fix: resolve custom type color and icon in all autocomplete contexts (#84)

* fix: resolve custom type color and icon in all autocomplete contexts

Custom types (e.g., Evergreen, Recipe) appeared grey in wikilink [[,
@-mention, Cmd+P, and search autocompletes because getTypeColor() was
called without the custom color key from the Type document.

Root causes:
- Editor.tsx: getTypeColor(group) missing typeEntryMap[group]?.color
- useNoteSearch.ts: getTypeColor(e.isA) missing custom color lookup
- SearchPanel.tsx: type badge had no color styling at all

Fixes:
- Extract buildTypeEntryMap to shared utility in typeColors.ts
- Pass custom color key in all autocomplete code paths
- Add type icon (Phosphor) to the left of each result in NoteSearchList
- Add TypeIcon to WikilinkSuggestionItem and NoteAutocomplete

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add design file for autocomplete type color fix

Shows the fixed state: type icon on the left, colored type badge on
the right, untyped notes neutral grey with no icon.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Rossi
2026-02-26 04:05:22 +01:00
committed by GitHub
parent 25a297007b
commit cb8efa7b0f
13 changed files with 155 additions and 59 deletions

File diff suppressed because one or more lines are too long

View File

@@ -390,11 +390,11 @@ describe('wikilink autocomplete', () => {
mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items)
})
it('always includes noteType for every item, including default Note type (regression)', async () => {
it('shows correct noteType and color for typed entries, neutral for untyped', async () => {
const mixedEntries: VaultEntry[] = [
{ ...mockEntry, title: 'My Project', filename: 'proj.md', path: '/vault/proj.md', isA: 'Project', aliases: [] },
{ ...mockEntry, title: 'Plain Note', filename: 'plain.md', path: '/vault/plain.md', isA: null, aliases: [] },
{ ...mockEntry, title: 'Explicit Note', filename: 'explicit.md', path: '/vault/explicit.md', isA: 'Note', aliases: [] },
{ ...mockEntry, title: 'Test Project', filename: 'proj.md', path: '/vault/proj.md', isA: 'Project', aliases: [] },
{ ...mockEntry, title: 'Test Plain', filename: 'plain.md', path: '/vault/plain.md', isA: null, aliases: [] },
{ ...mockEntry, title: 'Test Explicit', filename: 'explicit.md', path: '/vault/explicit.md', isA: 'Note', aliases: [] },
]
capturedGetItems = null
mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items)
@@ -406,16 +406,21 @@ describe('wikilink autocomplete', () => {
entries={mixedEntries}
/>
)
const items = await capturedGetItems!('Note')
// Every item must have a defined noteType — none should be blank
for (const item of items) {
expect(item.noteType).toBeTruthy()
expect(item.typeColor).toBeTruthy()
}
// Default notes (isA: null) should show 'Note' as their type
const plainNote = items.find((i: { title: string }) => i.title === 'Plain Note')
const items = await capturedGetItems!('Test')
// Typed entries should have noteType and color
const project = items.find((i: { title: string }) => i.title === 'Test Project')
expect(project).toBeDefined()
expect(project!.noteType).toBe('Project')
expect(project!.typeColor).toBeTruthy()
// Untyped entries (isA: null or 'Note') should have no noteType (grey/neutral)
const plainNote = items.find((i: { title: string }) => i.title === 'Test Plain')
expect(plainNote).toBeDefined()
expect(plainNote!.noteType).toBe('Note')
expect(plainNote!.noteType).toBeUndefined()
expect(plainNote!.typeColor).toBeUndefined()
const explicitNote = items.find((i: { title: string }) => i.title === 'Test Explicit')
expect(explicitNote).toBeDefined()
expect(explicitNote!.noteType).toBeUndefined()
expect(explicitNote!.typeColor).toBeUndefined()
mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items)
})

View File

@@ -17,7 +17,8 @@ import { splitFrontmatter, preProcessWikilinks, injectWikilinks, restoreWikilink
import { preFilterWikilinks, deduplicateByPath, disambiguateTitles, MAX_RESULTS, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
import { filterPersonMentions, PERSON_MENTION_MIN_QUERY } from '../utils/personMentionSuggestions'
import { resolveWikilinkColor as resolveColor } from '../utils/wikilinkColors'
import { getTypeColor } from '../utils/typeColors'
import { getTypeColor, buildTypeEntryMap } from '../utils/typeColors'
import { getTypeIcon } from './NoteItem'
import { WikilinkSuggestionMenu, type WikilinkSuggestionItem } from './WikilinkSuggestionMenu'
import './Editor.css'
import './EditorTheme.css'
@@ -138,6 +139,8 @@ function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { e
return () => container.removeEventListener('click', handler as EventListener, true)
}, [editor])
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const baseItems = useMemo(
() => deduplicateByPath(entries.map(entry => ({
title: entry.title,
@@ -167,12 +170,17 @@ function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { e
}))
const filtered = filterSuggestionItems(items, query).slice(0, MAX_RESULTS)
const final = disambiguateTitles(deduplicateByPath(filtered))
return final.map(({ group, ...rest }) => ({
...rest,
noteType: group,
typeColor: getTypeColor(group),
}))
}, [baseItems, editor])
return final.map(({ group, ...rest }) => {
const noteType = group !== 'Note' ? group : undefined
const te = typeEntryMap[group]
return {
...rest,
noteType,
typeColor: noteType ? getTypeColor(group, te?.color) : undefined,
TypeIcon: noteType ? getTypeIcon(group, te?.icon) : undefined,
}
})
}, [baseItems, editor, typeEntryMap])
const getPersonMentionItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => {
if (query.length < PERSON_MENTION_MIN_QUERY) return []
@@ -192,12 +200,17 @@ function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { e
}))
const filtered = filterSuggestionItems(items, query).slice(0, MAX_RESULTS)
const final = disambiguateTitles(deduplicateByPath(filtered))
return final.map(({ group, ...rest }) => ({
...rest,
noteType: group,
typeColor: getTypeColor(group),
}))
}, [baseItems, editor])
return final.map(({ group, ...rest }) => {
const noteType = group !== 'Note' ? group : undefined
const te = typeEntryMap[group]
return {
...rest,
noteType,
typeColor: noteType ? getTypeColor(group, te?.color) : undefined,
TypeIcon: noteType ? getTypeIcon(group, te?.icon) : undefined,
}
})
}, [baseItems, editor, typeEntryMap])
return (
<div ref={containerRef} className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`} style={cssVars as React.CSSProperties}>

View File

@@ -1,6 +1,7 @@
import { useState, useRef, useCallback, useMemo, useEffect } from 'react'
import { useState, useRef, useCallback, useMemo, useEffect, type ComponentType, type SVGAttributes } from 'react'
import type { VaultEntry } from '../types'
import { getTypeColor } from '../utils/typeColors'
import { getTypeIcon } from './NoteItem'
import './WikilinkSuggestionMenu.css'
const MIN_QUERY_LENGTH = 2
@@ -22,6 +23,7 @@ interface MatchedEntry {
title: string
noteType?: string
typeColor?: string
TypeIcon?: ComponentType<SVGAttributes<SVGSVGElement>>
}
function matchEntries(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>, query: string): MatchedEntry[] {
@@ -39,6 +41,7 @@ function matchEntries(entries: VaultEntry[], typeEntryMap: Record<string, VaultE
title: e.title,
noteType,
typeColor: noteType ? getTypeColor(isA, te?.color) : undefined,
TypeIcon: noteType ? getTypeIcon(isA, te?.icon) : undefined,
}
})
}
@@ -135,7 +138,10 @@ export function NoteAutocomplete({ entries, typeEntryMap, value, onChange, onSel
onClick={() => handleSelect(item.title)}
onMouseEnter={() => setSelectedIndex(index)}
>
<span className="wikilink-menu__title">{item.title}</span>
<span className="wikilink-menu__title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{item.TypeIcon && <item.TypeIcon width={14} height={14} style={{ color: item.typeColor, flexShrink: 0 }} />}
{item.title}
</span>
{item.noteType && (
<span className="wikilink-menu__type" style={{ color: item.typeColor }}>
{item.noteType}

View File

@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input'
import {
MagnifyingGlass, Plus, CaretDown, CaretRight, Warning,
} from '@phosphor-icons/react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
import { NoteItem, getTypeIcon } from './NoteItem'
import { SortDropdown } from './SortDropdown'
import {
@@ -106,13 +106,7 @@ function resolveHeaderTitle(selection: SidebarSelection, typeDocument: VaultEntr
}
function useTypeEntryMap(entries: VaultEntry[]) {
return useMemo(() => {
const map: Record<string, VaultEntry> = {}
for (const e of entries) {
if (e.isA === 'Type') map[e.title] = e
}
return map
}, [entries])
return useMemo(() => buildTypeEntryMap(entries), [entries])
}
// --- View sub-components ---

View File

@@ -1,4 +1,4 @@
import { useRef, useEffect } from 'react'
import { useRef, useEffect, type ComponentType, type SVGAttributes } from 'react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
@@ -6,6 +6,7 @@ export interface NoteSearchResultItem {
title: string
noteType?: string
typeColor?: string
TypeIcon?: ComponentType<SVGAttributes<SVGSVGElement>>
}
interface NoteSearchListProps<T extends NoteSearchResultItem> {
@@ -57,8 +58,16 @@ export function NoteSearchList<T extends NoteSearchResultItem>({
onClick={() => onItemClick(item, i)}
onMouseEnter={() => onItemHover?.(i)}
>
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
{item.title}
<span className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm text-foreground">
{item.TypeIcon && (
<item.TypeIcon
width={14}
height={14}
className="shrink-0"
style={item.typeColor ? { color: item.typeColor } : undefined}
/>
)}
<span className="truncate">{item.title}</span>
</span>
{item.noteType && (
<Badge

View File

@@ -1,8 +1,10 @@
import { useRef, useEffect, useCallback } from 'react'
import { useMemo } from 'react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import type { SearchResult, VaultEntry } from '../types'
import { useUnifiedSearch } from '../hooks/useUnifiedSearch'
import { getTypeColor, buildTypeEntryMap } from '../utils/typeColors'
interface SearchPanelProps {
open: boolean
@@ -59,9 +61,14 @@ export function SearchPanel({ open, vaultPath, entries, onSelectNote, onClose }:
return () => window.removeEventListener('keydown', handleKey)
}, [open, results, selectedIndex, handleSelect, setSelectedIndex, onClose])
if (!open) return null
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const entryLookup = useMemo(() => {
const map = new Map<string, { isA: string | null }>()
for (const e of entries) map.set(e.path, { isA: e.isA })
return map
}, [entries])
const entryTypeMap = new Map(entries.map(e => [e.path, e.isA]))
if (!open) return null
return (
<div
@@ -84,7 +91,8 @@ export function SearchPanel({ open, vaultPath, entries, onSelectNote, onClose }:
selectedIndex={selectedIndex}
loading={loading}
elapsedMs={elapsedMs}
entryTypeMap={entryTypeMap}
entryLookup={entryLookup}
typeEntryMap={typeEntryMap}
listRef={listRef}
onSelect={handleSelect}
onHover={setSelectedIndex}
@@ -140,14 +148,15 @@ interface SearchContentProps {
selectedIndex: number
loading: boolean
elapsedMs: number | null
entryTypeMap: Map<string, string | null>
entryLookup: Map<string, { isA: string | null }>
typeEntryMap: Record<string, VaultEntry>
listRef: React.RefObject<HTMLDivElement | null>
onSelect: (result: SearchResult) => void
onHover: (index: number) => void
}
function SearchContent({
query, results, selectedIndex, loading, elapsedMs, entryTypeMap, listRef, onSelect, onHover,
query, results, selectedIndex, loading, elapsedMs, entryLookup, typeEntryMap, listRef, onSelect, onHover,
}: SearchContentProps) {
return (
<div className="flex-1 overflow-y-auto">
@@ -181,7 +190,9 @@ function SearchContent({
</div>
<div ref={listRef}>
{results.map((result, i) => {
const noteType = entryTypeMap.get(result.path) ?? result.noteType
const isA = entryLookup.get(result.path)?.isA ?? result.noteType
const noteType = isA && isA !== 'Note' ? isA : null
const typeColor = noteType ? getTypeColor(isA, typeEntryMap[isA ?? '']?.color) : undefined
return (
<div
key={result.path}
@@ -195,7 +206,7 @@ function SearchContent({
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-foreground">{result.title}</span>
{noteType && (
<Badge variant="secondary" className="text-[10px]">
<Badge variant="secondary" className="text-[10px]" style={typeColor ? { color: typeColor } : undefined}>
{noteType}
</Badge>
)}

View File

@@ -1,6 +1,7 @@
import { useState, useMemo, useRef, useEffect, useCallback, memo } from 'react'
import type { VaultEntry, SidebarSelection } from '../types'
import { resolveIcon } from '../utils/iconRegistry'
import { buildTypeEntryMap } from '../utils/typeColors'
import { TypeCustomizePopover } from './TypeCustomizePopover'
import { useSectionVisibility } from '../hooks/useSectionVisibility'
import {
@@ -62,12 +63,6 @@ function useOutsideClick(ref: React.RefObject<HTMLElement | null>, isOpen: boole
}, [ref, isOpen, onClose])
}
function buildTypeEntryMap(entries: VaultEntry[]): Record<string, VaultEntry> {
const map: Record<string, VaultEntry> = {}
for (const e of entries) { if (e.isA === 'Type') map[e.title] = e }
return map
}
function applyOverrides(typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
return BUILT_IN_SECTION_GROUPS.map((sg) => {
const te = typeEntryMap[sg.type]

View File

@@ -1,3 +1,4 @@
import type { ComponentType, SVGAttributes } from 'react'
import { NoteSearchList } from './NoteSearchList'
import './WikilinkSuggestionMenu.css'
@@ -6,6 +7,7 @@ export interface WikilinkSuggestionItem {
onItemClick: () => void
noteType?: string
typeColor?: string
TypeIcon?: ComponentType<SVGAttributes<SVGSVGElement>>
aliases?: string[]
entryTitle?: string
path?: string

View File

@@ -171,4 +171,21 @@ describe('useNoteSearch', () => {
})
expect(preventDefaultSpy).not.toHaveBeenCalled()
})
it('resolves custom type color from Type entries', () => {
const withTypes: VaultEntry[] = [
makeEntry({ path: '/vault/t/recipe.md', title: 'Recipe', isA: 'Type', color: 'orange', icon: 'cooking-pot' }),
makeEntry({ path: '/vault/pasta.md', title: 'Pasta', isA: 'Recipe', modifiedAt: 1700000010 }),
makeEntry({ path: '/vault/proj.md', title: 'My Project', isA: 'Project', modifiedAt: 1700000009 }),
]
const { result } = renderHook(() => useNoteSearch(withTypes, ''))
const pasta = result.current.results.find(r => r.title === 'Pasta')
expect(pasta?.noteType).toBe('Recipe')
expect(pasta?.typeColor).toBe('var(--accent-orange)')
expect(pasta?.TypeIcon).toBeDefined()
// Built-in type still works
const project = result.current.results.find(r => r.title === 'My Project')
expect(project?.typeColor).toBe('var(--accent-red)')
expect(project?.TypeIcon).toBeDefined()
})
})

View File

@@ -1,7 +1,8 @@
import { useState, useMemo, useCallback, useEffect } from 'react'
import type { VaultEntry } from '../types'
import { fuzzyMatch } from '../utils/fuzzyMatch'
import { getTypeColor } from '../utils/typeColors'
import { getTypeColor, buildTypeEntryMap } from '../utils/typeColors'
import { getTypeIcon } from '../components/NoteItem'
import type { NoteSearchResultItem } from '../components/NoteSearchList'
const DEFAULT_MAX_RESULTS = 20
@@ -10,33 +11,37 @@ export interface NoteSearchResult extends NoteSearchResultItem {
entry: VaultEntry
}
function toResult(e: VaultEntry): NoteSearchResult {
function toResult(e: VaultEntry, typeEntryMap: Record<string, VaultEntry>): NoteSearchResult {
const noteType = e.isA && e.isA !== 'Note' ? e.isA : undefined
const te = typeEntryMap[e.isA ?? '']
return {
entry: e,
title: e.title,
noteType,
typeColor: noteType ? getTypeColor(e.isA) : undefined,
typeColor: noteType ? getTypeColor(e.isA, te?.color) : undefined,
TypeIcon: noteType ? getTypeIcon(e.isA, te?.icon) : undefined,
}
}
export function useNoteSearch(entries: VaultEntry[], query: string, maxResults = DEFAULT_MAX_RESULTS) {
const [selectedIndex, setSelectedIndex] = useState(0)
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const results: NoteSearchResult[] = useMemo(() => {
const mapResult = (e: VaultEntry) => toResult(e, typeEntryMap)
if (!query.trim()) {
return [...entries]
.sort((a, b) => (b.modifiedAt ?? 0) - (a.modifiedAt ?? 0))
.slice(0, maxResults)
.map(toResult)
.map(mapResult)
}
return entries
.map((e) => ({ entry: e, ...fuzzyMatch(query, e.title) }))
.filter((r) => r.match)
.sort((a, b) => b.score - a.score)
.slice(0, maxResults)
.map((r) => toResult(r.entry))
}, [entries, query, maxResults])
.map((r) => mapResult(r.entry))
}, [entries, query, maxResults, typeEntryMap])
useEffect(() => {
setSelectedIndex(0) // eslint-disable-line react-hooks/set-state-in-effect -- reset on query change

View File

@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import { getTypeColor, getTypeLightColor } from './typeColors'
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from './typeColors'
import type { VaultEntry } from '../types'
describe('getTypeColor', () => {
it('returns hardcoded color for known types', () => {
@@ -47,3 +48,31 @@ describe('getTypeLightColor', () => {
expect(getTypeLightColor('Recipe', 'purple')).toBe('var(--accent-purple-light)')
})
})
const baseEntry: VaultEntry = {
path: '', filename: '', title: '', isA: null, aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
modifiedAt: null, createdAt: null, fileSize: 0, snippet: '', relationships: {},
icon: null, color: null, order: null, outgoingLinks: [],
}
describe('buildTypeEntryMap', () => {
it('indexes Type entries by title', () => {
const entries: VaultEntry[] = [
{ ...baseEntry, title: 'Recipe', isA: 'Type', color: 'orange', icon: 'cooking-pot' },
{ ...baseEntry, title: 'My Note', isA: 'Note' },
{ ...baseEntry, title: 'Evergreen', isA: 'Type', color: 'green', icon: 'leaf' },
]
const map = buildTypeEntryMap(entries)
expect(Object.keys(map)).toEqual(['Recipe', 'Evergreen'])
expect(map['Recipe'].color).toBe('orange')
expect(map['Evergreen'].icon).toBe('leaf')
})
it('returns empty map when no Type entries exist', () => {
const entries: VaultEntry[] = [
{ ...baseEntry, title: 'A Note', isA: 'Note' },
]
expect(buildTypeEntryMap(entries)).toEqual({})
})
})

View File

@@ -3,6 +3,15 @@
* Single source of truth for type→color mapping used across Sidebar, NoteList, and Inspector.
*/
import type { VaultEntry } from '../types'
/** Builds a map from type name → Type document entry (for custom color/icon lookup) */
export function buildTypeEntryMap(entries: VaultEntry[]): Record<string, VaultEntry> {
const map: Record<string, VaultEntry> = {}
for (const e of entries) { if (e.isA === 'Type') map[e.title] = e }
return map
}
const TYPE_COLOR_MAP: Record<string, string> = {
Project: 'var(--accent-red)',
Experiment: 'var(--accent-red)',