import { useState, useRef, useEffect, useMemo } from 'react' import { cn } from '@/lib/utils' import { fuzzyMatch } from '../utils/fuzzyMatch' import type { CommandAction, CommandGroup } from '../hooks/useCommandRegistry' import { groupSortKey } from '../hooks/useCommandRegistry' interface CommandPaletteProps { open: boolean commands: CommandAction[] onClose: () => void } interface ScoredCommand { command: CommandAction score: number } function matchCommand(query: string, cmd: CommandAction): ScoredCommand | null { const labelResult = fuzzyMatch(query, cmd.label) if (labelResult.match) return { command: cmd, score: labelResult.score } for (const kw of cmd.keywords ?? []) { const kwResult = fuzzyMatch(query, kw) if (kwResult.match) return { command: cmd, score: kwResult.score - 1 } } const groupResult = fuzzyMatch(query, cmd.group) if (groupResult.match) return { command: cmd, score: groupResult.score - 2 } return null } function groupResults( commands: CommandAction[], byRelevance: boolean, ): { group: CommandGroup; items: CommandAction[] }[] { const map = new Map() for (const cmd of commands) { const list = map.get(cmd.group) if (list) list.push(cmd) else map.set(cmd.group, [cmd]) } const entries = Array.from(map.entries()) if (!byRelevance) { entries.sort((a, b) => groupSortKey(a[0]) - groupSortKey(b[0])) } return entries.map(([group, items]) => ({ group, items })) } export function CommandPalette({ open, commands, onClose }: CommandPaletteProps) { const [query, setQuery] = useState('') const [selectedIndex, setSelectedIndex] = useState(0) const inputRef = useRef(null) const listRef = useRef(null) useEffect(() => { if (open) { setQuery('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on open setSelectedIndex(0) setTimeout(() => inputRef.current?.focus(), 50) } }, [open]) const enabledCommands = useMemo( () => commands.filter(c => c.enabled), [commands], ) const filtered = useMemo(() => { if (!query.trim()) return enabledCommands return enabledCommands .map(cmd => matchCommand(query, cmd)) .filter((r): r is ScoredCommand => r !== null) .sort((a, b) => b.score - a.score) .map(r => r.command) }, [enabledCommands, query]) const hasQuery = !!query.trim() const groups = useMemo(() => groupResults(filtered, hasQuery), [filtered, hasQuery]) const flatList = useMemo(() => groups.flatMap(g => g.items), [groups]) useEffect(() => { setSelectedIndex(0) // eslint-disable-line react-hooks/set-state-in-effect -- reset on query change }, [query]) useEffect(() => { if (!listRef.current) return const el = listRef.current.querySelector('[data-selected="true"]') as HTMLElement | undefined el?.scrollIntoView({ block: 'nearest' }) }, [selectedIndex]) useEffect(() => { if (!open) return const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); onClose() } else if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(i => Math.min(i + 1, flatList.length - 1)) } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(i => Math.max(i - 1, 0)) } else if (e.key === 'Enter') { e.preventDefault() const cmd = flatList[selectedIndex] if (cmd) { onClose(); cmd.execute() } } } window.addEventListener('keydown', handleKey) return () => window.removeEventListener('keydown', handleKey) }, [open, flatList, selectedIndex, onClose]) if (!open) return null let runningIndex = 0 return (
e.stopPropagation()} > setQuery(e.target.value)} />
{flatList.length === 0 ? (
No matching commands
) : ( groups.map(({ group, items }) => { const startIndex = runningIndex runningIndex += items.length return (
{group}
{items.map((cmd, i) => { const globalIdx = startIndex + i return ( setSelectedIndex(globalIdx)} onSelect={() => { onClose(); cmd.execute() }} /> ) })}
) }) )}
↑↓ navigate ↵ select esc close
) } interface CommandRowProps { command: CommandAction selected: boolean onHover: () => void onSelect: () => void } function CommandRow({ command, selected, onHover, onSelect }: CommandRowProps) { return (
{command.label} {command.shortcut && ( {command.shortcut} )}
) }