@vobs/devtools-ui 1.0.0
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 +35 -0
- package/package.json +34 -0
- package/src/index.ts +7 -0
- package/src/panel.tsx +2164 -0
- package/src/styles/styles.css +1582 -0
- package/src/widget.tsx +51 -0
package/src/panel.tsx
ADDED
|
@@ -0,0 +1,2164 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getDevTools,
|
|
3
|
+
type ComponentDebugNode,
|
|
4
|
+
type DevToolsAPI,
|
|
5
|
+
type DevToolsErrorTrace,
|
|
6
|
+
type DevToolsRouterContext,
|
|
7
|
+
type DevToolsSelection,
|
|
8
|
+
type EffectExecutionInfo,
|
|
9
|
+
type EffectDebugInfo,
|
|
10
|
+
type LifecycleEvent,
|
|
11
|
+
type NetworkRequestTrace,
|
|
12
|
+
type SignalDebugInfo,
|
|
13
|
+
type UpdateTrace
|
|
14
|
+
} from '@vobs/devtools'
|
|
15
|
+
import { HTTP_KEY, type HTTPClient, type HTTPMethod } from '@vobs/http'
|
|
16
|
+
import type { Router, RouterDevToolsAPI, RouteDebugNode, RouteRecord, RouterDataRequestTrace, RouteErrorTrace } from '@vobs/router'
|
|
17
|
+
import { effect } from '@vobs/reactivity'
|
|
18
|
+
import { createComponent, createElement, createFragment, createText, inject, insertBefore, insertDynamic, onDispose, setAttribute, setProperty, state, type VobsNode } from '@vobs/vobs'
|
|
19
|
+
import { Alert, Button, Card, Icon, Select, Tabs, Tag } from '@vobs/ui'
|
|
20
|
+
|
|
21
|
+
export interface DevToolsSnapshot {
|
|
22
|
+
readonly api: DevToolsAPI | null
|
|
23
|
+
readonly tree: readonly ComponentDebugNode[]
|
|
24
|
+
readonly signals: readonly SignalDebugInfo[]
|
|
25
|
+
readonly effects: readonly EffectDebugInfo[]
|
|
26
|
+
readonly updates: readonly UpdateTrace[]
|
|
27
|
+
readonly lifecycle: readonly LifecycleEvent[]
|
|
28
|
+
readonly network: readonly NetworkRequestTrace[]
|
|
29
|
+
readonly errors: readonly DevToolsErrorTrace[]
|
|
30
|
+
readonly metrics: ReturnType<DevToolsAPI['getPerformanceMetrics']> | null
|
|
31
|
+
readonly performanceEntries: ReturnType<DevToolsAPI['getPerformanceEntries']>
|
|
32
|
+
readonly memory: ReturnType<DevToolsAPI['takeMemorySnapshot']> | null
|
|
33
|
+
readonly router: RouterDevToolsAPI | null
|
|
34
|
+
readonly routerContext: DevToolsRouterContext | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface DevToolsPanelProps {
|
|
38
|
+
readonly api?: DevToolsAPI | null
|
|
39
|
+
readonly router?: Router | null
|
|
40
|
+
readonly http?: HTTPClient | null
|
|
41
|
+
readonly query?: { value: string }
|
|
42
|
+
readonly toolbarPlacement?: 'content' | 'header' | 'none'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const EMPTY_SNAPSHOT: DevToolsSnapshot = {
|
|
46
|
+
api: null,
|
|
47
|
+
tree: [],
|
|
48
|
+
signals: [],
|
|
49
|
+
effects: [],
|
|
50
|
+
updates: [],
|
|
51
|
+
lifecycle: [],
|
|
52
|
+
network: [],
|
|
53
|
+
errors: [],
|
|
54
|
+
metrics: null,
|
|
55
|
+
performanceEntries: [],
|
|
56
|
+
memory: null,
|
|
57
|
+
router: null,
|
|
58
|
+
routerContext: null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The panel itself performs DOM work and creates reactive effects. Listening to
|
|
62
|
+
// creation/mutation events here would make the inspector refresh itself forever.
|
|
63
|
+
const PANEL_REFRESH_EVENTS = ['signal-update', 'update', 'lifecycle', 'network-request', 'router', 'error', 'collection', 'collection-cleared'] as const
|
|
64
|
+
|
|
65
|
+
export function readDevToolsSnapshot(api: DevToolsAPI | null = getDevTools()): DevToolsSnapshot {
|
|
66
|
+
return readDevToolsSnapshotWithRouter(api, null)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readDevToolsSnapshotWithRouter(api: DevToolsAPI | null, router: Router | null): DevToolsSnapshot {
|
|
70
|
+
if (!api) return { ...EMPTY_SNAPSHOT, router: router?.devtools ?? null }
|
|
71
|
+
return {
|
|
72
|
+
api,
|
|
73
|
+
tree: api.getComponentTree(),
|
|
74
|
+
signals: api.getSignals(),
|
|
75
|
+
effects: api.getEffects(),
|
|
76
|
+
updates: api.getUpdates(),
|
|
77
|
+
lifecycle: api.getLifecycleEvents(),
|
|
78
|
+
network: api.getNetworkRequests(),
|
|
79
|
+
errors: api.getErrors(),
|
|
80
|
+
metrics: api.getPerformanceMetrics(),
|
|
81
|
+
performanceEntries: api.getPerformanceEntries(),
|
|
82
|
+
memory: api.takeMemorySnapshot(),
|
|
83
|
+
router: router?.devtools ?? null,
|
|
84
|
+
routerContext: api.getRouterContext()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function DevToolsPanel(props: DevToolsPanelProps = {}) {
|
|
89
|
+
const refreshCount = state(0)
|
|
90
|
+
const activeSection = state<DevToolsSection>('updates')
|
|
91
|
+
const activeAdvancedSection = state<AdvancedSection>('signals')
|
|
92
|
+
const activeRouterTab = state<RouterPanelTab>('context')
|
|
93
|
+
const activeUpdatesTab = state<UpdatesPanelTab>('updates')
|
|
94
|
+
const activeComponentsTab = state<ComponentsPanelTab>('tree')
|
|
95
|
+
const activeRouteView = state<'tree' | 'list'>('tree')
|
|
96
|
+
const activeTimelineFilter = state<TimelineFilter>('all')
|
|
97
|
+
const networkSelection = state<string | null>(null)
|
|
98
|
+
const networkSourceFilter = state<NetworkSourceFilter>('all')
|
|
99
|
+
const networkStatusFilter = state<NetworkStatusFilter>('all')
|
|
100
|
+
const networkDetailTab = state<NetworkDetailTab>('overview')
|
|
101
|
+
const networkTesterOpen = state(false)
|
|
102
|
+
const networkTesterRevision = state(0)
|
|
103
|
+
const networkTesterRun = state<RequestTesterRun>({ status: 'idle' })
|
|
104
|
+
const networkTesterTab = state<RequestTesterTab>('params')
|
|
105
|
+
let networkTesterDraft = createRequestTesterDraft()
|
|
106
|
+
const selection = state<DevToolsSelection | null>(null)
|
|
107
|
+
const query = props.query ?? state('')
|
|
108
|
+
const http = props.http ?? inject(HTTP_KEY) ?? null
|
|
109
|
+
let refreshInvalidating = false
|
|
110
|
+
|
|
111
|
+
const notifyRefresh = (): void => {
|
|
112
|
+
// The panel's own local signals are also visible to the runtime hooks. Do
|
|
113
|
+
// not recursively invalidate it while handling the update it just caused.
|
|
114
|
+
if (refreshInvalidating) return
|
|
115
|
+
refreshInvalidating = true
|
|
116
|
+
refreshCount.value++
|
|
117
|
+
queueMicrotask(() => { refreshInvalidating = false })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Event callbacks only invalidate the panel. The next reactive render reads a fresh snapshot.
|
|
121
|
+
queueMicrotask(notifyRefresh)
|
|
122
|
+
const api = resolveApi(props)
|
|
123
|
+
if (api) {
|
|
124
|
+
const stops = PANEL_REFRESH_EVENTS.map(event => api.subscribe(event, notifyRefresh))
|
|
125
|
+
onDispose(() => { for (const stop of stops) stop() })
|
|
126
|
+
}
|
|
127
|
+
const router = props.router ?? null
|
|
128
|
+
if (router) {
|
|
129
|
+
const detachRouter = api?.attachRouter(router)
|
|
130
|
+
const stopNavigationStart = router.devtools.subscribe('navigation:start', notifyRefresh)
|
|
131
|
+
const stopNavigation = router.devtools.subscribe('navigation:end', notifyRefresh)
|
|
132
|
+
const stopRouteUpdate = router.devtools.subscribe('route:update', notifyRefresh)
|
|
133
|
+
const stopRouteError = router.devtools.subscribe('error', notifyRefresh)
|
|
134
|
+
onDispose(() => { stopNavigationStart(); stopNavigation(); stopRouteUpdate(); stopRouteError(); detachRouter?.() })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const toolbarPlacement = props.toolbarPlacement ?? 'content'
|
|
138
|
+
return <div class="vobs-devtools-shell">
|
|
139
|
+
<aside class="vobs-devtools-shell__rail" aria-label="DevTools sections">
|
|
140
|
+
<div class="vobs-devtools-shell__mark"><Icon name="code" /></div>
|
|
141
|
+
<SectionNav activeSection={activeSection} />
|
|
142
|
+
</aside>
|
|
143
|
+
<section class="vobs-devtools-shell__main">
|
|
144
|
+
<div class="vobs-devtools-shell__content">{toolbarPlacement === 'content' ? <DevToolsToolbar api={api} query={query} /> : null}<DevToolsContent refreshCount={refreshCount} activeSection={activeSection} activeAdvancedSection={activeAdvancedSection} activeRouterTab={activeRouterTab} activeUpdatesTab={activeUpdatesTab} activeComponentsTab={activeComponentsTab} activeRouteView={activeRouteView} router={router} api={api} http={http} selection={selection} query={query} networkSelection={networkSelection} networkSourceFilter={networkSourceFilter} networkStatusFilter={networkStatusFilter} networkDetailTab={networkDetailTab} networkTesterOpen={networkTesterOpen} networkTesterRevision={networkTesterRevision} networkTesterRun={networkTesterRun} networkTesterDraft={networkTesterDraft} networkTesterTab={networkTesterTab} /></div>
|
|
145
|
+
</section>
|
|
146
|
+
<ActivityRail refreshCount={refreshCount} router={router} api={api} selection={selection} activeSection={activeSection} activeFilter={activeTimelineFilter} />
|
|
147
|
+
</div>
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function DevToolsToolbar(props: { readonly api: DevToolsAPI | null; readonly query: { value: string }; readonly maximized?: boolean; readonly onToggleMaximize?: () => void }): VobsNode {
|
|
151
|
+
const refreshCount = state(0)
|
|
152
|
+
if (props.api) {
|
|
153
|
+
const stops = ['collection', 'collection-cleared'].map(event => props.api!.subscribe(event, () => { refreshCount.value++ }))
|
|
154
|
+
onDispose(() => { for (const stop of stops) stop() })
|
|
155
|
+
}
|
|
156
|
+
return createFragment((parent, anchor) => {
|
|
157
|
+
const toolbar = createElement('div')
|
|
158
|
+
setAttribute(toolbar, 'class', 'vobs-devtools-collection-toolbar vobs-devtools-collection-toolbar--header')
|
|
159
|
+
const search = createElement('input')
|
|
160
|
+
setAttribute(search, 'class', 'vobs-devtools-search vobs-devtools-header-search')
|
|
161
|
+
setAttribute(search, 'type', 'search')
|
|
162
|
+
setAttribute(search, 'placeholder', 'Search diagnostics')
|
|
163
|
+
// This must be its own reactive effect. Reading query while mounting the
|
|
164
|
+
// dialog header makes the header slot depend on the query and replaces the
|
|
165
|
+
// focused input after every keystroke.
|
|
166
|
+
effect(() => {
|
|
167
|
+
const next = props.query.value
|
|
168
|
+
if ((search as HTMLInputElement).value !== next) setProperty(search, 'value', next)
|
|
169
|
+
})
|
|
170
|
+
search.addEventListener('input', () => { props.query.value = (search as HTMLInputElement).value })
|
|
171
|
+
insertBefore(toolbar, search, null)
|
|
172
|
+
const actions = createElement('div')
|
|
173
|
+
setAttribute(actions, 'class', 'vobs-devtools-collection-toolbar__actions')
|
|
174
|
+
insertBefore(toolbar, actions, null)
|
|
175
|
+
insertBefore(parent, toolbar, anchor)
|
|
176
|
+
|
|
177
|
+
insertDynamic(actions, null, () => {
|
|
178
|
+
void refreshCount.value
|
|
179
|
+
if (!props.api) return null
|
|
180
|
+
const paused = props.api.getCollectionState().paused
|
|
181
|
+
return createFragment((actionParent, actionAnchor) => {
|
|
182
|
+
insertBefore(actionParent, createComponent(Button, {
|
|
183
|
+
variant: paused ? 'warning' : 'secondary',
|
|
184
|
+
iconOnly: true,
|
|
185
|
+
icon: createComponent(Icon, { name: paused ? 'play' : 'pause' }),
|
|
186
|
+
'aria-label': paused ? 'Resume collection' : 'Pause collection',
|
|
187
|
+
title: paused ? 'Resume collection' : 'Pause collection',
|
|
188
|
+
onClick: () => props.api?.setCollectionPaused(!paused)
|
|
189
|
+
}), actionAnchor)
|
|
190
|
+
insertBefore(actionParent, createComponent(Button, {
|
|
191
|
+
variant: 'danger-subtle',
|
|
192
|
+
iconOnly: true,
|
|
193
|
+
icon: createComponent(Icon, { name: 'trash' }),
|
|
194
|
+
'aria-label': 'Clear diagnostics',
|
|
195
|
+
title: 'Clear diagnostics',
|
|
196
|
+
onClick: () => {
|
|
197
|
+
props.api?.clearUpdates()
|
|
198
|
+
props.api?.clearNetworkRequests()
|
|
199
|
+
props.api?.clearErrors()
|
|
200
|
+
props.api?.clearLifecycleEvents()
|
|
201
|
+
}
|
|
202
|
+
}), actionAnchor)
|
|
203
|
+
const exportDiagnostics = (): void => {
|
|
204
|
+
const data = JSON.stringify(props.api?.exportDiagnostics(), null, 2)
|
|
205
|
+
if (typeof document === 'undefined' || typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function' || typeof URL.revokeObjectURL !== 'function' || typeof Blob === 'undefined') return
|
|
206
|
+
const link = document.createElement('a')
|
|
207
|
+
link.href = URL.createObjectURL(new Blob([data], { type: 'application/json' }))
|
|
208
|
+
link.download = `vobs-devtools-${new Date().toISOString().replace(/[:.]/g, '-')}.json`
|
|
209
|
+
link.click()
|
|
210
|
+
URL.revokeObjectURL(link.href)
|
|
211
|
+
}
|
|
212
|
+
insertBefore(actionParent, createComponent(Button, {
|
|
213
|
+
variant: 'ghost',
|
|
214
|
+
iconOnly: true,
|
|
215
|
+
icon: createComponent(Icon, { name: 'download' }),
|
|
216
|
+
'aria-label': 'Export diagnostics',
|
|
217
|
+
title: 'Export diagnostics',
|
|
218
|
+
onClick: exportDiagnostics
|
|
219
|
+
}), actionAnchor)
|
|
220
|
+
const importInput = createElement('input')
|
|
221
|
+
setAttribute(importInput, 'type', 'file')
|
|
222
|
+
setAttribute(importInput, 'accept', 'application/json,.json')
|
|
223
|
+
setAttribute(importInput, 'aria-label', 'Import diagnostics')
|
|
224
|
+
setAttribute(importInput, 'hidden', '')
|
|
225
|
+
importInput.addEventListener('change', () => {
|
|
226
|
+
const file = (importInput as HTMLInputElement).files?.[0]
|
|
227
|
+
if (!file) return
|
|
228
|
+
void file.text().then(text => props.api?.importDiagnostics(JSON.parse(text))).catch(error => props.api?.reportError('global', error))
|
|
229
|
+
})
|
|
230
|
+
insertBefore(actionParent, importInput, actionAnchor)
|
|
231
|
+
insertBefore(actionParent, createComponent(Button, {
|
|
232
|
+
variant: 'ghost',
|
|
233
|
+
iconOnly: true,
|
|
234
|
+
icon: createComponent(Icon, { name: 'upload' }),
|
|
235
|
+
'aria-label': 'Import diagnostics',
|
|
236
|
+
title: 'Import diagnostics',
|
|
237
|
+
onClick: () => (importInput as HTMLInputElement).click()
|
|
238
|
+
}), actionAnchor)
|
|
239
|
+
if (props.onToggleMaximize) insertBefore(actionParent, createComponent(Button, {
|
|
240
|
+
variant: 'ghost',
|
|
241
|
+
iconOnly: true,
|
|
242
|
+
icon: createComponent(Icon, { name: props.maximized ? 'arrow-minimize' : 'arrow-expand' }),
|
|
243
|
+
'aria-label': props.maximized ? 'Restore DevTools' : 'Maximize DevTools',
|
|
244
|
+
title: props.maximized ? 'Restore DevTools' : 'Maximize DevTools',
|
|
245
|
+
onClick: props.onToggleMaximize
|
|
246
|
+
}), actionAnchor)
|
|
247
|
+
})
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
type DevToolsSection = 'router' | 'components' | 'advanced' | 'updates' | 'network' | 'errors'
|
|
253
|
+
type AdvancedSection = 'signals' | 'effects'
|
|
254
|
+
type RouterPanelTab = 'context' | 'requests' | 'errors' | 'history' | 'routes'
|
|
255
|
+
type UpdatesPanelTab = 'performance' | 'slow' | 'updates'
|
|
256
|
+
type ComponentsPanelTab = 'tree' | 'lifecycle'
|
|
257
|
+
type TimelineFilter = 'all' | 'update' | 'request' | 'navigation' | 'error'
|
|
258
|
+
type NetworkSourceFilter = 'all' | 'http' | 'router' | 'ssr'
|
|
259
|
+
type NetworkStatusFilter = 'all' | 'loading' | 'success' | 'error' | 'cancelled'
|
|
260
|
+
type NetworkDetailTab = 'overview' | 'headers' | 'payload' | 'response' | 'timing' | 'context'
|
|
261
|
+
|
|
262
|
+
interface NetworkEntry {
|
|
263
|
+
readonly key: string
|
|
264
|
+
readonly source: 'http' | 'router' | 'ssr'
|
|
265
|
+
readonly method: string
|
|
266
|
+
readonly url: string
|
|
267
|
+
readonly status: string
|
|
268
|
+
readonly duration?: number
|
|
269
|
+
readonly startedAt?: number
|
|
270
|
+
readonly endedAt?: number
|
|
271
|
+
readonly request?: NetworkRequestTrace
|
|
272
|
+
readonly routerRequest?: DevToolsRouterContext['dataRequests'][number]
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
interface RequestTesterParam {
|
|
276
|
+
key: string
|
|
277
|
+
value: string
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
interface RequestTesterDraft {
|
|
281
|
+
url: string
|
|
282
|
+
method: HTTPMethod
|
|
283
|
+
params: RequestTesterParam[]
|
|
284
|
+
headers: RequestTesterParam[]
|
|
285
|
+
body: string
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
type RequestTesterRun = { status: 'idle' | 'running' | 'success' | 'error'; message?: string }
|
|
289
|
+
type RequestTesterTab = 'params' | 'headers' | 'body'
|
|
290
|
+
|
|
291
|
+
function createRequestTesterDraft(url = ''): RequestTesterDraft {
|
|
292
|
+
return { url, method: 'GET', params: [{ key: '', value: '' }], headers: [{ key: '', value: '' }], body: '' }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const TIMELINE_FILTER_OPTIONS: readonly { readonly value: TimelineFilter; readonly label: string }[] = [
|
|
296
|
+
{ value: 'all', label: 'All events' },
|
|
297
|
+
{ value: 'update', label: 'Updates' },
|
|
298
|
+
{ value: 'request', label: 'Requests' },
|
|
299
|
+
{ value: 'navigation', label: 'Navigation' },
|
|
300
|
+
{ value: 'error', label: 'Errors' }
|
|
301
|
+
]
|
|
302
|
+
|
|
303
|
+
function resolveApi(props: DevToolsPanelProps): DevToolsAPI | null {
|
|
304
|
+
return props.api === undefined ? getDevTools() : props.api
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
interface DevToolsContentProps {
|
|
308
|
+
readonly refreshCount: { readonly value: number }
|
|
309
|
+
readonly activeSection: { readonly value: DevToolsSection }
|
|
310
|
+
readonly activeAdvancedSection: { value: AdvancedSection }
|
|
311
|
+
readonly activeRouterTab: { value: RouterPanelTab }
|
|
312
|
+
readonly activeUpdatesTab: { value: UpdatesPanelTab }
|
|
313
|
+
readonly activeComponentsTab: { value: ComponentsPanelTab }
|
|
314
|
+
readonly activeRouteView: { value: 'tree' | 'list' }
|
|
315
|
+
readonly router: Router | null
|
|
316
|
+
readonly api: DevToolsAPI | null
|
|
317
|
+
readonly http: HTTPClient | null
|
|
318
|
+
readonly selection: { value: DevToolsSelection | null }
|
|
319
|
+
readonly query: { value: string }
|
|
320
|
+
readonly networkSelection: { value: string | null }
|
|
321
|
+
readonly networkSourceFilter: { value: NetworkSourceFilter }
|
|
322
|
+
readonly networkStatusFilter: { value: NetworkStatusFilter }
|
|
323
|
+
readonly networkDetailTab: { value: NetworkDetailTab }
|
|
324
|
+
readonly networkTesterOpen: { value: boolean }
|
|
325
|
+
readonly networkTesterRevision: { value: number }
|
|
326
|
+
readonly networkTesterRun: { value: RequestTesterRun }
|
|
327
|
+
readonly networkTesterDraft: RequestTesterDraft
|
|
328
|
+
readonly networkTesterTab: { value: RequestTesterTab }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
interface ActivityRailProps {
|
|
332
|
+
readonly refreshCount: { readonly value: number }
|
|
333
|
+
readonly router: Router | null
|
|
334
|
+
readonly api: DevToolsAPI | null
|
|
335
|
+
readonly selection: { value: DevToolsSelection | null }
|
|
336
|
+
readonly activeSection: { value: DevToolsSection }
|
|
337
|
+
readonly activeFilter: { value: TimelineFilter }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function DevToolsContent(props: DevToolsContentProps) {
|
|
341
|
+
return createFragment((parent, anchor) => {
|
|
342
|
+
insertDynamic(parent, anchor, () => {
|
|
343
|
+
void props.refreshCount.value
|
|
344
|
+
void props.activeSection.value
|
|
345
|
+
void props.activeRouterTab.value
|
|
346
|
+
void props.activeUpdatesTab.value
|
|
347
|
+
void props.activeComponentsTab.value
|
|
348
|
+
void props.activeRouteView.value
|
|
349
|
+
void props.query.value
|
|
350
|
+
void props.networkSelection.value
|
|
351
|
+
void props.networkSourceFilter.value
|
|
352
|
+
void props.networkStatusFilter.value
|
|
353
|
+
void props.networkDetailTab.value
|
|
354
|
+
void props.networkTesterOpen.value
|
|
355
|
+
void props.networkTesterRevision.value
|
|
356
|
+
void props.networkTesterRun.value
|
|
357
|
+
void props.networkTesterTab.value
|
|
358
|
+
const selected = props.selection.value
|
|
359
|
+
const snapshot = readDevToolsSnapshotWithRouter(props.api, props.router)
|
|
360
|
+
return renderDevToolsContent(snapshot, props.activeSection.value, props.router, props.activeRouterTab, props.activeUpdatesTab, props.activeComponentsTab, selected, focusSelection(props.activeSection, props.selection), props.query.value, props.activeAdvancedSection, props.activeRouteView, props.networkSelection, props.networkSourceFilter, props.networkStatusFilter, props.networkDetailTab, props.query, props.http, props.networkTesterOpen, props.networkTesterRevision, props.networkTesterRun, props.networkTesterDraft, props.networkTesterTab)
|
|
361
|
+
})
|
|
362
|
+
})
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function ActivityRail(props: ActivityRailProps) {
|
|
366
|
+
return createFragment((parent, anchor) => {
|
|
367
|
+
insertDynamic(parent, anchor, () => {
|
|
368
|
+
void props.refreshCount.value
|
|
369
|
+
const filter = props.activeFilter.value
|
|
370
|
+
const selected = props.selection.value
|
|
371
|
+
const snapshot = readDevToolsSnapshotWithRouter(props.api, props.router)
|
|
372
|
+
const updates = [...snapshot.updates].reverse().slice(0, 8)
|
|
373
|
+
const timeline = collectActivityTimeline(snapshot, props.router, filter)
|
|
374
|
+
return renderActivityRail(updates, timeline, snapshot.api, selected, filter, next => { props.activeFilter.value = next }, focusSelection(props.activeSection, props.selection))
|
|
375
|
+
})
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function focusSelection(
|
|
380
|
+
activeSection: { value: DevToolsSection },
|
|
381
|
+
selection: { value: DevToolsSelection | null }
|
|
382
|
+
): (section: DevToolsSection, next: DevToolsSelection) => void {
|
|
383
|
+
return (section, next) => {
|
|
384
|
+
selection.value = next
|
|
385
|
+
activeSection.value = section
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function SectionNav(props: { readonly activeSection: { value: DevToolsSection } }): VobsNode {
|
|
390
|
+
return createFragment((parent, anchor) => {
|
|
391
|
+
insertDynamic(parent, anchor, () => {
|
|
392
|
+
const selected = props.activeSection.value
|
|
393
|
+
return createFragment((navParent, navAnchor) => {
|
|
394
|
+
for (const section of ['updates', 'components', 'advanced', 'network', 'errors', 'router'] as const) {
|
|
395
|
+
const label = section === 'components' ? 'comp...' : section === 'advanced' ? 'adv...' : section
|
|
396
|
+
const button = createElement('button')
|
|
397
|
+
setAttribute(button, 'class', `vobs-devtools-shell__nav-item${selected === section ? ' is-active' : ''}`)
|
|
398
|
+
setAttribute(button, 'type', 'button')
|
|
399
|
+
const accessibleLabel = section === 'advanced' ? 'Advanced' : section
|
|
400
|
+
setAttribute(button, 'aria-label', accessibleLabel)
|
|
401
|
+
setAttribute(button, 'title', accessibleLabel)
|
|
402
|
+
setAttribute(button, 'aria-pressed', selected === section ? 'true' : 'false')
|
|
403
|
+
insertBefore(button, createComponent(Icon, { name: section === 'router' ? 'folder' : section === 'components' ? 'code' : section === 'advanced' ? 'atom' : section === 'network' ? 'globe' : section === 'errors' ? 'alert-triangle' : 'refresh' }), null)
|
|
404
|
+
insertBefore(button, createElementText(label), null)
|
|
405
|
+
button.addEventListener('click', () => { props.activeSection.value = section })
|
|
406
|
+
insertBefore(navParent, button, navAnchor)
|
|
407
|
+
}
|
|
408
|
+
})
|
|
409
|
+
})
|
|
410
|
+
})
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
interface ActivityTimelineItem {
|
|
414
|
+
readonly kind: Exclude<TimelineFilter, 'all'>
|
|
415
|
+
readonly timestamp: number
|
|
416
|
+
readonly title: string
|
|
417
|
+
readonly detail?: string
|
|
418
|
+
readonly icon: string
|
|
419
|
+
readonly update?: UpdateTrace
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function collectActivityTimeline(snapshot: DevToolsSnapshot, router: Router | null, filter: TimelineFilter = 'all'): readonly ActivityTimelineItem[] {
|
|
423
|
+
const items: ActivityTimelineItem[] = snapshot.updates.map(update => ({
|
|
424
|
+
kind: 'update',
|
|
425
|
+
timestamp: update.timestamp,
|
|
426
|
+
title: snapshot.api?.getSignal(update.signalId) ? displaySignalName(snapshot.api.getSignal(update.signalId)!) : displayDebugName(update.signalName),
|
|
427
|
+
detail: `${formatDebugSource(snapshot.api?.getSignal(update.signalId)?.component ?? 'unknown')} · ${update.duration.toFixed(2)} ms · ${update.effects.length} effects`,
|
|
428
|
+
icon: 'zap',
|
|
429
|
+
update
|
|
430
|
+
}))
|
|
431
|
+
for (const request of snapshot.network) {
|
|
432
|
+
items.push({
|
|
433
|
+
kind: 'request',
|
|
434
|
+
timestamp: request.startedAt,
|
|
435
|
+
title: `${request.method} ${request.url}`,
|
|
436
|
+
detail: `${request.source ?? 'http'} · ${request.status}${request.duration === undefined ? '' : ` · ${request.duration} ms`}`,
|
|
437
|
+
icon: 'download'
|
|
438
|
+
})
|
|
439
|
+
}
|
|
440
|
+
for (const request of snapshot.routerContext?.dataRequests ?? []) {
|
|
441
|
+
items.push({
|
|
442
|
+
kind: 'request',
|
|
443
|
+
timestamp: request.startedAt ?? Date.now(),
|
|
444
|
+
title: `${request.kind} · ${request.key}`,
|
|
445
|
+
detail: `${request.status}${request.route ? ` · ${request.route}` : ''}`,
|
|
446
|
+
icon: 'folder'
|
|
447
|
+
})
|
|
448
|
+
}
|
|
449
|
+
for (const trace of router?.devtools.getNavigationHistory() ?? []) {
|
|
450
|
+
items.push({
|
|
451
|
+
kind: 'navigation',
|
|
452
|
+
timestamp: trace.endedAt,
|
|
453
|
+
title: `Navigation ${trace.to}`,
|
|
454
|
+
detail: `${trace.status} · ${trace.duration.toFixed(2)} ms`,
|
|
455
|
+
icon: 'arrow-right'
|
|
456
|
+
})
|
|
457
|
+
}
|
|
458
|
+
for (const error of snapshot.errors) {
|
|
459
|
+
items.push({
|
|
460
|
+
kind: 'error',
|
|
461
|
+
timestamp: error.lastOccurredAt,
|
|
462
|
+
title: `${error.phase} · ${error.message}`,
|
|
463
|
+
detail: error.route ?? error.component,
|
|
464
|
+
icon: 'alert-triangle'
|
|
465
|
+
})
|
|
466
|
+
}
|
|
467
|
+
return items
|
|
468
|
+
.filter(item => filter === 'all' || item.kind === filter)
|
|
469
|
+
.sort((left, right) => right.timestamp - left.timestamp)
|
|
470
|
+
.slice(0, 12)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function renderActivityRail(
|
|
474
|
+
updates: readonly UpdateTrace[],
|
|
475
|
+
timeline: readonly ActivityTimelineItem[],
|
|
476
|
+
api: DevToolsAPI | null,
|
|
477
|
+
selection: DevToolsSelection | null,
|
|
478
|
+
filter: TimelineFilter,
|
|
479
|
+
onFilterChange: (filter: TimelineFilter) => void,
|
|
480
|
+
onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void
|
|
481
|
+
): VobsNode {
|
|
482
|
+
const root = createElement('aside')
|
|
483
|
+
setAttribute(root, 'class', 'vobs-devtools-shell__activity')
|
|
484
|
+
const header = createElement('div')
|
|
485
|
+
setAttribute(header, 'class', 'vobs-devtools-shell__activity-header')
|
|
486
|
+
const title = createElement('div')
|
|
487
|
+
setAttribute(title, 'class', 'vobs-devtools-shell__activity-title')
|
|
488
|
+
insertBefore(title, createElementText('Timeline'), null)
|
|
489
|
+
insertBefore(header, title, null)
|
|
490
|
+
insertBefore(header, createComponent(Select, {
|
|
491
|
+
class: 'vobs-devtools-timeline-filter',
|
|
492
|
+
value: filter,
|
|
493
|
+
'aria-label': 'Filter timeline events',
|
|
494
|
+
onChange: event => {
|
|
495
|
+
const next = (event.target as HTMLSelectElement).value
|
|
496
|
+
if (isTimelineFilter(next)) onFilterChange(next)
|
|
497
|
+
},
|
|
498
|
+
children: () => createFragment((parent, anchor) => {
|
|
499
|
+
for (const option of TIMELINE_FILTER_OPTIONS) {
|
|
500
|
+
const optionNode = createElement('option')
|
|
501
|
+
setAttribute(optionNode, 'value', option.value)
|
|
502
|
+
insertBefore(optionNode, createText(option.label), null)
|
|
503
|
+
insertBefore(parent, optionNode, anchor)
|
|
504
|
+
}
|
|
505
|
+
})
|
|
506
|
+
}), null)
|
|
507
|
+
insertBefore(root, header, null)
|
|
508
|
+
const subtitle = createElement('div')
|
|
509
|
+
setAttribute(subtitle, 'class', 'vobs-devtools-shell__activity-subtitle')
|
|
510
|
+
insertBefore(subtitle, createElementText(`${timeline.length} shown · ${updates.length} updates`), null)
|
|
511
|
+
insertBefore(root, subtitle, null)
|
|
512
|
+
const list = createElement('div')
|
|
513
|
+
setAttribute(list, 'class', 'vobs-devtools-activity-list')
|
|
514
|
+
for (const entry of timeline) {
|
|
515
|
+
const update = entry.update
|
|
516
|
+
const activityItem = createElement('div')
|
|
517
|
+
setAttribute(activityItem, 'class', `vobs-devtools-activity-item${update && selection?.type === 'update' && selection.id === update.id ? ' is-selected' : ''}`)
|
|
518
|
+
if (update) activityItem.addEventListener('click', () => onFocus('updates', { type: 'update', id: update.id }))
|
|
519
|
+
const dot = createElement('span')
|
|
520
|
+
setAttribute(dot, 'class', 'vobs-devtools-activity-item__dot')
|
|
521
|
+
insertBefore(dot, createComponent(Icon, { name: entry.icon }), null)
|
|
522
|
+
const body = createElement('div')
|
|
523
|
+
setAttribute(body, 'class', 'vobs-devtools-activity-item__body')
|
|
524
|
+
const name = createElement('span')
|
|
525
|
+
setAttribute(name, 'class', 'vobs-devtools-activity-item__name')
|
|
526
|
+
const signal = update ? api?.getSignal(update.signalId) : undefined
|
|
527
|
+
insertBefore(name, createElementText(entry.title), null)
|
|
528
|
+
const detail = createElement('span')
|
|
529
|
+
insertBefore(detail, createElementText(entry.detail ?? (update ? `${formatDebugSource(signal?.component ?? 'unknown')} · ${update.duration.toFixed(2)} ms · ${update.effects.length} effects` : '')), null)
|
|
530
|
+
insertBefore(body, name, null)
|
|
531
|
+
insertBefore(body, detail, null)
|
|
532
|
+
insertBefore(activityItem, dot, null)
|
|
533
|
+
insertBefore(activityItem, body, null)
|
|
534
|
+
insertBefore(list, activityItem, null)
|
|
535
|
+
}
|
|
536
|
+
if (timeline.length === 0) {
|
|
537
|
+
const empty = createElement('span')
|
|
538
|
+
setAttribute(empty, 'class', 'vobs-devtools-muted')
|
|
539
|
+
insertBefore(empty, createElementText(filter === 'all' ? 'No recent events.' : `No ${timelineFilterLabel(filter).toLowerCase()} events.`), null)
|
|
540
|
+
insertBefore(list, empty, null)
|
|
541
|
+
}
|
|
542
|
+
insertBefore(root, list, null)
|
|
543
|
+
return root
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function isTimelineFilter(value: string): value is TimelineFilter {
|
|
547
|
+
return TIMELINE_FILTER_OPTIONS.some(option => option.value === value)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function timelineFilterLabel(filter: TimelineFilter): string {
|
|
551
|
+
return TIMELINE_FILTER_OPTIONS.find(option => option.value === filter)?.label ?? 'All events'
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function createElementText(value: string): VobsNode {
|
|
555
|
+
return createText(value)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function renderDevToolsContent(
|
|
559
|
+
snapshot: DevToolsSnapshot,
|
|
560
|
+
section: DevToolsSection = 'updates',
|
|
561
|
+
router: Router | null = null,
|
|
562
|
+
activeRouterTab?: { value: RouterPanelTab },
|
|
563
|
+
activeUpdatesTab?: { value: UpdatesPanelTab },
|
|
564
|
+
activeComponentsTab?: { value: ComponentsPanelTab },
|
|
565
|
+
selection: DevToolsSelection | null = null,
|
|
566
|
+
onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void = () => undefined,
|
|
567
|
+
query = '',
|
|
568
|
+
activeAdvancedSection?: { value: AdvancedSection },
|
|
569
|
+
activeRouteView?: { value: 'tree' | 'list' },
|
|
570
|
+
networkSelection?: { value: string | null },
|
|
571
|
+
networkSourceFilter?: { value: NetworkSourceFilter },
|
|
572
|
+
networkStatusFilter?: { value: NetworkStatusFilter },
|
|
573
|
+
networkDetailTab?: { value: NetworkDetailTab },
|
|
574
|
+
queryState?: { value: string },
|
|
575
|
+
http?: HTTPClient | null,
|
|
576
|
+
networkTesterOpen?: { value: boolean },
|
|
577
|
+
networkTesterRevision?: { value: number },
|
|
578
|
+
networkTesterRun?: { value: RequestTesterRun },
|
|
579
|
+
networkTesterDraft?: RequestTesterDraft,
|
|
580
|
+
networkTesterTab?: { value: RequestTesterTab }
|
|
581
|
+
): VobsNode {
|
|
582
|
+
if (!snapshot.api) {
|
|
583
|
+
return createComponent(Alert, {
|
|
584
|
+
description: 'Enable the devtools plugin to inspect the runtime.'
|
|
585
|
+
})
|
|
586
|
+
}
|
|
587
|
+
return renderFocusedSection(snapshot, section, router, activeRouterTab, activeUpdatesTab, activeComponentsTab, selection, onFocus, query, activeAdvancedSection, activeRouteView, networkSelection, networkSourceFilter, networkStatusFilter, networkDetailTab, queryState, http, networkTesterOpen, networkTesterRevision, networkTesterRun, networkTesterDraft, networkTesterTab)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function renderFocusedSection(
|
|
591
|
+
snapshot: DevToolsSnapshot,
|
|
592
|
+
section: DevToolsSection,
|
|
593
|
+
router: Router | null,
|
|
594
|
+
activeRouterTab?: { value: RouterPanelTab },
|
|
595
|
+
activeUpdatesTab?: { value: UpdatesPanelTab },
|
|
596
|
+
activeComponentsTab?: { value: ComponentsPanelTab },
|
|
597
|
+
selection: DevToolsSelection | null = null,
|
|
598
|
+
onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void = () => undefined,
|
|
599
|
+
query = '',
|
|
600
|
+
activeAdvancedSection?: { value: AdvancedSection },
|
|
601
|
+
activeRouteView?: { value: 'tree' | 'list' },
|
|
602
|
+
networkSelection?: { value: string | null },
|
|
603
|
+
networkSourceFilter?: { value: NetworkSourceFilter },
|
|
604
|
+
networkStatusFilter?: { value: NetworkStatusFilter },
|
|
605
|
+
networkDetailTab?: { value: NetworkDetailTab },
|
|
606
|
+
queryState?: { value: string },
|
|
607
|
+
http?: HTTPClient | null,
|
|
608
|
+
networkTesterOpen?: { value: boolean },
|
|
609
|
+
networkTesterRevision?: { value: number },
|
|
610
|
+
networkTesterRun?: { value: RequestTesterRun },
|
|
611
|
+
networkTesterDraft?: RequestTesterDraft,
|
|
612
|
+
networkTesterTab?: { value: RequestTesterTab }
|
|
613
|
+
): VobsNode {
|
|
614
|
+
if (section === 'router') return renderRouterSection(router, activeRouterTab, activeRouteView)
|
|
615
|
+
if (section === 'updates') return renderUpdatesSection(snapshot, activeUpdatesTab, query, selection, onFocus)
|
|
616
|
+
if (section === 'components') return renderComponentsSection(snapshot, activeComponentsTab, query, selection, onFocus)
|
|
617
|
+
if (section === 'network') return createComponent(Card, {
|
|
618
|
+
children: () => renderUnifiedNetworkRequests(snapshot.network, snapshot.routerContext?.dataRequests ?? [], query, selection, onFocus, networkSelection, networkSourceFilter, networkStatusFilter, networkDetailTab, snapshot.api, queryState, http, networkTesterOpen, networkTesterRevision, networkTesterRun, networkTesterDraft, snapshot.routerContext?.route, networkTesterTab)
|
|
619
|
+
})
|
|
620
|
+
if (section === 'errors') return createComponent(Card, {
|
|
621
|
+
description: `${snapshot.errors.length} retained unique errors`,
|
|
622
|
+
children: () => renderErrors(filterErrors(snapshot.errors, query), selection, onFocus)
|
|
623
|
+
})
|
|
624
|
+
if (section === 'advanced') return renderAdvancedSection(snapshot, activeAdvancedSection, query)
|
|
625
|
+
const list = createElement('div')
|
|
626
|
+
setAttribute(list, 'class', 'vobs-devtools-list')
|
|
627
|
+
let title = 'Recent updates'
|
|
628
|
+
let description = `${snapshot.updates.length} retained traces`
|
|
629
|
+
return createComponent(Card, { title, description, children: () => list })
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function renderComponentsSection(
|
|
633
|
+
snapshot: DevToolsSnapshot,
|
|
634
|
+
activeTab?: { value: ComponentsPanelTab },
|
|
635
|
+
query = '',
|
|
636
|
+
selection: DevToolsSelection | null = null,
|
|
637
|
+
onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void = () => undefined
|
|
638
|
+
): VobsNode {
|
|
639
|
+
const tabs = createComponent(Tabs, {
|
|
640
|
+
class: 'vobs-devtools-router-tabs',
|
|
641
|
+
variant: 'filled',
|
|
642
|
+
items: [
|
|
643
|
+
{ id: 'tree', label: 'Component tree', content: () => renderComponentTree(snapshot, query, selection, onFocus) },
|
|
644
|
+
{ id: 'lifecycle', label: 'Lifecycle timeline', content: () => createComponent(Card, {
|
|
645
|
+
class: 'vobs-devtools-lifecycle-card',
|
|
646
|
+
description: `${snapshot.lifecycle.length} retained runtime events`,
|
|
647
|
+
children: () => renderLifecycleTimeline(snapshot.lifecycle, (section, next) => {
|
|
648
|
+
onFocus(section, next)
|
|
649
|
+
if (section === 'components' && activeTab) activeTab.value = 'tree'
|
|
650
|
+
})
|
|
651
|
+
}) }
|
|
652
|
+
],
|
|
653
|
+
get value() { return activeTab?.value ?? 'tree' },
|
|
654
|
+
onChange: id => { if (activeTab && isComponentsPanelTab(id)) activeTab.value = id }
|
|
655
|
+
})
|
|
656
|
+
const root = createElement('div')
|
|
657
|
+
setAttribute(root, 'class', 'vobs-devtools-components-tabs-wrap')
|
|
658
|
+
insertBefore(root, tabs, null)
|
|
659
|
+
return root
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function renderComponentTree(
|
|
663
|
+
snapshot: DevToolsSnapshot,
|
|
664
|
+
query: string,
|
|
665
|
+
selection: DevToolsSelection | null,
|
|
666
|
+
onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void
|
|
667
|
+
): VobsNode {
|
|
668
|
+
const componentTree = filterComponentTree(snapshot.tree, query)
|
|
669
|
+
const list = createElement('div')
|
|
670
|
+
setAttribute(list, 'class', 'vobs-devtools-list')
|
|
671
|
+
for (const node of componentTree) insertBefore(list, renderComponentSummary(node, snapshot, selection, onFocus), null)
|
|
672
|
+
if (componentTree.length === 0) appendMuted(list, query ? 'No components match the search.' : 'No components recorded.')
|
|
673
|
+
return createComponent(Card, {
|
|
674
|
+
class: 'vobs-devtools-component-tree-card',
|
|
675
|
+
description: `Component tree ${snapshot.memory?.ownerCount ?? 0} active owners`,
|
|
676
|
+
children: () => list
|
|
677
|
+
})
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function isComponentsPanelTab(value: string): value is ComponentsPanelTab {
|
|
681
|
+
return value === 'tree' || value === 'lifecycle'
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function renderUpdatesSection(
|
|
685
|
+
snapshot: DevToolsSnapshot,
|
|
686
|
+
activeTab?: { value: UpdatesPanelTab },
|
|
687
|
+
query = '',
|
|
688
|
+
selection: DevToolsSelection | null = null,
|
|
689
|
+
onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void = () => undefined
|
|
690
|
+
): VobsNode {
|
|
691
|
+
const tabs = createComponent(Tabs, {
|
|
692
|
+
class: 'vobs-devtools-router-tabs',
|
|
693
|
+
variant: 'filled',
|
|
694
|
+
items: [
|
|
695
|
+
{ id: 'performance', label: 'Performance', content: () => renderPerformanceMetrics(snapshot.metrics) },
|
|
696
|
+
{ id: 'slow', label: 'Slow items', content: () => renderSlowItems(snapshot.performanceEntries) },
|
|
697
|
+
{ id: 'updates', label: `Updates${snapshot.updates.length ? ` (${snapshot.updates.length})` : ''}`, content: () => renderUpdatesList(snapshot, query, selection, onFocus) }
|
|
698
|
+
],
|
|
699
|
+
get value() { return activeTab?.value ?? 'updates' },
|
|
700
|
+
onChange: id => { if (activeTab && isUpdatesPanelTab(id)) activeTab.value = id }
|
|
701
|
+
})
|
|
702
|
+
const root = createElement('div')
|
|
703
|
+
setAttribute(root, 'class', 'vobs-devtools-updates-tabs-wrap')
|
|
704
|
+
insertBefore(root, tabs, null)
|
|
705
|
+
return root
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function isUpdatesPanelTab(value: string): value is UpdatesPanelTab {
|
|
709
|
+
return value === 'performance' || value === 'slow' || value === 'updates'
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function renderUpdatesList(
|
|
713
|
+
snapshot: DevToolsSnapshot,
|
|
714
|
+
query: string,
|
|
715
|
+
selection: DevToolsSelection | null,
|
|
716
|
+
onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void
|
|
717
|
+
): VobsNode {
|
|
718
|
+
const list = createElement('div')
|
|
719
|
+
setAttribute(list, 'class', 'vobs-devtools-list')
|
|
720
|
+
for (const update of [...snapshot.updates].reverse().filter(update => matchesQuery(query, update.signalName, update.signalId, update.status, formatValue(update.previousValue), formatValue(update.nextValue))).slice(0, 50)) {
|
|
721
|
+
insertBefore(list, renderUpdateRow(update, snapshot.api, selection, onFocus), null)
|
|
722
|
+
}
|
|
723
|
+
if (snapshot.updates.length === 0) appendMuted(list, 'Interact with the playground to record updates.')
|
|
724
|
+
return createComponent(Card, {
|
|
725
|
+
class: 'vobs-devtools-updates-card',
|
|
726
|
+
children: () => list
|
|
727
|
+
})
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function renderAdvancedSection(
|
|
731
|
+
snapshot: DevToolsSnapshot,
|
|
732
|
+
activeSection?: { value: AdvancedSection },
|
|
733
|
+
query = ''
|
|
734
|
+
): VobsNode {
|
|
735
|
+
const tabs = createComponent(Tabs, {
|
|
736
|
+
class: 'vobs-devtools-advanced-tabs',
|
|
737
|
+
variant: 'filled',
|
|
738
|
+
items: [
|
|
739
|
+
{ id: 'signals', label: 'Signals', content: () => renderSignalsInspector(snapshot, query) },
|
|
740
|
+
{ id: 'effects', label: 'Effects', content: () => renderEffectsInspector(snapshot, query) }
|
|
741
|
+
],
|
|
742
|
+
get value() { return activeSection?.value ?? 'signals' },
|
|
743
|
+
onChange: id => { if (activeSection) activeSection.value = id as AdvancedSection }
|
|
744
|
+
})
|
|
745
|
+
const root = createElement('div')
|
|
746
|
+
setAttribute(root, 'class', 'vobs-devtools-advanced')
|
|
747
|
+
insertBefore(root, tabs, null)
|
|
748
|
+
return root
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function renderSignalsInspector(snapshot: DevToolsSnapshot, query: string): VobsNode {
|
|
752
|
+
const list = createElement('div')
|
|
753
|
+
setAttribute(list, 'class', 'vobs-devtools-list')
|
|
754
|
+
const signals = snapshot.signals.filter(signal => matchesQuery(query, signal.name, signal.component, formatValue(signal.value))).slice(0, 50)
|
|
755
|
+
for (const signal of signals) insertBefore(list, renderSignalInspector(signal, snapshot.api), null)
|
|
756
|
+
if (signals.length === 0) appendMuted(list, query ? 'No signals match the search.' : 'No signals recorded.')
|
|
757
|
+
return list
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function renderEffectsInspector(snapshot: DevToolsSnapshot, query: string): VobsNode {
|
|
761
|
+
const list = createElement('div')
|
|
762
|
+
setAttribute(list, 'class', 'vobs-devtools-list')
|
|
763
|
+
const effects = snapshot.effects.filter(effect => matchesQuery(query, effect.name, effect.component, effect.id)).slice(0, 50)
|
|
764
|
+
for (const effect of effects) insertBefore(list, renderEffectRow(effect), null)
|
|
765
|
+
if (effects.length === 0) appendMuted(list, query ? 'No effects match the search.' : 'No effects recorded.')
|
|
766
|
+
return list
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function renderPerformanceMetrics(metrics: DevToolsSnapshot['metrics']): VobsNode {
|
|
770
|
+
if (!metrics) {
|
|
771
|
+
return createComponent(Card, { description: 'Performance: Lightweight slow-path summary for the retained diagnostics.', children: () => {
|
|
772
|
+
const root = createElement('div')
|
|
773
|
+
appendMuted(root, 'No performance data available.')
|
|
774
|
+
return root
|
|
775
|
+
} })
|
|
776
|
+
}
|
|
777
|
+
const metricsGrid = createElement('div')
|
|
778
|
+
setAttribute(metricsGrid, 'class', 'vobs-devtools-performance-metrics')
|
|
779
|
+
appendMetric(metricsGrid, 'Slow updates', metrics.slowUpdateCount)
|
|
780
|
+
appendMetric(metricsGrid, 'Slow effects', metrics.slowEffectCount)
|
|
781
|
+
appendMetric(metricsGrid, 'Slow requests', metrics.slowRequestCount)
|
|
782
|
+
appendMetric(metricsGrid, 'Max update', `${metrics.maxUpdateDuration.toFixed(2)} ms`)
|
|
783
|
+
appendMetric(metricsGrid, 'Max effect', `${metrics.maxEffectDuration.toFixed(2)} ms`)
|
|
784
|
+
appendMetric(metricsGrid, 'Max request', `${metrics.maxRequestDuration.toFixed(2)} ms`)
|
|
785
|
+
return createComponent(Card, {
|
|
786
|
+
class: 'vobs-devtools-performance-card',
|
|
787
|
+
description: 'Lightweight slow-path summary for the retained diagnostics.',
|
|
788
|
+
children: () => metricsGrid
|
|
789
|
+
})
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function renderSlowItems(entries: DevToolsSnapshot['performanceEntries']): VobsNode {
|
|
793
|
+
const slowEntries = entries.filter(entry => entry.duration >= 16).slice(0, 8)
|
|
794
|
+
const slowList = createElement('div')
|
|
795
|
+
setAttribute(slowList, 'class', 'vobs-devtools-performance-slow-list')
|
|
796
|
+
for (const entry of slowEntries) {
|
|
797
|
+
const item = createElement('div')
|
|
798
|
+
setAttribute(item, 'class', 'vobs-devtools-performance-slow-item')
|
|
799
|
+
appendText(item, entry.kind, 'vobs-devtools-muted')
|
|
800
|
+
appendText(item, formatPerformanceLabel(entry), 'vobs-devtools-list__name')
|
|
801
|
+
appendText(item, `${entry.duration.toFixed(2)} ms`, 'vobs-devtools-code')
|
|
802
|
+
insertBefore(slowList, item, null)
|
|
803
|
+
}
|
|
804
|
+
if (slowEntries.length === 0) appendMuted(slowList, 'No slow items recorded.')
|
|
805
|
+
return createComponent(Card, {
|
|
806
|
+
class: 'vobs-devtools-performance-slow-card',
|
|
807
|
+
description: slowEntries.length ? `${slowEntries.length} retained item${slowEntries.length === 1 ? '' : 's'} at or above 16 ms.` : undefined,
|
|
808
|
+
children: () => slowList
|
|
809
|
+
})
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function formatPerformanceLabel(entry: DevToolsSnapshot['performanceEntries'][number]): string {
|
|
813
|
+
if (entry.kind === 'request') return entry.label
|
|
814
|
+
const formatted = formatDebugLocation(entry.label)
|
|
815
|
+
const openParen = formatted.indexOf('(')
|
|
816
|
+
const closeParen = openParen >= 0 ? formatted.indexOf(')', openParen) : -1
|
|
817
|
+
if (openParen < 0 || closeParen < 0) return stripDebugLocation(entry.label)
|
|
818
|
+
const componentName = formatted.slice(0, openParen).trim()
|
|
819
|
+
const source = formatted.slice(openParen + 1, closeParen)
|
|
820
|
+
const fileName = source.split('/').pop() ?? source
|
|
821
|
+
const suffix = formatted.slice(closeParen + 1).trim()
|
|
822
|
+
return [componentName, fileName, suffix].filter(Boolean).join(' ')
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function appendMetric(parent: Element, label: string, value: number | string): void {
|
|
826
|
+
const row = createElement('div')
|
|
827
|
+
setAttribute(row, 'class', 'vobs-devtools-performance-item')
|
|
828
|
+
appendText(row, label, 'vobs-devtools-muted')
|
|
829
|
+
appendText(row, String(value), 'vobs-devtools-list__name')
|
|
830
|
+
insertBefore(parent, row, null)
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function renderRouterSection(
|
|
834
|
+
router: Router | null,
|
|
835
|
+
activeRouterTab?: { value: RouterPanelTab },
|
|
836
|
+
activeRouteView?: { value: 'tree' | 'list' }
|
|
837
|
+
): VobsNode {
|
|
838
|
+
if (!router) return createComponent(Alert, { tone: 'warning', title: 'Router unavailable', description: 'Pass a Router instance to inspect route activity.' })
|
|
839
|
+
const devtools = router.devtools
|
|
840
|
+
const root = createElement('div')
|
|
841
|
+
setAttribute(root, 'class', 'vobs-devtools-router')
|
|
842
|
+
const current = devtools.getCurrentRoute()
|
|
843
|
+
const state = devtools.getNavigationState()
|
|
844
|
+
const metrics = devtools.getPerformanceMetrics()
|
|
845
|
+
const activeRequests = devtools.getDataRequests().filter(request => request.route === current.fullPath || request.key.startsWith(`${current.fullPath}#`))
|
|
846
|
+
const activeErrors = devtools.getErrors().filter(error => error.route === current.fullPath)
|
|
847
|
+
const toolbar = createElement('div')
|
|
848
|
+
setAttribute(toolbar, 'class', 'vobs-devtools-panel__toolbar')
|
|
849
|
+
insertBefore(toolbar, createComponent(Button, { variant: 'secondary', icon: createComponent(Icon, { name: 'refresh' }), children: () => 'Revalidate current route', onClick: () => { void devtools.revalidate(current.fullPath) } }), null)
|
|
850
|
+
const tabs = createComponent(Tabs, {
|
|
851
|
+
class: 'vobs-devtools-router-tabs',
|
|
852
|
+
variant: 'filled',
|
|
853
|
+
items: [
|
|
854
|
+
{ id: 'context', label: 'Context', content: () => createRouterContextPanel(current, state) },
|
|
855
|
+
{ id: 'requests', label: `Requests${activeRequests.length ? ` (${activeRequests.length})` : ''}`, content: () => createComponent(Card, { title: `Data requests for ${current.path}`, description: 'Loader, action and fetcher traces for the active route.', children: () => renderDataRequests(activeRequests) }) },
|
|
856
|
+
{ id: 'errors', label: `Errors${activeErrors.length ? ` (${activeErrors.length})` : ''}`, content: () => createComponent(Card, { title: `Errors for ${current.path}`, description: `${activeErrors.length} errors captured for the active route.`, children: () => renderRouterErrors(activeErrors) }) },
|
|
857
|
+
{ id: 'history', label: 'History', content: () => createComponent(Card, { title: 'Navigation history', description: `${metrics.navigationCount} recorded navigations · ${metrics.averageNavigationDuration.toFixed(2)} ms average`, children: () => renderNavigationHistory(devtools.getNavigationHistory(), router) }) },
|
|
858
|
+
{ id: 'routes', label: 'Route map', content: () => createComponent(Card, {
|
|
859
|
+
title: 'Route map',
|
|
860
|
+
description: 'All registered routes and their source locations.',
|
|
861
|
+
children: () => renderRouteMap(devtools.getRouteTree(), router, activeRouteView)
|
|
862
|
+
}) }
|
|
863
|
+
],
|
|
864
|
+
get value() { return activeRouterTab?.value ?? 'context' },
|
|
865
|
+
onChange: id => { if (activeRouterTab) activeRouterTab.value = id as RouterPanelTab }
|
|
866
|
+
})
|
|
867
|
+
const tabsWrap = createElement('div')
|
|
868
|
+
setAttribute(tabsWrap, 'class', 'vobs-devtools-router-tabs-wrap')
|
|
869
|
+
insertBefore(tabsWrap, tabs, null)
|
|
870
|
+
insertBefore(tabsWrap, toolbar, null)
|
|
871
|
+
insertBefore(root, tabsWrap, null)
|
|
872
|
+
return root
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function createRouterContextPanel(current: ReturnType<RouterDevToolsAPI['getCurrentRoute']>, state: ReturnType<RouterDevToolsAPI['getNavigationState']>): VobsNode {
|
|
876
|
+
return createFragment((parent, anchor) => {
|
|
877
|
+
insertBefore(parent, createComponent(Card, { title: 'Active route', children: () => renderRouterLocation(current, state) }), anchor)
|
|
878
|
+
insertBefore(parent, createComponent(Card, { title: 'Matched route structure', children: () => renderMatchedRoutes(current.matched) }), anchor)
|
|
879
|
+
})
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function renderDataRequests(requests: readonly RouterDataRequestTrace[]): VobsNode {
|
|
883
|
+
const root = createElement('div')
|
|
884
|
+
setAttribute(root, 'class', 'vobs-devtools-list')
|
|
885
|
+
for (const request of [...requests].reverse().slice(0, 30)) {
|
|
886
|
+
const row = createElement('details')
|
|
887
|
+
setAttribute(row, 'class', 'vobs-devtools-list__row')
|
|
888
|
+
const summary = createElement('summary')
|
|
889
|
+
appendText(summary, `${request.kind} · ${request.key}`, 'vobs-devtools-list__name')
|
|
890
|
+
insertBefore(summary, createComponent(Tag, { tone: request.status === 'success' ? 'success' : request.status === 'error' ? 'danger' : 'warning', children: () => request.status }), null)
|
|
891
|
+
appendText(summary, request.duration === undefined ? 'running' : `${request.duration.toFixed(2)} ms`)
|
|
892
|
+
insertBefore(row, summary, null)
|
|
893
|
+
if (request.error) appendText(row, request.error)
|
|
894
|
+
if (request.status === 'success' && request.result !== undefined) insertBefore(row, renderInspectableValue('Result', request.result), null)
|
|
895
|
+
insertBefore(root, row, null)
|
|
896
|
+
}
|
|
897
|
+
if (requests.length === 0) appendMuted(root, 'No data requests recorded.')
|
|
898
|
+
return root
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function renderRouterErrors(errors: readonly RouteErrorTrace[]): VobsNode {
|
|
902
|
+
const root = createElement('div')
|
|
903
|
+
setAttribute(root, 'class', 'vobs-devtools-list')
|
|
904
|
+
for (const error of [...errors].reverse().slice(0, 30)) {
|
|
905
|
+
const row = createElement('details')
|
|
906
|
+
setAttribute(row, 'class', 'vobs-devtools-list__row vobs-devtools-error-row')
|
|
907
|
+
const summary = createElement('summary')
|
|
908
|
+
appendText(summary, `${error.phase} · ${error.route}`, 'vobs-devtools-list__name')
|
|
909
|
+
appendText(summary, error.message)
|
|
910
|
+
insertBefore(row, summary, null)
|
|
911
|
+
if (error.stack) appendText(row, error.stack, 'vobs-devtools-code')
|
|
912
|
+
insertBefore(root, row, null)
|
|
913
|
+
}
|
|
914
|
+
if (errors.length === 0) appendMuted(root, 'No route errors recorded.')
|
|
915
|
+
return root
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function renderRouterLocation(route: ReturnType<RouterDevToolsAPI['getCurrentRoute']>, state: ReturnType<RouterDevToolsAPI['getNavigationState']>): VobsNode {
|
|
919
|
+
const root = createElement('div')
|
|
920
|
+
setAttribute(root, 'class', 'vobs-devtools-list')
|
|
921
|
+
const statusRow = createElement('div')
|
|
922
|
+
setAttribute(statusRow, 'class', 'vobs-devtools-list__row')
|
|
923
|
+
appendText(statusRow, route.fullPath, 'vobs-devtools-list__name')
|
|
924
|
+
insertBefore(statusRow, createComponent(Tag, { tone: state.status === 'error' ? 'danger' : state.status === 'loading' ? 'warning' : 'success', children: () => state.status }), null)
|
|
925
|
+
if (state.status === 'loading') appendText(statusRow, `to ${state.to}`)
|
|
926
|
+
if (state.error) appendText(statusRow, state.error)
|
|
927
|
+
insertBefore(root, statusRow, null)
|
|
928
|
+
insertBefore(root, renderInspectableValue('Params', route.params), null)
|
|
929
|
+
insertBefore(root, renderInspectableValue('Query', route.query), null)
|
|
930
|
+
insertBefore(root, renderInspectableValue('Meta', route.meta), null)
|
|
931
|
+
appendText(root, `outlet ${route.matched.map(record => record.path ?? '(layout)').join(' > ')}`)
|
|
932
|
+
return root
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function renderMatchedRoutes(records: readonly RouteRecord[]): VobsNode {
|
|
936
|
+
const root = createElement('div')
|
|
937
|
+
setAttribute(root, 'class', 'vobs-devtools-route-tree')
|
|
938
|
+
records.forEach((record, index) => {
|
|
939
|
+
const row = createElement('div')
|
|
940
|
+
setAttribute(row, 'class', 'vobs-devtools-route-node')
|
|
941
|
+
setAttribute(row, 'style', `--route-depth: ${index}`)
|
|
942
|
+
appendText(row, record.path ?? '(layout)', 'vobs-devtools-list__name')
|
|
943
|
+
appendText(row, routeComponentName(record))
|
|
944
|
+
if (record.source) appendText(row, record.source, 'vobs-devtools-code')
|
|
945
|
+
if (record.loader) insertBefore(row, createComponent(Tag, { tone: 'neutral-strong', children: () => 'loader' }), null)
|
|
946
|
+
if (record.action) insertBefore(row, createComponent(Tag, { tone: 'neutral-strong', children: () => 'action' }), null)
|
|
947
|
+
insertBefore(root, row, null)
|
|
948
|
+
})
|
|
949
|
+
if (records.length === 0) appendMuted(root, 'No matched route records.')
|
|
950
|
+
return root
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function renderRouteMap(
|
|
954
|
+
nodes: readonly RouteDebugNode[],
|
|
955
|
+
router: Router,
|
|
956
|
+
activeRouteView?: { value: 'tree' | 'list' }
|
|
957
|
+
): VobsNode {
|
|
958
|
+
const tree = createElement('div')
|
|
959
|
+
setAttribute(tree, 'class', 'vobs-devtools-route-tree')
|
|
960
|
+
const controls = createElement('div')
|
|
961
|
+
setAttribute(controls, 'class', 'vobs-devtools-route-view-toggle')
|
|
962
|
+
for (const mode of ['tree', 'list'] as const) {
|
|
963
|
+
const button = createElement('button')
|
|
964
|
+
setAttribute(button, 'class', `vobs-devtools-control${(activeRouteView?.value ?? 'tree') === mode ? ' is-active' : ''}`)
|
|
965
|
+
setAttribute(button, 'type', 'button')
|
|
966
|
+
setAttribute(button, 'aria-pressed', (activeRouteView?.value ?? 'tree') === mode ? 'true' : 'false')
|
|
967
|
+
insertBefore(button, createText(mode === 'tree' ? 'Tree' : 'List'), null)
|
|
968
|
+
button.addEventListener('click', () => { if (activeRouteView) activeRouteView.value = mode })
|
|
969
|
+
insertBefore(controls, button, null)
|
|
970
|
+
}
|
|
971
|
+
insertBefore(tree, controls, null)
|
|
972
|
+
const currentPath = router.currentRoute.value.path
|
|
973
|
+
if ((activeRouteView?.value ?? 'tree') === 'list') {
|
|
974
|
+
for (const node of flattenRouteNodes(nodes)) appendRouteConfigNode(tree, node, 0, router, currentPath)
|
|
975
|
+
} else {
|
|
976
|
+
for (const node of nodes) appendRouteConfigNode(tree, node, 0, router, currentPath)
|
|
977
|
+
}
|
|
978
|
+
return tree
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function flattenRouteNodes(nodes: readonly RouteDebugNode[]): readonly RouteDebugNode[] {
|
|
982
|
+
const result: RouteDebugNode[] = []
|
|
983
|
+
const visit = (node: RouteDebugNode): void => {
|
|
984
|
+
result.push({ ...node, children: [] })
|
|
985
|
+
for (const child of node.children) visit(child)
|
|
986
|
+
}
|
|
987
|
+
for (const node of nodes) visit(node)
|
|
988
|
+
return result
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function appendRouteConfigNode(parent: Element, node: RouteDebugNode, depth: number, router: Router, currentPath: string): void {
|
|
992
|
+
const concrete = node.children.length === 0 && !node.path.includes(':') && !node.path.includes('*')
|
|
993
|
+
const row = createElement(concrete ? 'button' : 'div')
|
|
994
|
+
setAttribute(row, 'class', `vobs-devtools-route-node${concrete && node.path === currentPath ? ' is-active' : ''}`)
|
|
995
|
+
setAttribute(row, 'style', `--route-depth: ${depth}`)
|
|
996
|
+
appendText(row, node.path, 'vobs-devtools-list__name')
|
|
997
|
+
appendText(row, node.component)
|
|
998
|
+
if (node.source) appendText(row, node.source, 'vobs-devtools-code')
|
|
999
|
+
if (concrete) {
|
|
1000
|
+
setAttribute(row, 'type', 'button')
|
|
1001
|
+
setAttribute(row, 'title', `Navigate to ${node.path}`)
|
|
1002
|
+
row.addEventListener('click', () => { void router.push(node.path) })
|
|
1003
|
+
}
|
|
1004
|
+
insertBefore(parent, row, null)
|
|
1005
|
+
for (const child of node.children) appendRouteConfigNode(parent, child, depth + 1, router, currentPath)
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function routeComponentName(record: RouteRecord): string {
|
|
1009
|
+
const definition = record.component
|
|
1010
|
+
if (!definition) return 'Route'
|
|
1011
|
+
if (typeof definition === 'function') return definition.name || 'Anonymous'
|
|
1012
|
+
return 'lazy(...)'
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function renderNavigationHistory(history: readonly import('@vobs/router').NavigationTrace[], router: Router): VobsNode {
|
|
1016
|
+
const root = createElement('div')
|
|
1017
|
+
setAttribute(root, 'class', 'vobs-devtools-list')
|
|
1018
|
+
for (const trace of [...history].reverse().slice(0, 30)) {
|
|
1019
|
+
const row = createElement('button')
|
|
1020
|
+
setAttribute(row, 'class', 'vobs-devtools-list__row')
|
|
1021
|
+
setAttribute(row, 'type', 'button')
|
|
1022
|
+
setAttribute(row, 'title', `Replay ${trace.to}`)
|
|
1023
|
+
appendText(row, `${trace.from} → ${trace.to}`, 'vobs-devtools-list__name')
|
|
1024
|
+
insertBefore(row, createComponent(Tag, { tone: trace.status === 'success' ? 'success' : trace.status === 'error' ? 'danger' : 'warning', children: () => trace.status }), null)
|
|
1025
|
+
appendText(row, `${trace.duration.toFixed(2)} ms · ${trace.source}`)
|
|
1026
|
+
row.addEventListener('click', () => { void router.push(trace.to) })
|
|
1027
|
+
insertBefore(root, row, null)
|
|
1028
|
+
}
|
|
1029
|
+
if (history.length === 0) appendMuted(root, 'No navigation history recorded.')
|
|
1030
|
+
return root
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function appendMuted(parent: Element, value: string): void {
|
|
1034
|
+
const node = createElement('span')
|
|
1035
|
+
setAttribute(node, 'class', 'vobs-devtools-muted')
|
|
1036
|
+
insertBefore(node, createText(value), null)
|
|
1037
|
+
insertBefore(parent, node, null)
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function renderSignalInspector(
|
|
1041
|
+
signal: SignalDebugInfo,
|
|
1042
|
+
api: DevToolsAPI | null
|
|
1043
|
+
): VobsNode {
|
|
1044
|
+
const row = createElement('details')
|
|
1045
|
+
setAttribute(row, 'class', 'vobs-devtools-list__row vobs-devtools-signal-row')
|
|
1046
|
+
const summary = createElement('summary')
|
|
1047
|
+
appendText(summary, displaySignalName(signal), 'vobs-devtools-list__name')
|
|
1048
|
+
appendText(summary, previewValue(signal.value), 'vobs-devtools-code')
|
|
1049
|
+
insertBefore(summary, createComponent(Tag, { tone: 'neutral-strong', children: () => `${signal.subscribers} subscribers` }), null)
|
|
1050
|
+
insertBefore(row, summary, null)
|
|
1051
|
+
if (!api) return row
|
|
1052
|
+
const dependencies = api.getDependencies(signal.id)
|
|
1053
|
+
const dependents = api.getDependents(signal.id)
|
|
1054
|
+
insertBefore(row, renderInspectableValue('Current value', signal.value), null)
|
|
1055
|
+
appendText(row, `${dependencies.length} dependencies · ${dependents.length} dependents`, 'vobs-devtools-muted')
|
|
1056
|
+
if (api.canMutate() && signal.kind !== 'memo') insertBefore(row, renderSignalMutationControl(signal, api), null)
|
|
1057
|
+
return row
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function renderSignalMutationControl(signal: SignalDebugInfo, api: DevToolsAPI): VobsNode {
|
|
1061
|
+
const root = createElement('div')
|
|
1062
|
+
setAttribute(root, 'class', 'vobs-devtools-mutation-control')
|
|
1063
|
+
appendText(root, 'Debug-only value edit (may trigger effects and requests).', 'vobs-devtools-muted')
|
|
1064
|
+
const input = createElement('input')
|
|
1065
|
+
setAttribute(input, 'class', 'vobs-devtools-search')
|
|
1066
|
+
setAttribute(input, 'type', 'text')
|
|
1067
|
+
setAttribute(input, 'aria-label', `Edit ${signal.name}`)
|
|
1068
|
+
setProperty(input, 'value', formatValue(signal.value))
|
|
1069
|
+
const button = createElement('button')
|
|
1070
|
+
setAttribute(button, 'class', 'vobs-devtools-control')
|
|
1071
|
+
setAttribute(button, 'type', 'button')
|
|
1072
|
+
insertBefore(button, createText('Apply debug value'), null)
|
|
1073
|
+
button.addEventListener('click', event => {
|
|
1074
|
+
event.stopPropagation()
|
|
1075
|
+
const raw = (input as HTMLInputElement).value
|
|
1076
|
+
let value: unknown = raw
|
|
1077
|
+
try { value = JSON.parse(raw) } catch { /* Treat non-JSON input as a string. */ }
|
|
1078
|
+
api.setSignalValue(signal.id, value)
|
|
1079
|
+
})
|
|
1080
|
+
insertBefore(root, input, null)
|
|
1081
|
+
insertBefore(root, button, null)
|
|
1082
|
+
return root
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function renderEffectRow(
|
|
1086
|
+
effect: EffectDebugInfo
|
|
1087
|
+
): VobsNode {
|
|
1088
|
+
const row = createElement('details')
|
|
1089
|
+
setAttribute(row, 'class', 'vobs-devtools-list__row')
|
|
1090
|
+
const summary = createElement('summary')
|
|
1091
|
+
const component = formatDebugLocation(effect.component)
|
|
1092
|
+
appendText(summary, effect.name || `${debugComponentName(component)} effect`, 'vobs-devtools-list__name')
|
|
1093
|
+
appendText(summary, formatDebugSource(component))
|
|
1094
|
+
insertBefore(summary, createComponent(Tag, { tone: effect.status === 'success' || effect.status === 'idle' ? 'success' : effect.status === 'error' ? 'danger' : 'warning', children: () => effect.status }), null)
|
|
1095
|
+
appendText(summary, `${effect.executionCount} runs`)
|
|
1096
|
+
insertBefore(row, summary, null)
|
|
1097
|
+
appendText(row, `${effect.dependencies.length} dependencies`, 'vobs-devtools-muted')
|
|
1098
|
+
if (effect.lastExecutionTime > 0) appendText(row, `last execution ${effect.lastDuration?.toFixed(2) ?? '0.00'} ms · ${effect.lastRunStatus ?? 'unknown'} · ${effect.lastDomUpdates ?? 0} DOM updates`, 'vobs-devtools-muted')
|
|
1099
|
+
if (effect.lastUpdateId) appendText(row, `last update ${effect.lastUpdateId}`, 'vobs-devtools-code')
|
|
1100
|
+
if (effect.lastError) appendText(row, `${effect.lastError.name}: ${effect.lastError.message}`, 'vobs-devtools-error')
|
|
1101
|
+
return row
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function renderUpdateRow(
|
|
1105
|
+
update: UpdateTrace,
|
|
1106
|
+
api: DevToolsAPI | null,
|
|
1107
|
+
selection: DevToolsSelection | null = null,
|
|
1108
|
+
onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void = () => undefined
|
|
1109
|
+
): VobsNode {
|
|
1110
|
+
const row = createElement('details')
|
|
1111
|
+
const selected = selection?.type === 'update' && selection.id === update.id
|
|
1112
|
+
setAttribute(row, 'class', `vobs-devtools-update-row${selected ? ' is-selected' : ''}`)
|
|
1113
|
+
if (selected) setProperty(row, 'open', true)
|
|
1114
|
+
const summary = createElement('summary')
|
|
1115
|
+
// The update row owns its own selection. Signal and Effect details stay local
|
|
1116
|
+
// to this trace and do not navigate to the Advanced inspectors.
|
|
1117
|
+
summary.addEventListener('click', () => onFocus('updates', { type: 'update', id: update.id }))
|
|
1118
|
+
const signal = api?.getSignal(update.signalId)
|
|
1119
|
+
appendText(summary, signal ? displaySignalName(signal) : displayDebugName(update.signalName), 'vobs-devtools-list__name')
|
|
1120
|
+
appendText(summary, formatDebugSource(signal?.component ?? 'unknown'), 'vobs-devtools-update-row__source')
|
|
1121
|
+
appendText(summary, `${update.duration.toFixed(2)} ms`)
|
|
1122
|
+
insertBefore(summary, createComponent(Tag, { tone: update.duration >= 16 ? 'warning' : 'neutral-strong', children: () => `${update.effects.length} effects` }), null)
|
|
1123
|
+
insertBefore(row, summary, null)
|
|
1124
|
+
insertBefore(row, renderValuePair('Value changed', update.previousValue, update.nextValue), null)
|
|
1125
|
+
appendText(row, `status ${update.status} · ${update.affectedSignals.length} signals · ${update.affectedEffects.length} effects`, 'vobs-devtools-muted')
|
|
1126
|
+
if (update.error) appendText(row, `${update.error.name}: ${update.error.message}`, 'vobs-devtools-error')
|
|
1127
|
+
if (update.effects.length === 0) appendText(row, 'No effects executed.', 'vobs-devtools-muted')
|
|
1128
|
+
for (const effect of update.effects) {
|
|
1129
|
+
insertBefore(row, renderEffectExecution(effect), null)
|
|
1130
|
+
if (effect.error) appendText(row, `${effect.error.name}: ${effect.error.message}`, 'vobs-devtools-error')
|
|
1131
|
+
}
|
|
1132
|
+
for (const domUpdate of update.domUpdates) {
|
|
1133
|
+
const operation = domUpdate.key ? `${domUpdate.operation}.${domUpdate.key}` : domUpdate.operation
|
|
1134
|
+
const mutation = createElement('div')
|
|
1135
|
+
setAttribute(mutation, 'class', 'vobs-devtools-dom-update')
|
|
1136
|
+
appendText(mutation, operation, 'vobs-devtools-list__name')
|
|
1137
|
+
appendText(mutation, domUpdate.target, 'vobs-devtools-muted')
|
|
1138
|
+
if (domUpdate.previousValue !== undefined || domUpdate.nextValue !== undefined) {
|
|
1139
|
+
insertBefore(mutation, renderValuePair('', domUpdate.previousValue, domUpdate.nextValue), null)
|
|
1140
|
+
}
|
|
1141
|
+
insertBefore(row, mutation, null)
|
|
1142
|
+
}
|
|
1143
|
+
return row
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function renderEffectExecution(
|
|
1147
|
+
effect: EffectExecutionInfo
|
|
1148
|
+
): VobsNode {
|
|
1149
|
+
const root = createElement('div')
|
|
1150
|
+
setAttribute(root, 'class', 'vobs-devtools-effect-execution')
|
|
1151
|
+
appendText(root, effect.effectId, 'vobs-devtools-code')
|
|
1152
|
+
appendText(root, `${formatDebugSource(effect.component)} · ${effect.duration.toFixed(2)} ms · ${effect.domUpdates} DOM updates · ${effect.status ?? 'success'}`)
|
|
1153
|
+
return root
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function appendUpdateLinks(
|
|
1157
|
+
parent: Element,
|
|
1158
|
+
ids: readonly string[],
|
|
1159
|
+
onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void
|
|
1160
|
+
): void {
|
|
1161
|
+
if (ids.length === 0) {
|
|
1162
|
+
appendText(parent, ' none', 'vobs-devtools-muted')
|
|
1163
|
+
return
|
|
1164
|
+
}
|
|
1165
|
+
for (const id of ids) {
|
|
1166
|
+
const button = createElement('button')
|
|
1167
|
+
setAttribute(button, 'class', 'vobs-devtools-link')
|
|
1168
|
+
setAttribute(button, 'type', 'button')
|
|
1169
|
+
button.addEventListener('click', event => {
|
|
1170
|
+
event.stopPropagation()
|
|
1171
|
+
onFocus('updates', { type: 'update', id })
|
|
1172
|
+
})
|
|
1173
|
+
insertBefore(button, createText(id), null)
|
|
1174
|
+
insertBefore(parent, button, null)
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function renderLifecycleTimeline(
|
|
1179
|
+
events: readonly LifecycleEvent[],
|
|
1180
|
+
onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void
|
|
1181
|
+
): VobsNode {
|
|
1182
|
+
const root = createElement('div')
|
|
1183
|
+
setAttribute(root, 'class', 'vobs-devtools-lifecycle-list')
|
|
1184
|
+
for (const event of [...events].reverse().slice(0, 40)) {
|
|
1185
|
+
const selection = lifecycleSelection(event)
|
|
1186
|
+
const row = createElement(selection?.type === 'component' ? 'button' : 'div')
|
|
1187
|
+
setAttribute(row, 'class', 'vobs-devtools-lifecycle-row')
|
|
1188
|
+
if (selection?.type === 'component') setAttribute(row, 'type', 'button')
|
|
1189
|
+
appendText(row, event.type, 'vobs-devtools-list__name')
|
|
1190
|
+
appendText(row, event.name ?? event.targetId)
|
|
1191
|
+
appendText(row, event.status ?? '', 'vobs-devtools-muted')
|
|
1192
|
+
if (selection?.type === 'component') row.addEventListener('click', () => onFocus('components', selection))
|
|
1193
|
+
insertBefore(root, row, null)
|
|
1194
|
+
}
|
|
1195
|
+
if (events.length === 0) appendMuted(root, 'No lifecycle events recorded.')
|
|
1196
|
+
return root
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function renderUnifiedNetworkRequests(
|
|
1200
|
+
requests: readonly NetworkRequestTrace[],
|
|
1201
|
+
routerRequests: readonly DevToolsRouterContext['dataRequests'][number][],
|
|
1202
|
+
query: string,
|
|
1203
|
+
selection: DevToolsSelection | null,
|
|
1204
|
+
onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void,
|
|
1205
|
+
networkSelection?: { value: string | null },
|
|
1206
|
+
sourceFilter?: { value: NetworkSourceFilter },
|
|
1207
|
+
statusFilter?: { value: NetworkStatusFilter },
|
|
1208
|
+
detailTab?: { value: NetworkDetailTab },
|
|
1209
|
+
api?: DevToolsAPI | null,
|
|
1210
|
+
queryState?: { value: string },
|
|
1211
|
+
http?: HTTPClient | null,
|
|
1212
|
+
testerOpen?: { value: boolean },
|
|
1213
|
+
testerRevision?: { value: number },
|
|
1214
|
+
testerRun?: { value: RequestTesterRun },
|
|
1215
|
+
testerDraft?: RequestTesterDraft,
|
|
1216
|
+
currentRoute?: string,
|
|
1217
|
+
testerTab?: { value: RequestTesterTab }
|
|
1218
|
+
): VobsNode {
|
|
1219
|
+
const root = createElement('div')
|
|
1220
|
+
setAttribute(root, 'class', 'vobs-devtools-network-inspector')
|
|
1221
|
+
const activeSource = sourceFilter?.value ?? 'all'
|
|
1222
|
+
const activeStatus = statusFilter?.value ?? 'all'
|
|
1223
|
+
const entries: NetworkEntry[] = [
|
|
1224
|
+
...requests.map<NetworkEntry>(request => ({
|
|
1225
|
+
key: `http:${request.id}`,
|
|
1226
|
+
source: request.source === 'ssr' ? 'ssr' as const : 'http' as const,
|
|
1227
|
+
method: request.method,
|
|
1228
|
+
url: request.url,
|
|
1229
|
+
status: request.status,
|
|
1230
|
+
duration: request.duration,
|
|
1231
|
+
startedAt: request.startedAt,
|
|
1232
|
+
endedAt: request.endedAt,
|
|
1233
|
+
request
|
|
1234
|
+
})),
|
|
1235
|
+
...routerRequests.map<NetworkEntry>(request => ({
|
|
1236
|
+
key: `router:${request.id}`,
|
|
1237
|
+
source: 'router' as const,
|
|
1238
|
+
method: request.kind,
|
|
1239
|
+
url: request.key,
|
|
1240
|
+
status: request.status,
|
|
1241
|
+
duration: request.duration,
|
|
1242
|
+
startedAt: request.startedAt,
|
|
1243
|
+
endedAt: request.endedAt,
|
|
1244
|
+
routerRequest: request
|
|
1245
|
+
}))
|
|
1246
|
+
].filter(entry => (activeSource === 'all' || entry.source === activeSource) && (activeStatus === 'all' || entry.status === activeStatus) && matchesQuery(query, entry.method, entry.url, entry.status, entry.source, entry.request?.responseStatus, entry.request?.route, entry.routerRequest?.route))
|
|
1247
|
+
.sort((left, right) => (right.startedAt ?? 0) - (left.startedAt ?? 0))
|
|
1248
|
+
const selectedKey = (selection?.type === 'request' ? `http:${selection.id}` : networkSelection?.value) ?? entries[0]?.key
|
|
1249
|
+
const selected = entries.find(entry => entry.key === selectedKey) ?? entries[0]
|
|
1250
|
+
|
|
1251
|
+
const controls = createElement('div')
|
|
1252
|
+
setAttribute(controls, 'class', 'vobs-devtools-network-toolbar')
|
|
1253
|
+
appendText(controls, 'Search', 'vobs-devtools-network-toolbar__label')
|
|
1254
|
+
const search = createElement('input')
|
|
1255
|
+
setAttribute(search, 'class', 'vobs-devtools-search vobs-devtools-network-toolbar__search')
|
|
1256
|
+
setAttribute(search, 'type', 'search')
|
|
1257
|
+
setAttribute(search, 'placeholder', 'Search requests')
|
|
1258
|
+
setProperty(search, 'value', query)
|
|
1259
|
+
// Commit on Enter/blur so the inspector does not replace the focused input
|
|
1260
|
+
// after every keystroke while the reactive panel refreshes.
|
|
1261
|
+
search.addEventListener('change', () => { if (queryState) queryState.value = (search as HTMLInputElement).value })
|
|
1262
|
+
insertBefore(controls, search, null)
|
|
1263
|
+
insertBefore(controls, createNetworkFilterSelect('Source', activeSource, [
|
|
1264
|
+
['all', 'All sources'], ['http', 'HTTP'], ['router', 'Router'], ['ssr', 'SSR']
|
|
1265
|
+
], value => { if (sourceFilter) sourceFilter.value = value as NetworkSourceFilter }), null)
|
|
1266
|
+
insertBefore(controls, createNetworkFilterSelect('Status', activeStatus, [
|
|
1267
|
+
['all', 'All statuses'], ['loading', 'Loading'], ['success', 'Success'], ['error', 'Error'], ['cancelled', 'Cancelled']
|
|
1268
|
+
], value => { if (statusFilter) statusFilter.value = value as NetworkStatusFilter }), null)
|
|
1269
|
+
if (api) insertBefore(controls, createComponent(Button, {
|
|
1270
|
+
variant: 'ghost',
|
|
1271
|
+
children: () => 'Clear',
|
|
1272
|
+
onClick: () => api.clearNetworkRequests()
|
|
1273
|
+
}), null)
|
|
1274
|
+
if (testerOpen && testerDraft) insertBefore(controls, createComponent(Button, {
|
|
1275
|
+
variant: 'ghost',
|
|
1276
|
+
iconOnly: true,
|
|
1277
|
+
icon: createComponent(Icon, { name: 'settings' }),
|
|
1278
|
+
'aria-label': 'Open Request Tester',
|
|
1279
|
+
title: 'Open Request Tester',
|
|
1280
|
+
onClick: () => {
|
|
1281
|
+
if (!testerDraft.url) testerDraft.url = selected?.url ?? currentRoute ?? ''
|
|
1282
|
+
testerOpen.value = true
|
|
1283
|
+
testerRevision && (testerRevision.value++)
|
|
1284
|
+
}
|
|
1285
|
+
}), null)
|
|
1286
|
+
insertBefore(root, controls, null)
|
|
1287
|
+
|
|
1288
|
+
const panes = createElement('div')
|
|
1289
|
+
setAttribute(panes, 'class', 'vobs-devtools-network-panes')
|
|
1290
|
+
const listPane = createElement('section')
|
|
1291
|
+
setAttribute(listPane, 'class', 'vobs-devtools-network-list-pane')
|
|
1292
|
+
setAttribute(listPane, 'aria-label', 'Network requests')
|
|
1293
|
+
const requestList = createElement('div')
|
|
1294
|
+
setAttribute(requestList, 'class', 'vobs-devtools-network-request-list')
|
|
1295
|
+
setAttribute(requestList, 'role', 'list')
|
|
1296
|
+
for (const entry of entries) {
|
|
1297
|
+
const row = createElement('button')
|
|
1298
|
+
const isSelected = selectedKey === entry.key
|
|
1299
|
+
setAttribute(row, 'class', `vobs-devtools-network-request${isSelected ? ' is-selected' : ''}`)
|
|
1300
|
+
setAttribute(row, 'type', 'button')
|
|
1301
|
+
setAttribute(row, 'role', 'listitem')
|
|
1302
|
+
setAttribute(row, 'aria-pressed', isSelected ? 'true' : 'false')
|
|
1303
|
+
row.addEventListener('click', () => {
|
|
1304
|
+
networkSelection && (networkSelection.value = entry.key)
|
|
1305
|
+
if (entry.request) onFocus('network', { type: 'request', id: entry.request.id })
|
|
1306
|
+
if (detailTab) detailTab.value = 'overview'
|
|
1307
|
+
})
|
|
1308
|
+
const pathLine = createElement('span')
|
|
1309
|
+
setAttribute(pathLine, 'class', 'vobs-devtools-network-request__path-line')
|
|
1310
|
+
appendText(pathLine, entry.url, 'vobs-devtools-network-request__url')
|
|
1311
|
+
if (entry.request?.test) insertBefore(pathLine, createComponent(Tag, { tone: 'warning', children: () => 'TEST' }), null)
|
|
1312
|
+
insertBefore(pathLine, createComponent(Tag, { tone: entry.status === 'success' ? 'success' : entry.status === 'error' || entry.status === 'cancelled' ? 'danger' : 'warning', children: () => entry.request?.responseStatus ? `${entry.status} ${entry.request.responseStatus}` : entry.status }), null)
|
|
1313
|
+
insertBefore(row, pathLine, null)
|
|
1314
|
+
const meta = createElement('span')
|
|
1315
|
+
setAttribute(meta, 'class', 'vobs-devtools-network-request__meta')
|
|
1316
|
+
appendText(meta, entry.duration === undefined ? 'Running' : `${entry.duration.toFixed(0)} ms`)
|
|
1317
|
+
appendText(meta, `ID ${entry.request?.id ?? entry.routerRequest?.id}`, 'vobs-devtools-code')
|
|
1318
|
+
insertBefore(row, meta, null)
|
|
1319
|
+
insertBefore(requestList, row, null)
|
|
1320
|
+
}
|
|
1321
|
+
insertBefore(listPane, requestList, null)
|
|
1322
|
+
if (entries.length === 0) appendMuted(listPane, query || activeSource !== 'all' || activeStatus !== 'all' ? 'No requests match the current filters.' : 'No requests recorded.')
|
|
1323
|
+
insertBefore(panes, listPane, null)
|
|
1324
|
+
const detailPane = createElement('section')
|
|
1325
|
+
setAttribute(detailPane, 'class', 'vobs-devtools-network-detail-pane')
|
|
1326
|
+
setAttribute(detailPane, 'aria-label', 'Selected network request')
|
|
1327
|
+
if (selected) insertBefore(detailPane, renderNetworkDetail(selected, detailTab?.value ?? 'overview', tab => { if (detailTab) detailTab.value = tab }), null)
|
|
1328
|
+
else appendMuted(detailPane, 'Select a request to inspect its details.')
|
|
1329
|
+
insertBefore(panes, detailPane, null)
|
|
1330
|
+
insertBefore(root, panes, null)
|
|
1331
|
+
if (testerOpen?.value && testerDraft) insertBefore(root, renderRequestTester(testerDraft, run => {
|
|
1332
|
+
if (testerRun) testerRun.value = run
|
|
1333
|
+
testerRevision && (testerRevision.value++)
|
|
1334
|
+
}, testerRun?.value ?? { status: 'idle' }, testerTab?.value ?? 'params', selected, http, () => { testerOpen.value = false }, tab => { if (testerTab) testerTab.value = tab; testerRevision && (testerRevision.value++) }), null)
|
|
1335
|
+
return root
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function createNetworkFilterSelect(label: string, value: string, options: readonly (readonly [string, string])[], onChange: (value: string) => void): VobsNode {
|
|
1339
|
+
const select = createElement('select')
|
|
1340
|
+
setAttribute(select, 'class', 'vobs-devtools-network-toolbar__select')
|
|
1341
|
+
setAttribute(select, 'aria-label', label)
|
|
1342
|
+
setProperty(select, 'value', value)
|
|
1343
|
+
for (const [optionValue, optionLabel] of options) {
|
|
1344
|
+
const option = createElement('option')
|
|
1345
|
+
setAttribute(option, 'value', optionValue)
|
|
1346
|
+
insertBefore(option, createText(optionLabel), null)
|
|
1347
|
+
insertBefore(select, option, null)
|
|
1348
|
+
}
|
|
1349
|
+
setProperty(select, 'value', value)
|
|
1350
|
+
select.addEventListener('change', event => onChange((event.target as HTMLSelectElement).value))
|
|
1351
|
+
return select
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
function renderRequestTester(
|
|
1355
|
+
draft: RequestTesterDraft,
|
|
1356
|
+
onRunChange: (run: RequestTesterRun) => void,
|
|
1357
|
+
run: RequestTesterRun,
|
|
1358
|
+
activeTab: RequestTesterTab,
|
|
1359
|
+
selected: NetworkEntry | undefined,
|
|
1360
|
+
http: HTTPClient | null | undefined,
|
|
1361
|
+
onClose: () => void,
|
|
1362
|
+
onTabChange: (tab: RequestTesterTab) => void
|
|
1363
|
+
): VobsNode {
|
|
1364
|
+
const drawer = createElement('aside')
|
|
1365
|
+
setAttribute(drawer, 'class', 'vobs-devtools-request-tester')
|
|
1366
|
+
setAttribute(drawer, 'aria-label', 'Request Tester')
|
|
1367
|
+
const heading = createElement('div')
|
|
1368
|
+
setAttribute(heading, 'class', 'vobs-devtools-request-tester__heading')
|
|
1369
|
+
appendText(heading, 'Request Tester', 'vobs-devtools-request-tester__title')
|
|
1370
|
+
const headingActions = createElement('div')
|
|
1371
|
+
setAttribute(headingActions, 'class', 'vobs-devtools-request-tester__heading-actions')
|
|
1372
|
+
insertBefore(headingActions, createComponent(Button, {
|
|
1373
|
+
variant: 'ghost',
|
|
1374
|
+
disabled: !selected,
|
|
1375
|
+
children: () => 'Use selected request',
|
|
1376
|
+
onClick: () => {
|
|
1377
|
+
if (!selected) return
|
|
1378
|
+
applyNetworkEntryToTesterDraft(selected, draft)
|
|
1379
|
+
onRunChange({ status: 'idle' })
|
|
1380
|
+
}
|
|
1381
|
+
}), null)
|
|
1382
|
+
insertBefore(headingActions, createComponent(Button, {
|
|
1383
|
+
variant: 'ghost',
|
|
1384
|
+
iconOnly: true,
|
|
1385
|
+
icon: createComponent(Icon, { name: 'x' }),
|
|
1386
|
+
'aria-label': 'Close Request Tester',
|
|
1387
|
+
title: 'Close Request Tester',
|
|
1388
|
+
onClick: onClose
|
|
1389
|
+
}), null)
|
|
1390
|
+
insertBefore(heading, headingActions, null)
|
|
1391
|
+
insertBefore(drawer, heading, null)
|
|
1392
|
+
|
|
1393
|
+
const requestLine = createElement('div')
|
|
1394
|
+
setAttribute(requestLine, 'class', 'vobs-devtools-request-tester__request-line')
|
|
1395
|
+
insertBefore(requestLine, createTesterInput('url', draft.url, value => { draft.url = value }), null)
|
|
1396
|
+
insertBefore(requestLine, createTesterMethodSelect(draft.method, value => { draft.method = value; onRunChange({ status: run.status, message: run.message }) }), null)
|
|
1397
|
+
insertBefore(requestLine, createComponent(Button, {
|
|
1398
|
+
variant: 'brand', iconOnly: true, icon: createComponent(Icon, { name: 'play' }),
|
|
1399
|
+
loading: run.status === 'running', disabled: run.status === 'running',
|
|
1400
|
+
'aria-label': run.status === 'running' ? 'Running request' : 'Start request',
|
|
1401
|
+
title: run.status === 'running' ? 'Running request' : 'Start request',
|
|
1402
|
+
onClick: () => { void executeRequestTester(http, draft, onRunChange) }
|
|
1403
|
+
}), null)
|
|
1404
|
+
insertBefore(drawer, requestLine, null)
|
|
1405
|
+
const tabs = createElement('div')
|
|
1406
|
+
setAttribute(tabs, 'class', 'vobs-devtools-request-tester__tabs')
|
|
1407
|
+
const tabLabels: readonly [RequestTesterTab, string][] = [['params', 'Params'], ['headers', 'Headers'], ['body', 'Body']]
|
|
1408
|
+
for (const [tab, label] of tabLabels) {
|
|
1409
|
+
const button = createElement('button')
|
|
1410
|
+
setAttribute(button, 'class', `vobs-devtools-request-tester__tab${activeTab === tab ? ' is-active' : ''}`)
|
|
1411
|
+
setAttribute(button, 'type', 'button')
|
|
1412
|
+
setAttribute(button, 'aria-selected', activeTab === tab ? 'true' : 'false')
|
|
1413
|
+
appendText(button, label)
|
|
1414
|
+
button.addEventListener('click', () => onTabChange(tab))
|
|
1415
|
+
insertBefore(tabs, button, null)
|
|
1416
|
+
}
|
|
1417
|
+
insertBefore(drawer, tabs, null)
|
|
1418
|
+
if (activeTab === 'params') insertBefore(drawer, renderTesterParamGroup('Params', draft.params, 'parameter', onRunChange), null)
|
|
1419
|
+
if (activeTab === 'headers') insertBefore(drawer, renderTesterParamGroup('Headers', draft.headers, 'header', onRunChange), null)
|
|
1420
|
+
if (activeTab === 'body') {
|
|
1421
|
+
const bodyLabel = createElement('label')
|
|
1422
|
+
setAttribute(bodyLabel, 'class', 'vobs-devtools-request-tester__field')
|
|
1423
|
+
appendText(bodyLabel, 'Body')
|
|
1424
|
+
const body = createElement('textarea')
|
|
1425
|
+
setAttribute(body, 'class', 'vobs-devtools-request-tester__textarea')
|
|
1426
|
+
setAttribute(body, 'rows', '8')
|
|
1427
|
+
setAttribute(body, 'placeholder', '{ "key": "value" }')
|
|
1428
|
+
setProperty(body, 'value', draft.body)
|
|
1429
|
+
body.addEventListener('change', () => { draft.body = (body as HTMLTextAreaElement).value })
|
|
1430
|
+
insertBefore(bodyLabel, body, null)
|
|
1431
|
+
insertBefore(drawer, bodyLabel, null)
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
const footer = createElement('div')
|
|
1435
|
+
setAttribute(footer, 'class', 'vobs-devtools-request-tester__footer')
|
|
1436
|
+
if (run.message) appendText(footer, run.message, `vobs-devtools-request-tester__status${run.status === 'error' ? ' is-error' : run.status === 'success' ? ' is-success' : ''}`)
|
|
1437
|
+
insertBefore(drawer, footer, null)
|
|
1438
|
+
return drawer
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
function applyNetworkEntryToTesterDraft(entry: NetworkEntry, draft: RequestTesterDraft): void {
|
|
1442
|
+
const request = entry.request
|
|
1443
|
+
const rawUrl = request?.url ?? entry.url
|
|
1444
|
+
let url = rawUrl
|
|
1445
|
+
let params: RequestTesterParam[] = []
|
|
1446
|
+
try {
|
|
1447
|
+
const parsed = new URL(rawUrl, typeof window !== 'undefined' ? window.location.href : 'http://localhost/')
|
|
1448
|
+
url = `${parsed.origin === 'http://localhost' && rawUrl.startsWith('/') ? '' : parsed.origin}${parsed.pathname}${parsed.hash}`
|
|
1449
|
+
params = [...parsed.searchParams.entries()].map(([key, value]) => ({ key, value }))
|
|
1450
|
+
} catch {
|
|
1451
|
+
// Keep non-URL request keys intact, such as router loader identifiers.
|
|
1452
|
+
}
|
|
1453
|
+
draft.url = url
|
|
1454
|
+
draft.method = isHTTPMethod(request?.method ?? entry.method) ? (request?.method ?? entry.method) as HTTPMethod : 'GET'
|
|
1455
|
+
draft.params = params.length > 0 ? params : [{ key: '', value: '' }]
|
|
1456
|
+
draft.headers = request ? Object.entries(request.headers).map(([key, value]) => ({ key, value })) : [{ key: '', value: '' }]
|
|
1457
|
+
const body = request?.requestBody
|
|
1458
|
+
draft.body = body === undefined || body === null || body === '' ? '' : typeof body === 'string' ? body : JSON.stringify(body, null, 2)
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function isHTTPMethod(value: string): value is HTTPMethod {
|
|
1462
|
+
return ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT'].includes(value)
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
function createTesterInput(name: string, value: string, onChange: (value: string) => void): VobsNode {
|
|
1466
|
+
const input = createElement('input')
|
|
1467
|
+
setAttribute(input, 'class', 'vobs-devtools-request-tester__input')
|
|
1468
|
+
setAttribute(input, 'name', name)
|
|
1469
|
+
setAttribute(input, 'type', 'text')
|
|
1470
|
+
setProperty(input, 'value', value)
|
|
1471
|
+
input.addEventListener('change', () => onChange((input as HTMLInputElement).value))
|
|
1472
|
+
return input
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
function createTesterMethodSelect(value: HTTPMethod, onChange: (value: HTTPMethod) => void): VobsNode {
|
|
1476
|
+
const select = createElement('select')
|
|
1477
|
+
setAttribute(select, 'class', 'vobs-devtools-request-tester__select')
|
|
1478
|
+
for (const method of ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] as HTTPMethod[]) {
|
|
1479
|
+
const option = createElement('option')
|
|
1480
|
+
setAttribute(option, 'value', method)
|
|
1481
|
+
if (method === value) setAttribute(option, 'selected', '')
|
|
1482
|
+
insertBefore(option, createText(method), null)
|
|
1483
|
+
insertBefore(select, option, null)
|
|
1484
|
+
}
|
|
1485
|
+
select.addEventListener('change', () => onChange((select as HTMLSelectElement).value as HTTPMethod))
|
|
1486
|
+
return select
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
function renderTesterParamGroup(label: string, params: RequestTesterParam[], singular: string, onChange: (run: RequestTesterRun) => void): VobsNode {
|
|
1490
|
+
const group = createElement('section')
|
|
1491
|
+
setAttribute(group, 'class', 'vobs-devtools-request-tester__group')
|
|
1492
|
+
const title = createElement('div')
|
|
1493
|
+
setAttribute(title, 'class', 'vobs-devtools-request-tester__group-title')
|
|
1494
|
+
appendText(title, label)
|
|
1495
|
+
const add = createElement('button')
|
|
1496
|
+
setAttribute(add, 'class', 'vobs-devtools-request-tester__add')
|
|
1497
|
+
setAttribute(add, 'type', 'button')
|
|
1498
|
+
appendText(add, '+')
|
|
1499
|
+
setAttribute(add, 'aria-label', `Add ${label}`)
|
|
1500
|
+
setAttribute(add, 'title', `Add ${label}`)
|
|
1501
|
+
add.addEventListener('click', () => { params.push({ key: '', value: '' }); onChange({ status: 'idle' }) })
|
|
1502
|
+
insertBefore(title, add, null)
|
|
1503
|
+
insertBefore(group, title, null)
|
|
1504
|
+
for (let index = 0; index < params.length; index++) {
|
|
1505
|
+
const param = params[index]
|
|
1506
|
+
const row = createElement('div')
|
|
1507
|
+
setAttribute(row, 'class', 'vobs-devtools-request-tester__param')
|
|
1508
|
+
insertBefore(row, createTesterInput(`${singular}-key-${index}`, param.key, value => { param.key = value }), null)
|
|
1509
|
+
insertBefore(row, createTesterInput(`${singular}-value-${index}`, param.value, value => { param.value = value }), null)
|
|
1510
|
+
const remove = createElement('button')
|
|
1511
|
+
setAttribute(remove, 'class', 'vobs-devtools-request-tester__remove')
|
|
1512
|
+
setAttribute(remove, 'type', 'button')
|
|
1513
|
+
setAttribute(remove, 'aria-label', `Remove ${label} row ${index + 1}`)
|
|
1514
|
+
setAttribute(remove, 'title', `Remove ${label} row ${index + 1}`)
|
|
1515
|
+
insertBefore(remove, createComponent(Icon, { name: 'trash' }), null)
|
|
1516
|
+
remove.addEventListener('click', () => { params.splice(index, 1); if (params.length === 0) params.push({ key: '', value: '' }); onChange({ status: 'idle' }) })
|
|
1517
|
+
insertBefore(row, remove, null)
|
|
1518
|
+
insertBefore(group, row, null)
|
|
1519
|
+
}
|
|
1520
|
+
return group
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
async function executeRequestTester(http: HTTPClient | null | undefined, draft: RequestTesterDraft, onRunChange: (run: RequestTesterRun) => void): Promise<void> {
|
|
1524
|
+
if (!http) {
|
|
1525
|
+
onRunChange({ status: 'error', message: 'HTTP client is unavailable.' })
|
|
1526
|
+
return
|
|
1527
|
+
}
|
|
1528
|
+
const url = draft.url.trim()
|
|
1529
|
+
if (!url) {
|
|
1530
|
+
onRunChange({ status: 'error', message: 'Enter a request URL.' })
|
|
1531
|
+
return
|
|
1532
|
+
}
|
|
1533
|
+
const params: Record<string, string> = {}
|
|
1534
|
+
for (const param of draft.params) if (param.key.trim()) params[param.key.trim()] = param.value
|
|
1535
|
+
const headers: Record<string, string> = {}
|
|
1536
|
+
for (const header of draft.headers) if (header.key.trim()) headers[header.key.trim()] = header.value
|
|
1537
|
+
let body: unknown
|
|
1538
|
+
if (!['GET', 'HEAD', 'DELETE'].includes(draft.method) && draft.body.trim()) {
|
|
1539
|
+
try { body = JSON.parse(draft.body) } catch {
|
|
1540
|
+
onRunChange({ status: 'error', message: 'Body must be valid JSON.' })
|
|
1541
|
+
return
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
onRunChange({ status: 'running', message: undefined })
|
|
1545
|
+
try {
|
|
1546
|
+
await http.request({
|
|
1547
|
+
url,
|
|
1548
|
+
method: draft.method,
|
|
1549
|
+
params: Object.keys(params).length > 0 ? params : undefined,
|
|
1550
|
+
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
|
1551
|
+
body,
|
|
1552
|
+
debugContext: { route: url, test: true }
|
|
1553
|
+
})
|
|
1554
|
+
onRunChange({ status: 'success', message: 'Request completed.' })
|
|
1555
|
+
} catch (error) {
|
|
1556
|
+
onRunChange({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
function renderNetworkDetail(entry: NetworkEntry, activeTab: NetworkDetailTab, onTabChange: (tab: NetworkDetailTab) => void): VobsNode {
|
|
1561
|
+
const root = createElement('section')
|
|
1562
|
+
setAttribute(root, 'class', 'vobs-devtools-network-detail')
|
|
1563
|
+
const request = entry.request
|
|
1564
|
+
const routerRequest = entry.routerRequest
|
|
1565
|
+
const heading = createElement('div')
|
|
1566
|
+
setAttribute(heading, 'class', 'vobs-devtools-network-detail__heading')
|
|
1567
|
+
appendText(heading, `${entry.method} ${entry.url}`, 'vobs-devtools-network-detail__title')
|
|
1568
|
+
if (request?.test) insertBefore(heading, createComponent(Tag, { tone: 'warning', children: () => 'TEST' }), null)
|
|
1569
|
+
insertBefore(heading, createComponent(Tag, { tone: entry.status === 'success' ? 'success' : entry.status === 'error' || entry.status === 'cancelled' ? 'danger' : 'warning', children: () => request?.responseStatus ? `${entry.status} ${request.responseStatus}` : entry.status }), null)
|
|
1570
|
+
if (entry.duration !== undefined) appendText(heading, `${entry.duration.toFixed(0)} ms`, 'vobs-devtools-network-detail__duration')
|
|
1571
|
+
insertBefore(root, heading, null)
|
|
1572
|
+
const tabs = createElement('div')
|
|
1573
|
+
setAttribute(tabs, 'class', 'vobs-devtools-network-detail__tabs')
|
|
1574
|
+
const tabLabels: readonly [NetworkDetailTab, string][] = [['overview', 'Overview'], ['headers', 'Headers'], ['payload', 'Payload'], ['response', 'Response'], ['timing', 'Timing'], ['context', 'Context']]
|
|
1575
|
+
for (const [tab, label] of tabLabels) {
|
|
1576
|
+
const button = createElement('button')
|
|
1577
|
+
setAttribute(button, 'type', 'button')
|
|
1578
|
+
setAttribute(button, 'class', `vobs-devtools-network-detail__tab${activeTab === tab ? ' is-active' : ''}`)
|
|
1579
|
+
setAttribute(button, 'aria-selected', activeTab === tab ? 'true' : 'false')
|
|
1580
|
+
appendText(button, label)
|
|
1581
|
+
button.addEventListener('click', () => onTabChange(tab))
|
|
1582
|
+
insertBefore(tabs, button, null)
|
|
1583
|
+
}
|
|
1584
|
+
insertBefore(root, tabs, null)
|
|
1585
|
+
const content = createElement('div')
|
|
1586
|
+
setAttribute(content, 'class', 'vobs-devtools-network-detail__content')
|
|
1587
|
+
if (activeTab === 'overview') {
|
|
1588
|
+
appendNetworkField(content, 'Source', entry.source.toUpperCase())
|
|
1589
|
+
appendNetworkField(content, 'Method / Type', entry.method)
|
|
1590
|
+
appendNetworkField(content, 'Request key', entry.url, 'code')
|
|
1591
|
+
appendNetworkField(content, 'Status', entry.status)
|
|
1592
|
+
if (request?.error || routerRequest?.error) appendText(content, request ? `${request.error?.name}: ${request.error?.message}` : routerRequest!.error!, 'vobs-devtools-error')
|
|
1593
|
+
} else if (activeTab === 'headers') {
|
|
1594
|
+
if (request && Object.keys(request.headers).length > 0) insertBefore(content, renderValueTree('Headers', request.headers, false), null)
|
|
1595
|
+
else appendMuted(content, 'No captured headers for this request.')
|
|
1596
|
+
} else if (activeTab === 'payload') {
|
|
1597
|
+
const payload = request?.requestBody
|
|
1598
|
+
if (payload !== undefined) insertBefore(content, renderValueTree('Request body', payload, false), null)
|
|
1599
|
+
else appendMuted(content, 'No request payload captured.')
|
|
1600
|
+
} else if (activeTab === 'response') {
|
|
1601
|
+
const response = request?.responseBody ?? routerRequest?.result
|
|
1602
|
+
if (response !== undefined) insertBefore(content, renderValueTree('Response', response, false), null)
|
|
1603
|
+
else appendMuted(content, routerRequest?.error ?? 'No response body captured.')
|
|
1604
|
+
} else if (activeTab === 'timing') {
|
|
1605
|
+
if (entry.startedAt !== undefined) appendNetworkField(content, 'Started at', formatNetworkTimestamp(entry.startedAt))
|
|
1606
|
+
if (entry.endedAt !== undefined) appendNetworkField(content, 'Ended at', formatNetworkTimestamp(entry.endedAt))
|
|
1607
|
+
appendNetworkField(content, 'Duration', entry.duration === undefined ? 'Running' : `${entry.duration.toFixed(2)} ms`)
|
|
1608
|
+
if (request) appendNetworkField(content, 'Attempts', `${request.attempt + 1} (${request.retries} retries)`)
|
|
1609
|
+
} else {
|
|
1610
|
+
appendNetworkField(content, 'Environment', request?.environment ?? routerRequest?.environment ?? 'client')
|
|
1611
|
+
if (request?.route ?? routerRequest?.route) appendNetworkField(content, 'Route', request?.route ?? routerRequest?.route ?? '', 'code')
|
|
1612
|
+
if (request?.navigationId ?? routerRequest?.navigationId) appendNetworkField(content, 'Navigation ID', String(request?.navigationId ?? routerRequest?.navigationId), 'code')
|
|
1613
|
+
if (request?.dataRequestId !== undefined) appendNetworkField(content, 'Data request ID', String(request.dataRequestId), 'code')
|
|
1614
|
+
if (routerRequest?.trigger) appendNetworkField(content, 'Trigger', routerRequest.trigger)
|
|
1615
|
+
appendNetworkField(content, 'Request ID', String(request?.id ?? routerRequest?.id), 'code')
|
|
1616
|
+
}
|
|
1617
|
+
insertBefore(root, content, null)
|
|
1618
|
+
return root
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
function appendNetworkField(parent: Element, label: string, value: string, valueKind?: 'code' | 'muted'): void {
|
|
1622
|
+
const field = createElement('div')
|
|
1623
|
+
setAttribute(field, 'class', 'vobs-devtools-network-detail__field')
|
|
1624
|
+
appendText(field, label, 'vobs-devtools-network-detail__label')
|
|
1625
|
+
appendText(field, value, `vobs-devtools-network-detail__value${valueKind ? ` vobs-devtools-${valueKind}` : ''}`)
|
|
1626
|
+
insertBefore(parent, field, null)
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
function formatNetworkTimestamp(value: number): string {
|
|
1630
|
+
const epoch = value > 100_000_000_000 ? value : (typeof performance !== 'undefined' ? performance.timeOrigin + value : Date.now())
|
|
1631
|
+
return new Date(epoch).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
function matchesQuery(query: string, ...values: readonly unknown[]): boolean {
|
|
1635
|
+
const needle = query.trim().toLowerCase()
|
|
1636
|
+
if (!needle) return true
|
|
1637
|
+
return values.some(value => String(value ?? '').toLowerCase().includes(needle))
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
function filterErrors(errors: readonly DevToolsErrorTrace[], query: string): readonly DevToolsErrorTrace[] {
|
|
1641
|
+
return errors.filter(error => matchesQuery(query, error.phase, error.phases, error.origin, error.code, error.name, error.message, error.component, error.route, error.id, error.effectId, error.requestId))
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
function filterComponentTree(nodes: readonly ComponentDebugNode[], query: string): readonly ComponentDebugNode[] {
|
|
1645
|
+
if (!query.trim()) return nodes
|
|
1646
|
+
return nodes.flatMap(node => {
|
|
1647
|
+
const children = filterComponentTree(node.children, query)
|
|
1648
|
+
if (!matchesQuery(query, node.name, node.id) && children.length === 0) return []
|
|
1649
|
+
return [{ ...node, children }]
|
|
1650
|
+
})
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
function renderErrors(
|
|
1654
|
+
errors: readonly DevToolsErrorTrace[],
|
|
1655
|
+
selection: DevToolsSelection | null,
|
|
1656
|
+
onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void
|
|
1657
|
+
): VobsNode {
|
|
1658
|
+
const root = createElement('div')
|
|
1659
|
+
setAttribute(root, 'class', 'vobs-devtools-error-list')
|
|
1660
|
+
for (const error of [...errors].reverse()) {
|
|
1661
|
+
const row = createElement('details')
|
|
1662
|
+
const selected = selection?.type === 'error' && selection.id === error.id
|
|
1663
|
+
setAttribute(row, 'class', `vobs-devtools-list__row vobs-devtools-error-item${selected ? ' is-selected' : ''}`)
|
|
1664
|
+
if (selected) setProperty(row, 'open', true)
|
|
1665
|
+
row.addEventListener('click', event => {
|
|
1666
|
+
event.stopPropagation()
|
|
1667
|
+
onFocus('errors', { type: 'error', id: error.id })
|
|
1668
|
+
})
|
|
1669
|
+
const summary = createElement('summary')
|
|
1670
|
+
appendText(summary, formatErrorSummary(error), 'vobs-devtools-list__name')
|
|
1671
|
+
appendText(summary, error.message, 'vobs-devtools-error-item__message')
|
|
1672
|
+
insertBefore(summary, createComponent(Tag, { tone: error.origin === 'framework' ? 'danger' : error.origin === 'usage' ? 'warning' : 'neutral-strong', children: () => error.origin }), null)
|
|
1673
|
+
insertBefore(summary, createComponent(Tag, { tone: 'danger', children: () => `${error.count}×` }), null)
|
|
1674
|
+
insertBefore(row, summary, null)
|
|
1675
|
+
const details = createElement('div')
|
|
1676
|
+
setAttribute(details, 'class', 'vobs-devtools-error-item__details')
|
|
1677
|
+
appendErrorField(details, 'Phase', error.phases.length > 1 ? error.phases.join(' / ') : error.phase)
|
|
1678
|
+
appendErrorField(details, 'Occurred', `first ${new Date(error.firstOccurredAt).toLocaleString()} · last ${new Date(error.lastOccurredAt).toLocaleString()}`, 'muted')
|
|
1679
|
+
if (error.code) appendErrorField(details, 'Code', error.code, 'code')
|
|
1680
|
+
if (error.component) appendErrorField(details, 'Component', displayErrorComponent(error.component))
|
|
1681
|
+
if (error.ownerId) appendErrorField(details, 'Owner', error.ownerId, 'code')
|
|
1682
|
+
appendErrorField(details, 'Status', `${error.handled ? 'handled' : 'propagated'} · ${error.recovery}`, 'muted')
|
|
1683
|
+
if (error.hint) appendErrorField(details, 'Hint', error.hint, 'muted', true)
|
|
1684
|
+
if (error.cause) appendErrorField(details, 'Cause', error.cause, 'muted', true)
|
|
1685
|
+
if (error.fix) appendErrorField(details, 'Fix', error.fix, 'muted', true)
|
|
1686
|
+
if (error.source) appendErrorField(details, 'Source', formatDebugLocation(error.source), 'code', true)
|
|
1687
|
+
if (error.updateId) appendErrorLinkField(details, 'Update', error.updateId, 'updates', { type: 'update', id: error.updateId }, onFocus)
|
|
1688
|
+
if (error.effectId) appendErrorField(details, 'Effect', error.effectId, 'code')
|
|
1689
|
+
if (error.requestId !== undefined) appendErrorLinkField(details, 'Request', String(error.requestId), 'network', { type: 'request', id: error.requestId }, onFocus)
|
|
1690
|
+
if (error.navigationId !== undefined) appendErrorField(details, 'Navigation', String(error.navigationId), 'code')
|
|
1691
|
+
if (error.route) appendErrorField(details, 'Route', error.route, 'code')
|
|
1692
|
+
if (error.hydration) {
|
|
1693
|
+
appendErrorField(details, 'Expected', error.hydration.expected, 'code', true)
|
|
1694
|
+
appendErrorField(details, 'Actual', error.hydration.actual, 'code', true)
|
|
1695
|
+
appendErrorField(details, 'DOM path', error.hydration.path, 'code')
|
|
1696
|
+
}
|
|
1697
|
+
if (error.stack) appendErrorStack(details, error.stack, error.name, error.message)
|
|
1698
|
+
insertBefore(row, details, null)
|
|
1699
|
+
insertBefore(root, row, null)
|
|
1700
|
+
}
|
|
1701
|
+
if (errors.length === 0) appendMuted(root, 'No errors recorded.')
|
|
1702
|
+
return root
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
function appendContextLink(
|
|
1706
|
+
parent: Element,
|
|
1707
|
+
label: string,
|
|
1708
|
+
section: DevToolsSection,
|
|
1709
|
+
selection: DevToolsSelection,
|
|
1710
|
+
onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void
|
|
1711
|
+
): void {
|
|
1712
|
+
const button = createElement('button')
|
|
1713
|
+
setAttribute(button, 'class', 'vobs-devtools-link')
|
|
1714
|
+
setAttribute(button, 'type', 'button')
|
|
1715
|
+
button.addEventListener('click', event => {
|
|
1716
|
+
event.stopPropagation()
|
|
1717
|
+
onFocus(section, selection)
|
|
1718
|
+
})
|
|
1719
|
+
insertBefore(button, createText(label), null)
|
|
1720
|
+
insertBefore(parent, button, null)
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
function appendErrorField(parent: Element, label: string, value: string, valueKind?: 'code' | 'muted', wide = false): void {
|
|
1724
|
+
const field = createElement('div')
|
|
1725
|
+
setAttribute(field, 'class', `vobs-devtools-error-item__field${wide ? ' vobs-devtools-error-item__field--wide' : ''}`)
|
|
1726
|
+
appendText(field, label, 'vobs-devtools-error-item__label')
|
|
1727
|
+
appendText(field, value, `vobs-devtools-error-item__value${valueKind ? ` vobs-devtools-${valueKind}` : ''}`)
|
|
1728
|
+
insertBefore(parent, field, null)
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
function appendErrorLinkField(
|
|
1732
|
+
parent: Element,
|
|
1733
|
+
label: string,
|
|
1734
|
+
value: string,
|
|
1735
|
+
section: DevToolsSection,
|
|
1736
|
+
selection: DevToolsSelection,
|
|
1737
|
+
onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void
|
|
1738
|
+
): void {
|
|
1739
|
+
const field = createElement('div')
|
|
1740
|
+
setAttribute(field, 'class', 'vobs-devtools-error-item__field')
|
|
1741
|
+
appendText(field, label, 'vobs-devtools-error-item__label')
|
|
1742
|
+
const valueNode = createElement('span')
|
|
1743
|
+
setAttribute(valueNode, 'class', 'vobs-devtools-error-item__value')
|
|
1744
|
+
appendContextLink(valueNode, value, section, selection, onFocus)
|
|
1745
|
+
insertBefore(field, valueNode, null)
|
|
1746
|
+
insertBefore(parent, field, null)
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
function appendErrorStack(parent: Element, value: string, name: string, message: string): void {
|
|
1750
|
+
const stack = createElement('div')
|
|
1751
|
+
setAttribute(stack, 'class', 'vobs-devtools-error-item__stack')
|
|
1752
|
+
appendText(stack, 'Stack trace', 'vobs-devtools-error-item__label')
|
|
1753
|
+
const code = createElement('pre')
|
|
1754
|
+
setAttribute(code, 'class', 'vobs-devtools-error-item__stack-code vobs-devtools-code')
|
|
1755
|
+
insertBefore(code, createText(formatDebugStack(value, name, message)), null)
|
|
1756
|
+
insertBefore(stack, code, null)
|
|
1757
|
+
insertBefore(parent, stack, null)
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
function formatErrorSummary(error: DevToolsErrorTrace): string {
|
|
1761
|
+
const phase = error.phases.length > 1 ? error.phases.join(' / ') : error.phase
|
|
1762
|
+
return error.name === 'Error' ? phase : `${phase} · ${error.name}`
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
function lifecycleSelection(event: LifecycleEvent): DevToolsSelection | null {
|
|
1766
|
+
if (event.type.startsWith('owner-')) return { type: 'component', id: event.targetId }
|
|
1767
|
+
if (event.type.startsWith('signal-') || event.type.startsWith('memo-')) return { type: 'signal', id: event.targetId }
|
|
1768
|
+
if (event.type.startsWith('effect-')) return { type: 'effect', id: event.targetId }
|
|
1769
|
+
return null
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
function displaySignalName(signal: SignalDebugInfo): string {
|
|
1773
|
+
if (!signal.name.startsWith('signal-')) return stripDebugLocation(signal.name)
|
|
1774
|
+
const component = formatDebugLocation(signal.component)
|
|
1775
|
+
return component === 'unknown' ? 'runtime state' : `${debugComponentName(component)} state`
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
function displayDebugName(name: string): string {
|
|
1779
|
+
return name.startsWith('signal-') ? 'runtime state' : stripDebugLocation(name)
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
function debugComponentName(value: string): string {
|
|
1783
|
+
const separator = value.indexOf(' (')
|
|
1784
|
+
return separator > 0 ? value.slice(0, separator) : value
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
function displayErrorComponent(value: string): string {
|
|
1788
|
+
return debugComponentName(formatDebugLocation(value))
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
function appendText(parent: Element, value: string, className?: string): void {
|
|
1792
|
+
const node = createElement('span')
|
|
1793
|
+
if (className) setAttribute(node, 'class', className)
|
|
1794
|
+
insertBefore(node, createText(value), null)
|
|
1795
|
+
insertBefore(parent, node, null)
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
function formatDebugLocation(value: string): string {
|
|
1799
|
+
const normalized = value.replaceAll('\\', '/')
|
|
1800
|
+
const lower = normalized.toLowerCase()
|
|
1801
|
+
const sourceIndex = lower.startsWith('src/') ? 0 : lower.indexOf('/src/') + 1
|
|
1802
|
+
if (sourceIndex <= 0 && !lower.startsWith('src/')) return normalized
|
|
1803
|
+
const sourcePath = normalized.slice(sourceIndex)
|
|
1804
|
+
const openParen = normalized.lastIndexOf('(', sourceIndex)
|
|
1805
|
+
return openParen >= 0
|
|
1806
|
+
? `${normalized.slice(0, openParen + 1)}${sourcePath}`
|
|
1807
|
+
: sourcePath
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
function formatDebugStack(value: string, name: string, message: string): string {
|
|
1811
|
+
const lines = value
|
|
1812
|
+
.split(/\r?\n/)
|
|
1813
|
+
.map(formatDebugStackLine)
|
|
1814
|
+
const first = lines[0]?.trim()
|
|
1815
|
+
const header = `${name}: ${message}`
|
|
1816
|
+
if (lines.length > 1 && (first === header || first?.startsWith(`${header} `))) return lines.slice(1).join('\n')
|
|
1817
|
+
return lines.join('\n')
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
function formatDebugStackLine(value: string): string {
|
|
1821
|
+
const normalized = value.replaceAll('\\', '/')
|
|
1822
|
+
const lower = normalized.toLowerCase()
|
|
1823
|
+
const sourceIndex = lower.startsWith('src/') ? 0 : lower.indexOf('/src/') + 1
|
|
1824
|
+
if (sourceIndex < 0) return normalized
|
|
1825
|
+
if (sourceIndex === 0) return normalized
|
|
1826
|
+
|
|
1827
|
+
const prefixBeforeLocation = normalized.slice(0, sourceIndex)
|
|
1828
|
+
const openParen = prefixBeforeLocation.lastIndexOf('(')
|
|
1829
|
+
const openBracket = prefixBeforeLocation.lastIndexOf('[')
|
|
1830
|
+
const opening = Math.max(openParen, openBracket)
|
|
1831
|
+
if (opening >= 0) return `${normalized.slice(0, opening + 1)}${normalized.slice(sourceIndex)}`
|
|
1832
|
+
|
|
1833
|
+
const at = prefixBeforeLocation.lastIndexOf('at ')
|
|
1834
|
+
return at >= 0
|
|
1835
|
+
? `${normalized.slice(0, at + 3)}${normalized.slice(sourceIndex)}`
|
|
1836
|
+
: normalized.slice(sourceIndex)
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
function formatDebugSource(value: string): string {
|
|
1840
|
+
const location = formatDebugLocation(value)
|
|
1841
|
+
const openParen = location.indexOf('(')
|
|
1842
|
+
if (openParen < 0) return location
|
|
1843
|
+
const closeParen = location.indexOf(')', openParen)
|
|
1844
|
+
return closeParen < 0 ? location : location.slice(openParen + 1, closeParen)
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
function stripDebugLocation(value: string): string {
|
|
1848
|
+
const normalized = value.replaceAll('\\', '/')
|
|
1849
|
+
const lower = normalized.toLowerCase()
|
|
1850
|
+
const sourceIndex = lower.startsWith('src/') ? 0 : lower.indexOf('/src/') + 1
|
|
1851
|
+
if (sourceIndex <= 0 && !lower.startsWith('src/')) return normalized
|
|
1852
|
+
const openParen = normalized.lastIndexOf('(', sourceIndex)
|
|
1853
|
+
if (openParen < 0) return normalized
|
|
1854
|
+
const closeParen = normalized.indexOf(')', sourceIndex)
|
|
1855
|
+
if (closeParen < 0) return normalized
|
|
1856
|
+
return `${normalized.slice(0, openParen)}${normalized.slice(closeParen + 1)}`.trim()
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
function renderComponentSummary(
|
|
1860
|
+
node: ComponentDebugNode,
|
|
1861
|
+
snapshot: DevToolsSnapshot,
|
|
1862
|
+
selection: DevToolsSelection | null,
|
|
1863
|
+
onFocus: (section: DevToolsSection, selection: DevToolsSelection) => void
|
|
1864
|
+
): VobsNode {
|
|
1865
|
+
const row = createElement('details')
|
|
1866
|
+
const selected = selection?.type === 'component' && selection.id === node.id
|
|
1867
|
+
setAttribute(row, 'class', `vobs-devtools-list__row vobs-devtools-component-row${selected ? ' is-selected' : ''}`)
|
|
1868
|
+
if (selected) setProperty(row, 'open', true)
|
|
1869
|
+
row.addEventListener('click', event => {
|
|
1870
|
+
event.stopPropagation()
|
|
1871
|
+
onFocus('components', { type: 'component', id: node.id })
|
|
1872
|
+
})
|
|
1873
|
+
|
|
1874
|
+
const summary = createElement('summary')
|
|
1875
|
+
appendText(summary, formatDebugLocation(node.name), 'vobs-devtools-list__name')
|
|
1876
|
+
insertBefore(summary, createComponent(Tag, { tone: node.mounted ? 'success' : 'neutral-strong', children: () => `${node.signals.length} signals` }), null)
|
|
1877
|
+
insertBefore(summary, createComponent(Tag, { tone: 'neutral-strong', children: () => `${node.effects.length} effects` }), null)
|
|
1878
|
+
appendText(summary, `${node.recentUpdates.length} updates · ${node.domUpdates} DOM`, 'vobs-devtools-muted')
|
|
1879
|
+
insertBefore(row, summary, null)
|
|
1880
|
+
|
|
1881
|
+
appendText(row, `Component ID: ${node.id}`, 'vobs-devtools-code')
|
|
1882
|
+
appendText(row, 'Recent updates:', 'vobs-devtools-code')
|
|
1883
|
+
appendUpdateLinks(row, node.recentUpdates, onFocus)
|
|
1884
|
+
if (node.domUpdates > 0) {
|
|
1885
|
+
appendText(row, 'DOM results:', 'vobs-devtools-code')
|
|
1886
|
+
for (const updateId of node.recentUpdates) {
|
|
1887
|
+
const update = snapshot.updates.find(item => item.id === updateId)
|
|
1888
|
+
for (const domUpdate of update?.domUpdates ?? []) {
|
|
1889
|
+
const operation = domUpdate.key ? `${domUpdate.operation}.${domUpdate.key}` : domUpdate.operation
|
|
1890
|
+
appendText(row, `${operation} ${domUpdate.target}`, 'vobs-devtools-muted')
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
if (node.children.length > 0) {
|
|
1895
|
+
appendText(row, 'Children:', 'vobs-devtools-code')
|
|
1896
|
+
for (const child of node.children) insertBefore(row, renderComponentSummary(child, snapshot, selection, onFocus), null)
|
|
1897
|
+
}
|
|
1898
|
+
return row
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
function formatValue(value: unknown): string {
|
|
1902
|
+
if (typeof value === 'string') return value
|
|
1903
|
+
try {
|
|
1904
|
+
return JSON.stringify(value) ?? String(value)
|
|
1905
|
+
} catch {
|
|
1906
|
+
return String(value)
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
function renderValuePair(label: string, previousValue: unknown, nextValue: unknown): VobsNode {
|
|
1911
|
+
const root = createElement('div')
|
|
1912
|
+
setAttribute(root, 'class', `vobs-devtools-value-pair${label ? '' : ' vobs-devtools-value-pair--compact'}`)
|
|
1913
|
+
const distinctMode = state(Boolean(label))
|
|
1914
|
+
root.addEventListener('click', event => event.stopPropagation())
|
|
1915
|
+
root.addEventListener('pointerdown', event => event.stopPropagation())
|
|
1916
|
+
if (label) {
|
|
1917
|
+
const title = createElement('div')
|
|
1918
|
+
setAttribute(title, 'class', 'vobs-devtools-value-pair__title')
|
|
1919
|
+
appendText(title, label)
|
|
1920
|
+
const toggle = createElement('button')
|
|
1921
|
+
setAttribute(toggle, 'class', 'vobs-devtools-distinct-toggle is-active')
|
|
1922
|
+
setAttribute(toggle, 'type', 'button')
|
|
1923
|
+
setAttribute(toggle, 'aria-pressed', 'true')
|
|
1924
|
+
setAttribute(toggle, 'title', 'Show only values that changed')
|
|
1925
|
+
insertBefore(toggle, createText('distinct'), null)
|
|
1926
|
+
toggle.addEventListener('click', event => {
|
|
1927
|
+
event.stopPropagation()
|
|
1928
|
+
const enabled = !distinctMode.value
|
|
1929
|
+
distinctMode.value = enabled
|
|
1930
|
+
setAttribute(toggle, 'class', `vobs-devtools-distinct-toggle${enabled ? ' is-active' : ''}`)
|
|
1931
|
+
setAttribute(toggle, 'aria-pressed', enabled ? 'true' : 'false')
|
|
1932
|
+
setAttribute(toggle, 'title', enabled ? 'Show only values that changed' : 'Show complete values')
|
|
1933
|
+
})
|
|
1934
|
+
insertBefore(title, toggle, null)
|
|
1935
|
+
insertBefore(root, title, null)
|
|
1936
|
+
}
|
|
1937
|
+
insertDynamic(root, null, () => {
|
|
1938
|
+
const distinct = distinctMode.value
|
|
1939
|
+
const difference = distinct ? createDistinctValuePair(previousValue, nextValue) : null
|
|
1940
|
+
const before = difference?.before ?? { value: previousValue, hasDifference: true }
|
|
1941
|
+
const after = difference?.after ?? { value: nextValue, hasDifference: true }
|
|
1942
|
+
return createFragment((parent, anchor) => {
|
|
1943
|
+
insertBefore(parent, renderValueTree('Before', before.value, Boolean(label), distinct && !before.hasDifference ? 'No differing fields' : undefined), anchor)
|
|
1944
|
+
insertBefore(parent, renderValueTree('After', after.value, Boolean(label), distinct && !after.hasDifference ? 'No differing fields' : undefined), anchor)
|
|
1945
|
+
})
|
|
1946
|
+
})
|
|
1947
|
+
return root
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
function renderValueTree(label: string, value: unknown, expanded: boolean, emptyMessage?: string): VobsNode {
|
|
1951
|
+
const root = createElement('section')
|
|
1952
|
+
setAttribute(root, 'class', 'vobs-devtools-value-inspector')
|
|
1953
|
+
// Value-tree interactions must not select and rerender their enclosing update row.
|
|
1954
|
+
// Otherwise native <details> toggles are immediately replaced by a fresh closed tree.
|
|
1955
|
+
root.addEventListener('click', event => event.stopPropagation())
|
|
1956
|
+
root.addEventListener('pointerdown', event => event.stopPropagation())
|
|
1957
|
+
if (hasExpandableEntries(value)) {
|
|
1958
|
+
const controls = createElement('div')
|
|
1959
|
+
setAttribute(controls, 'class', 'vobs-devtools-value-inspector__controls')
|
|
1960
|
+
insertBefore(controls, createComponent(Button, {
|
|
1961
|
+
variant: 'ghost',
|
|
1962
|
+
iconOnly: true,
|
|
1963
|
+
icon: createComponent(Icon, { name: 'plus' }),
|
|
1964
|
+
'aria-label': `Expand all ${label} values`,
|
|
1965
|
+
title: `Expand all ${label} values`,
|
|
1966
|
+
onClick: event => {
|
|
1967
|
+
event.stopPropagation()
|
|
1968
|
+
for (const node of root.querySelectorAll('.vobs-devtools-value-tree')) setAttribute(node, 'data-expanded', 'true')
|
|
1969
|
+
}
|
|
1970
|
+
}), null)
|
|
1971
|
+
insertBefore(controls, createComponent(Button, {
|
|
1972
|
+
variant: 'ghost',
|
|
1973
|
+
iconOnly: true,
|
|
1974
|
+
icon: createComponent(Icon, { name: 'minus' }),
|
|
1975
|
+
'aria-label': `Collapse all ${label} values`,
|
|
1976
|
+
title: `Collapse all ${label} values`,
|
|
1977
|
+
onClick: event => {
|
|
1978
|
+
event.stopPropagation()
|
|
1979
|
+
for (const node of root.querySelectorAll('.vobs-devtools-value-tree')) setAttribute(node, 'data-expanded', 'false')
|
|
1980
|
+
}
|
|
1981
|
+
}), null)
|
|
1982
|
+
insertBefore(root, controls, null)
|
|
1983
|
+
}
|
|
1984
|
+
const tree = createElement('div')
|
|
1985
|
+
setAttribute(tree, 'class', 'vobs-devtools-value-tree-root')
|
|
1986
|
+
setAttribute(tree, 'role', 'tree')
|
|
1987
|
+
setAttribute(tree, 'aria-label', `${label} value`)
|
|
1988
|
+
if (emptyMessage) appendText(tree, emptyMessage, 'vobs-devtools-value-tree__empty')
|
|
1989
|
+
else insertBefore(tree, renderInspectableValue(label, value, expanded), null)
|
|
1990
|
+
insertBefore(root, tree, null)
|
|
1991
|
+
return root
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
interface DistinctValue {
|
|
1995
|
+
readonly value: unknown
|
|
1996
|
+
readonly hasDifference: boolean
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
interface DistinctValuePair {
|
|
2000
|
+
readonly before: DistinctValue
|
|
2001
|
+
readonly after: DistinctValue
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
const MISSING_VALUE = Symbol('missing diagnostic value')
|
|
2005
|
+
|
|
2006
|
+
function createDistinctValuePair(before: unknown, after: unknown): DistinctValuePair {
|
|
2007
|
+
return projectDistinctValues(before, after)
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
function projectDistinctValues(before: unknown, after: unknown): DistinctValuePair {
|
|
2011
|
+
if (areValuesEqual(before, after)) {
|
|
2012
|
+
return {
|
|
2013
|
+
before: { value: before, hasDifference: false },
|
|
2014
|
+
after: { value: after, hasDifference: false }
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
if (!isExpandableValue(before) || !isExpandableValue(after)) {
|
|
2019
|
+
return {
|
|
2020
|
+
before: { value: before, hasDifference: true },
|
|
2021
|
+
after: { value: after, hasDifference: true }
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
if (Array.isArray(before) && Array.isArray(after)) {
|
|
2026
|
+
const beforeProjection: unknown[] = new Array(before.length)
|
|
2027
|
+
const afterProjection: unknown[] = new Array(after.length)
|
|
2028
|
+
for (let index = 0; index < Math.max(before.length, after.length); index++) {
|
|
2029
|
+
const child = projectDistinctValues(
|
|
2030
|
+
index in before ? before[index] : MISSING_VALUE,
|
|
2031
|
+
index in after ? after[index] : MISSING_VALUE
|
|
2032
|
+
)
|
|
2033
|
+
if (child.before.value !== MISSING_VALUE) beforeProjection[index] = child.before.value
|
|
2034
|
+
if (child.after.value !== MISSING_VALUE) afterProjection[index] = child.after.value
|
|
2035
|
+
}
|
|
2036
|
+
return {
|
|
2037
|
+
before: { value: beforeProjection, hasDifference: valueEntries(beforeProjection).length > 0 },
|
|
2038
|
+
after: { value: afterProjection, hasDifference: valueEntries(afterProjection).length > 0 }
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
if (!Array.isArray(before) && !Array.isArray(after)) {
|
|
2043
|
+
const beforeProjection: Record<string, unknown> = {}
|
|
2044
|
+
const afterProjection: Record<string, unknown> = {}
|
|
2045
|
+
const beforeObject = before as Record<string, unknown>
|
|
2046
|
+
const afterObject = after as Record<string, unknown>
|
|
2047
|
+
const keys = new Set([...Object.keys(beforeObject), ...Object.keys(afterObject)])
|
|
2048
|
+
for (const key of keys) {
|
|
2049
|
+
const child = projectDistinctValues(
|
|
2050
|
+
Object.prototype.hasOwnProperty.call(beforeObject, key) ? beforeObject[key] : MISSING_VALUE,
|
|
2051
|
+
Object.prototype.hasOwnProperty.call(afterObject, key) ? afterObject[key] : MISSING_VALUE
|
|
2052
|
+
)
|
|
2053
|
+
if (child.before.value !== MISSING_VALUE) beforeProjection[key] = child.before.value
|
|
2054
|
+
if (child.after.value !== MISSING_VALUE) afterProjection[key] = child.after.value
|
|
2055
|
+
}
|
|
2056
|
+
return {
|
|
2057
|
+
before: { value: beforeProjection, hasDifference: Object.keys(beforeProjection).length > 0 },
|
|
2058
|
+
after: { value: afterProjection, hasDifference: Object.keys(afterProjection).length > 0 }
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
return {
|
|
2063
|
+
before: { value: before, hasDifference: true },
|
|
2064
|
+
after: { value: after, hasDifference: true }
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
function areValuesEqual(before: unknown, after: unknown, seen = new WeakMap<object, WeakSet<object>>()): boolean {
|
|
2069
|
+
if (Object.is(before, after)) return true
|
|
2070
|
+
if (!isExpandableValue(before) || !isExpandableValue(after)) return false
|
|
2071
|
+
if (Array.isArray(before) !== Array.isArray(after)) return false
|
|
2072
|
+
if (Object.prototype.toString.call(before) !== Object.prototype.toString.call(after)) return false
|
|
2073
|
+
|
|
2074
|
+
const previousPairs = seen.get(before)
|
|
2075
|
+
if (previousPairs?.has(after)) return true
|
|
2076
|
+
if (previousPairs) previousPairs.add(after)
|
|
2077
|
+
else seen.set(before, new WeakSet([after]))
|
|
2078
|
+
|
|
2079
|
+
const beforeKeys = Object.keys(before)
|
|
2080
|
+
const afterKeys = Object.keys(after)
|
|
2081
|
+
if (beforeKeys.length !== afterKeys.length) return false
|
|
2082
|
+
const beforeObject = before as Record<string, unknown>
|
|
2083
|
+
const afterObject = after as Record<string, unknown>
|
|
2084
|
+
return beforeKeys.every(key => Object.prototype.hasOwnProperty.call(afterObject, key) && areValuesEqual(beforeObject[key], afterObject[key], seen))
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
function renderInspectableValue(
|
|
2088
|
+
label: string,
|
|
2089
|
+
value: unknown,
|
|
2090
|
+
expanded = false,
|
|
2091
|
+
ancestors: readonly object[] = []
|
|
2092
|
+
): VobsNode {
|
|
2093
|
+
if (!hasExpandableEntries(value) || ancestors.includes(value as object)) {
|
|
2094
|
+
const row = createElement('div')
|
|
2095
|
+
setAttribute(row, 'class', 'vobs-devtools-value-tree__leaf')
|
|
2096
|
+
setAttribute(row, 'role', 'treeitem')
|
|
2097
|
+
appendText(row, label, 'vobs-devtools-value-field__label')
|
|
2098
|
+
appendText(row, ancestors.includes(value as object) ? '[Circular]' : previewValue(value), 'vobs-devtools-code')
|
|
2099
|
+
return row
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
const root = createElement('div')
|
|
2103
|
+
setAttribute(root, 'class', 'vobs-devtools-value-tree')
|
|
2104
|
+
setAttribute(root, 'role', 'treeitem')
|
|
2105
|
+
setAttribute(root, 'data-expanded', expanded ? 'true' : 'false')
|
|
2106
|
+
const summary = createElement('button')
|
|
2107
|
+
setAttribute(summary, 'class', 'vobs-devtools-value-tree__summary')
|
|
2108
|
+
setAttribute(summary, 'type', 'button')
|
|
2109
|
+
setAttribute(summary, 'aria-expanded', expanded ? 'true' : 'false')
|
|
2110
|
+
setAttribute(summary, 'title', `Expand ${label}`)
|
|
2111
|
+
const toggleExpanded = (): void => {
|
|
2112
|
+
const expanded = root.getAttribute('data-expanded') !== 'true'
|
|
2113
|
+
setAttribute(root, 'data-expanded', expanded ? 'true' : 'false')
|
|
2114
|
+
setAttribute(summary, 'aria-expanded', expanded ? 'true' : 'false')
|
|
2115
|
+
setAttribute(summary, 'title', `${expanded ? 'Collapse' : 'Expand'} ${label}`)
|
|
2116
|
+
}
|
|
2117
|
+
summary.addEventListener('pointerdown', event => {
|
|
2118
|
+
event.preventDefault()
|
|
2119
|
+
event.stopPropagation()
|
|
2120
|
+
toggleExpanded()
|
|
2121
|
+
})
|
|
2122
|
+
summary.addEventListener('keydown', event => {
|
|
2123
|
+
const keyboardEvent = event as KeyboardEvent
|
|
2124
|
+
if (keyboardEvent.key !== 'Enter' && keyboardEvent.key !== ' ') return
|
|
2125
|
+
event.preventDefault()
|
|
2126
|
+
event.stopPropagation()
|
|
2127
|
+
toggleExpanded()
|
|
2128
|
+
})
|
|
2129
|
+
appendText(summary, label, 'vobs-devtools-value-field__label')
|
|
2130
|
+
appendText(summary, previewValue(value), 'vobs-devtools-code')
|
|
2131
|
+
insertBefore(root, summary, null)
|
|
2132
|
+
|
|
2133
|
+
const children = createElement('div')
|
|
2134
|
+
setAttribute(children, 'class', 'vobs-devtools-value-tree__children')
|
|
2135
|
+
setAttribute(children, 'role', 'group')
|
|
2136
|
+
for (const [key, child] of valueEntries(value)) {
|
|
2137
|
+
insertBefore(children, renderInspectableValue(key, child, false, [...ancestors, value as object]), null)
|
|
2138
|
+
}
|
|
2139
|
+
insertBefore(root, children, null)
|
|
2140
|
+
return root
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
function isExpandableValue(value: unknown): value is Record<string, unknown> | readonly unknown[] {
|
|
2144
|
+
return typeof value === 'object' && value !== null
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
function hasExpandableEntries(value: unknown): value is Record<string, unknown> | readonly unknown[] {
|
|
2148
|
+
return isExpandableValue(value) && valueEntries(value).length > 0
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
function valueEntries(value: Record<string, unknown> | readonly unknown[]): Array<[string, unknown]> {
|
|
2152
|
+
if (Array.isArray(value)) return value.slice(0, 100).map((item, index) => [`[${index}]`, item])
|
|
2153
|
+
return Object.entries(value).slice(0, 100)
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
function previewValue(value: unknown): string {
|
|
2157
|
+
if (value === undefined) return 'undefined'
|
|
2158
|
+
if (value === null) return 'null'
|
|
2159
|
+
if (typeof value === 'string') return value
|
|
2160
|
+
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') return String(value)
|
|
2161
|
+
if (Array.isArray(value)) return `Array (${value.length} items)`
|
|
2162
|
+
if (typeof value === 'object') return `Object (${Object.keys(value).length} fields)`
|
|
2163
|
+
return String(value)
|
|
2164
|
+
}
|