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
View File
@@ -14,6 +14,7 @@ import { viewerTheme } from '../util/settings';
import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
import { graphPage, makeNonce, messagePage, modelPage, prepareDiagramHtml } from '../webview/page';
import { graphScriptUri, sourceScriptUri, viewer3dOption, viewerRoots } from '../webview/viewer3d';
import { liveBridge } from '../sync/liveBridge';
import { SourceSession } from './sourceSession';
import { DECISION_VIEW_TYPE, DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
@@ -55,6 +56,8 @@ class DiagramPreview implements vscode.Disposable {
readonly source: SourceSession | undefined;
private heldHtml: string | undefined;
private disposed = false;
/** main.archify: the model page has its Live slot and takes `live` messages. */
private liveShown = false;
constructor(
private readonly context: vscode.ExtensionContext,
@@ -80,6 +83,7 @@ class DiagramPreview implements vscode.Disposable {
if (!shown && this.heldHtml !== undefined) {
const html = this.heldHtml;
this.heldHtml = undefined;
this.liveShown = false;
this.panel.webview.html = html;
}
});
@@ -115,10 +119,25 @@ class DiagramPreview implements vscode.Disposable {
this.setGraph(false);
return;
}
if (message.type === 'liveReady') {
this.liveShown = true;
void this.postLive();
return;
}
if (message.type === 'liveAction') {
if (this.root) void liveBridge.act(this.root, message.action);
return;
}
void handleWebviewMessage(message, this.uri, this.panel);
}),
);
if (isMainFile(uri.path)) {
this.disposables.push(liveBridge.onDidChange((root) => {
if (!root || (this.root && root.toString() === this.root.toString())) void this.postLive();
}));
}
if (uri.scheme === 'file') {
const watcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(vscode.Uri.file(path.dirname(uri.fsPath)), path.basename(uri.fsPath)),
@@ -134,6 +153,13 @@ class DiagramPreview implements vscode.Disposable {
void this.update();
}
/** The Live section of a shared model's page; nothing when the model is not shared. */
private async postLive(): Promise<void> {
if (!this.liveShown || !this.root || this.disposed) return;
const live = await liveBridge.panel(this.root).catch(() => undefined);
if (this.liveShown) this.post({ type: 'live', live });
}
/** Posts to the webview; a panel closed mid-update is not an error. */
private post(message: unknown): void {
Promise.resolve(this.panel.webview.postMessage(message)).catch(() => undefined);
@@ -147,6 +173,7 @@ class DiagramPreview implements vscode.Disposable {
return;
}
this.heldHtml = undefined;
this.liveShown = false;
this.panel.webview.html = html;
}
+30 -2
View File
@@ -1,6 +1,7 @@
import * as vscode from 'vscode';
import { EditorSchemas } from '../language/editorSchemas';
import { readSchemas } from '../language/schemaCheck';
import { liveBridge } from '../sync/liveBridge';
import { log } from '../util/log';
import { TextChange, normalizeEol, offsetOf, positionOf } from '../util/textDiff';
@@ -9,7 +10,8 @@ export type SourceMessage =
| { type: 'sourceReady' }
| { type: 'sourceOpen' }
| { type: 'sourceClose' }
| { type: 'sourceEdit'; base: string; text: string; changes: TextChange[] };
| { type: 'sourceEdit'; base: string; text: string; changes: TextChange[] }
| { type: 'sourceSelection'; anchor: number; head: number };
export type SourceKind = 'diagram' | 'registry' | 'decision';
@@ -27,7 +29,13 @@ function schemasFor(context: vscode.ExtensionContext): EditorSchemas {
}
export function isSourceMessage(message: { type?: string }): message is SourceMessage {
return message.type === 'sourceReady' || message.type === 'sourceOpen' || message.type === 'sourceClose' || message.type === 'sourceEdit';
return (
message.type === 'sourceReady' ||
message.type === 'sourceOpen' ||
message.type === 'sourceClose' ||
message.type === 'sourceEdit' ||
message.type === 'sourceSelection'
);
}
/**
@@ -68,6 +76,8 @@ export class SourceSession implements vscode.Disposable {
vscode.workspace.onDidSaveTextDocument((doc) => {
if (same(doc.uri)) this.post({ type: 'sourceDirty', dirty: false });
}),
// Teammates' cursors, when the model is shared (Archify Sync); nothing otherwise.
liveBridge.onDidChangeCursors(() => this.postCursors()),
];
}
@@ -94,10 +104,14 @@ export class SourceSession implements vscode.Disposable {
break;
case 'sourceClose':
this.setShown(false);
liveBridge.setCursor(this.uri, undefined);
break;
case 'sourceEdit':
this.edit(message);
break;
case 'sourceSelection':
liveBridge.setCursor(this.uri, { anchor: message.anchor, head: message.head });
break;
}
return true;
}
@@ -108,6 +122,17 @@ export class SourceSession implements vscode.Disposable {
this.onShown(shown);
}
private lastCursors = '[]';
private postCursors(): void {
if (!this.shown) return;
const cursors = liveBridge.cursors(this.uri);
const key = JSON.stringify(cursors);
if (key === this.lastCursors) return;
this.lastCursors = key;
this.post({ type: 'sourceCursors', cursors });
}
private post(message: unknown): void {
Promise.resolve(this.panel.webview.postMessage(message)).catch(() => undefined);
}
@@ -123,6 +148,8 @@ export class SourceSession implements vscode.Disposable {
this.schemaKey = undefined;
this.post({ type: 'sourceText', text, schema: this.schemaChange(text), checks: CHECKS[this.kind] });
this.post({ type: 'sourceDirty', dirty: doc.isDirty });
this.lastCursors = '';
this.postCursors();
if (withDiagnostics) await this.postDiagnostics();
}
@@ -196,6 +223,7 @@ export class SourceSession implements vscode.Disposable {
}
dispose(): void {
if (this.shown) liveBridge.setCursor(this.uri, undefined);
for (const d of this.disposables.splice(0)) d.dispose();
}
}