@zerotal/devtools 1.6.3 → 1.7.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +233 -1
  2. package/api-surface.md +296 -0
  3. package/package.json +5 -4
  4. package/src/DevtoolsInjectionMiddleware.ts +41 -4
  5. package/src/RequestTrace.ts +96 -1
  6. package/src/TraceStore.ts +12 -0
  7. package/src/activity.ts +116 -0
  8. package/src/callsite.ts +146 -0
  9. package/src/client/filter.ts +108 -0
  10. package/src/client/index.ts +122 -0
  11. package/src/client/metrics.ts +98 -0
  12. package/src/client/registry.ts +65 -0
  13. package/src/client/state.ts +311 -0
  14. package/src/client/tabs/all.ts +276 -0
  15. package/src/client/tabs/app.ts +292 -0
  16. package/src/client/tabs/cache.ts +49 -0
  17. package/src/client/tabs/channel.ts +263 -0
  18. package/src/client/tabs/exceptions.ts +68 -0
  19. package/src/client/tabs/jobs.ts +50 -0
  20. package/src/client/tabs/logs.ts +44 -0
  21. package/src/client/tabs/mail.ts +59 -0
  22. package/src/client/tabs/queries.ts +124 -0
  23. package/src/client/tabs/request.ts +76 -0
  24. package/src/client/tabs/timeline.ts +132 -0
  25. package/src/client/tabs/types.ts +51 -0
  26. package/src/client/transport.ts +81 -0
  27. package/src/client/tree.ts +138 -0
  28. package/src/client/ui/format.ts +118 -0
  29. package/src/client/ui/render.ts +87 -0
  30. package/src/client/ui/shell.ts +511 -0
  31. package/src/client/ui/theme.ts +389 -0
  32. package/src/client-auto.ts +1 -1
  33. package/src/config.ts +77 -2
  34. package/src/dashboard-auto.ts +1 -1
  35. package/src/editor.ts +107 -0
  36. package/src/enabled.ts +59 -0
  37. package/src/index.ts +19 -3
  38. package/src/map.ts +213 -0
  39. package/src/provider/DevtoolsProvider.ts +32 -7
  40. package/src/redaction.ts +161 -20
  41. package/src/tracing.ts +213 -24
  42. package/src/client.ts +0 -1048
  43. package/src/panel-app.js +0 -519
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Both halves of the exchange: what came in, what went out, and what the session
3
+ * was holding while it happened.
4
+ *
5
+ * Headers are an allowlist, not a denylist, because a trace is persisted —
6
+ * `cookie` and `authorization` are the request's credentials, and a header nobody
7
+ * thought to deny is a header on disk for a day. `devtools.headers` opens up the
8
+ * ones you are actually debugging.
9
+ *
10
+ * The session shows **key names only**. "Is the CSRF token there, did the flash
11
+ * survive the redirect, is the user id set" are all answered by the keys, and the
12
+ * values are the request's real state.
13
+ */
14
+ import { copyBtn, esc, scCls } from "../ui/format.ts";
15
+ import type { TabView } from "./types.ts";
16
+
17
+ function kvTable(pairs: Record<string, string>): string {
18
+ const rows = Object.entries(pairs)
19
+ .map(([k, v]) => `<tr><td>${esc(k)}</td><td>${esc(v)}</td></tr>`)
20
+ .join("");
21
+ return `<table class="kv">${rows || `<tr><td colspan="2" class="dim" style="padding:6px 12px">None</td></tr>`}</table>`;
22
+ }
23
+
24
+ /** The whole block as `name: value` lines, which is what you paste into a curl. */
25
+ function asText(pairs: Record<string, string>): string {
26
+ return Object.entries(pairs)
27
+ .map(([k, v]) => `${k}: ${v}`)
28
+ .join("\n");
29
+ }
30
+
31
+ function section(title: string, pairs: Record<string, string>): string {
32
+ return (
33
+ `<div class="sec"><div class="stitle">${esc(title)} (${Object.keys(pairs).length})` +
34
+ copyBtn(asText(pairs), `Copy ${title.toLowerCase()}`) +
35
+ `</div>${kvTable(pairs)}</div>`
36
+ );
37
+ }
38
+
39
+ export const requestTab: TabView = {
40
+ id: "request",
41
+ label: "Request",
42
+
43
+ render(host, { trace }) {
44
+ const t = trace!;
45
+ const params = t.queryParams ?? {};
46
+ const headers = t.headers ?? {};
47
+ const responseHeaders = t.responseHeaders ?? {};
48
+ const session = t.session ?? [];
49
+
50
+ const statusLine =
51
+ `<div class="rcard">` +
52
+ `<span class="meth ${t.method.toLowerCase()}">${esc(t.method)}</span> ` +
53
+ `<b>${esc(t.path)}</b> ` +
54
+ `<span class="sc ${scCls(t.statusCode)}">${t.statusCode || "—"}</span>` +
55
+ `</div>`;
56
+
57
+ const sessionKeys = session.length
58
+ ? `<div class="chips">` +
59
+ session.map((k) => `<span class="chip">${esc(k)}</span>`).join("") +
60
+ `</div>`
61
+ : `<p class="dim" style="padding:2px 0 4px">` +
62
+ `No session on this request — or no session middleware installed</p>`;
63
+
64
+ host.innerHTML =
65
+ statusLine +
66
+ section("Query Params", params) +
67
+ section("Request Headers", headers) +
68
+ section("Response Headers", responseHeaders) +
69
+ `<div class="sec"><div class="stitle">Session keys (${session.length})` +
70
+ copyBtn(session.join("\n"), "Copy keys") +
71
+ `</div>${sessionKeys}` +
72
+ `<p class="dim" style="font-size:10px;margin-top:6px">` +
73
+ `Names only — the values are this request's real state, and a trace is kept for a day.` +
74
+ `</p></div>`;
75
+ },
76
+ };
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Everything the request did, on one waterfall.
3
+ *
4
+ * Every entry already carries an offset from the request start; the waterfall is
5
+ * what makes "what was waiting on what" legible instead of a column of numbers
6
+ * you have to order in your head.
7
+ */
8
+ import { esc, fmt } from "../ui/format.ts";
9
+ import type { ClientMetric } from "../metrics.ts";
10
+ import type { TabView } from "./types.ts";
11
+
12
+ /**
13
+ * What the browser measured, above what the server did.
14
+ *
15
+ * Kept visibly separate rather than merged into the waterfall: these describe
16
+ * the *page*, not this request, and drawing them on the same track would claim a
17
+ * relationship that is not there. Shown anyway, because server duration reported
18
+ * as if it were the user's experience is the panel's most misleading number.
19
+ */
20
+ function clientStrip(metrics: ClientMetric[]): string {
21
+ if (!metrics.length) return "";
22
+ return (
23
+ `<div class="stats">` +
24
+ metrics
25
+ .map(
26
+ (m) =>
27
+ `<div class="stat" title="${esc(m.detail)}">` +
28
+ `<div class="slbl">${esc(m.label)}</div>` +
29
+ `<div class="sval">${fmt(m.value)}</div></div>`,
30
+ )
31
+ .join("") +
32
+ `</div>` +
33
+ `<div class="dim" style="font-size:10px;padding:4px 12px">` +
34
+ `Measured in the browser, for this page load — not for this request.</div>`
35
+ );
36
+ }
37
+
38
+ interface Span {
39
+ at: number;
40
+ dur: number;
41
+ kind: string;
42
+ text: string;
43
+ }
44
+
45
+ const KEY: Array<[string, string]> = [
46
+ ["query", "Queries"],
47
+ ["cache", "Cache"],
48
+ ["mail", "Mail"],
49
+ ["job", "Jobs"],
50
+ ["chan", "Channels"],
51
+ ["log", "Logs"],
52
+ ["warn", "Warnings"],
53
+ ];
54
+
55
+ export const timelineTab: TabView = {
56
+ id: "timeline",
57
+ label: "Timeline",
58
+
59
+ render(host, { trace, store }) {
60
+ const t = trace!;
61
+ const spans: Span[] = [];
62
+
63
+ for (const q of t.queries) {
64
+ spans.push({
65
+ at: Math.max(0, q.startMs - t.startMs),
66
+ dur: q.durationMs,
67
+ kind: "query",
68
+ text: q.sql,
69
+ });
70
+ }
71
+ for (const c of t.cache ?? []) {
72
+ spans.push({ at: c.offsetMs, dur: c.durationMs, kind: "cache", text: `${c.op} ${c.key}` });
73
+ }
74
+ for (const m of t.mail ?? []) {
75
+ spans.push({ at: m.offsetMs, dur: m.durationMs, kind: "mail", text: m.subject });
76
+ }
77
+ for (const j of t.jobs ?? []) {
78
+ spans.push({ at: j.offsetMs, dur: j.durationMs, kind: "job", text: j.className });
79
+ }
80
+ for (const l of t.logs ?? []) {
81
+ spans.push({
82
+ at: l.offsetMs,
83
+ dur: 0,
84
+ kind: l.level === "error" || l.level === "warn" ? "warn" : "log",
85
+ text: l.args.join(" "),
86
+ });
87
+ }
88
+ for (const [id, rows] of Object.entries(t.channels ?? {})) {
89
+ const desc = store.channels.find((c) => c.id === id);
90
+ for (const r of rows) {
91
+ const titleKey = desc?.title ?? desc?.badge;
92
+ spans.push({
93
+ at: r.offsetMs,
94
+ dur: typeof r["durationMs"] === "number" ? (r["durationMs"] as number) : 0,
95
+ kind: "chan",
96
+ text: `${desc?.label ?? id}: ${titleKey ? String(r[titleKey] ?? "") : ""}`,
97
+ });
98
+ }
99
+ }
100
+
101
+ const client = clientStrip(store.clientMetrics);
102
+
103
+ if (!spans.length) {
104
+ host.innerHTML =
105
+ client + '<p class="empty">Nothing recorded on the timeline for this request</p>';
106
+ return;
107
+ }
108
+
109
+ spans.sort((a, b) => a.at - b.at);
110
+ const total = Math.max(1, t.durationMs);
111
+
112
+ host.innerHTML =
113
+ client +
114
+ `<div class="tkey">` +
115
+ KEY.map(([k, lbl]) => `<span><i class="tmark ${k}"></i>${lbl}</span>`).join("") +
116
+ `</div><div>` +
117
+ spans
118
+ .map((s) => {
119
+ const left = Math.min(99, (s.at / total) * 100);
120
+ const width = Math.max(0.6, Math.min(100 - left, (s.dur / total) * 100));
121
+ return (
122
+ `<div class="trow">` +
123
+ `<span class="tlbl dim">+${s.at}ms</span>` +
124
+ `<span class="ttrack"><span class="tmark ${s.kind}" style="left:${left}%;width:${width}%"></span></span>` +
125
+ `<span class="ttxt dim" title="${esc(s.text)}">${esc(s.text)}</span>` +
126
+ `</div>`
127
+ );
128
+ })
129
+ .join("") +
130
+ `</div>`;
131
+ },
132
+ };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * What a tab is.
3
+ *
4
+ * Every tab in the panel — built-in, channel, or plugin — is reduced to this, so
5
+ * the shell has one loop for the tab strip and one call for the content instead
6
+ * of a switch that grows a case per feature. Adding a tab is adding a file.
7
+ */
8
+ import type { RequestTrace } from "../../RequestTrace.ts";
9
+ import type { Store } from "../state.ts";
10
+
11
+ export interface TabContext {
12
+ /** The pinned or live trace, or null before any traffic. */
13
+ trace: RequestTrace | null;
14
+ store: Store;
15
+ }
16
+
17
+ /** The count beside a tab's label, and whether it should read as a warning. */
18
+ export interface TabBadge {
19
+ count: number | string;
20
+ warn?: boolean;
21
+ }
22
+
23
+ export interface TabView {
24
+ id: string;
25
+ label: string;
26
+ /**
27
+ * Show the live dot while connected. For the tab whose contents change on
28
+ * their own rather than only when you pick a different request.
29
+ */
30
+ live?: boolean;
31
+ /**
32
+ * Redraw whenever anything in the store moved, not only when the selected
33
+ * trace changed. The default is the cheaper one: a tab that reads one trace is
34
+ * redrawn when that trace changes and left alone otherwise.
35
+ */
36
+ volatile?: boolean;
37
+ /**
38
+ * Render even when nothing is selected. Only the request list has anything to
39
+ * say before the first request arrives.
40
+ */
41
+ standsAlone?: boolean;
42
+ badge?(ctx: TabContext): TabBadge | undefined;
43
+ render(host: HTMLElement, ctx: TabContext): void;
44
+ /**
45
+ * Redraw for a scroll of the content host, without a store change.
46
+ *
47
+ * Only a tab that windows its rows needs this — for everything else the
48
+ * browser scrolls what is already drawn.
49
+ */
50
+ onScroll?(host: HTMLElement, ctx: TabContext): void;
51
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The wire: one `EventSource` and three JSON endpoints.
3
+ *
4
+ * Split out because it is the only part of the panel that talks to the server,
5
+ * and keeping it apart is what lets every tab be a pure function of the store.
6
+ * `EventSource` reconnects on its own timer, so there is no retry loop here —
7
+ * only the connection flag the bar's dot reads.
8
+ */
9
+ import type { RequestTrace, TraceChannelDescriptor } from "../RequestTrace.ts";
10
+ import type { EditorName } from "../editor.ts";
11
+ import type { Store } from "./state.ts";
12
+
13
+ /** What the SSE stream sends. */
14
+ type Frame =
15
+ | {
16
+ type: "history";
17
+ data: RequestTrace[];
18
+ channels?: TraceChannelDescriptor[];
19
+ capacity?: number;
20
+ editor?: EditorName | null;
21
+ editorPathMap?: Record<string, string>;
22
+ }
23
+ | { type: "trace"; data: RequestTrace }
24
+ | { type: "clear" };
25
+
26
+ export interface Transport {
27
+ /** Ask the server to drop its history. The `clear` frame comes back over SSE. */
28
+ clear(): void;
29
+ /** Close the stream. Called when the panel removes itself from the page. */
30
+ close(): void;
31
+ }
32
+
33
+ /**
34
+ * Connect the store to the stream.
35
+ *
36
+ * @param base - The devtools endpoint root, without a trailing slash.
37
+ * @param store - Mutated as frames arrive; it announces its own changes.
38
+ */
39
+ export function connect(base: string, store: Store): Transport {
40
+ const sse = new EventSource(`${base}/sse`);
41
+
42
+ sse.onopen = () => {
43
+ store.connected = true;
44
+ store.changed();
45
+ };
46
+
47
+ sse.onerror = () => {
48
+ store.connected = false;
49
+ store.changed();
50
+ };
51
+
52
+ sse.onmessage = (e: MessageEvent<string>) => {
53
+ let frame: Frame;
54
+ try {
55
+ frame = JSON.parse(e.data) as Frame;
56
+ } catch {
57
+ // A truncated frame is the stream's problem, not the panel's; the next one
58
+ // will be whole.
59
+ return;
60
+ }
61
+ if (frame.type === "history") {
62
+ store.loadHistory(frame.data, frame.channels ?? [], frame.capacity, {
63
+ editor: frame.editor ?? null,
64
+ editorPathMap: frame.editorPathMap ?? {},
65
+ });
66
+ } else if (frame.type === "trace") {
67
+ store.addTrace(frame.data);
68
+ } else {
69
+ store.clear();
70
+ }
71
+ };
72
+
73
+ return {
74
+ clear() {
75
+ void fetch(`${base}/api/clear`, { method: "POST" });
76
+ },
77
+ close() {
78
+ sse.close();
79
+ },
80
+ };
81
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Turning flat records into the nested views the panel draws.
3
+ *
4
+ * Two shapings, both pure and both testable without a DOM: dotted prop paths
5
+ * into a tree, and a set of traces into correlated groups.
6
+ */
7
+ import type { RequestTrace, TraceChannelDescriptor } from "../RequestTrace.ts";
8
+
9
+ /** A node of a dotted-path tree: a leaf with attributes, a branch with children, or both. */
10
+ export interface PathTreeNode {
11
+ children: Map<string, PathTreeNode>;
12
+ /** The node's own attributes, or null for a branch nothing was recorded against. */
13
+ attrs: Record<string, unknown> | null;
14
+ }
15
+
16
+ /**
17
+ * Split a map of dotted paths into the tree the dots already describe.
18
+ *
19
+ * `user.name` and `user.email` are two entries in a flat record and one branch
20
+ * with two leaves on screen — which is the difference between reading a prop bag
21
+ * and scanning one. A path can be both: `user` may carry attributes of its own
22
+ * *and* have children, so a branch keeps its `attrs` rather than being treated as
23
+ * a container.
24
+ *
25
+ * @param paths - Dotted path → that node's attributes.
26
+ */
27
+ export function buildPathTree(paths: Array<[string, unknown]>): Map<string, PathTreeNode> {
28
+ const root = new Map<string, PathTreeNode>();
29
+ for (const [path, attrs] of paths) {
30
+ let level = root;
31
+ const parts = path.split(".");
32
+ parts.forEach((part, i) => {
33
+ let node = level.get(part);
34
+ if (!node) {
35
+ node = { children: new Map(), attrs: null };
36
+ level.set(part, node);
37
+ }
38
+ if (i === parts.length - 1) {
39
+ node.attrs = attrs && typeof attrs === "object" ? (attrs as Record<string, unknown>) : {};
40
+ }
41
+ level = node.children;
42
+ });
43
+ }
44
+ return root;
45
+ }
46
+
47
+ /**
48
+ * The value that groups this trace with others, from whichever channel declared
49
+ * a `traceGroup` field. Null when nothing correlates it.
50
+ *
51
+ * One user action can be several requests — a visit, then the deferred-prop loads
52
+ * it triggers — and listing them as unrelated siblings is how the thing you are
53
+ * debugging scrolls off the top. Which field says so is the channel's to declare,
54
+ * so this stays free of any package's vocabulary.
55
+ *
56
+ * The id is prefixed with the channel's, so two channels that both correlate
57
+ * cannot collide on a shared value.
58
+ */
59
+ export function traceGroupKey(
60
+ trace: RequestTrace,
61
+ channels: TraceChannelDescriptor[],
62
+ ): string | null {
63
+ for (const c of channels) {
64
+ if (!c.traceGroup) continue;
65
+ const value = trace.channels?.[c.id]?.[0]?.[c.traceGroup];
66
+ if (value != null && value !== "") return `${c.id}:${String(value)}`;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /** One line of the All tab: a trace, its index in the full list, and its nesting. */
72
+ export interface TraceRow {
73
+ trace: RequestTrace;
74
+ /** Index into the *unfiltered* trace list, so a click still selects the right one. */
75
+ index: number;
76
+ /** True for a follow-up shown under its group head. */
77
+ child: boolean;
78
+ /** The group this row heads, when it heads one. */
79
+ groupKey?: string;
80
+ /** How many follow-ups are folded under this head. */
81
+ groupSize?: number;
82
+ }
83
+
84
+ /**
85
+ * Flatten the filtered traces into the rows the All tab draws, folding correlated
86
+ * requests under the oldest of each set.
87
+ *
88
+ * A group takes the position of its *newest* member, so a batch still receiving
89
+ * follow-ups stays where you are looking rather than sinking as it grows. The
90
+ * head is its oldest member — the request that started it — because that is the
91
+ * one you meant to click.
92
+ *
93
+ * Kept separate from the rendering so the All tab's structure can be asserted on
94
+ * without a DOM, and so virtualisation has a flat array to window over.
95
+ */
96
+ export function foldTraceRows(
97
+ matches: Array<{ trace: RequestTrace; index: number }>,
98
+ channels: TraceChannelDescriptor[],
99
+ expanded: ReadonlySet<string>,
100
+ ): TraceRow[] {
101
+ const order: string[] = [];
102
+ const groups = new Map<string, Array<{ trace: RequestTrace; index: number }>>();
103
+
104
+ for (const m of matches) {
105
+ // Uncorrelated traces get a key of their own so they never merge with each
106
+ // other — the fallback has to be unique, not shared.
107
+ const key = traceGroupKey(m.trace, channels) ?? `#${m.index}`;
108
+ let bucket = groups.get(key);
109
+ if (!bucket) {
110
+ groups.set(key, (bucket = []));
111
+ order.push(key);
112
+ }
113
+ bucket.push(m);
114
+ }
115
+
116
+ const rows: TraceRow[] = [];
117
+ for (const key of order) {
118
+ const members = groups.get(key)!;
119
+ if (members.length === 1) {
120
+ rows.push({ trace: members[0]!.trace, index: members[0]!.index, child: false });
121
+ continue;
122
+ }
123
+ // Oldest last, because the list is newest-first.
124
+ const head = members[members.length - 1]!;
125
+ const rest = members.slice(0, -1);
126
+ rows.push({
127
+ trace: head.trace,
128
+ index: head.index,
129
+ child: false,
130
+ groupKey: key,
131
+ groupSize: rest.length,
132
+ });
133
+ if (expanded.has(key)) {
134
+ for (const m of rest) rows.push({ trace: m.trace, index: m.index, child: true });
135
+ }
136
+ }
137
+ return rows;
138
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The small formatters every tab shares.
3
+ *
4
+ * They were nine closures inside `DevTools.start()`, which meant a tab could not
5
+ * be moved into a file of its own without taking copies of them with it. Nothing
6
+ * here touches the DOM or the store — it is all value-in, string-out.
7
+ */
8
+ import { editorUrl, shortLocation, type SourceLocation } from "../../editor.ts";
9
+ import type { EditorSettings } from "../state.ts";
10
+
11
+ const ESCAPES: Record<string, string> = {
12
+ "&": "&amp;",
13
+ "<": "&lt;",
14
+ ">": "&gt;",
15
+ '"': "&quot;",
16
+ "'": "&#39;",
17
+ };
18
+
19
+ /**
20
+ * HTML-escape a value for interpolation into markup **or** an attribute.
21
+ *
22
+ * Quotes are escaped as well as angle brackets, which is what makes it safe for
23
+ * `title="…"` and for the mail preview's `srcdoc="…"` — the panel renders text it
24
+ * did not write, on the app's own origin.
25
+ */
26
+ export function esc(s: unknown): string {
27
+ return String(s ?? "").replace(/[&<>"']/g, (c) => ESCAPES[c]!);
28
+ }
29
+
30
+ /** A duration, in the largest unit that stays readable. */
31
+ export function fmt(ms: number): string {
32
+ return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
33
+ }
34
+
35
+ /** A byte count as KB or MB. */
36
+ export function fmtMem(b: number): string {
37
+ return b >= 1_048_576 ? `${(b / 1_048_576).toFixed(1)} MB` : `${(b / 1024).toFixed(0)} KB`;
38
+ }
39
+
40
+ /** The colour class for a request duration — empty for "unremarkable". */
41
+ export function dCls(ms: number): string {
42
+ return ms > 1000 ? "red" : ms > 300 ? "yellow" : "";
43
+ }
44
+
45
+ /** The colour class for a status code. */
46
+ export function scCls(s: number): string {
47
+ return s >= 500 ? "srv" : s >= 400 ? "cli" : s >= 300 ? "redir" : "ok";
48
+ }
49
+
50
+ /** One channel value, as a single line of text. */
51
+ export function fmtCell(v: unknown): string {
52
+ if (v === null || v === undefined) return "";
53
+ if (typeof v === "object") return JSON.stringify(v) ?? "";
54
+ return String(v);
55
+ }
56
+
57
+ /**
58
+ * A badge chip, accented by hashing its own text.
59
+ *
60
+ * `partial` and `deferred` need to be tellable apart at a glance, and the only
61
+ * way to do that without devtools holding a list of every value every package
62
+ * might use is to derive the colour from the value itself. Stable per value, so
63
+ * a badge keeps its colour between requests.
64
+ */
65
+ export function chipFor(text: string, warn: boolean): string {
66
+ if (warn) return `<span class="chip warn">${esc(text)}</span>`;
67
+ let hash = 0;
68
+ for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
69
+ return `<span class="chip a${hash % 6}">${esc(text)}</span>`;
70
+ }
71
+
72
+ /**
73
+ * A copy-to-clipboard button carrying its own payload.
74
+ *
75
+ * The text rides in a data attribute rather than being read back out of the
76
+ * rendered DOM, because what you want on the clipboard is rarely what is on
77
+ * screen — the SQL without its duration bar, the log line without its offset.
78
+ * The shell's delegated handler does the copying; this only marks the target.
79
+ */
80
+ export function copyBtn(text: string, label = "Copy"): string {
81
+ if (!text) return "";
82
+ return `<button class="cpy" data-copy="${esc(text)}" title="${esc(label)}">⧉</button>`;
83
+ }
84
+
85
+ /** The table an N+1 warning's SQL reads from, for the suppression call. */
86
+ export function tableFrom(sql: string): string {
87
+ return /from\s+[`'"[]?(\w+)/i.exec(sql)?.[1] ?? "table_name";
88
+ }
89
+
90
+ /**
91
+ * A source location, as a link to the editor when there is one to make.
92
+ *
93
+ * The panel renders a location the same way everywhere it appears — a query's
94
+ * call site, a log line's, a stack frame, a prop's render source — because the
95
+ * gesture is the same one every time: this is where it happened, go there.
96
+ *
97
+ * Falls back to plain text when `editor` is null or the location has no file. A
98
+ * location worth showing is still worth showing when it cannot be opened.
99
+ *
100
+ * @param location - Where, or null/undefined for nothing at all.
101
+ * @param editor - The panel's resolved editor settings.
102
+ * @param extraClass - Appended to the element's class, for per-surface spacing.
103
+ */
104
+ export function sourceLink(
105
+ location: SourceLocation | null | undefined,
106
+ editor: EditorSettings,
107
+ extraClass = "",
108
+ ): string {
109
+ if (!location?.file) return "";
110
+ const label = shortLocation(location);
111
+ const title = location.function
112
+ ? `${location.function} — ${location.file}:${location.line}`
113
+ : `${location.file}:${location.line}`;
114
+ const url = editorUrl(location, editor.editor, editor.editorPathMap);
115
+ const cls = `src${extraClass ? ` ${extraClass}` : ""}`;
116
+ if (!url) return `<span class="${cls}" title="${esc(title)}">${esc(label)}</span>`;
117
+ return `<a class="${cls} link" href="${esc(url)}" title="${esc(title)}">${esc(label)}</a>`;
118
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * A keyed list reconciler, in about sixty lines and with no dependency.
3
+ *
4
+ * The panel used to redraw `#content` wholesale on every trace, which on the All
5
+ * tab — the one that redraws most, because every request changes it — threw away
6
+ * the scroll position, every open `<details>`, and the caret in the filter box,
7
+ * fifty times a second under load. Keying the rows means an arriving request
8
+ * inserts one node and touches nothing else.
9
+ *
10
+ * Deliberately not a virtual DOM. The panel is bundled on demand and injected
11
+ * into arbitrary apps, so it stays dependency-free and small; a list diff is the
12
+ * only reconciliation any of this needs.
13
+ */
14
+
15
+ /** Where a reconciler stores each child's identity. */
16
+ const KEY_ATTR = "data-k";
17
+
18
+ /**
19
+ * Make `host`'s element children match `items`, in order, reusing by key.
20
+ *
21
+ * A child whose key survives is moved rather than rebuilt, so anything the
22
+ * browser owns inside it — scroll offset, `<details>` state, an iframe's loaded
23
+ * document, text selection — survives with it.
24
+ *
25
+ * @param host - The element whose children are the list. Must hold nothing else.
26
+ * @param items - The list, in the order it should appear.
27
+ * @param keyOf - A stable identity per item. Two items must never share one.
28
+ * @param create - Build the element for an item seen for the first time.
29
+ * @param update - Bring a reused element up to date. Called for every survivor.
30
+ */
31
+ export function reconcile<T>(
32
+ host: HTMLElement,
33
+ items: readonly T[],
34
+ keyOf: (item: T) => string,
35
+ create: (item: T) => HTMLElement,
36
+ update?: (el: HTMLElement, item: T) => void,
37
+ ): void {
38
+ const existing = new Map<string, HTMLElement>();
39
+ // Anything without a key was not put there by a previous reconcile — an empty
40
+ // state, a message the host wrote directly — so it is not part of the list and
41
+ // has to go, or it lingers above the rows that replaced it.
42
+ const strays: Element[] = [];
43
+ for (const child of Array.from(host.children)) {
44
+ const key = child.getAttribute(KEY_ATTR);
45
+ if (key === null) strays.push(child);
46
+ else existing.set(key, child as HTMLElement);
47
+ }
48
+ for (const stray of strays) stray.remove();
49
+
50
+ // Walks forward through the host's children alongside `items`. Anything
51
+ // already in the right place is left untouched, which is what keeps a steady
52
+ // list from generating DOM writes at all.
53
+ let cursor: ChildNode | null = host.firstChild;
54
+
55
+ for (const item of items) {
56
+ const key = keyOf(item);
57
+ let el = existing.get(key);
58
+ if (el) {
59
+ existing.delete(key);
60
+ update?.(el, item);
61
+ } else {
62
+ el = create(item);
63
+ el.setAttribute(KEY_ATTR, key);
64
+ }
65
+ if (cursor === el) {
66
+ cursor = el.nextSibling;
67
+ } else {
68
+ host.insertBefore(el, cursor);
69
+ }
70
+ }
71
+
72
+ // Whatever the walk did not claim is gone from the list.
73
+ for (const el of existing.values()) el.remove();
74
+ }
75
+
76
+ /**
77
+ * Build an element from a markup string.
78
+ *
79
+ * The tabs are string builders — the shape they render is fixed and a template
80
+ * literal reads far better than twenty `createElement` calls — so the reconciler
81
+ * needs a way back from markup to a node it can key and move.
82
+ */
83
+ export function el(html: string): HTMLElement {
84
+ const template = document.createElement("template");
85
+ template.innerHTML = html.trim();
86
+ return template.content.firstElementChild as HTMLElement;
87
+ }