1007 lines
37 KiB
TypeScript
1007 lines
37 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest'
|
|
import { renderHook } from '@testing-library/react'
|
|
import { useCommandRegistry, buildTypeCommands, extractVaultTypes, pluralizeType, groupSortKey } from './useCommandRegistry'
|
|
import type { CommandAction } from './useCommandRegistry'
|
|
import { NEW_AI_CHAT_EVENT, OPEN_AI_CHAT_EVENT } from '../utils/aiPromptBridge'
|
|
import { formatShortcutDisplay } from './appCommandCatalog'
|
|
|
|
function makeConfig(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
activeTabPath: '/vault/test.md',
|
|
entries: [],
|
|
modifiedCount: 0,
|
|
onQuickOpen: vi.fn(),
|
|
onCreateNote: vi.fn(),
|
|
onCreateNoteOfType: vi.fn(),
|
|
onSave: vi.fn(),
|
|
onPastePlainText: vi.fn(),
|
|
onOpenSettings: vi.fn(),
|
|
onDeleteNote: vi.fn(),
|
|
onArchiveNote: vi.fn(),
|
|
onUnarchiveNote: vi.fn(),
|
|
onToggleOrganized: vi.fn(),
|
|
onCommitPush: vi.fn(),
|
|
onResolveConflicts: vi.fn(),
|
|
onSetViewMode: vi.fn(),
|
|
onToggleInspector: vi.fn(),
|
|
onToggleDiff: vi.fn(),
|
|
onToggleRawEditor: vi.fn(),
|
|
noteWidth: 'normal',
|
|
defaultNoteWidth: 'normal',
|
|
onSetNoteWidth: vi.fn(),
|
|
onSetDefaultNoteWidth: vi.fn(),
|
|
onToggleAIChat: vi.fn(),
|
|
onOpenVault: vi.fn(),
|
|
activeNoteModified: false,
|
|
onZoomIn: vi.fn(),
|
|
onZoomOut: vi.fn(),
|
|
onZoomReset: vi.fn(),
|
|
zoomLevel: 100,
|
|
onSelect: vi.fn(),
|
|
onCloseTab: vi.fn(),
|
|
onGoBack: vi.fn(),
|
|
onGoForward: vi.fn(),
|
|
canGoBack: false,
|
|
canGoForward: false,
|
|
onCheckForUpdates: vi.fn(),
|
|
onCreateType: vi.fn(),
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function findCommand(commands: CommandAction[], id: string): CommandAction | undefined {
|
|
return commands.find(c => c.id === id)
|
|
}
|
|
|
|
function expectFolderCommandStates(overrides: Record<string, unknown>, expected: {
|
|
copy: boolean
|
|
delete: boolean
|
|
rename: boolean
|
|
reveal: boolean
|
|
}) {
|
|
const { result } = renderHook(() => useCommandRegistry(makeConfig(overrides)))
|
|
|
|
expect(findCommand(result.current, 'reveal-selected-folder')?.enabled).toBe(expected.reveal)
|
|
expect(findCommand(result.current, 'copy-selected-folder-path')?.enabled).toBe(expected.copy)
|
|
expect(findCommand(result.current, 'rename-folder')?.enabled).toBe(expected.rename)
|
|
expect(findCommand(result.current, 'delete-folder')?.enabled).toBe(expected.delete)
|
|
}
|
|
|
|
describe('useCommandRegistry', () => {
|
|
it('includes resolve-conflicts command in Git group', () => {
|
|
const config = makeConfig()
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'resolve-conflicts')
|
|
expect(cmd).toBeDefined()
|
|
expect(cmd!.group).toBe('Git')
|
|
expect(cmd!.label).toBe('Resolve Conflicts')
|
|
})
|
|
|
|
it('resolve-conflicts is always enabled', () => {
|
|
const config = makeConfig()
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'resolve-conflicts')
|
|
expect(cmd!.enabled).toBe(true)
|
|
})
|
|
|
|
it('resolve-conflicts executes onResolveConflicts callback', () => {
|
|
const onResolveConflicts = vi.fn()
|
|
const config = makeConfig({ onResolveConflicts })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'resolve-conflicts')
|
|
cmd!.execute()
|
|
expect(onResolveConflicts).toHaveBeenCalled()
|
|
})
|
|
|
|
it('resolve-conflicts has searchable keywords', () => {
|
|
const config = makeConfig()
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'resolve-conflicts')
|
|
expect(cmd!.keywords).toContain('conflict')
|
|
expect(cmd!.keywords).toContain('merge')
|
|
})
|
|
|
|
it('commit-push is enabled when modifiedCount > 0', () => {
|
|
const config = makeConfig({ modifiedCount: 5 })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'commit-push')
|
|
expect(cmd!.enabled).toBe(true)
|
|
})
|
|
|
|
it('commit-push is disabled when modifiedCount is 0', () => {
|
|
const config = makeConfig({ modifiedCount: 0 })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'commit-push')
|
|
expect(cmd!.enabled).toBe(false)
|
|
})
|
|
|
|
it('includes initialize-git command for non-git vaults', () => {
|
|
const onInitializeGit = vi.fn()
|
|
const config = makeConfig({ isGitVault: false, onInitializeGit })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'initialize-git')
|
|
|
|
expect(cmd).toBeDefined()
|
|
expect(cmd!.group).toBe('Git')
|
|
expect(cmd!.label).toBe('Initialize Git for Current Vault')
|
|
expect(cmd!.enabled).toBe(true)
|
|
|
|
cmd!.execute()
|
|
expect(onInitializeGit).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('hides remote git commands for non-git vaults', () => {
|
|
const config = makeConfig({ isGitVault: false, modifiedCount: 5 })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
|
|
expect(findCommand(result.current, 'commit-push')).toBeUndefined()
|
|
expect(findCommand(result.current, 'git-pull')).toBeUndefined()
|
|
expect(findCommand(result.current, 'add-remote')).toBeUndefined()
|
|
expect(findCommand(result.current, 'view-changes')).toBeUndefined()
|
|
})
|
|
|
|
it('hides all Git commands when Git features are disabled globally', () => {
|
|
const config = makeConfig({ gitFeaturesEnabled: false, isGitVault: false, modifiedCount: 5 })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
|
|
expect(findCommand(result.current, 'initialize-git')).toBeUndefined()
|
|
expect(findCommand(result.current, 'commit-push')).toBeUndefined()
|
|
expect(findCommand(result.current, 'git-pull')).toBeUndefined()
|
|
expect(findCommand(result.current, 'add-remote')).toBeUndefined()
|
|
expect(findCommand(result.current, 'view-changes')).toBeUndefined()
|
|
})
|
|
|
|
it('exposes a single pull command for all active repositories', () => {
|
|
const onPull = vi.fn()
|
|
const onPullRepository = vi.fn()
|
|
const config = makeConfig({
|
|
gitRepositories: [
|
|
{ path: '/vault/main', label: 'Main Vault', defaultForNewNotes: true },
|
|
{ path: '/vault/brian', label: 'Brian', defaultForNewNotes: false },
|
|
],
|
|
onPull,
|
|
onPullRepository,
|
|
})
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
|
|
expect(findCommand(result.current, 'git-pull-0')).toBeUndefined()
|
|
expect(findCommand(result.current, 'git-pull-1')).toBeUndefined()
|
|
|
|
const pull = findCommand(result.current, 'git-pull')
|
|
expect(pull?.label).toBe('Pull from Remote')
|
|
pull?.execute()
|
|
expect(onPull).toHaveBeenCalled()
|
|
expect(onPullRepository).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('resolve-conflicts stays enabled across rerenders', () => {
|
|
const config = makeConfig()
|
|
const { result, rerender } = renderHook(
|
|
(props) => useCommandRegistry(props),
|
|
{ initialProps: config },
|
|
)
|
|
expect(findCommand(result.current, 'resolve-conflicts')!.enabled).toBe(true)
|
|
|
|
rerender(makeConfig())
|
|
expect(findCommand(result.current, 'resolve-conflicts')!.enabled).toBe(true)
|
|
})
|
|
|
|
it('includes set-note-icon command in Note group', () => {
|
|
const config = makeConfig({ onSetNoteIcon: vi.fn() })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'set-note-icon')
|
|
expect(cmd).toBeDefined()
|
|
expect(cmd!.group).toBe('Note')
|
|
expect(cmd!.label).toBe('Set Note Icon')
|
|
})
|
|
|
|
it('set-note-icon is enabled when active note and callback exist', () => {
|
|
const config = makeConfig({ onSetNoteIcon: vi.fn() })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'set-note-icon')
|
|
expect(cmd!.enabled).toBe(true)
|
|
})
|
|
|
|
it('set-note-icon is disabled when no active note', () => {
|
|
const config = makeConfig({ activeTabPath: null, onSetNoteIcon: vi.fn() })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'set-note-icon')
|
|
expect(cmd!.enabled).toBe(false)
|
|
})
|
|
|
|
it('remove-note-icon is enabled when active note has icon', () => {
|
|
const config = makeConfig({ onRemoveNoteIcon: vi.fn(), activeNoteHasIcon: true })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'remove-note-icon')
|
|
expect(cmd!.enabled).toBe(true)
|
|
})
|
|
|
|
it('remove-note-icon is disabled when active note has no icon', () => {
|
|
const config = makeConfig({ onRemoveNoteIcon: vi.fn(), activeNoteHasIcon: false })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'remove-note-icon')
|
|
expect(cmd!.enabled).toBe(false)
|
|
})
|
|
|
|
it('set-note-icon executes callback', () => {
|
|
const onSetNoteIcon = vi.fn()
|
|
const config = makeConfig({ onSetNoteIcon })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
findCommand(result.current, 'set-note-icon')!.execute()
|
|
expect(onSetNoteIcon).toHaveBeenCalled()
|
|
})
|
|
|
|
it('includes Change Note Type when the active note can be retargeted', () => {
|
|
const onChangeNoteType = vi.fn()
|
|
const config = makeConfig({ onChangeNoteType })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'change-note-type')
|
|
|
|
expect(cmd).toBeDefined()
|
|
expect(cmd!.enabled).toBe(true)
|
|
|
|
cmd!.execute()
|
|
expect(onChangeNoteType).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('enables Move Note to Folder only when another folder destination exists', () => {
|
|
const onMoveNoteToFolder = vi.fn()
|
|
const { result, rerender } = renderHook(
|
|
(props) => useCommandRegistry(props),
|
|
{
|
|
initialProps: makeConfig({
|
|
onMoveNoteToFolder,
|
|
canMoveNoteToFolder: true,
|
|
}),
|
|
},
|
|
)
|
|
|
|
expect(findCommand(result.current, 'move-note-to-folder')?.enabled).toBe(true)
|
|
findCommand(result.current, 'move-note-to-folder')!.execute()
|
|
expect(onMoveNoteToFolder).toHaveBeenCalledOnce()
|
|
|
|
rerender(makeConfig({
|
|
onMoveNoteToFolder,
|
|
canMoveNoteToFolder: false,
|
|
}))
|
|
expect(findCommand(result.current, 'move-note-to-folder')?.enabled).toBe(false)
|
|
})
|
|
|
|
it('includes restore deleted note command when provided', () => {
|
|
const config = makeConfig({ onRestoreDeletedNote: vi.fn(), canRestoreDeletedNote: true })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'restore-deleted-note')
|
|
expect(cmd).toBeDefined()
|
|
expect(cmd!.enabled).toBe(true)
|
|
})
|
|
|
|
it('disables restore deleted note when there is no deleted preview', () => {
|
|
const config = makeConfig({ onRestoreDeletedNote: vi.fn(), canRestoreDeletedNote: false })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'restore-deleted-note')
|
|
expect(cmd!.enabled).toBe(false)
|
|
})
|
|
|
|
it('includes Customize Inbox columns when the Inbox action is available', () => {
|
|
const onCustomizeNoteListColumns = vi.fn()
|
|
const config = makeConfig({
|
|
selection: { kind: 'filter', filter: 'inbox' },
|
|
onCustomizeNoteListColumns,
|
|
canCustomizeNoteListColumns: true,
|
|
})
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'customize-note-list-columns')
|
|
expect(cmd).toBeDefined()
|
|
expect(cmd!.enabled).toBe(true)
|
|
expect(cmd!.label).toBe('Customize Inbox columns')
|
|
|
|
cmd!.execute()
|
|
expect(onCustomizeNoteListColumns).toHaveBeenCalled()
|
|
})
|
|
|
|
it('includes Customize All Notes columns in the all-notes view', () => {
|
|
const config = makeConfig({
|
|
selection: { kind: 'filter', filter: 'all' },
|
|
onCustomizeNoteListColumns: vi.fn(),
|
|
canCustomizeNoteListColumns: true,
|
|
})
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'customize-note-list-columns')
|
|
expect(cmd).toBeDefined()
|
|
expect(cmd!.enabled).toBe(true)
|
|
expect(cmd!.label).toBe('Customize All Notes columns')
|
|
})
|
|
|
|
it('disables note-list column customization outside supported views', () => {
|
|
const config = makeConfig({
|
|
selection: { kind: 'sectionGroup', type: 'Book' },
|
|
onCustomizeNoteListColumns: vi.fn(),
|
|
canCustomizeNoteListColumns: false,
|
|
})
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'customize-note-list-columns')
|
|
expect(cmd!.enabled).toBe(false)
|
|
})
|
|
|
|
it('shows Cmd+E on toggle organized and removes it from archive note', () => {
|
|
const config = makeConfig()
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
expect(findCommand(result.current, 'toggle-organized')?.shortcut).toBe(
|
|
formatShortcutDisplay({ display: '⌘E' }),
|
|
)
|
|
expect(findCommand(result.current, 'archive-note')?.shortcut).toBeUndefined()
|
|
})
|
|
|
|
it('removes AI commands when AI features are disabled', () => {
|
|
const config = makeConfig({
|
|
aiFeaturesEnabled: false,
|
|
onToggleAIChat: vi.fn(),
|
|
onOpenAiAgents: vi.fn(),
|
|
aiAgentsStatus: {
|
|
claude_code: { status: 'installed', version: '1.0.0' },
|
|
codex: { status: 'missing', version: null },
|
|
opencode: { status: 'missing', version: null },
|
|
pi: { status: 'missing', version: null },
|
|
gemini: { status: 'missing', version: null },
|
|
},
|
|
selectedAiAgent: 'claude_code',
|
|
})
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
|
|
expect(findCommand(result.current, 'toggle-ai-panel')).toBeUndefined()
|
|
expect(findCommand(result.current, 'new-ai-chat')).toBeUndefined()
|
|
expect(findCommand(result.current, 'open-ai-agents')).toBeUndefined()
|
|
})
|
|
|
|
it('exposes active file actions when a note is selected', () => {
|
|
const onRevealActiveFile = vi.fn()
|
|
const onCopyActiveFilePath = vi.fn()
|
|
const config = makeConfig({
|
|
activeTabPath: '/vault/current.md',
|
|
entries: [{ path: '/vault/current.md', title: 'Current', fileKind: 'markdown' }],
|
|
onRevealActiveFile,
|
|
onCopyActiveFilePath,
|
|
})
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
|
|
expect(findCommand(result.current, 'reveal-active-file')).toMatchObject({
|
|
enabled: true,
|
|
group: 'Note',
|
|
label: 'Reveal in Finder',
|
|
})
|
|
expect(findCommand(result.current, 'copy-active-file-path')).toMatchObject({
|
|
enabled: true,
|
|
group: 'Note',
|
|
label: 'Copy File Path',
|
|
})
|
|
|
|
findCommand(result.current, 'reveal-active-file')!.execute()
|
|
findCommand(result.current, 'copy-active-file-path')!.execute()
|
|
|
|
expect(onRevealActiveFile).toHaveBeenCalledWith('/vault/current.md')
|
|
expect(onCopyActiveFilePath).toHaveBeenCalledWith('/vault/current.md')
|
|
})
|
|
|
|
it('only enables external open for non-markdown active files', () => {
|
|
const onOpenActiveFileExternal = vi.fn()
|
|
const { result, rerender } = renderHook(
|
|
(props) => useCommandRegistry(props),
|
|
{
|
|
initialProps: makeConfig({
|
|
activeTabPath: '/vault/current.md',
|
|
entries: [{ path: '/vault/current.md', title: 'Current', fileKind: 'markdown' }],
|
|
onOpenActiveFileExternal,
|
|
}),
|
|
},
|
|
)
|
|
|
|
expect(findCommand(result.current, 'open-active-file-external')?.enabled).toBe(false)
|
|
|
|
rerender(makeConfig({
|
|
activeTabPath: '/vault/Attachments/photo.png',
|
|
entries: [{ path: '/vault/Attachments/photo.png', title: 'photo.png', fileKind: 'binary' }],
|
|
onOpenActiveFileExternal,
|
|
}))
|
|
|
|
const command = findCommand(result.current, 'open-active-file-external')!
|
|
expect(command.enabled).toBe(true)
|
|
command.execute()
|
|
expect(onOpenActiveFileExternal).toHaveBeenCalledWith('/vault/Attachments/photo.png')
|
|
})
|
|
|
|
it('disables Toggle Raw Editor when the active file cannot switch to rich mode', () => {
|
|
const config = makeConfig({ onToggleRawEditor: undefined })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
expect(findCommand(result.current, 'toggle-raw-editor')?.enabled).toBe(false)
|
|
})
|
|
|
|
it('exposes command palette actions for note width modes', () => {
|
|
const onSetNoteWidth = vi.fn()
|
|
const config = makeConfig({ noteWidth: 'normal', onSetNoteWidth })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'set-note-width-wide')
|
|
|
|
expect(cmd).toBeDefined()
|
|
expect(cmd!.group).toBe('View')
|
|
expect(cmd!.label).toBe('Use Wide Note Width')
|
|
expect(cmd!.keywords).toContain('wide')
|
|
|
|
cmd!.execute()
|
|
|
|
expect(onSetNoteWidth).toHaveBeenCalledWith('wide')
|
|
})
|
|
|
|
it('exposes command palette actions for moving the selected saved view', () => {
|
|
const onMoveSelectedViewUp = vi.fn()
|
|
const onMoveSelectedViewDown = vi.fn()
|
|
const config = makeConfig({
|
|
selectedViewName: 'Active Projects',
|
|
onMoveSelectedViewUp,
|
|
onMoveSelectedViewDown,
|
|
canMoveSelectedViewUp: true,
|
|
canMoveSelectedViewDown: true,
|
|
})
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
|
|
const moveUp = findCommand(result.current, 'move-view-up')
|
|
const moveDown = findCommand(result.current, 'move-view-down')
|
|
|
|
expect(moveUp).toMatchObject({
|
|
label: 'Move Active Projects Up',
|
|
group: 'View',
|
|
enabled: true,
|
|
})
|
|
expect(moveDown).toMatchObject({
|
|
label: 'Move Active Projects Down',
|
|
group: 'View',
|
|
enabled: true,
|
|
})
|
|
|
|
moveUp!.execute()
|
|
moveDown!.execute()
|
|
|
|
expect(onMoveSelectedViewUp).toHaveBeenCalledOnce()
|
|
expect(onMoveSelectedViewDown).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('disables saved view move commands at list boundaries', () => {
|
|
const config = makeConfig({
|
|
selectedViewName: 'Top View',
|
|
onMoveSelectedViewUp: vi.fn(),
|
|
onMoveSelectedViewDown: vi.fn(),
|
|
canMoveSelectedViewUp: false,
|
|
canMoveSelectedViewDown: true,
|
|
})
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
|
|
expect(findCommand(result.current, 'move-view-up')?.enabled).toBe(false)
|
|
expect(findCommand(result.current, 'move-view-down')?.enabled).toBe(true)
|
|
})
|
|
|
|
it('disables the command for the active note width mode', () => {
|
|
const config = makeConfig({ noteWidth: 'wide' })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
|
|
expect(findCommand(result.current, 'set-note-width-wide')?.enabled).toBe(false)
|
|
expect(findCommand(result.current, 'set-note-width-normal')?.enabled).toBe(true)
|
|
})
|
|
|
|
it('exposes command palette actions for the default note width', () => {
|
|
const onSetDefaultNoteWidth = vi.fn()
|
|
const config = makeConfig({ defaultNoteWidth: 'normal', onSetDefaultNoteWidth })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'set-default-note-width-wide')
|
|
|
|
expect(cmd).toMatchObject({
|
|
label: 'Use Wide Note Width by Default',
|
|
group: 'View',
|
|
enabled: true,
|
|
})
|
|
|
|
cmd!.execute()
|
|
expect(onSetDefaultNoteWidth).toHaveBeenCalledWith('wide')
|
|
})
|
|
|
|
it('exposes command palette actions for light and dark mode', () => {
|
|
const onSetThemeMode = vi.fn()
|
|
const config = makeConfig({ onSetThemeMode })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const lightMode = findCommand(result.current, 'use-light-mode')
|
|
const darkMode = findCommand(result.current, 'use-dark-mode')
|
|
const systemMode = findCommand(result.current, 'use-system-theme-mode')
|
|
|
|
expect(lightMode).toMatchObject({
|
|
label: 'Use Light Mode',
|
|
enabled: true,
|
|
group: 'Settings',
|
|
})
|
|
expect(darkMode).toMatchObject({
|
|
label: 'Use Dark Mode',
|
|
enabled: true,
|
|
group: 'Settings',
|
|
})
|
|
expect(systemMode).toMatchObject({
|
|
label: 'Use System Theme',
|
|
enabled: true,
|
|
group: 'Settings',
|
|
})
|
|
|
|
lightMode?.execute()
|
|
darkMode?.execute()
|
|
systemMode?.execute()
|
|
|
|
expect(onSetThemeMode).toHaveBeenNthCalledWith(1, 'light')
|
|
expect(onSetThemeMode).toHaveBeenNthCalledWith(2, 'dark')
|
|
expect(onSetThemeMode).toHaveBeenNthCalledWith(3, 'system')
|
|
})
|
|
|
|
it('includes a New AI chat command that opens and resets the panel session', () => {
|
|
const config = makeConfig()
|
|
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'new-ai-chat')
|
|
|
|
expect(cmd).toBeDefined()
|
|
expect(cmd!.group).toBe('View')
|
|
expect(cmd!.label).toBe('New AI chat')
|
|
expect(cmd!.enabled).toBe(true)
|
|
|
|
cmd!.execute()
|
|
|
|
expect(dispatchSpy).toHaveBeenCalledWith(expect.objectContaining({ type: NEW_AI_CHAT_EVENT }))
|
|
expect(dispatchSpy).toHaveBeenCalledWith(expect.objectContaining({ type: OPEN_AI_CHAT_EVENT }))
|
|
dispatchSpy.mockRestore()
|
|
})
|
|
|
|
it('omits Inbox navigation when the explicit workflow is disabled', () => {
|
|
const config = makeConfig({ showInbox: false })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
expect(findCommand(result.current, 'go-inbox')).toBeUndefined()
|
|
})
|
|
|
|
it('enables folder commands when a folder is selected', () => {
|
|
expectFolderCommandStates({
|
|
selection: { kind: 'folder', path: 'projects' },
|
|
onRenameFolder: vi.fn(),
|
|
onDeleteFolder: vi.fn(),
|
|
onRevealSelectedFolder: vi.fn(),
|
|
onCopySelectedFolderPath: vi.fn(),
|
|
}, { copy: true, delete: true, rename: true, reveal: true })
|
|
})
|
|
|
|
it('disables folder commands outside folder selection', () => {
|
|
expectFolderCommandStates({
|
|
selection: { kind: 'filter', filter: 'all' },
|
|
onRenameFolder: vi.fn(),
|
|
onDeleteFolder: vi.fn(),
|
|
onRevealSelectedFolder: vi.fn(),
|
|
onCopySelectedFolderPath: vi.fn(),
|
|
}, { copy: false, delete: false, rename: false, reveal: false })
|
|
})
|
|
|
|
it('keeps root folder reveal and copy commands enabled without destructive actions', () => {
|
|
expectFolderCommandStates({
|
|
selection: { kind: 'folder', path: '', rootPath: '/Users/luca/Laputa' },
|
|
onRenameFolder: vi.fn(),
|
|
onDeleteFolder: vi.fn(),
|
|
onRevealSelectedFolder: vi.fn(),
|
|
onCopySelectedFolderPath: vi.fn(),
|
|
}, { copy: true, delete: false, rename: false, reveal: true })
|
|
})
|
|
|
|
it('executes folder command callbacks', () => {
|
|
const onRenameFolder = vi.fn()
|
|
const onDeleteFolder = vi.fn()
|
|
const onRevealSelectedFolder = vi.fn()
|
|
const onCopySelectedFolderPath = vi.fn()
|
|
const config = makeConfig({
|
|
selection: { kind: 'folder', path: 'projects' },
|
|
onRenameFolder,
|
|
onDeleteFolder,
|
|
onRevealSelectedFolder,
|
|
onCopySelectedFolderPath,
|
|
})
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
|
|
findCommand(result.current, 'reveal-selected-folder')!.execute()
|
|
findCommand(result.current, 'copy-selected-folder-path')!.execute()
|
|
findCommand(result.current, 'rename-folder')!.execute()
|
|
findCommand(result.current, 'delete-folder')!.execute()
|
|
|
|
expect(onRevealSelectedFolder).toHaveBeenCalledTimes(1)
|
|
expect(onCopySelectedFolderPath).toHaveBeenCalledTimes(1)
|
|
expect(onRenameFolder).toHaveBeenCalledTimes(1)
|
|
expect(onDeleteFolder).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('omits the removed daily-note command', () => {
|
|
const config = makeConfig()
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
expect(findCommand(result.current, 'open-daily-note')).toBeUndefined()
|
|
})
|
|
|
|
it('includes Contribute in the Settings group when available', () => {
|
|
const onOpenFeedback = vi.fn()
|
|
const config = makeConfig({ onOpenFeedback })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'open-contribute')
|
|
expect(cmd).toBeDefined()
|
|
expect(cmd!.label).toBe('Contribute')
|
|
expect(cmd!.group).toBe('Settings')
|
|
expect(cmd!.enabled).toBe(true)
|
|
|
|
cmd!.execute()
|
|
expect(onOpenFeedback).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('keeps a single canonical New Note command when generic note types are present', () => {
|
|
const config = makeConfig({
|
|
entries: [
|
|
{ path: '/type-note.md', title: 'Note', isA: 'Type' },
|
|
{ path: '/lowercase-note.md', title: 'lowercase-note', isA: 'note' },
|
|
],
|
|
})
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
|
|
const newNoteCommands = result.current.filter(command => command.label.toLowerCase() === 'new note')
|
|
|
|
expect(newNoteCommands).toHaveLength(1)
|
|
expect(newNoteCommands[0]).toMatchObject({
|
|
id: 'create-note',
|
|
shortcut: formatShortcutDisplay({ display: '⌘N' }),
|
|
})
|
|
})
|
|
|
|
it('exposes a current-folder create command only when a folder is selected', () => {
|
|
const onCreateNote = vi.fn()
|
|
const { result } = renderHook(() => useCommandRegistry(makeConfig({
|
|
onCreateNote,
|
|
selection: { kind: 'folder', path: 'Projects/2026 Planning', rootPath: '/vault' },
|
|
})))
|
|
|
|
const command = findCommand(result.current, 'create-note-current-folder')
|
|
expect(command).toMatchObject({
|
|
label: 'Create New Note in Current Folder',
|
|
group: 'Note',
|
|
enabled: true,
|
|
})
|
|
|
|
command!.execute()
|
|
expect(onCreateNote).toHaveBeenCalledWith(undefined, {
|
|
creationPath: 'folder_command_palette',
|
|
folderPath: 'Projects/2026 Planning',
|
|
vaultPath: '/vault',
|
|
})
|
|
})
|
|
|
|
it('disables the current-folder create command outside folder selections', () => {
|
|
const { result } = renderHook(() => useCommandRegistry(makeConfig({
|
|
selection: { kind: 'filter', filter: 'all' },
|
|
})))
|
|
|
|
expect(findCommand(result.current, 'create-note-current-folder')?.enabled).toBe(false)
|
|
})
|
|
|
|
it('exposes paste without formatting in the command palette', () => {
|
|
const onPastePlainText = vi.fn()
|
|
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onPastePlainText })))
|
|
const command = findCommand(result.current, 'paste-plain-text')
|
|
|
|
expect(command).toMatchObject({
|
|
label: 'Paste without formatting',
|
|
group: 'Note',
|
|
shortcut: formatShortcutDisplay({ display: '⌘⇧V' }),
|
|
enabled: true,
|
|
})
|
|
|
|
command!.execute()
|
|
expect(onPastePlainText).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('keeps a single canonical New Type command when the Type definition exists', () => {
|
|
const onCreateType = vi.fn()
|
|
const onCreateNoteOfType = vi.fn()
|
|
const config = makeConfig({
|
|
onCreateType,
|
|
onCreateNoteOfType,
|
|
entries: [
|
|
{ path: '/type-definition.md', title: 'Type', isA: 'Type' },
|
|
{ path: '/recipe-definition.md', title: 'Recipe', isA: 'Type' },
|
|
],
|
|
})
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
|
|
const newTypeCommands = result.current.filter(command => command.label === 'New Type')
|
|
|
|
expect(newTypeCommands).toHaveLength(1)
|
|
expect(newTypeCommands[0]).toMatchObject({
|
|
id: 'create-type',
|
|
group: 'Note',
|
|
})
|
|
expect(findCommand(result.current, 'list-type')).toMatchObject({
|
|
label: 'List Types',
|
|
group: 'Navigation',
|
|
})
|
|
|
|
newTypeCommands[0].execute()
|
|
expect(onCreateType).toHaveBeenCalledOnce()
|
|
expect(onCreateNoteOfType).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('pluralizeType', () => {
|
|
it('pluralizes regular types', () => {
|
|
expect(pluralizeType('Project')).toBe('Projects')
|
|
expect(pluralizeType('Note')).toBe('Notes')
|
|
})
|
|
|
|
it('uses overrides for irregular plurals', () => {
|
|
expect(pluralizeType('Person')).toBe('People')
|
|
expect(pluralizeType('Responsibility')).toBe('Responsibilities')
|
|
})
|
|
|
|
it('handles sibilant endings', () => {
|
|
expect(pluralizeType('Address')).toBe('Addresses')
|
|
})
|
|
})
|
|
|
|
describe('extractVaultTypes', () => {
|
|
it('returns default types when no entries', () => {
|
|
expect(extractVaultTypes([])).toEqual(['Event', 'Person', 'Project', 'Note'])
|
|
})
|
|
|
|
it('extracts unique types from entries', () => {
|
|
const entries = [
|
|
{ path: '/a', title: 'A', isA: 'Project' },
|
|
{ path: '/b', title: 'B', isA: 'Project' },
|
|
{ path: '/c', title: 'C', isA: 'Event' },
|
|
] as never[]
|
|
const types = extractVaultTypes(entries)
|
|
expect(types).toContain('Project')
|
|
expect(types).toContain('Event')
|
|
expect(types).toHaveLength(2)
|
|
})
|
|
|
|
it('includes types from Type definition entries', () => {
|
|
const entries = [
|
|
{ path: '/book.md', title: 'Book', isA: 'Type' },
|
|
] as never[]
|
|
const types = extractVaultTypes(entries)
|
|
expect(types).toContain('Book')
|
|
})
|
|
|
|
it('includes types from both definitions and instances', () => {
|
|
const entries = [
|
|
{ path: '/book.md', title: 'Book', isA: 'Type' },
|
|
{ path: '/hp.md', title: 'Harry Potter', isA: 'Book' },
|
|
{ path: '/person.md', title: 'Person', isA: 'Type' },
|
|
] as never[]
|
|
const types = extractVaultTypes(entries)
|
|
expect(types).toContain('Book')
|
|
expect(types).toContain('Person')
|
|
expect(types).toHaveLength(2)
|
|
})
|
|
|
|
it('deduplicates default types case-insensitively and keeps canonical casing', () => {
|
|
const entries = [
|
|
{ path: '/note-type.md', title: 'note', isA: 'Type' },
|
|
{ path: '/note-instance.md', title: 'Example', isA: 'Note' },
|
|
{ path: '/project-instance.md', title: 'Project Plan', isA: 'project' },
|
|
] as never[]
|
|
|
|
expect(extractVaultTypes(entries)).toEqual(['Note', 'Project'])
|
|
})
|
|
|
|
it('omits the legacy Journal type when no Type document defines it', () => {
|
|
const entries = [
|
|
{ path: '/2026-03-11.md', title: 'March 11', isA: 'Journal' },
|
|
{ path: '/note.md', title: 'General Note', isA: 'Note' },
|
|
] as never[]
|
|
|
|
expect(extractVaultTypes(entries)).toEqual(['Note'])
|
|
})
|
|
|
|
it('includes Journal when a real Type document defines it', () => {
|
|
const entries = [
|
|
{ path: '/journal.md', title: 'Journal', isA: 'Type' },
|
|
{ path: '/2026-03-11.md', title: 'March 11', isA: 'Journal' },
|
|
{ path: '/note.md', title: 'General Note', isA: 'Note' },
|
|
] as never[]
|
|
|
|
expect(extractVaultTypes(entries)).toEqual(['Journal', 'Note'])
|
|
})
|
|
|
|
it('omits hidden types from extracted command-palette types', () => {
|
|
const entries = [
|
|
{ path: '/recipe.md', title: 'Recipe', isA: 'Type', visible: false },
|
|
{ path: '/dinner.md', title: 'Dinner', isA: 'Recipe' },
|
|
{ path: '/project.md', title: 'Project', isA: 'Type' },
|
|
] as never[]
|
|
|
|
expect(extractVaultTypes(entries)).toEqual(['Project'])
|
|
})
|
|
})
|
|
|
|
describe('groupSortKey', () => {
|
|
it('returns correct order for groups', () => {
|
|
expect(groupSortKey('Navigation')).toBeLessThan(groupSortKey('Note'))
|
|
expect(groupSortKey('Note')).toBeLessThan(groupSortKey('Git'))
|
|
expect(groupSortKey('Git')).toBeLessThan(groupSortKey('View'))
|
|
})
|
|
})
|
|
|
|
describe('install-mcp command', () => {
|
|
it('is enabled when mcpStatus is not_installed and handler provided', () => {
|
|
const config = makeConfig({ mcpStatus: 'not_installed', onInstallMcp: vi.fn() })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'install-mcp')
|
|
expect(cmd).toBeDefined()
|
|
expect(cmd!.enabled).toBe(true)
|
|
expect(cmd!.label).toBe('Set Up External AI Tools…')
|
|
})
|
|
|
|
it('is enabled when mcpStatus is installed and handler provided (manage use case)', () => {
|
|
const config = makeConfig({ mcpStatus: 'installed', onInstallMcp: vi.fn() })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'install-mcp')
|
|
expect(cmd!.enabled).toBe(true)
|
|
expect(cmd!.label).toBe('Manage External AI Tools…')
|
|
})
|
|
|
|
it('is enabled even when mcpStatus is checking', () => {
|
|
const config = makeConfig({ mcpStatus: 'checking', onInstallMcp: vi.fn() })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'install-mcp')
|
|
expect(cmd!.enabled).toBe(true)
|
|
})
|
|
|
|
it('is enabled even when no handler provided', () => {
|
|
const config = makeConfig({ mcpStatus: 'not_installed' })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'install-mcp')
|
|
expect(cmd!.enabled).toBe(true)
|
|
})
|
|
|
|
it('has setup keywords for discoverability', () => {
|
|
const config = makeConfig({ mcpStatus: 'installed', onInstallMcp: vi.fn() })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'install-mcp')
|
|
expect(cmd!.keywords).toContain('setup')
|
|
expect(cmd!.keywords).toContain('external')
|
|
expect(cmd!.keywords).toContain('mcp')
|
|
expect(cmd!.keywords).toContain('cursor')
|
|
})
|
|
|
|
it('executes onInstallMcp callback', () => {
|
|
const onInstallMcp = vi.fn()
|
|
const config = makeConfig({ mcpStatus: 'installed', onInstallMcp })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'install-mcp')
|
|
cmd!.execute()
|
|
expect(onInstallMcp).toHaveBeenCalled()
|
|
})
|
|
|
|
it('is in Settings group', () => {
|
|
const config = makeConfig({ mcpStatus: 'installed', onInstallMcp: vi.fn() })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'install-mcp')
|
|
expect(cmd!.group).toBe('Settings')
|
|
})
|
|
})
|
|
|
|
describe('reload-vault command', () => {
|
|
it('is present in Settings group', () => {
|
|
const config = makeConfig({ onReloadVault: vi.fn() })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'reload-vault')
|
|
expect(cmd).toBeDefined()
|
|
expect(cmd!.group).toBe('Settings')
|
|
expect(cmd!.label).toBe('Reload Vault')
|
|
})
|
|
|
|
it('is enabled when onReloadVault is provided', () => {
|
|
const config = makeConfig({ onReloadVault: vi.fn() })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'reload-vault')
|
|
expect(cmd!.enabled).toBe(true)
|
|
})
|
|
|
|
it('is disabled when onReloadVault is not provided', () => {
|
|
const config = makeConfig()
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'reload-vault')
|
|
expect(cmd!.enabled).toBe(false)
|
|
})
|
|
|
|
it('executes onReloadVault callback', () => {
|
|
const onReloadVault = vi.fn()
|
|
const config = makeConfig({ onReloadVault })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'reload-vault')
|
|
cmd!.execute()
|
|
expect(onReloadVault).toHaveBeenCalled()
|
|
})
|
|
|
|
it('has searchable keywords', () => {
|
|
const config = makeConfig({ onReloadVault: vi.fn() })
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'reload-vault')
|
|
expect(cmd!.keywords).toContain('reload')
|
|
expect(cmd!.keywords).toContain('refresh')
|
|
expect(cmd!.keywords).toContain('rescan')
|
|
})
|
|
|
|
it('builds explicit AI agent switch commands for installed alternatives', () => {
|
|
const onSetDefaultAiAgent = vi.fn()
|
|
const config = makeConfig({
|
|
aiAgentsStatus: {
|
|
claude_code: { status: 'installed', version: '1.0.20' },
|
|
codex: { status: 'installed', version: '0.37.0' },
|
|
opencode: { status: 'installed', version: '0.3.1' },
|
|
pi: { status: 'installed', version: '0.70.2' },
|
|
gemini: { status: 'installed', version: '0.5.1' },
|
|
},
|
|
selectedAiAgent: 'claude_code',
|
|
onSetDefaultAiAgent,
|
|
})
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
const cmd = findCommand(result.current, 'switch-ai-agent-codex')
|
|
|
|
expect(cmd).toBeDefined()
|
|
expect(cmd!.label).toBe('Switch AI Agent to Codex')
|
|
expect(findCommand(result.current, 'switch-ai-agent-opencode')).toBeDefined()
|
|
expect(findCommand(result.current, 'switch-ai-agent-pi')).toBeDefined()
|
|
expect(findCommand(result.current, 'switch-ai-agent-gemini')).toBeDefined()
|
|
|
|
cmd!.execute()
|
|
expect(onSetDefaultAiAgent).toHaveBeenCalledWith('codex')
|
|
expect(findCommand(result.current, 'switch-default-ai-agent')).toBeUndefined()
|
|
})
|
|
|
|
it('omits explicit AI switch commands when no alternate installed agent exists', () => {
|
|
const config = makeConfig({
|
|
aiAgentsStatus: {
|
|
claude_code: { status: 'installed', version: '1.0.20' },
|
|
codex: { status: 'missing', version: null },
|
|
opencode: { status: 'missing', version: null },
|
|
pi: { status: 'missing', version: null },
|
|
gemini: { status: 'missing', version: null },
|
|
},
|
|
selectedAiAgent: 'claude_code',
|
|
onSetDefaultAiAgent: vi.fn(),
|
|
})
|
|
const { result } = renderHook(() => useCommandRegistry(config))
|
|
|
|
expect(findCommand(result.current, 'switch-ai-agent-codex')).toBeUndefined()
|
|
expect(findCommand(result.current, 'switch-ai-agent-opencode')).toBeUndefined()
|
|
expect(findCommand(result.current, 'switch-ai-agent-pi')).toBeUndefined()
|
|
expect(findCommand(result.current, 'switch-ai-agent-gemini')).toBeUndefined()
|
|
expect(findCommand(result.current, 'switch-default-ai-agent')).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('buildTypeCommands', () => {
|
|
it('creates new and list commands for each type', () => {
|
|
const onCreateNoteOfType = vi.fn()
|
|
const onSelect = vi.fn()
|
|
const commands = buildTypeCommands(['Project', 'Event'], onCreateNoteOfType, onSelect)
|
|
expect(commands).toHaveLength(4)
|
|
expect(commands[0].id).toBe('new-project')
|
|
expect(commands[1].id).toBe('list-project')
|
|
expect(commands[2].id).toBe('new-event')
|
|
expect(commands[3].id).toBe('list-event')
|
|
})
|
|
|
|
it('omits the generic Note create command while keeping navigation for notes', () => {
|
|
const onCreateNoteOfType = vi.fn()
|
|
const onSelect = vi.fn()
|
|
const commands = buildTypeCommands(['Note', 'Project'], onCreateNoteOfType, onSelect)
|
|
|
|
expect(commands.map(command => command.id)).toEqual([
|
|
'list-note',
|
|
'new-project',
|
|
'list-project',
|
|
])
|
|
})
|
|
})
|