vite-plugin-devtools-vue2 0.1.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.
package/lib/panel.js ADDED
@@ -0,0 +1,1550 @@
1
+ // panel.js — the floating inspector UI, a LitElement rendered inside a Shadow
2
+ // DOM overlay. It reads live Vue instances directly (same realm) and refreshes
3
+ // on every Vue scheduler flush.
4
+
5
+ import { LitElement, css, html } from 'lit';
6
+ import hook from './hook.js';
7
+ import { hide, highlight } from './inspector.js';
8
+ import { isPicking, startPicking, stopPicking } from './picker.js';
9
+ import { commitAll, getSnapshots, getStore, hasStore, travelTo, subscribe as vuexSubscribe } from './vuex.js';
10
+ import { buildTree, formatValue, getInstance } from './walker.js';
11
+
12
+ // Persisted UI state (survives reloads). Only stable, cheap bits — not the
13
+ // component tree / expanded set (ids are regenerated each load).
14
+ const STORE_KEY = 'vue-devtools:ui';
15
+
16
+ // Panel size (keep in sync with .panel CSS) — used to keep it on-screen.
17
+ const PANEL_W = 620;
18
+ const PANEL_H = 420;
19
+ const EDGE_MARGIN = 12;
20
+ // Distance from the viewport edge to the panel when open (leaves room for the
21
+ // floating entry to sit in the gutter, matching the official devtools).
22
+ const PANEL_EDGE = EDGE_MARGIN + 30 / 2;
23
+ const DRAG_THRESHOLD = 4;
24
+
25
+ export class VueDevToolsPanel extends LitElement {
26
+ static properties = {
27
+ tree: { state: true },
28
+ selectedId: { state: true },
29
+ expanded: { state: true },
30
+ collapsed: { state: true },
31
+ picking: { state: true },
32
+ query: { state: true },
33
+ tab: { state: true },
34
+ vuexSelected: { state: true },
35
+ renderCodeText: { state: true }
36
+ };
37
+
38
+ constructor() {
39
+ super();
40
+ const ui = VueDevToolsPanel._loadUiState();
41
+ this.tree = [];
42
+ this.selectedId = null;
43
+ this.expanded = new Set();
44
+ // Panel is closed by default; reopen state is remembered across reloads.
45
+ this.collapsed = ui.collapsed !== undefined ? ui.collapsed : true;
46
+ this.picking = false;
47
+ this.query = '';
48
+ this.tab = ui.tab || 'components';
49
+ this.vuexSelected = 0;
50
+ this.renderCodeText = null;
51
+ this.valueExpanded = new Set();
52
+ this.sectionCollapsed = new Set();
53
+ // Docked position of the entry/panel. Defaults to the bottom edge near
54
+ // the right; { edge: 'left'|'right'|'top'|'bottom', along: number }.
55
+ this._pos = ui.pos || { edge: 'bottom', along: Number.POSITIVE_INFINITY };
56
+ this._drag = null;
57
+ this._editingPath = null;
58
+ this._focusEdit = false;
59
+ this._flushTimer = null;
60
+ this._scrollToSelected = false;
61
+ // DOM fallback: when Vue is externalized as a global build (e.g. via
62
+ // vite-plugin-externals), the 'flush' hook may never fire — our
63
+ // head-prepended hook can load after the global Vue, or a production
64
+ // Vue build strips the devtools emit entirely. Watching the DOM for
65
+ // added/removed nodes keeps the tree live regardless, since the tree is
66
+ // derived from `el.__vue__` anyway.
67
+ this._domObserver = null;
68
+ this._onFlush = () => this._scheduleRefresh();
69
+ this._onKeydown = e => this._handleKeydown(e);
70
+ // Keep the entry/panel on-screen when the viewport shrinks. resize can
71
+ // fire many times per second while dragging the window edge, and
72
+ // _applyPos reads layout then writes styles — so coalesce to at most one
73
+ // call per frame via rAF to avoid layout thrashing.
74
+ this._resizeRaf = 0;
75
+ this._onResize = () => {
76
+ if (this._resizeRaf) return;
77
+ this._resizeRaf = requestAnimationFrame(() => {
78
+ this._resizeRaf = 0;
79
+ this._applyPos();
80
+ });
81
+ };
82
+ }
83
+
84
+ static _loadUiState() {
85
+ try {
86
+ return JSON.parse(localStorage.getItem(STORE_KEY)) || {};
87
+ } catch (e) {
88
+ return {};
89
+ }
90
+ }
91
+
92
+ _persistUiState() {
93
+ try {
94
+ const pos = this._pos && Number.isFinite(this._pos.along) ? this._pos : undefined;
95
+ localStorage.setItem(STORE_KEY, JSON.stringify({ collapsed: this.collapsed, tab: this.tab, pos }));
96
+ } catch (e) {
97
+ /* storage unavailable — ignore */
98
+ }
99
+ }
100
+
101
+ // Position the always-visible entry against its docked edge, and (when open)
102
+ // the panel adjacent to it so the panel follows the entry. Both are fixed to
103
+ // the viewport; clamped to stay fully on-screen.
104
+ _applyPos() {
105
+ const entry = this.renderRoot && this.renderRoot.querySelector('.entry');
106
+ if (!entry) return;
107
+ const p = this._pos || { edge: 'bottom', along: Number.POSITIVE_INFINITY };
108
+ const M = EDGE_MARGIN;
109
+ const vw = window.innerWidth;
110
+ const vh = window.innerHeight;
111
+ const er = entry.getBoundingClientRect();
112
+ const ew = er.width || 40;
113
+ const eh = er.height || 40;
114
+ const clamp = (v, max) => Math.min(Math.max(M, v), Math.max(M, max));
115
+
116
+ // Clamped offset of the entry along its docked edge. We derive the panel
117
+ // position from these numbers directly rather than re-reading the entry's
118
+ // live rect — on a fresh open/refresh that rect can still be stale (reads
119
+ // ~0), which left the entry bottom-right but the panel bottom-left.
120
+ const alongX = clamp(p.along, vw - ew - M);
121
+ const alongY = clamp(p.along, vh - eh - M);
122
+
123
+ const es = entry.style;
124
+ es.insetInlineStart = es.insetBlockStart = es.insetInlineEnd = es.insetBlockEnd = 'auto';
125
+ if (p.edge === 'right' || p.edge === 'left') {
126
+ es['inset' + (p.edge === 'right' ? 'InlineEnd' : 'InlineStart')] = M + 'px';
127
+ es.insetBlockStart = alongY + 'px';
128
+ } else {
129
+ es['inset' + (p.edge === 'bottom' ? 'BlockEnd' : 'BlockStart')] = M + 'px';
130
+ es.insetInlineStart = alongX + 'px';
131
+ }
132
+ this.setAttribute('dock', p.edge);
133
+
134
+ const panel = this.renderRoot.querySelector('.panel');
135
+ if (!panel) return;
136
+ const ps = panel.style;
137
+ ps.insetInlineStart = ps.insetBlockStart = ps.insetInlineEnd = ps.insetBlockEnd = 'auto';
138
+ // Entry center along its edge, computed from the clamped offsets above.
139
+ const cx = alongX + ew / 2;
140
+ const cy = alongY + eh / 2;
141
+ if (p.edge === 'right') {
142
+ ps.insetInlineEnd = PANEL_EDGE + 'px';
143
+ ps.insetBlockStart = clamp(cy - PANEL_H / 2, vh - PANEL_H - M) + 'px';
144
+ } else if (p.edge === 'left') {
145
+ ps.insetInlineStart = PANEL_EDGE + 'px';
146
+ ps.insetBlockStart = clamp(cy - PANEL_H / 2, vh - PANEL_H - M) + 'px';
147
+ } else if (p.edge === 'top') {
148
+ ps.insetBlockStart = PANEL_EDGE + 'px';
149
+ ps.insetInlineStart = clamp(cx - PANEL_W / 2, vw - PANEL_W - M) + 'px';
150
+ } else {
151
+ ps.insetBlockEnd = PANEL_EDGE + 'px';
152
+ ps.insetInlineStart = clamp(cx - PANEL_W / 2, vw - PANEL_W - M) + 'px';
153
+ }
154
+ }
155
+
156
+ // Drag the always-visible entry. Movement over a threshold = drag (live snap
157
+ // to nearest edge, panel follows); a plain click toggles the panel.
158
+ _startDrag(e) {
159
+ if (e.button !== 0) return;
160
+ e.preventDefault();
161
+ hide(); // clear any hover highlight before dragging
162
+ const entry = this.renderRoot.querySelector('.entry');
163
+ const rect = entry.getBoundingClientRect();
164
+ this._drag = {
165
+ startX: e.clientX,
166
+ startY: e.clientY,
167
+ offX: e.clientX - rect.left,
168
+ offY: e.clientY - rect.top,
169
+ moved: false
170
+ };
171
+ this._onDragMove = ev => this._dragMove(ev);
172
+ this._onDragUp = ev => this._dragUp(ev);
173
+ window.addEventListener('pointermove', this._onDragMove, true);
174
+ window.addEventListener('pointerup', this._onDragUp, true);
175
+ }
176
+
177
+ _onFabPointerDown(e) {
178
+ this._startDrag(e);
179
+ }
180
+
181
+ _dragMove(e) {
182
+ const d = this._drag;
183
+ if (!d) return;
184
+ if (!d.moved && Math.abs(e.clientX - d.startX) + Math.abs(e.clientY - d.startY) < DRAG_THRESHOLD) return;
185
+ d.moved = true;
186
+ // Snap to the nearest edge live during the drag (not on release).
187
+ const vw = window.innerWidth;
188
+ const vh = window.innerHeight;
189
+ const dist = {
190
+ left: e.clientX,
191
+ right: vw - e.clientX,
192
+ top: e.clientY,
193
+ bottom: vh - e.clientY
194
+ };
195
+ const edge = Object.keys(dist).reduce((a, b) => (dist[b] < dist[a] ? b : a));
196
+ const along = edge === 'left' || edge === 'right' ? e.clientY - d.offY : e.clientX - d.offX;
197
+ this._pos = { edge, along };
198
+ this._applyPos();
199
+ }
200
+
201
+ _dragUp() {
202
+ window.removeEventListener('pointermove', this._onDragMove, true);
203
+ window.removeEventListener('pointerup', this._onDragUp, true);
204
+ const d = this._drag;
205
+ this._drag = null;
206
+ if (!d) return;
207
+ if (!d.moved) {
208
+ // plain click → toggle the panel
209
+ this.collapsed = !this.collapsed;
210
+ return;
211
+ }
212
+ // Position was already decided live in _dragMove; just remember it.
213
+ this._persistUiState();
214
+ }
215
+
216
+ connectedCallback() {
217
+ super.connectedCallback();
218
+ hook.on('flush', this._onFlush);
219
+ window.addEventListener('keydown', this._onKeydown, true);
220
+ window.addEventListener('resize', this._onResize);
221
+ this._vuexUnsub = vuexSubscribe(() => this.requestUpdate());
222
+ // DOM-based fallback refresh (see constructor). Only childList/subtree —
223
+ // our own panel renders inside a shadow root (not observed), and the
224
+ // inspector highlight box only mutates via style, so this won't loop.
225
+ this._domObserver = new MutationObserver(() => this._scheduleRefresh());
226
+ this._domObserver.observe(document.body, { childList: true, subtree: true });
227
+ // First paint may happen before the app has mounted; retry shortly.
228
+ this.refresh();
229
+ setTimeout(() => this.refresh(), 300);
230
+ }
231
+
232
+ disconnectedCallback() {
233
+ super.disconnectedCallback();
234
+ hook.off('flush', this._onFlush);
235
+ window.removeEventListener('keydown', this._onKeydown, true);
236
+ window.removeEventListener('resize', this._onResize);
237
+ if (this._resizeRaf) {
238
+ cancelAnimationFrame(this._resizeRaf);
239
+ this._resizeRaf = 0;
240
+ }
241
+ if (this._domObserver) {
242
+ this._domObserver.disconnect();
243
+ this._domObserver = null;
244
+ }
245
+ if (this._vuexUnsub) this._vuexUnsub();
246
+ stopPicking();
247
+ }
248
+
249
+ firstUpdated() {
250
+ this._applyPos();
251
+ }
252
+
253
+ _scheduleRefresh() {
254
+ clearTimeout(this._flushTimer);
255
+ this._flushTimer = setTimeout(() => this.refresh(), 100);
256
+ }
257
+
258
+ refresh() {
259
+ const tree = buildTree();
260
+ this.tree = tree;
261
+ // Auto-expand roots the first time we see them.
262
+ if (this.expanded.size === 0) {
263
+ for (const root of tree) this.expanded.add(root.id);
264
+ }
265
+ this.requestUpdate();
266
+ }
267
+
268
+ _select(id) {
269
+ this.selectedId = id;
270
+ }
271
+
272
+ _toggle(id) {
273
+ if (this.expanded.has(id)) this.expanded.delete(id);
274
+ else this.expanded.add(id);
275
+ this.requestUpdate();
276
+ }
277
+
278
+ // Highlight the page DOM only while hovering a tree row (vue-devtools style).
279
+ _hoverEnter(id) {
280
+ if (this._drag) return; // don't highlight while dragging the entry/panel
281
+ const vm = getInstance(id);
282
+ if (vm) highlight(vm);
283
+ }
284
+
285
+ _hoverLeave() {
286
+ hide();
287
+ }
288
+
289
+ _togglePick() {
290
+ if (isPicking()) {
291
+ stopPicking();
292
+ this.picking = false;
293
+ return;
294
+ }
295
+ this.picking = true;
296
+ startPicking(vm => {
297
+ this.picking = false;
298
+ this._selectVm(vm);
299
+ });
300
+ }
301
+
302
+ // Select by live instance (used by the element picker): rebuild the tree,
303
+ // expand the ancestor chain so the node is visible, then select + scroll.
304
+ _selectVm(vm) {
305
+ this.refresh();
306
+ const path = this._pathToVm(vm);
307
+ if (!path.length) return;
308
+ for (let i = 0; i < path.length - 1; i++) this.expanded.add(path[i]);
309
+ this._select(path[path.length - 1]);
310
+ this._scrollToSelected = true;
311
+ this.requestUpdate();
312
+ }
313
+
314
+ _pathToVm(vm) {
315
+ let found = null;
316
+ const dfs = (node, trail) => {
317
+ const next = [...trail, node.id];
318
+ if (getInstance(node.id) === vm) {
319
+ found = next;
320
+ return true;
321
+ }
322
+ for (const c of node.children || []) if (dfs(c, next)) return true;
323
+ return false;
324
+ };
325
+ for (const r of this.tree) if (dfs(r, [])) break;
326
+ return found || [];
327
+ }
328
+
329
+ // Compute name-search filter. Returns null when no query is active.
330
+ // Like vue-devtools: a matched component becomes a top-level entry with its
331
+ // full descendant subtree shown; parent/ancestor components are hidden. A
332
+ // match nested inside another match is not promoted (it shows in the subtree).
333
+ _computeFilter() {
334
+ const q = this.query.trim().toLowerCase();
335
+ if (!q) return null;
336
+ const matched = new Set();
337
+ const mark = node => {
338
+ if (node.name.toLowerCase().includes(q)) matched.add(node.id);
339
+ for (const c of node.children || []) mark(c);
340
+ };
341
+ for (const r of this.tree) mark(r);
342
+
343
+ const roots = [];
344
+ const show = new Set();
345
+ const collect = node => {
346
+ show.add(node.id);
347
+ for (const c of node.children || []) collect(c);
348
+ };
349
+ const walk = (node, hasMatchedAncestor) => {
350
+ const isMatch = matched.has(node.id);
351
+ if (isMatch && !hasMatchedAncestor) {
352
+ roots.push(node);
353
+ collect(node);
354
+ }
355
+ for (const c of node.children || []) walk(c, hasMatchedAncestor || isMatch);
356
+ };
357
+ for (const r of this.tree) walk(r, false);
358
+ return { roots, show, q };
359
+ }
360
+
361
+ // Flatten the visible rows + parent links for keyboard navigation. During a
362
+ // search the visible rows are the matched subtrees (all descendants shown).
363
+ _index(filter) {
364
+ const order = [];
365
+ const parent = new Map();
366
+ const node = new Map();
367
+ if (filter) {
368
+ const walk = (n, p) => {
369
+ node.set(n.id, n);
370
+ parent.set(n.id, p);
371
+ order.push(n.id);
372
+ for (const c of n.children || []) {
373
+ if (filter.show.has(c.id)) walk(c, n.id);
374
+ }
375
+ };
376
+ for (const r of filter.roots) walk(r, null);
377
+ return { order, parent, node };
378
+ }
379
+ const walk = (n, p) => {
380
+ node.set(n.id, n);
381
+ parent.set(n.id, p);
382
+ order.push(n.id);
383
+ if (n.children && n.children.length && this.expanded.has(n.id)) {
384
+ for (const c of n.children) walk(c, n.id);
385
+ }
386
+ };
387
+ for (const r of this.tree) walk(r, null);
388
+ return { order, parent, node };
389
+ }
390
+
391
+ // Arrow-key navigation once a component is selected (VS Code / devtools style):
392
+ // ↑/↓ move through visible rows, → step into / expand, ← step out / collapse.
393
+ // During search the subtree is always shown, so →/← just navigate in/out.
394
+ _handleKeydown(e) {
395
+ if (this.collapsed || this.selectedId == null) return;
396
+ if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return;
397
+ // Don't hijack arrow keys while typing in a field. composedPath sees into
398
+ // our shadow root (the search box) as well as the app's own inputs.
399
+ const path = e.composedPath ? e.composedPath() : [];
400
+ const inEditable = path.some(el => el && el.tagName && (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) || el.isContentEditable));
401
+ if (inEditable) return;
402
+
403
+ const filter = this._computeFilter();
404
+ const searching = !!filter;
405
+ const { order, parent, node } = this._index(filter);
406
+ const id = this.selectedId;
407
+ const cur = node.get(id);
408
+ if (!cur) return;
409
+ const i = order.indexOf(id);
410
+ if (i < 0) return;
411
+ const shownChildren = (cur.children || []).filter(c => !filter || filter.show.has(c.id));
412
+ const hasChildren = shownChildren.length > 0;
413
+ // A node is "open" if its children are currently shown. In search mode the
414
+ // matched subtree is always shown.
415
+ const isOpen = searching || this.expanded.has(id);
416
+ let next = null;
417
+
418
+ if (e.key === 'ArrowDown') {
419
+ next = order[Math.min(order.length - 1, i + 1)];
420
+ } else if (e.key === 'ArrowUp') {
421
+ next = order[Math.max(0, i - 1)];
422
+ } else if (e.key === 'ArrowRight') {
423
+ // Closed with children -> open it; already open -> step into first child.
424
+ if (hasChildren && !isOpen) this.expanded.add(id);
425
+ else if (hasChildren && isOpen) next = shownChildren[0].id;
426
+ } else if (e.key === 'ArrowLeft') {
427
+ // Check open/closed FIRST: open -> collapse; closed (or leaf) -> parent.
428
+ if (isOpen && hasChildren && !searching) this.expanded.delete(id);
429
+ else next = parent.get(id);
430
+ }
431
+
432
+ e.preventDefault();
433
+ if (next != null) this._select(next);
434
+ this._scrollToSelected = true;
435
+ this.requestUpdate();
436
+ }
437
+
438
+ updated(changed) {
439
+ // Persist remembered UI bits across reloads.
440
+ if (changed && (changed.has('collapsed') || changed.has('tab'))) {
441
+ this._persistUiState();
442
+ }
443
+ // Re-clamp the docked position when switching fab <-> panel (sizes differ).
444
+ if (changed && changed.has('collapsed')) {
445
+ this._applyPos();
446
+ }
447
+ // Focus a freshly opened inline editor.
448
+ if (this._focusEdit) {
449
+ this._focusEdit = false;
450
+ const input = this.renderRoot.querySelector('.edit-input');
451
+ if (input) {
452
+ input.focus();
453
+ input.select();
454
+ }
455
+ }
456
+ // When the query changes, auto-select the first match (like vue-devtools).
457
+ if (changed && changed.has('query') && this.query.trim()) {
458
+ const f = this._computeFilter();
459
+ if (f && f.roots.length && !f.show.has(this.selectedId)) {
460
+ this._select(f.roots[0].id);
461
+ this._scrollToSelected = true;
462
+ }
463
+ }
464
+ if (!this._scrollToSelected) return;
465
+ this._scrollToSelected = false;
466
+ const el = this.renderRoot.querySelector('.node.selected');
467
+ if (el) el.scrollIntoView({ block: 'nearest' });
468
+ }
469
+
470
+ _renderNode(node, depth) {
471
+ const hasChildren = node.children && node.children.length > 0;
472
+ const isOpen = this.expanded.has(node.id);
473
+ const isSelected = node.id === this.selectedId;
474
+ return html`
475
+ <div>
476
+ <div
477
+ class="node ${isSelected ? 'selected' : ''}"
478
+ style="padding-left:${depth * 12 + 4}px"
479
+ @click=${() => this._select(node.id)}
480
+ @mouseenter=${() => this._hoverEnter(node.id)}
481
+ @mouseleave=${() => this._hoverLeave()}
482
+ >
483
+ <span
484
+ class="caret-btn"
485
+ @click=${e => {
486
+ e.stopPropagation();
487
+ this._toggle(node.id);
488
+ }}
489
+ >
490
+ ${this._caret(isOpen, !hasChildren)}
491
+ </span>
492
+ <span class="tag">&lt;${node.name}&gt;</span>
493
+ </div>
494
+ ${hasChildren && isOpen ? node.children.map(c => this._renderNode(c, depth + 1)) : null}
495
+ </div>
496
+ `;
497
+ }
498
+
499
+ // Search result row: matched node as a subtree root, with all descendants
500
+ // shown (always open). Ancestors are omitted. Arrow is non-interactive here.
501
+ _renderSearchNode(node, depth, show) {
502
+ const kids = (node.children || []).filter(c => show.has(c.id));
503
+ const isSelected = node.id === this.selectedId;
504
+ return html`
505
+ <div>
506
+ <div
507
+ class="node ${isSelected ? 'selected' : ''}"
508
+ style="padding-left:${depth * 12 + 4}px"
509
+ @click=${() => this._select(node.id)}
510
+ @mouseenter=${() => this._hoverEnter(node.id)}
511
+ @mouseleave=${() => this._hoverLeave()}
512
+ >
513
+ <span class="caret-btn static">${this._caret(kids.length > 0, kids.length === 0)}</span>
514
+ <span class="tag">&lt;${node.name}&gt;</span>
515
+ </div>
516
+ ${kids.map(c => this._renderSearchNode(c, depth + 1, show))}
517
+ </div>
518
+ `;
519
+ }
520
+
521
+ // Render an object as a titled, collapsible section of expandable value rows.
522
+ // `editable` enables inline editing of primitive leaves (writes back into obj).
523
+ _renderKvSection(title, obj, editable) {
524
+ const keys = obj ? Object.keys(obj) : [];
525
+ if (!keys.length) return null;
526
+ const collapsed = this.sectionCollapsed.has(title);
527
+ return html`
528
+ <div class="section-title" @click=${() => this._toggleSection(title)}>${this._caret(!collapsed, false)} ${title}</div>
529
+ ${collapsed ? null : keys.map(k => this._renderValueRow(k, obj[k], `${title}.${k}`, 0, obj, editable))}
530
+ `;
531
+ }
532
+
533
+ _toggleSection(title) {
534
+ if (this.sectionCollapsed.has(title)) this.sectionCollapsed.delete(title);
535
+ else this.sectionCollapsed.add(title);
536
+ this.requestUpdate();
537
+ }
538
+
539
+ _toggleValue(path) {
540
+ if (this.valueExpanded.has(path)) this.valueExpanded.delete(path);
541
+ else this.valueExpanded.add(path);
542
+ this.requestUpdate();
543
+ }
544
+
545
+ // Recursive, expandable value viewer. Objects/arrays expand in place; when
546
+ // `editable` is set, primitive leaves can be clicked to edit in place.
547
+ _renderValueRow(keyLabel, value, path, depth, parent, editable) {
548
+ const isObj = value !== null && typeof value === 'object';
549
+ const keys = isObj ? (Array.isArray(value) ? value.map((_, i) => i) : Object.keys(value)) : [];
550
+ const expandable = isObj && keys.length > 0;
551
+ const open = this.valueExpanded.has(path);
552
+ const canEdit = editable && !isObj && typeof value !== 'function';
553
+ const editing = this._editingPath === path;
554
+ // Single-line preview; full text is exposed via `title` since the value
555
+ // is truncated with an ellipsis when it overflows the row.
556
+ const preview = this._preview(value, keys);
557
+ return html`
558
+ <div class="vrow ${expandable ? 'expandable' : ''}" style="padding-left:${depth * 12 + 2}px" @click=${() => expandable && this._toggleValue(path)}>
559
+ <span class="caret-btn static">${this._caret(open, !expandable)}</span>
560
+ <span class="key">${keyLabel}</span>
561
+ <span class="colon">:</span>
562
+ ${editing
563
+ ? html`
564
+ <input
565
+ class="edit-input"
566
+ .value=${String(value)}
567
+ @click=${e => e.stopPropagation()}
568
+ @keydown=${e => this._onEditKeydown(e, parent, keyLabel, value)}
569
+ @blur=${e => this._commitEdit(parent, keyLabel, e.target.value, value)}
570
+ />
571
+ `
572
+ : html`
573
+ <span
574
+ class="val ${this._valClass(value)} ${canEdit ? 'editable' : ''}"
575
+ title=${preview}
576
+ @click=${e => {
577
+ if (!canEdit) return;
578
+ e.stopPropagation();
579
+ this._editingPath = path;
580
+ this._focusEdit = true;
581
+ this.requestUpdate();
582
+ }}
583
+ >
584
+ ${preview}
585
+ </span>
586
+ `}
587
+ ${!editing
588
+ ? html`
589
+ <button
590
+ class="copy-btn"
591
+ title="Copy value"
592
+ @click=${e => {
593
+ e.stopPropagation();
594
+ this._copyValue(value);
595
+ }}
596
+ >
597
+ ⧉
598
+ </button>
599
+ `
600
+ : null}
601
+ </div>
602
+ ${expandable && open ? keys.map(k => this._renderValueRow(k, value[k], `${path}.${k}`, depth + 1, value, editable)) : null}
603
+ `;
604
+ }
605
+
606
+ _copyValue(value) {
607
+ let text;
608
+ if (value !== null && typeof value === 'object') {
609
+ try {
610
+ text = JSON.stringify(value, null, 2);
611
+ } catch (e) {
612
+ text = String(value);
613
+ }
614
+ } else {
615
+ text = typeof value === 'string' ? value : String(value);
616
+ }
617
+ this._writeClipboard(text);
618
+ }
619
+
620
+ // navigator.clipboard only exists in secure contexts (https / localhost), so
621
+ // over plain http (e.g. http://localhost:5000) fall back to execCommand.
622
+ _writeClipboard(text) {
623
+ if (navigator.clipboard && window.isSecureContext) {
624
+ navigator.clipboard.writeText(text).catch(() => this._fallbackCopy(text));
625
+ } else {
626
+ this._fallbackCopy(text);
627
+ }
628
+ }
629
+
630
+ _fallbackCopy(text) {
631
+ const ta = document.createElement('textarea');
632
+ ta.value = text;
633
+ ta.style.cssText = 'position:fixed;top:-1000px;left:-1000px;opacity:0';
634
+ document.body.appendChild(ta);
635
+ ta.focus();
636
+ ta.select();
637
+ try {
638
+ document.execCommand('copy');
639
+ } catch (e) {
640
+ /* ignore */
641
+ }
642
+ document.body.removeChild(ta);
643
+ }
644
+
645
+ _onEditKeydown(e, parent, key, oldValue) {
646
+ e.stopPropagation();
647
+ if (e.key === 'Enter') {
648
+ this._commitEdit(parent, key, e.target.value, oldValue);
649
+ } else if (e.key === 'Escape') {
650
+ this._editingPath = null;
651
+ this.requestUpdate();
652
+ }
653
+ }
654
+
655
+ // Parse the input back to the original primitive type and write it into the
656
+ // reactive parent object (Vue.set keeps arrays / new keys reactive).
657
+ _commitEdit(parent, key, rawStr, oldValue) {
658
+ this._editingPath = null;
659
+ let parsed = rawStr;
660
+ const t = typeof oldValue;
661
+ if (t === 'number') {
662
+ const n = Number(rawStr);
663
+ parsed = Number.isNaN(n) ? oldValue : n;
664
+ } else if (t === 'boolean') {
665
+ parsed = rawStr === 'true' || rawStr === '1';
666
+ } else if (oldValue === null || oldValue === undefined) {
667
+ try {
668
+ parsed = JSON.parse(rawStr);
669
+ } catch (e) {
670
+ parsed = rawStr;
671
+ }
672
+ }
673
+ const Vue = hook.Vue;
674
+ if (parent) {
675
+ if (Vue && Vue.set) Vue.set(parent, key, parsed);
676
+ else parent[key] = parsed;
677
+ }
678
+ this.requestUpdate();
679
+ }
680
+
681
+ _valClass(value) {
682
+ if (value === null || value === undefined) return 'v-null';
683
+ const t = typeof value;
684
+ if (t === 'number') return 'v-num';
685
+ if (t === 'boolean') return 'v-bool';
686
+ if (t === 'string') return 'v-str';
687
+ if (t === 'function') return 'v-fn';
688
+ return 'v-obj';
689
+ }
690
+
691
+ // Short preview: expandable objects/arrays show a type/size summary; leaves
692
+ // reuse the walker's formatter.
693
+ _preview(value, keys) {
694
+ if (value !== null && typeof value === 'object') {
695
+ return Array.isArray(value) ? `Array[${value.length}]` : `Object{${keys.length}}`;
696
+ }
697
+ return formatValue(value);
698
+ }
699
+
700
+ _renderDetail() {
701
+ if (this.selectedId == null) {
702
+ return html`
703
+ <div class="empty">Select a component</div>
704
+ `;
705
+ }
706
+ const vm = getInstance(this.selectedId);
707
+ if (!vm)
708
+ return html`
709
+ <div class="empty">Component unmounted</div>
710
+ `;
711
+
712
+ const propsObj = vm._props || {};
713
+ const dataObj = vm._data || vm.$data || {};
714
+ const compDefs = (vm.$options && vm.$options.computed) || {};
715
+ const compObj = {};
716
+ for (const k of Object.keys(compDefs)) {
717
+ try {
718
+ compObj[k] = vm[k];
719
+ } catch (err) {
720
+ compObj[k] = `⚠ ${err && err.message}`;
721
+ }
722
+ }
723
+ const attrsObj = vm.$attrs || {};
724
+ const has = Object.keys(propsObj).length || Object.keys(dataObj).length || Object.keys(compObj).length || Object.keys(attrsObj).length;
725
+ const file = vm.$options && vm.$options.__file;
726
+ // Only offer "open in editor" for project source files. Library
727
+ // components (el-table etc.) either carry no __file or point into
728
+ // node_modules — like the official devtools, don't show it for those.
729
+ const canOpen = !!file && !/[\\/]node_modules[\\/]/.test(file);
730
+ return html`
731
+ <div class="detail-head">
732
+ <span class="detail-name">&lt;${this._vmName(vm)}&gt;</span>
733
+ <span class="detail-actions">
734
+ <button class="btn" @click=${() => this._scrollToComponent(vm)}>
735
+ ${this._icon('scroll')}
736
+ <span class="tip">Scroll to component</span>
737
+ </button>
738
+ <button class="btn" @click=${() => this._showRenderCode(vm)}>
739
+ ${this._icon('code')}
740
+ <span class="tip">Render code</span>
741
+ </button>
742
+ ${canOpen
743
+ ? html`
744
+ <button class="btn" @click=${() => this._openInEditor(file)}>
745
+ ${this._icon('open')}
746
+ <span class="tip">Open in editor</span>
747
+ </button>
748
+ `
749
+ : null}
750
+ </span>
751
+ </div>
752
+ ${this._renderKvSection('props', propsObj, true)} ${this._renderKvSection('data', dataObj, true)}
753
+ ${this._renderKvSection('computed', compObj, false)} ${this._renderKvSection('attrs', attrsObj, false)}
754
+ ${!has
755
+ ? html`
756
+ <div class="empty">No reactive state</div>
757
+ `
758
+ : null}
759
+ `;
760
+ }
761
+
762
+ _vmName(vm) {
763
+ const o = vm.$options || {};
764
+ let n = o.name || o._componentTag;
765
+ if (!n && o.__file)
766
+ n = String(o.__file)
767
+ .split(/[\\/]/)
768
+ .pop()
769
+ .replace(/\.vue$/, '');
770
+ if (!n && vm.$root === vm) n = 'Root';
771
+ return n || 'Anonymous';
772
+ }
773
+
774
+ // Ask the Vite dev server to open the component's source file in the editor.
775
+ _openInEditor(file) {
776
+ if (!file) return;
777
+ fetch('/__open-in-editor?file=' + encodeURIComponent(file)).catch(() => {});
778
+ }
779
+
780
+ // Scroll the component's root DOM element into view and flash the highlight.
781
+ _scrollToComponent(vm) {
782
+ const el = vm && vm.$el;
783
+ if (!el || !el.scrollIntoView) return;
784
+ el.scrollIntoView({
785
+ behavior: 'smooth',
786
+ block: 'center',
787
+ inline: 'center'
788
+ });
789
+ highlight(vm);
790
+ clearTimeout(this._scrollHlTimer);
791
+ this._scrollHlTimer = setTimeout(() => hide(), 1000);
792
+ }
793
+
794
+ // Show the component's (compiled) render function source in an overlay.
795
+ _showRenderCode(vm) {
796
+ const fn = vm && vm.$options && vm.$options.render;
797
+ this.renderCodeText = fn ? this._dedent(fn.toString()) : '// no render function on this component';
798
+ }
799
+
800
+ // fn.toString() keeps the source's original (often deep) indentation on every
801
+ // line except the first. Strip the common leading whitespace so it reads flush.
802
+ _dedent(code) {
803
+ const lines = code.split('\n');
804
+ let min = Infinity;
805
+ for (let i = 1; i < lines.length; i++) {
806
+ if (!lines[i].trim()) continue;
807
+ const indent = lines[i].match(/^[ \t]*/)[0].length;
808
+ if (indent < min) min = indent;
809
+ }
810
+ if (!isFinite(min) || min === 0) return code;
811
+ return lines.map((l, i) => (i === 0 ? l : l.slice(min))).join('\n');
812
+ }
813
+
814
+ render() {
815
+ return html`
816
+ <div class="entry" @pointerdown=${e => this._onFabPointerDown(e)}>
817
+ <span class="fab-icon">${this._vueLogo()}</span>
818
+ </div>
819
+ ${this.collapsed ? null : this._renderPanel()}
820
+ `;
821
+ }
822
+
823
+ _renderPanel() {
824
+ return html`
825
+ <div class="panel">
826
+ <nav class="sidebar">
827
+ <div class="logo">${this._vueLogo()}</div>
828
+ <button class="side-tab ${this.tab === 'components' ? 'active' : ''}" @click=${() => (this.tab = 'components')}>
829
+ ${this._icon('components')}
830
+ <span class="tip">Components</span>
831
+ </button>
832
+ <button class="side-tab ${this.tab === 'vuex' ? 'active' : ''}" @click=${() => (this.tab = 'vuex')}>
833
+ ${this._icon('vuex')}
834
+ <span class="tip">Vuex</span>
835
+ </button>
836
+ <span class="side-spacer"></span>
837
+ ${this.tab === 'components'
838
+ ? html`
839
+ <button class="side-tab ${this.picking ? 'active' : ''}" @click=${() => this._togglePick()}>
840
+ ${this._icon('pick')}
841
+ <span class="tip">${this.picking ? 'Cancel pick (Esc)' : 'Pick element'}</span>
842
+ </button>
843
+ `
844
+ : null}
845
+ <button class="side-tab side-tab--min" @click=${() => (this.collapsed = true)}>
846
+ ${this._icon('min')}
847
+ <span class="tip">Minimize</span>
848
+ </button>
849
+ </nav>
850
+ <div class="main">${this.tab === 'components' ? this._renderComponents() : this._renderVuex()}</div>
851
+ ${this.renderCodeText != null
852
+ ? html`
853
+ <div class="code-overlay">
854
+ <div class="code-head">
855
+ <span>Render code</span>
856
+ <button class="btn" @click=${() => (this.renderCodeText = null)}>${this._icon('close')}</button>
857
+ </div>
858
+ <pre class="code-body">${this.renderCodeText}</pre>
859
+ </div>
860
+ `
861
+ : null}
862
+ </div>
863
+ `;
864
+ }
865
+
866
+ _renderComponents() {
867
+ const filter = this._computeFilter();
868
+ const noMatch = filter && filter.roots.length === 0;
869
+ return html`
870
+ <div class="search">
871
+ <input
872
+ class="search-input"
873
+ type="search"
874
+ placeholder="Search components…"
875
+ .value=${this.query}
876
+ @input=${e => (this.query = e.target.value)}
877
+ @keydown=${e => {
878
+ if (e.key === 'Escape') {
879
+ this.query = '';
880
+ e.stopPropagation();
881
+ }
882
+ }}
883
+ />
884
+ </div>
885
+ <div class="body">
886
+ <div class="tree">
887
+ ${!this.tree.length
888
+ ? html`
889
+ <div class="empty">No Vue app detected</div>
890
+ `
891
+ : noMatch
892
+ ? html`
893
+ <div class="empty">No component matches</div>
894
+ `
895
+ : filter
896
+ ? filter.roots.map(n => this._renderSearchNode(n, 0, filter.show))
897
+ : this.tree.map(n => this._renderNode(n, 0))}
898
+ </div>
899
+ <div class="detail">${this._renderDetail()}</div>
900
+ </div>
901
+ `;
902
+ }
903
+
904
+ _renderVuex() {
905
+ if (!hasStore()) {
906
+ return html`
907
+ <div class="body">
908
+ <div class="empty">No Vuex store detected</div>
909
+ </div>
910
+ `;
911
+ }
912
+ const snaps = getSnapshots();
913
+ const sel = Math.min(this.vuexSelected, snaps.length - 1);
914
+ const snap = snaps[sel];
915
+ return html`
916
+ <div class="body">
917
+ <div class="tree">
918
+ <div class="vuex-bar">
919
+ <button
920
+ class="btn"
921
+ title="Commit all — clear history, keep current state"
922
+ @click=${() => {
923
+ commitAll();
924
+ this.vuexSelected = 0;
925
+ }}
926
+ >
927
+ ✓ Commit All
928
+ </button>
929
+ </div>
930
+ ${snaps.map(
931
+ (s, i) => html`
932
+ <div class="node ${i === sel ? 'selected' : ''}" @click=${() => (this.vuexSelected = i)}>
933
+ <span class="mut-index">${s.base ? '' : i}</span>
934
+ <span class="tag">${s.base ? 'Base State' : s.type}</span>
935
+ </div>
936
+ `
937
+ )}
938
+ </div>
939
+ <div class="detail">${snap ? this._renderVuexDetail(snap, sel) : null}</div>
940
+ </div>
941
+ `;
942
+ }
943
+
944
+ _renderVuexDetail(snap, index) {
945
+ const store = getStore();
946
+ const payloadObj = snap.payload === undefined ? null : { payload: snap.payload };
947
+ return html`
948
+ ${!snap.base
949
+ ? html`
950
+ <button class="btn on time-travel" @click=${() => travelTo(index)}>⏱ Time Travel</button>
951
+ `
952
+ : null}
953
+ ${this._renderKvSection('mutation', { type: snap.type }, false)} ${payloadObj ? this._renderKvSection('payload', payloadObj, false) : null}
954
+ ${this._renderKvSection('state', snap.state || {}, false)} ${store ? this._renderKvSection('getters (live)', store.getters || {}, false) : null}
955
+ `;
956
+ }
957
+
958
+ // Plugin logo (Baidu mark).
959
+ _vueLogo() {
960
+ return html`
961
+ <svg fill-rule="evenodd" viewBox="64 64 896 896" fill="#2932E1" aria-hidden="true">
962
+ <path
963
+ d="M250.02 547.04c92.37-19.8 79.77-130.07 76.95-154.18-4.56-37.2-48.26-102.16-107.63-97.02-74.7 6.7-85.65 114.58-85.65 114.58-10.04 49.88 24.2 156.43 116.33 136.62m84.7 214.14c10.28 38.7 43.95 40.43 43.95 40.43H427V683.55h-51.74c-23.22 6.96-34.5 25.1-36.98 32.8-2.74 7.8-8.71 27.6-3.57 44.83m169.07-531.1c0-72.42-41.13-131.08-92.2-131.08-50.92 0-92.21 58.66-92.21 131.07 0 72.5 41.3 131.16 92.2 131.16 51.08 0 92.21-58.66 92.21-131.16m248.1 9.1c8.86-54.92-35.08-118.88-83.34-129.82-48.34-11.1-108.7 66.28-114.18 116.74-6.55 61.72 8.79 123.28 76.86 132.06 68.16 8.87 112.03-63.87 120.65-118.97m46.35 433.02s-105.47-81.53-167-169.6c-83.4-129.91-201.98-77.05-241.62-11.02-39.47 66.03-101 107.87-109.7 118.9-8.87 10.93-127.36 74.8-101.07 191.55 26.28 116.65 118.73 114.5 118.73 114.5s68.08 6.7 147.1-10.94C523.7 888.03 591.7 910 591.7 910s184.57 61.72 235.07-57.18c50.41-118.97-28.53-180.61-28.53-180.61M362.42 849.17c-51.83-10.36-72.47-45.65-75.13-51.7-2.57-6.13-17.24-34.55-9.45-82.85 22.39-72.41 86.23-77.63 86.23-77.63h63.85v-78.46l54.4.82.08 289.82zm205.38-.83c-53.56-13.75-56.05-51.78-56.05-51.78V643.95l56.05-.92v137.12c3.4 14.59 21.65 17.32 21.65 17.32h56.88V643.95h59.62v204.39zm323.84-397.72c0-26.35-21.89-105.72-103.15-105.72-81.43 0-92.29 74.9-92.29 127.84 0 50.54 4.31 121.13 105.4 118.8 101.15-2.15 90.04-114.41 90.04-140.92"
964
+ />
965
+ </svg>
966
+ `;
967
+ }
968
+
969
+ // Inline stroked icons (currentColor) for the sidebar / toolbar.
970
+ _icon(name) {
971
+ switch (name) {
972
+ case 'components':
973
+ return html`
974
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
975
+ <rect x="9" y="3" width="6" height="5" rx="1" />
976
+ <rect x="3" y="16" width="6" height="5" rx="1" />
977
+ <rect x="15" y="16" width="6" height="5" rx="1" />
978
+ <path d="M12 8v3M6 16v-2h12v2" />
979
+ </svg>
980
+ `;
981
+ case 'vuex':
982
+ return html`
983
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
984
+ <ellipse cx="12" cy="5" rx="8" ry="3" />
985
+ <path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5" />
986
+ <path d="M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6" />
987
+ </svg>
988
+ `;
989
+ case 'pick':
990
+ return html`
991
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
992
+ <path d="M12 3v3M12 18v3M3 12h3M18 12h3" />
993
+ <circle cx="12" cy="12" r="4" />
994
+ </svg>
995
+ `;
996
+ case 'min':
997
+ return html`
998
+ <svg viewBox="64 64 896 896" fill="currentColor" aria-hidden="true">
999
+ <path
1000
+ d="M391 240.9c-.8-6.6-8.9-9.4-13.6-4.7l-43.7 43.7L200 146.3a8.03 8.03 0 00-11.3 0l-42.4 42.3a8.03 8.03 0 000 11.3L280 333.6l-43.9 43.9a8.01 8.01 0 004.7 13.6L401 410c5.1.6 9.5-3.7 8.9-8.9L391 240.9zm10.1 373.2L240.8 633c-6.6.8-9.4 8.9-4.7 13.6l43.9 43.9L146.3 824a8.03 8.03 0 000 11.3l42.4 42.3c3.1 3.1 8.2 3.1 11.3 0L333.7 744l43.7 43.7A8.01 8.01 0 00391 783l18.9-160.1c.6-5.1-3.7-9.4-8.8-8.8zm221.8-204.2L783.2 391c6.6-.8 9.4-8.9 4.7-13.6L744 333.6 877.7 200c3.1-3.1 3.1-8.2 0-11.3l-42.4-42.3a8.03 8.03 0 00-11.3 0L690.3 279.9l-43.7-43.7a8.01 8.01 0 00-13.6 4.7L614.1 401c-.6 5.2 3.7 9.5 8.8 8.9zM744 690.4l43.9-43.9a8.01 8.01 0 00-4.7-13.6L623 614c-5.1-.6-9.5 3.7-8.9 8.9L633 783.1c.8 6.6 8.9 9.4 13.6 4.7l43.7-43.7L824 877.7c3.1 3.1 8.2 3.1 11.3 0l42.4-42.3c3.1-3.1 3.1-8.2 0-11.3L744 690.4z"
1001
+ />
1002
+ </svg>
1003
+ `;
1004
+ case 'open':
1005
+ return html`
1006
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
1007
+ <path d="M15 3h6v6" />
1008
+ <path d="M10 14 21 3" />
1009
+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
1010
+ </svg>
1011
+ `;
1012
+ case 'scroll':
1013
+ return html`
1014
+ <svg viewBox="64 64 896 896" fill="currentColor" aria-hidden="true">
1015
+ <path
1016
+ d="M136 384h56c4.4 0 8-3.6 8-8V200h176c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H196c-37.6 0-68 30.4-68 68v180c0 4.4 3.6 8 8 8zm512-184h176v176c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V196c0-37.6-30.4-68-68-68H648c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8zM376 824H200V648c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v180c0 37.6 30.4 68 68 68h180c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zm512-184h-56c-4.4 0-8 3.6-8 8v176H648c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h180c37.6 0 68-30.4 68-68V648c0-4.4-3.6-8-8-8zm16-164H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8z"
1017
+ />
1018
+ </svg>
1019
+ `;
1020
+ case 'code':
1021
+ return html`
1022
+ <svg viewBox="64 64 896 896" fill="currentColor" aria-hidden="true">
1023
+ <path
1024
+ d="M516 673c0 4.4 3.4 8 7.5 8h185c4.1 0 7.5-3.6 7.5-8v-48c0-4.4-3.4-8-7.5-8h-185c-4.1 0-7.5 3.6-7.5 8v48zm-194.9 6.1l192-161c3.8-3.2 3.8-9.1 0-12.3l-192-160.9A7.95 7.95 0 00308 351v62.7c0 2.4 1 4.6 2.9 6.1L420.7 512l-109.8 92.2a8.1 8.1 0 00-2.9 6.1V673c0 6.8 7.9 10.5 13.1 6.1zM880 112H144c-17.7 0-32 14.3-32 32v736c0 17.7 14.3 32 32 32h736c17.7 0 32-14.3 32-32V144c0-17.7-14.3-32-32-32zm-40 728H184V184h656v656z"
1025
+ />
1026
+ </svg>
1027
+ `;
1028
+ case 'close':
1029
+ return html`
1030
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
1031
+ <path d="M6 6l12 12M18 6L6 18" />
1032
+ </svg>
1033
+ `;
1034
+ default:
1035
+ return null;
1036
+ }
1037
+ }
1038
+
1039
+ // Expand/collapse caret as an SVG chevron — rotates cleanly around center
1040
+ // (unlike a text glyph, which drifts when rotated).
1041
+ _caret(open, hidden) {
1042
+ return html`
1043
+ <svg class="caret ${open ? 'open' : ''} ${hidden ? 'hidden' : ''}" viewBox="0 0 16 16" aria-hidden="true">
1044
+ <path d="M6 4l4 4-4 4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
1045
+ </svg>
1046
+ `;
1047
+ }
1048
+
1049
+ static styles = css`
1050
+ :host {
1051
+ --accent: #41b883;
1052
+ --accent-600: #2f9e6d;
1053
+ --bg: #ffffff;
1054
+ --surface: #f7f8fa;
1055
+ --border: #edeff2;
1056
+ --border-strong: #e4e7ec;
1057
+ --field-border: #d0d5dd;
1058
+ --text: #1f2937;
1059
+ --text-strong: #101828;
1060
+ --muted: #98a2b3;
1061
+ --muted-2: #667085;
1062
+ --radius: 12px;
1063
+ --radius-sm: 7px;
1064
+ --c-key: #7c3aed;
1065
+ --c-num: #1d4ed8;
1066
+ --c-bool: #9333ea;
1067
+ --c-str: #16a34a;
1068
+ --c-null: #b45309;
1069
+ --c-fn: #2563eb;
1070
+ --c-obj: #475467;
1071
+
1072
+ position: fixed;
1073
+ inset: 0;
1074
+ pointer-events: none;
1075
+ z-index: 2147483647;
1076
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
1077
+ font-size: 12px;
1078
+ color: var(--text);
1079
+ }
1080
+ .entry {
1081
+ position: fixed;
1082
+ pointer-events: auto;
1083
+ z-index: 2;
1084
+ display: flex;
1085
+ align-items: center;
1086
+ gap: 2px;
1087
+ padding: 7px;
1088
+ border-radius: 10px;
1089
+ cursor: grab;
1090
+ touch-action: none;
1091
+ user-select: none;
1092
+ background: var(--bg);
1093
+ border: 1px solid var(--border-strong);
1094
+ box-shadow: 0 6px 20px rgb(16 24 40 / 0.18);
1095
+ }
1096
+ .entry:active {
1097
+ cursor: grabbing;
1098
+ }
1099
+ .fab-icon {
1100
+ display: grid;
1101
+ place-items: center;
1102
+ inline-size: 16px;
1103
+ block-size: 16px;
1104
+ }
1105
+ .fab-icon svg {
1106
+ inline-size: 16px;
1107
+ block-size: 16px;
1108
+ }
1109
+ /* On the left/right edges, stack the entry's icons vertically. */
1110
+ :host([dock='left']) .entry,
1111
+ :host([dock='right']) .entry {
1112
+ flex-direction: column;
1113
+ }
1114
+ .panel {
1115
+ position: fixed;
1116
+ pointer-events: auto;
1117
+ z-index: 1;
1118
+ inline-size: 620px;
1119
+ block-size: 420px;
1120
+ display: grid;
1121
+ grid-template-columns: 48px 1fr;
1122
+ overflow: hidden;
1123
+ background: var(--bg);
1124
+ border: 1px solid var(--border-strong);
1125
+ border-radius: var(--radius);
1126
+ box-shadow: 0 12px 40px rgb(16 24 40 / 0.18);
1127
+ }
1128
+ .panel {
1129
+ inline-size: 620px;
1130
+ block-size: 420px;
1131
+ display: grid;
1132
+ grid-template-columns: 48px 1fr;
1133
+ overflow: hidden;
1134
+ background: var(--bg);
1135
+ border: 1px solid var(--border-strong);
1136
+ border-radius: var(--radius);
1137
+ box-shadow: 0 12px 40px rgb(16 24 40 / 0.18);
1138
+ }
1139
+ .sidebar {
1140
+ position: relative;
1141
+ z-index: 2;
1142
+ display: flex;
1143
+ flex-direction: column;
1144
+ align-items: center;
1145
+ gap: 4px;
1146
+ padding-block: 8px;
1147
+ background: var(--surface);
1148
+ border-inline-end: 1px solid var(--border);
1149
+ }
1150
+ .logo {
1151
+ display: grid;
1152
+ place-items: center;
1153
+ inline-size: 34px;
1154
+ block-size: 34px;
1155
+ margin-block-end: 4px;
1156
+ border-radius: 9px;
1157
+
1158
+ & svg {
1159
+ inline-size: 22px;
1160
+ block-size: 22px;
1161
+ display: block;
1162
+ }
1163
+ }
1164
+ .side-tab {
1165
+ position: relative;
1166
+ display: grid;
1167
+ place-items: center;
1168
+ inline-size: 34px;
1169
+ block-size: 34px;
1170
+ border: 0;
1171
+ border-radius: 9px;
1172
+ color: var(--muted-2);
1173
+ background: transparent;
1174
+ cursor: pointer;
1175
+ transition:
1176
+ color 0.15s,
1177
+ background 0.15s;
1178
+
1179
+ & svg {
1180
+ inline-size: 20px;
1181
+ block-size: 20px;
1182
+ }
1183
+ /* Filled antd-style icon fills its box edge-to-edge — trim it a
1184
+ touch so it reads the same size as the stroked line icons. */
1185
+ &.side-tab--min svg {
1186
+ inline-size: 18px;
1187
+ block-size: 18px;
1188
+ }
1189
+ &:hover {
1190
+ color: var(--text);
1191
+ background: color-mix(in srgb, var(--text) 8%, transparent);
1192
+ }
1193
+ &.active {
1194
+ color: var(--accent);
1195
+ background: color-mix(in srgb, var(--accent) 14%, transparent);
1196
+ }
1197
+
1198
+ & .tip {
1199
+ inset-inline-start: calc(100% + 8px);
1200
+ inset-block-start: 50%;
1201
+ translate: -4px -50%;
1202
+
1203
+ &::before {
1204
+ content: '';
1205
+ position: absolute;
1206
+ inset-inline-end: 100%;
1207
+ inset-block-start: 50%;
1208
+ translate: 0 -50%;
1209
+ border: 4px solid transparent;
1210
+ border-inline-end-color: #1f2937;
1211
+ }
1212
+ }
1213
+ &:hover .tip {
1214
+ translate: 0 -50%;
1215
+ }
1216
+ }
1217
+ .tip {
1218
+ position: absolute;
1219
+ z-index: 20;
1220
+ padding: 3px 8px;
1221
+ border-radius: 6px;
1222
+ font-size: 12px;
1223
+ font-weight: 500;
1224
+ line-height: 1.4;
1225
+ white-space: nowrap;
1226
+ color: #fff;
1227
+ background: #1f2937;
1228
+ box-shadow: 0 4px 12px rgb(16 24 40 / 0.25);
1229
+ pointer-events: none;
1230
+ opacity: 0;
1231
+ transition:
1232
+ opacity 0.12s ease,
1233
+ translate 0.12s ease;
1234
+ }
1235
+ .btn .tip {
1236
+ inset-block-start: calc(100% + 6px);
1237
+ inset-inline-end: 0;
1238
+ }
1239
+ :is(.side-tab, .btn):hover > .tip {
1240
+ opacity: 1;
1241
+ }
1242
+ .side-spacer {
1243
+ flex: 1;
1244
+ }
1245
+ .main {
1246
+ position: relative;
1247
+ z-index: 1;
1248
+ display: flex;
1249
+ flex-direction: column;
1250
+ min-inline-size: 0;
1251
+ min-block-size: 0;
1252
+ overflow: hidden;
1253
+ }
1254
+ .btn {
1255
+ position: relative;
1256
+ display: inline-flex;
1257
+ padding: 4px 8px;
1258
+ border: 0;
1259
+ border-radius: var(--radius-sm);
1260
+ font-size: 13px;
1261
+ color: var(--muted-2);
1262
+ background: transparent;
1263
+ cursor: pointer;
1264
+ transition:
1265
+ background 0.15s,
1266
+ color 0.15s;
1267
+
1268
+ & svg {
1269
+ inline-size: 16px;
1270
+ block-size: 16px;
1271
+ display: block;
1272
+ }
1273
+ &:hover {
1274
+ color: var(--text);
1275
+ background: color-mix(in srgb, var(--text) 8%, transparent);
1276
+ }
1277
+ &.on {
1278
+ color: #fff;
1279
+ background: var(--accent);
1280
+ }
1281
+ }
1282
+ .body {
1283
+ flex: 1;
1284
+ min-block-size: 0;
1285
+ display: grid;
1286
+ grid-template-columns: 45% 1fr;
1287
+ }
1288
+ .search {
1289
+ padding: 5px 8px;
1290
+ background: var(--surface);
1291
+ border-block-end: 1px solid var(--border);
1292
+
1293
+ & .search-input {
1294
+ inline-size: 100%;
1295
+ box-sizing: border-box;
1296
+ padding: 4px 8px;
1297
+ color: var(--text-strong);
1298
+ background: var(--bg);
1299
+ border: 1px solid var(--field-border);
1300
+ border-radius: 4px;
1301
+ font-size: 12px;
1302
+ outline: none;
1303
+
1304
+ &:focus {
1305
+ border-color: var(--accent);
1306
+ }
1307
+ }
1308
+ }
1309
+ .tree {
1310
+ overflow: auto;
1311
+ padding-block: 4px;
1312
+ border-inline-end: 1px solid var(--border);
1313
+ }
1314
+ .detail {
1315
+ overflow: auto;
1316
+ padding: 6px 8px;
1317
+ }
1318
+ .detail-head {
1319
+ display: flex;
1320
+ align-items: center;
1321
+ gap: 6px;
1322
+ padding-block-end: 4px;
1323
+ margin-block-end: 4px;
1324
+ border-block-end: 1px solid var(--border);
1325
+
1326
+ & .detail-name {
1327
+ font-weight: 600;
1328
+ color: var(--accent-600);
1329
+ }
1330
+ & .detail-actions {
1331
+ display: flex;
1332
+ gap: 2px;
1333
+ margin-inline-start: auto;
1334
+
1335
+ & .btn {
1336
+ padding: 2px 5px;
1337
+ }
1338
+ & .btn svg {
1339
+ inline-size: 15px;
1340
+ block-size: 15px;
1341
+ }
1342
+ }
1343
+ }
1344
+ .code-overlay {
1345
+ position: absolute;
1346
+ inset-block: 0;
1347
+ inset-inline: 48px 0;
1348
+ z-index: 5;
1349
+ display: flex;
1350
+ flex-direction: column;
1351
+ background: var(--bg);
1352
+
1353
+ & .code-head {
1354
+ display: flex;
1355
+ align-items: center;
1356
+ justify-content: space-between;
1357
+ padding: 8px 10px;
1358
+ font-weight: 600;
1359
+ color: var(--text-strong);
1360
+ border-block-end: 1px solid var(--border);
1361
+
1362
+ & .btn {
1363
+ padding: 0;
1364
+ inline-size: 26px;
1365
+ block-size: 26px;
1366
+ align-items: center;
1367
+ justify-content: center;
1368
+ }
1369
+ }
1370
+ & .code-body {
1371
+ flex: 1;
1372
+ margin: 0;
1373
+ overflow: auto;
1374
+ padding: 10px 12px;
1375
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1376
+ font-size: 12px;
1377
+ line-height: 1.5;
1378
+ color: var(--c-obj);
1379
+ white-space: pre;
1380
+ tab-size: 2;
1381
+ }
1382
+ }
1383
+ .node {
1384
+ display: flex;
1385
+ align-items: center;
1386
+ gap: 4px;
1387
+ padding: 2px 4px;
1388
+ line-height: 18px;
1389
+ white-space: nowrap;
1390
+ cursor: pointer;
1391
+
1392
+ &:hover {
1393
+ background: color-mix(in srgb, var(--text) 6%, transparent);
1394
+ }
1395
+ &.selected {
1396
+ color: #fff;
1397
+ background: var(--accent);
1398
+
1399
+ & :is(.caret, .mut-index) {
1400
+ color: #fff;
1401
+ }
1402
+ }
1403
+ }
1404
+ .caret-btn {
1405
+ display: inline-flex;
1406
+ align-items: center;
1407
+ justify-content: center;
1408
+ inline-size: 12px;
1409
+ block-size: 17px;
1410
+ flex: none;
1411
+ }
1412
+ .caret-btn.static {
1413
+ pointer-events: none;
1414
+ }
1415
+ .caret {
1416
+ inline-size: 9px;
1417
+ block-size: 9px;
1418
+ color: var(--muted);
1419
+ transform-origin: 50% 50%;
1420
+ transition: transform 0.12s ease;
1421
+
1422
+ &.open {
1423
+ transform: rotate(90deg);
1424
+ }
1425
+ &.hidden {
1426
+ visibility: hidden;
1427
+ }
1428
+ }
1429
+ .tag {
1430
+ color: inherit;
1431
+ }
1432
+ .section-title {
1433
+ display: flex;
1434
+ align-items: center;
1435
+ gap: 3px;
1436
+ margin-block: 8px 2px;
1437
+ font-weight: 500;
1438
+ color: var(--muted);
1439
+ text-transform: lowercase;
1440
+ cursor: pointer;
1441
+
1442
+ &:hover {
1443
+ color: #475467;
1444
+ }
1445
+ }
1446
+ .vrow {
1447
+ display: flex;
1448
+ align-items: baseline;
1449
+ gap: 3px;
1450
+ padding-block: 1px;
1451
+ line-height: 17px;
1452
+ white-space: nowrap;
1453
+
1454
+ &.expandable {
1455
+ cursor: pointer;
1456
+ }
1457
+ &:hover .copy-btn {
1458
+ visibility: visible;
1459
+ }
1460
+ }
1461
+ .key {
1462
+ color: var(--c-key);
1463
+ }
1464
+ .colon {
1465
+ color: var(--muted);
1466
+ }
1467
+ .val {
1468
+ flex: 0 1 auto;
1469
+ min-inline-size: 0;
1470
+ overflow: hidden;
1471
+ white-space: nowrap;
1472
+ text-overflow: ellipsis;
1473
+
1474
+ &.editable {
1475
+ cursor: text;
1476
+ border-radius: 2px;
1477
+
1478
+ &:hover {
1479
+ background: color-mix(in srgb, var(--accent) 15%, transparent);
1480
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 40%, transparent);
1481
+ }
1482
+ }
1483
+ }
1484
+ .edit-input {
1485
+ min-inline-size: 60px;
1486
+ padding-inline: 4px;
1487
+ color: var(--text-strong);
1488
+ background: var(--bg);
1489
+ border: 1px solid var(--accent);
1490
+ border-radius: 2px;
1491
+ font: inherit;
1492
+ }
1493
+ .copy-btn {
1494
+ visibility: hidden;
1495
+ flex-shrink: 0;
1496
+ padding-inline: 4px;
1497
+ border: 0;
1498
+ line-height: 1;
1499
+ font-size: 12px;
1500
+ color: var(--muted);
1501
+ background: transparent;
1502
+ cursor: pointer;
1503
+
1504
+ &:hover {
1505
+ color: var(--accent-600);
1506
+ }
1507
+ }
1508
+ .v-num {
1509
+ color: var(--c-num);
1510
+ }
1511
+ .v-bool {
1512
+ color: var(--c-bool);
1513
+ }
1514
+ .v-str {
1515
+ color: var(--c-str);
1516
+ }
1517
+ .v-null {
1518
+ color: var(--c-null);
1519
+ }
1520
+ .v-fn {
1521
+ color: var(--c-fn);
1522
+ font-style: italic;
1523
+ }
1524
+ .v-obj {
1525
+ color: var(--c-obj);
1526
+ }
1527
+ .empty {
1528
+ padding: 8px;
1529
+ font-style: italic;
1530
+ color: var(--muted);
1531
+ }
1532
+ .vuex-bar {
1533
+ padding: 4px 6px;
1534
+ border-block-end: 1px solid var(--border);
1535
+ }
1536
+ .mut-index {
1537
+ display: inline-block;
1538
+ min-inline-size: 16px;
1539
+ font-size: 10px;
1540
+ text-align: end;
1541
+ color: var(--muted);
1542
+ }
1543
+ .time-travel {
1544
+ display: inline-block;
1545
+ margin-block: 4px 8px;
1546
+ }
1547
+ `;
1548
+ }
1549
+
1550
+ customElements.define('vue-devtools-panel', VueDevToolsPanel);