struct graph

This commit is contained in:
2026-09-07 15:52:46 -04:00
parent 30ed3351b5
commit 93521c53fb
15 changed files with 1061 additions and 30 deletions

View File

@@ -22,7 +22,7 @@ export function installGraph(editor: monaco.editor.IStandaloneCodeEditor, option
<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><p id="graph-status" class="hint" role="status">Select a concept or function name in the editor.</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);
@@ -107,7 +107,7 @@ export function installGraph(editor: monaco.editor.IStandaloneCodeEditor, option
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') { 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); }
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(() => ({}));
@@ -130,7 +130,7 @@ export function installGraph(editor: monaco.editor.IStandaloneCodeEditor, option
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','selected'].includes(node.kind)) void refresh(node.location); };group.onkeydown=event => {
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)!;
@@ -152,12 +152,13 @@ export function installGraph(editor: monaco.editor.IStandaloneCodeEditor, option
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…'; $<HTMLButtonElement>('graph-refresh').disabled=true;
$('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;
@@ -165,13 +166,13 @@ export function installGraph(editor: monaco.editor.IStandaloneCodeEditor, option
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 / callees ↑ · uses / callers ↓. Select for details; double-click a symbol to refocus. Drag to pan, Ctrl/⌘+scroll to zoom.';
$('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)$<HTMLButtonElement>('graph-refresh').disabled=false;}
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;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 or function name in the editor.';$<HTMLButtonElement>('graph-refresh').disabled=false;collapse();}, fileOpened(){toggle.disabled=false;$<HTMLButtonElement>('nav-graph').disabled=editor.getModel()?.getLanguageId()!=='cpp';} };
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';} };
}

View File

@@ -6,6 +6,7 @@ import 'monaco-editor/esm/vs/editor/contrib/folding/browser/folding';
import 'monaco-editor/esm/vs/editor/contrib/find/browser/findController';
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import './style.css';
import { installSidebarResize } from './resize';
import { installTheme } from './theme';
import { installNavigation } from './navigation';
import { installGraph } from './graph';
@@ -21,7 +22,7 @@ type Hit = { path: string; line: number; column: number; end_column: number; pre
type SearchResults = { hits: Hit[]; truncated: boolean; cancelled: boolean; skipped_files: number };
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<header><div class="brand">cex<span>CODE EXPLORER</span></div><div id="project-name">No project open</div><select id="theme-select" aria-label="Color theme" title="Color theme"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select><button id="toggle-sidebar" class="icon-button" aria-label="Toggle right sidebar" title="Toggle right sidebar" aria-expanded="false" aria-controls="explanation-pane" disabled><svg viewBox="0 0 20 20" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="2.5" y="3.5" width="15" height="13" rx="2"/><path d="M12.5 4v12"/></svg></button><button id="workspace-button">Open project…</button></header>
<header><details id="file-menu"><summary>File</summary><div class="file-dropdown"><button id="file-open">Open Project…</button><details id="file-recents"><summary>Open Recent</summary><div id="file-recent-list"><span class="hint">Loading…</span></div></details><button id="file-welcome">Return to Welcome</button></div></details><div class="brand">cex<span>CODE EXPLORER</span></div><div id="project-name">No project open</div><select id="theme-select" aria-label="Color theme" title="Color theme"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select><button id="toggle-sidebar" class="icon-button" aria-label="Toggle right sidebar" title="Toggle right sidebar" aria-expanded="false" aria-controls="explanation-pane" disabled><svg viewBox="0 0 20 20" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="2.5" y="3.5" width="15" height="13" rx="2"/><path d="M12.5 4v12"/></svg></button><button id="workspace-button">Open project…</button></header>
<main class="landing">
<section id="landing" aria-label="Open Recent"><div class="landing-intro"><div class="welcome-mark">{ cex }</div><h1>Open Recent</h1><p>Pick up where you left off, or explore a new project.</p><button id="recent-open" class="primary">Open a project</button></div><div class="recent-heading"><h2>Recent projects</h2><button id="refresh-recents" aria-label="Refresh recent projects">Refresh</button></div><p id="recent-status" class="hint" role="status">Loading recent projects…</p><div id="recent-projects"></div><small class="hint">History is stored in ~/.cex. Project settings stay with each project.</small></section>
<aside><nav><button id="files-tab" class="active">Files</button><button id="search-tab">Search</button><button id="navigation-tab">Navigation</button></nav>
@@ -75,11 +76,14 @@ const navigation = installNavigation(editor, { workspace: () => workspace, path:
const explanations = installExplanations(editor, { workspace: () => workspace, path: () => currentPath, epoch: () => epoch, ready: navigation.whenReady, open: openFile });
const graph = installGraph(editor, { workspace: () => workspace, path: () => currentPath, epoch: () => epoch, ready: navigation.whenReady, open: openFile });
const vim = installVim(editor, navigation);
installSidebarResize(editor);
function status(message: string, error = false) { $('status').textContent = message; $('status').classList.toggle('error', error); }
function errorText(error: unknown) { return error instanceof Error ? error.message : String(error); }
function button(text: string, className = '') { const el = document.createElement('button'); el.textContent = text; el.className = className; return el; }
let closingWorkspace = false;
function showSetup() {
if (closingWorkspace) return;
source.value = workspace?.root ?? '';
build.value = workspace?.settings.build_directory ?? '';
clangdPath.value = workspace?.settings.clangd_path ?? 'clangd';
@@ -312,10 +316,7 @@ async function loadRecents() {
const path = document.createElement('span'); path.textContent = project.root;
const date = document.createElement('small'); date.textContent = `Last opened ${new Date(project.last_opened * 1000).toLocaleString()}`;
open.append(name, path, date); open.title = project.root;
open.onclick = async () => {
showSetup(); source.value = project.root; await inspectSource();
if (inspection && !inspection.warning && source.value === project.root) $<HTMLFormElement>('setup-form').requestSubmit();
};
open.onclick = () => void openRecent(project.root);
const remove = button('×', 'recent-remove'); remove.setAttribute('aria-label', `Remove ${name.textContent} from recent projects`);
remove.title = 'Remove from history; project files are preserved';
remove.onclick = async () => {
@@ -331,3 +332,61 @@ async function loadRecents() {
$('recent-open').onclick = () => $('workspace-button').click();
$('refresh-recents').onclick = () => void loadRecents();
void loadRecents();
function closeFileMenu() { $<HTMLDetailsElement>('file-menu').open = false; }
async function openRecent(root: string) {
if (closingWorkspace) return;
closeFileMenu(); showSetup(); source.value = root; await inspectSource();
if (setup.open && inspection && !inspection.warning && source.value === root) $<HTMLFormElement>('setup-form').requestSubmit();
}
$('file-open').onclick = () => {
if (closingWorkspace) return;
closeFileMenu(); showSetup(); ++inspecting;
source.value = ''; build.value = ''; clangdPath.value = 'clangd'; inspectedRoot = ''; inspection = null;
reset.checked = false; reset.disabled = true; $('saved-state').textContent = ''; $('setup-title').textContent = 'Open project'; source.focus();
};
$('file-recents').addEventListener('toggle', async () => {
if (!$<HTMLDetailsElement>('file-recents').open) return;
$('file-recent-list').textContent = 'Loading…';
try {
const projects = await invoke<{root:string;last_opened:number}[]>('recent_projects');
$('file-recent-list').replaceChildren(...projects.map(project => {
const item = button('', 'file-recent');
const name = document.createElement('strong'); name.textContent = project.root.split('/').pop() || project.root;
const path = document.createElement('small'); path.textContent = project.root;
item.append(name,path); item.title = project.root; item.onclick = () => void openRecent(project.root); return item;
}));
if (!projects.length) $('file-recent-list').textContent = 'No recent projects yet.';
} catch(error) { $('file-recent-list').textContent = errorText(error); }
});
$('file-menu').addEventListener('toggle', () => {
if ($<HTMLDetailsElement>('file-menu').open) {
$<HTMLButtonElement>('file-welcome').disabled = !workspace || closingWorkspace;
$<HTMLDetailsElement>('file-recents').open = false;
}
});
document.addEventListener('pointerdown', event => { if (!$('file-menu').contains(event.target as Node)) closeFileMenu(); });
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && $<HTMLDetailsElement>('file-menu').open) { closeFileMenu(); $('file-menu').querySelector('summary')!.focus(); }
});
$('file-welcome').onclick = async () => {
if (!workspace || closingWorkspace) return;
const root = workspace.root;
closingWorkspace = true; closeFileMenu(); ++epoch; ++fileRequest; ++searchRequest;
clearTimeout(searchTimer); graph.reset(); explanations.reset();
status('Closing project…');
try {
await rememberQueue;
await invoke('close_workspace', {root});
workspace = null; currentPath = ''; viewStates.clear(); navigation.reset(); vim.reset();
const model = editor.getModel(); editor.setModel(null); model?.dispose();
graph.reset(); $<HTMLButtonElement>('toggle-sidebar').disabled = true;
$('landing').hidden = false; document.querySelector('main')!.classList.add('landing');
$('project-name').textContent = 'No project open'; $('project-name').title = '';
$('workspace-button').textContent = 'Open project…'; $('file-path').textContent = 'Welcome to CEX';
$('cursor').textContent = ''; $('tree').replaceChildren(); $('results').replaceChildren(); query.value = '';
status('Ready'); await loadRecents(); $('recent-open').focus();
} catch(error) { status(`Could not close project: ${errorText(error)}`, true); }
finally { closingWorkspace = false; }
};

46
frontend/resize.ts Normal file
View File

@@ -0,0 +1,46 @@
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
// Widths stay in effect while switching projects, tabs, and collapsed panels.
export function installSidebarResize(editor: monaco.editor.IStandaloneCodeEditor) {
const main = document.querySelector('main')!;
const left = main.querySelector('aside')!;
const right = document.getElementById('explanation-pane')!;
const handles: { side: 'left' | 'right'; panel: HTMLElement; handle: HTMLDivElement }[] = [];
function limits(side: 'left' | 'right') {
const overlay = window.innerWidth <= 1100 || right.classList.contains('graph-expanded');
const other = side === 'left' ? (right.hidden || overlay ? 0 : right.getBoundingClientRect().width) : (overlay ? 0 : left.getBoundingClientRect().width);
return { min: side === 'left' ? 180 : 300, max: Math.max(side === 'left' ? 180 : 300, Math.min(side === 'left' ? 650 : 1100, main.clientWidth - other - (overlay && side === 'right' ? 40 : 280))) };
}
function widthProperty(side: 'left' | 'right') { return side === 'right' && right.classList.contains('graph-expanded') ? '--expanded-sidebar-width' : `--${side}-sidebar-width`; }
function setWidth(side: 'left' | 'right', width: number) {
const {min,max} = limits(side);
document.documentElement.style.setProperty(widthProperty(side), `${Math.round(Math.max(min,Math.min(max,width)))}px`);
editor.layout(); update();
}
function update() {
for (const {side,panel,handle} of handles) {
const {min,max}=limits(side);
handle.setAttribute('aria-valuemin',String(min));handle.setAttribute('aria-valuemax',String(Math.round(max)));
handle.setAttribute('aria-valuenow',String(Math.round(panel.getBoundingClientRect().width)));
}
}
for (const [side,panel] of [['left',left],['right',right]] as const) {
const handle = document.createElement('div');handle.className=`sidebar-resizer ${side}-resizer`;handle.tabIndex=0;
handle.setAttribute('role','separator');handle.setAttribute('aria-orientation','vertical');
handle.setAttribute('aria-label',`Resize ${side} sidebar`);handle.title='Drag to resize · arrow keys adjust · double-click to reset';
panel.append(handle);handles.push({side,panel,handle});
let drag: {x:number;width:number} | null=null;
handle.onpointerdown=event=>{if(event.button)return;event.preventDefault();drag={x:event.clientX,width:panel.getBoundingClientRect().width};handle.setPointerCapture(event.pointerId);document.body.classList.add('resizing-sidebars');};
handle.onpointermove=event=>{if(drag)setWidth(side,drag.width+(event.clientX-drag.x)*(side==='left'?1:-1));};
const stop=()=>{drag=null;document.body.classList.remove('resizing-sidebars');};
handle.onpointerup=handle.onpointercancel=handle.onlostpointercapture=stop;
handle.ondblclick=()=>{document.documentElement.style.removeProperty(widthProperty(side));editor.layout();update();};
handle.onkeydown=event=>{
if(event.key==='ArrowLeft'||event.key==='ArrowRight') {event.preventDefault();const direction=(event.key==='ArrowRight'?1:-1)*(side==='left'?1:-1);setWidth(side,panel.getBoundingClientRect().width+direction*(event.shiftKey?50:10));}
else if(event.key==='Home'||event.key==='End') {event.preventDefault();const range=limits(side);setWidth(side,event.key==='Home'?range.min:range.max);}
};
}
const observer=new ResizeObserver(update);observer.observe(left);observer.observe(right);
window.addEventListener('resize',()=>{for(const {side,panel} of handles) if(!panel.hidden && document.documentElement.style.getPropertyValue(widthProperty(side))) setWidth(side,panel.getBoundingClientRect().width);});
update();
}

View File

@@ -260,3 +260,40 @@ dialog { background: var(--surface); border-color: var(--border); box-shadow: va
#graph-warnings { padding-top: 8px; }
#graph-zoom { margin-left: auto; font-variant-numeric: tabular-nums; }
#explanation-pane.graph-expanded { position: fixed; z-index: 15; right: 0; top: 58px; bottom: 29px; width: min(1050px, 94vw); box-shadow: var(--shadow); }
#graph-progress { display: flex; align-items: center; gap: 9px; padding: 9px 11px; background: var(--selected); color: var(--accent-text); border-radius: 7px; font-size: 12px; flex-shrink: 0; }
#graph-progress progress { width: 64px; height: 4px; margin-left: auto; accent-color: var(--accent); }
.graph-spinner { width: 14px; height: 14px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: graph-spin .8s linear infinite; }
@keyframes graph-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .graph-spinner { animation: none; } }
#file-menu { position: relative; font-size: 12px; }
#file-menu > summary { padding: 7px 8px; cursor: pointer; border-radius: 5px; list-style: none; }
#file-menu > summary::-webkit-details-marker { display: none; }
#file-menu[open] > summary, #file-menu > summary:hover { background: var(--hover); }
.file-dropdown { position: absolute; top: calc(100% + 8px); left: 0; width: min(350px, 85vw); z-index: 30; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 6px; box-shadow: var(--shadow); }
.file-dropdown > button, #file-recents > summary { display: block; width: 100%; text-align: left; background: transparent; border: 0; padding: 9px 10px; cursor: pointer; border-radius: 5px; }
.file-dropdown button:hover, #file-recents > summary:hover { background: var(--hover); }
#file-welcome { border-top: 1px solid var(--border); margin-top: 5px; }
#file-recent-list { max-height: 320px; overflow: auto; padding: 4px 8px; color: var(--muted); }
.file-recent { display: block; width: 100%; background: transparent; border: 0; text-align: left; padding: 8px; }
.file-recent strong, .file-recent small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-recent small { color: var(--muted); margin-top: 4px; }
main { grid-template-columns: minmax(180px, var(--left-sidebar-width, clamp(230px, 22vw, 320px))) minmax(280px, 1fr); }
main.explaining { grid-template-columns: minmax(180px, var(--left-sidebar-width, 19vw)) minmax(280px, 1fr) minmax(300px, var(--right-sidebar-width, 33vw)); }
main > aside, #explanation-pane { position: relative; }
.sidebar-resizer { position: absolute; top: 0; bottom: 0; width: 8px; z-index: 20; cursor: col-resize; touch-action: none; }
.left-resizer { right: -4px; }
.right-resizer { left: -4px; }
.sidebar-resizer:hover, .sidebar-resizer:focus-visible { background: var(--accent); opacity: .6; outline: none; }
body.resizing-sidebars, body.resizing-sidebars * { cursor: col-resize !important; user-select: none !important; }
.graph-node.type rect { fill: var(--node-operator); }
.graph-node.field rect, .graph-node.alias rect { fill: var(--node); }
.graph-node.parameter rect, .graph-node.template-parameter rect { fill: var(--node-requires); }
@media (max-width: 1100px) {
main.explaining { grid-template-columns: minmax(180px, var(--left-sidebar-width, clamp(230px, 22vw, 320px))) minmax(280px, 1fr); }
#explanation-pane { position: fixed; width: min(var(--right-sidebar-width, 520px), 94vw); }
}
#explanation-pane.graph-expanded { width: min(var(--expanded-sidebar-width, 1050px), 94vw); }
main > aside > nav { overflow-x: auto; flex-shrink: 0; }