import * as vscode from "vscode"; import type * as Y from "yjs"; import type { RoomClient } from "./room-client"; const LOCAL = "vscode-local"; /** * Two-way binding between one VS Code TextDocument and its Y.Text. Local edits become Yjs ops; remote * ops become WorkspaceEdits. If an interleaving ever leaves the two out of step, the document is * re-synced from the shared text, which is the source of truth. */ export class DocumentBinding implements vscode.Disposable { private applying = 0; private readonly observer: (event: Y.YTextEvent, transaction: Y.Transaction) => void; private chain: Promise = Promise.resolve(); private pending = false; /** Remote deltas already in the Y.Text but not yet in the document; local offsets are mapped through them. */ private unapplied: Array = []; private readonly text: Y.Text; constructor( private readonly room: RoomClient, readonly document: vscode.TextDocument, readonly path: string, ) { this.text = room.text(path); this.observer = (event, transaction) => { if (transaction.origin === LOCAL) return; // The room evicted this clean file to free capacity; keep the editor as is and reseed it from head. if (!room.files.has(path)) { room.control({ type: "open", path }); return; } this.unapplied.push(event.delta); this.queueRemote(); }; this.text.observe(this.observer); if (document.getText() !== this.text.toString()) this.queueRemote(); } onLocalChange(event: vscode.TextDocumentChangeEvent): void { if (this.applying > 0 || event.contentChanges.length === 0) return; if (!this.room.writable) return this.queueRemote(); const changes = [...event.contentChanges].sort((left, right) => right.rangeOffset - left.rangeOffset); this.room.doc.transact(() => { for (const change of changes) { const start = this.mapOffset(change.rangeOffset); const end = Math.max(start, this.mapOffset(change.rangeOffset + change.rangeLength)); if (end > start) this.text.delete(start, end - start); if (change.text) this.text.insert(start, change.text); } }, LOCAL); } /** Maps a document offset to the shared text by replaying remote deltas the document hasn't seen yet. */ private mapOffset(offset: number): number { let mapped = offset; for (const delta of this.unapplied) { let index = 0; for (const op of delta) { if (op.retain !== undefined) index += op.retain; else if (typeof op.insert === "string") { if (index <= mapped) mapped += op.insert.length; index += op.insert.length; } else if (op.delete !== undefined && index < mapped) { mapped -= Math.min(op.delete, mapped - index); } } } return Math.min(mapped, this.text.length); } /** * Remote changes are coalesced and applied as one minimal edit from the document's current text to the * shared text. Diffing at apply time (rather than replaying deltas) stays correct even when local typing * or an earlier edit landed in between. */ private queueRemote(): void { if (this.pending) return; this.pending = true; this.chain = this.chain.then(async () => { this.pending = false; await this.resync(); }); } async resync(): Promise { const covered = this.unapplied; this.unapplied = []; const current = this.document.getText(); const next = this.text.toString(); if (current === next) return; let start = 0; while (start < current.length && start < next.length && current[start] === next[start]) start++; let end = 0; while ( end < current.length - start && end < next.length - start && current[current.length - 1 - end] === next[next.length - 1 - end] ) { end++; } const edit = new vscode.WorkspaceEdit(); const range = new vscode.Range(this.document.positionAt(start), this.document.positionAt(current.length - end)); edit.replace(this.document.uri, range, next.slice(start, next.length - end)); if (!(await this.apply(edit))) this.unapplied = [...covered, ...this.unapplied]; } private async apply(edit: vscode.WorkspaceEdit): Promise { this.applying++; try { return await vscode.workspace.applyEdit(edit); } finally { this.applying--; } } dispose(): void { this.text.unobserve(this.observer); } }