diff --git a/src/App.tsx b/src/App.tsx index 74887052..9a31457e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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, diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index 8286fe47..241c8eb1 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -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, diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts index 8d49deb5..e22d541c 100644 --- a/src/hooks/useCommandRegistry.test.ts +++ b/src/hooks/useCommandRegistry.test.ts @@ -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 => ({ @@ -37,6 +37,7 @@ function makeConfig(overrides: Record = {}) { 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', () => { diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index 69455cf5..a4dc4dc4 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -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 = { + 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() + 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, ]) }