@@ -20,6 +20,7 @@ import {
2020 useRef ,
2121 useState ,
2222 type ErrorInfo ,
23+ type MouseEvent ,
2324 type ReactNode ,
2425} from 'react'
2526import { ConfirmDialog } from './ConfirmDialog'
@@ -47,11 +48,45 @@ function collectFilePaths(nodes: VaultTreeNode[], into: Set<string>): Set<string
4748 return into
4849}
4950
50- function countFiles ( nodes : VaultTreeNode [ ] ) : number {
51- return nodes . reduce (
52- ( sum , node ) => ( node . type === 'file' ? sum + 1 : sum + countFiles ( node . children ?? [ ] ) ) ,
53- 0 ,
54- )
51+ function resolveFilePath ( rawPath : string , filePaths : Set < string > ) : string | null {
52+ if ( filePaths . has ( rawPath ) ) return rawPath
53+ const path = rawPath . replace ( / ^ \/ + | \/ + $ / g, '' )
54+ return filePaths . has ( path ) ? path : null
55+ }
56+
57+ function treeClickPath ( event : MouseEvent < HTMLElement > ) : string | null {
58+ const path = event . nativeEvent . composedPath ?.( ) ?? [ ]
59+ for ( const item of path ) {
60+ if ( ! ( item instanceof HTMLElement ) ) continue
61+ if ( item . dataset . type !== 'item' ) continue
62+ if ( item . dataset . itemType !== 'file' ) return null
63+ return item . dataset . itemPath ?? null
64+ }
65+
66+ const target = event . target instanceof HTMLElement
67+ ? event . target . closest ( '[data-type="item"]' )
68+ : null
69+ if ( ! ( target instanceof HTMLElement ) ) return null
70+ if ( target . dataset . itemType !== 'file' ) return null
71+ return target . dataset . itemPath ?? null
72+ }
73+
74+ // Case-insensitive name filter over the tree: files survive when their name
75+ // matches; a directory survives whole (with all its children) when its own name
76+ // matches, otherwise only when some descendant survives.
77+ function filterNodes ( nodes : VaultTreeNode [ ] , q : string ) : VaultTreeNode [ ] {
78+ const out : VaultTreeNode [ ] = [ ]
79+ for ( const node of nodes ) {
80+ if ( node . type === 'file' ) {
81+ if ( node . name . toLowerCase ( ) . includes ( q ) ) out . push ( node )
82+ } else if ( node . name . toLowerCase ( ) . includes ( q ) ) {
83+ out . push ( node )
84+ } else {
85+ const children = filterNodes ( node . children ?? [ ] , q )
86+ if ( children . length > 0 ) out . push ( { ...node , children } )
87+ }
88+ }
89+ return out
5590}
5691
5792class EditorErrorBoundary extends Component < { children : ReactNode ; onReset ?: ( ) => void } , { error : unknown } > {
@@ -119,6 +154,24 @@ function EmptyState() {
119154 )
120155}
121156
157+ function ReadErrorState ( { message, onRetry } : { message : string ; onRetry : ( ) => void } ) {
158+ return (
159+ < div className = "flex h-full flex-col items-center justify-center gap-3 p-8 text-center" >
160+ < div >
161+ < h3 className = "text-sm font-medium text-foreground" > Couldn't open this file</ h3 >
162+ < p className = "mt-1 max-w-md text-xs text-muted-foreground" > { message } </ p >
163+ </ div >
164+ < button
165+ type = "button"
166+ onClick = { onRetry }
167+ className = "inline-flex h-8 items-center rounded-md border border-border px-3 text-xs font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/60"
168+ >
169+ Retry
170+ </ button >
171+ </ div >
172+ )
173+ }
174+
122175export function VaultPane ( props : VaultPaneProps ) {
123176 const {
124177 port,
@@ -149,6 +202,8 @@ export function VaultPane(props: VaultPaneProps) {
149202
150203 const [ selectedFile , setSelectedFile ] = useState < VaultFile | null > ( null )
151204 const [ fileLoading , setFileLoading ] = useState ( false )
205+ const [ readError , setReadError ] = useState < string | null > ( null )
206+ const [ reloadNonce , setReloadNonce ] = useState ( 0 )
152207 const [ saving , setSaving ] = useState ( false )
153208
154209 const [ editorMode , setEditorMode ] = useState < VaultEditorMode > ( 'rich' )
@@ -163,16 +218,25 @@ export function VaultPane(props: VaultPaneProps) {
163218 const [ deleting , setDeleting ] = useState ( false )
164219 const [ dockOpen , setDockOpen ] = useState ( false )
165220 const [ pendingNav , setPendingNav ] = useState < PendingNav > ( null )
221+ const [ query , setQuery ] = useState ( '' )
166222
167223 const savedContentRef = useRef ( '' )
168224 const loadedPathRef = useRef < string | null > ( null )
169225
170226 const filePaths = useMemo ( ( ) => collectFilePaths ( tree , new Set < string > ( ) ) , [ tree ] )
227+ const resolvedSelectedPath = useMemo (
228+ ( ) => selectedPath ? resolveFilePath ( selectedPath , filePaths ) : null ,
229+ [ selectedPath , filePaths ] ,
230+ )
171231 const treeRoot = useMemo < VaultTreeNode > (
172232 ( ) => ( { name : 'Vault' , path : '' , type : 'directory' , children : tree } ) ,
173233 [ tree ] ,
174234 )
175- const fileCount = useMemo ( ( ) => countFiles ( tree ) , [ tree ] )
235+ const visibleRoot = useMemo < VaultTreeNode > ( ( ) => {
236+ const q = query . trim ( ) . toLowerCase ( )
237+ if ( ! q ) return treeRoot
238+ return { ...treeRoot , children : filterNodes ( tree , q ) }
239+ } , [ treeRoot , tree , query ] )
176240
177241 const commitPath = useCallback (
178242 ( next : string | null ) => {
@@ -199,26 +263,43 @@ export function VaultPane(props: VaultPaneProps) {
199263 if ( ! selectedPath ) {
200264 setSelectedFile ( null )
201265 setFileLoading ( false )
266+ setReadError ( null )
267+ loadedPathRef . current = null
268+ return
269+ }
270+ if ( treeLoading ) return
271+ if ( ! resolvedSelectedPath ) {
272+ commitPath ( null )
273+ setSelectedFile ( null )
274+ setFileLoading ( false )
275+ setReadError ( null )
202276 loadedPathRef . current = null
203277 return
204278 }
205279 let cancelled = false
206- const path = selectedPath
280+ const path = resolvedSelectedPath
281+ if ( path !== selectedPath ) commitPath ( path )
207282 setFileLoading ( true )
283+ setReadError ( null )
208284 void ( async ( ) => {
209285 try {
210286 const file = await port . readFile ( path )
211287 if ( ! cancelled ) setSelectedFile ( file )
212- } catch {
213- if ( ! cancelled ) setSelectedFile ( null )
288+ } catch ( err ) {
289+ // Surface read failures instead of making them indistinguishable from
290+ // the intentionally empty "no file selected" state.
291+ if ( ! cancelled ) {
292+ setSelectedFile ( null )
293+ setReadError ( err instanceof Error ? err . message : 'Failed to read file' )
294+ }
214295 } finally {
215296 if ( ! cancelled ) setFileLoading ( false )
216297 }
217298 } ) ( )
218299 return ( ) => {
219300 cancelled = true
220301 }
221- } , [ port , selectedPath , refreshKey ] )
302+ } , [ port , selectedPath , resolvedSelectedPath , treeLoading , refreshKey , reloadNonce , commitPath ] )
222303
223304 useEffect ( ( ) => {
224305 if ( ! selectedFile ) {
@@ -252,6 +333,19 @@ export function VaultPane(props: VaultPaneProps) {
252333 [ isDirty , selectedPath , commitPath ] ,
253334 )
254335
336+ // Some tree models keep their original selection callback while resetting
337+ // paths internally. Keep the callable stable, but have it execute the latest
338+ // file-path validation and dirty-guard logic.
339+ const selectFileRef = useRef < ( path : string ) => void > ( ( ) => { } )
340+ selectFileRef . current = ( rawPath : string ) => {
341+ const path = resolveFilePath ( rawPath , filePaths )
342+ if ( path ) {
343+ guardedOpen ( path )
344+ return
345+ }
346+ }
347+ const handleTreeSelect = useCallback ( ( path : string ) => selectFileRef . current ( path ) , [ ] )
348+
255349 const guardedClose = useCallback ( ( ) => {
256350 if ( isDirty ) {
257351 setPendingNav ( { type : 'close' } )
@@ -353,12 +447,16 @@ export function VaultPane(props: VaultPaneProps) {
353447 < EditorErrorBoundary onReset = { ( ) => { commitPath ( null ) ; setSelectedFile ( null ) } } >
354448 < div className = { `flex min-h-0 flex-1 overflow-hidden ${ className ?? '' } ` } >
355449 < div className = "flex w-[23rem] min-w-[23rem] flex-col border-r border-border bg-background" >
356- < div className = "flex items-center justify-between gap-3 border-b border-border px-4 py-3" >
357- < div className = "flex min-w-0 items-center gap-2" >
358- < span className = "text-sm font-semibold text-foreground" > Vault</ span >
359- < span className = "text-xs text-muted-foreground" >
360- { fileCount } file{ fileCount === 1 ? '' : 's' }
361- </ span >
450+ < div className = "flex items-center gap-2 border-b border-border px-4 py-3" >
451+ < div className = "min-w-0 flex-1" >
452+ < input
453+ type = "text"
454+ value = { query }
455+ onChange = { ( e ) => setQuery ( e . target . value ) }
456+ placeholder = "Search…"
457+ aria-label = "Search vault"
458+ className = "h-8 w-full rounded-md border border-border bg-background px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-primary/60"
459+ />
362460 </ div >
363461 < div className = "flex shrink-0 items-center gap-1" >
364462 { headerActions }
@@ -382,14 +480,20 @@ export function VaultPane(props: VaultPaneProps) {
382480 ) }
383481 </ div >
384482 </ div >
385- < div className = "flex-1 overflow-y-auto" >
483+ < div
484+ className = "flex-1 overflow-y-auto"
485+ onClickCapture = { ( event ) => {
486+ const path = treeClickPath ( event )
487+ if ( path ) handleTreeSelect ( path )
488+ } }
489+ >
386490 { treeLoading ? (
387491 < TreeSkeleton />
388492 ) : (
389493 renderTree ( {
390- root : treeRoot ,
391- selectedPath : selectedPath ?? undefined ,
392- onSelect : ( path ) => { if ( filePaths . has ( path ) ) guardedOpen ( path ) } ,
494+ root : visibleRoot ,
495+ selectedPath : resolvedSelectedPath ?? undefined ,
496+ onSelect : handleTreeSelect ,
393497 } )
394498 ) }
395499 </ div >
@@ -464,6 +568,8 @@ export function VaultPane(props: VaultPaneProps) {
464568 < div className = "flex-1 overflow-hidden" >
465569 { fileLoading ? (
466570 < EditorSkeleton />
571+ ) : readError ? (
572+ < ReadErrorState message = { readError } onRetry = { ( ) => setReloadNonce ( ( n ) => n + 1 ) } />
467573 ) : selectedFile && canWrite && isMarkdownCapable && editorMode === 'source' ? (
468574 < SourceEditor
469575 path = { selectedFile . path }
0 commit comments