From 2a573d3d9703a73a8c3ac0b6f5f40e360797a5be Mon Sep 17 00:00:00 2001 From: Zeus-Deus Date: Thu, 14 May 2026 16:17:29 +0200 Subject: [PATCH 1/2] Fix select-all copy in the diff viewer The diff viewer is virtualized, so selecting all and copying only put the handful of rendered rows on the clipboard instead of the full file. Serialize the whole diff from the parsed model and intercept the copy event at the document level (the diff renders into shadow DOM, so the copy event never reaches the viewport element). Cmd/Ctrl+A inside the viewport arms the gesture; the next copy then writes the complete diff. The existing Copy button now uses the same serialized text. --- .../src/components/DiffPanel.logic.test.ts | 49 +++++++++- apps/web/src/components/DiffPanel.logic.ts | 35 +++++++- apps/web/src/components/DiffPanel.tsx | 69 +++++++++++--- apps/web/src/lib/diffRendering.test.ts | 90 ++++++++++++++++++- apps/web/src/lib/diffRendering.ts | 80 +++++++++++++++++ 5 files changed, 308 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/DiffPanel.logic.test.ts b/apps/web/src/components/DiffPanel.logic.test.ts index f08360fe..8bcce22b 100644 --- a/apps/web/src/components/DiffPanel.logic.test.ts +++ b/apps/web/src/components/DiffPanel.logic.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import type { DraftThreadState } from "../composerDraftStore"; import type { Thread } from "../types"; -import { resolveDiffPanelThread } from "./DiffPanel.logic"; +import { resolveDiffPanelThread, resolveDiffSelectAllArmed } from "./DiffPanel.logic"; const PROJECT_ID = ProjectId.makeUnsafe("project-1"); const THREAD_ID = ThreadId.makeUnsafe("thread-1"); @@ -103,3 +103,50 @@ describe("resolveDiffPanelThread", () => { ).toBeUndefined(); }); }); + +describe("resolveDiffSelectAllArmed", () => { + it("arms on Cmd/Ctrl+A inside the diff viewport", () => { + expect( + resolveDiffSelectAllArmed(false, { key: "a", metaKey: true, ctrlKey: false }, true), + ).toBe(true); + expect( + resolveDiffSelectAllArmed(false, { key: "A", metaKey: false, ctrlKey: true }, true), + ).toBe(true); + }); + + it("does not arm on Cmd/Ctrl+A outside the diff viewport", () => { + expect( + resolveDiffSelectAllArmed(false, { key: "a", metaKey: true, ctrlKey: false }, false), + ).toBe(false); + expect( + resolveDiffSelectAllArmed(true, { key: "a", metaKey: false, ctrlKey: true }, false), + ).toBe(false); + }); + + it("preserves the armed state through the copy half of the gesture", () => { + expect( + resolveDiffSelectAllArmed(true, { key: "c", metaKey: true, ctrlKey: false }, false), + ).toBe(true); + expect( + resolveDiffSelectAllArmed(false, { key: "c", metaKey: true, ctrlKey: false }, false), + ).toBe(false); + }); + + it("preserves the armed state through bare modifier keydowns", () => { + expect( + resolveDiffSelectAllArmed(true, { key: "Meta", metaKey: true, ctrlKey: false }, false), + ).toBe(true); + expect( + resolveDiffSelectAllArmed(true, { key: "Shift", metaKey: false, ctrlKey: false }, false), + ).toBe(true); + }); + + it("disarms on any other key that starts a fresh selection", () => { + expect( + resolveDiffSelectAllArmed(true, { key: "ArrowDown", metaKey: false, ctrlKey: false }, true), + ).toBe(false); + expect( + resolveDiffSelectAllArmed(true, { key: "x", metaKey: false, ctrlKey: false }, true), + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/DiffPanel.logic.ts b/apps/web/src/components/DiffPanel.logic.ts index f44245f9..4ad12f54 100644 --- a/apps/web/src/components/DiffPanel.logic.ts +++ b/apps/web/src/components/DiffPanel.logic.ts @@ -1,6 +1,6 @@ // FILE: DiffPanel.logic.ts // Purpose: Resolve the thread context the diff panel should use across server-backed and local draft chats. -// Exports: resolveDiffPanelThread +// Exports: resolveDiffPanelThread, resolveDiffSelectAllArmed // Depends on: ChatView.logic draft-thread normalization. import { DEFAULT_MODEL_BY_PROVIDER, type ModelSelection, type ThreadId } from "@t3tools/contracts"; @@ -33,3 +33,36 @@ export function resolveDiffPanelThread(input: { null, ); } + +// Track whether the diff viewport is in a "select all then copy" gesture so the copy +// handler can substitute the full serialized diff instead of the few mounted rows the +// virtualizer left in the DOM. Pure so it can be unit tested without a real DOM. +// +// The diff surface renders into shadow DOM, so a native Cmd/Ctrl+A actually selects the +// surrounding light-DOM page and the resulting `copy` event never travels through the +// viewport element. We instead listen on `document`: the keydown still passes through the +// viewport (so we can tell the select-all happened there), and this state machine decides +// whether the very next copy should be hijacked. +export function resolveDiffSelectAllArmed( + previous: boolean, + event: Pick, + isWithinDiffViewport: boolean, +): boolean { + const key = event.key.toLowerCase(); + const hasShortcutModifier = event.metaKey || event.ctrlKey; + + // Cmd/Ctrl+A arms the gesture, but only when it happens inside the diff viewport. + if (hasShortcutModifier && key === "a") { + return isWithinDiffViewport; + } + // Cmd/Ctrl+C is the copy half of the gesture — preserve whatever state we were in. + if (hasShortcutModifier && key === "c") { + return previous; + } + // Bare modifier keydowns precede the real shortcut keys; never disarm on them. + if (key === "meta" || key === "control" || key === "shift" || key === "alt") { + return previous; + } + // Any other key starts a fresh selection intent, so drop back to native copy behavior. + return false; +} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index c1760141..81445f36 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -40,6 +40,7 @@ import { getRenderablePatch, resolveDiffCopyText, resolveDiffThemeName, + serializeRenderablePatchText, summarizePatchStats, } from "../lib/diffRendering"; import { resolveDiffEnvironmentState } from "../lib/threadEnvironment"; @@ -56,7 +57,7 @@ import { getProviderStartOptions, useAppSettings } from "../appSettings"; import { useComposerDraftStore } from "../composerDraftStore"; import { formatShortTimestamp } from "../timestampFormat"; import ChatMarkdown from "./ChatMarkdown"; -import { resolveDiffPanelThread } from "./DiffPanel.logic"; +import { resolveDiffPanelThread, resolveDiffSelectAllArmed } from "./DiffPanel.logic"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; import { Button } from "./ui/button"; import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; @@ -195,6 +196,8 @@ export default function DiffPanel({ const setRepoDiffScope = useRepoDiffScopeStore((store) => store.setScope); const [collapsedFiles, setCollapsedFiles] = useState>(() => new Set()); const patchViewportRef = useRef(null); + // Tracks an in-flight "select all then copy" gesture inside the virtualized diff surface. + const diffSelectAllArmedRef = useRef(false); const turnStripRef = useRef(null); const previousDiffOpenRef = useRef(false); const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); @@ -403,11 +406,16 @@ export default function DiffPanel({ const isSidebarMode = mode === "sidebar"; const { copyToClipboard, isCopied: isSummaryCopied } = useCopyToClipboard(); const { copyToClipboard: copyDiffToClipboard, isCopied: isDiffCopied } = useCopyToClipboard(); - const diffCopyText = useMemo(() => resolveDiffCopyText(activeReviewPatch), [activeReviewPatch]); const renderablePatch = useMemo( () => getRenderablePatch(activeReviewPatch, `diff-panel:${resolvedTheme}`), [activeReviewPatch, resolvedTheme], ); + // Serialize the full diff straight from the parsed model so copy paths never depend on + // which virtualized rows happen to be mounted in the DOM. + const diffCopyText = useMemo( + () => serializeRenderablePatchText(renderablePatch) ?? resolveDiffCopyText(activeReviewPatch), + [renderablePatch, activeReviewPatch], + ); const renderableFiles = useMemo(() => { if (!renderablePatch || renderablePatch.kind !== "files") { return []; @@ -507,16 +515,10 @@ export default function DiffPanel({ ? "Failed to generate diff summary." : null; const canShowSummary = Boolean( - !diffEnvironmentPending && - activeCwd && - (!hasResolvedRepoPatch || !hasNoRepoChanges), + !diffEnvironmentPending && activeCwd && (!hasResolvedRepoPatch || !hasNoRepoChanges), ); const canPrefetchSummary = Boolean( - diffOpen && - !diffEnvironmentPending && - activeCwd && - normalizedRepoPatch && - !hasNoRepoChanges, + diffOpen && !diffEnvironmentPending && activeCwd && normalizedRepoPatch && !hasNoRepoChanges, ); const canShowTotal = Boolean(!diffEnvironmentPending && activeCwd); @@ -565,6 +567,49 @@ export default function DiffPanel({ }); }, []); + // The diff surface is virtualized and renders into shadow DOM, so a native + // "select all + copy" only captures the handful of mounted rows. We watch the + // document: a Cmd/Ctrl+A keydown still passes through the viewport element (so we can + // tell the gesture started in the diff), and the matching `copy` event — which does + // *not* travel through the viewport — is then hijacked to write the fully serialized + // diff so every line reaches the clipboard. + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const viewport = patchViewportRef.current; + const isWithinDiffViewport = viewport ? event.composedPath().includes(viewport) : false; + diffSelectAllArmedRef.current = resolveDiffSelectAllArmed( + diffSelectAllArmedRef.current, + event, + isWithinDiffViewport, + ); + }; + const handlePointerDown = () => { + // Any fresh pointer interaction ends the select-all gesture. + diffSelectAllArmedRef.current = false; + }; + const handleCopy = (event: ClipboardEvent) => { + if (!diffSelectAllArmedRef.current) { + return; + } + // One-shot: the next deliberate select-all must re-arm it. + diffSelectAllArmedRef.current = false; + if (!diffCopyText || !event.clipboardData) { + return; + } + event.preventDefault(); + event.clipboardData.setData("text/plain", diffCopyText); + }; + + document.addEventListener("keydown", handleKeyDown, true); + document.addEventListener("pointerdown", handlePointerDown, true); + document.addEventListener("copy", handleCopy, true); + return () => { + document.removeEventListener("keydown", handleKeyDown, true); + document.removeEventListener("pointerdown", handlePointerDown, true); + document.removeEventListener("copy", handleCopy, true); + }; + }, [diffCopyText]); + const selectTurn = (turnId: TurnId) => { if (!activeThread) return; if (onUpdatePanelState) { @@ -943,8 +988,8 @@ export default function DiffPanel({

Repo summary

- Generated from the current{" "} - {REPO_DIFF_SCOPE_LABELS[repoDiffScope].toLowerCase()} diff. + Generated from the current {REPO_DIFF_SCOPE_LABELS[repoDiffScope].toLowerCase()}{" "} + diff.

{diffSummaryText ? ( diff --git a/apps/web/src/lib/diffRendering.test.ts b/apps/web/src/lib/diffRendering.test.ts index 843d2a24..6ba0deea 100644 --- a/apps/web/src/lib/diffRendering.test.ts +++ b/apps/web/src/lib/diffRendering.test.ts @@ -4,7 +4,13 @@ // Depends on: Vitest and diffRendering helpers import { describe, expect, it } from "vitest"; -import { buildPatchCacheKey, resolveDiffCopyText, summarizePatchStats } from "./diffRendering"; +import { + buildPatchCacheKey, + getRenderablePatch, + resolveDiffCopyText, + serializeRenderablePatchText, + summarizePatchStats, +} from "./diffRendering"; describe("buildPatchCacheKey", () => { it("returns a stable cache key for identical content", () => { @@ -48,6 +54,88 @@ describe("resolveDiffCopyText", () => { }); }); +describe("serializeRenderablePatchText", () => { + it("returns every line for a large diff that would be virtualized in the DOM", () => { + const LINE_COUNT = 6000; + const bodyLines = Array.from( + { length: LINE_COUNT }, + (_, index) => `+line ${String(index + 1).padStart(4, "0")}`, + ); + const patch = [ + "diff --git a/big.txt b/big.txt", + "new file mode 100644", + "index 0000000..1111111", + "--- /dev/null", + "+++ b/big.txt", + `@@ -0,0 +1,${LINE_COUNT} @@`, + ...bodyLines, + "", + ].join("\n"); + + const renderable = getRenderablePatch(patch, "diff-panel:test"); + expect(renderable?.kind).toBe("files"); + + const serialized = serializeRenderablePatchText(renderable); + expect(serialized).not.toBeNull(); + + const serializedAdditions = serialized!.split("\n").filter((line) => line.startsWith("+line ")); + expect(serializedAdditions).toHaveLength(LINE_COUNT); + expect(serializedAdditions[0]).toBe("+line 0001"); + expect(serializedAdditions[2999]).toBe("+line 3000"); + expect(serializedAdditions.at(-1)).toBe(`+line ${String(LINE_COUNT).padStart(4, "0")}`); + for (const expected of bodyLines) { + expect(serialized).toContain(expected); + } + // The serializer must not inject blank lines between diff rows. + expect(serialized).not.toContain("\n\n"); + }); + + it("reconstructs context and change lines in order for a mixed patch", () => { + const patch = [ + "diff --git a/src/example.ts b/src/example.ts", + "index 1111111..2222222 100644", + "--- a/src/example.ts", + "+++ b/src/example.ts", + "@@ -1,3 +1,4 @@", + " const stable = true;", + "-const oldValue = 1;", + "+const newValue = 1;", + "+const addedValue = 2;", + " export { stable };", + "", + ].join("\n"); + + const serialized = serializeRenderablePatchText(getRenderablePatch(patch, "diff-panel:test")); + + expect(serialized).not.toBeNull(); + const serializedLines = serialized!.split("\n"); + expect(serializedLines).toContain(" const stable = true;"); + expect(serializedLines).toContain("-const oldValue = 1;"); + expect(serializedLines).toContain("+const newValue = 1;"); + expect(serializedLines).toContain("+const addedValue = 2;"); + expect(serializedLines).toContain(" export { stable };"); + // Deletions are emitted before additions within a change block. + expect(serializedLines.indexOf("-const oldValue = 1;")).toBeLessThan( + serializedLines.indexOf("+const newValue = 1;"), + ); + }); + + it("passes raw patches through untouched", () => { + const serialized = serializeRenderablePatchText({ + kind: "raw", + text: "not a parseable diff", + reason: "Showing raw patch.", + }); + + expect(serialized).toBe("not a parseable diff"); + }); + + it("returns null when there is nothing to copy", () => { + expect(serializeRenderablePatchText(null)).toBeNull(); + expect(serializeRenderablePatchText({ kind: "files", files: [] })).toBeNull(); + }); +}); + describe("summarizePatchStats", () => { it("summarizes additions and deletions from a unified patch", () => { const patch = [ diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts index 078da017..ce252409 100644 --- a/apps/web/src/lib/diffRendering.ts +++ b/apps/web/src/lib/diffRendering.ts @@ -4,6 +4,7 @@ // Depends on: @pierre/diffs patch parsing import { parsePatchFiles } from "@pierre/diffs"; +import type { Hunk } from "@pierre/diffs"; import type { FileDiffMetadata } from "@pierre/diffs/react"; export const DIFF_THEME_NAMES = { @@ -98,6 +99,85 @@ export function getRenderablePatch( } } +// @pierre/diffs keeps trailing newlines on parsed line entries and hunk headers; drop +// them so the serializer's own `\n` join does not introduce spurious blank lines. +function stripLineBreak(line: string): string { + return line.replace(/\r?\n$/, ""); +} + +function serializeHunkHeader(hunk: Hunk): string { + const specs = stripLineBreak( + hunk.hunkSpecs ?? + `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@`, + ); + const context = hunk.hunkContext ? stripLineBreak(hunk.hunkContext) : ""; + return context ? `${specs} ${context}` : specs; +} + +// Reconstruct the full unified-diff text for a single parsed file straight from the +// @pierre/diffs model so copy paths never depend on which virtualized rows are mounted. +export function serializeFileDiffMetadata(file: FileDiffMetadata): string { + const newPath = file.name; + const oldPath = file.prevName ?? file.name; + const lines: string[] = [`diff --git a/${oldPath} b/${newPath}`]; + + if (file.type === "new") { + lines.push(`new file mode ${file.mode ?? "100644"}`); + } else if (file.type === "deleted") { + lines.push(`deleted file mode ${file.prevMode ?? file.mode ?? "100644"}`); + } else if (file.type === "rename-pure" || file.type === "rename-changed") { + lines.push(`rename from ${oldPath}`, `rename to ${newPath}`); + } + + lines.push( + `--- ${file.type === "new" ? "/dev/null" : `a/${oldPath}`}`, + `+++ ${file.type === "deleted" ? "/dev/null" : `b/${newPath}`}`, + ); + + for (const hunk of file.hunks) { + lines.push(serializeHunkHeader(hunk)); + for (const segment of hunk.hunkContent) { + if (segment.type === "context") { + for (let offset = 0; offset < segment.lines; offset += 1) { + const content = + file.additionLines[segment.additionLineIndex + offset] ?? + file.deletionLines[segment.deletionLineIndex + offset] ?? + ""; + lines.push(` ${stripLineBreak(content)}`); + } + continue; + } + for (let offset = 0; offset < segment.deletions; offset += 1) { + lines.push( + `-${stripLineBreak(file.deletionLines[segment.deletionLineIndex + offset] ?? "")}`, + ); + } + for (let offset = 0; offset < segment.additions; offset += 1) { + lines.push( + `+${stripLineBreak(file.additionLines[segment.additionLineIndex + offset] ?? "")}`, + ); + } + } + } + + return lines.join("\n"); +} + +// Serialize an entire renderable patch (every file, every line) for clipboard writes. +export function serializeRenderablePatchText(renderable: RenderablePatch | null): string | null { + if (!renderable) { + return null; + } + if (renderable.kind === "raw") { + return renderable.text.length > 0 ? renderable.text : null; + } + if (renderable.files.length === 0) { + return null; + } + const serialized = renderable.files.map(serializeFileDiffMetadata).join("\n"); + return serialized.length > 0 ? serialized : null; +} + // Summarize parsed hunks for compact, consistent diff stats across panel chrome. export function summarizeFileDiffStats(files: ReadonlyArray): { additions: number; From 81b3e9f1e4429fd8d1817d49653696b8ea99ce30 Mon Sep 17 00:00:00 2001 From: Zeus-Deus Date: Fri, 15 May 2026 00:32:04 +0200 Subject: [PATCH 2/2] Trim comments in diff copy code Cut the explanatory comments down to one line per function to match the rest of these files. Follow-up to the select-all copy fix review. --- apps/web/src/components/DiffPanel.logic.ts | 15 ++------------- apps/web/src/components/DiffPanel.tsx | 14 +++----------- apps/web/src/lib/diffRendering.ts | 6 ++---- 3 files changed, 7 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/DiffPanel.logic.ts b/apps/web/src/components/DiffPanel.logic.ts index 4ad12f54..6bd0391b 100644 --- a/apps/web/src/components/DiffPanel.logic.ts +++ b/apps/web/src/components/DiffPanel.logic.ts @@ -34,15 +34,7 @@ export function resolveDiffPanelThread(input: { ); } -// Track whether the diff viewport is in a "select all then copy" gesture so the copy -// handler can substitute the full serialized diff instead of the few mounted rows the -// virtualizer left in the DOM. Pure so it can be unit tested without a real DOM. -// -// The diff surface renders into shadow DOM, so a native Cmd/Ctrl+A actually selects the -// surrounding light-DOM page and the resulting `copy` event never travels through the -// viewport element. We instead listen on `document`: the keydown still passes through the -// viewport (so we can tell the select-all happened there), and this state machine decides -// whether the very next copy should be hijacked. +// Tracks a select-all-then-copy gesture so the copy handler can swap in the full serialized diff; the diff renders into shadow DOM, so the native copy event never reaches the viewport. export function resolveDiffSelectAllArmed( previous: boolean, event: Pick, @@ -51,18 +43,15 @@ export function resolveDiffSelectAllArmed( const key = event.key.toLowerCase(); const hasShortcutModifier = event.metaKey || event.ctrlKey; - // Cmd/Ctrl+A arms the gesture, but only when it happens inside the diff viewport. if (hasShortcutModifier && key === "a") { return isWithinDiffViewport; } - // Cmd/Ctrl+C is the copy half of the gesture — preserve whatever state we were in. if (hasShortcutModifier && key === "c") { return previous; } - // Bare modifier keydowns precede the real shortcut keys; never disarm on them. + // Bare modifier keydowns precede the shortcut key, so they don't count as a new selection. if (key === "meta" || key === "control" || key === "shift" || key === "alt") { return previous; } - // Any other key starts a fresh selection intent, so drop back to native copy behavior. return false; } diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 81445f36..864cc41b 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -196,7 +196,6 @@ export default function DiffPanel({ const setRepoDiffScope = useRepoDiffScopeStore((store) => store.setScope); const [collapsedFiles, setCollapsedFiles] = useState>(() => new Set()); const patchViewportRef = useRef(null); - // Tracks an in-flight "select all then copy" gesture inside the virtualized diff surface. const diffSelectAllArmedRef = useRef(false); const turnStripRef = useRef(null); const previousDiffOpenRef = useRef(false); @@ -410,8 +409,7 @@ export default function DiffPanel({ () => getRenderablePatch(activeReviewPatch, `diff-panel:${resolvedTheme}`), [activeReviewPatch, resolvedTheme], ); - // Serialize the full diff straight from the parsed model so copy paths never depend on - // which virtualized rows happen to be mounted in the DOM. + // Serialize the whole diff from the parsed model so copy never depends on which virtualized rows are mounted. const diffCopyText = useMemo( () => serializeRenderablePatchText(renderablePatch) ?? resolveDiffCopyText(activeReviewPatch), [renderablePatch, activeReviewPatch], @@ -567,12 +565,7 @@ export default function DiffPanel({ }); }, []); - // The diff surface is virtualized and renders into shadow DOM, so a native - // "select all + copy" only captures the handful of mounted rows. We watch the - // document: a Cmd/Ctrl+A keydown still passes through the viewport element (so we can - // tell the gesture started in the diff), and the matching `copy` event — which does - // *not* travel through the viewport — is then hijacked to write the fully serialized - // diff so every line reaches the clipboard. + // Watch the document for select-all-then-copy: the Cmd/Ctrl+A keydown passes through the viewport but the copy event does not, so the native copy would only grab the mounted rows. useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { const viewport = patchViewportRef.current; @@ -584,14 +577,13 @@ export default function DiffPanel({ ); }; const handlePointerDown = () => { - // Any fresh pointer interaction ends the select-all gesture. diffSelectAllArmedRef.current = false; }; const handleCopy = (event: ClipboardEvent) => { if (!diffSelectAllArmedRef.current) { return; } - // One-shot: the next deliberate select-all must re-arm it. + // One-shot: the next select-all must re-arm it. diffSelectAllArmedRef.current = false; if (!diffCopyText || !event.clipboardData) { return; diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts index ce252409..22fe1ed2 100644 --- a/apps/web/src/lib/diffRendering.ts +++ b/apps/web/src/lib/diffRendering.ts @@ -99,8 +99,7 @@ export function getRenderablePatch( } } -// @pierre/diffs keeps trailing newlines on parsed line entries and hunk headers; drop -// them so the serializer's own `\n` join does not introduce spurious blank lines. +// @pierre/diffs leaves trailing newlines on parsed lines; drop them so the `\n` join below doesn't add blank lines. function stripLineBreak(line: string): string { return line.replace(/\r?\n$/, ""); } @@ -114,8 +113,7 @@ function serializeHunkHeader(hunk: Hunk): string { return context ? `${specs} ${context}` : specs; } -// Reconstruct the full unified-diff text for a single parsed file straight from the -// @pierre/diffs model so copy paths never depend on which virtualized rows are mounted. +// Rebuild the unified-diff text for one parsed file from the @pierre/diffs model. export function serializeFileDiffMetadata(file: FileDiffMetadata): string { const newPath = file.name; const oldPath = file.prevName ?? file.name;