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