Files
cex/frontend/graph.ts
2026-09-07 15:52:46 -04:00

179 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { invoke } from '@tauri-apps/api/core';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import dagre from '@dagrejs/dagre';
type Location = { path: string; range: { start: { line: number; character: number }; end: { line: number; character: number } }; label: string };
type Node = { id: string; label: string; kind: string; detail: string; location: Location };
type GraphOptions = { depth: number; excluded_namespaces: string[] };
type Graph = { title: string; nodes: Node[]; edges: { from: string; to: string; label: string }[]; warnings: string[] };
type Options = { workspace: () => { root: string; settings?: { graph?: GraphOptions } } | null; path: () => string; epoch: () => number; ready: () => Promise<unknown>; open: (path: string, hit: { path: string; line: number; column: number; end_column: number }, semantic?: boolean) => Promise<void> };
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
const ns = 'http://www.w3.org/2000/svg';
function svg<K extends keyof SVGElementTagNameMap>(tag: K, attrs: Record<string,string> = {}) { const el = document.createElementNS(ns, tag); for (const [k,v] of Object.entries(attrs)) el.setAttribute(k,v); return el; }
export function installGraph(editor: monaco.editor.IStandaloneCodeEditor, options: Options) {
const pane = $('explanation-pane');
const chat = document.createElement('div'); chat.id = 'chat-panel'; chat.setAttribute('role','tabpanel');
chat.append(...pane.childNodes); pane.append(chat);
const tabs = document.createElement('div'); tabs.className = 'sidebar-tabs';
tabs.innerHTML = '<div role="tablist" aria-label="Right sidebar"><button id="chat-tab" role="tab" aria-controls="chat-panel" aria-selected="true">Chat</button><button id="graph-tab" role="tab" aria-controls="graph-panel" aria-selected="false">Graph</button></div>';
const close = $('close-explanation'); close.setAttribute('aria-label','Collapse right sidebar'); close.title = 'Collapse right sidebar'; close.classList.add('icon-button'); tabs.append(close); pane.prepend(tabs);
const panel = document.createElement('div'); panel.id = 'graph-panel'; panel.hidden = true; panel.setAttribute('role','tabpanel');
panel.innerHTML = `<div class="graph-tools"><strong id="graph-title">Symbol graph</strong><button id="graph-refresh">Graph selected symbol</button><button id="graph-expand" title="Expand graph panel" aria-label="Expand graph panel" aria-pressed="false">⛶</button></div>
<details id="graph-options"><summary>Graph options <span id="graph-options-summary"></span></summary>
<label for="graph-depth">Graph depth</label><select id="graph-depth"><option value="1">1 · immediate neighbors</option><option value="2">2 levels</option><option value="3">3 levels</option><option value="4">4 levels</option></select>
<p class="hint">Independent of LLM context. Higher depths may take longer.</p>
<label for="graph-namespace">Excluded namespaces</label><div id="graph-exclusions"></div><div class="field-row"><input id="graph-namespace" placeholder="library::detail" autocomplete="off"><button id="graph-add">Add</button></div><button id="graph-apply">Apply to graph</button><p id="graph-options-status" class="hint" role="status"></p>
</details><div id="graph-progress" hidden role="status" aria-live="polite"><span class="graph-spinner" aria-hidden="true"></span><span>Building graph…</span><progress aria-label="Building graph"></progress></div><p id="graph-status" class="hint" role="status">Select a concept, function, class, or struct name in the editor.</p>
<div class="graph-tools"><button id="graph-back" aria-label="Previous graph" disabled>←</button><button id="graph-forward" aria-label="Next graph" disabled>→</button><button id="graph-fit">Fit</button><button id="graph-center">Center symbol</button><button id="graph-out" aria-label="Zoom out graph"></button><button id="graph-in" aria-label="Zoom in graph">+</button><span id="graph-zoom" class="hint">100%</span></div>
<div id="graph-canvas" tabindex="0" aria-label="Symbol relationship graph"></div><div id="graph-detail" class="hint"></div><details id="graph-warning-details"><summary>Coverage and limits</summary><div id="graph-warnings" class="hint"></div></details>`;
pane.append(panel);
let active: 'chat' | 'graph' = 'chat', graph: Graph | null = null, ticket = 0, scale = 1, needsFit = false;
let drawing: SVGSVGElement | null = null, size = { width: 1, height: 1 };
let graphOptions: GraphOptions = { depth: 1, excluded_namespaces: ['std'] };
let center: Location | undefined;
const back: Location[] = [], forward: Location[] = [];
let layoutPositions = new Map<string, {x:number; y:number}>();
const canvas = $('graph-canvas');
function historyControls() { $<HTMLButtonElement>('graph-back').disabled = !back.length; $<HTMLButtonElement>('graph-forward').disabled = !forward.length; }
function settingsUI() {
$<HTMLSelectElement>('graph-depth').value = String(graphOptions.depth);
$('graph-options-summary').textContent = `· depth ${graphOptions.depth} · ${graphOptions.excluded_namespaces.length} excluded`;
$('graph-exclusions').replaceChildren(...graphOptions.excluded_namespaces.map(name => {
const chip = document.createElement('button'); chip.textContent = `${name} ×`; chip.setAttribute('aria-label', `Remove namespace ${name}`);
chip.onclick = () => { graphOptions.excluded_namespaces = graphOptions.excluded_namespaces.filter(n => n !== name); settingsUI(); };
return chip;
}));
}
$('graph-depth').onchange = () => { graphOptions.depth = Number($<HTMLSelectElement>('graph-depth').value); settingsUI(); };
$('graph-add').onclick = () => {
const input = $<HTMLInputElement>('graph-namespace'), name = input.value.trim().replace(/^::/, '').replace(/::$/, '');
if (!/^[A-Za-z_][A-Za-z_0-9]*(::[A-Za-z_][A-Za-z_0-9]*)*$/.test(name) || name.length > 200 || graphOptions.excluded_namespaces.length >= 32) { $('graph-options-status').textContent = 'Enter a namespace such as std or library::detail (up to 32 entries).'; return; }
if (!graphOptions.excluded_namespaces.includes(name)) graphOptions.excluded_namespaces.push(name);
input.value = ''; $('graph-options-status').textContent = ''; settingsUI();
};
$('graph-namespace').onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); $('graph-add').click(); } };
$('graph-apply').onclick = async () => {
const workspace = options.workspace(); if (!workspace) return;
const epoch = options.epoch(), saved = structuredClone(graphOptions);
$<HTMLButtonElement>('graph-apply').disabled = true;
try {
await invoke('save_graph_options', { root: workspace.root, options: saved });
if (options.epoch() !== epoch) return;
if (workspace.settings) workspace.settings.graph = saved;
$('graph-options-status').textContent = 'Saved for this project.';
await refresh(center, false);
} catch (error) { if (options.epoch() === epoch) $('graph-options-status').textContent = String(error); }
finally { $<HTMLButtonElement>('graph-apply').disabled = false; }
};
$('graph-expand').onclick = () => { const expanded = pane.classList.toggle('graph-expanded'); $('graph-expand').setAttribute('aria-pressed', String(expanded)); editor.layout(); };
settingsUI();
const toggle = $<HTMLButtonElement>('toggle-sidebar');
function show(tab: 'chat' | 'graph') {
active = tab; if (tab === 'chat') { pane.classList.remove('graph-expanded'); $('graph-expand').setAttribute('aria-pressed','false'); } pane.hidden = false; document.querySelector('main')!.classList.add('explaining');
chat.hidden = tab !== 'chat'; panel.hidden = tab !== 'graph';
$('chat-tab').setAttribute('aria-selected', String(tab === 'chat')); $('graph-tab').setAttribute('aria-selected', String(tab === 'graph'));
toggle.setAttribute('aria-expanded','true'); editor.layout(); if (tab === 'graph' && needsFit) fit();
}
function collapse() { pane.classList.remove('graph-expanded'); $('graph-expand').setAttribute('aria-pressed','false'); pane.hidden = true; document.querySelector('main')!.classList.remove('explaining'); toggle.setAttribute('aria-expanded','false'); editor.layout(); }
$('chat-tab').onclick = () => show('chat');
$('graph-tab').onclick = () => { show('graph'); if (!graph) void refresh(); };
for (const [id, other] of [['chat-tab','graph-tab'],['graph-tab','chat-tab']]) $(id).onkeydown = event => { if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); $(other).focus(); $(other).click(); } };
close.onclick = collapse;
toggle.onclick = () => { if (pane.hidden) show(active); else collapse(); };
pane.addEventListener('cex-show-chat', () => show('chat'));
function zoom(next: number) {
const old = scale; scale = Math.max(0.12, Math.min(3,next));
if (drawing) {
const x = canvas.scrollLeft + canvas.clientWidth / 2, y = canvas.scrollTop + canvas.clientHeight / 2;
drawing.setAttribute('width',String(size.width*scale)); drawing.setAttribute('height',String(size.height*scale));
canvas.scrollLeft = x * scale / old - canvas.clientWidth / 2; canvas.scrollTop = y * scale / old - canvas.clientHeight / 2;
}
$('graph-zoom').textContent = `${Math.round(scale*100)}%`;
}
function fit(readable = false) { if (pane.hidden || panel.hidden) { needsFit = true; return; } needsFit = false; zoom(Math.max(readable ? .65 : .12, Math.min(1, Math.max(100,canvas.clientWidth-24)/size.width, Math.max(100,canvas.clientHeight-24)/size.height))); }
function centerSymbol() { const p = layoutPositions.get('selected'); if (p) { canvas.scrollLeft = p.x*scale-canvas.clientWidth/2; canvas.scrollTop = p.y*scale-canvas.clientHeight/2; } }
$('graph-fit').onclick = () => fit(); $('graph-center').onclick = () => { zoom(1); centerSymbol(); };
$('graph-in').onclick = () => zoom(scale*1.25); $('graph-out').onclick = () => zoom(scale/1.25);
canvas.addEventListener('wheel', event => { if (event.ctrlKey || event.metaKey) { event.preventDefault(); zoom(scale * (event.deltaY < 0 ? 1.1 : 1/1.1)); } }, { passive: false });
let drag: {x:number; y:number; left:number; top:number} | null = null;
canvas.onpointerdown = e => { if (e.button || (e.target as Element).closest('.graph-node')) return; drag={x:e.clientX,y:e.clientY,left:canvas.scrollLeft,top:canvas.scrollTop};canvas.setPointerCapture(e.pointerId);canvas.classList.add('dragging'); };
canvas.onpointermove = e => { if (drag) {canvas.scrollLeft=drag.left+drag.x-e.clientX;canvas.scrollTop=drag.top+drag.y-e.clientY;} };
canvas.onpointerup = canvas.onpointercancel = () => {drag=null;canvas.classList.remove('dragging');};
canvas.onkeydown = e => { if (e.key === '+' || e.key === '=') {e.preventDefault();zoom(scale*1.25);} else if (e.key === '-') {e.preventDefault();zoom(scale/1.25);} else if (e.key === '0') {e.preventDefault();fit();} else if (e.key === 'Home') {e.preventDefault();centerSymbol();} };
function select(node: Node) {
canvas.querySelectorAll('.graph-node').forEach(el => el.classList.toggle('focused', el.getAttribute('data-node') === node.id));
$('graph-detail').replaceChildren();
const pre = document.createElement('pre'); pre.textContent = node.detail;
const open = document.createElement('button'); open.textContent = 'Open source';
const p = node.location.range.start;
open.onclick = () => void options.open(node.location.path, { path: node.location.path, line:p.line+1, column:p.character+1,end_column:p.character+1 },true);
$('graph-detail').append(pre,open);
if (node.kind === 'concept' || node.kind === 'selected' || node.kind === 'function' || node.kind === 'type') { const focus = document.createElement('button'); focus.textContent = node.kind === 'concept' ? 'Focus this concept' : 'Focus this symbol'; focus.onclick = () => void refresh(node.location); $('graph-detail').append(focus); }
}
function render(value: Graph) {
const layout = new dagre.graphlib.Graph().setGraph({ rankdir:'TB', nodesep:32, ranksep:54, marginx:24, marginy:24 }).setDefaultEdgeLabel(() => ({}));
for (const node of value.nodes) layout.setNode(node.id,{ width:260,height:92 });
for (const edge of value.edges) layout.setEdge(edge.from,edge.to,{ label:edge.label, width:edge.label?110:0,height:16 });
dagre.layout(layout); layoutPositions = new Map(value.nodes.map(n => [n.id, layout.node(n.id)])); size = { width:layout.graph().width || 1,height:layout.graph().height || 1 };
drawing = svg('svg',{ viewBox:`0 0 ${size.width} ${size.height}`, role:'group','aria-label':value.title });
const defs = svg('defs'), marker = svg('marker',{id:'graph-arrow',viewBox:'0 0 10 10',refX:'9',refY:'5',markerWidth:'7',markerHeight:'7',orient:'auto-start-reverse'});
marker.append(svg('path',{d:'M 0 0 L 10 5 L 0 10 z',fill:'var(--graph-edge)'})); defs.append(marker); drawing.append(defs);
for (const edge of value.edges) {
const e = layout.edge(edge.from,edge.to);
drawing.append(svg('path',{d:e.points.map((p: {x:number;y:number},i: number) => `${i?'L':'M'} ${p.x} ${p.y}`).join(' '),fill:'none',stroke:'var(--graph-edge)','stroke-width':'1.5','marker-end':'url(#graph-arrow)'}));
if (edge.label) { const label=svg('text',{x:String(e.x),y:String(e.y-4),'text-anchor':'middle',class:'graph-edge-label'}); label.textContent=edge.label;drawing.append(label); }
}
for (const node of value.nodes) {
const n = layout.node(node.id), group = svg('g',{transform:`translate(${n.x-130},${n.y-46})`,role:'button',tabindex:'0','aria-label':`${node.kind}: ${node.label}`,'data-node':node.id,class:`graph-node ${node.kind}`});
group.append(svg('rect',{width:'260',height:'92',rx:'8'}));
const title=svg('title');title.textContent=node.detail;group.append(title);
const kind=svg('text',{x:'12',y:'18',class:'graph-kind'});kind.textContent=node.kind.toUpperCase();group.append(kind);
const label=svg('text',{x:'12',y:'38'});
const text=node.label.replace(/\s+/g,' '); const lines=text.match(/.{1,32}(?:\s|$)|.{1,32}/g) || [''];
lines.slice(0,3).forEach((line,i) => {const span=svg('tspan',{x:'12',dy:i?'17':'0'});span.textContent=line.trim()+(i===2&&lines.length>3?'…':'');label.append(span);});group.append(label);
group.onclick=() => select(node); group.ondblclick=() => { if (['concept','function','type','selected'].includes(node.kind)) void refresh(node.location); };group.onkeydown=event => {
if(event.key==='Enter'||event.key===' '){event.preventDefault();select(node);}
if (event.key.startsWith('Arrow')) {
const direction = event.key, here = layoutPositions.get(node.id)!;
const candidates = value.nodes.filter(other => {
if (other.id === node.id) return false;
const p = layoutPositions.get(other.id)!;
if (direction === 'ArrowUp') return p.y < here.y;
if (direction === 'ArrowDown') return p.y > here.y;
return Math.abs(p.y-here.y) < 50 && (direction === 'ArrowLeft' ? p.x < here.x : p.x > here.x);
}).sort((a,b) => {const p=layoutPositions.get(a.id)!,q=layoutPositions.get(b.id)!;return Math.hypot(p.x-here.x,p.y-here.y)-Math.hypot(q.x-here.x,q.y-here.y);});
if (candidates[0]) {event.preventDefault();const element = [...canvas.querySelectorAll<SVGGElement>('.graph-node')].find(el => el.dataset.node===candidates[0].id);element?.focus();element?.scrollIntoView({block:'nearest',inline:'nearest'});select(candidates[0]);}
}
};
drawing.append(group);
}
$('graph-canvas').replaceChildren(drawing); fit(true); centerSymbol();
$('graph-warnings').replaceChildren(...value.warnings.map(w => {const p=document.createElement('p');p.textContent=w;return p;}));
// Status and warnings affect the available canvas height after rendering.
const rendered = drawing;
requestAnimationFrame(() => { if (drawing === rendered) { fit(true); centerSymbol(); } });
}
function building(value: boolean) { $('graph-progress').hidden = !value; canvas.setAttribute('aria-busy', String(value)); $<HTMLButtonElement>('graph-refresh').disabled = value; }
async function refresh(location?: Location, record = true) {
const workspace=options.workspace(); if(!workspace) return;
const path=location?.path || options.path(), p=editor.getPosition(); if(!path||!p) return;
const position=location?.range.start || {line:p.lineNumber-1,character:p.column-1};
const operation=++ticket, epoch=options.epoch(); show('graph');
$('graph-status').textContent='Building relationships with clangd…'; building(true);
graph=null; $('graph-canvas').replaceChildren();$('graph-detail').replaceChildren();$('graph-warnings').replaceChildren();
try {
await options.ready(); if(operation!==ticket||epoch!==options.epoch())return;
const result=await invoke<Graph>('graph_request',{root:workspace.root,path,position,options:structuredClone(graphOptions)});
if(operation!==ticket||epoch!==options.epoch())return;
if (record && center) { back.push(center); if (back.length > 30) back.shift(); forward.length=0; }
graph=result; center=graph.nodes.find(n => n.id === 'selected')?.location; historyControls(); render(graph);$('graph-title').textContent=graph.title;
$('graph-status').textContent='Dependencies / constraints ↑ · members / uses / callers ↓. Select for details; double-click a symbol to refocus. Drag to pan, Ctrl/⌘+scroll to zoom.';
} catch(error) {if(operation===ticket)$('graph-status').textContent=String(error);}
finally {if(operation===ticket)building(false);}
}
$('graph-back').onclick = () => { const previous = back.pop(); if (previous) {if (center) forward.push(center); historyControls(); void refresh(previous, false);} };
$('graph-forward').onclick = () => { const next = forward.pop(); if (next) {if (center) back.push(center); historyControls(); void refresh(next, false);} };
$('graph-refresh').onclick=()=>void refresh();$('nav-graph').onclick=()=>void refresh();
editor.addAction({id:'cex.graph',label:'Show Symbol Graph',contextMenuGroupId:'navigation',contextMenuOrder:3,run:()=>refresh()});
return { reset(){++ticket;building(false);center=undefined;back.length=0;forward.length=0;historyControls();graphOptions=structuredClone(options.workspace()?.settings?.graph ?? {depth:1,excluded_namespaces:['std']});settingsUI();$('graph-options-status').textContent='';toggle.disabled=false;$<HTMLButtonElement>('nav-graph').disabled=true;graph=null;drawing=null; $('graph-canvas').replaceChildren();$('graph-detail').replaceChildren();$('graph-warnings').replaceChildren();$('graph-title').textContent='Symbol graph';$('graph-status').textContent='Select a concept, function, class, or struct name in the editor.';$<HTMLButtonElement>('graph-refresh').disabled=false;collapse();}, fileOpened(){toggle.disabled=false;$<HTMLButtonElement>('nav-graph').disabled=editor.getModel()?.getLanguageId()!=='cpp';} };
}