From ba3d413b94db7bd5c82a545c03f474d338fc4988 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 22 Apr 2026 20:18:26 +0200 Subject: [PATCH] fix: recover from missing note paths --- src/hooks/useNoteActions.missing-path.test.ts | 87 +++++++++++++++++++ src/hooks/useNoteActions.ts | 23 +++-- src/hooks/useTabManagement.test.ts | 85 +++++++++++++++++- src/hooks/useTabManagement.ts | 50 ++++++++++- 4 files changed, 233 insertions(+), 12 deletions(-) create mode 100644 src/hooks/useNoteActions.missing-path.test.ts diff --git a/src/hooks/useNoteActions.missing-path.test.ts b/src/hooks/useNoteActions.missing-path.test.ts new file mode 100644 index 00000000..fe9f74f6 --- /dev/null +++ b/src/hooks/useNoteActions.missing-path.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { isTauri, mockInvoke } from '../mock-tauri' +import type { VaultEntry } from '../types' +import { useNoteActions, type NoteActionsConfig } from './useNoteActions' + +vi.mock('../mock-tauri', () => ({ + isTauri: vi.fn(() => false), + addMockEntry: vi.fn(), + updateMockContent: vi.fn(), + trackMockChange: vi.fn(), + mockInvoke: vi.fn(), +})) + +const makeEntry = (overrides: Partial = {}): VaultEntry => ({ + path: '/test/vault/missing.md', + filename: 'missing.md', + title: 'Missing Note', + isA: 'Note', + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + archived: false, + modifiedAt: 1700000000, + createdAt: 1700000000, + fileSize: 100, + snippet: '', + wordCount: 0, + relationships: {}, + icon: null, + color: null, + order: null, + outgoingLinks: [], + template: null, + sort: null, + sidebarLabel: null, + view: null, + visible: null, + properties: {}, + organized: false, + favorite: false, + favoriteIndex: null, + listPropertiesDisplay: [], + hasH1: false, + ...overrides, +}) + +function makeConfig(overrides: Partial = {}): NoteActionsConfig { + return { + addEntry: vi.fn(), + removeEntry: vi.fn(), + entries: [makeEntry()], + reloadVault: vi.fn().mockResolvedValue([]), + setToastMessage: vi.fn(), + updateEntry: vi.fn(), + vaultPath: '/test/vault', + ...overrides, + } +} + +describe('useNoteActions missing-path recovery', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(isTauri).mockReturnValue(false) + }) + + it('reloads vault state and shows a toast when the selected note path is gone', async () => { + vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('File does not exist: /test/vault/missing.md')) + const config = makeConfig() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const { result } = renderHook(() => useNoteActions(config)) + + await act(async () => { + await result.current.handleSelectNote(makeEntry()) + }) + + expect(result.current.tabs).toEqual([]) + expect(result.current.activeTabPath).toBeNull() + expect(config.reloadVault).toHaveBeenCalledTimes(1) + expect(config.setToastMessage).toHaveBeenCalledWith( + '"Missing Note" could not be opened because its file is missing or moved.', + ) + warnSpy.mockRestore() + }) +}) diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index b4f90c0d..97212439 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -196,11 +196,24 @@ async function updateFrontmatterAndMaybeRename({ } function buildTabManagementOptions( - flushBeforeNoteSwitch?: NoteActionsConfig['flushBeforeNoteSwitch'], + config: Pick, ) { - return flushBeforeNoteSwitch - ? { beforeNavigate: (fromPath: string) => flushBeforeNoteSwitch(fromPath) } - : undefined + const options: { + beforeNavigate?: (fromPath: string, toPath: string) => Promise + onMissingNotePath: (entry: VaultEntry) => void + } = { + onMissingNotePath: (entry) => { + const label = entry.title.trim() || entry.filename + config.setToastMessage(`"${label}" could not be opened because its file is missing or moved.`) + void config.reloadVault?.() + }, + } + + if (config.flushBeforeNoteSwitch) { + options.beforeNavigate = (fromPath: string) => config.flushBeforeNoteSwitch!(fromPath) + } + + return options } function useFrontmatterActionHandlers({ @@ -283,7 +296,7 @@ function useFrontmatterActionHandlers({ export function useNoteActions(config: NoteActionsConfig) { const { entries, setToastMessage, updateEntry } = config - const tabMgmt = useTabManagement(buildTabManagementOptions(config.flushBeforeNoteSwitch)) + const tabMgmt = useTabManagement(buildTabManagementOptions(config)) const { setTabs, handleSelectNote, openTabWithContent, activeTabPathRef, handleSwitchTab } = tabMgmt const updateTabContent = useCallback((path: string, newContent: string) => { diff --git a/src/hooks/useTabManagement.test.ts b/src/hooks/useTabManagement.test.ts index 148a639e..4636ed76 100644 --- a/src/hooks/useTabManagement.test.ts +++ b/src/hooks/useTabManagement.test.ts @@ -142,6 +142,24 @@ describe('useTabManagement (single-note model)', () => { expect(result.current.tabs[0].content).toBe('') warnSpy.mockRestore() }) + + it('clears the active note when the file is missing on disk', async () => { + const { mockInvoke } = await import('../mock-tauri') + vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('File does not exist: /vault/note/missing.md')) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const onMissingNotePath = vi.fn() + + const { result } = renderHook(() => useTabManagement({ onMissingNotePath })) + await selectNote(result, { path: '/vault/note/missing.md', title: 'Missing Note' }) + + expect(result.current.tabs).toEqual([]) + expect(result.current.activeTabPath).toBeNull() + expect(onMissingNotePath).toHaveBeenCalledWith( + expect.objectContaining({ path: '/vault/note/missing.md', title: 'Missing Note' }), + expect.any(Error), + ) + warnSpy.mockRestore() + }) }) describe('handleReplaceActiveTab', () => { @@ -193,6 +211,31 @@ describe('useTabManagement (single-note model)', () => { expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(2) }) + it('clears the active note when a forced reload hits a missing file path', async () => { + const { mockInvoke } = await import('../mock-tauri') + vi.mocked(mockInvoke) + .mockResolvedValueOnce('# Existing content') + .mockRejectedValueOnce(new Error('File does not exist: /vault/a.md')) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const onMissingNotePath = vi.fn() + + const { result } = renderHook(() => useTabManagement({ onMissingNotePath })) + const entry = makeEntry({ path: '/vault/a.md', title: 'A' }) + await selectNote(result, entry) + + await act(async () => { + await result.current.handleReplaceActiveTab(entry) + }) + + expect(result.current.tabs).toEqual([]) + expect(result.current.activeTabPath).toBeNull() + expect(onMissingNotePath).toHaveBeenCalledWith( + expect.objectContaining({ path: '/vault/a.md', title: 'A' }), + expect.any(Error), + ) + warnSpy.mockRestore() + }) + it('opens a note when no note is active', async () => { const { result } = renderHook(() => useTabManagement()) await replaceActiveNote(result, { path: '/vault/a.md' }) @@ -250,14 +293,15 @@ describe('useTabManagement (single-note model)', () => { }) describe('content prefetch cache', () => { - it('prefetch serves content to loadNoteContent (no extra IPC)', async () => { + it('prefetch paints cached content immediately and still validates it from disk', async () => { const mockInvoke = await prefetchResolvedContent('/vault/note/pre.md', '# Prefetched content') + vi.mocked(mockInvoke).mockResolvedValue('# Prefetched content') const { result } = renderHook(() => useTabManagement()) await selectNote(result, { path: '/vault/note/pre.md', title: 'Pre' }) expect(result.current.tabs[0].content).toBe('# Prefetched content') - expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1) + expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(2) }) it('clearPrefetchCache prevents stale content from being served', async () => { @@ -286,6 +330,7 @@ describe('useTabManagement (single-note model)', () => { it('serves refreshed cached content after a save replaces stale prefetched data', async () => { const mockInvoke = await prefetchResolvedContent('/vault/note/saved.md', '# Stale prefetched content') + vi.mocked(mockInvoke).mockResolvedValue('# Persisted content') cacheNoteContent('/vault/note/saved.md', '# Persisted content') @@ -293,10 +338,13 @@ describe('useTabManagement (single-note model)', () => { await selectNote(result, { path: '/vault/note/saved.md', title: 'Saved' }) expect(result.current.tabs[0].content).toBe('# Persisted content') - expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1) + expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(2) }) it('activates a warmed note immediately while reusing the cached content', async () => { + const { mockInvoke } = await import('../mock-tauri') + const deferred = createDeferred() + vi.mocked(mockInvoke).mockImplementationOnce(() => deferred.promise) cacheNoteContent('/vault/note/warm.md', '# Warm content') const { result } = renderHook(() => useTabManagement()) @@ -308,6 +356,12 @@ describe('useTabManagement (single-note model)', () => { expect(result.current.activeTabPath).toBe('/vault/note/warm.md') expect(result.current.tabs).toHaveLength(1) expect(result.current.tabs[0].content).toBe('# Warm content') + expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1) + + await act(async () => { + deferred.resolve('# Warm content') + await Promise.resolve() + }) }) it('reuses cached content when reopening a recently loaded note', async () => { @@ -315,6 +369,7 @@ describe('useTabManagement (single-note model)', () => { vi.mocked(mockInvoke) .mockResolvedValueOnce('# A content') .mockResolvedValueOnce('# B content') + .mockResolvedValueOnce('# A content') const { result } = renderHook(() => useTabManagement()) await selectNote(result, { path: '/vault/a.md', title: 'A' }) @@ -323,7 +378,29 @@ describe('useTabManagement (single-note model)', () => { expect(result.current.tabs[0].entry.path).toBe('/vault/a.md') expect(result.current.tabs[0].content).toBe('# A content') - expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(2) + expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(3) + }) + + it('falls back instead of reopening cached content when the note file disappeared', async () => { + const { mockInvoke } = await import('../mock-tauri') + vi.mocked(mockInvoke) + .mockResolvedValueOnce('# Other note') + .mockRejectedValueOnce(new Error('File does not exist: /vault/note/missing-cached.md')) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + cacheNoteContent('/vault/note/missing-cached.md', '# Cached but stale') + const onMissingNotePath = vi.fn() + + const { result } = renderHook(() => useTabManagement({ onMissingNotePath })) + await selectNote(result, { path: '/vault/other.md', title: 'Other' }) + await selectNote(result, { path: '/vault/note/missing-cached.md', title: 'Missing cached' }) + + expect(result.current.tabs).toEqual([]) + expect(result.current.activeTabPath).toBeNull() + expect(onMissingNotePath).toHaveBeenCalledWith( + expect.objectContaining({ path: '/vault/note/missing-cached.md', title: 'Missing cached' }), + expect.any(Error), + ) + warnSpy.mockRestore() }) it('deduplicates a late prefetch after note opening already started', async () => { diff --git a/src/hooks/useTabManagement.ts b/src/hooks/useTabManagement.ts index 3907b9b3..98c0c217 100644 --- a/src/hooks/useTabManagement.ts +++ b/src/hooks/useTabManagement.ts @@ -102,6 +102,7 @@ export type { Tab } interface TabManagementOptions { beforeNavigate?: (fromPath: string, toPath: string) => Promise + onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise } function syncActiveTabPath( @@ -134,6 +135,14 @@ function setSingleTab( setTabs([nextTab]) } +function clearTabs( + tabsRef: React.MutableRefObject, + setTabs: React.Dispatch>, +) { + tabsRef.current = [] + setTabs([]) +} + function isAlreadyViewingPath( tabsRef: React.MutableRefObject, activeTabPathRef: React.MutableRefObject, @@ -171,6 +180,15 @@ function startEntryNavigation(options: { return { seq, cachedContent } } +function isMissingNotePathError(error: unknown): boolean { + const message = error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : String(error) + return /does not exist|not found|enoent/i.test(message) +} + function shouldApplyLoadedEntry(options: { seq: number navSeqRef: React.MutableRefObject @@ -200,20 +218,35 @@ function handleEntryLoadFailure(options: { seq: number navSeqRef: React.MutableRefObject tabsRef: React.MutableRefObject + activeTabPathRef: React.MutableRefObject setTabs: React.Dispatch> + setActiveTabPath: React.Dispatch> error: unknown + onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise }) { const { entry, seq, navSeqRef, tabsRef, + activeTabPathRef, setTabs, + setActiveTabPath, error, + onMissingNotePath, } = options console.warn('Failed to load note content:', error) if (navSeqRef.current !== seq) return + if (isMissingNotePathError(error)) { + clearTabs(tabsRef, setTabs) + syncActiveTabPath(activeTabPathRef, setActiveTabPath, null) + failNoteOpenTrace(entry.path, 'missing-path') + Promise.resolve(onMissingNotePath?.(entry, error)).catch((callbackError) => { + console.warn('Failed to handle missing note path:', callbackError) + }) + return + } setSingleTab(tabsRef, setTabs, { entry, content: '' }) failNoteOpenTrace(entry.path, 'load-failed') } @@ -226,6 +259,7 @@ async function navigateToEntry(options: { activeTabPathRef: React.MutableRefObject setTabs: React.Dispatch> setActiveTabPath: React.Dispatch> + onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise }) { const { entry, @@ -235,6 +269,7 @@ async function navigateToEntry(options: { activeTabPathRef, setTabs, setActiveTabPath, + onMissingNotePath, } = options if (entry.fileKind === 'binary') { @@ -258,7 +293,10 @@ async function navigateToEntry(options: { try { markNoteOpenTrace(entry.path, 'contentLoadStart') - const content = await loadNoteContent(entry.path, forceReload) + // Cached content keeps note switches instant, but synced vaults can make + // the underlying path disappear between opens. Reopened notes still need a + // fresh disk read so missing-file recovery can run. + const content = await loadNoteContent(entry.path, forceReload || cachedContent !== null) markNoteOpenTrace(entry.path, 'contentLoadEnd') if (!shouldApplyLoadedEntry({ seq, @@ -276,8 +314,11 @@ async function navigateToEntry(options: { seq, navSeqRef, tabsRef, + activeTabPathRef, setTabs, + setActiveTabPath, error: err, + onMissingNotePath, }) } } @@ -295,6 +336,7 @@ export function useTabManagement(options: TabManagementOptions = {}) { const navSeqRef = useRef(0) const beforeNavigateSeqRef = useRef(0) const beforeNavigate = options.beforeNavigate + const onMissingNotePath = options.onMissingNotePath const executeNavigationWithBoundary = useCallback(async ( targetPath: string, @@ -329,8 +371,9 @@ export function useTabManagement(options: TabManagementOptions = {}) { activeTabPathRef, setTabs, setActiveTabPath, + onMissingNotePath, })) - }, [executeNavigationWithBoundary]) + }, [executeNavigationWithBoundary, onMissingNotePath]) const handleSwitchTab = useCallback((path: string) => { syncActiveTabPath(activeTabPathRef, setActiveTabPath, path) @@ -356,8 +399,9 @@ export function useTabManagement(options: TabManagementOptions = {}) { activeTabPathRef, setTabs, setActiveTabPath, + onMissingNotePath, })) - }, [executeNavigationWithBoundary]) + }, [executeNavigationWithBoundary, onMissingNotePath]) const closeAllTabs = useCallback(() => { tabsRef.current = []