fix: keep new note title rename current

This commit is contained in:
lucaronin
2026-05-08 11:52:50 +02:00
parent 6b7de99f78
commit 4870d2a05f
3 changed files with 103 additions and 11 deletions

View File

@@ -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)

View File

@@ -17,6 +17,7 @@ const UNTITLED_RENAME_DEBOUNCE_MS = 2500
interface PendingUntitledRename {
path: string
title: string
timer: ReturnType<typeof setTimeout>
}
@@ -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<PendingUntitledRename | null>
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<string>
@@ -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,

View File

@@ -125,6 +125,12 @@ async function activeSelectionBlockType(page: Page): Promise<string | null> {
})
}
async function expectTitleHeadingText(page: Page, title: string): Promise<void> {
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.'