nuxt-devtools-observatory 0.1.9 → 0.1.10

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.
@@ -1,12 +1,12 @@
1
1
  <script setup lang="ts">
2
- import { ref, computed } from 'vue'
2
+ import { ref, computed, watch } from 'vue'
3
3
  import { useObservatoryData, type InjectEntry, type ProvideEntry } from '../stores/observatory'
4
4
 
5
5
  interface TreeNodeData {
6
6
  id: string
7
7
  label: string
8
8
  type: 'provider' | 'consumer' | 'both' | 'error'
9
- provides: Array<{ key: string; val: string; reactive: boolean }>
9
+ provides: Array<{ key: string; val: string; raw: unknown; reactive: boolean; complex: boolean }>
10
10
  injects: Array<{ key: string; from: string | null; ok: boolean }>
11
11
  children: TreeNodeData[]
12
12
  }
@@ -62,6 +62,47 @@ function stringifyValue(value: unknown) {
62
62
  }
63
63
  }
64
64
 
65
+ function isComplexValue(value: unknown) {
66
+ return typeof value === 'object' && value !== null
67
+ }
68
+
69
+ function formatValuePreview(value: unknown) {
70
+ if (value === null) {
71
+ return 'null'
72
+ }
73
+
74
+ if (Array.isArray(value)) {
75
+ return `Array(${value.length})`
76
+ }
77
+
78
+ if (typeof value === 'object') {
79
+ const keys = Object.keys(value as Record<string, unknown>)
80
+ return keys.length ? `{ ${keys.join(', ')} }` : '{}'
81
+ }
82
+
83
+ return stringifyValue(value)
84
+ }
85
+
86
+ function formatValueDetail(value: unknown) {
87
+ if (!isComplexValue(value)) {
88
+ return stringifyValue(value)
89
+ }
90
+
91
+ try {
92
+ return JSON.stringify(value, null, 2)
93
+ } catch {
94
+ return stringifyValue(value)
95
+ }
96
+ }
97
+
98
+ function provideValueId(nodeId: string, key: string, index: number) {
99
+ return `${nodeId}:${key}:${index}`
100
+ }
101
+
102
+ function basename(file: string) {
103
+ return file.split('/').pop() ?? file
104
+ }
105
+
65
106
  function componentId(entry: ProvideEntry | InjectEntry) {
66
107
  return String(entry.componentUid)
67
108
  }
@@ -80,7 +121,7 @@ const nodes = computed<TreeNodeData[]>(() => {
80
121
 
81
122
  const created: TreeNodeData = {
82
123
  id,
83
- label: entry.componentFile,
124
+ label: basename(entry.componentFile),
84
125
  type: 'consumer',
85
126
  provides: [],
86
127
  injects: [],
@@ -96,8 +137,10 @@ const nodes = computed<TreeNodeData[]>(() => {
96
137
  const node = ensureNode(entry)
97
138
  node.provides.push({
98
139
  key: entry.key,
99
- val: stringifyValue(entry.valueSnapshot),
140
+ val: formatValuePreview(entry.valueSnapshot),
141
+ raw: entry.valueSnapshot,
100
142
  reactive: entry.isReactive,
143
+ complex: isComplexValue(entry.valueSnapshot),
101
144
  })
102
145
  }
103
146
 
@@ -140,6 +183,23 @@ const nodes = computed<TreeNodeData[]>(() => {
140
183
 
141
184
  const activeFilter = ref('all')
142
185
  const selectedNode = ref<TreeNodeData | null>(null)
186
+ const expandedProvideValues = ref<Set<string>>(new Set())
187
+
188
+ watch(selectedNode, () => {
189
+ expandedProvideValues.value = new Set()
190
+ })
191
+
192
+ function toggleProvideValue(id: string) {
193
+ const next = new Set(expandedProvideValues.value)
194
+
195
+ if (next.has(id)) {
196
+ next.delete(id)
197
+ } else {
198
+ next.add(id)
199
+ }
200
+
201
+ expandedProvideValues.value = next
202
+ }
143
203
 
144
204
  const allKeys = computed(() => {
145
205
  const keys = new Set<string>()
@@ -157,6 +217,45 @@ const allKeys = computed(() => {
157
217
  return [...keys]
158
218
  })
159
219
 
220
+ const visibleNodes = computed<TreeNodeData[]>(() => {
221
+ function prune(node: TreeNodeData): TreeNodeData | null {
222
+ const visibleChildren = node.children.map(prune).filter(Boolean) as TreeNodeData[]
223
+ const selfMatches = matchesFilter(node, activeFilter.value)
224
+
225
+ if (!selfMatches && !visibleChildren.length) {
226
+ return null
227
+ }
228
+
229
+ return {
230
+ ...node,
231
+ children: visibleChildren,
232
+ }
233
+ }
234
+
235
+ return nodes.value.map(prune).filter(Boolean) as TreeNodeData[]
236
+ })
237
+
238
+ watch([visibleNodes, selectedNode], ([currentNodes, currentSelected]) => {
239
+ if (!currentSelected) {
240
+ return
241
+ }
242
+
243
+ const ids = new Set<string>()
244
+
245
+ function collect(nodesToVisit: TreeNodeData[]) {
246
+ nodesToVisit.forEach((node) => {
247
+ ids.add(node.id)
248
+ collect(node.children)
249
+ })
250
+ }
251
+
252
+ collect(currentNodes)
253
+
254
+ if (!ids.has(currentSelected.id)) {
255
+ selectedNode.value = null
256
+ }
257
+ })
258
+
160
259
  const layout = computed<LayoutNode[]>(() => {
161
260
  const flat: LayoutNode[] = []
162
261
  const pad = H_GAP
@@ -183,7 +282,7 @@ const layout = computed<LayoutNode[]>(() => {
183
282
 
184
283
  let left = pad
185
284
 
186
- for (const root of nodes.value) {
285
+ for (const root of visibleNodes.value) {
187
286
  const leaves = countLeaves(root)
188
287
  place(root, 0, left, null)
189
288
  left += leaves * (NODE_W + H_GAP) + H_GAP * 2
@@ -192,7 +291,7 @@ const layout = computed<LayoutNode[]>(() => {
192
291
  return flat
193
292
  })
194
293
 
195
- const canvasW = computed(() => layout.value.reduce((max, node) => Math.max(max, node.x + NODE_W / 2 + 20), 400))
294
+ const canvasW = computed(() => layout.value.reduce((max, node) => Math.max(max, node.x + NODE_W / 2 + 20), 520))
196
295
  const canvasH = computed(() => layout.value.reduce((max, node) => Math.max(max, node.y + NODE_H / 2 + 20), 200))
197
296
 
198
297
  const edges = computed<Edge[]>(() => {
@@ -243,38 +342,40 @@ const edges = computed<Edge[]>(() => {
243
342
  <span class="dot" style="background: var(--red)"></span>
244
343
  <span>missing provider</span>
245
344
  </div>
246
- <div class="canvas-wrap" :style="{ width: `${canvasW}px`, height: `${canvasH}px` }">
247
- <svg class="edges-svg" :width="canvasW" :height="canvasH" :viewBox="`0 0 ${canvasW} ${canvasH}`">
248
- <path
249
- v-for="edge in edges"
250
- :key="edge.id"
251
- :d="`M ${edge.x1},${edge.y1} C ${edge.x1},${(edge.y1 + edge.y2) / 2} ${edge.x2},${(edge.y1 + edge.y2) / 2} ${edge.x2},${edge.y2}`"
252
- class="edge"
253
- fill="none"
254
- />
255
- </svg>
256
- <div
257
- v-for="layoutNode in layout"
258
- :key="layoutNode.data.id"
259
- class="graph-node"
260
- :class="{
261
- 'is-selected': selectedNode?.id === layoutNode.data.id,
262
- 'is-dimmed': !matchesFilter(layoutNode.data, activeFilter),
263
- }"
264
- :style="{
265
- left: `${layoutNode.x - NODE_W / 2}px`,
266
- top: `${layoutNode.y - NODE_H / 2}px`,
267
- width: `${NODE_W}px`,
268
- '--node-color': nodeColor(layoutNode.data),
269
- }"
270
- @click="selectedNode = layoutNode.data"
271
- >
272
- <span class="node-dot" :style="{ background: nodeColor(layoutNode.data) }"></span>
273
- <span class="mono node-label">{{ layoutNode.data.label }}</span>
274
- <span v-if="layoutNode.data.provides.length" class="badge badge-ok badge-xs">+{{ layoutNode.data.provides.length }}</span>
275
- <span v-if="layoutNode.data.injects.some((entry) => !entry.ok)" class="badge badge-err badge-xs">!</span>
345
+ <div v-if="layout.length" class="canvas-stage">
346
+ <div class="canvas-wrap" :style="{ width: `${canvasW}px`, height: `${canvasH}px` }">
347
+ <svg class="edges-svg" :width="canvasW" :height="canvasH" :viewBox="`0 0 ${canvasW} ${canvasH}`">
348
+ <path
349
+ v-for="edge in edges"
350
+ :key="edge.id"
351
+ :d="`M ${edge.x1},${edge.y1} C ${edge.x1},${(edge.y1 + edge.y2) / 2} ${edge.x2},${(edge.y1 + edge.y2) / 2} ${edge.x2},${edge.y2}`"
352
+ class="edge"
353
+ fill="none"
354
+ />
355
+ </svg>
356
+ <div
357
+ v-for="layoutNode in layout"
358
+ :key="layoutNode.data.id"
359
+ class="graph-node"
360
+ :class="{ 'is-selected': selectedNode?.id === layoutNode.data.id }"
361
+ :style="{
362
+ left: `${layoutNode.x - NODE_W / 2}px`,
363
+ top: `${layoutNode.y - NODE_H / 2}px`,
364
+ width: `${NODE_W}px`,
365
+ '--node-color': nodeColor(layoutNode.data),
366
+ }"
367
+ @click="selectedNode = layoutNode.data"
368
+ >
369
+ <span class="node-dot" :style="{ background: nodeColor(layoutNode.data) }"></span>
370
+ <span class="mono node-label">{{ layoutNode.data.label }}</span>
371
+ <span v-if="layoutNode.data.provides.length" class="badge badge-ok badge-xs">+{{ layoutNode.data.provides.length }}</span>
372
+ <span v-if="layoutNode.data.injects.some((entry) => !entry.ok)" class="badge badge-err badge-xs">!</span>
373
+ </div>
276
374
  </div>
277
375
  </div>
376
+ <div v-else class="graph-empty">
377
+ {{ connected ? 'No components match the current provide/inject filter.' : 'Waiting for connection to the Nuxt app…' }}
378
+ </div>
278
379
  </div>
279
380
 
280
381
  <div v-if="selectedNode" class="detail-panel">
@@ -283,26 +384,45 @@ const edges = computed<Edge[]>(() => {
283
384
  <button @click="selectedNode = null">×</button>
284
385
  </div>
285
386
 
286
- <div v-if="selectedNode.provides.length">
387
+ <div v-if="selectedNode.provides.length" class="detail-section">
287
388
  <div class="section-label">provides ({{ selectedNode.provides.length }})</div>
288
- <div v-for="entry in selectedNode.provides" :key="entry.key" class="provide-row">
289
- <span class="mono text-sm" style="min-width: 100px; color: var(--text2)">{{ entry.key }}</span>
290
- <span class="mono text-sm muted" style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">
291
- {{ entry.val }}
292
- </span>
293
- <span class="badge" :class="entry.reactive ? 'badge-ok' : 'badge-gray'">
294
- {{ entry.reactive ? 'reactive' : 'static' }}
295
- </span>
389
+ <div class="detail-list">
390
+ <div
391
+ v-for="(entry, index) in selectedNode.provides"
392
+ :key="provideValueId(selectedNode.id, entry.key, index)"
393
+ class="provide-row"
394
+ >
395
+ <div class="row-main">
396
+ <span class="mono text-sm row-key">{{ entry.key }}</span>
397
+ <span class="mono text-sm muted row-value-preview" :title="entry.val">{{ entry.val }}</span>
398
+ <span class="badge" :class="entry.reactive ? 'badge-ok' : 'badge-gray'">
399
+ {{ entry.reactive ? 'reactive' : 'static' }}
400
+ </span>
401
+ <button
402
+ v-if="entry.complex"
403
+ class="row-toggle mono"
404
+ @click="toggleProvideValue(provideValueId(selectedNode.id, entry.key, index))"
405
+ >
406
+ {{ expandedProvideValues.has(provideValueId(selectedNode.id, entry.key, index)) ? 'hide' : 'view' }}
407
+ </button>
408
+ </div>
409
+ <pre
410
+ v-if="entry.complex && expandedProvideValues.has(provideValueId(selectedNode.id, entry.key, index))"
411
+ class="value-box"
412
+ >{{ formatValueDetail(entry.raw) }}</pre>
413
+ </div>
296
414
  </div>
297
415
  </div>
298
416
 
299
- <div v-if="selectedNode.injects.length" :style="{ marginTop: selectedNode.provides.length ? '10px' : '0' }">
417
+ <div v-if="selectedNode.injects.length" class="detail-section" :style="{ marginTop: selectedNode.provides.length ? '10px' : '0' }">
300
418
  <div class="section-label">injects ({{ selectedNode.injects.length }})</div>
301
- <div v-for="entry in selectedNode.injects" :key="entry.key" class="inject-row" :class="{ 'inject-miss': !entry.ok }">
302
- <span class="mono text-sm" style="min-width: 100px">{{ entry.key }}</span>
303
- <span v-if="entry.ok" class="badge badge-ok">resolved</span>
304
- <span v-else class="badge badge-err">no provider</span>
305
- <span class="mono muted text-sm" style="margin-left: auto">{{ entry.from ?? 'undefined' }}</span>
419
+ <div class="detail-list">
420
+ <div v-for="entry in selectedNode.injects" :key="entry.key" class="inject-row" :class="{ 'inject-miss': !entry.ok }">
421
+ <span class="mono text-sm row-key">{{ entry.key }}</span>
422
+ <span v-if="entry.ok" class="badge badge-ok">resolved</span>
423
+ <span v-else class="badge badge-err">no provider</span>
424
+ <span class="mono muted text-sm row-from" :title="entry.from ?? 'undefined'">{{ entry.from ?? 'undefined' }}</span>
425
+ </div>
306
426
  </div>
307
427
  </div>
308
428
 
@@ -361,6 +481,13 @@ const edges = computed<Edge[]>(() => {
361
481
  margin-bottom: 12px;
362
482
  }
363
483
 
484
+ .canvas-stage {
485
+ display: flex;
486
+ justify-content: center;
487
+ align-items: flex-start;
488
+ min-width: 100%;
489
+ }
490
+
364
491
  .dot {
365
492
  width: 8px;
366
493
  height: 8px;
@@ -413,11 +540,6 @@ const edges = computed<Edge[]>(() => {
413
540
  background: color-mix(in srgb, var(--node-color) 8%, transparent);
414
541
  }
415
542
 
416
- .graph-node.is-dimmed {
417
- opacity: 0.2;
418
- pointer-events: none;
419
- }
420
-
421
543
  .node-dot {
422
544
  width: 7px;
423
545
  height: 7px;
@@ -448,6 +570,7 @@ const edges = computed<Edge[]>(() => {
448
570
  display: flex;
449
571
  flex-direction: column;
450
572
  gap: 4px;
573
+ min-height: 0;
451
574
  }
452
575
 
453
576
  .detail-empty {
@@ -462,6 +585,15 @@ const edges = computed<Edge[]>(() => {
462
585
  flex-shrink: 0;
463
586
  }
464
587
 
588
+ .graph-empty {
589
+ display: flex;
590
+ align-items: center;
591
+ justify-content: center;
592
+ min-height: 180px;
593
+ color: var(--text3);
594
+ font-size: 12px;
595
+ }
596
+
465
597
  .detail-header {
466
598
  display: flex;
467
599
  align-items: center;
@@ -478,16 +610,70 @@ const edges = computed<Edge[]>(() => {
478
610
  margin: 8px 0 5px;
479
611
  }
480
612
 
613
+ .detail-section {
614
+ display: flex;
615
+ flex-direction: column;
616
+ min-height: 0;
617
+ }
618
+
619
+ .detail-list {
620
+ display: flex;
621
+ flex-direction: column;
622
+ gap: 3px;
623
+ overflow: auto;
624
+ max-height: 220px;
625
+ padding-right: 2px;
626
+ }
627
+
481
628
  .provide-row {
482
629
  display: flex;
483
- align-items: center;
484
- gap: 8px;
630
+ flex-direction: column;
631
+ gap: 6px;
485
632
  padding: 5px 8px;
486
633
  background: var(--bg2);
487
634
  border-radius: var(--radius);
488
635
  margin-bottom: 3px;
489
636
  }
490
637
 
638
+ .row-main {
639
+ display: flex;
640
+ align-items: center;
641
+ gap: 8px;
642
+ min-width: 0;
643
+ }
644
+
645
+ .row-key {
646
+ min-width: 100px;
647
+ color: var(--text2);
648
+ flex-shrink: 0;
649
+ }
650
+
651
+ .row-value-preview {
652
+ flex: 1;
653
+ min-width: 0;
654
+ overflow: hidden;
655
+ text-overflow: ellipsis;
656
+ white-space: nowrap;
657
+ }
658
+
659
+ .row-toggle {
660
+ padding: 2px 8px;
661
+ font-size: 10px;
662
+ }
663
+
664
+ .value-box {
665
+ font-family: var(--mono);
666
+ font-size: 11px;
667
+ color: var(--text2);
668
+ background: rgb(0 0 0 / 10%);
669
+ border-radius: var(--radius);
670
+ padding: 8px 10px;
671
+ white-space: pre-wrap;
672
+ word-break: break-word;
673
+ overflow: auto;
674
+ max-height: 180px;
675
+ }
676
+
491
677
  .inject-row {
492
678
  display: flex;
493
679
  align-items: center;
@@ -498,6 +684,14 @@ const edges = computed<Edge[]>(() => {
498
684
  margin-bottom: 3px;
499
685
  }
500
686
 
687
+ .row-from {
688
+ margin-left: auto;
689
+ min-width: 0;
690
+ overflow: hidden;
691
+ text-overflow: ellipsis;
692
+ white-space: nowrap;
693
+ }
694
+
501
695
  .inject-miss {
502
696
  background: rgb(226 75 74 / 8%);
503
697
  }