fix: remap buffered saves after untitled rename
This commit is contained in:
@@ -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 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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`,
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user