@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 +7 -1
- package/dist/appdata-panel-view.js +155 -58
- package/dist/appdata-tree.js +143 -0
- package/dist/connected-appdata-panel.js +9 -1
- package/package.json +1 -1
- package/src/appdata-edge-values.test.tsx +150 -0
- package/src/appdata-panel-view-wechat.test.tsx +268 -0
- package/src/appdata-panel-view.tsx +237 -107
- package/src/appdata-source.ts +5 -0
- package/src/appdata-tree-edit-hardening.test.tsx +384 -0
- package/src/appdata-tree-edit.test.tsx +247 -0
- package/src/appdata-tree.tsx +265 -0
- package/src/appdata-write-serialization.test.tsx +134 -0
- package/src/connected-appdata-panel.test.tsx +23 -15
- package/src/connected-appdata-panel.tsx +10 -0
|
@@ -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
|
|
2
|
-
//
|
|
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
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
112
|
-
<div
|
|
113
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
273
|
+
className="absolute inset-0 flex-col overflow-y-auto"
|
|
131
274
|
style={{ display: isActive ? 'flex' : 'none' }}
|
|
132
275
|
>
|
|
133
|
-
{
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/appdata-source.ts
CHANGED
|
@@ -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
|
}
|