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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user