diff --git a/src/hooks/useAppSave.test.ts b/src/hooks/useAppSave.test.ts index 374de1a6..d5209ae9 100644 --- a/src/hooks/useAppSave.test.ts +++ b/src/hooks/useAppSave.test.ts @@ -322,4 +322,64 @@ describe('useAppSave', () => { '# Fresh Title\n\nBody', ) }) + + it('uses the latest active tab content when untitled auto-rename resolves after continued typing', 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) => { + 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 '# Fresh Title\n\nBody from disk' + return undefined + }) + + const { result, rerender } = renderHook( + ({ currentTabs, currentActiveTabPath }: { currentTabs: typeof tabsState; currentActiveTabPath: string | null }) => useAppSave({ + ...deps, + setTabs, + tabs: currentTabs, + activeTabPath: currentActiveTabPath, + unsavedPaths: new Set([oldPath]), + }), + { + initialProps: { + currentTabs: tabsState, + currentActiveTabPath: oldPath, + }, + }, + ) + + await act(async () => { + result.current.handleContentChange(oldPath, '# Fresh Title\n\nBody') + await vi.advanceTimersByTimeAsync(500) + }) + + tabsState = [{ entry, content: '# Fresh Title\n\nBody that keeps changing while rename is pending' }] + rerender({ + currentTabs: tabsState, + currentActiveTabPath: oldPath, + }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(2_500) + }) + + expect(deps.replaceEntry).toHaveBeenCalledWith( + oldPath, + expect.objectContaining({ path: newPath, filename: 'fresh-title.md' }), + '# Fresh Title\n\nBody that keeps changing while rename is pending', + ) + expect(tabsState[0].entry.path).toBe(newPath) + expect(tabsState[0].content).toBe('# Fresh Title\n\nBody that keeps changing while rename is pending') + }) }) diff --git a/src/hooks/useAppSave.ts b/src/hooks/useAppSave.ts index 779a726d..c5d09c88 100644 --- a/src/hooks/useAppSave.ts +++ b/src/hooks/useAppSave.ts @@ -40,15 +40,27 @@ function trackRenamedPath(renamedPaths: RenamedPathMap, oldPath: string, newPath renamedPaths.set(oldPath, newPath) } -function findUnsavedFallback( - tabs: TabState[], activeTabPath: string | null, unsavedPaths: Set, -): { path: string; content: string } | undefined { +function findUnsavedFallback({ + tabs, + activeTabPath, + unsavedPaths, +}: { + tabs: TabState[] + activeTabPath: string | null + unsavedPaths: Set +}): { path: string; content: string } | undefined { const activeTab = tabs.find(t => t.entry.path === activeTabPath) if (!activeTab || !unsavedPaths.has(activeTab.entry.path)) return undefined return { path: activeTab.entry.path, content: activeTab.content } } -function activeTabNeedsRename(tabs: TabState[], activeTabPath: string | null): { path: string; title: string } | null { +function activeTabNeedsRename({ + tabs, + activeTabPath, +}: { + tabs: TabState[] + activeTabPath: string | null +}): { path: string; title: string } | null { const activeTab = tabs.find(t => t.entry.path === activeTabPath) if (!activeTab) return null return needsRenameOnSave(activeTab.entry.title, activeTab.entry.filename) @@ -62,48 +74,65 @@ function isUntitledRenameCandidate(path: string): boolean { return stem.startsWith('untitled-') && /\d+$/.test(stem) } -function shouldScheduleUntitledRename(path: string, content: string): boolean { +function shouldScheduleUntitledRename({ path, content }: { path: string; content: string }): boolean { return isTauri() && isUntitledRenameCandidate(path) && extractH1TitleFromContent(content) !== null } -function matchingPendingRename( - pending: PendingUntitledRename | null, - path?: string, +function matchingPendingRename({ + pending, + path, +}: { + pending: PendingUntitledRename | null + path?: string +}, ): PendingUntitledRename | null { if (!pending) return null if (path && pending.path !== path) return null return pending } -function takePendingRename( - pendingRenameRef: MutableRefObject, - path?: string, +function takePendingRename({ + pendingRenameRef, + path, +}: { + pendingRenameRef: MutableRefObject + path?: string +}, ): PendingUntitledRename | null { - const pending = matchingPendingRename(pendingRenameRef.current, path) + const pending = matchingPendingRename({ pending: pendingRenameRef.current, path }) if (!pending) return null clearTimeout(pending.timer) pendingRenameRef.current = null return pending } -function schedulePendingRename( - pendingRenameRef: MutableRefObject, - path: string, - onFire: (path: string) => void, +function schedulePendingRename({ + pendingRenameRef, + path, + onFire, +}: { + pendingRenameRef: MutableRefObject + path: string + onFire: (path: string) => void +}, ): void { - takePendingRename(pendingRenameRef) + takePendingRename({ pendingRenameRef }) const timer = setTimeout(() => { - const pending = takePendingRename(pendingRenameRef, path) + const pending = takePendingRename({ pendingRenameRef, path }) if (pending) onFire(pending.path) }, UNTITLED_RENAME_DEBOUNCE_MS) pendingRenameRef.current = { path, timer } } -function pendingRenameOutsideActiveTab( - pendingRenameRef: MutableRefObject, - activeTabPath: string | null, +function pendingRenameOutsideActiveTab({ + pendingRenameRef, + activeTabPath, +}: { + pendingRenameRef: MutableRefObject + activeTabPath: string | null +}, ): string | null { const pending = pendingRenameRef.current if (!pending || pending.path === activeTabPath) return null @@ -111,31 +140,46 @@ function pendingRenameOutsideActiveTab( } async function reloadAutoRenamedNote( - oldPath: string, - newPath: string, - tabs: TabState[], - activeTabPath: string | null, - setTabs: AppSaveDeps['setTabs'], - handleSwitchTab: AppSaveDeps['handleSwitchTab'], - replaceEntry: AppSaveDeps['replaceEntry'], - loadModifiedFiles: AppSaveDeps['loadModifiedFiles'], + { + oldPath, + newPath, + tabsRef, + activeTabPathRef, + setTabs, + handleSwitchTab, + replaceEntry, + loadModifiedFiles, + }: { + oldPath: string + newPath: string + tabsRef: MutableRefObject + activeTabPathRef: MutableRefObject + setTabs: AppSaveDeps['setTabs'] + handleSwitchTab: AppSaveDeps['handleSwitchTab'] + replaceEntry: AppSaveDeps['replaceEntry'] + loadModifiedFiles: AppSaveDeps['loadModifiedFiles'] + }, ): Promise { const [newEntry, newContent] = await Promise.all([ invoke('reload_vault_entry', { path: newPath }), invoke('get_note_content', { path: newPath }), ]) - const otherTabPaths = tabs + const preservedContent = activeTabPathRef.current === oldPath + ? tabsRef.current.find((tab) => tab.entry.path === oldPath)?.content ?? newContent + : newContent + + const otherTabPaths = tabsRef.current .filter((tab) => tab.entry.path !== oldPath && tab.entry.path !== newPath) .map((tab) => tab.entry.path) setTabs((prev: TabState[]) => prev.map((tab) => ( tab.entry.path === oldPath - ? { entry: { ...tab.entry, ...newEntry, path: newPath }, content: newContent } + ? { entry: { ...tab.entry, ...newEntry, path: newPath }, content: preservedContent } : tab ))) - if (activeTabPath === oldPath) handleSwitchTab(newPath) - replaceEntry(oldPath, { ...newEntry, path: newPath }, newContent) + if (activeTabPathRef.current === oldPath) handleSwitchTab(newPath) + replaceEntry(oldPath, { ...newEntry, path: newPath }, preservedContent) await Promise.all(otherTabPaths.map(async (path) => { const content = await invoke('get_note_content', { path }) setTabs((prev: TabState[]) => prev.map((tab) => ( @@ -171,13 +215,19 @@ export function useAppSave({ const contentChangeRef = useRef<(path: string, content: string) => void>(() => {}) const pendingUntitledRenameRef = useRef(null) const renamedPathsRef = useRef(new Map()) + const tabsRef = useRef(tabs) + tabsRef.current = tabs // eslint-disable-line react-hooks/refs -- ref sync pattern + const activeTabPathRef = useRef(activeTabPath) + activeTabPathRef.current = activeTabPath // eslint-disable-line react-hooks/refs -- ref sync pattern + const unsavedPathsRef = useRef(unsavedPaths) + unsavedPathsRef.current = unsavedPaths // eslint-disable-line react-hooks/refs -- ref sync pattern const onAfterSave = useCallback(() => { loadModifiedFiles() }, [loadModifiedFiles]) const cancelPendingUntitledRename = useCallback((path?: string) => ( - takePendingRename(pendingUntitledRenameRef, path) !== null + takePendingRename({ pendingRenameRef: pendingUntitledRenameRef, path }) !== null ), []) const executeUntitledRename = useCallback(async (path: string) => { @@ -188,36 +238,40 @@ export function useAppSave({ }) if (!result) return false trackRenamedPath(renamedPathsRef.current, path, result.new_path) - await reloadAutoRenamedNote( - path, - result.new_path, - tabs, - activeTabPath, + await reloadAutoRenamedNote({ + oldPath: path, + newPath: result.new_path, + tabsRef, + activeTabPathRef, setTabs, handleSwitchTab, replaceEntry, loadModifiedFiles, - ) + }) return true } catch { return false } - }, [resolvedPath, tabs, activeTabPath, setTabs, handleSwitchTab, replaceEntry, loadModifiedFiles]) + }, [resolvedPath, setTabs, handleSwitchTab, replaceEntry, loadModifiedFiles]) const flushPendingUntitledRename = useCallback(async (path?: string) => { - const pending = takePendingRename(pendingUntitledRenameRef, path) + const pending = takePendingRename({ pendingRenameRef: pendingUntitledRenameRef, path }) if (!pending) return false return executeUntitledRename(pending.path) }, [executeUntitledRename]) const scheduleUntitledRename = useCallback((path: string, content: string) => { - if (!shouldScheduleUntitledRename(path, content)) { + if (!shouldScheduleUntitledRename({ path, content })) { cancelPendingUntitledRename(path) return } - schedulePendingRename(pendingUntitledRenameRef, path, (pendingPath) => { + schedulePendingRename({ + pendingRenameRef: pendingUntitledRenameRef, + path, + onFire: (pendingPath) => { void executeUntitledRename(pendingPath) + }, }) }, [cancelPendingUntitledRename, executeUntitledRename]) @@ -251,16 +305,13 @@ export function useAppSave({ useEffect(() => { contentChangeRef.current = handleContentChange }, [handleContentChange]) useEffect(() => () => { cancelPendingUntitledRename() }, [cancelPendingUntitledRename]) useEffect(() => { - const pendingPath = pendingRenameOutsideActiveTab(pendingUntitledRenameRef, activeTabPath) + const pendingPath = pendingRenameOutsideActiveTab({ + pendingRenameRef: pendingUntitledRenameRef, + activeTabPath, + }) if (pendingPath) cancelPendingUntitledRename(pendingPath) }, [activeTabPath, cancelPendingUntitledRename]) - // Refs for stable closure in flushBeforeAction - const tabsRef = useRef(tabs) - tabsRef.current = tabs // eslint-disable-line react-hooks/refs -- ref sync pattern - const unsavedPathsRef = useRef(unsavedPaths) - unsavedPathsRef.current = unsavedPaths // eslint-disable-line react-hooks/refs -- ref sync pattern - const flushBeforeAction = useCallback(async (path: string) => { const currentPath = resolveCurrentPath(path) try { @@ -293,9 +344,13 @@ export function useAppSave({ const handleSave = useCallback(async () => { const resolvedActiveTabPath = activeTabPath ? resolveCurrentPath(activeTabPath) : null - await handleSaveRaw(findUnsavedFallback(tabs, resolvedActiveTabPath, unsavedPaths)) + await handleSaveRaw(findUnsavedFallback({ + tabs, + activeTabPath: resolvedActiveTabPath, + unsavedPaths, + })) const flushedUntitledRename = await flushPendingUntitledRename(resolvedActiveTabPath ?? undefined) - const rename = activeTabNeedsRename(tabs, resolvedActiveTabPath) + const rename = activeTabNeedsRename({ tabs, activeTabPath: resolvedActiveTabPath }) if (!flushedUntitledRename && rename) await handleRenameTab(rename.path, rename.title) }, [handleSaveRaw, handleRenameTab, tabs, activeTabPath, unsavedPaths, flushPendingUntitledRename, resolveCurrentPath])