fix: remap buffered saves after untitled rename

This commit is contained in:
lucaronin
2026-04-14 23:48:30 +02:00
parent 8a5763959b
commit 8f04ed59a4
5 changed files with 201 additions and 40 deletions

View File

@@ -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<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 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 },
])
})
})

View File

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

View File

@@ -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<void>
onNotePersisted?: EditorSaveConfig['onNotePersisted']
resolvePath?: EditorSaveConfig['resolvePath']
}): Promise<void> {
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<ReturnType<typeof setTimeout> | null>
flushPending: () => Promise<boolean>
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<ReturnType<typeof setTimeout> | 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<boolean> => {
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

View File

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

View File

@@ -58,6 +58,12 @@ async function expectRenamedFile({ vaultPath, filename }: FileExpectation): Prom
}).toPass({ timeout: 10_000 })
}
async function expectFileMissing({ vaultPath, filename }: FileExpectation): Promise<void> {
await expect(async () => {
expect(markdownFiles(vaultPath)).not.toContain(filename)
}).toPass({ timeout: 10_000 })
}
async function expectFileContentContains({ vaultPath, filename, text }: FileContentExpectation): Promise<void> {
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`,
})
})