fix: debounce untitled H1 filename sync

This commit is contained in:
lucaronin
2026-04-10 17:18:48 +02:00
parent 1e36cd2855
commit c8db92c92d
2 changed files with 221 additions and 29 deletions

View File

@@ -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<string, unknown>) => {
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())
})
})

View File

@@ -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<typeof setTimeout>
}
function findUnsavedFallback(
tabs: TabState[], activeTabPath: string | null, unsavedPaths: Set<string>,
): { 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<PendingUntitledRename | null>,
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<PendingUntitledRename | null>,
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<PendingUntitledRename | null>,
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<void> {
const [newEntry, newContent] = await Promise.all([
invoke<VaultEntry>('reload_vault_entry', { path: newPath }),
invoke<string>('get_note_content', { path: newPath }),
])
replaceEntry(oldPath, { ...newEntry, path: newPath }, newContent)
loadModifiedFiles()
}
interface AppSaveDeps {
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
setTabs: Parameters<typeof useEditorSaveWithLinks>[0]['setTabs']
@@ -49,41 +125,63 @@ export function useAppSave({
handleRenameNote, replaceEntry, resolvedPath,
}: AppSaveDeps) {
const contentChangeRef = useRef<(path: string, content: string) => void>(() => {})
const pendingUntitledRenameRef = useRef<PendingUntitledRename | null>(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<VaultEntry>('reload_vault_entry', { path: result.new_path }),
invoke<string>('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,