From 8f04ed59a4e2b05d658334253d55a52db275c560 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 14 Apr 2026 23:48:30 +0200 Subject: [PATCH] fix: remap buffered saves after untitled rename --- src/hooks/useAppSave.test.ts | 55 +++++++++ src/hooks/useAppSave.ts | 14 +-- src/hooks/useEditorSave.ts | 129 +++++++++++++++----- src/hooks/useEditorSaveWithLinks.ts | 10 +- tests/smoke/h1-untitled-auto-rename.spec.ts | 33 +++++ 5 files changed, 201 insertions(+), 40 deletions(-) diff --git a/src/hooks/useAppSave.test.ts b/src/hooks/useAppSave.test.ts index d5209ae9..57155109 100644 --- a/src/hooks/useAppSave.test.ts +++ b/src/hooks/useAppSave.test.ts @@ -382,4 +382,59 @@ describe('useAppSave', () => { expect(tabsState[0].entry.path).toBe(newPath) expect(tabsState[0].content).toBe('# Fresh Title\n\nBody that keeps changing while rename is pending') }) + + it('remaps a buffered auto-save to the renamed path when untitled rename lands mid-idle window', async () => { + vi.useFakeTimers() + vi.mocked(isTauri).mockReturnValue(true) + + const oldPath = '/vault/untitled-note-123.md' + const newPath = '/vault/fresh-title.md' + const initialContent = '# Fresh Title\n\nInitial body' + const bufferedContent = '# Fresh Title\n\nBody typed right before rename' + const entry = makeEntry(oldPath, 'Untitled Note 123', 'untitled-note-123.md') + let tabsState = [{ entry, content: initialContent }] + 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 initialContent + return undefined + }) + + const { result } = renderSave({ + setTabs, + tabs: tabsState, + activeTabPath: oldPath, + unsavedPaths: new Set([oldPath]), + }) + + await act(async () => { + result.current.handleContentChange(oldPath, initialContent) + await vi.advanceTimersByTimeAsync(500) + }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(2_300) + result.current.handleContentChange(oldPath, bufferedContent) + await vi.advanceTimersByTimeAsync(200) + }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(300) + }) + + const saveCalls = vi.mocked(invoke).mock.calls.filter(([command]) => command === 'save_note_content') + expect(saveCalls.at(-1)).toEqual([ + 'save_note_content', + { path: newPath, content: bufferedContent }, + ]) + expect(saveCalls).not.toContainEqual([ + 'save_note_content', + { path: oldPath, content: bufferedContent }, + ]) + }) }) diff --git a/src/hooks/useAppSave.ts b/src/hooks/useAppSave.ts index c5d09c88..92d980e3 100644 --- a/src/hooks/useAppSave.ts +++ b/src/hooks/useAppSave.ts @@ -230,6 +230,12 @@ export function useAppSave({ takePendingRename({ pendingRenameRef: pendingUntitledRenameRef, path }) !== null ), []) + const registerRenamedPath = useCallback((oldPath: string, newPath: string) => { + trackRenamedPath(renamedPathsRef.current, oldPath, newPath) + }, []) + + const resolveCurrentPath = useCallback((path: string) => resolveLatestPath(renamedPathsRef.current, path), []) + const executeUntitledRename = useCallback(async (path: string) => { try { const result = await invoke<{ new_path: string; updated_files: number } | null>('auto_rename_untitled', { @@ -282,15 +288,9 @@ export function useAppSave({ }, [clearUnsaved, reloadViews, scheduleUntitledRename]) const { handleSave: handleSaveRaw, handleContentChange: handleContentChangeRaw, savePendingForPath: savePendingForPathRaw, savePending } = useEditorSaveWithLinks({ - updateEntry, setTabs, setToastMessage, onAfterSave, onNotePersisted, + updateEntry, setTabs, setToastMessage, onAfterSave, onNotePersisted, resolvePath: resolveCurrentPath, }) - 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]) diff --git a/src/hooks/useEditorSave.ts b/src/hooks/useEditorSave.ts index 6e6f0d59..61c2587a 100644 --- a/src/hooks/useEditorSave.ts +++ b/src/hooks/useEditorSave.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useEffect, useRef, type MutableRefObject } from 'react' import type { SetStateAction } from 'react' import { useSaveNote } from './useSaveNote' @@ -15,6 +15,8 @@ interface EditorSaveConfig { onAfterSave?: () => void /** Called after content is persisted — used to clear unsaved state and live-reload themes. */ onNotePersisted?: (path: string, content: string) => void + /** Resolve stale paths (for example after a note rename) before persisting buffered content. */ + resolvePath?: (path: string) => string } /** @@ -25,15 +27,87 @@ const noop = () => {} const AUTO_SAVE_DEBOUNCE_MS = 500 -export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave = noop, onNotePersisted }: EditorSaveConfig) { +interface PendingContent { + path: string + content: string +} + +function resolveBufferedPath(path: string, resolvePath?: EditorSaveConfig['resolvePath']): string { + return resolvePath?.(path) ?? path +} + +function matchesPendingPath( + pending: PendingContent | null, + pathFilter?: string, + resolvePath?: EditorSaveConfig['resolvePath'], +): pending is PendingContent { + if (!pending) return false + if (!pathFilter) return true + return resolveBufferedPath(pending.path, resolvePath) === resolveBufferedPath(pathFilter, resolvePath) +} + +async function persistResolvedContent({ + path, + content, + saveNote, + onNotePersisted, + resolvePath, +}: { + path: string + content: string + saveNote: (path: string, content: string) => Promise + onNotePersisted?: EditorSaveConfig['onNotePersisted'] + resolvePath?: EditorSaveConfig['resolvePath'] +}): Promise { + const targetPath = resolveBufferedPath(path, resolvePath) + await saveNote(targetPath, content) + onNotePersisted?.(targetPath, content) +} + +function applyTabContent( + setTabs: EditorSaveConfig['setTabs'], + path: string, + content: string, +): void { + setTabs((prev: Tab[]) => + prev.map((t) => t.entry.path === path ? { ...t, content } : t) + ) +} + +function scheduleAutoSave({ + autoSaveTimerRef, + flushPending, + onAfterSaveRef, +}: { + autoSaveTimerRef: MutableRefObject | null> + flushPending: () => Promise + onAfterSaveRef: MutableRefObject<() => void> +}): void { + autoSaveTimerRef.current = setTimeout(async () => { + autoSaveTimerRef.current = null + try { + const saved = await flushPending() + if (saved) onAfterSaveRef.current() + } catch (err) { + console.error('Auto-save failed:', err) + } + }, AUTO_SAVE_DEBOUNCE_MS) +} + +export function useEditorSave({ + updateVaultContent, + setTabs, + setToastMessage, + onAfterSave = noop, + onNotePersisted, + resolvePath, +}: EditorSaveConfig) { const pendingContentRef = useRef<{ path: string; content: string } | null>(null) const autoSaveTimerRef = useRef | null>(null) const updateTabAndContent = useCallback((path: string, content: string) => { updateVaultContent(path, content) - setTabs((prev: Tab[]) => - prev.map((t) => t.entry.path === path ? { ...t, content } : t) - ) + applyTabContent(setTabs, path, content) }, [updateVaultContent, setTabs]) const { saveNote } = useSaveNote(updateTabAndContent) @@ -41,14 +115,12 @@ export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, on /** Persist pending content matching an optional path filter; returns true if saved */ const flushPending = useCallback(async (pathFilter?: string): Promise => { const pending = pendingContentRef.current - if (!pending) return false - if (pathFilter && pending.path !== pathFilter) return false - await saveNote(pending.path, pending.content) - const savedContent = pending.content + if (!matchesPendingPath(pending, pathFilter, resolvePath)) return false + const { path, content: savedContent } = pending + await persistResolvedContent({ path, content: savedContent, saveNote, onNotePersisted, resolvePath }) pendingContentRef.current = null - onNotePersisted?.(pending.path, savedContent) return true - }, [saveNote, onNotePersisted]) + }, [saveNote, onNotePersisted, resolvePath]) // Stable ref for onAfterSave so the auto-save timer closure always calls the latest version const onAfterSaveRef = useRef(onAfterSave) @@ -68,38 +140,31 @@ export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, on cancelAutoSave() try { const saved = await flushPending() - if (!saved && unsavedFallback) { - await saveNote(unsavedFallback.path, unsavedFallback.content) - onNotePersisted?.(unsavedFallback.path, unsavedFallback.content) - setToastMessage('Saved') - onAfterSave() - return - } - setToastMessage(saved ? 'Saved' : 'Nothing to save') + const savedFallback = !saved && !!unsavedFallback && await (async () => { + await persistResolvedContent({ + path: unsavedFallback.path, + content: unsavedFallback.content, + saveNote, + onNotePersisted, + resolvePath, + }) + return true + })() + setToastMessage(saved || savedFallback ? 'Saved' : 'Nothing to save') onAfterSave() } catch (err) { console.error('Save failed:', err) setToastMessage(`Save failed: ${err}`) } - }, [cancelAutoSave, flushPending, setToastMessage, onAfterSave, saveNote, onNotePersisted]) + }, [cancelAutoSave, flushPending, setToastMessage, onAfterSave, saveNote, onNotePersisted, resolvePath]) /** Called by Editor onChange — buffers the latest content, syncs tab state, * and schedules an auto-save after 500ms of inactivity. */ const handleContentChange = useCallback((path: string, content: string) => { pendingContentRef.current = { path, content } - setTabs((prev: Tab[]) => - prev.map((t) => t.entry.path === path ? { ...t, content } : t) - ) + applyTabContent(setTabs, path, content) cancelAutoSave() - autoSaveTimerRef.current = setTimeout(async () => { - autoSaveTimerRef.current = null - try { - const saved = await flushPending() - if (saved) onAfterSaveRef.current() - } catch (err) { - console.error('Auto-save failed:', err) - } - }, AUTO_SAVE_DEBOUNCE_MS) + scheduleAutoSave({ autoSaveTimerRef, flushPending: () => flushPending(), onAfterSaveRef }) }, [setTabs, cancelAutoSave, flushPending]) // Clear auto-save timer on unmount diff --git a/src/hooks/useEditorSaveWithLinks.ts b/src/hooks/useEditorSaveWithLinks.ts index 27ae99f5..9fb35183 100644 --- a/src/hooks/useEditorSaveWithLinks.ts +++ b/src/hooks/useEditorSaveWithLinks.ts @@ -11,6 +11,7 @@ export function useEditorSaveWithLinks(config: { setToastMessage: (msg: string | null) => void onAfterSave: () => void onNotePersisted?: (path: string, content: string) => void + resolvePath?: (path: string) => string }) { const { updateEntry } = config const saveContent = useCallback((path: string, content: string) => { @@ -20,7 +21,14 @@ export function useEditorSaveWithLinks(config: { wordCount: countWords(content), }) }, [updateEntry]) - const editor = useEditorSave({ updateVaultContent: saveContent, setTabs: config.setTabs, setToastMessage: config.setToastMessage, onAfterSave: config.onAfterSave, onNotePersisted: config.onNotePersisted }) + const editor = useEditorSave({ + updateVaultContent: saveContent, + setTabs: config.setTabs, + setToastMessage: config.setToastMessage, + onAfterSave: config.onAfterSave, + onNotePersisted: config.onNotePersisted, + resolvePath: config.resolvePath, + }) const { handleContentChange: rawOnChange } = editor const prevLinksKeyRef = useRef('') const prevFmKeyRef = useRef('') diff --git a/tests/smoke/h1-untitled-auto-rename.spec.ts b/tests/smoke/h1-untitled-auto-rename.spec.ts index a5dc0115..0670236f 100644 --- a/tests/smoke/h1-untitled-auto-rename.spec.ts +++ b/tests/smoke/h1-untitled-auto-rename.spec.ts @@ -58,6 +58,12 @@ async function expectRenamedFile({ vaultPath, filename }: FileExpectation): Prom }).toPass({ timeout: 10_000 }) } +async function expectFileMissing({ vaultPath, filename }: FileExpectation): Promise { + await expect(async () => { + expect(markdownFiles(vaultPath)).not.toContain(filename) + }).toPass({ timeout: 10_000 }) +} + async function expectFileContentContains({ vaultPath, filename, text }: FileContentExpectation): Promise { await expect(async () => { const content = fs.readFileSync(`${vaultPath}/${filename}`, 'utf-8') @@ -224,3 +230,30 @@ test('@smoke new-note H1 auto-rename preserves body typing and cursor while rena await expectEditorFocused(page) await expect(errors).toEqual([]) }) + +test('@smoke new-note H1 auto-rename does not recreate the untitled file when a buffered save lands after rename', async ({ page }) => { + const title = 'Late Save Guard' + const lateBody = 'Body typed right before rename' + + await createUntitledNote(page) + const untitledStem = (await page.getByTestId('breadcrumb-filename-trigger').textContent())?.trim() + expect(untitledStem).toMatch(/^untitled-note-\d+(?:-\d+)?$/i) + + await writeNewHeading(page, title) + await page.waitForTimeout(2_600) + await page.keyboard.type(lateBody) + + await expectActiveFilename(page, 'late-save-guard') + await expectRenamedFile({ vaultPath: tempVaultDir, filename: 'late-save-guard.md' }) + await expectFileContentContains({ + vaultPath: tempVaultDir, + filename: 'late-save-guard.md', + text: lateBody, + }) + + await page.waitForTimeout(800) + await expectFileMissing({ + vaultPath: tempVaultDir, + filename: `${untitledStem}.md`, + }) +})