@dimina-kit/inspect 0.4.0-dev.20260716163758 → 0.4.0-dev.20260717120050
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/dist/appdata-panel-view.js +155 -58
- package/dist/appdata-tree.js +143 -0
- package/dist/connected-appdata-panel.js +9 -1
- package/dist/panel-view.js +8 -3
- package/package.json +1 -1
- package/src/appdata-edge-values.test.tsx +150 -0
- package/src/appdata-panel-view-wechat.test.tsx +268 -0
- package/src/appdata-panel-view.tsx +237 -107
- package/src/appdata-source.ts +5 -0
- package/src/appdata-tree-edit-hardening.test.tsx +384 -0
- package/src/appdata-tree-edit.test.tsx +247 -0
- package/src/appdata-tree.tsx +265 -0
- package/src/appdata-write-serialization.test.tsx +134 -0
- package/src/connected-appdata-panel.test.tsx +23 -15
- package/src/connected-appdata-panel.tsx +10 -0
- package/src/panel-view-root-expand.test.tsx +53 -0
- package/src/panel-view.tsx +7 -3
package/README.md
CHANGED
|
@@ -44,13 +44,19 @@ wire-compatible.
|
|
|
44
44
|
their source contracts: seed on the (enabled && active) rising edge, live
|
|
45
45
|
updates via the push subscription, visibility gating, plus the
|
|
46
46
|
panel-specific parts — hover inspection (WXML), write forwarding
|
|
47
|
-
(Storage),
|
|
47
|
+
(Storage), Pages-sidebar auto-follow of the active page plus `setData`
|
|
48
|
+
edit write-back when the source provides `setData` (AppData), FIFO caps
|
|
48
49
|
and arrival-order `seq` stamping (编译). Hosts render them with their
|
|
49
50
|
source implementation and never duplicate the wiring.
|
|
50
51
|
- Styling uses Tailwind utility classes over CSS variables
|
|
51
52
|
(`--color-code-blue`, `--color-surface-2`, …); the consuming app provides
|
|
52
53
|
the Tailwind theme mapping and variable values, and must include this
|
|
53
54
|
package's sources in its Tailwind content scan.
|
|
55
|
+
- The panels fill their host: their roots use `flex-1` / `h-full`, and
|
|
56
|
+
AppData's kept-alive per-page trees are `absolute inset-0`. The host must
|
|
57
|
+
mount them inside a sized flex container (`height: 100%; display: flex;
|
|
58
|
+
flex-direction: column`) — in a plain unsized block the AppData content
|
|
59
|
+
collapses to zero height and the panel reads as blank.
|
|
54
60
|
- `WxmlPanelSource` (main entry, type-only) — the five-operation transport
|
|
55
61
|
contract behind the WXML panel: `getSnapshot` / `subscribe` / `setActive` /
|
|
56
62
|
`inspect` / `clearInspection`. Each host implements only how these travel
|
|
@@ -1,64 +1,161 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}
|
|
10
|
-
const JsonView = isInteropNamespace(moduleDefault) ? moduleDefault.default : moduleDefault;
|
|
2
|
+
// The pure AppData panel view, WeChat DevTools AppData layout: a Pages
|
|
3
|
+
// sidebar over one merged, collapsible (and optionally editable) data tree
|
|
4
|
+
// per page bridge, plus an expand/collapse/undo/redo toolbar. Pure
|
|
5
|
+
// presentation — bridge selection and data feeds live in the connected
|
|
6
|
+
// container (or the host, when it renders this view directly).
|
|
7
|
+
import { useRef, useState } from 'react';
|
|
8
|
+
import { AppDataTree } from './appdata-tree.js';
|
|
11
9
|
function bridgeLabel(bridge) {
|
|
12
10
|
return bridge.pagePath ?? bridge.id;
|
|
13
11
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
export function AppDataPanel({ state, onSelectBridge, isRuntimeRunning = true, }) {
|
|
12
|
+
function isThenable(v) {
|
|
13
|
+
return v != null && (typeof v === 'object' || typeof v === 'function')
|
|
14
|
+
&& typeof v.then === 'function';
|
|
15
|
+
}
|
|
16
|
+
/** One page's merged state: its component entries shallow-merged in insertion
|
|
17
|
+
* order (later entries win) — the single root object the tree renders. Copies
|
|
18
|
+
* via defineProperty, NOT Object.assign: an own enumerable `__proto__` data
|
|
19
|
+
* key (JSON.parse can produce one) would trigger the legacy setter under
|
|
20
|
+
* [[Set]], silently rebasing the merged object's prototype and dropping the
|
|
21
|
+
* key from the tree. */
|
|
22
|
+
function mergeEntries(bridgeEntries) {
|
|
23
|
+
const merged = {};
|
|
24
|
+
for (const key of Object.keys(bridgeEntries)) {
|
|
25
|
+
const data = bridgeEntries[key];
|
|
26
|
+
if (!data || typeof data !== 'object')
|
|
27
|
+
continue;
|
|
28
|
+
for (const dataKey of Object.keys(data)) {
|
|
29
|
+
Object.defineProperty(merged, dataKey, {
|
|
30
|
+
value: data[dataKey],
|
|
31
|
+
enumerable: true,
|
|
32
|
+
writable: true,
|
|
33
|
+
configurable: true,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return merged;
|
|
38
|
+
}
|
|
39
|
+
function ToolbarButton({ title, disabled, onClick, children }) {
|
|
40
|
+
return (_jsx("button", { title: title, disabled: disabled, onClick: onClick, className: "px-1.5 py-0.5 text-[12px] rounded text-text-secondary hover:bg-surface-3 hover:text-text-primary disabled:opacity-40 disabled:hover:bg-transparent", children: children }));
|
|
41
|
+
}
|
|
42
|
+
export function AppDataPanel({ state, onSelectBridge, isRuntimeRunning = true, onSetData, }) {
|
|
45
43
|
const { bridges, activeBridgeId, entries } = state;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
44
|
+
const [command, setCommand] = useState(null);
|
|
45
|
+
const [undoStack, setUndoStack] = useState([]);
|
|
46
|
+
const [redoStack, setRedoStack] = useState([]);
|
|
47
|
+
// The single write gate: at most ONE write (commit OR replay) is ever in
|
|
48
|
+
// flight. The ref blocks reentry synchronously — a concurrent replay would
|
|
49
|
+
// move the same record twice, a concurrent commit would settle out of
|
|
50
|
+
// action order (undo then reverts the wrong field) or resurrect a redo
|
|
51
|
+
// branch the commit just invalidated. The panel has no local echo, so a
|
|
52
|
+
// gated-away click is a clean no-op. The state mirror disables the
|
|
53
|
+
// undo/redo buttons while pending.
|
|
54
|
+
const writeInFlight = useRef(false);
|
|
55
|
+
const [writePending, setWritePending] = useState(false);
|
|
56
|
+
const emptyText = isRuntimeRunning ? '暂无页面数据(仅显示 Page 级 data)' : '小程序未运行';
|
|
57
|
+
const issueCommand = (mode) => {
|
|
58
|
+
if (!activeBridgeId)
|
|
59
|
+
return;
|
|
60
|
+
setCommand({ seq: (command?.seq ?? 0) + 1, mode, bridgeId: activeBridgeId });
|
|
61
|
+
};
|
|
62
|
+
/** Dispatch a write, then run `apply` with its acceptance: only an explicit
|
|
63
|
+
* `false` (sync or resolved) is a rejection — `void` keeps fire-and-forget
|
|
64
|
+
* hosts working. A synchronous result settles synchronously so the stacks
|
|
65
|
+
* (and their buttons) update in the same event turn as the click. A thrown
|
|
66
|
+
* error or rejected promise (IPC torn down mid-edit) settles as a rejection
|
|
67
|
+
* instead of escaping as an unhandled rejection. */
|
|
68
|
+
const dispatch = (bridgeId, patch, apply) => {
|
|
69
|
+
let result;
|
|
70
|
+
try {
|
|
71
|
+
result = onSetData?.(bridgeId, patch);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
apply(false);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// Duck-typed, not `instanceof Promise`: a cross-realm promise or plain
|
|
78
|
+
// thenable is still an ASYNC result — treating it as a sync truthy value
|
|
79
|
+
// would count it as an instant success and release the write gate early.
|
|
80
|
+
if (isThenable(result)) {
|
|
81
|
+
void Promise.resolve(result).then(v => v !== false, () => false).then(apply);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
apply(result !== false);
|
|
85
|
+
};
|
|
86
|
+
/** The only entry to `dispatch` — every write (commit and replay) passes
|
|
87
|
+
* through the gate so a second write can never start while one is pending. */
|
|
88
|
+
const performWrite = (bridgeId, patch, apply) => {
|
|
89
|
+
if (writeInFlight.current)
|
|
90
|
+
return;
|
|
91
|
+
writeInFlight.current = true;
|
|
92
|
+
setWritePending(true);
|
|
93
|
+
dispatch(bridgeId, patch, (ok) => {
|
|
94
|
+
writeInFlight.current = false;
|
|
95
|
+
setWritePending(false);
|
|
96
|
+
apply(ok);
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
const commitEdit = (bridgeId) => (path, next, prev) => {
|
|
100
|
+
performWrite(bridgeId, { [path]: next }, (ok) => {
|
|
101
|
+
if (!ok)
|
|
102
|
+
return;
|
|
103
|
+
setUndoStack(stack => [...stack, { bridgeId, path, before: prev, after: next }]);
|
|
104
|
+
setRedoStack([]);
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
const bridgeIsLive = (bridgeId) => bridges.some(b => b.id === bridgeId);
|
|
108
|
+
/** Replay one record in either direction through the shared write gate. */
|
|
109
|
+
const replay = (record, value, move) => {
|
|
110
|
+
performWrite(record.bridgeId, { [record.path]: value }, (ok) => {
|
|
111
|
+
// A rejected replay (runtime refused the write) leaves both stacks
|
|
112
|
+
// untouched so the UI state never claims an undo that didn't happen.
|
|
113
|
+
if (!ok)
|
|
114
|
+
return;
|
|
115
|
+
move(record);
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
const undo = () => {
|
|
119
|
+
const record = undoStack.at(-1);
|
|
120
|
+
if (!record)
|
|
121
|
+
return;
|
|
122
|
+
if (!bridgeIsLive(record.bridgeId)) {
|
|
123
|
+
// The page this edit targeted is gone — replaying it can only write into
|
|
124
|
+
// the void. Drop the record without dispatching.
|
|
125
|
+
setUndoStack(stack => stack.filter(r => r !== record));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
replay(record, record.before, (r) => {
|
|
129
|
+
setUndoStack(stack => stack.filter(item => item !== r));
|
|
130
|
+
setRedoStack(stack => [...stack, r]);
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
const redo = () => {
|
|
134
|
+
const record = redoStack.at(-1);
|
|
135
|
+
if (!record)
|
|
136
|
+
return;
|
|
137
|
+
if (!bridgeIsLive(record.bridgeId)) {
|
|
138
|
+
setRedoStack(stack => stack.filter(r => r !== record));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
replay(record, record.after, (r) => {
|
|
142
|
+
setRedoStack(stack => stack.filter(item => item !== r));
|
|
143
|
+
setUndoStack(stack => [...stack, r]);
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
if (bridges.length === 0) {
|
|
147
|
+
return (_jsx("div", { className: "flex flex-col overflow-hidden flex-1", "data-testid": "appdata-panel", children: _jsx("div", { className: "text-[12px] text-text-dim text-center px-4 py-6", children: emptyText }) }));
|
|
148
|
+
}
|
|
149
|
+
return (_jsxs("div", { className: "flex overflow-hidden flex-1", "data-testid": "appdata-panel", children: [_jsxs("div", { "data-testid": "appdata-pages", className: "w-40 shrink-0 border-r border-border-subtle bg-bg-panel flex flex-col overflow-hidden", children: [_jsx("div", { className: "px-2 py-1 text-[11px] text-text-secondary border-b border-border-subtle shrink-0", children: "Pages" }), _jsx("div", { className: "flex-1 overflow-y-auto", role: "listbox", children: bridges.map((b) => {
|
|
150
|
+
const isActive = b.id === activeBridgeId;
|
|
151
|
+
return (_jsx("button", { "data-testid": "appdata-page-item", role: "option", "aria-selected": isActive, title: b.id, onClick: () => onSelectBridge(b.id), className: 'block w-full text-left px-2 py-1 text-[11px] truncate '
|
|
152
|
+
+ (isActive ? 'bg-accent/20 text-accent' : 'text-text-dim hover:bg-surface-3'), children: bridgeLabel(b) }, b.id));
|
|
153
|
+
}) })] }), _jsxs("div", { className: "flex-1 flex flex-col overflow-hidden", children: [_jsxs("div", { "data-testid": "appdata-toolbar", className: "flex items-center gap-1 px-2 py-0.5 border-b border-border-subtle shrink-0 bg-bg-panel", children: [_jsx(ToolbarButton, { title: "\u5168\u90E8\u5C55\u5F00", onClick: () => issueCommand('expanded'), children: "\u2295" }), _jsx(ToolbarButton, { title: "\u5168\u90E8\u6536\u8D77", onClick: () => issueCommand('collapsed'), children: "\u2296" }), _jsx(ToolbarButton, { title: "\u64A4\u9500", disabled: undoStack.length === 0 || writePending, onClick: undo, children: "\u21B6" }), _jsx(ToolbarButton, { title: "\u91CD\u505A", disabled: redoStack.length === 0 || writePending, onClick: redo, children: "\u21B7" })] }), _jsx("div", { className: "flex-1 overflow-hidden relative", children: bridges.map((b) => {
|
|
154
|
+
const isActive = b.id === activeBridgeId;
|
|
155
|
+
const bridgeEntries = entries[b.id] ?? {};
|
|
156
|
+
const hasData = Object.keys(bridgeEntries).length > 0;
|
|
157
|
+
return (_jsx("div", { "data-bridge-id": b.id, className: "absolute inset-0 flex-col overflow-y-auto", style: { display: isActive ? 'flex' : 'none' }, children: hasData
|
|
158
|
+
? (_jsx(AppDataTree, { root: mergeEntries(bridgeEntries), bridgeId: b.id, command: command, onCommit: onSetData ? commitEdit(b.id) : undefined }))
|
|
159
|
+
: (_jsx("div", { className: "text-[12px] text-text-dim text-center px-4 py-6", children: emptyText })) }, b.id));
|
|
160
|
+
}) })] })] }));
|
|
64
161
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// The AppData tree: one page's merged setData state as a collapsible,
|
|
3
|
+
// optionally editable tree (WeChat DevTools AppData semantics). Pure
|
|
4
|
+
// presentation + local expansion/edit state; committed edits are reported
|
|
5
|
+
// upward as (path, next, prev) — the panel owns the undo/redo stack and the
|
|
6
|
+
// actual write-back, and the rendered VALUES always come from `root`, never
|
|
7
|
+
// from a local echo of an edit.
|
|
8
|
+
import { useState } from 'react';
|
|
9
|
+
/** WeChat setData path syntax: dots between keys, indices in brackets. */
|
|
10
|
+
function childPath(parent, key) {
|
|
11
|
+
if (typeof key === 'number')
|
|
12
|
+
return `${parent}[${key}]`;
|
|
13
|
+
return parent === '' ? key : `${parent}.${key}`;
|
|
14
|
+
}
|
|
15
|
+
function isContainer(v) {
|
|
16
|
+
return typeof v === 'object' && v !== null;
|
|
17
|
+
}
|
|
18
|
+
function sortedEntries(value) {
|
|
19
|
+
// Array.isArray narrows to any[]; the explicit unknown[] view keeps the
|
|
20
|
+
// elements typed (type-coverage counts every any-typed identifier).
|
|
21
|
+
if (Array.isArray(value))
|
|
22
|
+
return value.map((v, i) => [i, v]);
|
|
23
|
+
return Object.keys(value).sort().map((k) => [k, value[k]]);
|
|
24
|
+
}
|
|
25
|
+
function countLabel(value) {
|
|
26
|
+
return Array.isArray(value) ? `[${value.length}]` : `{${Object.keys(value).length}}`;
|
|
27
|
+
}
|
|
28
|
+
const ROOT_PATH = '';
|
|
29
|
+
function isExpanded(state, path) {
|
|
30
|
+
const override = state.overrides.get(path);
|
|
31
|
+
if (override !== undefined)
|
|
32
|
+
return override;
|
|
33
|
+
if (state.baseline === 'expanded')
|
|
34
|
+
return true;
|
|
35
|
+
if (state.baseline === 'collapsed')
|
|
36
|
+
return false;
|
|
37
|
+
return path === ROOT_PATH;
|
|
38
|
+
}
|
|
39
|
+
function ValueCell({ path, value, editable, ctx }) {
|
|
40
|
+
if (typeof value === 'boolean') {
|
|
41
|
+
return (_jsxs("span", { className: "inline-flex items-center gap-1 text-code-keyword", children: [editable && (_jsx("input", { type: "checkbox", checked: value, onChange: () => ctx.onCommit?.(path, !value, value), className: "accent-accent" })), String(value)] }));
|
|
42
|
+
}
|
|
43
|
+
if (value === null || value === undefined) {
|
|
44
|
+
return _jsx("span", { className: "text-code-keyword", children: String(value) });
|
|
45
|
+
}
|
|
46
|
+
if (ctx.state.editingPath === path) {
|
|
47
|
+
const commit = () => {
|
|
48
|
+
const prev = value;
|
|
49
|
+
if (typeof prev === 'number') {
|
|
50
|
+
const draft = ctx.state.draft.trim();
|
|
51
|
+
const next = Number(draft);
|
|
52
|
+
ctx.endEdit();
|
|
53
|
+
// Only finite numbers commit: Infinity/-Infinity (and 1e309-style
|
|
54
|
+
// overflow) are not serializable AppData values.
|
|
55
|
+
if (draft === '' || !Number.isFinite(next))
|
|
56
|
+
return;
|
|
57
|
+
ctx.onCommit?.(path, next, prev);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const next = ctx.state.draft;
|
|
61
|
+
ctx.endEdit();
|
|
62
|
+
ctx.onCommit?.(path, next, prev);
|
|
63
|
+
};
|
|
64
|
+
return (_jsx("input", { type: "text", autoFocus: true, value: ctx.state.draft, onChange: (e) => ctx.setDraft(e.target.value), onKeyDown: (e) => {
|
|
65
|
+
if (e.key === 'Enter')
|
|
66
|
+
commit();
|
|
67
|
+
else if (e.key === 'Escape')
|
|
68
|
+
ctx.endEdit();
|
|
69
|
+
}, onBlur: commit, className: "bg-surface-3 border border-accent rounded px-1 text-[12px] font-mono min-w-0 w-40" }));
|
|
70
|
+
}
|
|
71
|
+
const color = typeof value === 'number'
|
|
72
|
+
? { color: 'var(--color-code-number)' }
|
|
73
|
+
: undefined;
|
|
74
|
+
return (_jsx("span", { "data-testid": "appdata-value", className: typeof value === 'string' ? 'text-code-orange' : undefined, style: color, onDoubleClick: editable ? () => ctx.beginEdit(path, String(value)) : undefined, children: String(value) }));
|
|
75
|
+
}
|
|
76
|
+
/** True when a key segment would be re-parsed by the runtime's lodash-style
|
|
77
|
+
* `toPath` (dots / brackets), or silently DROPPED by it (empty keys — toPath
|
|
78
|
+
* never pushes an empty segment, so `profile.` parses to just `['profile']`
|
|
79
|
+
* and a write would overwrite the parent). Array indices are numbers and
|
|
80
|
+
* always safe. */
|
|
81
|
+
function segmentUnsafe(key) {
|
|
82
|
+
return typeof key === 'string' && (key === '' || /[.[\]]/.test(key));
|
|
83
|
+
}
|
|
84
|
+
function TreeNode({ path, label, value, depth, unsafeSegments, ctx }) {
|
|
85
|
+
const indent = { paddingLeft: depth * 14 };
|
|
86
|
+
if (isContainer(value)) {
|
|
87
|
+
const open = isExpanded(ctx.state, path);
|
|
88
|
+
return (_jsxs("div", { children: [_jsxs("div", { className: "flex items-center gap-1 px-2 py-px cursor-pointer hover:bg-surface-3 text-[12px] font-mono", style: indent, onClick: () => ctx.toggle(path, value), children: [_jsx("span", { className: "text-text-secondary w-3 shrink-0 text-center select-none", children: open ? '▾' : '▸' }), _jsx("span", { className: "text-code-blue", children: label }), _jsx("span", { className: "text-text-secondary", children: countLabel(value) })] }), open && sortedEntries(value).map(([key, child]) => (_jsx(TreeNode, { path: childPath(path, key), label: String(key), value: child, depth: depth + 1, unsafeSegments: unsafeSegments || segmentUnsafe(key), ctx: ctx }, String(key))))] }));
|
|
89
|
+
}
|
|
90
|
+
const primitive = typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
|
|
91
|
+
// A multi-segment path with an unsafe segment cannot round-trip through the
|
|
92
|
+
// runtime's string-path `set()` — the patch key would be re-split on the
|
|
93
|
+
// dots/brackets inside the key and write a DIFFERENT field. A single-segment
|
|
94
|
+
// (top-level) key is safe regardless of content: the runtime's own-key check
|
|
95
|
+
// short-circuits before path parsing. `__proto__` is unwritable at ANY
|
|
96
|
+
// depth: the runtime's isUnsafeProperty drops the write outright. Unsafe
|
|
97
|
+
// rows render read-only.
|
|
98
|
+
const pathAmbiguous = (depth > 1 && unsafeSegments) || label === '__proto__';
|
|
99
|
+
const editable = primitive && ctx.onCommit !== undefined && !pathAmbiguous;
|
|
100
|
+
return (_jsxs("div", { className: "flex items-center gap-1 px-2 py-px text-[12px] font-mono", style: indent, ...(editable ? { 'data-path': path } : {}), children: [_jsx("span", { className: "w-3 shrink-0" }), _jsx("span", { className: "text-code-blue", children: label }), _jsx("span", { className: "text-text-secondary", children: ":" }), _jsx(ValueCell, { path: path, value: value, editable: editable, ctx: ctx })] }));
|
|
101
|
+
}
|
|
102
|
+
export function AppDataTree({ root, bridgeId, command, onCommit }) {
|
|
103
|
+
const [baseline, setBaseline] = useState('default');
|
|
104
|
+
const [overrides, setOverrides] = useState(new Map());
|
|
105
|
+
const [appliedSeq, setAppliedSeq] = useState(0);
|
|
106
|
+
const [editingPath, setEditingPath] = useState(null);
|
|
107
|
+
const [draft, setDraft] = useState('');
|
|
108
|
+
// Applying a toolbar command during render (state-adjust-in-render, same
|
|
109
|
+
// pattern as useActiveBridgeId) keeps the frame it lands on consistent.
|
|
110
|
+
if (command && command.bridgeId === bridgeId && command.seq !== appliedSeq) {
|
|
111
|
+
setBaseline(command.mode);
|
|
112
|
+
setOverrides(new Map());
|
|
113
|
+
setAppliedSeq(command.seq);
|
|
114
|
+
}
|
|
115
|
+
const state = { baseline, overrides, editingPath, draft };
|
|
116
|
+
const ctx = {
|
|
117
|
+
state,
|
|
118
|
+
toggle: (path, value) => {
|
|
119
|
+
const next = new Map(overrides);
|
|
120
|
+
const opening = !isExpanded(state, path);
|
|
121
|
+
next.set(path, opening);
|
|
122
|
+
// Opening an array also opens its container elements: the elements are
|
|
123
|
+
// anonymous index rows, so surfacing their fields in the same click is
|
|
124
|
+
// what makes `list[0].id` reachable without a second dig.
|
|
125
|
+
if (opening && Array.isArray(value)) {
|
|
126
|
+
;
|
|
127
|
+
value.forEach((element, i) => {
|
|
128
|
+
if (isContainer(element))
|
|
129
|
+
next.set(childPath(path, i), true);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
setOverrides(next);
|
|
133
|
+
},
|
|
134
|
+
beginEdit: (path, initial) => {
|
|
135
|
+
setEditingPath(path);
|
|
136
|
+
setDraft(initial);
|
|
137
|
+
},
|
|
138
|
+
setDraft,
|
|
139
|
+
endEdit: () => setEditingPath(null),
|
|
140
|
+
onCommit,
|
|
141
|
+
};
|
|
142
|
+
return (_jsx("div", { "data-testid": "appdata-tree", className: "py-1", children: _jsx(TreeNode, { path: ROOT_PATH, label: "object", value: root, depth: 0, unsafeSegments: false, ctx: ctx }) }));
|
|
143
|
+
}
|
|
@@ -28,5 +28,13 @@ export function ConnectedAppDataPanel({ source, active = true, enabled = true, i
|
|
|
28
28
|
},
|
|
29
29
|
});
|
|
30
30
|
const { activeBridgeId, setActiveBridge } = useActiveBridgeId(snapshot.bridges, activePagePath);
|
|
31
|
-
|
|
31
|
+
// Only a source with a write-back channel makes the tree editable. The
|
|
32
|
+
// dispatch result flows through to the panel: its undo/redo stacks only
|
|
33
|
+
// advance when the runtime actually accepted the write. The authoritative
|
|
34
|
+
// new VALUE still arrives back through the snapshot push, never locally.
|
|
35
|
+
const setData = source.setData?.bind(source);
|
|
36
|
+
const onSetData = setData
|
|
37
|
+
? (bridgeId, patch) => setData(bridgeId, patch)
|
|
38
|
+
: undefined;
|
|
39
|
+
return (_jsx(AppDataPanel, { state: { bridges: snapshot.bridges, activeBridgeId, entries: snapshot.entries }, onSelectBridge: setActiveBridge, isRuntimeRunning: isRuntimeRunning, onSetData: onSetData }));
|
|
32
40
|
}
|
package/dist/panel-view.js
CHANGED
|
@@ -6,7 +6,12 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
6
6
|
* - 路径式 tag(含 `/`)= 页面或自定义组件,默认展开(让用户一眼看清组件层级)
|
|
7
7
|
* - 普通 DOM 节点(view/text 等)默认折叠,需要用户手动点开
|
|
8
8
|
*/
|
|
9
|
-
function isDefaultExpanded(node) {
|
|
9
|
+
function isDefaultExpanded(node, depth) {
|
|
10
|
+
// The root always opens expanded: collapsed, it hides the entire tree
|
|
11
|
+
// behind one ▸ row and the panel reads as blank — regardless of what tag
|
|
12
|
+
// shape the runtime gives the root (plain `page`, a component path, …).
|
|
13
|
+
if (depth === 0)
|
|
14
|
+
return true;
|
|
10
15
|
if (node.tagName === '#shadow-root')
|
|
11
16
|
return true;
|
|
12
17
|
if (node.tagName.includes('/'))
|
|
@@ -28,7 +33,7 @@ function WxmlFragmentNode({ node, depth, inspectedSid, onInspect }) {
|
|
|
28
33
|
// Shadow root — synthetic boundary for custom component internals.
|
|
29
34
|
// Clickable to collapse like WeChat DevTools; default expanded; no closing tag.
|
|
30
35
|
function WxmlShadowRootNode({ node, depth, inspectedSid, onInspect }) {
|
|
31
|
-
const [expanded, setExpanded] = useState(() => isDefaultExpanded(node));
|
|
36
|
+
const [expanded, setExpanded] = useState(() => isDefaultExpanded(node, depth));
|
|
32
37
|
const indent = depth * 16;
|
|
33
38
|
const hasShadowChildren = (node.children ?? []).length > 0;
|
|
34
39
|
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)))] }));
|
|
@@ -47,7 +52,7 @@ function WxmlBlockRow({ node, depth, indent, attrEntries, hasChildren, expanded,
|
|
|
47
52
|
}
|
|
48
53
|
// Ordinary element node — decides between the inline-text and block layouts.
|
|
49
54
|
function WxmlElementNode({ node, depth, inspectedSid, onInspect }) {
|
|
50
|
-
const [expanded, setExpanded] = useState(() => isDefaultExpanded(node));
|
|
55
|
+
const [expanded, setExpanded] = useState(() => isDefaultExpanded(node, depth));
|
|
51
56
|
const indent = depth * 16;
|
|
52
57
|
const isInspected = Boolean(node.sid && node.sid === inspectedSid);
|
|
53
58
|
const hasChildren = (node.children ?? []).length > 0;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dimina-kit/inspect",
|
|
3
|
-
"version": "0.4.0-dev.
|
|
3
|
+
"version": "0.4.0-dev.20260717120050",
|
|
4
4
|
"description": "Host-agnostic WXML tree extraction and inspection for dimina render layers: Vue runtime walk, stable-id registry, mutation-observing inspector, and a React tree panel.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dimina",
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AppDataPanel edge-value contracts:
|
|
3
|
+
*
|
|
4
|
+
* 1. A write result that is a thenable but not a native `Promise` instance
|
|
5
|
+
* (a cross-realm promise, a polyfill, any spec-conforming thenable) still
|
|
6
|
+
* settles asynchronously through its own `then` — it is never treated as
|
|
7
|
+
* an immediate synchronous success just because it is a truthy, non-`false`
|
|
8
|
+
* object. The write gate stays held until the thenable actually resolves.
|
|
9
|
+
* 2. An own `__proto__` data key in a setData payload (as produced by
|
|
10
|
+
* `JSON.parse`, which creates it as a normal own data property, not a
|
|
11
|
+
* prototype rewire) renders as an ordinary tree row and never reaches the
|
|
12
|
+
* merged object through a path that would trigger the legacy `__proto__`
|
|
13
|
+
* accessor and rewire the merged object's prototype.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
16
|
+
import { fireEvent, render, waitFor, within } from '@testing-library/react'
|
|
17
|
+
import { AppDataPanel, type AppDataPanelState as AppDataState } from './appdata-panel-view.js'
|
|
18
|
+
|
|
19
|
+
function writableState(): AppDataState {
|
|
20
|
+
return {
|
|
21
|
+
bridges: [{ id: 'b1', pagePath: '/pages/index/index' }],
|
|
22
|
+
activeBridgeId: 'b1',
|
|
23
|
+
entries: {
|
|
24
|
+
b1: {
|
|
25
|
+
comp: {
|
|
26
|
+
flag: true,
|
|
27
|
+
label: 'hello',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function checkboxFor(container: HTMLElement, path: string): HTMLInputElement {
|
|
35
|
+
const row = container.querySelector(`[data-path="${path}"]`) as HTMLElement
|
|
36
|
+
return row.querySelector('input[type="checkbox"]') as HTMLInputElement
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function valueElFor(container: HTMLElement, path: string): HTMLElement {
|
|
40
|
+
const row = container.querySelector(`[data-path="${path}"]`) as HTMLElement
|
|
41
|
+
return within(row).getByTestId('appdata-value')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Double-click a string/number row, replace its text, and commit on Enter. */
|
|
45
|
+
function commitText(container: HTMLElement, path: string, value: string): void {
|
|
46
|
+
fireEvent.dblClick(valueElFor(container, path))
|
|
47
|
+
const input = within(container).getByRole('textbox') as HTMLInputElement
|
|
48
|
+
fireEvent.change(input, { target: { value } })
|
|
49
|
+
fireEvent.keyDown(input, { key: 'Enter' })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A thenable that is deliberately not a `Promise` instance — same shape a
|
|
53
|
+
* cross-realm promise or a promise polyfill returns. `settled` flips only
|
|
54
|
+
* once the thenable's own `then` callback actually runs (a real macrotask
|
|
55
|
+
* later), so tests can wait for genuine settlement instead of guessing at a
|
|
56
|
+
* delay. */
|
|
57
|
+
function fakeThenable(resolveWith: boolean): { thenable: { then: (res: (v: boolean) => void) => void }; state: { settled: boolean } } {
|
|
58
|
+
const state = { settled: false }
|
|
59
|
+
const thenable = {
|
|
60
|
+
then(res: (v: boolean) => void) {
|
|
61
|
+
setTimeout(() => {
|
|
62
|
+
state.settled = true
|
|
63
|
+
res(resolveWith)
|
|
64
|
+
}, 0)
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
return { thenable, state }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('AppDataPanel: a thenable write result settles asynchronously, never as a same-tick success', () => {
|
|
71
|
+
it('keeps the commit off the undo stack until the thenable actually resolves, and rejects it once it does', async () => {
|
|
72
|
+
const { thenable, state } = fakeThenable(false)
|
|
73
|
+
expect(thenable).not.toBeInstanceOf(Promise)
|
|
74
|
+
const onSetData = vi.fn(() => thenable as unknown as Promise<boolean>)
|
|
75
|
+
const { container, getByTitle } = render(
|
|
76
|
+
<AppDataPanel state={writableState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
fireEvent.click(checkboxFor(container, 'flag'))
|
|
80
|
+
expect(onSetData).toHaveBeenCalledTimes(1)
|
|
81
|
+
// A truthy, non-`false` thenable is not a synchronous success — the gate
|
|
82
|
+
// must still be held in the same tick as the click that fired it.
|
|
83
|
+
expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(true)
|
|
84
|
+
|
|
85
|
+
await waitFor(() => expect(state.settled).toBe(true))
|
|
86
|
+
// The thenable resolved to `false` (a rejection) — the record never
|
|
87
|
+
// lands on the undo stack, even after real settlement.
|
|
88
|
+
expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(true)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('drops a second edit fired while the first thenable write is still pending', async () => {
|
|
92
|
+
const { thenable, state } = fakeThenable(true)
|
|
93
|
+
const onSetData = vi.fn(() => thenable as unknown as Promise<boolean>)
|
|
94
|
+
const { container, getByTitle } = render(
|
|
95
|
+
<AppDataPanel state={writableState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
fireEvent.click(checkboxFor(container, 'flag'))
|
|
99
|
+
expect(onSetData).toHaveBeenCalledTimes(1)
|
|
100
|
+
expect(state.settled).toBe(false)
|
|
101
|
+
|
|
102
|
+
// The write gate must still be held here — the first thenable has not
|
|
103
|
+
// resolved yet, so this edit has to be dropped outright, never queued.
|
|
104
|
+
commitText(container, 'label', 'ignored')
|
|
105
|
+
expect(onSetData).toHaveBeenCalledTimes(1)
|
|
106
|
+
|
|
107
|
+
await waitFor(() => expect(state.settled).toBe(true))
|
|
108
|
+
await waitFor(() => expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(false))
|
|
109
|
+
// Settlement only ever admitted the first write — the dropped edit never
|
|
110
|
+
// reached onSetData, not even after the gate freed up.
|
|
111
|
+
expect(onSetData).toHaveBeenCalledTimes(1)
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('AppDataPanel: an own `__proto__` data key renders as a plain, read-only row', () => {
|
|
116
|
+
it('does not let the nested key leak onto the merged tree, keeps the sibling key intact, and keeps `__proto__` itself visible and read-only', () => {
|
|
117
|
+
const data = JSON.parse('{"__proto__":{"hidden":1},"safe":2}') as Record<string, unknown>
|
|
118
|
+
// JSON.parse creates `__proto__` as a normal own data property (per spec,
|
|
119
|
+
// via CreateDataProperty) — this is not a prototype rewire of `data`
|
|
120
|
+
// itself, only a risk if something later assigns `data` into another
|
|
121
|
+
// object through the legacy `[[Set]]` path (e.g. `Object.assign`).
|
|
122
|
+
expect(Object.getPrototypeOf(data)).toBe(Object.prototype)
|
|
123
|
+
expect(Object.keys(data).sort()).toEqual(['__proto__', 'safe'])
|
|
124
|
+
|
|
125
|
+
const state: AppDataState = {
|
|
126
|
+
bridges: [{ id: 'b1', pagePath: '/pages/index/index' }],
|
|
127
|
+
activeBridgeId: 'b1',
|
|
128
|
+
entries: { b1: { comp: data } },
|
|
129
|
+
}
|
|
130
|
+
const { container, queryByText } = render(
|
|
131
|
+
<AppDataPanel state={state} onSelectBridge={vi.fn()} onSetData={vi.fn(() => true)} />,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
// The nested key never surfaces as a row of its own — a merge that runs
|
|
135
|
+
// `__proto__` through the legacy setter would rewire the merged object's
|
|
136
|
+
// prototype and make `hidden` an inherited (not own) property that no
|
|
137
|
+
// row is ever built for.
|
|
138
|
+
expect(queryByText('hidden')).toBeNull()
|
|
139
|
+
// The sibling key merges normally regardless of what happens to
|
|
140
|
+
// `__proto__` alongside it.
|
|
141
|
+
expect(valueElFor(container, 'safe').textContent).toBe('2')
|
|
142
|
+
|
|
143
|
+
// `__proto__` itself is real, visible data — a prototype-rewiring merge
|
|
144
|
+
// makes it vanish as an own key entirely, so this is the row that would
|
|
145
|
+
// never render on the unfixed merge.
|
|
146
|
+
expect(queryByText('__proto__')).not.toBeNull()
|
|
147
|
+
// And it stays read-only: no row for it may ever carry a write path.
|
|
148
|
+
expect(container.querySelector('[data-path="__proto__"]')).toBeNull()
|
|
149
|
+
})
|
|
150
|
+
})
|