mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-04 05:29:06 +00:00
Initial commit
This commit is contained in:
821
lib/components/CodeEditor.svelte
Normal file
821
lib/components/CodeEditor.svelte
Normal file
@@ -0,0 +1,821 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user