/** * EditorProposalController — F12/#64 editor surface (spec coauthoring-inline-editor-diff * §3.5). Reverses INV-32 for pending proposals: on `onDidChangeProposals` it * (1) optimistically applies any not-yet-applied proposal into the active editor's * buffer (ProposalController.optimisticApply, INV-48), (2) decorates each applied * proposal — insertion tint over the proposed text + a non-editable struck-red hint * for deletions (INV-52, decorationPlan/INV-49), and (3) provides a CodeLens * `Accept ▾ / Reject ▾` above each block whose ▾ opens a QuickPick (this / all). * Owns no proposal STATE — it is a view over ProposalController (which stays the * pure F4 owner). A document with no pending proposals shows nothing (INV-32's * spirit when nothing is pending). */ import * as vscode from "vscode"; import type { ProposalController } from "./proposalController"; import { decorationPlan } from "./trackChangesModel"; import { isAuthorable } from "./workspacePath"; export class EditorProposalController implements vscode.Disposable, vscode.CodeLensProvider { private readonly disposables: vscode.Disposable[] = []; private readonly insertionDeco = vscode.window.createTextEditorDecorationType({ backgroundColor: new vscode.ThemeColor("diffEditor.insertedTextBackground"), }); private readonly deletionDeco = vscode.window.createTextEditorDecorationType({ // a non-editable struck-red hint injected AFTER the insertion (INV-52) after: { color: new vscode.ThemeColor("gitDecoration.deletedResourceForeground") }, textDecoration: "none", }); private readonly lensEmitter = new vscode.EventEmitter(); readonly onDidChangeCodeLenses = this.lensEmitter.event; /** Pending debounce timers — one per URI — coalesce rapid-fire propose events * (e.g. runEditAndPropose's N sequential propose() calls) into a single * optimistic-apply pass that runs after ALL proposals are created. Without this, * each propose() fires onDidChangeProposals synchronously and the controller's * optimisticApply runs concurrently with the still-in-progress propose loop, * causing "file changed in the meantime" workspace-edit conflicts. */ private readonly pendingApply = new Map>(); constructor(private readonly proposals: ProposalController) { this.disposables.push( this.insertionDeco, this.deletionDeco, this.lensEmitter, vscode.languages.registerCodeLensProvider({ language: "markdown" }, this), this.proposals.onDidChangeProposals(({ uri }) => this.scheduleApply(uri)), vscode.window.onDidChangeActiveTextEditor((ed) => ed && this.renderEditor(ed)), // the four QuickPick-backed menu commands vscode.commands.registerCommand("cowriting.proposalAcceptMenu", (id?: string) => this.menu("accept", id)), vscode.commands.registerCommand("cowriting.proposalRejectMenu", (id?: string) => this.menu("reject", id)), ); } /** Debounce-schedule an optimistic-apply pass for the given URI. Multiple rapid * onDidChangeProposals events (from a single runEditAndPropose batch) collapse * into one pass that runs after the batch completes. */ private scheduleApply(uri: string): void { const prev = this.pendingApply.get(uri); if (prev !== undefined) clearTimeout(prev); this.pendingApply.set( uri, setTimeout(() => { this.pendingApply.delete(uri); void this.onProposalsChanged(uri); }, 0), ); } /** Apply any not-yet-applied proposals on the doc, then re-decorate + refresh lenses. */ private async onProposalsChanged(uri: string): Promise { const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); if (!doc || doc.languageId !== "markdown" || !isAuthorable(doc.uri.scheme)) return; const key = this.proposals.keyFor(doc); for (const v of this.proposals.listProposals(doc)) { if (v.anchorStart !== null && !this.proposals.isApplied(key, v.id)) { await this.proposals.optimisticApply(doc, v.id); // fires onDidChangeProposals again; guarded by isApplied } } const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri); if (ed) this.renderEditor(ed); this.lensEmitter.fire(); } /** Decorate the editor for every applied proposal on its document (INV-52). */ private renderEditor(editor: vscode.TextEditor): void { const doc = editor.document; if (doc.languageId !== "markdown") { editor.setDecorations(this.insertionDeco, []); editor.setDecorations(this.deletionDeco, []); return; } const key = this.proposals.keyFor(doc); const insertions: vscode.Range[] = []; const deletions: vscode.DecorationOptions[] = []; for (const v of this.proposals.listProposals(doc)) { if (v.anchorStart === null || v.original === undefined || !this.proposals.isApplied(key, v.id)) continue; const plan = decorationPlan(v.anchorStart, v.original, v.replacement); for (const ins of plan.insertions) { insertions.push(new vscode.Range(doc.positionAt(ins.start), doc.positionAt(ins.end))); } for (const del of plan.deletions) { deletions.push({ range: new vscode.Range(doc.positionAt(del.at), doc.positionAt(del.at)), renderOptions: { after: { contentText: ` ${del.text} `, textDecoration: "line-through" } }, }); } } editor.setDecorations(this.insertionDeco, insertions); editor.setDecorations(this.deletionDeco, deletions); } /** CodeLensProvider: a `Accept ▾` / `Reject ▾` pair above each applied block. */ provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] { if (document.languageId !== "markdown") return []; const key = this.proposals.keyFor(document); const lenses: vscode.CodeLens[] = []; for (const v of this.proposals.listProposals(document)) { if (v.anchorStart === null || !this.proposals.isApplied(key, v.id)) continue; const pos = document.positionAt(v.anchorStart); const line = new vscode.Range(pos.line, 0, pos.line, 0); lenses.push( new vscode.CodeLens(line, { title: "Accept ▾", command: "cowriting.proposalAcceptMenu", arguments: [v.id] }), new vscode.CodeLens(line, { title: "Reject ▾", command: "cowriting.proposalRejectMenu", arguments: [v.id] }), ); } return lenses; } /** The dropdown: this-proposal vs all-proposals, then dispatch. */ private async menu(kind: "accept" | "reject", id?: string): Promise { const doc = vscode.window.activeTextEditor?.document; if (!doc || !id) return; const key = this.proposals.keyFor(doc); const verb = kind === "accept" ? "Accept" : "Reject"; const pick = await vscode.window.showQuickPick( [`${verb} this proposal`, `${verb} ALL proposals`], { placeHolder: `${verb} Claude's proposal` }, ); if (!pick) return; const all = pick.includes("ALL"); if (kind === "accept") { if (all) await this.proposals.acceptAllProposals(doc); else await this.proposals.finalizeInPlace(key, id); } else { if (all) await this.proposals.rejectAll(doc); else await this.proposals.revertInPlace(key, id); } } dispose(): void { for (const t of this.pendingApply.values()) clearTimeout(t); this.pendingApply.clear(); for (const d of this.disposables) d.dispose(); } }