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 <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Rossi
2026-03-02 11:22:03 +01:00
committed by GitHub
parent e58f521262
commit 8aee1315d9
5 changed files with 51 additions and 17 deletions

View File

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

View File

@@ -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)
}

View File

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

View File

@@ -7,6 +7,7 @@ interface EntryActionsConfig {
handleUpdateFrontmatter: (path: string, key: string, value: string | number | boolean | string[]) => Promise<void>
handleDeleteProperty: (path: string, key: string) => Promise<void>
setToastMessage: (msg: string | null) => void
createTypeEntry: (typeName: string) => Promise<VaultEntry>
}
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) {

View File

@@ -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<VaultEntry> => {
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]),