From 24bb64841e44398a20752db1f60bc69174dadc7e Mon Sep 17 00:00:00 2001 From: Luca Rossi Date: Mon, 2 Mar 2026 11:22:03 +0100 Subject: [PATCH] fix: customize icon & color works for types without Type entry (#173) Types without a dedicated Type definition note in the vault silently failed to save icon/color customizations. Both applyCustomization (Sidebar) and handleCustomizeType (useEntryActions) returned early when no Type entry existed. Also fixed a race condition where two concurrent handleUpdateFrontmatter calls could overwrite each other. Changes: - useNoteActions: add createTypeEntrySilent for headless Type file creation - useEntryActions: auto-create Type entry when missing, serialize writes - Sidebar: applyCustomization proceeds with defaults when no Type entry - App: wire createTypeEntrySilent into useEntryActions config Co-authored-by: Test Co-authored-by: Claude Opus 4.6 --- src/App.tsx | 1 + src/components/Sidebar.tsx | 5 ++-- src/hooks/useEntryActions.test.ts | 38 ++++++++++++++++++++++++------- src/hooks/useEntryActions.ts | 15 ++++++------ src/hooks/useNoteActions.ts | 9 ++++++++ 5 files changed, 51 insertions(+), 17 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 1eb4a8e6..e236546a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -245,6 +245,7 @@ function App() { entries: vault.entries, updateEntry: vault.updateEntry, handleUpdateFrontmatter: notes.handleUpdateFrontmatter, handleDeleteProperty: notes.handleDeleteProperty, setToastMessage, + createTypeEntry: notes.createTypeEntrySilent, }) const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 6ad30281..3913a7b8 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -151,8 +151,9 @@ function applyCustomization( ): void { if (!target || !onCustomizeType) return const te = typeEntryMap[target] - if (!te) return - const [icon, color] = buildCustomizeArgs(te, prop, value) + const [icon, color] = te + ? buildCustomizeArgs(te, prop, value) + : [prop === 'icon' ? value : 'file-text', prop === 'color' ? value : 'blue'] onCustomizeType(target, icon, color) } diff --git a/src/hooks/useEntryActions.test.ts b/src/hooks/useEntryActions.test.ts index 380a6bbf..6fc87674 100644 --- a/src/hooks/useEntryActions.test.ts +++ b/src/hooks/useEntryActions.test.ts @@ -36,6 +36,9 @@ describe('useEntryActions', () => { const handleUpdateFrontmatter = vi.fn().mockResolvedValue(undefined) const handleDeleteProperty = vi.fn().mockResolvedValue(undefined) const setToastMessage = vi.fn() + const createTypeEntry = vi.fn().mockImplementation((typeName: string) => + Promise.resolve(makeEntry({ isA: 'Type', title: typeName, path: `/vault/type/${typeName.toLowerCase()}.md` })), + ) function setup(entries: VaultEntry[] = []) { return renderHook(() => @@ -45,6 +48,7 @@ describe('useEntryActions', () => { handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, + createTypeEntry, }) ) } @@ -118,12 +122,12 @@ describe('useEntryActions', () => { }) describe('handleCustomizeType', () => { - it('updates icon and color on the type entry', () => { + it('updates icon and color on the type entry', async () => { const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md' }) const { result } = setup([typeEntry]) - act(() => { - result.current.handleCustomizeType('Recipe', 'cooking-pot', 'green') + await act(async () => { + await result.current.handleCustomizeType('Recipe', 'cooking-pot', 'green') }) expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'icon', 'cooking-pot') @@ -131,15 +135,33 @@ describe('useEntryActions', () => { expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { icon: 'cooking-pot', color: 'green' }) }) - it('does nothing when type entry not found', () => { + it('auto-creates type entry when not found and applies customization', async () => { const { result } = setup([]) - act(() => { - result.current.handleCustomizeType('NonExistent', 'star', 'red') + await act(async () => { + await result.current.handleCustomizeType('Recipe', 'star', 'red') }) - expect(handleUpdateFrontmatter).not.toHaveBeenCalled() - expect(updateEntry).not.toHaveBeenCalled() + expect(createTypeEntry).toHaveBeenCalledWith('Recipe') + expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { icon: 'star', color: 'red' }) + expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'icon', 'star') + expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'color', 'red') + }) + + it('serializes frontmatter writes (icon before color)', async () => { + const callOrder: string[] = [] + handleUpdateFrontmatter.mockImplementation((_path: string, key: string) => { + callOrder.push(key) + return Promise.resolve() + }) + const typeEntry = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' }) + const { result } = setup([typeEntry]) + + await act(async () => { + await result.current.handleCustomizeType('Project', 'wrench', 'blue') + }) + + expect(callOrder).toEqual(['icon', 'color']) }) }) diff --git a/src/hooks/useEntryActions.ts b/src/hooks/useEntryActions.ts index 700f9c15..667853bd 100644 --- a/src/hooks/useEntryActions.ts +++ b/src/hooks/useEntryActions.ts @@ -7,6 +7,7 @@ interface EntryActionsConfig { handleUpdateFrontmatter: (path: string, key: string, value: string | number | boolean | string[]) => Promise handleDeleteProperty: (path: string, key: string) => Promise setToastMessage: (msg: string | null) => void + createTypeEntry: (typeName: string) => Promise } function findTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | undefined { @@ -14,7 +15,7 @@ function findTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | un } export function useEntryActions({ - entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, + entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, createTypeEntry, }: EntryActionsConfig) { const handleTrashNote = useCallback(async (path: string) => { const now = new Date().toISOString().slice(0, 10) @@ -43,13 +44,13 @@ export function useEntryActions({ setToastMessage('Note unarchived') }, [handleUpdateFrontmatter, updateEntry, setToastMessage]) - const handleCustomizeType = useCallback((typeName: string, icon: string, color: string) => { - const typeEntry = findTypeEntry(entries, typeName) - if (!typeEntry) return - handleUpdateFrontmatter(typeEntry.path, 'icon', icon) - handleUpdateFrontmatter(typeEntry.path, 'color', color) + const handleCustomizeType = useCallback(async (typeName: string, icon: string, color: string) => { + let typeEntry = findTypeEntry(entries, typeName) + if (!typeEntry) typeEntry = await createTypeEntry(typeName) updateEntry(typeEntry.path, { icon, color }) - }, [entries, handleUpdateFrontmatter, updateEntry]) + await handleUpdateFrontmatter(typeEntry.path, 'icon', icon) + await handleUpdateFrontmatter(typeEntry.path, 'color', color) + }, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry]) const handleReorderSections = useCallback((orderedTypes: { typeName: string; order: number }[]) => { for (const { typeName, order } of orderedTypes) { diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index a074bf3b..a3172a93 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -389,6 +389,14 @@ export function useNoteActions(config: NoteActionsConfig) { const handleCreateType = useCallback((typeName: string) => persistNew(resolveNewType(typeName)), [persistNew]) + /** Create a Type entry file silently (no tab opened). Adds to state and persists to disk. */ + const createTypeEntrySilent = useCallback(async (typeName: string): Promise => { + const resolved = resolveNewType(typeName) + addEntryWithMock(resolved.entry, resolved.content, addEntry) + await persistNewNote(resolved.entry.path, resolved.content) + return resolved.entry + }, [addEntry]) + const fmCallbacks = { updateTab: updateTabContent, updateEntry, toast: setToastMessage } const runFrontmatterOp = useCallback( @@ -426,6 +434,7 @@ export function useNoteActions(config: NoteActionsConfig) { handleCreateNoteImmediate, handleOpenDailyNote, handleCreateType, + createTypeEntrySilent, handleUpdateFrontmatter: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]), handleDeleteProperty: useCallback((path: string, key: string) => runFrontmatterOp('delete', path, key), [runFrontmatterOp]), handleAddProperty: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),