fix: keep new note title rename current
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.'
|
||||
|
||||
Reference in New Issue
Block a user