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..6bd0391b 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,25 @@ export function resolveDiffPanelThread(input: { null, ); } + +// 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, + isWithinDiffViewport: boolean, +): boolean { + const key = event.key.toLowerCase(); + const hasShortcutModifier = event.metaKey || event.ctrlKey; + + if (hasShortcutModifier && key === "a") { + return isWithinDiffViewport; + } + if (hasShortcutModifier && key === "c") { + return previous; + } + // 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; + } + return false; +} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index c1760141..864cc41b 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,7 @@ export default function DiffPanel({ const setRepoDiffScope = useRepoDiffScopeStore((store) => store.setScope); const [collapsedFiles, setCollapsedFiles] = useState>(() => new Set()); const patchViewportRef = useRef(null); + const diffSelectAllArmedRef = useRef(false); const turnStripRef = useRef(null); const previousDiffOpenRef = useRef(false); const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); @@ -403,11 +405,15 @@ 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 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], + ); const renderableFiles = useMemo(() => { if (!renderablePatch || renderablePatch.kind !== "files") { return []; @@ -507,16 +513,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 +565,43 @@ export default function DiffPanel({ }); }, []); + // 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; + const isWithinDiffViewport = viewport ? event.composedPath().includes(viewport) : false; + diffSelectAllArmedRef.current = resolveDiffSelectAllArmed( + diffSelectAllArmedRef.current, + event, + isWithinDiffViewport, + ); + }; + const handlePointerDown = () => { + diffSelectAllArmedRef.current = false; + }; + const handleCopy = (event: ClipboardEvent) => { + if (!diffSelectAllArmedRef.current) { + return; + } + // One-shot: the next 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 +980,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..22fe1ed2 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,83 @@ export function getRenderablePatch( } } +// @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$/, ""); +} + +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; +} + +// 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; + 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;