diff --git a/src/App.tsx b/src/App.tsx index 3018b477..7f759f87 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -187,7 +187,7 @@ function App() { onOpenFile: (relativePath) => conflictFlow.openConflictFileRef.current(relativePath), }) - const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => appSave.contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry }) + const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => appSave.contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry, onFrontmatterPersisted: vault.loadModifiedFiles }) // Note window: auto-open the note from URL params once vault entries load const noteWindowOpenedRef = useRef(false) @@ -381,7 +381,6 @@ function App() { handleUpdateFrontmatter: notes.handleUpdateFrontmatter, handleDeleteProperty: notes.handleDeleteProperty, setToastMessage, createTypeEntry: notes.createTypeEntrySilent, - onFrontmatterPersisted: vault.loadModifiedFiles, onBeforeAction: appSave.flushBeforeAction, }) diff --git a/src/hooks/useNoteActions.hook.test.ts b/src/hooks/useNoteActions.hook.test.ts index 116420a5..9d3b1269 100644 --- a/src/hooks/useNoteActions.hook.test.ts +++ b/src/hooks/useNoteActions.hook.test.ts @@ -641,4 +641,3 @@ describe('useNoteActions hook', () => { }) }) }) - diff --git a/src/hooks/useNoteActions.persistence.test.ts b/src/hooks/useNoteActions.persistence.test.ts new file mode 100644 index 00000000..aab7b9f1 --- /dev/null +++ b/src/hooks/useNoteActions.persistence.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useNoteActions, type NoteActionsConfig } from './useNoteActions' + +vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() })) +vi.mock('../mock-tauri', () => ({ + isTauri: vi.fn(() => false), + addMockEntry: vi.fn(), + updateMockContent: vi.fn(), + trackMockChange: vi.fn(), + mockInvoke: vi.fn().mockResolvedValue(''), +})) +vi.mock('./mockFrontmatterHelpers', () => ({ + updateMockFrontmatter: vi.fn().mockReturnValue('---\nupdated: true\n---\n'), + deleteMockFrontmatterProperty: vi.fn().mockReturnValue('---\n---\n'), +})) + +function makeConfig(onFrontmatterPersisted: () => void): NoteActionsConfig { + return { + addEntry: vi.fn(), + removeEntry: vi.fn(), + entries: [], + setToastMessage: vi.fn(), + updateEntry: vi.fn(), + vaultPath: '/test/vault', + onFrontmatterPersisted, + } +} + +describe('useNoteActions frontmatter persistence', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it.each([ + { + label: 'update', + run: async (result: ReturnType>['result']) => { + await result.current.handleUpdateFrontmatter('/vault/note.md', '_list_properties_display', ['Owner', 'Status']) + }, + }, + { + label: 'delete', + run: async (result: ReturnType>['result']) => { + await result.current.handleDeleteProperty('/vault/note.md', 'status') + }, + }, + ])('notifies after a frontmatter $label completes', async ({ run }) => { + const onFrontmatterPersisted = vi.fn() + const { result } = renderHook(() => useNoteActions(makeConfig(onFrontmatterPersisted))) + + await act(async () => { + await run(result) + }) + + expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index 84b0619c..bbe38463 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -27,6 +27,8 @@ export interface NoteActionsConfig { replaceEntry?: (oldPath: string, patch: Partial & { path: string }) => void /** Called after frontmatter is written to disk — used for live-reloading theme CSS vars. */ onFrontmatterContentChanged?: (path: string, content: string) => void + /** Called after a frontmatter mutation is fully persisted, including follow-up renames. */ + onFrontmatterPersisted?: () => void } function isTitleKey(key: string): boolean { @@ -44,6 +46,12 @@ interface TitleRenameDeps { updateTabContent: (path: string, content: string) => void } +function applyFrontmatterCallbacks(config: NoteActionsConfig, path: string, newContent: string | undefined): boolean { + if (!newContent) return false + config.onFrontmatterContentChanged?.(path, newContent) + return true +} + async function renameAfterTitleChange(path: string, newTitle: string, deps: TitleRenameDeps): Promise { const oldTitle = deps.tabsRef.current.find(t => t.entry.path === path)?.entry.title const result = await performRename(path, newTitle, deps.vaultPath, oldTitle) @@ -71,6 +79,20 @@ function navigateWikilink(entries: VaultEntry[], target: string, selectNote: (e: else console.warn(`Navigation target not found: ${target}`) } +async function maybeRenameAfterFrontmatterUpdate( + path: string, + key: string, + value: FrontmatterValue, + deps: TitleRenameDeps, +): Promise { + if (!shouldRenameOnTitleUpdate(key, value)) return + try { + await renameAfterTitleChange(path, value, deps) + } catch (err) { + console.error('Failed to rename note after title change:', err) + } +} + export function useNoteActions(config: NoteActionsConfig) { const { entries, setToastMessage, updateEntry } = config const tabMgmt = useTabManagement() @@ -108,25 +130,22 @@ export function useNoteActions(config: NoteActionsConfig) { createTypeEntrySilent: creation.createTypeEntrySilent, handleUpdateFrontmatter: useCallback(async (path: string, key: string, value: FrontmatterValue, options?: FrontmatterOpOptions) => { const newContent = await runFrontmatterOp('update', path, key, value, options) - if (newContent) config.onFrontmatterContentChanged?.(path, newContent) - if (shouldRenameOnTitleUpdate(key, value)) { - try { - await renameAfterTitleChange(path, value, { - vaultPath: config.vaultPath, tabsRef: rename.tabsRef, replaceEntry: config.replaceEntry, - setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent, - }) - } catch (err) { - console.error('Failed to rename note after title change:', err) - } - } + if (!applyFrontmatterCallbacks(config, path, newContent)) return + await maybeRenameAfterFrontmatterUpdate(path, key, value, { + vaultPath: config.vaultPath, tabsRef: rename.tabsRef, replaceEntry: config.replaceEntry, + setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent, + }) + config.onFrontmatterPersisted?.() }, [runFrontmatterOp, config, rename.tabsRef, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent]), handleDeleteProperty: useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => { const newContent = await runFrontmatterOp('delete', path, key, undefined, options) - if (newContent) config.onFrontmatterContentChanged?.(path, newContent) + if (!applyFrontmatterCallbacks(config, path, newContent)) return + config.onFrontmatterPersisted?.() }, [runFrontmatterOp, config]), handleAddProperty: useCallback(async (path: string, key: string, value: FrontmatterValue) => { const newContent = await runFrontmatterOp('update', path, key, value) - if (newContent) config.onFrontmatterContentChanged?.(path, newContent) + if (!applyFrontmatterCallbacks(config, path, newContent)) return + config.onFrontmatterPersisted?.() }, [runFrontmatterOp, config]), handleRenameNote: rename.handleRenameNote, }