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:
107
dashboard/src/lib/CodeEditor.svelte
Normal file
107
dashboard/src/lib/CodeEditor.svelte
Normal file
@@ -0,0 +1,107 @@
|
||||
<!--
|
||||
CodeMirror-backed text editor for the dashboard.
|
||||
|
||||
Replaces our plain <textarea> with line numbers, syntax highlighting,
|
||||
bracket matching, search/replace (Ctrl+F), and language-aware
|
||||
autocomplete. Two-way bound via `value`; the parent treats it the
|
||||
same as a textarea.
|
||||
|
||||
Languages: `rhai` (custom mode, ./rhai-mode.ts) and `json`
|
||||
(@codemirror/lang-json).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { basicSetup } from 'codemirror';
|
||||
import { EditorView, keymap, placeholder as cmPlaceholder } from '@codemirror/view';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { indentWithTab } from '@codemirror/commands';
|
||||
import { json as jsonLang } from '@codemirror/lang-json';
|
||||
import { rhai as rhaiLang } from './rhai-mode';
|
||||
import { dashboardSyntaxHighlighting, dashboardTheme } from './editor-theme';
|
||||
|
||||
type Language = 'rhai' | 'json';
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
language = 'rhai' as Language,
|
||||
placeholder = '',
|
||||
minHeight = '12rem'
|
||||
}: {
|
||||
value?: string;
|
||||
language?: Language;
|
||||
placeholder?: string;
|
||||
minHeight?: string;
|
||||
} = $props();
|
||||
|
||||
let host: HTMLDivElement | null = null;
|
||||
let view: EditorView | null = null;
|
||||
// Guard against the update-listener firing while we're pushing an
|
||||
// external value into the editor — without this, parent-driven
|
||||
// `value` changes would echo back through the listener and create a
|
||||
// dispatch loop in Svelte 5 reactivity.
|
||||
let pushingFromOutside = false;
|
||||
|
||||
function buildExtensions(lang: Language) {
|
||||
const extensions = [
|
||||
basicSetup,
|
||||
lang === 'json' ? jsonLang() : rhaiLang(),
|
||||
keymap.of([indentWithTab]),
|
||||
dashboardSyntaxHighlighting,
|
||||
dashboardTheme,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged && !pushingFromOutside) {
|
||||
value = update.state.doc.toString();
|
||||
}
|
||||
})
|
||||
];
|
||||
if (placeholder) extensions.push(cmPlaceholder(placeholder));
|
||||
return extensions;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!host) return;
|
||||
view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: value,
|
||||
extensions: buildExtensions(language)
|
||||
}),
|
||||
parent: host
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
view?.destroy();
|
||||
view = null;
|
||||
});
|
||||
|
||||
// Push parent-driven `value` updates back into the editor (e.g.
|
||||
// when the script is reloaded after Save, or "Format JSON" rewrites
|
||||
// the body). We only dispatch when the document genuinely differs
|
||||
// from the current `value`.
|
||||
$effect(() => {
|
||||
if (!view) return;
|
||||
const current = view.state.doc.toString();
|
||||
if (current !== value) {
|
||||
pushingFromOutside = true;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: current.length, insert: value }
|
||||
});
|
||||
pushingFromOutside = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={host} class="cm-host" style:min-height={minHeight}></div>
|
||||
|
||||
<style>
|
||||
.cm-host :global(.cm-editor) {
|
||||
height: 100%;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid #334155;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cm-host :global(.cm-scroller) {
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
|
||||
}
|
||||
</style>
|
||||
148
dashboard/src/lib/editor-theme.ts
Normal file
148
dashboard/src/lib/editor-theme.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
// Dashboard-matching theme + highlight style for CodeMirror.
|
||||
//
|
||||
// Colors are pulled from the existing slate/sky palette used across
|
||||
// the dashboard (#0f172a / #1e293b / #38bdf8) so the editor blends
|
||||
// into the surrounding cards instead of looking like a third-party
|
||||
// transplant. Stock dark themes like "One Dark" or "VS Code Dark"
|
||||
// would each clash with the slate background by a few shades.
|
||||
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||
import { tags as t } from '@lezer/highlight';
|
||||
|
||||
const palette = {
|
||||
bg: '#0b1220', // matches existing <input>/<textarea> backgrounds
|
||||
bgGutter: '#0f172a',
|
||||
border: '#334155',
|
||||
text: '#e2e8f0',
|
||||
textMuted: '#94a3b8',
|
||||
cursor: '#38bdf8',
|
||||
selection: '#38bdf830',
|
||||
activeLine: '#1e293b',
|
||||
matchingBracket: '#38bdf850',
|
||||
searchMatch: '#38bdf850',
|
||||
searchMatchSelected: '#38bdf8',
|
||||
|
||||
// Syntax — chosen to be readable against #0b1220 without making
|
||||
// any single token feel louder than the rest.
|
||||
comment: '#64748b',
|
||||
str: '#86efac',
|
||||
num: '#fbbf24',
|
||||
bool: '#fbbf24',
|
||||
keyword: '#c4b5fd',
|
||||
control: '#c4b5fd',
|
||||
operator: '#cbd5e1',
|
||||
punct: '#cbd5e1',
|
||||
function: '#38bdf8',
|
||||
property: '#e2e8f0',
|
||||
variable: '#e2e8f0',
|
||||
ctx: '#f472b6', // `ctx` is special — pinker so users notice it
|
||||
namespace: '#fbbf24', // `log::`
|
||||
invalid: '#ef4444'
|
||||
};
|
||||
|
||||
export const dashboardTheme = EditorView.theme(
|
||||
{
|
||||
'&': {
|
||||
color: palette.text,
|
||||
backgroundColor: palette.bg,
|
||||
borderRadius: '0.375rem'
|
||||
},
|
||||
'&.cm-focused': {
|
||||
outline: `1px solid ${palette.cursor}`
|
||||
},
|
||||
'.cm-content': {
|
||||
caretColor: palette.cursor,
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
|
||||
fontSize: '0.85rem',
|
||||
padding: '0.5rem 0'
|
||||
},
|
||||
'.cm-cursor, .cm-dropCursor': {
|
||||
borderLeftColor: palette.cursor,
|
||||
borderLeftWidth: '2px'
|
||||
},
|
||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
|
||||
{
|
||||
backgroundColor: palette.selection
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: palette.activeLine
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: palette.bgGutter,
|
||||
color: palette.textMuted,
|
||||
border: 'none',
|
||||
borderRight: `1px solid ${palette.border}`
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: palette.activeLine,
|
||||
color: palette.text
|
||||
},
|
||||
'.cm-matchingBracket, .cm-nonmatchingBracket': {
|
||||
backgroundColor: palette.matchingBracket,
|
||||
outline: 'none'
|
||||
},
|
||||
// Search / replace panel (Ctrl+F)
|
||||
'.cm-panels': {
|
||||
backgroundColor: palette.bgGutter,
|
||||
color: palette.text,
|
||||
borderTop: `1px solid ${palette.border}`
|
||||
},
|
||||
'.cm-searchMatch': {
|
||||
backgroundColor: palette.searchMatch
|
||||
},
|
||||
'.cm-searchMatch.cm-searchMatch-selected': {
|
||||
backgroundColor: palette.searchMatchSelected
|
||||
},
|
||||
'.cm-panel input, .cm-panel button': {
|
||||
backgroundColor: palette.bg,
|
||||
color: palette.text,
|
||||
border: `1px solid ${palette.border}`,
|
||||
borderRadius: '0.25rem',
|
||||
padding: '0.2rem 0.4rem'
|
||||
},
|
||||
'.cm-panel button:hover': {
|
||||
backgroundColor: palette.activeLine
|
||||
},
|
||||
// Autocomplete popup
|
||||
'.cm-tooltip': {
|
||||
backgroundColor: palette.bgGutter,
|
||||
border: `1px solid ${palette.border}`,
|
||||
color: palette.text
|
||||
},
|
||||
'.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected]': {
|
||||
backgroundColor: palette.activeLine,
|
||||
color: palette.text
|
||||
},
|
||||
'.cm-completionLabel': {
|
||||
color: palette.text
|
||||
},
|
||||
'.cm-completionDetail': {
|
||||
color: palette.textMuted,
|
||||
fontStyle: 'normal'
|
||||
},
|
||||
'.cm-completionIcon': {
|
||||
color: palette.textMuted
|
||||
}
|
||||
},
|
||||
{ dark: true }
|
||||
);
|
||||
|
||||
export const dashboardHighlight = HighlightStyle.define([
|
||||
{ tag: t.comment, color: palette.comment, fontStyle: 'italic' },
|
||||
{ tag: [t.string, t.special(t.string)], color: palette.str },
|
||||
{ tag: [t.number, t.bool, t.null], color: palette.num },
|
||||
{ tag: [t.keyword, t.modifier], color: palette.keyword, fontWeight: '600' },
|
||||
{ tag: t.controlKeyword, color: palette.control, fontWeight: '600' },
|
||||
{ tag: [t.operator, t.derefOperator, t.logicOperator], color: palette.operator },
|
||||
{ tag: [t.punctuation, t.bracket, t.brace, t.paren, t.squareBracket], color: palette.punct },
|
||||
{ tag: [t.function(t.variableName), t.function(t.propertyName)], color: palette.function },
|
||||
{ tag: [t.propertyName, t.attributeName], color: palette.property },
|
||||
{ tag: t.variableName, color: palette.variable },
|
||||
{ tag: t.special(t.variableName), color: palette.ctx, fontWeight: '600' },
|
||||
{ tag: t.namespace, color: palette.namespace, fontWeight: '600' },
|
||||
{ tag: t.invalid, color: palette.invalid, textDecoration: 'underline wavy' }
|
||||
]);
|
||||
|
||||
export const dashboardSyntaxHighlighting = syntaxHighlighting(dashboardHighlight);
|
||||
312
dashboard/src/lib/rhai-mode.ts
Normal file
312
dashboard/src/lib/rhai-mode.ts
Normal 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] })]);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { api, ApiError, type Script } from '$lib/api';
|
||||
import CodeEditor from '$lib/CodeEditor.svelte';
|
||||
|
||||
const SAMPLE_SOURCE = '#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
|
||||
|
||||
@@ -80,7 +81,7 @@
|
||||
</div>
|
||||
<label class="full">
|
||||
<span>Source (Rhai)</span>
|
||||
<textarea bind:value={createSource} rows="10" spellcheck="false"></textarea>
|
||||
<CodeEditor bind:value={createSource} language="rhai" minHeight="14rem" />
|
||||
</label>
|
||||
{#if createError}
|
||||
<div class="error">{createError}</div>
|
||||
@@ -191,8 +192,7 @@
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.create-form input,
|
||||
.create-form textarea {
|
||||
.create-form input {
|
||||
background: #0b1220;
|
||||
color: #e2e8f0;
|
||||
border: 1px solid #334155;
|
||||
@@ -201,13 +201,6 @@
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.create-form textarea {
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
|
||||
min-height: 8rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -13,6 +13,18 @@
|
||||
} from '$lib/api';
|
||||
import { logLevelColor, statusColor } from '$lib/styles';
|
||||
import { guessHostKind, guessPathKind, pathKindMismatchWarning } from '$lib/route-utils';
|
||||
import CodeEditor from '$lib/CodeEditor.svelte';
|
||||
|
||||
/// Pretty-print a JSON string in place, leaving it untouched if the
|
||||
/// input doesn't parse. The error state is shown next to the button
|
||||
/// so users see why it didn't reformat.
|
||||
function formatJson(s: string): { ok: true; text: string } | { ok: false; error: string } {
|
||||
try {
|
||||
return { ok: true, text: JSON.stringify(JSON.parse(s), null, 2) };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
// Route is `/scripts/[id]` so `page.params.id` is always present.
|
||||
let id = $derived(page.params.id ?? '');
|
||||
@@ -72,7 +84,28 @@
|
||||
|
||||
let testBody = $state('{}');
|
||||
let testHeaders = $state('{}');
|
||||
let testBodyFormatError = $state<string | null>(null);
|
||||
let testHeadersFormatError = $state<string | null>(null);
|
||||
let testInProgress = $state(false);
|
||||
|
||||
function formatTestBody() {
|
||||
const r = formatJson(testBody);
|
||||
if (r.ok) {
|
||||
testBody = r.text;
|
||||
testBodyFormatError = null;
|
||||
} else {
|
||||
testBodyFormatError = r.error;
|
||||
}
|
||||
}
|
||||
function formatTestHeaders() {
|
||||
const r = formatJson(testHeaders);
|
||||
if (r.ok) {
|
||||
testHeaders = r.text;
|
||||
testHeadersFormatError = null;
|
||||
} else {
|
||||
testHeadersFormatError = r.error;
|
||||
}
|
||||
}
|
||||
let testResult = $state<{
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
@@ -352,7 +385,7 @@
|
||||
<div class="grid">
|
||||
<section class="card">
|
||||
<h2>Source</h2>
|
||||
<textarea bind:value={editableSource} rows="14" spellcheck="false"></textarea>
|
||||
<CodeEditor bind:value={editableSource} language="rhai" minHeight="22rem" />
|
||||
{#if saveSourceError}
|
||||
<div class="error inline">{saveSourceError}</div>
|
||||
{/if}
|
||||
@@ -369,14 +402,30 @@
|
||||
|
||||
<section class="card">
|
||||
<h2>Test invoke</h2>
|
||||
<label>
|
||||
<span>Request body (JSON)</span>
|
||||
<textarea bind:value={testBody} rows="5" spellcheck="false"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span>Headers (JSON object)</span>
|
||||
<textarea bind:value={testHeaders} rows="3" spellcheck="false"></textarea>
|
||||
</label>
|
||||
<div class="json-block">
|
||||
<header class="json-header">
|
||||
<span>Request body (JSON)</span>
|
||||
<button type="button" class="ghost small" onclick={formatTestBody}>
|
||||
Format
|
||||
</button>
|
||||
</header>
|
||||
<CodeEditor bind:value={testBody} language="json" minHeight="9rem" />
|
||||
{#if testBodyFormatError}
|
||||
<div class="error inline">{testBodyFormatError}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="json-block">
|
||||
<header class="json-header">
|
||||
<span>Headers (JSON object)</span>
|
||||
<button type="button" class="ghost small" onclick={formatTestHeaders}>
|
||||
Format
|
||||
</button>
|
||||
</header>
|
||||
<CodeEditor bind:value={testHeaders} language="json" minHeight="6rem" />
|
||||
{#if testHeadersFormatError}
|
||||
<div class="error inline">{testHeadersFormatError}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" onclick={invoke} disabled={testInProgress}>
|
||||
{testInProgress ? 'Running…' : 'Send'}
|
||||
@@ -727,6 +776,24 @@
|
||||
color: #94a3b8;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
button.small {
|
||||
padding: 0.2rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.json-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.json-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.85rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
button.link {
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
@@ -798,7 +865,6 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
textarea,
|
||||
input,
|
||||
select {
|
||||
background: #0b1220;
|
||||
@@ -811,12 +877,6 @@
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
textarea {
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
resize: vertical;
|
||||
}
|
||||
input:disabled,
|
||||
select:disabled {
|
||||
opacity: 0.5;
|
||||
|
||||
Reference in New Issue
Block a user