fix: recover from missing note paths
This commit is contained in:
87
src/hooks/useNoteActions.missing-path.test.ts
Normal file
87
src/hooks/useNoteActions.missing-path.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
Reference in New Issue
Block a user