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; private readonly disposables: vscode.Disposable[] = []; private queue: Promise = 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(); 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(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>[], txn: Y.Transaction) => { if (txn.origin === this.origin) return; const paths = new Set(); 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 { 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> { const found = await vscode.workspace.findFiles(new vscode.RelativePattern(this.root, MODEL_GLOB), SKIP_GLOB); const out = new Map(); 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 { 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 { 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): Promise { 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 { 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 { try { await vscode.workspace.fs.stat(uri); return true; } catch { return false; } } async function readDisk(uri: vscode.Uri): Promise { try { return new TextDecoder('utf-8').decode(await vscode.workspace.fs.readFile(uri)); } catch { return undefined; } }