@dimina-kit/inspect 0.4.0-dev.20260716214527 → 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.
@@ -0,0 +1,268 @@
1
+ /**
2
+ * AppDataPanel — WeChat DevTools parity.
3
+ *
4
+ * A Pages sidebar drives a single merged data tree per page, replacing the
5
+ * old top bridge-tab bar and the one-JSON-card-per-component-path layout.
6
+ * Every active bridge's `entries[bridgeId]` component objects are
7
+ * shallow-merged (insertion order, later wins) into one root object and
8
+ * rendered as a collapsible tree: the root and its top-level keys are always
9
+ * visible, nested object/array nodes start collapsed, and a toolbar can
10
+ * expand or collapse everything at once. Editing behavior (checkboxes,
11
+ * inline text/number edit, data-path targeting, undo/redo) is covered
12
+ * separately in appdata-tree-edit.test.tsx.
13
+ */
14
+ import { describe, expect, it, vi } from 'vitest'
15
+ import { fireEvent, render } from '@testing-library/react'
16
+ import { AppDataPanel, type AppDataPanelState as AppDataState } from './appdata-panel-view.js'
17
+
18
+ function makeState(overrides: Partial<AppDataState> = {}): AppDataState {
19
+ return {
20
+ bridges: [{ id: 'b1', pagePath: 'pages/index/index' }],
21
+ activeBridgeId: 'b1',
22
+ entries: { b1: { 'pages/index/index': { count: 1 } } },
23
+ ...overrides,
24
+ }
25
+ }
26
+
27
+ describe('AppDataPanel: Pages sidebar', () => {
28
+ it('renders a Pages sidebar with one item even for a single bridge', () => {
29
+ const { getByTestId, getAllByTestId } = render(
30
+ <AppDataPanel state={makeState()} onSelectBridge={vi.fn()} />,
31
+ )
32
+ const sidebar = getByTestId('appdata-pages')
33
+ expect(sidebar.textContent).toContain('Pages')
34
+ expect(getAllByTestId('appdata-page-item')).toHaveLength(1)
35
+ })
36
+
37
+ it('labels each page item by pagePath, falling back to the bridge id when pagePath is null', () => {
38
+ const state = makeState({
39
+ bridges: [
40
+ { id: 'b1', pagePath: '/pages/index/index' },
41
+ { id: 'b2', pagePath: null },
42
+ ],
43
+ activeBridgeId: 'b1',
44
+ entries: {
45
+ b1: { 'pages/index/index': { count: 1 } },
46
+ b2: { 'pages/detail/detail': { count: 2 } },
47
+ },
48
+ })
49
+ const { getAllByTestId } = render(<AppDataPanel state={state} onSelectBridge={vi.fn()} />)
50
+ const labels = getAllByTestId('appdata-page-item').map(el => (el.textContent ?? '').trim())
51
+ expect(labels).toContain('/pages/index/index')
52
+ expect(labels).toContain('b2')
53
+ })
54
+
55
+ it('marks the active bridge row aria-selected=true and the rest aria-selected=false', () => {
56
+ const state = makeState({
57
+ bridges: [
58
+ { id: 'b1', pagePath: '/pages/index/index' },
59
+ { id: 'b2', pagePath: '/pages/detail/detail' },
60
+ ],
61
+ activeBridgeId: 'b2',
62
+ entries: {
63
+ b1: { 'pages/index/index': { count: 1 } },
64
+ b2: { 'pages/detail/detail': { count: 2 } },
65
+ },
66
+ })
67
+ const { getAllByTestId } = render(<AppDataPanel state={state} onSelectBridge={vi.fn()} />)
68
+ const items = getAllByTestId('appdata-page-item')
69
+ const b1Item = items.find(el => (el.textContent ?? '').trim() === '/pages/index/index')!
70
+ const b2Item = items.find(el => (el.textContent ?? '').trim() === '/pages/detail/detail')!
71
+ expect(b1Item.getAttribute('aria-selected')).toBe('false')
72
+ expect(b2Item.getAttribute('aria-selected')).toBe('true')
73
+ })
74
+
75
+ it('calls onSelectBridge with the clicked bridge id', () => {
76
+ const state = makeState({
77
+ bridges: [
78
+ { id: 'b1', pagePath: '/pages/index/index' },
79
+ { id: 'b2', pagePath: '/pages/detail/detail' },
80
+ ],
81
+ activeBridgeId: 'b1',
82
+ entries: {
83
+ b1: { 'pages/index/index': { count: 1 } },
84
+ b2: { 'pages/detail/detail': { count: 2 } },
85
+ },
86
+ })
87
+ const onSelectBridge = vi.fn()
88
+ const { getAllByTestId } = render(<AppDataPanel state={state} onSelectBridge={onSelectBridge} />)
89
+ const b2Item = getAllByTestId('appdata-page-item').find(
90
+ el => (el.textContent ?? '').trim() === '/pages/detail/detail',
91
+ )!
92
+ fireEvent.click(b2Item)
93
+ expect(onSelectBridge).toHaveBeenCalledWith('b2')
94
+ })
95
+ })
96
+
97
+ describe('AppDataPanel: merged data tree', () => {
98
+ it('shallow-merges every component entry for the active bridge into one tree, later entries winning on key conflicts', () => {
99
+ const state = makeState({
100
+ entries: {
101
+ b1: {
102
+ 'pages/index/index': { count: 1, shared: 'first' },
103
+ 'components/foo/foo': { visible: true, shared: 'second' },
104
+ },
105
+ },
106
+ })
107
+ const { getByTestId } = render(<AppDataPanel state={state} onSelectBridge={vi.fn()} />)
108
+ const tree = getByTestId('appdata-tree')
109
+ expect(tree.textContent).toContain('count')
110
+ expect(tree.textContent).toContain('visible')
111
+ expect(tree.textContent).toContain('second')
112
+ })
113
+
114
+ it('renders exactly one merged tree per bridge, not one card per component path', () => {
115
+ const state = makeState({
116
+ entries: {
117
+ b1: {
118
+ 'pages/index/index': { count: 1 },
119
+ 'components/foo/foo': { visible: true },
120
+ },
121
+ },
122
+ })
123
+ const { container } = render(<AppDataPanel state={state} onSelectBridge={vi.fn()} />)
124
+ const bridgeContainer = container.querySelector('[data-bridge-id="b1"]') as HTMLElement
125
+ expect(bridgeContainer.querySelectorAll('[data-testid="appdata-tree"]')).toHaveLength(1)
126
+ })
127
+
128
+ it('does not render the component path as a visible header inside the tree', () => {
129
+ const state = makeState({
130
+ entries: { b1: { 'pages/index/index': { count: 1 } } },
131
+ })
132
+ const { getByTestId } = render(<AppDataPanel state={state} onSelectBridge={vi.fn()} />)
133
+ expect(getByTestId('appdata-tree').textContent).not.toContain('pages/index/index')
134
+ })
135
+ })
136
+
137
+ describe('AppDataPanel: root default expansion and key order', () => {
138
+ it('shows top-level keys without any interaction, inside the merged appdata-tree container', () => {
139
+ const state = makeState({ entries: { b1: { comp: { count: 1, label: 'hi' } } } })
140
+ const { getByTestId } = render(<AppDataPanel state={state} onSelectBridge={vi.fn()} />)
141
+ const tree = getByTestId('appdata-tree')
142
+ expect(tree.textContent).toContain('count')
143
+ expect(tree.textContent).toContain('label')
144
+ })
145
+
146
+ it('shows the root row entry count as {n} for n top-level keys', () => {
147
+ const state = makeState({ entries: { b1: { comp: { alpha: 1, beta: 2, gamma: 3 } } } })
148
+ const { getByTestId } = render(<AppDataPanel state={state} onSelectBridge={vi.fn()} />)
149
+ expect(getByTestId('appdata-tree').textContent).toContain('{3}')
150
+ })
151
+
152
+ it('orders top-level keys by Array.prototype.sort() default (code-unit ascending), not insertion order', () => {
153
+ const state = makeState({ entries: { b1: { comp: { zeta: 1, alpha: 2 } } } })
154
+ const { getByTestId } = render(<AppDataPanel state={state} onSelectBridge={vi.fn()} />)
155
+ const text = getByTestId('appdata-tree').textContent ?? ''
156
+ expect(text.indexOf('alpha')).toBeGreaterThanOrEqual(0)
157
+ expect(text.indexOf('zeta')).toBeGreaterThanOrEqual(0)
158
+ expect(text.indexOf('alpha')).toBeLessThan(text.indexOf('zeta'))
159
+ })
160
+ })
161
+
162
+ describe('AppDataPanel: non-root node collapse', () => {
163
+ function nestedState(): AppDataState {
164
+ return makeState({
165
+ entries: {
166
+ b1: {
167
+ comp: {
168
+ obj: { first: 1, second: 2 },
169
+ list: [10, 20, 30, 40],
170
+ },
171
+ },
172
+ },
173
+ })
174
+ }
175
+
176
+ it('collapses a nested object by default, hiding its keys and showing {n} until the row is clicked', () => {
177
+ const { getByTestId, getByText, queryByText } = render(
178
+ <AppDataPanel state={nestedState()} onSelectBridge={vi.fn()} />,
179
+ )
180
+ expect(queryByText('first')).toBeNull()
181
+ expect(getByTestId('appdata-tree').textContent).toContain('{2}')
182
+
183
+ fireEvent.click(getByText('obj'))
184
+
185
+ expect(getByText('first')).toBeTruthy()
186
+ expect(getByText('second')).toBeTruthy()
187
+ })
188
+
189
+ it('collapses a nested array by default, showing [n] and revealing elements once clicked', () => {
190
+ const { getByTestId, getByText, queryByText } = render(
191
+ <AppDataPanel state={nestedState()} onSelectBridge={vi.fn()} />,
192
+ )
193
+ expect(getByTestId('appdata-tree').textContent).toContain('[4]')
194
+ expect(queryByText('10')).toBeNull()
195
+
196
+ fireEvent.click(getByText('list'))
197
+
198
+ expect(getByText('10')).toBeTruthy()
199
+ })
200
+ })
201
+
202
+ describe('AppDataPanel: toolbar expand/collapse all', () => {
203
+ function deepState(): AppDataState {
204
+ return makeState({
205
+ entries: {
206
+ b1: {
207
+ comp: {
208
+ outer: { inner: { leaf: 'value' } },
209
+ },
210
+ },
211
+ },
212
+ })
213
+ }
214
+
215
+ it('renders a toolbar with 全部展开/全部收起/撤销/重做 controls', () => {
216
+ const { getByTestId, getByTitle } = render(<AppDataPanel state={deepState()} onSelectBridge={vi.fn()} />)
217
+ expect(getByTestId('appdata-toolbar')).toBeTruthy()
218
+ expect(getByTitle('全部展开')).toBeTruthy()
219
+ expect(getByTitle('全部收起')).toBeTruthy()
220
+ expect(getByTitle('撤销')).toBeTruthy()
221
+ expect(getByTitle('重做')).toBeTruthy()
222
+ })
223
+
224
+ it('reveals every nested key when 全部展开 is clicked', () => {
225
+ const { getByTitle, getByText, queryByText } = render(
226
+ <AppDataPanel state={deepState()} onSelectBridge={vi.fn()} />,
227
+ )
228
+ expect(queryByText('leaf')).toBeNull()
229
+ fireEvent.click(getByTitle('全部展开'))
230
+ expect(getByText('leaf')).toBeTruthy()
231
+ })
232
+
233
+ it('hides every top-level key when 全部收起 is clicked, leaving only the root row', () => {
234
+ const { getByTitle, queryByText } = render(
235
+ <AppDataPanel state={deepState()} onSelectBridge={vi.fn()} />,
236
+ )
237
+ fireEvent.click(getByTitle('全部收起'))
238
+ expect(queryByText('outer')).toBeNull()
239
+ })
240
+ })
241
+
242
+ describe('AppDataPanel: keepalive preserves expand state across bridge switches', () => {
243
+ it('keeps a manually expanded node expanded after switching away and back', () => {
244
+ const state = makeState({
245
+ bridges: [
246
+ { id: 'b1', pagePath: '/pages/index/index' },
247
+ { id: 'b2', pagePath: '/pages/detail/detail' },
248
+ ],
249
+ activeBridgeId: 'b1',
250
+ entries: {
251
+ b1: { comp: { obj: { first: 1 } } },
252
+ b2: { comp: { count: 2 } },
253
+ },
254
+ })
255
+ const { container, getByText, rerender } = render(
256
+ <AppDataPanel state={state} onSelectBridge={vi.fn()} />,
257
+ )
258
+ const b1Tree = (container.querySelector('[data-bridge-id="b1"]') as HTMLElement)
259
+ .querySelector('[data-testid="appdata-tree"]') as HTMLElement
260
+ fireEvent.click(getByText('obj'))
261
+ expect(b1Tree.textContent).toContain('first')
262
+
263
+ rerender(<AppDataPanel state={{ ...state, activeBridgeId: 'b2' }} onSelectBridge={vi.fn()} />)
264
+ rerender(<AppDataPanel state={{ ...state, activeBridgeId: 'b1' }} onSelectBridge={vi.fn()} />)
265
+
266
+ expect(b1Tree.textContent).toContain('first')
267
+ })
268
+ })
@@ -1,34 +1,12 @@
1
- // The pure AppData panel view: per-page bridge tabs over cumulative setData
2
- // state, each component path rendered as a collapsible JSON tree. Pure
1
+ // The pure AppData panel view, WeChat DevTools AppData layout: a Pages
2
+ // sidebar over one merged, collapsible (and optionally editable) data tree
3
+ // per page bridge, plus an expand/collapse/undo/redo toolbar. Pure
3
4
  // presentation — bridge selection and data feeds live in the connected
4
5
  // container (or the host, when it renders this view directly).
5
- import React from 'react'
6
- import * as jsonViewModule from '@uiw/react-json-view'
6
+ import React, { useRef, useState } from 'react'
7
+ import { AppDataTree, type AppDataTreeCommand } from './appdata-tree.js'
7
8
  import type { AppDataSnapshot } from './appdata-accumulator.js'
8
9
 
9
- // The package ships CJS-flavoured type declarations (no `"type": "module"`),
10
- // so what `default` types as depends on the consumer's moduleResolution:
11
- // under NodeNext it is the whole module.exports object with the component on
12
- // `.default`; under Bundler (and in vite/vitest at runtime) it IS the
13
- // component (a forwardRef exotic object, so a typeof-function probe can't
14
- // tell the shapes apart; the interop namespace is the only shape carrying a
15
- // `default` key). The conditional type and the `in` probe resolve the
16
- // component under BOTH semantics — this package typechecks under NodeNext
17
- // and is also typechecked from source by Bundler-resolution consumers.
18
- type ModuleDefault = (typeof jsonViewModule)['default']
19
- type JsonViewComponent = ModuleDefault extends { default: infer C } ? C : ModuleDefault
20
- const moduleDefault: JsonViewComponent | { default: JsonViewComponent } = jsonViewModule.default
21
- // A type predicate (not a bare `in` check): plain `in` narrowing intersects
22
- // `Record<'default', unknown>` onto the component branch, degrading the
23
- // conditional's type to unknown.
24
- function isInteropNamespace(
25
- m: JsonViewComponent | { default: JsonViewComponent },
26
- ): m is { default: JsonViewComponent } {
27
- return 'default' in m
28
- }
29
- const JsonView: JsonViewComponent
30
- = isInteropNamespace(moduleDefault) ? moduleDefault.default : moduleDefault
31
-
32
10
  /** The view's full input state: a snapshot plus which bridge tab is active. */
33
11
  export interface AppDataPanelState {
34
12
  bridges: AppDataSnapshot['bridges']
@@ -36,70 +14,233 @@ export interface AppDataPanelState {
36
14
  entries: AppDataSnapshot['entries']
37
15
  }
38
16
 
39
- function bridgeLabel(bridge: { id: string; pagePath: string | null }): string {
40
- return bridge.pagePath ?? bridge.id
41
- }
42
-
43
- // Map the json-view CSS vars onto the panel's existing design tokens so the
44
- // component blends with both dark and light themes. CSS variables defined
45
- // here cascade to the json-view's internal vars (--w-rjv-*) via inline style.
46
- const JSON_VIEW_STYLE: React.CSSProperties = {
47
- '--w-rjv-font-family': 'var(--font-family-mono)',
48
- '--w-rjv-background-color': 'transparent',
49
- '--w-rjv-color': 'var(--color-code-blue)',
50
- '--w-rjv-key-string': 'var(--color-code-blue)',
51
- '--w-rjv-line-color': 'var(--color-border-subtle)',
52
- '--w-rjv-arrow-color': 'var(--color-text-secondary)',
53
- '--w-rjv-info-color': 'var(--color-code-label)',
54
- '--w-rjv-curlybraces-color': 'var(--color-text-secondary)',
55
- '--w-rjv-brackets-color': 'var(--color-text-secondary)',
56
- '--w-rjv-quotes-color': 'var(--color-code-orange)',
57
- '--w-rjv-quotes-string-color': 'var(--color-code-orange)',
58
- '--w-rjv-type-string-color': 'var(--color-code-orange)',
59
- '--w-rjv-type-int-color': 'var(--color-code-number)',
60
- '--w-rjv-type-float-color': 'var(--color-code-number)',
61
- '--w-rjv-type-bigint-color': 'var(--color-code-number)',
62
- '--w-rjv-type-boolean-color': 'var(--color-code-keyword)',
63
- '--w-rjv-type-null-color': 'var(--color-code-keyword)',
64
- '--w-rjv-type-nan-color': 'var(--color-code-keyword)',
65
- '--w-rjv-type-undefined-color': 'var(--color-code-keyword)',
66
- '--w-rjv-type-date-color': 'var(--color-code-label)',
67
- '--w-rjv-type-url-color': 'var(--color-code-blue)',
68
- fontSize: 12,
69
- padding: '6px 8px',
70
- wordBreak: 'break-all',
71
- whiteSpace: 'pre-wrap',
72
- } as React.CSSProperties
73
-
74
17
  export interface AppDataPanelProps {
75
18
  state: AppDataPanelState
76
19
  onSelectBridge: (id: string) => void
77
20
  /** Whether the mini-program's runtime session is `running` — distinguishes "小程序未运行" from a true empty-data vacuum below. Defaults to true so callers that don't track runtime status keep the plain empty-data text. */
78
21
  isRuntimeRunning?: boolean
22
+ /** Write-back for tree edits (setData path syntax keys). Absent → the tree
23
+ * renders read-only: no checkboxes, double-click is inert. The return value
24
+ * reports whether the write was dispatched to a live runtime: `false` (sync
25
+ * or resolved) rejects the edit — the undo/redo stacks only advance on
26
+ * success. `void`/`undefined` counts as success (fire-and-forget hosts). */
27
+ onSetData?: (bridgeId: string, patch: Record<string, unknown>) => void | boolean | Promise<boolean>
28
+ }
29
+
30
+ function bridgeLabel(bridge: { id: string; pagePath: string | null }): string {
31
+ return bridge.pagePath ?? bridge.id
32
+ }
33
+
34
+ function isThenable(v: unknown): v is PromiseLike<boolean> {
35
+ return v != null && (typeof v === 'object' || typeof v === 'function')
36
+ && typeof (v as { then?: unknown }).then === 'function'
37
+ }
38
+
39
+ /** One page's merged state: its component entries shallow-merged in insertion
40
+ * order (later entries win) — the single root object the tree renders. Copies
41
+ * via defineProperty, NOT Object.assign: an own enumerable `__proto__` data
42
+ * key (JSON.parse can produce one) would trigger the legacy setter under
43
+ * [[Set]], silently rebasing the merged object's prototype and dropping the
44
+ * key from the tree. */
45
+ function mergeEntries(bridgeEntries: Record<string, unknown>): Record<string, unknown> {
46
+ const merged: Record<string, unknown> = {}
47
+ for (const key of Object.keys(bridgeEntries)) {
48
+ const data = bridgeEntries[key]
49
+ if (!data || typeof data !== 'object') continue
50
+ for (const dataKey of Object.keys(data)) {
51
+ Object.defineProperty(merged, dataKey, {
52
+ value: (data as Record<string, unknown>)[dataKey],
53
+ enumerable: true,
54
+ writable: true,
55
+ configurable: true,
56
+ })
57
+ }
58
+ }
59
+ return merged
60
+ }
61
+
62
+ /** One committed edit — enough to replay in either direction. The stack lives
63
+ * on the panel (not per tree) so undo keeps working after a page switch. */
64
+ interface EditRecord {
65
+ bridgeId: string
66
+ path: string
67
+ before: unknown
68
+ after: unknown
69
+ }
70
+
71
+ function ToolbarButton({ title, disabled, onClick, children }: {
72
+ title: string
73
+ disabled?: boolean
74
+ onClick: () => void
75
+ children: React.ReactNode
76
+ }) {
77
+ return (
78
+ <button
79
+ title={title}
80
+ disabled={disabled}
81
+ onClick={onClick}
82
+ 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"
83
+ >
84
+ {children}
85
+ </button>
86
+ )
79
87
  }
80
88
 
81
89
  export function AppDataPanel({
82
90
  state,
83
91
  onSelectBridge,
84
92
  isRuntimeRunning = true,
93
+ onSetData,
85
94
  }: AppDataPanelProps) {
86
95
  const { bridges, activeBridgeId, entries } = state
96
+ const [command, setCommand] = useState<AppDataTreeCommand | null>(null)
97
+ const [undoStack, setUndoStack] = useState<EditRecord[]>([])
98
+ const [redoStack, setRedoStack] = useState<EditRecord[]>([])
99
+ // The single write gate: at most ONE write (commit OR replay) is ever in
100
+ // flight. The ref blocks reentry synchronously — a concurrent replay would
101
+ // move the same record twice, a concurrent commit would settle out of
102
+ // action order (undo then reverts the wrong field) or resurrect a redo
103
+ // branch the commit just invalidated. The panel has no local echo, so a
104
+ // gated-away click is a clean no-op. The state mirror disables the
105
+ // undo/redo buttons while pending.
106
+ const writeInFlight = useRef(false)
107
+ const [writePending, setWritePending] = useState(false)
108
+
109
+ const emptyText = isRuntimeRunning ? '暂无页面数据(仅显示 Page 级 data)' : '小程序未运行'
110
+
111
+ const issueCommand = (mode: AppDataTreeCommand['mode']): void => {
112
+ if (!activeBridgeId) return
113
+ setCommand({ seq: (command?.seq ?? 0) + 1, mode, bridgeId: activeBridgeId })
114
+ }
115
+
116
+ /** Dispatch a write, then run `apply` with its acceptance: only an explicit
117
+ * `false` (sync or resolved) is a rejection — `void` keeps fire-and-forget
118
+ * hosts working. A synchronous result settles synchronously so the stacks
119
+ * (and their buttons) update in the same event turn as the click. A thrown
120
+ * error or rejected promise (IPC torn down mid-edit) settles as a rejection
121
+ * instead of escaping as an unhandled rejection. */
122
+ const dispatch = (
123
+ bridgeId: string,
124
+ patch: Record<string, unknown>,
125
+ apply: (ok: boolean) => void,
126
+ ): void => {
127
+ let result: void | boolean | Promise<boolean>
128
+ try {
129
+ result = onSetData?.(bridgeId, patch)
130
+ } catch {
131
+ apply(false)
132
+ return
133
+ }
134
+ // Duck-typed, not `instanceof Promise`: a cross-realm promise or plain
135
+ // thenable is still an ASYNC result — treating it as a sync truthy value
136
+ // would count it as an instant success and release the write gate early.
137
+ if (isThenable(result)) {
138
+ void Promise.resolve(result).then(v => v !== false, () => false).then(apply)
139
+ return
140
+ }
141
+ apply(result !== false)
142
+ }
143
+
144
+ /** The only entry to `dispatch` — every write (commit and replay) passes
145
+ * through the gate so a second write can never start while one is pending. */
146
+ const performWrite = (
147
+ bridgeId: string,
148
+ patch: Record<string, unknown>,
149
+ apply: (ok: boolean) => void,
150
+ ): void => {
151
+ if (writeInFlight.current) return
152
+ writeInFlight.current = true
153
+ setWritePending(true)
154
+ dispatch(bridgeId, patch, (ok) => {
155
+ writeInFlight.current = false
156
+ setWritePending(false)
157
+ apply(ok)
158
+ })
159
+ }
160
+
161
+ const commitEdit = (bridgeId: string) => (path: string, next: unknown, prev: unknown): void => {
162
+ performWrite(bridgeId, { [path]: next }, (ok) => {
163
+ if (!ok) return
164
+ setUndoStack(stack => [...stack, { bridgeId, path, before: prev, after: next }])
165
+ setRedoStack([])
166
+ })
167
+ }
168
+
169
+ const bridgeIsLive = (bridgeId: string): boolean => bridges.some(b => b.id === bridgeId)
170
+
171
+ /** Replay one record in either direction through the shared write gate. */
172
+ const replay = (
173
+ record: EditRecord,
174
+ value: unknown,
175
+ move: (record: EditRecord) => void,
176
+ ): void => {
177
+ performWrite(record.bridgeId, { [record.path]: value }, (ok) => {
178
+ // A rejected replay (runtime refused the write) leaves both stacks
179
+ // untouched so the UI state never claims an undo that didn't happen.
180
+ if (!ok) return
181
+ move(record)
182
+ })
183
+ }
184
+
185
+ const undo = (): void => {
186
+ const record = undoStack.at(-1)
187
+ if (!record) return
188
+ if (!bridgeIsLive(record.bridgeId)) {
189
+ // The page this edit targeted is gone — replaying it can only write into
190
+ // the void. Drop the record without dispatching.
191
+ setUndoStack(stack => stack.filter(r => r !== record))
192
+ return
193
+ }
194
+ replay(record, record.before, (r) => {
195
+ setUndoStack(stack => stack.filter(item => item !== r))
196
+ setRedoStack(stack => [...stack, r])
197
+ })
198
+ }
199
+
200
+ const redo = (): void => {
201
+ const record = redoStack.at(-1)
202
+ if (!record) return
203
+ if (!bridgeIsLive(record.bridgeId)) {
204
+ setRedoStack(stack => stack.filter(r => r !== record))
205
+ return
206
+ }
207
+ replay(record, record.after, (r) => {
208
+ setRedoStack(stack => stack.filter(item => item !== r))
209
+ setUndoStack(stack => [...stack, r])
210
+ })
211
+ }
212
+
213
+ if (bridges.length === 0) {
214
+ return (
215
+ <div className="flex flex-col overflow-hidden flex-1" data-testid="appdata-panel">
216
+ <div className="text-[12px] text-text-dim text-center px-4 py-6">{emptyText}</div>
217
+ </div>
218
+ )
219
+ }
220
+
87
221
  return (
88
- <div className="flex flex-col overflow-hidden flex-1" data-testid="appdata-panel">
89
- {bridges.length > 1 && (
90
- <div className="flex gap-1 px-2 py-1 border-b border-border-subtle shrink-0 overflow-x-auto bg-bg-panel">
222
+ <div className="flex overflow-hidden flex-1" data-testid="appdata-panel">
223
+ <div
224
+ data-testid="appdata-pages"
225
+ className="w-40 shrink-0 border-r border-border-subtle bg-bg-panel flex flex-col overflow-hidden"
226
+ >
227
+ <div className="px-2 py-1 text-[11px] text-text-secondary border-b border-border-subtle shrink-0">
228
+ Pages
229
+ </div>
230
+ <div className="flex-1 overflow-y-auto" role="listbox">
91
231
  {bridges.map((b) => {
92
232
  const isActive = b.id === activeBridgeId
93
233
  return (
94
234
  <button
95
235
  key={b.id}
96
- onClick={() => onSelectBridge(b.id)}
236
+ data-testid="appdata-page-item"
237
+ role="option"
238
+ aria-selected={isActive}
97
239
  title={b.id}
240
+ onClick={() => onSelectBridge(b.id)}
98
241
  className={
99
- 'shrink-0 px-2 py-0.5 text-[11px] rounded border transition-colors '
100
- + (isActive
101
- ? 'border-accent text-accent bg-surface-3'
102
- : 'border-border-subtle text-text-dim hover:border-accent hover:text-accent')
242
+ 'block w-full text-left px-2 py-1 text-[11px] truncate '
243
+ + (isActive ? 'bg-accent/20 text-accent' : 'text-text-dim hover:bg-surface-3')
103
244
  }
104
245
  >
105
246
  {bridgeLabel(b)}
@@ -107,59 +248,48 @@ export function AppDataPanel({
107
248
  )
108
249
  })}
109
250
  </div>
110
- )}
111
- {bridges.length === 0 ? (
112
- <div className="text-[12px] text-text-dim text-center px-4 py-6">
113
- {isRuntimeRunning ? '暂无页面数据(仅显示 Page 级 data)' : '小程序未运行'}
251
+ </div>
252
+ <div className="flex-1 flex flex-col overflow-hidden">
253
+ <div
254
+ data-testid="appdata-toolbar"
255
+ className="flex items-center gap-1 px-2 py-0.5 border-b border-border-subtle shrink-0 bg-bg-panel"
256
+ >
257
+ <ToolbarButton title="全部展开" onClick={() => issueCommand('expanded')}>⊕</ToolbarButton>
258
+ <ToolbarButton title="全部收起" onClick={() => issueCommand('collapsed')}>⊖</ToolbarButton>
259
+ <ToolbarButton title="撤销" disabled={undoStack.length === 0 || writePending} onClick={undo}>↶</ToolbarButton>
260
+ <ToolbarButton title="重做" disabled={redoStack.length === 0 || writePending} onClick={redo}>↷</ToolbarButton>
114
261
  </div>
115
- ) : (
116
- // A bridge with zero entries still gets its keepalive container (with
117
- // the per-bridge empty text) so live pushes land in a mounted tab.
118
- // Keepalive: render every bridge's entries; hide non-active ones via
119
- // `display: none` so the JsonView instances stay mounted and preserve
120
- // their expand/collapse state across tab switches.
262
+ {/* Keepalive: every bridge's tree stays mounted (hidden via display:
263
+ none) so expand/collapse state survives page switches. */}
121
264
  <div className="flex-1 overflow-hidden relative">
122
265
  {bridges.map((b) => {
123
266
  const isActive = b.id === activeBridgeId
124
267
  const bridgeEntries = entries[b.id] ?? {}
125
- const keys = Object.keys(bridgeEntries)
268
+ const hasData = Object.keys(bridgeEntries).length > 0
126
269
  return (
127
270
  <div
128
271
  key={b.id}
129
272
  data-bridge-id={b.id}
130
- className="absolute inset-0 flex flex-col gap-2 p-2 overflow-y-auto"
273
+ className="absolute inset-0 flex-col overflow-y-auto"
131
274
  style={{ display: isActive ? 'flex' : 'none' }}
132
275
  >
133
- {keys.length === 0 ? (
134
- <div className="text-[12px] text-text-dim text-center px-4 py-6">
135
- {isRuntimeRunning ? '暂无页面数据(仅显示 Page 级 data)' : '小程序未运行'}
136
- </div>
137
- ) : (
138
- keys.map((comp) => (
139
- <div
140
- key={`${b.id}::${comp}`}
141
- className="border border-border-subtle rounded overflow-hidden shrink-0"
142
- >
143
- <div className="bg-surface-3 px-2 py-0.5 text-[11px] text-code-label truncate">
144
- {comp}
145
- </div>
146
- <JsonView
147
- value={(bridgeEntries[comp] ?? {}) as object}
148
- collapsed={1}
149
- displayDataTypes={false}
150
- displayObjectSize={false}
151
- enableClipboard={false}
152
- indentWidth={12}
153
- style={JSON_VIEW_STYLE}
276
+ {hasData
277
+ ? (
278
+ <AppDataTree
279
+ root={mergeEntries(bridgeEntries)}
280
+ bridgeId={b.id}
281
+ command={command}
282
+ onCommit={onSetData ? commitEdit(b.id) : undefined}
154
283
  />
155
- </div>
156
- ))
157
- )}
284
+ )
285
+ : (
286
+ <div className="text-[12px] text-text-dim text-center px-4 py-6">{emptyText}</div>
287
+ )}
158
288
  </div>
159
289
  )
160
290
  })}
161
291
  </div>
162
- )}
292
+ </div>
163
293
  </div>
164
294
  )
165
295
  }
@@ -15,4 +15,9 @@ export interface AppDataPanelSource {
15
15
  /** Visibility gate: hosts whose feed costs something (listeners, walks)
16
16
  * only keep it armed while some panel is visible. */
17
17
  setActive(on: boolean): void
18
+ /** Write an edit back into the running page (`page.setData(patch)`); patch
19
+ * keys use setData path syntax (`a.b`, `list[0].id`). Resolves true when the
20
+ * write was dispatched to a live runtime. Absent → the host has no
21
+ * write-back channel and the panel renders read-only. */
22
+ setData?: (bridgeId: string, patch: Record<string, unknown>) => Promise<boolean>
18
23
  }