@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.
@@ -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">&quot;{v}&quot;</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
+ }