Skip to content

Commit 1064bb2

Browse files
feat(web): collapsible file diffs in commit diff panel
Hovering the status badge reveals a chevron; clicking it toggles the diff body. Collapsed state is held in the FileDiffList so it survives virtualization unmounts, and on collapse we scroll the row's header to the top of the viewport when it's already been scrolled past. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e1bc871 commit 1064bb2

3 files changed

Lines changed: 88 additions & 23 deletions

File tree

packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/fileDiffList.tsx

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { FileDiff } from "@/features/git";
44
import { useVirtualizer } from "@tanstack/react-virtual";
5-
import { useMemo, useRef } from "react";
5+
import { useCallback, useRef, useState } from "react";
66
import { FileDiffRow } from "./fileDiffRow";
77

88
interface FileDiffListProps {
@@ -23,7 +23,15 @@ const LINE_HEIGHT_PX = 18;
2323
const MIN_ROW_HEIGHT_PX = 200;
2424
const CONTEXT_LINES_PER_HUNK = 6;
2525

26-
const estimateRowHeight = (file: FileDiff): number => {
26+
const getRowKey = (file: FileDiff, index: number): string => {
27+
return file.newPath ?? file.oldPath ?? `idx-${index}`;
28+
};
29+
30+
const estimateRowHeight = (file: FileDiff, isCollapsed: boolean): number => {
31+
if (isCollapsed) {
32+
return ROW_HEADER_PX;
33+
}
34+
2735
const visibleLines = file.hunks.reduce((sum, hunk) => {
2836
return sum + Math.max(hunk.oldRange.lines, hunk.newRange.lines) + CONTEXT_LINES_PER_HUNK;
2937
}, 0);
@@ -34,14 +42,39 @@ const estimateRowHeight = (file: FileDiff): number => {
3442

3543
export const FileDiffList = ({ files, repoName, commitSha, parentSha }: FileDiffListProps) => {
3644
const parentRef = useRef<HTMLDivElement>(null);
45+
const [collapsedKeys, setCollapsedKeys] = useState<Set<string>>(() => new Set());
46+
const collapsedKeysRef = useRef(collapsedKeys);
47+
collapsedKeysRef.current = collapsedKeys;
3748

3849
const virtualizer = useVirtualizer({
3950
count: files.length,
4051
getScrollElement: () => parentRef.current,
41-
estimateSize: (index) => estimateRowHeight(files[index]),
52+
estimateSize: (index) => {
53+
const file = files[index];
54+
return estimateRowHeight(file, collapsedKeys.has(getRowKey(file, index)));
55+
},
4256
overscan: 6,
4357
});
4458

59+
const toggleCollapsed = useCallback((key: string, index: number, rowStart: number) => {
60+
const willCollapse = !collapsedKeysRef.current.has(key);
61+
setCollapsedKeys((prev) => {
62+
const next = new Set(prev);
63+
if (next.has(key)) {
64+
next.delete(key);
65+
} else {
66+
next.add(key);
67+
}
68+
return next;
69+
});
70+
if (willCollapse) {
71+
const scrollTop = parentRef.current?.scrollTop ?? 0;
72+
if (scrollTop > rowStart) {
73+
virtualizer.scrollToIndex(index, { align: 'start' });
74+
}
75+
}
76+
}, [virtualizer]);
77+
4578
return (
4679
<div
4780
ref={parentRef}
@@ -61,7 +94,7 @@ export const FileDiffList = ({ files, repoName, commitSha, parentSha }: FileDiff
6194
>
6295
{virtualizer.getVirtualItems().map((virtualRow) => {
6396
const file = files[virtualRow.index];
64-
const rowKey = file.newPath ?? file.oldPath ?? `idx-${virtualRow.index}`;
97+
const rowKey = getRowKey(file, virtualRow.index);
6598
return (
6699
<div
67100
key={virtualRow.key}
@@ -82,6 +115,8 @@ export const FileDiffList = ({ files, repoName, commitSha, parentSha }: FileDiff
82115
repoName={repoName}
83116
commitSha={commitSha}
84117
parentSha={parentSha}
118+
isCollapsed={collapsedKeys.has(rowKey)}
119+
onToggleCollapsed={() => toggleCollapsed(rowKey, virtualRow.index, virtualRow.start)}
85120
/>
86121
</div>
87122
);

packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/fileDiffRow.tsx

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ interface FileDiffRowProps {
2424
commitSha: string;
2525
// Null for the initial commit (no parent).
2626
parentSha: string | null;
27+
isCollapsed: boolean;
28+
onToggleCollapsed: () => void;
2729
}
2830

29-
export const FileDiffRow = ({ file, yOffset, repoName, commitSha, parentSha }: FileDiffRowProps) => {
31+
export const FileDiffRow = ({ file, yOffset, repoName, commitSha, parentSha, isCollapsed, onToggleCollapsed }: FileDiffRowProps) => {
3032
const status = getFileStatus(file);
3133

3234
// Deleted files don't exist at the commit, so the link points to the
@@ -61,7 +63,7 @@ export const FileDiffRow = ({ file, yOffset, repoName, commitSha, parentSha }: F
6163
className="flex flex-row items-center gap-2 py-2 px-3 border-b bg-muted sticky z-10"
6264
style={{ top: `-${yOffset}px` }}
6365
>
64-
<StatusBadge status={status} />
66+
<StatusBadge status={status} onToggle={onToggleCollapsed} isCollapsed={isCollapsed} />
6567
<div className="flex-1 min-w-0 flex flex-row items-center gap-1 overflow-hidden">
6668
<code className="text-xs truncate">{getDisplayPath(file)}</code>
6769
<CopyIconButton onCopy={onCopyPath} className="flex-shrink-0" />
@@ -75,16 +77,18 @@ export const FileDiffRow = ({ file, yOffset, repoName, commitSha, parentSha }: F
7577
/>
7678
)}
7779
</div>
78-
{file.hunks.length === 0 ? (
79-
<div className="p-4 text-sm text-muted-foreground">
80-
No textual diff (binary file or empty change).
81-
</div>
82-
) : (
83-
<LightweightDiffViewer
84-
hunks={file.hunks}
85-
oldPath={file.oldPath}
86-
newPath={file.newPath}
87-
/>
80+
{!isCollapsed && (
81+
file.hunks.length === 0 ? (
82+
<div className="p-4 text-sm text-muted-foreground">
83+
No textual diff (binary file or empty change).
84+
</div>
85+
) : (
86+
<LightweightDiffViewer
87+
hunks={file.hunks}
88+
oldPath={file.oldPath}
89+
newPath={file.newPath}
90+
/>
91+
)
8892
)}
8993
</div>
9094
);

packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/fileStatus.tsx

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { FileDiff } from "@/features/git";
2+
import { ChevronDown, ChevronRight } from "lucide-react";
23

34
export type FileStatus = 'added' | 'modified' | 'deleted' | 'renamed';
45

@@ -29,10 +30,35 @@ const STATUS_BADGE_COLORS: Record<FileStatus, string> = {
2930
renamed: 'bg-blue-500/20 text-blue-700 dark:text-blue-400',
3031
};
3132

32-
export const StatusBadge = ({ status }: { status: FileStatus }) => (
33-
<span
34-
className={`inline-flex items-center justify-center w-5 h-5 rounded text-xs font-mono font-bold ${STATUS_BADGE_COLORS[status]}`}
35-
>
36-
{STATUS_BADGE_LABELS[status]}
37-
</span>
38-
);
33+
interface StatusBadgeProps {
34+
status: FileStatus;
35+
onToggle?: () => void;
36+
isCollapsed?: boolean;
37+
}
38+
39+
export const StatusBadge = ({ status, onToggle, isCollapsed }: StatusBadgeProps) => {
40+
const baseClassName = `inline-flex items-center justify-center w-5 h-5 rounded text-xs font-mono font-bold ${STATUS_BADGE_COLORS[status]}`;
41+
42+
if (!onToggle) {
43+
return (
44+
<span className={baseClassName}>
45+
{STATUS_BADGE_LABELS[status]}
46+
</span>
47+
);
48+
}
49+
50+
const Chevron = isCollapsed ? ChevronRight : ChevronDown;
51+
52+
return (
53+
<button
54+
type="button"
55+
onClick={onToggle}
56+
aria-label={isCollapsed ? "Expand diff" : "Collapse diff"}
57+
aria-expanded={!isCollapsed}
58+
className={`${baseClassName} group cursor-pointer`}
59+
>
60+
<span className="group-hover:hidden">{STATUS_BADGE_LABELS[status]}</span>
61+
<Chevron className="hidden group-hover:block w-3 h-3" />
62+
</button>
63+
);
64+
};

0 commit comments

Comments
 (0)