Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion apps/web/src/components/DiffPanel.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);
});
});
24 changes: 23 additions & 1 deletion apps/web/src/components/DiffPanel.logic.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<KeyboardEvent, "key" | "metaKey" | "ctrlKey">,
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;
}
61 changes: 49 additions & 12 deletions apps/web/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
getRenderablePatch,
resolveDiffCopyText,
resolveDiffThemeName,
serializeRenderablePatchText,
summarizePatchStats,
} from "../lib/diffRendering";
import { resolveDiffEnvironmentState } from "../lib/threadEnvironment";
Expand All @@ -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";
Expand Down Expand Up @@ -195,6 +196,7 @@ export default function DiffPanel({
const setRepoDiffScope = useRepoDiffScopeStore((store) => store.setScope);
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(() => new Set());
const patchViewportRef = useRef<HTMLDivElement>(null);
const diffSelectAllArmedRef = useRef(false);
const turnStripRef = useRef<HTMLDivElement>(null);
const previousDiffOpenRef = useRef(false);
const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false);
Expand Down Expand Up @@ -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 [];
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -943,8 +980,8 @@ export default function DiffPanel({
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">Repo summary</p>
<p className="text-[11px] text-muted-foreground">
Generated from the current{" "}
{REPO_DIFF_SCOPE_LABELS[repoDiffScope].toLowerCase()} diff.
Generated from the current {REPO_DIFF_SCOPE_LABELS[repoDiffScope].toLowerCase()}{" "}
diff.
</p>
</div>
{diffSummaryText ? (
Expand Down
90 changes: 89 additions & 1 deletion apps/web/src/lib/diffRendering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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 = [
Expand Down
Loading