/** * ScmSurfaceController — the native diff surface (spec §6.4/§5, INV-13). * Owns: the "Cowriting" SourceControl + QuickDiffProvider (gutter bars), the * cowriting-baseline: TextDocumentContentProvider, the "Review Changes" native * diff command, and the "✦ Coediting · N changes" status-bar item. Gated on * the CoeditingRegistry (INV-10): non-entered docs resolve no original. */ import * as path from "node:path"; import * as vscode from "vscode"; import type { CoeditingRegistry } from "./coeditingRegistry"; import type { DiffViewController } from "./diffViewController"; import { countLineHunks } from "./trackChangesModel"; export const BASELINE_SCHEME = "cowriting-baseline"; export function baselineUriFor(docUri: vscode.Uri): vscode.Uri { const name = path.basename(docUri.path) || "untitled.md"; return vscode.Uri.from({ scheme: BASELINE_SCHEME, path: `/${name}`, query: encodeURIComponent(docUri.toString()) }); } export class ScmSurfaceController implements vscode.Disposable { private readonly disposables: vscode.Disposable[] = []; private readonly statusItem: vscode.StatusBarItem; private readonly counts = new Map(); private readonly baselineEmitter = new vscode.EventEmitter(); private readonly recountTimers = new Map>(); constructor( private readonly registry: CoeditingRegistry, private readonly diffView: DiffViewController, ) { this.statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); this.statusItem.command = "cowriting.reviewChanges"; this.statusItem.tooltip = "Cowriting — click to review changes (baseline ⟷ live)"; this.disposables.push(this.statusItem, this.baselineEmitter); this.disposables.push( vscode.workspace.registerTextDocumentContentProvider(BASELINE_SCHEME, { onDidChange: this.baselineEmitter.event, provideTextDocumentContent: (uri) => this.diffView.getBaseline(decodeURIComponent(uri.query))?.text ?? "", }), ); const sc = vscode.scm.createSourceControl("cowriting", "✦ Cowriting"); sc.quickDiffProvider = { provideOriginalResource: (uri: vscode.Uri) => this.registry.isCoediting(uri) && this.diffView.getBaseline(uri.toString()) ? baselineUriFor(uri) : null, }; this.disposables.push(sc); this.disposables.push( vscode.commands.registerCommand("cowriting.reviewChanges", () => this.openReview()), this.diffView.onDidChangeBaseline(({ uri }) => { this.baselineEmitter.fire(baselineUriFor(vscode.Uri.parse(uri))); this.scheduleRecount(uri); }), this.registry.onDidChange(({ uri, coediting }) => { if (coediting) this.scheduleRecount(uri); this.refreshStatus(); }), vscode.workspace.onDidChangeTextDocument((e) => { if (this.registry.isCoediting(e.document.uri)) this.scheduleRecount(e.document.uri.toString()); }), vscode.window.onDidChangeActiveTextEditor(() => this.refreshStatus()), ); this.refreshStatus(); } changeCount(uriString: string): number { return this.counts.get(uriString) ?? 0; } private async openReview(): Promise { const ed = vscode.window.activeTextEditor; if (!ed || !this.registry.isCoediting(ed.document.uri)) { void vscode.window.showWarningMessage("Cowriting: not coediting this document."); return; } this.baselineEmitter.fire(baselineUriFor(ed.document.uri)); await vscode.commands.executeCommand( "vscode.diff", baselineUriFor(ed.document.uri), ed.document.uri, `${path.basename(ed.document.uri.path)} — Coediting (baseline ⟷ live)`, { preview: true }, ); } private scheduleRecount(uriString: string): void { const prev = this.recountTimers.get(uriString); if (prev !== undefined) clearTimeout(prev); this.recountTimers.set(uriString, setTimeout(() => { this.recountTimers.delete(uriString); const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString); const baseline = this.diffView.getBaseline(uriString); if (!doc || !baseline) return; this.counts.set(uriString, countLineHunks(baseline.text, doc.getText())); this.refreshStatus(); }, 150)); } private refreshStatus(): void { const ed = vscode.window.activeTextEditor; if (!ed || !this.registry.isCoediting(ed.document.uri)) { this.statusItem.hide(); return; } const n = this.counts.get(ed.document.uri.toString()) ?? 0; this.statusItem.text = `$(sparkle) Coediting · ${n} change${n === 1 ? "" : "s"}`; this.statusItem.show(); } dispose(): void { for (const t of this.recountTimers.values()) clearTimeout(t); for (const d of this.disposables) d.dispose(); } }