@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +329 -0
  2. package/api-surface.md +298 -0
  3. package/package.json +5 -4
  4. package/src/DevtoolsInjectionMiddleware.ts +41 -4
  5. package/src/RequestTrace.ts +106 -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 +127 -0
  11. package/src/client/metrics.ts +98 -0
  12. package/src/client/registry.ts +87 -0
  13. package/src/client/state.ts +350 -0
  14. package/src/client/tabs/all.ts +323 -0
  15. package/src/client/tabs/app.ts +293 -0
  16. package/src/client/tabs/cache.ts +50 -0
  17. package/src/client/tabs/channel.ts +264 -0
  18. package/src/client/tabs/exceptions.ts +69 -0
  19. package/src/client/tabs/jobs.ts +51 -0
  20. package/src/client/tabs/live.ts +66 -0
  21. package/src/client/tabs/logs.ts +45 -0
  22. package/src/client/tabs/mail.ts +60 -0
  23. package/src/client/tabs/queries.ts +125 -0
  24. package/src/client/tabs/request.ts +75 -0
  25. package/src/client/tabs/sections.ts +115 -0
  26. package/src/client/tabs/timeline.ts +133 -0
  27. package/src/client/tabs/types.ts +68 -0
  28. package/src/client/transport.ts +81 -0
  29. package/src/client/tree.ts +138 -0
  30. package/src/client/ui/format.ts +137 -0
  31. package/src/client/ui/render.ts +87 -0
  32. package/src/client/ui/shell.ts +560 -0
  33. package/src/client/ui/theme.ts +445 -0
  34. package/src/client-auto.ts +1 -1
  35. package/src/config.ts +77 -2
  36. package/src/dashboard-auto.ts +1 -1
  37. package/src/editor.ts +107 -0
  38. package/src/enabled.ts +59 -0
  39. package/src/index.ts +19 -3
  40. package/src/map.ts +213 -0
  41. package/src/provider/DevtoolsProvider.ts +32 -7
  42. package/src/redaction.ts +161 -20
  43. package/src/tracing.ts +261 -29
  44. package/src/client.ts +0 -1048
  45. package/src/panel-app.js +0 -519
@@ -0,0 +1,75 @@
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 } 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
+ scope: "request",
43
+
44
+ render(host, { trace }) {
45
+ const t = trace!;
46
+ const params = t.queryParams ?? {};
47
+ const headers = t.headers ?? {};
48
+ const responseHeaders = t.responseHeaders ?? {};
49
+ const session = t.session ?? [];
50
+
51
+ // No status line here. This view was once a tab of its own and had to say
52
+ // which request it was describing; it is now a section of that request, and
53
+ // whatever opened it — the row in the list, the header in Live — has already
54
+ // said the method, the path and the status directly above. Repeating them
55
+ // read as a second heading competing with the real one.
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
+ section("Query Params", params) +
66
+ section("Request Headers", headers) +
67
+ section("Response Headers", responseHeaders) +
68
+ `<div class="sec"><div class="stitle">Session keys (${session.length})` +
69
+ copyBtn(session.join("\n"), "Copy keys") +
70
+ `</div>${sessionKeys}` +
71
+ `<p class="dim" style="font-size:10px;margin-top:6px">` +
72
+ `Names only — the values are this request's real state, and a trace is kept for a day.` +
73
+ `</p></div>`;
74
+ },
75
+ };
@@ -0,0 +1,115 @@
1
+ /**
2
+ * A request, drawn as its own small set of tabs.
3
+ *
4
+ * Twelve request-scoped tabs in the panel's main strip is twelve headings that
5
+ * are empty for most requests and answer a question you can only ask about a
6
+ * request you have already picked. They belong to the request, so they are drawn
7
+ * inside it — by calling the views themselves, since a `TabView` is already
8
+ * "draw this trace into this element", which is exactly what a section is.
9
+ * Nothing here reimplements a view.
10
+ *
11
+ * Tabs rather than a stack, because the sections are alternatives: you are
12
+ * reading the queries *or* the headers *or* the waterfall, and stacking them
13
+ * makes you scroll past two to reach the third. Only the ones with something to
14
+ * say appear, so the strip is also the summary — a request with a Queries, a
15
+ * Logs and an Exception tab has already told you what happened before you click
16
+ * anything.
17
+ *
18
+ * Shared by the request list, where a row opens into its own detail, and by the
19
+ * Live view, which shows the newest request without your having to open anything.
20
+ */
21
+ import type { RequestTrace } from "../../RequestTrace.ts";
22
+ import { esc } from "../ui/format.ts";
23
+ import type { TabContext, TabView } from "./types.ts";
24
+
25
+ /** Whether a view drew anything beyond its own "nothing here" line. */
26
+ export function isEmptyRender(body: HTMLElement): boolean {
27
+ if (!body.textContent?.trim()) return true;
28
+ const kids = Array.from(body.children);
29
+ return kids.length > 0 && kids.every((k) => k.classList.contains("empty"));
30
+ }
31
+
32
+ interface Drawn {
33
+ view: TabView;
34
+ badge: ReturnType<NonNullable<TabView["badge"]>>;
35
+ body: HTMLElement;
36
+ }
37
+
38
+ /**
39
+ * Render every request-scoped view that has something to say about `trace`.
40
+ *
41
+ * A view is asked for its count first and skipped when it counts nothing — the
42
+ * queries view renders a stats strip even for a request that ran none, and a
43
+ * "Queries 0" heading over it is exactly the empty furniture this replaces. What
44
+ * survives that is rendered and then dropped anyway if what came back is only the
45
+ * view's own empty-state line, so a request that sent no mail has no Mail tab
46
+ * rather than a tab holding the word "none".
47
+ *
48
+ * Every surviving body is kept in the DOM and hidden rather than re-rendered on
49
+ * each switch: they are whole tab renderers, and the flick between two of them
50
+ * should cost nothing.
51
+ */
52
+ export function renderSections(hostEl: HTMLElement, trace: RequestTrace, ctx: TabContext): void {
53
+ hostEl.replaceChildren();
54
+
55
+ const drawn: Drawn[] = [];
56
+ for (const view of ctx.sections ?? []) {
57
+ const badge = view.badge?.({ trace, store: ctx.store });
58
+ if (badge && Number(badge.count) === 0) continue;
59
+
60
+ const body = document.createElement("div");
61
+ body.className = "dsec-body";
62
+ try {
63
+ view.render(body, { trace, store: ctx.store });
64
+ } catch {
65
+ // A view that throws must not take the request it belongs to with it.
66
+ continue;
67
+ }
68
+ if (isEmptyRender(body)) continue;
69
+ drawn.push({ view, badge, body });
70
+ }
71
+
72
+ if (!drawn.length) {
73
+ hostEl.innerHTML = `<p class="empty">Nothing else was recorded for this request</p>`;
74
+ return;
75
+ }
76
+
77
+ const activeId = activeSection(
78
+ ctx.store.sectionTab,
79
+ drawn.map((d) => d.view.id),
80
+ );
81
+
82
+ const strip = document.createElement("div");
83
+ strip.className = "dsecs";
84
+ strip.innerHTML = drawn
85
+ .map(({ view, badge }) => {
86
+ const count = badge
87
+ ? `<span class="dsec-n${badge.warn ? " warn" : ""}">${esc(String(badge.count))}</span>`
88
+ : "";
89
+ return (
90
+ `<button class="dsect${view.id === activeId ? " on" : ""}" ` +
91
+ `data-sec="${esc(view.id)}">${esc(view.label)}${count}</button>`
92
+ );
93
+ })
94
+ .join("");
95
+ hostEl.appendChild(strip);
96
+
97
+ for (const { view, body } of drawn) {
98
+ const pane = document.createElement("div");
99
+ pane.className = "dsec-pane";
100
+ if (view.id !== activeId) pane.style.display = "none";
101
+ pane.appendChild(body);
102
+ hostEl.appendChild(pane);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Which section to show: the one you were reading, or the first this request has.
108
+ *
109
+ * Falling back rather than clearing the preference — move from a request with
110
+ * queries to one without and you get its first section, but the one after that
111
+ * with queries again puts you back where you were.
112
+ */
113
+ export function activeSection(preferred: string, available: string[]): string {
114
+ return available.includes(preferred) ? preferred : (available[0] ?? "");
115
+ }
@@ -0,0 +1,133 @@
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
+ scope: "request",
59
+
60
+ render(host, { trace, store }) {
61
+ const t = trace!;
62
+ const spans: Span[] = [];
63
+
64
+ for (const q of t.queries) {
65
+ spans.push({
66
+ at: Math.max(0, q.startMs - t.startMs),
67
+ dur: q.durationMs,
68
+ kind: "query",
69
+ text: q.sql,
70
+ });
71
+ }
72
+ for (const c of t.cache ?? []) {
73
+ spans.push({ at: c.offsetMs, dur: c.durationMs, kind: "cache", text: `${c.op} ${c.key}` });
74
+ }
75
+ for (const m of t.mail ?? []) {
76
+ spans.push({ at: m.offsetMs, dur: m.durationMs, kind: "mail", text: m.subject });
77
+ }
78
+ for (const j of t.jobs ?? []) {
79
+ spans.push({ at: j.offsetMs, dur: j.durationMs, kind: "job", text: j.className });
80
+ }
81
+ for (const l of t.logs ?? []) {
82
+ spans.push({
83
+ at: l.offsetMs,
84
+ dur: 0,
85
+ kind: l.level === "error" || l.level === "warn" ? "warn" : "log",
86
+ text: l.args.join(" "),
87
+ });
88
+ }
89
+ for (const [id, rows] of Object.entries(t.channels ?? {})) {
90
+ const desc = store.channels.find((c) => c.id === id);
91
+ for (const r of rows) {
92
+ const titleKey = desc?.title ?? desc?.badge;
93
+ spans.push({
94
+ at: r.offsetMs,
95
+ dur: typeof r["durationMs"] === "number" ? (r["durationMs"] as number) : 0,
96
+ kind: "chan",
97
+ text: `${desc?.label ?? id}: ${titleKey ? String(r[titleKey] ?? "") : ""}`,
98
+ });
99
+ }
100
+ }
101
+
102
+ const client = clientStrip(store.clientMetrics);
103
+
104
+ if (!spans.length) {
105
+ host.innerHTML =
106
+ client + '<p class="empty">Nothing recorded on the timeline for this request</p>';
107
+ return;
108
+ }
109
+
110
+ spans.sort((a, b) => a.at - b.at);
111
+ const total = Math.max(1, t.durationMs);
112
+
113
+ host.innerHTML =
114
+ client +
115
+ `<div class="tkey">` +
116
+ KEY.map(([k, lbl]) => `<span><i class="tmark ${k}"></i>${lbl}</span>`).join("") +
117
+ `</div><div>` +
118
+ spans
119
+ .map((s) => {
120
+ const left = Math.min(99, (s.at / total) * 100);
121
+ const width = Math.max(0.6, Math.min(100 - left, (s.dur / total) * 100));
122
+ return (
123
+ `<div class="trow">` +
124
+ `<span class="tlbl dim">+${s.at}ms</span>` +
125
+ `<span class="ttrack"><span class="tmark ${s.kind}" style="left:${left}%;width:${width}%"></span></span>` +
126
+ `<span class="ttxt dim" title="${esc(s.text)}">${esc(s.text)}</span>` +
127
+ `</div>`
128
+ );
129
+ })
130
+ .join("") +
131
+ `</div>`;
132
+ },
133
+ };
@@ -0,0 +1,68 @@
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
+ * The request-scoped views, for the list to render inside whichever request is
17
+ * open. Absent in a test that renders one tab on its own.
18
+ */
19
+ sections?: TabView[];
20
+ }
21
+
22
+ /** The count beside a tab's label, and whether it should read as a warning. */
23
+ export interface TabBadge {
24
+ count: number | string;
25
+ warn?: boolean;
26
+ }
27
+
28
+ export interface TabView {
29
+ id: string;
30
+ label: string;
31
+ /**
32
+ * Whether this describes one request or the session.
33
+ *
34
+ * `"request"` is not a tab at all: it is a section of the request you opened
35
+ * in the list. Twelve of these in the strip is twelve tabs that are empty for
36
+ * most requests and answer a question you can only ask about a request you
37
+ * have already picked — so they are rendered inside its row instead, and only
38
+ * when they have something to say. `"session"` earns a tab, because it keeps
39
+ * reading while you move between requests: the list itself, and a plugin that
40
+ * owns live browser state.
41
+ */
42
+ scope: "request" | "session";
43
+ /**
44
+ * Show the live dot while connected. For the tab whose contents change on
45
+ * their own rather than only when you pick a different request.
46
+ */
47
+ live?: boolean;
48
+ /**
49
+ * Redraw whenever anything in the store moved, not only when the selected
50
+ * trace changed. The default is the cheaper one: a tab that reads one trace is
51
+ * redrawn when that trace changes and left alone otherwise.
52
+ */
53
+ volatile?: boolean;
54
+ /**
55
+ * Render even when nothing is selected. Only the request list has anything to
56
+ * say before the first request arrives.
57
+ */
58
+ standsAlone?: boolean;
59
+ badge?(ctx: TabContext): TabBadge | undefined;
60
+ render(host: HTMLElement, ctx: TabContext): void;
61
+ /**
62
+ * Redraw for a scroll of the content host, without a store change.
63
+ *
64
+ * Only a tab that windows its rows needs this — for everything else the
65
+ * browser scrolls what is already drawn.
66
+ */
67
+ onScroll?(host: HTMLElement, ctx: TabContext): void;
68
+ }
@@ -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
+ }