Initial Commit

This commit is contained in:
2026-09-07 15:16:52 -04:00
commit e8ce633e5e
54 changed files with 13079 additions and 0 deletions

241
frontend/explanations.ts Normal file
View File

@@ -0,0 +1,241 @@
import { Channel, invoke } from '@tauri-apps/api/core';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
type Audience = { id: string; name: string; instructions: string };
type Settings = { base_url: string; model: string; api_key_env: string; context_bytes: number; max_output_tokens: number; token_parameter: string; stream: boolean; selected_audience: string; audiences: Audience[] };
type Source = { id: string; role: string; path: string; start_line: number; end_line: number; enclosing: string; code: string; truncated: boolean };
type Callee = { is_concept: boolean; is_standard: boolean; id: string; location: { path: string; label: string; range: { start: { line: number; character: number } } } };
type Prepared = { concept_dependencies: boolean; include_standard: boolean; available_callees: Callee[]; selected_callees: string[]; recursion_depth: number; prompt_budget: number; id: number; endpoint: string; model: string; audience: string; prompt_bytes: number; messages: { role: string; content: string }[]; bundle: { selected_path: string; sources: Source[]; usages: unknown[]; references_found: number; callers_found: number; callees_found: number; omissions: string[]; diagnostics: string[] } };
type Options = { workspace: () => { root: string } | 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;
export function installExplanations(editor: monaco.editor.IStandaloneCodeEditor, options: Options) {
const pane = document.createElement('section'); pane.id = 'explanation-pane'; pane.hidden = true;
pane.innerHTML = `<div class="explanation-heading"><strong>Explain code</strong><button id="model-settings">Model settings</button><button id="close-explanation" aria-label="Close explanation panel">×</button></div>
<div class="explanation-content"><p id="explain-target" class="hint">Select C/C++ code, then prepare context.</p>
<label for="explain-question">Question (optional)</label><textarea id="explain-question" rows="2" placeholder="What does this code do, and how do its callers use it?"></textarea>
<div class="explanation-actions"><button id="prepare-context">Prepare context</button><button id="send-explanation" class="primary" disabled>Send explanation</button><button id="cancel-explanation" hidden>Cancel</button></div>
<fieldset id="callee-options" hidden><legend id="dependency-heading">Called-function bodies</legend><label id="include-standard-label" class="reset-label" hidden><input id="include-standard" type="checkbox">Include std namespace concepts (including nested dependencies)</label><label for="callee-depth">Recursion depth</label><select id="callee-depth"><option value="0">0 — No additional bodies</option><option value="1">1 — Selected direct callees</option><option value="2">2 — Selected callees and their callees</option></select><div class="explanation-actions"><button id="callees-all">Select all</button><button id="callees-none">Select none</button></div><div id="callee-list"></div><button id="apply-callees">Apply body selection</button><p class="hint">Depth is capped at 2. Shared dependencies are deduplicated. Definitions remain subject to the prompt and snapshot limits.</p></fieldset>
<p id="context-size-warning" class="hint" role="status"></p>
<p id="explain-status" class="hint" role="status">Preparation stays local. Sending uses the endpoint you configure.</p>
<p id="explain-destination" class="hint"></p><details id="context-details"><summary>Inspect context</summary><div id="context-summary"></div><div id="context-sources"></div><details><summary>Exact request messages</summary><pre id="context-messages"></pre></details></details>
<p id="citation-status" class="hint" role="status"></p><article id="explanation-answer" aria-label="Model explanation"></article></div>`;
document.querySelector('main')!.append(pane);
const dialog = document.createElement('dialog'); dialog.id = 'llm-settings';
dialog.innerHTML = `<form id="llm-form"><h2>Model settings</h2><p class="hint">Saved for this project. Use an OpenAI-compatible Chat Completions endpoint.</p>
<div class="model-presets"><button id="preset-local" type="button">Local endpoint</button><button id="preset-openai" type="button">OpenAI endpoint</button></div>
<label for="llm-url">API base URL</label><input id="llm-url" required placeholder="http://localhost:1234/v1">
<label for="llm-model">Model identifier</label><input id="llm-model" placeholder="Model name supplied by your server">
<label for="llm-key-env">API key environment variable (optional)</label><input id="llm-key-env" placeholder="OPENAI_API_KEY" autocomplete="off">
<label for="llm-key">API key (current session only)</label><input id="llm-key" type="password" autocomplete="off" placeholder="Leave blank to retain the current session key"><label class="reset-label"><input id="clear-llm-key" type="checkbox">Clear session key</label><p id="key-state" class="hint"></p>
<div class="model-grid"><div><label for="llm-budget">Prompt budget (KiB)</label><input id="llm-budget" type="number" min="16" max="256" required></div><div><label for="llm-output">Output token limit</label><input id="llm-output" type="number" min="128" max="32768" required></div></div>
<label for="llm-token-parameter">Output limit compatibility</label><select id="llm-token-parameter"><option value="max_completion_tokens">max_completion_tokens (OpenAI)</option><option value="max_tokens">max_tokens (older compatible servers)</option><option value="omit">Let the server choose</option></select><label class="reset-label"><input id="llm-stream" type="checkbox">Stream the explanation as it is generated</label>
<label for="llm-audience">Audience profile</label><select id="llm-audience"></select><label for="llm-instructions">Audience instructions (editable for each profile)</label><textarea id="llm-instructions" rows="4" required></textarea>
<p id="llm-error" class="error" role="alert"></p><div class="dialog-actions"><button id="llm-cancel" type="button">Cancel</button><button id="llm-save" type="submit" class="primary">Save settings</button></div></form>`;
document.body.append(dialog);
let prepared: Prepared | null = null, text = '', generation = 0, busy = false;
let questionRevision = 0, questionPending = false, bodiesDirty = false;
let questionTimer: ReturnType<typeof setTimeout> | undefined;
let settings: Settings | null = null, profile = '', renderTimer: ReturnType<typeof setTimeout> | undefined;
function show() { pane.dispatchEvent(new Event('cex-show-chat')); pane.hidden = false; document.querySelector('main')!.classList.add('explaining'); editor.layout(); }
function setBusy(value: boolean) {
busy = value; $<HTMLButtonElement>('prepare-context').disabled = value;
$<HTMLButtonElement>('send-explanation').disabled = value || !prepared || questionPending || bodiesDirty;
$<HTMLFieldSetElement>('callee-options').disabled = value;
$<HTMLButtonElement>('apply-callees').disabled = value || questionPending;
$('cancel-explanation').hidden = !value; $<HTMLButtonElement>('model-settings').disabled = value;
$<HTMLTextAreaElement>('explain-question').disabled = value;
}
function clearContext() { ++questionRevision; clearTimeout(questionTimer); questionPending = false; bodiesDirty = false; $('callee-options').hidden = true; $('context-size-warning').textContent = ''; prepared = null; $<HTMLButtonElement>('send-explanation').disabled = true; $('context-summary').replaceChildren(); $('context-sources').replaceChildren(); $('context-messages').textContent = ''; $('explain-destination').textContent = ''; }
async function citation(source: Source) {
await options.open(source.path, { path: source.path, line: source.start_line, column: 1, end_column: 1 }, true);
const current = editor.getModel()?.getLinesContent().slice(source.start_line - 1, source.end_line).join('\n');
$('citation-status').textContent = current === source.code ? `Opened ${source.path}:${source.start_line}.` : 'This file differs from the reviewed snapshot or could not be opened. Inspect context to see the exact evidence sent.';
}
function renderAnswer() {
const html = marked.parse(text, { async: false });
$('explanation-answer').innerHTML = DOMPurify.sanitize(html, { FORBID_TAGS: ['img', 'svg', 'math', 'iframe', 'object', 'form', 'input', 'button', 'video', 'audio'], FORBID_ATTR: ['style', 'src', 'srcset'] });
// Model-supplied URLs are text only. Only source IDs from our reviewed bundle
// become actionable links; citations cannot grant arbitrary file access.
$('explanation-answer').querySelectorAll('a').forEach(a => a.replaceWith(document.createTextNode(a.textContent ?? '')));
const walker = document.createTreeWalker($('explanation-answer'), NodeFilter.SHOW_TEXT);
const nodes: Text[] = [];
while (walker.nextNode()) { const node = walker.currentNode as Text; if (!node.parentElement?.closest('pre,code')) nodes.push(node); }
for (const node of nodes) {
const matches = [...node.data.matchAll(/\[S[1-9]\d*\]/g)]; if (!matches.length) continue;
const fragment = document.createDocumentFragment(); let offset = 0;
for (const match of matches) {
fragment.append(node.data.slice(offset, match.index));
const id = match[0].slice(1, -1), source = prepared?.bundle.sources.find(s => s.id === id);
if (source) { const button = document.createElement('button'); button.className = 'source-citation'; button.textContent = match[0]; button.title = `${source.path}:${source.start_line}-${source.end_line}`; button.onclick = () => void citation(source); fragment.append(button); }
else { const unknown = document.createElement('span'); unknown.textContent = `${match[0]} (unknown source)`; unknown.className = 'error'; fragment.append(unknown); }
offset = match.index! + match[0].length;
}
fragment.append(node.data.slice(offset)); node.replaceWith(fragment);
}
}
function renderContext(value: Prepared, controls = true) {
const fraction = value.prompt_bytes / value.prompt_budget;
$('context-size-warning').textContent = `${(value.prompt_bytes / 1024).toFixed(1)} KiB / ${(value.prompt_budget / 1024).toFixed(0)} KiB prompt budget. ${fraction >= 0.75 || value.prompt_bytes >= 32768 ? 'Large context: sending this much code can increase latency and cost and leave less model capacity for the answer.' : 'More included bodies increase request size and generation cost.'}${value.bundle.sources.some(s => s.truncated) ? ' Some bodies are truncated; inspect the snapshots.' : ''}`;
if (controls) {
bodiesDirty = false;
$('callee-options').hidden = false;
$('apply-callees').textContent = value.concept_dependencies ? 'Apply concept selection' : 'Apply body selection';
$('dependency-heading').textContent = value.concept_dependencies ? 'Concept dependencies' : 'Called-function bodies';
$('include-standard-label').hidden = !value.concept_dependencies;
$<HTMLInputElement>('include-standard').checked = value.include_standard ?? false;
const depthOptions = $<HTMLSelectElement>('callee-depth').options;
depthOptions[0].text = '0 — No additional definitions';
depthOptions[1].text = value.concept_dependencies ? '1 — Selected concepts' : '1 — Selected direct callees';
depthOptions[2].text = value.concept_dependencies ? '2 — Selected concepts and their dependencies' : '2 — Selected callees and their callees';
$<HTMLSelectElement>('callee-depth').value = String(value.recursion_depth);
$('callee-list').replaceChildren();
for (const callee of value.available_callees) {
const label = document.createElement('label'), input = document.createElement('input');
input.type = 'checkbox'; input.value = callee.id; input.dataset.standard = String(callee.is_standard ?? false); input.disabled = callee.is_standard && !value.include_standard; input.checked = !input.disabled && value.selected_callees.includes(callee.id);
input.onchange = bodiesChanged;
label.append(input, ` ${callee.location.label || 'Dependency'}${callee.is_standard ? ' [std]' : ''}${callee.location.path}:${callee.location.range.start.line + 1}`);
$('callee-list').append(label);
}
if (!value.available_callees.length) $('callee-list').textContent = value.concept_dependencies ? 'No referenced concepts found in this constraint.' : 'No direct callees found for this selection.';
}
$('explain-target').textContent = value.bundle.selected_path;
$('explain-destination').textContent = `Send to ${value.endpoint} · model: ${value.model || '(not configured)'} · audience: ${value.audience}`;
const summary = document.createElement('p'); summary.className = 'hint';
summary.textContent = `${value.bundle.sources.length} snapshots · ${value.bundle.references_found} references · ${value.bundle.callers_found} callers · ${value.concept_dependencies ? `${value.available_callees.length} concept dependencies` : `${value.bundle.callees_found} callees`} · ${(value.prompt_bytes / 1024).toFixed(1)} KiB of prompt messages (bytes, not tokens).`;
$('context-summary').replaceChildren(summary);
for (const message of [...value.bundle.omissions, ...value.bundle.diagnostics]) { const p = document.createElement('p'); p.className = 'hint'; p.textContent = message; $('context-summary').append(p); }
$('context-sources').replaceChildren();
for (const source of value.bundle.sources) {
const details = document.createElement('details'), title = document.createElement('summary'), pre = document.createElement('pre');
title.textContent = `[${source.id}] ${source.role}: ${source.path}:${source.start_line}-${source.end_line}${source.enclosing ? ` · ${source.enclosing}` : ''}${source.truncated ? ' (truncated)' : ''}`;
pre.textContent = source.code.split('\n').map((line, i) => `${source.start_line + i}: ${line}`).join('\n');
details.append(title, pre); $('context-sources').append(details);
}
$('context-messages').textContent = JSON.stringify(value.messages, null, 2);
}
async function prepareContext() {
const workspace = options.workspace(), path = options.path();
if (!workspace || !path || editor.getModel()?.getLanguageId() !== 'cpp') { $('explain-status').textContent = 'Open a C/C++ file and select a symbol first.'; return; }
show(); const operation = ++generation, epoch = options.epoch();
const selected = editor.getSelection();
const position = selected && !selected.isEmpty() ? selected.getStartPosition() : editor.getPosition()!;
const selection = selected && !selected.isEmpty() ? { start: { line: selected.startLineNumber - 1, character: selected.startColumn - 1 }, end: { line: selected.endLineNumber - 1, character: selected.endColumn - 1 } } : null;
clearContext(); text = ''; $('explanation-answer').replaceChildren(); $('citation-status').textContent = '';
$('explain-status').textContent = 'Gathering compiler-backed context locally…'; setBusy(true);
try {
await options.ready();
if (operation !== generation || epoch !== options.epoch()) return;
const value = await invoke<Prepared>('prepare_explanation', { root: workspace.root, path, position: { line: position.lineNumber - 1, character: position.column - 1 }, selection, question: $<HTMLTextAreaElement>('explain-question').value });
if (operation !== generation || epoch !== options.epoch()) return;
prepared = value; renderContext(value); $<HTMLDetailsElement>('context-details').open = true;
$('explain-status').textContent = 'Context ready. Review the evidence and destination, then send.';
} catch (error) { if (operation === generation) $('explain-status').textContent = String(error); }
finally { if (operation === generation) setBusy(false); }
}
function bodiesChanged() {
bodiesDirty = true;
if ($<HTMLSelectElement>('callee-depth').value === '0' && $('callee-list').querySelector('input:checked')) $<HTMLSelectElement>('callee-depth').value = '1';
setBusy(busy); $('explain-status').textContent = 'Dependency selection changed. Apply it to update the context and size preview.';
}
$('callee-depth').onchange = () => { bodiesDirty = true; setBusy(busy); $('explain-status').textContent = 'Apply body selection to update the preview.'; };
$('include-standard').onchange = () => {
const include = $<HTMLInputElement>('include-standard').checked;
$('callee-list').querySelectorAll<HTMLInputElement>('input[data-standard="true"]').forEach(input => { input.disabled = !include; if (!include) input.checked = false; });
bodiesChanged();
};
$('callees-all').onclick = () => { $('callee-list').querySelectorAll<HTMLInputElement>('input').forEach(input => input.checked = !input.disabled); bodiesChanged(); };
$('callees-none').onclick = () => { $('callee-list').querySelectorAll<HTMLInputElement>('input').forEach(input => input.checked = false); bodiesChanged(); };
$('apply-callees').onclick = async () => {
const workspace = options.workspace(); if (!workspace || !prepared || busy || questionPending) return;
const operation = ++generation, epoch = options.epoch(); setBusy(true);
$('explain-status').textContent = 'Collecting selected dependency definitions locally…';
try {
const value = await invoke<Prepared>('expand_explanation', { root: workspace.root, id: prepared.id, selected: [...$('callee-list').querySelectorAll<HTMLInputElement>('input:checked')].map(input => input.value), depth: Number($<HTMLSelectElement>('callee-depth').value), includeStandard: $<HTMLInputElement>('include-standard').checked, question: $<HTMLTextAreaElement>('explain-question').value });
if (operation !== generation || epoch !== options.epoch()) return;
prepared = value; text = ''; renderAnswer(); renderContext(value);
$('explain-status').textContent = 'Body selection applied. Review the size and any omissions before sending.';
} catch (error) { if (operation === generation) $('explain-status').textContent = String(error); }
finally { if (operation === generation) setBusy(false); }
};
$('explain-question').oninput = () => {
clearTimeout(questionTimer); const revision = ++questionRevision;
if (!prepared) return;
questionPending = true; setBusy(busy);
$('explain-status').textContent = 'Updating prompt using the existing source snapshots…';
questionTimer = setTimeout(async () => {
const workspace = options.workspace(), context = prepared; if (!workspace || !context) return;
try {
const value = await invoke<Prepared>('update_explanation_question', { root: workspace.root, id: context.id, question: $<HTMLTextAreaElement>('explain-question').value });
if (revision !== questionRevision) return;
prepared = value; renderContext(value, false); questionPending = false; setBusy(busy);
$('explain-status').textContent = 'Prompt updated. Existing source snapshots are ready to send.';
} catch (error) { if (revision === questionRevision) $('explain-status').textContent = String(error); }
}, 200);
};
dialog.addEventListener('cancel', () => { $<HTMLInputElement>('llm-key').value = ''; });
$('prepare-context').onclick = () => void prepareContext();
$('nav-explain').onclick = () => { show(); if (!busy) void prepareContext(); };
editor.addAction({ id: 'cex.explain', label: 'Explain Selected Code…', contextMenuGroupId: 'navigation', contextMenuOrder: 2, run: () => { show(); if (!busy) return prepareContext(); } });
$('send-explanation').onclick = async () => {
const workspace = options.workspace(); if (!workspace || !prepared || busy || questionPending || bodiesDirty) return;
const operation = ++generation, epoch = options.epoch(), id = prepared.id;
setBusy(true); text = ''; $('explanation-answer').replaceChildren();
$('explain-status').textContent = `Requesting explanation from ${prepared.endpoint}`;
const onDelta = new Channel<string>();
onDelta.onmessage = delta => {
if (operation !== generation || epoch !== options.epoch()) return;
text += delta;
if (renderTimer === undefined) renderTimer = setTimeout(() => { renderTimer = undefined; renderAnswer(); }, 80);
};
try {
const result = await invoke<{ text: string; finish_reason: string }>('send_explanation', { root: workspace.root, id, onDelta });
if (operation !== generation || epoch !== options.epoch()) return;
text = result.text; renderAnswer();
$('explain-status').textContent = result.finish_reason === 'length' ? 'Output limit reached; this explanation may be incomplete.' : result.finish_reason === 'content_filter' ? 'The provider filtered this response; it may be incomplete.' : 'Explanation complete. Source citations open the reviewed locations.';
} catch (error) { if (operation === generation) { $('explain-status').textContent = `${String(error)}${text ? ' Text shown is partial.' : ''}`; renderAnswer(); } }
finally { if (operation === generation) setBusy(false); }
};
$('cancel-explanation').onclick = () => {
++generation; const workspace = options.workspace();
if (workspace) void invoke('cancel_explanation', { root: workspace.root }).catch(error => { $('explain-status').textContent = String(error); });
setBusy(false); renderAnswer(); $('explain-status').textContent = `Cancelled.${text ? ' Text shown is partial.' : ''}`;
};
$('close-explanation').onclick = () => { pane.hidden = true; document.querySelector('main')!.classList.remove('explaining'); editor.layout(); };
function audienceInstructions() { if (settings) { const a = settings.audiences.find(a => a.id === profile); if (a) a.instructions = $<HTMLTextAreaElement>('llm-instructions').value; } }
$('llm-audience').onchange = () => { audienceInstructions(); profile = $<HTMLSelectElement>('llm-audience').value; $<HTMLTextAreaElement>('llm-instructions').value = settings?.audiences.find(a => a.id === profile)?.instructions ?? ''; };
$('model-settings').onclick = async () => {
const workspace = options.workspace(); if (!workspace) { $('explain-status').textContent = 'Open a project first.'; return; }
const epoch = options.epoch();
try {
const result = await invoke<{ settings: Settings; has_session_key: boolean }>('get_llm_settings', { root: workspace.root }); if (epoch !== options.epoch()) return;
settings = result.settings;
$<HTMLInputElement>('llm-url').value = settings.base_url; $<HTMLInputElement>('llm-model').value = settings.model;
$<HTMLInputElement>('llm-key-env').value = settings.api_key_env; $<HTMLInputElement>('llm-key').value = ''; $<HTMLInputElement>('clear-llm-key').checked = false;
$('key-state').textContent = result.has_session_key ? 'A key is set for this session and endpoint.' : 'No session key is set. Local endpoints may not require one.';
$<HTMLInputElement>('llm-budget').value = String(settings.context_bytes / 1024); $<HTMLInputElement>('llm-output').value = String(settings.max_output_tokens);
$<HTMLSelectElement>('llm-token-parameter').value = settings.token_parameter; $<HTMLInputElement>('llm-stream').checked = settings.stream;
$('llm-audience').replaceChildren(...settings.audiences.map(a => { const option = document.createElement('option'); option.value = a.id; option.textContent = a.name; return option; }));
profile = settings.selected_audience; $<HTMLSelectElement>('llm-audience').value = profile; $<HTMLTextAreaElement>('llm-instructions').value = settings.audiences.find(a => a.id === profile)!.instructions;
$('llm-error').textContent = ''; dialog.showModal();
} catch (error) { $('explain-status').textContent = String(error); }
};
$('preset-local').onclick = () => { $<HTMLInputElement>('llm-url').value = 'http://localhost:1234/v1'; $<HTMLInputElement>('llm-key-env').value = ''; $<HTMLInputElement>('clear-llm-key').checked = true; };
$('preset-openai').onclick = () => { $<HTMLInputElement>('llm-url').value = 'https://api.openai.com/v1'; $<HTMLInputElement>('llm-key-env').value = 'OPENAI_API_KEY'; $<HTMLInputElement>('clear-llm-key').checked = true; };
$('llm-cancel').onclick = () => { $<HTMLInputElement>('llm-key').value = ''; dialog.close(); };
$('llm-form').onsubmit = async event => {
event.preventDefault(); const workspace = options.workspace(); if (!workspace || !settings) return;
audienceInstructions();
settings = { ...settings, base_url: $<HTMLInputElement>('llm-url').value.trim(), model: $<HTMLInputElement>('llm-model').value.trim(), api_key_env: $<HTMLInputElement>('llm-key-env').value.trim(), context_bytes: Number($<HTMLInputElement>('llm-budget').value) * 1024, max_output_tokens: Number($<HTMLInputElement>('llm-output').value), token_parameter: $<HTMLSelectElement>('llm-token-parameter').value, stream: $<HTMLInputElement>('llm-stream').checked, selected_audience: profile };
const key = $<HTMLInputElement>('llm-key').value; const apiKey = key || ($<HTMLInputElement>('clear-llm-key').checked ? '' : null);
$<HTMLInputElement>('llm-key').value = ''; $<HTMLButtonElement>('llm-save').disabled = true;
try { await invoke('save_llm_settings', { root: workspace.root, settings, apiKey }); clearContext(); text = ''; renderAnswer(); dialog.close(); $('explain-status').textContent = 'Settings saved. Prepare context using the selected audience before sending.'; }
catch (error) { $('llm-error').textContent = String(error); }
finally { $<HTMLButtonElement>('llm-save').disabled = false; }
};
return {
reset() { ++generation; clearTimeout(renderTimer); renderTimer = undefined; clearContext(); text = ''; $('explanation-answer').replaceChildren(); $('citation-status').textContent = ''; $('explain-target').textContent = 'Select C/C++ code, then prepare context.'; $('explain-status').textContent = 'Preparation stays local. Sending uses the endpoint you configure.'; setBusy(false); dialog.close(); $<HTMLInputElement>('llm-key').value = ''; },
fileOpened() { $<HTMLButtonElement>('nav-explain').disabled = editor.getModel()?.getLanguageId() !== 'cpp'; },
};
}

177
frontend/graph.ts Normal file
View File

@@ -0,0 +1,177 @@
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><p id="graph-status" class="hint" role="status">Select a concept or function 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') { 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','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(); } });
}
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=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 / callees ↑ · 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;}
}
$('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';} };
}

1
frontend/icon.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"><rect width="128" height="128" rx="26" fill="#202630"/><path d="M47 34 22 64 47 94M81 34 106 64 81 94" fill="none" stroke="#8fe5ce" stroke-width="9" stroke-linecap="round" stroke-linejoin="round"/><path d="m73 31-18 66" stroke="#8fe5ce" stroke-width="6" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 372 B

333
frontend/main.ts Normal file
View File

@@ -0,0 +1,333 @@
import { invoke, isTauri } from '@tauri-apps/api/core';
import { open, confirm } from '@tauri-apps/plugin-dialog';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import 'monaco-editor/esm/vs/basic-languages/cpp/cpp.contribution';
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 { installTheme } from './theme';
import { installNavigation } from './navigation';
import { installGraph } from './graph';
import { installVim } from './vim';
import { installExplanations } from './explanations';
self.MonacoEnvironment = { getWorker: () => new EditorWorker() };
type Settings = { graph?: { depth: number; excluded_namespaces: string[] }; version: number; build_directory: string | null; last_file: string | null; search_exclusions: string[]; clangd_path: string };
type Workspace = { root: string; settings: Settings };
type Inspection = { root: string; settings: Settings | null; warning: string | null; can_reset: boolean };
type Entry = { path: string; name: string; directory: boolean };
type Hit = { path: string; line: number; column: number; end_column: number; preview: string };
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>
<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>
<section id="files-panel"><div class="panel-heading">PROJECT FILES<button id="refresh" title="Refresh file tree" aria-label="Refresh file tree">↻</button></div><div id="tree"><p class="hint">Open a directory to explore its source.</p></div></section>
<section id="search-panel" hidden><label for="query">Search project text</label><input id="query" type="search" placeholder="Literal, case-sensitive search" autocomplete="off"><label for="exclusions" class="exclusions-label">Exclude paths (one glob per line)</label><textarea id="exclusions" rows="2" placeholder="docs/&#10;**/generated/**"></textarea><button id="apply-exclusions">Apply exclusions</button><p id="exclusion-status" class="hint" role="status"></p><div class="search-actions"><small>Respects .gitignore</small><button id="cancel-search" hidden>Cancel</button></div><p id="search-summary" class="hint">Results appear as you type.</p><div id="results"></div></section>
<section id="navigation-panel" hidden><div class="panel-heading">CLANGD<button id="restart-analysis" title="Restart clangd and reload compilation commands">Restart analysis</button></div><p id="analysis-detail" class="hint">Open a project with a build directory to enable navigation.</p><p id="diagnostic-count" class="hint"></p><h3 id="navigation-title">Code navigation</h3><p id="navigation-summary" class="hint">Select a symbol, then choose Definition, References, or Callers.</p><div id="navigation-results"></div><p id="navigation-evidence" class="hint"></p></section>
</aside>
<section class="code-pane"><div id="file-bar"><span id="file-path">Welcome to CEX</span><span class="badge">READ ONLY</span><button id="find-file" disabled>Find in file</button></div><div id="navigation-bar"><button id="nav-back" disabled title="Navigate back (Alt+Left)">← Back</button><button id="nav-definition" disabled title="Go to definition (F12 or Ctrl/Cmd+click)">Definition</button><button id="nav-references" disabled title="Find references (Shift+F12)">References</button><button id="nav-callers" disabled>Callers</button><button id="nav-hover" disabled>Type info</button><button id="nav-graph" disabled>Graph</button><button id="nav-explain" disabled>Explain…</button><button id="analysis-state" title="Show analysis status">Navigation unavailable</button></div><div id="editor"></div><div id="welcome"><div class="welcome-mark">{ cex }</div><h1>Explore the code.</h1><p>Browse C and C++ source, fold sections,<br>and find text across your project.</p><button id="welcome-open" class="primary">Open a project</button><small>Select a Meson or CMake build to enable definitions, references, and callers.</small></div></section>
</main>
<footer><span id="status" role="status">Ready</span><span id="cursor">C / C++ · Read only</span></footer>
<dialog id="setup"><form id="setup-form"><h2 id="setup-title">Open project</h2><p class="hint">Choose the source directory. CEX stores its settings and caches in that directorys .cex folder.</p>
<label for="source">Source directory</label><div class="field-row"><input id="source" placeholder="/path/to/project" required><button id="browse-source" type="button">Browse…</button></div>
<p id="saved-state" class="hint" role="status"></p>
<label for="build">Meson or CMake build directory <span class="optional">optional for browsing</span></label><div class="field-row"><input id="build" placeholder="/path/to/project/build"><button id="browse-build" type="button">Browse…</button></div>
<p class="hint">Select an existing configured build with compile_commands.json. For CMake, enable CMAKE_EXPORT_COMPILE_COMMANDS with a Ninja or Makefile generator; for presets, choose the presets binary directory. CEX does not configure or build your project.</p>
<label for="clangd-path">clangd executable</label><input id="clangd-path" placeholder="clangd" value="clangd"><p class="hint">Executable name on PATH or an absolute path. Changing it restarts analysis.</p>
<label class="reset-label"><input id="reset" type="checkbox">Force reset — discard this projects CEX settings and caches</label>
<p id="setup-error" class="error" role="alert"></p><div class="dialog-actions"><button id="setup-cancel" type="button">Cancel</button><button id="setup-submit" class="primary" type="submit">Open project</button></div>
</form></dialog>`;
const editor = monaco.editor.create($('editor'), {
contextmenu: true,
theme: 'vs-dark', readOnly: true, domReadOnly: true, automaticLayout: true,
fontSize: 14, fontFamily: "'SFMono-Regular', Consolas, 'Liberation Mono', monospace",
lineHeight: 22, minimap: { enabled: false }, scrollBeyondLastLine: false,
folding: true, foldingStrategy: 'auto', showFoldingControls: 'always',
renderLineHighlight: 'all', padding: { top: 14 }, wordWrap: 'off',
model: null,
});
installTheme();
let workspace: Workspace | null = null;
let epoch = 0;
let fileRequest = 0;
let searchRequest = 0;
let treeRequest = 0;
let searchTimer: ReturnType<typeof setTimeout>;
let inspecting = 0;
let inspectedRoot = '';
let inspection: Inspection | null = null;
let currentPath = '';
const viewStates = new Map<string, monaco.editor.ICodeEditorViewState>();
let rememberQueue: Promise<unknown> = Promise.resolve();
const setup = $<HTMLDialogElement>('setup');
const source = $<HTMLInputElement>('source');
const build = $<HTMLInputElement>('build');
const reset = $<HTMLInputElement>('reset');
const query = $<HTMLInputElement>('query');
const exclusions = $<HTMLTextAreaElement>('exclusions');
const clangdPath = $<HTMLInputElement>('clangd-path');
const navigation = installNavigation(editor, { workspace: () => workspace, path: () => currentPath, epoch: () => epoch, showPanel: () => switchPanel('navigation'), open: openFile });
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);
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; }
function showSetup() {
source.value = workspace?.root ?? '';
build.value = workspace?.settings.build_directory ?? '';
clangdPath.value = workspace?.settings.clangd_path ?? 'clangd';
reset.checked = false; reset.disabled = true;
inspectedRoot = ''; inspection = null;
$('saved-state').textContent = ''; $('setup-error').textContent = '';
$('setup-title').textContent = workspace ? 'Workspace settings' : 'Open project';
setup.showModal();
if (source.value) void inspectSource();
}
async function inspectSource() {
const request = ++inspecting;
const root = source.value.trim();
inspectedRoot = ''; inspection = null; reset.checked = false; reset.disabled = true;
if (!root) { $('saved-state').textContent = ''; return; }
$('saved-state').textContent = 'Checking for saved workspace…';
try {
const result = await invoke<Inspection>('inspect_workspace', { root });
if (request !== inspecting || source.value.trim() !== root) return;
inspection = result; inspectedRoot = root;
build.value = result.settings?.build_directory ?? '';
clangdPath.value = result.settings?.clangd_path ?? 'clangd';
reset.disabled = !result.can_reset;
$('saved-state').textContent = result.warning ?? (result.settings ? 'Saved workspace found. Build selection and last file will be restored.' : 'New workspace. A .cex folder will be created on opening.');
} catch (error) { if (request === inspecting) $('saved-state').textContent = errorText(error); }
}
source.addEventListener('input', () => { ++inspecting; inspectedRoot = ''; inspection = null; reset.checked = false; reset.disabled = true; build.value = ''; clangdPath.value = 'clangd'; $('saved-state').textContent = ''; });
source.addEventListener('change', () => void inspectSource());
$('browse-source').onclick = async () => {
try { const path = await open({ directory: true, multiple: false, title: 'Select source directory' }); if (typeof path === 'string') { source.value = path; await inspectSource(); } }
catch (error) { $('setup-error').textContent = errorText(error); }
};
$('browse-build').onclick = async () => {
try { const path = await open({ directory: true, multiple: false, title: 'Select configured Meson or CMake build directory' }); if (typeof path === 'string') build.value = path; }
catch (error) { $('setup-error').textContent = errorText(error); }
};
$('workspace-button').onclick = showSetup;
$('welcome-open').onclick = showSetup;
$('setup-cancel').onclick = () => setup.close();
$('setup-form').onsubmit = async (event) => {
event.preventDefault();
const controls = [...setup.querySelectorAll<HTMLInputElement | HTMLButtonElement>('input, button')];
controls.forEach(c => c.disabled = true);
$('setup-error').textContent = '';
try {
if (inspectedRoot !== source.value.trim()) await inspectSource();
if (!inspection) throw new Error('Choose an accessible source directory first.');
if (inspection.warning && !reset.checked) throw new Error(inspection.warning);
if (reset.checked && !await confirm('Delete CEX settings and caches in this projects .cex folder and start fresh? Source and build files will be preserved.', { title: 'Force reset CEX workspace', kind: 'warning' })) return;
++fileRequest;
await rememberQueue;
const next = await invoke<Workspace>('open_workspace', { root: source.value.trim(), build: build.value.trim() || null, reset: reset.checked, clangdPath: clangdPath.value.trim() || "clangd" });
workspace = next; $('landing').hidden = true; document.querySelector('main')!.classList.remove('landing'); ++epoch; ++fileRequest; ++searchRequest;
navigation.reset();
vim.reset();
explanations.reset();
graph.reset();
exclusions.value = (next.settings.search_exclusions ?? []).join('\n');
$('exclusion-status').textContent = '';
void navigation.start();
clearTimeout(searchTimer); viewStates.clear(); currentPath = '';
const model = editor.getModel(); editor.setModel(null); model?.dispose();
$('welcome').hidden = false; $('file-path').textContent = 'Choose a source file';
$<HTMLButtonElement>('find-file').disabled = true;
query.value = ''; $('results').replaceChildren(); $('search-summary').textContent = 'Results appear as you type.';
$('cancel-search').hidden = true;
$('project-name').textContent = next.root.split('/').pop() || next.root;
$('project-name').title = next.root;
$('workspace-button').textContent = 'Workspace…';
setup.close(); switchPanel('files');
status(next.settings.build_directory ? `Build: ${next.settings.build_directory}` : 'Browsing without a build directory');
await refreshTree();
if (next.settings.last_file) await openFile(next.settings.last_file);
try { await invoke('remember_recent', { root: next.root }); }
catch (error) { status(`Project opened, but recent history could not be saved: ${errorText(error)}`, true); }
} catch (error) { $('setup-error').textContent = errorText(error); status(errorText(error), true); }
finally { controls.forEach(c => c.disabled = false); reset.disabled = !inspection?.can_reset; }
};
function switchPanel(panel: 'files' | 'search' | 'navigation') {
for (const name of ['files', 'search', 'navigation']) {
$(name + '-panel').hidden = name !== panel;
$(name + '-tab').classList.toggle('active', name === panel);
}
if (panel === 'search') query.focus();
}
$('files-tab').onclick = () => switchPanel('files');
$('search-tab').onclick = () => switchPanel('search');
$('navigation-tab').onclick = () => switchPanel('navigation');
async function directoryNodes(path: string, level: number, activeEpoch: number): Promise<DocumentFragment> {
const entries = await invoke<Entry[]>('list_directory', { path });
const fragment = document.createDocumentFragment();
if (epoch !== activeEpoch) return fragment;
for (const entry of entries) {
const row = button(`${entry.directory ? '▸' : '·'} ${entry.name}`, 'tree-row');
row.style.paddingLeft = `${12 + level * 15}px`; row.title = entry.path;
if (entry.directory) {
row.setAttribute('aria-expanded', 'false');
const children = document.createElement('div'); children.hidden = true;
let loaded = false;
row.onclick = async () => {
if (epoch !== activeEpoch) return;
if (!loaded) {
row.disabled = true;
try { children.replaceChildren(await directoryNodes(entry.path, level + 1, activeEpoch)); loaded = true; }
catch (error) { status(errorText(error), true); return; }
finally { row.disabled = false; }
}
children.hidden = !children.hidden;
row.textContent = `${children.hidden ? '▸' : '▾'} ${entry.name}`;
row.setAttribute('aria-expanded', String(!children.hidden));
};
fragment.append(row, children);
} else {
row.dataset.path = entry.path;
row.classList.toggle('selected', entry.path === currentPath);
row.onclick = () => { if (epoch === activeEpoch) void openFile(entry.path); };
fragment.append(row);
}
}
return fragment;
}
async function refreshTree() {
if (!workspace) return;
const request = ++treeRequest, activeEpoch = epoch;
$('tree').textContent = 'Loading files…';
try {
const nodes = await directoryNodes('', 0, activeEpoch);
if (request === treeRequest && activeEpoch === epoch) {
$('tree').replaceChildren(nodes);
if (!$('tree').childElementCount) $('tree').textContent = 'This directory is empty.';
}
} catch (error) { if (request === treeRequest) { $('tree').textContent = 'Unable to list files.'; status(errorText(error), true); } }
}
$('refresh').onclick = () => void refreshTree();
async function openFile(path: string, hit?: Omit<Hit, 'preview'>, semantic = false) {
if (!workspace) return;
const request = ++fileRequest, activeEpoch = epoch, root = workspace.root;
status(`Loading ${path}`);
try {
const doc = await invoke<{ path: string; content: string }>('read_file', { path });
if (request !== fileRequest || activeEpoch !== epoch) return;
const previous = editor.getModel(), view = editor.saveViewState();
if (view && currentPath) viewStates.set(currentPath, view);
const language = semantic || /\.(c|h|cc|hh|cpp|hpp|cxx|hxx|ipp|tpp|inl|ixx|cppm)$/i.test(path) ? 'cpp' : 'plaintext';
const model = monaco.editor.createModel(doc.content, language);
editor.setModel(model); previous?.dispose(); currentPath = path;
if (viewStates.has(path)) editor.restoreViewState(viewStates.get(path)!);
if (hit) { editor.setSelection({ startLineNumber: hit.line, endLineNumber: hit.line, startColumn: hit.column, endColumn: hit.end_column }); editor.revealLineInCenter(hit.line); }
$('welcome').hidden = true; $('file-path').textContent = path; $('file-path').title = path;
$<HTMLButtonElement>('find-file').disabled = false;
document.querySelectorAll<HTMLElement>('[data-path]').forEach(row => row.classList.toggle('selected', row.dataset.path === path));
status(`${model.getLineCount().toLocaleString()} lines · ${language === 'cpp' ? 'C / C++' : 'Plain text'} · Read only`);
editor.focus();
navigation.fileOpened();
explanations.fileOpened();
graph.fileOpened();
if (path.startsWith('/') || (workspace.settings.build_directory && `${root}/${path}`.startsWith(workspace.settings.build_directory + '/'))) return;
workspace.settings.last_file = path;
rememberQueue = rememberQueue.then(() => invoke('remember_file', { path, root })).catch(error => { if (epoch === activeEpoch) status(`File opened, but settings could not be saved: ${errorText(error)}`, true); });
} catch (error) { if (request === fileRequest && activeEpoch === epoch) status(errorText(error), true); }
}
editor.onDidChangeCursorPosition(event => { $('cursor').textContent = `Ln ${event.position.lineNumber}, Col ${event.position.column} · Read only`; });
$('find-file').onclick = () => { editor.focus(); void editor.getAction('actions.find')?.run(); };
function cancelSearch() {
++searchRequest; clearTimeout(searchTimer);
void invoke('cancel_search').catch(error => status(errorText(error), true));
$('cancel-search').hidden = true;
}
$('cancel-search').onclick = () => { cancelSearch(); $('search-summary').textContent = 'Search cancelled.'; };
$('apply-exclusions').onclick = async () => {
if (!workspace) { $('exclusion-status').textContent = 'Open a project first.'; return; }
const patterns = exclusions.value.split('\n').map(s => s.trim()).filter(Boolean);
const root = workspace.root, activeEpoch = epoch;
$<HTMLButtonElement>('apply-exclusions').disabled = true;
cancelSearch();
try {
await invoke('save_search_exclusions', { root, exclusions: patterns });
if (epoch !== activeEpoch) return;
workspace.settings.search_exclusions = patterns;
$('exclusion-status').textContent = 'Exclusions saved for this project.';
query.dispatchEvent(new Event('input'));
} catch (error) { if (epoch === activeEpoch) $('exclusion-status').textContent = errorText(error); }
finally { $<HTMLButtonElement>('apply-exclusions').disabled = false; }
};
query.oninput = () => {
cancelSearch(); $('results').replaceChildren();
if (!workspace) { $('search-summary').textContent = 'Open a project first.'; return; }
if (!query.value) { $('search-summary').textContent = 'Results appear as you type.'; return; }
const request = searchRequest, activeEpoch = epoch, term = query.value;
$('search-summary').textContent = 'Searching…'; $('cancel-search').hidden = false;
searchTimer = setTimeout(async () => {
try {
const result = await invoke<SearchResults>('search', { query: term });
if (request !== searchRequest || epoch !== activeEpoch) return;
const fragment = document.createDocumentFragment();
for (const hit of result.hits) {
const row = button('', 'result');
const location = document.createElement('strong'); location.textContent = `${hit.path}:${hit.line}`;
const preview = document.createElement('span'); preview.textContent = hit.preview;
row.append(location, preview); row.title = `${hit.path}:${hit.line}:${hit.column}`;
row.onclick = () => { if (epoch === activeEpoch) void openFile(hit.path, hit); };
fragment.append(row);
}
$('results').replaceChildren(fragment);
$('search-summary').textContent = `${result.hits.length}${result.truncated ? '+' : ''} matches${result.truncated ? ' — limit reached; narrow your search' : ''}${result.cancelled ? ' (cancelled)' : ''}${result.skipped_files ? ` · ${result.skipped_files} unreadable, binary, oversized or unsupported files skipped` : ''}`;
} catch (error) { if (request === searchRequest) $('search-summary').textContent = errorText(error); }
finally { if (request === searchRequest) $('cancel-search').hidden = true; }
}, 250);
};
document.addEventListener('keydown', event => {
if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key.toLowerCase() === 'f') { event.preventDefault(); switchPanel('search'); }
});
if (!isTauri()) {
status('Browser preview only. Run npm run desktop to access local projects.', true);
$('workspace-button').onclick = $('welcome-open').onclick = () => status('Folder access requires the desktop app: npm run desktop', true);
}
async function loadRecents() {
if (!isTauri()) { $('recent-status').textContent = 'Open the desktop app to see recent projects.'; return; }
$('recent-status').textContent = 'Loading recent projects…';
try {
const projects = await invoke<{ root: string; last_opened: number }[]>('recent_projects');
$('recent-projects').replaceChildren();
for (const project of projects) {
const row = document.createElement('div'); row.className = 'recent-row';
const open = button('', 'recent-project');
const name = document.createElement('strong'); name.textContent = project.root.split('/').pop() || project.root;
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();
};
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 () => {
remove.disabled = true;
try { await invoke('remove_recent', { root: project.root }); await loadRecents(); }
catch (error) { $('recent-status').textContent = errorText(error); remove.disabled = false; }
};
row.append(open, remove); $('recent-projects').append(row);
}
$('recent-status').textContent = projects.length ? 'Select a project to restore its saved build and last viewed file.' : 'No recent projects yet. Open a project to get started.';
} catch (error) { $('recent-status').textContent = `Could not load recent projects: ${errorText(error)}`; }
}
$('recent-open').onclick = () => $('workspace-button').click();
$('refresh-recents').onclick = () => void loadRecents();
void loadRecents();

198
frontend/navigation.ts Normal file
View File

@@ -0,0 +1,198 @@
import { invoke, isTauri } from '@tauri-apps/api/core';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import 'monaco-editor/esm/vs/editor/contrib/hover/browser/hoverContribution';
import 'monaco-editor/esm/vs/editor/contrib/contextmenu/browser/contextmenu';
type Position = { line: number; character: number };
type Range = { start: Position; end: Position };
type Location = { path: string; range: Range; label: string };
type Result = { locations: Location[]; hover: string; folds: { startLine: number; endLine: number; kind?: string }[]; evidence: { engine: string; coverage: string; build_directory: string | null } };
type Diagnostic = { range: Range; severity?: number; message: string };
type Status = { phase: string; message: string; server: string; indexing: boolean; diagnostics: Record<string, Diagnostic[]> };
type Workspace = { root: string; settings: { build_directory: string | null } };
type Options = {
workspace: () => Workspace | null;
path: () => string;
epoch: () => number;
showPanel: () => void;
open: (path: string, hit: { path: string; line: number; column: number; end_column: number }, semantic?: boolean) => Promise<void>;
};
const $ = (id: string) => document.getElementById(id)!;
export function installNavigation(editor: monaco.editor.IStandaloneCodeEditor, options: Options) {
let ready: Promise<unknown> = Promise.resolve();
let request = 0;
let locations: Location[] = [], locationIndex = -1;
let stepping = false;
let statusBusy = false;
let currentStatus: Status | null = null;
const history: { path: string; position: monaco.Position }[] = [];
function updateHistory() { ($('nav-back') as HTMLButtonElement).disabled = !history.length; }
function remember() {
const position = editor.getPosition();
if (position && options.path()) { history.push({ path: options.path(), position }); if (history.length > 100) history.shift(); }
updateHistory();
}
async function back() {
const previous = history.pop(); updateHistory();
if (previous) await options.open(previous.path, { path: previous.path, line: previous.position.lineNumber, column: previous.position.column, end_column: previous.position.column }, true);
}
function renderStatus(value: Status) {
currentStatus = value;
$('analysis-state').textContent = value.phase === 'unavailable' ? 'Navigation unavailable' : value.indexing ? 'Indexing…' : `clangd · ${value.phase}`;
$('analysis-state').title = value.message;
$('analysis-detail').textContent = value.message;
const model = editor.getModel();
if (!model) return;
const workspace = options.workspace();
if (!workspace) return;
const absolute = options.path().startsWith('/') ? options.path() : `${workspace.root}/${options.path()}`;
const uri = monaco.Uri.file(absolute).toString();
// URL serializers differ in escaping; compare decoded paths when necessary.
const entries = Object.entries(value.diagnostics ?? {});
const diagnostics = entries.find(([key]) => key === uri || decodeURI(key) === decodeURI(uri))?.[1] ?? [];
monaco.editor.setModelMarkers(model, 'clangd', diagnostics.map(d => ({
startLineNumber: d.range.start.line + 1, startColumn: d.range.start.character + 1,
endLineNumber: d.range.end.line + 1, endColumn: d.range.end.character + 1,
message: d.message, severity: d.severity === 1 ? monaco.MarkerSeverity.Error : d.severity === 2 ? monaco.MarkerSeverity.Warning : monaco.MarkerSeverity.Info,
})));
$('diagnostic-count').textContent = diagnostics.length ? `${diagnostics.length} diagnostic${diagnostics.length === 1 ? '' : 's'} in this file` : '';
}
async function poll() {
const workspace = options.workspace();
if (!workspace || statusBusy || !isTauri()) return;
statusBusy = true; const epoch = options.epoch();
try { const value = await invoke<Status>('analysis_status', { root: workspace.root }); if (epoch === options.epoch()) renderStatus(value); }
catch (error) { if (epoch === options.epoch()) $('analysis-detail').textContent = String(error); }
finally { statusBusy = false; }
}
async function start() {
const workspace = options.workspace(); if (!workspace) return;
const epoch = options.epoch(); ++request;
$('analysis-state').textContent = 'Starting clangd…';
$('analysis-detail').textContent = 'Starting analysis for the selected build…';
($('restart-analysis') as HTMLButtonElement).disabled = true;
ready = invoke<Status>('start_analysis', { root: workspace.root }).then(value => {
if (epoch === options.epoch()) renderStatus(value);
}).catch(error => { if (epoch === options.epoch()) renderStatus({ phase: 'unavailable', message: String(error), server: '', indexing: false, diagnostics: {} }); });
await ready;
if (epoch !== options.epoch()) return;
($('restart-analysis') as HTMLButtonElement).disabled = false;
editor.updateOptions({ foldingStrategy: 'indentation' });
editor.updateOptions({ foldingStrategy: 'auto' });
}
async function query(kind: string, position?: monaco.Position): Promise<Result> {
const workspace = options.workspace(), path = options.path(), epoch = options.epoch();
if (!workspace || !path || editor.getModel()?.getLanguageId() !== 'cpp') throw new Error('Open a C/C++ file first.');
await ready;
if (epoch !== options.epoch() || path !== options.path()) throw new Error('Source selection changed.');
if (currentStatus?.phase === 'unavailable' || currentStatus?.phase === 'error') throw new Error(currentStatus.message);
const p = position ?? editor.getPosition() ?? new monaco.Position(1, 1);
return invoke<Result>('analysis_request', { root: workspace.root, path, position: { line: p.lineNumber - 1, character: p.column - 1 }, kind });
}
async function jump(location: Location) {
remember();
await options.open(location.path, { path: location.path, line: location.range.start.line + 1, column: location.range.start.character + 1, end_column: location.range.end.line === location.range.start.line ? location.range.end.character + 1 : location.range.start.character + 1 }, true);
}
async function navigate(kind: 'definition' | 'references' | 'callers' | 'hover', openFirst = false) {
locations = []; locationIndex = -1;
const ticket = ++request, epoch = options.epoch(), path = options.path();
const position = editor.getPosition();
const symbol = position ? editor.getModel()?.getWordAtPosition(position)?.word : '';
options.showPanel();
const names = { definition: 'Definitions', references: 'References', callers: 'Callers', hover: 'Type information' };
$('navigation-title').textContent = `${names[kind]}${symbol ? ` · ${symbol}` : ''}`;
$('navigation-summary').textContent = 'Asking clangd…';
$('navigation-results').replaceChildren(); $('navigation-evidence').textContent = '';
try {
const result = await query(kind, position ?? undefined);
if (ticket !== request || epoch !== options.epoch() || path !== options.path()) return;
$('navigation-evidence').textContent = `${result.evidence.engine}. ${result.evidence.coverage}`;
if (kind === 'hover') {
$('navigation-summary').textContent = result.hover ? '' : 'No type information at this position.';
const pre = document.createElement('pre'); pre.className = 'type-info'; pre.textContent = result.hover;
$('navigation-results').append(pre); return;
}
$('navigation-summary').textContent = result.locations.length ? `${result.locations.length} location${result.locations.length === 1 ? '' : 's'}` : kind === 'callers' ? 'No callers found. Select a function; use References for concepts and types. Results may still be indexing.' : 'No locations found. Check indexing status and any diagnostics, then retry.';
locations = result.locations;
for (const [index, location] of result.locations.entries()) {
const button = document.createElement('button'); button.className = 'result';
const label = document.createElement('strong'); label.textContent = location.label || location.path;
const detail = document.createElement('span'); detail.textContent = `${location.path}:${location.range.start.line + 1}`;
button.append(label, detail); button.title = detail.textContent;
button.onclick = () => { if (epoch === options.epoch()) { locationIndex = index; markLocation(); void jump(location); } };
$('navigation-results').append(button);
}
if ((kind === 'definition' && result.locations.length === 1) || (openFirst && locations.length)) { locationIndex = 0; markLocation(); await jump(locations[0]); }
} catch (error) { if (ticket === request && epoch === options.epoch()) $('navigation-summary').textContent = String(error); }
}
function markLocation() {
[...$('navigation-results').children].forEach((row, index) => {
row.classList.toggle('selected', index === locationIndex);
if (index === locationIndex) row.scrollIntoView({ block: 'nearest' });
});
$('navigation-summary').textContent = `${locationIndex + 1} / ${locations.length} locations · n next, N previous`;
}
async function step(direction: number, count = 1) {
if (!locations.length || stepping) return;
stepping = true;
try {
locationIndex = (locationIndex + direction * count % locations.length + locations.length) % locations.length;
markLocation(); await jump(locations[locationIndex]);
} finally { stepping = false; }
}
for (const [kind, label, key] of [
['definition', 'Go to Definition', monaco.KeyCode.F12],
['references', 'Find References', monaco.KeyMod.Shift | monaco.KeyCode.F12],
['callers', 'Find Callers', undefined],
['hover', 'Show Type Information', undefined],
] as const) {
$('nav-' + kind).onclick = () => void navigate(kind);
editor.addAction({ id: 'cex.' + kind, label, keybindings: key ? [key] : undefined,
contextMenuGroupId: 'navigation', contextMenuOrder: 1, run: () => navigate(kind) });
}
$('nav-back').onclick = () => void back();
editor.addAction({ id: 'cex.back', label: 'Navigate Back', keybindings: [monaco.KeyMod.Alt | monaco.KeyCode.LeftArrow], run: back });
editor.onMouseDown(event => {
if (event.event.rightButton && event.target.position) {
const selection = editor.getSelection();
if (!selection?.containsPosition(event.target.position)) editor.setPosition(event.target.position);
}
if ((event.event.ctrlKey || event.event.metaKey) && event.event.leftButton && event.target.position) {
editor.setPosition(event.target.position); void navigate('definition');
}
});
monaco.languages.registerHoverProvider('cpp', {
async provideHover(model, position, token) {
const epoch = options.epoch();
try {
const result = await query('hover', position);
if (token.isCancellationRequested || model !== editor.getModel() || epoch !== options.epoch() || !result.hover) return null;
// Render server text as literal code; never enable trusted Markdown/commands.
return { contents: [{ value: '```text\n' + result.hover.replaceAll('```', "''' ") + '\n```', isTrusted: false }] };
} catch { return null; }
},
});
monaco.languages.registerFoldingRangeProvider('cpp', {
async provideFoldingRanges(model, _context, token) {
const epoch = options.epoch();
try {
const result = await query('folding', new monaco.Position(1, 1));
if (token.isCancellationRequested || model !== editor.getModel() || epoch !== options.epoch()) return undefined;
return result.folds.map(f => ({ start: f.startLine + 1, end: f.endLine + 1 }));
} catch { return undefined; }
},
});
$('restart-analysis').onclick = () => void start();
$('analysis-state').onclick = options.showPanel;
setInterval(() => void poll(), 1000);
return {
start, navigate, step, back,
whenReady: () => ready,
reset() { ++request; locations = []; locationIndex = -1; history.length = 0; updateHistory(); currentStatus = null; $('navigation-results').replaceChildren(); $('navigation-title').textContent = 'Code navigation'; $('navigation-summary').textContent = 'Select a symbol in C/C++ code, then choose Definition, References, or Callers.'; $('navigation-evidence').textContent = ''; },
fileOpened() {
const cpp = editor.getModel()?.getLanguageId() === 'cpp';
for (const kind of ['definition', 'references', 'callers', 'hover']) ($('nav-' + kind) as HTMLButtonElement).disabled = !cpp;
void poll();
},
};
}

262
frontend/style.css Normal file
View File

@@ -0,0 +1,262 @@
/* Shared semantic colors keep every surface in sync with Monaco. */
:root, :root[data-theme="dark"] {
color-scheme: dark;
--bg: #171c23; --surface: #1c222b; --raised: #242c37; --hover: #2d3845;
--border: #303946; --strong-border: #536174; --text: #dce3eb; --muted: #9caabc;
--accent: #83d8bd; --accent-text: #a4dccb; --selected: #28483f;
--input: #151b23; --primary: #83d8bd; --primary-hover: #a3e5d0; --on-primary: #14352b;
--error: #ffb4a9; --warning: #e7c480; --canvas: #171c23;
--node: #243440; --node-border: #6e8999; --node-requires: #3b3628;
--node-operator: #352e48; --node-focus: #b1ead9; --graph-muted: #a0becb; --graph-edge: #8eacb9;
--shadow: 0 12px 40px #0003;
}
:root[data-theme="light"] {
color-scheme: light;
--bg: #ffffff; --surface: #f5f6f4; --raised: #ffffff; --hover: #e9efeb;
--border: #dce2df; --strong-border: #a4b2ad; --text: #243347; --muted: #5c6b78;
--accent: #176e59; --accent-text: #216b59; --selected: #dceee5;
--input: #ffffff; --primary: #216e58; --primary-hover: #185844; --on-primary: #ffffff;
--error: #b02c28; --warning: #825900; --canvas: #fbfcfa;
--node: #edf3f7; --node-border: #8a9eaa; --node-requires: #fbf2db;
--node-operator: #efe8fa; --node-focus: #176e59; --graph-muted: #516b78; --graph-edge: #6b8390;
--shadow: 0 12px 40px #233b2814;
}
:root { font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: var(--text); background: var(--bg); font-synthesis: none; }
* { box-sizing: border-box; }
body { margin: 0; }
button, input { font: inherit; }
button { color: var(--text); background: var(--raised); border: 1px solid var(--border); border-radius: 5px; padding: 7px 11px; cursor: pointer; }
button:hover { background: var(--hover); }
button:disabled { opacity: .45; cursor: default; }
button:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.primary { background: var(--primary); color: var(--on-primary); border-color: var(--primary); font-weight: 600; }
.primary:hover { background: var(--primary-hover); }
[hidden] { display: none !important; }
#app { height: 100vh; display: grid; grid-template-rows: 58px minmax(0, 1fr) 29px; }
header { display: flex; align-items: center; gap: 28px; padding: 0 18px; border-bottom: 1px solid var(--border); background: var(--surface); }
.brand { color: var(--accent); font-size: 25px; font-weight: 750; letter-spacing: -1px; display: flex; align-items: center; gap: 12px; }
.brand span { font-size: 9px; letter-spacing: 1.8px; color: var(--muted); font-weight: 500; }
#project-name { flex: 1; color: var(--muted); font-size: 13px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
main { display: grid; grid-template-columns: clamp(250px, 27vw, 390px) minmax(0, 1fr); min-height: 0; }
aside { background: var(--surface); border-right: 1px solid var(--border); min-height: 0; display: flex; flex-direction: column; }
nav { display: flex; border-bottom: 1px solid var(--border); }
nav button { background: none; border: none; border-radius: 0; border-bottom: 2px solid transparent; padding: 13px 22px; color: var(--muted); }
nav button.active { color: var(--accent); border-bottom-color: var(--accent); }
#files-panel, #search-panel { overflow: auto; min-height: 0; }
.panel-heading { display: flex; align-items: center; justify-content: space-between; color: var(--muted); letter-spacing: 1.4px; font-size: 10px; padding: 8px 12px; }
.panel-heading button { border: none; background: none; font-size: 20px; padding: 0 6px; }
.tree-row { display: block; width: 100%; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border: 0; border-radius: 0; background: none; font-size: 13px; line-height: 20px; padding-top: 4px; padding-bottom: 4px; }
.tree-row.selected { background: var(--selected); color: var(--accent-text); }
#tree > .hint { padding: 0 14px; }
.code-pane { position: relative; min-width: 0; min-height: 0; display: grid; grid-template-rows: 45px minmax(0, 1fr); }
#file-bar { display: flex; align-items: center; gap: 14px; padding: 0 16px; background: var(--raised); border-bottom: 1px solid var(--border); font-size: 12px; }
#file-path { flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.badge { font-size: 9px; letter-spacing: 1px; color: var(--accent-text); white-space: nowrap; }
#find-file { font-size: 11px; padding: 4px 8px; }
#editor { min-height: 0; min-width: 0; }
#welcome { position: absolute; inset: 45px 0 0; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; background: var(--bg); padding: 24px; }
.welcome-mark { font-family: monospace; font-size: 44px; color: var(--accent); }
h1 { font-size: 29px; font-weight: 550; margin-bottom: 0; }
#welcome p { color: var(--muted); line-height: 1.8; margin: 18px 0 26px; }
#welcome small { color: var(--muted); font-size: 11px; margin-top: 30px; max-width: 350px; line-height: 1.7; }
footer { display: flex; justify-content: space-between; gap: 16px; padding: 6px 12px; border-top: 1px solid var(--border); color: var(--muted); background: var(--surface); font-size: 11px; }
#status { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#cursor { white-space: nowrap; }
#search-panel { padding: 14px 12px; }
label { display: block; font-size: 12px; margin-bottom: 8px; }
input:not([type=checkbox]) { width: 100%; min-width: 0; background: var(--input); border: 1px solid var(--strong-border); color: var(--text); padding: 9px 10px; border-radius: 5px; font-size: 13px; }
.search-actions { display: flex; align-items: center; justify-content: space-between; margin-top: 9px; font-size: 11px; color: var(--muted); }
.search-actions button { padding: 3px 6px; font-size: 11px; }
.hint { color: var(--muted); font-size: 12px; line-height: 1.6; }
.result { display: block; width: 100%; text-align: left; background: none; border: none; border-bottom: 1px solid var(--border); border-radius: 0; padding: 10px 2px; overflow: hidden; }
.result strong { display: block; font-size: 12px; font-weight: 500; color: var(--accent-text); overflow-wrap: anywhere; }
.result span { display: block; font-family: monospace; font-size: 12px; white-space: pre; overflow: hidden; text-overflow: ellipsis; margin-top: 7px; color: var(--muted); }
dialog { width: min(650px, 90vw); background: var(--raised); color: var(--text); border: 1px solid var(--strong-border); border-radius: 12px; padding: 28px; box-shadow: 0 24px 100px #0008; }
dialog::backdrop { background: #080c12b8; }
h2 { font-weight: 550; margin-top: 0; }
.field-row { display: flex; gap: 8px; }
.field-row button { flex-shrink: 0; }
.optional { color: var(--muted); font-weight: normal; margin-left: 7px; }
.reset-label { display: flex; align-items: center; gap: 9px; margin-top: 24px; line-height: 1.5; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
.error { color: var(--error); }
#setup-error { font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
/* Navigation is a separate service; text search exclusions never filter symbols. */
nav button { padding-left: 15px; padding-right: 15px; font-size: 13px; }
.code-pane { grid-template-rows: 45px 37px minmax(0, 1fr); }
#welcome { top: 82px; }
#navigation-bar { display: flex; gap: 5px; align-items: center; padding: 0 10px; background: var(--surface); border-bottom: 1px solid var(--border); min-width: 0; }
#navigation-bar button { font-size: 11px; padding: 4px 7px; white-space: nowrap; }
#analysis-state { margin-left: auto; color: var(--accent-text); overflow: hidden; text-overflow: ellipsis; }
#navigation-panel { padding: 10px 12px; overflow: auto; min-height: 0; }
#navigation-panel .panel-heading { padding: 0; }
#navigation-panel .panel-heading button { font-size: 11px; letter-spacing: normal; border: 1px solid var(--border); padding: 5px 8px; }
#navigation-title { font-size: 14px; font-weight: 550; overflow-wrap: anywhere; }
#analysis-detail, #navigation-summary, #navigation-evidence { overflow-wrap: anywhere; }
#navigation-evidence { font-size: 11px; margin-top: 22px; }
.type-info { font-family: monospace; font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--accent-text); }
.exclusions-label { margin-top: 15px; }
textarea { width: 100%; resize: vertical; color: var(--text); background: var(--input); border: 1px solid var(--strong-border); border-radius: 5px; padding: 8px; font: 12px monospace; }
#apply-exclusions { margin-top: 6px; font-size: 11px; }
#exclusion-status:empty { display: none; }
main.explaining { grid-template-columns: minmax(210px, 22vw) minmax(300px, 1fr) minmax(380px, 35vw); }
#explanation-pane { min-height: 0; min-width: 0; display: flex; flex-direction: column; border-left: 1px solid var(--border); background: var(--surface); }
.explanation-heading { display: flex; align-items: center; gap: 8px; padding: 11px 13px; border-bottom: 1px solid var(--border); }
.explanation-heading strong { flex: 1; font-size: 14px; }
.explanation-heading button { font-size: 11px; }
.explanation-content { padding: 12px 15px 24px; overflow: auto; min-height: 0; }
.explanation-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.explanation-actions button { font-size: 12px; }
#explain-destination, #explain-status { overflow-wrap: anywhere; }
#context-details { padding: 10px; border: 1px solid var(--border); border-radius: 6px; font-size: 12px; }
#context-details summary { cursor: pointer; line-height: 1.6; overflow-wrap: anywhere; }
#context-sources details { margin: 8px 0; }
#context-details pre { max-height: 300px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; padding: 8px; background: var(--input); font-size: 11px; }
#explanation-answer { font-size: 14px; line-height: 1.7; overflow-wrap: anywhere; }
#explanation-answer pre { padding: 10px; overflow: auto; background: var(--input); border-radius: 5px; font-size: 12px; }
#explanation-answer code { font-size: 12px; }
#explanation-answer h1 { font-size: 21px; }
#explanation-answer h2 { font-size: 18px; margin-top: 22px; }
#explanation-answer h3 { font-size: 16px; }
#explanation-answer table { display: block; overflow: auto; border-collapse: collapse; }
#explanation-answer td, #explanation-answer th { padding: 6px; border: 1px solid var(--border); }
.source-citation { color: var(--accent-text); border: 0; background: var(--selected); padding: 1px 5px; font-size: 11px; vertical-align: baseline; }
#llm-settings { max-height: 90vh; overflow: auto; }
#llm-settings label { margin-top: 13px; }
#llm-settings .reset-label { margin-top: 10px; }
#llm-settings select { width: 100%; padding: 8px; font: inherit; font-size: 13px; background: var(--input); color: var(--text); border: 1px solid var(--strong-border); border-radius: 5px; }
.model-presets { display: flex; gap: 8px; margin-bottom: 12px; }
.model-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
@media (max-width: 1100px) {
main.explaining { grid-template-columns: clamp(250px, 27vw, 390px) minmax(0, 1fr); }
#explanation-pane { position: fixed; right: 0; top: 58px; bottom: 29px; width: min(520px, 85vw); z-index: 10; box-shadow: -10px 0 40px #0006; }
}
#callee-options { border: 1px solid var(--border, var(--border)); border-radius: 5px; margin: 12px 0; padding: 10px; min-width: 0; }
#callee-options select { width: 100%; }
#callee-list { max-height: 180px; overflow: auto; margin: 8px 0; }
#callee-list label { display: block; overflow-wrap: anywhere; font-size: 12px; margin: 6px 0; }
#context-size-warning { color: var(--warning); }
#vim-status { color: var(--accent); font-size: 11px; white-space: nowrap; }
#navigation-results .result.selected { background: var(--selected); border-left: 2px solid var(--accent); }
main { position: relative; }
main.landing > aside, main.landing > .code-pane { visibility: hidden; }
#landing { position: absolute; inset: 0; z-index: 5; overflow: auto; padding: 40px max(28px, calc((100% - 860px) / 2)); background: var(--surface); }
.landing-intro { margin-bottom: 32px; }
.landing-intro h1 { margin: 12px 0; }
.landing-intro p { color: var(--muted); }
.recent-heading { display: flex; align-items: center; justify-content: space-between; }
.recent-heading h2 { font-size: 16px; font-weight: 500; }
#recent-projects { margin: 16px 0 24px; }
.recent-row { display: flex; align-items: stretch; margin-bottom: 8px; gap: 8px; }
.recent-project { flex: 1; min-width: 0; text-align: left; padding: 14px 16px; }
.recent-project strong, .recent-project span, .recent-project small { display: block; overflow-wrap: anywhere; }
.recent-project span { color: var(--muted); margin: 5px 0; font-size: 12px; }
.recent-project small { color: var(--muted); font-size: 11px; }
.recent-remove { align-self: center; font-size: 20px; }
.sidebar-tabs { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px; border-bottom: 1px solid var(--border); }
.sidebar-tabs [role=tablist] { display: flex; gap: 6px; }
.sidebar-tabs [aria-selected=true] { background: var(--selected); color: var(--accent); }
#chat-panel, #graph-panel { min-height: 0; display: flex; flex-direction: column; flex: 1; overflow: hidden; }
#graph-panel { padding: 10px; gap: 8px; }
.graph-tools { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.graph-tools button { font-size: 11px; }
#graph-status { margin: 0; }
#graph-canvas { min-height: 240px; flex: 1; overflow: auto; background: var(--canvas); border: 1px solid var(--border); }
#graph-canvas svg { display: block; }
.graph-node { cursor: pointer; }
.graph-node rect { fill: var(--node); stroke: var(--node-border); }
.graph-node.selected rect { fill: var(--selected); stroke: var(--accent); stroke-width: 2; }
.graph-node.requires rect { fill: var(--node-requires); }
.graph-node.operator rect { fill: var(--node-operator); }
.graph-node:focus rect, .graph-node:hover rect { stroke: var(--node-focus); stroke-width: 3; }
.graph-node text { fill: var(--text); font-size: 12px; font-family: monospace; }
.graph-node .graph-kind { fill: var(--graph-muted); font-size: 10px; }
.graph-edge-label { fill: var(--graph-muted); font-size: 11px; }
#graph-detail { max-height: 170px; overflow: auto; }
#graph-detail pre { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0 0 8px; }
#graph-detail button { font-size: 11px; margin-right: 8px; }
#graph-warnings { max-height: 85px; overflow: auto; font-size: 11px; }
#graph-warnings p { margin: 0 0 6px; }
/* Quiet chrome, with emphasis reserved for selection and primary actions. */
body { font-size: 13px; }
button, input, textarea, select { transition: background-color 120ms, border-color 120ms; }
button { border-radius: 6px; font-size: 12px; }
button:disabled:hover { background: inherit; }
button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
select { font: inherit; font-size: 12px; color: var(--text); background: var(--input); border: 1px solid var(--strong-border); padding: 6px 8px; border-radius: 6px; }
input::placeholder, textarea::placeholder { color: var(--muted); opacity: .85; }
input[type=checkbox] { accent-color: var(--accent); }
header { gap: 12px; }
.brand { margin-right: 20px; font-size: 24px; }
.brand span { letter-spacing: 1.6px; }
#theme-select { width: 83px; border-color: transparent; background-color: transparent; color: var(--muted); cursor: pointer; }
#theme-select:hover { background: var(--hover); }
.icon-button { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 3px; border-color: transparent; background: transparent; color: var(--muted); flex-shrink: 0; }
.icon-button:hover { background: var(--hover); color: var(--text); }
.icon-button[aria-expanded=true] { color: var(--accent); background: var(--selected); }
#close-explanation { font-size: 20px; }
#workspace-button { padding: 6px 10px; }
main { grid-template-columns: clamp(230px, 22vw, 320px) minmax(0, 1fr); }
main.explaining { grid-template-columns: minmax(210px, 19vw) minmax(300px, 1fr) minmax(370px, 33vw); }
nav button { padding: 12px 14px; font-size: 12px; }
nav button:hover { background: var(--hover); }
.tree-row { width: calc(100% - 12px); margin: 1px 6px; border-radius: 5px; }
#file-bar { background: var(--bg); gap: 10px; }
.badge { font-size: 9px; padding: 3px 6px; border: 1px solid var(--border); border-radius: 4px; }
#navigation-bar { overflow-x: auto; scrollbar-width: thin; }
#navigation-bar button { border-color: transparent; background: transparent; }
#navigation-bar button:not(:disabled):hover { background: var(--hover); }
#analysis-state { min-width: 90px; }
.sidebar-tabs { padding: 7px 10px; }
.sidebar-tabs [role=tablist] { background: var(--input); padding: 3px; border-radius: 7px; gap: 2px; }
.sidebar-tabs [role=tab] { padding: 5px 16px; border: 0; color: var(--muted); background: transparent; }
.sidebar-tabs [aria-selected=true] { background: var(--selected); color: var(--accent); }
.explanation-heading { padding: 12px 15px; }
.explanation-heading strong { font-size: 13px; }
#model-settings, #refresh-recents { background: transparent; border-color: transparent; color: var(--muted); }
#model-settings:hover, #refresh-recents:hover { background: var(--hover); }
#context-details, #callee-options { background: var(--bg); border-radius: 8px; }
#graph-canvas { border-radius: 8px; background-image: radial-gradient(var(--border) .7px, transparent .7px); background-size: 16px 16px; }
#landing { background: var(--surface); padding-top: clamp(36px, 7vh, 80px); }
.landing-intro { margin-bottom: 44px; }
.landing-intro .welcome-mark { font-size: 32px; }
.landing-intro h1 { font-size: 32px; letter-spacing: -.8px; margin-top: 20px; }
.landing-intro p { margin: 12px 0 24px; line-height: 1.6; }
.recent-project { background: var(--bg); border-color: var(--border); border-radius: 9px; padding: 16px 18px; }
.recent-project:hover { border-color: var(--strong-border); background: var(--hover); }
.recent-project strong { font-size: 14px; font-weight: 600; }
.recent-remove { border-color: transparent; background: transparent; color: var(--muted); padding: 3px 8px; }
dialog { background: var(--surface); border-color: var(--border); box-shadow: var(--shadow); }
#explanation-answer blockquote { margin-left: 0; padding-left: 14px; border-left: 3px solid var(--border); color: var(--muted); }
@media (max-width: 1100px) {
main.explaining { grid-template-columns: clamp(230px, 22vw, 320px) minmax(0, 1fr); }
#explanation-pane { box-shadow: var(--shadow); }
}
@media (prefers-reduced-motion: reduce) {
button, input, textarea, select { transition: none; }
}
#graph-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#graph-options { border: 1px solid var(--border); border-radius: 7px; padding: 8px 10px; font-size: 12px; max-height: 240px; overflow: auto; flex-shrink: 0; }
#graph-options summary { cursor: pointer; }
#graph-options-summary { color: var(--muted); font-size: 11px; }
#graph-options label { margin-top: 12px; }
#graph-exclusions { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 8px; }
#graph-exclusions button { background: var(--selected); color: var(--accent-text); border: 0; padding: 4px 8px; }
#graph-apply { margin-top: 10px; }
#graph-options-status:empty { display: none; }
#graph-canvas { cursor: grab; min-height: 180px; overscroll-behavior: contain; touch-action: none; }
#graph-canvas.dragging { cursor: grabbing; user-select: none; }
#graph-canvas svg { margin-inline: auto; }
.graph-node.focused rect { stroke: var(--node-focus); stroke-width: 3; }
.graph-node.function rect { fill: var(--node); }
#graph-warning-details { font-size: 11px; flex-shrink: 0; color: var(--muted); }
#graph-warning-details summary { cursor: pointer; }
#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); }

45
frontend/theme.ts Normal file
View File

@@ -0,0 +1,45 @@
import { invoke, isTauri } from '@tauri-apps/api/core';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
type Theme = 'system' | 'dark' | 'light';
export function installTheme() {
const media = window.matchMedia('(prefers-color-scheme: dark)');
const select = document.getElementById('theme-select') as HTMLSelectElement;
let preference: Theme = 'system';
let changed = false;
let saves = Promise.resolve();
for (const mode of ['dark', 'light'] as const) {
monaco.editor.defineTheme(`cex-${mode}`, {
base: mode === 'dark' ? 'vs-dark' : 'vs', inherit: true, rules: [],
colors: mode === 'dark' ? {
'editor.background': '#171c23', 'editor.foreground': '#dce3eb',
'editorLineNumber.foreground': '#738195', 'editorLineNumber.activeForeground': '#a4dccb',
'editor.lineHighlightBackground': '#202832', 'editor.selectionBackground': '#31564e',
'editorCursor.foreground': '#83d8bd',
} : {
'editor.background': '#ffffff', 'editor.foreground': '#243347',
'editorLineNumber.foreground': '#788496', 'editorLineNumber.activeForeground': '#176e59',
'editor.lineHighlightBackground': '#f2f6f8', 'editor.selectionBackground': '#c9e6dc',
'editorCursor.foreground': '#176e59',
},
});
}
function apply() {
const mode = preference === 'system' ? (media.matches ? 'dark' : 'light') : preference;
document.documentElement.dataset.theme = mode;
select.value = preference;
monaco.editor.setTheme(`cex-${mode}`);
}
select.onchange = () => {
changed = true; preference = select.value as Theme; apply();
const theme = preference;
if (isTauri()) saves = saves.then(() => invoke('save_theme', { theme })).then(() => {
select.title = 'Color theme';
}).catch(error => { select.title = `Theme applied, but could not save preference: ${String(error)}`; });
};
media.addEventListener('change', () => { if (preference === 'system') apply(); });
apply();
if (isTauri()) void invoke<Theme>('load_theme').then(value => {
if (!changed && ['system', 'dark', 'light'].includes(value)) { preference = value; apply(); }
}).catch(error => { select.title = `Using system theme. Could not load preference: ${String(error)}`; });
}

84
frontend/vim.ts Normal file
View File

@@ -0,0 +1,84 @@
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import 'monaco-editor/esm/vs/editor/contrib/wordOperations/browser/wordOperations';
import 'monaco-editor/esm/vs/editor/contrib/bracketMatching/browser/bracketMatching';
type Navigation = {
navigate: (kind: 'definition' | 'references' | 'callers' | 'hover', openFirst?: boolean) => Promise<void>;
step: (direction: number, count?: number) => Promise<void>;
back: () => Promise<void>;
};
// A reading-only key layer over Monaco's existing motions, search, and folding.
// No insert mode, editing operators, or separate text buffer.
export function installVim(editor: monaco.editor.IStandaloneCodeEditor, navigation: Navigation) {
let prefix = '', count = '', repeat: 'search' | 'locations' = 'search';
const indicator = document.createElement('span'); indicator.id = 'vim-status';
indicator.title = 'Vim reading: hjkl, w/b/e, gg/G, gd definition, gc callers, gr references, n/N next/previous, / search, Ctrl+o back, za fold. No insert mode.';
document.querySelector('footer')!.insertBefore(indicator, document.getElementById('cursor'));
const update = () => { indicator.textContent = `VIM · READ ONLY${count || prefix ? ` · ${count}${prefix}` : ''} · n: ${repeat}`; };
const clear = () => { prefix = ''; count = ''; update(); };
const trigger = (id: string, amount = 1) => { for (let i = 0; i < amount; i++) editor.trigger('vim', id, null); };
editor.onDidBlurEditorText(clear);
editor.onDidChangeModel(clear);
update();
const root = editor.getContainerDomNode();
root.addEventListener('keydown', event => {
if (event.isComposing || event.altKey || event.metaKey) return;
// Find and other input widgets keep ordinary text entry and shortcuts.
if (!editor.hasTextFocus()) {
if ((event.key === 'Enter' || event.key === 'Escape') && (event.target as HTMLElement).closest('.find-widget')) {
event.preventDefault(); event.stopImmediatePropagation();
trigger('closeFindWidget'); editor.focus(); repeat = 'search'; clear();
}
return;
}
if (!editor.getModel()) return;
const key = event.key;
if (event.ctrlKey && !['d', 'u', 'f', 'b', 'o', '['].includes(key.toLowerCase())) return;
if (!event.ctrlKey && (key.length !== 1 && key !== 'Escape')) { clear(); return; }
event.preventDefault(); event.stopImmediatePropagation();
const amount = Math.min(Number(count) || 1, 1000);
if (event.ctrlKey) {
clear();
if (key === 'o') { void navigation.back(); return; }
if (key === '[') { trigger('cancelSelection'); return; }
const down = key === 'd' || key === 'f';
const lines = Math.max(1, Math.floor(editor.getLayoutInfo().height / editor.getOption(monaco.editor.EditorOption.lineHeight) / (key === 'd' || key === 'u' ? 2 : 1)));
editor.trigger('vim', 'cursorMove', { to: down ? 'down' : 'up', by: 'wrappedLine', value: Math.min(lines * amount, 100000) });
return;
}
if (key === 'Escape') { clear(); trigger('cancelSelection'); return; }
if (/^[0-9]$/.test(key) && (key !== '0' || count)) { count = String(Math.min(Number(count + key), 1000)); update(); return; }
if (prefix) {
const chord = prefix + key; clear();
const semantic = { gd: 'definition', gc: 'callers', gr: 'references' } as const;
if (chord in semantic) { repeat = 'locations'; update(); void navigation.navigate(semantic[chord as keyof typeof semantic], true); }
else if (chord === 'gg') { editor.setPosition({ lineNumber: amount, column: 1 }); editor.revealPositionInCenter(editor.getPosition()!); }
else if (chord === 'ge') trigger('cursorWordEndLeft', amount);
else if (chord === 'za') trigger('editor.toggleFold');
else if (chord === 'zc') trigger('editor.fold');
else if (chord === 'zo') trigger('editor.unfold');
else if (chord === 'zM') trigger('editor.foldAll');
else if (chord === 'zR') trigger('editor.unfoldAll');
else if (chord === 'zz') editor.revealPositionInCenter(editor.getPosition()!);
return;
}
if (key === 'g' || key === 'z') { prefix = key; update(); return; }
const hadCount = !!count; clear();
if (key === 'G') {
editor.setPosition({ lineNumber: hadCount ? Math.min(amount, editor.getModel()!.getLineCount()) : editor.getModel()!.getLineCount(), column: 1 }); editor.revealPositionInCenter(editor.getPosition()!);
} else if (key === '/') { repeat = 'search'; update(); trigger('actions.find'); }
else if (key === 'n' || key === 'N') {
if (repeat === 'locations') void navigation.step(key === 'n' ? 1 : -1, amount);
else trigger(key === 'n' ? 'editor.action.nextMatchFindAction' : 'editor.action.previousMatchFindAction', amount);
} else if (key === 'K') void navigation.navigate('hover');
else {
const motions: Record<string, string> = { h: 'cursorLeft', j: 'cursorDown', k: 'cursorUp', l: 'cursorRight', w: 'cursorWordStartRight', b: 'cursorWordStartLeft', e: 'cursorWordEndRight', '0': 'cursorHome', '^': 'cursorHome', '$': 'cursorEnd', '%': 'editor.action.jumpToBracket' };
if (key === '0' || key === '^') {
const p = editor.getPosition()!;
editor.setPosition({ lineNumber: p.lineNumber, column: key === '0' ? 1 : editor.getModel()!.getLineFirstNonWhitespaceColumn(p.lineNumber) || 1 });
} else if (motions[key]) trigger(motions[key], amount);
// Other printable keys are consumed: i/a/o/d/c/x never edit or enter a mode.
}
}, true);
return { reset() { repeat = 'search'; clear(); } };
}