From 4870d2a05ff240b2ac20daaf4f9aeb13a6abfb38 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Fri, 8 May 2026 11:52:50 +0200 Subject: [PATCH] fix: keep new note title rename current --- src/hooks/useAppSave.test.ts | 36 +++++++++++++++++++ src/hooks/useAppSave.ts | 39 +++++++++++++++------ tests/smoke/h1-untitled-auto-rename.spec.ts | 39 +++++++++++++++++++++ 3 files changed, 103 insertions(+), 11 deletions(-) diff --git a/src/hooks/useAppSave.test.ts b/src/hooks/useAppSave.test.ts index 396b9d9b..16c3a8cf 100644 --- a/src/hooks/useAppSave.test.ts +++ b/src/hooks/useAppSave.test.ts @@ -416,6 +416,42 @@ describe('useAppSave', () => { ) }) + it('refreshes a pending untitled auto-rename when the H1 title changes before the timer fires', async () => { + const partialTitleContent = '# Obsi\n' + const revisedTitleContent = '# Obsidian\n\nBody starts after the title is complete' + const { result, oldPath } = setupUntitledRenameHarness({ + initialContent: partialTitleContent, + diskContent: revisedTitleContent, + }) + + await act(async () => { + result.current.handleContentChange(oldPath, partialTitleContent) + await vi.advanceTimersByTimeAsync(AUTO_SAVE_DEBOUNCE_MS) + }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(2_300) + result.current.handleContentChange(oldPath, revisedTitleContent) + await vi.advanceTimersByTimeAsync(200) + }) + + expect(vi.mocked(invoke).mock.calls.filter(([command]) => command === 'auto_rename_untitled')).toHaveLength(0) + + await act(async () => { + await vi.advanceTimersByTimeAsync(AUTO_SAVE_DEBOUNCE_MS) + }) + expect(vi.mocked(invoke)).toHaveBeenCalledWith('save_note_content', { + path: oldPath, + content: revisedTitleContent, + }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(2_000) + }) + + expect(vi.mocked(invoke).mock.calls.filter(([command]) => command === 'auto_rename_untitled')).toHaveLength(1) + }) + it('does not auto-rename untitled notes when the H1 auto-rename preference is disabled', async () => { vi.useFakeTimers() vi.mocked(isTauri).mockReturnValue(true) diff --git a/src/hooks/useAppSave.ts b/src/hooks/useAppSave.ts index 8821f8ef..d5e21676 100644 --- a/src/hooks/useAppSave.ts +++ b/src/hooks/useAppSave.ts @@ -17,6 +17,7 @@ const UNTITLED_RENAME_DEBOUNCE_MS = 2500 interface PendingUntitledRename { path: string + title: string timer: ReturnType } @@ -84,7 +85,7 @@ function isUntitledRenameCandidate(path: string): boolean { return stem.startsWith('untitled-') && /\d+$/.test(stem) } -function shouldScheduleUntitledRename({ +function schedulableUntitledRenameTitle({ path, content, initialH1AutoRenameEnabled, @@ -92,11 +93,9 @@ function shouldScheduleUntitledRename({ path: string content: string initialH1AutoRenameEnabled: boolean -}): boolean { - return isTauri() - && initialH1AutoRenameEnabled - && isUntitledRenameCandidate(path) - && extractH1TitleFromContent(content) !== null +}): string | null { + if (!isTauri() || !initialH1AutoRenameEnabled || !isUntitledRenameCandidate(path)) return null + return extractH1TitleFromContent(content) } function matchingPendingRename({ @@ -130,19 +129,23 @@ function takePendingRename({ function schedulePendingRename({ pendingRenameRef, path, + title, onFire, }: { pendingRenameRef: MutableRefObject path: string + title: string onFire: (path: string) => void }, ): void { + const currentPending = pendingRenameRef.current + if (currentPending?.path === path && currentPending.title === title) return takePendingRename({ pendingRenameRef }) const timer = setTimeout(() => { const pending = takePendingRename({ pendingRenameRef, path }) if (pending) onFire(pending.path) }, UNTITLED_RENAME_DEBOUNCE_MS) - pendingRenameRef.current = { path, timer } + pendingRenameRef.current = { path, title, timer } } function pendingRenameOutsideActiveTab({ @@ -337,7 +340,8 @@ function useUntitledRenameScheduler({ }, [executeUntitledRename]) const scheduleUntitledRename = useCallback((path: string, content: string) => { - if (!shouldScheduleUntitledRename({ path, content, initialH1AutoRenameEnabled })) { + const title = schedulableUntitledRenameTitle({ path, content, initialH1AutoRenameEnabled }) + if (!title) { cancelPendingUntitledRename(path) return } @@ -345,16 +349,23 @@ function useUntitledRenameScheduler({ schedulePendingRename({ pendingRenameRef: pendingUntitledRenameRef, path, + title, onFire: (pendingPath) => { void executeUntitledRename(pendingPath) }, }) }, [cancelPendingUntitledRename, executeUntitledRename, initialH1AutoRenameEnabled]) + const refreshPendingUntitledRename = useCallback((path: string, content: string) => { + if (!matchingPendingRename({ pending: pendingUntitledRenameRef.current, path })) return + scheduleUntitledRename(path, content) + }, [scheduleUntitledRename]) + return { pendingUntitledRenameRef, cancelPendingUntitledRename, flushPendingUntitledRename, + refreshPendingUntitledRename, scheduleUntitledRename, } } @@ -403,6 +414,7 @@ function useUntitledRenameCoordinator({ pendingUntitledRenameRef, cancelPendingUntitledRename, flushPendingUntitledRename, + refreshPendingUntitledRename, scheduleUntitledRename, } = useUntitledRenameScheduler({ executeUntitledRename, initialH1AutoRenameEnabled }) @@ -413,6 +425,7 @@ function useUntitledRenameCoordinator({ resolveCurrentPath, resolvePathBeforeSave, flushPendingUntitledRename, + refreshPendingUntitledRename, scheduleUntitledRename, } } @@ -448,6 +461,7 @@ interface EditorPersistenceOptions { clearUnsaved: AppSaveDeps['clearUnsaved'] onInternalVaultWrite?: AppSaveDeps['onInternalVaultWrite'] reloadViews: AppSaveDeps['reloadViews'] + refreshPendingUntitledRename: (path: string, content: string) => void scheduleUntitledRename: (path: string, content: string) => void resolveCurrentPath: (path: string) => string resolvePathBeforeSave: (path: string) => Promise @@ -634,6 +648,7 @@ function useEditorPersistence({ clearUnsaved, onInternalVaultWrite, reloadViews, + refreshPendingUntitledRename, scheduleUntitledRename, resolveCurrentPath, resolvePathBeforeSave, @@ -673,9 +688,10 @@ function useEditorPersistence({ const handleContentChange = useCallback((path: string, content: string) => { const currentPath = resolveCurrentPath(path) if (!canWritePathToVault(currentPath, persistenceScope)) return + refreshPendingUntitledRename(currentPath, content) trackUnsaved?.(currentPath) handleContentChangeRaw(currentPath, content) - }, [handleContentChangeRaw, persistenceScope, resolveCurrentPath, trackUnsaved]) + }, [handleContentChangeRaw, persistenceScope, refreshPendingUntitledRename, resolveCurrentPath, trackUnsaved]) const savePendingForPath = useCallback((path: string) => { const currentPath = resolveCurrentPath(path) @@ -800,14 +816,15 @@ export function useAppSave({ const { tabsRef, activeTabPathRef, unsavedPathsRef } = useAppSaveStateRefs({ tabs, activeTabPath, unsavedPaths }) const { pendingUntitledRenameRef, cancelPendingUntitledRename, registerRenamedPath, - resolveCurrentPath, resolvePathBeforeSave, flushPendingUntitledRename, scheduleUntitledRename, + resolveCurrentPath, resolvePathBeforeSave, flushPendingUntitledRename, + refreshPendingUntitledRename, scheduleUntitledRename, } = useUntitledRenameCoordinator({ resolvedPath, tabsRef, activeTabPathRef, setTabs, handleSwitchTab, replaceEntry, loadModifiedFiles, onInternalVaultWrite, initialH1AutoRenameEnabled, }) const { handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorPersistence({ updateEntry, setTabs, setToastMessage, loadModifiedFiles, trackUnsaved, - clearUnsaved, onInternalVaultWrite, reloadViews, scheduleUntitledRename, + clearUnsaved, onInternalVaultWrite, reloadViews, refreshPendingUntitledRename, scheduleUntitledRename, resolveCurrentPath, resolvePathBeforeSave, canPersist, persistenceScope: writableVaultPaths && writableVaultPaths.length > 0 ? writableVaultPaths : resolvedPath, locale, diff --git a/tests/smoke/h1-untitled-auto-rename.spec.ts b/tests/smoke/h1-untitled-auto-rename.spec.ts index ae157f6f..51179728 100644 --- a/tests/smoke/h1-untitled-auto-rename.spec.ts +++ b/tests/smoke/h1-untitled-auto-rename.spec.ts @@ -125,6 +125,12 @@ async function activeSelectionBlockType(page: Page): Promise { }) } +async function expectTitleHeadingText(page: Page, title: string): Promise { + await expect(page.locator('.bn-editor [data-content-type="heading"]').first()).toContainText(title, { + timeout: 5_000, + }) +} + let tempVaultDir: string test.beforeEach(async ({ page }, testInfo) => { @@ -189,6 +195,39 @@ test('@smoke new-note H1 auto-rename keeps the editor usable and leaves no untit expect(errors).toEqual([]) }) +test('@smoke new-note short title typing stays in the H1 until Enter', async ({ page }) => { + const titleStart = 'Obsi' + const title = 'Obsidian' + const bodyText = 'Body starts only after intentional Enter.' + + await createUntitledNote(page) + await page.keyboard.type(titleStart, { delay: 80 }) + await expectTitleHeadingText(page, titleStart) + await expectEditorFocused(page) + await expect.poll(() => activeSelectionBlockType(page), { timeout: 5_000 }).toBe('heading') + + await page.waitForTimeout(1_000) + await expectTitleHeadingText(page, titleStart) + await expect.poll(() => activeSelectionBlockType(page), { timeout: 5_000 }).toBe('heading') + + await page.keyboard.type('dian', { delay: 80 }) + await expectTitleHeadingText(page, title) + await page.keyboard.press('Enter') + await page.keyboard.type(bodyText, { delay: 35 }) + + await expectActiveFilename(page, slugifyTitle(title)) + await expectFileContentContains({ + vaultPath: tempVaultDir, + filename: `${slugifyTitle(title)}.md`, + text: `# ${title}`, + }) + await expectFileContentContains({ + vaultPath: tempVaultDir, + filename: `${slugifyTitle(title)}.md`, + text: bodyText, + }) +}) + test('@smoke new-note typing stays focused through initial save settlement', async ({ page }) => { const title = 'Creation Focus Guard' const bodyText = 'Body keeps accepting text while creation writes and saves settle.'