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

+569
View File
@@ -0,0 +1,569 @@
// Archify Sync in the extension: live, shared models. Bundled on its own
// (dist/sync.js) and loaded only when a model is shared or a sync command
// runs, so the extension without a server works exactly as before.
import * as vscode from 'vscode';
import { MAIN_FILE } from '../model/format';
import type { ModelService } from '../model/service';
import { SyncApi, SyncApiError, checkServerUrl, parseInviteLink } from './api';
import { SyncAuth } from './auth';
import { LiveSpace, StartMode } from './liveSpace';
import { LINK_FILE, Link, PROTOCOL_VERSION, Version, formatLink, parseLink, versionLabel } from './protocol';
import { EditorCursors } from './cursors';
import type { LiveAction, LiveBridge, LiveCursor, LivePanel, LiveProvider, LiveSelection } from './liveBridge';
import { VERSION_SCHEME, VersionContent, compareVersion, restoreVersion, showVersions } from './versions';
export const SYNC_COMMANDS = [
'archify.sync.signIn',
'archify.sync.signOut',
'archify.sync.share',
'archify.sync.invite',
'archify.sync.join',
'archify.sync.publish',
'archify.sync.versions',
'archify.sync.stop',
'archify.sync.menu',
] as const;
const SKIP = '{**/node_modules/**,**/.git/**}';
/** How long the model page's list of versions is reused before it is asked for again. */
const VERSIONS_TTL_MS = 20_000;
export class SyncFeature implements vscode.Disposable, LiveProvider {
private readonly auth: SyncAuth;
private readonly spaces = new Map<string, LiveSpace>();
private readonly status = vscode.window.createStatusBarItem('archify.sync', vscode.StatusBarAlignment.Left, 50);
private readonly content: VersionContent;
private readonly disposables: vscode.Disposable[] = [];
private readonly versionCache = new Map<string, { at: number; versions: Promise<Version[]> }>();
private readonly editorCursors: EditorCursors;
private cursorTimer: ReturnType<typeof setTimeout> | undefined;
constructor(
private readonly context: vscode.ExtensionContext,
private readonly models: ModelService,
private readonly log: vscode.LogOutputChannel,
private readonly bridge?: LiveBridge,
) {
this.auth = new SyncAuth(context, log);
this.content = new VersionContent((space) => {
const live = [...this.spaces.values()].find((s) => s.link.space === space);
return live ? this.api(live.link.server) : undefined;
});
this.status.name = 'Archify Sync';
this.status.command = 'archify.sync.menu';
const links = vscode.workspace.createFileSystemWatcher(`**/${LINK_FILE}`);
this.disposables.push(
this.auth,
this.status,
links,
links.onDidCreate((uri) => void this.open(uri, 'open')),
links.onDidDelete((uri) => this.close(vscode.Uri.joinPath(uri, '..'), false)),
vscode.workspace.registerTextDocumentContentProvider(VERSION_SCHEME, this.content),
this.auth.onDidChangeSessions(() => {
for (const space of this.spaces.values()) space.reconnect();
this.render();
}),
);
this.editorCursors = new EditorCursors({ cursors: (file) => this.cursors(file), setCursor: (file, selection) => this.setCursorIn(file, selection) });
this.disposables.push(this.editorCursors);
bridge?.setProvider(this);
}
/** Opens every shared model in the workspace. */
async start(): Promise<void> {
const found = await vscode.workspace.findFiles(`**/${LINK_FILE}`, SKIP, 50);
await Promise.all(found.map((uri) => this.open(uri, 'open')));
this.render();
}
private api(server: string): SyncApi {
return new SyncApi(server, (force) => this.auth.accessToken(server, force));
}
private async open(linkUri: vscode.Uri, mode: StartMode): Promise<LiveSpace | undefined> {
const root = vscode.Uri.joinPath(linkUri, '..');
if (this.spaces.has(root.toString())) return this.spaces.get(root.toString());
let link: Link | undefined;
try {
link = parseLink(new TextDecoder().decode(await vscode.workspace.fs.readFile(linkUri)));
} catch {
return undefined;
}
if (!link) {
this.log.warn(`sync: ${vscode.workspace.asRelativePath(linkUri)} is not a valid link file`);
return undefined;
}
if (!checkServerUrl(link.server).url) {
this.log.warn(`sync: ${vscode.workspace.asRelativePath(linkUri)} names a server that is not https: ${link.server}`);
return undefined;
}
const space = new LiveSpace(root, link, mode, {
context: this.context,
auth: this.auth,
log: this.log,
onChange: () => this.render(),
onCursors: () => this.cursorsMoved(),
});
this.spaces.set(root.toString(), space);
try {
await space.start();
} catch (error) {
this.log.error(`sync: ${link.name ?? root.fsPath} did not start: ${String(error)}`);
}
this.render();
return space;
}
private close(root: vscode.Uri, forget: boolean): void {
const space = this.spaces.get(root.toString());
if (!space) return;
this.spaces.delete(root.toString());
if (forget) space.forget();
else space.dispose();
this.render();
}
private render(): void {
const spaces = [...this.spaces.values()];
if (!spaces.length) {
this.status.hide();
void vscode.commands.executeCommand('setContext', 'archify.sync.active', false);
return;
}
void vscode.commands.executeCommand('setContext', 'archify.sync.active', true);
const signedOut = spaces.filter((s) => s.state === 'signed-out');
const offline = spaces.filter((s) => s.state !== 'online' && s.state !== 'signed-out');
const people = new Set(spaces.flatMap((s) => s.people().map((p) => p.user.id)));
if (signedOut.length) {
this.status.text = '$(account) Archify: sign in';
this.status.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
} else if (offline.length) {
this.status.text = offline.some((s) => s.state === 'connecting') ? '$(sync~spin) Archify connecting' : '$(cloud-offline) Archify offline';
this.status.backgroundColor = undefined;
} else {
this.status.text = `$(broadcast) Archify live · ${people.size} here`;
this.status.backgroundColor = undefined;
}
const tooltip = new vscode.MarkdownString(undefined, true);
for (const s of spaces) {
const who = s.people().map((p) => `${p.user.name}${p.file ? ` (${p.file})` : ''}`).join(', ');
tooltip.appendMarkdown(`**${s.name}**: ${s.state}${who ? ` \n${who}` : ''}\n\n`);
}
tooltip.appendMarkdown('Edits are kept on this machine first and sent when online.');
this.status.tooltip = tooltip;
this.status.show();
this.bridge?.changed();
}
// ------------------------------------------------- the model page's Live section
private versionsOf(space: LiveSpace, fresh = false): Promise<Version[]> {
const cached = this.versionCache.get(space.link.space);
if (!fresh && cached && Date.now() - cached.at < VERSIONS_TTL_MS) return cached.versions;
const versions = this.api(space.link.server).versions(space.link.space);
this.versionCache.set(space.link.space, { at: Date.now(), versions });
versions.catch(() => this.versionCache.delete(space.link.space));
return versions;
}
/** After a publish or a restore: the page shows the new version. */
private versionsChanged(space: LiveSpace): void {
this.versionCache.delete(space.link.space);
this.bridge?.changed(space.root);
}
async panel(root: vscode.Uri): Promise<LivePanel | undefined> {
const space = this.spaces.get(root.toString());
if (!space) return undefined;
const me = (space.awareness.getLocalState() as { user?: { id?: string } } | null)?.user?.id;
const panel: LivePanel = {
name: space.name,
host: new URL(space.link.server).host,
state: space.state,
people: space.people().map((p) => ({ name: p.user.name, file: p.file, me: p.user.id === me })),
};
if (space.state === 'signed-out' || space.state === 'forbidden') return panel;
try {
const versions = await this.versionsOf(space);
const label = new Map(versions.map((v) => [v.id, versionLabel(v)]));
panel.versions = versions.map((v) => ({
id: v.id,
label: versionLabel(v),
kind: v.kind,
name: v.name ?? undefined,
at: v.created_at,
restoredFrom: v.restored_from !== null ? label.get(v.restored_from) : undefined,
}));
} catch (error) {
panel.versionsProblem = space.state === 'online' ? `Versions could not be loaded: ${error instanceof Error ? error.message : String(error)}` : 'Versions show when the server can be reached.';
}
return panel;
}
/** Cursors drawn at most every 50 ms, however many people move. */
private cursorsMoved(): void {
if (this.cursorTimer) return;
this.cursorTimer = setTimeout(() => {
this.cursorTimer = undefined;
this.bridge?.cursorsChanged();
this.editorCursors.render();
}, 50);
}
private spaceOf(file: vscode.Uri): LiveSpace | undefined {
for (const space of this.spaces.values()) {
const root = space.root.path.endsWith('/') ? space.root.path : `${space.root.path}/`;
if (file.scheme === space.root.scheme && file.path.startsWith(root)) return space;
}
return undefined;
}
cursors(file: vscode.Uri): LiveCursor[] {
return this.spaceOf(file)?.cursors(file) ?? [];
}
setCursor(file: vscode.Uri, selection: LiveSelection | undefined): void {
this.setCursorIn(file, selection);
}
private setCursorIn(file: vscode.Uri, selection: LiveSelection | undefined): boolean {
return this.spaceOf(file)?.setCursor(file, selection) ?? false;
}
async act(root: vscode.Uri, action: LiveAction): Promise<void> {
const space = this.spaces.get(root.toString());
if (!space) return;
try {
switch (action.kind) {
case 'publish':
await this.publish(space);
break;
case 'invite':
await this.invite(space);
break;
case 'signIn':
if (await this.checkServer(space.link.server)) await this.auth.signIn(space.link.server);
break;
case 'versions':
await showVersions(space, this.api(space.link.server), this.content);
this.versionsChanged(space);
break;
case 'compare':
case 'restore': {
const version = (await this.versionsOf(space)).find((v) => v.id === action.version);
if (!version) throw new Error('That version is no longer listed.');
if (action.kind === 'compare') await compareVersion(space, version, this.content);
else if (await restoreVersion(space, this.api(space.link.server), version)) this.versionsChanged(space);
break;
}
}
} catch (error) {
this.report(error);
}
}
// ------------------------------------------------------------- commands
async run(command: (typeof SYNC_COMMANDS)[number], ...args: unknown[]): Promise<void> {
try {
switch (command) {
case 'archify.sync.signIn':
await this.signInCommand();
break;
case 'archify.sync.signOut':
await this.signOut();
break;
case 'archify.sync.share':
await this.share(args[0]);
break;
case 'archify.sync.invite':
await this.invite(args[0]);
break;
case 'archify.sync.join':
await this.join(typeof args[0] === 'string' ? args[0] : undefined);
break;
case 'archify.sync.publish':
await this.publish(args[0]);
break;
case 'archify.sync.versions': {
const space = await this.pickSpace(args[0]);
if (space) {
await showVersions(space, this.api(space.link.server), this.content);
this.versionsChanged(space);
}
break;
}
case 'archify.sync.stop':
await this.stop(args[0]);
break;
case 'archify.sync.menu':
await this.menu();
break;
}
} catch (error) {
this.report(error);
}
}
private report(error: unknown): void {
const message = error instanceof Error ? error.message : String(error);
if (/cancelled/i.test(message)) return;
this.log.error(`sync: ${message}`);
void vscode.window.showErrorMessage(`Archify Sync: ${message}`);
}
/** The invite and sign-in links: `…/auth?code=&state=`, `…/join?server=&invite=`. */
async handleUri(uri: vscode.Uri): Promise<void> {
if (uri.path === '/auth') this.auth.handleAuthUri(uri);
else if (uri.path === '/join') await this.run('archify.sync.join', uri.toString(true));
}
private async askServer(): Promise<string | undefined> {
const last = this.context.globalState.get<string>('archifySync.lastServer');
const value = await vscode.window.showInputBox({
title: 'Archify | Sync server',
prompt: 'The address of the Archify Sync server',
placeHolder: 'https://archify.example.com',
value: last,
ignoreFocusOut: true,
validateInput: (v) => checkServerUrl(v).problem,
});
return value === undefined ? undefined : checkServerUrl(value).url;
}
/** Checks the server answers and speaks this protocol; offers its setup page when it is new. */
private async checkServer(server: string): Promise<boolean> {
const info = await new SyncApi(server).info();
if (info.protocol !== PROTOCOL_VERSION) {
throw new Error(`${info.name} speaks protocol ${info.protocol}; this extension speaks ${PROTOCOL_VERSION}. Update ${info.protocol > PROTOCOL_VERSION ? 'the extension' : 'the server'}.`);
}
if (!info.set_up) {
const open = await vscode.window.showInformationMessage(
`${info.name} is new: its first admin sets it up with the first-start code from the server's log.`,
'Open setup page',
);
if (open) await vscode.env.openExternal(vscode.Uri.parse(`${server}/setup`));
return false;
}
await this.context.globalState.update('archifySync.lastServer', server);
return true;
}
private async signInCommand(): Promise<void> {
const server = await this.askServer();
if (!server || !(await this.checkServer(server))) return;
const session = await this.auth.signIn(server);
vscode.window.showInformationMessage(`Archify: signed in to ${new URL(server).host} as ${session.account.display_name}.`);
}
private async signOut(): Promise<void> {
const servers = await this.auth.servers();
if (!servers.length) {
vscode.window.showInformationMessage('Archify: not signed in to a sync server.');
return;
}
const server = servers.length === 1 ? servers[0] : await vscode.window.showQuickPick(servers, { title: 'Archify | Sign out of' });
const session = server ? await this.auth.session(server) : undefined;
if (session) await this.auth.removeSession(session.id);
}
/** A server with a session: the only one, a picked one, or a new sign-in. */
private async serverWithSession(): Promise<string | undefined> {
const servers = await this.auth.servers();
if (servers.length === 1) return servers[0];
if (servers.length > 1) {
const pick = await vscode.window.showQuickPick([...servers, '$(add) Another server…'], { title: 'Archify | Share to which server?' });
if (pick && !pick.startsWith('$(add)')) return pick;
if (!pick) return undefined;
}
const server = await this.askServer();
if (!server || !(await this.checkServer(server))) return undefined;
await this.auth.signIn(server);
return server;
}
/** The model root a command applies to: the argument, the active file, or a pick. */
private async modelRoot(arg: unknown): Promise<vscode.Uri | undefined> {
const uri = arg instanceof vscode.Uri ? arg : vscode.window.activeTextEditor?.document.uri ?? activeTabUri();
const root = uri ? await this.models.rootOf(uri) : undefined;
if (root) return root;
const mains = await vscode.workspace.findFiles(`**/${MAIN_FILE}`, SKIP, 50);
if (mains.length === 1) return vscode.Uri.joinPath(mains[0], '..');
const pick = await vscode.window.showQuickPick(
mains.map((m) => ({ label: vscode.workspace.asRelativePath(vscode.Uri.joinPath(m, '..')), root: vscode.Uri.joinPath(m, '..') })),
{ title: 'Archify | Which model?' },
);
return pick?.root;
}
private async pickSpace(arg: unknown): Promise<LiveSpace | undefined> {
if (arg instanceof LiveSpace) return arg;
const spaces = [...this.spaces.values()];
if (!spaces.length) {
vscode.window.showInformationMessage('Archify: no model in this workspace is shared. Share one with "Archify: Share Model…".');
return undefined;
}
const uri = arg instanceof vscode.Uri ? arg : vscode.window.activeTextEditor?.document.uri ?? activeTabUri();
const root = uri ? await this.models.rootOf(uri) : undefined;
const here = root && this.spaces.get(root.toString());
if (here) return here;
if (spaces.length === 1) return spaces[0];
const pick = await vscode.window.showQuickPick(spaces.map((s) => ({ label: s.name, description: new URL(s.link.server).host, space: s })), {
title: 'Archify | Which shared model?',
});
return pick?.space;
}
private async share(arg: unknown): Promise<void> {
const root = await this.modelRoot(arg);
if (!root) return;
if (this.spaces.has(root.toString())) {
await this.invite(root);
return;
}
const server = await this.serverWithSession();
if (!server) return;
const index = await this.models.index(root).catch(() => undefined);
const name = await vscode.window.showInputBox({
title: 'Archify | Share Model',
prompt: `Name of the shared model on ${new URL(server).host}`,
value: index?.meta.name ?? root.path.split('/').pop(),
validateInput: (v) => (v.trim() ? undefined : 'Give it a name.'),
});
if (!name) return;
const space = await this.api(server).createSpace(name.trim());
const linkUri = vscode.Uri.joinPath(root, LINK_FILE);
await vscode.workspace.fs.writeFile(linkUri, new TextEncoder().encode(formatLink({ server, space: space.id, name: space.name })));
await this.open(linkUri, 'share');
const next = await vscode.window.showInformationMessage(
`Archify: ${space.name} is shared. Edits now go to everyone you invite.`,
'Copy invite link',
);
if (next) await this.invite(root);
}
private async invite(arg: unknown): Promise<void> {
const space = await this.pickSpace(arg);
if (!space) return;
const invite = await this.api(space.link.server).invite(space.link.space);
await vscode.env.clipboard.writeText(invite.url);
vscode.window.showInformationMessage(
`Archify: invite link for ${space.name} copied. It works until ${new Date(invite.expires_at).toLocaleDateString()}; send it through any channel.`,
);
}
private async join(raw?: string): Promise<void> {
const text =
raw ??
(await vscode.window.showInputBox({
title: 'Archify | Join Shared Model',
prompt: 'Paste the invite link',
ignoreFocusOut: true,
validateInput: (v) => (parseInviteLink(v) ? undefined : 'This is not an Archify invite link.'),
}));
if (!text) return;
const parsed = parseInviteLink(text);
const server = parsed && checkServerUrl(parsed.server).url;
if (!parsed || !server) throw new Error('This is not an Archify invite link.');
if (!(await this.checkServer(server))) return;
const folder = await vscode.window.showOpenDialog({
title: 'Archify | Folder for the shared model',
openLabel: 'Put the model here',
canSelectFiles: false,
canSelectFolders: true,
defaultUri: vscode.workspace.workspaceFolders?.[0]?.uri,
});
const root = folder?.[0];
if (!root) return;
const existing = await vscode.workspace.findFiles(new vscode.RelativePattern(root, '**/*.{archify,archify-registry,archify-decision}'), SKIP, 1);
if (existing.length) {
throw new Error('That folder already holds Archify files. Pick an empty folder for the shared model.');
}
if (!(await this.auth.session(server))) await this.auth.signIn(server, parsed.invite);
const joined = await this.api(server).acceptInvite(parsed.invite);
const linkUri = vscode.Uri.joinPath(root, LINK_FILE);
await vscode.workspace.fs.writeFile(linkUri, new TextEncoder().encode(formatLink({ server, space: joined.space.id, name: joined.space.name })));
const inWorkspace = vscode.workspace.getWorkspaceFolder(root);
if (inWorkspace) {
await this.open(linkUri, 'join');
vscode.window.showInformationMessage(`Archify: you joined ${joined.space.name}. Its files are arriving in ${vscode.workspace.asRelativePath(root)}.`);
return;
}
const open = await vscode.window.showInformationMessage(
`Archify: you joined ${joined.space.name}. Open its folder to start working together.`,
'Open folder',
'Open in new window',
);
if (open) await vscode.commands.executeCommand('vscode.openFolder', root, { forceNewWindow: open === 'Open in new window' });
}
private async publish(arg: unknown): Promise<void> {
const space = await this.pickSpace(arg);
if (!space) return;
const name = await vscode.window.showInputBox({
title: `Archify | Publish ${space.name}`,
prompt: 'Name this version, for example "Q4 baseline"',
validateInput: (v) => (v.trim() ? undefined : 'Give the version a name.'),
});
if (!name) return;
const version = await this.api(space.link.server).publish(space.link.space, name.trim());
this.versionsChanged(space);
vscode.window.showInformationMessage(`Archify: published ${version.major}.${version.minor} "${version.name}" of ${space.name}.`);
}
private async stop(arg: unknown): Promise<void> {
const space = await this.pickSpace(arg);
if (!space) return;
const ok = await vscode.window.showWarningMessage(
`Stop syncing ${space.name} here?`,
{ modal: true, detail: `The files stay as they are. ${LINK_FILE} is removed, and edits here no longer go to the others. The model stays on the server.` },
'Stop syncing',
);
if (ok !== 'Stop syncing') return;
this.close(space.root, true);
await vscode.workspace.fs.delete(vscode.Uri.joinPath(space.root, LINK_FILE)).then(undefined, () => undefined);
}
private async menu(): Promise<void> {
const spaces = [...this.spaces.values()];
const signedOut = spaces.find((s) => s.state === 'signed-out');
const items: (vscode.QuickPickItem & { run: () => Promise<void> })[] = [];
if (signedOut) {
items.push({
label: `$(account) Sign in to ${new URL(signedOut.link.server).host}`,
run: async () => {
if (await this.checkServer(signedOut.link.server)) await this.auth.signIn(signedOut.link.server);
},
});
}
items.push(
{ label: '$(person-add) Copy invite link…', run: () => this.invite(undefined) },
{ label: '$(tag) Publish version…', run: () => this.publish(undefined) },
{ label: '$(history) Versions…', run: () => this.run('archify.sync.versions') },
{ label: '$(output) Show log', run: async () => this.log.show() },
{ label: '$(debug-disconnect) Stop syncing a model…', run: () => this.stop(undefined) },
);
const pick = await vscode.window.showQuickPick(items, { title: 'Archify Sync' });
await pick?.run();
}
dispose(): void {
clearTimeout(this.cursorTimer);
this.bridge?.setProvider(undefined);
for (const space of this.spaces.values()) space.dispose();
this.spaces.clear();
for (const d of this.disposables) d.dispose();
}
}
/** The file behind the active tab when it is a custom editor (the Archify pages). */
function activeTabUri(): vscode.Uri | undefined {
const input = vscode.window.tabGroups.activeTabGroup.activeTab?.input;
return input instanceof vscode.TabInputCustom || input instanceof vscode.TabInputText ? input.uri : undefined;
}
export { SyncApiError };