2026-04-26 13:52:42 +02:00
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
|
|
|
import { act, render, screen, fireEvent, waitFor } from '@testing-library/react'
|
2026-04-12 13:14:18 +02:00
|
|
|
import type { VaultEntry } from '../types'
|
|
|
|
|
import { queueAiPrompt, requestOpenAiChat } from '../utils/aiPromptBridge'
|
2026-04-13 21:07:10 +02:00
|
|
|
import { readSelectionRange } from './inlineWikilinkDom'
|
2026-02-24 23:43:58 +01:00
|
|
|
import { CommandPalette } from './CommandPalette'
|
|
|
|
|
import type { CommandAction } from '../hooks/useCommandRegistry'
|
|
|
|
|
|
2026-04-26 13:52:42 +02:00
|
|
|
type NativeDropPayload = {
|
|
|
|
|
type: string
|
|
|
|
|
paths: string[]
|
|
|
|
|
position: { x: number; y: number }
|
|
|
|
|
}
|
|
|
|
|
type NativeDropHandler = (event: { payload: NativeDropPayload }) => void
|
|
|
|
|
const nativeDropState = vi.hoisted(() => ({
|
|
|
|
|
tauriMode: false,
|
|
|
|
|
handlers: {} as Record<string, NativeDropHandler[] | undefined>,
|
|
|
|
|
}))
|
|
|
|
|
|
2026-02-24 23:43:58 +01:00
|
|
|
// jsdom doesn't implement scrollIntoView
|
|
|
|
|
Element.prototype.scrollIntoView = vi.fn()
|
|
|
|
|
|
2026-04-26 13:52:42 +02:00
|
|
|
vi.mock('../mock-tauri', () => ({
|
|
|
|
|
isTauri: () => nativeDropState.tauriMode,
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
vi.mock('@tauri-apps/api/window', () => ({
|
|
|
|
|
getCurrentWindow: () => ({
|
|
|
|
|
onDragDropEvent: vi.fn((handler: NativeDropHandler) => {
|
|
|
|
|
nativeDropState.handlers['native-drag-drop'] = [
|
|
|
|
|
...(nativeDropState.handlers['native-drag-drop'] ?? []),
|
|
|
|
|
handler,
|
|
|
|
|
]
|
|
|
|
|
return Promise.resolve(() => {
|
|
|
|
|
const handlers = nativeDropState.handlers['native-drag-drop']?.filter((candidate) => candidate !== handler) ?? []
|
|
|
|
|
if (handlers.length > 0) nativeDropState.handlers['native-drag-drop'] = handlers
|
|
|
|
|
else delete nativeDropState.handlers['native-drag-drop']
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
}),
|
|
|
|
|
}))
|
|
|
|
|
|
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)
|
2026-05-05 02:43:49 +02:00
|
|
|
return screen.queryByTestId('command-palette-ai-input') ?? editor
|
2026-04-12 13:14:18 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-26 13:52:42 +02:00
|
|
|
function resetNativeDropState() {
|
|
|
|
|
nativeDropState.tauriMode = false
|
|
|
|
|
for (const eventName of Object.keys(nativeDropState.handlers)) {
|
|
|
|
|
delete nativeDropState.handlers[eventName]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mockElementRect(element: HTMLElement) {
|
|
|
|
|
Object.defineProperty(element, 'getBoundingClientRect', {
|
|
|
|
|
configurable: true,
|
|
|
|
|
value: () => ({
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
left: 0,
|
|
|
|
|
top: 0,
|
|
|
|
|
right: 400,
|
|
|
|
|
bottom: 48,
|
|
|
|
|
width: 400,
|
|
|
|
|
height: 48,
|
|
|
|
|
toJSON: () => ({}),
|
|
|
|
|
}),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function emitNativePathDrop(paths: string[]) {
|
|
|
|
|
const handlers = nativeDropState.handlers['native-drag-drop']
|
|
|
|
|
if (!handlers || handlers.length === 0) throw new Error('No native drop handler registered')
|
|
|
|
|
for (const handler of handlers) {
|
|
|
|
|
handler({
|
|
|
|
|
payload: {
|
|
|
|
|
type: 'drop',
|
|
|
|
|
paths,
|
|
|
|
|
position: { x: 20, y: 20 },
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function waitForNativePathDropListener() {
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(nativeDropState.handlers['native-drag-drop']?.length).toBeGreaterThan(0)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 23:43:58 +01:00
|
|
|
describe('CommandPalette', () => {
|
|
|
|
|
const onClose = vi.fn()
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.clearAllMocks()
|
2026-04-26 13:52:42 +02:00
|
|
|
resetNativeDropState()
|
2026-02-24 23:43:58 +01:00
|
|
|
})
|
|
|
|
|
|
2026-04-26 13:52:42 +02:00
|
|
|
afterEach(resetNativeDropState)
|
|
|
|
|
|
2026-02-24 23:43:58 +01:00
|
|
|
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()
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-25 16:49:48 +02:00
|
|
|
it('opts the command input out of spellcheck and text correction', () => {
|
|
|
|
|
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
|
|
|
|
const input = screen.getByPlaceholderText('Type a command...')
|
|
|
|
|
|
|
|
|
|
expect(input).toHaveAttribute('spellcheck', 'false')
|
|
|
|
|
expect(input).toHaveAttribute('autocorrect', 'off')
|
|
|
|
|
expect(input).toHaveAttribute('autocapitalize', 'off')
|
|
|
|
|
expect(input).toHaveAttribute('autocomplete', 'off')
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-24 23:43:58 +01:00
|
|
|
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()
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-26 08:18:47 +02:00
|
|
|
it('localizes command palette chrome', () => {
|
2026-04-27 13:06:59 +02:00
|
|
|
render(<CommandPalette open={true} commands={commands} locale="zh-CN" onClose={onClose} />)
|
2026-04-26 08:18:47 +02:00
|
|
|
const input = screen.getByPlaceholderText('输入命令...')
|
|
|
|
|
fireEvent.change(input, { target: { value: 'zzzzzzz' } })
|
|
|
|
|
|
|
|
|
|
expect(screen.getByText('没有匹配的命令')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('↑↓ 导航')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-24 23:43:58 +01:00
|
|
|
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()
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-25 16:49:48 +02:00
|
|
|
it('keeps a short query keyboard-selectable after ArrowDown and Enter', () => {
|
|
|
|
|
const changeNoteType = makeCommand({
|
|
|
|
|
id: 'change-note-type',
|
|
|
|
|
label: 'Change Note Type…',
|
|
|
|
|
group: 'Note',
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
render(
|
|
|
|
|
<CommandPalette
|
|
|
|
|
open={true}
|
|
|
|
|
commands={[
|
|
|
|
|
changeNoteType,
|
|
|
|
|
makeCommand({ id: 'open-settings', label: 'Open Settings', group: 'Settings' }),
|
|
|
|
|
]}
|
|
|
|
|
onClose={onClose}
|
|
|
|
|
/>,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'ch' } })
|
|
|
|
|
fireEvent.keyDown(window, { key: 'ArrowDown' })
|
|
|
|
|
|
|
|
|
|
const selectedRow = screen.getByText('Change Note Type…').closest('[data-selected]')
|
|
|
|
|
expect(selectedRow).toHaveAttribute('data-selected', 'true')
|
|
|
|
|
|
|
|
|
|
fireEvent.keyDown(window, { key: 'Enter' })
|
|
|
|
|
expect(changeNoteType.execute).toHaveBeenCalledOnce()
|
|
|
|
|
expect(onClose).toHaveBeenCalledOnce()
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-24 23:43:58 +01:00
|
|
|
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()
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-12 22:30:24 +02:00
|
|
|
it('keeps leading-space input in command search when AI mode is disabled', () => {
|
|
|
|
|
render(
|
|
|
|
|
<CommandPalette
|
|
|
|
|
open={true}
|
|
|
|
|
commands={commands}
|
|
|
|
|
aiModeEnabled={false}
|
|
|
|
|
onClose={onClose}
|
|
|
|
|
/>,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const input = screen.getByPlaceholderText('Type a command...')
|
|
|
|
|
fireEvent.change(input, { target: { value: ' search' } })
|
|
|
|
|
|
|
|
|
|
expect(screen.queryByTestId('command-palette-ai-input')).not.toBeInTheDocument()
|
|
|
|
|
expect(input).toHaveValue(' search')
|
|
|
|
|
expect(queueAiPrompt).not.toHaveBeenCalled()
|
|
|
|
|
expect(requestOpenAiChat).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-26 13:52:42 +02:00
|
|
|
it('inserts Tauri native folder drops into the command query input', async () => {
|
|
|
|
|
nativeDropState.tauriMode = true
|
|
|
|
|
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
|
|
|
|
|
|
|
|
|
const input = screen.getByPlaceholderText('Type a command...') as HTMLInputElement
|
|
|
|
|
mockElementRect(input)
|
|
|
|
|
input.focus()
|
|
|
|
|
await waitForNativePathDropListener()
|
|
|
|
|
|
|
|
|
|
act(() => {
|
|
|
|
|
emitNativePathDrop(['/Users/test/Projects'])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(input).toHaveValue('/Users/test/Projects')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-13 13:42:46 +02:00
|
|
|
it('focuses the AI editor immediately when the leading space triggers AI mode', () => {
|
|
|
|
|
render(<CommandPalette open={true} commands={commands} entries={entries} onClose={onClose} />)
|
|
|
|
|
|
|
|
|
|
const input = screen.getByPlaceholderText('Type a command...')
|
|
|
|
|
input.focus()
|
|
|
|
|
fireEvent.change(input, { target: { value: ' ' } })
|
|
|
|
|
|
|
|
|
|
expect(screen.getByTestId('command-palette-ai-input')).toHaveFocus()
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-13 21:07:10 +02:00
|
|
|
it('places the AI editor caret after the trigger space on mode entry', () => {
|
|
|
|
|
render(<CommandPalette open={true} commands={commands} entries={entries} onClose={onClose} />)
|
|
|
|
|
|
|
|
|
|
const input = screen.getByPlaceholderText('Type a command...')
|
|
|
|
|
input.focus()
|
|
|
|
|
fireEvent.change(input, { target: { value: ' ' } })
|
|
|
|
|
|
|
|
|
|
const editor = screen.getByTestId('command-palette-ai-input') as HTMLDivElement
|
|
|
|
|
expect(editor).toHaveFocus()
|
|
|
|
|
expect(readSelectionRange(editor)).toEqual({ start: 1, end: 1 })
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-12 13:14:18 +02:00
|
|
|
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
|
|
|
})
|