From d3d663af0b0e7908e3bcc4eb3ca36428538f07db Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 21 Apr 2026 15:02:42 +0200 Subject: [PATCH] fix: reload vault entries on tauri startup --- src/hooks/useVaultLoader.test.ts | 375 +++++++++++++++++++------------ src/hooks/useVaultLoader.ts | 127 +++++++++-- 2 files changed, 335 insertions(+), 167 deletions(-) diff --git a/src/hooks/useVaultLoader.test.ts b/src/hooks/useVaultLoader.test.ts index e9f119cd..2a40a163 100644 --- a/src/hooks/useVaultLoader.test.ts +++ b/src/hooks/useVaultLoader.test.ts @@ -26,39 +26,113 @@ const mockGitHistory: GitCommit[] = [ { hash: 'abc1234567', shortHash: 'abc1234', message: 'initial commit', author: 'luca', date: 1700000000 }, ] -function defaultMockInvoke(cmd: string, args?: Record) { - if (cmd === 'list_vault') return Promise.resolve(mockEntries) - if (cmd === 'get_all_content') return Promise.resolve(mockContent) - if (cmd === 'get_modified_files') return Promise.resolve(mockModifiedFiles) - if (cmd === 'get_file_history') return Promise.resolve(mockGitHistory) - if (cmd === 'get_file_diff') return Promise.resolve('--- a/note.md\n+++ b/note.md') - if (cmd === 'get_file_diff_at_commit') return Promise.resolve(`diff for ${(args as Record)?.commitHash}`) - if (cmd === 'git_commit') return Promise.resolve('committed') - if (cmd === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed to remote' }) - return Promise.resolve(null) +type MockCommandHandler = (args?: Record) => unknown + +const defaultMockHandlers: Record = { + list_vault: () => mockEntries, + reload_vault: () => mockEntries, + get_all_content: () => mockContent, + get_modified_files: () => mockModifiedFiles, + get_file_history: () => mockGitHistory, + get_file_diff: () => '--- a/note.md\n+++ b/note.md', + get_file_diff_at_commit: (args) => `diff for ${(args as Record)?.commitHash}`, + git_commit: () => 'committed', + git_push: () => ({ status: 'ok', message: 'Pushed to remote' }), } -const mockInvokeFn = vi.fn(defaultMockInvoke) +function defaultMockInvoke(cmd: string, args?: Record) { + const handler = defaultMockHandlers[cmd] + return Promise.resolve(handler ? handler(args) : null) +} + +let mockIsTauri = false +const backendInvokeFn = vi.fn(defaultMockInvoke) + +function isVaultLoadCommand(cmd: string) { + return cmd === 'list_vault' || cmd === 'reload_vault' +} + +function buildVaultLoaderMock(options: { + entries?: VaultEntry[] + modifiedFiles?: ModifiedFile[] + pushResult?: { status: string; message: string } + failHistory?: boolean +} = {}) { + const { + entries = mockEntries, + modifiedFiles = mockModifiedFiles, + pushResult, + failHistory = false, + } = options + + return ((cmd: string, args?: Record) => { + if (isVaultLoadCommand(cmd)) return Promise.resolve(entries) + if (cmd === 'get_modified_files') return Promise.resolve(modifiedFiles) + if (cmd === 'list_vault_folders') return Promise.resolve([]) + if (cmd === 'list_views') return Promise.resolve([]) + if (cmd === 'get_file_history' && failHistory) return Promise.reject(new Error('fail')) + if (cmd === 'git_push' && pushResult) return Promise.resolve(pushResult) + return defaultMockInvoke(cmd, args) + }) as typeof defaultMockInvoke +} + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), })) vi.mock('../mock-tauri', () => ({ - isTauri: () => false, - mockInvoke: (cmd: string, args?: Record) => mockInvokeFn(cmd, args), + isTauri: () => mockIsTauri, + mockInvoke: (cmd: string, args?: Record) => backendInvokeFn(cmd, args), })) +async function waitForEntries( + result: ReturnType, undefined>>['result'], + length = 1, +) { + await waitFor(() => { + expect(result.current.entries).toHaveLength(length) + }) +} + +async function waitForModifiedFiles( + result: ReturnType, undefined>>['result'], + length = 1, +) { + await waitFor(() => { + expect(result.current.modifiedFiles).toHaveLength(length) + }) +} + /** Render the vault loader hook and wait for initial data to load. */ async function renderVaultLoader() { const hook = renderHook(() => useVaultLoader('/vault')) - await waitFor(() => { expect(hook.result.current.entries).toHaveLength(1) }) + await waitForEntries(hook.result) return hook } +async function enableTauriMode() { + mockIsTauri = true + const tauri = await import('@tauri-apps/api/core') + vi.mocked(tauri.invoke).mockImplementation((command: string, args?: Record) => + backendInvokeFn(command, args), + ) +} + describe('useVaultLoader', () => { beforeEach(() => { - mockInvokeFn.mockImplementation(defaultMockInvoke) + mockIsTauri = false + backendInvokeFn.mockReset() + backendInvokeFn.mockImplementation(defaultMockInvoke) }) it('loads entries on mount', async () => { @@ -70,13 +144,88 @@ describe('useVaultLoader', () => { it('loads modified files on mount', async () => { const { result } = renderHook(() => useVaultLoader('/vault')) - await waitFor(() => { - expect(result.current.modifiedFiles).toHaveLength(1) - }) + await waitForModifiedFiles(result) expect(result.current.modifiedFiles[0].status).toBe('modified') }) + it('loads initial vault entries from a fresh reload in Tauri mode', async () => { + await enableTauriMode() + backendInvokeFn.mockImplementation(((cmd: string) => { + if (cmd === 'list_vault') { + return Promise.resolve([ + { ...mockEntries[0], path: '/vault/stale.md', filename: 'stale.md', title: 'Stale', isA: 'Type' }, + ]) + } + if (cmd === 'reload_vault') { + return Promise.resolve([ + { ...mockEntries[0], path: '/vault/journal.md', filename: 'journal.md', title: 'Journal', isA: 'Type' }, + { ...mockEntries[0], path: '/vault/2026-03-11.md', filename: '2026-03-11.md', title: 'March 11', isA: 'Journal' }, + ]) + } + if (cmd === 'get_modified_files') return Promise.resolve([]) + if (cmd === 'list_vault_folders') return Promise.resolve([]) + if (cmd === 'list_views') return Promise.resolve([]) + return Promise.resolve(null) + }) as typeof defaultMockInvoke) + + const { result } = renderHook(() => useVaultLoader('/vault')) + + await waitFor(() => { + expect(result.current.entries.map((entry) => entry.title)).toEqual(['Journal', 'March 11']) + }) + const issuedCommands = backendInvokeFn.mock.calls.map(([command]) => command) + expect(issuedCommands).toContain('reload_vault') + expect(issuedCommands).not.toContain('list_vault') + }) + + it('ignores stale reload_vault results after the vault path changes', async () => { + await enableTauriMode() + const firstLoad = createDeferred() + const secondLoad = createDeferred() + + backendInvokeFn.mockImplementation(((cmd: string, args?: Record) => { + const path = typeof args?.path === 'string' ? args.path : undefined + + if (cmd === 'reload_vault') { + if (path === '/vault-a') return firstLoad.promise + if (path === '/vault-b') return secondLoad.promise + } + if (cmd === 'list_vault_folders') return Promise.resolve([]) + if (cmd === 'list_views') return Promise.resolve([]) + if (cmd === 'get_modified_files') return Promise.resolve([]) + return Promise.resolve(null) + }) as typeof defaultMockInvoke) + + const { result, rerender } = renderHook( + ({ path }) => useVaultLoader(path), + { initialProps: { path: '/vault-a' } }, + ) + + rerender({ path: '/vault-b' }) + + await act(async () => { + firstLoad.resolve([ + { ...mockEntries[0], path: '/vault-a/stale.md', filename: 'stale.md', title: 'Stale', isA: 'Type' }, + ]) + await firstLoad.promise + }) + + expect(result.current.entries).toEqual([]) + + await act(async () => { + secondLoad.resolve([ + { ...mockEntries[0], path: '/vault-b/journal.md', filename: 'journal.md', title: 'Journal', isA: 'Type' }, + { ...mockEntries[0], path: '/vault-b/2026-03-11.md', filename: '2026-03-11.md', title: 'March 11', isA: 'Journal' }, + ]) + await secondLoad.promise + }) + + await waitFor(() => { + expect(result.current.entries.map((entry) => entry.title)).toEqual(['Journal', 'March 11']) + }) + }) + describe('addEntry', () => { it('prepends new entry', async () => { const { result } = await renderVaultLoader() @@ -174,54 +323,44 @@ describe('useVaultLoader', () => { expect(result.current.getNoteStatus('/vault/note/brand-new.md')).toBe('new') }) - it('returns new for git-untracked files (saved but not committed)', async () => { - mockInvokeFn.mockImplementation(((cmd: string) => { - if (cmd === 'list_vault') return Promise.resolve(mockEntries) - if (cmd === 'get_all_content') return Promise.resolve(mockContent) - if (cmd === 'get_modified_files') return Promise.resolve([ - { path: '/vault/note/brand-new.md', relativePath: 'note/brand-new.md', status: 'untracked' }, - ]) - return Promise.resolve(null) - }) as typeof defaultMockInvoke) + it.each([ + { + name: 'returns new for git-untracked files (saved but not committed)', + path: '/vault/note/brand-new.md', + relativePath: 'note/brand-new.md', + status: 'untracked', + }, + { + name: 'returns new for git-added files (staged but not committed)', + path: '/vault/note/staged.md', + relativePath: 'note/staged.md', + status: 'added', + }, + { + name: 'treats untracked files as new (green dot, not orange)', + path: '/vault/note/hello.md', + relativePath: 'note/hello.md', + status: 'untracked', + }, + ])('$name', async ({ path, relativePath, status }) => { + backendInvokeFn.mockImplementation(buildVaultLoaderMock({ + modifiedFiles: [{ path, relativePath, status }], + })) const { result } = renderHook(() => useVaultLoader('/vault')) - await waitFor(() => { - expect(result.current.modifiedFiles).toHaveLength(1) - }) + await waitForModifiedFiles(result) - expect(result.current.getNoteStatus('/vault/note/brand-new.md')).toBe('new') - }) - - it('returns new for git-added files (staged but not committed)', async () => { - mockInvokeFn.mockImplementation(((cmd: string) => { - if (cmd === 'list_vault') return Promise.resolve(mockEntries) - if (cmd === 'get_all_content') return Promise.resolve(mockContent) - if (cmd === 'get_modified_files') return Promise.resolve([ - { path: '/vault/note/staged.md', relativePath: 'note/staged.md', status: 'added' }, - ]) - return Promise.resolve(null) - }) as typeof defaultMockInvoke) - - const { result } = renderHook(() => useVaultLoader('/vault')) - - await waitFor(() => { - expect(result.current.modifiedFiles).toHaveLength(1) - }) - - expect(result.current.getNoteStatus('/vault/note/staged.md')).toBe('new') + expect(result.current.getNoteStatus(path)).toBe('new') }) it('new status takes priority over git modified', async () => { // If a path is both new and in modifiedFiles, it should show as new - mockInvokeFn.mockImplementation(((cmd: string) => { - if (cmd === 'list_vault') return Promise.resolve(mockEntries) - if (cmd === 'get_all_content') return Promise.resolve(mockContent) - if (cmd === 'get_modified_files') return Promise.resolve([ + backendInvokeFn.mockImplementation(buildVaultLoaderMock({ + modifiedFiles: [ { path: '/vault/note/new.md', relativePath: 'note/new.md', status: 'modified' }, - ]) - return Promise.resolve(null) - }) as typeof defaultMockInvoke) + ], + })) const { result } = renderHook(() => useVaultLoader('/vault')) @@ -284,33 +423,11 @@ describe('useVaultLoader', () => { expect(result.current.getNoteStatus('/vault/note/draft.md')).toBe('new') }) - it('treats untracked files as new (green dot, not orange)', async () => { - mockInvokeFn.mockImplementation(((cmd: string) => { - if (cmd === 'list_vault') return Promise.resolve(mockEntries) - if (cmd === 'get_all_content') return Promise.resolve(mockContent) - if (cmd === 'get_modified_files') return Promise.resolve([ - { path: '/vault/note/hello.md', relativePath: 'note/hello.md', status: 'untracked' }, - ]) - return Promise.resolve(null) - }) as typeof defaultMockInvoke) - - const { result } = renderHook(() => useVaultLoader('/vault')) - - await waitFor(() => { - expect(result.current.modifiedFiles).toHaveLength(1) - }) - - expect(result.current.getNoteStatus('/vault/note/hello.md')).toBe('new') - }) }) describe('loadGitHistory', () => { it('returns git commits for a file', async () => { - const { result } = renderHook(() => useVaultLoader('/vault')) - - await waitFor(() => { - expect(result.current.entries).toHaveLength(1) - }) + const { result } = await renderVaultLoader() let history: GitCommit[] = [] await act(async () => { @@ -322,13 +439,11 @@ describe('useVaultLoader', () => { }) it('returns empty array on error', async () => { - mockInvokeFn.mockImplementation(((cmd: string) => { - if (cmd === 'get_file_history') return Promise.reject(new Error('fail')) - if (cmd === 'list_vault') return Promise.resolve([]) - if (cmd === 'get_all_content') return Promise.resolve({}) - if (cmd === 'get_modified_files') return Promise.resolve([]) - return Promise.resolve(null) - }) as typeof defaultMockInvoke) + backendInvokeFn.mockImplementation(buildVaultLoaderMock({ + entries: [], + modifiedFiles: [], + failHistory: true, + })) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const { result } = renderHook(() => useVaultLoader('/vault')) @@ -345,11 +460,7 @@ describe('useVaultLoader', () => { describe('loadDiff', () => { it('returns diff string for a file', async () => { - const { result } = renderHook(() => useVaultLoader('/vault')) - - await waitFor(() => { - expect(result.current.entries).toHaveLength(1) - }) + const { result } = await renderVaultLoader() let diff = '' await act(async () => { @@ -362,11 +473,7 @@ describe('useVaultLoader', () => { describe('loadDiffAtCommit', () => { it('returns diff for a specific commit', async () => { - const { result } = renderHook(() => useVaultLoader('/vault')) - - await waitFor(() => { - expect(result.current.entries).toHaveLength(1) - }) + const { result } = await renderVaultLoader() let diff = '' await act(async () => { @@ -379,11 +486,7 @@ describe('useVaultLoader', () => { describe('commitAndPush', () => { it('commits and pushes in mock mode', async () => { - const { result } = renderHook(() => useVaultLoader('/vault')) - - await waitFor(() => { - expect(result.current.entries).toHaveLength(1) - }) + const { result } = await renderVaultLoader() let response: { status: string; message: string } = { status: '', message: '' } await act(async () => { @@ -393,56 +496,42 @@ describe('useVaultLoader', () => { expect(response.status).toBe('ok') }) - it('returns rejected status when push is rejected', async () => { - mockInvokeFn.mockImplementation(((cmd: string) => { - if (cmd === 'list_vault') return Promise.resolve(mockEntries) - if (cmd === 'get_all_content') return Promise.resolve(mockContent) - if (cmd === 'get_modified_files') return Promise.resolve([]) - if (cmd === 'git_commit') return Promise.resolve('committed') - if (cmd === 'git_push') return Promise.resolve({ status: 'rejected', message: 'Push rejected: remote has new commits. Pull first, then push.' }) - return Promise.resolve(null) - }) as typeof defaultMockInvoke) + it.each([ + { + name: 'returns rejected status when push is rejected', + pushResult: { status: 'rejected', message: 'Push rejected: remote has new commits. Pull first, then push.' }, + expectedStatus: 'rejected', + expectedMessage: 'Pull first', + }, + { + name: 'returns network error status on network failure', + pushResult: { status: 'network_error', message: 'Push failed: network error. Check your connection and try again.' }, + expectedStatus: 'network_error', + expectedMessage: 'network error', + }, + ])('$name', async ({ pushResult, expectedStatus, expectedMessage }) => { + backendInvokeFn.mockImplementation(buildVaultLoaderMock({ + modifiedFiles: [], + pushResult, + })) - const { result } = renderHook(() => useVaultLoader('/vault')) - await waitFor(() => { expect(result.current.entries).toHaveLength(1) }) + const { result } = await renderVaultLoader() let response: { status: string; message: string } = { status: '', message: '' } await act(async () => { response = await result.current.commitAndPush('test commit') }) - expect(response.status).toBe('rejected') - expect(response.message).toContain('Pull first') - }) - - it('returns network error status on network failure', async () => { - mockInvokeFn.mockImplementation(((cmd: string) => { - if (cmd === 'list_vault') return Promise.resolve(mockEntries) - if (cmd === 'get_all_content') return Promise.resolve(mockContent) - if (cmd === 'get_modified_files') return Promise.resolve([]) - if (cmd === 'git_commit') return Promise.resolve('committed') - if (cmd === 'git_push') return Promise.resolve({ status: 'network_error', message: 'Push failed: network error. Check your connection and try again.' }) - return Promise.resolve(null) - }) as typeof defaultMockInvoke) - - const { result } = renderHook(() => useVaultLoader('/vault')) - await waitFor(() => { expect(result.current.entries).toHaveLength(1) }) - - let response: { status: string; message: string } = { status: '', message: '' } - await act(async () => { - response = await result.current.commitAndPush('test commit') - }) - - expect(response.status).toBe('network_error') - expect(response.message).toContain('network error') + expect(response.status).toBe(expectedStatus) + expect(response.message).toContain(expectedMessage) }) }) describe('reloadFolders', () => { it('refreshes folder tree from backend', async () => { const folders = [{ name: 'projects', path: 'projects', children: [] }] - mockInvokeFn.mockImplementation(((cmd: string) => { - if (cmd === 'list_vault') return Promise.resolve(mockEntries) + backendInvokeFn.mockImplementation(((cmd: string) => { + if (isVaultLoadCommand(cmd)) return Promise.resolve(mockEntries) if (cmd === 'get_modified_files') return Promise.resolve([]) if (cmd === 'list_vault_folders') return Promise.resolve(folders) return Promise.resolve(null) @@ -453,7 +542,7 @@ describe('useVaultLoader', () => { expect(result.current.folders).toEqual(folders) const updatedFolders = [...folders, { name: 'journal', path: 'journal', children: [] }] - mockInvokeFn.mockImplementation(((cmd: string) => { + backendInvokeFn.mockImplementation(((cmd: string) => { if (cmd === 'list_vault_folders') return Promise.resolve(updatedFolders) return defaultMockInvoke(cmd) }) as typeof defaultMockInvoke) @@ -466,11 +555,7 @@ describe('useVaultLoader', () => { describe('loadModifiedFiles', () => { it('refreshes modified files list', async () => { - const { result } = renderHook(() => useVaultLoader('/vault')) - - await waitFor(() => { - expect(result.current.modifiedFiles).toHaveLength(1) - }) + const { result } = await renderVaultLoader() await act(async () => { await result.current.loadModifiedFiles() diff --git a/src/hooks/useVaultLoader.ts b/src/hooks/useVaultLoader.ts index 345e0cf2..690379ea 100644 --- a/src/hooks/useVaultLoader.ts +++ b/src/hooks/useVaultLoader.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from '../mock-tauri' import type { VaultEntry, FolderNode, GitCommit, ModifiedFile, NoteStatus, GitPushResult, ViewFile } from '../types' @@ -8,13 +8,54 @@ function tauriCall(command: string, tauriArgs: Record, mockA return isTauri() ? invoke(command, tauriArgs) : mockInvoke(command, mockArgs ?? tauriArgs) } +function loadVaultEntries(vaultPath: string): Promise { + const command = isTauri() ? 'reload_vault' : 'list_vault' + return tauriCall(command, { path: vaultPath }) +} + async function loadVaultData(vaultPath: string) { if (!isTauri()) console.info('[mock] Using mock Tauri data for browser testing') - const entries = await tauriCall('list_vault', { path: vaultPath }) + const entries = await loadVaultEntries(vaultPath) console.log(`Vault scan complete: ${entries.length} entries found`) return { entries } } +function loadVaultFolders(vaultPath: string): Promise { + return tauriCall('list_vault_folders', { path: vaultPath }) +} + +function loadVaultViews(vaultPath: string): Promise { + return tauriCall('list_views', { vaultPath }) +} + +function resetVaultState(options: { + clearNewPaths: () => void + clearUnsaved: () => void + setEntries: (entries: VaultEntry[]) => void + setFolders: (folders: FolderNode[]) => void + setModifiedFiles: (files: ModifiedFile[]) => void + setModifiedFilesError: (message: string | null) => void + setViews: (views: ViewFile[]) => void +}) { + options.setEntries([]) + options.setFolders([]) + options.setViews([]) + options.setModifiedFiles([]) + options.setModifiedFilesError(null) + options.clearNewPaths() + options.clearUnsaved() +} + +function useCurrentVaultPathGuard(vaultPath: string) { + const currentPathRef = useRef(vaultPath) + + useEffect(() => { + currentPathRef.current = vaultPath + }, [vaultPath]) + + return useCallback((path: string) => currentPathRef.current === path, []) +} + async function commitWithPush(vaultPath: string, message: string): Promise { if (!isTauri()) { await mockInvoke('git_commit', { message }) @@ -96,32 +137,54 @@ export function useVaultLoader(vaultPath: string) { const tracker = useNewNoteTracker() const pendingSave = usePendingSaveTracker() const unsaved = useUnsavedTracker() + const isCurrentVaultPath = useCurrentVaultPathGuard(vaultPath) useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale data then load new vault - setEntries([]); setFolders([]); setViews([]); setModifiedFiles([]); setModifiedFilesError(null); tracker.clear(); unsaved.clearAll() - loadVaultData(vaultPath) - .then(({ entries: e }) => { setEntries(e) }) + const path = vaultPath + resetVaultState({ + clearNewPaths: tracker.clear, + clearUnsaved: unsaved.clearAll, + setEntries, + setFolders, + setModifiedFiles, + setModifiedFilesError, + setViews, + }) + loadVaultData(path) + .then(({ entries: e }) => { + if (!isCurrentVaultPath(path)) return + setEntries(e) + }) .catch((err) => console.warn('Vault scan failed:', err)) - tauriCall('list_vault_folders', { path: vaultPath }) - .then((f) => { setFolders(f ?? []) }) + loadVaultFolders(path) + .then((f) => { + if (!isCurrentVaultPath(path)) return + setFolders(f ?? []) + }) .catch(() => { /* folders are optional — ignore errors */ }) - tauriCall('list_views', { vaultPath }) - .then((v) => { setViews(v ?? []) }) + loadVaultViews(path) + .then((v) => { + if (!isCurrentVaultPath(path)) return + setViews(v ?? []) + }) .catch(() => { /* views are optional — ignore errors */ }) - }, [vaultPath]) // eslint-disable-line react-hooks/exhaustive-deps -- tracker.clear is stable + }, [vaultPath, tracker.clear, unsaved.clearAll, isCurrentVaultPath]) const loadModifiedFiles = useCallback(async () => { + const path = vaultPath try { setModifiedFilesError(null) - setModifiedFiles(await tauriCall('get_modified_files', { vaultPath }, {})) + const files = await tauriCall('get_modified_files', { vaultPath: path }, {}) + if (!isCurrentVaultPath(path)) return + setModifiedFiles(files) } catch (err) { + if (!isCurrentVaultPath(path)) return const message = typeof err === 'string' ? err : 'Failed to load changes' console.warn('Failed to load modified files:', err) setModifiedFilesError(message) setModifiedFiles([]) } - }, [vaultPath]) + }, [vaultPath, isCurrentVaultPath]) useEffect(() => { loadModifiedFiles() }, [loadModifiedFiles]) // eslint-disable-line react-hooks/set-state-in-effect -- trigger initial load @@ -176,27 +239,47 @@ export function useVaultLoader(vaultPath: string) { commitWithPush(vaultPath, message), [vaultPath]) const reloadFolders = useCallback( - () => tauriCall('list_vault_folders', { path: vaultPath }) - .then((f) => { setFolders(f ?? []) }) - .catch(() => { /* folders are optional — ignore errors */ }), - [vaultPath], + () => { + const path = vaultPath + return loadVaultFolders(path) + .then((f) => { + if (!isCurrentVaultPath(path)) return [] as FolderNode[] + const nextFolders = f ?? [] + setFolders(nextFolders) + return nextFolders + }) + .catch(() => [] as FolderNode[]) + }, + [vaultPath, isCurrentVaultPath], ) const reloadVault = useCallback( () => { + const path = vaultPath clearPrefetchCache() - return tauriCall('reload_vault', { path: vaultPath }) - .then((entries) => { setEntries(entries); loadModifiedFiles(); return entries }) + return tauriCall('reload_vault', { path }) + .then((entries) => { + if (!isCurrentVaultPath(path)) return [] as VaultEntry[] + setEntries(entries) + void loadModifiedFiles() + return entries + }) .catch((err) => { console.warn('Vault reload failed:', err); return [] as VaultEntry[] }) }, - [vaultPath, loadModifiedFiles], + [vaultPath, loadModifiedFiles, isCurrentVaultPath], ) const reloadViews = useCallback(async () => { + const path = vaultPath try { - setViews(await tauriCall('list_views', { vaultPath }) ?? []) + const nextViews = await loadVaultViews(path) + if (!isCurrentVaultPath(path)) return [] + const resolvedViews = nextViews ?? [] + setViews(resolvedViews) + return resolvedViews } catch { /* views are optional */ } - }, [vaultPath]) + return [] + }, [vaultPath, isCurrentVaultPath]) return { entries, folders, views, modifiedFiles, modifiedFilesError,