Skip to content

Commit e0624b2

Browse files
Merge pull request #116 from Zeus-Deus/feature/60-bug-select-all-and-copy-from-the-diff-file-viewer-doesn-t
Fix select-all copy in the diff viewer
2 parents 17fcf9f + 2a573d3 commit e0624b2

5 files changed

Lines changed: 308 additions & 15 deletions

File tree

apps/web/src/components/DiffPanel.logic.test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
33

44
import type { DraftThreadState } from "../composerDraftStore";
55
import type { Thread } from "../types";
6-
import { resolveDiffPanelThread } from "./DiffPanel.logic";
6+
import { resolveDiffPanelThread, resolveDiffSelectAllArmed } from "./DiffPanel.logic";
77

88
const PROJECT_ID = ProjectId.makeUnsafe("project-1");
99
const THREAD_ID = ThreadId.makeUnsafe("thread-1");
@@ -103,3 +103,50 @@ describe("resolveDiffPanelThread", () => {
103103
).toBeUndefined();
104104
});
105105
});
106+
107+
describe("resolveDiffSelectAllArmed", () => {
108+
it("arms on Cmd/Ctrl+A inside the diff viewport", () => {
109+
expect(
110+
resolveDiffSelectAllArmed(false, { key: "a", metaKey: true, ctrlKey: false }, true),
111+
).toBe(true);
112+
expect(
113+
resolveDiffSelectAllArmed(false, { key: "A", metaKey: false, ctrlKey: true }, true),
114+
).toBe(true);
115+
});
116+
117+
it("does not arm on Cmd/Ctrl+A outside the diff viewport", () => {
118+
expect(
119+
resolveDiffSelectAllArmed(false, { key: "a", metaKey: true, ctrlKey: false }, false),
120+
).toBe(false);
121+
expect(
122+
resolveDiffSelectAllArmed(true, { key: "a", metaKey: false, ctrlKey: true }, false),
123+
).toBe(false);
124+
});
125+
126+
it("preserves the armed state through the copy half of the gesture", () => {
127+
expect(
128+
resolveDiffSelectAllArmed(true, { key: "c", metaKey: true, ctrlKey: false }, false),
129+
).toBe(true);
130+
expect(
131+
resolveDiffSelectAllArmed(false, { key: "c", metaKey: true, ctrlKey: false }, false),
132+
).toBe(false);
133+
});
134+
135+
it("preserves the armed state through bare modifier keydowns", () => {
136+
expect(
137+
resolveDiffSelectAllArmed(true, { key: "Meta", metaKey: true, ctrlKey: false }, false),
138+
).toBe(true);
139+
expect(
140+
resolveDiffSelectAllArmed(true, { key: "Shift", metaKey: false, ctrlKey: false }, false),
141+
).toBe(true);
142+
});
143+
144+
it("disarms on any other key that starts a fresh selection", () => {
145+
expect(
146+
resolveDiffSelectAllArmed(true, { key: "ArrowDown", metaKey: false, ctrlKey: false }, true),
147+
).toBe(false);
148+
expect(
149+
resolveDiffSelectAllArmed(true, { key: "x", metaKey: false, ctrlKey: false }, true),
150+
).toBe(false);
151+
});
152+
});

apps/web/src/components/DiffPanel.logic.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// FILE: DiffPanel.logic.ts
22
// Purpose: Resolve the thread context the diff panel should use across server-backed and local draft chats.
3-
// Exports: resolveDiffPanelThread
3+
// Exports: resolveDiffPanelThread, resolveDiffSelectAllArmed
44
// Depends on: ChatView.logic draft-thread normalization.
55

66
import { DEFAULT_MODEL_BY_PROVIDER, type ModelSelection, type ThreadId } from "@t3tools/contracts";
@@ -33,3 +33,36 @@ export function resolveDiffPanelThread(input: {
3333
null,
3434
);
3535
}
36+
37+
// Track whether the diff viewport is in a "select all then copy" gesture so the copy
38+
// handler can substitute the full serialized diff instead of the few mounted rows the
39+
// virtualizer left in the DOM. Pure so it can be unit tested without a real DOM.
40+
//
41+
// The diff surface renders into shadow DOM, so a native Cmd/Ctrl+A actually selects the
42+
// surrounding light-DOM page and the resulting `copy` event never travels through the
43+
// viewport element. We instead listen on `document`: the keydown still passes through the
44+
// viewport (so we can tell the select-all happened there), and this state machine decides
45+
// whether the very next copy should be hijacked.
46+
export function resolveDiffSelectAllArmed(
47+
previous: boolean,
48+
event: Pick<KeyboardEvent, "key" | "metaKey" | "ctrlKey">,
49+
isWithinDiffViewport: boolean,
50+
): boolean {
51+
const key = event.key.toLowerCase();
52+
const hasShortcutModifier = event.metaKey || event.ctrlKey;
53+
54+
// Cmd/Ctrl+A arms the gesture, but only when it happens inside the diff viewport.
55+
if (hasShortcutModifier && key === "a") {
56+
return isWithinDiffViewport;
57+
}
58+
// Cmd/Ctrl+C is the copy half of the gesture — preserve whatever state we were in.
59+
if (hasShortcutModifier && key === "c") {
60+
return previous;
61+
}
62+
// Bare modifier keydowns precede the real shortcut keys; never disarm on them.
63+
if (key === "meta" || key === "control" || key === "shift" || key === "alt") {
64+
return previous;
65+
}
66+
// Any other key starts a fresh selection intent, so drop back to native copy behavior.
67+
return false;
68+
}

apps/web/src/components/DiffPanel.tsx

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
getRenderablePatch,
4141
resolveDiffCopyText,
4242
resolveDiffThemeName,
43+
serializeRenderablePatchText,
4344
summarizePatchStats,
4445
} from "../lib/diffRendering";
4546
import { resolveDiffEnvironmentState } from "../lib/threadEnvironment";
@@ -56,7 +57,7 @@ import { getProviderStartOptions, useAppSettings } from "../appSettings";
5657
import { useComposerDraftStore } from "../composerDraftStore";
5758
import { formatShortTimestamp } from "../timestampFormat";
5859
import ChatMarkdown from "./ChatMarkdown";
59-
import { resolveDiffPanelThread } from "./DiffPanel.logic";
60+
import { resolveDiffPanelThread, resolveDiffSelectAllArmed } from "./DiffPanel.logic";
6061
import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
6162
import { Button } from "./ui/button";
6263
import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";
@@ -195,6 +196,8 @@ export default function DiffPanel({
195196
const setRepoDiffScope = useRepoDiffScopeStore((store) => store.setScope);
196197
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(() => new Set());
197198
const patchViewportRef = useRef<HTMLDivElement>(null);
199+
// Tracks an in-flight "select all then copy" gesture inside the virtualized diff surface.
200+
const diffSelectAllArmedRef = useRef(false);
198201
const turnStripRef = useRef<HTMLDivElement>(null);
199202
const previousDiffOpenRef = useRef(false);
200203
const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false);
@@ -403,11 +406,16 @@ export default function DiffPanel({
403406
const isSidebarMode = mode === "sidebar";
404407
const { copyToClipboard, isCopied: isSummaryCopied } = useCopyToClipboard();
405408
const { copyToClipboard: copyDiffToClipboard, isCopied: isDiffCopied } = useCopyToClipboard();
406-
const diffCopyText = useMemo(() => resolveDiffCopyText(activeReviewPatch), [activeReviewPatch]);
407409
const renderablePatch = useMemo(
408410
() => getRenderablePatch(activeReviewPatch, `diff-panel:${resolvedTheme}`),
409411
[activeReviewPatch, resolvedTheme],
410412
);
413+
// Serialize the full diff straight from the parsed model so copy paths never depend on
414+
// which virtualized rows happen to be mounted in the DOM.
415+
const diffCopyText = useMemo(
416+
() => serializeRenderablePatchText(renderablePatch) ?? resolveDiffCopyText(activeReviewPatch),
417+
[renderablePatch, activeReviewPatch],
418+
);
411419
const renderableFiles = useMemo(() => {
412420
if (!renderablePatch || renderablePatch.kind !== "files") {
413421
return [];
@@ -507,16 +515,10 @@ export default function DiffPanel({
507515
? "Failed to generate diff summary."
508516
: null;
509517
const canShowSummary = Boolean(
510-
!diffEnvironmentPending &&
511-
activeCwd &&
512-
(!hasResolvedRepoPatch || !hasNoRepoChanges),
518+
!diffEnvironmentPending && activeCwd && (!hasResolvedRepoPatch || !hasNoRepoChanges),
513519
);
514520
const canPrefetchSummary = Boolean(
515-
diffOpen &&
516-
!diffEnvironmentPending &&
517-
activeCwd &&
518-
normalizedRepoPatch &&
519-
!hasNoRepoChanges,
521+
diffOpen && !diffEnvironmentPending && activeCwd && normalizedRepoPatch && !hasNoRepoChanges,
520522
);
521523
const canShowTotal = Boolean(!diffEnvironmentPending && activeCwd);
522524

@@ -565,6 +567,49 @@ export default function DiffPanel({
565567
});
566568
}, []);
567569

570+
// The diff surface is virtualized and renders into shadow DOM, so a native
571+
// "select all + copy" only captures the handful of mounted rows. We watch the
572+
// document: a Cmd/Ctrl+A keydown still passes through the viewport element (so we can
573+
// tell the gesture started in the diff), and the matching `copy` event — which does
574+
// *not* travel through the viewport — is then hijacked to write the fully serialized
575+
// diff so every line reaches the clipboard.
576+
useEffect(() => {
577+
const handleKeyDown = (event: KeyboardEvent) => {
578+
const viewport = patchViewportRef.current;
579+
const isWithinDiffViewport = viewport ? event.composedPath().includes(viewport) : false;
580+
diffSelectAllArmedRef.current = resolveDiffSelectAllArmed(
581+
diffSelectAllArmedRef.current,
582+
event,
583+
isWithinDiffViewport,
584+
);
585+
};
586+
const handlePointerDown = () => {
587+
// Any fresh pointer interaction ends the select-all gesture.
588+
diffSelectAllArmedRef.current = false;
589+
};
590+
const handleCopy = (event: ClipboardEvent) => {
591+
if (!diffSelectAllArmedRef.current) {
592+
return;
593+
}
594+
// One-shot: the next deliberate select-all must re-arm it.
595+
diffSelectAllArmedRef.current = false;
596+
if (!diffCopyText || !event.clipboardData) {
597+
return;
598+
}
599+
event.preventDefault();
600+
event.clipboardData.setData("text/plain", diffCopyText);
601+
};
602+
603+
document.addEventListener("keydown", handleKeyDown, true);
604+
document.addEventListener("pointerdown", handlePointerDown, true);
605+
document.addEventListener("copy", handleCopy, true);
606+
return () => {
607+
document.removeEventListener("keydown", handleKeyDown, true);
608+
document.removeEventListener("pointerdown", handlePointerDown, true);
609+
document.removeEventListener("copy", handleCopy, true);
610+
};
611+
}, [diffCopyText]);
612+
568613
const selectTurn = (turnId: TurnId) => {
569614
if (!activeThread) return;
570615
if (onUpdatePanelState) {
@@ -943,8 +988,8 @@ export default function DiffPanel({
943988
<div className="min-w-0">
944989
<p className="text-sm font-medium text-foreground">Repo summary</p>
945990
<p className="text-[11px] text-muted-foreground">
946-
Generated from the current{" "}
947-
{REPO_DIFF_SCOPE_LABELS[repoDiffScope].toLowerCase()} diff.
991+
Generated from the current {REPO_DIFF_SCOPE_LABELS[repoDiffScope].toLowerCase()}{" "}
992+
diff.
948993
</p>
949994
</div>
950995
{diffSummaryText ? (

apps/web/src/lib/diffRendering.test.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@
44
// Depends on: Vitest and diffRendering helpers
55

66
import { describe, expect, it } from "vitest";
7-
import { buildPatchCacheKey, resolveDiffCopyText, summarizePatchStats } from "./diffRendering";
7+
import {
8+
buildPatchCacheKey,
9+
getRenderablePatch,
10+
resolveDiffCopyText,
11+
serializeRenderablePatchText,
12+
summarizePatchStats,
13+
} from "./diffRendering";
814

915
describe("buildPatchCacheKey", () => {
1016
it("returns a stable cache key for identical content", () => {
@@ -48,6 +54,88 @@ describe("resolveDiffCopyText", () => {
4854
});
4955
});
5056

57+
describe("serializeRenderablePatchText", () => {
58+
it("returns every line for a large diff that would be virtualized in the DOM", () => {
59+
const LINE_COUNT = 6000;
60+
const bodyLines = Array.from(
61+
{ length: LINE_COUNT },
62+
(_, index) => `+line ${String(index + 1).padStart(4, "0")}`,
63+
);
64+
const patch = [
65+
"diff --git a/big.txt b/big.txt",
66+
"new file mode 100644",
67+
"index 0000000..1111111",
68+
"--- /dev/null",
69+
"+++ b/big.txt",
70+
`@@ -0,0 +1,${LINE_COUNT} @@`,
71+
...bodyLines,
72+
"",
73+
].join("\n");
74+
75+
const renderable = getRenderablePatch(patch, "diff-panel:test");
76+
expect(renderable?.kind).toBe("files");
77+
78+
const serialized = serializeRenderablePatchText(renderable);
79+
expect(serialized).not.toBeNull();
80+
81+
const serializedAdditions = serialized!.split("\n").filter((line) => line.startsWith("+line "));
82+
expect(serializedAdditions).toHaveLength(LINE_COUNT);
83+
expect(serializedAdditions[0]).toBe("+line 0001");
84+
expect(serializedAdditions[2999]).toBe("+line 3000");
85+
expect(serializedAdditions.at(-1)).toBe(`+line ${String(LINE_COUNT).padStart(4, "0")}`);
86+
for (const expected of bodyLines) {
87+
expect(serialized).toContain(expected);
88+
}
89+
// The serializer must not inject blank lines between diff rows.
90+
expect(serialized).not.toContain("\n\n");
91+
});
92+
93+
it("reconstructs context and change lines in order for a mixed patch", () => {
94+
const patch = [
95+
"diff --git a/src/example.ts b/src/example.ts",
96+
"index 1111111..2222222 100644",
97+
"--- a/src/example.ts",
98+
"+++ b/src/example.ts",
99+
"@@ -1,3 +1,4 @@",
100+
" const stable = true;",
101+
"-const oldValue = 1;",
102+
"+const newValue = 1;",
103+
"+const addedValue = 2;",
104+
" export { stable };",
105+
"",
106+
].join("\n");
107+
108+
const serialized = serializeRenderablePatchText(getRenderablePatch(patch, "diff-panel:test"));
109+
110+
expect(serialized).not.toBeNull();
111+
const serializedLines = serialized!.split("\n");
112+
expect(serializedLines).toContain(" const stable = true;");
113+
expect(serializedLines).toContain("-const oldValue = 1;");
114+
expect(serializedLines).toContain("+const newValue = 1;");
115+
expect(serializedLines).toContain("+const addedValue = 2;");
116+
expect(serializedLines).toContain(" export { stable };");
117+
// Deletions are emitted before additions within a change block.
118+
expect(serializedLines.indexOf("-const oldValue = 1;")).toBeLessThan(
119+
serializedLines.indexOf("+const newValue = 1;"),
120+
);
121+
});
122+
123+
it("passes raw patches through untouched", () => {
124+
const serialized = serializeRenderablePatchText({
125+
kind: "raw",
126+
text: "not a parseable diff",
127+
reason: "Showing raw patch.",
128+
});
129+
130+
expect(serialized).toBe("not a parseable diff");
131+
});
132+
133+
it("returns null when there is nothing to copy", () => {
134+
expect(serializeRenderablePatchText(null)).toBeNull();
135+
expect(serializeRenderablePatchText({ kind: "files", files: [] })).toBeNull();
136+
});
137+
});
138+
51139
describe("summarizePatchStats", () => {
52140
it("summarizes additions and deletions from a unified patch", () => {
53141
const patch = [

0 commit comments

Comments
 (0)