@polycode-projects/seonix 0.2.1 → 0.3.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 +132 -23
- package/bin/cli.mjs +71 -17
- package/package.json +5 -3
- package/roslyn/Program.cs +79 -11
- package/src/ask-nlp.mjs +73 -0
- package/src/ask-vocab.mjs +172 -3
- package/src/ask.mjs +703 -79
- package/src/browser.mjs +12 -6
- package/src/chat.mjs +188 -0
- package/src/codegraph.mjs +159 -4
- package/src/cs_treesitter.mjs +57 -34
- package/src/embed.mjs +191 -0
- package/src/extract.mjs +220 -39
- package/src/java_treesitter.mjs +82 -55
- package/src/jsts_tsc.mjs +51 -4
- package/src/prose-nlp.mjs +52 -0
- package/src/schema-docs.mjs +29 -0
- package/src/server.mjs +27 -4
- package/src/sessions.mjs +206 -0
- package/src/timeline.mjs +117 -0
- package/src/viz.mjs +185 -64
package/src/timeline.mjs
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// timeline.mjs — render the repo's commit timeline to a single, portable HTML
|
|
2
|
+
// page from a seonix graph artifact. Third sibling of the viz viewer and the
|
|
3
|
+
// Chronograph code browser: generated by `seonix viz` (--timeline-out, on by
|
|
4
|
+
// default next to --out) and served live at /timeline.html by `viz --serve`.
|
|
5
|
+
//
|
|
6
|
+
// Data comes straight from the graph artifact: Commit individuals carry the full
|
|
7
|
+
// sha (id), author, date, and message; each Module's `derived_from` lists the
|
|
8
|
+
// commits that touched it (`git:<short sha>`), which gives per-commit churn.
|
|
9
|
+
// Session individuals (chat sessions recorded by sessions.mjs) join the same
|
|
10
|
+
// timeline as `type:"session"` entries at their started timestamp — a graph with
|
|
11
|
+
// zero sessions renders exactly as before.
|
|
12
|
+
|
|
13
|
+
const esc = (s) =>
|
|
14
|
+
String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
15
|
+
|
|
16
|
+
export function extractTimeline(graph) {
|
|
17
|
+
const commits = [];
|
|
18
|
+
const sessions = [];
|
|
19
|
+
const churnByShort = new Map(); // short sha -> modules touched
|
|
20
|
+
for (const ind of graph.individuals || []) {
|
|
21
|
+
const attr = (key) => ind.attributes?.find((a) => a.key === key)?.value || "";
|
|
22
|
+
if (ind.class === "Module") {
|
|
23
|
+
for (const ref of ind.derived_from || []) {
|
|
24
|
+
const short = ref.replace(/^git:/, "");
|
|
25
|
+
churnByShort.set(short, (churnByShort.get(short) || 0) + 1);
|
|
26
|
+
}
|
|
27
|
+
} else if (ind.class === "Commit") {
|
|
28
|
+
commits.push({
|
|
29
|
+
sha: ind.id.replace(/^commit:/, ""),
|
|
30
|
+
short: ind.label,
|
|
31
|
+
date: attr("date"),
|
|
32
|
+
author: attr("author"),
|
|
33
|
+
message: attr("message"),
|
|
34
|
+
});
|
|
35
|
+
} else if (ind.class === "Session") {
|
|
36
|
+
// chat sessions (sessions.mjs) enter the timeline at their started timestamp
|
|
37
|
+
sessions.push({
|
|
38
|
+
type: "session",
|
|
39
|
+
id: ind.id.replace(/^session:/, ""),
|
|
40
|
+
short: ind.label,
|
|
41
|
+
date: attr("started"),
|
|
42
|
+
turns: Number(attr("turns")) || 0,
|
|
43
|
+
touched: 0,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
for (const c of commits) c.touched = churnByShort.get(c.short) || 0;
|
|
48
|
+
const entries = [...commits, ...sessions];
|
|
49
|
+
entries.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
|
50
|
+
return entries;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `nav` is the shared {name: href} nav object the viz CLI computes from the
|
|
54
|
+
* actual sibling output paths — header links render ONLY from its entries, so
|
|
55
|
+
* a page generated without siblings never carries dead links. */
|
|
56
|
+
export function renderTimelineHtml({ commits, repoUrl = "", repoRef = "main", generatedAt = "", nav = null } = {}) {
|
|
57
|
+
const base = repoUrl.replace(/\/+$/, "");
|
|
58
|
+
const sessionCount = commits.filter((c) => c.type === "session").length;
|
|
59
|
+
const commitCount = commits.length - sessionCount;
|
|
60
|
+
const maxTouched = Math.max(1, ...commits.map((c) => c.touched));
|
|
61
|
+
// Bars run oldest→newest left to right; the list reads newest first. Chat
|
|
62
|
+
// sessions are the visually distinct entries (amber, fixed-height bars).
|
|
63
|
+
const bars = commits
|
|
64
|
+
.map((c, i) =>
|
|
65
|
+
c.type === "session"
|
|
66
|
+
? `<a class="bar sess" href="#c${i}" title="chat session ${esc(c.short)} · ${esc(c.date.slice(0, 10))} — ${c.turns} turn(s)"><i style="height:10px"></i></a>`
|
|
67
|
+
: `<a class="bar" href="#c${i}" title="${esc(c.short)} · ${esc(c.date.slice(0, 10))} — ${esc(c.message)}"><i style="height:${Math.max(6, Math.round((c.touched / maxTouched) * 64))}px"></i></a>`,
|
|
68
|
+
)
|
|
69
|
+
.join("");
|
|
70
|
+
const rows = commits
|
|
71
|
+
.map((c, i) => {
|
|
72
|
+
if (c.type === "session") {
|
|
73
|
+
return `<li id="c${i}" class="sess"><span class="d">${esc(c.date.slice(0, 10))}</span> <code>${esc(c.short)}</code> <span class="m">chat session — ${c.turns} turn(s)</span></li>`;
|
|
74
|
+
}
|
|
75
|
+
const sha = base
|
|
76
|
+
? `<a href="${base}/-/commit/${esc(c.sha)}" target="_blank" rel="noopener"><code>${esc(c.short)}</code></a>`
|
|
77
|
+
: `<code>${esc(c.short)}</code>`;
|
|
78
|
+
return `<li id="c${i}"><span class="d">${esc(c.date.slice(0, 10))}</span> ${sha} <span class="a">${esc(c.author)}</span> <span class="m">${esc(c.message)}</span> <span class="t" title="modules touched">${c.touched}⛁</span></li>`;
|
|
79
|
+
})
|
|
80
|
+
.reverse()
|
|
81
|
+
.join("\n");
|
|
82
|
+
const navLinks = nav
|
|
83
|
+
? Object.entries(nav)
|
|
84
|
+
.map(([name, href]) => `<a href="${esc(href)}">${name === "home" ? "← home" : esc(name)}</a>`)
|
|
85
|
+
.join("\n ")
|
|
86
|
+
: "";
|
|
87
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
88
|
+
<title>seonix — commit timeline</title>
|
|
89
|
+
<style>
|
|
90
|
+
html,body{margin:0;font:14px/1.5 system-ui,sans-serif;background:#1a1b26;color:#c0caf5}
|
|
91
|
+
header{padding:14px 20px;background:#16161e;border-bottom:1px solid #2a2e42;display:flex;gap:16px;align-items:baseline;flex-wrap:wrap}
|
|
92
|
+
header b{color:#7aa2f7} header a{color:#7aa2f7;text-decoration:none} header a:hover{text-decoration:underline}
|
|
93
|
+
header .meta{color:#565f89;font-size:12px;margin-left:auto}
|
|
94
|
+
#bars{display:flex;align-items:flex-end;gap:3px;padding:18px 20px 6px;overflow-x:auto}
|
|
95
|
+
.bar i{display:block;width:9px;background:#3d59a1;border-radius:2px 2px 0 0}
|
|
96
|
+
.bar:hover i{background:#7aa2f7}
|
|
97
|
+
ol{list-style:none;margin:6px 0 40px;padding:0 20px}
|
|
98
|
+
li{padding:6px 8px;border-bottom:1px solid #20222f;display:flex;gap:10px;align-items:baseline;flex-wrap:wrap}
|
|
99
|
+
li:target{background:#20253a;border-radius:4px}
|
|
100
|
+
.d{color:#565f89;font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
101
|
+
code{color:#9ece6a} li a{text-decoration:none} li a:hover code{text-decoration:underline}
|
|
102
|
+
.a{color:#a9b1d6;white-space:nowrap} .m{flex:1 1 24ch} .t{color:#bb9af7;white-space:nowrap}
|
|
103
|
+
.bar.sess i{background:#8a6a2f;border-radius:5px} .bar.sess:hover i{background:#e0af68}
|
|
104
|
+
li.sess{border-left:3px solid #e0af68;padding-left:5px} li.sess code,li.sess .m{color:#e0af68}
|
|
105
|
+
</style></head><body>
|
|
106
|
+
<header>
|
|
107
|
+
<span><b>seonix</b> commit timeline — ${commitCount} commits${sessionCount ? ` · ${sessionCount} chat session(s)` : ""}</span>
|
|
108
|
+
${navLinks}
|
|
109
|
+
${base ? `<a href="${base}" target="_blank" rel="noopener">repository ↗</a>` : ""}
|
|
110
|
+
<span class="meta">bars = modules touched per commit · generated from the seonix graph artifact${generatedAt ? ` (${esc(generatedAt.slice(0, 10))})` : ""}</span>
|
|
111
|
+
</header>
|
|
112
|
+
<div id="bars">${bars}</div>
|
|
113
|
+
<ol>
|
|
114
|
+
${rows}
|
|
115
|
+
</ol>
|
|
116
|
+
</body></html>`;
|
|
117
|
+
}
|
package/src/viz.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import { parseEntities, resolveSymbol, relationKind, siteOf } from "./codegraph.
|
|
|
25
25
|
import { loadConfig } from "./config.mjs";
|
|
26
26
|
import * as source from "./source.mjs";
|
|
27
27
|
import { buildTemporalGraph, buildBrowserData, gitCommitOrder, gitCommitParents, renderBrowserHtml } from "./browser.mjs";
|
|
28
|
+
import { extractTimeline, renderTimelineHtml } from "./timeline.mjs";
|
|
28
29
|
|
|
29
30
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
30
31
|
const execFileP = promisify(execFile);
|
|
@@ -134,7 +135,7 @@ export function buildSubgraph(graph, { focusId, depth = 2, hubDegree = 40, maxNo
|
|
|
134
135
|
|
|
135
136
|
/** The viewer's runtime payload — everything repo-specific lives here, nothing in
|
|
136
137
|
* the page. Pure; schema is part of the viewer contract (tests pin it). */
|
|
137
|
-
export function buildViewerData(subgraph, { focusLabel, repoUrl = "", repoRef = "main", siteNav = false } = {}) {
|
|
138
|
+
export function buildViewerData(subgraph, { focusLabel, repoUrl = "", repoRef = "main", siteNav = false, nav = null } = {}) {
|
|
138
139
|
return {
|
|
139
140
|
focusId: subgraph.focusId,
|
|
140
141
|
focusLabel: focusLabel || subgraph.focusId,
|
|
@@ -142,7 +143,8 @@ export function buildViewerData(subgraph, { focusLabel, repoUrl = "", repoRef =
|
|
|
142
143
|
maxDepth: subgraph.nodes.reduce((m, n) => Math.max(m, n.depth), 0),
|
|
143
144
|
repoUrl: repoUrl.replace(/\/+$/, ""),
|
|
144
145
|
repoRef,
|
|
145
|
-
siteNav: !!siteNav,
|
|
146
|
+
siteNav: !!siteNav, // legacy flag — stale deployed data keeps its absolute links
|
|
147
|
+
...(nav ? { nav } : {}), // {name: href} — the viewer rebuilds #sitenav from this
|
|
146
148
|
nodes: subgraph.nodes,
|
|
147
149
|
edges: subgraph.edges,
|
|
148
150
|
};
|
|
@@ -219,10 +221,10 @@ export function renderViewerHtml({ cytoscape, askEngine = "", embedData = null,
|
|
|
219
221
|
#bar label,.lbl{color:#a9b1d6} #bar b{color:#7aa2f7}
|
|
220
222
|
#bar button{background:#1a1b26;color:#c0caf5;border:1px solid #2a2e42;border-radius:4px;padding:2px 8px;cursor:pointer;font:inherit;line-height:1.2}
|
|
221
223
|
#bar button:hover{border-color:#7aa2f7;color:#7aa2f7}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
.
|
|
225
|
-
#hub{width:3.5em;background:#1a1b26;color:#c0caf5;border:1px solid #2a2e42;border-radius:4px;padding:1px 4px;font:inherit}
|
|
224
|
+
#bar button:disabled{opacity:.4;cursor:default}
|
|
225
|
+
#bar button:disabled:hover{border-color:#2a2e42;color:#c0caf5}
|
|
226
|
+
#depthval{min-width:1.2em;text-align:center;display:inline-block;cursor:help}
|
|
227
|
+
#hub,#beam{width:3.5em;background:#1a1b26;color:#c0caf5;border:1px solid #2a2e42;border-radius:4px;padding:1px 4px;font:inherit}
|
|
226
228
|
#legend{padding:4px 12px 6px;background:#16161e;border-bottom:1px solid #2a2e42;display:flex;flex-wrap:wrap;gap:2px 12px;font-size:12px;flex:0 0 auto}
|
|
227
229
|
#main{flex:1 1 auto;position:relative;min-height:0}
|
|
228
230
|
#cy{position:absolute;top:0;left:0;right:300px;bottom:0}
|
|
@@ -251,14 +253,16 @@ export function renderViewerHtml({ cytoscape, askEngine = "", embedData = null,
|
|
|
251
253
|
</style></head><body>
|
|
252
254
|
<div id="bar">
|
|
253
255
|
<span class="grp"><b>seon</b> focus: <b id="focuslabel">…</b></span>
|
|
254
|
-
<span class="grp"><span class="lbl">depth</span><
|
|
255
|
-
<span class="grp"><label title="hide nodes with more connections than this (hubs swamp the layout)"><input type="checkbox" id="hubon" checked> hide hubs deg> <input id="hub" type="number" min="2" max="200" value="16"></label
|
|
256
|
+
<span class="grp"><span class="lbl">depth</span><button id="depthdown" title="shallower">−</button><b id="depthval"></b><button id="depthup" title="deeper">+</button></span>
|
|
257
|
+
<span class="grp"><label title="hide nodes with more connections than this (hubs swamp the layout)"><input type="checkbox" id="hubon" checked> hide hubs deg> <input id="hub" type="number" min="2" max="200" value="16"></label>
|
|
258
|
+
<label title="beam-prune from the focus: at each hop keep only the top-N neighbours by degree (margin+cap prune) — a degree-scored analogue of the ask engine's beam search, NOT the same scorer"><input type="checkbox" id="beamon"> beam <input id="beam" type="number" min="1" max="32" value="8"></label></span>
|
|
256
259
|
<span class="grp"><label>labels <select id="verb"><option value="smart">smart</option><option value="name">all names</option><option value="site">name+site</option><option value="none">none</option></select></label>
|
|
257
260
|
<label>layout <select id="layout"><option value="cose">cose</option><option value="breadthfirst">tree</option><option value="concentric">concentric</option></select></label></span>
|
|
258
261
|
<span class="grp" id="view">
|
|
259
|
-
<button id="
|
|
262
|
+
<button id="fit" title="fit the whole graph in view (Esc)">fit</button>
|
|
260
263
|
<button id="zoomout" title="zoom out">−</button>
|
|
261
264
|
<button id="zoomin" title="zoom in">+</button>
|
|
265
|
+
<button id="reset" title="restore the boot view: default filters, all types shown, original focus">reset</button>
|
|
262
266
|
</span>
|
|
263
267
|
<span class="grp nav" id="sitenav" hidden><a href="/">home</a><a href="/code-browser.html">browser</a><a href="/timeline.html">timeline</a></span>
|
|
264
268
|
</div>
|
|
@@ -321,19 +325,27 @@ function loadAskData(){
|
|
|
321
325
|
function init(G){
|
|
322
326
|
document.title='seon code-map — '+G.focusLabel;
|
|
323
327
|
$('focuslabel').textContent=G.focusLabel;
|
|
324
|
-
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
328
|
+
// nav rides the data payload ({name: href}, hrefs relative to THIS page);
|
|
329
|
+
// G.siteNav is the legacy flag — stale deployed data keeps its absolute links.
|
|
330
|
+
if(G.nav){$('sitenav').innerHTML=Object.entries(G.nav).map(([k,h])=>'<a href="'+esc(h)+'">'+esc(k)+'</a>').join('');$('sitenav').hidden=false;}
|
|
331
|
+
else if(G.siteNav)$('sitenav').hidden=false;
|
|
332
|
+
// Legend doubles as a node-type filter: a checkbox chip per type GROUP —
|
|
333
|
+
// Function/Method share a chip (legend-only merge; node fills stay distinct),
|
|
334
|
+
// with a live visible/loaded count summed across the group.
|
|
335
|
+
const GROUPS=[['Module'],['Class'],['Function','Method'],['Attribute'],['GlobalVariable'],['Commit']];
|
|
336
|
+
$('legend').innerHTML=GROUPS
|
|
337
|
+
.map(g=>'<label class="lg" title="show/hide '+g.join('/')+' nodes"><input type="checkbox" class="typechk" data-cls="'+g.join(',')+'" checked>'+g.map(k=>'<i style="background:'+COLORS[k]+'"></i>').join('')+g.join('/')+'<span class="cnt" data-cnt="'+g.join(',')+'"></span></label>')
|
|
330
338
|
.join(' ');
|
|
331
|
-
// depth is a
|
|
332
|
-
const maxDepth=Math.max(
|
|
333
|
-
|
|
334
|
-
|
|
339
|
+
// depth is a − [N] + stepper clamped to the DATA's max depth (no dead buttons), default 2
|
|
340
|
+
const maxDepth=Math.max(1,G.maxDepth);
|
|
341
|
+
let depthVal=Math.min(2,maxDepth);
|
|
342
|
+
$('depthval').title='captured to depth '+maxDepth+' — regenerate with --depth for more';
|
|
343
|
+
function syncDepthUi(){$('depthval').textContent=depthVal;$('depthdown').disabled=depthVal<=1;$('depthup').disabled=depthVal>=maxDepth;}
|
|
344
|
+
syncDepthUi();
|
|
345
|
+
const INITIAL_FOCUS=G.focusId;
|
|
335
346
|
const cy=cytoscape({container:document.getElementById('cy'),
|
|
336
|
-
|
|
347
|
+
// depth0 caches the boot depth per node BEFORE any recentre mutates data('depth') — the reset button restores from it
|
|
348
|
+
elements:[...G.nodes.map(n=>({data:{...n,depth0:n.depth,color:COLORS[n.cls]||'#8a8f98'}})),...G.edges.map((e,i)=>({data:{id:'e'+i,...e}}))],
|
|
337
349
|
style:[
|
|
338
350
|
// black text outline keeps white labels readable over node fills and the dark canvas;
|
|
339
351
|
// node size encodes degree (bounded) so hubs read as hubs at a glance.
|
|
@@ -355,26 +367,53 @@ function init(G){
|
|
|
355
367
|
let selId=null;
|
|
356
368
|
function enabledTypes(){
|
|
357
369
|
const on=new Set();
|
|
358
|
-
document.querySelectorAll('.typechk').forEach(c=>{if(c.checked)
|
|
370
|
+
document.querySelectorAll('.typechk').forEach(c=>{if(c.checked)c.dataset.cls.split(',').forEach(t=>on.add(t));});
|
|
359
371
|
return on;
|
|
360
372
|
}
|
|
361
|
-
function curDepth(){return
|
|
373
|
+
function curDepth(){return depthVal;}
|
|
362
374
|
function curHub(){return $('hubon').checked ? +$('hub').value : Infinity;}
|
|
375
|
+
const NODE=new Map(G.nodes.map(n=>[n.id,n]));
|
|
376
|
+
// Beam mode: BFS from the focus over the loaded adjacency, pruning each node's
|
|
377
|
+
// candidate neighbours (those passing hub+type filters) to the top-width by
|
|
378
|
+
// DEGREE with margin deg >= best*0.5 — mirrors BEAM_MARGIN_FRAC=0.5 in
|
|
379
|
+
// codegraph.mjs, but degree-scored: an analogue of the ask engine's beam
|
|
380
|
+
// search, not the same scorer.
|
|
381
|
+
function beamSet(width,h,types){
|
|
382
|
+
const pass=id=>{const n=NODE.get(id);return !!n&&(id===G.focusId||n.degree<=h)&&types.has(n.cls);};
|
|
383
|
+
const seen=new Set([G.focusId]);
|
|
384
|
+
let frontier=[G.focusId];
|
|
385
|
+
while(frontier.length){
|
|
386
|
+
const next=[];
|
|
387
|
+
for(const id of frontier){
|
|
388
|
+
const cand=(ADJ[id]||[]).filter(nb=>!seen.has(nb)&&pass(nb))
|
|
389
|
+
.sort((a,b)=>NODE.get(b).degree-NODE.get(a).degree);
|
|
390
|
+
const best=cand.length?NODE.get(cand[0]).degree:0;
|
|
391
|
+
for(const nb of cand.slice(0,width)){
|
|
392
|
+
if(NODE.get(nb).degree>=best*0.5){seen.add(nb);next.push(nb);}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
frontier=next;
|
|
396
|
+
}
|
|
397
|
+
return seen;
|
|
398
|
+
}
|
|
363
399
|
function applyFilters(){
|
|
364
400
|
const d=curDepth(), h=curHub(), types=enabledTypes();
|
|
401
|
+
const beam=$('beamon').checked?beamSet(Math.max(1,Math.min(32,+$('beam').value||8)),h,types):null;
|
|
365
402
|
const vis={}, tot={};
|
|
366
403
|
cy.batch(()=>cy.nodes().forEach(n=>{
|
|
367
404
|
const cls=n.data('cls');
|
|
368
405
|
tot[cls]=(tot[cls]||0)+1;
|
|
369
|
-
const v =
|
|
406
|
+
const v = beam ? beam.has(n.id())
|
|
407
|
+
: n.data('depth')<=d && (n.id()===G.focusId || n.data('degree')<=h) && types.has(cls);
|
|
370
408
|
if(v) vis[cls]=(vis[cls]||0)+1;
|
|
371
409
|
n.style('display', v?'element':'none');
|
|
372
410
|
}));
|
|
373
|
-
// live per-
|
|
411
|
+
// live per-group counts: visible/loaded summed across the group's classes
|
|
374
412
|
document.querySelectorAll('.cnt').forEach(s=>{
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
s.
|
|
413
|
+
const ks=s.dataset.cnt.split(',');
|
|
414
|
+
const v=ks.reduce((a,k)=>a+(vis[k]||0),0), t=ks.reduce((a,k)=>a+(tot[k]||0),0);
|
|
415
|
+
s.textContent=v+'/'+t;
|
|
416
|
+
s.title=v+' visible of '+t+' loaded '+ks.join('/')+' node(s) at depth '+d+(isFinite(h)?', hide deg>'+h:'');
|
|
378
417
|
});
|
|
379
418
|
applyLabels();
|
|
380
419
|
}
|
|
@@ -397,18 +436,39 @@ function init(G){
|
|
|
397
436
|
}
|
|
398
437
|
cy.on('mouseover','node',e=>{if($('verb').value==='smart')e.target.style('label',labelText(e.target,false));});
|
|
399
438
|
cy.on('mouseout','node',e=>{if($('verb').value==='smart')applyLabels();});
|
|
400
|
-
|
|
401
|
-
|
|
439
|
+
const stepDepth=dir=>{const d=Math.min(maxDepth,Math.max(1,depthVal+dir));if(d===depthVal)return;depthVal=d;syncDepthUi();applyFilters();};
|
|
440
|
+
$('depthdown').addEventListener('click',()=>stepDepth(-1));
|
|
441
|
+
$('depthup').addEventListener('click',()=>stepDepth(1));
|
|
402
442
|
$('hubon').addEventListener('change',applyFilters);
|
|
403
443
|
$('hub').addEventListener('input',applyFilters);
|
|
444
|
+
$('beamon').addEventListener('change',applyFilters);
|
|
445
|
+
$('beam').addEventListener('input',applyFilters);
|
|
404
446
|
document.querySelectorAll('.typechk').forEach(c=>c.addEventListener('change',applyFilters));
|
|
405
447
|
$('verb').addEventListener('change',applyLabels);
|
|
406
448
|
$('layout').addEventListener('change',()=>cy.layout({name:$('layout').value,animate:false}).run());
|
|
407
449
|
const ZF=1.2;
|
|
408
450
|
const zoomBy=(f)=>cy.zoom({level:cy.zoom()*f,renderedPosition:{x:cy.width()/2,y:cy.height()/2}});
|
|
409
|
-
$('
|
|
451
|
+
$('fit').addEventListener('click',()=>{cy.fit(undefined,30);});
|
|
410
452
|
$('zoomin').addEventListener('click',()=>zoomBy(ZF));
|
|
411
453
|
$('zoomout').addEventListener('click',()=>zoomBy(1/ZF));
|
|
454
|
+
// real reset: back to the boot view — defaults, all types on, depths from
|
|
455
|
+
// depth0 (the pre-recentre capture), focus ring on the boot focus.
|
|
456
|
+
$('reset').addEventListener('click',()=>{
|
|
457
|
+
if($('layout').value!=='cose'){$('layout').value='cose';cy.layout({name:'cose',animate:false}).run();}
|
|
458
|
+
$('verb').value='smart';
|
|
459
|
+
$('hubon').checked=true;$('hub').value=16;
|
|
460
|
+
$('beamon').checked=false;$('beam').value=8;
|
|
461
|
+
depthVal=Math.min(2,maxDepth);syncDepthUi();
|
|
462
|
+
document.querySelectorAll('.typechk').forEach(c=>{c.checked=true;});
|
|
463
|
+
cy.getElementById(G.focusId).removeClass('focus');
|
|
464
|
+
G.focusId=INITIAL_FOCUS;
|
|
465
|
+
cy.batch(()=>cy.nodes().forEach(n=>n.data('depth',n.data('depth0'))));
|
|
466
|
+
cy.getElementById(INITIAL_FOCUS).addClass('focus');
|
|
467
|
+
$('focuslabel').textContent=G.focusLabel;
|
|
468
|
+
applyFilters();
|
|
469
|
+
select(null);
|
|
470
|
+
cy.fit(undefined,30);
|
|
471
|
+
});
|
|
412
472
|
// Deep link into the source host (GitLab URL layout) when the data carries a
|
|
413
473
|
// repoUrl: Commit nodes -> /-/commit/<sha>, anything with a file site ->
|
|
414
474
|
// /-/blob/<ref>/<path>#L<lines>, bare Modules -> the file blob.
|
|
@@ -592,6 +652,8 @@ export async function startVizServer({ values, cytoscape, askEngine = "", log =
|
|
|
592
652
|
const repoUrl = values["repo-url"] || (await repoUrlFromGit(process.cwd()));
|
|
593
653
|
const viewer = renderViewerHtml({ cytoscape, askEngine });
|
|
594
654
|
const browserPage = await renderBrowserHtml({ cytoscape });
|
|
655
|
+
// nav = the routes this server itself serves; injected into every payload/page
|
|
656
|
+
const nav = { home: "/", graph: "/seonix-graph.html", browser: "/code-browser.html", timeline: "/timeline.html" };
|
|
595
657
|
const buildData = async () => {
|
|
596
658
|
const { graph } = await loadGraph(values);
|
|
597
659
|
const focus = resolveFocus(graph, values.focus);
|
|
@@ -602,7 +664,7 @@ export async function startVizServer({ values, cytoscape, askEngine = "", log =
|
|
|
602
664
|
hubDegree: Math.max(2, parseInt(values.hub, 10) || 40),
|
|
603
665
|
maxNodes: Math.max(2, parseInt(values.max, 10) || 200),
|
|
604
666
|
});
|
|
605
|
-
return buildViewerData(subgraph, { focusLabel: focus.label, repoUrl, repoRef: values.ref });
|
|
667
|
+
return buildViewerData(subgraph, { focusLabel: focus.label, repoUrl, repoRef: values.ref, nav });
|
|
606
668
|
};
|
|
607
669
|
// The chat panel's own channel — the FULL raw graph (not the depth-limited display
|
|
608
670
|
// sub-graph above), rebuilt fresh per request so a re-index shows up without a
|
|
@@ -620,7 +682,17 @@ export async function startVizServer({ values, cytoscape, askEngine = "", log =
|
|
|
620
682
|
gitCommitParents(process.cwd()), // P3 ghost-branch merges
|
|
621
683
|
]);
|
|
622
684
|
const tg = buildTemporalGraph(raw, order, { scope: values.scope, parentsBySha });
|
|
623
|
-
return buildBrowserData(tg, { repoUrl, repoRef: values.ref, live: true, gitHead: await gitHeadOf(process.cwd()) });
|
|
685
|
+
return buildBrowserData(tg, { repoUrl, repoRef: values.ref, live: true, gitHead: await gitHeadOf(process.cwd()), nav });
|
|
686
|
+
};
|
|
687
|
+
const buildTimelinePage = async () => {
|
|
688
|
+
const { raw } = await loadGraph(values);
|
|
689
|
+
return renderTimelineHtml({
|
|
690
|
+
commits: extractTimeline(raw),
|
|
691
|
+
repoUrl,
|
|
692
|
+
repoRef: values.ref,
|
|
693
|
+
generatedAt: raw.generated_at || "",
|
|
694
|
+
nav,
|
|
695
|
+
});
|
|
624
696
|
};
|
|
625
697
|
await buildData(); // fail fast (missing graph, bad focus) before binding the port
|
|
626
698
|
const server = createServer(async (req, res) => {
|
|
@@ -631,6 +703,16 @@ export async function startVizServer({ values, cytoscape, askEngine = "", log =
|
|
|
631
703
|
} else if (path === "/browser" || path === "/code-browser.html") {
|
|
632
704
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
633
705
|
res.end(browserPage);
|
|
706
|
+
} else if (path === "/timeline" || path === "/timeline.html") {
|
|
707
|
+
// rendered from a fresh graph load, same re-index-shows-on-refresh invariant
|
|
708
|
+
try {
|
|
709
|
+
const page = await buildTimelinePage();
|
|
710
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
711
|
+
res.end(page);
|
|
712
|
+
} catch (err) {
|
|
713
|
+
res.writeHead(500, { "content-type": "text/plain" });
|
|
714
|
+
res.end(`failed to render the timeline: ${String(err.message || err)}`);
|
|
715
|
+
}
|
|
634
716
|
} else if (path === "/seonix-graph-data.json") {
|
|
635
717
|
try {
|
|
636
718
|
res.writeHead(200, { "content-type": "application/json" });
|
|
@@ -693,9 +775,12 @@ export async function runVizCli(argv) {
|
|
|
693
775
|
"site-nav": { type: "boolean", default: false },
|
|
694
776
|
serve: { type: "boolean", default: false },
|
|
695
777
|
port: { type: "string", default: "0" },
|
|
696
|
-
// Chronograph (code browser) artifacts —
|
|
778
|
+
// Chronograph (code browser) + timeline artifacts — generated next to --out
|
|
779
|
+
// by default; --graph-only suppresses both. See src/browser.mjs, src/timeline.mjs.
|
|
697
780
|
"browser-out": { type: "string" },
|
|
698
781
|
"browser-data-out": { type: "string" },
|
|
782
|
+
"timeline-out": { type: "string" },
|
|
783
|
+
"graph-only": { type: "boolean", default: false },
|
|
699
784
|
scope: { type: "string", default: "product" },
|
|
700
785
|
},
|
|
701
786
|
allowPositionals: false,
|
|
@@ -721,13 +806,30 @@ export async function runVizCli(argv) {
|
|
|
721
806
|
hubDegree: Math.max(2, parseInt(values.hub, 10) || 40),
|
|
722
807
|
maxNodes: Math.max(2, parseInt(values.max, 10) || 200),
|
|
723
808
|
});
|
|
809
|
+
const out = resolve(values.out);
|
|
810
|
+
// Siblings by default: the code browser and the commit timeline land next to
|
|
811
|
+
// --out unless --graph-only. Nav hrefs are computed from the ACTUAL output
|
|
812
|
+
// paths (filenames vary: seon-graph.html locally, seonix-graph.html on site);
|
|
813
|
+
// --site-nav additionally adds the site's absolute home entry.
|
|
814
|
+
const browserOut = values["graph-only"] ? null : resolve(values["browser-out"] || join(dirname(out), "code-browser.html"));
|
|
815
|
+
const timelineOut = values["graph-only"] ? null : resolve(values["timeline-out"] || join(dirname(out), "timeline.html"));
|
|
816
|
+
const relHref = (from, to) => {
|
|
817
|
+
const r = relative(dirname(from), to);
|
|
818
|
+
return r.startsWith(".") ? r : `./${r}`;
|
|
819
|
+
};
|
|
820
|
+
const navFor = (from) => ({
|
|
821
|
+
...(values["site-nav"] ? { home: "/" } : {}),
|
|
822
|
+
graph: relHref(from, out),
|
|
823
|
+
browser: relHref(from, browserOut),
|
|
824
|
+
timeline: relHref(from, timelineOut),
|
|
825
|
+
});
|
|
724
826
|
const data = buildViewerData(subgraph, {
|
|
725
827
|
focusLabel: focus.label,
|
|
726
828
|
repoUrl: values["repo-url"],
|
|
727
829
|
repoRef: values.ref,
|
|
728
830
|
siteNav: values["site-nav"],
|
|
831
|
+
nav: values["graph-only"] ? null : navFor(out),
|
|
729
832
|
});
|
|
730
|
-
const out = resolve(values.out);
|
|
731
833
|
if (values["data-out"]) {
|
|
732
834
|
// site mode: viewer page + sibling data file (data updates don't touch the page).
|
|
733
835
|
// The chat panel's full-graph channel rides the same sibling-file convention,
|
|
@@ -755,36 +857,55 @@ export async function runVizCli(argv) {
|
|
|
755
857
|
);
|
|
756
858
|
}
|
|
757
859
|
|
|
758
|
-
//
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
860
|
+
// Sibling artifacts (code browser + commit timeline) — best-effort: a missing
|
|
861
|
+
// git history (or any other failure here) warns but never kills the graph
|
|
862
|
+
// artifact already written above.
|
|
863
|
+
if (browserOut || timelineOut) {
|
|
864
|
+
try {
|
|
865
|
+
const repoUrl = values["repo-url"] || (await repoUrlFromGit(process.cwd()));
|
|
866
|
+
if (browserOut) {
|
|
867
|
+
const commitIds = (raw.individuals || []).filter((i) => i.class === "Commit").map((i) => i.id);
|
|
868
|
+
const [order, parentsBySha] = await Promise.all([
|
|
869
|
+
gitCommitOrder(process.cwd(), commitIds),
|
|
870
|
+
gitCommitParents(process.cwd()), // P3 ghost-branch merges — static data, no polling here
|
|
871
|
+
]);
|
|
872
|
+
const tg = buildTemporalGraph(raw, order, { scope: values.scope, parentsBySha });
|
|
873
|
+
const browserData = buildBrowserData(tg, {
|
|
874
|
+
repoUrl,
|
|
875
|
+
repoRef: values.ref,
|
|
876
|
+
siteNav: values["site-nav"],
|
|
877
|
+
nav: navFor(browserOut),
|
|
878
|
+
});
|
|
879
|
+
if (values["browser-data-out"]) {
|
|
880
|
+
const bDataOut = resolve(values["browser-data-out"]);
|
|
881
|
+
const rel = relative(dirname(browserOut), bDataOut) || "code-browser-data.json";
|
|
882
|
+
await writeFile(bDataOut, JSON.stringify(browserData));
|
|
883
|
+
await writeFile(browserOut, await renderBrowserHtml({ cytoscape, dataPath: rel === "code-browser-data.json" ? null : rel }));
|
|
884
|
+
process.stderr.write(
|
|
885
|
+
`seonix viz: chronograph ${tg.nodes.length} nodes, ${tg.edges.length} edges, ${tg.commits.length} commits ` +
|
|
886
|
+
`(scope ${values.scope}) → ${browserOut} + ${bDataOut}\n`,
|
|
887
|
+
);
|
|
888
|
+
} else {
|
|
889
|
+
await writeFile(browserOut, await renderBrowserHtml({ cytoscape, embedData: browserData }));
|
|
890
|
+
process.stderr.write(
|
|
891
|
+
`seonix viz: chronograph ${tg.nodes.length} nodes, ${tg.edges.length} edges, ${tg.commits.length} commits ` +
|
|
892
|
+
`(scope ${values.scope}) → ${browserOut}\n`,
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
if (timelineOut) {
|
|
897
|
+
const commits = extractTimeline(raw);
|
|
898
|
+
await writeFile(timelineOut, renderTimelineHtml({
|
|
899
|
+
commits,
|
|
900
|
+
repoUrl,
|
|
901
|
+
repoRef: values.ref,
|
|
902
|
+
generatedAt: raw.generated_at || "",
|
|
903
|
+
nav: navFor(timelineOut),
|
|
904
|
+
}));
|
|
905
|
+
process.stderr.write(`seonix viz: timeline ${commits.length} commits → ${timelineOut}\n`);
|
|
906
|
+
}
|
|
907
|
+
} catch (err) {
|
|
908
|
+
process.stderr.write(`seonix viz: warning — sibling pages (browser/timeline) failed: ${String(err.message || err)}; the graph artifact is intact\n`);
|
|
788
909
|
}
|
|
789
910
|
}
|
|
790
911
|
}
|