Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 126 additions & 20 deletions src/vault/VaultPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
useRef,
useState,
type ErrorInfo,
type MouseEvent,
type ReactNode,
} from 'react'
import { ConfirmDialog } from './ConfirmDialog'
Expand Down Expand Up @@ -47,11 +48,45 @@ function collectFilePaths(nodes: VaultTreeNode[], into: Set<string>): Set<string
return into
}

function countFiles(nodes: VaultTreeNode[]): number {
return nodes.reduce(
(sum, node) => (node.type === 'file' ? sum + 1 : sum + countFiles(node.children ?? [])),
0,
)
function resolveFilePath(rawPath: string, filePaths: Set<string>): string | null {
if (filePaths.has(rawPath)) return rawPath
const path = rawPath.replace(/^\/+|\/+$/g, '')
return filePaths.has(path) ? path : null
}

function treeClickPath(event: MouseEvent<HTMLElement>): 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 }> {
Expand Down Expand Up @@ -119,6 +154,24 @@ function EmptyState() {
)
}

function ReadErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-8 text-center">
<div>
<h3 className="text-sm font-medium text-foreground">Couldn't open this file</h3>
<p className="mt-1 max-w-md text-xs text-muted-foreground">{message}</p>
</div>
<button
type="button"
onClick={onRetry}
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"
>
Retry
</button>
</div>
)
}

export function VaultPane(props: VaultPaneProps) {
const {
port,
Expand Down Expand Up @@ -149,6 +202,8 @@ export function VaultPane(props: VaultPaneProps) {

const [selectedFile, setSelectedFile] = useState<VaultFile | null>(null)
const [fileLoading, setFileLoading] = useState(false)
const [readError, setReadError] = useState<string | null>(null)
const [reloadNonce, setReloadNonce] = useState(0)
const [saving, setSaving] = useState(false)

const [editorMode, setEditorMode] = useState<VaultEditorMode>('rich')
Expand All @@ -163,16 +218,25 @@ export function VaultPane(props: VaultPaneProps) {
const [deleting, setDeleting] = useState(false)
const [dockOpen, setDockOpen] = useState(false)
const [pendingNav, setPendingNav] = useState<PendingNav>(null)
const [query, setQuery] = useState('')

const savedContentRef = useRef('')
const loadedPathRef = useRef<string | null>(null)

const filePaths = useMemo(() => collectFilePaths(tree, new Set<string>()), [tree])
const resolvedSelectedPath = useMemo(
() => selectedPath ? resolveFilePath(selectedPath, filePaths) : null,
[selectedPath, filePaths],
)
const treeRoot = useMemo<VaultTreeNode>(
() => ({ name: 'Vault', path: '', type: 'directory', children: tree }),
[tree],
)
const fileCount = useMemo(() => countFiles(tree), [tree])
const visibleRoot = useMemo<VaultTreeNode>(() => {
const q = query.trim().toLowerCase()
if (!q) return treeRoot
return { ...treeRoot, children: filterNodes(tree, q) }
}, [treeRoot, tree, query])

const commitPath = useCallback(
(next: string | null) => {
Expand All @@ -199,26 +263,43 @@ 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)
}
})()
return () => {
cancelled = true
}
}, [port, selectedPath, refreshKey])
}, [port, selectedPath, resolvedSelectedPath, treeLoading, refreshKey, reloadNonce, commitPath])

useEffect(() => {
if (!selectedFile) {
Expand Down Expand Up @@ -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' })
Expand Down Expand Up @@ -353,12 +447,16 @@ export function VaultPane(props: VaultPaneProps) {
<EditorErrorBoundary onReset={() => { commitPath(null); setSelectedFile(null) }}>
<div className={`flex min-h-0 flex-1 overflow-hidden ${className ?? ''}`}>
<div className="flex w-[23rem] min-w-[23rem] flex-col border-r border-border bg-background">
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<span className="text-sm font-semibold text-foreground">Vault</span>
<span className="text-xs text-muted-foreground">
{fileCount} file{fileCount === 1 ? '' : 's'}
</span>
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
<div className="min-w-0 flex-1">
<input
type="text"
value={query}
onChange={(e) => 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"
/>
</div>
<div className="flex shrink-0 items-center gap-1">
{headerActions}
Expand All @@ -382,14 +480,20 @@ export function VaultPane(props: VaultPaneProps) {
)}
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div
className="flex-1 overflow-y-auto"
onClickCapture={(event) => {
const path = treeClickPath(event)
if (path) handleTreeSelect(path)
}}
>
{treeLoading ? (
<TreeSkeleton />
) : (
renderTree({
root: treeRoot,
selectedPath: selectedPath ?? undefined,
onSelect: (path) => { if (filePaths.has(path)) guardedOpen(path) },
root: visibleRoot,
selectedPath: resolvedSelectedPath ?? undefined,
onSelect: handleTreeSelect,
})
)}
</div>
Expand Down Expand Up @@ -464,6 +568,8 @@ export function VaultPane(props: VaultPaneProps) {
<div className="flex-1 overflow-hidden">
{fileLoading ? (
<EditorSkeleton />
) : readError ? (
<ReadErrorState message={readError} onRetry={() => setReloadNonce((n) => n + 1)} />
) : selectedFile && canWrite && isMarkdownCapable && editorMode === 'source' ? (
<SourceEditor
path={selectedFile.path}
Expand Down
112 changes: 112 additions & 0 deletions tests/vault/vault-pane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,118 @@ describe('VaultPane — load + selection', () => {
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<typeof createElement>[] {
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<VaultFile> => ({ 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<VaultFile> => ({ 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())
Expand Down