@dimina-kit/inspect 0.4.0-dev.20260716214527 → 0.4.0-dev.20260718085557

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 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), bridge-tab auto-follow of the active page (AppData), FIFO caps
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
- import * as jsonViewModule from '@uiw/react-json-view';
3
- const moduleDefault = jsonViewModule.default;
4
- // A type predicate (not a bare `in` check): plain `in` narrowing intersects
5
- // `Record<'default', unknown>` onto the component branch, degrading the
6
- // conditional's type to unknown.
7
- function isInteropNamespace(m) {
8
- return 'default' in m;
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
- // Map the json-view CSS vars onto the panel's existing design tokens so the
15
- // component blends with both dark and light themes. CSS variables defined
16
- // here cascade to the json-view's internal vars (--w-rjv-*) via inline style.
17
- const JSON_VIEW_STYLE = {
18
- '--w-rjv-font-family': 'var(--font-family-mono)',
19
- '--w-rjv-background-color': 'transparent',
20
- '--w-rjv-color': 'var(--color-code-blue)',
21
- '--w-rjv-key-string': 'var(--color-code-blue)',
22
- '--w-rjv-line-color': 'var(--color-border-subtle)',
23
- '--w-rjv-arrow-color': 'var(--color-text-secondary)',
24
- '--w-rjv-info-color': 'var(--color-code-label)',
25
- '--w-rjv-curlybraces-color': 'var(--color-text-secondary)',
26
- '--w-rjv-brackets-color': 'var(--color-text-secondary)',
27
- '--w-rjv-quotes-color': 'var(--color-code-orange)',
28
- '--w-rjv-quotes-string-color': 'var(--color-code-orange)',
29
- '--w-rjv-type-string-color': 'var(--color-code-orange)',
30
- '--w-rjv-type-int-color': 'var(--color-code-number)',
31
- '--w-rjv-type-float-color': 'var(--color-code-number)',
32
- '--w-rjv-type-bigint-color': 'var(--color-code-number)',
33
- '--w-rjv-type-boolean-color': 'var(--color-code-keyword)',
34
- '--w-rjv-type-null-color': 'var(--color-code-keyword)',
35
- '--w-rjv-type-nan-color': 'var(--color-code-keyword)',
36
- '--w-rjv-type-undefined-color': 'var(--color-code-keyword)',
37
- '--w-rjv-type-date-color': 'var(--color-code-label)',
38
- '--w-rjv-type-url-color': 'var(--color-code-blue)',
39
- fontSize: 12,
40
- padding: '6px 8px',
41
- wordBreak: 'break-all',
42
- whiteSpace: 'pre-wrap',
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
- return (_jsxs("div", { className: "flex flex-col overflow-hidden flex-1", "data-testid": "appdata-panel", children: [bridges.length > 1 && (_jsx("div", { className: "flex gap-1 px-2 py-1 border-b border-border-subtle shrink-0 overflow-x-auto bg-bg-panel", children: bridges.map((b) => {
47
- const isActive = b.id === activeBridgeId;
48
- return (_jsx("button", { onClick: () => onSelectBridge(b.id), title: b.id, className: 'shrink-0 px-2 py-0.5 text-[11px] rounded border transition-colors '
49
- + (isActive
50
- ? 'border-accent text-accent bg-surface-3'
51
- : 'border-border-subtle text-text-dim hover:border-accent hover:text-accent'), children: bridgeLabel(b) }, b.id));
52
- }) })), bridges.length === 0 ? (_jsx("div", { className: "text-[12px] text-text-dim text-center px-4 py-6", children: isRuntimeRunning ? '暂无页面数据(仅显示 Page 级 data)' : '小程序未运行' })) : (
53
- // A bridge with zero entries still gets its keepalive container (with
54
- // the per-bridge empty text) so live pushes land in a mounted tab.
55
- // Keepalive: render every bridge's entries; hide non-active ones via
56
- // `display: none` so the JsonView instances stay mounted and preserve
57
- // their expand/collapse state across tab switches.
58
- _jsx("div", { className: "flex-1 overflow-hidden relative", children: bridges.map((b) => {
59
- const isActive = b.id === activeBridgeId;
60
- const bridgeEntries = entries[b.id] ?? {};
61
- const keys = Object.keys(bridgeEntries);
62
- return (_jsx("div", { "data-bridge-id": b.id, className: "absolute inset-0 flex flex-col gap-2 p-2 overflow-y-auto", style: { display: isActive ? 'flex' : 'none' }, children: keys.length === 0 ? (_jsx("div", { className: "text-[12px] text-text-dim text-center px-4 py-6", children: isRuntimeRunning ? '暂无页面数据(仅显示 Page 级 data)' : '小程序未运行' })) : (keys.map((comp) => (_jsxs("div", { className: "border border-border-subtle rounded overflow-hidden shrink-0", children: [_jsx("div", { className: "bg-surface-3 px-2 py-0.5 text-[11px] text-code-label truncate", children: comp }), _jsx(JsonView, { value: (bridgeEntries[comp] ?? {}), collapsed: 1, displayDataTypes: false, displayObjectSize: false, enableClipboard: false, indentWidth: 12, style: JSON_VIEW_STYLE })] }, `${b.id}::${comp}`)))) }, b.id));
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
- return (_jsx(AppDataPanel, { state: { bridges: snapshot.bridges, activeBridgeId, entries: snapshot.entries }, onSelectBridge: setActiveBridge, isRuntimeRunning: isRuntimeRunning }));
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dimina-kit/inspect",
3
- "version": "0.4.0-dev.20260716214527",
3
+ "version": "0.4.0-dev.20260718085557",
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
+ })