2026-02-24 23:43:58 +01:00
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
|
import { render, screen, fireEvent } from '@testing-library/react'
|
2026-04-12 13:14:18 +02:00
|
|
|
import type { VaultEntry } from '../types'
|
|
|
|
|
import { queueAiPrompt, requestOpenAiChat } from '../utils/aiPromptBridge'
|
2026-02-24 23:43:58 +01:00
|
|
|
import { CommandPalette } from './CommandPalette'
|
|
|
|
|
import type { CommandAction } from '../hooks/useCommandRegistry'
|
|
|
|
|
|
|
|
|
|
// jsdom doesn't implement scrollIntoView
|
|
|
|
|
Element.prototype.scrollIntoView = vi.fn()
|
|
|
|
|
|
2026-04-12 13:14:18 +02:00
|
|
|
vi.mock('../utils/aiPromptBridge', () => ({
|
|
|
|
|
queueAiPrompt: vi.fn(),
|
|
|
|
|
requestOpenAiChat: vi.fn(),
|
|
|
|
|
}))
|
|
|
|
|
|
2026-02-24 23:43:58 +01:00
|
|
|
const makeCommand = (overrides: Partial<CommandAction> = {}): CommandAction => ({
|
|
|
|
|
id: 'test-cmd',
|
|
|
|
|
label: 'Test Command',
|
|
|
|
|
group: 'Navigation',
|
|
|
|
|
keywords: [],
|
|
|
|
|
enabled: true,
|
|
|
|
|
shortcut: undefined,
|
|
|
|
|
execute: vi.fn(),
|
|
|
|
|
...overrides,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const commands: CommandAction[] = [
|
|
|
|
|
makeCommand({ id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: '⌘P', keywords: ['find'] }),
|
2026-04-11 22:05:13 +02:00
|
|
|
makeCommand({ id: 'create-note', label: 'New Note', group: 'Note', shortcut: '⌘N' }),
|
2026-02-24 23:43:58 +01:00
|
|
|
makeCommand({ id: 'commit-push', label: 'Commit & Push', group: 'Git', keywords: ['git', 'sync'] }),
|
|
|
|
|
makeCommand({ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,' }),
|
|
|
|
|
makeCommand({ id: 'disabled-cmd', label: 'Disabled Command', group: 'Note', enabled: false }),
|
|
|
|
|
]
|
|
|
|
|
|
2026-04-12 13:14:18 +02:00
|
|
|
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
|
|
|
|
path: '/vault/note/test.md',
|
|
|
|
|
filename: 'test.md',
|
|
|
|
|
title: 'Test Note',
|
|
|
|
|
isA: 'Note',
|
|
|
|
|
aliases: [],
|
|
|
|
|
belongsTo: [],
|
|
|
|
|
relatedTo: [],
|
|
|
|
|
status: null,
|
|
|
|
|
owner: null,
|
|
|
|
|
cadence: null,
|
|
|
|
|
archived: false,
|
|
|
|
|
modifiedAt: 1700000000,
|
|
|
|
|
createdAt: 1700000000,
|
|
|
|
|
fileSize: 100,
|
|
|
|
|
snippet: '',
|
|
|
|
|
wordCount: 0,
|
|
|
|
|
relationships: {},
|
|
|
|
|
icon: null,
|
|
|
|
|
color: null,
|
|
|
|
|
order: null,
|
|
|
|
|
outgoingLinks: [],
|
|
|
|
|
...overrides,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const entries: VaultEntry[] = [
|
|
|
|
|
makeEntry({ path: '/vault/alpha.md', filename: 'alpha.md', title: 'Alpha', isA: 'Project' }),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
function setSelection(editor: HTMLElement, offset: number) {
|
|
|
|
|
const selection = window.getSelection()
|
|
|
|
|
if (!selection) return
|
|
|
|
|
|
|
|
|
|
const targetNode = editor.firstChild ?? editor
|
|
|
|
|
const safeOffset = targetNode.nodeType === Node.TEXT_NODE
|
|
|
|
|
? Math.min(offset, targetNode.textContent?.length ?? 0)
|
|
|
|
|
: Math.min(offset, targetNode.childNodes.length)
|
|
|
|
|
|
|
|
|
|
const range = document.createRange()
|
|
|
|
|
range.setStart(targetNode, safeOffset)
|
|
|
|
|
range.collapse(true)
|
|
|
|
|
selection.removeAllRanges()
|
|
|
|
|
selection.addRange(range)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateAiInput(text: string) {
|
|
|
|
|
const editor = screen.getByTestId('command-palette-ai-input')
|
|
|
|
|
editor.textContent = text
|
|
|
|
|
setSelection(editor, text.length)
|
|
|
|
|
fireEvent.input(editor)
|
|
|
|
|
return editor
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 23:43:58 +01:00
|
|
|
describe('CommandPalette', () => {
|
|
|
|
|
const onClose = vi.fn()
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.clearAllMocks()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('renders nothing when closed', () => {
|
|
|
|
|
const { container } = render(
|
|
|
|
|
<CommandPalette open={false} commands={commands} onClose={onClose} />,
|
|
|
|
|
)
|
|
|
|
|
expect(container.firstChild).toBeNull()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows search input when open', () => {
|
|
|
|
|
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
|
|
|
|
expect(screen.getByPlaceholderText('Type a command...')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows all enabled commands grouped by category', () => {
|
|
|
|
|
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
|
|
|
|
expect(screen.getByText('Search Notes')).toBeInTheDocument()
|
2026-04-11 22:05:13 +02:00
|
|
|
expect(screen.getByText('New Note')).toBeInTheDocument()
|
2026-02-24 23:43:58 +01:00
|
|
|
expect(screen.getByText('Commit & Push')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('Open Settings')).toBeInTheDocument()
|
|
|
|
|
// Disabled command should not appear
|
|
|
|
|
expect(screen.queryByText('Disabled Command')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows group labels', () => {
|
|
|
|
|
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
|
|
|
|
expect(screen.getByText('Navigation')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('Note')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('Git')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('Settings')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows keyboard shortcuts', () => {
|
|
|
|
|
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
|
|
|
|
expect(screen.getByText('⌘P')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('⌘N')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('⌘,')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('filters commands by fuzzy search', () => {
|
|
|
|
|
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
|
|
|
|
const input = screen.getByPlaceholderText('Type a command...')
|
|
|
|
|
fireEvent.change(input, { target: { value: 'commit' } })
|
|
|
|
|
|
|
|
|
|
expect(screen.getByText('Commit & Push')).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Search Notes')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('matches by keyword', () => {
|
|
|
|
|
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
|
|
|
|
const input = screen.getByPlaceholderText('Type a command...')
|
|
|
|
|
fireEvent.change(input, { target: { value: 'find' } })
|
|
|
|
|
|
|
|
|
|
expect(screen.getByText('Search Notes')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows "No matching commands" when no results', () => {
|
|
|
|
|
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
|
|
|
|
const input = screen.getByPlaceholderText('Type a command...')
|
|
|
|
|
fireEvent.change(input, { target: { value: 'zzzzzzz' } })
|
|
|
|
|
|
|
|
|
|
expect(screen.getByText('No matching commands')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('calls onClose when pressing Escape', () => {
|
|
|
|
|
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
|
|
|
|
fireEvent.keyDown(window, { key: 'Escape' })
|
|
|
|
|
expect(onClose).toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('executes command and closes on Enter', () => {
|
|
|
|
|
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
|
|
|
|
fireEvent.keyDown(window, { key: 'Enter' })
|
|
|
|
|
|
|
|
|
|
// First enabled command (Search Notes) should execute
|
|
|
|
|
expect(commands[0].execute).toHaveBeenCalled()
|
|
|
|
|
expect(onClose).toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('navigates with arrow keys and selects with Enter', () => {
|
|
|
|
|
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
|
|
|
|
|
|
|
|
|
fireEvent.keyDown(window, { key: 'ArrowDown' })
|
|
|
|
|
fireEvent.keyDown(window, { key: 'Enter' })
|
|
|
|
|
|
2026-04-11 22:05:13 +02:00
|
|
|
// Second enabled command (New Note) should execute
|
2026-02-24 23:43:58 +01:00
|
|
|
expect(commands[1].execute).toHaveBeenCalled()
|
|
|
|
|
expect(onClose).toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('does not go below the last item', () => {
|
|
|
|
|
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < 20; i++) {
|
|
|
|
|
fireEvent.keyDown(window, { key: 'ArrowDown' })
|
|
|
|
|
}
|
|
|
|
|
fireEvent.keyDown(window, { key: 'Enter' })
|
|
|
|
|
|
|
|
|
|
// Should select last enabled command (Open Settings)
|
|
|
|
|
expect(commands[3].execute).toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('does not go above first item', () => {
|
|
|
|
|
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
|
|
|
|
|
|
|
|
|
fireEvent.keyDown(window, { key: 'ArrowUp' })
|
|
|
|
|
fireEvent.keyDown(window, { key: 'Enter' })
|
|
|
|
|
|
|
|
|
|
// Should still select first command
|
|
|
|
|
expect(commands[0].execute).toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('calls onClose when clicking backdrop', () => {
|
|
|
|
|
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
|
|
|
|
|
|
|
|
|
const backdrop = screen.getByPlaceholderText('Type a command...').closest('.fixed')!
|
|
|
|
|
fireEvent.click(backdrop)
|
|
|
|
|
|
|
|
|
|
expect(onClose).toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('executes command when clicking an item', () => {
|
|
|
|
|
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
|
|
|
|
fireEvent.click(screen.getByText('Commit & Push'))
|
|
|
|
|
|
|
|
|
|
expect(commands[2].execute).toHaveBeenCalled()
|
|
|
|
|
expect(onClose).toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows footer hints', () => {
|
|
|
|
|
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
|
|
|
|
expect(screen.getByText('↑↓ navigate')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('↵ select')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('esc close')).toBeInTheDocument()
|
|
|
|
|
})
|
2026-03-05 21:00:31 +01:00
|
|
|
|
2026-04-12 13:14:18 +02:00
|
|
|
it('switches into AI mode when the query starts with a leading space', () => {
|
|
|
|
|
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: ' ' } })
|
|
|
|
|
|
|
|
|
|
expect(screen.getByTestId('command-palette-ai-input')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getAllByText('Ask Claude Code').length).toBeGreaterThan(0)
|
|
|
|
|
expect(screen.queryByText('Search Notes')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('returns to command mode when the leading space is deleted', () => {
|
|
|
|
|
render(
|
|
|
|
|
<CommandPalette open={true} commands={commands} entries={entries} onClose={onClose} />,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: ' ' } })
|
|
|
|
|
updateAiInput('new')
|
|
|
|
|
|
|
|
|
|
const input = screen.getByPlaceholderText('Type a command...') as HTMLInputElement
|
|
|
|
|
expect(screen.queryByTestId('command-palette-ai-input')).toBeNull()
|
|
|
|
|
expect(input.value).toBe('new')
|
|
|
|
|
expect(screen.getByText('New Note')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('queues a stripped AI prompt and closes on Enter in AI mode', () => {
|
|
|
|
|
render(
|
|
|
|
|
<CommandPalette open={true} commands={commands} entries={entries} onClose={onClose} />,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: ' ' } })
|
|
|
|
|
const editor = updateAiInput(' hello world')
|
|
|
|
|
fireEvent.keyDown(editor, { key: 'Enter' })
|
|
|
|
|
|
|
|
|
|
expect(queueAiPrompt).toHaveBeenCalledWith('hello world', [])
|
|
|
|
|
expect(requestOpenAiChat).toHaveBeenCalledOnce()
|
|
|
|
|
expect(onClose).toHaveBeenCalledOnce()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('closes without queueing when AI mode only contains the trigger space', () => {
|
|
|
|
|
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
|
|
|
|
|
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: ' ' } })
|
|
|
|
|
fireEvent.keyDown(screen.getByTestId('command-palette-ai-input'), { key: 'Enter' })
|
|
|
|
|
|
|
|
|
|
expect(queueAiPrompt).not.toHaveBeenCalled()
|
|
|
|
|
expect(requestOpenAiChat).not.toHaveBeenCalled()
|
|
|
|
|
expect(onClose).toHaveBeenCalledOnce()
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-05 21:00:31 +01:00
|
|
|
describe('relevance ranking', () => {
|
|
|
|
|
const relevanceCommands: CommandAction[] = [
|
2026-04-11 22:05:13 +02:00
|
|
|
makeCommand({ id: 'create-note', label: 'New Note', group: 'Note' }),
|
2026-03-05 21:00:31 +01:00
|
|
|
makeCommand({ id: 'toggle-raw', label: 'Toggle Raw Editor', group: 'View' }),
|
|
|
|
|
makeCommand({ id: 'search-notes', label: 'Search Notes', group: 'Navigation' }),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
function getVisibleLabels() {
|
|
|
|
|
return screen.getAllByText(
|
|
|
|
|
(_content, el) =>
|
|
|
|
|
el?.tagName === 'SPAN' &&
|
|
|
|
|
el.classList.contains('text-foreground') &&
|
|
|
|
|
!!el.textContent,
|
|
|
|
|
).map(el => el.textContent)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 22:05:13 +02:00
|
|
|
it('shows only the relevant raw command for query "raw"', () => {
|
2026-03-05 21:00:31 +01:00
|
|
|
render(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
|
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'raw' } })
|
|
|
|
|
|
|
|
|
|
const labels = getVisibleLabels()
|
2026-04-11 22:05:13 +02:00
|
|
|
expect(labels).toEqual(['Toggle Raw Editor'])
|
2026-03-05 21:00:31 +01:00
|
|
|
})
|
|
|
|
|
|
2026-04-11 22:05:13 +02:00
|
|
|
it('ranks "New Note" first for query "new note"', () => {
|
2026-03-05 21:00:31 +01:00
|
|
|
render(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
|
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'new note' } })
|
|
|
|
|
|
|
|
|
|
const labels = getVisibleLabels()
|
2026-04-11 22:05:13 +02:00
|
|
|
expect(labels[0]).toBe('New Note')
|
2026-03-05 21:00:31 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('preserves default section order with empty query', () => {
|
|
|
|
|
render(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
|
|
|
|
|
|
|
|
|
|
const groupHeaders = screen.getAllByText(
|
|
|
|
|
(_content, el) =>
|
|
|
|
|
el?.tagName === 'DIV' &&
|
2026-03-29 17:00:04 +02:00
|
|
|
el.classList.contains('text-[11px]') &&
|
|
|
|
|
el.classList.contains('font-medium') &&
|
2026-03-05 21:00:31 +01:00
|
|
|
!!el.textContent,
|
|
|
|
|
).map(el => el.textContent)
|
|
|
|
|
|
refactor: remove theming system entirely
Remove all theme switching, creation, and management functionality
from the app — backend (Rust theme module, Tauri commands, menu items,
startup tasks, vault seeding), frontend (useThemeManager, ThemePropertyEditor,
themeSchema, command palette Appearance group, settings panel appearance
section, isDarkTheme prop chain), mock data, and all related tests.
Editor styling via theme.json/EditorTheme.css is preserved (not user theming).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 19:57:58 +01:00
|
|
|
// Default order: Navigation < Note < View
|
|
|
|
|
expect(groupHeaders).toEqual(['Navigation', 'Note', 'View'])
|
2026-03-05 21:00:31 +01:00
|
|
|
})
|
|
|
|
|
})
|
2026-02-24 23:43:58 +01:00
|
|
|
})
|