mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-03 05:29:05 +00:00
822 lines
24 KiB
Svelte
822 lines
24 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { EditorState, StateField, StateEffect, RangeSet } from '@codemirror/state';
|
|
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, gutter, GutterMarker, Decoration, WidgetType, type DecorationSet } from '@codemirror/view';
|
|
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
|
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching } from '@codemirror/language';
|
|
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
|
import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
|
|
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
|
|
|
// Docker Compose keywords for autocomplete
|
|
const COMPOSE_TOP_LEVEL = ['services', 'networks', 'volumes', 'configs', 'secrets', 'name', 'version'];
|
|
|
|
const COMPOSE_SERVICE_KEYS = [
|
|
'annotations', 'attach', 'build', 'blkio_config', 'cap_add', 'cap_drop', 'cgroup', 'cgroup_parent',
|
|
'command', 'configs', 'container_name', 'cpu_count', 'cpu_percent', 'cpu_period', 'cpu_quota',
|
|
'cpu_rt_period', 'cpu_rt_runtime', 'cpu_shares', 'cpus', 'cpuset', 'credential_spec',
|
|
'depends_on', 'deploy', 'develop', 'device_cgroup_rules', 'devices', 'dns', 'dns_opt', 'dns_search',
|
|
'domainname', 'driver_opts', 'entrypoint', 'env_file', 'environment', 'expose', 'extends',
|
|
'external_links', 'extra_hosts', 'gpus', 'group_add', 'healthcheck', 'hostname', 'image', 'init',
|
|
'ipc', 'isolation', 'labels', 'label_file', 'links', 'logging', 'mac_address', 'mem_limit',
|
|
'mem_reservation', 'mem_swappiness', 'memswap_limit', 'models', 'network_mode', 'networks',
|
|
'oom_kill_disable', 'oom_score_adj', 'pid', 'pids_limit', 'platform', 'ports', 'post_start',
|
|
'pre_stop', 'privileged', 'profiles', 'provider', 'pull_policy', 'read_only', 'restart', 'runtime',
|
|
'scale', 'secrets', 'security_opt', 'shm_size', 'stdin_open', 'stop_grace_period', 'stop_signal',
|
|
'storage_opt', 'sysctls', 'tmpfs', 'tty', 'ulimits', 'use_api_socket', 'user', 'userns_mode', 'uts',
|
|
'volumes', 'volumes_from', 'working_dir'
|
|
];
|
|
|
|
const COMPOSE_BUILD_KEYS = [
|
|
'context', 'dockerfile', 'dockerfile_inline', 'args', 'ssh', 'cache_from', 'cache_to',
|
|
'extra_hosts', 'isolation', 'labels', 'no_cache', 'pull', 'shm_size', 'target', 'secrets',
|
|
'tags', 'platforms', 'privileged', 'network'
|
|
];
|
|
|
|
const COMPOSE_DEPLOY_KEYS = [
|
|
'mode', 'replicas', 'endpoint_mode', 'labels', 'placement', 'resources', 'restart_policy',
|
|
'rollback_config', 'update_config'
|
|
];
|
|
|
|
const COMPOSE_HEALTHCHECK_KEYS = [
|
|
'test', 'interval', 'timeout', 'retries', 'start_period', 'start_interval', 'disable'
|
|
];
|
|
|
|
const COMPOSE_LOGGING_KEYS = ['driver', 'options'];
|
|
|
|
const COMPOSE_NETWORK_TOP_LEVEL = [
|
|
'driver', 'driver_opts', 'attachable', 'enable_ipv6', 'external', 'internal', 'ipam', 'labels', 'name'
|
|
];
|
|
|
|
const COMPOSE_VOLUME_TOP_LEVEL = [
|
|
'driver', 'driver_opts', 'external', 'labels', 'name'
|
|
];
|
|
|
|
const COMPOSE_DEPENDS_ON_VALUES = ['service_started', 'service_healthy', 'service_completed_successfully'];
|
|
|
|
const COMPOSE_RESTART_VALUES = ['no', 'always', 'on-failure', 'unless-stopped'];
|
|
|
|
const COMPOSE_PULL_POLICY_VALUES = ['always', 'never', 'missing', 'build', 'daily', 'weekly'];
|
|
|
|
const COMPOSE_NETWORK_MODE_VALUES = ['none', 'host', 'bridge'];
|
|
|
|
// All Docker Compose keywords combined for autocomplete
|
|
const ALL_COMPOSE_KEYWORDS = [
|
|
...COMPOSE_TOP_LEVEL,
|
|
...COMPOSE_SERVICE_KEYS,
|
|
...COMPOSE_BUILD_KEYS,
|
|
...COMPOSE_DEPLOY_KEYS,
|
|
...COMPOSE_HEALTHCHECK_KEYS,
|
|
...COMPOSE_LOGGING_KEYS,
|
|
...COMPOSE_NETWORK_TOP_LEVEL,
|
|
...COMPOSE_VOLUME_TOP_LEVEL
|
|
].filter((v, i, a) => a.indexOf(v) === i).sort(); // Remove duplicates and sort
|
|
|
|
// Docker Compose autocomplete source - always suggest all keywords
|
|
function composeCompletions(context: CompletionContext): CompletionResult | null {
|
|
// Get word before cursor
|
|
const word = context.matchBefore(/[a-z_]*/);
|
|
if (!word) return null;
|
|
|
|
// Only show completions if typing (not empty) or explicitly requested
|
|
if (word.from === word.to && !context.explicit) return null;
|
|
|
|
const line = context.state.doc.lineAt(context.pos);
|
|
const textBefore = line.text.slice(0, context.pos - line.from);
|
|
|
|
// Don't show in value position (after colon with content)
|
|
if (textBefore.match(/:\s*\S/)) return null;
|
|
|
|
return {
|
|
from: word.from,
|
|
options: ALL_COMPOSE_KEYWORDS.map(label => ({
|
|
label,
|
|
type: 'keyword',
|
|
apply: label + ':'
|
|
})),
|
|
validFor: /^[a-z_]*$/
|
|
};
|
|
}
|
|
|
|
// Value completions for specific keys
|
|
function composeValueCompletions(context: CompletionContext): CompletionResult | null {
|
|
const line = context.state.doc.lineAt(context.pos);
|
|
const textBefore = line.text.slice(0, context.pos - line.from);
|
|
|
|
// Check if we're after a key: pattern (value position)
|
|
const valueMatch = textBefore.match(/^\s*([a-z_]+):\s*/);
|
|
if (!valueMatch) return null;
|
|
|
|
const key = valueMatch[1];
|
|
|
|
// Get word at cursor for value
|
|
const word = context.matchBefore(/[a-z_-]*/);
|
|
if (!word) return null;
|
|
if (word.from === word.to && !context.explicit) return null;
|
|
|
|
let options: string[] = [];
|
|
|
|
switch (key) {
|
|
case 'restart':
|
|
options = COMPOSE_RESTART_VALUES;
|
|
break;
|
|
case 'pull_policy':
|
|
options = COMPOSE_PULL_POLICY_VALUES;
|
|
break;
|
|
case 'network_mode':
|
|
options = COMPOSE_NETWORK_MODE_VALUES;
|
|
break;
|
|
case 'condition':
|
|
options = COMPOSE_DEPENDS_ON_VALUES;
|
|
break;
|
|
default:
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
from: word.from,
|
|
options: options.map(label => ({
|
|
label,
|
|
type: 'value'
|
|
})),
|
|
validFor: /^[a-z_-]*$/
|
|
};
|
|
}
|
|
|
|
// Language imports
|
|
import { yaml } from '@codemirror/lang-yaml';
|
|
import { json } from '@codemirror/lang-json';
|
|
import { javascript } from '@codemirror/lang-javascript';
|
|
import { python } from '@codemirror/lang-python';
|
|
import { html } from '@codemirror/lang-html';
|
|
import { css } from '@codemirror/lang-css';
|
|
import { markdown } from '@codemirror/lang-markdown';
|
|
import { xml } from '@codemirror/lang-xml';
|
|
import { sql } from '@codemirror/lang-sql';
|
|
|
|
export interface VariableMarker {
|
|
name: string;
|
|
type: 'required' | 'optional' | 'missing';
|
|
value?: string; // The value provided in env vars editor
|
|
isSecret?: boolean; // Whether to mask the value
|
|
defaultValue?: string; // The default value from compose syntax (e.g., ${VAR:-default})
|
|
}
|
|
|
|
interface Props {
|
|
value: string;
|
|
language?: string;
|
|
readonly?: boolean;
|
|
theme?: 'dark' | 'light';
|
|
onchange?: (value: string) => void;
|
|
class?: string;
|
|
variableMarkers?: VariableMarker[];
|
|
}
|
|
|
|
let { value = '', language = 'yaml', readonly = false, theme = 'dark', onchange, class: className = '', variableMarkers = [] }: Props = $props();
|
|
|
|
let container: HTMLDivElement;
|
|
let view: EditorView | null = null;
|
|
|
|
// Mutable ref for callback - allows updating without recreating editor
|
|
let onchangeRef: ((value: string) => void) | undefined = onchange;
|
|
|
|
// Keep callback ref updated when prop changes
|
|
$effect(() => {
|
|
onchangeRef = onchange;
|
|
});
|
|
|
|
// Variable marker gutter icons
|
|
class VariableGutterMarker extends GutterMarker {
|
|
type: 'required' | 'optional' | 'missing';
|
|
hasValue: boolean;
|
|
|
|
constructor(type: 'required' | 'optional' | 'missing', hasValue: boolean = false) {
|
|
super();
|
|
this.type = type;
|
|
this.hasValue = hasValue;
|
|
}
|
|
|
|
toDOM() {
|
|
const wrapper = document.createElement('span');
|
|
wrapper.className = 'var-marker-wrapper';
|
|
|
|
// The colored dot
|
|
const dot = document.createElement('span');
|
|
dot.className = `var-marker var-marker-${this.type}`;
|
|
dot.title = this.type === 'missing' ? 'Missing required variable'
|
|
: this.type === 'required' ? 'Required variable (defined)'
|
|
: 'Optional variable (has default)';
|
|
wrapper.appendChild(dot);
|
|
|
|
// Checkmark if value is provided
|
|
if (this.hasValue) {
|
|
const check = document.createElement('span');
|
|
check.className = 'var-marker-check';
|
|
check.innerHTML = '✓';
|
|
check.title = 'Value provided';
|
|
wrapper.appendChild(check);
|
|
}
|
|
|
|
return wrapper;
|
|
}
|
|
}
|
|
|
|
// Widget to show variable value as inline overlay
|
|
// Supports three states: provided (green), default (blue), missing (red)
|
|
class VariableValueWidget extends WidgetType {
|
|
value: string;
|
|
isSecret: boolean;
|
|
variant: 'provided' | 'default' | 'missing';
|
|
|
|
constructor(value: string, isSecret: boolean = false, variant: 'provided' | 'default' | 'missing' = 'provided') {
|
|
super();
|
|
this.value = value;
|
|
this.isSecret = isSecret;
|
|
this.variant = variant;
|
|
}
|
|
|
|
toDOM() {
|
|
const span = document.createElement('span');
|
|
span.className = `var-value-overlay var-value-${this.variant}`;
|
|
|
|
if (this.variant === 'missing') {
|
|
// Red MISSING badge with icon
|
|
span.innerHTML = '⚠ MISSING';
|
|
span.title = 'Required variable not defined';
|
|
} else {
|
|
span.textContent = this.isSecret ? '••••••' : this.value;
|
|
span.title = this.isSecret ? 'Secret value' : this.value;
|
|
}
|
|
return span;
|
|
}
|
|
|
|
eq(other: VariableValueWidget) {
|
|
return this.value === other.value && this.isSecret === other.isSecret && this.variant === other.variant;
|
|
}
|
|
}
|
|
|
|
// Create inline value decorations
|
|
function createValueDecorations(doc: any, markers: VariableMarker[]): DecorationSet {
|
|
const decorations: {from: number, to: number, decoration: Decoration}[] = [];
|
|
|
|
if (markers.length === 0) return Decoration.none;
|
|
|
|
const text = doc.toString();
|
|
|
|
for (const marker of markers) {
|
|
// Find all occurrences of this variable in the text
|
|
// Match ${VAR_NAME} or ${VAR_NAME:-...} or $VAR_NAME patterns
|
|
const patterns = [
|
|
{ regex: new RegExp(`\\$\\{${marker.name}\\}`, 'g'), hasDefault: false },
|
|
{ regex: new RegExp(`\\$\\{${marker.name}:-([^}]*)\\}`, 'g'), hasDefault: true },
|
|
{ regex: new RegExp(`\\$\\{${marker.name}-([^}]*)\\}`, 'g'), hasDefault: true },
|
|
{ regex: new RegExp(`\\$\\{${marker.name}:\\?[^}]*\\}`, 'g'), hasDefault: false },
|
|
{ regex: new RegExp(`\\$\\{${marker.name}\\?[^}]*\\}`, 'g'), hasDefault: false },
|
|
{ regex: new RegExp(`\\$\\{${marker.name}:\\+[^}]*\\}`, 'g'), hasDefault: false },
|
|
{ regex: new RegExp(`\\$\\{${marker.name}\\+[^}]*\\}`, 'g'), hasDefault: false },
|
|
];
|
|
|
|
for (const { regex, hasDefault } of patterns) {
|
|
let match;
|
|
while ((match = regex.exec(text)) !== null) {
|
|
const from = match.index;
|
|
const to = from + match[0].length;
|
|
|
|
// Determine what to show:
|
|
// 1. If value is provided in env vars editor -> green with that value
|
|
// 2. If no value but has default in syntax -> blue with default value
|
|
// 3. If no value and no default (missing) -> red MISSING
|
|
|
|
let widget: VariableValueWidget;
|
|
|
|
if (marker.value) {
|
|
// Value provided in env vars editor -> GREEN
|
|
widget = new VariableValueWidget(marker.value, marker.isSecret ?? false, 'provided');
|
|
} else if (hasDefault && match[1]) {
|
|
// Has default value from compose syntax -> BLUE
|
|
widget = new VariableValueWidget(match[1], false, 'default');
|
|
} else if (marker.defaultValue) {
|
|
// Has default value from marker -> BLUE
|
|
widget = new VariableValueWidget(marker.defaultValue, false, 'default');
|
|
} else if (marker.type === 'missing') {
|
|
// Missing required variable -> RED
|
|
widget = new VariableValueWidget('', false, 'missing');
|
|
} else {
|
|
// Skip if nothing to show
|
|
continue;
|
|
}
|
|
|
|
// Add widget decoration at the end of the variable
|
|
decorations.push({
|
|
from: to,
|
|
to: to,
|
|
decoration: Decoration.widget({
|
|
widget,
|
|
side: 1
|
|
})
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort by position
|
|
decorations.sort((a, b) => a.from - b.from);
|
|
return Decoration.set(decorations.map(d => d.decoration.range(d.from, d.to)));
|
|
}
|
|
|
|
// Create decorations for variable markers
|
|
function createVariableDecorations(doc: any, markers: VariableMarker[]): RangeSet<GutterMarker> {
|
|
const gutterMarkers: {from: number, marker: GutterMarker}[] = [];
|
|
|
|
if (markers.length === 0) return RangeSet.empty;
|
|
|
|
const text = doc.toString();
|
|
const lines = text.split('\n');
|
|
let pos = 0;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
|
|
// Check if this line contains any of our marked variables
|
|
for (const marker of markers) {
|
|
// Match ${VAR_NAME} or ${VAR_NAME:-...} patterns
|
|
const patterns = [
|
|
`\${${marker.name}}`,
|
|
`\${${marker.name}:-`,
|
|
`\${${marker.name}-`,
|
|
`\${${marker.name}:?`,
|
|
`\${${marker.name}?`,
|
|
`\${${marker.name}:+`,
|
|
`\${${marker.name}+`,
|
|
`$${marker.name}`
|
|
];
|
|
|
|
const hasVariable = patterns.some(p => line.includes(p));
|
|
if (hasVariable) {
|
|
gutterMarkers.push({
|
|
from: pos,
|
|
marker: new VariableGutterMarker(marker.type, !!marker.value)
|
|
});
|
|
break; // Only one marker per line
|
|
}
|
|
}
|
|
|
|
pos += line.length + 1; // +1 for newline
|
|
}
|
|
|
|
// Sort by position and create RangeSet
|
|
gutterMarkers.sort((a, b) => a.from - b.from);
|
|
return RangeSet.of(gutterMarkers.map(m => m.marker.range(m.from)));
|
|
}
|
|
|
|
// Effect to update variable markers
|
|
const updateMarkersEffect = StateEffect.define<VariableMarker[]>();
|
|
|
|
// State field to track variable markers (gutter)
|
|
// IMPORTANT: Only updates via effects, not closure reference (fixes stale closure bug)
|
|
const variableMarkersField = StateField.define<RangeSet<GutterMarker>>({
|
|
create() {
|
|
// Start empty - markers will be pushed via effect
|
|
return RangeSet.empty;
|
|
},
|
|
update(markers, tr) {
|
|
for (const effect of tr.effects) {
|
|
if (effect.is(updateMarkersEffect)) {
|
|
return createVariableDecorations(tr.state.doc, effect.value);
|
|
}
|
|
}
|
|
// Don't recalculate on docChanged - wait for explicit effect from parent
|
|
return markers;
|
|
}
|
|
});
|
|
|
|
// State field to track value decorations (inline widgets)
|
|
// IMPORTANT: Only updates via effects, not closure reference (fixes stale closure bug)
|
|
const valueDecorationsField = StateField.define<DecorationSet>({
|
|
create() {
|
|
// Start empty - decorations will be pushed via effect
|
|
return Decoration.none;
|
|
},
|
|
update(decorations, tr) {
|
|
for (const effect of tr.effects) {
|
|
if (effect.is(updateMarkersEffect)) {
|
|
return createValueDecorations(tr.state.doc, effect.value);
|
|
}
|
|
}
|
|
// Don't recalculate on docChanged - wait for explicit effect from parent
|
|
return decorations;
|
|
},
|
|
provide: f => EditorView.decorations.from(f)
|
|
});
|
|
|
|
// Variable markers gutter
|
|
const variableGutter = gutter({
|
|
class: 'cm-variable-gutter',
|
|
markers: view => view.state.field(variableMarkersField),
|
|
initialSpacer: () => new VariableGutterMarker('required')
|
|
});
|
|
|
|
// Get language extension based on language name
|
|
function getLanguageExtension(lang: string) {
|
|
switch (lang) {
|
|
case 'yaml':
|
|
return yaml();
|
|
case 'json':
|
|
return json();
|
|
case 'javascript':
|
|
case 'js':
|
|
return javascript();
|
|
case 'typescript':
|
|
case 'ts':
|
|
return javascript({ typescript: true });
|
|
case 'jsx':
|
|
return javascript({ jsx: true });
|
|
case 'tsx':
|
|
return javascript({ jsx: true, typescript: true });
|
|
case 'python':
|
|
case 'py':
|
|
return python();
|
|
case 'html':
|
|
return html();
|
|
case 'css':
|
|
return css();
|
|
case 'markdown':
|
|
case 'md':
|
|
return markdown();
|
|
case 'xml':
|
|
return xml();
|
|
case 'sql':
|
|
return sql();
|
|
case 'dockerfile':
|
|
case 'shell':
|
|
case 'bash':
|
|
case 'sh':
|
|
// No dedicated shell/dockerfile support, use basic highlighting
|
|
return [];
|
|
default:
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Create custom dark theme that matches our UI
|
|
const dockhandDark = EditorView.theme({
|
|
'&': {
|
|
backgroundColor: '#1a1a1a',
|
|
color: '#d4d4d4',
|
|
height: '100%',
|
|
fontSize: '13px'
|
|
},
|
|
'.cm-content': {
|
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
|
padding: '8px 0'
|
|
},
|
|
'.cm-gutters': {
|
|
backgroundColor: '#1a1a1a',
|
|
color: '#858585',
|
|
border: 'none',
|
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
|
fontSize: '13px'
|
|
},
|
|
'.cm-activeLineGutter': {
|
|
backgroundColor: '#2a2a2a'
|
|
},
|
|
'.cm-activeLine': {
|
|
backgroundColor: '#2a2a2a'
|
|
},
|
|
'.cm-selectionBackground': {
|
|
backgroundColor: 'yellow !important'
|
|
},
|
|
'&.cm-focused .cm-selectionBackground': {
|
|
backgroundColor: 'yellow !important'
|
|
},
|
|
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
|
|
backgroundColor: 'yellow !important'
|
|
},
|
|
'.cm-cursor': {
|
|
borderLeftColor: '#d4d4d4'
|
|
},
|
|
'.cm-line': {
|
|
padding: '0 8px'
|
|
}
|
|
}, { dark: true });
|
|
|
|
// Create custom light theme
|
|
const dockhandLight = EditorView.theme({
|
|
'&': {
|
|
backgroundColor: '#fafafa',
|
|
color: '#3f3f46',
|
|
height: '100%',
|
|
fontSize: '13px'
|
|
},
|
|
'.cm-content': {
|
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
|
padding: '8px 0'
|
|
},
|
|
'.cm-gutters': {
|
|
backgroundColor: '#fafafa',
|
|
color: '#a1a1aa',
|
|
border: 'none',
|
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
|
fontSize: '13px'
|
|
},
|
|
'.cm-activeLineGutter': {
|
|
backgroundColor: '#f4f4f5'
|
|
},
|
|
'.cm-activeLine': {
|
|
backgroundColor: '#f4f4f5'
|
|
},
|
|
'.cm-selectionBackground': {
|
|
backgroundColor: '#e4e4e7 !important'
|
|
},
|
|
'&.cm-focused .cm-selectionBackground': {
|
|
backgroundColor: '#e4e4e7 !important'
|
|
},
|
|
'.cm-cursor': {
|
|
borderLeftColor: '#3f3f46'
|
|
},
|
|
'.cm-line': {
|
|
padding: '0 8px'
|
|
}
|
|
}, { dark: false });
|
|
|
|
// Track if we're initialized (prevents multiple createEditor calls)
|
|
let initialized = false;
|
|
|
|
function createEditor() {
|
|
if (!container || view || initialized) return;
|
|
initialized = true;
|
|
|
|
const themeExtensions = theme === 'dark'
|
|
? [dockhandDark, syntaxHighlighting(oneDarkHighlightStyle)]
|
|
: [dockhandLight, syntaxHighlighting(defaultHighlightStyle)];
|
|
|
|
// Build autocompletion config - add Docker Compose completions for YAML
|
|
const autocompletionConfig = language === 'yaml'
|
|
? autocompletion({
|
|
override: [composeCompletions, composeValueCompletions],
|
|
activateOnTyping: true
|
|
})
|
|
: autocompletion();
|
|
|
|
const extensions = [
|
|
lineNumbers(),
|
|
highlightActiveLineGutter(),
|
|
highlightActiveLine(),
|
|
history(),
|
|
indentOnInput(),
|
|
bracketMatching(),
|
|
closeBrackets(),
|
|
autocompletionConfig,
|
|
highlightSelectionMatches(),
|
|
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
|
keymap.of([
|
|
...defaultKeymap,
|
|
...historyKeymap,
|
|
...searchKeymap,
|
|
...completionKeymap,
|
|
...closeBracketsKeymap,
|
|
indentWithTab
|
|
]),
|
|
...themeExtensions,
|
|
EditorView.lineWrapping,
|
|
getLanguageExtension(language)
|
|
].flat();
|
|
|
|
if (readonly) {
|
|
extensions.push(EditorState.readOnly.of(true));
|
|
}
|
|
|
|
// Always add variable markers gutter and value decorations (can be updated dynamically)
|
|
extensions.push(variableMarkersField, variableGutter, valueDecorationsField);
|
|
|
|
const state = EditorState.create({
|
|
doc: value,
|
|
extensions
|
|
});
|
|
|
|
// Custom transaction handler - this is SYNCHRONOUS and more reliable than updateListener
|
|
// Based on the Svelte Playground pattern: https://svelte.dev/playground/91649ba3e0ce4122b3b34f3a95a00104
|
|
const dispatchTransactions = (trs: readonly import('@codemirror/state').Transaction[]) => {
|
|
if (!view) return;
|
|
|
|
// Apply all transactions
|
|
view.update(trs);
|
|
|
|
// Check if any transaction changed the document
|
|
const lastChangingTr = trs.findLast(tr => tr.docChanged);
|
|
if (lastChangingTr && onchangeRef) {
|
|
onchangeRef(lastChangingTr.newDoc.toString());
|
|
}
|
|
};
|
|
|
|
view = new EditorView({
|
|
state,
|
|
parent: container,
|
|
dispatchTransactions
|
|
});
|
|
|
|
|
|
// Push initial markers if provided
|
|
if (variableMarkers.length > 0) {
|
|
view.dispatch({
|
|
effects: updateMarkersEffect.of(variableMarkers)
|
|
});
|
|
}
|
|
}
|
|
|
|
function destroyEditor() {
|
|
if (view) {
|
|
view.destroy();
|
|
view = null;
|
|
}
|
|
initialized = false;
|
|
}
|
|
|
|
// Get current editor content
|
|
export function getValue(): string {
|
|
return view?.state.doc.toString() ?? value;
|
|
}
|
|
|
|
// Set editor content
|
|
export function setValue(newValue: string) {
|
|
if (view) {
|
|
view.dispatch({
|
|
changes: {
|
|
from: 0,
|
|
to: view.state.doc.length,
|
|
insert: newValue
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Focus the editor
|
|
export function focus() {
|
|
view?.focus();
|
|
}
|
|
|
|
// Update variable markers - this is the key method for parent to call
|
|
export function updateVariableMarkers(markers: VariableMarker[]) {
|
|
if (view) {
|
|
view.dispatch({
|
|
effects: updateMarkersEffect.of(markers)
|
|
});
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
createEditor();
|
|
});
|
|
|
|
onDestroy(() => {
|
|
destroyEditor();
|
|
});
|
|
|
|
// Track previous values for comparison
|
|
let prevLanguage = $state(language);
|
|
let prevTheme = $state(theme);
|
|
|
|
// Recreate editor if language or theme changes
|
|
$effect(() => {
|
|
const currentLanguage = language;
|
|
const currentTheme = theme;
|
|
|
|
// Only recreate if language or theme actually changed
|
|
if (view && (currentLanguage !== prevLanguage || currentTheme !== prevTheme)) {
|
|
prevLanguage = currentLanguage;
|
|
prevTheme = currentTheme;
|
|
const currentContent = view.state.doc.toString();
|
|
destroyEditor();
|
|
value = currentContent; // Preserve content
|
|
createEditor();
|
|
}
|
|
});
|
|
|
|
// Update markers when prop changes (backup mechanism, parent should also call updateVariableMarkers)
|
|
$effect(() => {
|
|
const markers = variableMarkers;
|
|
if (view && markers) {
|
|
view.dispatch({
|
|
effects: updateMarkersEffect.of(markers)
|
|
});
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<div
|
|
bind:this={container}
|
|
class="h-full w-full overflow-hidden {className}"
|
|
onkeydown={(e) => e.stopPropagation()}
|
|
></div>
|
|
|
|
<style>
|
|
div :global(.cm-editor) {
|
|
height: 100%;
|
|
}
|
|
div :global(.cm-scroller) {
|
|
overflow: auto;
|
|
}
|
|
|
|
/* Variable marker gutter */
|
|
div :global(.cm-variable-gutter) {
|
|
width: 28px;
|
|
min-width: 28px;
|
|
}
|
|
|
|
div :global(.var-marker-wrapper) {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 2px;
|
|
padding-left: 2px;
|
|
}
|
|
|
|
div :global(.var-marker-check) {
|
|
color: #22c55e;
|
|
font-size: 14px;
|
|
font-weight: bold;
|
|
line-height: 1;
|
|
margin-top: -1px;
|
|
}
|
|
|
|
div :global(.var-marker) {
|
|
display: inline-block;
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
margin: 4px 3px;
|
|
cursor: help;
|
|
}
|
|
|
|
div :global(.var-marker-required) {
|
|
background-color: #22c55e; /* green-500 */
|
|
box-shadow: 0 0 4px #22c55e;
|
|
}
|
|
|
|
div :global(.var-marker-optional) {
|
|
background-color: #60a5fa; /* blue-400 */
|
|
box-shadow: 0 0 4px #60a5fa;
|
|
}
|
|
|
|
div :global(.var-marker-missing) {
|
|
background-color: #ef4444; /* red-500 */
|
|
box-shadow: 0 0 4px #ef4444;
|
|
}
|
|
|
|
/* Variable value overlay widget - base styles */
|
|
div :global(.var-value-overlay) {
|
|
display: inline-block;
|
|
margin-left: 4px;
|
|
padding: 0 6px;
|
|
font-size: 11px;
|
|
font-family: inherit;
|
|
border-radius: 4px;
|
|
max-width: 150px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
vertical-align: middle;
|
|
cursor: help;
|
|
}
|
|
|
|
/* Provided value - GREEN */
|
|
div :global(.var-value-provided) {
|
|
background-color: rgba(34, 197, 94, 0.15);
|
|
color: #22c55e;
|
|
border: 1px solid rgba(34, 197, 94, 0.3);
|
|
}
|
|
|
|
/* Default value - BLUE */
|
|
div :global(.var-value-default) {
|
|
background-color: rgba(96, 165, 250, 0.15);
|
|
color: #60a5fa;
|
|
border: 1px solid rgba(96, 165, 250, 0.3);
|
|
}
|
|
|
|
/* Missing value - RED */
|
|
div :global(.var-value-missing) {
|
|
background-color: rgba(239, 68, 68, 0.15);
|
|
color: #ef4444;
|
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* Light theme adjustments */
|
|
:global(.cm-editor:not(.cm-dark)) div :global(.var-value-provided) {
|
|
background-color: rgba(34, 197, 94, 0.1);
|
|
color: #16a34a;
|
|
border-color: rgba(34, 197, 94, 0.4);
|
|
}
|
|
|
|
:global(.cm-editor:not(.cm-dark)) div :global(.var-value-default) {
|
|
background-color: rgba(59, 130, 246, 0.1);
|
|
color: #2563eb;
|
|
border-color: rgba(59, 130, 246, 0.4);
|
|
}
|
|
|
|
:global(.cm-editor:not(.cm-dark)) div :global(.var-value-missing) {
|
|
background-color: rgba(239, 68, 68, 0.1);
|
|
color: #dc2626;
|
|
border-color: rgba(239, 68, 68, 0.4);
|
|
}
|
|
</style>
|