deepspace 0.4.0 → 0.4.2
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.
- package/dist/cli.js +145 -20
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +67 -12
- package/dist/index.js +203 -72
- package/dist/index.js.map +1 -1
- package/dist/server.js +3 -1
- package/dist/server.js.map +1 -1
- package/dist/worker.js +3 -1
- package/dist/worker.js.map +1 -1
- package/features/file-attachments/feature.json +1 -0
- package/features/file-attachments/src/useFilePreview.ts +1 -1
- package/features/integration-test/src/IntegrationTestPage.tsx +1 -0
- package/features/integration-test/src/integration.spec.ts +14 -5
- package/features/messaging/src/components/chat/MessageList.tsx +1 -1
- package/features/presence-test/src/PresenceTestPage.tsx +1 -1
- package/features/search-bar/feature.json +34 -0
- package/features/search-bar/src/SearchOverlay.tsx +710 -0
- package/features/testing/src/subrequest-burn-action.ts +6 -3
- package/package.json +2 -2
- package/scripts/add-feature.cjs +5 -4
|
@@ -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
|
+
}
|