fix: harden rename saves and async deletes
This commit is contained in:
14
src/App.tsx
14
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}
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user