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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-23 23:46:30 +02:00
parent 6cdb1244b8
commit 1dc53a0226

View File

@@ -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<UsagesPanelState | null>();
const usagesPanelField = StateField.define<UsagesPanelState | null>({
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);