@dimina-kit/inspect 0.3.0-dev.20260711141929
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/LICENSE +21 -0
- package/README.md +56 -0
- package/dist/connected-panel.js +28 -0
- package/dist/connected-storage-panel.js +33 -0
- package/dist/index.js +4 -0
- package/dist/inspector.js +122 -0
- package/dist/panel-source.js +1 -0
- package/dist/panel-view.js +136 -0
- package/dist/panel.js +7 -0
- package/dist/sid-registry.js +45 -0
- package/dist/storage-panel-view.js +127 -0
- package/dist/storage-reducer.js +25 -0
- package/dist/storage-source.js +1 -0
- package/dist/storage-types.js +4 -0
- package/dist/types.js +5 -0
- package/dist/use-source-wiring.js +52 -0
- package/dist/wxml-extract.js +381 -0
- package/package.json +72 -0
- package/src/connected-panel.test.tsx +224 -0
- package/src/connected-panel.tsx +53 -0
- package/src/connected-storage-panel.test.tsx +289 -0
- package/src/connected-storage-panel.tsx +62 -0
- package/src/index.ts +16 -0
- package/src/inspector.test.ts +282 -0
- package/src/inspector.ts +144 -0
- package/src/panel-source.ts +20 -0
- package/src/panel-view.tsx +355 -0
- package/src/panel.tsx +7 -0
- package/src/sid-registry.ts +45 -0
- package/src/storage-panel-view.tsx +252 -0
- package/src/storage-reducer.test.ts +80 -0
- package/src/storage-reducer.ts +26 -0
- package/src/storage-source.ts +28 -0
- package/src/storage-types.ts +26 -0
- package/src/types.ts +35 -0
- package/src/use-source-wiring.ts +69 -0
- package/src/wxml-extract.ts +366 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { ElementInspection, WxmlNode } from './types.js'
|
|
3
|
+
|
|
4
|
+
interface WxmlPanelProps {
|
|
5
|
+
tree: WxmlNode | null
|
|
6
|
+
onInspectElement?: (sid: string) => Promise<ElementInspection | null>
|
|
7
|
+
onClearInspection?: () => Promise<void>
|
|
8
|
+
/** Whether the mini-program's runtime session is `running` — distinguishes "小程序未运行" from the normal "waiting for the tree" empty state below. Defaults to true so callers that don't track runtime status keep the plain waiting text. */
|
|
9
|
+
isRuntimeRunning?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface WxmlTreeNodeProps {
|
|
13
|
+
node: WxmlNode
|
|
14
|
+
depth: number
|
|
15
|
+
inspectedSid?: string | null
|
|
16
|
+
onInspect?: (node: WxmlNode) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 默认是否展开:对齐微信开发者工具的 wxml 面板。
|
|
21
|
+
* - `#shadow-root` 永远默认展开(组件边界标记,展开后才能看到内部结构)
|
|
22
|
+
* - 路径式 tag(含 `/`)= 页面或自定义组件,默认展开(让用户一眼看清组件层级)
|
|
23
|
+
* - 普通 DOM 节点(view/text 等)默认折叠,需要用户手动点开
|
|
24
|
+
*/
|
|
25
|
+
function isDefaultExpanded(node: WxmlNode): boolean {
|
|
26
|
+
if (node.tagName === '#shadow-root') return true
|
|
27
|
+
if (node.tagName.includes('/')) return true
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Renders a `[key, value]` attr list as `{' '}key="value"` pairs. */
|
|
32
|
+
function AttrList({ entries }: { entries: Array<[string, string]> }) {
|
|
33
|
+
return (
|
|
34
|
+
<>
|
|
35
|
+
{entries.map(([k, v]) => (
|
|
36
|
+
<span key={k}>
|
|
37
|
+
{' '}
|
|
38
|
+
<span className="text-code-blue">{k}</span>
|
|
39
|
+
<span className="text-text-dim">=</span>
|
|
40
|
+
<span className="text-code-orange">"{v}"</span>
|
|
41
|
+
</span>
|
|
42
|
+
))}
|
|
43
|
+
</>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Text node — render as plain text.
|
|
48
|
+
function WxmlTextNode({ node, depth }: { node: WxmlNode, depth: number }) {
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
className="py-px leading-[18px] hover:bg-surface-2"
|
|
52
|
+
style={{ paddingLeft: depth * 16 }}
|
|
53
|
+
>
|
|
54
|
+
<span className="w-3 inline-block" />
|
|
55
|
+
<span className="text-text">{node.text}</span>
|
|
56
|
+
</div>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Fragment — transparent root wrapper; render children directly without extra depth/tag.
|
|
61
|
+
function WxmlFragmentNode({ node, depth, inspectedSid, onInspect }: WxmlTreeNodeProps) {
|
|
62
|
+
return (
|
|
63
|
+
<>
|
|
64
|
+
{(node.children ?? []).map((child, i) => (
|
|
65
|
+
<WxmlTreeNode
|
|
66
|
+
key={i}
|
|
67
|
+
node={child}
|
|
68
|
+
depth={depth}
|
|
69
|
+
inspectedSid={inspectedSid}
|
|
70
|
+
onInspect={onInspect}
|
|
71
|
+
/>
|
|
72
|
+
))}
|
|
73
|
+
</>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Shadow root — synthetic boundary for custom component internals.
|
|
78
|
+
// Clickable to collapse like WeChat DevTools; default expanded; no closing tag.
|
|
79
|
+
function WxmlShadowRootNode({ node, depth, inspectedSid, onInspect }: WxmlTreeNodeProps) {
|
|
80
|
+
const [expanded, setExpanded] = useState(() => isDefaultExpanded(node))
|
|
81
|
+
const indent = depth * 16
|
|
82
|
+
const hasShadowChildren = (node.children ?? []).length > 0
|
|
83
|
+
return (
|
|
84
|
+
<div>
|
|
85
|
+
<div
|
|
86
|
+
className="py-px leading-[18px] hover:bg-surface-2 cursor-pointer"
|
|
87
|
+
style={{ paddingLeft: indent }}
|
|
88
|
+
onClick={() => hasShadowChildren && setExpanded(!expanded)}
|
|
89
|
+
>
|
|
90
|
+
<span className="text-text-dim w-3 shrink-0 inline-block text-center select-none">
|
|
91
|
+
{hasShadowChildren ? (expanded ? '▾' : '▸') : ' '}
|
|
92
|
+
</span>
|
|
93
|
+
<span className="text-text-dim italic">#shadow-root</span>
|
|
94
|
+
</div>
|
|
95
|
+
{expanded && node.children.map((child, i) => (
|
|
96
|
+
<WxmlTreeNode
|
|
97
|
+
key={i}
|
|
98
|
+
node={child}
|
|
99
|
+
depth={depth + 1}
|
|
100
|
+
inspectedSid={inspectedSid}
|
|
101
|
+
onInspect={onInspect}
|
|
102
|
+
/>
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface WxmlRowProps {
|
|
109
|
+
node: WxmlNode
|
|
110
|
+
indent: number
|
|
111
|
+
attrEntries: Array<[string, string]>
|
|
112
|
+
isInspected: boolean
|
|
113
|
+
onMouseEnter: () => void
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Single text child — render inline: <tag>text</tag>
|
|
117
|
+
function WxmlInlineTextRow({
|
|
118
|
+
node,
|
|
119
|
+
indent,
|
|
120
|
+
attrEntries,
|
|
121
|
+
isInspected,
|
|
122
|
+
onMouseEnter,
|
|
123
|
+
inlineText,
|
|
124
|
+
}: WxmlRowProps & { inlineText: string }) {
|
|
125
|
+
const rowClassName = `py-px leading-[18px] hover:bg-surface-2${isInspected ? ' bg-surface-2' : ''}`
|
|
126
|
+
return (
|
|
127
|
+
<div
|
|
128
|
+
className={rowClassName}
|
|
129
|
+
style={{ paddingLeft: indent }}
|
|
130
|
+
onMouseEnter={onMouseEnter}
|
|
131
|
+
data-wxml-sid={node.sid}
|
|
132
|
+
>
|
|
133
|
+
<span className="w-3 inline-block" />
|
|
134
|
+
<span className="text-code-keyword">{'<'}{node.tagName}</span>
|
|
135
|
+
<AttrList entries={attrEntries} />
|
|
136
|
+
<span className="text-code-keyword">{'>'}</span>
|
|
137
|
+
<span className="text-text">{inlineText}</span>
|
|
138
|
+
<span className="text-code-keyword">{'</'}{node.tagName}{'>'}</span>
|
|
139
|
+
</div>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface WxmlBlockRowProps extends WxmlRowProps {
|
|
144
|
+
depth: number
|
|
145
|
+
hasChildren: boolean
|
|
146
|
+
expanded: boolean
|
|
147
|
+
inspectedSid?: string | null
|
|
148
|
+
onInspect?: (node: WxmlNode) => void
|
|
149
|
+
onToggle: () => void
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Full open/close-tag row, with children rendered underneath when expanded.
|
|
153
|
+
function WxmlBlockRow({
|
|
154
|
+
node,
|
|
155
|
+
depth,
|
|
156
|
+
indent,
|
|
157
|
+
attrEntries,
|
|
158
|
+
hasChildren,
|
|
159
|
+
expanded,
|
|
160
|
+
isInspected,
|
|
161
|
+
inspectedSid,
|
|
162
|
+
onInspect,
|
|
163
|
+
onToggle,
|
|
164
|
+
onMouseEnter,
|
|
165
|
+
}: WxmlBlockRowProps) {
|
|
166
|
+
return (
|
|
167
|
+
<div>
|
|
168
|
+
<div
|
|
169
|
+
className={`flex items-start hover:bg-surface-2 py-px leading-[18px]${hasChildren ? ' cursor-pointer' : ''}${isInspected ? ' bg-surface-2' : ''}`}
|
|
170
|
+
style={{ paddingLeft: indent }}
|
|
171
|
+
onMouseEnter={onMouseEnter}
|
|
172
|
+
onClick={() => {
|
|
173
|
+
if (hasChildren) onToggle()
|
|
174
|
+
}}
|
|
175
|
+
data-wxml-sid={node.sid}
|
|
176
|
+
>
|
|
177
|
+
<span className="text-text-dim w-3 shrink-0 text-center select-none">
|
|
178
|
+
{hasChildren ? (expanded ? '▾' : '▸') : ' '}
|
|
179
|
+
</span>
|
|
180
|
+
<span>
|
|
181
|
+
<span className="text-code-keyword">{'<'}{node.tagName}</span>
|
|
182
|
+
<AttrList entries={attrEntries} />
|
|
183
|
+
<span className="text-code-keyword">{hasChildren ? '>' : ' />'}</span>
|
|
184
|
+
</span>
|
|
185
|
+
</div>
|
|
186
|
+
{expanded && hasChildren && (
|
|
187
|
+
<>
|
|
188
|
+
{node.children.map((child, i) => (
|
|
189
|
+
<WxmlTreeNode
|
|
190
|
+
key={i}
|
|
191
|
+
node={child}
|
|
192
|
+
depth={depth + 1}
|
|
193
|
+
inspectedSid={inspectedSid}
|
|
194
|
+
onInspect={onInspect}
|
|
195
|
+
/>
|
|
196
|
+
))}
|
|
197
|
+
<div style={{ paddingLeft: indent }} className="py-px leading-[18px]">
|
|
198
|
+
<span className="w-3 inline-block" />
|
|
199
|
+
<span className="text-code-keyword">{'</'}{node.tagName}{'>'}</span>
|
|
200
|
+
</div>
|
|
201
|
+
</>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Ordinary element node — decides between the inline-text and block layouts.
|
|
208
|
+
function WxmlElementNode({ node, depth, inspectedSid, onInspect }: WxmlTreeNodeProps) {
|
|
209
|
+
const [expanded, setExpanded] = useState(() => isDefaultExpanded(node))
|
|
210
|
+
const indent = depth * 16
|
|
211
|
+
const isInspected = Boolean(node.sid && node.sid === inspectedSid)
|
|
212
|
+
const hasChildren = (node.children ?? []).length > 0
|
|
213
|
+
const attrEntries = Object.entries(node.attrs) as Array<[string, string]>
|
|
214
|
+
const inspect = () => {
|
|
215
|
+
if (node.sid) onInspect?.(node)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Single text child — render inline: <tag>text</tag>
|
|
219
|
+
const inlineText = hasChildren && node.children.length === 1 && node.children[0]!.tagName === '#text'
|
|
220
|
+
? node.children[0]!.text
|
|
221
|
+
: null
|
|
222
|
+
|
|
223
|
+
if (inlineText) {
|
|
224
|
+
return (
|
|
225
|
+
<WxmlInlineTextRow
|
|
226
|
+
node={node}
|
|
227
|
+
indent={indent}
|
|
228
|
+
attrEntries={attrEntries}
|
|
229
|
+
isInspected={isInspected}
|
|
230
|
+
onMouseEnter={inspect}
|
|
231
|
+
inlineText={inlineText}
|
|
232
|
+
/>
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
<WxmlBlockRow
|
|
238
|
+
node={node}
|
|
239
|
+
depth={depth}
|
|
240
|
+
indent={indent}
|
|
241
|
+
attrEntries={attrEntries}
|
|
242
|
+
hasChildren={hasChildren}
|
|
243
|
+
expanded={expanded}
|
|
244
|
+
isInspected={isInspected}
|
|
245
|
+
inspectedSid={inspectedSid}
|
|
246
|
+
onInspect={onInspect}
|
|
247
|
+
onToggle={() => setExpanded(!expanded)}
|
|
248
|
+
onMouseEnter={inspect}
|
|
249
|
+
/>
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function WxmlTreeNode({ node, depth, inspectedSid, onInspect }: WxmlTreeNodeProps) {
|
|
254
|
+
if (node.tagName === '#text') return <WxmlTextNode node={node} depth={depth} />
|
|
255
|
+
if (node.tagName === '#fragment') {
|
|
256
|
+
return <WxmlFragmentNode node={node} depth={depth} inspectedSid={inspectedSid} onInspect={onInspect} />
|
|
257
|
+
}
|
|
258
|
+
if (node.tagName === '#shadow-root') {
|
|
259
|
+
return <WxmlShadowRootNode node={node} depth={depth} inspectedSid={inspectedSid} onInspect={onInspect} />
|
|
260
|
+
}
|
|
261
|
+
return <WxmlElementNode node={node} depth={depth} inspectedSid={inspectedSid} onInspect={onInspect} />
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function InspectionFooter({ inspection }: { inspection: ElementInspection | null }) {
|
|
265
|
+
if (!inspection) return null
|
|
266
|
+
const { rect, style } = inspection
|
|
267
|
+
const styleParts = [
|
|
268
|
+
style.display,
|
|
269
|
+
style.position !== 'static' ? style.position : null,
|
|
270
|
+
style.boxSizing,
|
|
271
|
+
].filter(Boolean)
|
|
272
|
+
return (
|
|
273
|
+
<div className="border-t border-border-subtle bg-bg-panel px-2.5 py-1.5 font-mono text-[11px] text-text-dim shrink-0">
|
|
274
|
+
<span className="text-text">box</span>
|
|
275
|
+
{' '}
|
|
276
|
+
{Math.round(rect.width)} x {Math.round(rect.height)}
|
|
277
|
+
{' @ '}
|
|
278
|
+
{Math.round(rect.x)}, {Math.round(rect.y)}
|
|
279
|
+
<span className="mx-2 text-border-subtle">|</span>
|
|
280
|
+
{styleParts.join(' / ')}
|
|
281
|
+
<span className="mx-2 text-border-subtle">|</span>
|
|
282
|
+
font {style.fontSize}
|
|
283
|
+
</div>
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function WxmlPanel({
|
|
288
|
+
tree,
|
|
289
|
+
onInspectElement,
|
|
290
|
+
onClearInspection,
|
|
291
|
+
isRuntimeRunning = true,
|
|
292
|
+
}: WxmlPanelProps) {
|
|
293
|
+
const [inspection, setInspection] = useState<ElementInspection | null>(null)
|
|
294
|
+
// 序号 + rAF 用于解决 hover 触发的两个独立问题:
|
|
295
|
+
// - reqSeqRef:防止 await 完成顺序与 hover 顺序不一致导致 footer 抖动;
|
|
296
|
+
// 每次发起请求自增,写入前比对,落后的响应直接丢弃。
|
|
297
|
+
// - rafRef:把 hover 触发的 IPC 合并到下一帧,连续扫过列表只会留最后一帧。
|
|
298
|
+
const reqSeqRef = useRef(0)
|
|
299
|
+
const rafRef = useRef<number | null>(null)
|
|
300
|
+
|
|
301
|
+
useEffect(() => () => {
|
|
302
|
+
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
|
|
303
|
+
}, [])
|
|
304
|
+
|
|
305
|
+
const inspectNode = useCallback((node: WxmlNode) => {
|
|
306
|
+
if (!node.sid || !onInspectElement) return
|
|
307
|
+
const sid = node.sid
|
|
308
|
+
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
|
|
309
|
+
rafRef.current = requestAnimationFrame(() => {
|
|
310
|
+
rafRef.current = null
|
|
311
|
+
const seq = ++reqSeqRef.current
|
|
312
|
+
onInspectElement(sid).then((next) => {
|
|
313
|
+
if (seq !== reqSeqRef.current) return
|
|
314
|
+
setInspection(next)
|
|
315
|
+
}).catch(() => {
|
|
316
|
+
if (seq !== reqSeqRef.current) return
|
|
317
|
+
setInspection(null)
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
}, [onInspectElement])
|
|
321
|
+
|
|
322
|
+
const clearInspection = useCallback(() => {
|
|
323
|
+
if (rafRef.current !== null) {
|
|
324
|
+
cancelAnimationFrame(rafRef.current)
|
|
325
|
+
rafRef.current = null
|
|
326
|
+
}
|
|
327
|
+
// 让所有 in-flight 响应被序号校验丢弃,避免它们在 clear 之后又把 inspection 写回来。
|
|
328
|
+
reqSeqRef.current++
|
|
329
|
+
setInspection(null)
|
|
330
|
+
void onClearInspection?.()
|
|
331
|
+
}, [onClearInspection])
|
|
332
|
+
|
|
333
|
+
if (!tree) {
|
|
334
|
+
return (
|
|
335
|
+
<div className="flex flex-col flex-1 overflow-hidden" data-testid="wxml-panel">
|
|
336
|
+
<div className="text-[12px] text-text-dim text-center px-4 py-6">
|
|
337
|
+
{isRuntimeRunning ? '等待小程序加载...' : '小程序未运行'}
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
return (
|
|
343
|
+
<div className="flex flex-col flex-1 overflow-hidden" onMouseLeave={clearInspection} data-testid="wxml-panel">
|
|
344
|
+
<div className="flex-1 overflow-y-auto p-2 font-mono text-[12px]">
|
|
345
|
+
<WxmlTreeNode
|
|
346
|
+
node={tree}
|
|
347
|
+
depth={0}
|
|
348
|
+
inspectedSid={inspection?.sid ?? null}
|
|
349
|
+
onInspect={inspectNode}
|
|
350
|
+
/>
|
|
351
|
+
</div>
|
|
352
|
+
<InspectionFooter inspection={inspection} />
|
|
353
|
+
</div>
|
|
354
|
+
)
|
|
355
|
+
}
|
package/src/panel.tsx
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Public React entry (`@dimina-kit/inspect/panel`): the pure views plus the
|
|
2
|
+
// source-connected containers. Split into files so a pure view stays
|
|
3
|
+
// importable without its data-wiring layer.
|
|
4
|
+
export { WxmlPanel } from './panel-view.js'
|
|
5
|
+
export { ConnectedWxmlPanel, type ConnectedWxmlPanelProps } from './connected-panel.js'
|
|
6
|
+
export { StoragePanel, type StoragePanelProps } from './storage-panel-view.js'
|
|
7
|
+
export { ConnectedStoragePanel, type ConnectedStoragePanelProps } from './connected-storage-panel.js'
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Stable-id registry for WXML nodes, shared by every extractor that walks a
|
|
2
|
+
// render-layer document (host iframe extractors, injected guest inspectors,
|
|
3
|
+
// browser preview bridges). It is dependency-free so injected bundles stay
|
|
4
|
+
// small and don't drag in host-specific machinery.
|
|
5
|
+
|
|
6
|
+
// 合成 sid 注册表:用 WeakMap 把元素 ↔ sid 双向绑定,避免在源 DOM 上写
|
|
7
|
+
// `data-*` 属性(提取本应只读,且属性形式会污染用户的快照/选择器)。
|
|
8
|
+
// elBySyntheticSid 为反向查找用 WeakRef,元素被 GC 后下次 lookup 自动清理。
|
|
9
|
+
export const SYNTHETIC_SID_PREFIX = 'devtools-'
|
|
10
|
+
const syntheticSidByEl = new WeakMap<HTMLElement, string>()
|
|
11
|
+
const elBySyntheticSid = new Map<string, WeakRef<HTMLElement>>()
|
|
12
|
+
let nextSyntheticSid = 1
|
|
13
|
+
|
|
14
|
+
export function registerSyntheticSid(el: HTMLElement): string {
|
|
15
|
+
const existing = syntheticSidByEl.get(el)
|
|
16
|
+
if (existing) return existing
|
|
17
|
+
const synthetic = `${SYNTHETIC_SID_PREFIX}${nextSyntheticSid++}`
|
|
18
|
+
syntheticSidByEl.set(el, synthetic)
|
|
19
|
+
elBySyntheticSid.set(synthetic, new WeakRef(el))
|
|
20
|
+
return synthetic
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function findElementBySid(doc: Document, sid: string): HTMLElement | null {
|
|
24
|
+
if (sid.startsWith(SYNTHETIC_SID_PREFIX)) {
|
|
25
|
+
const ref = elBySyntheticSid.get(sid)
|
|
26
|
+
if (!ref) return null
|
|
27
|
+
const el = ref.deref()
|
|
28
|
+
if (!el || !el.isConnected) {
|
|
29
|
+
elBySyntheticSid.delete(sid)
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
if (el.ownerDocument !== doc) return null
|
|
33
|
+
return el
|
|
34
|
+
}
|
|
35
|
+
return doc.querySelector(`[data-sid="${escapeForAttrSelector(sid)}"]`) as HTMLElement | null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// `CSS.escape` only exists in real browser realms (jsdom has no `window.CSS`).
|
|
39
|
+
// Inside a double-quoted attribute selector, escaping backslashes and quotes
|
|
40
|
+
// is sufficient, so fall back to that when the host lacks the API.
|
|
41
|
+
function escapeForAttrSelector(value: string): string {
|
|
42
|
+
const impl = (globalThis as { CSS?: { escape?: (v: string) => string } }).CSS?.escape
|
|
43
|
+
if (impl) return impl(value)
|
|
44
|
+
return value.replace(/[\\"]/g, '\\$&')
|
|
45
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// The pure Storage table view: items in, write callbacks out. No data
|
|
2
|
+
// wiring lives here (seed/subscribe/visibility belong to
|
|
3
|
+
// ConnectedStoragePanel); no host UI kit either — the couple of buttons are
|
|
4
|
+
// plain elements styled with the same Tailwind utility tokens as the rest of
|
|
5
|
+
// the panel, so any host that maps the CSS variables gets the native look.
|
|
6
|
+
import React, { useEffect, useState } from 'react'
|
|
7
|
+
import type { StorageItem, StorageWriteResult } from './storage-types.js'
|
|
8
|
+
|
|
9
|
+
export interface StoragePanelProps {
|
|
10
|
+
items: StorageItem[]
|
|
11
|
+
onSet: (key: string, value: string) => Promise<StorageWriteResult>
|
|
12
|
+
onRemove: (key: string) => Promise<StorageWriteResult>
|
|
13
|
+
onClear: () => Promise<StorageWriteResult>
|
|
14
|
+
/** Origin-wide wipe across every appId. Hosts whose localStorage is shared
|
|
15
|
+
* with non-mini-program data (e.g. a browser workbench living on the same
|
|
16
|
+
* origin) must omit it; the「清空所有」button then never renders. */
|
|
17
|
+
onClearAll?: () => Promise<StorageWriteResult>
|
|
18
|
+
getPrefix: () => Promise<string>
|
|
19
|
+
/** Whether the mini-program's runtime session is running — distinguishes
|
|
20
|
+
* "小程序未运行" from a true empty-data vacuum below. Defaults to true so
|
|
21
|
+
* callers that don't track runtime status keep the plain empty-data text. */
|
|
22
|
+
isRuntimeRunning?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const BUTTON_CLASS
|
|
26
|
+
= 'inline-flex items-center justify-center gap-1.5 font-medium transition-colors '
|
|
27
|
+
+ 'focus:outline-none disabled:opacity-35 disabled:cursor-not-allowed whitespace-nowrap shrink-0 '
|
|
28
|
+
+ 'border border-border text-text-muted hover:text-text rounded h-5 px-2 text-[11px]'
|
|
29
|
+
|
|
30
|
+
export function StoragePanel({
|
|
31
|
+
items,
|
|
32
|
+
onSet,
|
|
33
|
+
onRemove,
|
|
34
|
+
onClear,
|
|
35
|
+
onClearAll,
|
|
36
|
+
getPrefix,
|
|
37
|
+
isRuntimeRunning = true,
|
|
38
|
+
}: StoragePanelProps) {
|
|
39
|
+
const [editing, setEditing] = useState<{ key: string, draft: string } | null>(null)
|
|
40
|
+
const [error, setError] = useState<string | null>(null)
|
|
41
|
+
const [busy, setBusy] = useState(false)
|
|
42
|
+
const [prefix, setPrefix] = useState('')
|
|
43
|
+
const [addKey, setAddKey] = useState('')
|
|
44
|
+
const [addValue, setAddValue] = useState('')
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
let cancelled = false
|
|
48
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
49
|
+
// The panel can mount before the host has resolved the active appId, so
|
|
50
|
+
// `getPrefix()` initially returns '' and the key un-prefixing below would
|
|
51
|
+
// never engage. The prefix is empty only transiently during session
|
|
52
|
+
// warmup, so poll until it resolves non-empty (then stop). Once set it is
|
|
53
|
+
// stable for the session's lifetime.
|
|
54
|
+
const load = () => {
|
|
55
|
+
void getPrefix().then((p) => {
|
|
56
|
+
if (cancelled) return
|
|
57
|
+
if (p) { setPrefix(p); return }
|
|
58
|
+
timer = setTimeout(load, 300)
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
load()
|
|
62
|
+
return () => { cancelled = true; if (timer) clearTimeout(timer) }
|
|
63
|
+
}, [getPrefix])
|
|
64
|
+
|
|
65
|
+
async function withBusy<T extends StorageWriteResult>(op: () => Promise<T>): Promise<T | null> {
|
|
66
|
+
setBusy(true)
|
|
67
|
+
setError(null)
|
|
68
|
+
try {
|
|
69
|
+
const r = await op()
|
|
70
|
+
if (!r.ok) { setError(r.error); return null }
|
|
71
|
+
return r
|
|
72
|
+
} finally {
|
|
73
|
+
setBusy(false)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function startEdit(item: StorageItem) {
|
|
78
|
+
setEditing({ key: item.key, draft: item.value })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function commitEdit() {
|
|
82
|
+
if (!editing) return
|
|
83
|
+
const { key, draft } = editing
|
|
84
|
+
setEditing(null)
|
|
85
|
+
await withBusy(() => onSet(key, draft))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function handleRemove(key: string) {
|
|
89
|
+
await withBusy(() => onRemove(key))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function handleClear() {
|
|
93
|
+
if (items.length === 0) return
|
|
94
|
+
await withBusy(() => onClear())
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function handleClearAll() {
|
|
98
|
+
if (!onClearAll) return
|
|
99
|
+
// Origin-wide wipe affects every appId's keys in the shared storage
|
|
100
|
+
// partition. Use `window.confirm` so the user has to opt in explicitly.
|
|
101
|
+
if (typeof window !== 'undefined' && !window.confirm('清空所有 appId 的 Storage 数据?该操作不可撤销。')) return
|
|
102
|
+
await withBusy(() => onClearAll())
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function handleAdd() {
|
|
106
|
+
const trimmedKey = addKey.trim()
|
|
107
|
+
if (!trimmedKey) { setError('key 不能为空'); return }
|
|
108
|
+
const fullKey = prefix + trimmedKey
|
|
109
|
+
const r = await withBusy(() => onSet(fullKey, addValue))
|
|
110
|
+
if (r) {
|
|
111
|
+
setAddKey('')
|
|
112
|
+
setAddValue('')
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div className="flex flex-col overflow-hidden flex-1" data-testid="storage-panel">
|
|
118
|
+
<div className="flex items-center gap-1.5 px-2.5 py-1.5 border-b border-border-subtle shrink-0 bg-bg-panel">
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
onClick={() => void handleClear()}
|
|
122
|
+
disabled={busy || items.length === 0}
|
|
123
|
+
className={`${BUTTON_CLASS} hover:border-status-error hover:text-status-error`}
|
|
124
|
+
title="仅清空当前 appId 的 Storage"
|
|
125
|
+
>
|
|
126
|
+
清空
|
|
127
|
+
</button>
|
|
128
|
+
{onClearAll && (
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
onClick={() => void handleClearAll()}
|
|
132
|
+
disabled={busy}
|
|
133
|
+
className={`${BUTTON_CLASS} hover:border-status-error hover:text-status-error`}
|
|
134
|
+
title="清空所有 appId 的 Storage"
|
|
135
|
+
>
|
|
136
|
+
清空所有
|
|
137
|
+
</button>
|
|
138
|
+
)}
|
|
139
|
+
{error && (
|
|
140
|
+
<span className="ml-2 text-[11px] text-status-error truncate" title={error}>
|
|
141
|
+
{error}
|
|
142
|
+
</span>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
<div className="flex-1 overflow-y-auto">
|
|
146
|
+
<table className="w-full border-collapse text-[12px]">
|
|
147
|
+
<thead>
|
|
148
|
+
<tr>
|
|
149
|
+
<th className="text-left text-code-label font-normal px-2.5 py-1 border-b border-border-subtle text-[11px] sticky top-0 bg-bg-panel z-10 w-px pr-5">
|
|
150
|
+
Key
|
|
151
|
+
</th>
|
|
152
|
+
<th className="text-left text-code-label font-normal px-2.5 py-1 border-b border-border-subtle text-[11px] sticky top-0 bg-bg-panel z-10">
|
|
153
|
+
Value
|
|
154
|
+
</th>
|
|
155
|
+
<th className="w-px sticky top-0 bg-bg-panel z-10 border-b border-border-subtle" />
|
|
156
|
+
</tr>
|
|
157
|
+
</thead>
|
|
158
|
+
<tbody>
|
|
159
|
+
{items.length === 0
|
|
160
|
+
? (
|
|
161
|
+
<tr>
|
|
162
|
+
<td colSpan={3} className="text-[12px] text-text-dim text-center px-4 py-6">
|
|
163
|
+
{isRuntimeRunning ? '暂无 Storage 数据' : '小程序未运行'}
|
|
164
|
+
</td>
|
|
165
|
+
</tr>
|
|
166
|
+
)
|
|
167
|
+
: items.map((item) => {
|
|
168
|
+
const isEditing = editing?.key === item.key
|
|
169
|
+
// Display keys without the active appId namespace prefix
|
|
170
|
+
// (`${appId}_`), the way Chrome's Local Storage panel shows
|
|
171
|
+
// clean keys. The full key (used for every read/write below)
|
|
172
|
+
// stays in the `title` for discoverability.
|
|
173
|
+
const displayKey = prefix && item.key.startsWith(prefix)
|
|
174
|
+
? item.key.slice(prefix.length)
|
|
175
|
+
: item.key
|
|
176
|
+
return (
|
|
177
|
+
<tr key={item.key} className="hover:[&>td]:bg-surface">
|
|
178
|
+
<td className="px-2.5 py-0.5 border-b border-border-subtle w-px pr-5 align-top">
|
|
179
|
+
<div className="font-mono text-code-blue max-w-[240px] truncate" title={item.key}>
|
|
180
|
+
{displayKey}
|
|
181
|
+
</div>
|
|
182
|
+
</td>
|
|
183
|
+
<td
|
|
184
|
+
className="px-2.5 py-0.5 border-b border-border-subtle font-mono text-code-orange break-all align-top cursor-text"
|
|
185
|
+
onClick={() => { if (!isEditing) startEdit(item) }}
|
|
186
|
+
>
|
|
187
|
+
{isEditing
|
|
188
|
+
? (
|
|
189
|
+
<input
|
|
190
|
+
autoFocus
|
|
191
|
+
type="text"
|
|
192
|
+
value={editing.draft}
|
|
193
|
+
onChange={e => setEditing({ key: item.key, draft: e.target.value })}
|
|
194
|
+
onBlur={() => void commitEdit()}
|
|
195
|
+
onKeyDown={(e) => {
|
|
196
|
+
if (e.key === 'Enter') { e.preventDefault(); void commitEdit() }
|
|
197
|
+
else if (e.key === 'Escape') { e.preventDefault(); setEditing(null) }
|
|
198
|
+
}}
|
|
199
|
+
className="w-full bg-transparent outline-none border border-accent/60 rounded px-1 py-0 font-mono text-code-orange"
|
|
200
|
+
/>
|
|
201
|
+
)
|
|
202
|
+
: item.value}
|
|
203
|
+
</td>
|
|
204
|
+
<td className="px-1 py-0.5 border-b border-border-subtle align-top">
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
onClick={() => void handleRemove(item.key)}
|
|
208
|
+
disabled={busy}
|
|
209
|
+
title="删除"
|
|
210
|
+
className="text-text-dim hover:text-status-error px-1 leading-none"
|
|
211
|
+
>
|
|
212
|
+
×
|
|
213
|
+
</button>
|
|
214
|
+
</td>
|
|
215
|
+
</tr>
|
|
216
|
+
)
|
|
217
|
+
})}
|
|
218
|
+
</tbody>
|
|
219
|
+
</table>
|
|
220
|
+
</div>
|
|
221
|
+
<div className="flex items-center gap-1.5 px-2.5 py-1.5 border-t border-border-subtle shrink-0 bg-bg-panel">
|
|
222
|
+
{/* New keys are auto-namespaced with the active appId prefix (see
|
|
223
|
+
handleAdd); the raw prefix is intentionally not shown — Chrome's
|
|
224
|
+
Local Storage panel likewise hides the storage-key namespace. */}
|
|
225
|
+
<input
|
|
226
|
+
type="text"
|
|
227
|
+
placeholder="key"
|
|
228
|
+
value={addKey}
|
|
229
|
+
onChange={e => setAddKey(e.target.value)}
|
|
230
|
+
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); void handleAdd() } }}
|
|
231
|
+
className="w-32 bg-transparent border border-border-subtle rounded px-1.5 py-0.5 text-[12px] font-mono outline-none focus:border-accent"
|
|
232
|
+
/>
|
|
233
|
+
<input
|
|
234
|
+
type="text"
|
|
235
|
+
placeholder="value"
|
|
236
|
+
value={addValue}
|
|
237
|
+
onChange={e => setAddValue(e.target.value)}
|
|
238
|
+
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); void handleAdd() } }}
|
|
239
|
+
className="flex-1 bg-transparent border border-border-subtle rounded px-1.5 py-0.5 text-[12px] font-mono outline-none focus:border-accent"
|
|
240
|
+
/>
|
|
241
|
+
<button
|
|
242
|
+
type="button"
|
|
243
|
+
onClick={() => void handleAdd()}
|
|
244
|
+
disabled={busy || !addKey.trim()}
|
|
245
|
+
className={`${BUTTON_CLASS} hover:border-accent hover:text-accent`}
|
|
246
|
+
>
|
|
247
|
+
+ 新增
|
|
248
|
+
</button>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
)
|
|
252
|
+
}
|