From c8db92c92d491827efecbe3e8e28df49cf7d52d8 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Fri, 10 Apr 2026 17:18:48 +0200 Subject: [PATCH] fix: debounce untitled H1 filename sync --- src/hooks/useAppSave.test.ts | 94 ++++++++++++++++++++- src/hooks/useAppSave.ts | 156 +++++++++++++++++++++++++++++------ 2 files changed, 221 insertions(+), 29 deletions(-) diff --git a/src/hooks/useAppSave.test.ts b/src/hooks/useAppSave.test.ts index edf817b3..d4640a97 100644 --- a/src/hooks/useAppSave.test.ts +++ b/src/hooks/useAppSave.test.ts @@ -1,14 +1,17 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { renderHook, act } from '@testing-library/react' import { useAppSave } from './useAppSave' import type { VaultEntry } from '../types' +import { isTauri } from '../mock-tauri' +import { invoke } from '@tauri-apps/api/core' vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn().mockResolvedValue(undefined), })) vi.mock('../mock-tauri', () => ({ - isTauri: () => false, + isTauri: vi.fn(() => false), mockInvoke: vi.fn().mockResolvedValue(undefined), + updateMockContent: vi.fn(), })) function makeEntry(path: string, title = 'Test', filename = 'test.md'): VaultEntry { @@ -32,12 +35,18 @@ describe('useAppSave', () => { beforeEach(() => { vi.clearAllMocks() + vi.useRealTimers() + vi.mocked(isTauri).mockReturnValue(false) deps.unsavedPaths = new Set() deps.tabs = [] deps.activeTabPath = null deps.handleRenameNote.mockResolvedValue(undefined) }) + afterEach(() => { + vi.useRealTimers() + }) + function renderSave(overrides = {}) { return renderHook(() => useAppSave({ ...deps, ...overrides })) } @@ -91,4 +100,85 @@ describe('useAppSave', () => { const { result } = renderSave() expect(typeof result.current.handleContentChange).toBe('function') }) + + it('debounces untitled H1 auto-rename until the user pauses typing', async () => { + vi.useFakeTimers() + vi.mocked(isTauri).mockReturnValue(true) + vi.mocked(invoke).mockImplementation(async (command: string, args?: Record) => { + if (command === 'save_note_content') return undefined + if (command === 'auto_rename_untitled') return { new_path: '/vault/fresh-title.md', updated_files: 0 } + if (command === 'reload_vault_entry') return makeEntry('/vault/fresh-title.md', 'Fresh Title', 'fresh-title.md') + if (command === 'get_note_content' && args?.path === '/vault/fresh-title.md') return '# Fresh Title\n\nBody' + return undefined + }) + + const entry = makeEntry('/vault/untitled-note-123.md', 'Untitled Note 123', 'untitled-note-123.md') + const tabs = [{ entry, content: '# Fresh Title\n\nBody' }] + const { result } = renderSave({ + tabs, + activeTabPath: entry.path, + unsavedPaths: new Set([entry.path]), + }) + + await act(async () => { + result.current.handleContentChange(entry.path, '# Fresh Title\n\nBody') + await vi.advanceTimersByTimeAsync(500) + }) + + expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('auto_rename_untitled', expect.anything()) + + await act(async () => { + await vi.advanceTimersByTimeAsync(2_499) + }) + expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('auto_rename_untitled', expect.anything()) + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + + expect(vi.mocked(invoke)).toHaveBeenCalledWith('auto_rename_untitled', { + vaultPath: '/vault', + notePath: entry.path, + }) + expect(deps.replaceEntry).toHaveBeenCalledWith( + entry.path, + expect.objectContaining({ path: '/vault/fresh-title.md', filename: 'fresh-title.md' }), + '# Fresh Title\n\nBody', + ) + }) + + it('cancels a pending untitled auto-rename when the user navigates away', async () => { + vi.useFakeTimers() + vi.mocked(isTauri).mockReturnValue(true) + vi.mocked(invoke).mockImplementation(async (command: string) => { + if (command === 'save_note_content') return undefined + if (command === 'auto_rename_untitled') return { new_path: '/vault/fresh-title.md', updated_files: 0 } + return undefined + }) + + const entry = makeEntry('/vault/untitled-note-123.md', 'Untitled Note 123', 'untitled-note-123.md') + const tabs = [{ entry, content: '# Fresh Title\n\nBody' }] + const { result, rerender } = renderHook( + ({ currentActiveTabPath }: { currentActiveTabPath: string | null }) => useAppSave({ + ...deps, + tabs, + activeTabPath: currentActiveTabPath, + unsavedPaths: new Set([entry.path]), + }), + { initialProps: { currentActiveTabPath: entry.path } }, + ) + + await act(async () => { + result.current.handleContentChange(entry.path, '# Fresh Title\n\nBody') + await vi.advanceTimersByTimeAsync(500) + }) + + rerender({ currentActiveTabPath: '/vault/other.md' }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(2_500) + }) + + expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('auto_rename_untitled', expect.anything()) + }) }) diff --git a/src/hooks/useAppSave.ts b/src/hooks/useAppSave.ts index 25d243fc..520c7589 100644 --- a/src/hooks/useAppSave.ts +++ b/src/hooks/useAppSave.ts @@ -1,8 +1,9 @@ -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useEffect, useRef, type MutableRefObject } from 'react' import { invoke } from '@tauri-apps/api/core' import { useEditorSaveWithLinks } from './useEditorSaveWithLinks' import { needsRenameOnSave } from './useNoteRename' import { flushEditorContent } from '../utils/autoSave' +import { extractH1TitleFromContent } from '../utils/noteTitle' import { isTauri } from '../mock-tauri' import type { VaultEntry } from '../types' @@ -11,6 +12,13 @@ interface TabState { content: string } +const UNTITLED_RENAME_DEBOUNCE_MS = 2500 + +interface PendingUntitledRename { + path: string + timer: ReturnType +} + function findUnsavedFallback( tabs: TabState[], activeTabPath: string | null, unsavedPaths: Set, ): { path: string; content: string } | undefined { @@ -27,6 +35,74 @@ function activeTabNeedsRename(tabs: TabState[], activeTabPath: string | null): { : null } +function isUntitledRenameCandidate(path: string): boolean { + const filename = path.split('/').pop() ?? '' + const stem = filename.replace(/\.md$/, '') + return stem.startsWith('untitled-') && /\d+$/.test(stem) +} + +function shouldScheduleUntitledRename(path: string, content: string): boolean { + return isTauri() + && isUntitledRenameCandidate(path) + && extractH1TitleFromContent(content) !== null +} + +function matchingPendingRename( + 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, +): PendingUntitledRename | null { + const pending = matchingPendingRename(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, +): void { + takePendingRename(pendingRenameRef) + const timer = setTimeout(() => { + 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, +): string | null { + const pending = pendingRenameRef.current + if (!pending || pending.path === activeTabPath) return null + return pending.path +} + +async function reloadAutoRenamedNote( + oldPath: string, + newPath: string, + 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 }), + ]) + replaceEntry(oldPath, { ...newEntry, path: newPath }, newContent) + loadModifiedFiles() +} + interface AppSaveDeps { updateEntry: (path: string, patch: Partial) => void setTabs: Parameters[0]['setTabs'] @@ -49,41 +125,63 @@ export function useAppSave({ handleRenameNote, replaceEntry, resolvedPath, }: AppSaveDeps) { const contentChangeRef = useRef<(path: string, content: string) => void>(() => {}) + const pendingUntitledRenameRef = useRef(null) const onAfterSave = useCallback(() => { loadModifiedFiles() }, [loadModifiedFiles]) - const onNotePersisted = useCallback((path: string) => { - clearUnsaved(path) - if (path.endsWith('.yml')) reloadViews?.() - // Auto-rename untitled notes when they have an H1 heading - const filename = path.split('/').pop() ?? '' - const stem = filename.replace(/\.md$/, '') - if (isTauri() && stem.startsWith('untitled-') && /\d+$/.test(stem)) { - invoke<{ new_path: string; updated_files: number } | null>('auto_rename_untitled', { + const cancelPendingUntitledRename = useCallback((path?: string) => ( + takePendingRename(pendingUntitledRenameRef, path) !== null + ), []) + + const executeUntitledRename = useCallback(async (path: string) => { + try { + const result = await invoke<{ new_path: string; updated_files: number } | null>('auto_rename_untitled', { vaultPath: resolvedPath, notePath: path, - }).then((result) => { - if (result) { - // Re-read the renamed file content and entry, then replace - Promise.all([ - invoke('reload_vault_entry', { path: result.new_path }), - invoke('get_note_content', { path: result.new_path }), - ]).then(([newEntry, newContent]) => { - replaceEntry(path, { ...newEntry, path: result.new_path }, newContent) - loadModifiedFiles() - }).catch(() => { /* ignore reload failure */ }) - } - }).catch(() => { /* auto-rename is best-effort */ }) + }) + if (!result) return false + await reloadAutoRenamedNote(path, result.new_path, replaceEntry, loadModifiedFiles) + return true + } catch { + return false } - }, [clearUnsaved, reloadViews, resolvedPath, replaceEntry, loadModifiedFiles]) + }, [resolvedPath, replaceEntry, loadModifiedFiles]) + + const flushPendingUntitledRename = useCallback(async (path?: string) => { + const pending = takePendingRename(pendingUntitledRenameRef, path) + if (!pending) return false + return executeUntitledRename(pending.path) + }, [executeUntitledRename]) + + const scheduleUntitledRename = useCallback((path: string, content: string) => { + if (!shouldScheduleUntitledRename(path, content)) { + cancelPendingUntitledRename(path) + return + } + + schedulePendingRename(pendingUntitledRenameRef, path, (pendingPath) => { + void executeUntitledRename(pendingPath) + }) + }, [cancelPendingUntitledRename, executeUntitledRename]) + + const onNotePersisted = useCallback((path: string, content: string) => { + clearUnsaved(path) + if (path.endsWith('.yml')) reloadViews?.() + scheduleUntitledRename(path, content) + }, [clearUnsaved, reloadViews, scheduleUntitledRename]) const { handleSave: handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({ updateEntry, setTabs, setToastMessage, onAfterSave, onNotePersisted, }) useEffect(() => { contentChangeRef.current = handleContentChange }, [handleContentChange]) + useEffect(() => () => { cancelPendingUntitledRename() }, [cancelPendingUntitledRename]) + useEffect(() => { + const pendingPath = pendingRenameOutsideActiveTab(pendingUntitledRenameRef, activeTabPath) + if (pendingPath) cancelPendingUntitledRename(pendingPath) + }, [activeTabPath, cancelPendingUntitledRename]) // Refs for stable closure in flushBeforeAction const tabsRef = useRef(tabs) @@ -99,29 +197,33 @@ export function useAppSave({ isUnsaved: (p) => unsavedPathsRef.current.has(p), onSaved: (p) => { clearUnsaved(p) }, }) + await flushPendingUntitledRename(path) } catch (err) { setToastMessage(`Auto-save failed: ${err}`) throw err } - }, [savePendingForPath, clearUnsaved, setToastMessage]) + }, [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]) + }, [handleRenameNote, resolvedPath, replaceEntry, savePendingForPath, loadModifiedFiles, cancelPendingUntitledRename]) const handleSave = useCallback(async () => { await handleSaveRaw(findUnsavedFallback(tabs, activeTabPath, unsavedPaths)) + const flushedUntitledRename = await flushPendingUntitledRename(activeTabPath ?? undefined) const rename = activeTabNeedsRename(tabs, activeTabPath) - if (rename) await handleRenameTab(rename.path, rename.title) - }, [handleSaveRaw, handleRenameTab, tabs, activeTabPath, unsavedPaths]) + if (!flushedUntitledRename && rename) await handleRenameTab(rename.path, rename.title) + }, [handleSaveRaw, handleRenameTab, tabs, activeTabPath, unsavedPaths, flushPendingUntitledRename]) const handleTitleSync = useCallback((path: string, newTitle: string) => { + cancelPendingUntitledRename(path) savePendingForPath(path) .then(() => handleRenameNote(path, newTitle, resolvedPath, replaceEntry)) .then(loadModifiedFiles) .catch((err) => console.error('Title rename failed:', err)) - }, [handleRenameNote, resolvedPath, replaceEntry, savePendingForPath, loadModifiedFiles]) + }, [handleRenameNote, resolvedPath, replaceEntry, savePendingForPath, loadModifiedFiles, cancelPendingUntitledRename]) return { contentChangeRef,