nuxt-devtools-observatory 0.1.30 → 0.1.32
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/README.md +117 -30
- package/client/.env.example +2 -1
- package/client/dist/assets/index-5Wl1XYRH.js +17 -0
- package/client/dist/assets/index-DT_QUiIh.css +1 -0
- package/client/dist/index.html +2 -2
- package/client/src/App.vue +4 -0
- package/client/src/components/Flamegraph.vue +442 -0
- package/client/src/components/SpanInspector.vue +446 -0
- package/client/src/components/TraceFilter.vue +342 -0
- package/client/src/components/WaterfallView.vue +443 -0
- package/client/src/composables/composable-search.ts +124 -0
- package/client/src/composables/trace-render-aggregation.ts +254 -0
- package/client/src/composables/useExportImport.ts +63 -0
- package/client/src/composables/useTraceFilter.ts +160 -0
- package/client/src/stores/observatory.ts +13 -1
- package/client/src/views/ComposableTracker.vue +65 -30
- package/client/src/views/RenderHeatmap.vue +63 -1
- package/client/src/views/TraceViewer.vue +1212 -0
- package/client/src/views/TransitionTimeline.vue +1 -6
- package/dist/module.d.mts +5 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +31 -45
- package/dist/runtime/composables/composable-registry.d.ts +19 -0
- package/dist/runtime/composables/composable-registry.js +63 -5
- package/dist/runtime/composables/render-registry.js +74 -110
- package/dist/runtime/composables/transition-registry.js +103 -28
- package/dist/runtime/instrumentation/asyncData.d.ts +9 -0
- package/dist/runtime/instrumentation/asyncData.js +49 -0
- package/dist/runtime/instrumentation/component.d.ts +2 -0
- package/dist/runtime/instrumentation/component.js +126 -0
- package/dist/runtime/instrumentation/fetch.d.ts +2 -0
- package/dist/runtime/instrumentation/fetch.js +89 -0
- package/dist/runtime/instrumentation/route.d.ts +6 -0
- package/dist/runtime/instrumentation/route.js +41 -0
- package/dist/runtime/nitro/fetch-capture.d.ts +1 -2
- package/dist/runtime/nitro/fetch-capture.js +85 -7
- package/dist/runtime/nitro/ssr-trace-store.d.ts +85 -0
- package/dist/runtime/nitro/ssr-trace-store.js +84 -0
- package/dist/runtime/plugin.js +82 -2
- package/dist/runtime/tracing/context.d.ts +9 -0
- package/dist/runtime/tracing/context.js +15 -0
- package/dist/runtime/tracing/trace.d.ts +25 -0
- package/dist/runtime/tracing/trace.js +0 -0
- package/dist/runtime/tracing/traceStore.d.ts +39 -0
- package/dist/runtime/tracing/traceStore.js +101 -0
- package/dist/runtime/tracing/tracing.d.ts +27 -0
- package/dist/runtime/tracing/tracing.js +48 -0
- package/package.json +5 -1
- package/client/.env +0 -16
- package/client/dist/assets/index-BCaKoHBH.js +0 -17
- package/client/dist/assets/index-BmGW_M3W.css +0 -1
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import type { TraceEntry, TraceSpan } from '@observatory/types/snapshot'
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
trace: TraceEntry
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const props = defineProps<Props>()
|
|
10
|
+
|
|
11
|
+
const spansByType = computed(() => {
|
|
12
|
+
const groups: Record<string, TraceSpan[]> = {}
|
|
13
|
+
|
|
14
|
+
for (const span of props.trace.spans) {
|
|
15
|
+
if (!groups[span.type]) {
|
|
16
|
+
groups[span.type] = []
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
groups[span.type].push(span)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return Object.entries(groups)
|
|
23
|
+
.map(([type, spans]) => ({
|
|
24
|
+
type,
|
|
25
|
+
spans: spans.sort((a, b) => a.startTime - b.startTime),
|
|
26
|
+
}))
|
|
27
|
+
.sort((a, b) => {
|
|
28
|
+
const typeOrder: Record<string, number> = {
|
|
29
|
+
navigation: 0,
|
|
30
|
+
fetch: 1,
|
|
31
|
+
composable: 2,
|
|
32
|
+
component: 3,
|
|
33
|
+
render: 4,
|
|
34
|
+
transition: 5,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (typeOrder[a.type] ?? 999) - (typeOrder[b.type] ?? 999)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const timelineDuration = computed(() => {
|
|
42
|
+
if (props.trace.durationMs && props.trace.durationMs > 0) {
|
|
43
|
+
return props.trace.durationMs
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (props.trace.endTime && props.trace.endTime > props.trace.startTime) {
|
|
47
|
+
return props.trace.endTime - props.trace.startTime
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let maxEndOffset = 0
|
|
51
|
+
|
|
52
|
+
for (const span of props.trace.spans) {
|
|
53
|
+
const endTime = span.endTime ?? span.startTime + (span.durationMs ?? 0)
|
|
54
|
+
const endOffset = endTime - props.trace.startTime
|
|
55
|
+
|
|
56
|
+
if (endOffset > maxEndOffset) {
|
|
57
|
+
maxEndOffset = endOffset
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return maxEndOffset || 1
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
function getSpanX(span: TraceSpan): number {
|
|
65
|
+
const left = ((span.startTime - props.trace.startTime) / timelineDuration.value) * 100
|
|
66
|
+
|
|
67
|
+
return Math.min(100, Math.max(0, left))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getSpanWidth(span: TraceSpan): number {
|
|
71
|
+
const left = getSpanX(span)
|
|
72
|
+
const width = ((span.durationMs || 0) / timelineDuration.value) * 100
|
|
73
|
+
|
|
74
|
+
return Math.max(2, Math.min(100 - left, width))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isNarrowBar(span: TraceSpan): boolean {
|
|
78
|
+
const width = ((span.durationMs || 0) / timelineDuration.value) * 100
|
|
79
|
+
|
|
80
|
+
return width < 5
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function formatDuration(durationMs?: number) {
|
|
84
|
+
if (!durationMs) {
|
|
85
|
+
return '0ms'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (durationMs < 1) {
|
|
89
|
+
return `${(durationMs * 1000).toFixed(1)}μs`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (durationMs < 1000) {
|
|
93
|
+
return `${durationMs.toFixed(1)}ms`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return `${(durationMs / 1000).toFixed(2)}s`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function getSpanColorClass(type: string) {
|
|
100
|
+
const colors: Record<string, string> = {
|
|
101
|
+
fetch: 'bg-blue-500',
|
|
102
|
+
composable: 'bg-purple-500',
|
|
103
|
+
component: 'bg-green-500',
|
|
104
|
+
navigation: 'bg-yellow-500',
|
|
105
|
+
render: 'bg-orange-500',
|
|
106
|
+
transition: 'bg-pink-500',
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return colors[type] || 'bg-gray-500'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getStatusColor(status: string) {
|
|
113
|
+
const statusColors: Record<string, string> = {
|
|
114
|
+
ok: 'border-green-400',
|
|
115
|
+
error: 'border-red-400',
|
|
116
|
+
cancelled: 'border-gray-400',
|
|
117
|
+
active: 'border-yellow-400',
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return statusColors[status] || 'border-gray-400'
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function asString(val: unknown): string {
|
|
124
|
+
return typeof val === 'string' ? val : ''
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function getSpanDisplayName(span: TraceSpan): string {
|
|
128
|
+
const m = span.metadata as Record<string, unknown> | undefined
|
|
129
|
+
|
|
130
|
+
if (!m) {
|
|
131
|
+
return span.name
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const componentName = asString(m.componentName)
|
|
135
|
+
const lifecycle = asString(m.lifecycle)
|
|
136
|
+
const uid = m.uid !== undefined ? String(m.uid) : ''
|
|
137
|
+
const method = asString(m.method)
|
|
138
|
+
const url = asString(m.url)
|
|
139
|
+
const route = asString(m.route) || asString(m.path)
|
|
140
|
+
|
|
141
|
+
if (componentName && lifecycle) {
|
|
142
|
+
return uid ? `${componentName}.${lifecycle} #${uid}` : `${componentName}.${lifecycle}`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (componentName) {
|
|
146
|
+
return uid ? `${componentName} #${uid}` : componentName
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (method && url) {
|
|
150
|
+
return `${method} ${url}`
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (url) {
|
|
154
|
+
return url
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (route) {
|
|
158
|
+
return `${span.name} (${route})`
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return span.name
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function getSpanTooltip(span: TraceSpan): string {
|
|
165
|
+
const displayName = getSpanDisplayName(span)
|
|
166
|
+
const duration = formatDuration(span.durationMs)
|
|
167
|
+
const m = span.metadata as Record<string, unknown> | undefined
|
|
168
|
+
const route = m ? asString(m.route) || asString(m.path) : ''
|
|
169
|
+
let tooltip = `${displayName} — ${duration} (${span.status})`
|
|
170
|
+
|
|
171
|
+
if (route && !displayName.includes(route)) {
|
|
172
|
+
tooltip += ` [${route}]`
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return tooltip
|
|
176
|
+
}
|
|
177
|
+
</script>
|
|
178
|
+
|
|
179
|
+
<template>
|
|
180
|
+
<div class="waterfall">
|
|
181
|
+
<div class="waterfall__timeline-header">
|
|
182
|
+
<div class="waterfall__type-col">Type</div>
|
|
183
|
+
<div class="waterfall__timeline-col">
|
|
184
|
+
<div class="waterfall__time-markers">
|
|
185
|
+
<div class="waterfall__time-marker">0ms</div>
|
|
186
|
+
<div class="waterfall__time-marker">{{ formatDuration(timelineDuration / 4) }}</div>
|
|
187
|
+
<div class="waterfall__time-marker">{{ formatDuration(timelineDuration / 2) }}</div>
|
|
188
|
+
<div class="waterfall__time-marker">{{ formatDuration((timelineDuration * 3) / 4) }}</div>
|
|
189
|
+
<div class="waterfall__time-marker">{{ formatDuration(timelineDuration) }}</div>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<div class="waterfall__groups">
|
|
195
|
+
<div v-for="group in spansByType" :key="group.type" class="waterfall__group">
|
|
196
|
+
<div class="waterfall__group-header">
|
|
197
|
+
<span class="waterfall__group-type">
|
|
198
|
+
<span class="waterfall__color-dot" :class="getSpanColorClass(group.type)"></span>
|
|
199
|
+
{{ group.type }}
|
|
200
|
+
</span>
|
|
201
|
+
<span class="waterfall__group-count">{{ group.spans.length }}</span>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<div class="waterfall__group-spans">
|
|
205
|
+
<div v-for="span in group.spans" :key="span.id" class="waterfall__span-row">
|
|
206
|
+
<div class="waterfall__span-label">
|
|
207
|
+
<span :title="getSpanTooltip(span)">{{ getSpanDisplayName(span) }}</span>
|
|
208
|
+
</div>
|
|
209
|
+
<div class="waterfall__span-bar-container">
|
|
210
|
+
<div
|
|
211
|
+
class="waterfall__span-bar"
|
|
212
|
+
:class="[getSpanColorClass(span.type), getStatusColor(span.status)]"
|
|
213
|
+
:style="{
|
|
214
|
+
left: `${getSpanX(span)}%`,
|
|
215
|
+
width: `${Math.max(1, getSpanWidth(span))}%`,
|
|
216
|
+
}"
|
|
217
|
+
:title="getSpanTooltip(span)"
|
|
218
|
+
>
|
|
219
|
+
<span v-if="!isNarrowBar(span)" class="waterfall__bar-duration">{{ formatDuration(span.durationMs) }}</span>
|
|
220
|
+
</div>
|
|
221
|
+
<span
|
|
222
|
+
v-if="isNarrowBar(span)"
|
|
223
|
+
class="waterfall__bar-duration-outside"
|
|
224
|
+
:style="{ left: `calc(${getSpanX(span)}% + 4px)` }"
|
|
225
|
+
>
|
|
226
|
+
{{ formatDuration(span.durationMs) }}
|
|
227
|
+
</span>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<div v-if="spansByType.length === 0" class="waterfall__empty">No spans in this trace</div>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
</template>
|
|
237
|
+
|
|
238
|
+
<style scoped>
|
|
239
|
+
.waterfall {
|
|
240
|
+
display: flex;
|
|
241
|
+
flex-direction: column;
|
|
242
|
+
height: 100%;
|
|
243
|
+
overflow: auto;
|
|
244
|
+
font-family: var(--mono);
|
|
245
|
+
font-size: 11px;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.waterfall__timeline-header {
|
|
249
|
+
display: flex;
|
|
250
|
+
position: sticky;
|
|
251
|
+
top: 0;
|
|
252
|
+
z-index: 20;
|
|
253
|
+
background: var(--bg2, var(--bg));
|
|
254
|
+
box-shadow: 0 1px 0 var(--border);
|
|
255
|
+
border-bottom: 1px solid var(--border);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.waterfall__type-col {
|
|
259
|
+
width: 120px;
|
|
260
|
+
min-width: 120px;
|
|
261
|
+
padding: 8px 0;
|
|
262
|
+
border-right: 1px solid var(--border);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.waterfall__timeline-col {
|
|
266
|
+
flex: 1;
|
|
267
|
+
min-width: 0;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.waterfall__time-markers {
|
|
271
|
+
display: flex;
|
|
272
|
+
justify-content: space-around;
|
|
273
|
+
padding: 8px 16px;
|
|
274
|
+
gap: 8px;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.waterfall__time-marker {
|
|
278
|
+
font-size: 10px;
|
|
279
|
+
color: var(--text2, var(--text));
|
|
280
|
+
flex: 1;
|
|
281
|
+
text-align: center;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.waterfall__groups {
|
|
285
|
+
display: flex;
|
|
286
|
+
flex-direction: column;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.waterfall__group {
|
|
290
|
+
border-bottom: 1px solid var(--border);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.waterfall__group-header {
|
|
294
|
+
display: flex;
|
|
295
|
+
align-items: center;
|
|
296
|
+
justify-content: space-between;
|
|
297
|
+
background: var(--bg2, var(--bg));
|
|
298
|
+
padding: 8px 16px;
|
|
299
|
+
font-weight: 600;
|
|
300
|
+
font-size: 12px;
|
|
301
|
+
color: var(--text2, var(--text));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
.waterfall__group-type {
|
|
305
|
+
display: flex;
|
|
306
|
+
align-items: center;
|
|
307
|
+
gap: 6px;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.waterfall__color-dot {
|
|
311
|
+
display: inline-block;
|
|
312
|
+
width: 8px;
|
|
313
|
+
height: 8px;
|
|
314
|
+
border-radius: 2px;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.waterfall__group-count {
|
|
318
|
+
background: var(--bg);
|
|
319
|
+
border: 1px solid var(--border);
|
|
320
|
+
padding: 2px 6px;
|
|
321
|
+
border-radius: 3px;
|
|
322
|
+
font-size: 10px;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.waterfall__group-spans {
|
|
326
|
+
display: flex;
|
|
327
|
+
flex-direction: column;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.waterfall__span-row {
|
|
331
|
+
display: flex;
|
|
332
|
+
height: 28px;
|
|
333
|
+
align-items: center;
|
|
334
|
+
border-bottom: 1px solid var(--border);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
.waterfall__span-label {
|
|
338
|
+
width: 120px;
|
|
339
|
+
min-width: 120px;
|
|
340
|
+
padding: 0 16px;
|
|
341
|
+
overflow: hidden;
|
|
342
|
+
text-overflow: ellipsis;
|
|
343
|
+
white-space: nowrap;
|
|
344
|
+
color: var(--text);
|
|
345
|
+
font-size: 11px;
|
|
346
|
+
border-right: 1px solid var(--border);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
.waterfall__span-bar-container {
|
|
350
|
+
flex: 1;
|
|
351
|
+
height: 100%;
|
|
352
|
+
position: relative;
|
|
353
|
+
min-width: 0;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
.waterfall__span-bar {
|
|
357
|
+
position: absolute;
|
|
358
|
+
top: 50%;
|
|
359
|
+
transform: translateY(-50%);
|
|
360
|
+
height: 16px;
|
|
361
|
+
border-radius: 2px;
|
|
362
|
+
border-left: 2px solid;
|
|
363
|
+
display: flex;
|
|
364
|
+
align-items: center;
|
|
365
|
+
justify-content: center;
|
|
366
|
+
color: white;
|
|
367
|
+
overflow: hidden;
|
|
368
|
+
cursor: pointer;
|
|
369
|
+
transition: opacity 0.2s;
|
|
370
|
+
padding: 0 4px;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
.waterfall__span-bar:hover {
|
|
374
|
+
opacity: 0.8;
|
|
375
|
+
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 30%);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.waterfall__bar-duration {
|
|
379
|
+
font-size: 9px;
|
|
380
|
+
white-space: nowrap;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
.waterfall__bar-duration-outside {
|
|
384
|
+
position: absolute;
|
|
385
|
+
top: 50%;
|
|
386
|
+
transform: translateY(-50%);
|
|
387
|
+
font-size: 9px;
|
|
388
|
+
white-space: nowrap;
|
|
389
|
+
color: var(--text);
|
|
390
|
+
pointer-events: none;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
.waterfall__empty {
|
|
394
|
+
padding: 32px 16px;
|
|
395
|
+
text-align: center;
|
|
396
|
+
color: var(--text2, var(--text));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/* Color utilities */
|
|
400
|
+
.bg-blue-500 {
|
|
401
|
+
background-color: #3b82f6;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
.bg-purple-500 {
|
|
405
|
+
background-color: #a855f7;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
.bg-green-500 {
|
|
409
|
+
background-color: #22c55e;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
.bg-yellow-500 {
|
|
413
|
+
background-color: #eab308;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
.bg-orange-500 {
|
|
417
|
+
background-color: #f97316;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
.bg-pink-500 {
|
|
421
|
+
background-color: #ec4899;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
.bg-gray-500 {
|
|
425
|
+
background-color: #6b7280;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
.border-green-400 {
|
|
429
|
+
border-color: #4ade80;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
.border-red-400 {
|
|
433
|
+
border-color: #f87171;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
.border-gray-400 {
|
|
437
|
+
border-color: #9ca3af;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
.border-yellow-400 {
|
|
441
|
+
border-color: #facc15;
|
|
442
|
+
}
|
|
443
|
+
</style>
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { ComposableEntry } from '@observatory/types/snapshot'
|
|
2
|
+
|
|
3
|
+
const MAX_SEARCH_DEPTH = 6
|
|
4
|
+
const MAX_SEARCH_NODES = 1500
|
|
5
|
+
|
|
6
|
+
interface SearchBudget {
|
|
7
|
+
nodes: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function valueMatchesQuery(value: unknown, query: string, seen: WeakSet<object>, budget: SearchBudget, depth = 0): boolean {
|
|
11
|
+
if (budget.nodes >= MAX_SEARCH_NODES) {
|
|
12
|
+
return false
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
budget.nodes++
|
|
16
|
+
|
|
17
|
+
if (value === null || value === undefined) {
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const valueType = typeof value
|
|
22
|
+
|
|
23
|
+
if (valueType === 'string' || valueType === 'number' || valueType === 'boolean' || valueType === 'bigint') {
|
|
24
|
+
return String(value).toLowerCase().includes(query)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (valueType !== 'object') {
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (depth >= MAX_SEARCH_DEPTH) {
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const objectValue = value as object
|
|
36
|
+
|
|
37
|
+
if (seen.has(objectValue)) {
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
seen.add(objectValue)
|
|
42
|
+
|
|
43
|
+
if (Array.isArray(value)) {
|
|
44
|
+
return value.some((item) => valueMatchesQuery(item, query, seen, budget, depth + 1))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (value instanceof Map) {
|
|
48
|
+
for (const [mapKey, mapValue] of value.entries()) {
|
|
49
|
+
if (valueMatchesQuery(mapKey, query, seen, budget, depth + 1)) {
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (valueMatchesQuery(mapValue, query, seen, budget, depth + 1)) {
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (value instanceof Set) {
|
|
62
|
+
for (const setValue of value.values()) {
|
|
63
|
+
if (valueMatchesQuery(setValue, query, seen, budget, depth + 1)) {
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
73
|
+
|
|
74
|
+
for (const [key, nestedValue] of entries) {
|
|
75
|
+
if (key.toLowerCase().includes(query)) {
|
|
76
|
+
return true
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (valueMatchesQuery(nestedValue, query, seen, budget, depth + 1)) {
|
|
80
|
+
return true
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// Ignore objects that throw on entry access and continue matching safely.
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Returns true when a composable entry matches the search query.
|
|
93
|
+
* Search scope includes name, file, ref keys, and nested reactive key/value content.
|
|
94
|
+
*/
|
|
95
|
+
export function matchesComposableEntryQuery(entry: ComposableEntry, query: string): boolean {
|
|
96
|
+
const normalizedQuery = query.trim().toLowerCase()
|
|
97
|
+
|
|
98
|
+
if (!normalizedQuery) {
|
|
99
|
+
return true
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (entry.name.toLowerCase().includes(normalizedQuery)) {
|
|
103
|
+
return true
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (entry.componentFile.toLowerCase().includes(normalizedQuery)) {
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const seen = new WeakSet<object>()
|
|
111
|
+
const budget: SearchBudget = { nodes: 0 }
|
|
112
|
+
|
|
113
|
+
for (const [refKey, refInfo] of Object.entries(entry.refs)) {
|
|
114
|
+
if (refKey.toLowerCase().includes(normalizedQuery)) {
|
|
115
|
+
return true
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (valueMatchesQuery(refInfo.value, normalizedQuery, seen, budget)) {
|
|
119
|
+
return true
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return false
|
|
124
|
+
}
|