Merge branch 'main' into main
This commit is contained in:
21
src/App.tsx
21
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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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<string>(),
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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<RenamedPathMap>
|
||||
inFlightUntitledRenameRef: MutableRefObject<InFlightRenameMap>
|
||||
}) {
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -30,7 +30,7 @@ export interface NoteActionsConfig {
|
||||
clearUnsaved?: (path: string) => void
|
||||
unsavedPaths?: Set<string>
|
||||
markContentPending?: (path: string, content: string) => void
|
||||
onNewNotePersisted?: () => void
|
||||
onNewNotePersisted?: (path: string) => void
|
||||
replaceEntry?: (oldPath: string, patch: Partial<VaultEntry> & { path: string }) => void
|
||||
onPathRenamed?: (oldPath: string, newPath: string) => void
|
||||
/** Called after frontmatter is written to disk — used for live-reloading theme CSS vars. */
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string>
|
||||
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<string>
|
||||
markContentPending?: (path: string, content: string) => void
|
||||
onNewNotePersisted?: () => void
|
||||
onNewNotePersisted?: (path: string) => void
|
||||
onTypeStateChanged?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user