Skip to content

Commit 1a9e58e

Browse files
feat(vault): tree search + hardened file-tree selection and read-error handling (#147)
1 parent 4bd3f46 commit 1a9e58e

2 files changed

Lines changed: 238 additions & 20 deletions

File tree

src/vault/VaultPane.tsx

Lines changed: 126 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
useRef,
2121
useState,
2222
type ErrorInfo,
23+
type MouseEvent,
2324
type ReactNode,
2425
} from 'react'
2526
import { 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

5792
class 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+
122175
export 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}

tests/vault/vault-pane.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,118 @@ describe('VaultPane — load + selection', () => {
183183
expect(screen.getByTestId('artifact').textContent).toContain('body A')
184184
})
185185

186+
it('opens a file when a shadow-DOM tree row click only exposes data attributes', async () => {
187+
const inertTree = () =>
188+
createElement(
189+
'button',
190+
{
191+
type: 'button',
192+
'data-testid': 'pierre-row',
193+
'data-type': 'item',
194+
'data-item-type': 'file',
195+
'data-item-path': 'folder/c.md',
196+
},
197+
'c.md',
198+
)
199+
200+
const { port } = mount({ renderTree: inertTree })
201+
fireEvent.click(await screen.findByTestId('pierre-row'))
202+
203+
await waitFor(() => expect(port.readFile).toHaveBeenCalledWith('folder/c.md'))
204+
await waitFor(() => expect(screen.getByTestId('artifact').getAttribute('data-path')).toBe('folder/c.md'))
205+
})
206+
207+
it('opens after a tree renderer reuses its initial selection callback', async () => {
208+
const listTree = vi.fn()
209+
.mockResolvedValueOnce([])
210+
.mockResolvedValueOnce(TREE)
211+
const port = fakePort({ listTree })
212+
let capturedOnSelect: VaultTreeRenderProps['onSelect'] | null = null
213+
const staleCallbackTree = (props: VaultTreeRenderProps) => {
214+
capturedOnSelect ??= props.onSelect
215+
function walk(nodes: VaultTreeNode[]): ReturnType<typeof createElement>[] {
216+
return nodes.flatMap((n) => {
217+
const self = createElement(
218+
'button',
219+
{
220+
key: n.path,
221+
type: 'button',
222+
'data-testid': `tree-${n.path}`,
223+
onClick: () => capturedOnSelect?.(n.path),
224+
},
225+
n.name,
226+
)
227+
return n.children ? [self, ...walk(n.children)] : [self]
228+
})
229+
}
230+
return createElement('div', { 'data-testid': 'tree' }, ...walk([props.root]))
231+
}
232+
const el = (refreshKey: number) => createElement(VaultPane, {
233+
port,
234+
renderTree: staleCallbackTree,
235+
renderArtifact,
236+
codec: fmCodec,
237+
refreshKey,
238+
})
239+
const { rerender } = render(el(1))
240+
await waitFor(() => expect(listTree).toHaveBeenCalledTimes(1))
241+
242+
rerender(el(2))
243+
fireEvent.click(await screen.findByTestId('tree-a.md'))
244+
245+
await waitFor(() => expect(port.readFile).toHaveBeenCalledWith('a.md'))
246+
await waitFor(() => expect(screen.getByTestId('artifact').getAttribute('data-path')).toBe('a.md'))
247+
})
248+
249+
it('ignores directory tree selections instead of reading them as files', async () => {
250+
const readFile = vi.fn(async (path: string): Promise<VaultFile> => ({ path, content: 'folder body' }))
251+
const port = fakePort({ readFile })
252+
mount({ port })
253+
254+
fireEvent.click(await screen.findByTestId('tree-folder'))
255+
256+
await waitFor(() => expect(port.listTree).toHaveBeenCalled())
257+
expect(readFile).not.toHaveBeenCalled()
258+
expect(screen.getByText('Open a vault document')).toBeTruthy()
259+
})
260+
261+
it('clears a controlled selected path that is not a file', async () => {
262+
const onSelectedPathChange = vi.fn()
263+
const readFile = vi.fn(async (path: string): Promise<VaultFile> => ({ path, content: 'loose path body' }))
264+
const port = fakePort({ readFile })
265+
render(
266+
createElement(VaultPane, {
267+
port,
268+
renderTree,
269+
renderArtifact,
270+
codec: fmCodec,
271+
selectedPath: 'folder',
272+
onSelectedPathChange,
273+
}),
274+
)
275+
276+
await waitFor(() => expect(onSelectedPathChange).toHaveBeenCalledWith(null))
277+
expect(readFile).not.toHaveBeenCalled()
278+
expect(screen.getByTestId('tree-folder').getAttribute('data-selected')).toBe('false')
279+
})
280+
281+
it('surfaces read failures instead of falling back to the empty state', async () => {
282+
const readFile = vi.fn()
283+
.mockRejectedValueOnce(new Error('read exploded'))
284+
.mockResolvedValueOnce({ path: 'a.md', content: 'recovered A' })
285+
const port = fakePort({ readFile })
286+
mount({ port })
287+
288+
fireEvent.click(await screen.findByTestId('tree-a.md'))
289+
await waitFor(() => expect(screen.getByText("Couldn't open this file")).toBeTruthy())
290+
expect(screen.getByText('read exploded')).toBeTruthy()
291+
expect(screen.queryByText('Open a vault document')).toBeNull()
292+
293+
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
294+
await waitFor(() => expect(screen.getByTestId('artifact').getAttribute('data-path')).toBe('a.md'))
295+
expect(screen.getByTestId('artifact').textContent).toContain('recovered A')
296+
})
297+
186298
it('renders the empty state with no selection', async () => {
187299
mount()
188300
await waitFor(() => expect(screen.getByText('Open a vault document')).toBeTruthy())

0 commit comments

Comments
 (0)