Skip to content

Commit 38d1474

Browse files
zanetworkerclaude
andcommitted
feat: hierarchical file tree in diff review panel
Replace flat file list with collapsible directory tree: - Directories show as folders with file count and aggregate +/- stats - Click to expand/collapse directories - Single-child directories are flattened (cmd/aimux/cmd -> cmd/aimux/cmd) - Files sorted alphabetically within directories, dirs first - Indentation shows nesting depth - Filter input searches across the full tree Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f3cb35e commit 38d1474

1 file changed

Lines changed: 167 additions & 40 deletions

File tree

web/src/components/DiffReview.tsx

Lines changed: 167 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect } from 'react';
1+
import React, { useState, useEffect } from 'react';
22

33
interface DiffLine {
44
type: 'add' | 'del' | 'ctx' | 'collapse';
@@ -33,6 +33,7 @@ export function DiffReview({ sessionFile }: Props) {
3333
const [selectedFile, setSelectedFile] = useState<string | null>(null);
3434
const [fileFilter, setFileFilter] = useState('');
3535
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set());
36+
const [collapsedDirs, setCollapsedDirs] = useState<Set<string>>(new Set());
3637

3738
useEffect(() => {
3839
if (!sessionFile) return;
@@ -109,7 +110,7 @@ export function DiffReview({ sessionFile }: Props) {
109110
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
110111
{/* File tree */}
111112
<div style={{
112-
width: 220, borderRight: '1px solid var(--border)',
113+
width: 240, borderRight: '1px solid var(--border)',
113114
overflowY: 'auto', flexShrink: 0,
114115
}}>
115116
<input
@@ -124,44 +125,7 @@ export function DiffReview({ sessionFile }: Props) {
124125
boxSizing: 'border-box',
125126
}}
126127
/>
127-
{filtered.map(f => {
128-
const si = statusIcon(f.status);
129-
const isSelected = f.path === selectedFile;
130-
return (
131-
<div
132-
key={f.path}
133-
onClick={() => setSelectedFile(f.path)}
134-
style={{
135-
padding: '6px 10px', cursor: 'pointer',
136-
background: isSelected ? 'var(--bg-2)' : 'transparent',
137-
borderLeft: isSelected ? '2px solid var(--accent)' : '2px solid transparent',
138-
display: 'flex', alignItems: 'center', gap: 6,
139-
}}
140-
>
141-
<span style={{
142-
fontSize: 9, fontWeight: 700, color: si.color,
143-
width: 14, textAlign: 'center', flexShrink: 0,
144-
}}>
145-
{si.label}
146-
</span>
147-
<span style={{
148-
fontSize: 11, color: isSelected ? 'var(--fg)' : 'var(--fg-2)',
149-
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1,
150-
fontFamily: 'var(--mono)',
151-
}}>
152-
{f.shortPath}
153-
</span>
154-
<span style={{ fontSize: 9, color: 'var(--green)', fontFamily: 'var(--mono)', flexShrink: 0 }}>
155-
+{f.added}
156-
</span>
157-
{f.removed > 0 && (
158-
<span style={{ fontSize: 9, color: 'var(--accent)', fontFamily: 'var(--mono)', flexShrink: 0 }}>
159-
-{f.removed}
160-
</span>
161-
)}
162-
</div>
163-
);
164-
})}
128+
{renderFileTree(filtered, selectedFile, collapsedDirs, setCollapsedDirs, setSelectedFile, statusIcon)}
165129
</div>
166130

167131
{/* Diff content */}
@@ -266,3 +230,166 @@ export function DiffReview({ sessionFile }: Props) {
266230
</div>
267231
);
268232
}
233+
234+
interface TreeNode {
235+
name: string;
236+
path: string;
237+
isDir: boolean;
238+
children: TreeNode[];
239+
file?: FileDiff;
240+
}
241+
242+
function buildTree(files: FileDiff[]): TreeNode[] {
243+
const root: TreeNode = { name: '', path: '', isDir: true, children: [] };
244+
245+
for (const f of files) {
246+
const parts = f.path.split('/');
247+
let current = root;
248+
249+
for (let i = 0; i < parts.length; i++) {
250+
const part = parts[i];
251+
const isLast = i === parts.length - 1;
252+
const partPath = parts.slice(0, i + 1).join('/');
253+
254+
if (isLast) {
255+
current.children.push({ name: part, path: f.path, isDir: false, children: [], file: f });
256+
} else {
257+
let dir = current.children.find(c => c.isDir && c.name === part);
258+
if (!dir) {
259+
dir = { name: part, path: partPath, isDir: true, children: [] };
260+
current.children.push(dir);
261+
}
262+
current = dir;
263+
}
264+
}
265+
}
266+
267+
return flattenSingleChildDirs(root.children);
268+
}
269+
270+
function flattenSingleChildDirs(nodes: TreeNode[]): TreeNode[] {
271+
return nodes.map(node => {
272+
if (!node.isDir) return node;
273+
node.children = flattenSingleChildDirs(node.children);
274+
if (node.children.length === 1 && node.children[0].isDir) {
275+
const child = node.children[0];
276+
return { ...child, name: node.name + '/' + child.name, path: child.path };
277+
}
278+
return node;
279+
});
280+
}
281+
282+
function renderFileTree(
283+
files: FileDiff[],
284+
selectedFile: string | null,
285+
collapsedDirs: Set<string>,
286+
setCollapsedDirs: (fn: (prev: Set<string>) => Set<string>) => void,
287+
setSelectedFile: (path: string) => void,
288+
statusIcon: (status: string) => { label: string; color: string },
289+
) {
290+
const tree = buildTree(files);
291+
292+
const rows: React.ReactNode[] = [];
293+
294+
function renderNode(node: TreeNode, depth: number) {
295+
if (node.isDir) {
296+
const isCollapsed = collapsedDirs.has(node.path);
297+
const dirFiles = collectFiles(node);
298+
const dirAdded = dirFiles.reduce((s, f) => s + f.added, 0);
299+
const dirRemoved = dirFiles.reduce((s, f) => s + f.removed, 0);
300+
301+
rows.push(
302+
<div
303+
key={'dir-' + node.path}
304+
onClick={() => setCollapsedDirs(prev => {
305+
const next = new Set(prev);
306+
if (next.has(node.path)) next.delete(node.path); else next.add(node.path);
307+
return next;
308+
})}
309+
style={{
310+
padding: '4px 8px', paddingLeft: 8 + depth * 12,
311+
cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4,
312+
color: 'var(--fg-3)', fontSize: 11,
313+
}}
314+
>
315+
<span style={{ fontSize: 10, width: 12, flexShrink: 0, userSelect: 'none' }}>
316+
{isCollapsed ? '▸' : '▾'}
317+
</span>
318+
<span style={{ fontSize: 9, opacity: 0.5, flexShrink: 0 }}>📁</span>
319+
<span style={{ fontFamily: 'var(--mono)', fontWeight: 600, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
320+
{node.name}
321+
</span>
322+
<span style={{ fontSize: 9, color: 'var(--fg-4)', flexShrink: 0 }}>
323+
{dirFiles.length}
324+
</span>
325+
<span style={{ fontSize: 9, color: 'var(--green)', fontFamily: 'var(--mono)', flexShrink: 0 }}>
326+
+{dirAdded}
327+
</span>
328+
{dirRemoved > 0 && (
329+
<span style={{ fontSize: 9, color: 'var(--accent)', fontFamily: 'var(--mono)', flexShrink: 0 }}>
330+
-{dirRemoved}
331+
</span>
332+
)}
333+
</div>
334+
);
335+
336+
if (!isCollapsed) {
337+
const dirs = node.children.filter(c => c.isDir).sort((a, b) => a.name.localeCompare(b.name));
338+
const nodeFiles = node.children.filter(c => !c.isDir).sort((a, b) => a.name.localeCompare(b.name));
339+
for (const child of [...dirs, ...nodeFiles]) {
340+
renderNode(child, depth + 1);
341+
}
342+
}
343+
} else {
344+
const f = node.file!;
345+
const si = statusIcon(f.status);
346+
const isSelected = f.path === selectedFile;
347+
348+
rows.push(
349+
<div
350+
key={'file-' + f.path}
351+
onClick={() => setSelectedFile(f.path)}
352+
style={{
353+
padding: '4px 8px', paddingLeft: 20 + depth * 12,
354+
cursor: 'pointer',
355+
background: isSelected ? 'var(--bg-2)' : 'transparent',
356+
borderLeft: isSelected ? '2px solid var(--accent)' : '2px solid transparent',
357+
display: 'flex', alignItems: 'center', gap: 5,
358+
}}
359+
>
360+
<span style={{ fontSize: 9, fontWeight: 700, color: si.color, width: 12, textAlign: 'center', flexShrink: 0 }}>
361+
{si.label}
362+
</span>
363+
<span style={{
364+
fontSize: 11, color: isSelected ? 'var(--fg)' : 'var(--fg-2)',
365+
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1,
366+
fontFamily: 'var(--mono)',
367+
}}>
368+
{node.name}
369+
</span>
370+
<span style={{ fontSize: 9, color: 'var(--green)', fontFamily: 'var(--mono)', flexShrink: 0 }}>
371+
+{f.added}
372+
</span>
373+
{f.removed > 0 && (
374+
<span style={{ fontSize: 9, color: 'var(--accent)', fontFamily: 'var(--mono)', flexShrink: 0 }}>
375+
-{f.removed}
376+
</span>
377+
)}
378+
</div>
379+
);
380+
}
381+
}
382+
383+
const topDirs = tree.filter(n => n.isDir).sort((a, b) => a.name.localeCompare(b.name));
384+
const topFiles = tree.filter(n => !n.isDir).sort((a, b) => a.name.localeCompare(b.name));
385+
for (const node of [...topDirs, ...topFiles]) {
386+
renderNode(node, 0);
387+
}
388+
389+
return <>{rows}</>;
390+
}
391+
392+
function collectFiles(node: TreeNode): FileDiff[] {
393+
if (!node.isDir) return node.file ? [node.file] : [];
394+
return node.children.flatMap(c => collectFiles(c));
395+
}

0 commit comments

Comments
 (0)