179 lines
19 KiB
TypeScript
179 lines
19 KiB
TypeScript
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';} };
|
||
}
|