返回 DeepSeek-Reasonix
CommandPalette.tsx
根目录 / desktop / frontend / src / components / CommandPalette.tsx
1 import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2 import type { ReactNode } from "react";
3 import { Activity, AlarmClock, BarChart3, Brain, Command, Cpu, Palette, Puzzle, RotateCw, Search, Server, Settings, SquarePen, TerminalSquare, Trash2 } from "lucide-react";
4 import { useT } from "../lib/i18n";
5 import { useMountTransition } from "../lib/useMountTransition";
6
7 // CommandPalette is a ⌘K / Ctrl+K modal that surfaces the desktop app's
8 // long-tail navigation surface. Tabs through sessions, slash-commands, and
9 // recent files via a single fuzzy search. The list of items is provided by
10 // the caller (App) so the palette stays decoupled from the controller — the
11 // same component will work for skills, MCP servers, and future surfaces
12 // once a buildItems() helper is added for them.
13 //
14 // Interaction model:
15 // - Input is auto-focused on open; the first match is highlighted.
16 // - ↑/↓ move the highlight (wraps at the edges).
17 // - Enter runs the highlighted item's action.
18 // - Esc closes.
19 // - Mouse hover sets the highlight (so a click can be "pre-thought"); the
20 // click itself runs the action.
21 //
22 // Fuzzy match is a small case-insensitive substring scorer — every query
23 // token must appear in the candidate's title or hint, in order, but they
24 // may overlap (a real fuzzy matcher would be overkill for 50-200 items).
25 export type PaletteIconName = "appearance" | "automation" | "extension" | "memory" | "models" | "new" | "reload" | "remote" | "settings" | "tasks" | "terminal" | "trash" | "usage";
26
27 const paletteIcons = {
28 appearance: Palette,
29 automation: AlarmClock,
30 extension: Puzzle,
31 memory: Brain,
32 models: Cpu,
33 new: SquarePen,
34 reload: RotateCw,
35 remote: Server,
36 settings: Settings,
37 tasks: Activity,
38 terminal: TerminalSquare,
39 trash: Trash2,
40 usage: BarChart3,
41 } as const;
42
43 function PaletteIcon({ icon }: { icon?: ReactNode | PaletteIconName }) {
44 if (typeof icon === "string" && icon in paletteIcons) {
45 const Icon = paletteIcons[icon as PaletteIconName];
46 return <Icon size={15} />;
47 }
48 return icon ?? <Command size={15} />;
49 }
50
51 export interface PaletteItem {
52 // id is stable and unique within a single open of the palette.
53 id: string;
54 // title is the primary label.
55 title: string;
56 // hint is the secondary line (a path, a command's source, etc.).
57 hint?: string;
58 // meta is right-aligned secondary text (e.g. a timestamp).
59 meta?: string;
60 // badge is a right-aligned counter or label (e.g. turn count).
61 badge?: string;
62 // icon overrides the default Command icon shown on the left.
63 icon?: ReactNode | PaletteIconName;
64 // compact renders the item as a grid chip (icon + title, no hint/meta).
65 compact?: boolean;
66 // group is the section header this item belongs to.
67 group: string;
68 // keywords add to the searchable text (e.g. slash-command aliases).
69 keywords?: string[];
70 // run closes the palette and dispatches the action.
71 run: () => void | Promise<void>;
72 }
73
74 export function CommandPalette({
75 open,
76 onClose,
77 items,
78 placeholder,
79 emptyText,
80 }: {
81 open: boolean;
82 onClose: () => void;
83 items: PaletteItem[];
84 placeholder: string;
85 emptyText: string;
86 }) {
87 const t = useT();
88 const [query, setQuery] = useState("");
89 const [active, setActive] = useState(-1);
90 const inputRef = useRef<HTMLInputElement>(null);
91 const isOpenRef = useRef(false);
92 isOpenRef.current = open;
93 // Keep the palette mounted through its exit animation after `open` flips
94 // false; `status` drives the enter/exit keyframes via data-state.
95 const { mounted, status } = useMountTransition(open, 200);
96
97 // Re-init whenever the palette opens: clear the query, reset the
98 // highlight, and steal focus. Doing it on the open edge (not on every
99 // render) means a previously-typed query doesn't leak across opens.
100 // useLayoutEffect fires synchronously after DOM mutations, before the
101 // browser paints — ensures focus lands before any paint-time transitions
102 // can interfere.
103 useLayoutEffect(() => {
104 if (open) {
105 setQuery("");
106 setActive(items.length > 0 ? 0 : -1);
107 inputRef.current?.focus();
108 }
109 }, [open, items.length]);
110
111 // Callback ref: when the input element mounts while the palette is open,
112 // focus it immediately. This handles the case where the DOM element
113 // becomes available after the useLayoutEffect already ran.
114 const inputCallbackRef = useCallback(
115 (el: HTMLInputElement | null) => {
116 inputRef.current = el;
117 if (el && isOpenRef.current) el.focus();
118 },
119 [],
120 );
121
122 // score is the fuzzy match: every space-separated query token must
123 // appear (case-insensitively) in the candidate's haystack, in the order
124 // given. The score is the sum of the inverse lengths of the matching
125 // substrings (smaller span → higher rank) so a tight prefix match wins
126 // over a spread match.
127 const filtered = useMemo(() => {
128 const q = query.trim().toLowerCase();
129 if (!q) return items;
130 const tokens = q.split(/\s+/);
131 const scored: { item: PaletteItem; score: number }[] = [];
132 for (const it of items) {
133 const hay = [it.title, it.hint ?? "", ...(it.keywords ?? [])].join("\n").toLowerCase();
134 let cursor = 0;
135 let score = 0;
136 let ok = true;
137 for (const tok of tokens) {
138 const at = hay.indexOf(tok, cursor);
139 if (at < 0) {
140 ok = false;
141 break;
142 }
143 // Reward tight matches (smaller span) and matches early in the string.
144 score += 1000 - (at - cursor) - at;
145 cursor = at + tok.length;
146 }
147 if (ok) scored.push({ item: it, score });
148 }
149 scored.sort((a, b) => b.score - a.score);
150 return scored.map((s) => s.item);
151 }, [query, items]);
152
153 // Group the filtered items by their `group` field, preserving the order
154 // the groups first appear (so a "Sessions" group with a hit is shown
155 // before a "Commands" group with a hit, even if the commands' raw
156 // scores would outrank it). This matches the user's mental model:
157 // sessions are the most frequent target.
158 const grouped = useMemo(() => {
159 const out: { group: string; items: PaletteItem[] }[] = [];
160 const indexOf = (g: string) => out.findIndex((o) => o.group === g);
161 for (const it of filtered) {
162 const at = indexOf(it.group);
163 if (at < 0) out.push({ group: it.group, items: [it] });
164 else out[at].items.push(it);
165 }
166 return out;
167 }, [filtered]);
168
169 // Flat index -> grouped item lookup. The keyboard handler only needs
170 // the linear index, so we keep a parallel array to avoid a quadratic
171 // walk on every keypress.
172 const flat = useMemo(() => grouped.flatMap((g) => g.items), [grouped]);
173
174 // Clamp the active index whenever the result set shrinks (e.g. user
175 // typed something that filtered out the previously-highlighted item).
176 useEffect(() => {
177 if (active >= 0 && active >= flat.length) setActive(Math.max(0, flat.length - 1));
178 }, [flat.length, active]);
179
180 // Reset the highlight to the first match on every query change — the user
181 // just refined their search, the old highlight is rarely still interesting.
182 useEffect(() => {
183 setActive(0);
184 }, [query]);
185
186 // Esc closes; ↑/↓ move the highlight; Enter runs. We use a document-level
187 // listener so the palette is responsive even when focus drifts (e.g. the
188 // user clicks a result row, then presses ↑).
189 useEffect(() => {
190 if (!open) return;
191 const onKey = (e: KeyboardEvent) => {
192 const closeButtonHasFocus = e.target instanceof HTMLElement && Boolean(e.target.closest("[data-palette-close]"));
193 if (closeButtonHasFocus && (e.key === "Enter" || e.key === " ")) return;
194 if (e.key === "Escape") {
195 e.preventDefault();
196 onClose();
197 return;
198 }
199 if (e.key === "ArrowDown") {
200 e.preventDefault();
201 setActive((i) => (flat.length === 0 ? -1 : i < 0 ? 0 : (i + 1) % flat.length));
202 return;
203 }
204 if (e.key === "ArrowUp") {
205 e.preventDefault();
206 setActive((i) => (flat.length === 0 ? -1 : i <= 0 ? flat.length - 1 : i - 1));
207 return;
208 }
209 if (e.key === "Enter") {
210 e.preventDefault();
211 const it = flat[active];
212 if (it) void it.run();
213 onClose();
214 return;
215 }
216 };
217 document.addEventListener("keydown", onKey);
218 return () => document.removeEventListener("keydown", onKey);
219 }, [open, flat, active, onClose]);
220
221 if (!mounted) return null;
222
223 // The running counter maps a flat-index back to its group header so we
224 // can render the section dividers in order.
225 let running = 0;
226
227 return (
228 <div
229 className="drawer-backdrop"
230 data-app-overlay=""
231 data-state={status}
232 onClick={onClose}
233 role="presentation"
234 >
235 <div className="palette" data-state={status} onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label={placeholder}>
236 <div className="palette__inputrow">
237 <Search className="palette__search-icon" size={18} aria-hidden="true" />
238 <input
239 ref={inputCallbackRef}
240 className="palette__input"
241 value={query}
242 onChange={(e) => setQuery(e.target.value)}
243 placeholder={placeholder}
244 spellCheck={false}
245 autoComplete="off"
246 />
247 <button
248 className="palette__esc"
249 type="button"
250 onClick={onClose}
251 aria-label={t("common.close")}
252 title={t("common.close")}
253 data-palette-close
254 >
255 esc
256 </button>
257 </div>
258 <div className="palette__list" role="listbox">
259 {flat.length === 0 ? (
260 <div className="palette__empty">{emptyText}</div>
261 ) : (
262 grouped.map((g) => {
263 const isCompact = g.items[0]?.compact;
264 return (
265 <div className={`palette__group ${isCompact ? "palette__group--grid" : ""}`} key={g.group}>
266 <div className="palette__group-title">{g.group}</div>
267 {isCompact ? (
268 <div className="palette__grid">
269 {g.items.map((it) => {
270 const idx = running++;
271 const on = idx === active;
272 return (
273 <button
274 type="button"
275 role="option"
276 aria-selected={on}
277 key={it.id}
278 className={`palette__chip ${on ? "palette__chip--on" : ""}`}
279 onMouseEnter={() => setActive(idx)}
280 onClick={() => {
281 void it.run();
282 onClose();
283 }}
284 >
285 <span className="palette__chip-icon" aria-hidden="true">
286 <PaletteIcon icon={it.icon} />
287 </span>
288 <span className="palette__chip-label">{it.title}</span>
289 </button>
290 );
291 })}
292 </div>
293 ) : (
294 g.items.map((it) => {
295 const idx = running++;
296 const on = idx === active;
297 return (
298 <button
299 type="button"
300 role="option"
301 aria-selected={on}
302 key={it.id}
303 className={`palette__item ${on ? "palette__item--on" : ""}`}
304 onMouseEnter={() => setActive(idx)}
305 onClick={() => {
306 void it.run();
307 onClose();
308 }}
309 >
310 <span className="palette__item-icon" aria-hidden="true">
311 <PaletteIcon icon={it.icon} />
312 </span>
313 <span className="palette__body">
314 <span className="palette__title">{it.title}</span>
315 {(it.hint || it.meta || it.badge) && (
316 <span className="palette__hint">
317 {it.hint && <span className="palette__hint-text">{it.hint}</span>}
318 {it.meta && <span className="palette__meta">{it.meta}</span>}
319 {it.badge && <span className="palette__badge">{it.badge}</span>}
320 </span>
321 )}
322 </span>
323 </button>
324 );
325 })
326 )}
327 </div>
328 );
329 })
330 )}
331 </div>
332 <div className="palette__foot">
333 <span>
334 <kbd>↑</kbd>
335 <kbd>↓</kbd> navigate
336 </span>
337 <span>
338 <kbd>↵</kbd> run
339 </span>
340 <span>
341 <kbd>esc</kbd> close
342 </span>
343 </div>
344 </div>
345 </div>
346 );
347 }
348
348 lines Plain Text