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; open: (path: string, hit: { path: string; line: number; column: number; end_column: number }, semantic?: boolean) => Promise }; const $ = (id: string) => document.getElementById(id) as T; const ns = 'http://www.w3.org/2000/svg'; function svg(tag: K, attrs: Record = {}) { 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 = '
'; 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 = `
Symbol graph
Graph options

Independent of LLM context. Higher depths may take longer.

Select a concept, function, class, or struct name in the editor.

100%
Coverage and limits
`; 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(); const canvas = $('graph-canvas'); function historyControls() { $('graph-back').disabled = !back.length; $('graph-forward').disabled = !forward.length; } function settingsUI() { $('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($('graph-depth').value); settingsUI(); }; $('graph-add').onclick = () => { const input = $('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); $('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 { $('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 = $('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('.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)); $('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_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;$('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.';$('graph-refresh').disabled=false;collapse();}, fileOpened(){toggle.disabled=false;$('nav-graph').disabled=editor.getModel()?.getLanguageId()!=='cpp';} }; }