gabriel / musehub public
commit-detail.ts typescript
480 lines 18.7 KB
d4eb1c39 Theme overhaul: domains, new-repo, MCP docs, copy icons; legacy CSS rem… Gabriel Cardona <cgcardona@gmail.com> 3d ago
1 /**
2 * commit-detail.ts — D3 symbol graph for the MuseHub commit detail page.
3 *
4 * Reads `structuredDelta` from the page-data JSON block and renders:
5 * - A force-directed symbol graph (nodes = symbols, file cluster backgrounds)
6 * - Three modes: Graph (default) | Dead Code | Blast Radius (impact)
7 * - A symbol info panel (click a node to inspect)
8 *
9 * Data shape (from commit_meta.structured_delta):
10 * { domain, ops: [{ op, address, child_ops: [{ op, address, content_summary }] }] }
11 */
12
13 import * as d3 from 'd3';
14
15 // ── Types ──────────────────────────────────────────────────────────────────────
16
17 interface ChildOp {
18 op: string;
19 address: string;
20 content_summary?: string;
21 }
22
23 interface FileOp {
24 op: string;
25 address: string;
26 child_summary?: string;
27 child_ops?: ChildOp[];
28 }
29
30 interface StructuredDelta {
31 domain?: string;
32 ops: FileOp[];
33 }
34
35 interface SnapshotDiff {
36 added: string[];
37 modified: string[];
38 removed: string[];
39 total_files: number;
40 }
41
42 interface Provenance {
43 agent_id?: string;
44 model_id?: string;
45 is_agent?: boolean;
46 sem_ver_bump?: string;
47 }
48
49 interface PageData {
50 page: string;
51 repoId?: string;
52 commitId?: string;
53 owner?: string;
54 repoSlug?: string;
55 baseUrl?: string;
56 structuredDelta?: StructuredDelta | null;
57 snapshotDiff?: SnapshotDiff | null;
58 provenance?: Provenance;
59 commitType?: string;
60 isBreaking?: boolean;
61 }
62
63 interface SymNode extends d3.SimulationNodeDatum {
64 id: string;
65 label: string;
66 file: string;
67 kind: string; // class | method | function | variable | unknown
68 op: string; // insert | delete | replace | unknown
69 summary: string;
70 isDeadCode: boolean;
71 fileOp: string; // op of the parent file
72 }
73
74 interface SymEdge {
75 source: string | SymNode;
76 target: string | SymNode;
77 }
78
79 interface FileCluster {
80 file: string;
81 nodes: SymNode[];
82 }
83
84 // ── Constants ──────────────────────────────────────────────────────────────────
85
86 const OP_COLOR: Record<string, string> = {
87 insert: '#34d399', // green
88 delete: '#f87171', // red
89 replace: '#fbbf24', // amber
90 patch: '#fbbf24',
91 unknown: '#94a3b8',
92 };
93
94 const KIND_FILL: Record<string, string> = {
95 class: '#a78bfa', // purple
96 method: '#2dd4bf', // teal
97 function: '#60a5fa', // blue
98 variable: '#f9a825', // gold
99 unknown: '#78909c',
100 };
101
102 const NODE_R = 10;
103 const DEAD_DASHES = '3,3';
104
105 // ── State ─────────────────────────────────────────────────────────────────────
106
107 let _graphMode: 'default' | 'dead' | 'impact' = 'default';
108 let _selectedNode: SymNode | null = null;
109 let _simulation: d3.Simulation<SymNode, SymEdge> | null = null;
110 let _nodes: SymNode[] = [];
111 let _edges: SymEdge[] = [];
112
113 // ── Helpers ───────────────────────────────────────────────────────────────────
114
115 function detectKind(address: string, summary: string): string {
116 const s = summary.toLowerCase();
117 if (s.includes('class')) return 'class';
118 if (s.includes('method')) return 'method';
119 if (s.includes('function')) return 'function';
120 if (s.includes('variable') || s.includes('constant')) return 'variable';
121 // Heuristic from address: Class.method => method, _foo => function
122 if (address.includes('::')) {
123 const sym = address.split('::').pop() ?? '';
124 if (sym.includes('.')) return 'method';
125 }
126 return 'unknown';
127 }
128
129 function shortLabel(address: string): string {
130 const sym = address.split('::').pop() ?? address;
131 const part = sym.split('.').pop() ?? sym;
132 return part.length > 16 ? part.slice(0, 15) + '…' : part;
133 }
134
135 function buildGraph(delta: StructuredDelta): { nodes: SymNode[]; edges: SymEdge[] } {
136 const nodes: SymNode[] = [];
137 const edges: SymEdge[] = [];
138 const seen = new Set<string>();
139
140 for (const fileOp of delta.ops) {
141 const file = fileOp.address.split('/').pop() ?? fileOp.address;
142 if (!fileOp.child_ops?.length) continue;
143
144 const fileNodeIds: string[] = [];
145 for (const co of fileOp.child_ops) {
146 const id = co.address;
147 if (seen.has(id)) continue;
148 seen.add(id);
149
150 const summary = co.content_summary ?? '';
151 const kind = detectKind(co.address, summary);
152 // Dead code: "0 callers" in summary, or delete op
153 const isDeadCode = co.op === 'delete' ||
154 summary.includes('0 callers') ||
155 summary.includes('dead');
156
157 nodes.push({
158 id,
159 label: shortLabel(co.address),
160 file,
161 kind,
162 op: co.op || fileOp.op || 'unknown',
163 summary,
164 isDeadCode,
165 fileOp: fileOp.op,
166 });
167 fileNodeIds.push(id);
168 }
169
170 // Infer parent → child edges from address hierarchy
171 // e.g. Foo::Bar.method → Bar is child of Foo
172 for (const nid of fileNodeIds) {
173 const sym = nid.split('::').pop() ?? '';
174 if (sym.includes('.')) {
175 const parentSym = sym.split('.')[0];
176 // Find parent node in same file
177 const parentId = fileNodeIds.find(id => {
178 const s = id.split('::').pop() ?? '';
179 return s === parentSym;
180 });
181 if (parentId && parentId !== nid) {
182 edges.push({ source: parentId, target: nid });
183 }
184 }
185 }
186 }
187
188 return { nodes, edges };
189 }
190
191 // ── Render ────────────────────────────────────────────────────────────────────
192
193 function renderGraph(delta: StructuredDelta): void {
194 const svgEl = document.getElementById('cd2-sym-svg') as SVGElement | null;
195 if (!svgEl) return;
196
197 const { nodes, edges } = buildGraph(delta);
198 if (!nodes.length) {
199 svgEl.style.display = 'none';
200 const section = document.getElementById('cd2-sym-graph-section');
201 if (section) section.style.display = 'none';
202 return;
203 }
204 _nodes = nodes;
205 _edges = edges;
206
207 // Size SVG to its container
208 const W = svgEl.parentElement?.clientWidth ? Math.min(svgEl.parentElement.clientWidth - 240, 760) : 600;
209 const H = Math.max(300, Math.min(nodes.length * 28, 420));
210
211 const svg = d3.select<SVGElement, unknown>('#cd2-sym-svg')
212 .attr('viewBox', `0 0 ${W} ${H}`)
213 .attr('width', W).attr('height', H);
214
215 svg.selectAll('*').remove();
216
217 // ── Defs: arrowhead marker ─────────────────────────────────────────────────
218 svg.append('defs').append('marker')
219 .attr('id', 'sg-arrow').attr('viewBox', '-2 -4 10 8')
220 .attr('refX', NODE_R + 4).attr('refY', 0)
221 .attr('markerWidth', 5).attr('markerHeight', 5).attr('orient', 'auto')
222 .append('path').attr('d', 'M-2,-4L6,0L-2,4Z')
223 .attr('fill', 'rgba(255,255,255,.25)');
224
225 // ── File cluster backgrounds ───────────────────────────────────────────────
226 const clusterLayer = svg.append('g').attr('class', 'sg-cluster-layer');
227 const infoLayer = svg.append('g').attr('class', 'sg-info-layer');
228 const edgeLayer = svg.append('g').attr('class', 'sg-edge-layer');
229 const nodeLayer = svg.append('g').attr('class', 'sg-node-layer');
230
231 // Group by file
232 const byFile = d3.group(nodes, d => d.file);
233 const fileArr = Array.from(byFile.keys());
234
235 // Partition canvas into columns per file
236 const colW = W / fileArr.length;
237 const fileCols = new Map<string, number>();
238 fileArr.forEach((f, i) => fileCols.set(f, i));
239
240 // Initial positions: spread by file column
241 nodes.forEach(n => {
242 const col = fileCols.get(n.file) ?? 0;
243 n.x = colW * col + colW / 2 + (Math.random() - 0.5) * 40;
244 n.y = H / 2 + (Math.random() - 0.5) * 60;
245 });
246
247 // ── Simulation ─────────────────────────────────────────────────────────────
248 _simulation = d3.forceSimulation<SymNode>(nodes)
249 .force('link', d3.forceLink<SymNode, SymEdge>(edges)
250 .id(d => d.id).distance(55).strength(0.6))
251 .force('charge', d3.forceManyBody().strength(-160))
252 .force('center', d3.forceCenter(W / 2, H / 2))
253 .force('collide', d3.forceCollide(NODE_R + 18))
254 .force('column', () => {
255 // Gently pull each node toward its file's column centre
256 for (const n of nodes) {
257 const col = fileCols.get(n.file) ?? 0;
258 const cx = colW * col + colW / 2;
259 n.vx = (n.vx ?? 0) + (cx - (n.x ?? 0)) * 0.04;
260 }
261 });
262
263 // ── Edges ──────────────────────────────────────────────────────────────────
264 const edgeSel = edgeLayer.selectAll<SVGLineElement, SymEdge>('.sg-edge')
265 .data(edges).enter()
266 .append('line').attr('class', 'sg-edge inactive')
267 .attr('stroke', 'rgba(255,255,255,.2)').attr('stroke-width', 1.2)
268 .attr('marker-end', 'url(#sg-arrow)');
269
270 // ── Nodes ──────────────────────────────────────────────────────────────────
271 const nodeSel = nodeLayer.selectAll<SVGGElement, SymNode>('.sg-node')
272 .data(nodes).enter()
273 .append('g').attr('class', 'sg-node')
274 .call(d3.drag<SVGGElement, SymNode>()
275 .on('start', (ev, d) => {
276 if (!ev.active && _simulation) _simulation.alphaTarget(0.3).restart();
277 d.fx = d.x; d.fy = d.y;
278 })
279 .on('drag', (ev, d) => { d.fx = ev.x; d.fy = ev.y; })
280 .on('end', (ev, d) => {
281 if (!ev.active && _simulation) _simulation.alphaTarget(0);
282 d.fx = null; d.fy = null;
283 }))
284 .on('click', (_ev, d) => selectNode(d));
285
286 // Dead code dashed ring
287 nodeSel.filter(d => d.isDeadCode)
288 .append('circle').attr('r', NODE_R + 5)
289 .attr('fill', 'none').attr('stroke', '#f87171')
290 .attr('stroke-width', 1.5).attr('stroke-dasharray', DEAD_DASHES)
291 .attr('opacity', .55).attr('class', 'sg-dead-ring');
292
293 // Op-coloured outer ring
294 nodeSel.append('circle').attr('class', 'sg-ring').attr('r', NODE_R + 2)
295 .attr('fill', 'none')
296 .attr('stroke', d => OP_COLOR[d.op] ?? OP_COLOR.unknown)
297 .attr('stroke-width', 2).attr('opacity', .75);
298
299 // Kind-fill inner circle
300 nodeSel.append('circle').attr('class', 'sg-fill').attr('r', NODE_R - 1)
301 .attr('fill', d => KIND_FILL[d.kind] ?? KIND_FILL.unknown)
302 .attr('fill-opacity', .85)
303 .attr('stroke', 'rgba(0,0,0,.3)').attr('stroke-width', 1);
304
305 // Label below node
306 nodeSel.append('text').attr('class', 'sg-lbl')
307 .attr('y', NODE_R + 12).attr('text-anchor', 'middle')
308 .attr('font-size', 8).attr('font-family', 'var(--font-mono)')
309 .attr('fill', 'rgba(255,255,255,.7)').attr('pointer-events', 'none')
310 .text(d => d.label);
311
312 // ── File cluster boxes (drawn after layout settles) ────────────────────────
313 _simulation.on('end', () => {
314 clusterLayer.selectAll('*').remove();
315 infoLayer.selectAll('*').remove();
316
317 byFile.forEach((fileNodes, file) => {
318 const xs = fileNodes.map(n => n.x ?? 0);
319 const ys = fileNodes.map(n => n.y ?? 0);
320 const pad = 22;
321 const x1 = Math.min(...xs) - pad, y1 = Math.min(...ys) - pad;
322 const x2 = Math.max(...xs) + pad, y2 = Math.max(...ys) + pad;
323 clusterLayer.append('rect')
324 .attr('x', x1).attr('y', y1)
325 .attr('width', x2 - x1).attr('height', y2 - y1)
326 .attr('rx', 6).attr('class', 'sg-cluster-bg')
327 .attr('fill', 'rgba(255,255,255,.013)')
328 .attr('stroke', 'rgba(255,255,255,.06)');
329 infoLayer.append('text')
330 .attr('x', x1 + 6).attr('y', y1 + 12)
331 .attr('font-size', 9).attr('font-family', 'var(--font-mono)')
332 .attr('fill', 'rgba(255,255,255,.28)').attr('pointer-events', 'none')
333 .text(file);
334 });
335 });
336
337 // ── Tick ───────────────────────────────────────────────────────────────────
338 _simulation.on('tick', () => {
339 edgeSel
340 .attr('x1', d => (d.source as SymNode).x ?? 0)
341 .attr('y1', d => (d.source as SymNode).y ?? 0)
342 .attr('x2', d => (d.target as SymNode).x ?? 0)
343 .attr('y2', d => (d.target as SymNode).y ?? 0);
344
345 nodeSel.attr('transform', d => `translate(${d.x ?? 0},${d.y ?? 0})`);
346 });
347 }
348
349 // ── Mode switching ────────────────────────────────────────────────────────────
350
351 function setMode(mode: 'default' | 'dead' | 'impact'): void {
352 _graphMode = mode;
353
354 // Update button states
355 ['graph', 'dead', 'impact'].forEach(m => {
356 const el = document.getElementById(`sgm-${m}`);
357 if (!el) return;
358 const isActive = (m === 'graph' && mode === 'default') ||
359 (m === 'dead' && mode === 'dead') ||
360 (m === 'impact' && mode === 'impact');
361 el.className = 'cd2-mode-btn' + (isActive ? (mode === 'dead' ? ' active-red' : ' active') : '');
362 });
363
364 const svgEl = document.getElementById('cd2-sym-svg');
365 if (!svgEl) return;
366
367 if (mode === 'default') {
368 svgEl.removeAttribute('data-mode');
369 d3.selectAll('.sg-node').classed('sg-inactive', false);
370 d3.selectAll('.sg-edge').classed('inactive', false);
371 } else if (mode === 'dead') {
372 svgEl.setAttribute('data-mode', 'dead');
373 d3.selectAll<SVGGElement, SymNode>('.sg-node')
374 .classed('sg-inactive', d => !d.isDeadCode);
375 d3.selectAll('.sg-edge').classed('inactive', true);
376 } else if (mode === 'impact') {
377 svgEl.setAttribute('data-mode', 'impact');
378 if (_selectedNode) highlightImpact(_selectedNode);
379 else {
380 // Auto-pick first node with outgoing edges
381 const firstConnected = _nodes.find(n =>
382 _edges.some(e => (e.source as SymNode).id === n.id)
383 ) ?? _nodes[0];
384 if (firstConnected) selectNode(firstConnected);
385 }
386 }
387 }
388
389 function highlightImpact(node: SymNode): void {
390 // Transitive callers: walk edges backward from `node`
391 const impacted = new Set<string>([node.id]);
392 for (let depth = 0; depth < 4; depth++) {
393 _edges.forEach(e => {
394 const t = (e.target as SymNode).id;
395 const s = (e.source as SymNode).id;
396 if (impacted.has(t)) impacted.add(s);
397 });
398 }
399 d3.selectAll<SVGGElement, SymNode>('.sg-node')
400 .classed('sg-inactive', d => !impacted.has(d.id));
401 d3.selectAll<SVGLineElement, SymEdge>('.sg-edge')
402 .classed('inactive', e => {
403 const s = (e.source as SymNode).id, t = (e.target as SymNode).id;
404 return !impacted.has(s) && !impacted.has(t);
405 });
406 }
407
408 // ── Symbol info panel ─────────────────────────────────────────────────────────
409
410 function selectNode(node: SymNode): void {
411 _selectedNode = node;
412
413 // Highlight selected ring
414 d3.selectAll<SVGGElement, SymNode>('.sg-node')
415 .classed('sg-selected', d => d.id === node.id);
416
417 if (_graphMode === 'impact') highlightImpact(node);
418
419 const panel = document.getElementById('cd2-sym-info');
420 if (!panel) return;
421
422 const opCol = OP_COLOR[node.op] ?? OP_COLOR.unknown;
423 const kndCol = KIND_FILL[node.kind] ?? KIND_FILL.unknown;
424
425 const callees = _edges
426 .filter(e => (e.source as SymNode).id === node.id)
427 .map(e => (e.target as SymNode).label);
428 const callers = _edges
429 .filter(e => (e.target as SymNode).id === node.id)
430 .map(e => (e.source as SymNode).label);
431
432 const pill = (label: string, col: string, text: string) =>
433 `<span style="font-family:var(--font-mono);font-size:10px;padding:1px 6px;border-radius:3px;border:1px solid ${col}40;color:${col};background:${col}10">${text}</span>`;
434
435 panel.innerHTML = `
436 <div style="font-family:var(--font-mono);font-size:12px;color:var(--color-accent);margin-bottom:6px">${node.id.split('::').pop()}</div>
437 <div style="display:flex;gap:4px;flex-wrap:wrap;margin-bottom:6px">
438 ${pill('op', opCol, node.op)}
439 ${pill('knd', kndCol, node.kind)}
440 </div>
441 <div style="font-size:10px;color:var(--text-muted);margin-bottom:2px">file</div>
442 <div style="font-family:var(--font-mono);font-size:10px;color:var(--text-secondary);margin-bottom:6px">${node.file}</div>
443 ${node.summary ? `<div style="font-size:11px;color:var(--text-muted);line-height:1.4;margin-bottom:6px">${node.summary}</div>` : ''}
444 ${node.isDeadCode ? `<div style="color:#f87171;font-size:10px;margin-bottom:6px">⚠ dead code — 0 callers</div>` : ''}
445 ${callees.length ? `
446 <div style="font-size:9px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);margin-bottom:3px">calls →</div>
447 <div style="display:flex;flex-wrap:wrap;gap:2px;margin-bottom:4px">${callees.map(c =>
448 `<span style="font-family:var(--font-mono);font-size:9px;color:var(--color-success);padding:1px 5px;border-radius:3px;background:rgba(52,211,153,.08);border:1px solid rgba(52,211,153,.2)">${c}</span>`
449 ).join('')}</div>` : ''}
450 ${callers.length ? `
451 <div style="font-size:9px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);margin-bottom:3px">← callers</div>
452 <div style="display:flex;flex-wrap:wrap;gap:2px">${callers.map(c =>
453 `<span style="font-family:var(--font-mono);font-size:9px;color:#2dd4bf;padding:1px 5px;border-radius:3px;background:rgba(45,212,191,.08);border:1px solid rgba(45,212,191,.2)">${c}</span>`
454 ).join('')}</div>` : ''}
455 ${!callees.length && !callers.length ? `<div style="font-size:10px;color:var(--text-muted)">No call-graph edges in this commit.</div>` : ''}
456 `;
457 }
458
459 // ── Entry point ───────────────────────────────────────────────────────────────
460
461 export function initCommitDetail(): void {
462 // Expose mode setter for onclick attributes in template
463 (window as Window & { __sgSetMode?: (m: string) => void }).__sgSetMode = (m: string) => {
464 if (m === 'default' || m === 'dead' || m === 'impact') setMode(m);
465 };
466
467 const dataEl = document.getElementById('page-data');
468 if (!dataEl) return;
469
470 let data: PageData;
471 try {
472 data = JSON.parse(dataEl.textContent ?? '{}') as PageData;
473 } catch {
474 return;
475 }
476
477 if (data.structuredDelta?.ops?.length) {
478 renderGraph(data.structuredDelta);
479 }
480 }