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, Range, StateEffect, StateField, Transaction } from '@codemirror/state'; import { Decoration, DecorationSet, EditorView, WidgetType, 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; } /** A teammate's cursor (Archify Sync), as offsets into the editor's text. */ export interface RemoteCursor { name: string; color: string; anchor: number; head: number; } const setRemoteCursors = StateEffect.define(); class RemoteCaret extends WidgetType { constructor( readonly name: string, readonly color: string, ) { super(); } eq(other: RemoteCaret): boolean { return other.name === this.name && other.color === this.color; } toDOM(): HTMLElement { const caret = document.createElement('span'); caret.className = 'cm-remote-caret'; caret.style.borderLeftColor = this.color; const label = document.createElement('span'); label.className = 'cm-remote-label'; label.textContent = this.name; label.style.backgroundColor = this.color; caret.appendChild(label); return caret; } ignoreEvent(): boolean { return true; } } function remoteDecorations(cursors: RemoteCursor[], length: number): DecorationSet { const ranges: Range[] = []; const clamp = (n: number) => Math.max(0, Math.min(n, length)); for (const c of cursors) { const anchor = clamp(c.anchor); const head = clamp(c.head); if (anchor !== head) { ranges.push( Decoration.mark({ class: 'cm-remote-selection', attributes: { style: `background-color: color-mix(in srgb, ${c.color} 22%, transparent)` } }).range(Math.min(anchor, head), Math.max(anchor, head)), ); } ranges.push(Decoration.widget({ widget: new RemoteCaret(c.name, c.color), side: 1 }).range(head)); } return Decoration.set(ranges, true); } /** Teammates' carets and selections; they move with the text as anyone types. */ const remoteCursors = StateField.define({ create: () => Decoration.none, update(decorations, tr) { decorations = decorations.map(tr.changes); for (const effect of tr.effects) if (effect.is(setRemoteCursors)) decorations = remoteDecorations(effect.value, tr.state.doc.length); return decorations; }, provide: (field) => EditorView.decorations.from(field), }); /** 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-remote-caret': { position: 'relative', borderLeft: '2px solid', marginLeft: '-1px', marginRight: '-1px', pointerEvents: 'none' }, '.cm-remote-label': { position: 'absolute', bottom: '100%', left: '-2px', padding: '0 4px', borderRadius: '3px 3px 3px 0', color: '#fff', fontSize: '10px', lineHeight: '1.5', fontFamily: 'var(--vscode-font-family, system-ui, sans-serif)', whiteSpace: 'nowrap', zIndex: '5', opacity: '0.9', }, '.cm-line:first-child .cm-remote-label': { bottom: 'auto', top: '100%', borderRadius: '0 3px 3px 3px' }, '.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, /** The main selection moved (or the editor got focus); offsets into the text. */ onSelection: (anchor: number, head: number) => void = () => undefined, ) { 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()), remoteCursors, EditorView.updateListener.of((update) => { if (update.selectionSet || update.docChanged || (update.focusChanged && update.view.hasFocus)) { const main = update.state.selection.main; onSelection(main.anchor, main.head); } 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)] }); } /** Teammates' cursors, from the host; they replace the ones shown. */ setRemoteCursors(cursors: RemoteCursor[]): void { this.view.dispatch({ effects: setRemoteCursors.of(cursors) }); } /** 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(); } }