@dimina-kit/inspect 0.3.0-dev.20260711141929

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 EchoTechFE and dimina-kit contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # @dimina-kit/inspect
2
+
3
+ Host-agnostic runtime inspection for dimina mini-programs: WXML tree
4
+ extraction and Storage inspection. One package owns the protocol types, the
5
+ pure logic (Vue-runtime walk, stable-id registry, mutation-observing
6
+ inspector, storage-event reduction), the React panels, and the panels' data
7
+ wiring — so the Electron devtools and any downstream host (browser workbench,
8
+ preview iframe) share a single implementation and stay wire-compatible.
9
+
10
+ ## Entry points
11
+
12
+ - `@dimina-kit/inspect` — core, zero runtime dependencies:
13
+ - `WxmlNode` / `ElementInspection` — the wire-format types. Hosts transport
14
+ them over IPC, `postMessage` or anything else.
15
+ - `walkInstance(instance, depth)` — walks a mounted dimina render-layer Vue
16
+ instance (`document.body.__vue_app__`) into a `WxmlNode` tree.
17
+ - `registerSyntheticSid` / `findElementBySid` — stable element ids without
18
+ writing `data-*` attributes into the page.
19
+ - `createWxmlInspector(options)` — bundles the above into the surface a
20
+ host injects into the render document: `getWxml()`,
21
+ `highlightElement(sid)` (measure-only), `elementFor(sid)`,
22
+ `setObserving(on)` (debounced `onMutated` callback while a panel is
23
+ visible), `dispose()`.
24
+ - `StorageItem` / `StorageEvent` / `StorageWriteResult` — the Storage
25
+ wire-format types, plus `applyStorageEvent(items, evt)`, the pure
26
+ reducer that folds a change feed into an item list.
27
+ - `@dimina-kit/inspect/panel` — the React layer (React ≥ 18 peer):
28
+ - `WxmlPanel` / `StoragePanel` — the pure views (props in, no data wiring).
29
+ - `ConnectedWxmlPanel` / `ConnectedStoragePanel` — the panels' data wiring,
30
+ written once against their source contracts: seed on the
31
+ (enabled && active) rising edge, live updates via the push subscription,
32
+ visibility gating, hover inspection (WXML) / write forwarding (Storage).
33
+ Hosts render them with their source implementation and never duplicate
34
+ the wiring.
35
+ - Styling uses Tailwind utility classes over CSS variables
36
+ (`--color-code-blue`, `--color-surface-2`, …); the consuming app provides
37
+ the Tailwind theme mapping and variable values, and must include this
38
+ package's sources in its Tailwind content scan.
39
+ - `WxmlPanelSource` (main entry, type-only) — the five-operation transport
40
+ contract behind the WXML panel: `getSnapshot` / `subscribe` / `setActive` /
41
+ `inspect` / `clearInspection`. Each host implements only how these travel
42
+ (Electron IPC channels, preview-iframe postMessage, …).
43
+ - `StoragePanelSource` (main entry, type-only) — the Storage counterpart:
44
+ `getSnapshot` / `subscribe` / `setActive` / `setItem` / `removeItem` /
45
+ `clear` / `clearAll?` / `getPrefix`. `clearAll` is optional — hosts whose
46
+ storage partition is shared with non-mini-program data must omit it, and
47
+ the panel then hides the origin-wide wipe entirely.
48
+
49
+ ## Contract notes
50
+
51
+ - Every inspector method is read-only on the page. Visual highlighting is the
52
+ host's job (CDP overlay, DOM overlay, …).
53
+ - `setObserving(true)` is only meant to be on while a WXML panel is visible —
54
+ the tree walk is not free, so hosts gate it on panel visibility.
55
+ - After a render-document reload the injected realm (and its sid registry) is
56
+ gone: the host must re-inject and re-push a full snapshot.
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // The WXML panel's data wiring, written once against WxmlPanelSource: seed on
3
+ // the (enabled && active) rising edge, stay live through the push
4
+ // subscription, forward the visibility gate, and route hover inspection.
5
+ // Hosts render this with their transport implementation (Electron IPC,
6
+ // preview-iframe postMessage, …) and the pure WxmlPanel view underneath never
7
+ // needs host-specific code.
8
+ import { useState } from 'react';
9
+ import { WxmlPanel } from './panel-view.js';
10
+ import { useSourceWiring } from './use-source-wiring.js';
11
+ export function ConnectedWxmlPanel({ source, active = true, enabled = true, isRuntimeRunning = true, }) {
12
+ const [tree, setTree] = useState(null);
13
+ useSourceWiring({
14
+ source,
15
+ enabled,
16
+ active,
17
+ subscribe: s => s.subscribe(setTree),
18
+ seed: (s, isDisposed) => {
19
+ void s.getSnapshot().then((next) => {
20
+ if (!isDisposed())
21
+ setTree(next);
22
+ });
23
+ },
24
+ });
25
+ return (_jsx(WxmlPanel, { tree: tree, onInspectElement: sid => source.inspect(sid), onClearInspection: async () => {
26
+ await source.clearInspection();
27
+ }, isRuntimeRunning: isRuntimeRunning }));
28
+ }
@@ -0,0 +1,33 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // The Storage panel's data wiring, written once against StoragePanelSource:
3
+ // seed on the (enabled && active) rising edge, stay live by reducing the
4
+ // push subscription's StorageEvents into the item list, forward the
5
+ // visibility gate, and route the panel's writes. Hosts render this with
6
+ // their transport implementation (Electron IPC, same-origin localStorage +
7
+ // `storage` events, …) and the pure StoragePanel view underneath never
8
+ // needs host-specific code.
9
+ import { useState } from 'react';
10
+ import { StoragePanel } from './storage-panel-view.js';
11
+ import { applyStorageEvent } from './storage-reducer.js';
12
+ import { useSourceWiring } from './use-source-wiring.js';
13
+ export function ConnectedStoragePanel({ source, active = true, enabled = true, isRuntimeRunning = true, }) {
14
+ const [items, setItems] = useState([]);
15
+ useSourceWiring({
16
+ source,
17
+ enabled,
18
+ active,
19
+ subscribe: s => s.subscribe((evt) => {
20
+ setItems(prev => applyStorageEvent(prev, evt));
21
+ }),
22
+ seed: (s, isDisposed) => {
23
+ void s.getSnapshot().then((next) => {
24
+ if (!isDisposed())
25
+ setItems(next);
26
+ });
27
+ },
28
+ });
29
+ // clearAll is re-bound per source: passing it through only when the source
30
+ // has the capability is what makes the view hide the origin-wide wipe.
31
+ const clearAll = source.clearAll?.bind(source);
32
+ return (_jsx(StoragePanel, { items: items, onSet: (key, value) => source.setItem(key, value), onRemove: key => source.removeItem(key), onClear: () => source.clear(), onClearAll: clearAll, getPrefix: () => source.getPrefix(), isRuntimeRunning: isRuntimeRunning }));
33
+ }
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { SYNTHETIC_SID_PREFIX, registerSyntheticSid, findElementBySid, } from './sid-registry.js';
2
+ export { walkInstance } from './wxml-extract.js';
3
+ export { createWxmlInspector, } from './inspector.js';
4
+ export { applyStorageEvent } from './storage-reducer.js';
@@ -0,0 +1,122 @@
1
+ // Host-agnostic WXML inspector over a render-layer document. It owns the
2
+ // three read paths every host needs — Vue-tree walk (getWxml), sid → element
3
+ // resolution (elementFor), element measurement (highlightElement) — plus the
4
+ // visibility-gated DOM observer that debounces a setData burst into a single
5
+ // onMutated callback. Hosts wire the callback to their own transport
6
+ // (Electron bridge message, iframe postMessage, …) and draw any visual
7
+ // highlight themselves: every method here is strictly read-only on the page.
8
+ import { findElementBySid } from './sid-registry.js';
9
+ import { walkInstance } from './wxml-extract.js';
10
+ /** Coalesce a burst of setData-driven mutations into one notify per frame-ish. */
11
+ const DEFAULT_DEBOUNCE_MS = 200;
12
+ export function createWxmlInspector(options = {}) {
13
+ const doc = options.document ?? globalThis.document;
14
+ const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
15
+ const onMutated = options.onMutated;
16
+ const getVueApp = () => {
17
+ try {
18
+ const body = doc?.body;
19
+ const app = body?.__vue_app__;
20
+ if (!app)
21
+ return null;
22
+ if (app._instance)
23
+ return app._instance;
24
+ const container = app._container;
25
+ const vnode = container?._vnode;
26
+ return vnode?.component ?? null;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ };
32
+ const getWxml = () => {
33
+ const instance = getVueApp();
34
+ if (!instance)
35
+ return null;
36
+ const tree = walkInstance(instance, 0);
37
+ if (!tree)
38
+ return null;
39
+ return Array.isArray(tree)
40
+ ? { tagName: '#fragment', attrs: {}, children: tree }
41
+ : tree;
42
+ };
43
+ const elementFor = (sid) => {
44
+ if (!sid)
45
+ return null;
46
+ return findElementBySid(doc, sid);
47
+ };
48
+ const highlightElement = (sid) => {
49
+ const el = elementFor(sid);
50
+ if (!el)
51
+ return null;
52
+ const rect = el.getBoundingClientRect();
53
+ const style = el.ownerDocument.defaultView?.getComputedStyle(el);
54
+ if (!style)
55
+ return null;
56
+ return {
57
+ sid,
58
+ rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
59
+ style: {
60
+ display: style.display,
61
+ position: style.position,
62
+ boxSizing: style.boxSizing,
63
+ margin: style.margin,
64
+ padding: style.padding,
65
+ color: style.color,
66
+ backgroundColor: style.backgroundColor,
67
+ fontSize: style.fontSize,
68
+ },
69
+ };
70
+ };
71
+ // getWxml/highlightElement are read-only, so the observer never re-triggers
72
+ // itself. Debounced so a setData burst coalesces into a single callback.
73
+ let observer = null;
74
+ let debounceTimer = null;
75
+ const notifyMutated = () => {
76
+ debounceTimer = null;
77
+ onMutated?.();
78
+ };
79
+ const setObserving = (on) => {
80
+ if (on) {
81
+ if (observer)
82
+ return;
83
+ observer = new MutationObserver(() => {
84
+ if (debounceTimer !== null)
85
+ clearTimeout(debounceTimer);
86
+ debounceTimer = setTimeout(notifyMutated, debounceMs);
87
+ });
88
+ // The host may enable observing before the page DOM is up. Observing a
89
+ // null body throws, so defer to DOMContentLoaded when needed — but bail
90
+ // if observing was turned off again before the body arrived.
91
+ const begin = () => {
92
+ if (!observer || !doc.body)
93
+ return;
94
+ observer.observe(doc.body, {
95
+ childList: true,
96
+ subtree: true,
97
+ attributes: true,
98
+ characterData: true,
99
+ });
100
+ };
101
+ if (doc.body)
102
+ begin();
103
+ else
104
+ doc.addEventListener('DOMContentLoaded', begin, { once: true });
105
+ }
106
+ else {
107
+ if (debounceTimer !== null) {
108
+ clearTimeout(debounceTimer);
109
+ debounceTimer = null;
110
+ }
111
+ observer?.disconnect();
112
+ observer = null;
113
+ }
114
+ };
115
+ return {
116
+ getWxml,
117
+ highlightElement,
118
+ elementFor,
119
+ setObserving,
120
+ dispose: () => setObserving(false),
121
+ };
122
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,136 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef, useState } from 'react';
3
+ /**
4
+ * 默认是否展开:对齐微信开发者工具的 wxml 面板。
5
+ * - `#shadow-root` 永远默认展开(组件边界标记,展开后才能看到内部结构)
6
+ * - 路径式 tag(含 `/`)= 页面或自定义组件,默认展开(让用户一眼看清组件层级)
7
+ * - 普通 DOM 节点(view/text 等)默认折叠,需要用户手动点开
8
+ */
9
+ function isDefaultExpanded(node) {
10
+ if (node.tagName === '#shadow-root')
11
+ return true;
12
+ if (node.tagName.includes('/'))
13
+ return true;
14
+ return false;
15
+ }
16
+ /** Renders a `[key, value]` attr list as `{' '}key="value"` pairs. */
17
+ function AttrList({ entries }) {
18
+ return (_jsx(_Fragment, { children: entries.map(([k, v]) => (_jsxs("span", { children: [' ', _jsx("span", { className: "text-code-blue", children: k }), _jsx("span", { className: "text-text-dim", children: "=" }), _jsxs("span", { className: "text-code-orange", children: ["\"", v, "\""] })] }, k))) }));
19
+ }
20
+ // Text node — render as plain text.
21
+ function WxmlTextNode({ node, depth }) {
22
+ return (_jsxs("div", { className: "py-px leading-[18px] hover:bg-surface-2", style: { paddingLeft: depth * 16 }, children: [_jsx("span", { className: "w-3 inline-block" }), _jsx("span", { className: "text-text", children: node.text })] }));
23
+ }
24
+ // Fragment — transparent root wrapper; render children directly without extra depth/tag.
25
+ function WxmlFragmentNode({ node, depth, inspectedSid, onInspect }) {
26
+ return (_jsx(_Fragment, { children: (node.children ?? []).map((child, i) => (_jsx(WxmlTreeNode, { node: child, depth: depth, inspectedSid: inspectedSid, onInspect: onInspect }, i))) }));
27
+ }
28
+ // Shadow root — synthetic boundary for custom component internals.
29
+ // Clickable to collapse like WeChat DevTools; default expanded; no closing tag.
30
+ function WxmlShadowRootNode({ node, depth, inspectedSid, onInspect }) {
31
+ const [expanded, setExpanded] = useState(() => isDefaultExpanded(node));
32
+ const indent = depth * 16;
33
+ const hasShadowChildren = (node.children ?? []).length > 0;
34
+ return (_jsxs("div", { children: [_jsxs("div", { className: "py-px leading-[18px] hover:bg-surface-2 cursor-pointer", style: { paddingLeft: indent }, onClick: () => hasShadowChildren && setExpanded(!expanded), children: [_jsx("span", { className: "text-text-dim w-3 shrink-0 inline-block text-center select-none", children: hasShadowChildren ? (expanded ? '▾' : '▸') : ' ' }), _jsx("span", { className: "text-text-dim italic", children: "#shadow-root" })] }), expanded && node.children.map((child, i) => (_jsx(WxmlTreeNode, { node: child, depth: depth + 1, inspectedSid: inspectedSid, onInspect: onInspect }, i)))] }));
35
+ }
36
+ // Single text child — render inline: <tag>text</tag>
37
+ function WxmlInlineTextRow({ node, indent, attrEntries, isInspected, onMouseEnter, inlineText, }) {
38
+ const rowClassName = `py-px leading-[18px] hover:bg-surface-2${isInspected ? ' bg-surface-2' : ''}`;
39
+ return (_jsxs("div", { className: rowClassName, style: { paddingLeft: indent }, onMouseEnter: onMouseEnter, "data-wxml-sid": node.sid, children: [_jsx("span", { className: "w-3 inline-block" }), _jsxs("span", { className: "text-code-keyword", children: ['<', node.tagName] }), _jsx(AttrList, { entries: attrEntries }), _jsx("span", { className: "text-code-keyword", children: '>' }), _jsx("span", { className: "text-text", children: inlineText }), _jsxs("span", { className: "text-code-keyword", children: ['</', node.tagName, '>'] })] }));
40
+ }
41
+ // Full open/close-tag row, with children rendered underneath when expanded.
42
+ function WxmlBlockRow({ node, depth, indent, attrEntries, hasChildren, expanded, isInspected, inspectedSid, onInspect, onToggle, onMouseEnter, }) {
43
+ return (_jsxs("div", { children: [_jsxs("div", { className: `flex items-start hover:bg-surface-2 py-px leading-[18px]${hasChildren ? ' cursor-pointer' : ''}${isInspected ? ' bg-surface-2' : ''}`, style: { paddingLeft: indent }, onMouseEnter: onMouseEnter, onClick: () => {
44
+ if (hasChildren)
45
+ onToggle();
46
+ }, "data-wxml-sid": node.sid, children: [_jsx("span", { className: "text-text-dim w-3 shrink-0 text-center select-none", children: hasChildren ? (expanded ? '▾' : '▸') : ' ' }), _jsxs("span", { children: [_jsxs("span", { className: "text-code-keyword", children: ['<', node.tagName] }), _jsx(AttrList, { entries: attrEntries }), _jsx("span", { className: "text-code-keyword", children: hasChildren ? '>' : ' />' })] })] }), expanded && hasChildren && (_jsxs(_Fragment, { children: [node.children.map((child, i) => (_jsx(WxmlTreeNode, { node: child, depth: depth + 1, inspectedSid: inspectedSid, onInspect: onInspect }, i))), _jsxs("div", { style: { paddingLeft: indent }, className: "py-px leading-[18px]", children: [_jsx("span", { className: "w-3 inline-block" }), _jsxs("span", { className: "text-code-keyword", children: ['</', node.tagName, '>'] })] })] }))] }));
47
+ }
48
+ // Ordinary element node — decides between the inline-text and block layouts.
49
+ function WxmlElementNode({ node, depth, inspectedSid, onInspect }) {
50
+ const [expanded, setExpanded] = useState(() => isDefaultExpanded(node));
51
+ const indent = depth * 16;
52
+ const isInspected = Boolean(node.sid && node.sid === inspectedSid);
53
+ const hasChildren = (node.children ?? []).length > 0;
54
+ const attrEntries = Object.entries(node.attrs);
55
+ const inspect = () => {
56
+ if (node.sid)
57
+ onInspect?.(node);
58
+ };
59
+ // Single text child — render inline: <tag>text</tag>
60
+ const inlineText = hasChildren && node.children.length === 1 && node.children[0].tagName === '#text'
61
+ ? node.children[0].text
62
+ : null;
63
+ if (inlineText) {
64
+ return (_jsx(WxmlInlineTextRow, { node: node, indent: indent, attrEntries: attrEntries, isInspected: isInspected, onMouseEnter: inspect, inlineText: inlineText }));
65
+ }
66
+ return (_jsx(WxmlBlockRow, { node: node, depth: depth, indent: indent, attrEntries: attrEntries, hasChildren: hasChildren, expanded: expanded, isInspected: isInspected, inspectedSid: inspectedSid, onInspect: onInspect, onToggle: () => setExpanded(!expanded), onMouseEnter: inspect }));
67
+ }
68
+ function WxmlTreeNode({ node, depth, inspectedSid, onInspect }) {
69
+ if (node.tagName === '#text')
70
+ return _jsx(WxmlTextNode, { node: node, depth: depth });
71
+ if (node.tagName === '#fragment') {
72
+ return _jsx(WxmlFragmentNode, { node: node, depth: depth, inspectedSid: inspectedSid, onInspect: onInspect });
73
+ }
74
+ if (node.tagName === '#shadow-root') {
75
+ return _jsx(WxmlShadowRootNode, { node: node, depth: depth, inspectedSid: inspectedSid, onInspect: onInspect });
76
+ }
77
+ return _jsx(WxmlElementNode, { node: node, depth: depth, inspectedSid: inspectedSid, onInspect: onInspect });
78
+ }
79
+ function InspectionFooter({ inspection }) {
80
+ if (!inspection)
81
+ return null;
82
+ const { rect, style } = inspection;
83
+ const styleParts = [
84
+ style.display,
85
+ style.position !== 'static' ? style.position : null,
86
+ style.boxSizing,
87
+ ].filter(Boolean);
88
+ return (_jsxs("div", { className: "border-t border-border-subtle bg-bg-panel px-2.5 py-1.5 font-mono text-[11px] text-text-dim shrink-0", children: [_jsx("span", { className: "text-text", children: "box" }), ' ', Math.round(rect.width), " x ", Math.round(rect.height), ' @ ', Math.round(rect.x), ", ", Math.round(rect.y), _jsx("span", { className: "mx-2 text-border-subtle", children: "|" }), styleParts.join(' / '), _jsx("span", { className: "mx-2 text-border-subtle", children: "|" }), "font ", style.fontSize] }));
89
+ }
90
+ export function WxmlPanel({ tree, onInspectElement, onClearInspection, isRuntimeRunning = true, }) {
91
+ const [inspection, setInspection] = useState(null);
92
+ // 序号 + rAF 用于解决 hover 触发的两个独立问题:
93
+ // - reqSeqRef:防止 await 完成顺序与 hover 顺序不一致导致 footer 抖动;
94
+ // 每次发起请求自增,写入前比对,落后的响应直接丢弃。
95
+ // - rafRef:把 hover 触发的 IPC 合并到下一帧,连续扫过列表只会留最后一帧。
96
+ const reqSeqRef = useRef(0);
97
+ const rafRef = useRef(null);
98
+ useEffect(() => () => {
99
+ if (rafRef.current !== null)
100
+ cancelAnimationFrame(rafRef.current);
101
+ }, []);
102
+ const inspectNode = useCallback((node) => {
103
+ if (!node.sid || !onInspectElement)
104
+ return;
105
+ const sid = node.sid;
106
+ if (rafRef.current !== null)
107
+ cancelAnimationFrame(rafRef.current);
108
+ rafRef.current = requestAnimationFrame(() => {
109
+ rafRef.current = null;
110
+ const seq = ++reqSeqRef.current;
111
+ onInspectElement(sid).then((next) => {
112
+ if (seq !== reqSeqRef.current)
113
+ return;
114
+ setInspection(next);
115
+ }).catch(() => {
116
+ if (seq !== reqSeqRef.current)
117
+ return;
118
+ setInspection(null);
119
+ });
120
+ });
121
+ }, [onInspectElement]);
122
+ const clearInspection = useCallback(() => {
123
+ if (rafRef.current !== null) {
124
+ cancelAnimationFrame(rafRef.current);
125
+ rafRef.current = null;
126
+ }
127
+ // 让所有 in-flight 响应被序号校验丢弃,避免它们在 clear 之后又把 inspection 写回来。
128
+ reqSeqRef.current++;
129
+ setInspection(null);
130
+ void onClearInspection?.();
131
+ }, [onClearInspection]);
132
+ if (!tree) {
133
+ return (_jsx("div", { className: "flex flex-col flex-1 overflow-hidden", "data-testid": "wxml-panel", children: _jsx("div", { className: "text-[12px] text-text-dim text-center px-4 py-6", children: isRuntimeRunning ? '等待小程序加载...' : '小程序未运行' }) }));
134
+ }
135
+ return (_jsxs("div", { className: "flex flex-col flex-1 overflow-hidden", onMouseLeave: clearInspection, "data-testid": "wxml-panel", children: [_jsx("div", { className: "flex-1 overflow-y-auto p-2 font-mono text-[12px]", children: _jsx(WxmlTreeNode, { node: tree, depth: 0, inspectedSid: inspection?.sid ?? null, onInspect: inspectNode }) }), _jsx(InspectionFooter, { inspection: inspection })] }));
136
+ }
package/dist/panel.js ADDED
@@ -0,0 +1,7 @@
1
+ // Public React entry (`@dimina-kit/inspect/panel`): the pure views plus the
2
+ // source-connected containers. Split into files so a pure view stays
3
+ // importable without its data-wiring layer.
4
+ export { WxmlPanel } from './panel-view.js';
5
+ export { ConnectedWxmlPanel } from './connected-panel.js';
6
+ export { StoragePanel } from './storage-panel-view.js';
7
+ export { ConnectedStoragePanel } from './connected-storage-panel.js';
@@ -0,0 +1,45 @@
1
+ // Stable-id registry for WXML nodes, shared by every extractor that walks a
2
+ // render-layer document (host iframe extractors, injected guest inspectors,
3
+ // browser preview bridges). It is dependency-free so injected bundles stay
4
+ // small and don't drag in host-specific machinery.
5
+ // 合成 sid 注册表:用 WeakMap 把元素 ↔ sid 双向绑定,避免在源 DOM 上写
6
+ // `data-*` 属性(提取本应只读,且属性形式会污染用户的快照/选择器)。
7
+ // elBySyntheticSid 为反向查找用 WeakRef,元素被 GC 后下次 lookup 自动清理。
8
+ export const SYNTHETIC_SID_PREFIX = 'devtools-';
9
+ const syntheticSidByEl = new WeakMap();
10
+ const elBySyntheticSid = new Map();
11
+ let nextSyntheticSid = 1;
12
+ export function registerSyntheticSid(el) {
13
+ const existing = syntheticSidByEl.get(el);
14
+ if (existing)
15
+ return existing;
16
+ const synthetic = `${SYNTHETIC_SID_PREFIX}${nextSyntheticSid++}`;
17
+ syntheticSidByEl.set(el, synthetic);
18
+ elBySyntheticSid.set(synthetic, new WeakRef(el));
19
+ return synthetic;
20
+ }
21
+ export function findElementBySid(doc, sid) {
22
+ if (sid.startsWith(SYNTHETIC_SID_PREFIX)) {
23
+ const ref = elBySyntheticSid.get(sid);
24
+ if (!ref)
25
+ return null;
26
+ const el = ref.deref();
27
+ if (!el || !el.isConnected) {
28
+ elBySyntheticSid.delete(sid);
29
+ return null;
30
+ }
31
+ if (el.ownerDocument !== doc)
32
+ return null;
33
+ return el;
34
+ }
35
+ return doc.querySelector(`[data-sid="${escapeForAttrSelector(sid)}"]`);
36
+ }
37
+ // `CSS.escape` only exists in real browser realms (jsdom has no `window.CSS`).
38
+ // Inside a double-quoted attribute selector, escaping backslashes and quotes
39
+ // is sufficient, so fall back to that when the host lacks the API.
40
+ function escapeForAttrSelector(value) {
41
+ const impl = globalThis.CSS?.escape;
42
+ if (impl)
43
+ return impl(value);
44
+ return value.replace(/[\\"]/g, '\\$&');
45
+ }
@@ -0,0 +1,127 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // The pure Storage table view: items in, write callbacks out. No data
3
+ // wiring lives here (seed/subscribe/visibility belong to
4
+ // ConnectedStoragePanel); no host UI kit either — the couple of buttons are
5
+ // plain elements styled with the same Tailwind utility tokens as the rest of
6
+ // the panel, so any host that maps the CSS variables gets the native look.
7
+ import { useEffect, useState } from 'react';
8
+ const BUTTON_CLASS = 'inline-flex items-center justify-center gap-1.5 font-medium transition-colors '
9
+ + 'focus:outline-none disabled:opacity-35 disabled:cursor-not-allowed whitespace-nowrap shrink-0 '
10
+ + 'border border-border text-text-muted hover:text-text rounded h-5 px-2 text-[11px]';
11
+ export function StoragePanel({ items, onSet, onRemove, onClear, onClearAll, getPrefix, isRuntimeRunning = true, }) {
12
+ const [editing, setEditing] = useState(null);
13
+ const [error, setError] = useState(null);
14
+ const [busy, setBusy] = useState(false);
15
+ const [prefix, setPrefix] = useState('');
16
+ const [addKey, setAddKey] = useState('');
17
+ const [addValue, setAddValue] = useState('');
18
+ useEffect(() => {
19
+ let cancelled = false;
20
+ let timer;
21
+ // The panel can mount before the host has resolved the active appId, so
22
+ // `getPrefix()` initially returns '' and the key un-prefixing below would
23
+ // never engage. The prefix is empty only transiently during session
24
+ // warmup, so poll until it resolves non-empty (then stop). Once set it is
25
+ // stable for the session's lifetime.
26
+ const load = () => {
27
+ void getPrefix().then((p) => {
28
+ if (cancelled)
29
+ return;
30
+ if (p) {
31
+ setPrefix(p);
32
+ return;
33
+ }
34
+ timer = setTimeout(load, 300);
35
+ });
36
+ };
37
+ load();
38
+ return () => { cancelled = true; if (timer)
39
+ clearTimeout(timer); };
40
+ }, [getPrefix]);
41
+ async function withBusy(op) {
42
+ setBusy(true);
43
+ setError(null);
44
+ try {
45
+ const r = await op();
46
+ if (!r.ok) {
47
+ setError(r.error);
48
+ return null;
49
+ }
50
+ return r;
51
+ }
52
+ finally {
53
+ setBusy(false);
54
+ }
55
+ }
56
+ function startEdit(item) {
57
+ setEditing({ key: item.key, draft: item.value });
58
+ }
59
+ async function commitEdit() {
60
+ if (!editing)
61
+ return;
62
+ const { key, draft } = editing;
63
+ setEditing(null);
64
+ await withBusy(() => onSet(key, draft));
65
+ }
66
+ async function handleRemove(key) {
67
+ await withBusy(() => onRemove(key));
68
+ }
69
+ async function handleClear() {
70
+ if (items.length === 0)
71
+ return;
72
+ await withBusy(() => onClear());
73
+ }
74
+ async function handleClearAll() {
75
+ if (!onClearAll)
76
+ return;
77
+ // Origin-wide wipe affects every appId's keys in the shared storage
78
+ // partition. Use `window.confirm` so the user has to opt in explicitly.
79
+ if (typeof window !== 'undefined' && !window.confirm('清空所有 appId 的 Storage 数据?该操作不可撤销。'))
80
+ return;
81
+ await withBusy(() => onClearAll());
82
+ }
83
+ async function handleAdd() {
84
+ const trimmedKey = addKey.trim();
85
+ if (!trimmedKey) {
86
+ setError('key 不能为空');
87
+ return;
88
+ }
89
+ const fullKey = prefix + trimmedKey;
90
+ const r = await withBusy(() => onSet(fullKey, addValue));
91
+ if (r) {
92
+ setAddKey('');
93
+ setAddValue('');
94
+ }
95
+ }
96
+ return (_jsxs("div", { className: "flex flex-col overflow-hidden flex-1", "data-testid": "storage-panel", children: [_jsxs("div", { className: "flex items-center gap-1.5 px-2.5 py-1.5 border-b border-border-subtle shrink-0 bg-bg-panel", children: [_jsx("button", { type: "button", onClick: () => void handleClear(), disabled: busy || items.length === 0, className: `${BUTTON_CLASS} hover:border-status-error hover:text-status-error`, title: "\u4EC5\u6E05\u7A7A\u5F53\u524D appId \u7684 Storage", children: "\u6E05\u7A7A" }), onClearAll && (_jsx("button", { type: "button", onClick: () => void handleClearAll(), disabled: busy, className: `${BUTTON_CLASS} hover:border-status-error hover:text-status-error`, title: "\u6E05\u7A7A\u6240\u6709 appId \u7684 Storage", children: "\u6E05\u7A7A\u6240\u6709" })), error && (_jsx("span", { className: "ml-2 text-[11px] text-status-error truncate", title: error, children: error }))] }), _jsx("div", { className: "flex-1 overflow-y-auto", children: _jsxs("table", { className: "w-full border-collapse text-[12px]", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { className: "text-left text-code-label font-normal px-2.5 py-1 border-b border-border-subtle text-[11px] sticky top-0 bg-bg-panel z-10 w-px pr-5", children: "Key" }), _jsx("th", { className: "text-left text-code-label font-normal px-2.5 py-1 border-b border-border-subtle text-[11px] sticky top-0 bg-bg-panel z-10", children: "Value" }), _jsx("th", { className: "w-px sticky top-0 bg-bg-panel z-10 border-b border-border-subtle" })] }) }), _jsx("tbody", { children: items.length === 0
97
+ ? (_jsx("tr", { children: _jsx("td", { colSpan: 3, className: "text-[12px] text-text-dim text-center px-4 py-6", children: isRuntimeRunning ? '暂无 Storage 数据' : '小程序未运行' }) }))
98
+ : items.map((item) => {
99
+ const isEditing = editing?.key === item.key;
100
+ // Display keys without the active appId namespace prefix
101
+ // (`${appId}_`), the way Chrome's Local Storage panel shows
102
+ // clean keys. The full key (used for every read/write below)
103
+ // stays in the `title` for discoverability.
104
+ const displayKey = prefix && item.key.startsWith(prefix)
105
+ ? item.key.slice(prefix.length)
106
+ : item.key;
107
+ return (_jsxs("tr", { className: "hover:[&>td]:bg-surface", children: [_jsx("td", { className: "px-2.5 py-0.5 border-b border-border-subtle w-px pr-5 align-top", children: _jsx("div", { className: "font-mono text-code-blue max-w-[240px] truncate", title: item.key, children: displayKey }) }), _jsx("td", { className: "px-2.5 py-0.5 border-b border-border-subtle font-mono text-code-orange break-all align-top cursor-text", onClick: () => { if (!isEditing)
108
+ startEdit(item); }, children: isEditing
109
+ ? (_jsx("input", { autoFocus: true, type: "text", value: editing.draft, onChange: e => setEditing({ key: item.key, draft: e.target.value }), onBlur: () => void commitEdit(), onKeyDown: (e) => {
110
+ if (e.key === 'Enter') {
111
+ e.preventDefault();
112
+ void commitEdit();
113
+ }
114
+ else if (e.key === 'Escape') {
115
+ e.preventDefault();
116
+ setEditing(null);
117
+ }
118
+ }, className: "w-full bg-transparent outline-none border border-accent/60 rounded px-1 py-0 font-mono text-code-orange" }))
119
+ : item.value }), _jsx("td", { className: "px-1 py-0.5 border-b border-border-subtle align-top", children: _jsx("button", { type: "button", onClick: () => void handleRemove(item.key), disabled: busy, title: "\u5220\u9664", className: "text-text-dim hover:text-status-error px-1 leading-none", children: "\u00D7" }) })] }, item.key));
120
+ }) })] }) }), _jsxs("div", { className: "flex items-center gap-1.5 px-2.5 py-1.5 border-t border-border-subtle shrink-0 bg-bg-panel", children: [_jsx("input", { type: "text", placeholder: "key", value: addKey, onChange: e => setAddKey(e.target.value), onKeyDown: (e) => { if (e.key === 'Enter') {
121
+ e.preventDefault();
122
+ void handleAdd();
123
+ } }, className: "w-32 bg-transparent border border-border-subtle rounded px-1.5 py-0.5 text-[12px] font-mono outline-none focus:border-accent" }), _jsx("input", { type: "text", placeholder: "value", value: addValue, onChange: e => setAddValue(e.target.value), onKeyDown: (e) => { if (e.key === 'Enter') {
124
+ e.preventDefault();
125
+ void handleAdd();
126
+ } }, className: "flex-1 bg-transparent border border-border-subtle rounded px-1.5 py-0.5 text-[12px] font-mono outline-none focus:border-accent" }), _jsx("button", { type: "button", onClick: () => void handleAdd(), disabled: busy || !addKey.trim(), className: `${BUTTON_CLASS} hover:border-accent hover:text-accent`, children: "+ \u65B0\u589E" })] })] }));
127
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Applies one incremental StorageEvent to an item list, pure-function style
3
+ * (the input array is never mutated; a new array is always returned).
4
+ * `added` and `updated` are both tolerant upserts — host change feeds can
5
+ * deliver them out of order (an `updated` may arrive before its `added`, or
6
+ * an `added` may repeat for an existing key), and the reducer must converge
7
+ * on the same list either way instead of duplicating keys or dropping data.
8
+ */
9
+ export function applyStorageEvent(items, evt) {
10
+ switch (evt.type) {
11
+ case 'added':
12
+ case 'updated': {
13
+ const idx = items.findIndex(it => it.key === evt.key);
14
+ if (idx < 0)
15
+ return [...items, { key: evt.key, value: evt.newValue }];
16
+ const next = [...items];
17
+ next[idx] = { key: evt.key, value: evt.newValue };
18
+ return next;
19
+ }
20
+ case 'removed':
21
+ return items.filter(it => it.key !== evt.key);
22
+ case 'cleared':
23
+ return [];
24
+ }
25
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ // Wire-format types for mini-program Storage inspection. Hosts transport
2
+ // them over IPC, postMessage, or read them straight off a shared
3
+ // localStorage — the shapes are the contract, not the transport.
4
+ export {};
package/dist/types.js ADDED
@@ -0,0 +1,5 @@
1
+ // Wire-format types shared by every host that extracts or displays a WXML
2
+ // tree (Electron devtools, browser workbench panels, downstream hosts). They
3
+ // are the protocol layer: hosts may transport them over IPC, postMessage or
4
+ // any other channel, but the shapes themselves stay host-agnostic.
5
+ export {};