import { memo, useEffect, useMemo, useRef, useState } from "react"; import { cn } from "../../lib/utils"; import { Dialog, DialogContent, DialogDescription, DialogTitle } from "../../ui/dialog"; import { filterCommandPaletteItems, firstEnabled } from "./filter"; import type { CommandPaletteItem, CommandPaletteModifier, CommandPaletteShortcut } from "./types"; export type CommandPaletteProps = { open: boolean; items: CommandPaletteItem[]; onOpenChange: (open: boolean) => void; onSelect: (item: CommandPaletteItem) => void; placeholder?: string; emptyLabel?: string; title?: string; description?: string; className?: string; }; function mac() { if (typeof navigator === "undefined") return false; let ua = (navigator as Navigator & { userAgentData?: { platform: string } }).userAgentData; return ua ? /mac/i.test(ua.platform) : /Mac|iPhone|iPad|iPod/.test(navigator.userAgent); } const apple = mac(); const glyphs = { meta: "⌘", ctrl: apple ? "⌃" : "Ctrl+", mod: apple ? "⌘" : "Ctrl+", alt: apple ? "⌥" : "Alt+", shift: apple ? "⇧" : "Shift+", } satisfies Record; function shortcutLabel(shortcut?: CommandPaletteShortcut) { if (!shortcut) return; if (shortcut.label) return shortcut.label; let mods = shortcut.modifiers?.map(mod => glyphs[mod]).join("") || ""; let key = shortcut.key.length === 1 ? shortcut.key.toUpperCase() : shortcut.key; return `${mods}${key}`; } function statusLabel(item: CommandPaletteItem) { if (item.kind !== "session") return shortcutLabel(item.shortcut); if (item.archived) return "Archived"; if (item.status === "mention") return "Mention"; if (item.status === "unread") return "Unread"; if (item.status === "busy") return "Busy"; } export const CommandPalette = memo(function CommandPalette({ open, items, onOpenChange, onSelect, placeholder = "Type a command...", emptyLabel = "No commands found", title = "Command Palette", description = "Search commands and sessions", className, }: CommandPaletteProps) { return ( {title} {description} ); }); function Palette({ items, onOpenChange, onSelect, placeholder, emptyLabel, title }: Pick< CommandPaletteProps, "items" | "onOpenChange" | "onSelect" | "placeholder" | "emptyLabel" | "title" >) { let [query, setQuery] = useState(""); let [selected, setSelected] = useState(); let list = useRef(null); let results = useMemo(() => filterCommandPaletteItems(items, query), [items, query]); let index = results.findIndex(({ item }) => item.id === selected && !item.disabled); if (index < 0) index = firstEnabled(results); function move(delta: number) { let enabled = results .map((result, i) => result.item.disabled ? -1 : i) .filter(i => i >= 0); if (!enabled.length) return; let current = enabled.indexOf(index); let next = current < 0 ? 0 : (current + delta + enabled.length) % enabled.length; setSelected(results[enabled[next]!]?.item.id); } function choose(item: CommandPaletteItem) { if (item.disabled) return; onSelect(item); onOpenChange(false); } function keydown(event: React.KeyboardEvent) { if (event.key === "ArrowDown") { event.preventDefault(); move(1); return; } if (event.key === "ArrowUp") { event.preventDefault(); move(-1); return; } if (event.key === "Enter") { event.preventDefault(); let item = results[index]?.item; if (item) choose(item); } } function updateQuery(value: string) { let next = filterCommandPaletteItems(items, value); let idx = firstEnabled(next); setQuery(value); setSelected(idx >= 0 ? next[idx]?.item.id : undefined); list.current?.scrollTo({ top: 0 }); } useEffect(() => { let id = results[index]?.item.id; if (!id) return; document.getElementById(`command-palette-${id}`)?.scrollIntoView({ block: "nearest" }); }, [index, results]); return ( <>
updateQuery(event.target.value)} onKeyDown={keydown} placeholder={placeholder} autoCorrect="off" autoCapitalize="off" spellCheck={false} aria-label={title} aria-controls="command-palette-results" aria-activedescendant={results[index] ? `command-palette-${results[index]!.item.id}` : undefined} className="block inline-size-full bg-transparent px-4 py-3 text-sm text-foreground outline-none placeholder:text-muted-foreground" />
{results.length ? (
{results.map(({ item }, i) => { let selected = i === index; let label = statusLabel(item); return ( ); })}
) : (
{emptyLabel}
)}
); }