@three-flatland/devtools 0.1.0-alpha.3
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 +82 -0
- package/dist/buffers-modal.cjs +491 -0
- package/dist/buffers-modal.cjs.map +1 -0
- package/dist/buffers-modal.d.cts +62 -0
- package/dist/buffers-modal.d.ts +62 -0
- package/dist/buffers-modal.js +467 -0
- package/dist/buffers-modal.js.map +1 -0
- package/dist/buffers-view.cjs +269 -0
- package/dist/buffers-view.cjs.map +1 -0
- package/dist/buffers-view.d.cts +39 -0
- package/dist/buffers-view.d.ts +39 -0
- package/dist/buffers-view.js +245 -0
- package/dist/buffers-view.js.map +1 -0
- package/dist/create-pane.cjs +203 -0
- package/dist/create-pane.cjs.map +1 -0
- package/dist/create-pane.d.cts +66 -0
- package/dist/create-pane.d.ts +66 -0
- package/dist/create-pane.js +178 -0
- package/dist/create-pane.js.map +1 -0
- package/dist/dashboard/app.tsx +42 -0
- package/dist/dashboard/client.ts +21 -0
- package/dist/dashboard/export.ts +84 -0
- package/dist/dashboard/hooks.ts +75 -0
- package/dist/dashboard/index.html +1149 -0
- package/dist/dashboard/index.tsx +18 -0
- package/dist/dashboard/panels/batches.tsx +282 -0
- package/dist/dashboard/panels/buffers.tsx +425 -0
- package/dist/dashboard/panels/env.tsx +85 -0
- package/dist/dashboard/panels/header-stats.tsx +30 -0
- package/dist/dashboard/panels/producer-select.tsx +81 -0
- package/dist/dashboard/panels/protocol-log.tsx +743 -0
- package/dist/dashboard/panels/registry.tsx +642 -0
- package/dist/dashboard/panels/stats.tsx +431 -0
- package/dist/dashboard/protocol-store.ts +312 -0
- package/dist/dashboard/tsconfig.json +19 -0
- package/dist/dashboard/vendor/hooks.module.js +1 -0
- package/dist/dashboard/vendor/jsx-runtime.js +22 -0
- package/dist/dashboard/vendor/preact.module.js +12 -0
- package/dist/devtools-client.cjs +870 -0
- package/dist/devtools-client.cjs.map +1 -0
- package/dist/devtools-client.d.cts +362 -0
- package/dist/devtools-client.d.ts +362 -0
- package/dist/devtools-client.js +852 -0
- package/dist/devtools-client.js.map +1 -0
- package/dist/index.cjs +42 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/perf-trace.cjs +103 -0
- package/dist/perf-trace.cjs.map +1 -0
- package/dist/perf-trace.d.cts +27 -0
- package/dist/perf-trace.d.ts +27 -0
- package/dist/perf-trace.js +79 -0
- package/dist/perf-trace.js.map +1 -0
- package/dist/plugins.cjs +44 -0
- package/dist/plugins.cjs.map +1 -0
- package/dist/plugins.d.cts +7 -0
- package/dist/plugins.d.ts +7 -0
- package/dist/plugins.js +9 -0
- package/dist/plugins.js.map +1 -0
- package/dist/provider-switcher.cjs +95 -0
- package/dist/provider-switcher.cjs.map +1 -0
- package/dist/provider-switcher.d.cts +18 -0
- package/dist/provider-switcher.d.ts +18 -0
- package/dist/provider-switcher.js +71 -0
- package/dist/provider-switcher.js.map +1 -0
- package/dist/react/devtools-provider.cjs +59 -0
- package/dist/react/devtools-provider.cjs.map +1 -0
- package/dist/react/devtools-provider.d.cts +39 -0
- package/dist/react/devtools-provider.d.ts +39 -0
- package/dist/react/devtools-provider.js +38 -0
- package/dist/react/devtools-provider.js.map +1 -0
- package/dist/react/use-fps-graph.cjs +49 -0
- package/dist/react/use-fps-graph.cjs.map +1 -0
- package/dist/react/use-fps-graph.d.cts +15 -0
- package/dist/react/use-fps-graph.d.ts +15 -0
- package/dist/react/use-fps-graph.js +25 -0
- package/dist/react/use-fps-graph.js.map +1 -0
- package/dist/react/use-pane-button.cjs +58 -0
- package/dist/react/use-pane-button.cjs.map +1 -0
- package/dist/react/use-pane-button.d.cts +10 -0
- package/dist/react/use-pane-button.d.ts +10 -0
- package/dist/react/use-pane-button.js +34 -0
- package/dist/react/use-pane-button.js.map +1 -0
- package/dist/react/use-pane-folder.cjs +48 -0
- package/dist/react/use-pane-folder.cjs.map +1 -0
- package/dist/react/use-pane-folder.d.cts +15 -0
- package/dist/react/use-pane-folder.d.ts +15 -0
- package/dist/react/use-pane-folder.js +24 -0
- package/dist/react/use-pane-folder.js.map +1 -0
- package/dist/react/use-pane-input.cjs +70 -0
- package/dist/react/use-pane-input.cjs.map +1 -0
- package/dist/react/use-pane-input.d.cts +44 -0
- package/dist/react/use-pane-input.d.ts +44 -0
- package/dist/react/use-pane-input.js +46 -0
- package/dist/react/use-pane-input.js.map +1 -0
- package/dist/react/use-pane-radio-grid.cjs +78 -0
- package/dist/react/use-pane-radio-grid.cjs.map +1 -0
- package/dist/react/use-pane-radio-grid.d.cts +35 -0
- package/dist/react/use-pane-radio-grid.d.ts +35 -0
- package/dist/react/use-pane-radio-grid.js +54 -0
- package/dist/react/use-pane-radio-grid.js.map +1 -0
- package/dist/react/use-pane.cjs +45 -0
- package/dist/react/use-pane.cjs.map +1 -0
- package/dist/react/use-pane.d.cts +40 -0
- package/dist/react/use-pane.d.ts +40 -0
- package/dist/react/use-pane.js +21 -0
- package/dist/react/use-pane.js.map +1 -0
- package/dist/react.cjs +50 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +12 -0
- package/dist/react.d.ts +12 -0
- package/dist/react.js +19 -0
- package/dist/react.js.map +1 -0
- package/dist/registry-view.cjs +371 -0
- package/dist/registry-view.cjs.map +1 -0
- package/dist/registry-view.d.cts +24 -0
- package/dist/registry-view.d.ts +24 -0
- package/dist/registry-view.js +347 -0
- package/dist/registry-view.js.map +1 -0
- package/dist/stats-graph.cjs +297 -0
- package/dist/stats-graph.cjs.map +1 -0
- package/dist/stats-graph.d.cts +40 -0
- package/dist/stats-graph.d.ts +40 -0
- package/dist/stats-graph.js +273 -0
- package/dist/stats-graph.js.map +1 -0
- package/dist/stats-row.cjs +153 -0
- package/dist/stats-row.cjs.map +1 -0
- package/dist/stats-row.d.cts +24 -0
- package/dist/stats-row.d.ts +24 -0
- package/dist/stats-row.js +129 -0
- package/dist/stats-row.js.map +1 -0
- package/dist/theme.cjs +260 -0
- package/dist/theme.cjs.map +1 -0
- package/dist/theme.d.cts +10 -0
- package/dist/theme.d.ts +10 -0
- package/dist/theme.js +235 -0
- package/dist/theme.js.map +1 -0
- package/dist/vite-plugin.cjs +113 -0
- package/dist/vite-plugin.cjs.map +1 -0
- package/dist/vite-plugin.d.cts +9 -0
- package/dist/vite-plugin.d.ts +9 -0
- package/dist/vite-plugin.js +88 -0
- package/dist/vite-plugin.js.map +1 -0
- package/package.json +124 -0
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
/**
|
|
3
|
+
* Protocol log panel — virtualized, fixed-row, tail-pinned.
|
|
4
|
+
*
|
|
5
|
+
* The client mutates its `state` in place, so a time-series of *messages*
|
|
6
|
+
* isn't available from state alone. A local ring buffer here is fed by
|
|
7
|
+
* `addRawMessageListener`. Because messages arrive at up to ~4Hz × many
|
|
8
|
+
* streams, we render a windowed slice of the ring — only rows in the
|
|
9
|
+
* visible viewport mount to the DOM.
|
|
10
|
+
*
|
|
11
|
+
* Scroll model:
|
|
12
|
+
* - Newest entry at top (visual index 0), oldest at the bottom.
|
|
13
|
+
* - Tail on: scrollTop = 0 after every update; the viewport tracks
|
|
14
|
+
* the head of the stream.
|
|
15
|
+
* - Tail off: scrollTop stays put in terms of *content*, not pixels —
|
|
16
|
+
* each newly prepended entry bumps scrollTop by exactly ROW_HEIGHT
|
|
17
|
+
* so the rows the user is reading don't drift downward on screen.
|
|
18
|
+
* - User scrolls to within TAIL_RESUME_PIX of the top and holds for
|
|
19
|
+
* RESUME_TAIL_MS → auto-resume.
|
|
20
|
+
*
|
|
21
|
+
* Row detail:
|
|
22
|
+
* - Click a row → opens the side detail pane with the full message
|
|
23
|
+
* JSON. List height stays uniform; expanding/collapsing doesn't
|
|
24
|
+
* reflow the virtualized rows.
|
|
25
|
+
*/
|
|
26
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks'
|
|
27
|
+
import type { DebugMessage } from 'three-flatland/debug-protocol'
|
|
28
|
+
import { getClient } from '../client.js'
|
|
29
|
+
import { exportSession } from '../export.js'
|
|
30
|
+
import { useDevtoolsState } from '../hooks.js'
|
|
31
|
+
import { getProtocolStore, type LogEntry } from '../protocol-store.js'
|
|
32
|
+
|
|
33
|
+
const ROW_HEIGHT = 22
|
|
34
|
+
const OVERSCAN = 6
|
|
35
|
+
const TAIL_RESUME_PIX = 4
|
|
36
|
+
const RESUME_TAIL_MS = 600
|
|
37
|
+
|
|
38
|
+
export function ProtocolLog() {
|
|
39
|
+
const client = getClient()
|
|
40
|
+
const store = getProtocolStore()
|
|
41
|
+
const state = useDevtoolsState()
|
|
42
|
+
const activeProviderId = state.selectedProviderId
|
|
43
|
+
const [paused, setPaused] = useState(false)
|
|
44
|
+
const pausedRef = useRef(false)
|
|
45
|
+
pausedRef.current = paused
|
|
46
|
+
const [filter, setFilter] = useState('')
|
|
47
|
+
// Optional direction gate — `null` = both, 'in' / 'out' = pin to one.
|
|
48
|
+
const [dirFilter, setDirFilter] = useState<'in' | 'out' | null>(null)
|
|
49
|
+
// Bytes threshold — hide messages smaller than this. Useful for
|
|
50
|
+
// spotting fat buffer:chunk frames while ignoring stats heartbeats.
|
|
51
|
+
const [minBytes, setMinBytes] = useState(0)
|
|
52
|
+
// Type multiselect — empty set = show all; otherwise only these.
|
|
53
|
+
const [excludedTypes, setExcludedTypes] = useState<Set<string>>(() => new Set())
|
|
54
|
+
const [filterMenuOpen, setFilterMenuOpen] = useState(false)
|
|
55
|
+
const filterMenuRef = useRef<HTMLDivElement | null>(null)
|
|
56
|
+
|
|
57
|
+
const [selectedId, setSelectedId] = useState<number | null>(null)
|
|
58
|
+
const [selectedEntry, setSelectedEntry] = useState<LogEntry | null>(null)
|
|
59
|
+
// Hover tracked by entry id, not DOM slot — slots get recycled as new
|
|
60
|
+
// messages arrive, which means CSS `:hover` applied to a slot would
|
|
61
|
+
// land on whichever entry happens to occupy the slot a frame later.
|
|
62
|
+
// Tracking by id means the highlight stays on the entry the user is
|
|
63
|
+
// actually pointing at.
|
|
64
|
+
const [hoveredId, setHoveredId] = useState<number | null>(null)
|
|
65
|
+
|
|
66
|
+
const [tail, setTail] = useState(true)
|
|
67
|
+
const tailRef = useRef(true)
|
|
68
|
+
tailRef.current = tail
|
|
69
|
+
|
|
70
|
+
const scrollerRef = useRef<HTMLDivElement | null>(null)
|
|
71
|
+
const filterInputRef = useRef<HTMLInputElement | null>(null)
|
|
72
|
+
const resumeTimerRef = useRef<number | null>(null)
|
|
73
|
+
// Count of entries that arrived since the last render — used by the
|
|
74
|
+
// post-render effect to compensate scrollTop when tail is off.
|
|
75
|
+
const pendingBumpRef = useRef(0)
|
|
76
|
+
const [viewport, setViewport] = useState({ scrollTop: 0, height: 0 })
|
|
77
|
+
|
|
78
|
+
// Wire the raw-message tap → store. The store owns IDB persistence,
|
|
79
|
+
// counters, cache + eviction; this listener translates raw messages
|
|
80
|
+
// to log rows AND tags each with whichever provider was selected at
|
|
81
|
+
// the moment it arrived, so per-provider scoping stays honest even
|
|
82
|
+
// when the user switches mid-stream.
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
return client.addRawMessageListener((msg, direction) => {
|
|
85
|
+
if (pausedRef.current) return
|
|
86
|
+
const providerId = client.state.selectedProviderId
|
|
87
|
+
if (providerId === null) return
|
|
88
|
+
store.push(providerId, {
|
|
89
|
+
at: Date.now(),
|
|
90
|
+
direction,
|
|
91
|
+
type: msg.type,
|
|
92
|
+
tag: extractTag(msg),
|
|
93
|
+
frame: extractFrame(msg),
|
|
94
|
+
bytes: estimateBytes(msg),
|
|
95
|
+
msg,
|
|
96
|
+
})
|
|
97
|
+
if (providerId === activeProviderId) pendingBumpRef.current += 1
|
|
98
|
+
})
|
|
99
|
+
}, [client, store, activeProviderId])
|
|
100
|
+
|
|
101
|
+
// The shared `useDevtoolsState` hook + store-driven listener both
|
|
102
|
+
// tick through the dashboard-wide rAF scheduler, so we don't need our
|
|
103
|
+
// own rAF here — the `state` read above is enough to re-render each
|
|
104
|
+
// frame the store fires.
|
|
105
|
+
|
|
106
|
+
// After each render but BEFORE paint: tail ON pins to top; tail OFF
|
|
107
|
+
// bumps scrollTop by exactly the number of new rows prepended since
|
|
108
|
+
// the last paint. Must be `useLayoutEffect` — if this ran in a regular
|
|
109
|
+
// effect the browser would paint the grown-but-unscrolled state for
|
|
110
|
+
// one frame, which reads as rows jumping down a row each update.
|
|
111
|
+
// `> TAIL_RESUME_PIX` (not `> 0`) — when the user is actively
|
|
112
|
+
// scrolling back toward the tail, we don't want automatic bumps
|
|
113
|
+
// fighting their input. Within the band the resume timer handles it.
|
|
114
|
+
useLayoutEffect(() => {
|
|
115
|
+
const el = scrollerRef.current
|
|
116
|
+
if (el === null) return
|
|
117
|
+
if (tailRef.current) {
|
|
118
|
+
el.scrollTop = 0
|
|
119
|
+
} else if (pendingBumpRef.current > 0 && el.scrollTop > TAIL_RESUME_PIX) {
|
|
120
|
+
el.scrollTop += pendingBumpRef.current * ROW_HEIGHT
|
|
121
|
+
}
|
|
122
|
+
pendingBumpRef.current = 0
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// Capture scroller height for virtualization math. Updates on first
|
|
126
|
+
// mount + any resize.
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
const el = scrollerRef.current
|
|
129
|
+
if (el === null) return
|
|
130
|
+
setViewport((v) => ({ ...v, height: el.clientHeight }))
|
|
131
|
+
const obs = new ResizeObserver(() => {
|
|
132
|
+
const h = scrollerRef.current?.clientHeight ?? 0
|
|
133
|
+
setViewport((v) => (v.height === h ? v : { ...v, height: h }))
|
|
134
|
+
})
|
|
135
|
+
obs.observe(el)
|
|
136
|
+
return () => obs.disconnect()
|
|
137
|
+
}, [])
|
|
138
|
+
|
|
139
|
+
const onScroll = (e: Event): void => {
|
|
140
|
+
const el = e.currentTarget as HTMLDivElement
|
|
141
|
+
setViewport((v) => (v.scrollTop === el.scrollTop ? v : { ...v, scrollTop: el.scrollTop }))
|
|
142
|
+
const atTail = el.scrollTop <= TAIL_RESUME_PIX
|
|
143
|
+
if (atTail) {
|
|
144
|
+
if (!tailRef.current) {
|
|
145
|
+
if (resumeTimerRef.current !== null) window.clearTimeout(resumeTimerRef.current)
|
|
146
|
+
resumeTimerRef.current = window.setTimeout(() => {
|
|
147
|
+
if (scrollerRef.current !== null && scrollerRef.current.scrollTop <= TAIL_RESUME_PIX) {
|
|
148
|
+
setTail(true)
|
|
149
|
+
}
|
|
150
|
+
}, RESUME_TAIL_MS)
|
|
151
|
+
}
|
|
152
|
+
} else if (tailRef.current) {
|
|
153
|
+
if (resumeTimerRef.current !== null) {
|
|
154
|
+
window.clearTimeout(resumeTimerRef.current)
|
|
155
|
+
resumeTimerRef.current = null
|
|
156
|
+
}
|
|
157
|
+
setTail(false)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (!filterMenuOpen) return
|
|
163
|
+
const onDocClick = (e: MouseEvent) => {
|
|
164
|
+
const root = filterMenuRef.current
|
|
165
|
+
if (root !== null && !root.contains(e.target as Node)) setFilterMenuOpen(false)
|
|
166
|
+
}
|
|
167
|
+
document.addEventListener('mousedown', onDocClick)
|
|
168
|
+
return () => document.removeEventListener('mousedown', onDocClick)
|
|
169
|
+
}, [filterMenuOpen])
|
|
170
|
+
|
|
171
|
+
const toggleTail = (): void => {
|
|
172
|
+
const next = !tail
|
|
173
|
+
setTail(next)
|
|
174
|
+
if (next) {
|
|
175
|
+
const el = scrollerRef.current
|
|
176
|
+
if (el !== null) el.scrollTop = 0
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Virtualization math, scoped to the active provider. Per-provider
|
|
181
|
+
// ids array is dense (append-only) so visual index i maps to
|
|
182
|
+
// ids[total-1-i].
|
|
183
|
+
const stats = store.statsFor(activeProviderId)
|
|
184
|
+
const allIds = stats.ids
|
|
185
|
+
const allTotal = stats.total
|
|
186
|
+
|
|
187
|
+
// Filter currently walks the hot cache only. Fine for live tailing;
|
|
188
|
+
// proper "filter across entire session" belongs on an indexed IDB
|
|
189
|
+
// query (future work — hook up when filter is non-empty by querying
|
|
190
|
+
// ranges and collecting matches).
|
|
191
|
+
const needle = filter.trim().toLowerCase()
|
|
192
|
+
const filterSpec: FilterSpec = {
|
|
193
|
+
needle,
|
|
194
|
+
dir: dirFilter,
|
|
195
|
+
minBytes,
|
|
196
|
+
excluded: excludedTypes,
|
|
197
|
+
}
|
|
198
|
+
const hasActiveFilter = needle.length > 0
|
|
199
|
+
|| dirFilter !== null
|
|
200
|
+
|| minBytes > 0
|
|
201
|
+
|| excludedTypes.size > 0
|
|
202
|
+
|
|
203
|
+
// Filter pipeline is authoritative — when a filter is active, we
|
|
204
|
+
// query IDB directly via the store's `queryFiltered` cursor. Query
|
|
205
|
+
// is debounced so typing doesn't thrash the cursor. While a query is
|
|
206
|
+
// in-flight, render the previous result to avoid empty-state flashes.
|
|
207
|
+
// New arrivals append their id to `filteredIds` synchronously in the
|
|
208
|
+
// raw-message listener when they match, so live tail keeps working
|
|
209
|
+
// without a re-query.
|
|
210
|
+
const [filteredIds, setFilteredIds] = useState<number[]>([])
|
|
211
|
+
const [filterLoading, setFilterLoading] = useState(false)
|
|
212
|
+
// Ref to the current filter predicate so the raw-message listener
|
|
213
|
+
// can test new arrivals without capturing stale filter state.
|
|
214
|
+
const filterPredicateRef = useRef<((e: LogEntry) => boolean) | null>(null)
|
|
215
|
+
filterPredicateRef.current = hasActiveFilter ? (e: LogEntry) => matchesFilter(e, filterSpec) : null
|
|
216
|
+
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
if (!hasActiveFilter || activeProviderId === null) {
|
|
219
|
+
setFilteredIds([])
|
|
220
|
+
setFilterLoading(false)
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
const signal = { aborted: false }
|
|
224
|
+
const predicate = filterPredicateRef.current!
|
|
225
|
+
setFilterLoading(true)
|
|
226
|
+
const handle = (globalThis.setTimeout as unknown as (cb: () => void, ms: number) => number)(
|
|
227
|
+
() => {
|
|
228
|
+
void store.queryFiltered(activeProviderId, predicate, signal).then((result) => {
|
|
229
|
+
if (signal.aborted) return
|
|
230
|
+
setFilteredIds(result)
|
|
231
|
+
setFilterLoading(false)
|
|
232
|
+
})
|
|
233
|
+
},
|
|
234
|
+
150,
|
|
235
|
+
)
|
|
236
|
+
return () => {
|
|
237
|
+
signal.aborted = true
|
|
238
|
+
clearTimeout(handle)
|
|
239
|
+
}
|
|
240
|
+
// Filter primitives as deps; `filterPredicateRef.current` is read
|
|
241
|
+
// inside but kept stable against renders via the ref.
|
|
242
|
+
|
|
243
|
+
}, [store, activeProviderId, hasActiveFilter, needle, dirFilter, minBytes, excludedTypes])
|
|
244
|
+
|
|
245
|
+
// Live-append: when a new message matches the active filter, extend
|
|
246
|
+
// `filteredIds` without re-querying. Subscribes to raw messages
|
|
247
|
+
// independently of the append-to-store listener higher up.
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
if (!hasActiveFilter || activeProviderId === null) return
|
|
250
|
+
return client.addRawMessageListener(() => {
|
|
251
|
+
const pred = filterPredicateRef.current
|
|
252
|
+
if (pred === null) return
|
|
253
|
+
// The entry was just pushed into the store above us in the
|
|
254
|
+
// listener chain. Look it up by maxId.
|
|
255
|
+
const s = store.statsFor(activeProviderId)
|
|
256
|
+
const latest = s.ids[s.ids.length - 1]
|
|
257
|
+
if (latest === undefined) return
|
|
258
|
+
const entry = store.peek(activeProviderId, latest)
|
|
259
|
+
if (entry === null) return
|
|
260
|
+
if (!pred(entry)) return
|
|
261
|
+
setFilteredIds((prev) => {
|
|
262
|
+
if (prev.length > 0 && prev[prev.length - 1] === latest) return prev
|
|
263
|
+
return [...prev, latest]
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
}, [client, store, activeProviderId, hasActiveFilter])
|
|
267
|
+
|
|
268
|
+
const ids = hasActiveFilter ? filteredIds : allIds
|
|
269
|
+
const total = ids.length
|
|
270
|
+
|
|
271
|
+
// Move selection by `delta` positions (newest-first ordering). Used
|
|
272
|
+
// by j/k shortcuts below.
|
|
273
|
+
const moveSelection = (delta: number): void => {
|
|
274
|
+
if (total === 0 || activeProviderId === null) return
|
|
275
|
+
let visualIdx = 0
|
|
276
|
+
if (selectedId !== null) {
|
|
277
|
+
for (let i = 0; i < total; i++) {
|
|
278
|
+
if (ids[total - 1 - i] === selectedId) { visualIdx = i; break }
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const nextIdx = Math.max(0, Math.min(total - 1, visualIdx + delta))
|
|
282
|
+
const nextId = ids[total - 1 - nextIdx]
|
|
283
|
+
if (nextId !== undefined) setSelectedId(nextId)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Keyboard shortcuts. Active whenever the panel is mounted; typing
|
|
287
|
+
// inside inputs passes through so `/`-to-focus doesn't steal regular
|
|
288
|
+
// filter input.
|
|
289
|
+
useEffect(() => {
|
|
290
|
+
const isTypingTarget = (t: EventTarget | null): boolean => {
|
|
291
|
+
if (!(t instanceof HTMLElement)) return false
|
|
292
|
+
const tag = t.tagName
|
|
293
|
+
return tag === 'INPUT' || tag === 'TEXTAREA' || t.isContentEditable
|
|
294
|
+
}
|
|
295
|
+
const onKey = (e: KeyboardEvent) => {
|
|
296
|
+
if (e.key === 'Escape') {
|
|
297
|
+
if (selectedId !== null) { setSelectedId(null); e.preventDefault() }
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
if (isTypingTarget(e.target)) return
|
|
301
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return
|
|
302
|
+
if (e.key === '/') {
|
|
303
|
+
filterInputRef.current?.focus()
|
|
304
|
+
filterInputRef.current?.select()
|
|
305
|
+
e.preventDefault()
|
|
306
|
+
} else if (e.key === 'j' || e.key === 'J') {
|
|
307
|
+
moveSelection(1)
|
|
308
|
+
e.preventDefault()
|
|
309
|
+
} else if (e.key === 'k' || e.key === 'K') {
|
|
310
|
+
moveSelection(-1)
|
|
311
|
+
e.preventDefault()
|
|
312
|
+
} else if (e.key === 'Enter' || e.key === ' ') {
|
|
313
|
+
toggleTail()
|
|
314
|
+
e.preventDefault()
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
document.addEventListener('keydown', onKey)
|
|
318
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
319
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
320
|
+
}, [selectedId, tail, activeProviderId, total])
|
|
321
|
+
|
|
322
|
+
const start = Math.max(0, Math.floor(viewport.scrollTop / ROW_HEIGHT) - OVERSCAN)
|
|
323
|
+
const visibleCount = Math.max(1, Math.ceil(viewport.height / ROW_HEIGHT) + OVERSCAN * 2)
|
|
324
|
+
const end = Math.min(total, start + visibleCount)
|
|
325
|
+
|
|
326
|
+
// Window-scope prefetch (unfiltered case). When filter is active the
|
|
327
|
+
// bulk prefetch above covers it.
|
|
328
|
+
const startId = end > 0 && total > 0 ? ids[Math.max(0, total - end)] ?? 0 : 0
|
|
329
|
+
const endId = total > 0 ? ids[Math.max(0, total - 1 - start)] ?? 0 : 0
|
|
330
|
+
useEffect(() => {
|
|
331
|
+
if (hasActiveFilter) return
|
|
332
|
+
if (activeProviderId === null || startId === 0 || startId > endId) return
|
|
333
|
+
let needsFetch = false
|
|
334
|
+
for (let i = start; i < end; i++) {
|
|
335
|
+
const id = ids[total - 1 - i]
|
|
336
|
+
if (id === undefined) continue
|
|
337
|
+
if (store.peek(activeProviderId, id) === null) { needsFetch = true; break }
|
|
338
|
+
}
|
|
339
|
+
if (needsFetch) void store.prefetchRange(activeProviderId, startId, endId)
|
|
340
|
+
}, [store, activeProviderId, hasActiveFilter, startId, endId, start, end, total, ids])
|
|
341
|
+
|
|
342
|
+
type Row = LogEntry | { kind: 'loading'; id: number }
|
|
343
|
+
const rows: Row[] = []
|
|
344
|
+
for (let i = start; i < end; i++) {
|
|
345
|
+
const id = ids[total - 1 - i] ?? 0
|
|
346
|
+
const entry = activeProviderId !== null ? store.peek(activeProviderId, id) : null
|
|
347
|
+
if (entry === null) rows.push({ kind: 'loading', id })
|
|
348
|
+
else rows.push(entry)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Selection follows the active provider. Swapping providers clears
|
|
352
|
+
// any cross-provider selection.
|
|
353
|
+
useEffect(() => {
|
|
354
|
+
if (selectedId === null || activeProviderId === null) { setSelectedEntry(null); return }
|
|
355
|
+
const cached = store.peek(activeProviderId, selectedId)
|
|
356
|
+
if (cached !== null) { setSelectedEntry(cached); return }
|
|
357
|
+
let cancelled = false
|
|
358
|
+
void store.prefetchRange(activeProviderId, selectedId, selectedId).then(() => {
|
|
359
|
+
if (cancelled) return
|
|
360
|
+
const fetched = store.peek(activeProviderId, selectedId)
|
|
361
|
+
if (fetched !== null) setSelectedEntry(fetched)
|
|
362
|
+
})
|
|
363
|
+
return () => { cancelled = true }
|
|
364
|
+
}, [store, activeProviderId, selectedId])
|
|
365
|
+
const selected = selectedEntry
|
|
366
|
+
|
|
367
|
+
const backlog = 0
|
|
368
|
+
|
|
369
|
+
return (
|
|
370
|
+
<section class="panel protocol-panel">
|
|
371
|
+
<header class="panel-header protocol-header">
|
|
372
|
+
<span>Protocol</span>
|
|
373
|
+
<input
|
|
374
|
+
type="text"
|
|
375
|
+
class="protocol-filter"
|
|
376
|
+
placeholder="filter… (/)"
|
|
377
|
+
value={filter}
|
|
378
|
+
ref={filterInputRef}
|
|
379
|
+
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
|
|
380
|
+
/>
|
|
381
|
+
{hasActiveFilter && (
|
|
382
|
+
<span class="protocol-match-count" title="Matches / total">
|
|
383
|
+
{filterLoading ? '…' : `${total}/${allTotal}`}
|
|
384
|
+
</span>
|
|
385
|
+
)}
|
|
386
|
+
<button
|
|
387
|
+
type="button"
|
|
388
|
+
class="protocol-btn"
|
|
389
|
+
onClick={() => {
|
|
390
|
+
setFilter('')
|
|
391
|
+
setDirFilter(null)
|
|
392
|
+
setMinBytes(0)
|
|
393
|
+
setExcludedTypes(new Set())
|
|
394
|
+
}}
|
|
395
|
+
disabled={!hasActiveFilter}
|
|
396
|
+
title="Reset all filters"
|
|
397
|
+
>
|
|
398
|
+
✕ Reset
|
|
399
|
+
</button>
|
|
400
|
+
<div class="protocol-dir-toggle" role="group" aria-label="Direction filter">
|
|
401
|
+
<button
|
|
402
|
+
type="button"
|
|
403
|
+
class={dirFilter === 'in' ? 'protocol-btn protocol-btn-on' : 'protocol-btn'}
|
|
404
|
+
onClick={() => setDirFilter((d) => (d === 'in' ? null : 'in'))}
|
|
405
|
+
title="Show inbound messages only"
|
|
406
|
+
>
|
|
407
|
+
<span class="header-arrow-in">↓</span>
|
|
408
|
+
</button>
|
|
409
|
+
<button
|
|
410
|
+
type="button"
|
|
411
|
+
class={dirFilter === 'out' ? 'protocol-btn protocol-btn-on' : 'protocol-btn'}
|
|
412
|
+
onClick={() => setDirFilter((d) => (d === 'out' ? null : 'out'))}
|
|
413
|
+
title="Show outbound messages only"
|
|
414
|
+
>
|
|
415
|
+
<span class="header-arrow-out">↑</span>
|
|
416
|
+
</button>
|
|
417
|
+
</div>
|
|
418
|
+
<div class="protocol-filter-menu-wrap" ref={filterMenuRef}>
|
|
419
|
+
<button
|
|
420
|
+
type="button"
|
|
421
|
+
class={
|
|
422
|
+
(minBytes > 0 || excludedTypes.size > 0
|
|
423
|
+
? 'protocol-btn protocol-btn-on'
|
|
424
|
+
: 'protocol-btn')
|
|
425
|
+
}
|
|
426
|
+
onClick={() => setFilterMenuOpen((o) => !o)}
|
|
427
|
+
title="Type + size filters"
|
|
428
|
+
>
|
|
429
|
+
▼ Filter
|
|
430
|
+
</button>
|
|
431
|
+
{filterMenuOpen && (
|
|
432
|
+
<FilterMenu
|
|
433
|
+
store={store}
|
|
434
|
+
minBytes={minBytes}
|
|
435
|
+
setMinBytes={setMinBytes}
|
|
436
|
+
excludedTypes={excludedTypes}
|
|
437
|
+
setExcludedTypes={setExcludedTypes}
|
|
438
|
+
onClose={() => setFilterMenuOpen(false)}
|
|
439
|
+
/>
|
|
440
|
+
)}
|
|
441
|
+
</div>
|
|
442
|
+
<button
|
|
443
|
+
type="button"
|
|
444
|
+
class={tail ? 'protocol-btn protocol-btn-on' : 'protocol-btn'}
|
|
445
|
+
onClick={toggleTail}
|
|
446
|
+
title="Toggle live tail. When off, the viewport anchors to whatever you're currently reading; new entries slide in above."
|
|
447
|
+
>
|
|
448
|
+
{tail ? '◉ Tail' : backlog > 0 ? `○ Tail (+${backlog})` : '○ Tail'}
|
|
449
|
+
</button>
|
|
450
|
+
<button
|
|
451
|
+
type="button"
|
|
452
|
+
class={paused ? 'protocol-btn protocol-btn-on' : 'protocol-btn'}
|
|
453
|
+
onClick={() => setPaused((p) => !p)}
|
|
454
|
+
>
|
|
455
|
+
{paused ? '▶ Resume' : '⏸ Pause'}
|
|
456
|
+
</button>
|
|
457
|
+
<button
|
|
458
|
+
type="button"
|
|
459
|
+
class="protocol-btn"
|
|
460
|
+
onClick={() => void exportSession(activeProviderId)}
|
|
461
|
+
title="Download the session log as JSON"
|
|
462
|
+
disabled={activeProviderId === null}
|
|
463
|
+
>
|
|
464
|
+
⬇ Export
|
|
465
|
+
</button>
|
|
466
|
+
<button
|
|
467
|
+
type="button"
|
|
468
|
+
class="protocol-btn"
|
|
469
|
+
onClick={() => {
|
|
470
|
+
store.clear()
|
|
471
|
+
setSelectedId(null)
|
|
472
|
+
}}
|
|
473
|
+
title="Drop all entries from memory and IndexedDB."
|
|
474
|
+
>
|
|
475
|
+
Clear
|
|
476
|
+
</button>
|
|
477
|
+
</header>
|
|
478
|
+
<div class="protocol-layout">
|
|
479
|
+
<div class="protocol-body" ref={scrollerRef} onScroll={onScroll}>
|
|
480
|
+
{total === 0 ? (
|
|
481
|
+
<div class="panel-empty">No messages{needle.length > 0 ? ' match' : ' yet'}.</div>
|
|
482
|
+
) : (
|
|
483
|
+
<div class="protocol-spacer" style={{ height: `${total * ROW_HEIGHT}px` }}>
|
|
484
|
+
{rows.map((r, i) => {
|
|
485
|
+
// Key by virtual slot index so the same DOM element is
|
|
486
|
+
// re-used as ids scroll past — content + top style
|
|
487
|
+
// update in place instead of Preact inserting / moving
|
|
488
|
+
// keyed nodes, which would cause a 1-frame flash when a
|
|
489
|
+
// new entry takes over the top slot. Always render as
|
|
490
|
+
// `<button>` (placeholder just dims + disables click)
|
|
491
|
+
// so slot tag never switches, keeping Preact's in-place
|
|
492
|
+
// update path.
|
|
493
|
+
const offsetTop = (start + i) * ROW_HEIGHT
|
|
494
|
+
const isLoading = 'kind' in r
|
|
495
|
+
const isPlaceholder = isLoading
|
|
496
|
+
const e = isPlaceholder ? null : r
|
|
497
|
+
const id = isPlaceholder ? (r as { id: number }).id : e!.id
|
|
498
|
+
const isHover = !isPlaceholder && hoveredId === id
|
|
499
|
+
const isSelected = !isPlaceholder && selectedId === id
|
|
500
|
+
let cls = 'protocol-row'
|
|
501
|
+
if (isLoading) cls += ' protocol-row-placeholder'
|
|
502
|
+
else {
|
|
503
|
+
cls += ` protocol-row-${e!.direction}`
|
|
504
|
+
if (isSelected) cls += ' protocol-row-selected'
|
|
505
|
+
if (isHover) cls += ' protocol-row-hover'
|
|
506
|
+
}
|
|
507
|
+
return (
|
|
508
|
+
<button
|
|
509
|
+
key={i}
|
|
510
|
+
type="button"
|
|
511
|
+
class={cls}
|
|
512
|
+
style={{ transform: `translateY(${offsetTop}px)`, height: `${ROW_HEIGHT}px` }}
|
|
513
|
+
onClick={isPlaceholder ? undefined : () => setSelectedId((x) => (x === id ? null : id))}
|
|
514
|
+
onMouseEnter={isPlaceholder ? undefined : () => setHoveredId(id)}
|
|
515
|
+
onMouseLeave={isPlaceholder ? undefined : () => setHoveredId((h) => (h === id ? null : h))}
|
|
516
|
+
title={isPlaceholder ? undefined : new Date(e!.at).toISOString()}
|
|
517
|
+
disabled={isPlaceholder}
|
|
518
|
+
>
|
|
519
|
+
<span class="protocol-dir">{isPlaceholder ? '·' : e!.direction === 'in' ? '↓' : '↑'}</span>
|
|
520
|
+
<span class="protocol-time">{isLoading ? 'loading…' : formatTime(e!.at)}</span>
|
|
521
|
+
<span class="protocol-frame">{!isPlaceholder && e!.frame !== undefined ? `#${e!.frame}` : ''}</span>
|
|
522
|
+
<span class="protocol-type">{isPlaceholder ? '' : e!.type}</span>
|
|
523
|
+
<span class="protocol-tag">{isPlaceholder ? '' : e!.tag ?? ''}</span>
|
|
524
|
+
<span class="protocol-size">{isPlaceholder ? '' : formatBytes(e!.bytes)}</span>
|
|
525
|
+
</button>
|
|
526
|
+
)
|
|
527
|
+
})}
|
|
528
|
+
</div>
|
|
529
|
+
)}
|
|
530
|
+
</div>
|
|
531
|
+
{selected !== null && (
|
|
532
|
+
<aside class="protocol-detail">
|
|
533
|
+
<div class="protocol-detail-header">
|
|
534
|
+
<span class="protocol-detail-title">
|
|
535
|
+
{selected.direction === 'in' ? '↓ ' : '↑ '}
|
|
536
|
+
{selected.type}
|
|
537
|
+
{selected.tag !== undefined && <span class="protocol-tag"> · {selected.tag}</span>}
|
|
538
|
+
</span>
|
|
539
|
+
<button
|
|
540
|
+
type="button"
|
|
541
|
+
class="protocol-btn"
|
|
542
|
+
onClick={() => setSelectedId(null)}
|
|
543
|
+
aria-label="Close detail"
|
|
544
|
+
>×</button>
|
|
545
|
+
</div>
|
|
546
|
+
<div class="protocol-detail-meta">
|
|
547
|
+
<span>{formatTime(selected.at)}</span>
|
|
548
|
+
{selected.frame !== undefined && <span>frame #{selected.frame}</span>}
|
|
549
|
+
<span>{formatBytes(selected.bytes)}</span>
|
|
550
|
+
</div>
|
|
551
|
+
<pre
|
|
552
|
+
class="protocol-detail-body"
|
|
553
|
+
dangerouslySetInnerHTML={{ __html: highlightJson(stringify(selected.msg as DebugMessage)) }}
|
|
554
|
+
/>
|
|
555
|
+
</aside>
|
|
556
|
+
)}
|
|
557
|
+
</div>
|
|
558
|
+
</section>
|
|
559
|
+
)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
interface FilterSpec {
|
|
563
|
+
needle: string
|
|
564
|
+
dir: 'in' | 'out' | null
|
|
565
|
+
minBytes: number
|
|
566
|
+
excluded: Set<string>
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function FilterMenu({
|
|
570
|
+
store,
|
|
571
|
+
minBytes,
|
|
572
|
+
setMinBytes,
|
|
573
|
+
excludedTypes,
|
|
574
|
+
setExcludedTypes,
|
|
575
|
+
onClose: _onClose,
|
|
576
|
+
}: {
|
|
577
|
+
store: ReturnType<typeof getProtocolStore>
|
|
578
|
+
minBytes: number
|
|
579
|
+
setMinBytes: (n: number) => void
|
|
580
|
+
excludedTypes: Set<string>
|
|
581
|
+
setExcludedTypes: (s: Set<string>) => void
|
|
582
|
+
onClose: () => void
|
|
583
|
+
}): preact.JSX.Element {
|
|
584
|
+
const types = Array.from(store.knownTypes).sort()
|
|
585
|
+
const toggle = (t: string): void => {
|
|
586
|
+
const next = new Set(excludedTypes)
|
|
587
|
+
if (next.has(t)) next.delete(t)
|
|
588
|
+
else next.add(t)
|
|
589
|
+
setExcludedTypes(next)
|
|
590
|
+
}
|
|
591
|
+
return (
|
|
592
|
+
<div class="protocol-filter-menu">
|
|
593
|
+
<div class="protocol-filter-menu-section">
|
|
594
|
+
<div class="protocol-filter-menu-title">Minimum size</div>
|
|
595
|
+
<input
|
|
596
|
+
type="range"
|
|
597
|
+
min={0}
|
|
598
|
+
max={65536}
|
|
599
|
+
step={64}
|
|
600
|
+
value={minBytes}
|
|
601
|
+
onInput={(e) => setMinBytes(Number((e.target as HTMLInputElement).value))}
|
|
602
|
+
/>
|
|
603
|
+
<span class="protocol-filter-menu-value">
|
|
604
|
+
{minBytes === 0 ? 'any' : minBytes < 1024 ? `${minBytes} B` : `${(minBytes / 1024).toFixed(1)} KB`}
|
|
605
|
+
</span>
|
|
606
|
+
</div>
|
|
607
|
+
<div class="protocol-filter-menu-section">
|
|
608
|
+
<div class="protocol-filter-menu-title">
|
|
609
|
+
Hidden types
|
|
610
|
+
{excludedTypes.size > 0 && (
|
|
611
|
+
<button
|
|
612
|
+
type="button"
|
|
613
|
+
class="protocol-btn protocol-filter-menu-clear"
|
|
614
|
+
onClick={() => setExcludedTypes(new Set())}
|
|
615
|
+
>reset</button>
|
|
616
|
+
)}
|
|
617
|
+
</div>
|
|
618
|
+
<ul class="protocol-filter-types">
|
|
619
|
+
{types.length === 0 ? (
|
|
620
|
+
<li class="panel-empty">No types yet.</li>
|
|
621
|
+
) : (
|
|
622
|
+
types.map((t) => (
|
|
623
|
+
<li key={t}>
|
|
624
|
+
<label class="protocol-filter-type">
|
|
625
|
+
<input
|
|
626
|
+
type="checkbox"
|
|
627
|
+
checked={!excludedTypes.has(t)}
|
|
628
|
+
onChange={() => toggle(t)}
|
|
629
|
+
/>
|
|
630
|
+
<span>{t}</span>
|
|
631
|
+
</label>
|
|
632
|
+
</li>
|
|
633
|
+
))
|
|
634
|
+
)}
|
|
635
|
+
</ul>
|
|
636
|
+
</div>
|
|
637
|
+
</div>
|
|
638
|
+
)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function matchesFilter(e: LogEntry, f: FilterSpec): boolean {
|
|
642
|
+
if (f.dir !== null && e.direction !== f.dir) return false
|
|
643
|
+
if (f.minBytes > 0 && e.bytes < f.minBytes) return false
|
|
644
|
+
if (f.excluded.has(e.type)) return false
|
|
645
|
+
if (f.needle.length === 0) return true
|
|
646
|
+
if (e.type.includes(f.needle)) return true
|
|
647
|
+
if (e.tag !== undefined && e.tag.toLowerCase().includes(f.needle)) return true
|
|
648
|
+
return false
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function extractTag(msg: DebugMessage): string | undefined {
|
|
652
|
+
if (msg.type === 'data') {
|
|
653
|
+
const features = (msg as unknown as { payload?: { features?: Record<string, unknown> } }).payload?.features
|
|
654
|
+
if (features !== undefined) {
|
|
655
|
+
const keys = Object.keys(features).filter((k) => features[k] != null)
|
|
656
|
+
return keys.length > 0 ? keys.join(',') : 'empty'
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
const p = (msg as unknown as { payload?: { name?: string } }).payload
|
|
660
|
+
if (p !== undefined && typeof p === 'object' && typeof p.name === 'string') return p.name
|
|
661
|
+
return undefined
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function extractFrame(msg: DebugMessage): number | undefined {
|
|
665
|
+
const f = (msg as unknown as { payload?: { frame?: number } }).payload?.frame
|
|
666
|
+
return typeof f === 'number' ? f : undefined
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function estimateBytes(msg: DebugMessage): number {
|
|
670
|
+
let bytes = 0
|
|
671
|
+
const seen = new WeakSet<object>()
|
|
672
|
+
const walk = (v: unknown): void => {
|
|
673
|
+
if (v === null || v === undefined) return
|
|
674
|
+
if (typeof v === 'string') { bytes += v.length; return }
|
|
675
|
+
if (typeof v === 'number' || typeof v === 'boolean') { bytes += 8; return }
|
|
676
|
+
if (v instanceof ArrayBuffer) { bytes += v.byteLength; return }
|
|
677
|
+
if (ArrayBuffer.isView(v)) { bytes += v.byteLength; return }
|
|
678
|
+
if (typeof v === 'object') {
|
|
679
|
+
if (seen.has(v)) return
|
|
680
|
+
seen.add(v)
|
|
681
|
+
if (Array.isArray(v)) { for (const x of v) walk(x); return }
|
|
682
|
+
for (const k in v as Record<string, unknown>) {
|
|
683
|
+
bytes += k.length
|
|
684
|
+
walk((v as Record<string, unknown>)[k])
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
walk(msg)
|
|
689
|
+
return bytes
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function formatBytes(n: number): string {
|
|
693
|
+
if (n < 1024) return `${n}B`
|
|
694
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}K`
|
|
695
|
+
return `${(n / (1024 * 1024)).toFixed(1)}M`
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function formatTime(at: number): string {
|
|
699
|
+
const d = new Date(at)
|
|
700
|
+
const ms = d.getMilliseconds().toString().padStart(3, '0')
|
|
701
|
+
return `${d.toLocaleTimeString([], { hour12: false })}.${ms}`
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function stringify(msg: DebugMessage): string {
|
|
705
|
+
try {
|
|
706
|
+
return JSON.stringify(msg, (_k, v) => {
|
|
707
|
+
if (v instanceof ArrayBuffer) return `[ArrayBuffer ${v.byteLength}B]`
|
|
708
|
+
if (ArrayBuffer.isView(v)) return `[${v.constructor.name} ${v.byteLength}B]`
|
|
709
|
+
return v as unknown
|
|
710
|
+
}, 2)
|
|
711
|
+
} catch {
|
|
712
|
+
return '[unserialisable]'
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Tiny JSON syntax highlighter. Takes a `JSON.stringify(obj, null, 2)`
|
|
718
|
+
* string and wraps tokens (keys, strings, numbers, booleans, null) in
|
|
719
|
+
* spans with `.json-*` classes. Not a real parser — the input always
|
|
720
|
+
* comes from `JSON.stringify` so the token grammar is well-formed and a
|
|
721
|
+
* single regex pass is enough. HTML-escapes first to avoid injecting
|
|
722
|
+
* arbitrary markup from message payloads.
|
|
723
|
+
*/
|
|
724
|
+
function highlightJson(json: string): string {
|
|
725
|
+
const escaped = json
|
|
726
|
+
.replace(/&/g, '&')
|
|
727
|
+
.replace(/</g, '<')
|
|
728
|
+
.replace(/>/g, '>')
|
|
729
|
+
return escaped.replace(
|
|
730
|
+
/("(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
|
|
731
|
+
(match) => {
|
|
732
|
+
let cls = 'json-num'
|
|
733
|
+
if (match.startsWith('"')) {
|
|
734
|
+
cls = match.endsWith(':') || match.endsWith(': ') ? 'json-key' : 'json-str'
|
|
735
|
+
} else if (match === 'true' || match === 'false') {
|
|
736
|
+
cls = 'json-bool'
|
|
737
|
+
} else if (match === 'null') {
|
|
738
|
+
cls = 'json-null'
|
|
739
|
+
}
|
|
740
|
+
return `<span class="${cls}">${match}</span>`
|
|
741
|
+
},
|
|
742
|
+
)
|
|
743
|
+
}
|