diff --git a/src/vault/VaultPane.tsx b/src/vault/VaultPane.tsx index d89ef83..94db869 100644 --- a/src/vault/VaultPane.tsx +++ b/src/vault/VaultPane.tsx @@ -20,6 +20,7 @@ import { useRef, useState, type ErrorInfo, + type MouseEvent, type ReactNode, } from 'react' import { ConfirmDialog } from './ConfirmDialog' @@ -47,11 +48,45 @@ function collectFilePaths(nodes: VaultTreeNode[], into: Set): Set (node.type === 'file' ? sum + 1 : sum + countFiles(node.children ?? [])), - 0, - ) +function resolveFilePath(rawPath: string, filePaths: Set): string | null { + if (filePaths.has(rawPath)) return rawPath + const path = rawPath.replace(/^\/+|\/+$/g, '') + return filePaths.has(path) ? path : null +} + +function treeClickPath(event: MouseEvent): string | null { + const path = event.nativeEvent.composedPath?.() ?? [] + for (const item of path) { + if (!(item instanceof HTMLElement)) continue + if (item.dataset.type !== 'item') continue + if (item.dataset.itemType !== 'file') return null + return item.dataset.itemPath ?? null + } + + const target = event.target instanceof HTMLElement + ? event.target.closest('[data-type="item"]') + : null + if (!(target instanceof HTMLElement)) return null + if (target.dataset.itemType !== 'file') return null + return target.dataset.itemPath ?? null +} + +// Case-insensitive name filter over the tree: files survive when their name +// matches; a directory survives whole (with all its children) when its own name +// matches, otherwise only when some descendant survives. +function filterNodes(nodes: VaultTreeNode[], q: string): VaultTreeNode[] { + const out: VaultTreeNode[] = [] + for (const node of nodes) { + if (node.type === 'file') { + if (node.name.toLowerCase().includes(q)) out.push(node) + } else if (node.name.toLowerCase().includes(q)) { + out.push(node) + } else { + const children = filterNodes(node.children ?? [], q) + if (children.length > 0) out.push({ ...node, children }) + } + } + return out } class EditorErrorBoundary extends Component<{ children: ReactNode; onReset?: () => void }, { error: unknown }> { @@ -119,6 +154,24 @@ function EmptyState() { ) } +function ReadErrorState({ message, onRetry }: { message: string; onRetry: () => void }) { + return ( +
+
+

Couldn't open this file

+

{message}

+
+ +
+ ) +} + export function VaultPane(props: VaultPaneProps) { const { port, @@ -149,6 +202,8 @@ export function VaultPane(props: VaultPaneProps) { const [selectedFile, setSelectedFile] = useState(null) const [fileLoading, setFileLoading] = useState(false) + const [readError, setReadError] = useState(null) + const [reloadNonce, setReloadNonce] = useState(0) const [saving, setSaving] = useState(false) const [editorMode, setEditorMode] = useState('rich') @@ -163,16 +218,25 @@ export function VaultPane(props: VaultPaneProps) { const [deleting, setDeleting] = useState(false) const [dockOpen, setDockOpen] = useState(false) const [pendingNav, setPendingNav] = useState(null) + const [query, setQuery] = useState('') const savedContentRef = useRef('') const loadedPathRef = useRef(null) const filePaths = useMemo(() => collectFilePaths(tree, new Set()), [tree]) + const resolvedSelectedPath = useMemo( + () => selectedPath ? resolveFilePath(selectedPath, filePaths) : null, + [selectedPath, filePaths], + ) const treeRoot = useMemo( () => ({ name: 'Vault', path: '', type: 'directory', children: tree }), [tree], ) - const fileCount = useMemo(() => countFiles(tree), [tree]) + const visibleRoot = useMemo(() => { + const q = query.trim().toLowerCase() + if (!q) return treeRoot + return { ...treeRoot, children: filterNodes(tree, q) } + }, [treeRoot, tree, query]) const commitPath = useCallback( (next: string | null) => { @@ -199,18 +263,35 @@ export function VaultPane(props: VaultPaneProps) { if (!selectedPath) { setSelectedFile(null) setFileLoading(false) + setReadError(null) + loadedPathRef.current = null + return + } + if (treeLoading) return + if (!resolvedSelectedPath) { + commitPath(null) + setSelectedFile(null) + setFileLoading(false) + setReadError(null) loadedPathRef.current = null return } let cancelled = false - const path = selectedPath + const path = resolvedSelectedPath + if (path !== selectedPath) commitPath(path) setFileLoading(true) + setReadError(null) void (async () => { try { const file = await port.readFile(path) if (!cancelled) setSelectedFile(file) - } catch { - if (!cancelled) setSelectedFile(null) + } catch (err) { + // Surface read failures instead of making them indistinguishable from + // the intentionally empty "no file selected" state. + if (!cancelled) { + setSelectedFile(null) + setReadError(err instanceof Error ? err.message : 'Failed to read file') + } } finally { if (!cancelled) setFileLoading(false) } @@ -218,7 +299,7 @@ export function VaultPane(props: VaultPaneProps) { return () => { cancelled = true } - }, [port, selectedPath, refreshKey]) + }, [port, selectedPath, resolvedSelectedPath, treeLoading, refreshKey, reloadNonce, commitPath]) useEffect(() => { if (!selectedFile) { @@ -252,6 +333,19 @@ export function VaultPane(props: VaultPaneProps) { [isDirty, selectedPath, commitPath], ) + // Some tree models keep their original selection callback while resetting + // paths internally. Keep the callable stable, but have it execute the latest + // file-path validation and dirty-guard logic. + const selectFileRef = useRef<(path: string) => void>(() => {}) + selectFileRef.current = (rawPath: string) => { + const path = resolveFilePath(rawPath, filePaths) + if (path) { + guardedOpen(path) + return + } + } + const handleTreeSelect = useCallback((path: string) => selectFileRef.current(path), []) + const guardedClose = useCallback(() => { if (isDirty) { setPendingNav({ type: 'close' }) @@ -353,12 +447,16 @@ export function VaultPane(props: VaultPaneProps) { { commitPath(null); setSelectedFile(null) }}>
-
-
- Vault - - {fileCount} file{fileCount === 1 ? '' : 's'} - +
+
+ setQuery(e.target.value)} + placeholder="Search…" + aria-label="Search vault" + 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" + />
{headerActions} @@ -382,14 +480,20 @@ export function VaultPane(props: VaultPaneProps) { )}
-
+
{ + const path = treeClickPath(event) + if (path) handleTreeSelect(path) + }} + > {treeLoading ? ( ) : ( renderTree({ - root: treeRoot, - selectedPath: selectedPath ?? undefined, - onSelect: (path) => { if (filePaths.has(path)) guardedOpen(path) }, + root: visibleRoot, + selectedPath: resolvedSelectedPath ?? undefined, + onSelect: handleTreeSelect, }) )}
@@ -464,6 +568,8 @@ export function VaultPane(props: VaultPaneProps) {
{fileLoading ? ( + ) : readError ? ( + setReloadNonce((n) => n + 1)} /> ) : selectedFile && canWrite && isMarkdownCapable && editorMode === 'source' ? ( { expect(screen.getByTestId('artifact').textContent).toContain('body A') }) + it('opens a file when a shadow-DOM tree row click only exposes data attributes', async () => { + const inertTree = () => + createElement( + 'button', + { + type: 'button', + 'data-testid': 'pierre-row', + 'data-type': 'item', + 'data-item-type': 'file', + 'data-item-path': 'folder/c.md', + }, + 'c.md', + ) + + const { port } = mount({ renderTree: inertTree }) + fireEvent.click(await screen.findByTestId('pierre-row')) + + await waitFor(() => expect(port.readFile).toHaveBeenCalledWith('folder/c.md')) + await waitFor(() => expect(screen.getByTestId('artifact').getAttribute('data-path')).toBe('folder/c.md')) + }) + + it('opens after a tree renderer reuses its initial selection callback', async () => { + const listTree = vi.fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce(TREE) + const port = fakePort({ listTree }) + let capturedOnSelect: VaultTreeRenderProps['onSelect'] | null = null + const staleCallbackTree = (props: VaultTreeRenderProps) => { + capturedOnSelect ??= props.onSelect + function walk(nodes: VaultTreeNode[]): ReturnType[] { + return nodes.flatMap((n) => { + const self = createElement( + 'button', + { + key: n.path, + type: 'button', + 'data-testid': `tree-${n.path}`, + onClick: () => capturedOnSelect?.(n.path), + }, + n.name, + ) + return n.children ? [self, ...walk(n.children)] : [self] + }) + } + return createElement('div', { 'data-testid': 'tree' }, ...walk([props.root])) + } + const el = (refreshKey: number) => createElement(VaultPane, { + port, + renderTree: staleCallbackTree, + renderArtifact, + codec: fmCodec, + refreshKey, + }) + const { rerender } = render(el(1)) + await waitFor(() => expect(listTree).toHaveBeenCalledTimes(1)) + + rerender(el(2)) + fireEvent.click(await screen.findByTestId('tree-a.md')) + + await waitFor(() => expect(port.readFile).toHaveBeenCalledWith('a.md')) + await waitFor(() => expect(screen.getByTestId('artifact').getAttribute('data-path')).toBe('a.md')) + }) + + it('ignores directory tree selections instead of reading them as files', async () => { + const readFile = vi.fn(async (path: string): Promise => ({ path, content: 'folder body' })) + const port = fakePort({ readFile }) + mount({ port }) + + fireEvent.click(await screen.findByTestId('tree-folder')) + + await waitFor(() => expect(port.listTree).toHaveBeenCalled()) + expect(readFile).not.toHaveBeenCalled() + expect(screen.getByText('Open a vault document')).toBeTruthy() + }) + + it('clears a controlled selected path that is not a file', async () => { + const onSelectedPathChange = vi.fn() + const readFile = vi.fn(async (path: string): Promise => ({ path, content: 'loose path body' })) + const port = fakePort({ readFile }) + render( + createElement(VaultPane, { + port, + renderTree, + renderArtifact, + codec: fmCodec, + selectedPath: 'folder', + onSelectedPathChange, + }), + ) + + await waitFor(() => expect(onSelectedPathChange).toHaveBeenCalledWith(null)) + expect(readFile).not.toHaveBeenCalled() + expect(screen.getByTestId('tree-folder').getAttribute('data-selected')).toBe('false') + }) + + it('surfaces read failures instead of falling back to the empty state', async () => { + const readFile = vi.fn() + .mockRejectedValueOnce(new Error('read exploded')) + .mockResolvedValueOnce({ path: 'a.md', content: 'recovered A' }) + const port = fakePort({ readFile }) + mount({ port }) + + fireEvent.click(await screen.findByTestId('tree-a.md')) + await waitFor(() => expect(screen.getByText("Couldn't open this file")).toBeTruthy()) + expect(screen.getByText('read exploded')).toBeTruthy() + expect(screen.queryByText('Open a vault document')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(screen.getByTestId('artifact').getAttribute('data-path')).toBe('a.md')) + expect(screen.getByTestId('artifact').textContent).toContain('recovered A') + }) + it('renders the empty state with no selection', async () => { mount() await waitFor(() => expect(screen.getByText('Open a vault document')).toBeTruthy())