From c7b7b97022f923866d65e991ef00a2c1b6a72a96 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Fri, 15 May 2026 02:42:51 +0200 Subject: [PATCH] fix: suppress app-owned frontmatter reloads --- src/App.tsx | 1 + src/hooks/useNoteActions.hook.test.ts | 25 ++++++++++++ src/hooks/useNoteActions.ts | 59 +++++++++++++++++++++------ 3 files changed, 73 insertions(+), 12 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 55228d7e..cda8af09 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -602,6 +602,7 @@ function App() { onMissingActiveVault: handleMissingActiveVault, onTypeStateChanged: async () => { await vault.reloadVault() }, replaceEntry: vault.replaceEntry, + onInternalVaultWrite: markRecentVaultWrite, onFrontmatterPersisted: refreshGitModifiedFiles, onPathRenamed: (oldPath, newPath) => appSave.trackRenamedPath(oldPath, newPath), }) diff --git a/src/hooks/useNoteActions.hook.test.ts b/src/hooks/useNoteActions.hook.test.ts index c6892684..123d3d7c 100644 --- a/src/hooks/useNoteActions.hook.test.ts +++ b/src/hooks/useNoteActions.hook.test.ts @@ -83,6 +83,7 @@ describe('useNoteActions hook', () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(invoke).mockReset() vi.mocked(isTauri).mockReturnValue(false) vi.useRealTimers() }) @@ -216,6 +217,30 @@ describe('useNoteActions hook', () => { expect(setToastMessage).toHaveBeenCalledWith('Property updated') }) + it('marks Tauri frontmatter writes as internal before invoking the command', async () => { + vi.mocked(isTauri).mockReturnValue(true) + const order: string[] = [] + const onInternalVaultWrite = vi.fn((path: string) => { + order.push(`mark:${path}`) + }) + vi.mocked(invoke).mockImplementation(async (command) => { + order.push(`invoke:${String(command)}`) + return '---\nstatus: Done\n---\nBody' + }) + + const { result } = renderHook(() => useNoteActions({ + ...makeConfig(), + onInternalVaultWrite, + })) + + await act(async () => { + await result.current.handleUpdateFrontmatter('/vault/note.md', 'status', 'Done') + }) + + expect(onInternalVaultWrite).toHaveBeenCalledWith('/vault/note.md') + expect(order).toEqual(['mark:/vault/note.md', 'invoke:update_frontmatter']) + }) + it('handleUpdateFrontmatter syncs is_a and color changes to entries', async () => { const { result } = renderHook(() => useNoteActions(makeConfig())) diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index 85eb0481..028fe83f 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -43,6 +43,8 @@ export interface NoteActionsConfig { onFrontmatterContentChanged?: (path: string, content: string) => void /** Called after a frontmatter mutation is fully persisted, including follow-up renames. */ onFrontmatterPersisted?: () => void | Promise + /** Called for note-action owned disk writes so file watchers can ignore app-originated changes. */ + onInternalVaultWrite?: (path: string) => void /** Called after type files or type assignments change, so derived type surfaces can reload. */ onTypeStateChanged?: () => void | Promise } @@ -97,6 +99,7 @@ interface TitleRenameDeps { handleSwitchTab: (path: string) => void setToastMessage: (msg: string | null) => void updateTabContent: (path: string, content: string) => void + onInternalVaultWrite?: (path: string) => void } interface FrontmatterCallbackParams { @@ -117,22 +120,50 @@ interface RenameAfterTitleChangeParams { deps: TitleRenameDeps } +interface ApplyTitleRenamePathChangeParams { + deps: TitleRenameDeps + newPath: string + newTitle: string + path: string +} + +function tabPathsExceptRenamed( + tabs: { entry: VaultEntry; content: string }[], + path: string, + newPath: string, +): string[] { + return tabs + .filter(t => !notePathsMatch(t.entry.path, path) && !notePathsMatch(t.entry.path, newPath)) + .map(t => t.entry.path) +} + +async function applyTitleRenamePathChange({ + deps, + newPath, + newTitle, + path, +}: ApplyTitleRenamePathChangeParams): Promise { + const newFilename = notePathFilename(newPath) + deps.onInternalVaultWrite?.(newPath) + deps.onPathRenamed?.(path, newPath) + deps.replaceEntry?.(path, { path: newPath, filename: newFilename, title: newTitle } as Partial & { path: string }) + const newContent = await loadNoteContent({ path: newPath }) + deps.setTabs(prev => prev.map(t => notePathsMatch(t.entry.path, path) + ? { entry: { ...t.entry, path: newPath, filename: newFilename, title: newTitle }, content: newContent } + : t)) + if (notePathsMatch(deps.activeTabPathRef.current, path)) deps.handleSwitchTab(newPath) + await reloadTabsAfterRename({ + tabPaths: tabPathsExceptRenamed(deps.tabsRef.current, path, newPath), + updateTabContent: deps.updateTabContent, + }) +} + async function renameAfterTitleChange({ path, newTitle, deps }: RenameAfterTitleChangeParams): Promise { const oldTitle = deps.tabsRef.current.find(t => notePathsMatch(t.entry.path, path))?.entry.title + deps.onInternalVaultWrite?.(path) const result = await performRename({ path, newTitle, vaultPath: deps.vaultPath, oldTitle }) if (!notePathsMatch(result.new_path, path)) { - const newFilename = notePathFilename(result.new_path) - deps.onPathRenamed?.(path, result.new_path) - deps.replaceEntry?.(path, { path: result.new_path, filename: newFilename, title: newTitle } as Partial & { path: string }) - const newContent = await loadNoteContent({ path: result.new_path }) - deps.setTabs(prev => prev.map(t => notePathsMatch(t.entry.path, path) - ? { entry: { ...t.entry, path: result.new_path, filename: newFilename, title: newTitle }, content: newContent } - : t)) - if (notePathsMatch(deps.activeTabPathRef.current, path)) deps.handleSwitchTab(result.new_path) - const otherTabPaths = deps.tabsRef.current - .filter(t => !notePathsMatch(t.entry.path, path) && !notePathsMatch(t.entry.path, result.new_path)) - .map(t => t.entry.path) - await reloadTabsAfterRename({ tabPaths: otherTabPaths, updateTabContent: deps.updateTabContent }) + await applyTitleRenamePathChange({ path, newPath: result.new_path, newTitle, deps }) } await reloadVaultAfterRename(deps.reloadVault) deps.setToastMessage(renameToastMessage(result.updated_files, result.failed_updates ?? 0)) @@ -244,6 +275,7 @@ async function updateFrontmatterAndMaybeRename({ if (!canFlush) return if (!activePathGuardAllowsMutation(path, deps.activeTabPathRef, options)) return + config.onInternalVaultWrite?.(path) const newContent = await runFrontmatterOp('update', path, key, value, options) if (!applyFrontmatterCallbacks({ config, path, newContent })) return @@ -353,6 +385,7 @@ function useFrontmatterActionHandlers({ handleSwitchTab, setToastMessage, updateTabContent, + onInternalVaultWrite: config.onInternalVaultWrite, }, path: currentPath, key, @@ -369,6 +402,7 @@ function useFrontmatterActionHandlers({ if (!canFlush) return if (!activePathGuardAllowsMutation(currentPath, activeTabPathRef, options)) return + config.onInternalVaultWrite?.(currentPath) const newContent = await runFrontmatterOp('delete', currentPath, key, undefined, options) if (!applyFrontmatterCallbacks({ config, path: currentPath, newContent })) return await notifyFrontmatterPersisted(config, key) @@ -381,6 +415,7 @@ function useFrontmatterActionHandlers({ if (!canFlush) return if (!activePathGuardAllowsMutation(currentPath, activeTabPathRef, options)) return + config.onInternalVaultWrite?.(currentPath) const newContent = await runFrontmatterOp('update', currentPath, key, value, options) if (!applyFrontmatterCallbacks({ config, path: currentPath, newContent })) return await notifyFrontmatterPersisted(config, key)