fix: recover from missing note paths

This commit is contained in:
lucaronin
2026-04-22 20:18:26 +02:00
parent f3286922ad
commit ba3d413b94
4 changed files with 233 additions and 12 deletions

View File

@@ -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> = {}): 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> = {}): 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()
})
})

View File

@@ -196,11 +196,24 @@ async function updateFrontmatterAndMaybeRename({
}
function buildTabManagementOptions(
flushBeforeNoteSwitch?: NoteActionsConfig['flushBeforeNoteSwitch'],
config: Pick<NoteActionsConfig, 'flushBeforeNoteSwitch' | 'reloadVault' | 'setToastMessage'>,
) {
return flushBeforeNoteSwitch
? { beforeNavigate: (fromPath: string) => flushBeforeNoteSwitch(fromPath) }
: undefined
const options: {
beforeNavigate?: (fromPath: string, toPath: string) => Promise<void>
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) => {

View File

@@ -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<string>()
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 () => {

View File

@@ -102,6 +102,7 @@ export type { Tab }
interface TabManagementOptions {
beforeNavigate?: (fromPath: string, toPath: string) => Promise<void>
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
}
function syncActiveTabPath(
@@ -134,6 +135,14 @@ function setSingleTab(
setTabs([nextTab])
}
function clearTabs(
tabsRef: React.MutableRefObject<Tab[]>,
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>,
) {
tabsRef.current = []
setTabs([])
}
function isAlreadyViewingPath(
tabsRef: React.MutableRefObject<Tab[]>,
activeTabPathRef: React.MutableRefObject<string | null>,
@@ -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<number>
@@ -200,20 +218,35 @@ function handleEntryLoadFailure(options: {
seq: number
navSeqRef: React.MutableRefObject<number>
tabsRef: React.MutableRefObject<Tab[]>
activeTabPathRef: React.MutableRefObject<string | null>
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
error: unknown
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
}) {
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<string | null>
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
}) {
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 = []