fix: harden stale note rename and open flows

This commit is contained in:
lucaronin
2026-05-03 19:40:50 +02:00
parent 05a9aacd1e
commit c7b6832cb1
4 changed files with 82 additions and 6 deletions

View File

@@ -85,6 +85,31 @@ describe('useNoteActions missing-path recovery', () => {
warnSpy.mockRestore()
})
it('recovers from missing stale sidebar entries with incomplete string metadata', async () => {
vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('File does not exist: /test/vault/reloaded.md'))
const staleEntry = {
...makeEntry({ path: '/test/vault/reloaded.md' }),
filename: undefined,
title: undefined,
} as unknown as VaultEntry
const config = makeConfig()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
await result.current.handleSelectNote(staleEntry)
})
expect(result.current.tabs).toEqual([])
expect(result.current.activeTabPath).toBeNull()
expect(config.reloadVault).toHaveBeenCalledTimes(1)
expect(config.setToastMessage).toHaveBeenCalledWith(
'"Note" could not be opened because its file is missing or moved.',
)
warnSpy.mockRestore()
})
it('shows a toast without reloading the vault when note content is not valid UTF-8 text', async () => {
vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('File is not valid UTF-8 text: /test/vault/bad.csv'))
const config = makeConfig({ entries: [makeEntry({ path: '/test/vault/bad.csv', filename: 'bad.csv', title: 'bad.csv', fileKind: 'text' })] })

View File

@@ -47,6 +47,16 @@ function isTitleKey(key: string): boolean {
return key.toLowerCase().replace(/\s+/g, '_') === 'title'
}
function safeString(value: unknown): string {
return typeof value === 'string' ? value : ''
}
function entryDisplayLabel(entry: VaultEntry): string {
return safeString(entry.title).trim()
|| safeString(entry.filename).trim()
|| 'Note'
}
interface TitleRenameDeps {
vaultPath: string
tabsRef: React.MutableRefObject<{ entry: VaultEntry; content: string }[]>
@@ -210,12 +220,12 @@ function buildTabManagementOptions(
void config.onMissingActiveVault?.(entry, error)
},
onMissingNotePath: (entry) => {
const label = entry.title.trim() || entry.filename
const label = entryDisplayLabel(entry)
config.setToastMessage(`"${label}" could not be opened because its file is missing or moved.`)
void config.reloadVault?.()
},
onUnreadableNoteContent: (entry) => {
const label = entry.title.trim() || entry.filename
const label = entryDisplayLabel(entry)
config.setToastMessage(`"${label}" could not be opened because it is not valid UTF-8 text.`)
},
}

View File

@@ -230,6 +230,41 @@ describe('useNoteRename hook', () => {
expect(setToastMessage).toHaveBeenCalledWith('Updated 1 note')
})
it('preserves active tab metadata when filename rename lands after a stale vault reload', async () => {
const entry = makeEntry({ path: '/vault/untitled-1.md', filename: 'untitled-1.md', title: 'Fresh Title' })
let tabs = [{ entry, content: '# Fresh Title\n' }]
const setTabs = vi.fn((update: typeof tabs | ((prev: typeof tabs) => typeof tabs)) => {
tabs = typeof update === 'function' ? update(tabs) : update
})
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
if (cmd === 'rename_note_filename') return { new_path: '/vault/fresh-title.md', updated_files: 0, failed_updates: 0 }
if (cmd === 'get_note_content') return '# Fresh Title\n'
return ''
})
const { result } = renderHook(() => useNoteRename(
{ entries: [], setToastMessage },
{ tabs, setTabs, activeTabPathRef, handleSwitchTab, updateTabContent },
))
const onEntryRenamed = vi.fn()
await act(async () => {
await result.current.handleRenameFilename('/vault/untitled-1.md', 'fresh-title', '/vault', onEntryRenamed)
})
expect(tabs[0].entry).toEqual(expect.objectContaining({
path: '/vault/fresh-title.md',
filename: 'fresh-title.md',
title: 'Fresh Title',
isA: 'Note',
}))
expect(onEntryRenamed).toHaveBeenCalledWith(
'/vault/untitled-1.md',
expect.objectContaining({ title: 'Fresh Title', filename: 'fresh-title.md' }),
'# Fresh Title\n',
)
})
it('warns when rename succeeds but some backlink rewrites fail', async () => {
const entry = makeEntry({ path: '/vault/old.md', title: 'Old' })
await runHandleRenameNote({

View File

@@ -194,6 +194,11 @@ interface Tab {
content: string
}
function findRenameEntry(entries: VaultEntry[], tabs: Tab[], path: string): VaultEntry | undefined {
return entries.find((entry) => entry.path === path)
?? tabs.find((tab) => tab.entry.path === path)?.entry
}
function renameErrorMessage(err: unknown): string {
const message = typeof err === 'string'
? err.trim()
@@ -251,10 +256,11 @@ function useRenameResultApplier(
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void,
options?: ApplyRenameOptions,
) => {
const entry = entries.find((item) => item.path === oldPath)
const currentTabs = tabsRef.current
const entry = findRenameEntry(entries, currentTabs, oldPath)
const newContent = await loadNoteContent({ path: result.new_path })
const newEntry = buildEntry(entry, result.new_path)
const otherTabPaths = tabsRef.current.filter((tab) => tab.entry.path !== oldPath).map((tab) => tab.entry.path)
const otherTabPaths = currentTabs.filter((tab) => tab.entry.path !== oldPath).map((tab) => tab.entry.path)
setTabs((prev) => prev.map((tab) => tab.entry.path === oldPath ? { entry: newEntry, content: newContent } : tab))
if (activeTabPathRef.current === oldPath) handleSwitchTab(result.new_path)
onEntryRenamed(oldPath, newEntry, newContent)
@@ -322,7 +328,7 @@ export function useNoteRename(config: NoteRenameConfig, tabDeps: RenameTabDeps)
path: string, newTitle: string, vaultPath: string,
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void,
) => {
const entry = entries.find((e) => e.path === path)
const entry = findRenameEntry(entries, tabsRef.current, path)
await runRenameAction({
path,
perform: () => performRename({ path, newTitle, vaultPath, oldTitle: entry?.title }),
@@ -333,7 +339,7 @@ export function useNoteRename(config: NoteRenameConfig, tabDeps: RenameTabDeps)
errorMessage: renameErrorMessage,
logLabel: 'Failed to rename note',
})
}, [entries, applyRenameResult, setToastMessage])
}, [entries, tabsRef, applyRenameResult, setToastMessage])
const handleRenameFilename = useCallback(async (
path: string,