diff --git a/src/App.tsx b/src/App.tsx index c044bacd..1df44b2e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -249,7 +249,7 @@ function App() { onOpenFile: (relativePath) => conflictFlow.openConflictFileRef.current(relativePath), }) - const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, reloadVault: vault.reloadVault, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => appSave.contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry, onFrontmatterPersisted: vault.loadModifiedFiles }) + const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, reloadVault: vault.reloadVault, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => appSave.contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry, onFrontmatterPersisted: vault.loadModifiedFiles, onPathRenamed: (oldPath, newPath) => appSave.trackRenamedPath(oldPath, newPath) }) // Note window: auto-open the note from URL params once vault entries load const noteWindowOpenedRef = useRef(false) @@ -321,17 +321,10 @@ function App() { loadModifiedFiles: vault.loadModifiedFiles, reloadViews: vault.reloadViews, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, tabs: notes.tabs, activeTabPath: notes.activeTabPath, - handleRenameNote: notes.handleRenameNote, + handleRenameNote: notes.handleRenameNote, handleRenameFilename: notes.handleRenameFilename, replaceEntry: vault.replaceEntry, resolvedPath, }) - const handleFilenameRename = useCallback((path: string, newFilenameStem: string) => { - appSave.savePendingForPath(path) - .then(() => notes.handleRenameFilename(path, newFilenameStem, resolvedPath, vault.replaceEntry)) - .then(vault.loadModifiedFiles) - .catch((err) => console.error('Filename rename failed:', err)) - }, [appSave, notes, resolvedPath, vault]) - const aiActivity = useAiActivity({ onOpenNote: vaultBridge.openNoteByPath, onOpenTab: vaultBridge.openNoteByPath, @@ -463,6 +456,7 @@ function App() { const deleteActions = useDeleteActions({ onDeselectNote: (path: string) => { if (notes.activeTabPath === path) notes.closeAllTabs() }, removeEntry: vault.removeEntry, + removeEntries: vault.removeEntries, refreshModifiedFiles: vault.loadModifiedFiles, reloadVault: vault.reloadVault, setToastMessage, @@ -783,7 +777,7 @@ function App() { onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote} onContentChange={appSave.handleContentChange} onSave={appSave.handleSave} - onRenameFilename={activeDeletedFile ? undefined : handleFilenameRename} + onRenameFilename={activeDeletedFile ? undefined : appSave.handleFilenameRename} rawToggleRef={rawToggleRef} diffToggleRef={diffToggleRef} canGoBack={canGoBack} diff --git a/src/hooks/useAppSave.test.ts b/src/hooks/useAppSave.test.ts index 98a2ab77..374de1a6 100644 --- a/src/hooks/useAppSave.test.ts +++ b/src/hooks/useAppSave.test.ts @@ -31,6 +31,7 @@ describe('useAppSave', () => { tabs: [] as Array<{ entry: VaultEntry; content: string }>, activeTabPath: null as string | null, handleRenameNote: vi.fn().mockResolvedValue(undefined), + handleRenameFilename: vi.fn().mockResolvedValue(undefined), replaceEntry: vi.fn(), resolvedPath: '/vault', } @@ -43,6 +44,7 @@ describe('useAppSave', () => { deps.tabs = [] deps.activeTabPath = null deps.handleRenameNote.mockResolvedValue(undefined) + deps.handleRenameFilename.mockResolvedValue(undefined) }) afterEach(() => { @@ -221,4 +223,103 @@ describe('useAppSave', () => { expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('auto_rename_untitled', expect.anything()) }) + + it('redirects stale editor saves to the latest renamed path', async () => { + vi.useFakeTimers() + vi.mocked(isTauri).mockReturnValue(true) + + const oldPath = '/vault/untitled-note-123.md' + const newPath = '/vault/fresh-title.md' + const entry = makeEntry(oldPath, 'Untitled Note 123', 'untitled-note-123.md') + let tabsState = [{ entry, content: '# Fresh Title\n\nBody' }] + const setTabs = vi.fn((updater: SetStateAction) => { + tabsState = typeof updater === 'function' ? updater(tabsState) : updater + }) + + vi.mocked(invoke).mockImplementation(async (command: string, args?: Record) => { + if (command === 'save_note_content') return undefined + if (command === 'auto_rename_untitled') return { new_path: newPath, updated_files: 0 } + if (command === 'reload_vault_entry') return makeEntry(newPath, 'Fresh Title', 'fresh-title.md') + if (command === 'get_note_content' && args?.path === newPath) return '# Fresh Title\n\nBody' + return undefined + }) + + const { result } = renderSave({ + setTabs, + tabs: tabsState, + activeTabPath: oldPath, + unsavedPaths: new Set([oldPath]), + }) + + await act(async () => { + result.current.handleContentChange(oldPath, '# Fresh Title\n\nBody') + await vi.advanceTimersByTimeAsync(3_000) + }) + + await act(async () => { + result.current.handleContentChange(oldPath, '# Fresh Title\n\nBody\n\nMore text') + await vi.advanceTimersByTimeAsync(500) + }) + + const saveCalls = vi.mocked(invoke).mock.calls.filter(([command]) => command === 'save_note_content') + expect(saveCalls.at(-1)).toEqual([ + 'save_note_content', + { path: newPath, content: '# Fresh Title\n\nBody\n\nMore text' }, + ]) + expect(saveCalls).not.toContainEqual([ + 'save_note_content', + { path: oldPath, content: '# Fresh Title\n\nBody\n\nMore text' }, + ]) + }) + + it('tracks filename renames so follow-up saves do not recreate the old path', async () => { + vi.useFakeTimers() + vi.mocked(isTauri).mockReturnValue(true) + + const oldPath = '/vault/fresh-title.md' + const newPath = '/vault/manual-name.md' + const entry = makeEntry(oldPath, 'Fresh Title', 'fresh-title.md') + + vi.mocked(invoke).mockImplementation(async (command: string) => { + if (command === 'save_note_content') return undefined + return undefined + }) + + deps.handleRenameFilename.mockImplementation(async (path, newFilenameStem, vaultPath, onEntryRenamed) => { + expect(path).toBe(oldPath) + expect(newFilenameStem).toBe('manual-name') + expect(vaultPath).toBe('/vault') + onEntryRenamed(path, { path: newPath, filename: 'manual-name.md', title: 'Fresh Title' }, '# Fresh Title\n\nBody') + }) + + const { result } = renderSave({ + tabs: [{ entry, content: '# Fresh Title\n\nBody' }], + activeTabPath: oldPath, + unsavedPaths: new Set([oldPath]), + }) + + await act(async () => { + await result.current.handleFilenameRename(oldPath, 'manual-name') + }) + + await act(async () => { + result.current.handleContentChange(oldPath, '# Fresh Title\n\nBody\n\nMore text') + await vi.advanceTimersByTimeAsync(500) + }) + + const saveCalls = vi.mocked(invoke).mock.calls.filter(([command]) => command === 'save_note_content') + expect(saveCalls.at(-1)).toEqual([ + 'save_note_content', + { path: newPath, content: '# Fresh Title\n\nBody\n\nMore text' }, + ]) + expect(saveCalls).not.toContainEqual([ + 'save_note_content', + { path: oldPath, content: '# Fresh Title\n\nBody\n\nMore text' }, + ]) + expect(deps.replaceEntry).toHaveBeenCalledWith( + oldPath, + expect.objectContaining({ path: newPath, filename: 'manual-name.md' }), + '# Fresh Title\n\nBody', + ) + }) }) diff --git a/src/hooks/useAppSave.ts b/src/hooks/useAppSave.ts index 5cbd142d..779a726d 100644 --- a/src/hooks/useAppSave.ts +++ b/src/hooks/useAppSave.ts @@ -19,6 +19,27 @@ interface PendingUntitledRename { timer: ReturnType } +type RenamedPathMap = Map + +function resolveLatestPath(renamedPaths: RenamedPathMap, path: string): string { + let current = path + const visited = new Set() + + while (!visited.has(current)) { + visited.add(current) + const next = renamedPaths.get(current) + if (!next || next === current) break + current = next + } + + return current +} + +function trackRenamedPath(renamedPaths: RenamedPathMap, oldPath: string, newPath: string): void { + if (oldPath === newPath) return + renamedPaths.set(oldPath, newPath) +} + function findUnsavedFallback( tabs: TabState[], activeTabPath: string | null, unsavedPaths: Set, ): { path: string; content: string } | undefined { @@ -136,6 +157,7 @@ interface AppSaveDeps { tabs: TabState[] activeTabPath: string | null handleRenameNote: (path: string, newTitle: string, vaultPath: string, onEntryRenamed: (oldPath: string, newEntry: Partial & { path: string }, newContent: string) => void) => Promise + handleRenameFilename: (path: string, newFilenameStem: string, vaultPath: string, onEntryRenamed: (oldPath: string, newEntry: Partial & { path: string }, newContent: string) => void) => Promise replaceEntry: (oldPath: string, newEntry: Partial & { path: string }, newContent: string) => void resolvedPath: string } @@ -144,10 +166,11 @@ export function useAppSave({ updateEntry, setTabs, handleSwitchTab, setToastMessage, loadModifiedFiles, reloadViews, clearUnsaved, unsavedPaths, tabs, activeTabPath, - handleRenameNote, replaceEntry, resolvedPath, + handleRenameNote, handleRenameFilename: handleRenameFilenameRaw, replaceEntry, resolvedPath, }: AppSaveDeps) { const contentChangeRef = useRef<(path: string, content: string) => void>(() => {}) const pendingUntitledRenameRef = useRef(null) + const renamedPathsRef = useRef(new Map()) const onAfterSave = useCallback(() => { loadModifiedFiles() @@ -164,6 +187,7 @@ export function useAppSave({ notePath: path, }) if (!result) return false + trackRenamedPath(renamedPathsRef.current, path, result.new_path) await reloadAutoRenamedNote( path, result.new_path, @@ -203,10 +227,27 @@ export function useAppSave({ scheduleUntitledRename(path, content) }, [clearUnsaved, reloadViews, scheduleUntitledRename]) - const { handleSave: handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({ + const { handleSave: handleSaveRaw, handleContentChange: handleContentChangeRaw, savePendingForPath: savePendingForPathRaw, savePending } = useEditorSaveWithLinks({ updateEntry, setTabs, setToastMessage, onAfterSave, onNotePersisted, }) + const registerRenamedPath = useCallback((oldPath: string, newPath: string) => { + trackRenamedPath(renamedPathsRef.current, oldPath, newPath) + }, []) + + const resolveCurrentPath = useCallback((path: string) => resolveLatestPath(renamedPathsRef.current, path), []) + + const handleContentChange = useCallback((path: string, content: string) => { + handleContentChangeRaw(resolveCurrentPath(path), content) + }, [handleContentChangeRaw, resolveCurrentPath]) + + const savePendingForPath = useCallback((path: string) => savePendingForPathRaw(resolveCurrentPath(path)), [savePendingForPathRaw, resolveCurrentPath]) + + const replaceRenamedEntry = useCallback((oldPath: string, newEntry: Partial & { path: string }, newContent: string) => { + registerRenamedPath(oldPath, newEntry.path) + replaceEntry(oldPath, newEntry, newContent) + }, [registerRenamedPath, replaceEntry]) + useEffect(() => { contentChangeRef.current = handleContentChange }, [handleContentChange]) useEffect(() => () => { cancelPendingUntitledRename() }, [cancelPendingUntitledRename]) useEffect(() => { @@ -221,48 +262,61 @@ export function useAppSave({ unsavedPathsRef.current = unsavedPaths // eslint-disable-line react-hooks/refs -- ref sync pattern const flushBeforeAction = useCallback(async (path: string) => { + const currentPath = resolveCurrentPath(path) try { - await flushEditorContent(path, { + await flushEditorContent(currentPath, { savePendingForPath, getTabContent: (p) => tabsRef.current.find(t => t.entry.path === p)?.content, isUnsaved: (p) => unsavedPathsRef.current.has(p), onSaved: (p) => { clearUnsaved(p) }, }) - await flushPendingUntitledRename(path) + await flushPendingUntitledRename(currentPath) } catch (err) { setToastMessage(`Auto-save failed: ${err}`) throw err } - }, [savePendingForPath, clearUnsaved, setToastMessage, flushPendingUntitledRename]) + }, [resolveCurrentPath, savePendingForPath, clearUnsaved, setToastMessage, flushPendingUntitledRename]) const handleRenameTab = useCallback(async (path: string, newTitle: string) => { - await savePendingForPath(path) - cancelPendingUntitledRename(path) - await handleRenameNote(path, newTitle, resolvedPath, replaceEntry).then(loadModifiedFiles) - }, [handleRenameNote, resolvedPath, replaceEntry, savePendingForPath, loadModifiedFiles, cancelPendingUntitledRename]) + const currentPath = resolveCurrentPath(path) + await savePendingForPath(currentPath) + cancelPendingUntitledRename(currentPath) + await handleRenameNote(currentPath, newTitle, resolvedPath, replaceRenamedEntry).then(loadModifiedFiles) + }, [resolveCurrentPath, handleRenameNote, resolvedPath, replaceRenamedEntry, savePendingForPath, loadModifiedFiles, cancelPendingUntitledRename]) + + const handleFilenameRename = useCallback(async (path: string, newFilenameStem: string) => { + const currentPath = resolveCurrentPath(path) + await savePendingForPath(currentPath) + cancelPendingUntitledRename(currentPath) + await handleRenameFilenameRaw(currentPath, newFilenameStem, resolvedPath, replaceRenamedEntry).then(loadModifiedFiles) + }, [resolveCurrentPath, handleRenameFilenameRaw, resolvedPath, replaceRenamedEntry, savePendingForPath, loadModifiedFiles, cancelPendingUntitledRename]) const handleSave = useCallback(async () => { - await handleSaveRaw(findUnsavedFallback(tabs, activeTabPath, unsavedPaths)) - const flushedUntitledRename = await flushPendingUntitledRename(activeTabPath ?? undefined) - const rename = activeTabNeedsRename(tabs, activeTabPath) + const resolvedActiveTabPath = activeTabPath ? resolveCurrentPath(activeTabPath) : null + await handleSaveRaw(findUnsavedFallback(tabs, resolvedActiveTabPath, unsavedPaths)) + const flushedUntitledRename = await flushPendingUntitledRename(resolvedActiveTabPath ?? undefined) + const rename = activeTabNeedsRename(tabs, resolvedActiveTabPath) if (!flushedUntitledRename && rename) await handleRenameTab(rename.path, rename.title) - }, [handleSaveRaw, handleRenameTab, tabs, activeTabPath, unsavedPaths, flushPendingUntitledRename]) + }, [handleSaveRaw, handleRenameTab, tabs, activeTabPath, unsavedPaths, flushPendingUntitledRename, resolveCurrentPath]) const handleTitleSync = useCallback((path: string, newTitle: string) => { - cancelPendingUntitledRename(path) - savePendingForPath(path) - .then(() => handleRenameNote(path, newTitle, resolvedPath, replaceEntry)) + const currentPath = resolveCurrentPath(path) + cancelPendingUntitledRename(currentPath) + savePendingForPath(currentPath) + .then(() => handleRenameNote(currentPath, newTitle, resolvedPath, replaceRenamedEntry)) .then(loadModifiedFiles) .catch((err) => console.error('Title rename failed:', err)) - }, [handleRenameNote, resolvedPath, replaceEntry, savePendingForPath, loadModifiedFiles, cancelPendingUntitledRename]) + }, [resolveCurrentPath, handleRenameNote, resolvedPath, replaceRenamedEntry, savePendingForPath, loadModifiedFiles, cancelPendingUntitledRename]) return { contentChangeRef, handleContentChange, + handleFilenameRename, handleSave, handleTitleSync, savePending, savePendingForPath, + trackRenamedPath: registerRenamedPath, flushBeforeAction, } } diff --git a/src/hooks/useDeleteActions.test.ts b/src/hooks/useDeleteActions.test.ts index 2109686e..e3a25cea 100644 --- a/src/hooks/useDeleteActions.test.ts +++ b/src/hooks/useDeleteActions.test.ts @@ -13,6 +13,7 @@ const mockInvokeFn = mockInvoke as ReturnType describe('useDeleteActions', () => { let onDeselectNote: ReturnType let removeEntry: ReturnType + let removeEntries: ReturnType let refreshModifiedFiles: ReturnType let reloadVault: ReturnType let setToastMessage: ReturnType @@ -20,6 +21,7 @@ describe('useDeleteActions', () => { beforeEach(() => { onDeselectNote = vi.fn() removeEntry = vi.fn() + removeEntries = vi.fn() refreshModifiedFiles = vi.fn().mockResolvedValue(undefined) reloadVault = vi.fn().mockResolvedValue(undefined) setToastMessage = vi.fn() @@ -31,6 +33,7 @@ describe('useDeleteActions', () => { useDeleteActions({ onDeselectNote, removeEntry, + removeEntries, refreshModifiedFiles, reloadVault, setToastMessage, @@ -92,7 +95,8 @@ describe('useDeleteActions', () => { expect(result.current.pendingDeleteCount).toBe(1) expect(mockInvokeFn).toHaveBeenCalledWith('batch_delete_notes', { paths: ['/vault/a.md'] }) expect(onDeselectNote).toHaveBeenCalledWith('/vault/a.md') - expect(removeEntry).toHaveBeenCalledWith('/vault/a.md') + expect(removeEntries).toHaveBeenCalledWith(['/vault/a.md']) + expect(removeEntry).not.toHaveBeenCalled() expect(setToastMessage).toHaveBeenNthCalledWith(1, 'Deleting note...') let ok: boolean | undefined @@ -153,6 +157,7 @@ describe('useDeleteActions', () => { it('onConfirm deletes all paths in one backend call and shows toast', async () => { await confirmDeleteAndExpectBatchCall(['/vault/a.md', '/vault/b.md']) + expect(removeEntries).toHaveBeenCalledWith(['/vault/a.md', '/vault/b.md']) expect(setToastMessage).toHaveBeenCalledWith('2 notes permanently deleted') }) diff --git a/src/hooks/useDeleteActions.ts b/src/hooks/useDeleteActions.ts index 744bef47..bd63c493 100644 --- a/src/hooks/useDeleteActions.ts +++ b/src/hooks/useDeleteActions.ts @@ -14,6 +14,7 @@ interface UseDeleteActionsInput { /** Called to deselect the note if it is currently open. */ onDeselectNote: (path: string) => void removeEntry: (path: string) => void + removeEntries?: (paths: string[]) => void refreshModifiedFiles: () => Promise | void reloadVault: () => Promise | void setToastMessage: (msg: string | null) => void @@ -50,6 +51,7 @@ async function runDeleteCommand(paths: string[]): Promise { function useDeleteRunner({ onDeselectNote, removeEntry, + removeEntries, refreshModifiedFiles, reloadVault, setToastMessage, @@ -65,12 +67,14 @@ function useDeleteRunner({ const optimisticallyRemoveEntries = useCallback((paths: string[]) => { startTransition(() => { - for (const path of paths) { - onDeselectNote(path) - removeEntry(path) + for (const path of paths) onDeselectNote(path) + if (removeEntries) { + removeEntries(paths) + return } + for (const path of paths) removeEntry(path) }) - }, [onDeselectNote, removeEntry]) + }, [onDeselectNote, removeEntries, removeEntry]) const deleteNotesFromDisk = useCallback(async (paths: string[]) => { if (paths.length === 0) return 0 @@ -120,6 +124,7 @@ function useDeleteRunner({ export function useDeleteActions({ onDeselectNote, removeEntry, + removeEntries, refreshModifiedFiles, reloadVault, setToastMessage, @@ -132,6 +137,7 @@ export function useDeleteActions({ } = useDeleteRunner({ onDeselectNote, removeEntry, + removeEntries, refreshModifiedFiles, reloadVault, setToastMessage, diff --git a/src/hooks/useNoteActions.hook.test.ts b/src/hooks/useNoteActions.hook.test.ts index cc000c1e..37ada99e 100644 --- a/src/hooks/useNoteActions.hook.test.ts +++ b/src/hooks/useNoteActions.hook.test.ts @@ -573,8 +573,10 @@ describe('useNoteActions hook', () => { filename: 'old-name.md', title: 'Old Name', }) + const onPathRenamed = vi.fn() const replaceEntry = vi.fn() const config = makeConfig([entry]) + config.onPathRenamed = onPathRenamed config.replaceEntry = replaceEntry vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => { @@ -601,6 +603,7 @@ describe('useNoteActions hook', () => { '/test/vault/old-name.md', expect.objectContaining({ path: '/test/vault/new-name.md', title: 'New Name' }), ) + expect(onPathRenamed).toHaveBeenCalledWith('/test/vault/old-name.md', '/test/vault/new-name.md') }) it('handleUpdateFrontmatter does not trigger rename for non-title keys', async () => { diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index f699f8b9..e38b3f83 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -26,6 +26,7 @@ export interface NoteActionsConfig { markContentPending?: (path: string, content: string) => void onNewNotePersisted?: () => void replaceEntry?: (oldPath: string, patch: Partial & { path: string }) => void + onPathRenamed?: (oldPath: string, newPath: string) => void /** Called after frontmatter is written to disk — used for live-reloading theme CSS vars. */ onFrontmatterContentChanged?: (path: string, content: string) => void /** Called after a frontmatter mutation is fully persisted, including follow-up renames. */ @@ -41,6 +42,7 @@ interface TitleRenameDeps { tabsRef: React.MutableRefObject<{ entry: VaultEntry; content: string }[]> reloadVault?: () => Promise replaceEntry?: (oldPath: string, patch: Partial & { path: string }) => void + onPathRenamed?: (oldPath: string, newPath: string) => void setTabs: React.Dispatch> activeTabPathRef: React.MutableRefObject handleSwitchTab: (path: string) => void @@ -71,6 +73,7 @@ async function renameAfterTitleChange({ path, newTitle, deps }: RenameAfterTitle const result = await performRename({ path, newTitle, vaultPath: deps.vaultPath, oldTitle }) if (result.new_path !== path) { const newFilename = result.new_path.split('/').pop() ?? '' + deps.onPathRenamed?.(path, result.new_path) deps.replaceEntry?.(path, { path: result.new_path, filename: newFilename, title: newTitle } as Partial & { path: string }) const newContent = await loadNoteContent({ path: result.new_path }) deps.setTabs(prev => prev.map(t => t.entry.path === path @@ -167,6 +170,7 @@ export function useNoteActions(config: NoteActionsConfig) { tabsRef: rename.tabsRef, reloadVault: config.reloadVault, replaceEntry: config.replaceEntry, + onPathRenamed: config.onPathRenamed, setTabs, activeTabPathRef, handleSwitchTab, diff --git a/src/hooks/useVaultLoader.test.ts b/src/hooks/useVaultLoader.test.ts index 3637df75..e9f119cd 100644 --- a/src/hooks/useVaultLoader.test.ts +++ b/src/hooks/useVaultLoader.test.ts @@ -119,6 +119,20 @@ describe('useVaultLoader', () => { }) }) + describe('removeEntries', () => { + it('removes multiple entries in one state update', async () => { + const { result } = await renderVaultLoader() + const secondEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/second.md', filename: 'second.md', title: 'Second' } + + act(() => { + result.current.addEntry(secondEntry) + result.current.removeEntries(['/vault/note/hello.md', '/vault/note/second.md']) + }) + + expect(result.current.entries).toHaveLength(0) + }) + }) + describe('updateEntry', () => { it('patches an existing entry by path', async () => { const { result } = await renderVaultLoader() diff --git a/src/hooks/useVaultLoader.ts b/src/hooks/useVaultLoader.ts index fcadb7ae..345e0cf2 100644 --- a/src/hooks/useVaultLoader.ts +++ b/src/hooks/useVaultLoader.ts @@ -148,6 +148,12 @@ export function useVaultLoader(vaultPath: string) { setEntries((prev) => prev.filter((e) => e.path !== path)) }, []) + const removeEntries = useCallback((paths: string[]) => { + if (paths.length === 0) return + const pathSet = new Set(paths) + setEntries((prev) => prev.filter((entry) => !pathSet.has(entry.path))) + }, []) + const replaceEntry = useCallback((oldPath: string, patch: Partial & { path: string }) => { setEntries((prev) => prev.map((e) => e.path === oldPath ? { ...e, ...patch } : e)) }, []) @@ -194,7 +200,7 @@ export function useVaultLoader(vaultPath: string) { return { entries, folders, views, modifiedFiles, modifiedFilesError, - addEntry, updateEntry, removeEntry, replaceEntry, + addEntry, updateEntry, removeEntry, removeEntries, replaceEntry, loadModifiedFiles, loadGitHistory, loadDiff, loadDiffAtCommit, getNoteStatus, commitAndPush, reloadVault, reloadFolders, reloadViews, addPendingSave: pendingSave.addPendingSave, diff --git a/tests/smoke/delete-note-nonblocking.spec.ts b/tests/smoke/delete-note-nonblocking.spec.ts index 74a18f45..88ada93b 100644 --- a/tests/smoke/delete-note-nonblocking.spec.ts +++ b/tests/smoke/delete-note-nonblocking.spec.ts @@ -26,8 +26,10 @@ test.describe('responsive note deletion', () => { await page.evaluate(() => { const handlers = window.__mockHandlers if (!handlers) throw new Error('Mock handlers unavailable for delete override') - handlers.batch_delete_notes = (args?: { paths?: string[] }) => - new Promise((resolve) => window.setTimeout(() => resolve(args?.paths ?? []), 1_200)) + const delayedDelete = (args?: { paths?: string[] }) => + new Promise((resolve) => window.setTimeout(() => resolve(args?.paths ?? []), 3_000)) + handlers.batch_delete_notes = delayedDelete + handlers.batch_delete_notes_async = delayedDelete }) await triggerShortcutCommand(page, APP_COMMAND_IDS.noteDelete)