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:
1 parent
a9021312b0
commit
c99f3e5fe8
27 files changed
+2899
-14
No files matched your search
@@ -15,6 +15,7 @@ import { DiagramViewManager } from './views/diagramView';
|
||||
import { RegistryViewProvider } from './views/registryView';
|
||||
import { DecisionViewProvider } from './views/decisionView';
|
||||
import { DECISION_SELECTOR, DecisionLanguage } from './language/decisionLanguage';
|
||||
import { registerSync } from './sync/loader';
|
||||
|
||||
export function activate(context: vscode.ExtensionContext): void {
|
||||
const models = new ModelService();
|
||||
@@ -47,6 +48,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
...newDiagramCommands(ctx),
|
||||
...renderCommands(ctx),
|
||||
...modelCommands(ctx),
|
||||
...registerSync(context, models),
|
||||
);
|
||||
registerContextKeys(context, models);
|
||||
}
|
||||
|
||||
+102
@@ -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`);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) }),
|
||||
];
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)`);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { viewerTheme } from '../util/settings';
|
||||
import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
|
||||
import { graphPage, makeNonce, messagePage, modelPage, prepareDiagramHtml } from '../webview/page';
|
||||
import { graphScriptUri, sourceScriptUri, viewer3dOption, viewerRoots } from '../webview/viewer3d';
|
||||
import { liveBridge } from '../sync/liveBridge';
|
||||
import { SourceSession } from './sourceSession';
|
||||
|
||||
import { DECISION_VIEW_TYPE, DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
|
||||
@@ -55,6 +56,8 @@ class DiagramPreview implements vscode.Disposable {
|
||||
readonly source: SourceSession | undefined;
|
||||
private heldHtml: string | undefined;
|
||||
private disposed = false;
|
||||
/** main.archify: the model page has its Live slot and takes `live` messages. */
|
||||
private liveShown = false;
|
||||
|
||||
constructor(
|
||||
private readonly context: vscode.ExtensionContext,
|
||||
@@ -80,6 +83,7 @@ class DiagramPreview implements vscode.Disposable {
|
||||
if (!shown && this.heldHtml !== undefined) {
|
||||
const html = this.heldHtml;
|
||||
this.heldHtml = undefined;
|
||||
this.liveShown = false;
|
||||
this.panel.webview.html = html;
|
||||
}
|
||||
});
|
||||
@@ -115,10 +119,25 @@ class DiagramPreview implements vscode.Disposable {
|
||||
this.setGraph(false);
|
||||
return;
|
||||
}
|
||||
if (message.type === 'liveReady') {
|
||||
this.liveShown = true;
|
||||
void this.postLive();
|
||||
return;
|
||||
}
|
||||
if (message.type === 'liveAction') {
|
||||
if (this.root) void liveBridge.act(this.root, message.action);
|
||||
return;
|
||||
}
|
||||
void handleWebviewMessage(message, this.uri, this.panel);
|
||||
}),
|
||||
);
|
||||
|
||||
if (isMainFile(uri.path)) {
|
||||
this.disposables.push(liveBridge.onDidChange((root) => {
|
||||
if (!root || (this.root && root.toString() === this.root.toString())) void this.postLive();
|
||||
}));
|
||||
}
|
||||
|
||||
if (uri.scheme === 'file') {
|
||||
const watcher = vscode.workspace.createFileSystemWatcher(
|
||||
new vscode.RelativePattern(vscode.Uri.file(path.dirname(uri.fsPath)), path.basename(uri.fsPath)),
|
||||
@@ -134,6 +153,13 @@ class DiagramPreview implements vscode.Disposable {
|
||||
void this.update();
|
||||
}
|
||||
|
||||
/** The Live section of a shared model's page; nothing when the model is not shared. */
|
||||
private async postLive(): Promise<void> {
|
||||
if (!this.liveShown || !this.root || this.disposed) return;
|
||||
const live = await liveBridge.panel(this.root).catch(() => undefined);
|
||||
if (this.liveShown) this.post({ type: 'live', live });
|
||||
}
|
||||
|
||||
/** Posts to the webview; a panel closed mid-update is not an error. */
|
||||
private post(message: unknown): void {
|
||||
Promise.resolve(this.panel.webview.postMessage(message)).catch(() => undefined);
|
||||
@@ -147,6 +173,7 @@ class DiagramPreview implements vscode.Disposable {
|
||||
return;
|
||||
}
|
||||
this.heldHtml = undefined;
|
||||
this.liveShown = false;
|
||||
this.panel.webview.html = html;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { EditorSchemas } from '../language/editorSchemas';
|
||||
import { readSchemas } from '../language/schemaCheck';
|
||||
import { liveBridge } from '../sync/liveBridge';
|
||||
import { log } from '../util/log';
|
||||
import { TextChange, normalizeEol, offsetOf, positionOf } from '../util/textDiff';
|
||||
|
||||
@@ -9,7 +10,8 @@ export type SourceMessage =
|
||||
| { type: 'sourceReady' }
|
||||
| { type: 'sourceOpen' }
|
||||
| { type: 'sourceClose' }
|
||||
| { type: 'sourceEdit'; base: string; text: string; changes: TextChange[] };
|
||||
| { type: 'sourceEdit'; base: string; text: string; changes: TextChange[] }
|
||||
| { type: 'sourceSelection'; anchor: number; head: number };
|
||||
|
||||
export type SourceKind = 'diagram' | 'registry' | 'decision';
|
||||
|
||||
@@ -27,7 +29,13 @@ function schemasFor(context: vscode.ExtensionContext): EditorSchemas {
|
||||
}
|
||||
|
||||
export function isSourceMessage(message: { type?: string }): message is SourceMessage {
|
||||
return message.type === 'sourceReady' || message.type === 'sourceOpen' || message.type === 'sourceClose' || message.type === 'sourceEdit';
|
||||
return (
|
||||
message.type === 'sourceReady' ||
|
||||
message.type === 'sourceOpen' ||
|
||||
message.type === 'sourceClose' ||
|
||||
message.type === 'sourceEdit' ||
|
||||
message.type === 'sourceSelection'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,6 +76,8 @@ export class SourceSession implements vscode.Disposable {
|
||||
vscode.workspace.onDidSaveTextDocument((doc) => {
|
||||
if (same(doc.uri)) this.post({ type: 'sourceDirty', dirty: false });
|
||||
}),
|
||||
// Teammates' cursors, when the model is shared (Archify Sync); nothing otherwise.
|
||||
liveBridge.onDidChangeCursors(() => this.postCursors()),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -94,10 +104,14 @@ export class SourceSession implements vscode.Disposable {
|
||||
break;
|
||||
case 'sourceClose':
|
||||
this.setShown(false);
|
||||
liveBridge.setCursor(this.uri, undefined);
|
||||
break;
|
||||
case 'sourceEdit':
|
||||
this.edit(message);
|
||||
break;
|
||||
case 'sourceSelection':
|
||||
liveBridge.setCursor(this.uri, { anchor: message.anchor, head: message.head });
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -108,6 +122,17 @@ export class SourceSession implements vscode.Disposable {
|
||||
this.onShown(shown);
|
||||
}
|
||||
|
||||
private lastCursors = '[]';
|
||||
|
||||
private postCursors(): void {
|
||||
if (!this.shown) return;
|
||||
const cursors = liveBridge.cursors(this.uri);
|
||||
const key = JSON.stringify(cursors);
|
||||
if (key === this.lastCursors) return;
|
||||
this.lastCursors = key;
|
||||
this.post({ type: 'sourceCursors', cursors });
|
||||
}
|
||||
|
||||
private post(message: unknown): void {
|
||||
Promise.resolve(this.panel.webview.postMessage(message)).catch(() => undefined);
|
||||
}
|
||||
@@ -123,6 +148,8 @@ export class SourceSession implements vscode.Disposable {
|
||||
this.schemaKey = undefined;
|
||||
this.post({ type: 'sourceText', text, schema: this.schemaChange(text), checks: CHECKS[this.kind] });
|
||||
this.post({ type: 'sourceDirty', dirty: doc.isDirty });
|
||||
this.lastCursors = '';
|
||||
this.postCursors();
|
||||
if (withDiagnostics) await this.postDiagnostics();
|
||||
}
|
||||
|
||||
@@ -196,6 +223,7 @@ export class SourceSession implements vscode.Disposable {
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.shown) liveBridge.setCursor(this.uri, undefined);
|
||||
for (const d of this.disposables.splice(0)) d.dispose();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { repoRootFor } from '../archify/runtime';
|
||||
import { log } from '../util/log';
|
||||
import type { LiveAction } from '../sync/liveBridge';
|
||||
|
||||
export type WebviewMessage =
|
||||
| { type: 'hash'; hash: string }
|
||||
@@ -14,6 +15,8 @@ export type WebviewMessage =
|
||||
| { type: 'openDecision'; file: string }
|
||||
| { type: 'showGraph'; query?: string }
|
||||
| { type: 'showModel' }
|
||||
| { type: 'liveReady' }
|
||||
| { type: 'liveAction'; action: LiveAction }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
/** Column for opening files next to a webview without covering it. */
|
||||
@@ -100,6 +103,8 @@ export async function handleWebviewMessage(
|
||||
case 'openDiagram':
|
||||
case 'showGraph':
|
||||
case 'showModel':
|
||||
case 'liveReady':
|
||||
case 'liveAction':
|
||||
// Handled by the view, which knows the model root.
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -618,6 +618,11 @@ function bridgeScript(options: PageOptions): string {
|
||||
if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(head, anchor); else document.body.appendChild(head);
|
||||
|
||||
var body = el('section', 'archify-model archify-model-body');
|
||||
// Live: filled by the host when the model is shared (Archify Sync); hidden otherwise.
|
||||
var live = el('div', 'archify-live');
|
||||
live.id = 'archify-live';
|
||||
live.hidden = true;
|
||||
body.appendChild(live);
|
||||
var count = model.groups.reduce(function (n, g) { return n + g.diagrams.length; }, 0);
|
||||
var diagramsPanel = panel('diagrams', 'archify-model-diagrams', 'Diagrams', count, true);
|
||||
body.appendChild(diagramsPanel);
|
||||
@@ -635,6 +640,7 @@ function bridgeScript(options: PageOptions): string {
|
||||
var button = el('button', 'archify-model-open');
|
||||
button.type = 'button';
|
||||
button.title = diagram.path;
|
||||
button.setAttribute('data-path', diagram.path);
|
||||
button.appendChild(el('span', 'archify-model-item-title', diagram.title || diagram.path.split('/').pop()));
|
||||
button.appendChild(el('span', 'archify-model-item-meta', diagram.parseError ? 'does not parse: ' + diagram.parseError : (diagram.kind || 'diagram') + ' · ' + diagram.path));
|
||||
button.addEventListener('click', function () { post({ type: 'openDiagram', path: diagram.path }); });
|
||||
@@ -677,6 +683,99 @@ function bridgeScript(options: PageOptions): string {
|
||||
decisionsPanel.appendChild(decisionList);
|
||||
}
|
||||
if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(body, anchor.nextSibling); else document.body.appendChild(body);
|
||||
post({ type: 'liveReady' });
|
||||
}
|
||||
|
||||
var LIVE_STATE = {
|
||||
online: 'Live',
|
||||
connecting: 'Connecting\u2026',
|
||||
offline: 'Offline \u00b7 edits are kept here and sent when the server is back',
|
||||
'signed-out': 'Signed out \u00b7 edits are kept here until you sign in',
|
||||
forbidden: 'No access \u00b7 you are no longer a member of this model',
|
||||
outdated: 'The server needs a newer extension',
|
||||
};
|
||||
var KIND_LABEL = { snapshot: 'snapshot', publish: 'published', restore: 'restored' };
|
||||
|
||||
function liveButton(label, title, action) {
|
||||
var button = el('button', 'archify-model-used-link archify-live-action', label);
|
||||
button.type = 'button';
|
||||
button.title = title;
|
||||
button.addEventListener('click', function (event) { event.preventDefault(); event.stopPropagation(); post({ type: 'liveAction', action: action }); });
|
||||
return button;
|
||||
}
|
||||
|
||||
function when(iso) {
|
||||
var date = new Date(iso);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
var seconds = (Date.now() - date.getTime()) / 1000;
|
||||
if (seconds < 60) return 'just now';
|
||||
if (seconds < 3600) return Math.floor(seconds / 60) + ' min ago';
|
||||
if (seconds < 86400) return Math.floor(seconds / 3600) + ' h ago';
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
// The Live section of a shared model: who is here, the state, and versions.
|
||||
function renderLive(info) {
|
||||
var holder = document.getElementById('archify-live');
|
||||
if (!holder) return;
|
||||
holder.textContent = '';
|
||||
document.querySelectorAll('.archify-live-here').forEach(function (n) { n.remove(); });
|
||||
if (!info) { holder.hidden = true; return; }
|
||||
holder.hidden = false;
|
||||
var others = info.people.filter(function (p) { return !p.me; });
|
||||
var livePanel = panel('live', 'archify-model-live is-' + info.state, 'Shared \u00b7 ' + info.name, others.length ? others.length + 1 + ' here' : info.host, true);
|
||||
var status = el('div', 'archify-live-status');
|
||||
status.appendChild(el('span', 'archify-live-dot'));
|
||||
status.appendChild(el('span', 'archify-live-state', (LIVE_STATE[info.state] || info.state) + (info.state === 'online' ? ' on ' + info.host : '')));
|
||||
if (info.state === 'signed-out') status.appendChild(liveButton('Sign in', 'Sign in to ' + info.host, { kind: 'signIn' }));
|
||||
var actions = el('span', 'archify-live-actions');
|
||||
actions.appendChild(liveButton('Publish version', 'Name the current state as a version', { kind: 'publish' }));
|
||||
actions.appendChild(liveButton('Copy invite link', 'Copy a link that lets someone join this model', { kind: 'invite' }));
|
||||
status.appendChild(actions);
|
||||
livePanel.appendChild(status);
|
||||
|
||||
var people = el('ul', 'archify-live-people');
|
||||
info.people.forEach(function (person) {
|
||||
var item = el('li', 'archify-live-person' + (person.me ? ' is-me' : ''));
|
||||
item.appendChild(el('strong', '', person.me ? person.name + ' (you)' : person.name));
|
||||
if (person.file) item.appendChild(el('small', '', person.file));
|
||||
people.appendChild(item);
|
||||
});
|
||||
livePanel.appendChild(people);
|
||||
|
||||
// Who is in which diagram, on the diagram rows.
|
||||
others.forEach(function (person) {
|
||||
if (!person.file) return;
|
||||
document.querySelectorAll('.archify-model-open[data-path]').forEach(function (button) {
|
||||
if (button.getAttribute('data-path') !== person.file) return;
|
||||
var meta = button.querySelector('.archify-live-here');
|
||||
if (!meta) { meta = el('span', 'archify-live-here', ''); button.appendChild(meta); }
|
||||
meta.textContent = (meta.textContent ? meta.textContent.replace(/ here$/, '') + ', ' : '') + person.name + ' here';
|
||||
});
|
||||
});
|
||||
|
||||
var versions = info.versions || [];
|
||||
var head = el('div', 'archify-live-versions-head');
|
||||
head.appendChild(el('span', 'archify-model-summary-title', 'Versions'));
|
||||
if (versions.length > 8) head.appendChild(liveButton('All ' + versions.length + '\u2026', 'Pick from every version', { kind: 'versions' }));
|
||||
livePanel.appendChild(head);
|
||||
if (info.versionsProblem) livePanel.appendChild(el('p', 'archify-model-empty archify-live-note', info.versionsProblem));
|
||||
else if (!versions.length) livePanel.appendChild(el('p', 'archify-model-empty archify-live-note', 'No versions yet. One is taken on its own after a pause in editing.'));
|
||||
var list = el('ul', 'archify-live-versions');
|
||||
versions.slice(0, 8).forEach(function (v) {
|
||||
var item = el('li', 'archify-live-version is-' + v.kind);
|
||||
item.appendChild(el('strong', 'archify-live-version-label', v.label));
|
||||
item.appendChild(el('span', 'archify-decision-status archify-live-kind', KIND_LABEL[v.kind] + (v.restoredFrom ? ' ' + v.restoredFrom : '')));
|
||||
if (v.name) item.appendChild(el('span', 'archify-live-version-name', v.name));
|
||||
item.appendChild(el('small', 'archify-live-version-when', when(v.at)));
|
||||
var buttons = el('span', 'archify-live-actions');
|
||||
buttons.appendChild(liveButton('Compare', 'Compare ' + v.label + ' with the files here', { kind: 'compare', version: v.id }));
|
||||
buttons.appendChild(liveButton('Restore', 'Bring back ' + v.label + ' for everyone, as a new version', { kind: 'restore', version: v.id }));
|
||||
item.appendChild(buttons);
|
||||
list.appendChild(item);
|
||||
});
|
||||
livePanel.appendChild(list);
|
||||
holder.appendChild(livePanel);
|
||||
}
|
||||
|
||||
// What the page shows: Diagram | Graph | Source (Model | Graph | Source on
|
||||
@@ -775,6 +874,7 @@ function bridgeScript(options: PageOptions): string {
|
||||
if (message.type === 'notice') showNotice(message.notice);
|
||||
if (message.type === 'sourceDiagnostics') showProblemCount(message.count);
|
||||
if (message.type === 'setView') setSource(message.view === 'source');
|
||||
if (message.type === 'live') renderLive(message.live);
|
||||
});
|
||||
function onReady(fn) {
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn);
|
||||
@@ -854,6 +954,26 @@ const MODEL_STYLE = `
|
||||
.archify-modal-defined { margin: 0.75rem 0 0; font-size: 0.8125rem; color: var(--m-muted); display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; }
|
||||
.archify-modal .archify-model-title { margin: 1.25rem 0 0.6rem; }
|
||||
.archify-modal .archify-model-list { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
|
||||
/* Live: a shared model's state, people and versions. */
|
||||
.archify-live-status { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin: 0 0.9rem; font-size: 0.8125rem; }
|
||||
.archify-live-dot { width: 0.55rem; height: 0.55rem; border-radius: 50%; background: var(--m-muted); flex: none; }
|
||||
.archify-model-live.is-online .archify-live-dot { background: var(--vscode-testing-iconPassed, #3fb950); }
|
||||
.archify-model-live.is-signed-out .archify-live-dot, .archify-model-live.is-forbidden .archify-live-dot, .archify-model-live.is-outdated .archify-live-dot { background: var(--vscode-editorWarning-foreground, #d29922); }
|
||||
.archify-live-actions { display: inline-flex; flex-wrap: wrap; gap: 0.35rem; margin-left: auto; }
|
||||
.archify-live .archify-live-action { font-family: var(--vscode-font-family, system-ui, sans-serif); }
|
||||
.archify-live-people { list-style: none; display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.6rem 0.9rem 0; padding: 0; }
|
||||
.archify-live-person { display: inline-flex; align-items: baseline; gap: 0.4rem; padding: 0.2rem 0.6rem; border: 1px solid var(--m-border); border-radius: 999px; font-size: 0.8125rem; }
|
||||
.archify-live-person small { color: var(--m-muted); font-family: ui-monospace, Menlo, monospace; font-size: 0.6875rem; }
|
||||
.archify-live-person.is-me { opacity: 0.75; }
|
||||
.archify-live-here { font-size: 0.6875rem; color: var(--m-accent); font-weight: 600; }
|
||||
.archify-live-versions-head { display: flex; align-items: center; gap: 0.5rem; margin: 0.9rem 0.9rem 0.35rem; font-size: 0.8125rem; font-weight: 600; }
|
||||
.archify-live-note { margin: 0 0.9rem; font-size: 0.8125rem; }
|
||||
.archify-live-versions { list-style: none; margin: 0 0.9rem; padding: 0; display: grid; gap: 0.3rem; }
|
||||
.archify-live-version { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; padding: 0.4rem 0.7rem; border: 1px solid var(--m-border); border-radius: 0.5rem; font-size: 0.8125rem; }
|
||||
.archify-live-version-label { font-family: ui-monospace, Menlo, monospace; }
|
||||
.archify-live-version.is-publish .archify-live-version-label { color: var(--m-accent); }
|
||||
.archify-live-version-name { font-weight: 600; }
|
||||
.archify-live-version-when { color: var(--m-muted); }
|
||||
/* Decisions: rows, chips and status badges. */
|
||||
.archify-decision-list { grid-template-columns: 1fr; gap: 0.35rem; }
|
||||
.archify-decision-open { padding: 0.55rem 0.85rem; }
|
||||
|
||||
@@ -3,9 +3,12 @@ import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirro
|
||||
import { yaml, yamlLanguage } from '@codemirror/lang-yaml';
|
||||
import { HighlightStyle, bracketMatching, foldGutter, foldKeymap, indentOnInput, indentUnit, syntaxHighlighting } from '@codemirror/language';
|
||||
import { lintGutter, lintKeymap, nextDiagnostic, setDiagnostics } from '@codemirror/lint';
|
||||
import { Annotation, EditorState, Transaction } from '@codemirror/state';
|
||||
import { Annotation, EditorState, Range, StateEffect, StateField, Transaction } from '@codemirror/state';
|
||||
import {
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
EditorView,
|
||||
WidgetType,
|
||||
drawSelection,
|
||||
dropCursor,
|
||||
highlightActiveLine,
|
||||
@@ -30,6 +33,72 @@ export interface SourceDiagnostic {
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/** A teammate's cursor (Archify Sync), as offsets into the editor's text. */
|
||||
export interface RemoteCursor {
|
||||
name: string;
|
||||
color: string;
|
||||
anchor: number;
|
||||
head: number;
|
||||
}
|
||||
|
||||
const setRemoteCursors = StateEffect.define<RemoteCursor[]>();
|
||||
|
||||
class RemoteCaret extends WidgetType {
|
||||
constructor(
|
||||
readonly name: string,
|
||||
readonly color: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
eq(other: RemoteCaret): boolean {
|
||||
return other.name === this.name && other.color === this.color;
|
||||
}
|
||||
|
||||
toDOM(): HTMLElement {
|
||||
const caret = document.createElement('span');
|
||||
caret.className = 'cm-remote-caret';
|
||||
caret.style.borderLeftColor = this.color;
|
||||
const label = document.createElement('span');
|
||||
label.className = 'cm-remote-label';
|
||||
label.textContent = this.name;
|
||||
label.style.backgroundColor = this.color;
|
||||
caret.appendChild(label);
|
||||
return caret;
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function remoteDecorations(cursors: RemoteCursor[], length: number): DecorationSet {
|
||||
const ranges: Range<Decoration>[] = [];
|
||||
const clamp = (n: number) => Math.max(0, Math.min(n, length));
|
||||
for (const c of cursors) {
|
||||
const anchor = clamp(c.anchor);
|
||||
const head = clamp(c.head);
|
||||
if (anchor !== head) {
|
||||
ranges.push(
|
||||
Decoration.mark({ class: 'cm-remote-selection', attributes: { style: `background-color: color-mix(in srgb, ${c.color} 22%, transparent)` } }).range(Math.min(anchor, head), Math.max(anchor, head)),
|
||||
);
|
||||
}
|
||||
ranges.push(Decoration.widget({ widget: new RemoteCaret(c.name, c.color), side: 1 }).range(head));
|
||||
}
|
||||
return Decoration.set(ranges, true);
|
||||
}
|
||||
|
||||
/** Teammates' carets and selections; they move with the text as anyone types. */
|
||||
const remoteCursors = StateField.define<DecorationSet>({
|
||||
create: () => Decoration.none,
|
||||
update(decorations, tr) {
|
||||
decorations = decorations.map(tr.changes);
|
||||
for (const effect of tr.effects) if (effect.is(setRemoteCursors)) decorations = remoteDecorations(effect.value, tr.state.doc.length);
|
||||
return decorations;
|
||||
},
|
||||
provide: (field) => EditorView.decorations.from(field),
|
||||
});
|
||||
|
||||
/** Marks transactions that carry text from the host, so they are not sent back. */
|
||||
const fromHost = Annotation.define<boolean>();
|
||||
|
||||
@@ -56,6 +125,22 @@ const theme = EditorView.theme({
|
||||
'.cm-tooltip code': { fontFamily: 'var(--vscode-editor-font-family, ui-monospace, Menlo, monospace)', color: 'var(--frontend-stroke, var(--vscode-textPreformat-foreground))' },
|
||||
'.cm-tooltip-autocomplete ul li[aria-selected]': { backgroundColor: 'var(--frontend-stroke, var(--vscode-list-activeSelectionBackground))', color: 'var(--bg, var(--vscode-list-activeSelectionForeground))' },
|
||||
'.cm-diagnostic': { padding: '4px 8px' },
|
||||
'.cm-remote-caret': { position: 'relative', borderLeft: '2px solid', marginLeft: '-1px', marginRight: '-1px', pointerEvents: 'none' },
|
||||
'.cm-remote-label': {
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
left: '-2px',
|
||||
padding: '0 4px',
|
||||
borderRadius: '3px 3px 3px 0',
|
||||
color: '#fff',
|
||||
fontSize: '10px',
|
||||
lineHeight: '1.5',
|
||||
fontFamily: 'var(--vscode-font-family, system-ui, sans-serif)',
|
||||
whiteSpace: 'nowrap',
|
||||
zIndex: '5',
|
||||
opacity: '0.9',
|
||||
},
|
||||
'.cm-line:first-child .cm-remote-label': { bottom: 'auto', top: '100%', borderRadius: '0 3px 3px 3px' },
|
||||
'.cm-foldPlaceholder': { backgroundColor: 'transparent', border: '1px solid var(--panel-border, var(--vscode-editorWidget-border))', color: 'inherit' },
|
||||
});
|
||||
|
||||
@@ -71,7 +156,13 @@ export class SourceEditor {
|
||||
readonly view: EditorView;
|
||||
private schemaKey: string | undefined;
|
||||
|
||||
constructor(parent: HTMLElement, text: string, onEdit: (changes: TextChange[], text: string) => void) {
|
||||
constructor(
|
||||
parent: HTMLElement,
|
||||
text: string,
|
||||
onEdit: (changes: TextChange[], text: string) => void,
|
||||
/** The main selection moved (or the editor got focus); offsets into the text. */
|
||||
onSelection: (anchor: number, head: number) => void = () => undefined,
|
||||
) {
|
||||
this.view = new EditorView({
|
||||
parent,
|
||||
state: EditorState.create({
|
||||
@@ -105,7 +196,12 @@ export class SourceEditor {
|
||||
stateExtensions(undefined as never),
|
||||
yamlLanguage.data.of({ autocomplete: yamlCompletion() }),
|
||||
hoverTooltip(yamlSchemaHover()),
|
||||
remoteCursors,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.selectionSet || update.docChanged || (update.focusChanged && update.view.hasFocus)) {
|
||||
const main = update.state.selection.main;
|
||||
onSelection(main.anchor, main.head);
|
||||
}
|
||||
if (!update.docChanged || update.transactions.some((tr) => tr.annotation(fromHost))) return;
|
||||
const changes: TextChange[] = [];
|
||||
update.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => changes.push({ from: fromA, to: toA, insert: inserted.toString() }));
|
||||
@@ -125,6 +221,11 @@ export class SourceEditor {
|
||||
this.view.dispatch({ changes: change, annotations: [fromHost.of(true), Transaction.addToHistory.of(false)] });
|
||||
}
|
||||
|
||||
/** Teammates' cursors, from the host; they replace the ones shown. */
|
||||
setRemoteCursors(cursors: RemoteCursor[]): void {
|
||||
this.view.dispatch({ effects: setRemoteCursors.of(cursors) });
|
||||
}
|
||||
|
||||
/** The schema that completion and hover read; `undefined` turns them off. */
|
||||
setSchema(key: string | undefined, schema: object | undefined): void {
|
||||
if (key === this.schemaKey) return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ArchifyHost } from '../host';
|
||||
import { TextChange, minimalChange } from '../../util/textDiff';
|
||||
import { SourceDiagnostic, SourceEditor } from './editor';
|
||||
import { RemoteCursor, SourceDiagnostic, SourceEditor } from './editor';
|
||||
|
||||
/**
|
||||
* The Source view of an Archify page: the file's YAML in an editor, in the
|
||||
@@ -15,7 +15,8 @@ type HostMessage =
|
||||
| { type: 'sourceText'; text: string; schema?: { key: string; schema: object } | null; checks: string }
|
||||
| { type: 'sourceDiagnostics'; items: SourceDiagnostic[]; count: number }
|
||||
| { type: 'sourceSchema'; schema: { key: string; schema: object } | null }
|
||||
| { type: 'sourceDirty'; dirty: boolean };
|
||||
| { type: 'sourceDirty'; dirty: boolean }
|
||||
| { type: 'sourceCursors'; cursors: RemoteCursor[] };
|
||||
|
||||
const STYLE = `
|
||||
html.archify-source-on .container > :not(.header):not(#archify-source),
|
||||
@@ -54,7 +55,10 @@ class SourcePane {
|
||||
/** The text the host last knew the page to have; edits are sent against it. */
|
||||
private text = '';
|
||||
private diagnostics: SourceDiagnostic[] = [];
|
||||
private cursors: RemoteCursor[] = [];
|
||||
private shown = false;
|
||||
private selectionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private selection: { anchor: number; head: number } | undefined;
|
||||
|
||||
constructor(private readonly host: ArchifyHost) {
|
||||
window.addEventListener('message', (event) => this.onMessage(event.data as HostMessage));
|
||||
@@ -139,8 +143,14 @@ class SourcePane {
|
||||
this.build();
|
||||
if (!this.editor) {
|
||||
this.text = message.text;
|
||||
this.editor = new SourceEditor(this.editorHost, message.text, (changes, text) => this.onEdit(changes, text));
|
||||
this.editor = new SourceEditor(
|
||||
this.editorHost,
|
||||
message.text,
|
||||
(changes, text) => this.onEdit(changes, text),
|
||||
(anchor, head) => this.onSelection(anchor, head),
|
||||
);
|
||||
this.editor.setDiagnostics(this.diagnostics);
|
||||
this.editor.setRemoteCursors(this.cursors);
|
||||
this.fit();
|
||||
if (this.shown) this.editor.focus();
|
||||
} else {
|
||||
@@ -162,9 +172,23 @@ class SourcePane {
|
||||
case 'sourceDirty':
|
||||
this.renderState(message.dirty);
|
||||
return;
|
||||
case 'sourceCursors':
|
||||
this.cursors = message.cursors;
|
||||
this.editor?.setRemoteCursors(message.cursors);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** Teammates see where you are; sent at most every 80 ms, after the edit it follows. */
|
||||
private onSelection(anchor: number, head: number): void {
|
||||
this.selection = { anchor, head };
|
||||
if (this.selectionTimer) return;
|
||||
this.selectionTimer = setTimeout(() => {
|
||||
this.selectionTimer = undefined;
|
||||
if (this.selection && this.shown) this.host.post({ type: 'sourceSelection', ...this.selection });
|
||||
}, 80);
|
||||
}
|
||||
|
||||
private onEdit(changes: TextChange[], text: string): void {
|
||||
const base = this.text;
|
||||
this.text = text;
|
||||
|
||||
Reference in new issue
Block a user