Initial Commit
This commit is contained in:
198
frontend/navigation.ts
Normal file
198
frontend/navigation.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user