import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent, } from 'react'; import type { RegisteredAction } from './types'; import type { HotkeyStore } from './hotkeyStore'; import { formatHotkeyForDisplay } from './hotkey-utils'; interface CommandPaletteProps { /** Whether the palette is open */ isOpen: boolean; /** Callback when the palette should close */ onClose: () => void; /** Callback when a command is executed */ onExecute: (action: RegisteredAction) => void; /** All available actions */ actions: RegisteredAction[]; /** Hotkey store for resolving effective hotkeys */ hotkeyStore: HotkeyStore; /** Whether the platform is macOS (affects hotkey display) */ isMac: boolean; /** Optional placeholder text */ placeholder?: string; } /** * Command palette modal overlay. * * Features: * - Fuzzy search filtering of actions * - Keyboard navigation (Up/Down arrows, Enter, Escape) * - Click to execute * - Shows command hotkey hint if available */ export function CommandPalette({ isOpen, onClose, onExecute, actions, hotkeyStore, isMac, placeholder = 'Type a command...', }: CommandPaletteProps) { const [query, setQuery] = useState(''); const [selectedIndex, setSelectedIndex] = useState(0); const inputRef = useRef(null); const listRef = useRef(null); // Filter actions by query const filteredActions = useMemo(() => { if (!query.trim()) return actions; return defaultFilter(actions, query); }, [actions, query]); // Reset state when opening useEffect(() => { if (isOpen) { setQuery(''); setSelectedIndex(0); setTimeout(() => inputRef.current?.focus(), 0); } }, [isOpen]); // Stable identity for the filtered result set const filteredKey = useMemo( () => filteredActions.map((a) => a.id).join('\0'), [filteredActions], ); // Reset selection when results change useEffect(() => { setSelectedIndex(0); }, [filteredKey]); // Scroll selected item into view useEffect(() => { if (listRef.current && filteredActions.length > 0) { const selectedEl = listRef.current.querySelector( '[aria-selected="true"]', ); if (selectedEl && typeof selectedEl.scrollIntoView === 'function') { selectedEl.scrollIntoView({ block: 'nearest' }); } } }, [selectedIndex, filteredActions.length]); const handleKeyDown = useCallback( (e: KeyboardEvent) => { switch (e.key) { case 'ArrowDown': e.preventDefault(); setSelectedIndex( (prev) => (prev + 1) % Math.max(filteredActions.length, 1), ); break; case 'ArrowUp': e.preventDefault(); setSelectedIndex((prev) => prev <= 0 ? Math.max(filteredActions.length - 1, 0) : prev - 1, ); break; case 'Enter': e.preventDefault(); if (filteredActions[selectedIndex]) { onExecute(filteredActions[selectedIndex]); onClose(); } break; case 'Escape': e.preventDefault(); onClose(); break; } }, [filteredActions, selectedIndex, onExecute, onClose], ); const handleActionClick = useCallback( (action: RegisteredAction) => { onExecute(action); onClose(); }, [onExecute, onClose], ); if (!isOpen) return null; const listId = 'command-palette-list'; const selectedId = filteredActions.length > 0 ? `command-palette-item-${selectedIndex}` : undefined; return (
event.stopPropagation()} > {/* Search input */}
setQuery(e.target.value)} onKeyDown={handleKeyDown} role="combobox" aria-controls={listId} aria-activedescendant={selectedId} aria-autocomplete="list" aria-expanded={filteredActions.length > 0} />
{/* Results */}
{filteredActions.length === 0 ? (
{query.trim() ? 'No commands found' : actions.length === 0 ? 'No commands available' : 'Type to search commands'}
) : (
{filteredActions.map((action, index) => { const isSelected = index === selectedIndex; const effectiveHotkeys = hotkeyStore.getEffectiveHotkeys(action); const hotkeyHint = effectiveHotkeys[0] ? formatHotkeyForDisplay(effectiveHotkeys[0], isMac) : undefined; return (
handleActionClick(action)} onMouseEnter={() => setSelectedIndex(index)} > {action.name} {hotkeyHint ? ( {hotkeyHint} ) : null}
); })}
)}
); } /** * Default filter when no external search is provided. * All query words must appear in the action name or description (case-insensitive). */ function defaultFilter( actions: RegisteredAction[], query: string, ): RegisteredAction[] { const words = query.toLowerCase().split(/\s+/).filter(Boolean); return actions.filter((action) => { const haystack = ( action.name + ' ' + (action.description ?? '') ).toLowerCase(); return words.every((word) => haystack.includes(word)); }); }