From c50312c868b6d24406b5358445c7807bb82b47fa Mon Sep 17 00:00:00 2001
From: JRoy <10731363+JRoy@users.noreply.github.com>
Date: Thu, 3 Jul 2025 23:32:45 -0700
Subject: [PATCH 1/4] Add support for making PR comments
---
bun.lock | 3 +
web/package.json | 1 +
.../lib/components/diff/CommentDisplay.svelte | 429 +++++++++++++++++
.../lib/components/diff/CommentForm.svelte | 299 ++++++++++++
.../lib/components/diff/CommentThread.svelte | 251 ++++++++++
.../components/diff/ConciseDiffView.svelte | 441 +++++++++++++++++-
.../settings-popover/SettingsPopover.svelte | 6 +-
web/src/lib/diff-viewer-multi-file.svelte.ts | 181 ++++++-
web/src/lib/github.svelte.ts | 221 ++++++++-
web/src/routes/+page.svelte | 58 ++-
10 files changed, 1853 insertions(+), 37 deletions(-)
create mode 100644 web/src/lib/components/diff/CommentDisplay.svelte
create mode 100644 web/src/lib/components/diff/CommentForm.svelte
create mode 100644 web/src/lib/components/diff/CommentThread.svelte
diff --git a/bun.lock b/bun.lock
index d3c647a..34559cd 100644
--- a/bun.lock
+++ b/bun.lock
@@ -10,6 +10,7 @@
"dependencies": {
"bits-ui": "^2.4.1",
"chroma-js": "^3.1.2",
+ "date-fns": "^4.1.0",
"diff": "^8.0.2",
"luxon": "^3.6.1",
"runed": "^0.28.0",
@@ -500,6 +501,8 @@
"data-uri-to-buffer": ["data-uri-to-buffer@2.0.2", "", {}, "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA=="],
+ "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
+
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
diff --git a/web/package.json b/web/package.json
index 10b772e..f99042a 100644
--- a/web/package.json
+++ b/web/package.json
@@ -48,6 +48,7 @@
"dependencies": {
"bits-ui": "^2.4.1",
"chroma-js": "^3.1.2",
+ "date-fns": "^4.1.0",
"diff": "^8.0.2",
"luxon": "^3.6.1",
"runed": "^0.28.0",
diff --git a/web/src/lib/components/diff/CommentDisplay.svelte b/web/src/lib/components/diff/CommentDisplay.svelte
new file mode 100644
index 0000000..1c0b37c
--- /dev/null
+++ b/web/src/lib/components/diff/CommentDisplay.svelte
@@ -0,0 +1,429 @@
+
+
+
+
+
diff --git a/web/src/lib/components/diff/CommentForm.svelte b/web/src/lib/components/diff/CommentForm.svelte
new file mode 100644
index 0000000..0e1f491
--- /dev/null
+++ b/web/src/lib/components/diff/CommentForm.svelte
@@ -0,0 +1,299 @@
+
+
+
+
+
diff --git a/web/src/lib/components/diff/CommentThread.svelte b/web/src/lib/components/diff/CommentThread.svelte
new file mode 100644
index 0000000..2d48861
--- /dev/null
+++ b/web/src/lib/components/diff/CommentThread.svelte
@@ -0,0 +1,251 @@
+
+
+
+
+
diff --git a/web/src/lib/components/diff/ConciseDiffView.svelte b/web/src/lib/components/diff/ConciseDiffView.svelte
index a0fe7eb..b3f835d 100644
--- a/web/src/lib/components/diff/ConciseDiffView.svelte
+++ b/web/src/lib/components/diff/ConciseDiffView.svelte
@@ -13,6 +13,10 @@
type SearchSegment,
} from "$lib/components/diff/concise-diff-view.svelte";
import Spinner from "$lib/components/Spinner.svelte";
+ import CommentThread from "./CommentThread.svelte";
+ import CommentForm from "./CommentForm.svelte";
+ import type { CommentThread as CommentThreadType } from "$lib/diff-viewer-multi-file.svelte";
+ import { getGithubToken, type GithubPRComment } from "$lib/github.svelte";
import { type StructuredPatch } from "diff";
import { onDestroy } from "svelte";
import { type MutableValue } from "$lib/util";
@@ -31,7 +35,26 @@
activeSearchResult = -1,
cache,
cacheKey,
- }: ConciseDiffViewProps = $props();
+ showComments = false,
+ commentsForLine,
+ onCommentAdded,
+ onCommentUpdated,
+ onCommentDeleted,
+ filePath,
+ owner,
+ repo,
+ prNumber,
+ }: ConciseDiffViewProps & {
+ showComments?: boolean;
+ commentsForLine?: (line: number, side: "LEFT" | "RIGHT") => CommentThreadType[];
+ onCommentAdded?: (comment: GithubPRComment) => void;
+ onCommentUpdated?: (comment: GithubPRComment) => void;
+ onCommentDeleted?: (commentId: number) => void;
+ filePath?: string;
+ owner?: string;
+ repo?: string;
+ prNumber?: string;
+ } = $props();
const parsedPatch: Promise = $derived.by(async () => {
if (rawPatchContent !== undefined) {
@@ -53,6 +76,20 @@
cacheKey: box.with(() => cacheKey),
});
+ let hoveredLineKey = $state(null);
+ let showNewCommentForm = $state(null); // "line:side" format
+
+ // Multiline comment selection state
+ let isSelecting = $state(false);
+ let selectionStart = $state<{ line: PatchLine; side: "LEFT" | "RIGHT" } | null>(null);
+ let selectionEnd = $state<{ line: PatchLine; side: "LEFT" | "RIGHT" } | null>(null);
+ let isDragging = $state(false);
+ let dragThreshold = 5; // pixels
+ let dragStartPosition = $state<{ x: number; y: number } | null>(null);
+
+ const hasToken = $derived(!!getGithubToken());
+ const canAddComments = $derived(showComments && hasToken && owner && repo && prNumber && filePath);
+
function getDisplayLineNo(line: PatchLine, num: number | undefined) {
if (line.type == PatchLineType.HEADER) {
return "...";
@@ -61,6 +98,149 @@
}
}
+ function getLineKey(line: PatchLine, side: "LEFT" | "RIGHT"): string {
+ const lineNum = side === "LEFT" ? line.oldLineNo : line.newLineNo;
+ return `${lineNum}:${side}`;
+ }
+
+ function getLineNumber(line: PatchLine, side: "LEFT" | "RIGHT"): number | undefined {
+ return side === "LEFT" ? line.oldLineNo : line.newLineNo;
+ }
+
+ function handleAddComment(line: PatchLine, side: "LEFT" | "RIGHT") {
+ const lineKey = getLineKey(line, side);
+
+ // If there's already a comment form open for a different line, close it and start new
+ if (showNewCommentForm && showNewCommentForm !== lineKey) {
+ showNewCommentForm = lineKey;
+ clearSelection();
+ return;
+ }
+
+ // Check if this is a multiline selection
+ if (selectionStart && selectionEnd && (selectionStart.line !== selectionEnd.line || selectionStart.side !== selectionEnd.side)) {
+ // Create multiline comment
+ const startLineNum = getLineNumber(selectionStart.line, selectionStart.side);
+ const endLineNum = getLineNumber(selectionEnd.line, selectionEnd.side);
+
+ if (startLineNum !== undefined && endLineNum !== undefined) {
+ // Ensure proper order (start should be before end)
+ let actualStart = selectionStart;
+ let actualEnd = selectionEnd;
+
+ if (startLineNum > endLineNum || (startLineNum === endLineNum && selectionStart.side === "RIGHT" && selectionEnd.side === "LEFT")) {
+ actualStart = selectionEnd;
+ actualEnd = selectionStart;
+ }
+
+ // Update the selection state with the correct order
+ selectionStart = actualStart;
+ selectionEnd = actualEnd;
+
+ showNewCommentForm = getLineKey(actualEnd.line, actualEnd.side);
+ clearSelection();
+ return;
+ }
+ }
+
+ showNewCommentForm = lineKey;
+ clearSelection();
+ }
+
+ function handleCommentFormCancel() {
+ showNewCommentForm = null;
+ clearSelection();
+ }
+
+ function handleCommentSubmitted(comment: GithubPRComment) {
+ showNewCommentForm = null;
+ clearSelection();
+ onCommentAdded?.(comment);
+ }
+
+ function handleCommentUpdated(comment: GithubPRComment) {
+ onCommentUpdated?.(comment);
+ }
+
+ function handleCommentDeleted(commentId: number) {
+ onCommentDeleted?.(commentId);
+ }
+
+ // Mouse event handlers for drag selection
+ function handleMouseDown(event: MouseEvent, line: PatchLine, side: "LEFT" | "RIGHT") {
+ if (!canAddComments || line.type === PatchLineType.HEADER) return;
+
+ const lineNum = getLineNumber(line, side);
+ if (lineNum === undefined) return;
+
+ // Store initial position for drag threshold
+ dragStartPosition = { x: event.clientX, y: event.clientY };
+
+ isSelecting = true;
+ isDragging = false; // Don't set to true until we actually start dragging
+ selectionStart = { line, side };
+ selectionEnd = { line, side };
+ }
+
+ function handleMouseMove(event: MouseEvent, line: PatchLine, side: "LEFT" | "RIGHT") {
+ if (!isSelecting || !canAddComments || line.type === PatchLineType.HEADER) return;
+
+ const lineNum = getLineNumber(line, side);
+ if (lineNum === undefined) return;
+
+ // Check if we've moved far enough to start dragging
+ if (!isDragging && dragStartPosition) {
+ const dx = event.clientX - dragStartPosition.x;
+ const dy = event.clientY - dragStartPosition.y;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+
+ if (distance > dragThreshold) {
+ isDragging = true;
+ event.preventDefault();
+ }
+ }
+
+ if (isDragging) {
+ selectionEnd = { line, side };
+ }
+ }
+
+ function handleMouseUp() {
+ if (!isSelecting) return;
+
+ // If we were dragging and have a multiline selection, open comment form
+ if (isDragging && selectionStart && selectionEnd && (selectionStart.line !== selectionEnd.line || selectionStart.side !== selectionEnd.side)) {
+ // Open comment form for multiline selection
+ const startLineNum = getLineNumber(selectionStart.line, selectionStart.side);
+ const endLineNum = getLineNumber(selectionEnd.line, selectionEnd.side);
+
+ if (startLineNum !== undefined && endLineNum !== undefined) {
+ // Ensure proper order (start should be before end)
+ let actualStart = selectionStart;
+ let actualEnd = selectionEnd;
+
+ // If dragging from bottom to top, swap the selection
+ if (startLineNum > endLineNum || (startLineNum === endLineNum && selectionStart.side === "RIGHT" && selectionEnd.side === "LEFT")) {
+ actualStart = selectionEnd;
+ actualEnd = selectionStart;
+ }
+
+ // Update the selection state with the correct order
+ selectionStart = actualStart;
+ selectionEnd = actualEnd;
+
+ showNewCommentForm = getLineKey(actualEnd.line, actualEnd.side);
+ isSelecting = false;
+ isDragging = false;
+ dragStartPosition = null;
+ return;
+ }
+ }
+
+ // Otherwise clear selection
+ clearSelection();
+ }
+
let searchResultElements: HTMLSpanElement[] = $state([]);
let didInitialJump = $state(false);
let scheduledJump: ReturnType | undefined = undefined;
@@ -135,8 +315,76 @@
}
return segments;
});
+
+ function isLineInSelection(line: PatchLine, side: "LEFT" | "RIGHT"): boolean {
+ if (!selectionStart || !selectionEnd) return false;
+
+ const lineNum = getLineNumber(line, side);
+ if (lineNum === undefined) return false;
+
+ const startLineNum = getLineNumber(selectionStart.line, selectionStart.side);
+ const endLineNum = getLineNumber(selectionEnd.line, selectionEnd.side);
+
+ if (startLineNum === undefined || endLineNum === undefined) return false;
+
+ // Determine actual start and end (handle both directions)
+ let actualStartLineNum = startLineNum;
+ let actualEndLineNum = endLineNum;
+ let actualStartSide = selectionStart.side;
+ let actualEndSide = selectionEnd.side;
+
+ // If dragging from bottom to top, swap the order
+ if (startLineNum > endLineNum || (startLineNum === endLineNum && selectionStart.side === "RIGHT" && selectionEnd.side === "LEFT")) {
+ actualStartLineNum = endLineNum;
+ actualEndLineNum = startLineNum;
+ actualStartSide = selectionEnd.side;
+ actualEndSide = selectionStart.side;
+ }
+
+ // For same side selection
+ if (actualStartSide === actualEndSide && side === actualStartSide) {
+ return lineNum >= actualStartLineNum && lineNum <= actualEndLineNum;
+ }
+
+ // For cross-side selection (left to right or right to left)
+ if (actualStartSide !== actualEndSide) {
+ if (side === actualStartSide) {
+ return lineNum >= actualStartLineNum;
+ } else if (side === actualEndSide) {
+ return lineNum <= actualEndLineNum;
+ }
+ }
+
+ return false;
+ }
+
+ function clearSelection() {
+ isSelecting = false;
+ selectionStart = null;
+ selectionEnd = null;
+ isDragging = false;
+ dragStartPosition = null;
+ }
+
+ function handleClick(event: MouseEvent, line: PatchLine, side: "LEFT" | "RIGHT") {
+ // Don't handle click if we just finished dragging
+ if (isDragging) {
+ event.preventDefault();
+ return;
+ }
+
+ // Don't handle click if there's a multiline selection (from drag)
+ if (selectionStart && selectionEnd && (selectionStart.line !== selectionEnd.line || selectionStart.side !== selectionEnd.side)) {
+ event.preventDefault();
+ return;
+ }
+
+ handleAddComment(line, side);
+ }
+
+
{#snippet lineContent(line: PatchLine, lineType: PatchLineTypeProps, innerLineType: InnerPatchLineTypeProps)}
{#if line.lineBreak}
{:else}
@@ -187,19 +435,118 @@
{/await}
{/snippet}
+{#snippet commentButton(line: PatchLine, side: "LEFT" | "RIGHT")}
+ {@const lineKey = getLineKey(line, side)}
+ {@const lineNum = getLineNumber(line, side)}
+ {@const isSelected = isLineInSelection(line, side)}
+ {#if canAddComments && lineNum !== undefined && line.type !== PatchLineType.HEADER}
+
+ {/if}
+{/snippet}
+
{#snippet renderLine(line: PatchLine, hunkIndex: number, lineIndex: number)}
{@const lineType = patchLineTypeProps[line.type]}
-
{comment.body}
+