omkit 0.8.15 → 0.8.17
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/README.md +413 -411
- package/dist/core/ActionRun.d.ts +4 -0
- package/dist/core/ActionRun.js +74 -7
- package/dist/core/refTrace.d.ts +22 -0
- package/dist/core/refTrace.js +71 -0
- package/dist/foundation/LogEntry.d.ts +21 -1
- package/dist/output/writers/report/report.css +493 -433
- package/dist/output/writers/report/report.js +537 -322
- package/package.json +1 -1
|
@@ -4,15 +4,15 @@
|
|
|
4
4
|
(function () {
|
|
5
5
|
"use strict";
|
|
6
6
|
|
|
7
|
+
const TIP_TAGS = 6; // tag chips a hover tooltip shows before "+N"
|
|
8
|
+
const TREE_TAGS = 4; // …and a timeline tree row
|
|
7
9
|
const ROW_H = 42;
|
|
8
10
|
const BAR_Y = 6;
|
|
9
11
|
const BAR_H = 20;
|
|
10
|
-
const FLAME_ROW = 26;
|
|
11
12
|
|
|
12
13
|
// ── icons (24×24, stroked) ───────────────────────────────
|
|
13
14
|
const ICON = {
|
|
14
15
|
timeline: "M3 17l5-5 4 4 8-8M3 21h18",
|
|
15
|
-
flame: "M12 3c1 4 6 6 6 11a6 6 0 0 1-12 0c0-3 2-4 2-7 2 1 3 3 3 5 1-2 1-6 1-9z",
|
|
16
16
|
flow: "M4 4h7v6H4zM13 14h7v6h-7zM7.5 10v4h9v0M16.5 14v-1",
|
|
17
17
|
zoomIn: "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4M11 8v6M8 11h6",
|
|
18
18
|
zoomOut: "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4M8 11h6",
|
|
@@ -43,7 +43,6 @@
|
|
|
43
43
|
info: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 11v5M12 8h.01",
|
|
44
44
|
locate: "M12 5a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM12 2v3M12 19v3M2 12h3M19 12h3M12 10.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z",
|
|
45
45
|
code: "M8 7l-5 5 5 5M16 7l5 5-5 5",
|
|
46
|
-
ret: "M9 10l-4 4 4 4M5 14h11a4 4 0 0 0 4-4V5",
|
|
47
46
|
external: "M14 4h6v6M20 4l-9 9M18 14v6H4V6h6",
|
|
48
47
|
};
|
|
49
48
|
const svgIcon = (name, extra) =>
|
|
@@ -64,6 +63,7 @@
|
|
|
64
63
|
cancel: { color: "--cancel", icon: "stop", label: "Cancelled" },
|
|
65
64
|
lifecycle: { color: "--c1", icon: "play", label: "Lifecycle" },
|
|
66
65
|
run: { color: "--text-3", icon: "root", label: "Run" },
|
|
66
|
+
link: { color: "--text-3", icon: "flow", label: "Link" },
|
|
67
67
|
};
|
|
68
68
|
|
|
69
69
|
// ── formatting ───────────────────────────────────────────
|
|
@@ -78,10 +78,6 @@
|
|
|
78
78
|
};
|
|
79
79
|
const fmtRel = (ms) => `+${fmtDur(Math.max(0, ms)).replace(/^0ms$/, "0ms")}`;
|
|
80
80
|
const pad2 = (n) => String(n).padStart(2, "0");
|
|
81
|
-
const fmtClock = (ts) => {
|
|
82
|
-
const d = new Date(ts);
|
|
83
|
-
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, "0")}`;
|
|
84
|
-
};
|
|
85
81
|
const fmtDate = (ts) => {
|
|
86
82
|
const d = new Date(ts);
|
|
87
83
|
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
|
|
@@ -161,7 +157,7 @@
|
|
|
161
157
|
items.push(item);
|
|
162
158
|
if (item.node) {
|
|
163
159
|
item.node.entries.push(item);
|
|
164
|
-
if (kind !== "info" && kind !== "lifecycle") item.node.marks.push(item);
|
|
160
|
+
if (kind !== "info" && kind !== "lifecycle" && kind !== "link") item.node.marks.push(item);
|
|
165
161
|
} else runEntries.push(item);
|
|
166
162
|
}
|
|
167
163
|
|
|
@@ -191,6 +187,15 @@
|
|
|
191
187
|
}
|
|
192
188
|
}
|
|
193
189
|
|
|
190
|
+
// cross-node interactions, recorded on their target: who waited on, cancelled, or called into whom
|
|
191
|
+
const links = [];
|
|
192
|
+
for (const it of items) {
|
|
193
|
+
const l = it.kind === "link" && it.node && it.e.link;
|
|
194
|
+
const from = l && byId.get(l.from);
|
|
195
|
+
if (!from) continue;
|
|
196
|
+
links.push({ item: it, op: l.op, from, to: it.node, t: it.t, t0: it.t - (l.ms || 0), key: l.key || "", ms: l.ms, outcome: l.outcome || "", site: l.site || "" });
|
|
197
|
+
}
|
|
198
|
+
|
|
194
199
|
const asserts = items.filter((i) => i.kind === "pass" || i.kind === "fail");
|
|
195
200
|
const errors = collectErrors(items, nodes, run, tEnd - t0);
|
|
196
201
|
const errorsBy = new Map();
|
|
@@ -214,6 +219,7 @@
|
|
|
214
219
|
byId,
|
|
215
220
|
root: nodes[0],
|
|
216
221
|
items,
|
|
222
|
+
links,
|
|
217
223
|
runEntries,
|
|
218
224
|
gallery,
|
|
219
225
|
asserts,
|
|
@@ -275,6 +281,7 @@
|
|
|
275
281
|
return String(s).split("\n")[0];
|
|
276
282
|
}
|
|
277
283
|
function kindOf(e) {
|
|
284
|
+
if (e.level === "link") return "link";
|
|
278
285
|
if (e.level === "assert") return e.message.startsWith("⊭") ? "fail" : "pass";
|
|
279
286
|
if (e.level === "snapshot") return "snapshot";
|
|
280
287
|
if (e.level === "artifact") return "artifact";
|
|
@@ -311,7 +318,7 @@
|
|
|
311
318
|
selected: model.root.id,
|
|
312
319
|
collapsed: new Set(),
|
|
313
320
|
zoom: d3.zoomIdentity,
|
|
314
|
-
|
|
321
|
+
detailOpen: new Set(["errors"]), // details-panel sections left open
|
|
315
322
|
errFilter: "all",
|
|
316
323
|
errHl: false,
|
|
317
324
|
errOpen: new Set(),
|
|
@@ -332,7 +339,6 @@
|
|
|
332
339
|
body: app.querySelector(".tl-body"),
|
|
333
340
|
tree: app.querySelector(".tree"),
|
|
334
341
|
chart: app.querySelector(".chart"),
|
|
335
|
-
flame: app.querySelector(".flame-view"),
|
|
336
342
|
flow: app.querySelector(".flow-view"),
|
|
337
343
|
details: app.querySelector(".details"),
|
|
338
344
|
lower: app.querySelector(".lower"),
|
|
@@ -345,10 +351,9 @@
|
|
|
345
351
|
tip.hidden = true;
|
|
346
352
|
document.body.appendChild(tip);
|
|
347
353
|
|
|
348
|
-
let W = 600; // chart pixel width
|
|
354
|
+
let W = 600; // chart pixel width
|
|
349
355
|
let baseX = d3.scaleLinear();
|
|
350
356
|
const xz = () => state.zoom.rescaleX(baseX);
|
|
351
|
-
let syncing = false;
|
|
352
357
|
|
|
353
358
|
// ── header controls ──
|
|
354
359
|
app.querySelectorAll("[data-view]").forEach((b) =>
|
|
@@ -371,6 +376,18 @@
|
|
|
371
376
|
info.addEventListener("mousemove", (ev) => moveTip(ev));
|
|
372
377
|
info.addEventListener("mouseleave", hideTip);
|
|
373
378
|
info.addEventListener("click", () => select(model.root.id));
|
|
379
|
+
const legendBtn = app.querySelector("#legend-btn");
|
|
380
|
+
const legend = app.querySelector("#legend");
|
|
381
|
+
const toggleLegend = (open) => {
|
|
382
|
+
legend.hidden = !open;
|
|
383
|
+
legendBtn.setAttribute("aria-expanded", String(open));
|
|
384
|
+
legendBtn.classList.toggle("active", open);
|
|
385
|
+
};
|
|
386
|
+
legendBtn.addEventListener("click", () => toggleLegend(legend.hidden));
|
|
387
|
+
document.addEventListener("mousedown", (e) => {
|
|
388
|
+
if (!legend.hidden && !legend.contains(e.target) && !legendBtn.contains(e.target)) toggleLegend(false);
|
|
389
|
+
});
|
|
390
|
+
document.addEventListener("keydown", (e) => e.key === "Escape" && !legend.hidden && toggleLegend(false));
|
|
374
391
|
|
|
375
392
|
// ── zoom behaviours ──
|
|
376
393
|
const chartZoom = d3
|
|
@@ -378,28 +395,13 @@
|
|
|
378
395
|
.scaleExtent([1, 5000])
|
|
379
396
|
.filter((e) => (e.type === "wheel" ? e.ctrlKey || e.metaKey : !e.button && e.type !== "dblclick"))
|
|
380
397
|
.on("zoom", (e) => onZoom(e.transform));
|
|
381
|
-
const flameZoom = d3
|
|
382
|
-
.zoom()
|
|
383
|
-
.scaleExtent([1, 5000])
|
|
384
|
-
.filter((e) => (e.type === "wheel" ? e.ctrlKey || e.metaKey : !e.button && e.type !== "dblclick"))
|
|
385
|
-
.on("zoom", (e) => onZoom(e.transform));
|
|
386
398
|
const flowZoom = d3.zoom().scaleExtent([0.1, 4]).on("zoom", (e) => flowG && flowG.attr("transform", e.transform));
|
|
387
399
|
|
|
388
400
|
function activeZoom() {
|
|
389
|
-
return
|
|
390
|
-
? { z: flameZoom, sel: d3.select(el.flame).select("svg") }
|
|
391
|
-
: { z: chartZoom, sel: d3.select(el.chart).select("svg") };
|
|
401
|
+
return { z: chartZoom, sel: d3.select(el.chart).select("svg") };
|
|
392
402
|
}
|
|
393
403
|
function onZoom(t) {
|
|
394
404
|
state.zoom = t;
|
|
395
|
-
if (!syncing) {
|
|
396
|
-
syncing = true;
|
|
397
|
-
// keep the other x-view's internal transform in step
|
|
398
|
-
const other = state.view === "flame" ? d3.select(el.chart).select("svg") : d3.select(el.flame).select("svg");
|
|
399
|
-
const oz = state.view === "flame" ? chartZoom : flameZoom;
|
|
400
|
-
if (!other.empty()) other.call(oz.transform, t);
|
|
401
|
-
syncing = false;
|
|
402
|
-
}
|
|
403
405
|
redrawX();
|
|
404
406
|
}
|
|
405
407
|
function zoomBy(k) {
|
|
@@ -427,19 +429,18 @@
|
|
|
427
429
|
function measure() {
|
|
428
430
|
const sb = el.body.offsetWidth - el.body.clientWidth;
|
|
429
431
|
el.head.style.paddingRight = `${sb}px`;
|
|
430
|
-
W = Math.max(200,
|
|
432
|
+
W = Math.max(200, el.chart.clientWidth);
|
|
431
433
|
const span = model.span;
|
|
432
434
|
baseX = d3.scaleLinear().domain([-span * 0.01, span * 1.015]).range([0, W]);
|
|
433
|
-
|
|
435
|
+
chartZoom.translateExtent([[0, -1e6], [W, 1e6]]).extent([[0, 0], [W, 1]]);
|
|
434
436
|
}
|
|
435
437
|
|
|
436
438
|
function setView(v) {
|
|
437
439
|
state.view = v;
|
|
438
440
|
app.querySelectorAll("[data-view]").forEach((b) => b.classList.toggle("active", b.dataset.view === v));
|
|
439
|
-
el.timeline.classList.toggle("no-tree", v === "flame");
|
|
440
441
|
el.body.hidden = v !== "timeline";
|
|
441
442
|
el.head.hidden = v === "flow";
|
|
442
|
-
el.
|
|
443
|
+
el.timeline.classList.toggle("is-flow", v === "flow");
|
|
443
444
|
el.flow.hidden = v !== "flow";
|
|
444
445
|
el.windowSel.disabled = v === "flow";
|
|
445
446
|
renderAll();
|
|
@@ -479,17 +480,17 @@
|
|
|
479
480
|
? `<button class="twist ${open ? "open" : ""}" title="${open ? "Collapse" : "Expand"}">${svgIcon("chevron")}</button>`
|
|
480
481
|
: '<span class="twist"></span>';
|
|
481
482
|
const color = colorOf(n);
|
|
482
|
-
const
|
|
483
|
-
const tags = rest.length ? `<span class="tagchip">${esc(rest.join(" · "))}</span>` : "";
|
|
483
|
+
const tags = chipsHtml(n.v.tags, TREE_TAGS, 18);
|
|
484
484
|
return `<div class="row ${state.selected === n.id ? "selected" : ""}" data-id="${esc(n.id)}" title="${esc(n.v.path)}">
|
|
485
485
|
<div class="name" style="padding-left:${n.depth ? 4 : 10}px">${guides}${twist}
|
|
486
486
|
<span class="icon" style="color:${color};background:${alpha(color, 0.16)}">${svgIcon(iconOf(n))}</span>
|
|
487
|
-
<span class="
|
|
488
|
-
|
|
489
|
-
|
|
487
|
+
<span class="lines">
|
|
488
|
+
<span class="l1"><span class="label">${labelHtml(n)}</span>${
|
|
489
|
+
model.errorsBy.get(n) ? `<span class="errbadge" title="${model.errorsBy.get(n)} error${model.errorsBy.get(n) > 1 ? "s" : ""}">${model.errorsBy.get(n)}</span>` : ""
|
|
490
|
+
}</span>
|
|
491
|
+
<span class="l2">${tags}<span class="meta"><span class="st"><span class="dot ${n.status}"></span>${esc(n.status)}</span><span class="sep">·</span><span class="du">${fmtDur(n.duration)}</span></span></span>
|
|
492
|
+
</span>
|
|
490
493
|
</div>
|
|
491
|
-
<div class="status"><span class="dot ${n.status}"></span>${esc(n.status)}</div>
|
|
492
|
-
<div class="dur">${fmtDur(n.duration)}</div>
|
|
493
494
|
</div>`;
|
|
494
495
|
}
|
|
495
496
|
function toggle(n) {
|
|
@@ -514,7 +515,21 @@
|
|
|
514
515
|
.on("mouseleave", () => moveCursor(null));
|
|
515
516
|
chartSvg.append("g").attr("class", "grid");
|
|
516
517
|
chartSvg.append("g").attr("class", "links");
|
|
518
|
+
chartSvg
|
|
519
|
+
.append("defs")
|
|
520
|
+
.append("marker")
|
|
521
|
+
.attr("id", "xl-arrow")
|
|
522
|
+
.attr("viewBox", "0 0 10 10")
|
|
523
|
+
.attr("refX", 9)
|
|
524
|
+
.attr("refY", 5)
|
|
525
|
+
.attr("markerWidth", 6)
|
|
526
|
+
.attr("markerHeight", 6)
|
|
527
|
+
.attr("orient", "auto")
|
|
528
|
+
.append("path")
|
|
529
|
+
.attr("d", "M0,1 L10,5 L0,9 z");
|
|
517
530
|
chartSvg.append("g").attr("class", "rows");
|
|
531
|
+
// over the rows: their hover targets would otherwise swallow the link strips
|
|
532
|
+
chartSvg.append("g").attr("class", "xlinks");
|
|
518
533
|
chartSvg.append("line").attr("class", "pin").attr("stroke", css("--accent")).attr("stroke-width", 1.5).attr("y1", 0).attr("y2", H);
|
|
519
534
|
chartSvg.append("line").attr("class", "cursor").attr("y1", 0).attr("y2", H).attr("visibility", "hidden");
|
|
520
535
|
chartSvg.call(chartZoom).call(chartZoom.transform, state.zoom);
|
|
@@ -527,7 +542,6 @@
|
|
|
527
542
|
updateWindowLabel(x);
|
|
528
543
|
updateOverviewBrush(x);
|
|
529
544
|
if (state.view === "timeline" && chartSvg) drawRows(x);
|
|
530
|
-
if (state.view === "flame") drawFlame(x);
|
|
531
545
|
}
|
|
532
546
|
|
|
533
547
|
function drawRows(x) {
|
|
@@ -575,9 +589,6 @@
|
|
|
575
589
|
const cb = r.append("g").attr("class", "code-btn src");
|
|
576
590
|
cb.append("circle").attr("r", 9);
|
|
577
591
|
cb.append("text").attr("text-anchor", "middle").attr("y", 3.5).text("<>");
|
|
578
|
-
const rb = r.append("g").attr("class", "code-btn ret");
|
|
579
|
-
rb.append("circle").attr("r", 9);
|
|
580
|
-
rb.append("path").attr("d", ICON.ret).attr("transform", "translate(-6,-6) scale(0.5)");
|
|
581
592
|
return r;
|
|
582
593
|
})
|
|
583
594
|
.attr("transform", (_n, i) => `translate(0,${i * ROW_H})`)
|
|
@@ -598,7 +609,7 @@
|
|
|
598
609
|
const t = d3.select(this);
|
|
599
610
|
const x0 = x(n.start);
|
|
600
611
|
const w = x(n.end) - x0;
|
|
601
|
-
const text = `${
|
|
612
|
+
const text = `${boxLabel(n)} (${fmtDur(n.duration)})`;
|
|
602
613
|
const need = text.length * 6.6 + 14;
|
|
603
614
|
const inside = w >= need;
|
|
604
615
|
const clipX = Math.max(x0, 0);
|
|
@@ -617,43 +628,115 @@
|
|
|
617
628
|
// …and, for a block spanning the chart, inside it right after its label
|
|
618
629
|
const cx =
|
|
619
630
|
right + 8 <= W - 10 ? right + 8 : left - 8 >= 10 ? left - 8 : Math.min(W - 12, (flip ? x0 : lx + tw) + 14);
|
|
620
|
-
|
|
621
|
-
const dir = right + 8 <= W - 10 || left - 8 < 10 ? 1 : -1;
|
|
622
|
-
const src = hasCode(n);
|
|
623
|
-
const row = d3.select(this.parentNode);
|
|
624
|
-
row
|
|
631
|
+
d3.select(this.parentNode)
|
|
625
632
|
.select("g.code-btn.src")
|
|
626
|
-
.style("display",
|
|
633
|
+
.style("display", hasPopup(n) ? null : "none")
|
|
627
634
|
.attr("transform", `translate(${cx},${BAR_Y + BAR_H / 2})`);
|
|
628
|
-
row
|
|
629
|
-
.select("g.code-btn.ret")
|
|
630
|
-
.style("display", hasValue(n) ? null : "none")
|
|
631
|
-
.attr("transform", `translate(${src ? cx + dir * 21 : cx},${BAR_Y + BAR_H / 2})`);
|
|
632
635
|
});
|
|
633
636
|
g.select("g.code-btn.src")
|
|
634
637
|
.on("click", (ev, n) => {
|
|
635
638
|
ev.stopPropagation();
|
|
636
639
|
toggleCode(n, ev);
|
|
637
640
|
})
|
|
638
|
-
.on("mouseenter", (ev) => showTip(ev,
|
|
639
|
-
.on("mouseleave", hideTip);
|
|
640
|
-
g.select("g.code-btn.ret")
|
|
641
|
-
.on("click", (ev, n) => {
|
|
642
|
-
ev.stopPropagation();
|
|
643
|
-
toggleCode(n, ev, "value", true);
|
|
644
|
-
})
|
|
645
|
-
.on("mouseenter", (ev, n) => showTip(ev, `Returned: <span class="mono">${esc(valuePreview(n, 80))}</span>`))
|
|
641
|
+
.on("mouseenter", (ev, n) => showTip(ev, popupTip(n)))
|
|
646
642
|
.on("mouseleave", hideTip);
|
|
647
643
|
g.selectAll("rect.hit, text.bar-label").call(bindNode);
|
|
648
644
|
|
|
649
645
|
g.select("g.marks").each(function (n) {
|
|
650
646
|
drawMarks(d3.select(this), n, x);
|
|
651
647
|
});
|
|
648
|
+
drawLinks(x, index);
|
|
652
649
|
|
|
653
650
|
chartSvg.select("line.pin").attr("x1", state.pin != null ? x(state.pin) : -10).attr("x2", state.pin != null ? x(state.pin) : -10);
|
|
654
651
|
if (app.classList.contains("hl-on") || state.errHl) applyHl();
|
|
655
652
|
}
|
|
656
653
|
|
|
654
|
+
/** The visible row standing in for `n`: its own, or its nearest expanded ancestor's. */
|
|
655
|
+
function rowIndexOf(n, index) {
|
|
656
|
+
for (let p = n; p; p = p.parent) if (index.has(p.id)) return index.get(p.id);
|
|
657
|
+
return -1;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Cross-node links on the timeline. A strip under the caller's bar holds its waits
|
|
661
|
+
* (`once`, dashed) and its calls on another action's ref (solid), in the target's colour;
|
|
662
|
+
* a dashed connector joins the two rows where a wait ended (target → waiter) and where a
|
|
663
|
+
* cancel landed (canceller → target).
|
|
664
|
+
*/
|
|
665
|
+
function drawLinks(x, index) {
|
|
666
|
+
const STRIP_Y = BAR_Y + BAR_H + 3;
|
|
667
|
+
const spans = [];
|
|
668
|
+
const wires = [];
|
|
669
|
+
for (const l of model.links) {
|
|
670
|
+
const fi = rowIndexOf(l.from, index);
|
|
671
|
+
const ti = rowIndexOf(l.to, index);
|
|
672
|
+
if (fi < 0) continue;
|
|
673
|
+
if (l.op !== "cancel") spans.push({ l, fi });
|
|
674
|
+
if (l.op !== "ref" && ti >= 0 && ti !== fi) wires.push({ l, fi, ti });
|
|
675
|
+
}
|
|
676
|
+
const layer = chartSvg.select(".xlinks");
|
|
677
|
+
layer
|
|
678
|
+
.selectAll("line.xl-span")
|
|
679
|
+
.data(spans)
|
|
680
|
+
.join("line")
|
|
681
|
+
.attr("class", (d) => `xl-span ${d.l.op}`)
|
|
682
|
+
.attr("x1", (d) => x(d.l.t0))
|
|
683
|
+
.attr("x2", (d) => Math.max(x(d.l.t), x(d.l.t0) + 2))
|
|
684
|
+
.attr("y1", (d) => d.fi * ROW_H + STRIP_Y)
|
|
685
|
+
.attr("y2", (d) => d.fi * ROW_H + STRIP_Y)
|
|
686
|
+
.attr("stroke", (d) => colorOf(d.l.to))
|
|
687
|
+
.call(bindLink);
|
|
688
|
+
const wirePath = (d) => {
|
|
689
|
+
const px = x(d.l.t);
|
|
690
|
+
const fy = d.fi * ROW_H + STRIP_Y;
|
|
691
|
+
const ty = d.ti * ROW_H + BAR_Y + BAR_H / 2;
|
|
692
|
+
const [y1, y2] = d.l.op === "once" ? [ty, fy] : [fy, ty];
|
|
693
|
+
return `M${px},${y1} C${px + 10},${(2 * y1 + y2) / 3} ${px + 10},${(y1 + 2 * y2) / 3} ${px},${y2}`;
|
|
694
|
+
};
|
|
695
|
+
layer
|
|
696
|
+
.selectAll("path.xl-wire")
|
|
697
|
+
.data(wires)
|
|
698
|
+
.join("path")
|
|
699
|
+
.attr("class", (d) => `xl-wire ${d.l.op}`)
|
|
700
|
+
.attr("d", wirePath)
|
|
701
|
+
.attr("stroke", (d) => (d.l.op === "cancel" ? css("--cancel") : colorOf(d.l.to)))
|
|
702
|
+
.attr("marker-end", "url(#xl-arrow)");
|
|
703
|
+
// a wide transparent twin over each wire, so a 1px dashed line is easy to hover
|
|
704
|
+
layer.selectAll("path.xl-hit").data(wires).join("path").attr("class", "xl-hit").attr("d", wirePath).call(bindLink);
|
|
705
|
+
}
|
|
706
|
+
function bindLink(sel) {
|
|
707
|
+
sel
|
|
708
|
+
.on("click", (ev, d) => {
|
|
709
|
+
ev.stopPropagation();
|
|
710
|
+
setPin(d.l.t);
|
|
711
|
+
select(d.l.from.id, "links");
|
|
712
|
+
})
|
|
713
|
+
.on("mouseenter", (ev, d) => showTip(ev, linkTip(d.l), true))
|
|
714
|
+
.on("mousemove", (ev) => moveTip(ev))
|
|
715
|
+
.on("mouseleave", hideTip);
|
|
716
|
+
}
|
|
717
|
+
/** A link in words: who did what to whom, how it ended, and the line that did it. */
|
|
718
|
+
function linkTip(l, count) {
|
|
719
|
+
const from = esc(displayName(l.from));
|
|
720
|
+
const to = esc(displayName(l.to));
|
|
721
|
+
const title = l.op === "once" ? `once · ${esc(l.key)}` : l.op === "cancel" ? "cancel" : `ref · ${esc(l.key)}()`;
|
|
722
|
+
const what =
|
|
723
|
+
l.op === "once"
|
|
724
|
+
? `<b>${from}</b> waited for <b>${esc(l.key)}</b> on <b>${to}</b>`
|
|
725
|
+
: l.op === "cancel"
|
|
726
|
+
? `<b>${from}</b> cancelled <b>${to}</b>`
|
|
727
|
+
: `<b>${from}</b> called <b>${esc(l.key)}()</b> on <b>${to}</b>'s ref`;
|
|
728
|
+
const meta = [
|
|
729
|
+
count > 1 ? `×${count}` : "",
|
|
730
|
+
l.outcome ? esc(l.outcome) : "",
|
|
731
|
+
l.ms != null ? (l.op === "once" ? `after ${fmtDur(l.ms)}` : fmtDur(l.ms)) : "",
|
|
732
|
+
fmtRel(l.t),
|
|
733
|
+
].filter(Boolean);
|
|
734
|
+
const line = (/:(\d+)$/.exec(l.site) || [])[1];
|
|
735
|
+
return `<div class="tt">${title}</div><div class="summary">${what}</div>
|
|
736
|
+
<div class="tmeta">${meta.join('<span class="sep">·</span>')}</div>
|
|
737
|
+
${l.site ? `<div class="mono small" style="margin-top:6px">${esc(relOf(l.site))}${line ? ":" + line : ""}</div>` : ""}`;
|
|
738
|
+
}
|
|
739
|
+
|
|
657
740
|
function drawMarks(g, n, x) {
|
|
658
741
|
const cy = BAR_Y + BAR_H / 2;
|
|
659
742
|
// cluster marks closer than 4px so a chatty node draws a readable line of dots
|
|
@@ -721,7 +804,7 @@
|
|
|
721
804
|
function updateAxis(x) {
|
|
722
805
|
const svg = d3.select(el.axis);
|
|
723
806
|
if (state.view === "flow") return;
|
|
724
|
-
const w =
|
|
807
|
+
const w = el.chart.clientWidth || W;
|
|
725
808
|
svg.attr("viewBox", null);
|
|
726
809
|
const g = svg.selectAll("g.axis").data([0]).join("g").attr("class", "axis").attr("transform", "translate(0,33)");
|
|
727
810
|
g.call(d3.axisTop(x).ticks(Math.max(2, Math.floor(w / 90))).tickFormat(fmtTick).tickSizeOuter(0));
|
|
@@ -729,9 +812,8 @@
|
|
|
729
812
|
svg.selectAll("text.cursor-t").data([0]).join("text").attr("class", "cursor-t").attr("y", 12).attr("fill", css("--accent")).attr("font-size", 11);
|
|
730
813
|
}
|
|
731
814
|
function moveCursor(px) {
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
s.select("line.cursor").attr("visibility", px == null ? "hidden" : "visible").attr("x1", px).attr("x2", px);
|
|
815
|
+
if (chartSvg) {
|
|
816
|
+
chartSvg.select("line.cursor").attr("visibility", px == null ? "hidden" : "visible").attr("x1", px).attr("x2", px);
|
|
735
817
|
}
|
|
736
818
|
const label = d3.select(el.axis).select("text.cursor-t");
|
|
737
819
|
if (px == null) label.text("");
|
|
@@ -748,91 +830,6 @@
|
|
|
748
830
|
el.windowSel.value = "0";
|
|
749
831
|
}
|
|
750
832
|
|
|
751
|
-
// ── flame view ──
|
|
752
|
-
let flameSvg = null;
|
|
753
|
-
/**
|
|
754
|
-
* A row per node, packed: a node goes on the first row below its parent's that nothing
|
|
755
|
-
* already placed there overlaps in time. Plain depth rows hid every step that ran beside a
|
|
756
|
-
* long-lived sibling (a daemon, a server) — they shared a row and were drawn over.
|
|
757
|
-
*/
|
|
758
|
-
let flameRows = null;
|
|
759
|
-
function packFlame() {
|
|
760
|
-
const rows = []; // rows[r] = [[start, end], …] already taken
|
|
761
|
-
const out = new Map();
|
|
762
|
-
const free = (r, a, b) => !(rows[r] || []).some(([s, e]) => a < e && s < b);
|
|
763
|
-
(function place(n, parentRow) {
|
|
764
|
-
let r = parentRow + 1;
|
|
765
|
-
// a zero-length node still needs a sliver of time to claim, or it would stack on anything
|
|
766
|
-
const a = n.start;
|
|
767
|
-
const b = Math.max(n.end, n.start + model.span / 2000);
|
|
768
|
-
while (!free(r, a, b)) r++;
|
|
769
|
-
(rows[r] = rows[r] || []).push([a, b]);
|
|
770
|
-
out.set(n.id, r);
|
|
771
|
-
[...n.children].sort((p, q) => p.start - q.start).forEach((c) => place(c, r));
|
|
772
|
-
})(model.root, -1);
|
|
773
|
-
return { rowOf: out, count: rows.length };
|
|
774
|
-
}
|
|
775
|
-
function renderFlame() {
|
|
776
|
-
d3.select(el.flame).selectAll("svg").remove();
|
|
777
|
-
flameRows = flameRows || packFlame();
|
|
778
|
-
flameSvg = d3
|
|
779
|
-
.select(el.flame)
|
|
780
|
-
.append("svg")
|
|
781
|
-
.style("height", `${Math.max(el.flame.clientHeight, 16 + flameRows.count * FLAME_ROW)}px`)
|
|
782
|
-
.on("mousemove", (ev) => moveCursor(d3.pointer(ev)[0]))
|
|
783
|
-
.on("mouseleave", () => moveCursor(null));
|
|
784
|
-
flameSvg.append("g").attr("class", "grid");
|
|
785
|
-
flameSvg.append("g").attr("class", "cells");
|
|
786
|
-
flameSvg.append("line").attr("class", "pin").attr("stroke", css("--accent")).attr("stroke-width", 1.5).attr("y1", 0).attr("y2", 4000);
|
|
787
|
-
flameSvg.append("line").attr("class", "cursor").attr("y1", 0).attr("y2", 4000).attr("visibility", "hidden");
|
|
788
|
-
flameSvg.call(flameZoom).call(flameZoom.transform, state.zoom);
|
|
789
|
-
redrawX();
|
|
790
|
-
}
|
|
791
|
-
function drawFlame(x) {
|
|
792
|
-
if (!flameSvg) return;
|
|
793
|
-
const px = state.pin != null ? x(state.pin) : -10;
|
|
794
|
-
queueMicrotask(() => (app.classList.contains("hl-on") || state.errHl) && applyHl());
|
|
795
|
-
flameSvg.select("line.pin").attr("x1", px).attr("x2", px);
|
|
796
|
-
const H = el.flame.clientHeight;
|
|
797
|
-
const ticks = x.ticks(Math.max(2, Math.floor(W / 90)));
|
|
798
|
-
flameSvg
|
|
799
|
-
.select(".grid")
|
|
800
|
-
.selectAll("line")
|
|
801
|
-
.data(ticks)
|
|
802
|
-
.join("line")
|
|
803
|
-
.attr("x1", (d) => x(d))
|
|
804
|
-
.attr("x2", (d) => x(d))
|
|
805
|
-
.attr("y1", 0)
|
|
806
|
-
.attr("y2", H);
|
|
807
|
-
const cells = flameSvg
|
|
808
|
-
.select(".cells")
|
|
809
|
-
.selectAll("g.cell")
|
|
810
|
-
.data(model.nodes, (n) => n.id)
|
|
811
|
-
.join((enter) => {
|
|
812
|
-
const g = enter.append("g").attr("class", "cell");
|
|
813
|
-
g.append("rect").attr("class", "flame-rect").attr("rx", 3).attr("height", FLAME_ROW - 2);
|
|
814
|
-
g.append("text").attr("class", "flame-text").attr("y", FLAME_ROW / 2 + 3);
|
|
815
|
-
return g;
|
|
816
|
-
})
|
|
817
|
-
.attr("transform", (n) => `translate(0,${8 + flameRows.rowOf.get(n.id) * FLAME_ROW})`);
|
|
818
|
-
cells
|
|
819
|
-
.select("rect")
|
|
820
|
-
.attr("x", (n) => x(n.start))
|
|
821
|
-
.attr("width", (n) => Math.max(1, x(n.end) - x(n.start)))
|
|
822
|
-
.attr("fill", (n) => colorOf(n))
|
|
823
|
-
.attr("fill-opacity", (n) => (n.status === "cancelled" ? 0.55 : 0.92))
|
|
824
|
-
.classed("selected", (n) => n.id === state.selected)
|
|
825
|
-
.call(bindNode);
|
|
826
|
-
cells
|
|
827
|
-
.select("text")
|
|
828
|
-
.attr("x", (n) => Math.max(x(n.start), 0) + 5)
|
|
829
|
-
.text((n) => {
|
|
830
|
-
const w = x(n.end) - Math.max(x(n.start), 0);
|
|
831
|
-
return w < 34 ? "" : truncate(`${displayName(n)} ${fmtDur(n.duration)}`, (w - 10) / 6.2);
|
|
832
|
-
})
|
|
833
|
-
.call(bindNode);
|
|
834
|
-
}
|
|
835
|
-
|
|
836
833
|
// ── flow view ──
|
|
837
834
|
//
|
|
838
835
|
// Each node's children form one *lane*: a column to its right, in start order, joined by
|
|
@@ -842,7 +839,19 @@
|
|
|
842
839
|
let flowG = null;
|
|
843
840
|
let flowPos = new Map();
|
|
844
841
|
let flowBounds = null;
|
|
845
|
-
const FLOW = { CW: 250, CH: 58, GX: 64, GY: 26, LANE_DX: 22, TW: 46, TH: 28 };
|
|
842
|
+
const FLOW = { CW: 250, CH: 58, TAGS_H: 18, GX: 64, GY: 26, LANE_DX: 22, TW: 46, TH: 28 };
|
|
843
|
+
/** A tag chip in SVG, matching the HTML `.chip`; returns its group and width. */
|
|
844
|
+
function svgChip(parent, x, label, color) {
|
|
845
|
+
const g = parent.append("g").attr("class", "tchip").attr("transform", `translate(${x},0)`);
|
|
846
|
+
const rect = g.append("rect").attr("height", 16).attr("rx", 4);
|
|
847
|
+
const text = g.append("text").attr("x", 6).attr("y", 11.5).text(label);
|
|
848
|
+
const w = (text.node().getComputedTextLength() || label.length * 6) + 12;
|
|
849
|
+
rect
|
|
850
|
+
.attr("width", w)
|
|
851
|
+
.attr("fill", color ? alpha(color, 0.14) : "none")
|
|
852
|
+
.attr("stroke", color ? alpha(color, 0.45) : css("--line"));
|
|
853
|
+
return { g, w };
|
|
854
|
+
}
|
|
846
855
|
function renderFlow() {
|
|
847
856
|
d3.select(el.flow).selectAll("svg").remove();
|
|
848
857
|
const svg = d3.select(el.flow).append("svg");
|
|
@@ -860,7 +869,9 @@
|
|
|
860
869
|
.attr("class", "flow-arrowhead")
|
|
861
870
|
.attr("d", "M0,1 L10,5 L0,9 z");
|
|
862
871
|
flowG = svg.append("g");
|
|
863
|
-
const { CW,
|
|
872
|
+
const { CW, GX, GY, LANE_DX, TW, TH } = FLOW;
|
|
873
|
+
// every card gets the tag row when any is tagged, so lanes stay on one rhythm
|
|
874
|
+
const CH = FLOW.CH + (model.nodes.some((n) => n.v.tags && n.v.tags.length) ? FLOW.TAGS_H : 0);
|
|
864
875
|
|
|
865
876
|
const shotsBy = new Map();
|
|
866
877
|
for (const g of model.gallery) {
|
|
@@ -954,6 +965,70 @@
|
|
|
954
965
|
}
|
|
955
966
|
|
|
956
967
|
const nodes = [...pos.keys()].map((id) => model.byId.get(id));
|
|
968
|
+
// cross-node links: one curve per (caller, target, kind), arcing out past the cards' right
|
|
969
|
+
// edges; drawn first so the cards sit on top of their ends
|
|
970
|
+
const agg = new Map();
|
|
971
|
+
for (const l of model.links) {
|
|
972
|
+
if (!pos.has(l.from.id) || !pos.has(l.to.id) || l.from === l.to) continue;
|
|
973
|
+
const k = `${l.from.id}\0${l.to.id}\0${l.op}`;
|
|
974
|
+
if (!agg.has(k)) agg.set(k, { op: l.op, from: l.from, to: l.to, list: [] });
|
|
975
|
+
agg.get(k).list.push(l);
|
|
976
|
+
}
|
|
977
|
+
const xl = flowG.append("g").attr("class", "flow-xlinks");
|
|
978
|
+
const fan = new Map(); // edges between the same two cards fan out instead of overlapping
|
|
979
|
+
for (const a of agg.values()) {
|
|
980
|
+
const f = pos.get(a.from.id);
|
|
981
|
+
const t = pos.get(a.to.id);
|
|
982
|
+
const [s, e] = a.op === "once" ? [t, f] : [f, t];
|
|
983
|
+
const pair = [a.from.id, a.to.id].sort().join("");
|
|
984
|
+
const k = fan.get(pair) || 0;
|
|
985
|
+
fan.set(pair, k + 1);
|
|
986
|
+
const off = (k % 2 ? 1 : -1) * Math.ceil(k / 2) * 10;
|
|
987
|
+
let x1, y1, x2, y2, path, lx, ly;
|
|
988
|
+
if (Math.abs(s.x - e.x) < CW) {
|
|
989
|
+
// same column: arc out past the right edges
|
|
990
|
+
x1 = s.x + CW;
|
|
991
|
+
x2 = e.x + CW;
|
|
992
|
+
y1 = s.y + CH / 2 + off;
|
|
993
|
+
y2 = e.y + CH / 2 + off;
|
|
994
|
+
const bulge = 36 + Math.min(140, Math.abs(y2 - y1) * 0.2) + Math.abs(off);
|
|
995
|
+
path = `M${x1},${y1} C${x1 + bulge},${y1} ${x2 + bulge},${y2} ${x2 + 2},${y2}`;
|
|
996
|
+
lx = (x1 + x2) / 2 + bulge * 0.75 + 4;
|
|
997
|
+
ly = (y1 + y2) / 2 + 4;
|
|
998
|
+
} else {
|
|
999
|
+
// different columns: facing sides, an S-curve between them, below the tree's own links
|
|
1000
|
+
const rightward = e.x > s.x;
|
|
1001
|
+
x1 = rightward ? s.x + CW : s.x;
|
|
1002
|
+
x2 = rightward ? e.x : e.x + CW;
|
|
1003
|
+
y1 = s.y + CH * 0.72 + off;
|
|
1004
|
+
y2 = e.y + CH * 0.72 + off;
|
|
1005
|
+
const mx = (x1 + x2) / 2;
|
|
1006
|
+
path = `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2 + (rightward ? -2 : 2)},${y2}`;
|
|
1007
|
+
lx = mx + 4;
|
|
1008
|
+
ly = (y1 + y2) / 2 - 4;
|
|
1009
|
+
}
|
|
1010
|
+
const keys = [...new Set(a.list.map((l) => l.key))];
|
|
1011
|
+
const label =
|
|
1012
|
+
a.op === "cancel" ? "cancel" : a.op === "once" ? `once ${keys.join(", ")}` : keys.length === 1 ? `${keys[0]}() ×${a.list.length}` : `ref ×${a.list.length}`;
|
|
1013
|
+
const g = xl
|
|
1014
|
+
.append("g")
|
|
1015
|
+
.attr("class", `flow-xlink ${a.op}`)
|
|
1016
|
+
.on("click", (ev) => {
|
|
1017
|
+
ev.stopPropagation();
|
|
1018
|
+
select(a.from.id, "links");
|
|
1019
|
+
})
|
|
1020
|
+
.on("mouseenter", (ev) => showTip(ev, linkTip(a.list[a.list.length - 1], a.list.length), true))
|
|
1021
|
+
.on("mousemove", (ev) => moveTip(ev))
|
|
1022
|
+
.on("mouseleave", hideTip);
|
|
1023
|
+
g.append("path")
|
|
1024
|
+
.attr("d", path)
|
|
1025
|
+
.attr("stroke", a.op === "cancel" ? css("--cancel") : colorOf(a.to))
|
|
1026
|
+
.attr("marker-end", "url(#flow-arrow)");
|
|
1027
|
+
g.append("path").attr("class", "hit").attr("d", path);
|
|
1028
|
+
// between neighbouring columns there is no room for words — the tooltip carries them
|
|
1029
|
+
if (Math.abs(x2 - x1) >= 90) g.append("text").attr("x", lx).attr("y", ly).text(truncate(label, 28));
|
|
1030
|
+
}
|
|
1031
|
+
|
|
957
1032
|
const card = flowG
|
|
958
1033
|
.append("g")
|
|
959
1034
|
.selectAll("g.flow-card")
|
|
@@ -988,7 +1063,7 @@
|
|
|
988
1063
|
.attr("color", (n) => colorOf(n))
|
|
989
1064
|
.html((n) => `<path d="${ICON[iconOf(n)]}" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>`);
|
|
990
1065
|
card.each(function (n) {
|
|
991
|
-
const lines = wrapName(
|
|
1066
|
+
const lines = wrapName(boxLabel(n), 31);
|
|
992
1067
|
const t = d3.select(this);
|
|
993
1068
|
lines.forEach((line, i) =>
|
|
994
1069
|
t
|
|
@@ -1003,6 +1078,21 @@
|
|
|
1003
1078
|
.attr("x", 42)
|
|
1004
1079
|
.attr("y", lines.length > 1 ? 49 : 41)
|
|
1005
1080
|
.text(`${nodeKind(n)} · ${n.status} · ${fmtDur(n.duration)} · +${fmtTick(n.start)}`);
|
|
1081
|
+
const tags = n.v.tags || [];
|
|
1082
|
+
if (!tags.length) return;
|
|
1083
|
+
const row = t.append("g").attr("class", "tchips").attr("transform", `translate(42,${lines.length > 1 ? 56 : 51})`);
|
|
1084
|
+
const room = CW - 42 - 10;
|
|
1085
|
+
let x = 0;
|
|
1086
|
+
for (let i = 0; i < tags.length; i++) {
|
|
1087
|
+
const c = svgChip(row, x, truncate(tags[i], 22), tagColor(tags[i]));
|
|
1088
|
+
// keep room for a "+N" after it, unless it is the last
|
|
1089
|
+
if (i > 0 && x + c.w > room - (i < tags.length - 1 ? 30 : 0)) {
|
|
1090
|
+
c.g.remove();
|
|
1091
|
+
svgChip(row, x, `+${tags.length - i}`, null).g.classed("more", true);
|
|
1092
|
+
break;
|
|
1093
|
+
}
|
|
1094
|
+
x += c.w + 4;
|
|
1095
|
+
}
|
|
1006
1096
|
});
|
|
1007
1097
|
card
|
|
1008
1098
|
.append("circle")
|
|
@@ -1011,7 +1101,7 @@
|
|
|
1011
1101
|
.attr("r", 4)
|
|
1012
1102
|
.attr("fill", (n) => css(statusVar(n.status)));
|
|
1013
1103
|
const fcb = card
|
|
1014
|
-
.filter((n) =>
|
|
1104
|
+
.filter((n) => hasPopup(n))
|
|
1015
1105
|
.append("g")
|
|
1016
1106
|
.attr("class", "code-btn")
|
|
1017
1107
|
.attr("transform", `translate(${CW / 2},0)`)
|
|
@@ -1019,27 +1109,12 @@
|
|
|
1019
1109
|
ev.stopPropagation();
|
|
1020
1110
|
toggleCode(n, ev);
|
|
1021
1111
|
})
|
|
1022
|
-
.on("mouseenter", (ev) => {
|
|
1112
|
+
.on("mouseenter", (ev, n) => {
|
|
1023
1113
|
ev.stopPropagation();
|
|
1024
|
-
showTip(ev,
|
|
1114
|
+
showTip(ev, popupTip(n));
|
|
1025
1115
|
});
|
|
1026
1116
|
fcb.append("circle").attr("r", 9);
|
|
1027
1117
|
fcb.append("text").attr("text-anchor", "middle").attr("y", 3.5).text("<>");
|
|
1028
|
-
const frb = card
|
|
1029
|
-
.filter((n) => hasValue(n))
|
|
1030
|
-
.append("g")
|
|
1031
|
-
.attr("class", "code-btn ret")
|
|
1032
|
-
.attr("transform", (n) => `translate(${CW / 2 + (hasCode(n) ? 22 : 0)},0)`)
|
|
1033
|
-
.on("click", (ev, n) => {
|
|
1034
|
-
ev.stopPropagation();
|
|
1035
|
-
toggleCode(n, ev, "value", true);
|
|
1036
|
-
})
|
|
1037
|
-
.on("mouseenter", (ev, n) => {
|
|
1038
|
-
ev.stopPropagation();
|
|
1039
|
-
showTip(ev, `Returned: <span class="mono">${esc(valuePreview(n, 80))}</span>`);
|
|
1040
|
-
});
|
|
1041
|
-
frb.append("circle").attr("r", 9);
|
|
1042
|
-
frb.append("path").attr("d", ICON.ret).attr("transform", "translate(-6,-6) scale(0.5)");
|
|
1043
1118
|
|
|
1044
1119
|
// snapshot chips — on lane arrows, and in strips under leaf cards
|
|
1045
1120
|
const chipLayer = flowG.append("g").attr("class", "flow-shots");
|
|
@@ -1182,7 +1257,7 @@
|
|
|
1182
1257
|
// ── selection + details ──
|
|
1183
1258
|
function select(id, tab) {
|
|
1184
1259
|
state.selected = id;
|
|
1185
|
-
if (tab) state.
|
|
1260
|
+
if (tab) state.detailOpen.add(tab);
|
|
1186
1261
|
state.details = true;
|
|
1187
1262
|
el.main.classList.remove("no-details");
|
|
1188
1263
|
el.tree.querySelectorAll(".row").forEach((r) => r.classList.toggle("selected", r.dataset.id === id));
|
|
@@ -1190,7 +1265,6 @@
|
|
|
1190
1265
|
chartSvg.selectAll("rect.bar").classed("selected", (n) => n.id === id);
|
|
1191
1266
|
chartSvg.selectAll("g.r").classed("sel", (n) => n.id === id);
|
|
1192
1267
|
}
|
|
1193
|
-
if (flameSvg) flameSvg.selectAll("rect.flame-rect").classed("selected", (n) => n.id === id);
|
|
1194
1268
|
d3.select(el.flow).selectAll("g.flow-card").classed("selected", (n) => n.id === id);
|
|
1195
1269
|
renderDetails();
|
|
1196
1270
|
}
|
|
@@ -1240,10 +1314,9 @@
|
|
|
1240
1314
|
flash(matchMark);
|
|
1241
1315
|
}
|
|
1242
1316
|
function flash(matchMark) {
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
s.selectAll("line.pin").classed("flash", true);
|
|
1317
|
+
if (chartSvg) {
|
|
1318
|
+
chartSvg.selectAll("line.pin").classed("flash", false).node()?.getBoundingClientRect();
|
|
1319
|
+
chartSvg.selectAll("line.pin").classed("flash", true);
|
|
1247
1320
|
}
|
|
1248
1321
|
if (chartSvg) {
|
|
1249
1322
|
const hit = chartSvg
|
|
@@ -1266,15 +1339,7 @@
|
|
|
1266
1339
|
function renderDetails() {
|
|
1267
1340
|
const n = model.byId.get(state.selected) || model.root;
|
|
1268
1341
|
const color = colorOf(n);
|
|
1269
|
-
const
|
|
1270
|
-
["overview", "Overview"],
|
|
1271
|
-
["args", "Args"],
|
|
1272
|
-
...(hasValue(n) ? [["value", "Return"]] : []),
|
|
1273
|
-
...(model.errorsBy.get(n) ? [["errors", `Errors (${model.errorsBy.get(n)})`]] : []),
|
|
1274
|
-
["events", `Events`],
|
|
1275
|
-
["logs", "Logs"],
|
|
1276
|
-
["snapshots", "Snapshots"],
|
|
1277
|
-
];
|
|
1342
|
+
const call = callOf(n);
|
|
1278
1343
|
el.details.innerHTML = `
|
|
1279
1344
|
<div class="dh">Action details <button class="x" title="Close">${svgIcon("close", ' width="16" height="16"')}</button></div>
|
|
1280
1345
|
<div class="who">
|
|
@@ -1283,25 +1348,30 @@
|
|
|
1283
1348
|
<span class="pill"><span class="dot ${n.status}"></span>${esc(n.status)}</span>
|
|
1284
1349
|
</div>
|
|
1285
1350
|
<div class="scroll">
|
|
1351
|
+
${call ? `<div class="tcall who-call">${callHtml(n, call)}</div>` : ""}
|
|
1286
1352
|
<div class="kv">
|
|
1287
1353
|
<div class="k">Duration</div><div class="v">${fmtDur(n.duration)}</div>
|
|
1288
|
-
<div class="k">Start</div><div class="v">${fmtRel(n.start)} (${fmtClock(n.startAbs)})</div>
|
|
1289
|
-
<div class="k">End</div><div class="v">${fmtRel(n.end)} (${fmtClock(n.endAbs)})</div>
|
|
1290
1354
|
${n.v.description ? `<div class="k">Summary</div><div class="v">${esc(n.v.description.summary)}</div>` : ""}
|
|
1355
|
+
${n.v.tags.length ? `<div class="k">Tags</div><div class="v">${chipsHtml(n.v.tags, Infinity, 40)}</div>` : ""}
|
|
1291
1356
|
${n.v.mcp ? `<div class="k">MCP</div><div class="v"><span class="badge-inline">${esc(n.v.mcp.mode)}</span></div>` : ""}
|
|
1292
|
-
<div class="k">
|
|
1357
|
+
<div class="k">Id</div><div class="v mono-sm">${esc(n.id)}</div>
|
|
1293
1358
|
${userSite(n) ? `<div class="k">${siteLabel(n)}</div><div class="v defined">
|
|
1294
1359
|
<button class="icon-btn" data-copy-source aria-label="Copy relative path">${svgIcon("copy")}</button>
|
|
1295
|
-
${
|
|
1296
|
-
${hasValue(n) ? `<button class="icon-btn" data-show-value aria-label="Show returned value">${svgIcon("ret")}</button>` : ""}
|
|
1360
|
+
${hasPopup(n) ? `<button class="icon-btn" data-show-code aria-label="Show source">${svgIcon("code")}</button>` : ""}
|
|
1297
1361
|
<a class="icon-btn" href="${esc(editorUrl(userSite(n)))}" aria-label="Open in VS Code" data-tip-text="Open in VS Code">${svgIcon("external")}</a>
|
|
1298
1362
|
<span class="defined-name" >${esc(basename(sourceOf(n)))}</span>
|
|
1299
1363
|
</div>` : ""}
|
|
1300
1364
|
</div>
|
|
1301
|
-
<div class="
|
|
1302
|
-
.map((
|
|
1365
|
+
<div class="acc">${detailSections(n)
|
|
1366
|
+
.map((sec) =>
|
|
1367
|
+
sec.empty
|
|
1368
|
+
? `<div class="acc-sec empty"><div class="acc-h">${svgIcon("chevron")}<span>${esc(sec.title)}</span><span class="acc-n">0</span></div></div>`
|
|
1369
|
+
: `<details class="acc-sec ${sec.key}" data-sec="${sec.key}" ${state.detailOpen.has(sec.key) ? "open" : ""}>
|
|
1370
|
+
<summary class="acc-h">${svgIcon("chevron")}<span>${esc(sec.title)}</span>${sec.count != null ? `<span class="acc-n">${sec.count}</span>` : ""}</summary>
|
|
1371
|
+
<div class="acc-b">${sec.body()}</div>
|
|
1372
|
+
</details>`
|
|
1373
|
+
)
|
|
1303
1374
|
.join("")}</div>
|
|
1304
|
-
<div class="tabbody">${detailTabHtml(n)}</div>
|
|
1305
1375
|
</div>`;
|
|
1306
1376
|
const copySrc = el.details.querySelector("[data-copy-source]");
|
|
1307
1377
|
if (copySrc) {
|
|
@@ -1312,17 +1382,11 @@
|
|
|
1312
1382
|
copySrc.addEventListener("mousemove", (ev) => moveTip(ev));
|
|
1313
1383
|
copySrc.addEventListener("mouseleave", hideTip);
|
|
1314
1384
|
}
|
|
1315
|
-
const showValue = el.details.querySelector("[data-show-value]");
|
|
1316
|
-
if (showValue) {
|
|
1317
|
-
showValue.addEventListener("click", (ev) => toggleCode(n, ev, "value", true));
|
|
1318
|
-
showValue.addEventListener("mouseenter", (ev) => showTip(ev, `Returned: <span class="mono">${esc(valuePreview(n, 80))}</span>`));
|
|
1319
|
-
showValue.addEventListener("mouseleave", hideTip);
|
|
1320
|
-
}
|
|
1321
1385
|
wireValue(el.details, n);
|
|
1322
1386
|
const showCode = el.details.querySelector("[data-show-code]");
|
|
1323
1387
|
if (showCode) {
|
|
1324
1388
|
showCode.addEventListener("click", (ev) => toggleCode(n, ev));
|
|
1325
|
-
showCode.addEventListener("mouseenter", (ev) => showTip(ev,
|
|
1389
|
+
showCode.addEventListener("mouseenter", (ev) => showTip(ev, popupTip(n)));
|
|
1326
1390
|
showCode.addEventListener("mouseleave", hideTip);
|
|
1327
1391
|
}
|
|
1328
1392
|
el.details.querySelectorAll("[data-tip-text]").forEach((a) => {
|
|
@@ -1334,67 +1398,140 @@
|
|
|
1334
1398
|
el.main.classList.add("no-details");
|
|
1335
1399
|
relayout();
|
|
1336
1400
|
});
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1401
|
+
// which sections are open is a preference: it carries over from action to action
|
|
1402
|
+
el.details.querySelectorAll("details[data-sec]").forEach((d) =>
|
|
1403
|
+
d.addEventListener("toggle", () => {
|
|
1404
|
+
if (d.open) state.detailOpen.add(d.dataset.sec);
|
|
1405
|
+
else state.detailOpen.delete(d.dataset.sec);
|
|
1341
1406
|
})
|
|
1342
1407
|
);
|
|
1343
1408
|
wireItems(el.details);
|
|
1344
1409
|
}
|
|
1345
1410
|
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
return `<div class="section-title err-kind ${e.type}">${esc(kind)} <span class="muted">${fmtRel(e.t - n.start)}</span></div><pre class="code err-text">${esc(e.text)}</pre>`;
|
|
1355
|
-
})
|
|
1356
|
-
.join("");
|
|
1357
|
-
}
|
|
1358
|
-
if (tab === "value") {
|
|
1359
|
-
return hasValue(n) ? `<div class="code-box">${valueHtml(n)}</div>` : '<div class="muted">Returned nothing.</div>';
|
|
1360
|
-
}
|
|
1361
|
-
if (tab === "args") {
|
|
1362
|
-
const params = schemaFields(n.v.inputSchema);
|
|
1363
|
-
const schema = params.length ? `<div class="section-title">Schema</div><div class="params">${params.map(paramHtml).join("")}</div><div class="section-title">Values</div>` : "";
|
|
1364
|
-
return schema + (n.v.args && n.v.args.length ? `<pre class="code">${esc(json(n.v.args.length === 1 ? n.v.args[0] : n.v.args))}</pre>` : '<div class="muted">No arguments.</div>');
|
|
1365
|
-
}
|
|
1366
|
-
if (tab === "events") {
|
|
1367
|
-
const evs = n.entries.filter((i) => i.kind !== "info");
|
|
1368
|
-
return evs.length ? `<ul class="ev-list">${evs.map((i) => evLi(i, n)).join("")}</ul>` : '<div class="muted">No events.</div>';
|
|
1369
|
-
}
|
|
1370
|
-
if (tab === "logs") {
|
|
1371
|
-
const logs = n.entries.filter((i) => i.kind === "info" || i.kind === "error");
|
|
1372
|
-
return logs.length
|
|
1373
|
-
? `<pre class="code">${logs.map((i) => `<span class="muted">${esc(fmtRel(i.t - n.start).padEnd(8))}</span>${esc(i.e.message)}`).join("\n")}</pre>`
|
|
1374
|
-
: '<div class="muted">No log output.</div>';
|
|
1375
|
-
}
|
|
1376
|
-
if (tab === "snapshots") {
|
|
1377
|
-
const g = model.gallery.filter((x) => x.node === n);
|
|
1378
|
-
return g.length ? `<div class="thumbs">${g.map(thumbHtml).join("")}</div>` : '<div class="muted">No snapshots or files.</div>';
|
|
1379
|
-
}
|
|
1380
|
-
// overview
|
|
1381
|
-
const fails = model.run.failures.filter((f) => f.action === n.id || f.action === n.name || f.action === n.v.path);
|
|
1411
|
+
/** The details panel's accordion: one section per facet of `n`, empty ones shown but shut. */
|
|
1412
|
+
function detailSections(n) {
|
|
1413
|
+
const errs = model.errors.filter((e) => e.node === n);
|
|
1414
|
+
const params = schemaFields(n.v.inputSchema);
|
|
1415
|
+
const evs = n.entries.filter((i) => i.kind !== "info" && i.kind !== "link");
|
|
1416
|
+
const logs = n.entries.filter((i) => i.kind === "info" || i.kind === "error");
|
|
1417
|
+
const lks = model.links.filter((l) => l.from === n || l.to === n);
|
|
1418
|
+
const shots = model.gallery.filter((x) => x.node === n);
|
|
1382
1419
|
const asserts = n.entries.filter((i) => i.kind === "pass" || i.kind === "fail");
|
|
1383
|
-
const
|
|
1384
|
-
return
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1420
|
+
const logLink = `<div class="acc-file">${svgIcon("file", ' width="14" height="14"')}<a href="${esc(fileUrl(n.v.logFile))}" title="${esc(n.v.logFile)}">${esc(basename(n.v.logFile))}</a></div>`;
|
|
1421
|
+
return [
|
|
1422
|
+
{
|
|
1423
|
+
key: "errors",
|
|
1424
|
+
title: "Errors",
|
|
1425
|
+
count: errs.length,
|
|
1426
|
+
empty: !errs.length,
|
|
1427
|
+
body: () =>
|
|
1428
|
+
errs
|
|
1429
|
+
.map((e) => {
|
|
1430
|
+
const kind = ERR_TYPES.find(([k]) => k === e.type)[1];
|
|
1431
|
+
return `<div class="section-title err-kind ${e.type}">${esc(kind)} <span class="muted">${fmtRel(e.t - n.start)}</span></div><pre class="code err-text">${esc(e.text)}</pre>`;
|
|
1432
|
+
})
|
|
1433
|
+
.join(""),
|
|
1434
|
+
},
|
|
1435
|
+
{
|
|
1436
|
+
key: "args",
|
|
1437
|
+
title: "Args",
|
|
1438
|
+
empty: !hasArgs(n) && !params.length,
|
|
1439
|
+
body: () =>
|
|
1440
|
+
(params.length ? `<div class="params">${params.map(paramHtml).join("")}</div>` : "") +
|
|
1441
|
+
(hasArgs(n) ? `<div class="code-box">${argsHtml(n)}</div>` : '<div class="muted">No arguments.</div>'),
|
|
1442
|
+
},
|
|
1443
|
+
{ key: "value", title: "Returned", empty: !hasValue(n), body: () => `<div class="code-box">${valueHtml(n)}</div>` },
|
|
1444
|
+
{
|
|
1445
|
+
key: "snapshots",
|
|
1446
|
+
title: "Snapshots & files",
|
|
1447
|
+
count: shots.length,
|
|
1448
|
+
empty: !shots.length,
|
|
1449
|
+
body: () => `<div class="shots">${shots.map((g) => shotHtml(g, n)).join("")}</div>`,
|
|
1450
|
+
},
|
|
1451
|
+
{
|
|
1452
|
+
key: "events",
|
|
1453
|
+
title: "Events",
|
|
1454
|
+
count: evs.length,
|
|
1455
|
+
empty: !evs.length,
|
|
1456
|
+
body: () => `<ul class="ev-list">${evs.map((i) => evLi(i, n)).join("")}</ul>`,
|
|
1457
|
+
},
|
|
1458
|
+
{
|
|
1459
|
+
key: "links",
|
|
1460
|
+
title: "Links",
|
|
1461
|
+
count: lks.length,
|
|
1462
|
+
empty: !lks.length,
|
|
1463
|
+
body: () => linksHtml(n, lks),
|
|
1464
|
+
},
|
|
1465
|
+
{
|
|
1466
|
+
key: "logs",
|
|
1467
|
+
title: "Logs",
|
|
1468
|
+
count: logs.length,
|
|
1469
|
+
empty: !logs.length,
|
|
1470
|
+
body: () =>
|
|
1471
|
+
`${logLink}<pre class="code">${logs.map((i) => `<span class="muted">${esc(fmtRel(i.t - n.start).padEnd(8))}</span>${esc(i.e.message)}`).join("\n")}</pre>`,
|
|
1472
|
+
},
|
|
1473
|
+
{
|
|
1474
|
+
key: "assertions",
|
|
1475
|
+
title: "Assertions",
|
|
1476
|
+
count: asserts.length,
|
|
1477
|
+
empty: !asserts.length,
|
|
1478
|
+
body: () => `<ul class="ev-list">${asserts.map((i) => evLi(i, n)).join("")}</ul>`,
|
|
1479
|
+
},
|
|
1480
|
+
];
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/**
|
|
1484
|
+
* The details panel's links: what `n` did to other actions and what they did to it. Ref
|
|
1485
|
+
* calls fold into one row per (other action, method), with a count and their total time.
|
|
1486
|
+
*/
|
|
1487
|
+
function linksHtml(n, lks) {
|
|
1488
|
+
const rows = [];
|
|
1489
|
+
const refs = new Map();
|
|
1490
|
+
for (const l of lks) {
|
|
1491
|
+
if (l.op === "ref") {
|
|
1492
|
+
const k = `${l.from.id}\0${l.to.id}\0${l.key}`;
|
|
1493
|
+
if (!refs.has(k)) {
|
|
1494
|
+
refs.set(k, { l, n: 0, ms: 0 });
|
|
1495
|
+
rows.push(refs.get(k));
|
|
1496
|
+
}
|
|
1497
|
+
const r = refs.get(k);
|
|
1498
|
+
r.n++;
|
|
1499
|
+
r.ms += l.ms || 0;
|
|
1500
|
+
} else rows.push({ l, n: 1, ms: l.ms || 0 });
|
|
1501
|
+
}
|
|
1502
|
+
const name = (x) => `<b>${esc(displayName(x))}</b>`;
|
|
1503
|
+
return `<ul class="ev-list">${rows
|
|
1504
|
+
.map(({ l, n: count, ms }) => {
|
|
1505
|
+
const out = l.from === n;
|
|
1506
|
+
const text =
|
|
1507
|
+
l.op === "once"
|
|
1508
|
+
? out
|
|
1509
|
+
? `waited for ${esc(l.key)} on ${name(l.to)} · ${esc(l.outcome)} after ${fmtDur(ms)}`
|
|
1510
|
+
: `${name(l.from)} waited for ${esc(l.key)} · ${esc(l.outcome)} after ${fmtDur(ms)}`
|
|
1511
|
+
: l.op === "cancel"
|
|
1512
|
+
? out
|
|
1513
|
+
? `cancelled ${name(l.to)}`
|
|
1514
|
+
: `cancelled by ${name(l.from)}`
|
|
1515
|
+
: out
|
|
1516
|
+
? `${name(l.to)}.${esc(l.key)}() ×${count} · ${fmtDur(ms)}`
|
|
1517
|
+
: `${name(l.from)} called ${esc(l.key)}() ×${count} · ${fmtDur(ms)}`;
|
|
1518
|
+
const color = l.op === "cancel" ? css("--cancel") : colorOf(out ? l.to : l.from);
|
|
1519
|
+
return `<li data-link="${model.links.indexOf(l)}" style="cursor:pointer">
|
|
1520
|
+
<span class="t">${fmtRel(l.t - n.start)}</span>
|
|
1521
|
+
<span class="ev-ico" style="background:${alpha(color, 0.2)};color:${color}">${svgIcon(l.op === "cancel" ? "stop" : l.op === "once" ? "pulse" : "code")}</span>
|
|
1522
|
+
<span class="m">${out ? "→ " : "← "}${text}</span></li>`;
|
|
1523
|
+
})
|
|
1524
|
+
.join("")}</ul>`;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
/** A snapshot or file in the details panel: a small thumb with its name and when it landed. */
|
|
1528
|
+
function shotHtml(g, n) {
|
|
1529
|
+
const i = model.gallery.indexOf(g);
|
|
1530
|
+
return `<div class="shot" data-gallery="${i}" title="${esc(g.name)}">
|
|
1531
|
+
${thumbHtml(g)}
|
|
1532
|
+
<span class="nm">${esc(g.name)}</span>
|
|
1533
|
+
${g.t != null ? `<span class="t">${fmtRel(g.t - n.start)}</span>` : ""}
|
|
1534
|
+
</div>`;
|
|
1398
1535
|
}
|
|
1399
1536
|
|
|
1400
1537
|
function evLi(i, n) {
|
|
@@ -1421,6 +1558,9 @@
|
|
|
1421
1558
|
root.querySelectorAll("[data-gallery]").forEach((t) =>
|
|
1422
1559
|
t.addEventListener("click", () => openGallery(model.gallery[+t.dataset.gallery]))
|
|
1423
1560
|
);
|
|
1561
|
+
root.querySelectorAll("[data-link]").forEach((t) =>
|
|
1562
|
+
t.addEventListener("click", () => setPin(model.links[+t.dataset.link].t))
|
|
1563
|
+
);
|
|
1424
1564
|
root.querySelectorAll("[data-item]").forEach((t) =>
|
|
1425
1565
|
t.addEventListener("click", () => openItem(model.items[+t.dataset.item]))
|
|
1426
1566
|
);
|
|
@@ -1561,7 +1701,6 @@
|
|
|
1561
1701
|
const mark = (sel, idOf) =>
|
|
1562
1702
|
sel.classed("err", (d) => !!set && set.has(idOf(d))).classed("err-path", (d) => !!set && path.has(idOf(d)));
|
|
1563
1703
|
if (chartSvg) mark(chartSvg.selectAll("g.r"), (n) => n.id);
|
|
1564
|
-
if (flameSvg) mark(flameSvg.selectAll("g.cell"), (n) => n.id);
|
|
1565
1704
|
mark(d3.select(el.flow).selectAll("g.flow-card"), (n) => n.id);
|
|
1566
1705
|
el.tree.querySelectorAll(".row").forEach((r) => {
|
|
1567
1706
|
r.classList.toggle("err", !!set && set.has(r.dataset.id));
|
|
@@ -1641,7 +1780,7 @@
|
|
|
1641
1780
|
? (i) => i.kind === "info" || i.kind === "error"
|
|
1642
1781
|
: tab === "asserts"
|
|
1643
1782
|
? (i) => i.kind === "pass" || i.kind === "fail"
|
|
1644
|
-
: (i) => i.kind !== "info";
|
|
1783
|
+
: (i) => i.kind !== "info" && !(i.kind === "link" && i.e.link && i.e.link.op === "ref");
|
|
1645
1784
|
const rows = [];
|
|
1646
1785
|
for (let idx = 0; idx < model.items.length; idx++) {
|
|
1647
1786
|
const i = model.items[idx];
|
|
@@ -1762,40 +1901,20 @@
|
|
|
1762
1901
|
hovered = n; // the node `C` copies while its tooltip is up
|
|
1763
1902
|
const v = n.v;
|
|
1764
1903
|
const shown = n.depth === 0 ? model.data.name : displayName(n);
|
|
1765
|
-
const
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
n.
|
|
1769
|
-
n.
|
|
1770
|
-
|
|
1771
|
-
const rows = [
|
|
1772
|
-
["Duration", fmtDur(n.duration)],
|
|
1773
|
-
["When", `${fmtRel(n.start)} → ${fmtRel(n.end)}`],
|
|
1774
|
-
v.tags.length ? ["Tags", esc(v.tags.join(" · "))] : null,
|
|
1775
|
-
userSite(n) ? [siteLabel(n) === "Called at" ? "Called" : "Defined", `<span class="mono">${esc(sourceOf(n))}</span>`] : null,
|
|
1776
|
-
hasValue(n) ? ["Returns", `<span class="mono">${esc(valuePreview(n, 90))}</span>`] : null,
|
|
1777
|
-
n.children.length ? ["Children", String(n.children.length)] : null,
|
|
1778
|
-
n.marks.length ? ["Events", String(n.marks.length)] : null,
|
|
1904
|
+
const call = callOf(n);
|
|
1905
|
+
const meta = [
|
|
1906
|
+
`<span class="dot ${n.status}"></span>${esc(n.status)}`,
|
|
1907
|
+
esc(fmtDur(n.duration)),
|
|
1908
|
+
n.depth === 0 ? "om" : v.kind === "step" ? "step" : "",
|
|
1909
|
+
v.mcp ? `<span class="mcp">MCP · ${esc(v.mcp.mode)}</span>` : "",
|
|
1779
1910
|
].filter(Boolean);
|
|
1780
|
-
const args = !promptOf(n) && v.args && v.args.length ? truncate(oneLine(v.args.length === 1 ? v.args[0] : v.args), 220) : "";
|
|
1781
|
-
const params = schemaFields(v.inputSchema);
|
|
1782
|
-
const pr = promptOf(n);
|
|
1783
|
-
const promptRows = pr
|
|
1784
|
-
? `<div class="sec">Prompt</div><div class="tprompt">${esc(truncate(pr.message || "", 200))}</div>${
|
|
1785
|
-
pr.answer ? `<div class="tkv"><span class="k">Answer</span><span class="mono">${esc(truncate(typeof pr.answer.value === "string" ? pr.answer.value : oneLine(pr.answer.value), 120))}</span>${pr.answer.via ? `<span class="k">Via</span><span>${esc(pr.answer.via)}</span>` : ""}</div>` : ""
|
|
1786
|
-
}`
|
|
1787
|
-
: "";
|
|
1788
1911
|
const fails = model.run.failures.filter((f) => f.action === n.id || f.action === n.name || f.action === v.path);
|
|
1789
1912
|
return `<div class="tt">${esc(shown)}</div>
|
|
1790
|
-
${
|
|
1791
|
-
<div class="
|
|
1792
|
-
${v.description ? `<div class="summary">${esc(v.description.summary)}</div>` :
|
|
1793
|
-
|
|
1794
|
-
${
|
|
1795
|
-
${params.length ? `<div class="sec">Args schema</div><div class="params">${params.map(paramHtml).join("")}</div>` : ""}
|
|
1796
|
-
${args ? `<div class="sec">Args</div><div class="mono small">${esc(args)}</div>` : ""}
|
|
1797
|
-
${v.definedAt ? '<div class="hint">Press <kbd>C</kbd> to copy the path</div>' : ""}
|
|
1798
|
-
${fails.length ? `<div class="sec fail">Failed</div><div class="mono small">${esc(truncate(String(fails[0].error).split(String.fromCharCode(10))[0], 200))}</div>` : ""}`;
|
|
1913
|
+
${call ? `<div class="tcall">${callHtml(n, call)}</div>` : ""}
|
|
1914
|
+
<div class="tmeta">${meta.join('<span class="sep">·</span>')}</div>
|
|
1915
|
+
${v.description ? `<div class="summary">${esc(truncate(v.description.summary, 240))}</div>` : ""}
|
|
1916
|
+
${chipsHtml(v.tags, TIP_TAGS, 28)}
|
|
1917
|
+
${fails.length ? `<div class="tfail">${esc(truncate(String(fails[0].error).split(String.fromCharCode(10))[0], 160))}</div>` : ""}`;
|
|
1799
1918
|
}
|
|
1800
1919
|
function galleryTip(x) {
|
|
1801
1920
|
const img = x.image ? `<img src="${model.asset(x.f.asset)}" alt="">` : "";
|
|
@@ -1844,19 +1963,89 @@
|
|
|
1844
1963
|
function codeOf(site) {
|
|
1845
1964
|
return site && model.data.code ? model.data.code[site] : undefined;
|
|
1846
1965
|
}
|
|
1847
|
-
/**
|
|
1966
|
+
/**
|
|
1967
|
+
* How the user's code launched `n`, read off the source at its call site: the variable it
|
|
1968
|
+
* was bound to and the callee — `page = chromePage(…)`. Sites carry a line but no column,
|
|
1969
|
+
* so this takes the first call on that line; null when the site is library source.
|
|
1970
|
+
*/
|
|
1971
|
+
function callExpr(n) {
|
|
1972
|
+
const site = n.v.calledAt;
|
|
1973
|
+
const c = site && !isLib(site) && codeOf(site);
|
|
1974
|
+
if (!c) return null;
|
|
1975
|
+
const at = Number((/:(\d+)$/.exec(site) || [])[1]);
|
|
1976
|
+
const lines = c.text.split("\n");
|
|
1977
|
+
const line = lines[at - c.start];
|
|
1978
|
+
if (line == null) return null;
|
|
1979
|
+
let call = null;
|
|
1980
|
+
for (const m of line.matchAll(/(?:^|[^\w$.])([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\(/g)) {
|
|
1981
|
+
if (!/^(if|for|while|switch|return|catch|function|await|typeof|new|async)$/.test(m[1])) {
|
|
1982
|
+
call = { callee: m[1], index: m.index };
|
|
1983
|
+
break;
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
if (!call) return null;
|
|
1987
|
+
const DECL = /\b(?:const|let|var)\s+([\w$]+|\{[^}]*\}|\[[^\]]*\])/;
|
|
1988
|
+
const prefix = line.slice(0, call.index);
|
|
1989
|
+
const prev = lines[at - c.start - 1] || "";
|
|
1990
|
+
const m =
|
|
1991
|
+
DECL.exec(prefix) ||
|
|
1992
|
+
/^\s*([\w$.]+)\s*=(?!=)/.exec(prefix) ||
|
|
1993
|
+
(/^\s*(?:await\s+)?$/.test(prefix) && /=\s*(?:await\s*)?$/.test(prev) ? DECL.exec(prev) : null);
|
|
1994
|
+
return { callee: call.callee, bind: m ? m[1].replace(/\s+/g, " ") : "" };
|
|
1995
|
+
}
|
|
1996
|
+
/** The popover's tabs for `n`: what it is (prompt, source), then what went in and came out. */
|
|
1848
1997
|
function codeTabs(n) {
|
|
1849
1998
|
const tabs = [];
|
|
1850
1999
|
if (promptOf(n)) tabs.push({ key: "prompt", label: "Prompt" });
|
|
1851
|
-
if (hasValue(n)) tabs.push({ key: "value", label: "Returned" });
|
|
1852
2000
|
const mine = userSite(n);
|
|
1853
2001
|
if (codeOf(mine)) tabs.push({ key: "user", label: siteLabel(n) === "Called at" ? "Call site" : "Source", site: mine });
|
|
1854
2002
|
const def = n.v.definedAt;
|
|
1855
2003
|
if (def && def !== mine && codeOf(def)) tabs.push({ key: "def", label: isLib(def) ? "Definition (omkit)" : "Definition", site: def });
|
|
2004
|
+
if (hasArgs(n)) tabs.push({ key: "args", label: "Args" });
|
|
2005
|
+
if (hasValue(n)) tabs.push({ key: "value", label: "Returned" });
|
|
1856
2006
|
return tabs;
|
|
1857
2007
|
}
|
|
1858
2008
|
function hasCode(n) {
|
|
1859
|
-
return codeTabs(n).some((t) => t.key !== "value");
|
|
2009
|
+
return codeTabs(n).some((t) => t.key !== "value" && t.key !== "args");
|
|
2010
|
+
}
|
|
2011
|
+
const callMemo = new Map();
|
|
2012
|
+
/** {@link callExpr}, once per node — bar labels ask on every redraw. */
|
|
2013
|
+
function callOf(n) {
|
|
2014
|
+
if (!callMemo.has(n.id)) callMemo.set(n.id, n.depth === 0 ? null : callExpr(n));
|
|
2015
|
+
return callMemo.get(n.id);
|
|
2016
|
+
}
|
|
2017
|
+
/**
|
|
2018
|
+
* An action box's label: the call that launched it, as the tooltip shows it —
|
|
2019
|
+
* `watchBuildDirectory = watchDir(…)` — then omkit's name when the callee does not already
|
|
2020
|
+
* say it (`command(…) · npm run dev`). Just the name when the call is unknown.
|
|
2021
|
+
*/
|
|
2022
|
+
function boxLabel(n) {
|
|
2023
|
+
const c = callOf(n);
|
|
2024
|
+
if (!c) return displayName(n);
|
|
2025
|
+
const call = `${c.bind ? `${c.bind} = ` : ""}${c.callee}(${hasArgs(n) ? "…" : ""})`;
|
|
2026
|
+
return c.callee === displayName(n) ? call : `${call} · ${displayName(n)}`;
|
|
2027
|
+
}
|
|
2028
|
+
function labelHtml(n) {
|
|
2029
|
+
const c = callOf(n);
|
|
2030
|
+
if (!c) return esc(displayName(n));
|
|
2031
|
+
const name = c.callee === displayName(n) ? "" : `<span class="sep"> · </span>${esc(displayName(n))}`;
|
|
2032
|
+
return `<span class="tcall">${callHtml(n, c)}</span>${name}`;
|
|
2033
|
+
}
|
|
2034
|
+
/** `page = chromePage(…)`, from {@link callExpr}. */
|
|
2035
|
+
function callHtml(n, call) {
|
|
2036
|
+
return `${call.bind ? `<span class="b">${esc(call.bind)}</span> = ` : ""}<span class="f">${esc(call.callee)}</span>(${hasArgs(n) ? "…" : ""})`;
|
|
2037
|
+
}
|
|
2038
|
+
function hasPopup(n) {
|
|
2039
|
+
return codeTabs(n).length > 0;
|
|
2040
|
+
}
|
|
2041
|
+
function popupTip(n) {
|
|
2042
|
+
return codeTabs(n).map((t) => esc(t.label)).join(" · ");
|
|
2043
|
+
}
|
|
2044
|
+
function hasArgs(n) {
|
|
2045
|
+
return !!(n.v.args && n.v.args.length);
|
|
2046
|
+
}
|
|
2047
|
+
function argsOf(n) {
|
|
2048
|
+
return n.v.args.length === 1 ? n.v.args[0] : n.v.args;
|
|
1860
2049
|
}
|
|
1861
2050
|
function relOf(site) {
|
|
1862
2051
|
return (model.data.sources && model.data.sources[site]) || String(site).replace(/:\d+(:\d+)?$/, "");
|
|
@@ -1864,27 +2053,32 @@
|
|
|
1864
2053
|
function hasValue(n) {
|
|
1865
2054
|
return n.v.value !== undefined || !!n.v.valueNote;
|
|
1866
2055
|
}
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
}
|
|
1871
|
-
/** The returned value in the code view: pretty JSON, highlighted, numbered, copyable. */
|
|
1872
|
-
function valueHtml(n) {
|
|
1873
|
-
if (n.v.value === undefined) {
|
|
1874
|
-
return `<div class="pp"><div class="muted">${esc(n.v.valueNote || "Returned nothing.")}</div></div>`;
|
|
1875
|
-
}
|
|
1876
|
-
const text = json(n.v.value);
|
|
2056
|
+
/** A value in the code view: pretty JSON, highlighted, numbered, copyable. */
|
|
2057
|
+
function jsonHtml(value, what) {
|
|
2058
|
+
const text = json(value);
|
|
1877
2059
|
const lines = highlight(text);
|
|
1878
|
-
const kind = Array.isArray(
|
|
2060
|
+
const kind = Array.isArray(value) ? `array · ${value.length} items` : value === null ? "null" : typeof value === "object" ? `object · ${Object.keys(value).length} keys` : typeof value;
|
|
1879
2061
|
return `<div class="cp-tools"><span class="muted">${esc(kind)} · ${lines.length} line${lines.length > 1 ? "s" : ""} · ${fmtSize(text.length)}</span>
|
|
1880
|
-
<button class="btn solo small" data-a="copy-json">${svgIcon("copy")}Copy JSON</button></div>
|
|
2062
|
+
<button class="btn solo small" data-a="copy-json" data-what="${what}">${svgIcon("copy")}Copy JSON</button></div>
|
|
1881
2063
|
<div class="cp-code">${lines
|
|
1882
2064
|
.map((html, i) => `<div class="cl"><span class="ln">${i + 1}</span><span class="lc">${html || " "}</span></div>`)
|
|
1883
2065
|
.join("")}</div>`;
|
|
1884
2066
|
}
|
|
2067
|
+
function valueHtml(n) {
|
|
2068
|
+
if (n.v.value === undefined) {
|
|
2069
|
+
return `<div class="pp"><div class="muted">${esc(n.v.valueNote || "Returned nothing.")}</div></div>`;
|
|
2070
|
+
}
|
|
2071
|
+
return jsonHtml(n.v.value, "value");
|
|
2072
|
+
}
|
|
2073
|
+
function argsHtml(n) {
|
|
2074
|
+
return hasArgs(n) ? jsonHtml(argsOf(n), "args") : '<div class="pp"><div class="muted">No arguments.</div></div>';
|
|
2075
|
+
}
|
|
1885
2076
|
function wireValue(root, n) {
|
|
1886
|
-
|
|
1887
|
-
|
|
2077
|
+
root.querySelectorAll('[data-a="copy-json"]').forEach((b) =>
|
|
2078
|
+
b.addEventListener("click", () =>
|
|
2079
|
+
b.dataset.what === "args" ? copy(json(argsOf(n)), "Copied the args") : copy(json(n.v.value), "Copied the returned value")
|
|
2080
|
+
)
|
|
2081
|
+
);
|
|
1888
2082
|
}
|
|
1889
2083
|
function codeHtml(site) {
|
|
1890
2084
|
const c = codeOf(site);
|
|
@@ -1927,7 +2121,7 @@
|
|
|
1927
2121
|
if (codeFor === n.id && (!want || (toggle && codeTab === want))) return closeCode();
|
|
1928
2122
|
const tabs = codeTabs(n);
|
|
1929
2123
|
if (!tabs.length) return;
|
|
1930
|
-
const tab = tabs.find((t) => t.key === want) || tabs.find((t) => t.key !== "value") || tabs[0];
|
|
2124
|
+
const tab = tabs.find((t) => t.key === want) || tabs.find((t) => t.key !== "value" && t.key !== "args") || tabs[0];
|
|
1931
2125
|
codeTab = tab.key;
|
|
1932
2126
|
hideTip();
|
|
1933
2127
|
const reopen = codeFor !== n.id;
|
|
@@ -1943,7 +2137,9 @@
|
|
|
1943
2137
|
<button class="icon-btn" data-a="close" aria-label="Close">${svgIcon("close")}</button>
|
|
1944
2138
|
</div>
|
|
1945
2139
|
${tabs.length > 1 ? `<div class="cp-tabs">${tabs.map((t) => `<button class="tab ${t === tab ? "active" : ""}" data-tab="${t.key}">${esc(t.label)}</button>`).join("")}</div>` : ""}
|
|
1946
|
-
<div class="cp-body">${
|
|
2140
|
+
<div class="cp-body">${
|
|
2141
|
+
tab.key === "prompt" ? promptHtml(promptOf(n)) : tab.key === "value" ? valueHtml(n) : tab.key === "args" ? argsHtml(n) : codeHtml(site)
|
|
2142
|
+
}</div>`;
|
|
1947
2143
|
wireValue(codeEl, n);
|
|
1948
2144
|
const cp = codeEl.querySelector('[data-a="copy"]');
|
|
1949
2145
|
if (cp) cp.addEventListener("click", () => copy(rel, `Copied ${rel}`));
|
|
@@ -1964,7 +2160,7 @@
|
|
|
1964
2160
|
if (at) at.scrollIntoView({ block: "center" });
|
|
1965
2161
|
if (codeAway) document.removeEventListener("mousedown", codeAway, true);
|
|
1966
2162
|
codeAway = (e) => {
|
|
1967
|
-
if (!codeEl.contains(e.target) && !e.target.closest(".code-btn, [data-show-code]
|
|
2163
|
+
if (!codeEl.contains(e.target) && !e.target.closest(".code-btn, [data-show-code]")) closeCode();
|
|
1968
2164
|
};
|
|
1969
2165
|
setTimeout(() => codeAway && document.addEventListener("mousedown", codeAway, true), 0);
|
|
1970
2166
|
}
|
|
@@ -2020,7 +2216,6 @@
|
|
|
2020
2216
|
function renderAll() {
|
|
2021
2217
|
measure();
|
|
2022
2218
|
if (state.view === "timeline") renderTimeline();
|
|
2023
|
-
if (state.view === "flame") renderFlame();
|
|
2024
2219
|
if (state.view === "flow") renderFlow();
|
|
2025
2220
|
renderOverview();
|
|
2026
2221
|
}
|
|
@@ -2094,7 +2289,6 @@
|
|
|
2094
2289
|
</div>
|
|
2095
2290
|
<div class="btn-group">
|
|
2096
2291
|
<button class="btn active" data-view="timeline">${svgIcon("timeline")}Timeline + Graph</button>
|
|
2097
|
-
<button class="btn" data-view="flame">${svgIcon("flame")}Flame Graph</button>
|
|
2098
2292
|
<button class="btn" data-view="flow">${svgIcon("flow")}Flow</button>
|
|
2099
2293
|
</div>
|
|
2100
2294
|
<div class="btn-group">
|
|
@@ -2108,20 +2302,15 @@
|
|
|
2108
2302
|
<button class="btn solo" id="more" title="More">${svgIcon("more")}</button>
|
|
2109
2303
|
</header>
|
|
2110
2304
|
<div class="main">
|
|
2111
|
-
<section class="card timeline" style="--tree-w:
|
|
2305
|
+
<section class="card timeline" style="--tree-w:360px;--row-h:${ROW_H}px;position:relative">
|
|
2112
2306
|
<div class="tl-head">
|
|
2113
|
-
<div class="cols"><div>Action / Event</div
|
|
2307
|
+
<div class="cols"><div>Action / Event</div></div>
|
|
2114
2308
|
<svg></svg>
|
|
2115
2309
|
</div>
|
|
2116
2310
|
<div class="tl-body"><div class="tree"></div><div class="chart"></div></div>
|
|
2117
|
-
<div class="flame-view" hidden style="top:34px"></div>
|
|
2118
2311
|
<div class="flow-view" hidden></div>
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
</div>
|
|
2122
|
-
<div class="bottom">
|
|
2123
|
-
<section class="card lower"></section>
|
|
2124
|
-
<section class="card legend">
|
|
2312
|
+
<button class="info-btn legend-btn" id="legend-btn" aria-label="Legend" aria-expanded="false" title="Legend">${svgIcon("info")}</button>
|
|
2313
|
+
<div class="legend legend-pop" id="legend" hidden>
|
|
2125
2314
|
<h3>Legend</h3>
|
|
2126
2315
|
<div class="grid2">
|
|
2127
2316
|
${legendItem("--c1", "Action run", "bar")}
|
|
@@ -2132,9 +2321,16 @@
|
|
|
2132
2321
|
${legendItem("--fail", "Failure / error", "dot")}
|
|
2133
2322
|
${legendItem("--cancel", "Cancelled", "dash")}
|
|
2134
2323
|
${legendItem("--accent", "Pinned time", "line")}
|
|
2324
|
+
${legendItem("--text-2", "Waits for an event", "dash")}
|
|
2325
|
+
${legendItem("--text-2", "Call on a ref", "tick")}
|
|
2135
2326
|
</div>
|
|
2136
2327
|
<div class="muted" style="margin-top:12px;font-size:11px">Ctrl + wheel to zoom · drag to pan · double-click a bar to focus · ↑/↓ to step</div>
|
|
2328
|
+
</div>
|
|
2137
2329
|
</section>
|
|
2330
|
+
<aside class="card details"></aside>
|
|
2331
|
+
</div>
|
|
2332
|
+
<div class="bottom">
|
|
2333
|
+
<section class="card lower"></section>
|
|
2138
2334
|
<section class="card overview"><h3>Overview</h3><div class="range"></div><svg></svg></section>
|
|
2139
2335
|
</div>`;
|
|
2140
2336
|
}
|
|
@@ -2143,7 +2339,9 @@
|
|
|
2143
2339
|
const sw =
|
|
2144
2340
|
shape === "bar"
|
|
2145
2341
|
? `<span style="width:16px;height:9px;border-radius:3px;background:${c}"></span>`
|
|
2146
|
-
: shape === "
|
|
2342
|
+
: shape === "tick"
|
|
2343
|
+
? `<span style="width:16px;height:3px;border-radius:2px;background:${c}"></span>`
|
|
2344
|
+
: shape === "dash"
|
|
2147
2345
|
? `<span style="width:16px;height:0;border-top:2px dashed ${c}"></span>`
|
|
2148
2346
|
: shape === "line"
|
|
2149
2347
|
? `<span style="width:2px;height:14px;background:${c};margin:0 7px"></span>`
|
|
@@ -2285,6 +2483,23 @@
|
|
|
2285
2483
|
n = Math.max(1, Math.floor(n));
|
|
2286
2484
|
return s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s;
|
|
2287
2485
|
}
|
|
2486
|
+
/** A tag's colour: hashed from its text, so a tag reads the same in every view. */
|
|
2487
|
+
function tagColor(t) {
|
|
2488
|
+
let h = 0;
|
|
2489
|
+
for (let i = 0; i < t.length; i++) h = (h * 31 + t.charCodeAt(i)) | 0;
|
|
2490
|
+
return css(PALETTE[Math.abs(h) % PALETTE.length]);
|
|
2491
|
+
}
|
|
2492
|
+
/** One tag as a chip — the tree, tooltips and details share it; the flow draws the same in SVG. */
|
|
2493
|
+
function chipHtml(t, max) {
|
|
2494
|
+
const c = tagColor(t);
|
|
2495
|
+
return `<span class="chip" style="background:${alpha(c, 0.14)};border-color:${alpha(c, 0.45)}" title="${esc(t)}">${esc(truncate(t, max))}</span>`;
|
|
2496
|
+
}
|
|
2497
|
+
/** Up to `limit` tag chips, then a "+N" chip holding the rest. */
|
|
2498
|
+
function chipsHtml(tags, limit, max) {
|
|
2499
|
+
if (!tags || !tags.length) return "";
|
|
2500
|
+
const more = tags.length > limit ? `<span class="chip more" title="${esc(tags.slice(limit).join(", "))}">+${tags.length - limit}</span>` : "";
|
|
2501
|
+
return `<span class="chips">${tags.slice(0, limit).map((t) => chipHtml(t, max)).join("")}${more}</span>`;
|
|
2502
|
+
}
|
|
2288
2503
|
function displayName(n) {
|
|
2289
2504
|
if (n.depth === 0) return "main";
|
|
2290
2505
|
const p = promptOf(n);
|