From 1dc53a022683a1b6419da2cba4194cbdf35e5509 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 23 May 2026 23:46:30 +0200 Subject: [PATCH] feat(dashboard): go-to-definition, Ctrl+Click, and find-usages panel `F12` jumps the cursor to the declaration of the identifier under the caret; `Shift+F12` opens a CodeMirror panel listing every range that resolves to the same declaration (declaration site plus all usages), with line-number snippets that click to jump. `Ctrl+Click` (Cmd+Click on macOS) on an identifier is wired to the same goto path. `Esc` closes the panel. All three features read from `rhaiAnalysisField`, so they automatically follow the cached parse + symbol table. The panel's styling lives in a CodeMirror `baseTheme` keyed to the dashboard's slate palette. Bundle delta: +3 KB raw, +1 KB gzipped. Co-Authored-By: Claude Opus 4.7 (1M context) --- dashboard/src/lib/rhai-mode.ts | 199 ++++++++++++++++++++++++++++++++- 1 file changed, 197 insertions(+), 2 deletions(-) diff --git a/dashboard/src/lib/rhai-mode.ts b/dashboard/src/lib/rhai-mode.ts index f9cf4e4..729b876 100644 --- a/dashboard/src/lib/rhai-mode.ts +++ b/dashboard/src/lib/rhai-mode.ts @@ -16,8 +16,9 @@ import { StreamLanguage, LanguageSupport } from '@codemirror/language'; import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete'; -import { StateField, type Extension } from '@codemirror/state'; -import { parse, buildSymbolTable, type ParseResult, type SymbolTable } from './rhai'; +import { EditorSelection, StateEffect, StateField, type Extension } from '@codemirror/state'; +import { EditorView, keymap, showPanel, type Panel } from '@codemirror/view'; +import { parse, buildSymbolTable, type ParseResult, type Range, type SymbolTable } from './rhai'; // Keywords that drive control flow (`if`, `for`, ...) — these get the // `controlKeyword` tag so the theme can color them distinctly from @@ -396,9 +397,203 @@ export function scopeCompletionSource(context: CompletionContext): CompletionRes }; } +// --------------------------------------------------------------------------- +// Go-to-definition, Ctrl/Cmd+Click, find-usages +// --------------------------------------------------------------------------- + +interface UsagesPanelState { + name: string; + ranges: Range[]; +} + +const setUsagesPanel = StateEffect.define(); + +const usagesPanelField = StateField.define({ + create: () => null, + update(value, tr) { + for (const e of tr.effects) if (e.is(setUsagesPanel)) return e.value; + return value; + }, + provide: (f) => showPanel.from(f, (value) => (value ? buildUsagesPanel : null)) +}); + +function buildUsagesPanel(view: EditorView): Panel { + const state = view.state.field(usagesPanelField); + const dom = document.createElement('div'); + dom.className = 'cm-rhai-usages'; + + const head = document.createElement('div'); + head.className = 'cm-rhai-usages-head'; + const count = state ? state.ranges.length : 0; + const label = document.createElement('span'); + label.textContent = `${count} occurrence${count === 1 ? '' : 's'} of "${state?.name ?? ''}"`; + head.appendChild(label); + const close = document.createElement('button'); + close.type = 'button'; + close.textContent = '×'; + close.title = 'Close (Esc)'; + close.onclick = () => { + view.dispatch({ effects: setUsagesPanel.of(null) }); + view.focus(); + }; + head.appendChild(close); + dom.appendChild(head); + + if (state) { + for (const r of state.ranges) { + const line = view.state.doc.lineAt(r.start); + const row = document.createElement('button'); + row.type = 'button'; + row.className = 'cm-rhai-usages-row'; + const num = document.createElement('span'); + num.className = 'cm-rhai-usages-line'; + num.textContent = String(line.number); + const snip = document.createElement('span'); + snip.className = 'cm-rhai-usages-snip'; + snip.textContent = line.text.trim(); + row.appendChild(num); + row.appendChild(snip); + row.onclick = () => { + view.dispatch({ + selection: EditorSelection.cursor(r.start), + scrollIntoView: true + }); + view.focus(); + }; + dom.appendChild(row); + } + } + + return { dom, top: false }; +} + +const usagesPanelTheme = EditorView.baseTheme({ + '.cm-rhai-usages': { + background: '#0f172a', + color: '#e2e8f0', + borderTop: '1px solid #334155', + padding: '0.4rem 0.5rem', + maxHeight: '180px', + overflowY: 'auto', + fontSize: '0.85rem' + }, + '.cm-rhai-usages-head': { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + color: '#94a3b8', + marginBottom: '0.25rem' + }, + '.cm-rhai-usages-head button': { + background: 'transparent', + color: '#94a3b8', + border: 'none', + fontSize: '1rem', + cursor: 'pointer', + padding: '0 0.4rem' + }, + '.cm-rhai-usages-row': { + display: 'flex', + gap: '0.5rem', + alignItems: 'baseline', + width: '100%', + background: 'transparent', + color: '#e2e8f0', + border: 'none', + textAlign: 'left', + padding: '0.15rem 0.25rem', + cursor: 'pointer', + fontFamily: 'inherit', + fontSize: 'inherit', + borderRadius: '0.25rem' + }, + '.cm-rhai-usages-row:hover': { + background: '#1e293b' + }, + '.cm-rhai-usages-line': { + color: '#64748b', + minWidth: '2.5rem', + flexShrink: 0 + }, + '.cm-rhai-usages-snip': { + fontFamily: + 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis' + } +}); + +function declAtCursor(view: EditorView, pos: number) { + const analysis = view.state.field(rhaiAnalysisField, false); + if (!analysis) return null; + return analysis.table.declOfUsageAt(pos); +} + +export function gotoDefinition(view: EditorView): boolean { + const pos = view.state.selection.main.head; + const decl = declAtCursor(view, pos); + if (!decl) return false; + view.dispatch({ + selection: EditorSelection.cursor(decl.nameRange.start), + scrollIntoView: true + }); + return true; +} + +export function findUsages(view: EditorView): boolean { + const pos = view.state.selection.main.head; + const analysis = view.state.field(rhaiAnalysisField, false); + if (!analysis) return false; + const decl = analysis.table.declOfUsageAt(pos); + if (!decl) return false; + view.dispatch({ + effects: setUsagesPanel.of({ + name: decl.name, + ranges: analysis.table.usagesOf(decl) + }) + }); + return true; +} + +function closeUsagesPanel(view: EditorView): boolean { + if (!view.state.field(usagesPanelField, false)) return false; + view.dispatch({ effects: setUsagesPanel.of(null) }); + return true; +} + +const rhaiKeymap = keymap.of([ + { key: 'F12', run: gotoDefinition }, + { key: 'Shift-F12', run: findUsages }, + { key: 'Escape', run: closeUsagesPanel } +]); + +// Ctrl+Click (Cmd+Click on macOS) on an identifier → jump to its decl. +// Returning `true` suppresses CodeMirror's default selection behavior so +// the click doesn't simultaneously place the caret at the click site. +const ctrlClickHandler = EditorView.domEventHandlers({ + mousedown(event, view) { + if (!(event.metaKey || event.ctrlKey) || event.button !== 0) return false; + const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }); + if (pos == null) return false; + const decl = declAtCursor(view, pos); + if (!decl) return false; + view.dispatch({ + selection: EditorSelection.cursor(decl.nameRange.start), + scrollIntoView: true + }); + event.preventDefault(); + return true; + } +}); + export function rhai(): LanguageSupport { const extensions: Extension[] = [ rhaiAnalysisField, + usagesPanelField, + usagesPanelTheme, + rhaiKeymap, + ctrlClickHandler, autocompletion({ override: [scopeCompletionSource, rhaiCompletions] }) ]; return new LanguageSupport(rhaiLanguage, extensions);