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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user