fix: sync in-memory entries after property/rename changes

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 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-22 17:48:39 +01:00
parent 8b994e7ccf
commit a4bdc359ce
3 changed files with 169 additions and 42 deletions

View File

@@ -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,

View File

@@ -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<VaultEntry>][])(
'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<VaultEntry>][])(
'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')
})
})

View File

@@ -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<VaultEntry>) => void
}
async function performRename(
path: string,
newTitle: string,
@@ -103,6 +111,31 @@ const TYPE_FOLDER_MAP: Record<string, string> = {
const NO_STATUS_TYPES = new Set(['Topic', 'Person'])
const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
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<VaultEntry> {
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<string, Partial<VaultEntry>> = {
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<string> {
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<void> {
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,