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 }; 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; }; const $ = (id: string) => document.getElementById(id)!; export function installNavigation(editor: monaco.editor.IStandaloneCodeEditor, options: Options) { let ready: Promise = 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('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('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 { 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('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(); }, }; }