1- import React from "react" ;
1+ import React , { useCallback , useEffect , useRef , useState } from "react" ;
22import { Folder , FolderUp } from "lucide-react" ;
33
44interface DirectoryTreeEntry {
@@ -12,29 +12,151 @@ interface DirectoryTreeProps {
1212 isLoading ?: boolean ;
1313 onNavigateTo : ( path : string ) => void ;
1414 onNavigateParent : ( ) => void ;
15+ onConfirm : ( ) => void ;
16+ selectedIndex : number ;
17+ onSelectedIndexChange : ( index : number ) => void ;
1518}
1619
1720export const DirectoryTree : React . FC < DirectoryTreeProps > = ( props ) => {
18- const { currentPath, entries, isLoading = false , onNavigateTo, onNavigateParent } = props ;
21+ const {
22+ currentPath,
23+ entries,
24+ isLoading = false ,
25+ onNavigateTo,
26+ onNavigateParent,
27+ onConfirm,
28+ selectedIndex,
29+ onSelectedIndexChange,
30+ } = props ;
1931
2032 const hasEntries = entries . length > 0 ;
21- const containerRef = React . useRef < HTMLDivElement | null > ( null ) ;
33+ const containerRef = useRef < HTMLDivElement | null > ( null ) ;
34+ const selectedItemRef = useRef < HTMLLIElement | null > ( null ) ;
35+ const [ typeAheadBuffer , setTypeAheadBuffer ] = useState ( "" ) ;
36+ const typeAheadTimeoutRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
2237
23- React . useEffect ( ( ) => {
38+ // Total navigable items: parent (..) + entries
39+ const totalItems = ( currentPath ? 1 : 0 ) + entries . length ;
40+
41+ // Scroll container to top when path changes
42+ useEffect ( ( ) => {
2443 if ( containerRef . current ) {
2544 containerRef . current . scrollTop = 0 ;
2645 }
2746 } , [ currentPath ] ) ;
2847
48+ // Scroll selected item into view
49+ useEffect ( ( ) => {
50+ if ( selectedItemRef . current ) {
51+ selectedItemRef . current . scrollIntoView ( { block : "nearest" } ) ;
52+ }
53+ } , [ selectedIndex ] ) ;
54+
55+ // Clear type-ahead buffer after 500ms of inactivity
56+ const resetTypeAhead = useCallback ( ( ) => {
57+ if ( typeAheadTimeoutRef . current ) {
58+ clearTimeout ( typeAheadTimeoutRef . current ) ;
59+ }
60+ typeAheadTimeoutRef . current = setTimeout ( ( ) => {
61+ setTypeAheadBuffer ( "" ) ;
62+ } , 500 ) ;
63+ } , [ ] ) ;
64+
65+ // Handle keyboard navigation
66+ const handleKeyDown = useCallback (
67+ ( e : React . KeyboardEvent ) => {
68+ // Type-ahead search for printable characters
69+ if ( e . key . length === 1 && ! e . ctrlKey && ! e . metaKey && ! e . altKey ) {
70+ const newBuffer = typeAheadBuffer + e . key . toLowerCase ( ) ;
71+ setTypeAheadBuffer ( newBuffer ) ;
72+ resetTypeAhead ( ) ;
73+
74+ // Find first entry matching the buffer
75+ const matchIndex = entries . findIndex ( ( entry ) =>
76+ entry . name . toLowerCase ( ) . startsWith ( newBuffer )
77+ ) ;
78+ if ( matchIndex !== - 1 ) {
79+ // Offset by 1 if parent exists (index 0 is parent)
80+ const actualIndex = currentPath ? matchIndex + 1 : matchIndex ;
81+ onSelectedIndexChange ( actualIndex ) ;
82+ }
83+ e . preventDefault ( ) ;
84+ return ;
85+ }
86+
87+ switch ( e . key ) {
88+ case "ArrowUp" :
89+ e . preventDefault ( ) ;
90+ if ( totalItems > 0 ) {
91+ onSelectedIndexChange ( selectedIndex <= 0 ? totalItems - 1 : selectedIndex - 1 ) ;
92+ }
93+ break ;
94+ case "ArrowDown" :
95+ e . preventDefault ( ) ;
96+ if ( totalItems > 0 ) {
97+ onSelectedIndexChange ( selectedIndex >= totalItems - 1 ? 0 : selectedIndex + 1 ) ;
98+ }
99+ break ;
100+ case "Enter" :
101+ e . preventDefault ( ) ;
102+ if ( selectedIndex === 0 && currentPath ) {
103+ // Parent directory selected
104+ onNavigateParent ( ) ;
105+ } else if ( entries . length > 0 ) {
106+ // Navigate into selected directory
107+ const entryIndex = currentPath ? selectedIndex - 1 : selectedIndex ;
108+ if ( entryIndex >= 0 && entryIndex < entries . length ) {
109+ onNavigateTo ( entries [ entryIndex ] . path ) ;
110+ }
111+ }
112+ break ;
113+ case "Backspace" :
114+ e . preventDefault ( ) ;
115+ if ( currentPath ) {
116+ onNavigateParent ( ) ;
117+ }
118+ break ;
119+ case "o" :
120+ if ( e . ctrlKey || e . metaKey ) {
121+ e . preventDefault ( ) ;
122+ onConfirm ( ) ;
123+ }
124+ break ;
125+ }
126+ } ,
127+ [
128+ selectedIndex ,
129+ totalItems ,
130+ currentPath ,
131+ entries ,
132+ onSelectedIndexChange ,
133+ onNavigateTo ,
134+ onNavigateParent ,
135+ onConfirm ,
136+ typeAheadBuffer ,
137+ resetTypeAhead ,
138+ ]
139+ ) ;
140+
141+ const isSelected = ( index : number ) => selectedIndex === index ;
142+
29143 return (
30- < div ref = { containerRef } className = "h-full overflow-y-auto p-2 text-sm" >
144+ < div
145+ ref = { containerRef }
146+ className = "h-full overflow-y-auto p-2 text-sm outline-none"
147+ tabIndex = { 0 }
148+ onKeyDown = { handleKeyDown }
149+ >
31150 { isLoading && ! currentPath ? (
32151 < div className = "text-muted py-4 text-center" > Loading directories...</ div >
33152 ) : (
34153 < ul className = "m-0 list-none p-0" >
35154 { currentPath && (
36155 < li
37- className = "text-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-white/5"
156+ ref = { isSelected ( 0 ) ? selectedItemRef : null }
157+ className = { `text-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 ${
158+ isSelected ( 0 ) ? "bg-white/10" : "hover:bg-white/5"
159+ } `}
38160 onClick = { onNavigateParent }
39161 >
40162 < FolderUp size = { 16 } className = "text-muted shrink-0" />
@@ -46,16 +168,22 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = (props) => {
46168 < li className = "text-muted px-2 py-1.5" > No subdirectories found</ li >
47169 ) : null }
48170
49- { entries . map ( ( entry ) => (
50- < li
51- key = { entry . path }
52- className = "flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-white/5"
53- onClick = { ( ) => onNavigateTo ( entry . path ) }
54- >
55- < Folder size = { 16 } className = "shrink-0 text-yellow-500/80" />
56- < span className = "truncate" > { entry . name } </ span >
57- </ li >
58- ) ) }
171+ { entries . map ( ( entry , idx ) => {
172+ const actualIndex = currentPath ? idx + 1 : idx ;
173+ return (
174+ < li
175+ key = { entry . path }
176+ ref = { isSelected ( actualIndex ) ? selectedItemRef : null }
177+ className = { `flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 ${
178+ isSelected ( actualIndex ) ? "bg-white/10" : "hover:bg-white/5"
179+ } `}
180+ onClick = { ( ) => onNavigateTo ( entry . path ) }
181+ >
182+ < Folder size = { 16 } className = "shrink-0 text-yellow-500/80" />
183+ < span className = "truncate" > { entry . name } </ span >
184+ </ li >
185+ ) ;
186+ } ) }
59187
60188 { isLoading && currentPath && ! hasEntries ? (
61189 < li className = "text-muted px-2 py-1.5" > Loading directories...</ li >
0 commit comments