// Command palette (Cmd/Ctrl+K): jump to an app, a tab, a screen, or run an action. Arrow keys, Enter, Esc. import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; import { t } from '../i18n'; import { fuzzy } from '../lib/model'; import { Icon, type IconName } from './icons'; import { AppIcon, Kbd, MOD } from './kit'; export interface Command { id: string; group: string; title: string; hint?: string; icon?: IconName; app?: { name: string; icon?: string | null }; keywords?: string; run: () => void } export function Palette({ commands, onClose }: { commands: Command[]; onClose: () => void }) { const [q, setQ] = useState(''); const [sel, setSel] = useState(0); const input = useRef(null); const list = useRef(null); const before = useRef(null); useEffect(() => { before.current = document.activeElement; input.current?.focus(); return () => { (before.current as HTMLElement | null)?.focus?.(); }; }, []); const items = useMemo(() => { if (!q.trim()) return commands; return commands.map(c => ({ c, s: Math.max(fuzzy(q, c.title), fuzzy(q, `${c.group} ${c.title} ${c.keywords ?? ''}`) - 1) })) .filter(x => x.s >= 0).sort((a, b) => b.s - a.s).map(x => x.c); }, [q, commands]); useEffect(() => { setSel(0); }, [q]); useEffect(() => { list.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' }); }, [sel]); const run = (c: Command | undefined) => { if (!c) return; onClose(); c.run(); }; const onKey = (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault(); setSel(s => Math.min(items.length - 1, s + 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSel(s => Math.max(0, s - 1)); } else if (e.key === 'Enter') { e.preventDefault(); run(items[sel]); } else if (e.key === 'Escape') { e.preventDefault(); onClose(); } else if (e.key === 'Tab') e.preventDefault(); }; const rows: ReactNode[] = []; let group = ''; items.forEach((c, i) => { if (!q.trim() && c.group !== group) { group = c.group; rows.push(
{group}
); } rows.push(
setSel(i)} onClick={() => run(c)}> {c.app ? : } {c.title} {c.hint && {c.hint}}
, ); }); return (
{ if (e.target === e.currentTarget) onClose(); }}>
setQ(e.target.value)} placeholder={t('palette.placeholder')} role="combobox" aria-expanded="true" aria-controls="palette-list" aria-activedescendant={items.length ? `pi-${sel}` : undefined} aria-autocomplete="list" spellCheck={false} />
{items.length ? rows :
{t('palette.none', { q })}
}
{t('palette.move')} {t('palette.open')} {t('palette.toggle')}
); }