@zerotal/devtools 1.6.3 → 1.7.2
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/CHANGELOG.md +329 -0
- package/api-surface.md +298 -0
- package/package.json +5 -4
- package/src/DevtoolsInjectionMiddleware.ts +41 -4
- package/src/RequestTrace.ts +106 -1
- package/src/TraceStore.ts +12 -0
- package/src/activity.ts +116 -0
- package/src/callsite.ts +146 -0
- package/src/client/filter.ts +108 -0
- package/src/client/index.ts +127 -0
- package/src/client/metrics.ts +98 -0
- package/src/client/registry.ts +87 -0
- package/src/client/state.ts +350 -0
- package/src/client/tabs/all.ts +323 -0
- package/src/client/tabs/app.ts +293 -0
- package/src/client/tabs/cache.ts +50 -0
- package/src/client/tabs/channel.ts +264 -0
- package/src/client/tabs/exceptions.ts +69 -0
- package/src/client/tabs/jobs.ts +51 -0
- package/src/client/tabs/live.ts +66 -0
- package/src/client/tabs/logs.ts +45 -0
- package/src/client/tabs/mail.ts +60 -0
- package/src/client/tabs/queries.ts +125 -0
- package/src/client/tabs/request.ts +75 -0
- package/src/client/tabs/sections.ts +115 -0
- package/src/client/tabs/timeline.ts +133 -0
- package/src/client/tabs/types.ts +68 -0
- package/src/client/transport.ts +81 -0
- package/src/client/tree.ts +138 -0
- package/src/client/ui/format.ts +137 -0
- package/src/client/ui/render.ts +87 -0
- package/src/client/ui/shell.ts +560 -0
- package/src/client/ui/theme.ts +445 -0
- package/src/client-auto.ts +1 -1
- package/src/config.ts +77 -2
- package/src/dashboard-auto.ts +1 -1
- package/src/editor.ts +107 -0
- package/src/enabled.ts +59 -0
- package/src/index.ts +19 -3
- package/src/map.ts +213 -0
- package/src/provider/DevtoolsProvider.ts +32 -7
- package/src/redaction.ts +161 -20
- package/src/tracing.ts +261 -29
- package/src/client.ts +0 -1048
- package/src/panel-app.js +0 -519
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The panel's frame: the bar, the tab strip, the content host, and every event
|
|
3
|
+
* listener the panel owns.
|
|
4
|
+
*
|
|
5
|
+
* Listeners live on the three persistent containers — `#bar`, `#tabs`,
|
|
6
|
+
* `#content` — and dispatch on `data-*` attributes, so a tab can replace its
|
|
7
|
+
* markup wholesale without any of them needing to be reattached. That is the one
|
|
8
|
+
* structural rule this file keeps: nothing below ever binds a listener to a node
|
|
9
|
+
* a tab renders.
|
|
10
|
+
*/
|
|
11
|
+
import type { RequestTrace } from "../../RequestTrace.ts";
|
|
12
|
+
import { toggleFacet } from "../tabs/all.ts";
|
|
13
|
+
import { APP_TABS, invalidateMap } from "../tabs/app.ts";
|
|
14
|
+
import { channelTab } from "../tabs/channel.ts";
|
|
15
|
+
import type { TabContext, TabView } from "../tabs/types.ts";
|
|
16
|
+
import { ensureRegistry, type DevtoolsRegistry } from "../registry.ts";
|
|
17
|
+
import { MIN_HEIGHT, type Store } from "../state.ts";
|
|
18
|
+
import type { Transport } from "../transport.ts";
|
|
19
|
+
import { dCls, esc, fmt, scCls } from "./format.ts";
|
|
20
|
+
import { CSS, isLightTheme, THEME_CYCLE, THEME_ICON } from "./theme.ts";
|
|
21
|
+
|
|
22
|
+
export interface ShellOptions {
|
|
23
|
+
base: string;
|
|
24
|
+
standalone: boolean;
|
|
25
|
+
mount: HTMLElement;
|
|
26
|
+
store: Store;
|
|
27
|
+
transport: Transport;
|
|
28
|
+
/** The built-in tabs, in strip order. */
|
|
29
|
+
tabs: TabView[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** How long a copy button shows that it worked. */
|
|
33
|
+
const COPY_FEEDBACK_MS = 900;
|
|
34
|
+
|
|
35
|
+
export function mountShell(opts: ShellOptions): void {
|
|
36
|
+
const { base, standalone, store, transport, tabs } = opts;
|
|
37
|
+
const registry = ensureRegistry();
|
|
38
|
+
|
|
39
|
+
// ── DOM ─────────────────────────────────────────────────────────────────────
|
|
40
|
+
const host = document.createElement("div");
|
|
41
|
+
host.id = "__zerotal_dt__";
|
|
42
|
+
host.style.cssText = standalone
|
|
43
|
+
? "position:fixed;inset:0;z-index:2147483647"
|
|
44
|
+
: "position:fixed;bottom:0;left:0;right:0;z-index:2147483647;pointer-events:none";
|
|
45
|
+
opts.mount.appendChild(host);
|
|
46
|
+
|
|
47
|
+
const shadow = host.attachShadow({ mode: "open" });
|
|
48
|
+
shadow.innerHTML =
|
|
49
|
+
`${CSS}<div id="wrap" class="${standalone ? "standalone" : ""}" tabindex="-1" ` +
|
|
50
|
+
`style="pointer-events:auto">` +
|
|
51
|
+
`<div id="panel" style="display:${store.open ? "flex" : "none"}">` +
|
|
52
|
+
`<div id="grip" title="Drag to resize"></div>` +
|
|
53
|
+
`<div id="tabs"></div><div id="content"></div></div>` +
|
|
54
|
+
`<div id="bar"></div></div>`;
|
|
55
|
+
|
|
56
|
+
const $ = <T extends HTMLElement = HTMLElement>(sel: string): T => shadow.querySelector<T>(sel)!;
|
|
57
|
+
|
|
58
|
+
const wrap = $("#wrap");
|
|
59
|
+
const panel = $("#panel");
|
|
60
|
+
const bar = $("#bar");
|
|
61
|
+
const tabStrip = $("#tabs");
|
|
62
|
+
const content = $("#content");
|
|
63
|
+
|
|
64
|
+
// ── Tab table ───────────────────────────────────────────────────────────────
|
|
65
|
+
//
|
|
66
|
+
// Rebuilt per render because channels arrive over the wire and plugins register
|
|
67
|
+
// whenever their own package is ready — the set is not known at start.
|
|
68
|
+
|
|
69
|
+
/** Every view the section showing can offer, before scope decides where it goes. */
|
|
70
|
+
function everyTab(): TabView[] {
|
|
71
|
+
if (store.section === "app") return APP_TABS;
|
|
72
|
+
return [...tabs, ...store.channels.map(channelTab), ...registry.panels.map(pluginTab)];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The tabs for the section showing.
|
|
77
|
+
*
|
|
78
|
+
* Only the session-scoped ones. Everything that describes a single request —
|
|
79
|
+
* its queries, its logs, its channels — is a section of that request in the
|
|
80
|
+
* list rather than a tab of its own, so the strip is what you can look at
|
|
81
|
+
* rather than a dozen headings that are empty until you have picked something.
|
|
82
|
+
*/
|
|
83
|
+
function allTabs(): TabView[] {
|
|
84
|
+
return everyTab().filter((t) => t.scope === "session");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The views rendered inside whichever request is open. */
|
|
88
|
+
function requestTabs(): TabView[] {
|
|
89
|
+
return everyTab().filter((t) => t.scope === "request");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function pluginTab(p: DevtoolsRegistry["panels"][number]): TabView {
|
|
93
|
+
return {
|
|
94
|
+
id: `plugin:${p.id}`,
|
|
95
|
+
label: p.title,
|
|
96
|
+
// Live browser state, which keeps reading as you move between requests.
|
|
97
|
+
scope: "session",
|
|
98
|
+
// A plugin owns its data and its DOM; the panel cannot know when either
|
|
99
|
+
// moved, so it redraws whenever anything else did and on explicit refresh.
|
|
100
|
+
volatile: true,
|
|
101
|
+
standsAlone: true,
|
|
102
|
+
badge: () => {
|
|
103
|
+
const count = p.badge?.();
|
|
104
|
+
return count ? { count } : undefined;
|
|
105
|
+
},
|
|
106
|
+
render(el) {
|
|
107
|
+
try {
|
|
108
|
+
p.render(el, { trace: store.selected });
|
|
109
|
+
} catch {
|
|
110
|
+
// A broken extension tab must not take the panel with it.
|
|
111
|
+
el.innerHTML = '<p class="empty">Panel error</p>';
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function currentTab(): TabView | undefined {
|
|
118
|
+
const list = allTabs();
|
|
119
|
+
return list.find((t) => t.id === store.activeTab) ?? list[0];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function ctx(): TabContext {
|
|
123
|
+
return { trace: store.selected, store, sections: requestTabs() };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Render ──────────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* What the content host currently shows.
|
|
130
|
+
*
|
|
131
|
+
* A tab that reads one trace has nothing new to say until that trace changes,
|
|
132
|
+
* so redrawing it on every arriving request only destroys the scroll position
|
|
133
|
+
* and any open `<details>`. Volatile tabs opt out by folding the store's
|
|
134
|
+
* revision into the key.
|
|
135
|
+
*/
|
|
136
|
+
let contentKey = "";
|
|
137
|
+
|
|
138
|
+
function render(): void {
|
|
139
|
+
applyTheme();
|
|
140
|
+
renderBar();
|
|
141
|
+
if (!store.open) return;
|
|
142
|
+
renderTabs();
|
|
143
|
+
renderContent();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function applyTheme(): void {
|
|
147
|
+
wrap.classList.toggle("light", isLightTheme(store.theme));
|
|
148
|
+
if (!standalone) panel.style.height = `${store.height}px`;
|
|
149
|
+
reserveSpace();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Give the host page back the strip the panel covers.
|
|
154
|
+
*
|
|
155
|
+
* The panel is fixed to the bottom of the viewport, so without this the last
|
|
156
|
+
* 32px of a page — or the panel's full height when open — is behind it and
|
|
157
|
+
* cannot be scrolled to. The value is written as a custom property as well as
|
|
158
|
+
* the padding, so an app that would rather move something else of its own can
|
|
159
|
+
* read `--zt-dt-height` instead.
|
|
160
|
+
*/
|
|
161
|
+
function reserveSpace(): void {
|
|
162
|
+
if (standalone) return;
|
|
163
|
+
const height = wrap.getBoundingClientRect().height;
|
|
164
|
+
document.documentElement.style.setProperty("--zt-dt-height", `${height}px`);
|
|
165
|
+
document.body.style.paddingBottom = `${height}px`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function renderBar(): void {
|
|
169
|
+
const t = store.selected;
|
|
170
|
+
const dot =
|
|
171
|
+
`<span class="dot ${store.connected ? "ok" : "err"}" ` +
|
|
172
|
+
`title="${store.connected ? "Connected" : "Disconnected"}">●</span>`;
|
|
173
|
+
|
|
174
|
+
// Pinned and behind: an offer to catch up, never a jump. Nothing is more
|
|
175
|
+
// hostile than a list that scrolls away from what you were reading.
|
|
176
|
+
const catchUp =
|
|
177
|
+
!store.live && store.pending
|
|
178
|
+
? `<button class="ibtn pending" data-action="live" ` +
|
|
179
|
+
`title="Jump to the newest request">⤒ ${store.pending} new</button>`
|
|
180
|
+
: "";
|
|
181
|
+
const liveBtn = store.live
|
|
182
|
+
? `<button class="ibtn live-on" data-action="pin" title="Live — click to pin">⏵ Live</button>`
|
|
183
|
+
: `<button class="ibtn" data-action="live" title="Pinned — click to follow newest">⏸ Pinned</button>`;
|
|
184
|
+
const arrow = store.open ? "▼" : "▲";
|
|
185
|
+
const theme =
|
|
186
|
+
`<button class="ibtn" data-action="theme" ` +
|
|
187
|
+
`title="Theme: ${store.theme}">${THEME_ICON[store.theme]}</button>`;
|
|
188
|
+
|
|
189
|
+
// The dashboard has no page to collapse back to and no host page to be
|
|
190
|
+
// removed from, so it drops the toggle, popout, and close controls.
|
|
191
|
+
const chrome = standalone
|
|
192
|
+
? `<button class="ibtn" data-action="clear" title="Clear traces">🗑</button>${theme}`
|
|
193
|
+
: `<button class="ibtn" data-action="clear" title="Clear traces">🗑</button>` +
|
|
194
|
+
theme +
|
|
195
|
+
`<button class="ibtn" data-action="dash" title="Open inspector">⤢</button>` +
|
|
196
|
+
`<button class="ibtn" data-action="close" title="Remove devtools">✕</button>` +
|
|
197
|
+
`<button class="ibtn" data-action="toggle" title="Toggle panel (Alt+D)">${arrow}</button>`;
|
|
198
|
+
|
|
199
|
+
if (!t) {
|
|
200
|
+
bar.innerHTML =
|
|
201
|
+
`<span class="logo">⬡ <b>Zerotal</b></span>${dot}<span class="sp"></span>` +
|
|
202
|
+
`${catchUp}${liveBtn}${chrome}`;
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const n1 = t.warnings.length ? `<span class="chip warn">⚠${t.warnings.length}</span>` : "";
|
|
207
|
+
const user = t.auth
|
|
208
|
+
? `<span class="chip" title="${esc(String(t.auth.email ?? t.auth.id ?? ""))}">👤</span>`
|
|
209
|
+
: "";
|
|
210
|
+
const mail = t.mail?.length ? `<span class="chip">📧${t.mail.length}</span>` : "";
|
|
211
|
+
const jobs = t.jobs?.length ? `<span class="chip">⚙${t.jobs.length}</span>` : "";
|
|
212
|
+
const cache = t.cache?.length
|
|
213
|
+
? `<span class="chip">${t.cache.filter((c) => c.op === "hit").length}/${t.cache.length}c</span>`
|
|
214
|
+
: "";
|
|
215
|
+
|
|
216
|
+
bar.innerHTML =
|
|
217
|
+
`<span class="logo">⬡ <b>Zerotal</b></span>${dot}<span class="bdiv">│</span>` +
|
|
218
|
+
`<span class="meth ${t.method.toLowerCase()}">${esc(t.method)}</span>` +
|
|
219
|
+
`<span class="bpath">${esc(t.path)}</span><span class="bdiv">│</span>` +
|
|
220
|
+
`<span class="sc ${scCls(t.statusCode)}">${t.statusCode || "—"}</span>` +
|
|
221
|
+
`<span class="dim">·</span><span class="${dCls(t.durationMs) || "dim"}">${fmt(t.durationMs)}</span>` +
|
|
222
|
+
`<span class="dim">·</span><span class="dim">${t.queries.length}q</span>` +
|
|
223
|
+
`${n1}${user}${mail}${jobs}${cache}<span class="sp"></span>` +
|
|
224
|
+
`${catchUp}${liveBtn}${chrome}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Requests | App.
|
|
229
|
+
*
|
|
230
|
+
* Two sections rather than fifteen tabs in one scrolling strip: they answer
|
|
231
|
+
* different questions — what the app just did, and what the app is — and a
|
|
232
|
+
* strip you have to scroll to reach the routes list is one you stop reaching
|
|
233
|
+
* for.
|
|
234
|
+
*/
|
|
235
|
+
function sectionSwitch(): string {
|
|
236
|
+
const button = (id: "requests" | "app", label: string): string =>
|
|
237
|
+
`<button class="sect${store.section === id ? " on" : ""}" ` +
|
|
238
|
+
`data-section="${id}">${label}</button>`;
|
|
239
|
+
return (
|
|
240
|
+
`<div class="sects">${button("requests", "Requests")}${button("app", "App")}</div>` +
|
|
241
|
+
`<span class="tabdiv"></span>`
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function renderTabs(): void {
|
|
246
|
+
const c = ctx();
|
|
247
|
+
tabStrip.innerHTML =
|
|
248
|
+
sectionSwitch() +
|
|
249
|
+
allTabs()
|
|
250
|
+
.map((tab) => {
|
|
251
|
+
const b = tab.badge?.(c);
|
|
252
|
+
const badge = b?.count
|
|
253
|
+
? `<span class="tbdg${b.warn ? " warn" : ""}">${esc(String(b.count))}</span>`
|
|
254
|
+
: "";
|
|
255
|
+
const dot = tab.live && store.connected ? '<span class="ldot">●</span>' : "";
|
|
256
|
+
return (
|
|
257
|
+
`<button class="tab${store.activeTab === tab.id ? " active" : ""}" ` +
|
|
258
|
+
`data-tab="${esc(tab.id)}">${esc(tab.label)}${badge}${dot}</button>`
|
|
259
|
+
);
|
|
260
|
+
})
|
|
261
|
+
.join("");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function renderContent(force = false): void {
|
|
265
|
+
const tab = currentTab();
|
|
266
|
+
if (!tab) {
|
|
267
|
+
content.innerHTML = '<p class="empty">Nothing to show</p>';
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (!store.selected && !tab.standsAlone) {
|
|
271
|
+
contentKey = "waiting";
|
|
272
|
+
content.innerHTML = '<p class="empty">Waiting for traffic…</p>';
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const key =
|
|
276
|
+
`${store.section}|${tab.id}|${store.selected?.id ?? ""}|` +
|
|
277
|
+
`${tab.volatile ? store.revision : ""}`;
|
|
278
|
+
if (!force && key === contentKey) return;
|
|
279
|
+
const switchingTab = !contentKey.startsWith(`${store.section}|${tab.id}|`);
|
|
280
|
+
contentKey = key;
|
|
281
|
+
// A tab that keeps its own scaffold (the request list) detects it by looking
|
|
282
|
+
// for it, so the host is only emptied when the tab itself changed.
|
|
283
|
+
if (switchingTab) content.innerHTML = "";
|
|
284
|
+
tab.render(content, ctx());
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── Bar actions ─────────────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
bar.addEventListener("click", (e: MouseEvent) => {
|
|
290
|
+
const btn = (e.target as HTMLElement).closest("[data-action]") as HTMLElement | null;
|
|
291
|
+
if (!btn) {
|
|
292
|
+
togglePanel();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
switch (btn.dataset["action"]) {
|
|
296
|
+
case "toggle":
|
|
297
|
+
togglePanel();
|
|
298
|
+
break;
|
|
299
|
+
case "live":
|
|
300
|
+
store.follow();
|
|
301
|
+
break;
|
|
302
|
+
case "pin":
|
|
303
|
+
store.pin();
|
|
304
|
+
break;
|
|
305
|
+
case "clear":
|
|
306
|
+
transport.clear();
|
|
307
|
+
break;
|
|
308
|
+
case "theme":
|
|
309
|
+
store.setTheme(THEME_CYCLE[store.theme]);
|
|
310
|
+
break;
|
|
311
|
+
case "dash":
|
|
312
|
+
window.open(base, "_blank");
|
|
313
|
+
break;
|
|
314
|
+
case "close":
|
|
315
|
+
transport.close();
|
|
316
|
+
host.remove();
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
tabStrip.addEventListener("click", (e: MouseEvent) => {
|
|
322
|
+
const section = (e.target as HTMLElement).closest("[data-section]") as HTMLElement | null;
|
|
323
|
+
if (section) {
|
|
324
|
+
store.setSection(section.dataset["section"] === "app" ? "app" : "requests");
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const btn = (e.target as HTMLElement).closest("[data-tab]") as HTMLElement | null;
|
|
328
|
+
if (btn) store.setTab(btn.dataset["tab"]!);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// ── Content actions ─────────────────────────────────────────────────────────
|
|
332
|
+
//
|
|
333
|
+
// One delegated handler for every interactive thing a tab can render. Checked
|
|
334
|
+
// most-specific first: the group toggle and the copy button both sit *inside* a
|
|
335
|
+
// selectable row, so testing for the row first would make expanding a batch
|
|
336
|
+
// also pin its first request.
|
|
337
|
+
|
|
338
|
+
content.addEventListener("click", (e: MouseEvent) => {
|
|
339
|
+
const target = e.target as HTMLElement;
|
|
340
|
+
|
|
341
|
+
const copy = target.closest("[data-copy]") as HTMLElement | null;
|
|
342
|
+
if (copy) {
|
|
343
|
+
e.stopPropagation();
|
|
344
|
+
void copyToClipboard(copy);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// The framework map is cached for the life of the panel, so re-reading it is
|
|
349
|
+
// an explicit ask — for the case where a provider registered a route late.
|
|
350
|
+
if (target.closest("[data-map-refresh]")) {
|
|
351
|
+
e.stopPropagation();
|
|
352
|
+
invalidateMap();
|
|
353
|
+
renderContent(true);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const facet = target.closest("[data-facet]") as HTMLElement | null;
|
|
358
|
+
if (facet) {
|
|
359
|
+
e.stopPropagation();
|
|
360
|
+
store.setFacets(
|
|
361
|
+
toggleFacet(store.facets, facet.dataset["facet"]!, facet.dataset["value"] ?? ""),
|
|
362
|
+
);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const group = target.closest("[data-group]") as HTMLElement | null;
|
|
367
|
+
if (group) {
|
|
368
|
+
e.stopPropagation();
|
|
369
|
+
store.toggleGroup(group.dataset["group"]!);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// A view inside the request being read. Checked before the row, since the
|
|
374
|
+
// strip sits inside the detail the row opened.
|
|
375
|
+
const sec = target.closest("[data-sec]") as HTMLElement | null;
|
|
376
|
+
if (sec?.dataset["sec"]) {
|
|
377
|
+
store.setSectionTab(sec.dataset["sec"]);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// The list carries its own Clear, beside the filter it belongs with.
|
|
382
|
+
if (target.closest('[data-action="clear"]')) {
|
|
383
|
+
transport.clear();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Opening a request is the whole navigation now: its queries, logs, timeline
|
|
388
|
+
// and channels are sections of it rather than tabs you go and find.
|
|
389
|
+
const row = target.closest("[data-idx]") as HTMLElement | null;
|
|
390
|
+
if (row) store.toggleOpen(store.traces[Number(row.dataset["idx"])] ?? null);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
content.addEventListener("input", (e: Event) => {
|
|
394
|
+
const input = e.target as HTMLInputElement;
|
|
395
|
+
if (input.id !== "filter") return;
|
|
396
|
+
// The input is never re-created, so there is no caret to restore: the store
|
|
397
|
+
// update redraws the rows beneath it and leaves the field alone.
|
|
398
|
+
store.setFilter(input.value);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
content.addEventListener("scroll", () => {
|
|
402
|
+
currentTab()?.onScroll?.(content, ctx());
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
/** Copy a button's payload, and say so where the user is looking. */
|
|
406
|
+
async function copyToClipboard(btn: HTMLElement): Promise<void> {
|
|
407
|
+
const text = btn.dataset["copy"] ?? "";
|
|
408
|
+
try {
|
|
409
|
+
await navigator.clipboard.writeText(text);
|
|
410
|
+
const original = btn.textContent;
|
|
411
|
+
btn.textContent = "✓";
|
|
412
|
+
btn.classList.add("done");
|
|
413
|
+
setTimeout(() => {
|
|
414
|
+
btn.textContent = original;
|
|
415
|
+
btn.classList.remove("done");
|
|
416
|
+
}, COPY_FEEDBACK_MS);
|
|
417
|
+
} catch {
|
|
418
|
+
// Denied permission, or an insecure origin. Nothing useful to do about it,
|
|
419
|
+
// and a thrown promise in a dev panel helps no one.
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ── Resize ──────────────────────────────────────────────────────────────────
|
|
424
|
+
//
|
|
425
|
+
// A fixed 380px panel is either too small to read a stack trace in or too big
|
|
426
|
+
// to see the page under. Pointer events rather than mouse, so a trackpad or a
|
|
427
|
+
// pen drags it too, and capture so the drag survives leaving the strip.
|
|
428
|
+
|
|
429
|
+
const grip = $("#grip");
|
|
430
|
+
grip.addEventListener("pointerdown", (e: PointerEvent) => {
|
|
431
|
+
if (standalone) return;
|
|
432
|
+
e.preventDefault();
|
|
433
|
+
grip.setPointerCapture(e.pointerId);
|
|
434
|
+
grip.classList.add("dragging");
|
|
435
|
+
|
|
436
|
+
const move = (ev: PointerEvent): void => {
|
|
437
|
+
const next = window.innerHeight - ev.clientY;
|
|
438
|
+
// Leave the host page a strip of itself: a panel dragged to full height is
|
|
439
|
+
// one you cannot get out of by dragging.
|
|
440
|
+
panel.style.height = `${Math.max(MIN_HEIGHT, Math.min(next, window.innerHeight - 60))}px`;
|
|
441
|
+
reserveSpace();
|
|
442
|
+
};
|
|
443
|
+
const up = (): void => {
|
|
444
|
+
grip.classList.remove("dragging");
|
|
445
|
+
grip.removeEventListener("pointermove", move);
|
|
446
|
+
grip.removeEventListener("pointerup", up);
|
|
447
|
+
store.setHeight(panel.getBoundingClientRect().height);
|
|
448
|
+
};
|
|
449
|
+
grip.addEventListener("pointermove", move);
|
|
450
|
+
grip.addEventListener("pointerup", up);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// ── Keyboard ────────────────────────────────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* The panel's shortcuts fire only while the panel has focus.
|
|
457
|
+
*
|
|
458
|
+
* It is an overlay on somebody's application: binding `j` on `document` would
|
|
459
|
+
* mean a developer typing into their own form navigated the trace list instead.
|
|
460
|
+
* Focus is claimed when the panel is opened or clicked, and released the moment
|
|
461
|
+
* anything on the page takes it back.
|
|
462
|
+
*/
|
|
463
|
+
wrap.addEventListener("keydown", (e: KeyboardEvent) => {
|
|
464
|
+
const typing =
|
|
465
|
+
e.target instanceof HTMLElement &&
|
|
466
|
+
(e.target.tagName === "INPUT" ||
|
|
467
|
+
e.target.tagName === "TEXTAREA" ||
|
|
468
|
+
e.target.isContentEditable);
|
|
469
|
+
|
|
470
|
+
if (e.key === "Escape") {
|
|
471
|
+
e.preventDefault();
|
|
472
|
+
if (typing) (e.target as HTMLElement).blur();
|
|
473
|
+
else if (!standalone && store.open) togglePanel();
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (typing || e.altKey || e.ctrlKey || e.metaKey) return;
|
|
477
|
+
|
|
478
|
+
if (e.key === "j" || e.key === "ArrowDown") {
|
|
479
|
+
e.preventDefault();
|
|
480
|
+
step(1);
|
|
481
|
+
} else if (e.key === "k" || e.key === "ArrowUp") {
|
|
482
|
+
e.preventDefault();
|
|
483
|
+
step(-1);
|
|
484
|
+
} else if (e.key === "/") {
|
|
485
|
+
e.preventDefault();
|
|
486
|
+
store.setTab("all");
|
|
487
|
+
// After the store's render has put the field on screen.
|
|
488
|
+
queueMicrotask(() => shadow.querySelector<HTMLInputElement>("#filter")?.focus());
|
|
489
|
+
} else if (e.key >= "1" && e.key <= "9") {
|
|
490
|
+
const tab = allTabs()[Number(e.key) - 1];
|
|
491
|
+
if (tab) {
|
|
492
|
+
e.preventDefault();
|
|
493
|
+
store.setTab(tab.id);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
wrap.addEventListener("pointerdown", () => {
|
|
499
|
+
if (store.open) wrap.focus({ preventScroll: true });
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
/** Move the selection through the filtered list, which is what you can see. */
|
|
503
|
+
function step(delta: number): void {
|
|
504
|
+
const rows = store.visible();
|
|
505
|
+
if (!rows.length) return;
|
|
506
|
+
const at = rows.findIndex((r) => r.trace.id === store.selected?.id);
|
|
507
|
+
const next = at === -1 ? 0 : Math.min(rows.length - 1, Math.max(0, at + delta));
|
|
508
|
+
select(rows[next]!.trace);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Alt+D stays global — it is how you reach a panel that does not have focus.
|
|
512
|
+
document.addEventListener("keydown", (e: KeyboardEvent) => {
|
|
513
|
+
if ((e.altKey || e.metaKey) && e.key === "d") {
|
|
514
|
+
e.preventDefault();
|
|
515
|
+
togglePanel();
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
// ── Commands ────────────────────────────────────────────────────────────────
|
|
520
|
+
|
|
521
|
+
function togglePanel(): void {
|
|
522
|
+
if (standalone) return; // nothing to collapse into
|
|
523
|
+
store.setOpen(!store.open);
|
|
524
|
+
panel.style.display = store.open ? "flex" : "none";
|
|
525
|
+
reserveSpace();
|
|
526
|
+
if (store.open) {
|
|
527
|
+
wrap.focus({ preventScroll: true });
|
|
528
|
+
renderContent(true);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function select(trace: RequestTrace | null): void {
|
|
533
|
+
if (!trace) return;
|
|
534
|
+
store.select(trace);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ── Extension panels ────────────────────────────────────────────────────────
|
|
538
|
+
// A late registration (flow enabling its timeline once its WebSocket is ready)
|
|
539
|
+
// adds the tab live; `refresh` pushes a badge or content update.
|
|
540
|
+
|
|
541
|
+
registry._emit = (panel) => {
|
|
542
|
+
if (!store.open) return;
|
|
543
|
+
renderTabs();
|
|
544
|
+
// A panel that registers after we restored its tab from a previous session
|
|
545
|
+
// would otherwise leave "Panel unavailable" on screen until the next click.
|
|
546
|
+
if (store.activeTab === `plugin:${panel.id}`) renderContent(true);
|
|
547
|
+
};
|
|
548
|
+
registry._refresh = (id) => {
|
|
549
|
+
if (!store.open) return;
|
|
550
|
+
renderTabs();
|
|
551
|
+
if (store.activeTab.startsWith("plugin:") && (!id || store.activeTab === `plugin:${id}`)) {
|
|
552
|
+
renderContent(true);
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
// ── Go ──────────────────────────────────────────────────────────────────────
|
|
557
|
+
|
|
558
|
+
store.subscribe(render);
|
|
559
|
+
render();
|
|
560
|
+
}
|