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