"use client"; import { useEffect, useRef, useState, useMemo } from "react"; import * as Dialog from "@radix-ui/react-dialog"; import { Search, Clock } from "lucide-react"; import { useCommandRegistry } from "@/hooks/useCommandRegistry"; import { CommandPaletteItem } from "./CommandPaletteItem"; import { SHORTCUT_CATEGORIES } from "@/lib/shortcuts"; import type { Command, ShortcutCategory } from "@/lib/shortcuts"; import { cn } from "@/lib/utils"; /** Fuzzy match: every character of query must appear in order in target */ function fuzzyMatch(target: string, query: string): boolean { if (!query) return true; const t = target.toLowerCase(); const q = query.toLowerCase(); let qi = 0; for (let i = 0; i < t.length && qi < q.length; i++) { if (t[i] === q[qi]) qi++; } return qi === q.length; } /** Score a command against a search query (higher = better) */ function score(cmd: Command, query: string): number { const q = query.toLowerCase(); const label = cmd.label.toLowerCase(); if (label === q) return 100; if (label.startsWith(q)) return 80; if (label.includes(q)) return 60; if (cmd.description.toLowerCase().includes(q)) return 40; if (fuzzyMatch(label, q)) return 20; return 0; } interface GroupedResults { label: string; commands: Command[]; } export function CommandPalette() { const { paletteOpen, closePalette, commands, runCommand, recentCommandIds, openHelp, } = useCommandRegistry(); const [query, setQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(0); const inputRef = useRef(null); const listRef = useRef(null); // Reset state when palette opens useEffect(() => { if (paletteOpen) { setQuery(""); setActiveIndex(0); // Small delay to let the dialog animate in before focusing setTimeout(() => inputRef.current?.focus(), 10); } }, [paletteOpen]); const filteredGroups = useMemo(() => { if (!query.trim()) { // Show recents first, then all categories const recentCmds = recentCommandIds .map((id) => commands.find((c) => c.id === id)) .filter((c): c is Command => !!c); const groups: GroupedResults[] = []; if (recentCmds.length > 0) { groups.push({ label: "Recent", commands: recentCmds }); } for (const cat of SHORTCUT_CATEGORIES) { const catCmds = commands.filter((c) => c.category === cat); if (catCmds.length > 0) { groups.push({ label: cat, commands: catCmds }); } } return groups; } // Search mode: flat scored list, re-grouped by category const scored = commands .map((cmd) => ({ cmd, s: score(cmd, query) })) .filter(({ s }) => s > 0) .sort((a, b) => b.s - a.s) .map(({ cmd }) => cmd); if (scored.length === 0) return []; const byCategory: Partial> = {}; for (const cmd of scored) { if (!byCategory[cmd.category]) byCategory[cmd.category] = []; byCategory[cmd.category]!.push(cmd); } return SHORTCUT_CATEGORIES.filter((c) => byCategory[c]?.length).map( (c) => ({ label: c, commands: byCategory[c]! }) ); }, [query, commands, recentCommandIds]); const flatResults = useMemo( () => filteredGroups.flatMap((g) => g.commands), [filteredGroups] ); // Clamp activeIndex when results change useEffect(() => { setActiveIndex((i) => Math.min(i, Math.max(flatResults.length - 1, 0))); }, [flatResults.length]); // Scroll active item into view useEffect(() => { const el = listRef.current?.querySelector(`[data-index="${activeIndex}"]`); el?.scrollIntoView({ block: "nearest" }); }, [activeIndex]); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "ArrowDown") { e.preventDefault(); setActiveIndex((i) => Math.min(i + 1, flatResults.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setActiveIndex((i) => Math.max(i - 1, 0)); } else if (e.key === "Enter") { e.preventDefault(); const cmd = flatResults[activeIndex]; if (cmd) { closePalette(); runCommand(cmd.id); } } }; const handleSelect = (cmd: Command) => { closePalette(); runCommand(cmd.id); }; let flatIdx = 0; return ( !open && closePalette()}> {/* Search input */}
{ setQuery(e.target.value); setActiveIndex(0); }} placeholder="Search commands..." className={cn( "flex-1 bg-transparent text-sm text-surface-100", "placeholder:text-surface-500 focus:outline-none" )} /> Esc
{/* Results */}
{filteredGroups.length === 0 ? (
No commands found
) : ( filteredGroups.map((group) => (
{group.label === "Recent" && ( )} {group.label}
{group.commands.map((cmd) => { const idx = flatIdx++; return (
handleSelect(cmd)} onHighlight={() => setActiveIndex(idx)} />
); })}
)) )}
{/* Footer */}
↑↓ navigate select Esc close
); }