@dimina-kit/inspect 0.3.0-dev.20260711141929 → 0.4.0-dev.20260716153350

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
@@ -1,15 +1,18 @@
1
1
  # @dimina-kit/inspect
2
2
 
3
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.
4
+ extraction, Storage inspection, AppData (page `setData` state) and the 编译
5
+ (compile) timeline. One package owns the protocol types, the pure logic
6
+ (Vue-runtime walk, stable-id registry, mutation-observing inspector,
7
+ storage-event reduction, setData accumulation), the React panels, and the
8
+ panels' data wiring so the Electron devtools and any downstream host
9
+ (browser workbench, preview iframe) share a single implementation and stay
10
+ wire-compatible.
9
11
 
10
12
  ## Entry points
11
13
 
12
- - `@dimina-kit/inspect` — core, zero runtime dependencies:
14
+ - `@dimina-kit/inspect` — core (no runtime dependencies; the JSON tree viewer
15
+ is pulled in by the `/panel` entry only):
13
16
  - `WxmlNode` / `ElementInspection` — the wire-format types. Hosts transport
14
17
  them over IPC, `postMessage` or anything else.
15
18
  - `walkInstance(instance, depth)` — walks a mounted dimina render-layer Vue
@@ -24,14 +27,26 @@ preview iframe) share a single implementation and stay wire-compatible.
24
27
  - `StorageItem` / `StorageEvent` / `StorageWriteResult` — the Storage
25
28
  wire-format types, plus `applyStorageEvent(items, evt)`, the pure
26
29
  reducer that folds a change feed into an item list.
30
+ - `AppDataAccumulator` + `decodeWorkerMessage` / `decodeOutgoingMessage` /
31
+ `decodedToInput` — the cumulative per-(bridge, module) `setData` state
32
+ behind the AppData panel. Hosts tap the dimina service→render message
33
+ stream wherever they can reach it (an Electron preload sniffing Worker
34
+ `message` events, a same-origin workbench observing the pageFrame's
35
+ Worker) and feed this one accumulator, so the decode/merge/page-only
36
+ policy can't drift between hosts. `AppDataSnapshot` is the wire format.
37
+ - `CompileEvent` / `CompileLogEntry` — the 编译 panel's two feed item
38
+ shapes (status transitions and per-line compiler output).
27
39
  - `@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.
40
+ - `WxmlPanel` / `StoragePanel` / `AppDataPanel` / `CompilePanel` the pure
41
+ views (props in, no data wiring).
42
+ - `ConnectedWxmlPanel` / `ConnectedStoragePanel` / `ConnectedAppDataPanel` /
43
+ `ConnectedCompilePanel` the panels' data wiring, written once against
44
+ their source contracts: seed on the (enabled && active) rising edge, live
45
+ updates via the push subscription, visibility gating, plus the
46
+ panel-specific parts — hover inspection (WXML), write forwarding
47
+ (Storage), bridge-tab auto-follow of the active page (AppData), FIFO caps
48
+ and arrival-order `seq` stamping (编译). Hosts render them with their
49
+ source implementation and never duplicate the wiring.
35
50
  - Styling uses Tailwind utility classes over CSS variables
36
51
  (`--color-code-blue`, `--color-surface-2`, …); the consuming app provides
37
52
  the Tailwind theme mapping and variable values, and must include this
@@ -45,6 +60,15 @@ preview iframe) share a single implementation and stay wire-compatible.
45
60
  `clear` / `clearAll?` / `getPrefix`. `clearAll` is optional — hosts whose
46
61
  storage partition is shared with non-mini-program data must omit it, and
47
62
  the panel then hides the origin-wide wipe entirely.
63
+ - `AppDataPanelSource` (main entry, type-only) — the AppData counterpart:
64
+ `getSnapshot` / `subscribe` / `setActive`. Pushes carry the FULL cumulative
65
+ `AppDataSnapshot` (merging patches is the producer-side accumulator's job).
66
+ - `CompilePanelSource` (main entry, type-only) — the 编译 counterpart:
67
+ `getSnapshot` / `subscribe` / `setActive` / `clear?`. The subscription
68
+ pushes `CompileFeedEvent`s (`event` / `log` appends or a host-side
69
+ `reset`); the connected panel owns the FIFO caps (200 events / 300 logs)
70
+ and stamps a shared monotonic `seq` onto unstamped arrivals so same-`at`
71
+ ties keep arrival order in the merged timeline.
48
72
 
49
73
  ## Contract notes
50
74
 
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Framework-agnostic AppData accumulator.
3
+ *
4
+ * The dimina runtime emits two service→render messages relevant to the AppData
5
+ * panel, identical in shape on BOTH container paths:
6
+ * • update batches `{type:'ub', body:{bridgeId, updates:[{moduleId,data}]}}`
7
+ * — each `data` is a partial setData patch.
8
+ * • instance init: `type === 'page_*'`, body `{bridgeId, path, data}` with the
9
+ * COMPLETE initial state.
10
+ *
11
+ * Hosts differ only in where they tap that stream — an Electron preload
12
+ * sniffing Worker `message` events, a main-process service→render forward, or
13
+ * a same-origin web workbench observing the pageFrame's Worker. Every tap
14
+ * feeds this one accumulator so the decode/merge/page-only/init-gate policy
15
+ * can't drift between hosts.
16
+ */
17
+ /** Parse a service/worker message payload; a non-string is passed through as-is. */
18
+ function parseMessagePayload(message) {
19
+ if (typeof message !== 'string')
20
+ return message;
21
+ try {
22
+ return JSON.parse(message);
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ /**
29
+ * Decode a `ub` (update batch) body into patch entries, PAGE modules only —
30
+ * component-module updates are dropped (see `decodeWorkerMessage`).
31
+ */
32
+ function decodeUpdateBatchBody(rawBody) {
33
+ const body = rawBody;
34
+ if (!body || typeof body !== 'object')
35
+ return null;
36
+ if (typeof body.bridgeId !== 'string' || !Array.isArray(body.updates))
37
+ return null;
38
+ const out = [];
39
+ for (const u of body.updates) {
40
+ if (!u || typeof u.moduleId !== 'string')
41
+ continue;
42
+ if (!u.moduleId.startsWith('page_'))
43
+ continue;
44
+ out.push({ mode: 'patch', bridgeId: body.bridgeId, moduleId: u.moduleId, data: u.data });
45
+ }
46
+ return out.length > 0 ? out : null;
47
+ }
48
+ /** Decode a `page_*` instance-init body into its single init entry. */
49
+ function decodePageInitBody(moduleId, rawBody) {
50
+ const body = rawBody;
51
+ if (!body || typeof body !== 'object')
52
+ return null;
53
+ if (typeof body.bridgeId !== 'string' || typeof body.path !== 'string')
54
+ return null;
55
+ if (!body.data || typeof body.data !== 'object')
56
+ return null;
57
+ return [{
58
+ mode: 'init',
59
+ bridgeId: body.bridgeId,
60
+ moduleId,
61
+ componentPath: body.path,
62
+ data: body.data,
63
+ }];
64
+ }
65
+ /**
66
+ * Decode a service→render message into AppData entries, or null when it is not
67
+ * AppData-relevant. Policy: surface PAGE entries only — component entries are
68
+ * dropped (they sometimes flow on a bridge id distinct from their owning page's
69
+ * and never receive pageUnload, which would manifest as ghost tabs).
70
+ */
71
+ export function decodeWorkerMessage(message) {
72
+ const payload = parseMessagePayload(message);
73
+ if (!payload || typeof payload !== 'object')
74
+ return null;
75
+ const record = payload;
76
+ if (record.type === 'ub')
77
+ return decodeUpdateBatchBody(record.body);
78
+ if (typeof record.type === 'string' && record.type.startsWith('page_')) {
79
+ return decodePageInitBody(record.type, record.body);
80
+ }
81
+ return null;
82
+ }
83
+ /** main→worker direction: container signals page teardown so cache can evict. */
84
+ export function decodeOutgoingMessage(message) {
85
+ const payload = parseMessagePayload(message);
86
+ if (!payload || typeof payload !== 'object')
87
+ return null;
88
+ const r = payload;
89
+ if (typeof r.type !== 'string')
90
+ return null;
91
+ return {
92
+ type: r.type,
93
+ bridgeId: typeof r.body?.bridgeId === 'string' ? r.body.bridgeId : undefined,
94
+ };
95
+ }
96
+ /** Convert a decoded entry to the normalized accumulator input. */
97
+ export function decodedToInput(entry) {
98
+ const input = {
99
+ bridgeId: entry.bridgeId,
100
+ moduleId: entry.moduleId,
101
+ data: entry.data,
102
+ mode: entry.mode,
103
+ };
104
+ if (entry.mode === 'init')
105
+ input.componentPath = entry.componentPath;
106
+ return input;
107
+ }
108
+ /**
109
+ * Cumulative per-(bridgeId, moduleId) setData state. Pure data structure — no
110
+ * Worker / IPC / DOM. Callers own transport (publish/emit) and the automation
111
+ * mirror.
112
+ */
113
+ export class AppDataAccumulator {
114
+ cache = new Map();
115
+ // Bridges in insertion order — drives the `bridges` array (stable tab order).
116
+ bridgeOrder = [];
117
+ // Page path per bridge: set from `page_*` init's body.path (the page route).
118
+ bridgePagePath = new Map();
119
+ recordBridge(bridgeId) {
120
+ if (!this.bridgeOrder.includes(bridgeId))
121
+ this.bridgeOrder.push(bridgeId);
122
+ }
123
+ /**
124
+ * Apply one entry. Returns true if it was accepted (a mutation worth
125
+ * republishing), false if dropped (missing ids, or the init-gate).
126
+ */
127
+ apply(input) {
128
+ if (!input.bridgeId || !input.moduleId)
129
+ return false;
130
+ // Drop ub patches whose bridge has never been initialised. dimina
131
+ // dispatches pageUnload → onUnload, whose setData produces a late `ub`
132
+ // arriving AFTER clearBridge; without this gate it would resurrect the
133
+ // unloaded bridge as a ghost tab.
134
+ if (input.mode !== 'init' && !this.bridgePagePath.has(input.bridgeId))
135
+ return false;
136
+ const key = `${input.bridgeId}/${input.moduleId}`;
137
+ const prev = this.cache.get(key);
138
+ const incoming = input.data && typeof input.data === 'object'
139
+ ? input.data
140
+ : {};
141
+ // init = full-state replace; patch = merge into previous (setData semantics)
142
+ const merged = input.mode === 'init'
143
+ ? { ...incoming }
144
+ : { ...(prev?.data ?? {}), ...incoming };
145
+ const componentPath = input.componentPath ?? prev?.componentPath;
146
+ const next = componentPath !== undefined
147
+ ? { componentPath, data: merged }
148
+ : { data: merged };
149
+ this.cache.set(key, next);
150
+ this.recordBridge(input.bridgeId);
151
+ if (input.mode === 'init' && input.moduleId.startsWith('page_') && input.componentPath) {
152
+ this.bridgePagePath.set(input.bridgeId, input.componentPath);
153
+ }
154
+ return true;
155
+ }
156
+ /** Evict every entry for a bridge (page teardown). */
157
+ clearBridge(bridgeId) {
158
+ const prefix = `${bridgeId}/`;
159
+ for (const key of [...this.cache.keys()]) {
160
+ if (key.startsWith(prefix))
161
+ this.cache.delete(key);
162
+ }
163
+ const idx = this.bridgeOrder.indexOf(bridgeId);
164
+ if (idx >= 0)
165
+ this.bridgeOrder.splice(idx, 1);
166
+ this.bridgePagePath.delete(bridgeId);
167
+ }
168
+ /** The full cumulative snapshot for the panel. */
169
+ snapshot() {
170
+ const bridges = [];
171
+ for (const id of this.bridgeOrder) {
172
+ bridges.push({ id, pagePath: this.bridgePagePath.get(id) ?? null });
173
+ }
174
+ const entries = {};
175
+ for (const [key, entry] of this.cache) {
176
+ const slash = key.indexOf('/');
177
+ if (slash < 0)
178
+ continue;
179
+ const bridgeId = key.slice(0, slash);
180
+ const moduleId = key.slice(slash + 1);
181
+ if (!entries[bridgeId])
182
+ entries[bridgeId] = {};
183
+ const displayKey = entry.componentPath ?? moduleId;
184
+ entries[bridgeId][displayKey] = entry.data;
185
+ }
186
+ return { bridges, entries };
187
+ }
188
+ /**
189
+ * The current reactive page state for a bridge: shallow-merge of `entry.data`
190
+ * across every cache entry whose key starts with `${bridgeId}/`, in insertion
191
+ * order (later entries win on key conflicts). `{}` when no entries match
192
+ * (unknown bridge / after clearBridge). Pure, no side effects.
193
+ */
194
+ pageData(bridgeId) {
195
+ const merged = {};
196
+ for (const [key, entry] of this.cache) {
197
+ const slash = key.indexOf('/');
198
+ if (slash < 0)
199
+ continue;
200
+ if (key.slice(0, slash) !== bridgeId)
201
+ continue;
202
+ Object.assign(merged, entry.data);
203
+ }
204
+ return merged;
205
+ }
206
+ /** Flat `key → data` map for the `__simulatorData.getAppdata()` mirror. */
207
+ flat() {
208
+ const data = {};
209
+ for (const [key, entry] of this.cache)
210
+ data[key] = entry.data;
211
+ return data;
212
+ }
213
+ clear() {
214
+ this.cache.clear();
215
+ this.bridgeOrder.length = 0;
216
+ this.bridgePagePath.clear();
217
+ }
218
+ }
@@ -0,0 +1,64 @@
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;
11
+ function bridgeLabel(bridge) {
12
+ return bridge.pagePath ?? bridge.id;
13
+ }
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, }) {
45
+ 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: "\u6682\u65E0\u9875\u9762\u6570\u636E\uFF08\u4EC5\u663E\u793A Page \u7EA7 data\uFF09" })) : (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
+ }) }))] }));
64
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,92 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // The pure 编译 panel view: the host's compile-status events and per-line
3
+ // compiler log merged — in the VIEW layer only — into one oldest-first
4
+ // (chronological) timeline with a text filter. Pure presentation — feed
5
+ // state lives in the connected container (or the host, when it renders this
6
+ // view directly).
7
+ import { useEffect, useMemo, useRef, useState } from 'react';
8
+ /** Join the truthy class fragments (the panel's only class-composition need). */
9
+ function cn(...parts) {
10
+ return parts.filter(Boolean).join(' ');
11
+ }
12
+ function formatTime(at) {
13
+ const d = new Date(at);
14
+ return [d.getHours(), d.getMinutes(), d.getSeconds()]
15
+ .map((n) => String(n).padStart(2, '0'))
16
+ .join(':');
17
+ }
18
+ /** Status → row/badge accent. Unknown statuses fall back to muted text. */
19
+ function statusClass(status) {
20
+ if (status === 'error')
21
+ return 'text-red-500';
22
+ if (status === 'compiling')
23
+ return 'text-amber-500';
24
+ if (status === 'ready')
25
+ return 'text-emerald-600';
26
+ return 'text-text-muted';
27
+ }
28
+ /** Extract the display text from a timeline item for filter matching. */
29
+ function itemText(item) {
30
+ return item.kind === 'event' ? item.event.message : item.log.text;
31
+ }
32
+ export function CompilePanel({ events, logs = [], onClear }) {
33
+ const latest = events.length > 0 ? events[events.length - 1] : null;
34
+ const [filter, setFilter] = useState('');
35
+ const scrollRef = useRef(null);
36
+ const bottomRef = useRef(null);
37
+ const shouldAutoScroll = useRef(true);
38
+ // Track whether the user has scrolled away from the bottom.
39
+ useEffect(() => {
40
+ const container = scrollRef.current;
41
+ if (!container)
42
+ return;
43
+ const handleScroll = () => {
44
+ const { scrollTop, scrollHeight, clientHeight } = container;
45
+ shouldAutoScroll.current = scrollHeight - scrollTop - clientHeight < 20;
46
+ };
47
+ container.addEventListener('scroll', handleScroll);
48
+ return () => container.removeEventListener('scroll', handleScroll);
49
+ }, []);
50
+ const totalItems = events.length + logs.length;
51
+ // Auto-scroll to bottom when new items arrive. Optional-call: jsdom-class
52
+ // environments don't implement scrollIntoView.
53
+ useEffect(() => {
54
+ if (shouldAutoScroll.current) {
55
+ bottomRef.current?.scrollIntoView?.({ behavior: 'auto' });
56
+ }
57
+ }, [totalItems]);
58
+ const timeline = useMemo(() => {
59
+ const items = [];
60
+ events.forEach((event, index) => {
61
+ // Duration pairs strictly with the IMMEDIATELY preceding event: a
62
+ // ready right after a compiling shows the elapsed compile time; any
63
+ // intervening event (e.g. an error) breaks the pair.
64
+ const previous = index > 0 ? events[index - 1] : null;
65
+ const durationMs = event.status === 'ready' && previous?.status === 'compiling'
66
+ ? event.at - previous.at
67
+ : null;
68
+ items.push({ kind: 'event', at: event.at, seq: event.seq, event, durationMs });
69
+ });
70
+ for (const log of logs) {
71
+ items.push({ kind: 'log', at: log.at, seq: log.seq, log });
72
+ }
73
+ // Oldest first (chronological). Within a same-`at` tie (millisecond
74
+ // stamps — an event and the logs of the same compile collide routinely)
75
+ // the shared monotonic `seq` keeps ARRIVAL order.
76
+ return items.sort((a, b) => {
77
+ if (a.at !== b.at)
78
+ return a.at - b.at;
79
+ if (a.seq !== undefined && b.seq !== undefined)
80
+ return a.seq - b.seq;
81
+ return 0;
82
+ });
83
+ }, [events, logs]);
84
+ const filtered = useMemo(() => {
85
+ if (!filter)
86
+ return timeline;
87
+ const lower = filter.toLowerCase();
88
+ return timeline.filter((item) => itemText(item).toLowerCase().includes(lower));
89
+ }, [timeline, filter]);
90
+ const isEmpty = events.length === 0 && logs.length === 0;
91
+ return (_jsxs("div", { className: "flex flex-col h-full w-full min-h-0 text-[12px]", children: [_jsxs("div", { className: "flex items-center gap-2 px-2 h-8 shrink-0 border-b border-border-subtle", children: [latest ? (_jsx("span", { "data-compile-current": true, "data-status": latest.status, className: cn('truncate font-medium shrink-0', statusClass(latest.status)), children: latest.message })) : (_jsx("span", { className: "text-text-muted shrink-0", children: "\u7F16\u8BD1\u4FE1\u606F" })), _jsx("div", { className: "flex-1 min-w-0" }), _jsx("input", { type: "text", value: filter, onChange: (e) => setFilter(e.target.value), placeholder: "\u8FC7\u6EE4...", className: "h-6 px-2 text-[12px] rounded-sm border border-border-subtle bg-surface\n text-text placeholder:text-text-dim\n focus:outline-none focus:border-text-muted\n w-40 shrink-0" }), filter && (_jsxs("span", { className: "text-[11px] text-text-muted tabular-nums shrink-0", children: [filtered.length, "/", timeline.length] })), _jsx("button", { type: "button", onClick: onClear, className: "px-2 h-6 rounded-sm text-text-muted hover:text-text hover:bg-surface-2 transition-colors shrink-0", children: "\u6E05\u7A7A" })] }), _jsx("div", { ref: scrollRef, className: "flex-1 min-h-0 overflow-auto", children: isEmpty ? (_jsx("div", { className: "flex items-center justify-center h-full text-text-muted", children: "\u6682\u65E0\u7F16\u8BD1\u4FE1\u606F" })) : filtered.length === 0 ? (_jsx("div", { className: "flex items-center justify-center h-full text-text-muted", children: "\u65E0\u5339\u914D\u65E5\u5FD7" })) : (_jsxs("ul", { className: "px-2 py-1 space-y-0.5", children: [filtered.map((item, index) => item.kind === 'event' ? (_jsxs("li", { "data-compile-row": true, "data-status": item.event.status, className: "flex items-baseline gap-2 leading-5", children: [_jsx("span", { className: "text-text-muted tabular-nums shrink-0", children: formatTime(item.at) }), _jsx("span", { className: cn('break-all', statusClass(item.event.status)), children: item.event.message }), item.event.hotReload === true && (_jsx("span", { className: "shrink-0 px-1 rounded-sm bg-surface-2 text-text-muted", children: "\u5DF2\u91CD\u542F" })), item.durationMs !== null && (_jsxs("span", { "data-compile-duration": true, className: "shrink-0 text-text-muted tabular-nums", children: [(item.durationMs / 1000).toFixed(1), "s"] }))] }, `e-${item.at}-${index}`)) : (_jsxs("li", { "data-compile-log": true, "data-stream": item.log.stream, className: "flex items-baseline gap-2 leading-5", children: [_jsx("span", { className: "text-text-muted tabular-nums shrink-0", children: formatTime(item.at) }), _jsx("span", { className: cn('break-all font-mono', item.log.stream === 'stderr' ? 'text-red-500' : 'text-text'), children: item.log.text })] }, `l-${item.at}-${index}`))), _jsx("div", { ref: bottomRef })] })) })] }));
92
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ // The compile panel's two feed item shapes. The stores never cross: status
2
+ // events come from the host's compile-status feed, per-line compiler output
3
+ // lands in the log feed — merging them is a view concern.
4
+ export {};
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // The AppData panel's data wiring, written once against AppDataPanelSource:
3
+ // seed on the (enabled && active) rising edge, stay live by replacing state
4
+ // with each pushed full snapshot, forward the visibility gate, and own the
5
+ // bridge-tab selection (auto-follow of the simulator's active page + manual
6
+ // picks). Hosts render this with their transport implementation (Electron
7
+ // IPC, a same-origin Worker tap, …) and the pure AppDataPanel view underneath
8
+ // never needs host-specific code.
9
+ import { useState } from 'react';
10
+ import { AppDataPanel } from './appdata-panel-view.js';
11
+ import { useActiveBridgeId } from './use-active-bridge-id.js';
12
+ import { useSourceWiring } from './use-source-wiring.js';
13
+ const EMPTY_SNAPSHOT = { bridges: [], entries: {} };
14
+ export function ConnectedAppDataPanel({ source, active = true, enabled = true, isRuntimeRunning = true, activePagePath = '', }) {
15
+ const [snapshot, setSnapshot] = useState(EMPTY_SNAPSHOT);
16
+ useSourceWiring({
17
+ source,
18
+ enabled,
19
+ active,
20
+ subscribe: s => s.subscribe((next) => {
21
+ setSnapshot(next);
22
+ }),
23
+ seed: (s, isDisposed) => {
24
+ void s.getSnapshot().then((next) => {
25
+ if (!isDisposed())
26
+ setSnapshot(next);
27
+ });
28
+ },
29
+ });
30
+ const { activeBridgeId, setActiveBridge } = useActiveBridgeId(snapshot.bridges, activePagePath);
31
+ return (_jsx(AppDataPanel, { state: { bridges: snapshot.bridges, activeBridgeId, entries: snapshot.entries }, onSelectBridge: setActiveBridge, isRuntimeRunning: isRuntimeRunning }));
32
+ }
@@ -0,0 +1,72 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // The 编译 panel's data wiring, written once against CompilePanelSource: seed
3
+ // on the (enabled && active) rising edge, stay live by appending pushed
4
+ // events/logs under FIFO caps, stamp a shared monotonic `seq` onto unstamped
5
+ // arrivals (the merged timeline's same-`at` tie-break), forward the
6
+ // visibility gate, and route 清空. Hosts render this with their transport
7
+ // implementation and the pure CompilePanel view underneath never needs
8
+ // host-specific code.
9
+ import { useRef, useState } from 'react';
10
+ import { CompilePanel } from './compile-panel-view.js';
11
+ import { useSourceWiring } from './use-source-wiring.js';
12
+ /** events cap — FIFO, oldest evicted first. */
13
+ const MAX_COMPILE_EVENTS = 200;
14
+ /** logs cap — FIFO, oldest evicted first. */
15
+ const MAX_COMPILE_LOGS = 300;
16
+ const EMPTY_FEED = { events: [], logs: [] };
17
+ export function ConnectedCompilePanel({ source, active = true, enabled = true, }) {
18
+ const [feed, setFeed] = useState(EMPTY_FEED);
19
+ // The shared arrival counter behind `seq` stamping. Ratcheted past every
20
+ // explicit seq seen so a locally stamped item can never undercut one the
21
+ // host stamped itself (which would silently reverse arrival order on a
22
+ // same-`at` tie).
23
+ const nextSeq = useRef(0);
24
+ const stamp = (item) => {
25
+ if (item.seq !== undefined) {
26
+ nextSeq.current = Math.max(nextSeq.current, item.seq + 1);
27
+ return item;
28
+ }
29
+ return { ...item, seq: nextSeq.current++ };
30
+ };
31
+ useSourceWiring({
32
+ source,
33
+ enabled,
34
+ active,
35
+ subscribe: s => s.subscribe((evt) => {
36
+ if (evt.kind === 'reset') {
37
+ setFeed(EMPTY_FEED);
38
+ return;
39
+ }
40
+ if (evt.kind === 'event') {
41
+ const event = stamp(evt.event);
42
+ setFeed(prev => ({
43
+ ...prev,
44
+ events: [...prev.events, event].slice(-MAX_COMPILE_EVENTS),
45
+ }));
46
+ return;
47
+ }
48
+ const log = stamp(evt.log);
49
+ setFeed(prev => ({
50
+ ...prev,
51
+ logs: [...prev.logs, log].slice(-MAX_COMPILE_LOGS),
52
+ }));
53
+ }),
54
+ seed: (s, isDisposed) => {
55
+ void s.getSnapshot().then((snap) => {
56
+ if (isDisposed())
57
+ return;
58
+ setFeed({
59
+ events: snap.events.map(stamp).slice(-MAX_COMPILE_EVENTS),
60
+ logs: snap.logs.map(stamp).slice(-MAX_COMPILE_LOGS),
61
+ });
62
+ });
63
+ },
64
+ });
65
+ const handleClear = () => {
66
+ // The local timeline empties regardless; clearing the host-side history
67
+ // is the optional capability.
68
+ setFeed(EMPTY_FEED);
69
+ void source.clear?.();
70
+ };
71
+ return _jsx(CompilePanel, { events: feed.events, logs: feed.logs, onClear: handleClear });
72
+ }
package/dist/index.js CHANGED
@@ -2,3 +2,4 @@ export { SYNTHETIC_SID_PREFIX, registerSyntheticSid, findElementBySid, } from '.
2
2
  export { walkInstance } from './wxml-extract.js';
3
3
  export { createWxmlInspector, } from './inspector.js';
4
4
  export { applyStorageEvent } from './storage-reducer.js';
5
+ export { AppDataAccumulator, decodeWorkerMessage, decodeOutgoingMessage, decodedToInput, } from './appdata-accumulator.js';
package/dist/panel.js CHANGED
@@ -5,3 +5,8 @@ export { WxmlPanel } from './panel-view.js';
5
5
  export { ConnectedWxmlPanel } from './connected-panel.js';
6
6
  export { StoragePanel } from './storage-panel-view.js';
7
7
  export { ConnectedStoragePanel } from './connected-storage-panel.js';
8
+ export { AppDataPanel } from './appdata-panel-view.js';
9
+ export { ConnectedAppDataPanel } from './connected-appdata-panel.js';
10
+ export { useActiveBridgeId } from './use-active-bridge-id.js';
11
+ export { CompilePanel } from './compile-panel-view.js';
12
+ export { ConnectedCompilePanel } from './connected-compile-panel.js';
@@ -0,0 +1,59 @@
1
+ import { useState } from 'react';
2
+ function normalizePagePath(p) {
3
+ return (p ?? '').replace(/^\/+/, '');
4
+ }
5
+ /**
6
+ * Derive which AppData page tab is active, synchronously.
7
+ *
8
+ * `activeBridgeId` is a pure derivation of (current bridges, the simulator's
9
+ * active page, manual pick), computed during render — so it is never stale for
10
+ * a frame the way a useEffect-derived value would be. Auto-follow tracks the
11
+ * page the user is actually looking at: when `activePagePath` matches a
12
+ * bridge's pagePath the panel snaps to it, so switching tabBar tabs (which
13
+ * re-inits no bridge) still moves the panel. A brand-new bridge id appearing or
14
+ * the active page changing both drop a prior manual pick back to auto-follow.
15
+ */
16
+ export function useActiveBridgeId(bridges, activePagePath = '') {
17
+ const ids = bridges.map((b) => b.id);
18
+ // NUL separator: ids are runtime-generated and never contain it, so two
19
+ // distinct id lists can never collide on the same key.
20
+ const idsKey = ids.join('\x00');
21
+ // The user's manual tab pick; null means "auto-follow the active page".
22
+ const [selectedBridgeId, setSelectedBridgeId] = useState(null);
23
+ // The id list from the previous render — lets us spot a freshly-inited page.
24
+ const [prevIdsKey, setPrevIdsKey] = useState('');
25
+ // The active page from the previous render — lets us spot a tab switch.
26
+ const [prevActivePath, setPrevActivePath] = useState(activePagePath);
27
+ if (idsKey !== prevIdsKey) {
28
+ // The bridge set changed. If a never-before-seen id appeared, a new page
29
+ // inited → drop back to auto-follow. Adjusting state during render is the
30
+ // React-sanctioned alternative to an effect: React re-renders synchronously
31
+ // before the commit, so `activeBridgeId` below is correct this frame.
32
+ const prevIds = prevIdsKey ? prevIdsKey.split('\x00') : [];
33
+ if (ids.some((id) => !prevIds.includes(id))) {
34
+ setSelectedBridgeId(null);
35
+ }
36
+ setPrevIdsKey(idsKey);
37
+ }
38
+ if (activePagePath !== prevActivePath) {
39
+ // The simulator navigated to a different active page (e.g. a tabBar switch).
40
+ // Drop the manual pick so the panel re-follows the page on screen — without
41
+ // this, switching tabs leaves the panel stuck on whichever page inited last.
42
+ setSelectedBridgeId(null);
43
+ setPrevActivePath(activePagePath);
44
+ }
45
+ // Auto-follow target: the bridge whose pagePath matches the active page.
46
+ const activeFollowId = activePagePath
47
+ ? bridges.find((b) => normalizePagePath(b.pagePath) === normalizePagePath(activePagePath))?.id ?? null
48
+ : null;
49
+ const activeBridgeId = selectedBridgeId && ids.includes(selectedBridgeId)
50
+ ? selectedBridgeId
51
+ // Prefer the active page; fall back to the newest bridge when the active
52
+ // page is unknown or has no matching bridge yet.
53
+ : (activeFollowId ?? ids.at(-1) ?? null);
54
+ const setActiveBridge = (id) => {
55
+ if (ids.includes(id))
56
+ setSelectedBridgeId(id);
57
+ };
58
+ return { activeBridgeId, setActiveBridge };
59
+ }