// Syntax-highlighted search input with label/author autocomplete. // // Architecture: two layers share the same font/padding so they appear identical: // 1. A "backdrop" div (aria-hidden) renders colored s for each token. // 2. The real floats on top with transparent text and bg, so the caret // is visible but the text itself is hidden in favour of the backdrop. // // Autocomplete: when the cursor is inside a `label:` or `author:` token, a // dropdown appears with filtered suggestions fetched from GraphQL. Clicking or // keyboard-selecting a suggestion replaces the current token in the input. import { useState, useRef, useMemo, type ChangeEvent } from 'react' import { Search } from 'lucide-react' import { cn } from '@/lib/utils' import { useValidLabelsQuery, useAllIdentitiesQuery } from '@/__generated__/graphql' import { useRepo } from '@/lib/repo' // ── Segment parsing (for the syntax-highlight backdrop) ─────────────────────── type SegmentType = 'status-open' | 'status-closed' | 'label' | 'author' | 'text' | 'space' interface Segment { text: string type: SegmentType } // Parse the query string into typed segments, preserving all whitespace. // Walks char-by-char so that quoted values (e.g. label:"my label") are kept as // a single token and spaces inside quotes don't split the segment. function parseSegments(input: string): Segment[] { const segments: Segment[] = [] let i = 0 while (i < input.length) { // Whitespace runs — preserved as a separate 'space' segment so the backdrop // can use whitespace-pre and match the input exactly. if (input[i] === ' ') { let j = i while (j < input.length && input[j] === ' ') j++ segments.push({ text: input.slice(i, j), type: 'space' }) i = j continue } // Token — consume until an unquoted space let j = i let inQuote = false while (j < input.length) { if (input[j] === '"') { inQuote = !inQuote; j++; continue } if (!inQuote && input[j] === ' ') break j++ } const token = input.slice(i, j) let type: SegmentType = 'text' if (token === 'status:open') type = 'status-open' else if (token === 'status:closed') type = 'status-closed' else if (token.startsWith('label:')) type = 'label' else if (token.startsWith('author:')) type = 'author' segments.push({ text: token, type }) i = j } return segments } // Only the key portion (e.g. "label:") is colored; the value stays in foreground. function renderSegment(seg: Segment, i: number): React.ReactNode { if (seg.type === 'space' || seg.type === 'text') { return {seg.text} } const colon = seg.text.indexOf(':') const key = seg.text.slice(0, colon + 1) const val = seg.text.slice(colon + 1) const keyClass = seg.type === 'status-open' ? 'text-green-600 dark:text-green-400' : seg.type === 'status-closed' ? 'text-purple-600 dark:text-purple-400' : seg.type === 'label' ? 'text-yellow-600 dark:text-yellow-500' : /* author */ 'text-blue-600 dark:text-blue-400' return ( {key} {val} ) } // ── Autocomplete logic ──────────────────────────────────────────────────────── interface CompletionInfo { type: 'label' | 'author' /** Text typed after the prefix (e.g. "bu" for "label:bu"). Quotes stripped. */ query: string /** Byte position in `value` where the current token starts. */ tokenStart: number } // Inspects the text to the left of `cursor` to determine if the user is in the // middle of a `label:` or `author:` token and what they've typed so far. // Returns null when not in an autocomplete-eligible position. function getCompletionInfo(value: string, cursor: number): CompletionInfo | null { // Walk backward to find the start of the current token let tokenStart = 0 for (let i = cursor - 1; i >= 0; i--) { if (value[i] === ' ') { tokenStart = i + 1; break } } const partial = value.slice(tokenStart, cursor) if (partial.startsWith('label:')) { return { type: 'label', query: partial.slice(6), tokenStart } } if (partial.startsWith('author:')) { // Strip a leading quote that the user may have typed return { type: 'author', query: partial.slice(7).replace(/^"/, ''), tokenStart } } return null } // Find where the current token ends (next unquoted space, or end of string). // Used when replacing a token on suggestion selection so we don't leave stale text. function getTokenEnd(value: string, tokenStart: number): number { let inQuote = false for (let i = tokenStart; i < value.length; i++) { if (value[i] === '"') { inQuote = !inQuote; continue } if (!inQuote && value[i] === ' ') return i } return value.length } // ── Component ───────────────────────────────────────────────────────────────── interface QueryInputProps { value: string onChange: (value: string) => void onSubmit: () => void placeholder?: string className?: string } export function QueryInput({ value, onChange, onSubmit, placeholder, className }: QueryInputProps) { const inputRef = useRef(null) const repo = useRepo() // Autocomplete state: null when the dropdown is hidden. const [completion, setCompletion] = useState(null) // Keyboard-highlighted index within the visible suggestions list. const [acIndex, setAcIndex] = useState(0) // Fetch all labels and identities for autocomplete suggestions. // These queries are cheap (cached by Apollo) and already used by IssueFilters, // so there is no extra network cost. const { data: labelsData } = useValidLabelsQuery({ variables: { ref: repo } }) const { data: authorsData } = useAllIdentitiesQuery({ variables: { ref: repo } }) const allLabels = labelsData?.repository?.validLabels.nodes ?? [] const allAuthors = authorsData?.repository?.allIdentities.nodes ?? [] // Compute the filtered suggestion list whenever completion info changes. const suggestions = useMemo(() => { if (!completion) return [] if (completion.type === 'label') { const q = completion.query.toLowerCase() return allLabels .filter((l) => q === '' || l.name.toLowerCase().includes(q)) .slice(0, 8) .map((l) => ({ display: l.name, // Quote the token value if the label name contains a space completedToken: `label:${l.name.includes(' ') ? `"${l.name}"` : l.name}`, color: l.color, })) } // author suggestions — match against displayName, login, and name const q = completion.query.toLowerCase() return allAuthors .filter( (a) => q === '' || a.displayName.toLowerCase().includes(q) || (a.login ?? '').toLowerCase().includes(q) || (a.name ?? '').toLowerCase().includes(q), ) .slice(0, 8) .map((a) => { // Prefer login (no spaces, stable) → name → humanId as the query value. // Same preference used by IssueFilters.authorQueryValue. const qv = a.login ?? a.name ?? a.humanId return { display: a.displayName, completedToken: `author:${qv.includes(' ') ? `"${qv}"` : qv}`, color: null, } }) }, [completion, allLabels, allAuthors]) // ── Recompute completion state after every input change or cursor move ────── function updateCompletion(newValue: string, cursor: number) { const info = getCompletionInfo(newValue, cursor) setCompletion(info) setAcIndex(0) } function handleChange(e: ChangeEvent) { const newValue = e.target.value const cursor = e.target.selectionStart ?? newValue.length onChange(newValue) updateCompletion(newValue, cursor) } // onSelect fires on cursor movement (arrow keys, click-to-reposition), which // lets us show/hide the dropdown correctly when the cursor moves into or out // of an autocomplete-eligible token without changing the text. function handleSelect(e: React.SyntheticEvent) { updateCompletion(value, e.currentTarget.selectionStart ?? value.length) } // ── Keyboard navigation ─────────────────────────────────────────────────── function handleKeyDown(e: React.KeyboardEvent) { if (e.key === 'Enter' && !completion) { e.preventDefault() onSubmit() return } if (!completion || suggestions.length === 0) return if (e.key === 'ArrowDown') { e.preventDefault() setAcIndex((i) => (i + 1) % suggestions.length) } else if (e.key === 'ArrowUp') { e.preventDefault() setAcIndex((i) => (i - 1 + suggestions.length) % suggestions.length) } else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault() applySuggestion(suggestions[acIndex]) } else if (e.key === 'Escape') { setCompletion(null) } } // ── Apply a selected suggestion ────────────────────────────────────────── function applySuggestion(s: { completedToken: string }) { if (!completion) return const tokenEnd = getTokenEnd(value, completion.tokenStart) // Replace the current token (from tokenStart to tokenEnd) with the completed // token, then add a space so the user can type the next filter immediately. const newValue = value.slice(0, completion.tokenStart) + s.completedToken + ' ' + value.slice(tokenEnd).trimStart() onChange(newValue) setCompletion(null) // Restore focus and position cursor after the inserted token + space const newCursor = completion.tokenStart + s.completedToken.length + 1 requestAnimationFrame(() => { inputRef.current?.focus() inputRef.current?.setSelectionRange(newCursor, newCursor) }) } // ── Render ──────────────────────────────────────────────────────────────── const segments = parseSegments(value) const showDropdown = completion !== null && suggestions.length > 0 return (
inputRef.current?.focus()} > {/* Colored backdrop — same font/size/padding as the input. aria-hidden so screen readers only see the real input, not the duplicate text. */}
{value === '' ? null : segments.map((seg, i) => renderSegment(seg, i))}
{/* Actual input — transparent bg and text so the backdrop shows through. caret-foreground keeps the cursor visible despite text-transparent. */} {/* Autocomplete dropdown — positioned below the input via absolute+top-full. Uses onMouseDown+preventDefault so clicking a suggestion doesn't blur the input before the click registers (classic focus-race problem). */} {showDropdown && (
{suggestions.map((s, i) => ( ))}
)}
) }