From a4bdc359ce10e36fbcc46050fa7608103ec2a4ec Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 22 Feb 2026 17:48:39 +0100 Subject: [PATCH] fix: sync in-memory entries after property/rename changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two propagation gaps fixed: 1. Property changes (type, status, color, etc.) now immediately sync to the entries state via frontmatterToEntryPatch(), so sidebar, note list, and relations panel reflect updates without restart. 2. After renaming a note, all other open tabs reload their content from disk, picking up updated wikilinks immediately. Design decisions: - Used key-mapping approach (frontmatterToEntryPatch) instead of a new parse_md_file Tauri command — simpler, works in both Tauri and mock modes, covers all VaultEntry fields. - Converted useNoteActions to config object to avoid excess arguments. - Extracted findWikilinkTarget, reloadTabsAfterRename, renameToastMessage as standalone functions to reduce hook complexity (cc 10→9). Co-Authored-By: Claude Opus 4.6 --- src/App.tsx | 2 +- src/hooks/useNoteActions.test.ts | 123 ++++++++++++++++++++++++------- src/hooks/useNoteActions.ts | 86 +++++++++++++++++---- 3 files changed, 169 insertions(+), 42 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 7550da9c..08b03fc8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -70,7 +70,7 @@ function App() { setApiKey(settings.anthropic_key ?? '') }, [settings.anthropic_key]) - const notes = useNoteActions(vault.addEntry, vault.updateContent, vault.entries, setToastMessage) + const notes = useNoteActions({ addEntry: vault.addEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry }) const entryActions = useEntryActions({ entries: vault.entries, diff --git a/src/hooks/useNoteActions.test.ts b/src/hooks/useNoteActions.test.ts index 18bf92c7..63e7ebc5 100644 --- a/src/hooks/useNoteActions.test.ts +++ b/src/hooks/useNoteActions.test.ts @@ -9,8 +9,10 @@ import { buildNoteContent, resolveNewNote, resolveNewType, + frontmatterToEntryPatch, useNoteActions, } from './useNoteActions' +import type { NoteActionsConfig } from './useNoteActions' // Mock dependencies vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() })) @@ -211,20 +213,75 @@ describe('resolveNewType', () => { }) }) +describe('frontmatterToEntryPatch', () => { + it.each([ + ['is_a', 'Project', { isA: 'Project' }], + ['status', 'Done', { status: 'Done' }], + ['color', 'red', { color: 'red' }], + ['icon', 'star', { icon: 'star' }], + ['owner', 'Luca', { owner: 'Luca' }], + ['cadence', 'Weekly', { cadence: 'Weekly' }], + ['archived', true, { archived: true }], + ['trashed', true, { trashed: true }], + ['order', 5, { order: 5 }], + ] as [string, unknown, Partial][])( + 'maps %s update to correct entry field', + (key, value, expected) => { + expect(frontmatterToEntryPatch('update', key, value as never)).toEqual(expected) + }, + ) + + it('maps aliases update with array value', () => { + expect(frontmatterToEntryPatch('update', 'aliases', ['A', 'B'])).toEqual({ aliases: ['A', 'B'] }) + }) + + it('maps belongs_to update with array value', () => { + expect(frontmatterToEntryPatch('update', 'belongs_to', ['[[parent]]'])).toEqual({ belongsTo: ['[[parent]]'] }) + }) + + it('handles case-insensitive keys with spaces (e.g. "Is A", "Belongs to")', () => { + expect(frontmatterToEntryPatch('update', 'Is A', 'Experiment')).toEqual({ isA: 'Experiment' }) + expect(frontmatterToEntryPatch('update', 'Belongs to', ['[[x]]'])).toEqual({ belongsTo: ['[[x]]'] }) + }) + + it('returns empty object for unknown keys', () => { + expect(frontmatterToEntryPatch('update', 'custom_field', 'value')).toEqual({}) + }) + + it.each([ + ['status', { status: null }], + ['color', { color: null }], + ['aliases', { aliases: [] }], + ['archived', { archived: false }], + ['order', { order: null }], + ] as [string, Partial][])( + 'maps delete of %s to null/default', + (key, expected) => { + expect(frontmatterToEntryPatch('delete', key)).toEqual(expected) + }, + ) + + it('returns empty object for unknown key on delete', () => { + expect(frontmatterToEntryPatch('delete', 'unknown_key')).toEqual({}) + }) +}) + describe('useNoteActions hook', () => { const addEntry = vi.fn() const updateContent = vi.fn() + const updateEntry = vi.fn() const setToastMessage = vi.fn() + const makeConfig = (entries: VaultEntry[] = []): NoteActionsConfig => ({ + addEntry, updateContent, entries, setToastMessage, updateEntry, + }) + beforeEach(() => { vi.clearAllMocks() }) it('handleCreateNote calls addEntry and creates correct entry', () => { - const entries: VaultEntry[] = [] - const { result } = renderHook(() => - useNoteActions(addEntry, updateContent, entries, setToastMessage) - ) + const { result } = renderHook(() => useNoteActions(makeConfig())) act(() => { result.current.handleCreateNote('Test Note', 'Note') @@ -239,10 +296,7 @@ describe('useNoteActions hook', () => { }) it('handleCreateType creates type entry', () => { - const entries: VaultEntry[] = [] - const { result } = renderHook(() => - useNoteActions(addEntry, updateContent, entries, setToastMessage) - ) + const { result } = renderHook(() => useNoteActions(makeConfig())) act(() => { result.current.handleCreateType('Recipe') @@ -256,27 +310,20 @@ describe('useNoteActions hook', () => { it('handleNavigateWikilink finds entry by title', async () => { const target = makeEntry({ title: 'Target Note', path: '/vault/note/target.md' }) - const entries = [target] - const { result } = renderHook(() => - useNoteActions(addEntry, updateContent, entries, setToastMessage) - ) + const { result } = renderHook(() => useNoteActions(makeConfig([target]))) await act(async () => { result.current.handleNavigateWikilink('Target Note') }) - // Should set active tab path (via handleSelectNote which loads content) expect(result.current.activeTabPath).toBe('/vault/note/target.md') }) it('handleNavigateWikilink warns when target not found', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const entries: VaultEntry[] = [] - const { result } = renderHook(() => - useNoteActions(addEntry, updateContent, entries, setToastMessage) - ) + const { result } = renderHook(() => useNoteActions(makeConfig())) act(() => { result.current.handleNavigateWikilink('Nonexistent') @@ -286,31 +333,53 @@ describe('useNoteActions hook', () => { warnSpy.mockRestore() }) - it('handleUpdateFrontmatter calls mock and shows toast on success', async () => { - const entries: VaultEntry[] = [] - const { result } = renderHook(() => - useNoteActions(addEntry, updateContent, entries, setToastMessage) - ) + it('handleUpdateFrontmatter calls updateEntry with mapped patch', async () => { + const { result } = renderHook(() => useNoteActions(makeConfig())) await act(async () => { await result.current.handleUpdateFrontmatter('/vault/note.md', 'status', 'Done') }) expect(updateContent).toHaveBeenCalled() + expect(updateEntry).toHaveBeenCalledWith('/vault/note.md', { status: 'Done' }) expect(setToastMessage).toHaveBeenCalledWith('Property updated') }) - it('handleDeleteProperty calls mock and shows toast on success', async () => { - const entries: VaultEntry[] = [] - const { result } = renderHook(() => - useNoteActions(addEntry, updateContent, entries, setToastMessage) - ) + it('handleUpdateFrontmatter syncs is_a and color changes to entries', async () => { + const { result } = renderHook(() => useNoteActions(makeConfig())) + + await act(async () => { + await result.current.handleUpdateFrontmatter('/vault/note.md', 'is_a', 'Project') + }) + expect(updateEntry).toHaveBeenCalledWith('/vault/note.md', { isA: 'Project' }) + + vi.clearAllMocks() + await act(async () => { + await result.current.handleUpdateFrontmatter('/vault/note.md', 'color', 'blue') + }) + expect(updateEntry).toHaveBeenCalledWith('/vault/note.md', { color: 'blue' }) + }) + + it('handleDeleteProperty calls updateEntry with null/default values', async () => { + const { result } = renderHook(() => useNoteActions(makeConfig())) await act(async () => { await result.current.handleDeleteProperty('/vault/note.md', 'status') }) expect(updateContent).toHaveBeenCalled() + expect(updateEntry).toHaveBeenCalledWith('/vault/note.md', { status: null }) expect(setToastMessage).toHaveBeenCalledWith('Property deleted') }) + + it('handleUpdateFrontmatter does not call updateEntry for unknown keys', async () => { + const { result } = renderHook(() => useNoteActions(makeConfig())) + + await act(async () => { + await result.current.handleUpdateFrontmatter('/vault/note.md', 'custom_field', 'value') + }) + + expect(updateEntry).not.toHaveBeenCalled() + expect(setToastMessage).toHaveBeenCalledWith('Property updated') + }) }) diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index c094dcbb..bb3be8b4 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react' +import { useCallback, useRef } from 'react' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke, addMockEntry, updateMockContent } from '../mock-tauri' import type { VaultEntry } from '../types' @@ -19,6 +19,14 @@ interface RenameResult { updated_files: number } +export interface NoteActionsConfig { + addEntry: (entry: VaultEntry, content: string) => void + updateContent: (path: string, content: string) => void + entries: VaultEntry[] + setToastMessage: (msg: string | null) => void + updateEntry: (path: string, patch: Partial) => void +} + async function performRename( path: string, newTitle: string, @@ -103,6 +111,31 @@ const TYPE_FOLDER_MAP: Record = { const NO_STATUS_TYPES = new Set(['Topic', 'Person']) +const ENTRY_DELETE_MAP: Record> = { + is_a: { isA: null }, status: { status: null }, color: { color: null }, + icon: { icon: null }, owner: { owner: null }, cadence: { cadence: null }, + aliases: { aliases: [] }, belongs_to: { belongsTo: [] }, related_to: { relatedTo: [] }, + archived: { archived: false }, trashed: { trashed: false }, order: { order: null }, +} + +/** Map a frontmatter key+value to the corresponding VaultEntry field(s). */ +export function frontmatterToEntryPatch( + op: 'update' | 'delete', key: string, value?: FrontmatterValue, +): Partial { + const k = key.toLowerCase().replace(/\s+/g, '_') + if (op === 'delete') return ENTRY_DELETE_MAP[k] ?? {} + const str = value != null ? String(value) : null + const arr = Array.isArray(value) ? value.map(String) : [] + const updates: Record> = { + is_a: { isA: str }, status: { status: str }, color: { color: str }, + icon: { icon: str }, owner: { owner: str }, cadence: { cadence: str }, + aliases: { aliases: arr }, belongs_to: { belongsTo: arr }, related_to: { relatedTo: arr }, + archived: { archived: Boolean(value) }, trashed: { trashed: Boolean(value) }, + order: { order: typeof value === 'number' ? value : null }, + } + return updates[k] ?? {} +} + function addEntryWithMock(entry: VaultEntry, content: string, addEntry: (e: VaultEntry, c: string) => void) { if (!isTauri()) addMockEntry(entry, content) addEntry(entry, content) @@ -129,6 +162,12 @@ export function resolveNewType(typeName: string): { entry: VaultEntry; content: return { entry, content: `---\nIs A: Type\n---\n\n# ${typeName}\n\n` } } +function findWikilinkTarget(entries: VaultEntry[], target: string): VaultEntry | undefined { + const targetLower = target.toLowerCase() + const targetAsWords = target.split('/').pop()?.replace(/-/g, ' ').toLowerCase() ?? targetLower + return entries.find((e) => entryMatchesTarget(e, targetLower, targetAsWords)) +} + async function executeFrontmatterOp(op: 'update' | 'delete', path: string, key: string, value?: FrontmatterValue): Promise { if (op === 'update') { return isTauri() ? invokeFrontmatter('update_frontmatter', { path, key, value }) : applyMockFrontmatterUpdate(path, key, value!) @@ -136,14 +175,29 @@ async function executeFrontmatterOp(op: 'update' | 'delete', path: string, key: return isTauri() ? invokeFrontmatter('delete_frontmatter_property', { path, key }) : applyMockFrontmatterDelete(path, key) } -export function useNoteActions( - addEntry: (entry: VaultEntry, content: string) => void, - updateContent: (path: string, content: string) => void, - entries: VaultEntry[], - setToastMessage: (msg: string | null) => void, -) { +function renameToastMessage(updatedFiles: number): string { + if (updatedFiles === 0) return 'Renamed' + return `Renamed — updated ${updatedFiles} wiki link${updatedFiles > 1 ? 's' : ''}` +} + +/** Reload content for open tabs whose wikilinks may have changed after a rename. */ +async function reloadTabsAfterRename( + tabPaths: string[], + updateTabContent: (path: string, content: string) => void, +): Promise { + for (const tabPath of tabPaths) { + try { + updateTabContent(tabPath, await loadNoteContent(tabPath)) + } catch { /* skip tabs that fail to reload */ } + } +} + +export function useNoteActions(config: NoteActionsConfig) { + const { addEntry, updateContent, entries, setToastMessage, updateEntry } = config const tabMgmt = useTabManagement() const { setTabs, handleSelectNote, activeTabPathRef, handleSwitchTab } = tabMgmt + const tabsRef = useRef(tabMgmt.tabs) + tabsRef.current = tabMgmt.tabs const updateTabContent = useCallback((path: string, newContent: string) => { setTabs((prev) => prev.map((t) => t.entry.path === path ? { ...t, content: newContent } : t)) @@ -151,9 +205,7 @@ export function useNoteActions( }, [setTabs, updateContent]) const handleNavigateWikilink = useCallback((target: string) => { - const targetLower = target.toLowerCase() - const targetAsWords = target.split('/').pop()?.replace(/-/g, ' ').toLowerCase() ?? targetLower - const found = entries.find((e) => entryMatchesTarget(e, targetLower, targetAsWords)) + const found = findWikilinkTarget(entries, target) if (found) handleSelectNote(found) else console.warn(`Navigation target not found: ${target}`) }, [entries, handleSelectNote]) @@ -173,12 +225,14 @@ export function useNoteActions( const runFrontmatterOp = useCallback(async (op: 'update' | 'delete', path: string, key: string, value?: FrontmatterValue) => { try { updateTabContent(path, await executeFrontmatterOp(op, path, key, value)) + const patch = frontmatterToEntryPatch(op, key, value) + if (Object.keys(patch).length > 0) updateEntry(path, patch) setToastMessage(op === 'update' ? 'Property updated' : 'Property deleted') } catch (err) { console.error(`Failed to ${op} frontmatter:`, err) setToastMessage(`Failed to ${op} property`) } - }, [updateTabContent, setToastMessage]) + }, [updateTabContent, updateEntry, setToastMessage]) const handleRenameNote = useCallback(async ( path: string, @@ -192,17 +246,21 @@ export function useNoteActions( const entry = entries.find((e) => e.path === path) const newEntry = buildRenamedEntry(entry ?? {} as VaultEntry, newTitle, result.new_path) + const otherTabPaths = tabsRef.current + .filter(t => t.entry.path !== path) + .map(t => t.entry.path) + setTabs((prev) => prev.map((t) => t.entry.path === path ? { entry: newEntry, content: newContent } : t)) if (activeTabPathRef.current === path) handleSwitchTab(result.new_path) onEntryRenamed(path, newEntry, newContent) - const n = result.updated_files - setToastMessage(n > 0 ? `Renamed — updated ${n} wiki link${n > 1 ? 's' : ''}` : 'Renamed') + await reloadTabsAfterRename(otherTabPaths, updateTabContent) + setToastMessage(renameToastMessage(result.updated_files)) } catch (err) { console.error('Failed to rename note:', err) setToastMessage('Failed to rename note') } - }, [entries, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage]) + }, [entries, setTabs, activeTabPathRef, handleSwitchTab, updateTabContent, setToastMessage]) return { ...tabMgmt,