fix: sidebar section header reflects type icon, color, and label
- Add GearSix icon ('gear-six') to icon registry — was missing, causing
Config type to show FileText fallback instead of its configured icon
- Add 'gray' to ACCENT_COLORS palette with CSS variables — was missing,
causing Config type color to fall back to muted foreground
- Extract sidebar section logic to utils/sidebarSections.ts for testability
- Add Config type + instance to mock entries for browser dev mode
- Add tests: icon resolution, gray color, sidebar section builder
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
61
src/components/Sidebar.test.ts
Normal file
61
src/components/Sidebar.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildSectionGroup } from '../utils/sidebarSections'
|
||||
import { resolveIcon } from '../utils/iconRegistry'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { GearSix, CookingPot, FileText } from '@phosphor-icons/react'
|
||||
|
||||
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: {},
|
||||
wordCount: 0,
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
|
||||
view: null, visible: null, outgoingLinks: [], properties: {},
|
||||
}
|
||||
|
||||
describe('buildSectionGroup', () => {
|
||||
it('uses type entry icon/color/sidebarLabel for custom type', () => {
|
||||
const typeEntryMap: Record<string, VaultEntry> = {
|
||||
Config: { ...baseEntry, title: 'Config', isA: 'Type', icon: 'gear-six', color: 'blue', sidebarLabel: 'Config' },
|
||||
}
|
||||
const group = buildSectionGroup('Config', typeEntryMap)
|
||||
expect(group.label).toBe('Config')
|
||||
expect(group.customColor).toBe('blue')
|
||||
expect(group.Icon).toBe(GearSix)
|
||||
})
|
||||
|
||||
it('uses type entry icon/color for custom type with custom icon', () => {
|
||||
const typeEntryMap: Record<string, VaultEntry> = {
|
||||
Recipe: { ...baseEntry, title: 'Recipe', isA: 'Type', icon: 'cooking-pot', color: 'orange' },
|
||||
}
|
||||
const group = buildSectionGroup('Recipe', typeEntryMap)
|
||||
expect(group.label).toBe('Recipes')
|
||||
expect(group.customColor).toBe('orange')
|
||||
expect(group.Icon).toBe(CookingPot)
|
||||
})
|
||||
|
||||
it('falls back to pluralized name and FileText when no type entry', () => {
|
||||
const group = buildSectionGroup('Widget', {})
|
||||
expect(group.label).toBe('Widgets')
|
||||
expect(group.customColor).toBeNull()
|
||||
expect(group.Icon).toBe(FileText)
|
||||
})
|
||||
|
||||
it('overrides built-in type icon/color when type entry has custom values', () => {
|
||||
const typeEntryMap: Record<string, VaultEntry> = {
|
||||
Project: { ...baseEntry, title: 'Project', isA: 'Type', icon: 'rocket', color: 'green', sidebarLabel: 'My Projects' },
|
||||
}
|
||||
const group = buildSectionGroup('Project', typeEntryMap)
|
||||
expect(group.label).toBe('My Projects')
|
||||
expect(group.customColor).toBe('green')
|
||||
expect(group.Icon).toBe(resolveIcon('rocket'))
|
||||
})
|
||||
|
||||
it('uses gray color for Config type', () => {
|
||||
const typeEntryMap: Record<string, VaultEntry> = {
|
||||
Config: { ...baseEntry, title: 'Config', isA: 'Type', icon: 'gear-six', color: 'gray', sidebarLabel: 'Config' },
|
||||
}
|
||||
const group = buildSectionGroup('Config', typeEntryMap)
|
||||
expect(group.customColor).toBe('gray')
|
||||
})
|
||||
})
|
||||
@@ -1,8 +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 { pluralizeType } from '../hooks/useCommandRegistry'
|
||||
import { buildDynamicSections, sortSections } from '../utils/sidebarSections'
|
||||
import { TypeCustomizePopover } from './TypeCustomizePopover'
|
||||
import {
|
||||
DndContext, closestCenter, KeyboardSensor, PointerSensor,
|
||||
@@ -13,8 +12,7 @@ import {
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import {
|
||||
FileText, Wrench, Flask, Target, ArrowsClockwise,
|
||||
Users, CalendarBlank, Tag, Trash, StackSimple, Archive, CaretLeft, GitDiff, Pulse,
|
||||
FileText, Trash, Archive, CaretLeft, GitDiff, Pulse,
|
||||
} from '@phosphor-icons/react'
|
||||
import { GitCommitHorizontal, SlidersHorizontal } from 'lucide-react'
|
||||
import {
|
||||
@@ -41,20 +39,6 @@ interface SidebarProps {
|
||||
isGitVault?: boolean
|
||||
}
|
||||
|
||||
const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [
|
||||
{ label: 'Projects', type: 'Project', Icon: Wrench },
|
||||
{ label: 'Experiments', type: 'Experiment', Icon: Flask },
|
||||
{ label: 'Responsibilities', type: 'Responsibility', Icon: Target },
|
||||
{ label: 'Procedures', type: 'Procedure', Icon: ArrowsClockwise },
|
||||
{ label: 'People', type: 'Person', Icon: Users },
|
||||
{ label: 'Events', type: 'Event', Icon: CalendarBlank },
|
||||
{ label: 'Topics', type: 'Topic', Icon: Tag },
|
||||
{ label: 'Types', type: 'Type', Icon: StackSimple },
|
||||
]
|
||||
|
||||
/** Metadata lookup for well-known types (icon/label only — NOT used to determine which sections to show) */
|
||||
const BUILT_IN_TYPE_MAP = new Map(BUILT_IN_SECTION_GROUPS.map((sg) => [sg.type, sg]))
|
||||
|
||||
// --- Hooks ---
|
||||
|
||||
function useOutsideClick(ref: React.RefObject<HTMLElement | null>, isOpen: boolean, onClose: () => void) {
|
||||
@@ -68,42 +52,6 @@ function useOutsideClick(ref: React.RefObject<HTMLElement | null>, isOpen: boole
|
||||
}, [ref, isOpen, onClose])
|
||||
}
|
||||
|
||||
/** Collect unique isA values from active (non-trashed, non-archived) entries, excluding generic Note */
|
||||
function collectActiveTypes(entries: VaultEntry[]): Set<string> {
|
||||
const types = new Set<string>()
|
||||
for (const e of entries) {
|
||||
if (e.isA && e.isA !== 'Note' && !e.trashed && !e.archived) types.add(e.isA)
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
/** Build a single SectionGroup for a type, using built-in metadata or Type entry for icon/label */
|
||||
function buildSectionGroup(type: string, typeEntryMap: Record<string, VaultEntry>): SectionGroup {
|
||||
const builtIn = BUILT_IN_TYPE_MAP.get(type)
|
||||
const typeEntry = typeEntryMap[type]
|
||||
const customColor = typeEntry?.color ?? null
|
||||
const label = typeEntry?.sidebarLabel || (builtIn?.label ?? pluralizeType(type))
|
||||
if (builtIn) {
|
||||
const Icon = typeEntry?.icon ? resolveIcon(typeEntry.icon) : builtIn.Icon
|
||||
return { ...builtIn, label, Icon, customColor }
|
||||
}
|
||||
return { label, type, Icon: resolveIcon(typeEntry?.icon ?? null), customColor }
|
||||
}
|
||||
|
||||
/** Build sections dynamically from actual vault entries — only types with ≥1 active note appear */
|
||||
function buildDynamicSections(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
|
||||
const activeTypes = collectActiveTypes(entries)
|
||||
return Array.from(activeTypes, (type) => buildSectionGroup(type, typeEntryMap))
|
||||
}
|
||||
|
||||
function sortSections(groups: SectionGroup[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
|
||||
return [...groups].sort((a, b) => {
|
||||
const orderA = typeEntryMap[a.type]?.order ?? Infinity
|
||||
const orderB = typeEntryMap[b.type]?.order ?? Infinity
|
||||
return orderA !== orderB ? orderA - orderB : a.label.localeCompare(b.label)
|
||||
})
|
||||
}
|
||||
|
||||
function useSidebarSections(entries: VaultEntry[]) {
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
const allSectionGroups = useMemo(() => {
|
||||
|
||||
@@ -82,6 +82,8 @@
|
||||
--accent-teal-light: rgba(49, 151, 149, 0.1);
|
||||
--accent-pink: #D53F8C;
|
||||
--accent-pink-light: rgba(213, 63, 140, 0.1);
|
||||
--accent-gray: #718096;
|
||||
--accent-gray-light: rgba(113, 128, 150, 0.1);
|
||||
--border-primary: #E9E9E7;
|
||||
--border-subtle: #E9E9E7;
|
||||
--border-input: #E9E9E7;
|
||||
|
||||
@@ -826,6 +826,34 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
properties: {},
|
||||
},
|
||||
// --- Custom type documents ---
|
||||
{
|
||||
path: '/Users/luca/Laputa/type/config.md',
|
||||
filename: 'config.md',
|
||||
title: 'Config',
|
||||
isA: 'Type',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: now - 86400 * 30,
|
||||
createdAt: now - 86400 * 365,
|
||||
fileSize: 300,
|
||||
snippet: 'Vault configuration files. These control how AI agents, tools, and other integrations interact with this vault.',
|
||||
wordCount: 25,
|
||||
relationships: {},
|
||||
icon: 'gear-six',
|
||||
color: 'gray',
|
||||
order: 90,
|
||||
sidebarLabel: 'Config',
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/type/recipe.md',
|
||||
filename: 'recipe.md',
|
||||
@@ -883,6 +911,36 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
properties: {},
|
||||
},
|
||||
// --- Instances of custom types ---
|
||||
{
|
||||
path: '/Users/luca/Laputa/config/agents.md',
|
||||
filename: 'agents.md',
|
||||
title: 'Agent Instructions',
|
||||
isA: 'Config',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: now - 86400 * 5,
|
||||
createdAt: now - 86400 * 30,
|
||||
fileSize: 1200,
|
||||
snippet: 'Vault instructions for AI agents. Defines how tools and integrations interact with this vault.',
|
||||
wordCount: 200,
|
||||
relationships: {
|
||||
'Type': ['[[type/config]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/recipe/pasta-carbonara.md',
|
||||
filename: 'pasta-carbonara.md',
|
||||
|
||||
27
src/utils/iconRegistry.test.ts
Normal file
27
src/utils/iconRegistry.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { resolveIcon, ICON_OPTIONS } from './iconRegistry'
|
||||
import { FileText, GearSix, CookingPot } from '@phosphor-icons/react'
|
||||
|
||||
describe('resolveIcon', () => {
|
||||
it('returns FileText for null', () => {
|
||||
expect(resolveIcon(null)).toBe(FileText)
|
||||
})
|
||||
|
||||
it('returns FileText for unknown icon name', () => {
|
||||
expect(resolveIcon('nonexistent-icon')).toBe(FileText)
|
||||
})
|
||||
|
||||
it('resolves gear-six to GearSix', () => {
|
||||
expect(resolveIcon('gear-six')).toBe(GearSix)
|
||||
})
|
||||
|
||||
it('resolves cooking-pot to CookingPot', () => {
|
||||
expect(resolveIcon('cooking-pot')).toBe(CookingPot)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ICON_OPTIONS', () => {
|
||||
it('includes gear-six', () => {
|
||||
expect(ICON_OPTIONS.some((o) => o.name === 'gear-six')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Door, Drop, Envelope, Eye, Eyeglasses, Factory, Fan, Farm, Feather, File,
|
||||
FileCode, FileText, FilmReel, Fingerprint, Fire, FirstAid, Fish, Flag, Flame, Flashlight,
|
||||
Flask, Flower, FlowerLotus, Folder, FolderOpen, Football, ForkKnife, Function, Funnel, GameController,
|
||||
Gauge, Gavel, Gear, Ghost, Gift, GitBranch, Globe, GraduationCap, Guitar, Hammer,
|
||||
Gauge, Gavel, Gear, GearSix, Ghost, Gift, GitBranch, Globe, GraduationCap, Guitar, Hammer,
|
||||
Hand, Handshake, Headphones, Headset, Heart, Heartbeat, Horse, Hospital, Hourglass, House,
|
||||
IceCream, IdentificationBadge, Image, Infinity as InfinityIcon, Island, Joystick, Kanban, Key, Keyboard, Knife,
|
||||
Ladder, Lamp, Laptop, Leaf, Lifebuoy, Lightbulb, Lighthouse, Lightning, Link, List,
|
||||
@@ -174,6 +174,7 @@ export const ICON_OPTIONS: IconEntry[] = [
|
||||
{ name: 'gauge', Icon: Gauge },
|
||||
{ name: 'gavel', Icon: Gavel },
|
||||
{ name: 'gear', Icon: Gear },
|
||||
{ name: 'gear-six', Icon: GearSix },
|
||||
{ name: 'ghost', Icon: Ghost },
|
||||
{ name: 'gift', Icon: Gift },
|
||||
{ name: 'git-branch', Icon: GitBranch },
|
||||
|
||||
65
src/utils/sidebarSections.ts
Normal file
65
src/utils/sidebarSections.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Pure functions for building sidebar section groups from vault entries.
|
||||
* Extracted from Sidebar.tsx for testability.
|
||||
*/
|
||||
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { SectionGroup } from '../components/SidebarParts'
|
||||
import { resolveIcon } from './iconRegistry'
|
||||
import { pluralizeType } from '../hooks/useCommandRegistry'
|
||||
import {
|
||||
Wrench, Flask, Target, ArrowsClockwise,
|
||||
Users, CalendarBlank, Tag, StackSimple,
|
||||
} from '@phosphor-icons/react'
|
||||
|
||||
const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [
|
||||
{ label: 'Projects', type: 'Project', Icon: Wrench },
|
||||
{ label: 'Experiments', type: 'Experiment', Icon: Flask },
|
||||
{ label: 'Responsibilities', type: 'Responsibility', Icon: Target },
|
||||
{ label: 'Procedures', type: 'Procedure', Icon: ArrowsClockwise },
|
||||
{ label: 'People', type: 'Person', Icon: Users },
|
||||
{ label: 'Events', type: 'Event', Icon: CalendarBlank },
|
||||
{ label: 'Topics', type: 'Topic', Icon: Tag },
|
||||
{ label: 'Types', type: 'Type', Icon: StackSimple },
|
||||
]
|
||||
|
||||
/** Metadata lookup for well-known types (icon/label only — NOT used to determine which sections to show) */
|
||||
const BUILT_IN_TYPE_MAP = new Map(BUILT_IN_SECTION_GROUPS.map((sg) => [sg.type, sg]))
|
||||
|
||||
/** Collect unique isA values from active (non-trashed, non-archived) entries, excluding generic Note */
|
||||
export function collectActiveTypes(entries: VaultEntry[]): Set<string> {
|
||||
const types = new Set<string>()
|
||||
for (const e of entries) {
|
||||
if (e.isA && e.isA !== 'Note' && !e.trashed && !e.archived) types.add(e.isA)
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
/** Build a single SectionGroup for a type, using built-in metadata or Type entry for icon/label */
|
||||
export function buildSectionGroup(type: string, typeEntryMap: Record<string, VaultEntry>): SectionGroup {
|
||||
const builtIn = BUILT_IN_TYPE_MAP.get(type)
|
||||
const typeEntry = typeEntryMap[type]
|
||||
const customColor = typeEntry?.color ?? null
|
||||
const label = typeEntry?.sidebarLabel || (builtIn?.label ?? pluralizeType(type))
|
||||
if (builtIn) {
|
||||
const Icon = typeEntry?.icon ? resolveIcon(typeEntry.icon) : builtIn.Icon
|
||||
return { ...builtIn, label, Icon, customColor }
|
||||
}
|
||||
return { label, type, Icon: resolveIcon(typeEntry?.icon ?? null), customColor }
|
||||
}
|
||||
|
||||
/** Build sections dynamically from actual vault entries — only types with ≥1 active note appear */
|
||||
export function buildDynamicSections(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
|
||||
const activeTypes = collectActiveTypes(entries)
|
||||
return Array.from(activeTypes, (type) => buildSectionGroup(type, typeEntryMap))
|
||||
}
|
||||
|
||||
export function sortSections(groups: SectionGroup[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
|
||||
return [...groups].sort((a, b) => {
|
||||
const orderA = typeEntryMap[a.type]?.order ?? Infinity
|
||||
const orderB = typeEntryMap[b.type]?.order ?? Infinity
|
||||
return orderA !== orderB ? orderA - orderB : a.label.localeCompare(b.label)
|
||||
})
|
||||
}
|
||||
|
||||
export { BUILT_IN_SECTION_GROUPS }
|
||||
@@ -28,6 +28,10 @@ describe('getTypeColor', () => {
|
||||
it('ignores invalid custom color key', () => {
|
||||
expect(getTypeColor('Project', 'invalid')).toBe('var(--accent-red)')
|
||||
})
|
||||
|
||||
it('uses gray custom color key', () => {
|
||||
expect(getTypeColor('Config', 'gray')).toBe('var(--accent-gray)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTypeLightColor', () => {
|
||||
@@ -47,6 +51,10 @@ describe('getTypeLightColor', () => {
|
||||
it('uses custom color key for light variant', () => {
|
||||
expect(getTypeLightColor('Recipe', 'purple')).toBe('var(--accent-purple-light)')
|
||||
})
|
||||
|
||||
it('uses gray custom color key for light variant', () => {
|
||||
expect(getTypeLightColor('Config', 'gray')).toBe('var(--accent-gray-light)')
|
||||
})
|
||||
})
|
||||
|
||||
const baseEntry: VaultEntry = {
|
||||
@@ -54,7 +62,8 @@ const baseEntry: VaultEntry = {
|
||||
status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: null, createdAt: null, fileSize: 0, snippet: '', relationships: {},
|
||||
wordCount: 0,
|
||||
icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [],
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
|
||||
view: null, visible: null, outgoingLinks: [], properties: {},
|
||||
}
|
||||
|
||||
describe('buildTypeEntryMap', () => {
|
||||
@@ -76,4 +85,14 @@ describe('buildTypeEntryMap', () => {
|
||||
]
|
||||
expect(buildTypeEntryMap(entries)).toEqual({})
|
||||
})
|
||||
|
||||
it('preserves sidebarLabel in type entry', () => {
|
||||
const entries: VaultEntry[] = [
|
||||
{ ...baseEntry, title: 'Config', isA: 'Type', icon: 'gear-six', color: 'gray', sidebarLabel: 'Config' },
|
||||
]
|
||||
const map = buildTypeEntryMap(entries)
|
||||
expect(map['Config'].sidebarLabel).toBe('Config')
|
||||
expect(map['Config'].icon).toBe('gear-six')
|
||||
expect(map['Config'].color).toBe('gray')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,6 +47,7 @@ export const ACCENT_COLORS: { key: string; label: string; css: string; cssLight:
|
||||
{ key: 'purple', label: 'Purple', css: 'var(--accent-purple)', cssLight: 'var(--accent-purple-light)' },
|
||||
{ key: 'teal', label: 'Teal', css: 'var(--accent-teal)', cssLight: 'var(--accent-teal-light)' },
|
||||
{ key: 'pink', label: 'Pink', css: 'var(--accent-pink)', cssLight: 'var(--accent-pink-light)' },
|
||||
{ key: 'gray', label: 'Gray', css: 'var(--accent-gray)', cssLight: 'var(--accent-gray-light)' },
|
||||
]
|
||||
|
||||
const COLOR_KEY_TO_CSS: Record<string, string> = Object.fromEntries(
|
||||
|
||||
Reference in New Issue
Block a user