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