152 lines
7.3 KiB
TypeScript
152 lines
7.3 KiB
TypeScript
/**
|
|
* 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<void>();
|
|
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<string, ReturnType<typeof setTimeout>>();
|
|
|
|
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<void> {
|
|
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<void> {
|
|
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();
|
|
}
|
|
}
|