deepspace 0.4.1 → 0.4.3

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,710 @@
1
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
2
+ import { Badge, Button, EmptyState, SearchInput, cn } from 'deepspace'
3
+ import { Loader2 } from 'lucide-react'
4
+
5
+ export interface SearchItem {
6
+ id: string
7
+ title: string
8
+ subtitle?: string
9
+ description?: string
10
+ group?: string
11
+ keywords?: string[]
12
+ meta?: ReactNode
13
+ disabled?: boolean
14
+ }
15
+
16
+ export interface HighlightedPart {
17
+ text: string
18
+ match: boolean
19
+ }
20
+
21
+ export type SearchResultRenderer<T extends SearchItem = SearchItem> = (item: T, query: string) => ReactNode
22
+
23
+ interface SearchBaseProps<T extends SearchItem = SearchItem> {
24
+ query: string
25
+ onQueryChange: (query: string) => void
26
+ items: T[]
27
+ onSelect: (item: T) => void
28
+ placeholder?: string
29
+ loading?: boolean
30
+ error?: string | null
31
+ emptyTitle?: string
32
+ emptyDescription?: string
33
+ className?: string
34
+ renderTitle?: SearchResultRenderer<T>
35
+ renderSubtitle?: SearchResultRenderer<T>
36
+ renderDescription?: SearchResultRenderer<T>
37
+ }
38
+
39
+ export interface SearchOverlayProps<T extends SearchItem = SearchItem> extends SearchBaseProps<T> {
40
+ open: boolean
41
+ onOpenChange: (open: boolean) => void
42
+ triggerLabel?: string
43
+ title?: string
44
+ description?: string
45
+ onSearchSubmit?: (query: string) => void
46
+ searchSubmitLabel?: string
47
+ showRecentSearches?: boolean
48
+ recentStorageKey?: string
49
+ maxRecentItems?: number
50
+ }
51
+
52
+ export interface InlineSearchProps<T extends SearchItem = SearchItem> extends SearchBaseProps<T> {
53
+ label?: string
54
+ description?: string
55
+ dimContent?: boolean
56
+ openOnFocus?: boolean
57
+ maxResultsHeightClassName?: string
58
+ }
59
+
60
+ export interface UseSearchIndexOptions<T> {
61
+ items: T[]
62
+ query: string
63
+ getText: (item: T) => Array<string | number | null | undefined>
64
+ limit?: number
65
+ }
66
+
67
+ export function useSearchIndex<T>({ items, query, getText, limit = 20 }: UseSearchIndexOptions<T>): T[] {
68
+ return useMemo(() => {
69
+ const normalizedQuery = normalize(query)
70
+ if (!normalizedQuery) return items.slice(0, limit)
71
+
72
+ const scored = items
73
+ .map((item, index) => {
74
+ const haystack = getText(item)
75
+ .filter((value): value is string | number => value !== null && value !== undefined)
76
+ .map(String)
77
+ .join(' ')
78
+ const score = scoreText(haystack, normalizedQuery)
79
+ return { item, index, score }
80
+ })
81
+ .filter((entry) => entry.score > 0)
82
+ .sort((a, b) => b.score - a.score || a.index - b.index)
83
+
84
+ return scored.slice(0, limit).map((entry) => entry.item)
85
+ }, [getText, items, limit, query])
86
+ }
87
+
88
+ export function getHighlightedParts(text: string, query: string): HighlightedPart[] {
89
+ const normalizedQuery = normalize(query)
90
+ if (!text || !normalizedQuery) return [{ text, match: false }]
91
+
92
+ const normalizedText = text.toLowerCase()
93
+ const parts: HighlightedPart[] = []
94
+ let cursor = 0
95
+ let index = normalizedText.indexOf(normalizedQuery)
96
+
97
+ while (index !== -1) {
98
+ if (index > cursor) {
99
+ parts.push({ text: text.slice(cursor, index), match: false })
100
+ }
101
+ parts.push({ text: text.slice(index, index + normalizedQuery.length), match: true })
102
+ cursor = index + normalizedQuery.length
103
+ index = normalizedText.indexOf(normalizedQuery, cursor)
104
+ }
105
+
106
+ if (cursor < text.length) {
107
+ parts.push({ text: text.slice(cursor), match: false })
108
+ }
109
+
110
+ return parts.length > 0 ? parts : [{ text, match: false }]
111
+ }
112
+
113
+ export interface UseAsyncSearchOptions<T> {
114
+ query: string
115
+ search: (query: string) => Promise<T[]>
116
+ minLength?: number
117
+ delayMs?: number
118
+ }
119
+
120
+ export function useAsyncSearch<T>({
121
+ query,
122
+ search,
123
+ minLength = 2,
124
+ delayMs = 250,
125
+ }: UseAsyncSearchOptions<T>) {
126
+ const [items, setItems] = useState<T[]>([])
127
+ const [loading, setLoading] = useState(false)
128
+ const [error, setError] = useState<string | null>(null)
129
+ const requestId = useRef(0)
130
+
131
+ useEffect(() => {
132
+ const trimmed = query.trim()
133
+ requestId.current += 1
134
+ const current = requestId.current
135
+
136
+ if (trimmed.length < minLength) {
137
+ setItems([])
138
+ setLoading(false)
139
+ setError(null)
140
+ return
141
+ }
142
+
143
+ setLoading(true)
144
+ setError(null)
145
+
146
+ const timer = window.setTimeout(() => {
147
+ search(trimmed)
148
+ .then((results) => {
149
+ if (requestId.current !== current) return
150
+ setItems(results)
151
+ setError(null)
152
+ })
153
+ .catch((err) => {
154
+ if (requestId.current !== current) return
155
+ setItems([])
156
+ setError(err instanceof Error ? err.message : 'Search failed')
157
+ })
158
+ .finally(() => {
159
+ if (requestId.current === current) setLoading(false)
160
+ })
161
+ }, delayMs)
162
+
163
+ return () => window.clearTimeout(timer)
164
+ }, [delayMs, minLength, query, search])
165
+
166
+ return { items, loading, error }
167
+ }
168
+
169
+ export function SearchOverlay<T extends SearchItem = SearchItem>({
170
+ open,
171
+ onOpenChange,
172
+ query,
173
+ onQueryChange,
174
+ items,
175
+ onSelect,
176
+ triggerLabel = 'Search',
177
+ placeholder = 'Search...',
178
+ title = 'Search',
179
+ description,
180
+ onSearchSubmit,
181
+ searchSubmitLabel = 'Search all results',
182
+ loading = false,
183
+ error = null,
184
+ emptyTitle = 'No matches',
185
+ emptyDescription = 'Try another search term.',
186
+ className,
187
+ renderTitle,
188
+ renderSubtitle,
189
+ renderDescription,
190
+ showRecentSearches = true,
191
+ recentStorageKey,
192
+ maxRecentItems = 5,
193
+ }: SearchOverlayProps<T>) {
194
+ const panelRef = useRef<HTMLDivElement>(null)
195
+ const inputRef = useRef<HTMLInputElement>(null)
196
+ const [recentItems, setRecentItems] = useState<SearchItem[]>([])
197
+ const resolvedRecentStorageKey = recentStorageKey ?? createRecentStorageKey(title, placeholder, triggerLabel)
198
+ const showRecents = showRecentSearches && query.trim() === '' && recentItems.length > 0
199
+ const visibleItems = showRecents
200
+ ? recentItems.map((item) => ({ ...item, group: 'Recent' }))
201
+ : items
202
+ const canSubmitSearch = Boolean(onSearchSubmit && query.trim())
203
+
204
+ function submitSearch(rawQuery: string) {
205
+ const trimmed = rawQuery.trim()
206
+ if (!trimmed || !onSearchSubmit) return
207
+ const nextRecentItems = writeRecentSearchQuery(resolvedRecentStorageKey, trimmed, maxRecentItems)
208
+ setRecentItems(nextRecentItems)
209
+ onSearchSubmit(trimmed)
210
+ onOpenChange(false)
211
+ }
212
+
213
+ useEffect(() => {
214
+ if (!open) return
215
+ window.setTimeout(() => inputRef.current?.focus(), 0)
216
+ if (showRecentSearches) {
217
+ setRecentItems(readRecentSearchItems(resolvedRecentStorageKey, maxRecentItems))
218
+ }
219
+ }, [maxRecentItems, open, resolvedRecentStorageKey, showRecentSearches])
220
+
221
+ useEffect(() => {
222
+ if (!open) return
223
+
224
+ function onPointerDown(event: MouseEvent) {
225
+ if (panelRef.current && !panelRef.current.contains(event.target as Node)) {
226
+ onOpenChange(false)
227
+ }
228
+ }
229
+
230
+ document.addEventListener('mousedown', onPointerDown)
231
+ return () => document.removeEventListener('mousedown', onPointerDown)
232
+ }, [onOpenChange, open])
233
+
234
+ return (
235
+ <div className={className}>
236
+ <Button type="button" onClick={() => onOpenChange(true)} data-testid="search-trigger">
237
+ {triggerLabel}
238
+ </Button>
239
+
240
+ {open && (
241
+ <div className="fixed inset-0 z-50 bg-background/70 backdrop-blur-sm px-3 py-4 sm:py-16" data-testid="search-overlay">
242
+ <div
243
+ ref={panelRef}
244
+ className="mx-auto flex max-h-[min(720px,calc(100vh-2rem))] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-border bg-card shadow-2xl"
245
+ >
246
+ <div className="border-b border-border p-4">
247
+ <div className="mb-3 flex items-start justify-between gap-4">
248
+ <div>
249
+ <h2 className="text-lg font-semibold text-foreground">{title}</h2>
250
+ {description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
251
+ </div>
252
+ <Button type="button" variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
253
+ Close
254
+ </Button>
255
+ </div>
256
+ <SearchInput
257
+ ref={inputRef}
258
+ value={query}
259
+ onChange={(event) => onQueryChange(event.target.value)}
260
+ onClear={() => onQueryChange('')}
261
+ placeholder={placeholder}
262
+ aria-label={placeholder}
263
+ data-testid="search-input"
264
+ />
265
+ {canSubmitSearch && (
266
+ <Button
267
+ type="button"
268
+ variant="ghost"
269
+ size="sm"
270
+ className="mt-2 w-full justify-start text-muted-foreground"
271
+ onClick={() => submitSearch(query)}
272
+ data-testid="search-submit"
273
+ >
274
+ {searchSubmitLabel}: "{query.trim()}"
275
+ </Button>
276
+ )}
277
+ </div>
278
+
279
+ <SearchPanel
280
+ items={visibleItems}
281
+ query={showRecents ? '' : query}
282
+ onSelect={(item) => {
283
+ if (showRecents && isRecentSearchQueryItem(item)) {
284
+ onQueryChange(item.title)
285
+ submitSearch(item.title)
286
+ return
287
+ }
288
+ if (!showRecents) {
289
+ const nextRecentItems = writeRecentSearchItem(resolvedRecentStorageKey, item, maxRecentItems)
290
+ setRecentItems(nextRecentItems)
291
+ }
292
+ onSelect(item as T)
293
+ onOpenChange(false)
294
+ }}
295
+ onClose={() => onOpenChange(false)}
296
+ loading={loading}
297
+ error={error}
298
+ emptyTitle={emptyTitle}
299
+ emptyDescription={emptyDescription}
300
+ onSubmitQuery={onSearchSubmit ? submitSearch : undefined}
301
+ renderTitle={renderTitle as SearchResultRenderer<SearchItem> | undefined}
302
+ renderSubtitle={renderSubtitle as SearchResultRenderer<SearchItem> | undefined}
303
+ renderDescription={renderDescription as SearchResultRenderer<SearchItem> | undefined}
304
+ />
305
+ </div>
306
+ </div>
307
+ )}
308
+ </div>
309
+ )
310
+ }
311
+
312
+ export function InlineSearch<T extends SearchItem = SearchItem>({
313
+ query,
314
+ onQueryChange,
315
+ items,
316
+ onSelect,
317
+ placeholder = 'Search...',
318
+ label,
319
+ description,
320
+ loading = false,
321
+ error = null,
322
+ emptyTitle = 'No matches',
323
+ emptyDescription = 'Try another search term.',
324
+ dimContent = false,
325
+ openOnFocus = true,
326
+ maxResultsHeightClassName = 'max-h-80',
327
+ className,
328
+ renderTitle,
329
+ renderSubtitle,
330
+ renderDescription,
331
+ }: InlineSearchProps<T>) {
332
+ const wrapperRef = useRef<HTMLDivElement>(null)
333
+ const [open, setOpen] = useState(false)
334
+ const showPanel = open && (openOnFocus || query.trim().length > 0)
335
+
336
+ useEffect(() => {
337
+ function onPointerDown(event: MouseEvent) {
338
+ if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
339
+ setOpen(false)
340
+ }
341
+ }
342
+
343
+ document.addEventListener('mousedown', onPointerDown)
344
+ return () => document.removeEventListener('mousedown', onPointerDown)
345
+ }, [])
346
+
347
+ return (
348
+ <div ref={wrapperRef} className={cn('relative', className)} data-testid="inline-search">
349
+ {dimContent && showPanel && (
350
+ <div className="fixed inset-0 z-30 bg-background/50 backdrop-blur-[2px]" aria-hidden="true" data-testid="inline-search-dimmer" />
351
+ )}
352
+
353
+ <div className="relative z-40">
354
+ {(label || description) && (
355
+ <div className="mb-2">
356
+ {label && <label className="text-sm font-medium text-foreground">{label}</label>}
357
+ {description && <p className="mt-1 text-xs text-muted-foreground">{description}</p>}
358
+ </div>
359
+ )}
360
+ <SearchInput
361
+ value={query}
362
+ onChange={(event) => {
363
+ onQueryChange(event.target.value)
364
+ setOpen(true)
365
+ }}
366
+ onFocus={() => setOpen(true)}
367
+ onClear={() => {
368
+ onQueryChange('')
369
+ setOpen(true)
370
+ }}
371
+ placeholder={placeholder}
372
+ aria-label={placeholder}
373
+ data-testid="inline-search-input"
374
+ />
375
+
376
+ {showPanel && (
377
+ <div className={cn(
378
+ 'absolute left-0 right-0 top-full z-50 mt-2 overflow-hidden rounded-xl border border-border bg-card shadow-xl',
379
+ maxResultsHeightClassName
380
+ )} data-testid="inline-search-panel">
381
+ <SearchPanel
382
+ items={items}
383
+ query={query}
384
+ onSelect={(item) => {
385
+ onSelect(item)
386
+ setOpen(false)
387
+ }}
388
+ onClose={() => setOpen(false)}
389
+ loading={loading}
390
+ error={error}
391
+ emptyTitle={emptyTitle}
392
+ emptyDescription={emptyDescription}
393
+ compact
394
+ renderTitle={renderTitle}
395
+ renderSubtitle={renderSubtitle}
396
+ renderDescription={renderDescription}
397
+ />
398
+ </div>
399
+ )}
400
+ </div>
401
+ </div>
402
+ )
403
+ }
404
+
405
+ interface SearchPanelProps<T extends SearchItem = SearchItem> {
406
+ items: T[]
407
+ query: string
408
+ onSelect: (item: T) => void
409
+ onClose: () => void
410
+ loading?: boolean
411
+ error?: string | null
412
+ emptyTitle: string
413
+ emptyDescription: string
414
+ compact?: boolean
415
+ onSubmitQuery?: (query: string) => void
416
+ renderTitle?: SearchResultRenderer<T>
417
+ renderSubtitle?: SearchResultRenderer<T>
418
+ renderDescription?: SearchResultRenderer<T>
419
+ }
420
+
421
+ function SearchPanel<T extends SearchItem = SearchItem>({
422
+ items,
423
+ query,
424
+ onSelect,
425
+ onClose,
426
+ loading = false,
427
+ error = null,
428
+ emptyTitle,
429
+ emptyDescription,
430
+ compact = false,
431
+ onSubmitQuery,
432
+ renderTitle,
433
+ renderSubtitle,
434
+ renderDescription,
435
+ }: SearchPanelProps<T>) {
436
+ const [activeIndex, setActiveIndex] = useState(0)
437
+ const enabledItems = items.filter((item) => !item.disabled)
438
+
439
+ useEffect(() => {
440
+ setActiveIndex(0)
441
+ }, [items])
442
+
443
+ function moveActive(delta: number) {
444
+ if (enabledItems.length === 0) return
445
+ setActiveIndex((current) => (current + delta + enabledItems.length) % enabledItems.length)
446
+ }
447
+
448
+ function selectItem(item: T) {
449
+ if (item.disabled) return
450
+ onSelect(item)
451
+ }
452
+
453
+ useEffect(() => {
454
+ document.addEventListener('keydown', onKeyDown)
455
+ return () => document.removeEventListener('keydown', onKeyDown)
456
+ })
457
+
458
+ function onKeyDown(event: globalThis.KeyboardEvent) {
459
+ if (event.key === 'Escape') {
460
+ event.preventDefault()
461
+ onClose()
462
+ } else if (event.key === 'ArrowDown') {
463
+ event.preventDefault()
464
+ moveActive(1)
465
+ } else if (event.key === 'ArrowUp') {
466
+ event.preventDefault()
467
+ moveActive(-1)
468
+ } else if (event.key === 'Enter') {
469
+ if (onSubmitQuery && query.trim()) {
470
+ event.preventDefault()
471
+ onSubmitQuery(query)
472
+ return
473
+ }
474
+ const active = enabledItems[activeIndex]
475
+ if (active) {
476
+ event.preventDefault()
477
+ selectItem(active)
478
+ }
479
+ }
480
+ }
481
+
482
+ return (
483
+ <div
484
+ className={cn('min-h-0 flex-1 overflow-y-auto p-2', compact ? 'max-h-80' : '')}
485
+ tabIndex={-1}
486
+ >
487
+ {loading ? (
488
+ <div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground" data-testid="search-loading">
489
+ <Loader2 className="h-4 w-4 animate-spin" />
490
+ Searching...
491
+ </div>
492
+ ) : error ? (
493
+ <div className="px-3 py-10 text-center text-sm text-destructive" data-testid="search-error">
494
+ {error}
495
+ </div>
496
+ ) : items.length === 0 ? (
497
+ <div data-testid="search-empty">
498
+ <EmptyState
499
+ title={emptyTitle}
500
+ description={emptyDescription}
501
+ className={compact ? 'py-8' : undefined}
502
+ />
503
+ </div>
504
+ ) : (
505
+ <SearchResultList
506
+ items={items}
507
+ query={query}
508
+ activeItemId={enabledItems[activeIndex]?.id}
509
+ onSelect={selectItem}
510
+ renderTitle={renderTitle}
511
+ renderSubtitle={renderSubtitle}
512
+ renderDescription={renderDescription}
513
+ />
514
+ )}
515
+ </div>
516
+ )
517
+ }
518
+
519
+ export interface SearchResultListProps<T extends SearchItem = SearchItem> {
520
+ items: T[]
521
+ query?: string
522
+ activeItemId?: string
523
+ onSelect: (item: T) => void
524
+ renderTitle?: SearchResultRenderer<T>
525
+ renderSubtitle?: SearchResultRenderer<T>
526
+ renderDescription?: SearchResultRenderer<T>
527
+ }
528
+
529
+ export function SearchResultList<T extends SearchItem = SearchItem>({
530
+ items,
531
+ query = '',
532
+ activeItemId,
533
+ onSelect,
534
+ renderTitle,
535
+ renderSubtitle,
536
+ renderDescription,
537
+ }: SearchResultListProps<T>) {
538
+ const groups = groupItems(items)
539
+
540
+ return (
541
+ <div className="space-y-3" data-testid="search-results">
542
+ {groups.map(([group, groupItems]) => (
543
+ <section key={group}>
544
+ <div className="px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
545
+ {group}
546
+ </div>
547
+ <div className="space-y-1">
548
+ {groupItems.map((item) => {
549
+ const active = item.id === activeItemId
550
+ return (
551
+ <button
552
+ key={item.id}
553
+ type="button"
554
+ disabled={item.disabled}
555
+ onClick={() => onSelect(item)}
556
+ className={cn(
557
+ 'flex w-full items-start justify-between gap-3 rounded-lg px-3 py-3 text-left transition-colors',
558
+ active ? 'bg-primary/10 text-foreground' : 'hover:bg-muted/70',
559
+ item.disabled && 'cursor-not-allowed opacity-50'
560
+ )}
561
+ data-testid="search-result"
562
+ data-active={active ? 'true' : 'false'}
563
+ >
564
+ <span className="min-w-0">
565
+ <span className="block truncate text-sm font-medium text-foreground">
566
+ {renderTitle ? renderTitle(item, query) : item.title}
567
+ </span>
568
+ {item.subtitle && (
569
+ <span className="mt-0.5 block truncate text-xs text-muted-foreground">
570
+ {renderSubtitle ? renderSubtitle(item, query) : item.subtitle}
571
+ </span>
572
+ )}
573
+ {item.description && (
574
+ <span className="mt-1 block text-xs text-muted-foreground">
575
+ {renderDescription ? renderDescription(item, query) : item.description}
576
+ </span>
577
+ )}
578
+ </span>
579
+ {item.meta && <span className="shrink-0 text-xs text-muted-foreground">{item.meta}</span>}
580
+ </button>
581
+ )
582
+ })}
583
+ </div>
584
+ </section>
585
+ ))}
586
+ </div>
587
+ )
588
+ }
589
+
590
+ export function toSearchItem(item: SearchItem): SearchItem {
591
+ return item
592
+ }
593
+
594
+ function createRecentStorageKey(title: string, placeholder: string, triggerLabel: string) {
595
+ const scope = [title, placeholder, triggerLabel]
596
+ .map((value) => value.trim().toLowerCase())
597
+ .find(Boolean) ?? 'search'
598
+ const normalizedScope = scope.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'search'
599
+ return `deepspace:search-bar:recent:${normalizedScope}`
600
+ }
601
+
602
+ function readRecentSearchItems(storageKey: string, maxItems: number): SearchItem[] {
603
+ try {
604
+ const raw = window.localStorage.getItem(storageKey)
605
+ if (!raw) return []
606
+ const parsed = JSON.parse(raw)
607
+ if (!Array.isArray(parsed)) return []
608
+ return parsed
609
+ .filter(isStoredSearchItem)
610
+ .slice(0, maxItems)
611
+ .map((item) => ({ ...item, group: 'Recent' }))
612
+ } catch {
613
+ return []
614
+ }
615
+ }
616
+
617
+ function writeRecentSearchItem(storageKey: string, item: SearchItem, maxItems: number): SearchItem[] {
618
+ const recentItem = toStoredSearchItem(item)
619
+ const next = [
620
+ recentItem,
621
+ ...readRecentSearchItems(storageKey, maxItems).filter((existing) => existing.id !== recentItem.id),
622
+ ].slice(0, maxItems)
623
+
624
+ try {
625
+ window.localStorage.setItem(storageKey, JSON.stringify(next))
626
+ } catch {
627
+ // Ignore storage failures; search should still work without persistence.
628
+ }
629
+
630
+ return next.map((storedItem) => ({ ...storedItem, group: 'Recent' }))
631
+ }
632
+
633
+ function writeRecentSearchQuery(storageKey: string, query: string, maxItems: number): SearchItem[] {
634
+ const recentItem = toStoredSearchQuery(query)
635
+ const next = [
636
+ recentItem,
637
+ ...readRecentSearchItems(storageKey, maxItems).filter((existing) => existing.id !== recentItem.id),
638
+ ].slice(0, maxItems)
639
+
640
+ try {
641
+ window.localStorage.setItem(storageKey, JSON.stringify(next))
642
+ } catch {
643
+ // Ignore storage failures; search should still work without persistence.
644
+ }
645
+
646
+ return next.map((storedItem) => ({ ...storedItem, group: 'Recent' }))
647
+ }
648
+
649
+ function toStoredSearchItem(item: SearchItem): SearchItem {
650
+ return {
651
+ id: item.id,
652
+ title: item.title,
653
+ subtitle: item.subtitle,
654
+ description: item.description,
655
+ group: 'Recent',
656
+ keywords: item.keywords,
657
+ disabled: item.disabled,
658
+ }
659
+ }
660
+
661
+ function toStoredSearchQuery(query: string): SearchItem {
662
+ return {
663
+ id: `search-query:${normalize(query)}`,
664
+ title: query,
665
+ subtitle: 'Search query',
666
+ group: 'Recent',
667
+ keywords: [query],
668
+ }
669
+ }
670
+
671
+ function isRecentSearchQueryItem(item: SearchItem) {
672
+ return item.id.startsWith('search-query:')
673
+ }
674
+
675
+ function isStoredSearchItem(value: unknown): value is SearchItem {
676
+ if (!value || typeof value !== 'object') return false
677
+ const candidate = value as Partial<SearchItem>
678
+ return typeof candidate.id === 'string' && typeof candidate.title === 'string'
679
+ }
680
+
681
+ function groupItems<T extends SearchItem>(items: T[]): Array<[string, T[]]> {
682
+ const groups = new Map<string, T[]>()
683
+ for (const item of items) {
684
+ const group = item.group ?? 'Results'
685
+ if (!groups.has(group)) groups.set(group, [])
686
+ groups.get(group)!.push(item)
687
+ }
688
+ return Array.from(groups.entries())
689
+ }
690
+
691
+ function normalize(value: string) {
692
+ return value.trim().toLowerCase()
693
+ }
694
+
695
+ function scoreText(value: string, query: string) {
696
+ const text = normalize(value)
697
+ if (!text || !query) return 0
698
+ if (text === query) return 100
699
+ if (text.startsWith(query)) return 80
700
+ if (text.includes(` ${query}`)) return 60
701
+ if (text.includes(query)) return 40
702
+
703
+ const terms = query.split(/\s+/).filter(Boolean)
704
+ if (terms.length > 1 && terms.every((term) => text.includes(term))) return 20
705
+ return 0
706
+ }
707
+
708
+ export function ResultCountBadge({ count }: { count: number }) {
709
+ return <Badge variant="secondary" size="sm">{count} results</Badge>
710
+ }