diff --git a/src/App.tsx b/src/App.tsx index 08eccaa5..e7f85794 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -374,7 +374,10 @@ function App() { }, [resolvedPath, setToastMessage]) const vault = useVaultLoader(noteWindowParams ? '' : resolvedPath) - const recentVaultWrites = useRecentVaultWrites({ vaultPath: noteWindowParams ? '' : resolvedPath }) + const { + markInternalWrite: markRecentVaultWrite, + filterExternalPaths: filterExternalVaultPaths, + } = useRecentVaultWrites({ vaultPath: noteWindowParams ? '' : resolvedPath }) const { status: vaultAiGuidanceStatus, refresh: refreshVaultAiGuidance, @@ -562,6 +565,14 @@ function App() { flushPendingRawContentRef.current?.(path) await appSave.flushBeforeAction(path) } + const handleCreatedVaultEntryPersisting = useCallback((path: string) => { + markRecentVaultWrite(path) + vault.addPendingSave(path) + }, [markRecentVaultWrite, vault]) + const handleCreatedVaultEntryPersisted = useCallback((path: string) => { + markRecentVaultWrite(path) + vault.loadModifiedFiles() + }, [markRecentVaultWrite, vault]) const notes = useNoteActions({ addEntry: vault.addEntry, @@ -573,13 +584,13 @@ function App() { setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, - addPendingSave: vault.addPendingSave, + addPendingSave: handleCreatedVaultEntryPersisting, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => appSave.contentChangeRef.current(path, content), - onNewNotePersisted: vault.loadModifiedFiles, + onNewNotePersisted: handleCreatedVaultEntryPersisted, onTypeStateChanged: async () => { await vault.reloadVault() }, replaceEntry: vault.replaceEntry, onFrontmatterPersisted: vault.loadModifiedFiles, @@ -620,7 +631,7 @@ function App() { useVaultWatcher({ vaultPath: noteWindowParams ? '' : resolvedPath, onVaultChanged: handlePulledVaultUpdate, - filterChangedPaths: recentVaultWrites.filterExternalPaths, + filterChangedPaths: filterExternalVaultPaths, }) const autoSync = useAutoSync({ enabled: gitRepoState === 'ready', @@ -773,7 +784,7 @@ function App() { handleRenameNote: notes.handleRenameNote, handleRenameFilename: notes.handleRenameFilename, replaceEntry: vault.replaceEntry, resolvedPath, initialH1AutoRenameEnabled: settings.initial_h1_auto_rename_enabled !== false, - onInternalVaultWrite: recentVaultWrites.markInternalWrite, + onInternalVaultWrite: markRecentVaultWrite, locale: appLocale, }) diff --git a/src/hooks/useAppSave.test.ts b/src/hooks/useAppSave.test.ts index de0669bf..a1634043 100644 --- a/src/hooks/useAppSave.test.ts +++ b/src/hooks/useAppSave.test.ts @@ -38,6 +38,7 @@ describe('useAppSave', () => { handleSwitchTab: vi.fn(), setToastMessage: vi.fn(), loadModifiedFiles: vi.fn(), + onInternalVaultWrite: vi.fn(), trackUnsaved: vi.fn(), clearUnsaved: vi.fn(), unsavedPaths: new Set(), @@ -58,6 +59,7 @@ describe('useAppSave', () => { deps.tabs = [] deps.activeTabPath = null deps.trackUnsaved.mockReset() + deps.onInternalVaultWrite.mockReset() deps.handleRenameNote.mockResolvedValue(undefined) deps.handleRenameFilename.mockResolvedValue(undefined) deps.initialH1AutoRenameEnabled = true @@ -581,6 +583,18 @@ describe('useAppSave', () => { expect(getTabs()[0].content).toBe('# Fresh Title\n\nBody that keeps changing while rename is pending') }) + it('marks untitled auto-rename paths as internal vault writes', async () => { + const { result, oldPath, newPath } = setupUntitledRenameHarness() + + await act(async () => { + result.current.handleContentChange(oldPath, '# Fresh Title\n\nBody') + await vi.advanceTimersByTimeAsync(3_000) + }) + + expect(deps.onInternalVaultWrite).toHaveBeenCalledWith(oldPath) + expect(deps.onInternalVaultWrite).toHaveBeenCalledWith(newPath) + }) + it('does not run markdown title-sync renames for non-markdown text files', async () => { vi.mocked(isTauri).mockReturnValue(true) diff --git a/src/hooks/useAppSave.ts b/src/hooks/useAppSave.ts index c6c470f6..69899152 100644 --- a/src/hooks/useAppSave.ts +++ b/src/hooks/useAppSave.ts @@ -253,6 +253,7 @@ function useUntitledRenameExecutor({ handleSwitchTab, replaceEntry, loadModifiedFiles, + onInternalVaultWrite, renamedPathsRef, inFlightUntitledRenameRef, }: { @@ -263,6 +264,7 @@ function useUntitledRenameExecutor({ handleSwitchTab: AppSaveDeps['handleSwitchTab'] replaceEntry: AppSaveDeps['replaceEntry'] loadModifiedFiles: AppSaveDeps['loadModifiedFiles'] + onInternalVaultWrite?: AppSaveDeps['onInternalVaultWrite'] renamedPathsRef: MutableRefObject inFlightUntitledRenameRef: MutableRefObject }) { @@ -277,6 +279,8 @@ function useUntitledRenameExecutor({ notePath: path, }) if (!result) return path + onInternalVaultWrite?.(path) + onInternalVaultWrite?.(result.new_path) trackRenamedPath(renamedPathsRef.current, path, result.new_path) await reloadAutoRenamedNote({ oldPath: path, @@ -306,6 +310,7 @@ function useUntitledRenameExecutor({ handleSwitchTab, replaceEntry, loadModifiedFiles, + onInternalVaultWrite, renamedPathsRef, inFlightUntitledRenameRef, ]) @@ -361,6 +366,7 @@ function useUntitledRenameCoordinator({ handleSwitchTab, replaceEntry, loadModifiedFiles, + onInternalVaultWrite, initialH1AutoRenameEnabled, }: { resolvedPath: string @@ -370,6 +376,7 @@ function useUntitledRenameCoordinator({ handleSwitchTab: AppSaveDeps['handleSwitchTab'] replaceEntry: AppSaveDeps['replaceEntry'] loadModifiedFiles: AppSaveDeps['loadModifiedFiles'] + onInternalVaultWrite?: AppSaveDeps['onInternalVaultWrite'] initialH1AutoRenameEnabled: boolean }) { const { @@ -387,6 +394,7 @@ function useUntitledRenameCoordinator({ handleSwitchTab, replaceEntry, loadModifiedFiles, + onInternalVaultWrite, renamedPathsRef, inFlightUntitledRenameRef, }) @@ -770,11 +778,10 @@ function useAppSaveHandlers({ } export function useAppSave({ - updateEntry, setTabs, handleSwitchTab, setToastMessage, loadModifiedFiles, reloadViews, - trackUnsaved, clearUnsaved, unsavedPaths, tabs, activeTabPath, handleRenameNote, - handleRenameFilename: handleRenameFilenameRaw, replaceEntry, resolvedPath, - initialH1AutoRenameEnabled, - onInternalVaultWrite, + updateEntry, setTabs, handleSwitchTab, setToastMessage, loadModifiedFiles, + reloadViews, trackUnsaved, clearUnsaved, unsavedPaths, tabs, activeTabPath, + handleRenameNote, handleRenameFilename: handleRenameFilenameRaw, replaceEntry, + resolvedPath, initialH1AutoRenameEnabled, onInternalVaultWrite, locale = 'en', }: AppSaveDeps) { const contentChangeRef = useRef<(path: string, content: string) => void>(() => {}) @@ -784,58 +791,26 @@ export function useAppSave({ pendingUntitledRenameRef, cancelPendingUntitledRename, registerRenamedPath, resolveCurrentPath, resolvePathBeforeSave, flushPendingUntitledRename, scheduleUntitledRename, } = useUntitledRenameCoordinator({ - resolvedPath, - tabsRef, - activeTabPathRef, - setTabs, - handleSwitchTab, - replaceEntry, - loadModifiedFiles, - initialH1AutoRenameEnabled, + resolvedPath, tabsRef, activeTabPathRef, setTabs, handleSwitchTab, + replaceEntry, loadModifiedFiles, onInternalVaultWrite, initialH1AutoRenameEnabled, }) const { handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorPersistence({ - updateEntry, - setTabs, - setToastMessage, - loadModifiedFiles, - trackUnsaved, - clearUnsaved, - onInternalVaultWrite, - reloadViews, - scheduleUntitledRename, - resolveCurrentPath, - resolvePathBeforeSave, - canPersist, + updateEntry, setTabs, setToastMessage, loadModifiedFiles, trackUnsaved, + clearUnsaved, onInternalVaultWrite, reloadViews, scheduleUntitledRename, + resolveCurrentPath, resolvePathBeforeSave, canPersist, locale, }) const replaceRenamedEntry = useReplaceRenamedEntry({ registerRenamedPath, replaceEntry }) const { handleFilenameRename, handleSave, handleTitleSync, flushBeforeAction } = useAppSaveHandlers({ - contentChangeRef, - handleContentChange, - canPersist, - cancelPendingUntitledRename, - pendingUntitledRenameRef, - activeTabPath, - resolveCurrentPath, - savePendingForPath, - tabsRef, - unsavedPathsRef, - clearUnsaved, - setToastMessage, - flushPendingUntitledRename, - locale, - handleRenameNote, + contentChangeRef, handleContentChange, canPersist, cancelPendingUntitledRename, + pendingUntitledRenameRef, activeTabPath, resolveCurrentPath, savePendingForPath, + tabsRef, unsavedPathsRef, clearUnsaved, setToastMessage, flushPendingUntitledRename, locale, handleRenameNote, handleRenameFilename: handleRenameFilenameRaw, - resolvedPath, - replaceRenamedEntry, - loadModifiedFiles, - handleSaveRaw, - tabs, - unsavedPaths, + resolvedPath, replaceRenamedEntry, loadModifiedFiles, handleSaveRaw, tabs, unsavedPaths, }) return { - contentChangeRef, handleContentChange, handleFilenameRename, handleSave, handleTitleSync, - savePending, savePendingForPath, trackRenamedPath: registerRenamedPath, flushBeforeAction, + contentChangeRef, handleContentChange, handleFilenameRename, handleSave, + handleTitleSync, savePending, savePendingForPath, trackRenamedPath: registerRenamedPath, flushBeforeAction, } } diff --git a/src/hooks/useNoteActions.hook.test.ts b/src/hooks/useNoteActions.hook.test.ts index 307fe045..93672076 100644 --- a/src/hooks/useNoteActions.hook.test.ts +++ b/src/hooks/useNoteActions.hook.test.ts @@ -380,6 +380,7 @@ describe('useNoteActions hook', () => { expect(addPendingSave).toHaveBeenCalledWith(createdPath) expect(removePendingSave).toHaveBeenCalledWith(createdPath) expect(onNewNotePersisted).toHaveBeenCalledOnce() + expect(onNewNotePersisted).toHaveBeenCalledWith(createdPath) expect(addEntry).toHaveBeenCalledTimes(1) expect(result.current.tabs[0].entry.path).toMatch(/untitled-note-\d+\.md$/) }) @@ -397,6 +398,7 @@ describe('useNoteActions hook', () => { }) expect(onNewNotePersisted).toHaveBeenCalledTimes(1) + expect(onNewNotePersisted).toHaveBeenCalledWith(expect.stringContaining('persist-callback.md')) }) it('does not call onNewNotePersisted when disk write fails (Tauri)', async () => { diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index bc6de776..770e6d37 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -30,7 +30,7 @@ export interface NoteActionsConfig { clearUnsaved?: (path: string) => void unsavedPaths?: Set markContentPending?: (path: string, content: string) => void - onNewNotePersisted?: () => void + onNewNotePersisted?: (path: string) => void replaceEntry?: (oldPath: string, patch: Partial & { path: string }) => void onPathRenamed?: (oldPath: string, newPath: string) => void /** Called after frontmatter is written to disk — used for live-reloading theme CSS vars. */ diff --git a/src/hooks/useNoteCreation.test.ts b/src/hooks/useNoteCreation.test.ts index ae8b9a16..44ababda 100644 --- a/src/hooks/useNoteCreation.test.ts +++ b/src/hooks/useNoteCreation.test.ts @@ -382,6 +382,7 @@ describe('useNoteCreation hook', () => { expect(addPendingSave).toHaveBeenCalledWith(createdPath) expect(removePendingSave).toHaveBeenCalledWith(createdPath) expect(onNewNotePersisted).toHaveBeenCalledOnce() + expect(onNewNotePersisted).toHaveBeenCalledWith(createdPath) expect(addEntry).toHaveBeenCalledTimes(1) expect(openTabWithContent).toHaveBeenCalledTimes(1) expect(vi.mocked(invoke).mock.invocationCallOrder[0]).toBeLessThan( diff --git a/src/hooks/useNoteCreation.ts b/src/hooks/useNoteCreation.ts index f43f9695..4cae117d 100644 --- a/src/hooks/useNoteCreation.ts +++ b/src/hooks/useNoteCreation.ts @@ -284,7 +284,7 @@ function signalFocusEditor(opts?: { selectTitle?: boolean; path?: string }): voi interface PersistCallbacks { onStart?: (p: string) => void onEnd?: (p: string) => void - onPersisted?: () => void + onPersisted?: (path: string) => void } /** Persist to disk; track pending state via onStart/onEnd. */ @@ -292,7 +292,7 @@ async function persistOptimistic(path: string, content: string, cbs: PersistCall cbs.onStart?.(path) try { await persistNewNote(path, content) - cbs.onPersisted?.() + cbs.onPersisted?.(path) } finally { cbs.onEnd?.(path) } @@ -410,7 +410,7 @@ interface ImmediateCreateDeps { pendingSlugs: Set openTabWithContent: (entry: VaultEntry, content: string) => void addEntry: (entry: VaultEntry) => void - onNewNotePersisted?: () => void + onNewNotePersisted?: (path: string) => void removePendingSave?: (path: string) => void setToastMessage: (msg: string | null) => void } @@ -425,7 +425,7 @@ interface ImmediateCreateQueueConfig { vaultPath: string addEntry: (entry: VaultEntry) => void openTabWithContent: (entry: VaultEntry, content: string) => void - onNewNotePersisted?: () => void + onNewNotePersisted?: (path: string) => void removePendingSave?: (path: string) => void setToastMessage: (msg: string | null) => void } @@ -600,7 +600,7 @@ export interface NoteCreationConfig { clearUnsaved?: (path: string) => void unsavedPaths?: Set markContentPending?: (path: string, content: string) => void - onNewNotePersisted?: () => void + onNewNotePersisted?: (path: string) => void onTypeStateChanged?: () => void | Promise } diff --git a/tests/smoke/h1-untitled-auto-rename.spec.ts b/tests/smoke/h1-untitled-auto-rename.spec.ts index b64f8eeb..ae157f6f 100644 --- a/tests/smoke/h1-untitled-auto-rename.spec.ts +++ b/tests/smoke/h1-untitled-auto-rename.spec.ts @@ -189,6 +189,26 @@ test('@smoke new-note H1 auto-rename keeps the editor usable and leaves no untit expect(errors).toEqual([]) }) +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.' + + await createUntitledNote(page) + await page.keyboard.type(title, { delay: 35 }) + await page.keyboard.press('Enter') + await page.keyboard.type(bodyText, { delay: 35 }) + await page.waitForTimeout(1_000) + + await expectEditorFocused(page) + await page.keyboard.type(' Still focused.') + await expectActiveFilename(page, slugifyTitle(title)) + await expectFileContentContains({ + vaultPath: tempVaultDir, + filename: `${slugifyTitle(title)}.md`, + text: 'Still focused.', + }) +}) + test('@smoke new-note H1 auto-rename preserves body typing and cursor while rename lands', async ({ page }) => { const errors: string[] = [] page.on('pageerror', (err) => {