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

+163
View File
@@ -1,6 +1,10 @@
import assert from 'node:assert/strict';
import path from 'node:path';
import * as vscode from 'vscode';
import * as Y from 'yjs';
import { FileBridge } from '../../src/sync/fileBridge';
import { LiveSpace } from '../../src/sync/liveSpace';
import type { SyncAuth } from '../../src/sync/auth';
const EXTENSION_ID = 'root-at-skic.archify-vscode-ext';
@@ -483,6 +487,165 @@ export async function run(): Promise<void> {
await vscode.workspace.fs.delete(target);
});
await check('without a shared model, sync stays unloaded and its commands are there', async () => {
const commands = await vscode.commands.getCommands(true);
for (const id of ['archify.sync.signIn', 'archify.sync.share', 'archify.sync.join']) assert.ok(commands.includes(id), `${id} is registered`);
// The extension host's module cache: dist/sync.js (Yjs, the WebSocket) is not loaded.
const loaded = Object.keys(require.cache).filter((file) => file.endsWith(`${path.sep}dist${path.sep}sync.js`));
assert.deepEqual(loaded, [], 'the sync bundle is not loaded');
});
await check('sync file bridge: local edits reach the document, teammates\' edits reach files and editors', async () => {
const root = workspaceFile('shared');
const file = (rel: string) => vscode.Uri.joinPath(root, ...rel.split('/'));
const write = (rel: string, text: string) => vscode.workspace.fs.writeFile(file(rel), new TextEncoder().encode(text));
const read = async (rel: string) => new TextDecoder().decode(await vscode.workspace.fs.readFile(file(rel)));
const exists = (rel: string) => Promise.resolve(vscode.workspace.fs.stat(file(rel))).then(() => true, () => false);
await write('main.archify', 'model:\n id: shared\n');
await write('0100-context/a.archify', 'schema_version: 1\r\nmeta:\r\n title: A\r\n');
await write('notes.txt', 'not a model file');
const doc = new Y.Doc();
const teammate = new Y.Doc();
// The server in between: every update reaches the other side.
doc.on('update', (u: Uint8Array, origin: unknown) => origin !== 'remote' && Y.applyUpdate(teammate, u, 'remote'));
teammate.on('update', (u: Uint8Array, origin: unknown) => origin !== 'remote' && Y.applyUpdate(doc, u, 'remote'));
const quiet = { info() {}, warn() {}, error() {}, debug() {}, trace() {} } as unknown as vscode.LogOutputChannel;
const bridge = new FileBridge(root, doc, { origin: 'local' }, quiet);
const theirs = () => teammate.getMap<Y.Text>('files');
try {
await bridge.pushAll(false);
bridge.start(true);
assert.deepEqual([...theirs().keys()].sort(), ['0100-context/a.archify', 'main.archify'], 'only model files are shared');
assert.equal(theirs().get('0100-context/a.archify')!.toString(), 'schema_version: 1\nmeta:\n title: A\n', 'line endings travel as \\n');
// A local edit in an open editor.
const main = await vscode.workspace.openTextDocument(file('main.archify'));
const edit = new vscode.WorkspaceEdit();
edit.insert(main.uri, new vscode.Position(2, 0), ' name: Shared\n');
assert.ok(await vscode.workspace.applyEdit(edit));
await waitFor('local edit at the teammate', () => theirs().get('main.archify')!.toString() === 'model:\n id: shared\n name: Shared\n');
await main.save();
// The teammate types twice in a row into the open file: both land, nothing is undone by an echo.
theirs().get('main.archify')!.insert(0, '# one\n');
theirs().get('main.archify')!.insert(0, '# two\n');
await waitFor('teammate edits in the open editor', () => main.getText() === '# two\n# one\nmodel:\n id: shared\n name: Shared\n');
await waitFor('saved, so git sees it', () => !main.isDirty);
await new Promise((r) => setTimeout(r, 500));
assert.equal(theirs().get('main.archify')!.toString(), main.getText(), 'no echo changed it back');
// The teammate edits a closed file, keeping its CRLF on disk.
const a = theirs().get('0100-context/a.archify')!;
a.insert(a.length, ' output: a.html\n');
const deadline = Date.now() + 10_000;
while ((await read('0100-context/a.archify')) !== 'schema_version: 1\r\nmeta:\r\n title: A\r\n output: a.html\r\n' && Date.now() < deadline) await new Promise((r) => setTimeout(r, 200));
assert.equal(await read('0100-context/a.archify'), 'schema_version: 1\r\nmeta:\r\n title: A\r\n output: a.html\r\n');
// A new file and a deletion from the teammate.
teammate.transact(() => {
const t = new Y.Text();
t.insert(0, 'schema_version: 1\n');
theirs().set('registry/people.archify-registry', t);
theirs().set('../escape.archify', new Y.Text('nope'));
});
const until = async (what: string, probe: () => Promise<boolean>) => {
const end = Date.now() + 10_000;
while (!(await probe())) {
if (Date.now() > end) throw new Error(`Timed out waiting for ${what}`);
await new Promise((r) => setTimeout(r, 200));
}
};
await until('new file on disk', () => exists('registry/people.archify-registry'));
assert.ok(!(await exists('../escape.archify')), 'a path outside the model is never written');
teammate.transact(() => theirs().delete('0100-context/a.archify'));
await until('deleted file gone', async () => !(await exists('0100-context/a.archify')));
// A change on disk by another tool (git, an agent) to a closed file.
await write('registry/people.archify-registry', 'schema_version: 1\nelements: {}\n');
await waitFor('disk change at the teammate', () => theirs().get('registry/people.archify-registry')?.toString() === 'schema_version: 1\nelements: {}\n');
} finally {
bridge.dispose();
await vscode.commands.executeCommand('workbench.action.closeAllEditors');
await vscode.workspace.fs.delete(root, { recursive: true });
}
});
// Opt-in: a running Archify Sync server, a space on it and an access token.
const syncServer = process.env.ARCHIFY_SYNC_TEST_URL;
if (syncServer) await check('two shared copies of a model stay the same through the sync server', async () => {
const space = process.env.ARCHIFY_SYNC_TEST_SPACE!;
const token = process.env.ARCHIFY_SYNC_TEST_TOKEN!;
const storage = workspaceFile('.sync-storage');
const auth = {
session: async () => ({ account: { id: 'it', display_name: 'Integration' } }),
accessToken: async () => token,
} as unknown as SyncAuth;
const quiet = { info() {}, warn() {}, error() {}, debug() {}, trace() {} } as unknown as vscode.LogOutputChannel;
const services = (dir: string) => ({ context: { globalStorageUri: vscode.Uri.joinPath(storage, dir) } as vscode.ExtensionContext, auth, log: quiet, onChange() {} });
const link = { server: syncServer, space, name: 'IT' };
const mine = workspaceFile('live-mine');
const theirs = workspaceFile('live-theirs');
const read = async (root: vscode.Uri, rel: string) => {
try {
return new TextDecoder().decode(await vscode.workspace.fs.readFile(vscode.Uri.joinPath(root, ...rel.split('/'))));
} catch {
return undefined;
}
};
const until = async (what: string, probe: () => Promise<boolean>) => {
const end = Date.now() + 15_000;
while (!(await probe())) {
if (Date.now() > end) throw new Error(`Timed out waiting for ${what}`);
await new Promise((r) => setTimeout(r, 200));
}
};
await vscode.workspace.fs.writeFile(vscode.Uri.joinPath(mine, 'main.archify'), new TextEncoder().encode('model:\n id: it\n'));
await vscode.workspace.fs.writeFile(vscode.Uri.joinPath(mine, 'registry', 'people.archify-registry'), new TextEncoder().encode('schema_version: 1\n'));
await vscode.workspace.fs.createDirectory(theirs);
const a = new LiveSpace(mine, link, 'share', services('a'));
let b: LiveSpace | undefined;
try {
await a.start();
await until('shared copy online', async () => a.state === 'online');
b = new LiveSpace(theirs, link, 'join', services('b'));
await b.start();
await until('joined copy gets the files', async () => (await read(theirs, 'main.archify')) === 'model:\n id: it\n' && (await read(theirs, 'registry/people.archify-registry')) === 'schema_version: 1\n');
// Typing in one copy's editor shows up in the other copy's file.
const doc = await vscode.workspace.openTextDocument(vscode.Uri.joinPath(mine, 'main.archify'));
const edit = new vscode.WorkspaceEdit();
edit.insert(doc.uri, new vscode.Position(2, 0), ' name: Live 🚀\n');
assert.ok(await vscode.workspace.applyEdit(edit));
await until('edit arrives in the other copy', async () => (await read(theirs, 'main.archify')) === 'model:\n id: it\n name: Live 🚀\n');
// A file written by another tool in the other copy comes back.
await vscode.workspace.fs.writeFile(vscode.Uri.joinPath(theirs, '0100-context', 'c.archify'), new TextEncoder().encode('schema_version: 1\n'));
await until('new file arrives back', async () => (await read(mine, '0100-context/c.archify')) === 'schema_version: 1\n');
await until('presence shows one person in both', async () => a.people().length === 1 && b!.people().length === 1);
// A cursor in one copy shows in the other, and stays on its word while text is typed above it.
const at = 'model:\n id: it\n name: '.length;
assert.ok(a.setCursor(vscode.Uri.joinPath(mine, 'main.archify'), { anchor: at, head: at + 4 }));
const theirsMain = vscode.Uri.joinPath(theirs, 'main.archify');
await until('cursor arrives in the other copy', async () => b!.cursors(theirsMain).length === 1);
const cursor = b.cursors(theirsMain)[0];
assert.deepEqual([cursor.name, cursor.anchor, cursor.head], ['Integration', at, at + 4]);
assert.equal(a.cursors(vscode.Uri.joinPath(mine, 'main.archify')).length, 0, 'your own cursor is not drawn for you');
const above = new vscode.WorkspaceEdit();
above.insert(theirsMain, new vscode.Position(0, 0), '# top\n');
assert.ok(await vscode.workspace.applyEdit(above));
await until('cursor moves with the text', async () => b!.cursors(theirsMain)[0]?.anchor === at + '# top\n'.length);
a.setCursor(vscode.Uri.joinPath(mine, 'main.archify'), undefined);
await until('cursor goes away', async () => b!.cursors(theirsMain).length === 0);
} finally {
a.dispose();
b?.dispose();
await vscode.commands.executeCommand('workbench.action.revertAndCloseActiveEditor');
for (const uri of [mine, theirs, storage]) await vscode.workspace.fs.delete(uri, { recursive: true }).then(undefined, () => undefined);
}
});
await check('registry and decision pages own their document: edits make it dirty and VS Code undo reverts them', async () => {
for (const [file, viewType] of [
['model/registry/containers.archify-registry', 'archify.registry'],