@copperbox/why 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +168 -0
- package/dist/anchor.d.ts +112 -0
- package/dist/anchor.js +697 -0
- package/dist/anchors.d.ts +101 -0
- package/dist/anchors.js +223 -0
- package/dist/audit.d.ts +120 -0
- package/dist/audit.js +483 -0
- package/dist/blame.d.ts +91 -0
- package/dist/blame.js +294 -0
- package/dist/bundle.d.ts +78 -0
- package/dist/bundle.js +188 -0
- package/dist/capture.d.ts +76 -0
- package/dist/capture.js +462 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +641 -0
- package/dist/dig-state.d.ts +46 -0
- package/dist/dig-state.js +146 -0
- package/dist/dig.d.ts +125 -0
- package/dist/dig.js +380 -0
- package/dist/discover.d.ts +11 -0
- package/dist/discover.js +47 -0
- package/dist/doctor.d.ts +135 -0
- package/dist/doctor.js +263 -0
- package/dist/evidence.d.ts +73 -0
- package/dist/evidence.js +425 -0
- package/dist/export.d.ts +67 -0
- package/dist/export.js +106 -0
- package/dist/find-symbol.d.ts +19 -0
- package/dist/find-symbol.js +267 -0
- package/dist/git.d.ts +17 -0
- package/dist/git.js +51 -0
- package/dist/init.d.ts +24 -0
- package/dist/init.js +100 -0
- package/dist/lint.d.ts +40 -0
- package/dist/lint.js +161 -0
- package/dist/serve-assets.d.ts +10 -0
- package/dist/serve-assets.js +55 -0
- package/dist/serve.d.ts +53 -0
- package/dist/serve.js +226 -0
- package/dist/trace-range.d.ts +22 -0
- package/dist/trace-range.js +216 -0
- package/package.json +44 -0
- package/ui/app.js +323 -0
- package/ui/graph.js +164 -0
- package/ui/highlight.js +202 -0
- package/ui/story-panel.d.ts +9 -0
- package/ui/story-panel.js +125 -0
- package/ui/style.css +229 -0
package/ui/app.js
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// The `why serve` SPA shell: hash-routed views (file tree + blame gutter,
|
|
2
|
+
// graph, doctor list) over the UI data contract payloads. Dumb renderer by
|
|
3
|
+
// design — confidence, hedging, and health semantics all arrive precomputed
|
|
4
|
+
// from the JSON API; nothing here re-derives them (docs/ui-contract.md).
|
|
5
|
+
|
|
6
|
+
import { renderGraph, TYPE_COLORS } from "./graph.js";
|
|
7
|
+
import { appendTokens, langForPath, tokenize } from "./highlight.js";
|
|
8
|
+
import { renderStoryPanel } from "./story-panel.js";
|
|
9
|
+
|
|
10
|
+
const doc = document;
|
|
11
|
+
|
|
12
|
+
const state = {
|
|
13
|
+
files: [],
|
|
14
|
+
/** path → coverage spans (schemas/coverage.schema.json). */
|
|
15
|
+
coverage: new Map(),
|
|
16
|
+
coverageHead: "",
|
|
17
|
+
doctor: null,
|
|
18
|
+
stopGraph: null,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
async function api(path) {
|
|
22
|
+
const res = await fetch(path);
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
const body = await res.json().catch(() => ({}));
|
|
25
|
+
throw new Error(body.error ?? `${res.status} on ${path}`);
|
|
26
|
+
}
|
|
27
|
+
return res.json();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function h(tag, className, text) {
|
|
31
|
+
const node = doc.createElement(tag);
|
|
32
|
+
if (className) node.className = className;
|
|
33
|
+
if (text !== undefined) node.textContent = text;
|
|
34
|
+
return node;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function clear(node) {
|
|
38
|
+
while (node.firstChild) node.removeChild(node.firstChild);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function refreshCoverage() {
|
|
42
|
+
const coverage = await api("/api/coverage");
|
|
43
|
+
state.coverage = new Map(coverage.files.map((file) => [file.path, file.spans]));
|
|
44
|
+
state.coverageHead = coverage.head;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// --- Header ------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
const CHIP_SPECS = [
|
|
50
|
+
["lostAnchors", "lost anchors"],
|
|
51
|
+
["reviewByPastDue", "overdue reviews"],
|
|
52
|
+
["openQuestions", "open questions"],
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
function renderChips(container) {
|
|
56
|
+
clear(container);
|
|
57
|
+
if (!state.doctor) return;
|
|
58
|
+
const sections = new Map(state.doctor.sections.map((section) => [section.key, section]));
|
|
59
|
+
for (const [key, label] of CHIP_SPECS) {
|
|
60
|
+
const section = sections.get(key);
|
|
61
|
+
if (!section) continue;
|
|
62
|
+
const tone = section.count === 0 ? "ok" : section.severity;
|
|
63
|
+
const chip = h("a", `chip chip-${tone}`, `${section.count} ${label}`);
|
|
64
|
+
chip.href = "#/doctor";
|
|
65
|
+
container.append(chip);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- File tree -----------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
function buildTree(paths) {
|
|
72
|
+
const root = { dirs: new Map(), files: [] };
|
|
73
|
+
for (const path of paths) {
|
|
74
|
+
const parts = path.split("/");
|
|
75
|
+
let node = root;
|
|
76
|
+
for (const part of parts.slice(0, -1)) {
|
|
77
|
+
if (!node.dirs.has(part)) node.dirs.set(part, { dirs: new Map(), files: [] });
|
|
78
|
+
node = node.dirs.get(part);
|
|
79
|
+
}
|
|
80
|
+
node.files.push({ name: parts[parts.length - 1], path });
|
|
81
|
+
}
|
|
82
|
+
return root;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function renderTree(node, openDepth) {
|
|
86
|
+
const list = h("ul", "tree");
|
|
87
|
+
for (const [name, child] of [...node.dirs.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
88
|
+
const item = h("li");
|
|
89
|
+
const details = h("details");
|
|
90
|
+
if (openDepth > 0) details.open = true;
|
|
91
|
+
details.append(h("summary", "dir", name));
|
|
92
|
+
details.append(renderTree(child, openDepth - 1));
|
|
93
|
+
item.append(details);
|
|
94
|
+
list.append(item);
|
|
95
|
+
}
|
|
96
|
+
for (const file of node.files.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
97
|
+
const item = h("li");
|
|
98
|
+
const link = h("a", "file", file.name);
|
|
99
|
+
link.href = `#/file/${encodeURIComponent(file.path)}`;
|
|
100
|
+
if (state.coverage.has(file.path)) link.append(h("span", "covered-dot", "●"));
|
|
101
|
+
item.append(link);
|
|
102
|
+
list.append(item);
|
|
103
|
+
}
|
|
104
|
+
return list;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --- File view ------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
/** Rough age for the git column: newest-commit recency at a glance. */
|
|
110
|
+
function age(dateStr) {
|
|
111
|
+
const days = Math.max(0, Math.floor((Date.now() - Date.parse(dateStr)) / 86400000));
|
|
112
|
+
if (days < 30) return `${days}d`;
|
|
113
|
+
if (days < 365) return `${Math.floor(days / 30)}mo`;
|
|
114
|
+
return `${Math.floor(days / 365)}y`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function covering(spans, line) {
|
|
118
|
+
return spans.filter(
|
|
119
|
+
(span) => span.lines === undefined || (span.lines.start <= line && line <= span.lines.end),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The gutter treatment for one line: scar tissue and open questions stand
|
|
124
|
+
* apart; everything else colors by confidence (docs/ui-contract.md). */
|
|
125
|
+
function stripeClass(spans) {
|
|
126
|
+
if (spans.length === 0) return "";
|
|
127
|
+
if (spans.some((span) => span.type === "constraint" && span.status === "expired")) return "why-expired";
|
|
128
|
+
if (spans.some((span) => span.type === "question")) return "why-question";
|
|
129
|
+
return `why-conf-${spans[0].confidence ?? "none"}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function showFile(path, fileView, storyPanel) {
|
|
133
|
+
clear(fileView);
|
|
134
|
+
fileView.append(h("h2", "file-path", path));
|
|
135
|
+
let blame;
|
|
136
|
+
try {
|
|
137
|
+
blame = await api(`/api/blame?path=${encodeURIComponent(path)}`);
|
|
138
|
+
} catch (e) {
|
|
139
|
+
fileView.append(h("p", "error", e.message));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// HEAD moved since the coverage snapshot — refresh rather than paint stale spans.
|
|
143
|
+
if (blame.head !== state.coverageHead) await refreshCoverage();
|
|
144
|
+
const spans = state.coverage.get(path) ?? [];
|
|
145
|
+
|
|
146
|
+
const table = h("table", "code");
|
|
147
|
+
const lang = langForPath(path);
|
|
148
|
+
let hlState = null; // threaded across lines so a block comment can span rows
|
|
149
|
+
let prevSha = "";
|
|
150
|
+
blame.lines.forEach((line, i) => {
|
|
151
|
+
const n = i + 1;
|
|
152
|
+
const row = h("tr");
|
|
153
|
+
row.append(h("td", "num", String(n)));
|
|
154
|
+
const gitCell = h("td", "git");
|
|
155
|
+
if (line.sha !== prevSha) {
|
|
156
|
+
gitCell.textContent = `${line.author} · ${age(line.date)}`;
|
|
157
|
+
gitCell.title = `${line.sha.slice(0, 10)} ${line.date} — ${line.summary}`;
|
|
158
|
+
row.classList.add("group-start");
|
|
159
|
+
}
|
|
160
|
+
prevSha = line.sha;
|
|
161
|
+
row.append(gitCell);
|
|
162
|
+
const here = covering(spans, n);
|
|
163
|
+
const why = h("td", `why ${stripeClass(here)}`);
|
|
164
|
+
if (here.length > 0) {
|
|
165
|
+
why.textContent = here[0].glyph;
|
|
166
|
+
why.title = here.map((span) => span.conceptId).join("\n");
|
|
167
|
+
row.classList.add("covered");
|
|
168
|
+
}
|
|
169
|
+
row.append(why);
|
|
170
|
+
const textCell = h("td", "text");
|
|
171
|
+
const { tokens, state } = tokenize(line.text, lang, hlState);
|
|
172
|
+
hlState = state;
|
|
173
|
+
appendTokens(doc, textCell, tokens);
|
|
174
|
+
row.append(textCell);
|
|
175
|
+
row.onclick = () => showStory(path, n, storyPanel);
|
|
176
|
+
table.append(row);
|
|
177
|
+
});
|
|
178
|
+
fileView.append(table);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function showStory(path, line, storyPanel) {
|
|
182
|
+
clear(storyPanel);
|
|
183
|
+
try {
|
|
184
|
+
const story = await api(`/api/story?path=${encodeURIComponent(path)}&start=${line}&end=${line}`);
|
|
185
|
+
storyPanel.append(renderStoryPanel(doc, story));
|
|
186
|
+
} catch (e) {
|
|
187
|
+
storyPanel.append(h("p", "error", e.message));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// --- Views -----------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
function filesView(main, filePath) {
|
|
194
|
+
const layout = h("div", "layout");
|
|
195
|
+
const sidebar = h("aside", "sidebar");
|
|
196
|
+
sidebar.append(renderTree(buildTree(state.files), 2));
|
|
197
|
+
const fileView = h("section", "file-view");
|
|
198
|
+
const storyPanel = h("aside", "story-panel");
|
|
199
|
+
storyPanel.append(h("p", "hint", "Click a line to see the story behind it."));
|
|
200
|
+
layout.append(sidebar, fileView, storyPanel);
|
|
201
|
+
main.append(layout);
|
|
202
|
+
if (filePath) void showFile(filePath, fileView, storyPanel);
|
|
203
|
+
else fileView.append(h("p", "hint", "Pick a file — ● marks files with a recorded why."));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function graphView(main) {
|
|
207
|
+
const wrap = h("section", "graph-view");
|
|
208
|
+
const legend = h("div", "legend");
|
|
209
|
+
for (const [type, color] of Object.entries(TYPE_COLORS)) {
|
|
210
|
+
const entry = h("span", "legend-entry", type);
|
|
211
|
+
const dot = h("span", "legend-dot", "●");
|
|
212
|
+
dot.style.color = color;
|
|
213
|
+
entry.prepend(dot);
|
|
214
|
+
legend.append(entry);
|
|
215
|
+
}
|
|
216
|
+
wrap.append(legend);
|
|
217
|
+
const canvas = doc.createElement("canvas");
|
|
218
|
+
wrap.append(canvas);
|
|
219
|
+
main.append(wrap);
|
|
220
|
+
canvas.width = Math.max(wrap.clientWidth - 16, 480);
|
|
221
|
+
canvas.height = Math.max(doc.documentElement.clientHeight - 180, 420);
|
|
222
|
+
try {
|
|
223
|
+
const graph = await api("/api/graph");
|
|
224
|
+
state.stopGraph = renderGraph(canvas, graph);
|
|
225
|
+
} catch (e) {
|
|
226
|
+
wrap.append(h("p", "error", e.message));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function doctorView(main) {
|
|
231
|
+
const wrap = h("section", "doctor-view");
|
|
232
|
+
main.append(wrap);
|
|
233
|
+
try {
|
|
234
|
+
const doctor = await api("/api/doctor");
|
|
235
|
+
state.doctor = doctor;
|
|
236
|
+
renderChips(doc.getElementById("chips"));
|
|
237
|
+
const headline = doctor.healthy ? "healthy" : "unhealthy";
|
|
238
|
+
wrap.append(
|
|
239
|
+
h(
|
|
240
|
+
"h2",
|
|
241
|
+
`doctor-headline ${doctor.healthy ? "ok" : "red"}`,
|
|
242
|
+
`why doctor: ${doctor.concepts} concepts — ${doctor.red} red, ${doctor.yellow} yellow (${headline})`,
|
|
243
|
+
),
|
|
244
|
+
);
|
|
245
|
+
for (const section of doctor.sections) {
|
|
246
|
+
const block = h("section", "doctor-section");
|
|
247
|
+
const tone = section.count === 0 ? "ok" : section.severity;
|
|
248
|
+
block.append(h("h3", `doctor-title tone-${tone}`, `${section.title} — ${section.count}`));
|
|
249
|
+
if (section.skipped) block.append(h("p", "hint", `not checked: ${section.skipped}`));
|
|
250
|
+
if (section.items.length > 0) {
|
|
251
|
+
const list = h("ul", "doctor-items");
|
|
252
|
+
for (const item of section.items) list.append(h("li", "doctor-item", item));
|
|
253
|
+
block.append(list);
|
|
254
|
+
}
|
|
255
|
+
wrap.append(block);
|
|
256
|
+
}
|
|
257
|
+
} catch (e) {
|
|
258
|
+
wrap.append(h("p", "error", e.message));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// --- Shell + routing ---------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
function route() {
|
|
265
|
+
const main = doc.getElementById("main");
|
|
266
|
+
clear(main);
|
|
267
|
+
if (state.stopGraph) {
|
|
268
|
+
state.stopGraph();
|
|
269
|
+
state.stopGraph = null;
|
|
270
|
+
}
|
|
271
|
+
const hash = decodeURIComponent(location.hash);
|
|
272
|
+
const segment = hash.split("/")[1] ?? "";
|
|
273
|
+
const activeTab = segment === "file" ? "" : segment; // a file view is the Files tab
|
|
274
|
+
for (const link of doc.querySelectorAll("nav a")) {
|
|
275
|
+
link.classList.toggle("active", link.dataset.route === activeTab);
|
|
276
|
+
}
|
|
277
|
+
if (hash.startsWith("#/file/")) filesView(main, hash.slice("#/file/".length));
|
|
278
|
+
else if (hash === "#/graph") void graphView(main);
|
|
279
|
+
else if (hash === "#/doctor") void doctorView(main);
|
|
280
|
+
else filesView(main, null);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function boot() {
|
|
284
|
+
const app = doc.getElementById("app");
|
|
285
|
+
const header = h("header", "topbar");
|
|
286
|
+
header.append(h("span", "brand", "why"));
|
|
287
|
+
const nav = h("nav");
|
|
288
|
+
for (const [label, href, route] of [
|
|
289
|
+
["Files", "#/", ""],
|
|
290
|
+
["Graph", "#/graph", "graph"],
|
|
291
|
+
["Doctor", "#/doctor", "doctor"],
|
|
292
|
+
]) {
|
|
293
|
+
const link = h("a", "tab", label);
|
|
294
|
+
link.href = href;
|
|
295
|
+
link.dataset.route = route;
|
|
296
|
+
nav.append(link);
|
|
297
|
+
}
|
|
298
|
+
header.append(nav);
|
|
299
|
+
const chips = h("span", "chips");
|
|
300
|
+
chips.id = "chips";
|
|
301
|
+
header.append(chips);
|
|
302
|
+
const main = h("main");
|
|
303
|
+
main.id = "main";
|
|
304
|
+
app.append(header, main);
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
const [files, , doctor] = await Promise.all([
|
|
308
|
+
api("/api/files"),
|
|
309
|
+
refreshCoverage(),
|
|
310
|
+
api("/api/doctor"),
|
|
311
|
+
]);
|
|
312
|
+
state.files = files.files;
|
|
313
|
+
state.doctor = doctor;
|
|
314
|
+
} catch (e) {
|
|
315
|
+
main.append(h("p", "error", e.message));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
renderChips(chips);
|
|
319
|
+
window.addEventListener("hashchange", route);
|
|
320
|
+
route();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
void boot();
|
package/ui/graph.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Graph tab: the bundle graph payload (schemas/graph.schema.json) drawn with
|
|
2
|
+
// a hand-rolled canvas force simulation — the okf-mcp `graph html` approach
|
|
3
|
+
// (embedded, zero dependencies), reimplemented here against the contract's
|
|
4
|
+
// nodes/edges shape. Type-colored nodes, relation-labeled edges, drag to pin.
|
|
5
|
+
|
|
6
|
+
export const TYPE_COLORS = {
|
|
7
|
+
decision: "#5b9cf5",
|
|
8
|
+
constraint: "#e2a336",
|
|
9
|
+
attempt: "#8d97a5",
|
|
10
|
+
incident: "#e05d5d",
|
|
11
|
+
question: "#b57edc",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const RADIUS = 9;
|
|
15
|
+
|
|
16
|
+
function initNodes(graph, width, height) {
|
|
17
|
+
// Deterministic ring seeding — stable layouts across reloads beat jitter.
|
|
18
|
+
return graph.nodes.map((node, i) => {
|
|
19
|
+
const angle = (2 * Math.PI * i) / graph.nodes.length;
|
|
20
|
+
const ring = Math.min(width, height) / 4;
|
|
21
|
+
return {
|
|
22
|
+
...node,
|
|
23
|
+
x: width / 2 + ring * Math.cos(angle),
|
|
24
|
+
y: height / 2 + ring * Math.sin(angle),
|
|
25
|
+
vx: 0,
|
|
26
|
+
vy: 0,
|
|
27
|
+
pinned: false,
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function tick(nodes, edges, width, height) {
|
|
33
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
34
|
+
const a = nodes[i];
|
|
35
|
+
// Pairwise repulsion.
|
|
36
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
37
|
+
const b = nodes[j];
|
|
38
|
+
const dx = a.x - b.x;
|
|
39
|
+
const dy = a.y - b.y;
|
|
40
|
+
const d2 = Math.max(dx * dx + dy * dy, 25);
|
|
41
|
+
const force = 2600 / d2;
|
|
42
|
+
const d = Math.sqrt(d2);
|
|
43
|
+
a.vx += (dx / d) * force;
|
|
44
|
+
a.vy += (dy / d) * force;
|
|
45
|
+
b.vx -= (dx / d) * force;
|
|
46
|
+
b.vy -= (dy / d) * force;
|
|
47
|
+
}
|
|
48
|
+
// Gravity toward the center.
|
|
49
|
+
a.vx += (width / 2 - a.x) * 0.005;
|
|
50
|
+
a.vy += (height / 2 - a.y) * 0.005;
|
|
51
|
+
}
|
|
52
|
+
// Edge springs.
|
|
53
|
+
for (const edge of edges) {
|
|
54
|
+
const dx = edge.to.x - edge.from.x;
|
|
55
|
+
const dy = edge.to.y - edge.from.y;
|
|
56
|
+
const d = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
|
|
57
|
+
const force = (d - 130) * 0.02;
|
|
58
|
+
edge.from.vx += (dx / d) * force;
|
|
59
|
+
edge.from.vy += (dy / d) * force;
|
|
60
|
+
edge.to.vx -= (dx / d) * force;
|
|
61
|
+
edge.to.vy -= (dy / d) * force;
|
|
62
|
+
}
|
|
63
|
+
for (const node of nodes) {
|
|
64
|
+
if (node.pinned) {
|
|
65
|
+
node.vx = 0;
|
|
66
|
+
node.vy = 0;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
node.vx *= 0.85;
|
|
70
|
+
node.vy *= 0.85;
|
|
71
|
+
node.x = Math.min(Math.max(node.x + node.vx, RADIUS * 2), width - RADIUS * 2);
|
|
72
|
+
node.y = Math.min(Math.max(node.y + node.vy, RADIUS * 2), height - RADIUS * 2);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function draw(ctx, nodes, edges, width, height) {
|
|
77
|
+
ctx.clearRect(0, 0, width, height);
|
|
78
|
+
ctx.font = "11px system-ui, sans-serif";
|
|
79
|
+
for (const edge of edges) {
|
|
80
|
+
ctx.strokeStyle = "#5a6472";
|
|
81
|
+
ctx.lineWidth = 1;
|
|
82
|
+
ctx.beginPath();
|
|
83
|
+
ctx.moveTo(edge.from.x, edge.from.y);
|
|
84
|
+
ctx.lineTo(edge.to.x, edge.to.y);
|
|
85
|
+
ctx.stroke();
|
|
86
|
+
// Arrowhead toward the target.
|
|
87
|
+
const dx = edge.to.x - edge.from.x;
|
|
88
|
+
const dy = edge.to.y - edge.from.y;
|
|
89
|
+
const d = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
|
|
90
|
+
const tipX = edge.to.x - (dx / d) * (RADIUS + 3);
|
|
91
|
+
const tipY = edge.to.y - (dy / d) * (RADIUS + 3);
|
|
92
|
+
ctx.fillStyle = "#5a6472";
|
|
93
|
+
ctx.beginPath();
|
|
94
|
+
ctx.moveTo(tipX, tipY);
|
|
95
|
+
ctx.lineTo(tipX - (dx / d) * 7 - (dy / d) * 3.5, tipY - (dy / d) * 7 + (dx / d) * 3.5);
|
|
96
|
+
ctx.lineTo(tipX - (dx / d) * 7 + (dy / d) * 3.5, tipY - (dy / d) * 7 - (dx / d) * 3.5);
|
|
97
|
+
ctx.fill();
|
|
98
|
+
// Relation label at the midpoint.
|
|
99
|
+
ctx.fillStyle = "#8b95a5";
|
|
100
|
+
ctx.textAlign = "center";
|
|
101
|
+
ctx.fillText(edge.relation, (edge.from.x + edge.to.x) / 2, (edge.from.y + edge.to.y) / 2 - 4);
|
|
102
|
+
}
|
|
103
|
+
for (const node of nodes) {
|
|
104
|
+
ctx.beginPath();
|
|
105
|
+
ctx.arc(node.x, node.y, RADIUS, 0, 2 * Math.PI);
|
|
106
|
+
ctx.fillStyle = TYPE_COLORS[node.type] ?? "#8d97a5";
|
|
107
|
+
ctx.fill();
|
|
108
|
+
if (node.status === "expired" || node.status === "superseded") {
|
|
109
|
+
ctx.strokeStyle = "#ff6b6b";
|
|
110
|
+
ctx.lineWidth = 2.5;
|
|
111
|
+
ctx.stroke();
|
|
112
|
+
}
|
|
113
|
+
ctx.fillStyle = "#dde3ec";
|
|
114
|
+
ctx.textAlign = "center";
|
|
115
|
+
ctx.fillText(node.title, node.x, node.y + RADIUS + 13);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Run the simulation on `canvas`; returns a stop() for teardown. */
|
|
120
|
+
export function renderGraph(canvas, graph) {
|
|
121
|
+
const ctx = canvas.getContext("2d");
|
|
122
|
+
const width = canvas.width;
|
|
123
|
+
const height = canvas.height;
|
|
124
|
+
const nodes = initNodes(graph, width, height);
|
|
125
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
126
|
+
const edges = graph.edges.map((edge) => ({
|
|
127
|
+
from: byId.get(edge.from),
|
|
128
|
+
to: byId.get(edge.to),
|
|
129
|
+
relation: edge.relation,
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
let dragging = null;
|
|
133
|
+
const pos = (event) => {
|
|
134
|
+
const rect = canvas.getBoundingClientRect();
|
|
135
|
+
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
|
136
|
+
};
|
|
137
|
+
canvas.onmousedown = (event) => {
|
|
138
|
+
const { x, y } = pos(event);
|
|
139
|
+
dragging = nodes.find((n) => (n.x - x) ** 2 + (n.y - y) ** 2 <= (RADIUS + 4) ** 2) ?? null;
|
|
140
|
+
if (dragging) dragging.pinned = true;
|
|
141
|
+
};
|
|
142
|
+
canvas.onmousemove = (event) => {
|
|
143
|
+
if (!dragging) return;
|
|
144
|
+
const { x, y } = pos(event);
|
|
145
|
+
dragging.x = x;
|
|
146
|
+
dragging.y = y;
|
|
147
|
+
};
|
|
148
|
+
canvas.onmouseup = () => {
|
|
149
|
+
if (dragging) dragging.pinned = false;
|
|
150
|
+
dragging = null;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
let running = true;
|
|
154
|
+
const frame = () => {
|
|
155
|
+
if (!running) return;
|
|
156
|
+
tick(nodes, edges, width, height);
|
|
157
|
+
draw(ctx, nodes, edges, width, height);
|
|
158
|
+
requestAnimationFrame(frame);
|
|
159
|
+
};
|
|
160
|
+
requestAnimationFrame(frame);
|
|
161
|
+
return () => {
|
|
162
|
+
running = false;
|
|
163
|
+
};
|
|
164
|
+
}
|
package/ui/highlight.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Hand-rolled, dependency-free syntax highlighting for the file view — the same
|
|
2
|
+
// self-contained/zero-CDN philosophy as the graph's canvas force sim (ui/graph.js).
|
|
3
|
+
// Highlighting is pure presentation: it colors the code text the blame payload
|
|
4
|
+
// already carries and asserts nothing about the *why*, so it lives in the client
|
|
5
|
+
// renderer rather than in the UI data contract.
|
|
6
|
+
//
|
|
7
|
+
// `tokenize` is a small, stateless-per-call lexer that a caller drives line by
|
|
8
|
+
// line, threading the returned `state` back in so a block comment can span rows
|
|
9
|
+
// (blame gives us one line at a time). It is deliberately best-effort: unknown
|
|
10
|
+
// languages, multi-line strings, and Rust lifetimes vs char literals are handled
|
|
11
|
+
// gracefully but not perfectly. Every character of the input is emitted in some
|
|
12
|
+
// token, so joining the token texts always reproduces the line verbatim.
|
|
13
|
+
|
|
14
|
+
/** Extensions → a language key in LANGS, or null for "render as plain text". */
|
|
15
|
+
const LANG_BY_EXTENSION = {
|
|
16
|
+
".ts": "ts", ".mts": "ts", ".cts": "ts", ".tsx": "ts",
|
|
17
|
+
".js": "ts", ".mjs": "ts", ".cjs": "ts", ".jsx": "ts",
|
|
18
|
+
".rs": "rust",
|
|
19
|
+
".py": "python", ".pyi": "python",
|
|
20
|
+
".go": "go",
|
|
21
|
+
".c": "c", ".h": "c", ".cc": "c", ".cpp": "c", ".hpp": "c", ".cxx": "c",
|
|
22
|
+
".java": "c", ".cs": "c",
|
|
23
|
+
".json": "json",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const words = (s) => new Set(s.split(/\s+/).filter(Boolean));
|
|
27
|
+
|
|
28
|
+
// A language is a lexer config, not a grammar: comment/string delimiters plus a
|
|
29
|
+
// keyword set. `char` enables Rust-style char-literal detection so lifetimes
|
|
30
|
+
// (`'a`) are not mistaken for an unterminated string.
|
|
31
|
+
const LANGS = {
|
|
32
|
+
ts: {
|
|
33
|
+
line: "//", block: ["/*", "*/"], quotes: ["\"", "'", "`"],
|
|
34
|
+
keywords: words(`abstract any as async await boolean break case catch class const continue
|
|
35
|
+
debugger declare default delete do else enum export extends false finally for from function
|
|
36
|
+
get if implements import in infer instanceof interface is keyof let module namespace never new
|
|
37
|
+
null number object of override private protected public readonly return satisfies set static
|
|
38
|
+
string super switch symbol this throw true try type typeof undefined unknown var void while
|
|
39
|
+
with yield`),
|
|
40
|
+
},
|
|
41
|
+
rust: {
|
|
42
|
+
line: "//", block: ["/*", "*/"], quotes: ["\""], char: true,
|
|
43
|
+
keywords: words(`as async await bool break char const continue crate dyn else enum extern false
|
|
44
|
+
fn for i8 i16 i32 i64 i128 if impl in isize let loop match mod move mut pub ref return self
|
|
45
|
+
Self static str struct super trait true type u8 u16 u32 u64 u128 union unsafe use usize where
|
|
46
|
+
while f32 f64`),
|
|
47
|
+
},
|
|
48
|
+
python: {
|
|
49
|
+
line: "#", block: null, quotes: ["\"", "'"],
|
|
50
|
+
keywords: words(`and as assert async await break class continue def del elif else except False
|
|
51
|
+
finally for from global if import in is lambda None nonlocal not or pass raise return True try
|
|
52
|
+
while with yield self`),
|
|
53
|
+
},
|
|
54
|
+
go: {
|
|
55
|
+
line: "//", block: ["/*", "*/"], quotes: ["\"", "`", "'"], char: true,
|
|
56
|
+
keywords: words(`break case chan const continue default defer else fallthrough for func go goto
|
|
57
|
+
if import interface map package range return select struct switch type var bool byte error
|
|
58
|
+
false float32 float64 int int8 int16 int32 int64 nil rune string true uint uintptr`),
|
|
59
|
+
},
|
|
60
|
+
c: {
|
|
61
|
+
line: "//", block: ["/*", "*/"], quotes: ["\"", "'"],
|
|
62
|
+
keywords: words(`auto bool break case catch char class const constexpr continue default delete
|
|
63
|
+
do double else enum extern false final float for friend goto if inline int long namespace new
|
|
64
|
+
nullptr operator override private protected public register return short signed sizeof static
|
|
65
|
+
struct switch template this throw true try typedef typename union unsigned using virtual void
|
|
66
|
+
volatile while`),
|
|
67
|
+
},
|
|
68
|
+
json: { line: null, block: null, quotes: ["\""], keywords: words("true false null") },
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const isIdentStart = (ch) => /[A-Za-z_$]/.test(ch);
|
|
72
|
+
const isIdent = (ch) => /[A-Za-z0-9_$]/.test(ch);
|
|
73
|
+
const isDigit = (ch) => ch >= "0" && ch <= "9";
|
|
74
|
+
|
|
75
|
+
/** File extension of a repo-relative path, lowercased, including the dot. */
|
|
76
|
+
function extname(path) {
|
|
77
|
+
const base = path.slice(path.lastIndexOf("/") + 1);
|
|
78
|
+
const dot = base.lastIndexOf(".");
|
|
79
|
+
return dot <= 0 ? "" : base.slice(dot).toLowerCase();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The language key for a path, or null when we have no lexer for it. */
|
|
83
|
+
export function langForPath(path) {
|
|
84
|
+
return LANG_BY_EXTENSION[extname(path)] ?? null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Lex one line into `{ text, cls }` runs. `cls` is a token class ("kw", "str",
|
|
89
|
+
* "com", "num", "type", "fn") or null for plain text. `state` carries a pending
|
|
90
|
+
* block comment across lines; pass the returned `state` into the next line.
|
|
91
|
+
*/
|
|
92
|
+
export function tokenize(text, lang, state) {
|
|
93
|
+
const spec = lang && LANGS[lang];
|
|
94
|
+
if (!spec) return { tokens: text === "" ? [] : [{ text, cls: null }], state: null };
|
|
95
|
+
|
|
96
|
+
const tokens = [];
|
|
97
|
+
let plain = "";
|
|
98
|
+
const flush = () => {
|
|
99
|
+
if (plain !== "") tokens.push({ text: plain, cls: null });
|
|
100
|
+
plain = "";
|
|
101
|
+
};
|
|
102
|
+
const push = (t, cls) => {
|
|
103
|
+
flush();
|
|
104
|
+
tokens.push({ text: t, cls });
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
let i = 0;
|
|
108
|
+
// Resume an open block comment from the previous line.
|
|
109
|
+
if (state && state.block && spec.block) {
|
|
110
|
+
const close = text.indexOf(spec.block[1]);
|
|
111
|
+
if (close === -1) {
|
|
112
|
+
return { tokens: [{ text, cls: "com" }], state: { block: true } };
|
|
113
|
+
}
|
|
114
|
+
push(text.slice(0, close + spec.block[1].length), "com");
|
|
115
|
+
i = close + spec.block[1].length;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
while (i < text.length) {
|
|
119
|
+
const ch = text[i];
|
|
120
|
+
const rest = text.slice(i);
|
|
121
|
+
|
|
122
|
+
if (spec.line && rest.startsWith(spec.line)) {
|
|
123
|
+
push(rest, "com");
|
|
124
|
+
i = text.length;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
if (spec.block && rest.startsWith(spec.block[0])) {
|
|
128
|
+
const close = text.indexOf(spec.block[1], i + spec.block[0].length);
|
|
129
|
+
if (close === -1) {
|
|
130
|
+
push(rest, "com");
|
|
131
|
+
return { tokens, state: { block: true } };
|
|
132
|
+
}
|
|
133
|
+
const end = close + spec.block[1].length;
|
|
134
|
+
push(text.slice(i, end), "com");
|
|
135
|
+
i = end;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
// Rust/Go char vs lifetime: only lex `'` as a string when it looks like a
|
|
139
|
+
// char literal; otherwise it is a lifetime/label and stays plain.
|
|
140
|
+
if (spec.char && ch === "'") {
|
|
141
|
+
const m = /^'(\\.|[^'\\])'/.exec(rest);
|
|
142
|
+
if (m) {
|
|
143
|
+
push(m[0], "str");
|
|
144
|
+
i += m[0].length;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
plain += ch;
|
|
148
|
+
i++;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (spec.quotes.includes(ch)) {
|
|
152
|
+
let j = i + 1;
|
|
153
|
+
while (j < text.length) {
|
|
154
|
+
if (text[j] === "\\") { j += 2; continue; }
|
|
155
|
+
if (text[j] === ch) { j++; break; }
|
|
156
|
+
j++;
|
|
157
|
+
}
|
|
158
|
+
push(text.slice(i, j), "str");
|
|
159
|
+
i = j;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (isDigit(ch) || (ch === "." && isDigit(text[i + 1] ?? ""))) {
|
|
163
|
+
let j = i;
|
|
164
|
+
while (j < text.length && /[0-9a-fA-FxXoObB._]/.test(text[j])) j++;
|
|
165
|
+
push(text.slice(i, j), "num");
|
|
166
|
+
i = j;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (isIdentStart(ch)) {
|
|
170
|
+
let j = i;
|
|
171
|
+
while (j < text.length && isIdent(text[j])) j++;
|
|
172
|
+
const word = text.slice(i, j);
|
|
173
|
+
let k = j;
|
|
174
|
+
while (k < text.length && (text[k] === " " || text[k] === "\t")) k++;
|
|
175
|
+
let cls = null;
|
|
176
|
+
if (spec.keywords.has(word)) cls = "kw";
|
|
177
|
+
else if (/^[A-Z]/.test(word)) cls = "type";
|
|
178
|
+
else if (text[k] === "(") cls = "fn";
|
|
179
|
+
push(word, cls);
|
|
180
|
+
i = j;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
plain += ch;
|
|
184
|
+
i++;
|
|
185
|
+
}
|
|
186
|
+
flush();
|
|
187
|
+
return { tokens, state: null };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Append `tokenize` output into `cell`; classed runs become spans, plain text
|
|
191
|
+
* stays a text node so `white-space: pre` preserves it exactly. */
|
|
192
|
+
export function appendTokens(doc, cell, tokens) {
|
|
193
|
+
for (const { text, cls } of tokens) {
|
|
194
|
+
if (cls === null) cell.append(doc.createTextNode(text));
|
|
195
|
+
else {
|
|
196
|
+
const span = doc.createElement("span");
|
|
197
|
+
span.className = `tok-${cls}`;
|
|
198
|
+
span.textContent = text;
|
|
199
|
+
cell.append(span);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Types for the story-panel renderer, so the DOM smoke test type-checks
|
|
2
|
+
// without enabling allowJs for the whole UI. The story parameter is the
|
|
3
|
+
// payload of schemas/story.schema.json (`why blame --json`).
|
|
4
|
+
|
|
5
|
+
import type { BlameReport } from "../src/blame.ts";
|
|
6
|
+
|
|
7
|
+
export function glyphFor(type: string, status: string | undefined): "●" | "⚠" | "?";
|
|
8
|
+
export function formatTarget(target: BlameReport["target"]): string;
|
|
9
|
+
export function renderStoryPanel(doc: Document, story: BlameReport): HTMLElement;
|