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

+102
View File
@@ -0,0 +1,102 @@
import {
ApiError,
Invite,
JoinResponse,
Member,
PROTOCOL_VERSION,
ServerInfo,
Space,
TokenResponse,
Version,
VersionFiles,
} from './protocol';
export class SyncApiError extends Error {
constructor(readonly status: number, readonly code: string, message: string) {
super(message);
}
}
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
/**
* The server address as the extension keeps it: http(s), no trailing slash.
* Plain http is refused except on this machine; tokens travel on it.
*/
export function checkServerUrl(raw: string): { url?: string; problem?: string } {
let url: URL;
try {
url = new URL(raw.trim());
} catch {
return { problem: 'Enter the server address, for example https://archify.example.com' };
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') return { problem: 'The address starts with https://' };
if (url.protocol === 'http:' && !LOOPBACK.has(url.hostname)) return { problem: 'Use https://. Plain http:// is only for a server on this machine.' };
return { url: `${url.origin}${url.pathname.replace(/\/+$/, '')}` };
}
export function syncSocketUrl(server: string, space: string): string {
return `${server.replace(/^http/, 'ws')}/v1/spaces/${space}/sync?v=${PROTOCOL_VERSION}`;
}
/** Reads `server` and `invite` from an invite link: `https://…/join/<token>` or the extension's `…/join?server=&invite=`. */
export function parseInviteLink(raw: string): { server: string; invite: string } | undefined {
try {
const url = new URL(raw.trim());
const server = url.searchParams.get('server');
const invite = url.searchParams.get('invite');
if (server && invite) return { server: server.replace(/\/+$/, ''), invite };
const match = /^(.*)\/join\/([A-Za-z0-9_-]{20,})\/?$/.exec(url.pathname);
if (match && (url.protocol === 'https:' || url.protocol === 'http:')) return { server: `${url.origin}${match[1]}`, invite: match[2] };
} catch {
// not a URL
}
return undefined;
}
/** The server's HTTP API. `token` gives a fresh access token for calls that need one. */
export class SyncApi {
constructor(readonly server: string, private readonly token?: (force?: boolean) => Promise<string | undefined>) {}
private async request<T>(method: string, path: string, body?: unknown, signedIn = true): Promise<T> {
const send = async (force: boolean) => {
const headers: Record<string, string> = { accept: 'application/json' };
if (body !== undefined) headers['content-type'] = 'application/json';
if (signedIn) {
const token = await this.token?.(force);
if (!token) throw new SyncApiError(401, 'unauthorized', 'Sign in to the sync server first.');
headers.authorization = `Bearer ${token}`;
}
return fetch(`${this.server}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
};
let response = await send(false);
// The access token may have expired between the check and the call.
if (response.status === 401 && signedIn) response = await send(true);
const text = await response.text();
if (!response.ok) {
let error: Partial<ApiError> = {};
try {
error = JSON.parse(text) as ApiError;
} catch {
// not JSON
}
throw new SyncApiError(response.status, error.error ?? 'http', error.message ?? `The server answered ${response.status}.`);
}
return (text ? JSON.parse(text) : undefined) as T;
}
info = () => this.request<ServerInfo>('GET', '/v1/info', undefined, false);
exchangeCode = (code: string, codeVerifier: string, redirectUri: string) =>
this.request<TokenResponse>('POST', '/v1/auth/token', { grant_type: 'authorization_code', code, code_verifier: codeVerifier, redirect_uri: redirectUri }, false);
refresh = (refreshToken: string) => this.request<TokenResponse>('POST', '/v1/auth/token', { grant_type: 'refresh_token', refresh_token: refreshToken }, false);
logout = (refreshToken: string) => this.request<void>('POST', '/v1/auth/logout', { refresh_token: refreshToken }, false);
spaces = () => this.request<Space[]>('GET', '/v1/spaces');
createSpace = (name: string) => this.request<Space>('POST', '/v1/spaces', { name });
members = (space: string) => this.request<Member[]>('GET', `/v1/spaces/${space}/members`);
invite = (space: string) => this.request<Invite>('POST', `/v1/spaces/${space}/invites`, {});
acceptInvite = (token: string) => this.request<JoinResponse>('POST', `/v1/invites/${encodeURIComponent(token)}/accept`);
versions = (space: string) => this.request<Version[]>('GET', `/v1/spaces/${space}/versions`);
publish = (space: string, name: string) => this.request<Version>('POST', `/v1/spaces/${space}/versions`, { name });
versionFiles = (space: string, version: number) => this.request<VersionFiles>('GET', `/v1/spaces/${space}/versions/${version}/files`);
restore = (space: string, version: number) => this.request<Version>('POST', `/v1/spaces/${space}/versions/${version}/restore`);
}
+206
View File
@@ -0,0 +1,206 @@
import * as crypto from 'node:crypto';
import * as vscode from 'vscode';
import { SyncApi, SyncApiError } from './api';
import { Account, TokenResponse } from './protocol';
export const AUTH_PROVIDER_ID = 'archify-sync';
const SECRET_KEY = 'archifySync.sessions';
const SIGN_IN_TIMEOUT_MS = 5 * 60_000;
interface StoredSession {
id: string;
server: string;
accessToken: string;
/** Epoch ms. */
expiresAt: number;
refreshToken: string;
account: Account;
}
interface PendingSignIn {
resolve: (code: string) => void;
reject: (error: Error) => void;
}
/**
* "Archify Sync" in the Accounts menu: one session per server. Sign-in runs
* in the browser and comes back through the extension's URI handler with a
* one-time code, exchanged with a PKCE verifier. Tokens live in SecretStorage.
* Refresh tokens are single use, so refreshes are serialised per server and
* read the latest stored session first (another window may have refreshed).
*/
export class SyncAuth implements vscode.AuthenticationProvider, vscode.Disposable {
private readonly changed = new vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();
readonly onDidChangeSessions = this.changed.event;
private readonly pending = new Map<string, PendingSignIn>();
private readonly refreshing = new Map<string, Promise<string | undefined>>();
private readonly disposables: vscode.Disposable[];
constructor(private readonly context: vscode.ExtensionContext, private readonly log: vscode.LogOutputChannel) {
this.disposables = [
vscode.authentication.registerAuthenticationProvider(AUTH_PROVIDER_ID, 'Archify Sync', this, { supportsMultipleAccounts: true }),
// Another window signed in or out.
context.secrets.onDidChange((e) => {
if (e.key === SECRET_KEY) this.changed.fire({ added: [], removed: [], changed: [] });
}),
];
}
private async load(): Promise<StoredSession[]> {
try {
return JSON.parse((await this.context.secrets.get(SECRET_KEY)) ?? '[]') as StoredSession[];
} catch {
return [];
}
}
private async save(sessions: StoredSession[]): Promise<void> {
await this.context.secrets.store(SECRET_KEY, JSON.stringify(sessions));
}
private toSession(s: StoredSession): vscode.AuthenticationSession {
return {
id: s.id,
accessToken: s.accessToken,
account: { id: s.account.id, label: `${s.account.display_name} (${new URL(s.server).host})` },
scopes: [s.server],
};
}
async getSessions(scopes?: readonly string[]): Promise<vscode.AuthenticationSession[]> {
const server = scopes?.[0];
return (await this.load()).filter((s) => !server || s.server === server).map((s) => this.toSession(s));
}
async createSession(scopes: readonly string[]): Promise<vscode.AuthenticationSession> {
const server = scopes[0];
if (!server) throw new Error('Archify Sync: say which server to sign in to.');
return this.toSession(await this.signIn(server));
}
async removeSession(id: string): Promise<void> {
const sessions = await this.load();
const gone = sessions.find((s) => s.id === id);
if (!gone) return;
await this.save(sessions.filter((s) => s.id !== id));
new SyncApi(gone.server).logout(gone.refreshToken).catch(() => undefined);
this.changed.fire({ added: [], removed: [this.toSession(gone)], changed: [] });
}
async session(server: string): Promise<StoredSession | undefined> {
return (await this.load()).find((s) => s.server === server);
}
async servers(): Promise<string[]> {
return [...new Set((await this.load()).map((s) => s.server))];
}
/** Signs in through the browser; with an invite, the page also offers to make an account. */
async signIn(server: string, invite?: string): Promise<StoredSession> {
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
const state = crypto.randomBytes(16).toString('base64url');
const callback = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://${this.context.extension.id}/auth`));
const redirectUri = callback.toString(true);
const query = new URLSearchParams({
response_type: 'code',
redirect_uri: redirectUri,
state,
code_challenge: challenge,
code_challenge_method: 'S256',
});
if (invite) query.set('invite', invite);
const code = await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: `Archify: signing in to ${new URL(server).host} in the browser…`, cancellable: true },
(_progress, cancel) =>
new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('The sign-in took too long. Try again.')), SIGN_IN_TIMEOUT_MS);
const done = () => {
clearTimeout(timer);
this.pending.delete(state);
};
this.pending.set(state, {
resolve: (c) => (done(), resolve(c)),
reject: (e) => (done(), reject(e)),
});
cancel.onCancellationRequested(() => this.pending.get(state)?.reject(new Error('Sign-in cancelled.')));
void vscode.env.openExternal(vscode.Uri.parse(`${server}/authorize?${query}`, true)).then((opened) => {
if (!opened) this.pending.get(state)?.reject(new Error('The browser did not open.'));
});
}),
);
const tokens = await new SyncApi(server).exchangeCode(code, verifier, redirectUri);
const session = await this.store(server, tokens);
await this.context.globalState.update('archifySync.used', true);
this.log.info(`sync: signed in to ${server} as ${tokens.account.name}`);
return session;
}
/** The browser came back: `…/auth?code=&state=`. */
handleAuthUri(uri: vscode.Uri): void {
const query = new URLSearchParams(uri.query);
const state = query.get('state') ?? '';
const code = query.get('code');
const pending = this.pending.get(state);
if (!pending) {
this.log.warn('sync: a sign-in came back that this window did not start');
return;
}
if (code) pending.resolve(code);
else pending.reject(new Error(query.get('error_description') ?? 'The sign-in was not completed.'));
}
private async store(server: string, tokens: TokenResponse, id?: string): Promise<StoredSession> {
const session: StoredSession = {
id: id ?? crypto.randomUUID(),
server,
accessToken: tokens.access_token,
expiresAt: Date.now() + tokens.expires_in * 1000,
refreshToken: tokens.refresh_token,
account: tokens.account,
};
const others = (await this.load()).filter((s) => s.server !== server);
await this.save([...others, session]);
const event = id ? { added: [], removed: [], changed: [this.toSession(session)] } : { added: [this.toSession(session)], removed: [], changed: [] };
this.changed.fire(event);
return session;
}
/** A valid access token for the server, refreshed when close to expiry; undefined without a session. */
accessToken(server: string, force = false): Promise<string | undefined> {
const running = this.refreshing.get(server);
if (running) return running;
const work = this.freshToken(server, force).finally(() => this.refreshing.delete(server));
this.refreshing.set(server, work);
return work;
}
private async freshToken(server: string, force: boolean): Promise<string | undefined> {
const session = await this.session(server);
if (!session) return undefined;
if (!force && session.expiresAt - 60_000 > Date.now()) return session.accessToken;
try {
const tokens = await new SyncApi(server).refresh(session.refreshToken);
return (await this.store(server, tokens, session.id)).accessToken;
} catch (error) {
if (error instanceof SyncApiError && error.status === 401) {
// Expired, or signed out elsewhere: the person signs in again.
const latest = await this.session(server);
if (latest && latest.refreshToken !== session.refreshToken) return latest.accessToken;
await this.removeSession(session.id);
this.log.warn(`sync: the session for ${server} ended; sign in again`);
return undefined;
}
// Offline: keep the old token; the connection waits and tries again.
this.log.info(`sync: could not refresh the token for ${server} (${String(error)})`);
return session.accessToken;
}
}
dispose(): void {
for (const pending of this.pending.values()) pending.reject(new Error('Closed.'));
for (const d of this.disposables) d.dispose();
this.changed.dispose();
}
}
+197
View File
@@ -0,0 +1,197 @@
import WebSocket from 'ws';
import * as Y from 'yjs';
import * as awarenessProtocol from 'y-protocols/awareness';
import * as syncProtocol from 'y-protocols/sync';
import * as decoding from 'lib0/decoding';
import * as encoding from 'lib0/encoding';
const MESSAGE_SYNC = 0;
const MESSAGE_AWARENESS = 1;
const MESSAGE_QUERY_AWARENESS = 3;
export type ConnectionState =
| 'connecting'
| 'online'
| 'offline'
/** No session for the server, or the server refused it. */
| 'signed-out'
/** The server no longer lets this account in (removed from the space). */
| 'forbidden'
/** The server speaks another protocol version. */
| 'outdated';
export interface ConnectionOptions {
url: string;
/** A fresh access token; `force` after the server refused the last one. */
token: (force: boolean) => Promise<string | undefined>;
doc: Y.Doc;
awareness: awarenessProtocol.Awareness;
onState: (state: ConnectionState) => void;
/** After the first full exchange of each connection. */
onSynced: () => void;
log: (message: string) => void;
}
/**
* One space's WebSocket to the server: the y-websocket protocol (sync step 1
* and 2, updates, awareness), reconnecting with backoff. Edits made while
* offline go out on reconnect through the state-vector exchange.
*/
export class LiveConnection {
state: ConnectionState = 'connecting';
synced = false;
private socket?: WebSocket;
private attempt = 0;
private timer?: NodeJS.Timeout;
private closed = false;
private forceToken = false;
constructor(private readonly options: ConnectionOptions) {
options.doc.on('update', this.onDocUpdate);
options.awareness.on('update', this.onAwarenessUpdate);
void this.connect();
}
/** Connects at once, e.g. after signing in. */
reconnectNow(): void {
if (this.closed || this.state === 'online' || this.state === 'connecting') return;
clearTimeout(this.timer);
this.attempt = 0;
void this.connect();
}
private setState(state: ConnectionState): void {
if (this.state === state) return;
this.state = state;
this.options.onState(state);
}
private async connect(): Promise<void> {
if (this.closed) return;
this.setState('connecting');
let token: string | undefined;
try {
token = await this.options.token(this.forceToken);
} catch (error) {
this.options.log(`sync: no token (${String(error)})`);
}
this.forceToken = false;
if (this.closed) return;
if (!token) {
this.setState('signed-out');
return;
}
const socket = new WebSocket(this.options.url, { headers: { authorization: `Bearer ${token}` }, handshakeTimeout: 10_000 });
socket.binaryType = 'arraybuffer';
this.socket = socket;
let refused = false;
socket.on('open', () => {
const step1 = encoding.createEncoder();
encoding.writeVarUint(step1, MESSAGE_SYNC);
syncProtocol.writeSyncStep1(step1, this.options.doc);
this.send(encoding.toUint8Array(step1));
if (this.options.awareness.getLocalState() !== null) this.sendAwareness([this.options.doc.clientID]);
});
socket.on('message', (data: ArrayBuffer) => this.receive(new Uint8Array(data)));
socket.on('unexpected-response', (_request, response) => {
refused = true;
const status = response.statusCode ?? 0;
this.options.log(`sync: the server refused the connection (${status})`);
if (status === 401) {
// Try once more with a refreshed token; without a session, stop.
this.forceToken = true;
this.setState('signed-out');
this.schedule(true);
} else if (status === 403 || status === 404) {
this.setState('forbidden');
} else if (status === 426) {
this.setState('outdated');
} else {
this.setState('offline');
this.schedule();
}
socket.terminate();
});
socket.on('error', (error) => this.options.log(`sync: ${error.message}`));
socket.on('close', () => {
if (this.socket !== socket) return;
this.socket = undefined;
this.synced = false;
const others = [...this.options.awareness.getStates().keys()].filter((id) => id !== this.options.doc.clientID);
awarenessProtocol.removeAwarenessStates(this.options.awareness, others, this);
if (this.closed || refused) return;
this.setState('offline');
this.schedule();
});
}
/** Backoff: 0.5 s doubling to 30 s, with jitter. */
private schedule(once = false): void {
if (this.closed) return;
clearTimeout(this.timer);
if (once && this.attempt > 0) return;
const delay = Math.min(30_000, 500 * 2 ** this.attempt) * (0.75 + Math.random() / 2);
this.attempt++;
this.timer = setTimeout(() => void this.connect(), delay);
}
private receive(bytes: Uint8Array): void {
const decoder = decoding.createDecoder(bytes);
const type = decoding.readVarUint(decoder);
if (type === MESSAGE_SYNC) {
const reply = encoding.createEncoder();
encoding.writeVarUint(reply, MESSAGE_SYNC);
const sub = syncProtocol.readSyncMessage(decoder, reply, this.options.doc, this);
if (encoding.length(reply) > 1) this.send(encoding.toUint8Array(reply));
if (sub === syncProtocol.messageYjsSyncStep2 && !this.synced) {
this.synced = true;
this.attempt = 0;
this.setState('online');
this.options.onSynced();
}
} else if (type === MESSAGE_AWARENESS) {
awarenessProtocol.applyAwarenessUpdate(this.options.awareness, decoding.readVarUint8Array(decoder), this);
} else if (type === MESSAGE_QUERY_AWARENESS) {
this.sendAwareness([...this.options.awareness.getStates().keys()]);
}
}
private send(bytes: Uint8Array): void {
if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(bytes);
}
private sendAwareness(clients: number[]): void {
const message = encoding.createEncoder();
encoding.writeVarUint(message, MESSAGE_AWARENESS);
encoding.writeVarUint8Array(message, awarenessProtocol.encodeAwarenessUpdate(this.options.awareness, clients));
this.send(encoding.toUint8Array(message));
}
private readonly onDocUpdate = (update: Uint8Array, origin: unknown) => {
if (origin === this) return;
const message = encoding.createEncoder();
encoding.writeVarUint(message, MESSAGE_SYNC);
syncProtocol.writeUpdate(message, update);
this.send(encoding.toUint8Array(message));
};
private readonly onAwarenessUpdate = (
{ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
origin: unknown,
) => {
if (origin === this) return;
this.sendAwareness([...added, ...updated, ...removed]);
};
dispose(): void {
this.closed = true;
clearTimeout(this.timer);
// Tell the others this editor left before closing.
awarenessProtocol.removeAwarenessStates(this.options.awareness, [this.options.doc.clientID], 'dispose');
this.options.doc.off('update', this.onDocUpdate);
this.options.awareness.off('update', this.onAwarenessUpdate);
this.socket?.close();
this.socket = undefined;
}
}
+140
View File
@@ -0,0 +1,140 @@
import * as vscode from 'vscode';
import { normalizeEol, offsetOf, positionOf } from '../util/textDiff';
import type { LiveCursor, LiveSelection } from './liveBridge';
/** One colour per person, the same in every window. */
const COLORS = ['#e5534b', '#57ab5a', '#539bf5', '#c69026', '#b083f0', '#39c5cf', '#e0823d', '#d16d9e'];
export function colorFor(id: string): string {
let hash = 0;
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
return COLORS[hash % COLORS.length];
}
/** What the text-editor side needs from the shared models. */
export interface CursorSource {
cursors(file: vscode.Uri): LiveCursor[];
setCursor(file: vscode.Uri, selection: LiveSelection | undefined): boolean;
}
interface Styles {
caret: vscode.TextEditorDecorationType;
selection: vscode.TextEditorDecorationType;
}
/**
* Cursors in VS Code's text editors: yours go to the others as you move, and
* theirs are drawn as a coloured caret with their name and a tinted selection.
*/
export class EditorCursors implements vscode.Disposable {
private readonly styles = new Map<string, Styles>();
private readonly disposables: vscode.Disposable[] = [];
private timer: ReturnType<typeof setTimeout> | undefined;
private pending: vscode.TextEditor | undefined;
constructor(private readonly source: CursorSource) {
this.disposables.push(
vscode.window.onDidChangeTextEditorSelection((event) => this.moved(event.textEditor)),
vscode.window.onDidChangeActiveTextEditor((editor) => editor && this.moved(editor)),
vscode.window.onDidChangeVisibleTextEditors(() => this.render()),
vscode.workspace.onDidChangeTextDocument((event) => {
if (vscode.window.visibleTextEditors.some((e) => e.document === event.document)) this.render();
}),
);
}
/** Sent at most every 80 ms; the last position wins. */
private moved(editor: vscode.TextEditor): void {
if (editor.document.uri.scheme !== 'file') return;
this.pending = editor;
if (this.timer) return;
this.timer = setTimeout(() => {
this.timer = undefined;
const e = this.pending;
this.pending = undefined;
if (!e || e.document.isClosed) return;
const text = normalizeEol(e.document.getText());
const { anchor, active } = e.selection;
this.source.setCursor(e.document.uri, {
anchor: offsetOf(text, anchor.line, anchor.character),
head: offsetOf(text, active.line, active.character),
});
}, 80);
}
private stylesFor(color: string): Styles {
let found = this.styles.get(color);
if (!found) {
found = {
caret: vscode.window.createTextEditorDecorationType({
rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed,
// A zero-width caret and the name above it; the CSS rides in textDecoration,
// the usual way to place a decoration without moving the text.
before: {
contentText: '',
textDecoration: `none; position: absolute; display: inline-block; height: 1.25em; border-left: 2px solid ${color}; margin-left: -1px; z-index: 1;`,
},
}),
selection: vscode.window.createTextEditorDecorationType({
backgroundColor: `${color}38`,
rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed,
}),
};
this.styles.set(color, found);
}
return found;
}
/** Draws everyone's cursors in the visible editors of shared files. */
render(): void {
for (const editor of vscode.window.visibleTextEditors) {
if (editor.document.uri.scheme !== 'file') continue;
const cursors = this.source.cursors(editor.document.uri);
const text = normalizeEol(editor.document.getText());
const at = (offset: number) => {
const { line, character } = positionOf(text, Math.max(0, Math.min(offset, text.length)));
return new vscode.Position(line, character);
};
const byColor = new Map<string, { carets: vscode.DecorationOptions[]; selections: vscode.Range[] }>();
for (const c of cursors) {
let group = byColor.get(c.color);
if (!group) byColor.set(c.color, (group = { carets: [], selections: [] }));
const head = at(c.head);
group.carets.push({
range: new vscode.Range(head, head),
hoverMessage: new vscode.MarkdownString(`**${c.name.replace(/[\\`*_[\]<>]/g, '')}** is here`),
renderOptions: {
after: {
contentText: c.name,
color: '#fff',
backgroundColor: c.color,
fontWeight: '500',
textDecoration: 'none; position: absolute; transform: translateY(-1.15em); font-size: 0.75em; padding: 0 4px; border-radius: 3px; white-space: nowrap; pointer-events: none; z-index: 2; opacity: 0.9;',
},
},
});
if (c.anchor !== c.head) {
const [from, to] = c.anchor < c.head ? [c.anchor, c.head] : [c.head, c.anchor];
group.selections.push(new vscode.Range(at(from), at(to)));
}
}
for (const color of byColor.keys()) this.stylesFor(color);
// Every colour is set, so a person who left or moved away disappears.
for (const [color, styles] of this.styles) {
const group = byColor.get(color);
editor.setDecorations(styles.caret, group?.carets ?? []);
editor.setDecorations(styles.selection, group?.selections ?? []);
}
}
}
dispose(): void {
clearTimeout(this.timer);
for (const styles of this.styles.values()) {
styles.caret.dispose();
styles.selection.dispose();
}
this.styles.clear();
for (const d of this.disposables.splice(0)) d.dispose();
}
}
+230
View File
@@ -0,0 +1,230 @@
import * as vscode from 'vscode';
import * as Y from 'yjs';
import { normalizeEol, positionOf } from '../util/textDiff';
import { FILES_MAP } from './protocol';
import { applyToYText, safeChange } from './textSync';
const MODEL_GLOB = '**/*.{archify,archify-registry,archify-decision}';
const SKIP_GLOB = '{**/node_modules/**,**/.git/**}';
const MODEL_FILE = /\.(archify|archify-registry|archify-decision)$/i;
/** A model-root-relative path the bridge accepts: POSIX, no `..`, a model file. */
export function isSafeModelPath(path: string): boolean {
if (!MODEL_FILE.test(path) || path.startsWith('/') || path.includes('\\')) return false;
return path.split('/').every((part) => part !== '' && part !== '.' && part !== '..' && part !== '.git' && part !== 'node_modules');
}
/**
* Keeps a model's files and the space's Y.Text per file the same.
*
* Local edits (any editor, the Source view, a drag on a diagram, git, an
* agent) become minimal Y.Text edits. Remote edits go back as WorkspaceEdits
* for open documents, so undo, dirty state and the pages stay right, and as
* file writes for closed ones. Each side compares texts before writing, so
* an edit coming back from the other side ends as a no-op instead of an echo.
*/
export class FileBridge implements vscode.Disposable {
readonly files: Y.Map<Y.Text>;
private readonly disposables: vscode.Disposable[] = [];
private queue: Promise<void> = Promise.resolve();
private listening = false;
/**
* Text the bridge last wrote per file. Its own write comes back as a change
* event, possibly after the document moved on; pushing it then would undo
* the newer edit, so it is skipped once.
*/
private readonly written = new Map<string, string>();
constructor(
private readonly root: vscode.Uri,
private readonly doc: Y.Doc,
/** Transaction origin of edits made here. */
private readonly origin: object,
private readonly log: vscode.LogOutputChannel,
) {
this.files = doc.getMap<Y.Text>(FILES_MAP);
}
/** The model-relative path of a file in this model, else undefined. */
relative(uri: vscode.Uri): string | undefined {
if (uri.scheme !== this.root.scheme) return undefined;
const base = this.root.path.endsWith('/') ? this.root.path : `${this.root.path}/`;
if (!uri.path.startsWith(base)) return undefined;
const rel = uri.path.slice(base.length);
return isSafeModelPath(rel) ? rel : undefined;
}
uriOf(rel: string): vscode.Uri {
return vscode.Uri.joinPath(this.root, ...rel.split('/'));
}
/** Remote edits start going to disk; with `local`, local edits start going out. */
start(local: boolean): void {
if (!this.disposables.length) {
const observer = (events: Y.YEvent<Y.AbstractType<unknown>>[], txn: Y.Transaction) => {
if (txn.origin === this.origin) return;
const paths = new Set<string>();
for (const event of events) {
if (event.target === this.files) for (const key of event.changes.keys.keys()) paths.add(key);
else if (typeof event.path[0] === 'string') paths.add(event.path[0]);
}
for (const path of paths) this.enqueue(() => this.writeOut(path));
};
this.files.observeDeep(observer);
this.disposables.push({ dispose: () => this.files.unobserveDeep(observer) });
}
if (local && !this.listening) {
this.listening = true;
const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(this.root, MODEL_GLOB));
this.disposables.push(
vscode.workspace.onDidChangeTextDocument((e) => {
const rel = this.relative(e.document.uri);
if (rel && e.contentChanges.length) this.pushLocal(rel, normalizeEol(e.document.getText()));
}),
watcher,
watcher.onDidCreate((uri) => this.enqueue(() => this.pushFromDisk(uri))),
watcher.onDidChange((uri) => this.enqueue(() => this.pushFromDisk(uri))),
watcher.onDidDelete((uri) => {
const rel = this.relative(uri);
if (rel && this.files.has(rel)) this.enqueue(async () => {
// Gone for good, not an editor's save-by-rename.
if (!(await exists(uri))) this.doc.transact(() => this.files.delete(rel), this.origin);
});
}),
);
}
}
private pushLocal(rel: string, text: string): void {
if (this.written.get(rel) === text) {
this.written.delete(rel);
return;
}
this.push(rel, text);
}
/** One file's text into the document. */
push(rel: string, text: string): void {
const existing = this.files.get(rel);
if (existing instanceof Y.Text) {
if (existing.toString() !== text) this.doc.transact(() => applyToYText(existing, text), this.origin);
return;
}
this.doc.transact(() => {
const created = new Y.Text();
created.insert(0, text);
this.files.set(rel, created);
}, this.origin);
}
private async pushFromDisk(uri: vscode.Uri): Promise<void> {
const rel = this.relative(uri);
// Open documents report through their change events.
if (!rel || vscode.workspace.textDocuments.some((d) => d.uri.toString() === uri.toString())) return;
const text = await readDisk(uri);
if (text !== undefined) this.pushLocal(rel, normalizeEol(text));
}
/** The model files as they are now: open editors' text, else the disk. */
async scan(): Promise<Map<string, string>> {
const found = await vscode.workspace.findFiles(new vscode.RelativePattern(this.root, MODEL_GLOB), SKIP_GLOB);
const out = new Map<string, string>();
for (const uri of found) {
const rel = this.relative(uri);
if (!rel) continue;
const open = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString());
const text = open ? open.getText() : await readDisk(uri);
if (text !== undefined) out.set(rel, normalizeEol(text));
}
return out;
}
/**
* Brings changes made while the extension was not running into the
* document (git pull, an edit in another tool). With `deleteMissing`,
* files in the document but gone from disk are removed.
*/
async pushAll(deleteMissing: boolean): Promise<void> {
const local = await this.scan();
for (const [rel, text] of local) this.push(rel, text);
if (deleteMissing) {
const gone = [...this.files.keys()].filter((rel) => !local.has(rel));
if (gone.length) this.doc.transact(() => gone.forEach((rel) => this.files.delete(rel)), this.origin);
}
}
/** Writes every file of the document to disk; then sends local files the document lacks. */
async pullAll(): Promise<void> {
for (const rel of this.files.keys()) await this.enqueue(() => this.writeOut(rel));
const local = await this.scan();
for (const [rel, text] of local) if (!this.files.has(rel)) this.push(rel, text);
}
private enqueue(work: () => Promise<void>): Promise<void> {
this.queue = this.queue.then(work).catch((error) => this.log.error(`sync: ${error instanceof Error ? error.message : String(error)}`));
return this.queue;
}
/** The document's text of one file onto disk or into its open editor. */
private async writeOut(rel: string): Promise<void> {
if (!isSafeModelPath(rel)) {
this.log.warn(`sync: ignored a file outside the model: ${rel}`);
return;
}
const uri = this.uriOf(rel);
const value = this.files.get(rel);
if (!(value instanceof Y.Text)) {
if (await exists(uri)) await vscode.workspace.fs.delete(uri, { useTrash: true });
return;
}
const text = value.toString();
const open = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString());
if (open) {
const current = normalizeEol(open.getText());
const change = safeChange(current, text);
if (!change) return;
const wasDirty = open.isDirty;
const from = positionOf(current, change.from);
const to = positionOf(current, change.to);
this.written.set(rel, text);
const edit = new vscode.WorkspaceEdit();
edit.replace(uri, new vscode.Range(from.line, from.character, to.line, to.character), change.insert);
if (!(await vscode.workspace.applyEdit(edit))) {
this.log.warn(`sync: could not apply a teammate's edit to ${rel}`);
return;
}
// Teammates' edits land on disk, where git sees them; the person's own unsaved work stays unsaved.
if (!wasDirty) await open.save();
return;
}
const disk = await readDisk(uri);
const eol = disk?.includes('\r\n') ? '\r\n' : '\n';
const out = eol === '\n' ? text : text.replace(/\n/g, '\r\n');
if (disk === out) return;
this.written.set(rel, text);
await vscode.workspace.fs.createDirectory(vscode.Uri.joinPath(uri, '..'));
await vscode.workspace.fs.writeFile(uri, new TextEncoder().encode(out));
}
dispose(): void {
for (const d of this.disposables.splice(0)) d.dispose();
this.listening = false;
}
}
async function exists(uri: vscode.Uri): Promise<boolean> {
try {
await vscode.workspace.fs.stat(uri);
return true;
} catch {
return false;
}
}
async function readDisk(uri: vscode.Uri): Promise<string | undefined> {
try {
return new TextDecoder('utf-8').decode(await vscode.workspace.fs.readFile(uri));
} catch {
return undefined;
}
}
+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 };
+110
View File
@@ -0,0 +1,110 @@
import * as vscode from 'vscode';
// What the model page shows of a shared model. Lives in the main bundle; the
// sync bundle plugs in as the provider once it loads. Without it, the model
// page has no Live section and nothing else changes.
export interface LivePerson {
name: string;
/** Model-relative path of the file the person has open. */
file?: string;
me?: boolean;
}
export interface LiveVersion {
id: number;
label: string;
kind: 'snapshot' | 'publish' | 'restore';
name?: string;
/** ISO time. */
at: string;
restoredFrom?: string;
}
export interface LivePanel {
name: string;
host: string;
state: 'connecting' | 'online' | 'offline' | 'signed-out' | 'forbidden' | 'outdated';
people: LivePerson[];
versions?: LiveVersion[];
versionsProblem?: string;
}
/** Someone else's cursor in a file, as offsets into its "\n" text. */
export interface LiveCursor {
name: string;
color: string;
anchor: number;
head: number;
}
/** A selection as offsets into a file's "\n" text. */
export interface LiveSelection {
anchor: number;
head: number;
}
export type LiveAction =
| { kind: 'publish' }
| { kind: 'invite' }
| { kind: 'signIn' }
| { kind: 'versions' }
| { kind: 'compare'; version: number }
| { kind: 'restore'; version: number };
export interface LiveProvider {
panel(root: vscode.Uri): Promise<LivePanel | undefined>;
act(root: vscode.Uri, action: LiveAction): Promise<void>;
/** Others' cursors in a shared file; empty when the file is not shared. */
cursors(file: vscode.Uri): LiveCursor[];
/** This person's cursor in a page's Source view; undefined when it closes. */
setCursor(file: vscode.Uri, selection: LiveSelection | undefined): void;
}
class LiveBridge implements vscode.Disposable {
private provider: LiveProvider | undefined;
private readonly emitter = new vscode.EventEmitter<vscode.Uri | undefined>();
/** A model root whose Live section changed; undefined for all of them. */
readonly onDidChange = this.emitter.event;
private readonly cursorEmitter = new vscode.EventEmitter<void>();
/** Someone's cursor moved somewhere; ask `cursors` for the file you show. */
readonly onDidChangeCursors = this.cursorEmitter.event;
setProvider(provider: LiveProvider | undefined): void {
this.provider = provider;
this.emitter.fire(undefined);
this.cursorEmitter.fire();
}
changed(root?: vscode.Uri): void {
this.emitter.fire(root);
}
async panel(root: vscode.Uri): Promise<LivePanel | undefined> {
return this.provider?.panel(root);
}
async act(root: vscode.Uri, action: LiveAction): Promise<void> {
await this.provider?.act(root, action);
}
cursorsChanged(): void {
this.cursorEmitter.fire();
}
cursors(file: vscode.Uri): LiveCursor[] {
return this.provider?.cursors(file) ?? [];
}
setCursor(file: vscode.Uri, selection: LiveSelection | undefined): void {
this.provider?.setCursor(file, selection);
}
dispose(): void {
this.emitter.dispose();
this.cursorEmitter.dispose();
}
}
export const liveBridge = new LiveBridge();
export type { LiveBridge };
+231
View File
@@ -0,0 +1,231 @@
import * as path from 'node:path';
import * as vscode from 'vscode';
import * as Y from 'yjs';
import { Awareness } from 'y-protocols/awareness';
import { syncSocketUrl } from './api';
import { SyncAuth } from './auth';
import { ConnectionState, LiveConnection } from './connection';
import { FileBridge } from './fileBridge';
import { OfflineStore } from './offlineStore';
import { colorFor } from './cursors';
import type { LiveCursor, LiveSelection } from './liveBridge';
import { Link } from './protocol';
/** How a space starts: as found in the workspace, just shared from here, or just joined. */
export type StartMode = 'open' | 'share' | 'join';
/** Edits loaded from the offline copy; not stored again. */
const FROM_COPY = Symbol('offline copy');
const COMPACT_AFTER = 200;
export interface Presence {
user: { id: string; name: string };
/** Model-relative path of the file the person has in front of them. */
file?: string;
/** Where the person's cursor is: Yjs relative positions, so it stays put while others type. */
cursor?: { file: string; anchor: unknown; head: unknown };
}
/**
* One shared model in this window: the Yjs document, its offline copy, the
* connection to the server, the bridge to the files, and presence.
*/
export class LiveSpace implements vscode.Disposable {
readonly doc = new Y.Doc();
readonly awareness = new Awareness(this.doc);
private readonly store: OfflineStore;
private readonly local = { origin: 'local' };
private readonly bridge: FileBridge;
private connection?: LiveConnection;
private readonly disposables: vscode.Disposable[] = [];
private hadCopy = false;
private firstSync = true;
constructor(
readonly root: vscode.Uri,
readonly link: Link,
private readonly mode: StartMode,
private readonly services: {
context: vscode.ExtensionContext;
auth: SyncAuth;
log: vscode.LogOutputChannel;
onChange: () => void;
/** Someone's cursor moved. */
onCursors?: () => void;
},
) {
const dir = path.join(services.context.globalStorageUri.fsPath, 'sync');
this.store = new OfflineStore(dir, link.space);
this.bridge = new FileBridge(root, this.doc, this.local, services.log);
}
get state(): ConnectionState {
return this.connection?.state ?? 'connecting';
}
get name(): string {
return this.link.name ?? path.basename(this.root.fsPath);
}
/** Distinct people in the space, this one included. */
people(): Presence[] {
const seen = new Map<string, Presence>();
for (const state of this.awareness.getStates().values()) {
const p = state as Partial<Presence>;
if (p.user?.id) seen.set(p.user.id, { user: p.user, file: p.file });
}
return [...seen.values()];
}
/** Puts this person's cursor in a shared file; false when the file is not part of this model. */
setCursor(uri: vscode.Uri, selection: LiveSelection | undefined): boolean {
const rel = this.bridge.relative(uri);
if (!rel) return false;
const current = this.awareness.getLocalState() as Presence | null;
if (!current) return true;
if (!selection) {
// Only the file the cursor is in can take it away.
if (current.cursor?.file === rel) this.awareness.setLocalStateField('cursor', undefined);
return true;
}
const text = this.doc.getMap<Y.Text>('files').get(rel);
if (!(text instanceof Y.Text)) return true;
const at = (offset: number) => Y.relativePositionToJSON(Y.createRelativePositionFromTypeIndex(text, Math.max(0, Math.min(offset, text.length))));
this.awareness.setLocalStateField('cursor', { file: rel, anchor: at(selection.anchor), head: at(selection.head) });
return true;
}
/** Other people's cursors in a file, as offsets into its text here. */
cursors(uri: vscode.Uri): LiveCursor[] {
const rel = this.bridge.relative(uri);
if (!rel) return [];
const found: LiveCursor[] = [];
for (const [client, state] of this.awareness.getStates()) {
if (client === this.doc.clientID) continue;
const p = state as Partial<Presence>;
if (!p.user?.id || p.cursor?.file !== rel) continue;
const index = (json: unknown) => {
try {
return Y.createAbsolutePositionFromRelativePosition(Y.createRelativePositionFromJSON(json), this.doc)?.index;
} catch {
return undefined;
}
};
const anchor = index(p.cursor.anchor);
const head = index(p.cursor.head);
if (anchor === undefined || head === undefined) continue;
found.push({ name: p.user.name, color: colorFor(p.user.id), anchor, head });
}
return found;
}
async start(): Promise<void> {
const { log } = this.services;
this.hadCopy = this.store.exists();
for (const update of this.store.load()) Y.applyUpdate(this.doc, update, FROM_COPY);
// Registered before the connection's listener, so every edit is on disk before it is sent.
this.doc.on('update', (update: Uint8Array, origin: unknown) => {
if (origin === FROM_COPY) return;
this.store.append(update);
if (this.store.pending >= COMPACT_AFTER) this.compact();
});
if (this.hadCopy) {
// Edits made while the extension was not running (git pull, another tool).
await this.bridge.pushAll(true);
} else if (this.mode === 'share') {
await this.bridge.pushAll(false);
}
// Without a copy, local edits wait for the server's files, so a file
// does not start twice (once here, once there) and lose one side.
this.bridge.start(this.hadCopy || this.mode === 'share');
const session = await this.services.auth.session(this.link.server);
if (session) this.awareness.setLocalState({ user: { id: session.account.id, name: session.account.display_name } } satisfies Presence);
this.disposables.push(
vscode.window.onDidChangeActiveTextEditor(() => this.updateFile()),
vscode.window.tabGroups.onDidChangeTabs(() => this.updateFile()),
{ dispose: () => this.awareness.destroy() },
);
this.updateFile();
let people = '';
this.awareness.on('change', () => {
// Cursors move all the time; the status bar and pages only care who is where.
const now = JSON.stringify(this.people());
if (now !== people) {
people = now;
this.services.onChange();
}
this.services.onCursors?.();
});
this.connection = new LiveConnection({
url: syncSocketUrl(this.link.server, this.link.space),
token: (force) => this.services.auth.accessToken(this.link.server, force),
doc: this.doc,
awareness: this.awareness,
onState: (state) => {
log.info(`sync: ${this.name} is ${state}`);
this.services.onChange();
},
onSynced: () => void this.synced(),
log: (message) => log.info(message),
});
}
private async synced(): Promise<void> {
if (!this.firstSync) return;
this.firstSync = false;
if (!this.hadCopy && this.mode !== 'share') {
// First time here: the server's files come to disk, then local-only files go up.
await this.bridge.pullAll();
this.bridge.start(true);
this.hadCopy = true;
}
this.compact();
}
private compact(): void {
try {
this.store.compact(Y.encodeStateAsUpdate(this.doc));
} catch (error) {
this.services.log.warn(`sync: could not compact the offline copy (${String(error)})`);
}
}
private updateFile(): void {
const current = this.awareness.getLocalState() as Presence | null;
if (!current) return;
// The text editor, or the tab of an Archify page (the model page, a diagram, a registry).
const input = vscode.window.tabGroups.activeTabGroup.activeTab?.input;
const tab = input instanceof vscode.TabInputCustom || input instanceof vscode.TabInputText ? input.uri : undefined;
const uri = vscode.window.activeTextEditor?.document.uri ?? tab;
const file = uri ? this.bridge.relative(uri) : undefined;
if (current.file !== file) this.awareness.setLocalState({ ...current, file });
}
/** After signing in again. */
reconnect(): void {
void this.services.auth.session(this.link.server).then((session) => {
if (session && !this.awareness.getLocalState()) {
this.awareness.setLocalState({ user: { id: session.account.id, name: session.account.display_name } } satisfies Presence);
}
this.connection?.reconnectNow();
});
}
/** Stops syncing this model here and forgets the offline copy. */
forget(): void {
this.dispose();
this.store.remove();
}
dispose(): void {
this.connection?.dispose();
this.connection = undefined;
this.bridge.dispose();
for (const d of this.disposables.splice(0)) d.dispose();
if (this.store.pending) this.compact();
this.doc.destroy();
}
}
+53
View File
@@ -0,0 +1,53 @@
import * as path from 'node:path';
import * as vscode from 'vscode';
import type { ModelService } from '../model/service';
import { log } from '../util/log';
import type { SyncFeature } from './index';
import { liveBridge } from './liveBridge';
// Kept in step with SYNC_COMMANDS in ./index (which lives in the other bundle).
const 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;
/**
* Archify Sync is optional. This registers its commands and the URI handler,
* and loads dist/sync.js (Yjs, the WebSocket) only when it is needed: a model
* in the workspace is shared, someone signed in on this machine, or a sync
* command or link runs. Without that, nothing else happens.
*/
export function registerSync(context: vscode.ExtensionContext, models: ModelService): vscode.Disposable[] {
let feature: Promise<SyncFeature> | undefined;
const load = (): Promise<SyncFeature> =>
(feature ??= (async () => {
// A runtime path, so esbuild leaves the sync bundle out of this one.
const bundle = path.join(__dirname, 'sync.js');
const mod = require(bundle) as typeof import('./index');
const sync = new mod.SyncFeature(context, models, log(), liveBridge);
context.subscriptions.push(sync);
await sync.start();
return sync;
})());
void (async () => {
const used = context.globalState.get<boolean>('archifySync.used') === true;
const shared = used || (await vscode.workspace.findFiles('**/.archify-sync', '{**/node_modules/**,**/.git/**}', 1)).length > 0;
if (shared) load().catch((error) => log().error(`sync: did not start: ${String(error)}`));
})();
return [
liveBridge,
...COMMANDS.map((id) =>
vscode.commands.registerCommand(id, async (...args: unknown[]) => (await load()).run(id, ...args)),
),
vscode.window.registerUriHandler({ handleUri: async (uri) => (await load()).handleUri(uri) }),
];
}
+68
View File
@@ -0,0 +1,68 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
/**
* The offline copy of one shared model: the last compacted Yjs state and a
* log of every update since, each written before it is sent. Lets people
* keep working while the server is away and catch up afterwards.
*/
export class OfflineStore {
private readonly statePath: string;
private readonly logPath: string;
private logged = 0;
constructor(dir: string, id: string) {
fs.mkdirSync(dir, { recursive: true });
this.statePath = path.join(dir, `${id}.state`);
this.logPath = path.join(dir, `${id}.log`);
}
exists(): boolean {
return fs.existsSync(this.statePath) || fs.existsSync(this.logPath);
}
/** Updates to apply in order: the state, then the log. A torn last entry is skipped. */
load(): Uint8Array[] {
const out: Uint8Array[] = [];
if (fs.existsSync(this.statePath)) out.push(new Uint8Array(fs.readFileSync(this.statePath)));
if (fs.existsSync(this.logPath)) {
const log = fs.readFileSync(this.logPath);
let at = 0;
while (at + 4 <= log.length) {
const length = log.readUInt32LE(at);
if (at + 4 + length > log.length) break;
out.push(new Uint8Array(log.subarray(at + 4, at + 4 + length)));
at += 4 + length;
this.logged++;
}
}
return out;
}
/** Appends one update; synchronous, so it is on disk before the caller sends it. */
append(update: Uint8Array): void {
const head = Buffer.alloc(4);
head.writeUInt32LE(update.length);
fs.appendFileSync(this.logPath, Buffer.concat([head, update]));
this.logged++;
}
/** Updates in the log since the last compaction. */
get pending(): number {
return this.logged;
}
/** Replaces state and log with one encoded state. */
compact(state: Uint8Array): void {
const tmp = `${this.statePath}.tmp`;
fs.writeFileSync(tmp, state);
fs.renameSync(tmp, this.statePath);
fs.rmSync(this.logPath, { force: true });
this.logged = 0;
}
remove(): void {
fs.rmSync(this.statePath, { force: true });
fs.rmSync(this.logPath, { force: true });
}
}
+60
View File
@@ -0,0 +1,60 @@
// What the extension and the Archify Sync server agree on. Mirrors
// `packages/protocol` (generated from Rust) in archify-vscode-ext-sync; keep
// both in step when the protocol changes.
export const PROTOCOL_VERSION = 1;
/** Root map of a space's Yjs document: model-root-relative POSIX path → Y.Text. */
export const FILES_MAP = 'files';
/** The link file in a model root: names server and space, holds no secrets. */
export const LINK_FILE = '.archify-sync';
export type OrgRole = 'admin' | 'member';
export type SpaceRole = 'owner' | 'editor';
export type VersionKind = 'snapshot' | 'publish' | 'restore';
export interface ApiError { error: string; message: string }
export interface Account { id: string; organisation_id: string; name: string; display_name: string; role: OrgRole }
export interface TokenResponse { access_token: string; expires_in: number; refresh_token: string; account: Account }
export interface ServerInfo { name: string; version: string; protocol: number; set_up: boolean }
export interface Space { id: string; name: string; role: SpaceRole; created_at: string }
export interface Member { account_id: string; name: string; display_name: string; role: SpaceRole }
export interface Invite { token: string; url: string; expires_at: string }
export interface JoinResponse { space: Space }
export interface Version {
id: number;
major: number;
minor: number;
kind: VersionKind;
name: string | null;
created_by: string | null;
created_at: string;
restored_from: number | null;
}
export interface VersionFiles { version: Version; files: { path: string; text: string }[] }
/** The contents of `.archify-sync`. */
export interface Link {
server: string;
space: string;
name?: string;
}
export function parseLink(text: string): Link | undefined {
try {
const value = JSON.parse(text) as Partial<Link>;
if (typeof value.server === 'string' && typeof value.space === 'string' && /^[0-9a-f-]{36}$/i.test(value.space)) {
return { server: value.server.replace(/\/+$/, ''), space: value.space, name: typeof value.name === 'string' ? value.name : undefined };
}
} catch {
// not a link
}
return undefined;
}
export function formatLink(link: Link): string {
return `${JSON.stringify({ server: link.server, space: link.space, name: link.name }, null, 2)}\n`;
}
export function versionLabel(v: Version): string {
return `${v.major}.${v.minor}`;
}
+33
View File
@@ -0,0 +1,33 @@
import * as Y from 'yjs';
import { TextChange, minimalChange } from '../util/textDiff';
const isHigh = (code: number) => code >= 0xd800 && code <= 0xdbff;
const isLow = (code: number) => code >= 0xdc00 && code <= 0xdfff;
/**
* `minimalChange`, widened so it never splits a surrogate pair: Yjs would
* replace half a pair with U+FFFD, and every copy would get the broken text.
*/
export function safeChange(before: string, after: string): TextChange | undefined {
const change = minimalChange(before, after);
if (!change) return undefined;
let { from, to } = change;
let endAfter = after.length - (before.length - to);
// A common start ending in a high surrogate, or a common end starting with
// a low one, would split a pair: take the whole character into the change.
if (from > 0 && isHigh(before.charCodeAt(from - 1))) from--;
if (to < before.length && isLow(before.charCodeAt(to))) {
to++;
endAfter++;
}
return { from, to, insert: after.slice(from, endAfter) };
}
/** Makes the Y.Text read `after` with one minimal edit; false when it already does. */
export function applyToYText(text: Y.Text, after: string): boolean {
const change = safeChange(text.toString(), after);
if (!change) return false;
if (change.to > change.from) text.delete(change.from, change.to - change.from);
if (change.insert) text.insert(change.from, change.insert);
return true;
}
+100
View File
@@ -0,0 +1,100 @@
import * as vscode from 'vscode';
import { SyncApi } from './api';
import { LiveSpace } from './liveSpace';
import { Version, VersionFiles, versionLabel } from './protocol';
export const VERSION_SCHEME = 'archify-sync-version';
/** Read-only documents of a version's files: `archify-sync-version:/<space>/<version>/<path>`. */
export class VersionContent implements vscode.TextDocumentContentProvider {
private readonly cache = new Map<string, Promise<VersionFiles>>();
constructor(private readonly api: (space: string) => SyncApi | undefined) {}
files(space: string, version: number): Promise<VersionFiles> {
const key = `${space}/${version}`;
let found = this.cache.get(key);
if (!found) {
const api = this.api(space);
if (!api) return Promise.reject(new Error('This model is not shared from here.'));
found = api.versionFiles(space, version);
found.catch(() => this.cache.delete(key));
this.cache.set(key, found);
}
return found;
}
async provideTextDocumentContent(uri: vscode.Uri): Promise<string> {
const [, space, version, ...rest] = uri.path.split('/');
const files = await this.files(space, Number(version));
return files.files.find((f) => f.path === rest.join('/'))?.text ?? '';
}
}
function describe(v: Version): vscode.QuickPickItem & { version: Version } {
const when = new Date(v.created_at).toLocaleString();
const kind = v.kind === 'publish' ? 'published' : v.kind === 'restore' ? `restored ${v.restored_from ?? ''}`.trim() : 'snapshot';
return {
label: `${v.kind === 'publish' ? '$(tag)' : v.kind === 'restore' ? '$(history)' : '$(circle-small)'} ${versionLabel(v)}${v.name ? ` ${v.name}` : ''}`,
description: kind,
detail: when,
version: v,
};
}
/** Versions of a space: pick one, then compare with the files here or restore it. */
export async function showVersions(space: LiveSpace, api: SyncApi, content: VersionContent): Promise<void> {
const versions = await api.versions(space.link.space);
if (!versions.length) {
vscode.window.showInformationMessage(`Archify: ${space.name} has no versions yet. One is taken on its own after a pause in editing.`);
return;
}
const picked = await vscode.window.showQuickPick(versions.map(describe), { title: `Archify | Versions of ${space.name}`, placeHolder: 'Pick a version' });
if (!picked) return;
const v = picked.version;
const action = await vscode.window.showQuickPick(
[
{ label: '$(diff) Compare with the files here', id: 'compare' },
{ label: '$(history) Restore this version', id: 'restore', detail: 'Brings these files back for everyone, as a new version. Nothing is lost.' },
],
{ title: `Archify | ${versionLabel(v)}${v.name ? ` ${v.name}` : ''}` },
);
if (action?.id === 'compare') await compareVersion(space, v, content);
if (action?.id === 'restore') await restoreVersion(space, api, v);
}
/** Asks, then brings back a version's files for everyone as a new version. */
export async function restoreVersion(space: LiveSpace, api: SyncApi, v: Version): Promise<boolean> {
const ok = await vscode.window.showWarningMessage(
`Restore ${versionLabel(v)} of ${space.name} for everyone?`,
{ modal: true, detail: 'The files go back to this version for everyone in the space. It becomes a new version; the history stays.' },
'Restore',
);
if (ok !== 'Restore') return false;
const restored = await api.restore(space.link.space, v.id);
vscode.window.showInformationMessage(`Archify: restored ${versionLabel(v)} as ${versionLabel(restored)}.`);
return true;
}
/** Lists the files that differ from a version and opens the diff of the one picked. */
export async function compareVersion(space: LiveSpace, v: Version, content: VersionContent): Promise<void> {
const files = await content.files(space.link.space, v.id);
const here = new Map<string, string>();
for (const [rel, text] of space.doc.getMap<{ toString(): string }>('files').entries()) here.set(rel, text.toString());
const paths = [...new Set([...files.files.map((f) => f.path), ...here.keys()])].sort();
const changed = paths
.map((p) => ({ path: p, then: files.files.find((f) => f.path === p)?.text, now: here.get(p) }))
.filter((f) => f.then !== f.now);
if (!changed.length) {
vscode.window.showInformationMessage(`Archify: the files here are the same as ${versionLabel(v)}.`);
return;
}
const pick = await vscode.window.showQuickPick(
changed.map((f) => ({ label: f.path, description: f.then === undefined ? 'added since' : f.now === undefined ? 'removed since' : 'changed', path: f.path })),
{ title: `Archify | Changes since ${versionLabel(v)}`, placeHolder: `${changed.length} file(s) differ` },
);
if (!pick) return;
const left = vscode.Uri.from({ scheme: VERSION_SCHEME, path: `/${space.link.space}/${v.id}/${pick.path}` });
const right = vscode.Uri.joinPath(space.root, ...pick.path.split('/'));
await vscode.commands.executeCommand('vscode.diff', left, right, `${pick.path} (${versionLabel(v)} ↔ here)`);
}