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 { StreamLanguage, LanguageSupport } from '@codemirror/language';
|
||||||
import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
|
import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
|
||||||
import { StateField, type Extension } from '@codemirror/state';
|
import { EditorSelection, StateEffect, StateField, type Extension } from '@codemirror/state';
|
||||||
import { parse, buildSymbolTable, type ParseResult, type SymbolTable } from './rhai';
|
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
|
// Keywords that drive control flow (`if`, `for`, ...) — these get the
|
||||||
// `controlKeyword` tag so the theme can color them distinctly from
|
// `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 {
|
export function rhai(): LanguageSupport {
|
||||||
const extensions: Extension[] = [
|
const extensions: Extension[] = [
|
||||||
rhaiAnalysisField,
|
rhaiAnalysisField,
|
||||||
|
usagesPanelField,
|
||||||
|
usagesPanelTheme,
|
||||||
|
rhaiKeymap,
|
||||||
|
ctrlClickHandler,
|
||||||
autocompletion({ override: [scopeCompletionSource, rhaiCompletions] })
|
autocompletion({ override: [scopeCompletionSource, rhaiCompletions] })
|
||||||
];
|
];
|
||||||
return new LanguageSupport(rhaiLanguage, extensions);
|
return new LanguageSupport(rhaiLanguage, extensions);
|
||||||
|
|||||||
Reference in New Issue
Block a user