@skyhook-io/radar-app 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,493 @@
1
+ import { useState, useMemo, useRef, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { Search, CornerDownLeft, Loader2, AlertTriangle } from 'lucide-react'
4
+ import { clsx } from 'clsx'
5
+ import { SearchPillInput, type SearchModifier } from '@skyhook-io/k8s-ui'
6
+ import { getResourceIcon } from '../../utils/resource-icons'
7
+ import { useSearch, useNamespaceScope, useContexts, type SearchHit, type SearchMatchedField } from '../../api/client'
8
+ import { useAPIResources } from '../../api/apiResources'
9
+ import { loadRecentResources, recordRecentResource } from '../../hooks/useRecentResources'
10
+ import { useCommandItems, bestScore, type CommandItem, type CommandItemCallbacks } from './command-items'
11
+ import { SearchSyntaxHelp } from './SearchSyntaxHelp'
12
+
13
+ // Health → dot color (summaryContext.health is the same vocabulary as the rest
14
+ // of Radar). Kept local + tiny to avoid pulling the full status-tone system.
15
+ function healthDot(health?: string): string | null {
16
+ switch (health) {
17
+ case 'healthy': return 'bg-emerald-500'
18
+ case 'degraded': return 'bg-amber-500'
19
+ case 'unhealthy': return 'bg-red-500'
20
+ case 'unknown': return 'bg-theme-text-tertiary'
21
+ default: return null
22
+ }
23
+ }
24
+
25
+ function escapeRe(s: string): string {
26
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
27
+ }
28
+
29
+ // Wrap matched substrings in a brand-tinted, bold run so the user can see WHY a
30
+ // result matched — including when the match is on the namespace/kind, not the
31
+ // name. Longest tokens first so "staging" wins over a stray "s".
32
+ function highlight(text: string, tokens: string[]): React.ReactNode {
33
+ const toks = [...new Set(tokens.filter(Boolean))].sort((a, b) => b.length - a.length)
34
+ if (!toks.length || !text) return text
35
+ const re = new RegExp(`(${toks.map(escapeRe).join('|')})`, 'ig')
36
+ const parts: React.ReactNode[] = []
37
+ let last = 0
38
+ for (const m of text.matchAll(re)) {
39
+ const i = m.index ?? 0
40
+ if (i > last) parts.push(text.slice(last, i))
41
+ parts.push(<mark key={i} className="bg-transparent font-semibold text-[var(--color-brand)]">{m[0]}</mark>)
42
+ last = i + m[0].length
43
+ }
44
+ if (!parts.length) return text
45
+ if (last < text.length) parts.push(text.slice(last))
46
+ return parts
47
+ }
48
+
49
+ // The query tokens that the search engine recorded as landing on a given field
50
+ // (site), so each displayed field highlights only what actually matched it.
51
+ function tokensForSite(matched: SearchMatchedField[] | undefined, ...sites: string[]): string[] {
52
+ if (!matched) return []
53
+ return matched.filter((m) => sites.includes(m.site)).map((m) => m.token)
54
+ }
55
+
56
+ function useDebounced<T>(value: T, ms: number): T {
57
+ const [v, setV] = useState(value)
58
+ useEffect(() => {
59
+ const t = setTimeout(() => setV(value), ms)
60
+ return () => clearTimeout(t)
61
+ }, [value, ms])
62
+ return v
63
+ }
64
+
65
+ export interface OmnibarHandle {
66
+ focus: () => void
67
+ }
68
+
69
+ interface OmnibarProps extends CommandItemCallbacks {
70
+ /** Open a resource hit (route-based — sets the URL + opens the drawer). */
71
+ onOpenResource: (hit: SearchHit) => void
72
+ }
73
+
74
+ type Row =
75
+ | { id: string; kind: 'resource'; hit: SearchHit; recent?: boolean }
76
+ | { id: string; kind: 'command'; command: CommandItem }
77
+
78
+ const COMMAND_CATEGORY_ORDER = ['Views', 'Resource Kinds', 'Namespaces', 'Clusters', 'Actions']
79
+ const PAGE = 8
80
+ const STRONG_KIND = 100 // exact (150) or prefix (100) kind-name match
81
+
82
+ function pillsToQuery(pills: SearchModifier[]): string {
83
+ return pills.map((p) => `${p.key}:${p.value}`).join(' ')
84
+ }
85
+
86
+ // The standalone omnibar: a persistent top-center search box that IS the ⌘K
87
+ // surface. Typing runs the live, GLOBAL resource search (/api/search) alongside
88
+ // the command-palette items; modifiers (ns:, kind:, …) become removable pills.
89
+ // Resources lead, commands follow. ⌘K focuses it.
90
+ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
91
+ { onOpenResource, ...callbacks },
92
+ ref,
93
+ ) {
94
+ const [text, setText] = useState('')
95
+ const [pills, setPills] = useState<SearchModifier[]>([])
96
+ const [open, setOpen] = useState(false)
97
+ const [suggesting, setSuggesting] = useState(false)
98
+ const inputRef = useRef<HTMLInputElement>(null)
99
+ const containerRef = useRef<HTMLDivElement>(null)
100
+ const panelRef = useRef<HTMLDivElement>(null)
101
+ const listRef = useRef<HTMLDivElement>(null)
102
+ // The dropdown is portaled to <body> (so the header's stacking context can't
103
+ // trap the dim overlay). `centerX` aligns the panel under the input; `top` is
104
+ // the HEADER's bottom (not the input's) so the dim starts cleanly below the
105
+ // whole top bar — the input is shorter than the bar, so anchoring to it would
106
+ // slice the dim through the taller right-side controls.
107
+ const [anchor, setAnchor] = useState<{ centerX: number; top: number } | null>(null)
108
+
109
+ useImperativeHandle(ref, () => ({ focus: () => { inputRef.current?.focus(); inputRef.current?.select() } }), [])
110
+
111
+ const { data: nsScope } = useNamespaceScope()
112
+ const { data: apiResources } = useAPIResources()
113
+ const { data: contexts } = useContexts()
114
+ // Recents are partitioned by the current cluster so a context switch never
115
+ // surfaces (or opens) the previous cluster's resources.
116
+ const contextKey = useMemo(() => contexts?.find((c) => c.isCurrent)?.name ?? '', [contexts])
117
+ // ns + kind are the bounded, knowable modifier value sets worth autocompleting.
118
+ const modifierOptions = useMemo(() => ({
119
+ ns: nsScope?.accessibleNamespaces ?? [],
120
+ kind: apiResources ? [...new Set(apiResources.filter((r) => r.verbs?.includes('list')).map((r) => r.kind))].sort() : [],
121
+ }), [nsScope, apiResources])
122
+
123
+ // Reflect the current view scope as an editable `ns:` pill on open, so a
124
+ // deliberately broad ⌘K search shows (and lets you remove) the namespace it's
125
+ // narrowed to instead of silently scoping. Seeded once per open, only from a
126
+ // truly empty launcher state.
127
+ const actives = nsScope?.actives
128
+ const seededRef = useRef(false)
129
+ useEffect(() => {
130
+ if (!open) { seededRef.current = false; return }
131
+ if (seededRef.current || actives === undefined) return
132
+ seededRef.current = true
133
+ if (pills.length === 0 && text === '' && actives.length > 0) {
134
+ setPills(actives.map((ns) => ({ key: 'ns', value: ns })))
135
+ }
136
+ }, [open, actives, pills.length, text])
137
+
138
+ const freeText = text.trim()
139
+ const queryString = useMemo(() => [pillsToQuery(pills), freeText].filter(Boolean).join(' '), [pills, freeText])
140
+ const searchActive = queryString.length >= 2
141
+ // Small debounce: /api/search is a local in-memory index, so this exists only
142
+ // to coalesce fast keystrokes (less list reshuffle), not to cut network cost —
143
+ // kept under the ~100-150ms "feels instant" threshold. keepPreviousData +
144
+ // AbortSignal (see useSearch) handle the smoothness; commands aren't debounced.
145
+ const debounced = useDebounced(queryString, 120)
146
+ // globalNs: ⌘K searches the user's full RBAC ceiling; scope comes only from
147
+ // `ns:` pills, never the silent view filter.
148
+ const { data: searchData, isFetching, isPlaceholderData, isError } = useSearch(debounced, { enabled: open, globalNs: true })
149
+
150
+ const commandItems = useCommandItems(callbacks)
151
+
152
+ // Commands score against the FREE text only — modifiers live in pills, so the
153
+ // launcher never sees "ns:" polluting a "go to topology" match. Empty + no
154
+ // pills → Views + Actions (launcher default); with pills but no text the user
155
+ // is browsing a scope, so suppress the command default.
156
+ const scoredCommands = useMemo(() => {
157
+ if (!freeText) {
158
+ return pills.length ? [] : commandItems.filter((i) => i.category === 'Views' || i.category === 'Actions').map((item) => ({ item, score: 1 }))
159
+ }
160
+ return commandItems.map((item) => ({ item, score: bestScore(item, freeText) })).filter((x) => x.score > 0).sort((a, b) => b.score - a.score)
161
+ }, [commandItems, freeText, pills.length])
162
+
163
+ // Kinds whose NAME strongly matches (exact 150 / prefix 100) lead ABOVE the
164
+ // resource instances: "⌘K → deployment → Deployments list" is a navigation
165
+ // flow the instance hits otherwise bury.
166
+ const leadingKinds = useMemo<CommandItem[]>(
167
+ () => (freeText.length < 2 ? [] : scoredCommands.filter((x) => x.item.category === 'Resource Kinds' && x.score >= STRONG_KIND).slice(0, 5).map((x) => x.item)),
168
+ [scoredCommands, freeText],
169
+ )
170
+ const leadingIds = useMemo(() => new Set(leadingKinds.map((i) => i.id)), [leadingKinds])
171
+
172
+ const resourceRows = useMemo<Row[]>(() => {
173
+ const hits = searchData?.hits ?? []
174
+ return hits.map((hit) => ({ id: `res:${hit.kind}:${hit.group || ''}:${hit.namespace || ''}:${hit.name}`, kind: 'resource' as const, hit }))
175
+ }, [searchData])
176
+
177
+ // Launcher recents: only in the truly-empty state (no text, no pills). Read
178
+ // fresh from localStorage each open.
179
+ const recentRows = useMemo<Row[]>(() => {
180
+ if (!open || freeText || pills.length > 0) return []
181
+ return loadRecentResources(contextKey).map((r) => ({
182
+ id: `recent:${r.kind}:${r.group || ''}:${r.namespace || ''}:${r.name}`,
183
+ kind: 'resource' as const,
184
+ recent: true,
185
+ hit: { score: 0, kind: r.kind, group: r.group, namespace: r.namespace, name: r.name } as SearchHit,
186
+ }))
187
+ }, [open, freeText, pills.length, contextKey])
188
+
189
+ // Remaining matched commands (leading kinds removed so they don't repeat),
190
+ // grouped by their real category in a fixed order.
191
+ const commandGroups = useMemo(() => {
192
+ const rest = scoredCommands.filter((x) => !leadingIds.has(x.item.id)).slice(0, 8).map((x) => x.item)
193
+ const byCat = new Map<string, CommandItem[]>()
194
+ for (const c of rest) { const l = byCat.get(c.category) ?? []; l.push(c); byCat.set(c.category, l) }
195
+ return COMMAND_CATEGORY_ORDER.filter((cat) => byCat.has(cat)).map((cat) => ({ category: cat, items: byCat.get(cat)! }))
196
+ }, [scoredCommands, leadingIds])
197
+
198
+ const toCmdRow = (c: CommandItem): Row => ({ id: `cmd:${c.id}`, kind: 'command', command: c })
199
+
200
+ // Free-text tokens for highlighting command labels (commands are scored
201
+ // client-side, so there's no server `matched`).
202
+ const queryTokens = useMemo(() => freeText.split(/\s+/).filter(Boolean), [freeText])
203
+
204
+ // Ordered, id-stable list (render order == keyboard model): recents (launcher
205
+ // only), then leading kinds, then resources (when searchActive), then commands.
206
+ const rows = useMemo<Row[]>(() => {
207
+ const cmds: Row[] = commandGroups.flatMap((g) => g.items.map(toCmdRow))
208
+ if (!freeText && pills.length === 0) return [...recentRows, ...cmds]
209
+ return [...leadingKinds.map(toCmdRow), ...(searchActive ? resourceRows : []), ...cmds]
210
+ // eslint-disable-next-line react-hooks/exhaustive-deps
211
+ }, [recentRows, leadingKinds, resourceRows, commandGroups, freeText, pills.length, searchActive])
212
+
213
+ // Selection tracked by stable id (not array index) so Enter can never fire a
214
+ // stale row when the set shifts. Auto-follows the TOP result until the user
215
+ // arrow-keys; a new query re-enables auto-follow.
216
+ const [selectedId, setSelectedId] = useState<string | null>(null)
217
+ const userMovedRef = useRef(false)
218
+ useEffect(() => { userMovedRef.current = false }, [queryString])
219
+ const rowsKey = rows.map((r) => r.id).join('|')
220
+ useEffect(() => {
221
+ setSelectedId((cur) => {
222
+ if (!userMovedRef.current) return rows[0]?.id ?? null
223
+ return cur && rows.some((r) => r.id === cur) ? cur : rows[0]?.id ?? null
224
+ })
225
+ // eslint-disable-next-line react-hooks/exhaustive-deps
226
+ }, [rowsKey])
227
+ const selectedIndex = rows.findIndex((r) => r.id === selectedId)
228
+ const moveSelection = (delta: number) => {
229
+ userMovedRef.current = true
230
+ setSelectedId(rows[Math.min(Math.max(selectedIndex + delta, 0), rows.length - 1)]?.id ?? null)
231
+ }
232
+ const selectRow = (id: string) => { userMovedRef.current = true; setSelectedId(id) }
233
+ // Page by a full screenful of visible rows (minus one for context overlap),
234
+ // measured from the scroll container — a fixed count feels short on tall lists.
235
+ const pageStep = () => {
236
+ const list = listRef.current
237
+ const rowH = (list?.querySelector('button') as HTMLElement | null)?.offsetHeight
238
+ if (!list || !rowH) return PAGE
239
+ return Math.max(1, Math.floor(list.clientHeight / rowH) - 1)
240
+ }
241
+
242
+ const execute = useCallback((row: Row) => {
243
+ if (row.kind === 'command') {
244
+ row.command.action()
245
+ } else {
246
+ const h = row.hit
247
+ recordRecentResource({ kind: h.kind, group: h.group, namespace: h.namespace, name: h.name }, contextKey)
248
+ onOpenResource(h)
249
+ }
250
+ setOpen(false)
251
+ setText('')
252
+ setPills([])
253
+ inputRef.current?.blur()
254
+ }, [onOpenResource, contextKey])
255
+
256
+ // The resources shown don't (yet) belong to the current query: the debounce
257
+ // hasn't fired, the data is React Query placeholder from a prior query, or
258
+ // results for this query haven't landed. Swallow Enter so it can't open a
259
+ // stale hit or a command standing in for an imminent resource.
260
+ const resourcesStale = searchActive && (debounced !== queryString || isPlaceholderData || (resourceRows.length === 0 && isFetching))
261
+
262
+ // Forwarded from SearchPillInput for keys it doesn't consume (it owns Space →
263
+ // pill, Backspace → pop pill, and suggestion nav).
264
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
265
+ if (e.key === 'Escape') { e.preventDefault(); setOpen(false); inputRef.current?.blur(); return }
266
+ if (e.key === 'ArrowDown') { e.preventDefault(); moveSelection(1) }
267
+ else if (e.key === 'ArrowUp') { e.preventDefault(); moveSelection(-1) }
268
+ else if (e.key === 'PageDown') { e.preventDefault(); moveSelection(pageStep()) }
269
+ else if (e.key === 'PageUp') { e.preventDefault(); moveSelection(-pageStep()) }
270
+ // Home/End deliberately left native so they move the text caret, not the list.
271
+ else if (e.key === 'Enter') {
272
+ e.preventDefault()
273
+ const row = rows[selectedIndex]
274
+ if (!row) return
275
+ // Block Enter only for a stale RESOURCE row (could open a hidden/stale
276
+ // hit); commands and ready resources fire immediately.
277
+ if (row.kind === 'resource' && resourcesStale) return
278
+ execute(row)
279
+ }
280
+ // eslint-disable-next-line react-hooks/exhaustive-deps
281
+ }, [rows, selectedIndex, execute, resourcesStale])
282
+
283
+ // Keep the selected row in view.
284
+ useEffect(() => {
285
+ listRef.current?.querySelector('[data-selected="true"]')?.scrollIntoView({ block: 'nearest' })
286
+ }, [selectedId])
287
+
288
+ // Close on outside click — the panel is portaled out of the container, so it
289
+ // must be excluded explicitly or clicking a row would count as "outside".
290
+ useEffect(() => {
291
+ if (!open) return
292
+ const onDown = (e: MouseEvent) => {
293
+ const t = e.target as Node
294
+ if (!containerRef.current?.contains(t) && !panelRef.current?.contains(t)) setOpen(false)
295
+ }
296
+ document.addEventListener('mousedown', onDown)
297
+ return () => document.removeEventListener('mousedown', onDown)
298
+ }, [open])
299
+
300
+ // Track the input's position so the portaled panel stays anchored under it
301
+ // through scroll / resize / layout shifts.
302
+ useEffect(() => {
303
+ if (!open) { setAnchor(null); return }
304
+ const update = () => {
305
+ const el = containerRef.current
306
+ if (!el) return
307
+ const r = el.getBoundingClientRect()
308
+ const header = el.closest('header')
309
+ setAnchor({ centerX: r.left + r.width / 2, top: header ? header.getBoundingClientRect().bottom : r.bottom })
310
+ }
311
+ update()
312
+ window.addEventListener('resize', update)
313
+ window.addEventListener('scroll', update, true)
314
+ return () => { window.removeEventListener('resize', update); window.removeEventListener('scroll', update, true) }
315
+ }, [open])
316
+
317
+ const mac = typeof navigator !== 'undefined' && navigator.platform.includes('Mac')
318
+ const total = searchData?.total ?? 0
319
+ const totalMatched = searchData?.total_matched ?? 0
320
+ const hasNsPill = pills.some((p) => p.key === 'ns')
321
+ const dropdownOpen = open && !suggesting && (rows.length > 0 || searchActive)
322
+
323
+ const clearNsPills = () => { setPills((prev) => prev.filter((p) => p.key !== 'ns')); inputRef.current?.focus() }
324
+
325
+ return (
326
+ <div ref={containerRef} className="relative w-full max-w-xl">
327
+ <SearchPillInput
328
+ className="min-h-8 px-2.5 rounded-md bg-theme-elevated border border-transparent focus-within:border-theme-border focus-within:bg-theme-surface transition-colors"
329
+ text={text}
330
+ pills={pills}
331
+ onChange={({ text: t, pills: p }) => { setText(t); setPills(p); setOpen(true) }}
332
+ onKeyDown={handleKeyDown}
333
+ onFocus={() => setOpen(true)}
334
+ onSuggestingChange={setSuggesting}
335
+ modifierOptions={modifierOptions}
336
+ placeholder="Search resources & commands…"
337
+ aria-label="Search resources and commands"
338
+ inputRef={inputRef}
339
+ leftSlot={<Search className="w-3.5 h-3.5 shrink-0 text-theme-text-tertiary" />}
340
+ rightSlot={
341
+ <div className="flex items-center gap-1.5 shrink-0">
342
+ <SearchSyntaxHelp />
343
+ {!text && pills.length === 0 && (
344
+ <kbd className="text-[10px] text-theme-text-tertiary bg-theme-surface px-1 py-0.5 rounded border border-theme-border-light">
345
+ {mac ? '⌘' : 'Ctrl+'}K
346
+ </kbd>
347
+ )}
348
+ </div>
349
+ }
350
+ />
351
+
352
+ {open && anchor && (dropdownOpen || suggesting) && createPortal(
353
+ <>
354
+ {/* Dim + blur the busy dashboard behind so results read as a focused
355
+ search surface (Spotlight/Linear pattern), not a weak float. Starts
356
+ at the header's bottom edge so the search box + top bar stay crisp.
357
+ Tied to `open`, NOT the results panel, so completing a modifier (the
358
+ panel briefly yields to the autocomplete) doesn't strobe the dim. */}
359
+ <div
360
+ className="fixed left-0 right-0 bottom-0 z-[120] bg-black/25 dark:bg-black/55 backdrop-blur-[2px]"
361
+ style={{ top: anchor.top }}
362
+ onClick={() => { setOpen(false); inputRef.current?.blur() }}
363
+ />
364
+ {dropdownOpen && (
365
+ <div
366
+ ref={panelRef}
367
+ style={{ position: 'fixed', top: anchor.top + 8, left: anchor.centerX, transform: 'translateX(-50%)', width: 640, maxWidth: 'calc(100vw - 2rem)' }}
368
+ className="z-[121] dialog shadow-theme-lg overflow-hidden"
369
+ >
370
+ <div ref={listRef} className="max-h-[60vh] overflow-y-auto py-1">
371
+ {/* Recently viewed — launcher state only. */}
372
+ {recentRows.length > 0 && (
373
+ <div>
374
+ <div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-theme-text-tertiary">Recently viewed</div>
375
+ {recentRows.map((row) => row.kind === 'resource' && (
376
+ <ResourceRow key={row.id} hit={row.hit} selected={row.id === selectedId} onSelect={() => selectRow(row.id)} onActivate={() => execute(row)} />
377
+ ))}
378
+ </div>
379
+ )}
380
+
381
+ {/* Leading kinds — strong kind-name matches lead so ⌘K navigation
382
+ to a kind isn't buried under instance hits. */}
383
+ {leadingKinds.length > 0 && (
384
+ <div>
385
+ <div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-theme-text-tertiary">Resource Kinds</div>
386
+ {leadingKinds.map((item) => {
387
+ const id = `cmd:${item.id}`
388
+ return <CommandRow key={id} item={item} tokens={queryTokens} selected={id === selectedId} onSelect={() => selectRow(id)} onActivate={() => execute(toCmdRow(item))} />
389
+ })}
390
+ </div>
391
+ )}
392
+
393
+ {/* Resources section */}
394
+ {searchActive && (
395
+ <>
396
+ <div className="flex items-center justify-between px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-theme-text-tertiary">
397
+ <span>Resources</span>
398
+ {isFetching && <Loader2 className="w-3 h-3 animate-spin" />}
399
+ {!isFetching && !isError && totalMatched > total && <span className="normal-case font-normal">showing {total} of {totalMatched}</span>}
400
+ </div>
401
+ {isError ? (
402
+ <div className="flex items-center gap-2 px-3 py-2 text-xs text-amber-600 dark:text-amber-400">
403
+ <AlertTriangle className="w-3.5 h-3.5 shrink-0" /> Search is unavailable right now.
404
+ </div>
405
+ ) : resourceRows.length === 0 && !isFetching ? (
406
+ <div className="px-3 py-2 text-xs text-theme-text-tertiary">
407
+ No resources match{freeText ? <> “{freeText}”</> : ''}.
408
+ {hasNsPill && (
409
+ <button onMouseDown={(e) => { e.preventDefault(); clearNsPills() }} className="ml-1.5 text-[var(--color-brand)] hover:underline">
410
+ Search all namespaces
411
+ </button>
412
+ )}
413
+ </div>
414
+ ) : (
415
+ resourceRows.map((row) => row.kind === 'resource' && (
416
+ // Mirror the Enter guard: ignore clicks on stale rows (prior
417
+ // query's results during debounce/placeholder) so a click can't
418
+ // open/record the wrong resource. Dim them so it reads as pending.
419
+ <ResourceRow key={row.id} hit={row.hit} stale={resourcesStale} selected={row.id === selectedId} onSelect={() => selectRow(row.id)} onActivate={() => { if (!resourcesStale) execute(row) }} />
420
+ ))
421
+ )}
422
+ </>
423
+ )}
424
+
425
+ {/* Command groups, each under its real category header. */}
426
+ {commandGroups.map((group) => (
427
+ <div key={group.category}>
428
+ <div className="px-3 py-1 mt-1 text-[10px] font-semibold uppercase tracking-wider text-theme-text-tertiary">{group.category}</div>
429
+ {group.items.map((item) => {
430
+ const id = `cmd:${item.id}`
431
+ return <CommandRow key={id} item={item} tokens={queryTokens} selected={id === selectedId} onSelect={() => selectRow(id)} onActivate={() => execute({ id, kind: 'command', command: item })} />
432
+ })}
433
+ </div>
434
+ ))}
435
+ </div>
436
+ <div className="flex items-center gap-3 px-3 py-1.5 border-t border-theme-border text-[11px] text-theme-text-tertiary">
437
+ <span className="flex items-center gap-1"><CornerDownLeft className="w-3 h-3" /> open</span>
438
+ <span>↑↓ navigate</span>
439
+ <span>⇞⇟ page</span>
440
+ <span>esc close</span>
441
+ </div>
442
+ </div>
443
+ )}
444
+ </>,
445
+ document.body,
446
+ )}
447
+ </div>
448
+ )
449
+ })
450
+
451
+ function ResourceRow({ hit, selected, stale, onSelect, onActivate }: { hit: SearchHit; selected: boolean; stale?: boolean; onSelect: () => void; onActivate: () => void }) {
452
+ const Icon = getResourceIcon(hit.kind)
453
+ const dot = healthDot(hit.summaryContext?.health)
454
+ const issues = hit.summaryContext?.issueCount ?? 0
455
+ // Lead is a name match; flag content-only matches so a name search isn't
456
+ // silently padded with body hits.
457
+ const contentOnly = !!hit.matched?.length && hit.matched.every((m) => m.site.startsWith('content:'))
458
+ return (
459
+ <button
460
+ data-selected={selected}
461
+ onClick={onActivate}
462
+ onMouseMove={onSelect}
463
+ className={clsx('w-full flex items-center gap-2.5 px-3 py-1.5 text-left transition-colors', selected ? 'selection' : 'hover:bg-theme-elevated/40', stale && 'opacity-50')}
464
+ >
465
+ <Icon className="w-4 h-4 shrink-0 text-theme-text-tertiary" />
466
+ <span className="min-w-0 truncate text-sm text-theme-text-primary">{highlight(hit.name, tokensForSite(hit.matched, 'name'))}</span>
467
+ {dot && <span className={clsx('h-1.5 w-1.5 rounded-full shrink-0', dot)} />}
468
+ <span className="shrink-0 max-w-[45%] truncate text-xs text-theme-text-tertiary">
469
+ {highlight(hit.kind, tokensForSite(hit.matched, 'kind'))}
470
+ {hit.namespace ? <> · {highlight(hit.namespace, tokensForSite(hit.matched, 'namespace'))}</> : ''}
471
+ </span>
472
+ {contentOnly && <span className="shrink-0 text-[10px] text-theme-text-tertiary italic">in spec</span>}
473
+ {issues > 0 && <span className="ml-auto shrink-0 text-[10px] font-medium text-amber-600 dark:text-amber-400">{issues} issue{issues === 1 ? '' : 's'}</span>}
474
+ </button>
475
+ )
476
+ }
477
+
478
+ function CommandRow({ item, tokens, selected, onSelect, onActivate }: { item: CommandItem; tokens: string[]; selected: boolean; onSelect: () => void; onActivate: () => void }) {
479
+ const Icon = item.icon
480
+ return (
481
+ <button
482
+ data-selected={selected}
483
+ onClick={onActivate}
484
+ onMouseMove={onSelect}
485
+ className={clsx('w-full flex items-center gap-2.5 px-3 py-1.5 text-left transition-colors', selected ? 'selection' : 'hover:bg-theme-elevated/40')}
486
+ >
487
+ {Icon ? <Icon className="w-4 h-4 shrink-0 text-theme-text-tertiary" /> : <span className="w-4 shrink-0" />}
488
+ <span className="min-w-0 truncate text-sm text-theme-text-primary">{highlight(item.label, tokens)}</span>
489
+ {item.sublabel && <span className="shrink-0 max-w-[45%] truncate text-xs text-theme-text-tertiary">{highlight(item.sublabel, tokens)}</span>}
490
+ {item.shortcut && <kbd className="ml-auto shrink-0 text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 py-0.5 rounded border border-theme-border-light">{item.shortcut}</kbd>}
491
+ </button>
492
+ )
493
+ }
@@ -0,0 +1,89 @@
1
+ import { useState, useRef, useEffect, useCallback } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { HelpCircle } from 'lucide-react'
4
+ import { clsx } from 'clsx'
5
+
6
+ // The query operators recognized by the search engine (internal/search/parse.go).
7
+ const OPERATORS: { syntax: string; desc: string }[] = [
8
+ { syntax: 'ns:prod', desc: 'filter by namespace' },
9
+ { syntax: 'kind:Deployment', desc: 'filter by resource kind' },
10
+ { syntax: 'label:app=api', desc: 'filter by label' },
11
+ { syntax: 'image:nginx', desc: 'match container image' },
12
+ { syntax: 'redis cache', desc: 'multiple terms — all must match' },
13
+ ]
14
+
15
+ // A "?" affordance that reveals the search query syntax on HOVER (a transient
16
+ // reference tooltip, not a second click-to-open panel stacked over the results).
17
+ // Self-contained + portaled to <body> so the header's stacking context can't
18
+ // trap it; a short close grace lets the pointer travel onto the card.
19
+ export function SearchSyntaxHelp() {
20
+ const [open, setOpen] = useState(false)
21
+ const btnRef = useRef<HTMLButtonElement>(null)
22
+ const popRef = useRef<HTMLDivElement>(null)
23
+ const closeTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
24
+ const [anchor, setAnchor] = useState<{ right: number; top: number } | null>(null)
25
+
26
+ const show = useCallback(() => { clearTimeout(closeTimer.current); setOpen(true) }, [])
27
+ const scheduleHide = useCallback(() => {
28
+ clearTimeout(closeTimer.current)
29
+ closeTimer.current = setTimeout(() => setOpen(false), 120)
30
+ }, [])
31
+
32
+ useEffect(() => () => clearTimeout(closeTimer.current), [])
33
+
34
+ useEffect(() => {
35
+ if (!open) { setAnchor(null); return }
36
+ const update = () => {
37
+ const el = btnRef.current
38
+ if (el) { const r = el.getBoundingClientRect(); setAnchor({ right: window.innerWidth - r.right, top: r.bottom + 6 }) }
39
+ }
40
+ update()
41
+ window.addEventListener('resize', update)
42
+ window.addEventListener('scroll', update, true)
43
+ return () => {
44
+ window.removeEventListener('resize', update)
45
+ window.removeEventListener('scroll', update, true)
46
+ }
47
+ }, [open])
48
+
49
+ return (
50
+ <>
51
+ <button
52
+ ref={btnRef}
53
+ type="button"
54
+ tabIndex={-1}
55
+ aria-label="Search syntax help"
56
+ onMouseEnter={show}
57
+ onMouseLeave={scheduleHide}
58
+ onMouseDown={(e) => e.preventDefault()}
59
+ className={clsx('shrink-0 rounded p-0.5 cursor-help transition-colors', open ? 'text-theme-text-secondary' : 'text-theme-text-tertiary hover:text-theme-text-secondary')}
60
+ >
61
+ <HelpCircle className="w-3.5 h-3.5" />
62
+ </button>
63
+ {open && anchor && createPortal(
64
+ <div
65
+ ref={popRef}
66
+ onMouseEnter={show}
67
+ onMouseLeave={scheduleHide}
68
+ onMouseDown={(e) => e.stopPropagation()}
69
+ style={{ position: 'fixed', top: anchor.top, right: anchor.right, width: 280 }}
70
+ className="z-[130] dialog shadow-theme-lg p-3"
71
+ >
72
+ <div className="text-[10px] font-semibold uppercase tracking-wider text-theme-text-tertiary mb-2">Search syntax</div>
73
+ <div className="space-y-1.5">
74
+ {OPERATORS.map((op) => (
75
+ <div key={op.syntax} className="flex items-baseline gap-2">
76
+ <code className="shrink-0 rounded bg-theme-elevated border border-theme-border-light px-1.5 py-0.5 text-xs text-theme-text-primary">{op.syntax}</code>
77
+ <span className="text-xs text-theme-text-secondary">{op.desc}</span>
78
+ </div>
79
+ ))}
80
+ </div>
81
+ <div className="mt-2.5 pt-2 border-t border-theme-border text-[11px] text-theme-text-tertiary">
82
+ Type <code className="text-theme-text-secondary">ns:</code> or <code className="text-theme-text-secondary">kind:</code> for value suggestions.
83
+ </div>
84
+ </div>,
85
+ document.body,
86
+ )}
87
+ </>
88
+ )
89
+ }