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:
1
design/autocomplete-type-color.pen
Normal file
1
design/autocomplete-type-color.pen
Normal file
File diff suppressed because one or more lines are too long
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)',
|
||||
|
||||
Reference in New Issue
Block a user