Files
archify-vscode-ext/src/webview/source/editor.ts
T
root-at-skic c99f3e5fe8 0.10.0: Archify Sync, optional live collaboration on a model
Several people edit one model at once through a server you run yourself
(archify-vscode-ext-sync); the files stay ordinary files on each disk.
Sign-in through the browser, offline-first with later merge, teammates'
cursors in the Source view and text editors, and a Shared section on the
model page with people, versions, Compare and Restore.

Without a server nothing changes: the sync code is a separate bundle that
loads only when a model is shared, after a sign-in, or when a sync command
or invite link is used.
2026-11-06 22:55:01 +02:00

259 lines
11 KiB
TypeScript

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<RemoteCursor[]>();
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<Decoration>[] = [];
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<DecorationSet>({
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<boolean>();
// 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();
}
}