2170a0d282
Deletes the last bespoke UI surface (TrackChangesPreviewController + its sealed webview client assets); every entry point re-points at native VS Code chrome per spec §5: the #41 right-click entries become cowriting.openReviewPreview (enter coediting if needed -> "Open Preview to the Side"), Ctrl+Alt+R/Cmd+Alt+R moves to cowriting.reviewChanges (native diff), and the F12 CodeLens per-proposal titles read "Keep"/"Reject" with a top-of-file "Keep all (N)"/"Reject all" pair once >=2 proposals are pending. EditFlow drops its own askClaude/askEditInstruction (the webview's only caller) and its now-unused constructor params. Coverage that lived only in the webview's test seams (renderHtmlFor, receiveMessage, isOpen, ...) moves to direct calls against the surviving controllers/pure renderReview (test/e2e/suite/helpers.ts gains a shared renderHtmlFor probe); a genuine gap (pending-proposal + unchanged-block rendering) is backfilled in test/previewAnnotations.test.ts. README's "how it works" is rewritten as the native-surface map; the superseded F6/F7/F9/ F10/F11 sections are kept as a marked historical record rather than deleted outright. 292 unit + 91 E2E green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
280 lines
15 KiB
TypeScript
280 lines
15 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). Phase 2 decorates ALL committed changes-since-baseline by author
|
|
* (human green / Claude blue + struck hints for deletions), plus pending proposals;
|
|
* a pinned baseline suppresses committed marks entirely (pin→clean, INV-48).
|
|
*/
|
|
import * as vscode from "vscode";
|
|
import type { ProposalController } from "./proposalController";
|
|
import type { DiffViewController } from "./diffViewController";
|
|
import type { AttributionController } from "./attributionController";
|
|
import { decorationPlan, wordEditHunks, authorAt } from "./trackChangesModel";
|
|
import { isAuthorable } from "./workspacePath";
|
|
import type { CoeditingRegistry } from "./coeditingRegistry";
|
|
|
|
export class EditorProposalController implements vscode.Disposable, vscode.CodeLensProvider {
|
|
private readonly disposables: vscode.Disposable[] = [];
|
|
private readonly insDeco: Record<"human" | "claude", vscode.TextEditorDecorationType> = {
|
|
human: vscode.window.createTextEditorDecorationType({
|
|
backgroundColor: "rgba(63,185,80,0.14)", borderColor: "#3fb950",
|
|
border: "0 0 2px 0", borderStyle: "solid",
|
|
}),
|
|
claude: vscode.window.createTextEditorDecorationType({
|
|
backgroundColor: "rgba(88,166,255,0.15)", borderColor: "#58a6ff",
|
|
border: "0 0 2px 0", borderStyle: "solid",
|
|
}),
|
|
};
|
|
private readonly delDeco: Record<"human" | "claude" | "none", vscode.TextEditorDecorationType> = {
|
|
human: vscode.window.createTextEditorDecorationType({ after: { color: "#f85149" } }),
|
|
claude: vscode.window.createTextEditorDecorationType({ after: { color: "#bc8cff" } }),
|
|
none: vscode.window.createTextEditorDecorationType({ after: { color: new vscode.ThemeColor("descriptionForeground") } }),
|
|
};
|
|
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>>();
|
|
/** Debounce timers for committed-change re-render (one per URI, 50 ms). */
|
|
private readonly pendingRender = new Map<string, ReturnType<typeof setTimeout>>();
|
|
|
|
constructor(
|
|
private readonly proposals: ProposalController,
|
|
private readonly diffView: DiffViewController,
|
|
private readonly attribution: AttributionController,
|
|
private readonly registry: CoeditingRegistry,
|
|
) {
|
|
this.disposables.push(
|
|
this.insDeco.human, this.insDeco.claude, this.delDeco.human, this.delDeco.claude, this.delDeco.none, this.lensEmitter,
|
|
vscode.languages.registerCodeLensProvider({ language: "markdown" }, this),
|
|
this.proposals.onDidChangeProposals(({ uri }) => this.scheduleApply(uri)),
|
|
vscode.window.onDidChangeActiveTextEditor((ed) => ed && this.renderEditor(ed)),
|
|
// Refresh committed decorations when the baseline changes (establish / pin / head-refresh).
|
|
this.diffView.onDidChangeBaseline(({ uri }) => {
|
|
const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri);
|
|
if (ed) this.renderEditor(ed);
|
|
}),
|
|
// Refresh committed decorations (debounced) when the active doc changes — attribution spans shift.
|
|
vscode.workspace.onDidChangeTextDocument((e) => {
|
|
const ed = vscode.window.activeTextEditor;
|
|
if (ed && e.document === ed.document) this.scheduleRender(ed);
|
|
}),
|
|
// INV-10: re-render (and refresh CodeLens) on every gate change — exit clears
|
|
// decorations/CodeLens via renderEditor's own gate check, re-entry redraws.
|
|
this.registry.onDidChange(({ uri }) => {
|
|
const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri);
|
|
if (ed) this.renderEditor(ed);
|
|
this.lensEmitter.fire();
|
|
}),
|
|
// 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),
|
|
);
|
|
}
|
|
|
|
/** Debounce a committed-change re-render for `editor` (50 ms — lets rapid typing settle). */
|
|
private scheduleRender(editor: vscode.TextEditor): void {
|
|
const uri = editor.document.uri.toString();
|
|
const prev = this.pendingRender.get(uri);
|
|
if (prev !== undefined) clearTimeout(prev);
|
|
this.pendingRender.set(
|
|
uri,
|
|
setTimeout(() => {
|
|
this.pendingRender.delete(uri);
|
|
this.renderEditor(editor);
|
|
}, 50),
|
|
);
|
|
}
|
|
|
|
/** 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;
|
|
// INV-10: a doc that exited coediting between the schedule and this tick gets
|
|
// no optimistic-apply/decorations/CodeLens.
|
|
if (!this.registry.isCoediting(doc.uri)) 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;
|
|
const clearAll = () => {
|
|
for (const a of ["human", "claude"] as const) editor.setDecorations(this.insDeco[a], []);
|
|
for (const a of ["human", "claude", "none"] as const) editor.setDecorations(this.delDeco[a], []);
|
|
};
|
|
if (doc.languageId !== "markdown") return clearAll();
|
|
// INV-10: a non-entered doc gets no decorations at all.
|
|
if (!this.registry.isCoediting(doc.uri)) return clearAll();
|
|
const key = this.proposals.keyFor(doc);
|
|
const ins: Record<"human" | "claude", vscode.Range[]> = { human: [], claude: [] };
|
|
const del: Record<"human" | "claude" | "none", vscode.DecorationOptions[]> = { human: [], claude: [], none: [] };
|
|
// Pending proposals — always Claude (INV: proposals are agent-authored).
|
|
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 i of plan.insertions) ins.claude.push(new vscode.Range(doc.positionAt(i.start), doc.positionAt(i.end)));
|
|
for (const d of plan.deletions) {
|
|
del.claude.push({
|
|
range: new vscode.Range(doc.positionAt(d.at), doc.positionAt(d.at)),
|
|
renderOptions: { after: { contentText: ` ${d.text} `, textDecoration: "line-through" } },
|
|
});
|
|
}
|
|
}
|
|
// Committed changes-since-baseline are added by decorateCommitted (pushes into ins/del[author]).
|
|
this.decorateCommitted(editor, ins, del);
|
|
for (const a of ["human", "claude"] as const) editor.setDecorations(this.insDeco[a], ins[a]);
|
|
for (const a of ["human", "claude", "none"] as const) editor.setDecorations(this.delDeco[a], del[a]);
|
|
}
|
|
|
|
/**
|
|
* Decorates committed (non-proposal) changes-since-baseline by author (Task 7 / reverse INV-32).
|
|
* Diffs current buffer against the F6 baseline; attributes each changed run to its author;
|
|
* skips any hunk overlapping an applied pending proposal (the proposal loop owns those).
|
|
* pin→clean: returns without marking when baseline.reason === "pinned".
|
|
* Adjacency heuristic: a deletion in a hunk that has insertions inherits the hunk author;
|
|
* a standalone deletion (hunk with no insertions) renders neutral ("none") — matching the
|
|
* preview's cw-del-none treatment.
|
|
*/
|
|
private decorateCommitted(
|
|
editor: vscode.TextEditor,
|
|
ins: Record<"human" | "claude", vscode.Range[]>,
|
|
del: Record<"human" | "claude" | "none", vscode.DecorationOptions[]>,
|
|
): void {
|
|
const doc = editor.document;
|
|
const baseline = this.diffView.getBaseline(doc.uri.toString());
|
|
// No baseline yet, or baseline was pinned (pin→clean: no committed marks, INV-48).
|
|
if (!baseline || baseline.reason === "pinned") return;
|
|
const current = doc.getText();
|
|
// Nothing changed — short-circuit before the diff.
|
|
if (current === baseline.text) return;
|
|
const spans = this.attribution.spansFor(doc);
|
|
const key = this.proposals.keyFor(doc);
|
|
// Build the set of current-buffer ranges owned by applied pending proposals so we
|
|
// can skip hunks that fall inside them — the proposal loop already decorates those.
|
|
const appliedRanges: { start: number; end: number }[] = [];
|
|
for (const v of this.proposals.listProposals(doc)) {
|
|
if (v.anchorStart !== null && v.original !== undefined && this.proposals.isApplied(key, v.id)) {
|
|
appliedRanges.push({ start: v.anchorStart, end: v.anchorStart + v.replacement.length });
|
|
}
|
|
}
|
|
// wordEditHunks(current, baseline.text): start/end are in current coords; replacement is baseline text.
|
|
const hunks = wordEditHunks(current, baseline.text);
|
|
for (const h of hunks) {
|
|
// Skip hunks whose current-buffer range overlaps an applied pending proposal.
|
|
// The skip is whole-hunk and therefore conservative: a committed edit word-merged
|
|
// into a proposal's hunk is skipped entirely — rare at word granularity.
|
|
if (appliedRanges.some((r) => h.start < r.end && h.end > r.start)) continue;
|
|
const author = authorAt(h.start, spans) ?? "human";
|
|
// h.replacement = baseline text for this span; current.slice(h.start, h.end) = applied text.
|
|
const plan = decorationPlan(h.start, h.replacement, current.slice(h.start, h.end));
|
|
for (const i of plan.insertions) {
|
|
ins[author].push(new vscode.Range(doc.positionAt(i.start), doc.positionAt(i.end)));
|
|
}
|
|
// Adjacency heuristic: a deletion paired with an insertion in THIS hunk inherits the
|
|
// insertion author; a standalone deletion (no insertion in the hunk) is neutral.
|
|
const delAuthor = plan.insertions.length > 0 ? author : "none";
|
|
for (const d of plan.deletions) {
|
|
del[delAuthor].push({
|
|
range: new vscode.Range(doc.positionAt(d.at), doc.positionAt(d.at)),
|
|
renderOptions: { after: { contentText: ` ${d.text} `, textDecoration: "line-through" } },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* CodeLensProvider (Task 8, PUC-2 wording): a `✓ Keep` / `✗ Reject` pair above
|
|
* each applied block, plus — when ≥2 proposals are pending on the doc — a
|
|
* top-of-file `✓ Keep all (N)` / `✗ Reject all` pair routed directly at the
|
|
* batch commands (no QuickPick detour for the common "review is done" case).
|
|
*/
|
|
provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] {
|
|
if (document.languageId !== "markdown") return [];
|
|
if (!this.registry.isCoediting(document.uri)) return [];
|
|
const key = this.proposals.keyFor(document);
|
|
const applied = this.proposals
|
|
.listProposals(document)
|
|
.filter((v) => v.anchorStart !== null && this.proposals.isApplied(key, v.id));
|
|
const lenses: vscode.CodeLens[] = [];
|
|
if (applied.length >= 2) {
|
|
const top = new vscode.Range(0, 0, 0, 0);
|
|
lenses.push(
|
|
new vscode.CodeLens(top, { title: `✓ Keep all (${applied.length})`, command: "cowriting.acceptAllProposals" }),
|
|
new vscode.CodeLens(top, { title: "✗ Reject all", command: "cowriting.rejectAllProposals" }),
|
|
);
|
|
}
|
|
for (const v of applied) {
|
|
const pos = document.positionAt(v.anchorStart!);
|
|
const line = new vscode.Range(pos.line, 0, pos.line, 0);
|
|
lenses.push(
|
|
new vscode.CodeLens(line, { title: "✓ Keep", 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 t of this.pendingRender.values()) clearTimeout(t);
|
|
this.pendingRender.clear();
|
|
for (const d of this.disposables) d.dispose();
|
|
}
|
|
}
|