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.
259 lines
11 KiB
TypeScript
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();
|
|
}
|
|
}
|