fix: harden untitled h1 auto-rename flow

This commit is contained in:
lucaronin
2026-04-11 15:22:34 +02:00
parent 0c365eb7dd
commit dbf54657f0
12 changed files with 564 additions and 15 deletions

View File

@@ -280,7 +280,7 @@ function App() {
})
const appSave = useAppSave({
updateEntry: vault.updateEntry, setTabs: notes.setTabs, setToastMessage,
updateEntry: vault.updateEntry, setTabs: notes.setTabs, handleSwitchTab: notes.handleSwitchTab, setToastMessage,
loadModifiedFiles: vault.loadModifiedFiles, reloadViews: vault.reloadViews,
clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths,
tabs: notes.tabs, activeTabPath: notes.activeTabPath,

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import type { SetStateAction } from 'react'
import { useAppSave } from './useAppSave'
import type { VaultEntry } from '../types'
import { isTauri } from '../mock-tauri'
@@ -22,6 +23,7 @@ describe('useAppSave', () => {
const deps = {
updateEntry: vi.fn(),
setTabs: vi.fn(),
handleSwitchTab: vi.fn(),
setToastMessage: vi.fn(),
loadModifiedFiles: vi.fn(),
clearUnsaved: vi.fn(),
@@ -147,6 +149,44 @@ describe('useAppSave', () => {
)
})
it('switches the active tab to the renamed path after untitled H1 auto-rename', 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<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 '# Fresh Title\n\nBody'
return undefined
})
const { result } = renderSave({
setTabs,
tabs: tabsState,
activeTabPath: oldPath,
unsavedPaths: new Set([oldPath]),
})
await act(async () => {
result.current.handleContentChange(oldPath, '# Fresh Title\n\nBody')
await vi.advanceTimersByTimeAsync(3_000)
})
expect(deps.handleSwitchTab).toHaveBeenCalledWith(newPath)
expect(tabsState[0].entry.path).toBe(newPath)
expect(tabsState[0].entry.filename).toBe('fresh-title.md')
expect(tabsState[0].content).toBe('# Fresh Title\n\nBody')
})
it('cancels a pending untitled auto-rename when the user navigates away', async () => {
vi.useFakeTimers()
vi.mocked(isTauri).mockReturnValue(true)

View File

@@ -92,6 +92,10 @@ 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'],
): Promise<void> {
@@ -99,13 +103,31 @@ async function reloadAutoRenamedNote(
invoke<VaultEntry>('reload_vault_entry', { path: newPath }),
invoke<string>('get_note_content', { path: newPath }),
])
const otherTabPaths = tabs
.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 }
: tab
)))
if (activeTabPath === oldPath) handleSwitchTab(newPath)
replaceEntry(oldPath, { ...newEntry, path: newPath }, newContent)
await Promise.all(otherTabPaths.map(async (path) => {
const content = await invoke<string>('get_note_content', { path })
setTabs((prev: TabState[]) => prev.map((tab) => (
tab.entry.path === path ? { ...tab, content } : tab
)))
}))
loadModifiedFiles()
}
interface AppSaveDeps {
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
setTabs: Parameters<typeof useEditorSaveWithLinks>[0]['setTabs']
handleSwitchTab: (path: string) => void
setToastMessage: (msg: string | null) => void
loadModifiedFiles: () => void
reloadViews?: () => Promise<void>
@@ -119,7 +141,7 @@ interface AppSaveDeps {
}
export function useAppSave({
updateEntry, setTabs, setToastMessage,
updateEntry, setTabs, handleSwitchTab, setToastMessage,
loadModifiedFiles, reloadViews, clearUnsaved, unsavedPaths,
tabs, activeTabPath,
handleRenameNote, replaceEntry, resolvedPath,
@@ -142,12 +164,21 @@ export function useAppSave({
notePath: path,
})
if (!result) return false
await reloadAutoRenamedNote(path, result.new_path, replaceEntry, loadModifiedFiles)
await reloadAutoRenamedNote(
path,
result.new_path,
tabs,
activeTabPath,
setTabs,
handleSwitchTab,
replaceEntry,
loadModifiedFiles,
)
return true
} catch {
return false
}
}, [resolvedPath, replaceEntry, loadModifiedFiles])
}, [resolvedPath, tabs, activeTabPath, setTabs, handleSwitchTab, replaceEntry, loadModifiedFiles])
const flushPendingUntitledRename = useCallback(async (path?: string) => {
const pending = takePendingRename(pendingUntitledRenameRef, path)

View File

@@ -48,6 +48,41 @@ describe('useEditorFocus', () => {
vi.useRealTimers()
})
it('waits for the matching tab swap event when a target path is provided', () => {
const rAF = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
vi.spyOn(window, 'setTimeout')
const { editor } = setup(true)
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { path: '/vault/new-note.md' } }))
expect(editor.focus).not.toHaveBeenCalled()
expect(rAF).not.toHaveBeenCalled()
window.dispatchEvent(new CustomEvent('laputa:editor-tab-swapped', { detail: { path: '/vault/other.md' } }))
expect(editor.focus).not.toHaveBeenCalled()
window.dispatchEvent(new CustomEvent('laputa:editor-tab-swapped', { detail: { path: '/vault/new-note.md' } }))
expect(rAF).toHaveBeenCalled()
expect(editor.focus).toHaveBeenCalled()
})
it('falls back to focusing when the swap event never arrives', () => {
vi.useFakeTimers()
const rAF = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const { editor } = setup(true)
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { path: '/vault/new-note.md' } }))
expect(editor.focus).not.toHaveBeenCalled()
vi.advanceTimersByTime(249)
expect(editor.focus).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(rAF).toHaveBeenCalled()
expect(editor.focus).toHaveBeenCalled()
vi.useRealTimers()
})
it('cleans up event listener on unmount', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal mock for test
const editor = { focus: vi.fn() } as any

View File

@@ -1,5 +1,9 @@
import { useEffect } from 'react'
const TAB_SWAP_EVENT_NAME = 'laputa:editor-tab-swapped'
const FOCUS_EVENT_NAME = 'laputa:focus-editor'
const SWAP_WAIT_FALLBACK_MS = 250
interface TiptapChain {
setTextSelection: (pos: { from: number; to: number }) => TiptapChain
run: () => void
@@ -42,10 +46,13 @@ export function useEditorFocus(
editorMountedRef: React.RefObject<boolean>,
) {
useEffect(() => {
const pendingCleanups = new Set<() => void>()
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail as { t0?: number; selectTitle?: boolean } | undefined
const detail = (e as CustomEvent).detail as { t0?: number; selectTitle?: boolean; path?: string | null } | undefined
const t0 = detail?.t0
const selectTitle = detail?.selectTitle ?? false
const targetPath = detail?.path ?? null
const doFocus = () => {
editor.focus()
if (!selectTitle) {
@@ -63,13 +70,47 @@ export function useEditorFocus(
if (t0) console.debug(`[perf] createNote → focus+select: ${(performance.now() - t0).toFixed(1)}ms`)
})
}
if (editorMountedRef.current) {
requestAnimationFrame(doFocus)
} else {
const scheduleFocus = () => {
if (editorMountedRef.current) {
requestAnimationFrame(doFocus)
return
}
setTimeout(doFocus, 80)
}
if (!targetPath) {
scheduleFocus()
return
}
const handleTabSwap = (event: Event) => {
const swapPath = (event as CustomEvent).detail?.path
if (swapPath !== targetPath) return
cleanupPending()
scheduleFocus()
}
const fallbackTimer = window.setTimeout(() => {
cleanupPending()
scheduleFocus()
}, SWAP_WAIT_FALLBACK_MS)
const cleanupPending = () => {
window.clearTimeout(fallbackTimer)
window.removeEventListener(TAB_SWAP_EVENT_NAME, handleTabSwap)
pendingCleanups.delete(cleanupPending)
}
pendingCleanups.add(cleanupPending)
window.addEventListener(TAB_SWAP_EVENT_NAME, handleTabSwap)
}
window.addEventListener(FOCUS_EVENT_NAME, handler)
return () => {
window.removeEventListener(FOCUS_EVENT_NAME, handler)
pendingCleanups.forEach((cleanup) => cleanup())
pendingCleanups.clear()
}
window.addEventListener('laputa:focus-editor', handler)
return () => window.removeEventListener('laputa:focus-editor', handler)
}, [editor, editorMountedRef])
}

View File

@@ -167,6 +167,13 @@ function makeTab(path: string, title: string) {
}
}
function makeUntitledTab(path: string, title = 'Untitled Note 1') {
return {
entry: { path, title, filename: `${title}.md`, type: 'Note', status: 'Active', aliases: [], isA: '' } as never,
content: '---\ntype: Note\nstatus: Active\n---\n',
}
}
function makeMockEditor(docRef: { current: unknown[] }) {
return {
document: docRef.current,
@@ -220,6 +227,69 @@ describe('useEditorTabSwap raw mode sync', () => {
expect(mockEditor.replaceBlocks).toHaveBeenCalled()
})
it('signals when the target tab content has been applied', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const swapListener = vi.fn()
window.addEventListener('laputa:editor-tab-swapped', swapListener)
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
const tabA = makeTab('a.md', 'Note A')
const tabB = makeTab('b.md', 'March 2024')
const { rerender } = renderHook(
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
tabs, activeTabPath, editor: mockEditor as never, rawMode,
}),
{ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false as boolean } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
swapListener.mockClear()
rerender({ tabs: [tabB], activeTabPath: 'b.md', rawMode: false })
await act(() => new Promise(r => setTimeout(r, 0)))
expect(swapListener).toHaveBeenCalledTimes(1)
const event = swapListener.mock.calls[0][0] as CustomEvent
expect(event.detail.path).toBe('b.md')
window.removeEventListener('laputa:editor-tab-swapped', swapListener)
})
it('hard-resets the editor when the target note body is blank', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
const populatedTab = makeTab('a.md', 'Note A')
const untitledTab = makeUntitledTab('untitled.md')
const { rerender } = renderHook(
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
tabs, activeTabPath, editor: mockEditor as never, rawMode,
}),
{ initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false as boolean } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
mockEditor._tiptapEditor.commands.setContent.mockClear()
mockEditor.replaceBlocks.mockClear()
rerender({ tabs: [untitledTab], activeTabPath: 'untitled.md', rawMode: false })
await act(() => new Promise(r => setTimeout(r, 0)))
expect(mockEditor._tiptapEditor.commands.setContent).toHaveBeenCalledWith('<p></p>')
expect(mockEditor.replaceBlocks).not.toHaveBeenCalled()
})
it('re-parses from tab.content when rawMode transitions from true to false', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })

View File

@@ -22,6 +22,12 @@ interface UseEditorTabSwapOptions {
rawMode?: boolean
}
function signalEditorTabSwapped(path: string): void {
window.dispatchEvent(new CustomEvent('laputa:editor-tab-swapped', {
detail: { path },
}))
}
/** Strip the YAML frontmatter from raw file content, returning the body
* (including any H1 heading) that should appear in the editor. */
export function extractEditorBody(rawFileContent: string): string {
@@ -83,6 +89,14 @@ function buildFastPathBlocks(preprocessed: string): EditorBlocks | null {
]
}
function isBlankBodyContent(content: string): boolean {
return extractEditorBody(content).trim() === ''
}
function blankParagraphBlocks(): EditorBlocks {
return [{ type: 'paragraph', content: [], children: [] }]
}
async function parseMarkdownBlocks(
editor: ReturnType<typeof useCreateBlockNote>,
preprocessed: string,
@@ -153,6 +167,26 @@ function applyBlocksToEditor(
})
}
function applyBlankStateToEditor(
editor: ReturnType<typeof useCreateBlockNote>,
suppressChangeRef: MutableRefObject<boolean>,
) {
suppressChangeRef.current = true
try {
editor._tiptapEditor.commands.setContent('<p></p>')
} catch (err) {
console.error('applyBlankStateToEditor failed, falling back to replaceBlocks:', err)
applyBlocksToEditor(editor, blankParagraphBlocks(), 0, suppressChangeRef)
return
}
queueMicrotask(() => { suppressChangeRef.current = false })
requestAnimationFrame(() => {
const scrollEl = document.querySelector('.editor__blocknote-container')
if (scrollEl) scrollEl.scrollTop = 0
})
}
function findActiveTab(tabs: Tab[], activeTabPath: string | null): Tab | undefined {
return activeTabPath
? tabs.find(tab => tab.entry.path === activeTabPath)
@@ -319,10 +353,19 @@ function scheduleTabSwap(options: {
const doSwap = () => {
if (prevActivePathRef.current !== targetPath) return
rawSwapPendingRef.current = false
if (isBlankBodyContent(activeTab.content)) {
cache.set(targetPath, { blocks: blankParagraphBlocks(), scrollTop: 0 })
applyBlankStateToEditor(editor, suppressChangeRef)
requestAnimationFrame(() => signalEditorTabSwapped(targetPath))
return
}
void resolveBlocksForTarget(editor, cache, targetPath, activeTab.content)
.then(({ blocks, scrollTop }) => {
if (prevActivePathRef.current !== targetPath) return
applyBlocksToEditor(editor, blocks, scrollTop, suppressChangeRef)
requestAnimationFrame(() => signalEditorTabSwapped(targetPath))
})
.catch((err: unknown) => {
console.error('Failed to parse/swap editor content:', err)

View File

@@ -342,6 +342,20 @@ describe('useNoteCreation hook', () => {
expect(markContentPending).toHaveBeenCalled()
})
it('handleCreateNoteImmediate requests editor focus for the new path', () => {
const focusListener = vi.fn()
window.addEventListener('laputa:focus-editor', focusListener)
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => { result.current.handleCreateNoteImmediate() })
expect(focusListener).toHaveBeenCalledTimes(1)
const event = focusListener.mock.calls[0][0] as CustomEvent
expect(event.detail.path).toMatch(/\/test\/vault\/untitled-note-\d+\.md$/)
window.removeEventListener('laputa:focus-editor', focusListener)
})
it('handleOpenDailyNote creates new daily note when none exists', () => {
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => { result.current.handleOpenDailyNote() })
@@ -358,6 +372,21 @@ describe('useNoteCreation hook', () => {
expect(handleSelectNote).toHaveBeenCalledWith(existing)
})
it('handleOpenDailyNote requests focus for the daily note path', () => {
const focusListener = vi.fn()
window.addEventListener('laputa:focus-editor', focusListener)
const today = todayDateString()
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => { result.current.handleOpenDailyNote() })
expect(focusListener).toHaveBeenCalledTimes(1)
const event = focusListener.mock.calls[0][0] as CustomEvent
expect(event.detail.path).toBe(`/test/vault/${today}.md`)
window.removeEventListener('laputa:focus-editor', focusListener)
})
it('handleCreateType creates type entry', () => {
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => { result.current.handleCreateType('Recipe') })

View File

@@ -161,9 +161,9 @@ function addEntryWithMock(entry: VaultEntry, content: string, addEntry: (e: Vaul
}
/** Dispatch focus-editor event with perf timing marker. */
function signalFocusEditor(opts?: { selectTitle?: boolean }): void {
function signalFocusEditor(opts?: { selectTitle?: boolean; path?: string }): void {
window.dispatchEvent(new CustomEvent('laputa:focus-editor', {
detail: { t0: performance.now(), selectTitle: opts?.selectTitle ?? false },
detail: { t0: performance.now(), selectTitle: opts?.selectTitle ?? false, path: opts?.path ?? null },
}))
}
@@ -200,9 +200,10 @@ function createAndPersist(
function openDailyNote(entries: VaultEntry[], selectNote: (e: VaultEntry) => void, persist: PersistFn, vaultPath: string): void {
const date = todayDateString()
const existing = findDailyNote(entries, date)
const targetPath = existing?.path ?? `${vaultPath}/${date}.md`
if (existing) selectNote(existing)
else persist(resolveDailyNote(date, vaultPath))
signalFocusEditor()
signalFocusEditor({ path: targetPath })
}
interface ImmediateCreateDeps {
@@ -250,7 +251,7 @@ function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): void {
addEntryWithMock(entry, content, deps.addEntry)
deps.trackUnsaved?.(entry.path)
deps.markContentPending?.(entry.path, content)
signalFocusEditor()
signalFocusEditor({ path: entry.path })
}
interface RelationshipCreateDeps {