1// Syntax-highlighted search input with label/author autocomplete.
2//
3// Architecture: two layers share the same font/padding so they appear identical:
4// 1. A "backdrop" div (aria-hidden) renders colored <span>s for each token.
5// 2. The real <input> floats on top with transparent text and bg, so the caret
6// is visible but the text itself is hidden in favour of the backdrop.
7//
8// Autocomplete: when the cursor is inside a `label:` or `author:` token, a
9// dropdown appears with filtered suggestions fetched from GraphQL. Clicking or
10// keyboard-selecting a suggestion replaces the current token in the input.
11
12import { Search } from "lucide-react";
13import { useState, useRef, useMemo, type ChangeEvent } from "react";
14
15import type { LabelItem, IdentityItem } from "@/components/bugs/IssueFilters";
16import { cn } from "@/lib/utils";
17
18// ── Segment parsing (for the syntax-highlight backdrop) ───────────────────────
19
20type SegmentType = "status-open" | "status-closed" | "label" | "author" | "text" | "space";
21
22interface Segment {
23 text: string;
24 type: SegmentType;
25}
26
27// Parse the query string into typed segments, preserving all whitespace.
28// Walks char-by-char so that quoted values (e.g. label:"my label") are kept as
29// a single token and spaces inside quotes don't split the segment.
30function parseSegments(input: string): Segment[] {
31 const segments: Segment[] = [];
32 let i = 0;
33
34 while (i < input.length) {
35 // Whitespace runs — preserved as a separate 'space' segment so the backdrop
36 // can use whitespace-pre and match the input exactly.
37 if (input[i] === " ") {
38 let j = i;
39 while (j < input.length && input[j] === " ") j++;
40 segments.push({ text: input.slice(i, j), type: "space" });
41 i = j;
42 continue;
43 }
44
45 // Token — consume until an unquoted space
46 let j = i;
47 let inQuote = false;
48 while (j < input.length) {
49 if (input[j] === '"') {
50 inQuote = !inQuote;
51 j++;
52 continue;
53 }
54 if (!inQuote && input[j] === " ") break;
55 j++;
56 }
57
58 const token = input.slice(i, j);
59 let type: SegmentType = "text";
60 if (token === "status:open") type = "status-open";
61 else if (token === "status:closed") type = "status-closed";
62 else if (token.startsWith("label:")) type = "label";
63 else if (token.startsWith("author:")) type = "author";
64
65 segments.push({ text: token, type });
66 i = j;
67 }
68
69 return segments;
70}
71
72// Only the key portion (e.g. "label:") is colored; the value stays in foreground.
73function renderSegment(seg: Segment, i: number): React.ReactNode {
74 if (seg.type === "space" || seg.type === "text") {
75 return <span key={i}>{seg.text}</span>;
76 }
77 const colon = seg.text.indexOf(":");
78 const key = seg.text.slice(0, colon + 1);
79 const val = seg.text.slice(colon + 1);
80
81 const keyClass =
82 seg.type === "status-open"
83 ? "text-green-600 dark:text-green-400"
84 : seg.type === "status-closed"
85 ? "text-purple-600 dark:text-purple-400"
86 : seg.type === "label"
87 ? "text-yellow-600 dark:text-yellow-500"
88 : /* author */ "text-blue-600 dark:text-blue-400";
89
90 return (
91 <span key={i}>
92 <span className={keyClass}>{key}</span>
93 <span>{val}</span>
94 </span>
95 );
96}
97
98// ── Autocomplete logic ────────────────────────────────────────────────────────
99
100interface CompletionInfo {
101 type: "label" | "author";
102 /** Text typed after the prefix (e.g. "bu" for "label:bu"). Quotes stripped. */
103 query: string;
104 /** Byte position in `value` where the current token starts. */
105 tokenStart: number;
106}
107
108// Inspects the text to the left of `cursor` to determine if the user is in the
109// middle of a `label:` or `author:` token and what they've typed so far.
110// Returns null when not in an autocomplete-eligible position.
111function getCompletionInfo(value: string, cursor: number): CompletionInfo | null {
112 // Walk backward to find the start of the current token
113 let tokenStart = 0;
114 for (let i = cursor - 1; i >= 0; i--) {
115 if (value[i] === " ") {
116 tokenStart = i + 1;
117 break;
118 }
119 }
120
121 const partial = value.slice(tokenStart, cursor);
122 if (partial.startsWith("label:")) {
123 return { type: "label", query: partial.slice(6), tokenStart };
124 }
125 if (partial.startsWith("author:")) {
126 // Strip a leading quote that the user may have typed
127 return { type: "author", query: partial.slice(7).replace(/^"/, ""), tokenStart };
128 }
129 return null;
130}
131
132// Find where the current token ends (next unquoted space, or end of string).
133// Used when replacing a token on suggestion selection so we don't leave stale text.
134function getTokenEnd(value: string, tokenStart: number): number {
135 let inQuote = false;
136 for (let i = tokenStart; i < value.length; i++) {
137 if (value[i] === '"') {
138 inQuote = !inQuote;
139 continue;
140 }
141 if (!inQuote && value[i] === " ") return i;
142 }
143 return value.length;
144}
145
146// ── Component ─────────────────────────────────────────────────────────────────
147
148interface QueryInputProps {
149 value: string;
150 onChange: (value: string) => void;
151 onSubmit: () => void;
152 placeholder?: string;
153 className?: string;
154 labels: readonly LabelItem[];
155 identities: readonly IdentityItem[];
156}
157
158export function QueryInput({
159 value,
160 onChange,
161 onSubmit,
162 placeholder,
163 className,
164 labels,
165 identities,
166}: QueryInputProps) {
167 const inputRef = useRef<HTMLInputElement>(null);
168
169 // Autocomplete state: null when the dropdown is hidden.
170 const [completion, setCompletion] = useState<CompletionInfo | null>(null);
171 // Keyboard-highlighted index within the visible suggestions list.
172 const [acIndex, setAcIndex] = useState(0);
173
174 const allLabels = labels;
175 const allAuthors = identities;
176
177 // Compute the filtered suggestion list whenever completion info changes.
178 const suggestions = useMemo(() => {
179 if (!completion) return [];
180
181 if (completion.type === "label") {
182 const q = completion.query.toLowerCase();
183 return allLabels
184 .filter((l) => q === "" || l.name.toLowerCase().includes(q))
185 .slice(0, 8)
186 .map((l) => ({
187 display: l.name,
188 // Quote the token value if the label name contains a space
189 completedToken: `label:${l.name.includes(" ") ? `"${l.name}"` : l.name}`,
190 color: l.color,
191 }));
192 }
193
194 // author suggestions — match against displayName, login, and name
195 const q = completion.query.toLowerCase();
196 return allAuthors
197 .filter(
198 (a) =>
199 q === "" ||
200 a.displayName.toLowerCase().includes(q) ||
201 (a.login ?? "").toLowerCase().includes(q) ||
202 (a.name ?? "").toLowerCase().includes(q),
203 )
204 .slice(0, 8)
205 .map((a) => {
206 // Prefer login (no spaces, stable) → name → humanId as the query value.
207 // Same preference used by IssueFilters.authorQueryValue.
208 const qv = a.login ?? a.name ?? a.humanId;
209 return {
210 display: a.displayName,
211 completedToken: `author:${qv.includes(" ") ? `"${qv}"` : qv}`,
212 color: null,
213 };
214 });
215 }, [completion, allLabels, allAuthors]);
216
217 // ── Recompute completion state after every input change or cursor move ──────
218
219 function updateCompletion(newValue: string, cursor: number) {
220 const info = getCompletionInfo(newValue, cursor);
221 setCompletion(info);
222 setAcIndex(0);
223 }
224
225 function handleChange(e: ChangeEvent<HTMLInputElement>) {
226 const newValue = e.target.value;
227 const cursor = e.target.selectionStart ?? newValue.length;
228 onChange(newValue);
229 updateCompletion(newValue, cursor);
230 }
231
232 // onSelect fires on cursor movement (arrow keys, click-to-reposition), which
233 // lets us show/hide the dropdown correctly when the cursor moves into or out
234 // of an autocomplete-eligible token without changing the text.
235 function handleSelect(e: React.SyntheticEvent<HTMLInputElement>) {
236 updateCompletion(value, e.currentTarget.selectionStart ?? value.length);
237 }
238
239 // ── Keyboard navigation ───────────────────────────────────────────────────
240
241 function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
242 if (e.key === "Enter" && !completion) {
243 e.preventDefault();
244 onSubmit();
245 return;
246 }
247
248 if (!completion || suggestions.length === 0) return;
249
250 if (e.key === "ArrowDown") {
251 e.preventDefault();
252 setAcIndex((i) => (i + 1) % suggestions.length);
253 } else if (e.key === "ArrowUp") {
254 e.preventDefault();
255 setAcIndex((i) => (i - 1 + suggestions.length) % suggestions.length);
256 } else if (e.key === "Enter" || e.key === "Tab") {
257 e.preventDefault();
258 const suggestion = suggestions[acIndex];
259 if (suggestion) applySuggestion(suggestion);
260 } else if (e.key === "Escape") {
261 setCompletion(null);
262 }
263 }
264
265 // ── Apply a selected suggestion ──────────────────────────────────────────
266
267 function applySuggestion(s: { completedToken: string }) {
268 if (!completion) return;
269 const tokenEnd = getTokenEnd(value, completion.tokenStart);
270 // Replace the current token (from tokenStart to tokenEnd) with the completed
271 // token, then add a space so the user can type the next filter immediately.
272 const newValue =
273 value.slice(0, completion.tokenStart) +
274 s.completedToken +
275 " " +
276 value.slice(tokenEnd).trimStart();
277 onChange(newValue);
278 setCompletion(null);
279
280 // Restore focus and position cursor after the inserted token + space
281 const newCursor = completion.tokenStart + s.completedToken.length + 1;
282 requestAnimationFrame(() => {
283 inputRef.current?.focus();
284 inputRef.current?.setSelectionRange(newCursor, newCursor);
285 });
286 }
287
288 // ── Render ────────────────────────────────────────────────────────────────
289
290 const segments = parseSegments(value);
291 const showDropdown = completion !== null && suggestions.length > 0;
292
293 return (
294 <div
295 className={cn(
296 "relative flex-1 flex items-center rounded-md border border-input bg-background",
297 "ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
298 className,
299 )}
300 onClick={() => inputRef.current?.focus()}
301 >
302 <Search className="text-muted-foreground pointer-events-none absolute left-3 size-4 shrink-0" />
303
304 {/* Colored backdrop — same font/size/padding as the input. aria-hidden so
305 screen readers only see the real input, not the duplicate text. */}
306 <div
307 aria-hidden
308 className="text-foreground pointer-events-none absolute inset-0 flex items-center overflow-hidden pr-3 pl-9 font-mono text-sm whitespace-pre"
309 >
310 {value === "" ? null : segments.map((seg, i) => renderSegment(seg, i))}
311 </div>
312
313 {/* Actual input — transparent bg and text so the backdrop shows through.
314 caret-foreground keeps the cursor visible despite text-transparent. */}
315 <input
316 ref={inputRef}
317 type="text"
318 value={value}
319 placeholder={placeholder}
320 onChange={handleChange}
321 onKeyDown={handleKeyDown}
322 onSelect={handleSelect}
323 className="caret-foreground placeholder:text-muted-foreground relative w-full bg-transparent py-2 pr-3 pl-9 font-mono text-sm text-transparent outline-hidden placeholder:font-sans"
324 spellCheck={false}
325 autoComplete="off"
326 />
327
328 {/* Autocomplete dropdown — positioned below the input via absolute+top-full.
329 Uses onMouseDown+preventDefault so clicking a suggestion doesn't blur
330 the input before the click registers (classic focus-race problem). */}
331 {showDropdown && (
332 <div className="border-border bg-popover absolute top-full right-0 left-0 z-50 mt-1 overflow-hidden rounded-md border shadow-md">
333 {suggestions.map((s, i) => (
334 <button
335 key={s.completedToken}
336 onMouseDown={(e) => {
337 e.preventDefault();
338 applySuggestion(s);
339 }}
340 className={cn(
341 "flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm",
342 i === acIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted",
343 )}
344 >
345 {s.color && (
346 <span
347 className="size-2 shrink-0 rounded-full"
348 style={{ backgroundColor: `rgb(${s.color.R},${s.color.G},${s.color.B})` }}
349 />
350 )}
351 <span className="font-mono">{s.completedToken}</span>
352 {s.display !== s.completedToken.split(":")[1]?.replace(/"/g, "") && (
353 <span className="text-muted-foreground ml-auto text-xs">{s.display}</span>
354 )}
355 </button>
356 ))}
357 </div>
358 )}
359 </div>
360 );
361}