feat(proposals): optimisticApply + finalize/revert in place + rejectAll (#64)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+157
-3
@@ -13,8 +13,8 @@
|
||||
import * as vscode from "vscode";
|
||||
import { SidecarRouter, docIdentity } from "./sidecarRouter";
|
||||
import { emptyArtifact, type Artifact, type Fingerprint, type Proposal, type Provenance } from "./model";
|
||||
import { resolve, shift, type OffsetRange } from "./anchorer";
|
||||
import { addProposal, removeProposal } from "./proposalModel";
|
||||
import { resolve, shift, buildFingerprint, type OffsetRange } from "./anchorer";
|
||||
import { addProposal, removeProposal, setProposalApplied } from "./proposalModel";
|
||||
import type { AttributionController } from "./attributionController";
|
||||
import type { VersionGuard } from "./versionGuard";
|
||||
import { isAuthorable } from "./workspacePath";
|
||||
@@ -39,6 +39,8 @@ interface DocState {
|
||||
live: Map<string, OffsetRange>;
|
||||
/** proposal ids whose anchor did not resolve at last render (stale/orphaned). */
|
||||
unresolved: Set<string>;
|
||||
/** ids already optimistically applied to the buffer (so the trigger doesn't re-apply). */
|
||||
applied: Set<string>;
|
||||
}
|
||||
|
||||
export class ProposalController implements vscode.Disposable {
|
||||
@@ -91,6 +93,7 @@ export class ProposalController implements vscode.Disposable {
|
||||
anchorEnd: resolved === "orphaned" ? null : resolved.end,
|
||||
replaced: fp?.text ?? "",
|
||||
replacement: p.replacement,
|
||||
original: p.original,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -104,6 +107,7 @@ export class ProposalController implements vscode.Disposable {
|
||||
artifact: this.store.load(docPath) ?? emptyArtifact(docPath),
|
||||
live: new Map(),
|
||||
unresolved: new Set(),
|
||||
applied: new Set(),
|
||||
};
|
||||
this.docs.set(docPath, state);
|
||||
}
|
||||
@@ -137,8 +141,11 @@ export class ProposalController implements vscode.Disposable {
|
||||
|
||||
// ---- PUC-2/PUC-3: accept / reject (INV-11/INV-12) ----------------------------------
|
||||
|
||||
/** Accept by proposal id (test-facing twin of the thread-menu gesture). */
|
||||
/** Accept by id — F12: finalize the already-applied text in place (INV-51). */
|
||||
async acceptById(docPath: string, proposalId: string, opts?: { silent?: boolean }): Promise<boolean> {
|
||||
if (this.isApplied(docPath, proposalId)) return this.finalizeInPlace(docPath, proposalId);
|
||||
// Fallback: a proposal that was never optimistically applied (e.g. orphaned at
|
||||
// apply time) keeps the legacy seam-apply accept.
|
||||
const hit = this.byId(docPath, proposalId);
|
||||
return hit ? this.accept(hit.state, hit.proposal, opts) : false;
|
||||
}
|
||||
@@ -181,6 +188,12 @@ export class ProposalController implements vscode.Disposable {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Reject by id — F12: revert the applied text in place (INV-51). */
|
||||
async rejectByIdInPlace(docPath: string, proposalId: string): Promise<boolean> {
|
||||
if (this.isApplied(docPath, proposalId)) return this.revertInPlace(docPath, proposalId);
|
||||
return this.rejectById(docPath, proposalId);
|
||||
}
|
||||
|
||||
private async accept(state: DocState, proposal: Proposal, opts?: { silent?: boolean }): Promise<boolean> {
|
||||
if (this.guard.isReadOnly(state.docPath)) return false;
|
||||
const document = this.openDoc(state);
|
||||
@@ -257,6 +270,147 @@ export class ProposalController implements vscode.Disposable {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** True once this proposal's text is in the buffer (optimistic apply ran). */
|
||||
isApplied(docPath: string, proposalId: string): boolean {
|
||||
return this.docs.get(docPath)?.applied.has(proposalId) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* F12/#64 (INV-48): optimistically apply a pending proposal INTO the buffer so
|
||||
* the editor shows the would-be-accepted result (editable). Reuses the F4
|
||||
* word-precise seam (block → per-word hunks, INV-40; single → whole range) but
|
||||
* with `landBaseline:false` (the change stays pending). Then re-anchors the
|
||||
* proposal to the applied text and stores the original (`setProposalApplied`), so
|
||||
* `resolve()` finds it in the mutated buffer and revert/decorate key off it.
|
||||
* Idempotent: a no-op if already applied.
|
||||
*/
|
||||
async optimisticApply(document: vscode.TextDocument, proposalId: string): Promise<boolean> {
|
||||
if (!this.isTracked(document) || this.guard.isReadOnly(this.keyOf(document))) return false;
|
||||
const docPath = this.keyOf(document);
|
||||
const state = this.ensureState(document);
|
||||
if (state.applied.has(proposalId)) return true;
|
||||
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
|
||||
const proposal = state.artifact.proposals.find((p) => p.id === proposalId);
|
||||
const fp = proposal ? state.artifact.anchors[proposal.anchorId]?.fingerprint : undefined;
|
||||
if (!proposal || !fp) return false;
|
||||
const resolved = resolve(document.getText(), fp);
|
||||
if (resolved === "orphaned") return false;
|
||||
const original = document.getText(
|
||||
new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)),
|
||||
);
|
||||
const ok =
|
||||
proposal.granularity === "block"
|
||||
? await this.applyBlockOptimistic(document, resolved, proposal)
|
||||
: await this.attribution.applyAgentEdit(
|
||||
document,
|
||||
new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)),
|
||||
proposal.replacement,
|
||||
proposal.author,
|
||||
{ expectedVersion: document.version, turnId: proposal.turnId, landBaseline: false },
|
||||
);
|
||||
if (!ok) return false;
|
||||
state.applied.add(proposalId);
|
||||
// Re-anchor to the applied text now in the buffer (its start is unchanged; its
|
||||
// end shifts by the net length delta of the replacement).
|
||||
const appliedStart = resolved.start;
|
||||
const appliedEnd = appliedStart + proposal.replacement.length;
|
||||
const appliedFp = buildFingerprint(document.getText(), { start: appliedStart, end: appliedEnd });
|
||||
this.store.update(docPath, (a) => setProposalApplied(a, proposalId, appliedFp, original));
|
||||
this.renderAll(document);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Block optimistic apply: the INV-40 per-word hunks, but landBaseline:false. */
|
||||
private async applyBlockOptimistic(
|
||||
document: vscode.TextDocument,
|
||||
resolved: OffsetRange,
|
||||
proposal: Proposal,
|
||||
): Promise<boolean> {
|
||||
const blockText = document.getText(
|
||||
new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)),
|
||||
);
|
||||
const subHunks = wordEditHunks(blockText, proposal.replacement);
|
||||
if (subHunks.length === 0) return true;
|
||||
for (const h of [...subHunks].sort((a, b) => b.start - a.start)) {
|
||||
const range = new vscode.Range(
|
||||
document.positionAt(resolved.start + h.start),
|
||||
document.positionAt(resolved.start + h.end),
|
||||
);
|
||||
const ok = await this.attribution.applyAgentEdit(document, range, h.replacement, proposal.author, {
|
||||
expectedVersion: document.version, turnId: proposal.turnId, landBaseline: false,
|
||||
});
|
||||
if (!ok) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* F12/#64 (INV-51): ACCEPT an optimistically-applied proposal — the text is
|
||||
* already in the buffer, so this only advances the F6 baseline (machine-landing,
|
||||
* via `attribution.signalLanded`) and clears the proposal. No re-application.
|
||||
*/
|
||||
async finalizeInPlace(docPath: string, proposalId: string): Promise<boolean> {
|
||||
const hit = this.byId(docPath, proposalId);
|
||||
if (!hit) return false;
|
||||
const document = this.openDoc(hit.state);
|
||||
if (!document) return false;
|
||||
this.attribution.signalLanded(document);
|
||||
this.store.update(docPath, (a) => removeProposal(a, proposalId));
|
||||
hit.state.applied.delete(proposalId);
|
||||
this.renderAll(document);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* F12/#64 (INV-51): REJECT an optimistically-applied proposal — replace its live
|
||||
* applied span with the stored `original`, then clear it. Reverts the whole block
|
||||
* regardless of any in-place edits the human made to the inserted text.
|
||||
*/
|
||||
async revertInPlace(docPath: string, proposalId: string): Promise<boolean> {
|
||||
const hit = this.byId(docPath, proposalId);
|
||||
if (!hit) return false;
|
||||
const document = this.openDoc(hit.state);
|
||||
if (!document) return false;
|
||||
const fp = hit.state.artifact.anchors[hit.proposal.anchorId]?.fingerprint;
|
||||
const resolved = fp ? resolve(document.getText(), fp) : "orphaned";
|
||||
if (resolved !== "orphaned" && hit.proposal.original !== undefined) {
|
||||
const we = new vscode.WorkspaceEdit();
|
||||
we.replace(
|
||||
document.uri,
|
||||
new vscode.Range(document.positionAt(resolved.start), document.positionAt(resolved.end)),
|
||||
hit.proposal.original,
|
||||
);
|
||||
if (!(await vscode.workspace.applyEdit(we))) return false;
|
||||
}
|
||||
this.store.update(docPath, (a) => removeProposal(a, proposalId));
|
||||
hit.state.applied.delete(proposalId);
|
||||
this.renderAll(document);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* F12/#64 (INV-53): reject EVERY pending proposal on a document — revert each in
|
||||
* DESCENDING anchor order (so an earlier revert never shifts a later one's
|
||||
* offsets), symmetric with #46's accept-all. Returns the reverted count.
|
||||
*/
|
||||
async rejectAll(document: vscode.TextDocument): Promise<{ reverted: number }> {
|
||||
if (!this.isTracked(document)) return { reverted: 0 };
|
||||
const docPath = this.keyOf(document);
|
||||
const state = this.ensureState(document);
|
||||
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
|
||||
const text = document.getText();
|
||||
const ordered = state.artifact.proposals
|
||||
.map((p) => {
|
||||
const fp = state.artifact.anchors[p.anchorId]?.fingerprint;
|
||||
const r = fp ? resolve(text, fp) : "orphaned";
|
||||
return { id: p.id, start: r === "orphaned" ? -1 : r.start };
|
||||
})
|
||||
.sort((a, b) => b.start - a.start);
|
||||
let reverted = 0;
|
||||
for (const it of ordered) if (await this.revertInPlace(docPath, it.id)) reverted++;
|
||||
return { reverted };
|
||||
}
|
||||
|
||||
private reject(state: DocState, proposal: Proposal): void {
|
||||
if (this.guard.isReadOnly(state.docPath)) return;
|
||||
this.store.update(state.docPath, (a) => removeProposal(a, proposal.id));
|
||||
|
||||
@@ -701,6 +701,8 @@ export interface ProposalView {
|
||||
replaced: string;
|
||||
/** the proposed replacement text. */
|
||||
replacement: string;
|
||||
/** F12/#64 (INV-48): the pre-apply original, set after optimistic apply. */
|
||||
original?: string;
|
||||
}
|
||||
|
||||
function proposalBlockHtml(p: ProposalView, render: (src: string) => string): string {
|
||||
|
||||
@@ -46,3 +46,73 @@ suite("F12 inline diff — seam landBaseline (#64, INV-48)", () => {
|
||||
assert.strictEqual(api.diffViewController.getBaseline(key)?.text ?? doc.getText(), before, "baseline unchanged");
|
||||
});
|
||||
});
|
||||
|
||||
suite("F12 inline diff — finalize / revert in place (#64, INV-51)", () => {
|
||||
// Optimistic apply lands the text + re-anchors; the buffer becomes the accepted result.
|
||||
test("optimisticApply puts the proposed text in the buffer and re-anchors", async () => {
|
||||
const { doc } = await freshDoc("docs/f12-opt.md", "# T\n\nReplace alpha please.\n");
|
||||
const api = await getApi();
|
||||
const p = api.proposalController;
|
||||
const fp = { text: "Replace alpha please.", before: "", after: "", lineHint: 2 };
|
||||
const id = await p.propose(doc, fp, "Replace ALPHA please.",
|
||||
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
|
||||
await api.proposalController.optimisticApply(doc, id!);
|
||||
await settle();
|
||||
assert.ok(doc.getText().includes("Replace ALPHA please."), "applied to buffer");
|
||||
// re-anchored: the proposal still resolves against the mutated buffer
|
||||
assert.strictEqual(p.listProposals(doc)[0].anchorStart !== null, true, "re-anchored, resolves");
|
||||
assert.strictEqual(p.listProposals(doc)[0].original, "Replace alpha please.", "original stored");
|
||||
});
|
||||
|
||||
test("finalizeInPlace clears the proposal and keeps the applied text (no double-apply)", async () => {
|
||||
const { doc } = await freshDoc("docs/f12-fin.md", "# T\n\nKeep alpha now.\n");
|
||||
const api = await getApi();
|
||||
const p = api.proposalController;
|
||||
const docPath = p.keyFor(doc);
|
||||
const fp = { text: "Keep alpha now.", before: "", after: "", lineHint: 2 };
|
||||
const id = await p.propose(doc, fp, "Keep ALPHA now.",
|
||||
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
|
||||
await p.optimisticApply(doc, id!);
|
||||
await settle();
|
||||
const ok = await p.finalizeInPlace(docPath, id!);
|
||||
await settle();
|
||||
assert.ok(ok, "finalized");
|
||||
assert.strictEqual(doc.getText(), "# T\n\nKeep ALPHA now.\n", "applied text retained, no double-apply");
|
||||
assert.strictEqual(p.listProposals(doc).length, 0, "proposal cleared");
|
||||
});
|
||||
|
||||
test("revertInPlace restores the original and clears the proposal", async () => {
|
||||
const { doc } = await freshDoc("docs/f12-rev.md", "# T\n\nUndo alpha here.\n");
|
||||
const api = await getApi();
|
||||
const p = api.proposalController;
|
||||
const docPath = p.keyFor(doc);
|
||||
const fp = { text: "Undo alpha here.", before: "", after: "", lineHint: 2 };
|
||||
const id = await p.propose(doc, fp, "Undo ALPHA here.",
|
||||
{ kind: "agent", id: "claude", agent: { sdk: "x", model: "m", sessionId: "s" } }, { granularity: "block" });
|
||||
await p.optimisticApply(doc, id!);
|
||||
await settle();
|
||||
const ok = await p.revertInPlace(docPath, id!);
|
||||
await settle();
|
||||
assert.ok(ok, "reverted");
|
||||
assert.strictEqual(doc.getText(), "# T\n\nUndo alpha here.\n", "original restored");
|
||||
assert.strictEqual(p.listProposals(doc).length, 0, "proposal cleared");
|
||||
});
|
||||
|
||||
test("rejectAll reverts every pending proposal", async () => {
|
||||
const { doc } = await freshDoc("docs/f12-rejall.md", "# T\n\nOne aaa.\n\nTwo bbb.\n");
|
||||
const api = await getApi();
|
||||
const ctl = api.trackChangesPreviewController;
|
||||
const p = api.proposalController;
|
||||
ctl.setEditTurnForTest(async () => ({ replacement: "# T\n\nOne AAA.\n\nTwo BBB.\n", model: "m", sessionId: "s" }));
|
||||
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up");
|
||||
await settle();
|
||||
for (const id of ids) await p.optimisticApply(doc, id);
|
||||
await settle();
|
||||
assert.ok(doc.getText().includes("AAA") && doc.getText().includes("BBB"), "both applied");
|
||||
const { reverted } = await p.rejectAll(doc);
|
||||
await settle();
|
||||
assert.strictEqual(reverted, ids.length, "all reverted");
|
||||
assert.strictEqual(doc.getText(), "# T\n\nOne aaa.\n\nTwo bbb.\n", "document restored");
|
||||
assert.strictEqual(p.listProposals(doc).length, 0, "all cleared");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user