feat: type-aware commands in Command Palette (New/List [Type])

Add dynamic "New [Type]" and "List [Type]" commands to the Command
Palette, generated from vault entry types. Typing "new ev" fuzzy-matches
"New Event", typing "list pe" matches "List People".

- extractVaultTypes: scans vault entries for unique isA values
- buildTypeCommands: generates New/List command pairs per type
- pluralizeType: handles Person→People, Responsibility→Responsibilities
- Falls back to default types (Event, Person, Project, Note) when
  vault has no typed notes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-28 19:13:29 +01:00
parent 9338356e20
commit fac1fefcf7
4 changed files with 225 additions and 4 deletions

View File

@@ -266,7 +266,9 @@ function App() {
modifiedCount: vault.modifiedFiles.length, selection,
onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette,
onSearch: dialogs.openSearch,
onCreateNote: notes.handleCreateNoteImmediate, onSave: handleSave,
onCreateNote: notes.handleCreateNoteImmediate,
onCreateNoteOfType: notes.handleCreateNoteImmediate,
onSave: handleSave,
onOpenSettings: dialogs.openSettings,
onTrashNote: entryActions.handleTrashNote, onArchiveNote: entryActions.handleArchiveNote,
onUnarchiveNote: entryActions.handleUnarchiveNote,

View File

@@ -21,6 +21,7 @@ interface AppCommandsConfig {
onCommandPalette: () => void
onSearch: () => void
onCreateNote: () => void
onCreateNoteOfType: (type: string) => void
onSave: () => void
onOpenSettings: () => void
onTrashNote: (path: string) => void
@@ -87,6 +88,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
modifiedCount: config.modifiedCount,
onQuickOpen: config.onQuickOpen,
onCreateNote: config.onCreateNote,
onCreateNoteOfType: config.onCreateNoteOfType,
onSave: config.onSave,
onOpenSettings: config.onOpenSettings,
onTrashNote: config.onTrashNote,

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useCommandRegistry, groupSortKey } from './useCommandRegistry'
import { useCommandRegistry, groupSortKey, pluralizeType, extractVaultTypes } from './useCommandRegistry'
import type { VaultEntry } from '../types'
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
@@ -37,6 +37,7 @@ function makeConfig(overrides: Record<string, unknown> = {}) {
modifiedCount: 0,
onQuickOpen: vi.fn(),
onCreateNote: vi.fn(),
onCreateNoteOfType: vi.fn(),
onSave: vi.fn(),
onOpenSettings: vi.fn(),
onTrashNote: vi.fn(),
@@ -174,6 +175,169 @@ describe('useCommandRegistry', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ zoomLevel: 120 })))
expect(result.current.find(c => c.id === 'zoom-in')!.label).toContain('120%')
})
describe('type-aware commands', () => {
it('generates "New [Type]" commands from vault entries', () => {
const entries = [
makeEntry({ path: '/vault/event/birthday.md', isA: 'Event' }),
makeEntry({ path: '/vault/person/alice.md', isA: 'Person' }),
]
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries })))
const newEvent = result.current.find(c => c.id === 'new-event')
const newPerson = result.current.find(c => c.id === 'new-person')
expect(newEvent).toBeDefined()
expect(newEvent!.label).toBe('New Event')
expect(newEvent!.group).toBe('Note')
expect(newPerson).toBeDefined()
expect(newPerson!.label).toBe('New Person')
})
it('generates "List [Type]" commands with pluralized names', () => {
const entries = [
makeEntry({ path: '/vault/event/birthday.md', isA: 'Event' }),
makeEntry({ path: '/vault/person/alice.md', isA: 'Person' }),
]
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries })))
const listEvents = result.current.find(c => c.id === 'list-event')
const listPeople = result.current.find(c => c.id === 'list-person')
expect(listEvents).toBeDefined()
expect(listEvents!.label).toBe('List Events')
expect(listEvents!.group).toBe('Navigation')
expect(listPeople).toBeDefined()
expect(listPeople!.label).toBe('List People')
})
it('calls onCreateNoteOfType when "New [Type]" executes', () => {
const onCreateNoteOfType = vi.fn()
const entries = [makeEntry({ path: '/vault/event/birthday.md', isA: 'Event' })]
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries, onCreateNoteOfType })))
result.current.find(c => c.id === 'new-event')!.execute()
expect(onCreateNoteOfType).toHaveBeenCalledWith('Event')
})
it('calls onSelect with sectionGroup when "List [Type]" executes', () => {
const onSelect = vi.fn()
const entries = [makeEntry({ path: '/vault/person/alice.md', isA: 'Person' })]
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries, onSelect })))
result.current.find(c => c.id === 'list-person')!.execute()
expect(onSelect).toHaveBeenCalledWith({ kind: 'sectionGroup', type: 'Person' })
})
it('uses default types when vault has no typed notes', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries: [] })))
expect(result.current.find(c => c.id === 'new-event')).toBeDefined()
expect(result.current.find(c => c.id === 'new-person')).toBeDefined()
expect(result.current.find(c => c.id === 'new-project')).toBeDefined()
expect(result.current.find(c => c.id === 'new-note')).toBeDefined()
})
it('excludes Type entries from vault type list', () => {
const entries = [
makeEntry({ path: '/vault/type/event.md', isA: 'Type', title: 'Event' }),
makeEntry({ path: '/vault/event/birthday.md', isA: 'Event' }),
]
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries })))
expect(result.current.find(c => c.id === 'new-type')).toBeUndefined()
})
it('excludes trashed entries from type extraction', () => {
const entries = [
makeEntry({ path: '/vault/event/old.md', isA: 'Event', trashed: true }),
]
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries })))
// Should fall back to defaults since the only Event entry is trashed
expect(result.current.find(c => c.id === 'new-event')).toBeDefined()
expect(result.current.find(c => c.id === 'new-project')).toBeDefined()
})
it('deduplicates types from multiple entries', () => {
const entries = [
makeEntry({ path: '/vault/event/a.md', isA: 'Event' }),
makeEntry({ path: '/vault/event/b.md', isA: 'Event' }),
makeEntry({ path: '/vault/event/c.md', isA: 'Event' }),
]
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries })))
const eventCmds = result.current.filter(c => c.id === 'new-event')
expect(eventCmds).toHaveLength(1)
})
})
})
describe('pluralizeType', () => {
it('handles regular plurals', () => {
expect(pluralizeType('Event')).toBe('Events')
expect(pluralizeType('Project')).toBe('Projects')
expect(pluralizeType('Note')).toBe('Notes')
expect(pluralizeType('Topic')).toBe('Topics')
})
it('handles special plurals', () => {
expect(pluralizeType('Person')).toBe('People')
expect(pluralizeType('Responsibility')).toBe('Responsibilities')
})
it('handles words ending in y', () => {
expect(pluralizeType('Category')).toBe('Categories')
})
it('handles words ending in s/x/ch/sh', () => {
expect(pluralizeType('Process')).toBe('Processes')
expect(pluralizeType('Box')).toBe('Boxes')
})
it('does not double-pluralize words ending in vowel+y', () => {
expect(pluralizeType('Key')).toBe('Keys')
expect(pluralizeType('Day')).toBe('Days')
})
})
describe('extractVaultTypes', () => {
it('extracts unique types from entries', () => {
const entries = [
makeEntry({ isA: 'Event' }),
makeEntry({ isA: 'Person' }),
makeEntry({ isA: 'Event' }),
]
const types = extractVaultTypes(entries)
expect(types).toEqual(['Event', 'Person'])
})
it('returns default types when no entries', () => {
const types = extractVaultTypes([])
expect(types).toContain('Event')
expect(types).toContain('Person')
expect(types).toContain('Project')
expect(types).toContain('Note')
expect(types).toHaveLength(4)
})
it('excludes Type entries', () => {
const entries = [
makeEntry({ isA: 'Type' }),
makeEntry({ isA: 'Event' }),
]
const types = extractVaultTypes(entries)
expect(types).toEqual(['Event'])
expect(types).not.toContain('Type')
})
it('excludes trashed entries', () => {
const entries = [makeEntry({ isA: 'Event', trashed: true })]
const types = extractVaultTypes(entries)
// Falls back to defaults since the only typed entry is trashed
expect(types).toContain('Event')
expect(types).toContain('Person')
expect(types).toHaveLength(4)
})
it('returns sorted types', () => {
const entries = [
makeEntry({ isA: 'Procedure' }),
makeEntry({ isA: 'Event' }),
makeEntry({ isA: 'Note' }),
]
expect(extractVaultTypes(entries)).toEqual(['Event', 'Note', 'Procedure'])
})
})
describe('groupSortKey', () => {

View File

@@ -21,6 +21,7 @@ interface CommandRegistryConfig {
onQuickOpen: () => void
onCreateNote: () => void
onCreateNoteOfType: (type: string) => void
onSave: () => void
onOpenSettings: () => void
onTrashNote: (path: string) => void
@@ -41,16 +42,62 @@ interface CommandRegistryConfig {
canGoForward?: boolean
}
const PLURAL_OVERRIDES: Record<string, string> = {
Person: 'People',
Responsibility: 'Responsibilities',
}
const DEFAULT_TYPES = ['Event', 'Person', 'Project', 'Note']
export function pluralizeType(type: string): string {
if (PLURAL_OVERRIDES[type]) return PLURAL_OVERRIDES[type]
if (type.endsWith('s') || type.endsWith('x') || type.endsWith('ch') || type.endsWith('sh')) return `${type}es`
if (type.endsWith('y') && !/[aeiou]y$/i.test(type)) return `${type.slice(0, -1)}ies`
return `${type}s`
}
export function extractVaultTypes(entries: VaultEntry[]): string[] {
const typeSet = new Set<string>()
for (const e of entries) {
if (e.isA && e.isA !== 'Type' && !e.trashed) typeSet.add(e.isA)
}
if (typeSet.size === 0) return DEFAULT_TYPES
return Array.from(typeSet).sort()
}
const GROUP_ORDER: CommandGroup[] = ['Navigation', 'Note', 'Git', 'View', 'Settings']
export function groupSortKey(group: CommandGroup): number {
return GROUP_ORDER.indexOf(group)
}
export function buildTypeCommands(
types: string[],
onCreateNoteOfType: (type: string) => void,
onSelect: (sel: SidebarSelection) => void,
): CommandAction[] {
return types.flatMap((type) => {
const slug = type.toLowerCase().replace(/\s+/g, '-')
const plural = pluralizeType(type)
return [
{
id: `new-${slug}`, label: `New ${type}`, group: 'Note' as CommandGroup,
keywords: ['new', 'create', type.toLowerCase()],
enabled: true, execute: () => onCreateNoteOfType(type),
},
{
id: `list-${slug}`, label: `List ${plural}`, group: 'Navigation' as CommandGroup,
keywords: ['list', 'show', 'filter', type.toLowerCase(), plural.toLowerCase()],
enabled: true, execute: () => onSelect({ kind: 'sectionGroup', type }),
},
]
})
}
export function useCommandRegistry(config: CommandRegistryConfig): CommandAction[] {
const {
activeTabPath, entries, modifiedCount,
onQuickOpen, onCreateNote, onSave, onOpenSettings,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
onTrashNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onSetViewMode, onToggleInspector,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
@@ -66,6 +113,8 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
)
const isArchived = activeEntry?.archived ?? false
const vaultTypes = useMemo(() => extractVaultTypes(entries), [entries])
return useMemo(() => {
const cmds: CommandAction[] = [
// Navigation
@@ -104,16 +153,20 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
// Settings
{ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings },
// Type-aware: "New [Type]" and "List [Type]"
...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
]
return cmds
}, [
hasActiveNote, activeTabPath, isArchived, modifiedCount,
onQuickOpen, onCreateNote, onSave, onOpenSettings,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
onTrashNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onSetViewMode, onToggleInspector,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onCloseTab,
onGoBack, onGoForward, canGoBack, canGoForward,
vaultTypes,
])
}