fix: harden rename saves and async deletes

This commit is contained in:
lucaronin
2026-04-14 15:40:46 +02:00
parent 9f46f22667
commit 0078cc257a
10 changed files with 224 additions and 35 deletions

View File

@@ -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}

View File

@@ -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<typeof tabsState>) => {
tabsState = typeof updater === 'function' ? updater(tabsState) : updater
})
vi.mocked(invoke).mockImplementation(async (command: string, args?: Record<string, unknown>) => {
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',
)
})
})

View File

@@ -19,6 +19,27 @@ interface PendingUntitledRename {
timer: ReturnType<typeof setTimeout>
}
type RenamedPathMap = Map<string, string>
function resolveLatestPath(renamedPaths: RenamedPathMap, path: string): string {
let current = path
const visited = new Set<string>()
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<string>,
): { 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<VaultEntry> & { path: string }, newContent: string) => void) => Promise<void>
handleRenameFilename: (path: string, newFilenameStem: string, vaultPath: string, onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void) => Promise<void>
replaceEntry: (oldPath: string, newEntry: Partial<VaultEntry> & { 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<PendingUntitledRename | null>(null)
const renamedPathsRef = useRef<RenamedPathMap>(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<VaultEntry> & { 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,
}
}

View File

@@ -13,6 +13,7 @@ const mockInvokeFn = mockInvoke as ReturnType<typeof vi.fn>
describe('useDeleteActions', () => {
let onDeselectNote: ReturnType<typeof vi.fn>
let removeEntry: ReturnType<typeof vi.fn>
let removeEntries: ReturnType<typeof vi.fn>
let refreshModifiedFiles: ReturnType<typeof vi.fn>
let reloadVault: ReturnType<typeof vi.fn>
let setToastMessage: ReturnType<typeof vi.fn>
@@ -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')
})

View File

@@ -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<unknown> | void
reloadVault: () => Promise<unknown> | void
setToastMessage: (msg: string | null) => void
@@ -50,6 +51,7 @@ async function runDeleteCommand(paths: string[]): Promise<string[]> {
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,

View File

@@ -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 () => {

View File

@@ -26,6 +26,7 @@ export interface NoteActionsConfig {
markContentPending?: (path: string, content: string) => void
onNewNotePersisted?: () => void
replaceEntry?: (oldPath: string, patch: Partial<VaultEntry> & { 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<unknown>
replaceEntry?: (oldPath: string, patch: Partial<VaultEntry> & { path: string }) => void
onPathRenamed?: (oldPath: string, newPath: string) => void
setTabs: React.Dispatch<React.SetStateAction<{ entry: VaultEntry; content: string }[]>>
activeTabPathRef: React.MutableRefObject<string | null>
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<VaultEntry> & { 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,

View File

@@ -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()

View File

@@ -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<VaultEntry> & { 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,

View File

@@ -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)