1- import { useState , useEffect } from 'react' ;
1+ import React , { useState , useEffect } from 'react' ;
22
33interface 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