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.
This commit is contained in:
root-at-skic committed 2026-11-06 22:55:01 +02:00
1 parent a9021312b0
commit c99f3e5fe8
27 files changed
+2899 -14

No files matched your search

+103 -2
View File
@@ -3,9 +3,12 @@ import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirro
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 { Annotation, EditorState, Range, StateEffect, StateField, Transaction } from '@codemirror/state';
import {
Decoration,
DecorationSet,
EditorView,
WidgetType,
drawSelection,
dropCursor,
highlightActiveLine,
@@ -30,6 +33,72 @@ export interface SourceDiagnostic {
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>();
@@ -56,6 +125,22 @@ const theme = EditorView.theme({
'.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' },
});
@@ -71,7 +156,13 @@ export class SourceEditor {
readonly view: EditorView;
private schemaKey: string | undefined;
constructor(parent: HTMLElement, text: string, onEdit: (changes: TextChange[], text: string) => void) {
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({
@@ -105,7 +196,12 @@ export class SourceEditor {
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() }));
@@ -125,6 +221,11 @@ export class SourceEditor {
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;