fix: validate active note cache refresh
This commit is contained in:
@@ -497,10 +497,14 @@ describe('useNoteActions hook', () => {
|
||||
})
|
||||
|
||||
describe('note open is read-only', () => {
|
||||
it('does not sync title or reload entry when opening or reopening a note', async () => {
|
||||
it('does not sync title or reload entry when opening or freshness-validating a note', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
const entry = makeEntry({ path: '/test/vault/qa-test.md', filename: 'qa-test.md', title: 'Qa Test' })
|
||||
vi.mocked(invoke).mockResolvedValueOnce('# Qa Test\n')
|
||||
vi.mocked(invoke).mockImplementation(async (command) => {
|
||||
if (command === 'validate_note_content') return true
|
||||
if (command === 'get_note_content') return '# Qa Test\n'
|
||||
return null
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig([entry])))
|
||||
|
||||
@@ -510,9 +514,10 @@ describe('useNoteActions hook', () => {
|
||||
const desyncedEntry = { ...entry, title: 'Wrong Title Desynced' }
|
||||
await act(async () => { await result.current.handleSelectNote(desyncedEntry) })
|
||||
|
||||
expect(vi.mocked(invoke)).toHaveBeenCalledTimes(callCountAfterFirstOpen)
|
||||
expect(vi.mocked(invoke)).toHaveBeenCalledTimes(callCountAfterFirstOpen + 1)
|
||||
expect(vi.mocked(invoke).mock.calls).toEqual([
|
||||
['get_note_content', { path: '/test/vault/qa-test.md' }],
|
||||
['validate_note_content', { path: '/test/vault/qa-test.md', content: '# Qa Test\n' }],
|
||||
])
|
||||
expect(result.current.tabs[0].entry.title).toBe('Qa Test')
|
||||
})
|
||||
|
||||
@@ -194,13 +194,15 @@ async function updateFrontmatterAndMaybeRename({
|
||||
}
|
||||
|
||||
function buildTabManagementOptions(
|
||||
config: Pick<NoteActionsConfig, 'flushBeforeNoteSwitch' | 'reloadVault' | 'setToastMessage'>,
|
||||
config: Pick<NoteActionsConfig, 'flushBeforeNoteSwitch' | 'reloadVault' | 'setToastMessage' | 'unsavedPaths'>,
|
||||
) {
|
||||
const options: {
|
||||
beforeNavigate?: (fromPath: string, toPath: string) => Promise<void>
|
||||
hasUnsavedChanges: (path: string) => boolean
|
||||
onMissingNotePath: (entry: VaultEntry) => void
|
||||
onUnreadableNoteContent: (entry: VaultEntry) => void
|
||||
} = {
|
||||
hasUnsavedChanges: (path) => config.unsavedPaths?.has(path) ?? false,
|
||||
onMissingNotePath: (entry) => {
|
||||
const label = entry.title.trim() || entry.filename
|
||||
config.setToastMessage(`"${label}" could not be opened because its file is missing or moved.`)
|
||||
|
||||
@@ -161,15 +161,24 @@ describe('useTabManagement (single-note model)', () => {
|
||||
expectSingleActiveTab(result, '/vault/b.md')
|
||||
})
|
||||
|
||||
it('is a no-op when selecting the already-open note', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
it('keeps a dirty already-open note in place when selecting it again', async () => {
|
||||
const entry = { path: '/vault/a.md' }
|
||||
await selectNote(result, entry)
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry(entry))
|
||||
const { result: dirtyResult } = renderHook(() => useTabManagement({
|
||||
hasUnsavedChanges: (path) => path === '/vault/a.md',
|
||||
}))
|
||||
await selectNote(dirtyResult, entry)
|
||||
act(() => {
|
||||
dirtyResult.current.setTabs(prev => prev.map(tab =>
|
||||
tab.entry.path === entry.path ? { ...tab, content: '# Local draft' } : tab
|
||||
))
|
||||
})
|
||||
|
||||
expect(result.current.tabs).toHaveLength(1)
|
||||
await act(async () => {
|
||||
await dirtyResult.current.handleSelectNote(makeEntry(entry))
|
||||
})
|
||||
|
||||
expect(dirtyResult.current.tabs).toHaveLength(1)
|
||||
expect(dirtyResult.current.tabs[0].content).toBe('# Local draft')
|
||||
})
|
||||
|
||||
it('handles load content failure gracefully', async () => {
|
||||
@@ -605,6 +614,24 @@ describe('useTabManagement (single-note model)', () => {
|
||||
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('refreshes an already-open clean note when cached content is stale on disk', async () => {
|
||||
mockNoteContent({ '/vault/a.md': '# Original content' })
|
||||
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
await selectNote(result, { path: '/vault/a.md', title: 'A' })
|
||||
|
||||
mockNoteContent({ '/vault/a.md': '# External edit' })
|
||||
await selectNote(result, { path: '/vault/a.md', title: 'A' })
|
||||
|
||||
expect(result.current.tabs[0].entry.path).toBe('/vault/a.md')
|
||||
expect(result.current.tabs[0].content).toBe('# External edit')
|
||||
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(3)
|
||||
expect(vi.mocked(mockInvoke)).toHaveBeenNthCalledWith(2, 'validate_note_content', {
|
||||
path: '/vault/a.md',
|
||||
content: '# Original content',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back instead of reopening cached content when the note file disappeared', async () => {
|
||||
vi.mocked(mockInvoke)
|
||||
.mockResolvedValueOnce('# Other note')
|
||||
|
||||
@@ -224,6 +224,7 @@ export type { Tab }
|
||||
|
||||
interface TabManagementOptions {
|
||||
beforeNavigate?: (fromPath: string, toPath: string) => Promise<void>
|
||||
hasUnsavedChanges?: (path: string) => boolean
|
||||
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
}
|
||||
@@ -236,6 +237,7 @@ interface NavigateToEntryOptions {
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
|
||||
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
|
||||
hasUnsavedChanges?: (path: string) => boolean
|
||||
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
}
|
||||
@@ -363,6 +365,7 @@ function isUnreadableNoteContentError(error: unknown): boolean {
|
||||
function shouldApplyLoadedEntry(options: {
|
||||
seq: number
|
||||
navSeqRef: React.MutableRefObject<number>
|
||||
content: string
|
||||
forceReload: boolean
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
tabsRef: React.MutableRefObject<Tab[]>
|
||||
@@ -371,6 +374,7 @@ function shouldApplyLoadedEntry(options: {
|
||||
const {
|
||||
seq,
|
||||
navSeqRef,
|
||||
content,
|
||||
forceReload,
|
||||
activeTabPathRef,
|
||||
tabsRef,
|
||||
@@ -380,7 +384,8 @@ function shouldApplyLoadedEntry(options: {
|
||||
if (navSeqRef.current !== seq) return false
|
||||
if (forceReload) return true
|
||||
if (!pathsMatch(activeTabPathRef.current, path)) return true
|
||||
return !tabsRef.current.some((tab) => pathsMatch(tab.entry.path, path))
|
||||
const openTab = tabsRef.current.find((tab) => pathsMatch(tab.entry.path, path))
|
||||
return !openTab || openTab.content !== content
|
||||
}
|
||||
|
||||
type EntryLoadFailureKind =
|
||||
@@ -529,8 +534,10 @@ function reopenAlreadyViewingEntry({
|
||||
tabsRef,
|
||||
activeTabPathRef,
|
||||
setActiveTabPath,
|
||||
}: Pick<NavigateToEntryOptions, 'entry' | 'tabsRef' | 'activeTabPathRef' | 'setActiveTabPath'>): boolean {
|
||||
hasUnsavedChanges,
|
||||
}: Pick<NavigateToEntryOptions, 'entry' | 'tabsRef' | 'activeTabPathRef' | 'setActiveTabPath' | 'hasUnsavedChanges'>): boolean {
|
||||
if (!isAlreadyViewingPath(tabsRef, activeTabPathRef, entry.path)) return false
|
||||
if (!hasUnsavedChanges?.(entry.path)) return false
|
||||
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
|
||||
finishNoteOpenTrace(entry.path)
|
||||
return true
|
||||
@@ -567,6 +574,7 @@ async function loadTextEntry(options: Required<Pick<NavigateToEntryOptions, 'for
|
||||
if (!shouldApplyLoadedEntry({
|
||||
seq,
|
||||
navSeqRef,
|
||||
content,
|
||||
forceReload,
|
||||
activeTabPathRef,
|
||||
tabsRef,
|
||||
@@ -615,6 +623,7 @@ export function useTabManagement(options: TabManagementOptions = {}) {
|
||||
const navSeqRef = useRef(0)
|
||||
const beforeNavigateSeqRef = useRef(0)
|
||||
const beforeNavigate = options.beforeNavigate
|
||||
const hasUnsavedChanges = options.hasUnsavedChanges
|
||||
const onMissingNotePath = options.onMissingNotePath
|
||||
const onUnreadableNoteContent = options.onUnreadableNoteContent
|
||||
|
||||
@@ -641,7 +650,9 @@ export function useTabManagement(options: TabManagementOptions = {}) {
|
||||
|
||||
/** Open a note — replaces the current note (single-note model). */
|
||||
const handleSelectNote = useCallback(async (entry: VaultEntry) => {
|
||||
if (!pathsMatch(entry.path, activeTabPathRef.current)) {
|
||||
const alreadyViewingDirtyEntry = pathsMatch(entry.path, activeTabPathRef.current)
|
||||
&& !!hasUnsavedChanges?.(entry.path)
|
||||
if (!alreadyViewingDirtyEntry) {
|
||||
beginNoteOpenTrace(entry.path, 'select-note')
|
||||
}
|
||||
await executeNavigationWithBoundary(entry.path, () => navigateToEntry({
|
||||
@@ -651,10 +662,11 @@ export function useTabManagement(options: TabManagementOptions = {}) {
|
||||
activeTabPathRef,
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
hasUnsavedChanges,
|
||||
onMissingNotePath,
|
||||
onUnreadableNoteContent,
|
||||
}))
|
||||
}, [executeNavigationWithBoundary, onMissingNotePath, onUnreadableNoteContent])
|
||||
}, [executeNavigationWithBoundary, hasUnsavedChanges, onMissingNotePath, onUnreadableNoteContent])
|
||||
|
||||
const handleSwitchTab = useCallback((path: string) => {
|
||||
syncActiveTabPath(activeTabPathRef, setActiveTabPath, path)
|
||||
|
||||
Reference in New Issue
Block a user