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.
231 lines
9.2 KiB
TypeScript
231 lines
9.2 KiB
TypeScript
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;
|
|
}
|
|
}
|