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

+27 -3
View File
@@ -1,6 +1,6 @@
import type { ArchifyHost } from '../host';
import { TextChange, minimalChange } from '../../util/textDiff';
import { SourceDiagnostic, SourceEditor } from './editor';
import { RemoteCursor, SourceDiagnostic, SourceEditor } from './editor';
/**
* The Source view of an Archify page: the file's YAML in an editor, in the
@@ -15,7 +15,8 @@ type HostMessage =
| { type: 'sourceText'; text: string; schema?: { key: string; schema: object } | null; checks: string }
| { type: 'sourceDiagnostics'; items: SourceDiagnostic[]; count: number }
| { type: 'sourceSchema'; schema: { key: string; schema: object } | null }
| { type: 'sourceDirty'; dirty: boolean };
| { type: 'sourceDirty'; dirty: boolean }
| { type: 'sourceCursors'; cursors: RemoteCursor[] };
const STYLE = `
html.archify-source-on .container > :not(.header):not(#archify-source),
@@ -54,7 +55,10 @@ class SourcePane {
/** The text the host last knew the page to have; edits are sent against it. */
private text = '';
private diagnostics: SourceDiagnostic[] = [];
private cursors: RemoteCursor[] = [];
private shown = false;
private selectionTimer: ReturnType<typeof setTimeout> | undefined;
private selection: { anchor: number; head: number } | undefined;
constructor(private readonly host: ArchifyHost) {
window.addEventListener('message', (event) => this.onMessage(event.data as HostMessage));
@@ -139,8 +143,14 @@ class SourcePane {
this.build();
if (!this.editor) {
this.text = message.text;
this.editor = new SourceEditor(this.editorHost, message.text, (changes, text) => this.onEdit(changes, text));
this.editor = new SourceEditor(
this.editorHost,
message.text,
(changes, text) => this.onEdit(changes, text),
(anchor, head) => this.onSelection(anchor, head),
);
this.editor.setDiagnostics(this.diagnostics);
this.editor.setRemoteCursors(this.cursors);
this.fit();
if (this.shown) this.editor.focus();
} else {
@@ -162,9 +172,23 @@ class SourcePane {
case 'sourceDirty':
this.renderState(message.dirty);
return;
case 'sourceCursors':
this.cursors = message.cursors;
this.editor?.setRemoteCursors(message.cursors);
return;
}
}
/** Teammates see where you are; sent at most every 80 ms, after the edit it follows. */
private onSelection(anchor: number, head: number): void {
this.selection = { anchor, head };
if (this.selectionTimer) return;
this.selectionTimer = setTimeout(() => {
this.selectionTimer = undefined;
if (this.selection && this.shown) this.host.post({ type: 'sourceSelection', ...this.selection });
}, 80);
}
private onEdit(changes: TextChange[], text: string): void {
const base = this.text;
this.text = text;