From c7b6832cb1f5d88fdb77c4a98fac7f736a3a9b44 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 3 May 2026 19:40:50 +0200 Subject: [PATCH] fix: harden stale note rename and open flows --- src/hooks/useNoteActions.missing-path.test.ts | 25 +++++++++++++ src/hooks/useNoteActions.ts | 14 ++++++-- src/hooks/useNoteRename.test.ts | 35 +++++++++++++++++++ src/hooks/useNoteRename.ts | 14 +++++--- 4 files changed, 82 insertions(+), 6 deletions(-) diff --git a/src/hooks/useNoteActions.missing-path.test.ts b/src/hooks/useNoteActions.missing-path.test.ts index 02c56fb3..0341b625 100644 --- a/src/hooks/useNoteActions.missing-path.test.ts +++ b/src/hooks/useNoteActions.missing-path.test.ts @@ -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' })] }) diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index 66cc1038..0d30574b 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -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.`) }, } diff --git a/src/hooks/useNoteRename.test.ts b/src/hooks/useNoteRename.test.ts index 1f7c9e1a..713f9040 100644 --- a/src/hooks/useNoteRename.test.ts +++ b/src/hooks/useNoteRename.test.ts @@ -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({ diff --git a/src/hooks/useNoteRename.ts b/src/hooks/useNoteRename.ts index 9eda606c..3aceafd0 100644 --- a/src/hooks/useNoteRename.ts +++ b/src/hooks/useNoteRename.ts @@ -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 & { 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 & { 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,