feat(dashboard): CodeMirror editors for Rhai source + JSON

Replaces the four <textarea> usages with a CodeMirror 6 editor that
brings, just by being a real editor: syntax highlighting, line
numbers, bracket matching, multi-cursor, proper undo/redo, and
search/replace (Ctrl+F / Ctrl+H). Plus a Rhai-aware autocomplete and
a "Format JSON" button on the test-invoke panels.

Per discussion, deliberately did NOT add: LSP, go-to-definition,
Rhai formatter (none exists), or anything else IDE-shaped. The
existing CodeEditor component is wired so swapping the language
extension later is a one-line change.

Lay of the land (from the research pass):
  * No CodeMirror Rhai package exists on npm.
  * No Rhai formatter exists anywhere.
  * The Rhai authors publish a TextMate grammar at
    rhaiscript/vscode-rhai (MPL-2.0). We don't load the full
    grammar (would cost ~250KB of vscode-textmate + oniguruma);
    we cite it as the source-of-truth for our keyword/operator
    lists in a small custom StreamLanguage.
  * rhaiscript/lsp exists but is experimental + unmaintained
    since 2023; skipped.

Files:
  * dashboard/src/lib/editor-theme.ts — CodeMirror theme +
    HighlightStyle wired to the existing slate/sky palette so the
    editor blends into the cards instead of looking transplanted.
  * dashboard/src/lib/rhai-mode.ts — StreamLanguage tokenizer for
    Rhai with the upstream grammar's keyword/operator lists, plus
    a completion source pulling ctx.* / log::* from our SDK
    contract suite (the authoritative list).
  * dashboard/src/lib/CodeEditor.svelte — wraps EditorView with
    two-way $bindable() value, language picker ('rhai' | 'json'),
    placeholder, minHeight props. Guards against the update
    listener echoing parent-driven changes back as edits.
  * Replaces textareas in:
      routes/+page.svelte                 — create form source
      routes/scripts/[id]/+page.svelte    — Edit tab source +
                                            Test invoke body +
                                            headers
  * Format buttons next to the body/headers editors run
    JSON.stringify(JSON.parse(value), null, 2); errors surface
    inline next to the button without trashing the field.

Bundle:
  * +~430KB to the CodeMirror chunk in dashboard build (~150KB
    gzipped on the wire). Lazy-loaded — only fetched when a route
    that uses CodeEditor renders.
  * `npm install` clean, 0 vulnerabilities, `npm run check`
    clean, `npm run build` clean.

No backend / API / SDK / schema / wire changes. No version bumps.
This commit is contained in:
MechaCat02
2026-05-23 22:52:07 +02:00
parent 0eaf4aee69
commit a80e6d1ca4
7 changed files with 836 additions and 29 deletions

View File

@@ -0,0 +1,312 @@
// CodeMirror StreamLanguage for Rhai.
//
// Keyword and operator lists are sourced from the upstream TextMate
// grammar maintained by the Rhai authors:
// https://github.com/rhaiscript/vscode-rhai
// syntax/rhai.tmLanguage.json (MPL-2.0)
// This file does NOT copy the upstream grammar bytes — only the
// symbol lists. The matching logic is a simple regex tokenizer
// tailored to CodeMirror's StreamLanguage shape; if richer
// highlighting is wanted later, swap this out for a full tmLanguage
// loader (vscode-textmate + oniguruma) without touching callers.
//
// SDK completions (`ctx.*`, `log::*`) come from our own SDK contract
// in crates/executor-core/tests/sdk_contract.rs — that file is the
// authoritative list of what scripts can do.
import { StreamLanguage, LanguageSupport } from '@codemirror/language';
import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
// Keywords that drive control flow (`if`, `for`, ...) — these get the
// `controlKeyword` tag so the theme can color them distinctly from
// declaration-style keywords like `let` or `fn`.
const CONTROL_KEYWORDS = new Set([
'if',
'else',
'for',
'while',
'loop',
'do',
'switch',
'case',
'default',
'return',
'break',
'continue',
'try',
'catch',
'throw'
]);
const DECLARATION_KEYWORDS = new Set([
'let',
'const',
'fn',
'private',
'in',
'as',
'is'
]);
// Reserved-but-not-currently-valid keywords from the upstream grammar.
// We still highlight them so users notice them; the parser will reject
// at execute time.
const RESERVED_KEYWORDS = new Set([
'var',
'match',
'public',
'protected',
'new',
'use',
'with',
'module',
'package',
'super',
'spawn',
'thread',
'go',
'sync',
'async',
'await',
'yield',
'void',
'null',
'nil',
'debug',
'eval',
'print',
'import',
'export'
]);
const BOOLEAN_LITERALS = new Set(['true', 'false']);
const SPECIAL_VARIABLES = new Set(['ctx']);
const NAMESPACES = new Set(['log']);
interface RhaiState {
inBlockComment: boolean;
inString: false | '"' | '`';
}
export const rhaiLanguage = StreamLanguage.define<RhaiState>({
name: 'rhai',
startState: () => ({ inBlockComment: false, inString: false }),
token(stream, state) {
// --- inside a /* … */ block comment ---
if (state.inBlockComment) {
if (stream.match(/.*?\*\//)) {
state.inBlockComment = false;
} else {
stream.skipToEnd();
}
return 'comment';
}
// --- inside a multi-line string (rare but possible with ` ` strings) ---
if (state.inString) {
const quote = state.inString;
while (!stream.eol()) {
const ch = stream.next();
if (ch === '\\') {
stream.next();
continue;
}
if (ch === quote) {
state.inString = false;
return 'string';
}
}
return 'string';
}
// Skip whitespace
if (stream.eatSpace()) return null;
// --- line comment ---
if (stream.match('//')) {
stream.skipToEnd();
return 'comment';
}
// --- block comment ---
if (stream.match('/*')) {
state.inBlockComment = true;
if (stream.match(/.*?\*\//)) {
state.inBlockComment = false;
} else {
stream.skipToEnd();
}
return 'comment';
}
// --- strings ---
const quote = stream.peek();
if (quote === '"' || quote === '`') {
stream.next();
while (!stream.eol()) {
const ch = stream.next();
if (ch === '\\') {
stream.next();
continue;
}
if (ch === quote) {
return 'string';
}
}
// String continues on next line (only really valid for ` `).
state.inString = quote;
return 'string';
}
// --- numbers (hex, binary, decimal, float) ---
if (stream.match(/^0x[0-9a-fA-F_]+/)) return 'number';
if (stream.match(/^0b[01_]+/)) return 'number';
if (stream.match(/^\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?/)) return 'number';
// --- module path: name::name (used for log::info etc.) ---
// Recognized before plain identifiers so we can return 'namespace'.
if (stream.match(/^[a-zA-Z_]\w*(?=::)/)) {
const word = stream.current();
if (NAMESPACES.has(word)) return 'namespace';
return 'variableName';
}
// --- identifiers + keywords ---
if (stream.match(/^[a-zA-Z_]\w*/)) {
const word = stream.current();
if (CONTROL_KEYWORDS.has(word)) return 'controlKeyword';
if (DECLARATION_KEYWORDS.has(word)) return 'keyword';
if (RESERVED_KEYWORDS.has(word)) return 'keyword';
if (BOOLEAN_LITERALS.has(word)) return 'bool';
if (SPECIAL_VARIABLES.has(word)) return 'variableName.special';
if (NAMESPACES.has(word)) return 'namespace';
// Followed by `(` → function call. We highlight as a function name.
if (stream.peek() === '(') return 'function(variableName)';
// Property after `.`
const before = stream.string.slice(0, stream.start);
if (before.endsWith('.')) return 'propertyName';
return 'variableName';
}
// --- operators / punctuation ---
if (stream.match(/^(\?\?|\.\.=|\.\.|::|==|!=|<=|>=|&&|\|\||<<|>>|=>|->|[+\-*/%<>!&|^~=])/)) {
return 'operator';
}
if (stream.match(/^[(){}[\];,.]/)) return 'punctuation';
// Unrecognized — advance one char and bail.
stream.next();
return null;
},
languageData: {
commentTokens: { line: '//', block: { open: '/*', close: '*/' } },
closeBrackets: { brackets: ['(', '[', '{', '"', '`'] },
indentOnInput: /^\s*[}\])]$/
}
});
// ---------------------------------------------------------------------------
// Autocomplete
// ---------------------------------------------------------------------------
interface CompletionItem {
label: string;
detail?: string;
type?: 'keyword' | 'variable' | 'function' | 'property' | 'namespace';
}
const KEYWORD_COMPLETIONS: CompletionItem[] = [
...['let', 'const', 'fn'].map((k) => ({ label: k, type: 'keyword' as const, detail: 'declaration' })),
...['if', 'else', 'for', 'while', 'loop', 'switch', 'return', 'break', 'continue', 'try', 'catch', 'throw'].map(
(k) => ({ label: k, type: 'keyword' as const, detail: 'control flow' })
),
...['in', 'as', 'is'].map((k) => ({ label: k, type: 'keyword' as const })),
{ label: 'true', type: 'keyword', detail: 'boolean' },
{ label: 'false', type: 'keyword', detail: 'boolean' }
];
// ctx.* — keep aligned with `build_ctx_map` in
// crates/executor-core/src/engine.rs.
const CTX_TOP_COMPLETIONS: CompletionItem[] = [
{ label: 'execution_id', type: 'property', detail: 'string' },
{ label: 'script_id', type: 'property', detail: 'string' },
{ label: 'script_name', type: 'property', detail: 'string' },
{ label: 'request_id', type: 'property', detail: 'string' },
{ label: 'invocation_type', type: 'property', detail: '"http" | "function" | "scheduled"' },
{ label: 'sdk_version', type: 'property', detail: 'string ("major.minor")' },
{ label: 'request', type: 'property', detail: 'object' }
];
const CTX_REQUEST_COMPLETIONS: CompletionItem[] = [
{ label: 'path', type: 'property', detail: 'string' },
{ label: 'headers', type: 'property', detail: 'map of string→string' },
{ label: 'body', type: 'property', detail: 'parsed JSON value' },
{ label: 'params', type: 'property', detail: 'map (param-route captures, SDK 1.1+)' },
{ label: 'query', type: 'property', detail: 'map (parsed query string, SDK 1.1+)' },
{ label: 'rest', type: 'property', detail: 'string (prefix-route tail, SDK 1.1+)' }
];
const LOG_COMPLETIONS: CompletionItem[] = [
{ label: 'info', type: 'function', detail: 'log::info(msg, data?)' },
{ label: 'warn', type: 'function', detail: 'log::warn(msg, data?)' },
{ label: 'error', type: 'function', detail: 'log::error(msg, data?)' },
{ label: 'trace', type: 'function', detail: 'log::trace(msg, data?) — use instead of "debug" (reserved keyword)' }
];
const TOP_LEVEL_GLOBALS: CompletionItem[] = [
{ label: 'ctx', type: 'variable', detail: 'invocation context' },
{ label: 'log', type: 'namespace', detail: 'log::info/warn/error/trace' }
];
function toCMCompletions(items: CompletionItem[]) {
return items.map((c) => ({
label: c.label,
type: c.type,
detail: c.detail
}));
}
export function rhaiCompletions(context: CompletionContext): CompletionResult | null {
// `log::` namespace
const ns = context.matchBefore(/log::\w*/);
if (ns) {
return {
from: ns.from + 'log::'.length,
options: toCMCompletions(LOG_COMPLETIONS),
validFor: /^\w*$/
};
}
// `ctx.request.` properties
const ctxReq = context.matchBefore(/ctx\.request\.\w*/);
if (ctxReq) {
return {
from: ctxReq.from + 'ctx.request.'.length,
options: toCMCompletions(CTX_REQUEST_COMPLETIONS),
validFor: /^\w*$/
};
}
// `ctx.` properties
const ctx = context.matchBefore(/ctx\.\w*/);
if (ctx) {
return {
from: ctx.from + 'ctx.'.length,
options: toCMCompletions(CTX_TOP_COMPLETIONS),
validFor: /^\w*$/
};
}
// Plain word at the cursor → keywords + top-level names.
const word = context.matchBefore(/\w+/);
if (!word && !context.explicit) return null;
return {
from: word ? word.from : context.pos,
options: toCMCompletions([...KEYWORD_COMPLETIONS, ...TOP_LEVEL_GLOBALS]),
validFor: /^\w*$/
};
}
export function rhai(): LanguageSupport {
return new LanguageSupport(rhaiLanguage, [autocompletion({ override: [rhaiCompletions] })]);
}