From 66723e90c537cd04455a6fbf56181b443c9742ab Mon Sep 17 00:00:00 2001 From: huxcrux Date: Sun, 15 Mar 2026 22:48:30 +0100 Subject: [PATCH] Add per-file diff collapse toggle --- apps/web/src/components/DiffPanel.tsx | 93 ++++++++++++++++++---- apps/web/src/lib/diffPanelCollapse.test.ts | 61 ++++++++++++++ apps/web/src/lib/diffPanelCollapse.ts | 52 ++++++++++++ 3 files changed, 191 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/lib/diffPanelCollapse.test.ts create mode 100644 apps/web/src/lib/diffPanelCollapse.ts diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 34ad78881..c1d5fc728 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -3,7 +3,14 @@ import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/reac import { useQuery } from "@tanstack/react-query"; import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import { ThreadId, type TurnId } from "@t3tools/contracts"; -import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react"; +import { + ChevronDownIcon, + ChevronUpIcon, + ChevronLeftIcon, + ChevronRightIcon, + Columns2Icon, + Rows3Icon, +} from "lucide-react"; import { type WheelEvent as ReactWheelEvent, useCallback, @@ -20,6 +27,13 @@ import { readNativeApi } from "../nativeApi"; import { resolvePathLinkTarget } from "../terminal-links"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { useTheme } from "../hooks/useTheme"; +import { + buildFileDiffRenderKey, + expandCollapsedDiffFileForPath, + resetCollapsedDiffFiles, + resolveFileDiffPath, + toggleCollapsedDiffFile, +} from "../lib/diffPanelCollapse"; import { buildPatchCacheKey } from "../lib/diffRendering"; import { resolveDiffThemeName } from "../lib/diffRendering"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; @@ -94,6 +108,11 @@ const DIFF_PANEL_UNSAFE_CSS = ` color: color-mix(in srgb, var(--foreground) 84%, var(--primary)) !important; text-decoration-color: currentColor; } + +:host(.diff-file-collapsed) pre, +:host(.diff-file-collapsed) [data-virtualizer-buffer] { + display: none !important; +} `; type RenderablePatch = @@ -139,18 +158,6 @@ function getRenderablePatch( } } -function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { - const raw = fileDiff.name ?? fileDiff.prevName ?? ""; - if (raw.startsWith("a/") || raw.startsWith("b/")) { - return raw.slice(2); - } - return raw; -} - -function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { - return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`; -} - interface DiffPanelProps { mode?: DiffPanelMode; } @@ -162,6 +169,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const { resolvedTheme } = useTheme(); const { settings } = useAppSettings(); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); + const [collapsedFileKeys, setCollapsedFileKeys] = + useState>(resetCollapsedDiffFiles); const patchViewportRef = useRef(null); const turnStripRef = useRef(null); const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); @@ -293,15 +302,41 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { ); }, [renderablePatch]); + useEffect(() => { + setCollapsedFileKeys(resetCollapsedDiffFiles()); + }, [selectedPatch]); + + useEffect(() => { + if (!selectedFilePath) { + return; + } + + setCollapsedFileKeys((current) => + expandCollapsedDiffFileForPath(current, renderableFiles, selectedFilePath), + ); + }, [renderableFiles, selectedFilePath]); + useEffect(() => { if (!selectedFilePath || !patchViewportRef.current) { return; } + + const selectedFile = renderableFiles.find( + (fileDiff) => resolveFileDiffPath(fileDiff) === selectedFilePath, + ); + if (!selectedFile) { + return; + } + + if (collapsedFileKeys.has(buildFileDiffRenderKey(selectedFile))) { + return; + } + const target = Array.from( patchViewportRef.current.querySelectorAll("[data-diff-file-path]"), ).find((element) => element.dataset.diffFilePath === selectedFilePath); target?.scrollIntoView({ block: "nearest" }); - }, [selectedFilePath, renderableFiles]); + }, [collapsedFileKeys, renderableFiles, selectedFilePath]); const openDiffFileInEditor = useCallback( (filePath: string) => { @@ -561,6 +596,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const filePath = resolveFileDiffPath(fileDiff); const fileKey = buildFileDiffRenderKey(fileDiff); const themedFileKey = `${fileKey}:${resolvedTheme}`; + const isCollapsed = collapsedFileKeys.has(fileKey); return (
{ + if (!(node instanceof Element)) return false; + return node.hasAttribute("data-diff-collapse-toggle"); + }); + if (!clickedHeader || clickedCollapseToggle) return; openDiffFileInEditor(filePath); }} > ( + + )} />
); diff --git a/apps/web/src/lib/diffPanelCollapse.test.ts b/apps/web/src/lib/diffPanelCollapse.test.ts new file mode 100644 index 000000000..8c3878dbc --- /dev/null +++ b/apps/web/src/lib/diffPanelCollapse.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import { + buildFileDiffRenderKey, + expandCollapsedDiffFileForPath, + resetCollapsedDiffFiles, + toggleCollapsedDiffFile, +} from "./diffPanelCollapse"; + +describe("diffPanelCollapse", () => { + const firstFile = { + name: "src/app.ts", + cacheKey: "file-1", + }; + const secondFile = { + name: "src/routes.ts", + cacheKey: "file-2", + }; + + it("defaults files to expanded", () => { + const collapsed = resetCollapsedDiffFiles(); + + expect(collapsed.has(buildFileDiffRenderKey(firstFile))).toBe(false); + }); + + it("toggles one file without affecting others", () => { + const collapsedFirst = toggleCollapsedDiffFile( + resetCollapsedDiffFiles(), + buildFileDiffRenderKey(firstFile), + ); + + expect(collapsedFirst.has(buildFileDiffRenderKey(firstFile))).toBe(true); + expect(collapsedFirst.has(buildFileDiffRenderKey(secondFile))).toBe(false); + }); + + it("resets all collapsed files for a new patch selection", () => { + const collapsed = toggleCollapsedDiffFile( + resetCollapsedDiffFiles(), + buildFileDiffRenderKey(firstFile), + ); + + expect(collapsed.size).toBe(1); + expect(resetCollapsedDiffFiles().size).toBe(0); + }); + + it("auto-expands the selected file path when it was collapsed", () => { + const renamedFile = { + name: "b/src/new-name.ts", + prevName: "a/src/old-name.ts", + cacheKey: "file-rename", + }; + const collapsed = toggleCollapsedDiffFile( + resetCollapsedDiffFiles(), + buildFileDiffRenderKey(renamedFile), + ); + + const expanded = expandCollapsedDiffFileForPath(collapsed, [renamedFile], "src/new-name.ts"); + + expect(expanded.has(buildFileDiffRenderKey(renamedFile))).toBe(false); + }); +}); diff --git a/apps/web/src/lib/diffPanelCollapse.ts b/apps/web/src/lib/diffPanelCollapse.ts new file mode 100644 index 000000000..3ca99db06 --- /dev/null +++ b/apps/web/src/lib/diffPanelCollapse.ts @@ -0,0 +1,52 @@ +import type { FileDiffMetadata } from "@pierre/diffs/react"; + +export function resolveFileDiffPath(fileDiff: Pick): string { + const raw = fileDiff.name ?? fileDiff.prevName ?? ""; + if (raw.startsWith("a/") || raw.startsWith("b/")) { + return raw.slice(2); + } + return raw; +} + +export function buildFileDiffRenderKey( + fileDiff: Pick, +): string { + return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`; +} + +export function toggleCollapsedDiffFile( + current: ReadonlySet, + fileKey: string, +): ReadonlySet { + const next = new Set(current); + if (next.has(fileKey)) { + next.delete(fileKey); + } else { + next.add(fileKey); + } + return next; +} + +export function resetCollapsedDiffFiles(): ReadonlySet { + return new Set(); +} + +export function expandCollapsedDiffFileForPath( + current: ReadonlySet, + files: ReadonlyArray>, + filePath: string, +): ReadonlySet { + const match = files.find((fileDiff) => resolveFileDiffPath(fileDiff) === filePath); + if (!match) { + return current; + } + + const fileKey = buildFileDiffRenderKey(match); + if (!current.has(fileKey)) { + return current; + } + + const next = new Set(current); + next.delete(fileKey); + return next; +}