1+ import { useVirtualizer } from '@tanstack/react-virtual' ;
12import { AlertCircle , ChevronDown , ChevronRight , Loader2 , RefreshCw , Search } from 'lucide-react' ;
2- import { memo , useCallback } from 'react' ;
3+ import { memo , useCallback , useMemo , useRef } from 'react' ;
34
5+ import type { FileNode } from '@/types/protocol' ;
46import type { FC } from 'react' ;
57
68import { FileIcon , FolderIcon } from '@/components/files' ;
7- import { useFileTree , useFileTreeItem } from '@/hooks/use-file-tree' ;
9+ import { useFileTree } from '@/hooks/use-file-tree' ;
810import { cn } from '@/lib/utils' ;
911import { useFileStore } from '@/stores/file-store' ;
1012
13+ // ═══════════════════════════════════════════════════════════════
14+ // Types
15+ // ═══════════════════════════════════════════════════════════════
16+
17+ /** Flattened tree item for virtualization */
18+ interface FlatTreeItem {
19+ /** Full path to the file/folder */
20+ path : string ;
21+ /** Depth in tree (0 = root level) */
22+ depth : number ;
23+ /** The node data */
24+ node : FileNode ;
25+ }
26+
27+ // ═══════════════════════════════════════════════════════════════
28+ // Constants
29+ // ═══════════════════════════════════════════════════════════════
30+
31+ /** Height of each row in pixels */
32+ const ROW_HEIGHT = 24 ;
33+
34+ /** Overscan - render extra rows above/below viewport for smooth scrolling */
35+ const OVERSCAN = 10 ;
36+
37+ // ═══════════════════════════════════════════════════════════════
38+ // Tree Flattening (Iterative to avoid stack overflow on deep trees)
39+ // ═══════════════════════════════════════════════════════════════
40+
41+ /**
42+ * Flatten the tree into a list for virtualization.
43+ * Uses iterative approach to handle deeply nested structures.
44+ * Only includes visible items (expanded folders show their children).
45+ */
46+ function flattenTree (
47+ rootChildren : readonly FileNode [ ] ,
48+ treeNodes : Record < string , FileNode [ ] > ,
49+ expandedFolders : Set < string >
50+ ) : FlatTreeItem [ ] {
51+ const result : FlatTreeItem [ ] = [ ] ;
52+
53+ // Stack holds: [nodes to process, current depth, index in nodes]
54+ interface StackFrame {
55+ nodes : readonly FileNode [ ] ;
56+ depth : number ;
57+ index : number ;
58+ }
59+
60+ const stack : StackFrame [ ] = [ { nodes : rootChildren , depth : 0 , index : 0 } ] ;
61+
62+ while ( stack . length > 0 ) {
63+ const frame = stack [ stack . length - 1 ] ;
64+ if ( ! frame ) break ; // Type guard (shouldn't happen but satisfies TS)
65+
66+ if ( frame . index >= frame . nodes . length ) {
67+ // Done with this level, pop stack
68+ stack . pop ( ) ;
69+ continue ;
70+ }
71+
72+ const node = frame . nodes [ frame . index ] ;
73+ if ( ! node ) {
74+ frame . index ++ ;
75+ continue ;
76+ }
77+ frame . index ++ ;
78+
79+ result . push ( { path : node . path , depth : frame . depth , node } ) ;
80+
81+ // If expanded directory with children, push children onto stack
82+ if ( node . isDirectory && expandedFolders . has ( node . path ) ) {
83+ const children = treeNodes [ node . path ] ;
84+ if ( children && children . length > 0 ) {
85+ stack . push ( { nodes : children , depth : frame . depth + 1 , index : 0 } ) ;
86+ }
87+ }
88+ }
89+
90+ return result ;
91+ }
92+
1193// ═══════════════════════════════════════════════════════════════
1294// File Explorer Component
1395// ═══════════════════════════════════════════════════════════════
@@ -28,8 +110,29 @@ export const FileExplorer: FC<FileExplorerProps> = ({ collapsed = false }) => {
28110 retryFolder,
29111 } = useFileTree ( ) ;
30112
113+ // Get tree state for flattening - these selectors are stable (Immer)
114+ const treeNodes = useFileStore ( ( s ) => s . treeNodes ) ;
115+ const expandedFolders = useFileStore ( ( s ) => s . expandedFolders ) ;
31116 const selectTreePath = useFileStore ( ( s ) => s . selectTreePath ) ;
32117
118+ // Flatten tree for virtualization
119+ // Recalculates when tree structure or expansion state changes
120+ const flatItems = useMemo (
121+ ( ) => flattenTree ( rootChildren , treeNodes , expandedFolders ) ,
122+ [ rootChildren , treeNodes , expandedFolders ]
123+ ) ;
124+
125+ // Virtualizer setup
126+ const parentRef = useRef < HTMLDivElement > ( null ) ;
127+ const virtualizer = useVirtualizer ( {
128+ count : flatItems . length ,
129+ getScrollElement : ( ) => parentRef . current ,
130+ estimateSize : ( ) => ROW_HEIGHT ,
131+ overscan : OVERSCAN ,
132+ // Maintain scroll position when items change
133+ getItemKey : ( index ) => flatItems [ index ] ?. path ?? index ,
134+ } ) ;
135+
33136 const handleOpenQuickSearch = useCallback ( ( ) : void => {
34137 window . dispatchEvent ( new CustomEvent ( 'openCommandPalette' ) ) ;
35138 } , [ ] ) ;
@@ -38,6 +141,8 @@ export const FileExplorer: FC<FileExplorerProps> = ({ collapsed = false }) => {
38141 return null ;
39142 }
40143
144+ const virtualItems = virtualizer . getVirtualItems ( ) ;
145+
41146 return (
42147 < div className = "flex flex-col h-full" >
43148 { /* Header */ }
@@ -70,7 +175,7 @@ export const FileExplorer: FC<FileExplorerProps> = ({ collapsed = false }) => {
70175 </ div >
71176
72177 { /* Tree content */ }
73- < div className = "flex-1 overflow-y-auto overflow-x-hidden" >
178+ < div ref = { parentRef } className = "flex-1 overflow-y-auto overflow-x-hidden" >
74179 { /* Root error state */ }
75180 { rootError ? (
76181 < div className = "flex flex-col items-center justify-center py-8 px-4 text-center" >
@@ -95,18 +200,31 @@ export const FileExplorer: FC<FileExplorerProps> = ({ collapsed = false }) => {
95200 < span className = "text-sm" > No files found</ span >
96201 </ div >
97202 ) : (
98- < div className = "py-1" >
99- { rootChildren . map ( ( node ) => (
100- < FileTreeItem
101- key = { node . path }
102- path = { node . path }
103- depth = { 0 }
104- onToggle = { toggleFolder }
105- onOpen = { openFile }
106- onSelect = { selectTreePath }
107- onRetry = { retryFolder }
108- />
109- ) ) }
203+ < div
204+ style = { {
205+ height : virtualizer . getTotalSize ( ) ,
206+ width : '100%' ,
207+ position : 'relative' ,
208+ } }
209+ >
210+ { virtualItems . map ( ( virtualRow ) => {
211+ const item = flatItems [ virtualRow . index ] ;
212+ if ( ! item ) return null ;
213+ return (
214+ < FileTreeRow
215+ key = { item . path }
216+ path = { item . path }
217+ depth = { item . depth }
218+ node = { item . node }
219+ top = { virtualRow . start }
220+ height = { virtualRow . size }
221+ onToggle = { toggleFolder }
222+ onOpen = { openFile }
223+ onSelect = { selectTreePath }
224+ onRetry = { retryFolder }
225+ />
226+ ) ;
227+ } ) }
110228 </ div >
111229 ) }
112230 </ div >
@@ -115,44 +233,44 @@ export const FileExplorer: FC<FileExplorerProps> = ({ collapsed = false }) => {
115233} ;
116234
117235// ═══════════════════════════════════════════════════════════════
118- // File Tree Item Component (Memoized for Performance )
236+ // File Tree Row Component (Virtualized, Memoized )
119237// ═══════════════════════════════════════════════════════════════
120238
121- interface FileTreeItemProps {
239+ interface FileTreeRowProps {
122240 readonly path : string ;
123241 readonly depth : number ;
242+ readonly node : FileNode ;
243+ readonly top : number ;
244+ readonly height : number ;
124245 readonly onToggle : ( path : string ) => void ;
125246 readonly onOpen : ( path : string ) => void ;
126247 readonly onSelect : ( path : string | null ) => void ;
127248 readonly onRetry : ( path : string ) => void ;
128249}
129250
130251/**
131- * Memoized tree item that subscribes to ONLY its own state .
252+ * Single virtualized row in the file tree .
132253 *
133- * This prevents cascading re-renders:
134- * - Parent expanding doesn't re-render siblings
135- * - Sibling selection doesn't re-render other siblings
136- * - Each item only re-renders when ITS state changes
254+ * Uses Zustand selectors for minimal re-renders:
255+ * - Only re-renders when THIS row's state changes
256+ * - Not affected by other rows expanding/selecting
137257 */
138- const FileTreeItem : FC < FileTreeItemProps > = memo (
139- ( { path, depth, onToggle, onOpen, onSelect, onRetry } ) => {
140- // Subscribe to only this item's state (prevents cascading re-renders)
141- const { node, isExpanded, isLoading, isSelected, error, children } = useFileTreeItem ( path ) ;
258+ const FileTreeRow : FC < FileTreeRowProps > = memo (
259+ ( { path, depth, node, top, height, onToggle, onOpen, onSelect, onRetry } ) => {
260+ // Subscribe to only this row's state
261+ const isExpanded = useFileStore ( ( s ) => s . expandedFolders . has ( path ) ) ;
262+ const isLoading = useFileStore ( ( s ) => s . loadingPaths . has ( path ) ) ;
263+ const isSelected = useFileStore ( ( s ) => s . selectedTreePath === path ) ;
264+ const error = useFileStore ( ( s ) => s . errorPaths . get ( path ) ?? null ) ;
142265
143266 const handleClick = useCallback ( ( ) : void => {
144- if ( ! node ) return ;
145-
146267 if ( node . isDirectory ) {
147- // Toggle expansion (hook auto-fetches children if needed)
148268 onToggle ( path ) ;
149269 } else {
150- // Select the file in explorer
151270 onSelect ( path ) ;
152- // Open file in the viewer (hook handles content fetch + LSP)
153271 onOpen ( path ) ;
154272 }
155- } , [ node , path , onToggle , onSelect , onOpen ] ) ;
273+ } , [ node . isDirectory , path , onToggle , onSelect , onOpen ] ) ;
156274
157275 const handleRetry = useCallback (
158276 ( e : React . MouseEvent ) : void => {
@@ -162,86 +280,74 @@ const FileTreeItem: FC<FileTreeItemProps> = memo(
162280 [ path , onRetry ]
163281 ) ;
164282
165- // Node not found (shouldn't happen, but be defensive)
166- if ( ! node ) {
167- return null ;
168- }
169-
170283 const indentPx = depth * 12 + 8 ;
171284
172285 return (
173- < >
174- < button
175- className = { cn (
176- 'flex items-center w-full h-6 text-sm hover:bg-accent/50 transition-colors' ,
177- isSelected && 'bg-accent text-accent-foreground'
178- ) }
179- style = { { paddingLeft : indentPx } }
180- onClick = { handleClick }
181- title = { path }
182- >
183- { /* Expand/collapse chevron for directories */ }
184- < span className = "w-4 h-4 flex items-center justify-center shrink-0" >
185- { node . isDirectory ? (
186- isLoading ? (
187- < Loader2 className = "h-3 w-3 animate-spin text-muted-foreground" />
188- ) : error ? (
189- < AlertCircle className = "h-3 w-3 text-destructive" />
190- ) : isExpanded ? (
191- < ChevronDown className = "h-3 w-3 text-muted-foreground" />
192- ) : (
193- < ChevronRight className = "h-3 w-3 text-muted-foreground" />
194- )
195- ) : null }
196- </ span >
197-
198- { /* Icon */ }
199- < span className = "w-4 h-4 flex items-center justify-center shrink-0 mr-1" >
200- { node . isDirectory ? (
201- < FolderIcon folderName = { node . name } isOpen = { isExpanded } className = "h-4 w-4" />
286+ < button
287+ className = { cn (
288+ 'flex items-center w-full text-sm hover:bg-accent/50 transition-colors' ,
289+ isSelected && 'bg-accent text-accent-foreground'
290+ ) }
291+ style = { {
292+ position : 'absolute' ,
293+ top : 0 ,
294+ left : 0 ,
295+ width : '100%' ,
296+ height,
297+ transform : `translateY(${ String ( top ) } px)` ,
298+ paddingLeft : indentPx ,
299+ } }
300+ onClick = { handleClick }
301+ title = { path }
302+ >
303+ { /* Expand/collapse chevron for directories */ }
304+ < span className = "w-4 h-4 flex items-center justify-center shrink-0" >
305+ { node . isDirectory ? (
306+ isLoading ? (
307+ < Loader2 className = "h-3 w-3 animate-spin text-muted-foreground" />
308+ ) : error ? (
309+ < AlertCircle className = "h-3 w-3 text-destructive" />
310+ ) : isExpanded ? (
311+ < ChevronDown className = "h-3 w-3 text-muted-foreground" />
202312 ) : (
203- < FileIcon fileName = { node . name } className = "h-4 w-4" />
204- ) }
205- </ span >
313+ < ChevronRight className = "h-3 w-3 text-muted-foreground" />
314+ )
315+ ) : null }
316+ </ span >
206317
207- { /* Name */ }
208- < span className = "truncate text-left flex-1" > { node . name } </ span >
318+ { /* Icon */ }
319+ < span className = "w-4 h-4 flex items-center justify-center shrink-0 mr-1" >
320+ { node . isDirectory ? (
321+ < FolderIcon folderName = { node . name } isOpen = { isExpanded } className = "h-4 w-4" />
322+ ) : (
323+ < FileIcon fileName = { node . name } className = "h-4 w-4" />
324+ ) }
325+ </ span >
209326
210- { /* Error retry button */ }
211- { error ? (
212- < span
213- className = "text-xs text-destructive hover:underline px-1"
214- onClick = { handleRetry }
215- role = "button"
216- tabIndex = { 0 }
217- >
218- retry
219- </ span >
220- ) : null }
221- </ button >
222-
223- { /* Children (if directory is expanded and loaded) */ }
224- { node . isDirectory && isExpanded && ! error && children . length > 0 ? (
225- < div >
226- { children . map ( ( child ) => (
227- < FileTreeItem
228- key = { child . path }
229- path = { child . path }
230- depth = { depth + 1 }
231- onToggle = { onToggle }
232- onOpen = { onOpen }
233- onSelect = { onSelect }
234- onRetry = { onRetry }
235- />
236- ) ) }
237- </ div >
327+ { /* Name */ }
328+ < span className = "truncate text-left flex-1" > { node . name } </ span >
329+
330+ { /* Error retry button */ }
331+ { error ? (
332+ < span
333+ className = "text-xs text-destructive hover:underline px-1"
334+ onClick = { handleRetry }
335+ role = "button"
336+ tabIndex = { 0 }
337+ >
338+ retry
339+ </ span >
238340 ) : null }
239- </ >
341+ </ button >
240342 ) ;
241343 } ,
242- // Custom comparison - only re-render if path or depth changes
243- // State changes are handled by the hook's selectors
244- ( prevProps , nextProps ) => prevProps . path === nextProps . path && prevProps . depth === nextProps . depth
344+ // Custom comparison - re-render if position, identity, or node name changes
345+ ( prevProps , nextProps ) =>
346+ prevProps . path === nextProps . path &&
347+ prevProps . depth === nextProps . depth &&
348+ prevProps . node . name === nextProps . node . name &&
349+ prevProps . top === nextProps . top &&
350+ prevProps . height === nextProps . height
245351) ;
246352
247- FileTreeItem . displayName = 'FileTreeItem ' ;
353+ FileTreeRow . displayName = 'FileTreeRow ' ;
0 commit comments