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
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user