import { autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete'; import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'; import { yaml, yamlLanguage } from '@codemirror/lang-yaml'; import { HighlightStyle, bracketMatching, foldGutter, foldKeymap, indentOnInput, indentUnit, syntaxHighlighting } from '@codemirror/language'; import { lintGutter, lintKeymap, nextDiagnostic, setDiagnostics } from '@codemirror/lint'; import { Annotation, EditorState, Transaction } from '@codemirror/state'; import { EditorView, drawSelection, dropCursor, highlightActiveLine, highlightActiveLineGutter, highlightSpecialChars, hoverTooltip, keymap, lineNumbers, rectangularSelection, } from '@codemirror/view'; import { tags as t } from '@lezer/highlight'; import { stateExtensions, updateSchema } from 'codemirror-json-schema'; import { yamlCompletion, yamlSchemaHover } from 'codemirror-json-schema/yaml'; import type { TextChange } from '../../util/textDiff'; /** A problem from the host, as offsets into the "\n" text it checked. */ export interface SourceDiagnostic { from: number; to: number; severity: 'error' | 'warning' | 'info'; message: string; source?: string; } /** Marks transactions that carry text from the host, so they are not sent back. */ const fromHost = Annotation.define(); // The page's own variables (Archify's, or ours on model pages), so the // editor follows the page theme, light or dark. const theme = EditorView.theme({ '&': { height: '100%', color: 'var(--text, var(--vscode-editor-foreground))', backgroundColor: 'transparent', fontSize: '12.5px' }, '.cm-scroller': { fontFamily: 'var(--vscode-editor-font-family, ui-monospace, Menlo, "SF Mono", monospace)', lineHeight: '1.6' }, '.cm-content': { caretColor: 'var(--frontend-stroke, var(--vscode-editorCursor-foreground))' }, '.cm-cursor, .cm-dropCursor': { borderLeftColor: 'var(--frontend-stroke, var(--vscode-editorCursor-foreground))' }, '.cm-gutters': { backgroundColor: 'transparent', color: 'var(--text-muted, var(--vscode-editorLineNumber-foreground))', border: 'none' }, '.cm-activeLine, .cm-activeLineGutter': { backgroundColor: 'color-mix(in srgb, var(--text, var(--vscode-editor-foreground)) 5%, transparent)' }, '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': { backgroundColor: 'color-mix(in srgb, var(--frontend-stroke, var(--vscode-editor-selectionBackground)) 28%, transparent) !important', }, '&.cm-focused': { outline: 'none' }, '.cm-tooltip': { backgroundColor: 'var(--panel, var(--vscode-editorHoverWidget-background))', color: 'var(--text, var(--vscode-editorHoverWidget-foreground))', border: '1px solid var(--panel-border, var(--vscode-editorHoverWidget-border))', borderRadius: '8px', maxWidth: '440px', }, '.cm-tooltip code': { fontFamily: 'var(--vscode-editor-font-family, ui-monospace, Menlo, monospace)', color: 'var(--frontend-stroke, var(--vscode-textPreformat-foreground))' }, '.cm-tooltip-autocomplete ul li[aria-selected]': { backgroundColor: 'var(--frontend-stroke, var(--vscode-list-activeSelectionBackground))', color: 'var(--bg, var(--vscode-list-activeSelectionForeground))' }, '.cm-diagnostic': { padding: '4px 8px' }, '.cm-foldPlaceholder': { backgroundColor: 'transparent', border: '1px solid var(--panel-border, var(--vscode-editorWidget-border))', color: 'inherit' }, }); const highlight = HighlightStyle.define([ { tag: [t.propertyName, t.definition(t.propertyName)], color: 'var(--frontend-stroke, var(--vscode-symbolIcon-propertyForeground, #22d3ee))' }, { tag: [t.number, t.bool, t.null, t.atom], color: 'var(--cloud-stroke, var(--vscode-debugTokenExpression-number, #fbbf24))' }, { tag: [t.comment, t.lineComment], color: 'var(--text-muted, var(--vscode-descriptionForeground))', fontStyle: 'italic' }, { tag: [t.punctuation, t.separator, t.squareBracket, t.brace], color: 'var(--text-muted, var(--vscode-descriptionForeground))' }, { tag: [t.keyword, t.typeName, t.labelName, t.meta], color: 'var(--database-stroke, var(--vscode-symbolIcon-keywordForeground, #a78bfa))' }, ]); export class SourceEditor { readonly view: EditorView; private schemaKey: string | undefined; constructor(parent: HTMLElement, text: string, onEdit: (changes: TextChange[], text: string) => void) { this.view = new EditorView({ parent, state: EditorState.create({ doc: text, extensions: [ // Undo and redo are the editor's own: VS Code sends ⌘Z in a focused // webview to the webview, not to the document. Every step still // reaches the document as an edit. Save and find stay VS Code's // (no search panel here). history(), lineNumbers(), highlightActiveLineGutter(), highlightSpecialChars(), foldGutter(), drawSelection(), dropCursor(), EditorState.allowMultipleSelections.of(true), indentOnInput(), bracketMatching(), closeBrackets(), autocompletion(), rectangularSelection(), highlightActiveLine(), keymap.of([...closeBracketsKeymap, ...defaultKeymap, ...historyKeymap, ...foldKeymap, ...completionKeymap, ...lintKeymap, indentWithTab]), EditorState.tabSize.of(2), indentUnit.of(' '), yaml(), theme, syntaxHighlighting(highlight), lintGutter(), stateExtensions(undefined as never), yamlLanguage.data.of({ autocomplete: yamlCompletion() }), hoverTooltip(yamlSchemaHover()), EditorView.updateListener.of((update) => { if (!update.docChanged || update.transactions.some((tr) => tr.annotation(fromHost))) return; const changes: TextChange[] = []; update.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => changes.push({ from: fromA, to: toA, insert: inserted.toString() })); onEdit(changes, update.state.doc.toString()); }), ], }), }); } get text(): string { return this.view.state.doc.toString(); } /** Text from the host (an edit elsewhere, git): one change, cursor kept, not undoable here. */ applyFromHost(change: TextChange): void { this.view.dispatch({ changes: change, annotations: [fromHost.of(true), Transaction.addToHistory.of(false)] }); } /** The schema that completion and hover read; `undefined` turns them off. */ setSchema(key: string | undefined, schema: object | undefined): void { if (key === this.schemaKey) return; this.schemaKey = key; updateSchema(this.view, schema as never); } /** The host's problems for the text it checked; the editor maps them through later typing. */ setDiagnostics(items: SourceDiagnostic[]): void { const length = this.view.state.doc.length; const diagnostics = items .filter((d) => d.from <= length) .map((d) => ({ from: d.from, to: Math.min(Math.max(d.to, d.from), length), severity: d.severity, message: d.message, source: d.source })); this.view.dispatch(setDiagnostics(this.view.state, diagnostics)); } /** Moves the cursor to the next problem and opens its message. */ nextProblem(): void { this.view.focus(); nextDiagnostic(this.view); } focus(): void { this.view.focus(); } requestMeasure(): void { this.view.requestMeasure(); } }