fix: harden stale note rename and open flows
This commit is contained in:
@@ -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' })] })
|
||||
|
||||
@@ -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.`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user