diff --git a/bun.lock b/bun.lock index d3c647a..92c2dc3 100644 --- a/bun.lock +++ b/bun.lock @@ -10,8 +10,11 @@ "dependencies": { "bits-ui": "^2.4.1", "chroma-js": "^3.1.2", + "date-fns": "^4.1.0", "diff": "^8.0.2", + "dompurify": "^3.2.6", "luxon": "^3.6.1", + "marked": "^16.0.0", "runed": "^0.28.0", "shiki": "^3.4.2", "svelte-toolbelt": "^0.9.1", @@ -29,7 +32,9 @@ "@sveltejs/vite-plugin-svelte": "^5.1.0", "@tailwindcss/vite": "^4.1.8", "@types/chroma-js": "^3.1.1", + "@types/dompurify": "^3.2.0", "@types/luxon": "^3.6.2", + "@types/marked": "^6.0.0", "@types/wicg-file-system-access": "^2023.10.6", "eslint": "^9.28.0", "eslint-config-prettier": "^10.1.5", @@ -364,6 +369,8 @@ "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/dompurify": ["@types/dompurify@3.2.0", "", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="], + "@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="], "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], @@ -372,10 +379,14 @@ "@types/luxon": ["@types/luxon@3.6.2", "", {}, "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw=="], + "@types/marked": ["@types/marked@6.0.0", "", { "dependencies": { "marked": "*" } }, "sha512-jmjpa4BwUsmhxcfsgUit/7A9KbrC48Q0q8KvnY107ogcjGgTFDlIL3RpihNpx2Mu1hM4mdFQjoVc4O6JoGKHsA=="], + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], "@types/node": ["@types/node@22.15.29", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/webextension-polyfill": ["@types/webextension-polyfill@0.12.3", "", {}, "sha512-F58aDVSeN/MjUGazXo/cPsmR76EvqQhQ1v4x23hFjUX0cfAJYE+JBWwiOGW36/VJGGxoH74sVlRIF3z7SJCKyg=="], @@ -500,6 +511,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=="], @@ -520,6 +533,8 @@ "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + "dompurify": ["dompurify@3.2.6", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ=="], + "enhanced-resolve": ["enhanced-resolve@5.18.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg=="], "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], @@ -688,6 +703,8 @@ "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], + "marked": ["marked@16.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-MUKMXDjsD/eptB7GPzxo4xcnLS6oo7/RHimUMHEDRhUooPwmN9BEpMl7AEOJv3bmso169wHI2wUF9VQgL7zfmA=="], + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="], "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], diff --git a/web/package.json b/web/package.json index 10b772e..7464a11 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,9 @@ "@sveltejs/vite-plugin-svelte": "^5.1.0", "@tailwindcss/vite": "^4.1.8", "@types/chroma-js": "^3.1.1", + "@types/dompurify": "^3.2.0", "@types/luxon": "^3.6.2", + "@types/marked": "^6.0.0", "@types/wicg-file-system-access": "^2023.10.6", "eslint": "^9.28.0", "eslint-config-prettier": "^10.1.5", @@ -48,8 +50,11 @@ "dependencies": { "bits-ui": "^2.4.1", "chroma-js": "^3.1.2", + "date-fns": "^4.1.0", "diff": "^8.0.2", + "dompurify": "^3.2.6", "luxon": "^3.6.1", + "marked": "^16.0.0", "runed": "^0.28.0", "shiki": "^3.4.2", "svelte-toolbelt": "^0.9.1", diff --git a/web/src/lib/components/diff/CommentDisplay.svelte b/web/src/lib/components/diff/CommentDisplay.svelte new file mode 100644 index 0000000..16f36fb --- /dev/null +++ b/web/src/lib/components/diff/CommentDisplay.svelte @@ -0,0 +1,349 @@ + + +
+
+ {comment.user.login} +
+ + {comment.user.login} + + + {formatTimestamp(comment.created_at)} + + {#if comment.created_at !== comment.updated_at} + (edited) + {/if} + {#if comment.html_url} + + {/if} +
+ + {#if (displayState.canEdit || displayState.canDelete) && !displayState.isEditing} +
+ {#if displayState.canEdit} + + {/if} + {#if displayState.canDelete} + + {/if} +
+ {/if} +
+ + {#if displayState.error} +
+ {displayState.error} +
+ {/if} + + {#if displayState.isEditing} +
+ +
+
+ + Use Cmd/Ctrl + Enter to save, Escape to cancel +
+
+ + +
+
+
+ {:else} +
+ +
+ {/if} +
+ + diff --git a/web/src/lib/components/diff/CommentForm.svelte b/web/src/lib/components/diff/CommentForm.svelte new file mode 100644 index 0000000..05ce948 --- /dev/null +++ b/web/src/lib/components/diff/CommentForm.svelte @@ -0,0 +1,407 @@ + + +
+ {#if formState.error} +
+ {formState.error} +
+ {/if} + + {#if formState.isMultilineComment} +
+ + Commenting on lines {formState.orderedLines.startLine}-{formState.orderedLines.endLine} ({formState.orderedLines.startSide === + formState.orderedLines.endSide + ? formState.orderedLines.endSide + : `${formState.orderedLines.startSide} to ${formState.orderedLines.endSide}`}) +
+ {/if} + +
+
+ + + + +
+ +
+ +
+ +
+
+ + Use Cmd/Ctrl + Enter to submit +
+ +
+ {#if onCancel} + + {/if} + + +
+
+
+ + diff --git a/web/src/lib/components/diff/CommentThread.svelte b/web/src/lib/components/diff/CommentThread.svelte new file mode 100644 index 0000000..779f842 --- /dev/null +++ b/web/src/lib/components/diff/CommentThread.svelte @@ -0,0 +1,207 @@ + + +
+
+ + +
+ {thread.position.path}:{threadState.lineRangeDisplay} ({threadState.sideDisplay}) +
+
+ {#if !threadState.collapsed} +
+ + {#each threadState.topLevelComments as comment (comment.id)} + + {/each} + + + {#if threadState.replies.length > 0} +
+
+ Replies +
+ {#each threadState.replies as reply (reply.id)} + + {/each} +
+ {/if} + + + {#if threadState.hasToken && threadState.topLevelComments.length > 0} +
+ {#if threadState.showReplyForm} + (threadState.showReplyForm = false)} + onSubmit={threadState.handleReplyAdded} + /> + {:else} + + {/if} +
+ {/if} +
+ {/if} +
+ + diff --git a/web/src/lib/components/diff/ConciseDiffView.svelte b/web/src/lib/components/diff/ConciseDiffView.svelte index a0fe7eb..ba6ba98 100644 --- a/web/src/lib/components/diff/ConciseDiffView.svelte +++ b/web/src/lib/components/diff/ConciseDiffView.svelte @@ -11,10 +11,16 @@ type PatchLineTypeProps, patchLineTypeProps, type SearchSegment, + type DiffViewerPatch, + type DiffViewerPatchHunk, } 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 { onDestroy, setContext } from "svelte"; import { type MutableValue } from "$lib/util"; import { box } from "svelte-toolbelt"; @@ -31,7 +37,33 @@ 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(); + + setContext("diff-settings", { + syntaxHighlighting, + syntaxHighlightingTheme, + wordDiffs, + lineWrap, + }); const parsedPatch: Promise = $derived.by(async () => { if (rawPatchContent !== undefined) { @@ -53,6 +85,21 @@ cacheKey: box.with(() => cacheKey), }); + let hoveredLineKey = $state(null); + let showNewCommentForm = $state(null); // "line:side" format + + // Multiline comment selection state + // Note: GitHub API requires multiline comments to be within the same hunk, so we track hunkIndex + let isSelecting = $state(false); + let selectionStart = $state<{ line: PatchLine; side: "LEFT" | "RIGHT"; hunkIndex: number } | null>(null); + let selectionEnd = $state<{ line: PatchLine; side: "LEFT" | "RIGHT"; hunkIndex: number } | 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 +108,182 @@ } } + 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 getOriginalContentForThread(thread: CommentThreadType, hunk: DiffViewerPatchHunk): string { + const firstComment = thread.comments[0]; + if (!firstComment) return ""; + + const isMultiline = firstComment.start_line !== undefined && firstComment.start_line !== null && firstComment.start_line !== firstComment.line; + + const startLine = isMultiline ? firstComment.start_line! : thread.position.line; + const endLine = thread.position.line; + + const selectedLinesContent: string[] = []; + + for (const line of hunk.lines) { + if (line.type === PatchLineType.ADD || line.type === PatchLineType.CONTEXT) { + const lineNum = line.newLineNo; + if (lineNum !== undefined && lineNum >= startLine && lineNum <= endLine) { + const content = extractLineContent(line); + selectedLinesContent.push(content); + } + } + } + + return selectedLinesContent.join("\n"); + } + + function extractLineContent(line: PatchLine): string { + return line.content.map((segment) => segment.text || "").join(""); + } + + 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", hunkIndex: number) { + 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, hunkIndex }; + selectionEnd = { line, side, hunkIndex }; + } + + function handleMouseMove(event: MouseEvent, line: PatchLine, side: "LEFT" | "RIGHT", hunkIndex: number) { + if (!isSelecting || !canAddComments || line.type === PatchLineType.HEADER) return; + + const lineNum = getLineNumber(line, side); + if (lineNum === undefined) return; + + // Don't allow selection across different hunks + if (selectionStart && selectionStart.hunkIndex !== hunkIndex) { + 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, hunkIndex }; + } + } + + 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 +358,117 @@ } return segments; }); + + function isLineInSelection(line: PatchLine, side: "LEFT" | "RIGHT", hunkIndex: number): boolean { + if (!selectionStart || !selectionEnd) return false; + + // Only lines in the same hunk as the selection can be selected + if (selectionStart.hunkIndex !== hunkIndex) 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; + + // For single line selection (same line and side) + if (selectionStart.line === selectionEnd.line && selectionStart.side === selectionEnd.side) { + return line === selectionStart.line && side === selectionStart.side; + } + + // 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, only include the exact start and end lines + if (actualStartSide !== actualEndSide) { + if (side === actualStartSide && line === selectionStart.line) { + return true; + } else if (side === actualEndSide && line === selectionEnd.line) { + return true; + } + } + + 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); + } + + function getSelectionInfo(diffViewerPatch: DiffViewerPatch): { content: string; containsDeletedLines: boolean } { + if (!selectionStart || !selectionEnd) return { content: "", containsDeletedLines: false }; + + let containsDeletedLines = false; + const selectedLines: string[] = []; + const hunk = diffViewerPatch.hunks[selectionStart.hunkIndex]; + + if (hunk) { + for (let lineIndex = 0; lineIndex < hunk.lines.length; lineIndex++) { + const line = hunk.lines[lineIndex]; + + // Skip header and spacer lines + if (line.type === PatchLineType.HEADER || line.type === PatchLineType.SPACER) continue; + + // Check if this line is in the selection using the same logic as isLineInSelection + if (isLineInSelection(line, "LEFT", selectionStart.hunkIndex) || isLineInSelection(line, "RIGHT", selectionStart.hunkIndex)) { + if (line.type === PatchLineType.REMOVE) { + containsDeletedLines = true; + } + const content = extractLineContent(line); + // Remove diff prefix if present + let cleanContent = content; + if (content.startsWith("+") || content.startsWith("-") || content.startsWith(" ")) { + cleanContent = content.substring(1); + } + selectedLines.push(cleanContent); + } + } + } + + return { content: selectedLines.join("\n"), containsDeletedLines }; + } + + {#snippet lineContent(line: PatchLine, lineType: PatchLineTypeProps, innerLineType: InnerPatchLineTypeProps)} {#if line.lineBreak}
{:else} @@ -187,19 +519,189 @@ {/await} {/snippet} +{#snippet commentButton(line: PatchLine, hunkIndex: number, isRightColumn: boolean)} + {@const leftLineKey = getLineKey(line, "LEFT")} + {@const rightLineKey = getLineKey(line, "RIGHT")} + {@const leftLineNum = getLineNumber(line, "LEFT")} + {@const rightLineNum = getLineNumber(line, "RIGHT")} + {@const leftSelected = isLineInSelection(line, "LEFT", hunkIndex)} + {@const rightSelected = isLineInSelection(line, "RIGHT", hunkIndex)} + {@const isThisLineHovered = hoveredLineKey === leftLineKey || hoveredLineKey === rightLineKey} + + {#if canAddComments && (leftLineNum !== undefined || rightLineNum !== undefined) && line.type !== PatchLineType.HEADER} + {#if line.type === PatchLineType.REMOVE && isRightColumn} + + + {:else if line.type === PatchLineType.ADD && isRightColumn} + + + {:else if line.type === PatchLineType.CONTEXT && isRightColumn} + + + {/if} + {/if} +{/snippet} + {#snippet renderLine(line: PatchLine, hunkIndex: number, lineIndex: number)} {@const lineType = patchLineTypeProps[line.type]} -
-
{getDisplayLineNo(line, line.oldLineNo)}
+ {@const leftLineKey = getLineKey(line, "LEFT")} + {@const rightLineKey = getLineKey(line, "RIGHT")} + {@const leftSelected = isLineInSelection(line, "LEFT", hunkIndex)} + {@const rightSelected = isLineInSelection(line, "RIGHT", hunkIndex)} +
{ + if (line.oldLineNo !== undefined) { + hoveredLineKey = leftLineKey; + } + }} + onmouseleave={() => (hoveredLineKey = null)} + aria-label="Add comment" + role="button" + tabindex="0" + > +
{getDisplayLineNo(line, line.oldLineNo)}
-
-
{getDisplayLineNo(line, line.newLineNo)}
+
{ + if (line.newLineNo !== undefined) { + hoveredLineKey = rightLineKey; + } else if (line.oldLineNo !== undefined) { + // For deleted lines (no right line number), use left line key + hoveredLineKey = leftLineKey; + } + }} + onmouseleave={() => (hoveredLineKey = null)} + aria-label="Add comment" + role="button" + tabindex="0" + > +
{getDisplayLineNo(line, line.newLineNo)}
+ {@render commentButton(line, hunkIndex, true)}
-
+
{@render lineContentWrapper(line, hunkIndex, lineIndex, lineType, innerPatchLineTypeProps[line.innerPatchLineType])}
{/snippet} +{#snippet renderComments(line: PatchLine, diffViewerPatch: DiffViewerPatch, hunk: DiffViewerPatchHunk)} + {#if showComments && commentsForLine && filePath} + {@const leftLineNum = line.oldLineNo} + {@const rightLineNum = line.newLineNo} + {@const leftComments = leftLineNum ? commentsForLine(leftLineNum, "LEFT") : []} + {@const rightComments = rightLineNum ? commentsForLine(rightLineNum, "RIGHT") : []} + {@const allComments = [...leftComments, ...rightComments]} + + {#if allComments.length > 0} +
+
+
+
+ {#each allComments as thread (thread.id)} + {@const originalContentForSuggestion = getOriginalContentForThread(thread, hunk)} + + {/each} +
+
+ {/if} + + {#if showNewCommentForm} + {@const leftKey = getLineKey(line, "LEFT")} + {@const rightKey = getLineKey(line, "RIGHT")} + {#if showNewCommentForm === leftKey || showNewCommentForm === rightKey} + {@const isLeft = showNewCommentForm === leftKey} + {@const lineNum = isLeft ? line.oldLineNo : line.newLineNo} + {@const currentSide = isLeft ? "LEFT" : "RIGHT"} + {#if lineNum !== undefined} + {@const startLineNum = selectionStart ? getLineNumber(selectionStart.line, selectionStart.side) : undefined} + {@const startSide = selectionStart?.side} + {@const hasMultilineSelection = + selectionStart && selectionEnd && (selectionStart.line !== selectionEnd.line || selectionStart.side !== selectionEnd.side)} + {@const selectionInfo = getSelectionInfo(diffViewerPatch)} + {@const singleLineContent = + !hasMultilineSelection && selectionInfo.content === "" + ? (() => { + const content = extractLineContent(line); + // Remove diff prefix if present + if (content.startsWith("+") || content.startsWith("-") || content.startsWith(" ")) { + return content.substring(1); + } + return content; + })() + : selectionInfo.content} + {@const singleLineIsDeleted = !hasMultilineSelection && line.type === PatchLineType.REMOVE} + {@const shouldDisableSuggestions = selectionInfo.containsDeletedLines || singleLineIsDeleted} +
+
+
+
+ +
+
+ {/if} + {/if} + {/if} + {/if} +{/snippet} + {#await Promise.all([view.rootStyle, view.diffViewerPatch])}
{:then [rootStyle, diffViewerPatch]} @@ -211,6 +713,7 @@ {#each diffViewerPatch.hunks as hunk, hunkIndex (hunkIndex)} {#each hunk.lines as line, lineIndex (lineIndex)} {@render renderLine(line, hunkIndex, lineIndex)} + {@render renderComments(line, diffViewerPatch, hunk)} {/each} {/each}
@@ -256,6 +759,9 @@ text-align: end; vertical-align: top; text-wrap: nowrap; + position: relative; + z-index: 0; + padding: 0 8px 0 8px; } .prefix { @@ -267,4 +773,82 @@ left: -0.75rem; top: 0; } + + .line-cell { + position: relative; + line-height: 1.25rem; + min-height: 1.25rem; + max-height: 1.25rem; + overflow: visible; + z-index: 1; + } + + .line-cell.line-selected { + background: var(--select-bg, var(--color-blue-100)) !important; + border: 1px solid var(--color-primary); + border-radius: 2px; + } + + .line-selected { + background: var(--select-bg, var(--color-blue-50)) !important; + } + + .comment-button { + position: absolute; + top: 50%; + right: 2px; + transform: translateY(-50%); + background: var(--color-primary); + border: none; + border-radius: 50%; + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + transition: opacity 0.2s ease; + color: white; + font-size: 10px; + z-index: 10; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + } + + .comment-button.visible { + opacity: 1; + } + + .comment-button.selected { + opacity: 1; + background: var(--color-blue-600); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); + } + + .comment-button:hover { + background: var(--color-primary); + } + + .comment-button.selected:hover { + background: var(--color-blue-600); + } + + .comment-row { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + background: var(--color-neutral-1); + border-top: 1px solid var(--color-border); + border-bottom: 1px solid var(--color-border); + } + + .comment-spacer { + background: var(--color-neutral-2); + border-right: 1px solid var(--color-border); + } + + .comments-container { + padding: 2px 6px; + background: var(--color-neutral-1); + } diff --git a/web/src/lib/components/diff/MarkdownRenderer.svelte b/web/src/lib/components/diff/MarkdownRenderer.svelte new file mode 100644 index 0000000..fcd1854 --- /dev/null +++ b/web/src/lib/components/diff/MarkdownRenderer.svelte @@ -0,0 +1,254 @@ + + +
+ {#each parts as part, index (index)} + {#if part.type === "markdown"} + + {@html renderMarkdown(part.text)} + {:else if part.type === "suggestion"} + + {/if} + {/each} +
+ + diff --git a/web/src/lib/components/diff/SuggestionDiff.svelte b/web/src/lib/components/diff/SuggestionDiff.svelte new file mode 100644 index 0000000..39411ff --- /dev/null +++ b/web/src/lib/components/diff/SuggestionDiff.svelte @@ -0,0 +1,103 @@ + + +
+ +
+ + diff --git a/web/src/lib/components/diff/comment-state.svelte.ts b/web/src/lib/components/diff/comment-state.svelte.ts new file mode 100644 index 0000000..a00fa20 --- /dev/null +++ b/web/src/lib/components/diff/comment-state.svelte.ts @@ -0,0 +1,349 @@ +import { + type GithubPRComment, + getGithubToken, + getGithubUsername, + createGithubPRComment, + replyToGithubPRComment, + updateGithubPRComment, + deleteGithubPRComment, +} from "$lib/github.svelte"; +import type { CommentThread } from "$lib/diff-viewer-multi-file.svelte"; + +export interface CommentFormProps { + owner: string; + repo: string; + prNumber: string; + path?: string; + line?: number; + side?: "LEFT" | "RIGHT"; + startLine?: number; + startSide?: "LEFT" | "RIGHT"; + replyToId?: number; + placeholder?: string; +} + +export class CommentFormState { + text: string = $state(""); + isSubmitting: boolean = $state(false); + error: string | null = $state(null); + + private readonly props!: CommentFormProps; + private readonly onSubmit?: (comment: GithubPRComment) => void; + private readonly onCancel?: () => void; + + readonly canSubmit = $derived(this.text.trim().length > 0 && !this.isSubmitting); + readonly isReply = $derived(!!this.props.replyToId); + readonly isMultilineComment = $derived( + !this.isReply && this.props.startLine !== undefined && this.props.startSide !== undefined && this.props.startLine !== this.props.line, + ); + + constructor(props: CommentFormProps, onSubmit?: (comment: GithubPRComment) => void, onCancel?: () => void) { + this.props = props; + this.onSubmit = onSubmit; + this.onCancel = onCancel; + } + + // Ensure correct line ordering for GitHub API + readonly orderedLines = $derived.by(() => { + if (!this.isMultilineComment || this.props.startLine === undefined || this.props.startSide === undefined || this.props.line === undefined) { + return { + startLine: this.props.startLine, + startSide: this.props.startSide, + endLine: this.props.line, + endSide: this.props.side, + }; + } + + // GitHub API requires start_line < line + if (this.props.startLine < this.props.line) { + return { + startLine: this.props.startLine, + startSide: this.props.startSide, + endLine: this.props.line, + endSide: this.props.side, + }; + } else if (this.props.startLine > this.props.line) { + return { + startLine: this.props.line, + startSide: this.props.side, + endLine: this.props.startLine, + endSide: this.props.startSide, + }; + } else { + // Same line number - order by side (LEFT before RIGHT) + if (this.props.startSide === "LEFT" && this.props.side === "RIGHT") { + return { + startLine: this.props.startLine, + startSide: this.props.startSide, + endLine: this.props.line, + endSide: this.props.side, + }; + } else { + return { + startLine: this.props.line, + startSide: this.props.side, + endLine: this.props.startLine, + endSide: this.props.startSide, + }; + } + } + }); + + submit = async (): Promise => { + if (!this.canSubmit) return; + + const token = getGithubToken(); + if (!token) { + this.error = "Authentication required to post comments"; + return; + } + + this.isSubmitting = true; + this.error = null; + + try { + let comment: GithubPRComment; + + if (this.isReply) { + comment = await replyToGithubPRComment(token, this.props.owner, this.props.repo, this.props.prNumber, this.props.replyToId!, this.text.trim()); + } else { + if (!this.props.path || this.props.line === undefined || !this.props.side) { + throw new Error("Path, line, and side are required for new comments"); + } + + if (this.orderedLines.endLine === undefined) { + throw new Error("End line is required for new comments"); + } + + comment = await createGithubPRComment( + token, + this.props.owner, + this.props.repo, + this.props.prNumber, + this.text.trim(), + this.props.path, + this.orderedLines.endLine, + this.orderedLines.endSide, + this.orderedLines.startLine, + this.orderedLines.startSide, + ); + } + + this.text = ""; + this.onSubmit?.(comment); + } catch (err) { + if (err instanceof Error) { + // Handle specific GitHub API validation errors + if (err.message.includes("start_line must be part of the same hunk as the line")) { + this.error = "Multiline comments must be within the same section of the diff. Please select lines within the same hunk."; + } else if (err.message.includes("Validation Failed") && err.message.includes("pull_request_review_thread")) { + this.error = "GitHub API validation error: " + err.message + ". Try selecting lines closer together or within the same diff section."; + } else { + this.error = err.message; + } + } else { + this.error = "Failed to post comment"; + } + } finally { + this.isSubmitting = false; + } + }; + + cancel = (): void => { + this.text = ""; + this.error = null; + this.onCancel?.(); + }; + + handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + this.submit(); + } + }; +} + +export class CommentDisplayState { + isEditing: boolean = $state(false); + editText: string = $state(""); + isSubmitting: boolean = $state(false); + error: string | null = $state(null); + isDeleting: boolean = $state(false); + + private readonly comment!: GithubPRComment; + private readonly owner!: string; + private readonly repo!: string; + private readonly onUpdated?: (comment: GithubPRComment) => void; + private readonly onDeleted?: (commentId: number) => void; + + readonly currentUser = $derived(getGithubUsername()); + readonly hasToken = $derived(!!getGithubToken()); + readonly canEdit = $derived(this.hasToken && this.currentUser === this.comment.user.login); + readonly canDelete = $derived(this.hasToken && this.currentUser === this.comment.user.login); + + constructor( + comment: GithubPRComment, + owner: string, + repo: string, + onUpdated?: (comment: GithubPRComment) => void, + onDeleted?: (commentId: number) => void, + ) { + this.comment = comment; + this.owner = owner; + this.repo = repo; + this.onUpdated = onUpdated; + this.onDeleted = onDeleted; + } + + startEdit = (): void => { + this.isEditing = true; + this.editText = this.comment.body; + this.error = null; + }; + + cancelEdit = (): void => { + this.isEditing = false; + this.editText = ""; + this.error = null; + }; + + saveEdit = async (): Promise => { + if (!this.editText.trim()) { + this.error = "Comment cannot be empty"; + return; + } + + const token = getGithubToken(); + if (!token) { + this.error = "Authentication required to edit comments"; + return; + } + + this.isSubmitting = true; + this.error = null; + + try { + const updatedComment = await updateGithubPRComment(token, this.owner, this.repo, this.comment.id, this.editText.trim()); + this.onUpdated?.(updatedComment); + this.isEditing = false; + this.editText = ""; + } catch (err) { + this.error = err instanceof Error ? err.message : "Failed to update comment"; + } finally { + this.isSubmitting = false; + } + }; + + deleteComment = async (): Promise => { + if (!confirm("Are you sure you want to delete this comment?")) { + return; + } + + const token = getGithubToken(); + if (!token) { + this.error = "Authentication required to delete comments"; + return; + } + + this.isDeleting = true; + this.error = null; + + try { + await deleteGithubPRComment(token, this.owner, this.repo, this.comment.id); + this.onDeleted?.(this.comment.id); + } catch (err) { + this.error = err instanceof Error ? err.message : "Failed to delete comment"; + } finally { + this.isDeleting = false; + } + }; + + handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + this.saveEdit(); + } + if (event.key === "Escape") { + this.cancelEdit(); + } + }; +} + +export class CommentThreadState { + collapsed: boolean = $state(false); + showReplyForm: boolean = $state(false); + + private readonly thread!: CommentThread; + private readonly onCommentAdded?: (comment: GithubPRComment) => void; + private readonly onCommentUpdated?: (comment: GithubPRComment) => void; + private readonly onCommentDeleted?: (commentId: number) => void; + + readonly hasToken = $derived(!!getGithubToken()); + readonly topLevelComments = $derived(this.thread.comments.filter((comment) => !comment.in_reply_to_id)); + readonly replies = $derived(this.thread.comments.filter((comment) => comment.in_reply_to_id)); + + constructor( + thread: CommentThread, + onCommentAdded?: (comment: GithubPRComment) => void, + onCommentUpdated?: (comment: GithubPRComment) => void, + onCommentDeleted?: (commentId: number) => void, + ) { + this.thread = thread; + this.collapsed = thread.collapsed; + this.onCommentAdded = onCommentAdded; + this.onCommentUpdated = onCommentUpdated; + this.onCommentDeleted = onCommentDeleted; + } + + readonly isMultilineThread = $derived.by(() => { + const firstComment = this.topLevelComments[0]; + return firstComment && firstComment.start_line !== undefined && firstComment.start_line !== null && firstComment.start_line !== firstComment.line; + }); + + readonly lineRangeDisplay = $derived.by(() => { + if (!this.isMultilineThread) { + return `${this.thread.position.line}`; + } + + const firstComment = this.topLevelComments[0]; + if (!firstComment || firstComment.start_line === undefined || firstComment.start_line === null) { + return `${this.thread.position.line}`; + } + + return `${firstComment.start_line}-${firstComment.line}`; + }); + + readonly sideDisplay = $derived.by(() => { + if (!this.isMultilineThread) { + return this.thread.position.side; + } + + const firstComment = this.topLevelComments[0]; + if (!firstComment || firstComment.start_side === undefined || firstComment.start_side === null) { + return this.thread.position.side; + } + + if (firstComment.start_side === firstComment.side) { + return firstComment.side; + } + + return `${firstComment.start_side} to ${firstComment.side}`; + }); + + toggleCollapse = (): void => { + this.collapsed = !this.collapsed; + }; + + handleReplyAdded = (comment: GithubPRComment): void => { + this.showReplyForm = false; + this.onCommentAdded?.(comment); + }; + + handleCommentUpdated = (comment: GithubPRComment): void => { + this.onCommentUpdated?.(comment); + }; + + handleCommentDeleted = (commentId: number): void => { + this.onCommentDeleted?.(commentId); + }; +} diff --git a/web/src/lib/components/diff/concise-diff-view.svelte.ts b/web/src/lib/components/diff/concise-diff-view.svelte.ts index a89c861..7ce548b 100644 --- a/web/src/lib/components/diff/concise-diff-view.svelte.ts +++ b/web/src/lib/components/diff/concise-diff-view.svelte.ts @@ -13,8 +13,8 @@ import { import { guessLanguageFromExtension, type MutableValue, type ReadableBoxedValues } from "$lib/util"; import type { IRawThemeSetting } from "shiki/textmate"; import chroma from "chroma-js"; -import { getEffectiveGlobalTheme } from "$lib/theme.svelte"; import { onDestroy } from "svelte"; +import { getEffectiveGlobalTheme } from "$lib/theme.svelte"; export const DEFAULT_THEME_LIGHT: BundledTheme = "github-light-default"; export const DEFAULT_THEME_DARK: BundledTheme = "github-dark-default"; @@ -198,6 +198,11 @@ class LineProcessor { private async processInternal() { for (let i = 0; i < this.contentLines.length; i++) { + // Yield to the event loop every couple hundred lines so large diffs don't freeze the UI + if (i % 200 === 0) { + // Using a micro-task is enough to let rendering catch up without noticeable slowdown + await Promise.resolve(); + } const lineText = this.contentLines[i]; const oldState = this.state; @@ -835,132 +840,145 @@ function makeTransparent(hex: string | undefined) { return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.5)`; } -export async function getBaseColors(themeKey: BundledTheme | undefined, syntaxHighlighting: boolean): Promise { - const theme = await getTheme(themeKey); - if (!syntaxHighlighting || !theme) { - let styles = ""; - if (getEffectiveGlobalTheme() === "dark") { - // Make sure tailwind emits these props - // "text-green-600 text-red-600 text-green-700 text-red-700 text-green-800 text-red-800 text-blue-800" - styles += ` - --hunk-header-bg-themed: var(--color-gray-800); - --select-bg-themed: var(--color-blue-800); - - --inserted-text-bg-themed: var(--color-green-700); - --removed-text-bg-themed: var(--color-red-700); - --inserted-line-bg-themed: var(--color-green-800); - --removed-line-bg-themed: var(--color-red-800); - --inner-inserted-line-bg-themed: var(--color-green-600); - --inner-removed-line-bg-themed: var(--color-red-600); - --inner-inserted-line-fg-themed: var(--color-green-300); - --inner-removed-line-fg-themed: var(--color-red-300); - `; - } else { - styles += ` - --inserted-text-bg-themed: var(--color-green-400); - --removed-text-bg-themed: var(--color-red-400); - --inserted-line-bg-themed: var(--color-green-100); - --removed-line-bg-themed: var(--color-red-100); - --inner-inserted-line-bg-themed: var(--color-green-300); - --inner-removed-line-bg-themed: var(--color-red-300); - --inner-inserted-line-fg-themed: var(--color-green-800); - --inner-removed-line-fg-themed: var(--color-red-800); - `; - } - return styles; +// Memoization cache to avoid recomputing base colors for every diff view +const baseColorCache: Map> = new Map(); + +export async function getBaseColors(themeKey: BundledTheme | undefined, syntaxHighlighting: boolean, isDark: boolean): Promise { + const cacheKey = `${String(themeKey)}|${syntaxHighlighting}|${isDark}`; + if (baseColorCache.has(cacheKey)) { + return baseColorCache.get(cacheKey)!; } - const tokenColors = theme.default.tokenColors || []; - const style: Map = new Map(); + const compute = (async () => { + const theme = await getTheme(themeKey); + if (!syntaxHighlighting || !theme) { + let styles = ""; + if (isDark) { + // Make sure tailwind emits these props + // "text-green-600 text-red-600 text-green-700 text-red-700 text-green-800 text-red-800 text-blue-800" + styles += ` + --hunk-header-bg-themed: var(--color-gray-800); + --select-bg-themed: var(--color-blue-800); + + --inserted-text-bg-themed: var(--color-green-700); + --removed-text-bg-themed: var(--color-red-700); + --inserted-line-bg-themed: var(--color-green-800); + --removed-line-bg-themed: var(--color-red-800); + --inner-inserted-line-bg-themed: var(--color-green-600); + --inner-removed-line-bg-themed: var(--color-red-600); + --inner-inserted-line-fg-themed: var(--color-green-300); + --inner-removed-line-fg-themed: var(--color-red-300); + `; + } else { + styles += ` + --inserted-text-bg-themed: var(--color-green-400); + --removed-text-bg-themed: var(--color-red-400); + --inserted-line-bg-themed: var(--color-green-100); + --removed-line-bg-themed: var(--color-red-100); + --inner-inserted-line-bg-themed: var(--color-green-300); + --inner-removed-line-bg-themed: var(--color-red-300); + --inner-inserted-line-fg-themed: var(--color-green-800); + --inner-removed-line-fg-themed: var(--color-red-800); + `; + } + return styles; + } + const tokenColors = theme.default.tokenColors || []; + + const style: Map = new Map(); - // Find the foreground/default text color for the theme - const foundFg = extractColor(theme.default, { color: "editor.foreground" }); - if (foundFg) { - style.set("--editor-fg-themed", foundFg); - makeLCHVars("--editor-foreground", foundFg, style); - } else { - let globalScope = tokenColors.find((t) => t.scope === undefined); - if (!globalScope) { - // Tokenize something to force Shiki to run it's fix for 'broken' themes - await codeToTokens("hi", { theme: theme.default, lang: "text" }); - globalScope = tokenColors.find((t) => t.scope === undefined); + // Find the foreground/default text color for the theme + const foundFg = extractColor(theme.default, { color: "editor.foreground" }); + if (foundFg) { + style.set("--editor-fg-themed", foundFg); + makeLCHVars("--editor-foreground", foundFg, style); + } else { + let globalScope = tokenColors.find((t) => t.scope === undefined); + if (!globalScope) { + // Tokenize something to force Shiki to run it's fix for 'broken' themes + await codeToTokens("hi", { theme: theme.default, lang: "text" }); + globalScope = tokenColors.find((t) => t.scope === undefined); + } + const globalFg = globalScope?.settings.foreground; + if (globalFg) { + style.set("--editor-fg-themed", globalFg); + makeLCHVars("--editor-foreground", globalFg, style); + } else { + console.error("No foreground color found in theme"); + } + } + + // These colors are mostly universal + style.set("--editor-bg-themed", extractColor(theme.default, { color: "editor.background" })); + makeLCHVars("--editor-background", style.get("--editor-bg-themed"), style); + style.set("--select-bg-themed", extractColor(theme.default, { color: "editor.selectionBackground" })); + + // Don't use these - just add chroma to the inner diff highlight below for consistency + // These are also applied to the line by VSCode...? + // style.set("--inserted-text-bg-themed", extractColor(theme.default, { color: "diffEditor.insertedTextBackground" })); + // style.set("--removed-text-bg-themed", extractColor(theme.default, { color: "diffEditor.removedTextBackground" })); + + // 1) Try diffEditor.insertedLineBackground for inserted line highlight color + // 2) Try editorGutter.addedBackground for inserted line highlight color + // 3) Try markup.inserted scope bg for inserted line highlight color + // 4) Try markup.inserted scope fg for inserted line text color + let insertLineBg = extractColor(theme.default, { color: "diffEditor.insertedLineBackground" }); + if (!insertLineBg) { + insertLineBg = extractColor(theme.default, { color: "editorGutter.addedBackground", modifier: makeTransparent }); + } + if (!insertLineBg) { + insertLineBg = extractColor(theme.default, { bgTokenScope: "markup.inserted", modifier: makeTransparent }); } - const globalFg = globalScope?.settings.foreground; - if (globalFg) { - style.set("--editor-fg-themed", globalFg); - makeLCHVars("--editor-foreground", globalFg, style); + if (insertLineBg) { + style.set("--inserted-line-bg-themed", insertLineBg); + style.set("--inner-inserted-line-bg-themed", moreChroma(insertLineBg, 0.5)); + style.set("--inserted-text-bg-themed", darken(moreChroma(insertLineBg, 1.25), 0.25)); + + // Only use the fg color if we have a bg color -- otherwise it will conflict with the top level diff add/remove lines + // Increase chroma to match our adjustments to bg color above + style.set("--inner-inserted-line-fg-themed", moreChroma(extractColor(theme.default, { fgTokenScope: "markup.inserted" }))); } else { - console.error("No foreground color found in theme"); + style.set("--inserted-line-fg-themed", extractColor(theme.default, { fgTokenScope: "markup.inserted" })); } - } - // These colors are mostly universal - style.set("--editor-bg-themed", extractColor(theme.default, { color: "editor.background" })); - makeLCHVars("--editor-background", style.get("--editor-bg-themed"), style); - style.set("--select-bg-themed", extractColor(theme.default, { color: "editor.selectionBackground" })); - - // Don't use these - just add chroma to the inner diff highlight below for consistency - // These are also applied to the line by VSCode...? - // style.set("--inserted-text-bg-themed", extractColor(theme.default, { color: "diffEditor.insertedTextBackground" })); - // style.set("--removed-text-bg-themed", extractColor(theme.default, { color: "diffEditor.removedTextBackground" })); - - // 1) Try diffEditor.insertedLineBackground for inserted line highlight color - // 2) Try editorGutter.addedBackground for inserted line highlight color - // 3) Try markup.inserted scope bg for inserted line highlight color - // 4) Try markup.inserted scope fg for inserted line text color - let insertLineBg = extractColor(theme.default, { color: "diffEditor.insertedLineBackground" }); - if (!insertLineBg) { - insertLineBg = extractColor(theme.default, { color: "editorGutter.addedBackground", modifier: makeTransparent }); - } - if (!insertLineBg) { - insertLineBg = extractColor(theme.default, { bgTokenScope: "markup.inserted", modifier: makeTransparent }); - } - if (insertLineBg) { - style.set("--inserted-line-bg-themed", insertLineBg); - style.set("--inner-inserted-line-bg-themed", moreChroma(insertLineBg, 0.5)); - style.set("--inserted-text-bg-themed", darken(moreChroma(insertLineBg, 1.25), 0.25)); - - // Only use the fg color if we have a bg color -- otherwise it will conflict with the top level diff add/remove lines - // Increase chroma to match our adjustments to bg color above - style.set("--inner-inserted-line-fg-themed", moreChroma(extractColor(theme.default, { fgTokenScope: "markup.inserted" }))); - } else { - style.set("--inserted-line-fg-themed", extractColor(theme.default, { fgTokenScope: "markup.inserted" })); - } + // 1) Try diffEditor.removedLineBackground for removed line highlight color + // 2) Try editorGutter.deletedBackground for removed line highlight color + // 3) Try markup.deleted scope bg for removed line highlight color + // 4) Try markup.deleted scope fg for removed line text color + let removeLineBg = extractColor(theme.default, { color: "diffEditor.removedLineBackground" }); + if (!removeLineBg) { + removeLineBg = extractColor(theme.default, { color: "editorGutter.deletedBackground", modifier: makeTransparent }); + } + if (!removeLineBg) { + removeLineBg = extractColor(theme.default, { bgTokenScope: "markup.deleted", modifier: makeTransparent }); + } + if (removeLineBg) { + style.set("--removed-line-bg-themed", removeLineBg); + style.set("--inner-removed-line-bg-themed", moreChroma(removeLineBg, 0.5)); + style.set("--removed-text-bg-themed", darken(moreChroma(removeLineBg, 1.25), 0.25)); + + // Only use the fg color if we have a bg color -- otherwise it will conflict with the top level diff add/remove lines + // Increase chroma to match our adjustments to bg color above + style.set("--inner-removed-line-fg-themed", moreChroma(extractColor(theme.default, { fgTokenScope: "markup.deleted" }))); + } else { + style.set("--removed-line-fg-themed", extractColor(theme.default, { fgTokenScope: "markup.deleted" })); + } - // 1) Try diffEditor.removedLineBackground for removed line highlight color - // 2) Try editorGutter.deletedBackground for removed line highlight color - // 3) Try markup.deleted scope bg for removed line highlight color - // 4) Try markup.deleted scope fg for removed line text color - let removeLineBg = extractColor(theme.default, { color: "diffEditor.removedLineBackground" }); - if (!removeLineBg) { - removeLineBg = extractColor(theme.default, { color: "editorGutter.deletedBackground", modifier: makeTransparent }); - } - if (!removeLineBg) { - removeLineBg = extractColor(theme.default, { bgTokenScope: "markup.deleted", modifier: makeTransparent }); - } - if (removeLineBg) { - style.set("--removed-line-bg-themed", removeLineBg); - style.set("--inner-removed-line-bg-themed", moreChroma(removeLineBg, 0.5)); - style.set("--removed-text-bg-themed", darken(moreChroma(removeLineBg, 1.25), 0.25)); - - // Only use the fg color if we have a bg color -- otherwise it will conflict with the top level diff add/remove lines - // Increase chroma to match our adjustments to bg color above - style.set("--inner-removed-line-fg-themed", moreChroma(extractColor(theme.default, { fgTokenScope: "markup.deleted" }))); - } else { - style.set("--removed-line-fg-themed", extractColor(theme.default, { fgTokenScope: "markup.deleted" })); - } + // One or both of these is often missing, see ConciseDiffView.svelte