F12: inline editable proposed-change diff in the Markdown editor + Accept/Reject control parity (#64) (#66)
This commit was merged in pull request #66.
This commit is contained in:
@@ -249,7 +249,7 @@ export class AttributionController implements vscode.Disposable {
|
||||
range: vscode.Range,
|
||||
newText: string,
|
||||
provenance: Provenance,
|
||||
opts?: { expectedVersion?: number; turnId?: string },
|
||||
opts?: { expectedVersion?: number; turnId?: string; landBaseline?: boolean },
|
||||
): Promise<boolean> {
|
||||
if (!this.isTracked(document)) return false;
|
||||
if (opts?.expectedVersion !== undefined && document.version !== opts.expectedVersion) return false;
|
||||
@@ -290,15 +290,25 @@ export class AttributionController implements vscode.Disposable {
|
||||
"(host minimized differently?) — the edit may be mis-attributed (INV-9).",
|
||||
);
|
||||
}
|
||||
if (ok) {
|
||||
// F6 (INV-18): a real machine landing — signal the baseline to advance so
|
||||
// this text never shows as a change in the diff view. Fire regardless of
|
||||
// attribution-match bookkeeping above; the landing happened either way.
|
||||
if (ok && opts?.landBaseline !== false) {
|
||||
// F6 (INV-18): a real machine landing — advance the baseline. F12 (INV-48)
|
||||
// suppresses this for optimistic apply: the proposed text is in the buffer
|
||||
// but the change stays PENDING (baseline at pre-proposal) until accept.
|
||||
this.applyEmitter.fire({ document });
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* F12/#64 (INV-51): fire the machine-landing signal WITHOUT applying text — used
|
||||
* by finalize-in-place, where the proposed text already landed in the buffer via
|
||||
* optimistic apply (`landBaseline:false`) and accept only needs to advance the
|
||||
* F6 baseline so the now-accepted change stops reading as pending.
|
||||
*/
|
||||
signalLanded(document: vscode.TextDocument): void {
|
||||
if (this.isTracked(document)) this.applyEmitter.fire({ document });
|
||||
}
|
||||
|
||||
// ---- PUC-4: persistence on save ----------------------------------------------------
|
||||
|
||||
private onDidSave(document: vscode.TextDocument): void {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { SidecarRouter } from "./sidecarRouter";
|
||||
import { DiffViewController } from "./diffViewController";
|
||||
import { TrackChangesPreviewController } from "./trackChangesPreview";
|
||||
import { LiveProgressUi } from "./liveProgressUi";
|
||||
import { EditorProposalController } from "./editorProposalController";
|
||||
import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath";
|
||||
|
||||
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
||||
@@ -25,6 +26,7 @@ export interface CowritingApi {
|
||||
trackChangesPreviewController: TrackChangesPreviewController;
|
||||
sidecarRouter: SidecarRouter;
|
||||
liveProgressUi: LiveProgressUi;
|
||||
editorProposalController: EditorProposalController;
|
||||
}
|
||||
|
||||
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
|
||||
@@ -115,6 +117,11 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
);
|
||||
context.subscriptions.push(trackChangesPreviewController);
|
||||
|
||||
// --- F12 (#64): the editor surface — optimistic-apply proposals into the buffer,
|
||||
// decorate the diff, and provide Accept ▾/Reject ▾ CodeLens (INV-48/49/52/53). ---
|
||||
const editorProposalController = new EditorProposalController(proposalController);
|
||||
context.subscriptions.push(editorProposalController);
|
||||
|
||||
// #46 (INV-42): accept every pending proposal on the active doc in one gesture
|
||||
// (also reachable from the preview toolbar's "Accept all" button). Reuses the
|
||||
// batched F4 seam + reports applied-vs-skipped.
|
||||
@@ -129,6 +136,18 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
}),
|
||||
);
|
||||
|
||||
// #64 (INV-53): reject every pending proposal on the active doc in one gesture.
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cowriting.rejectAllProposals", async () => {
|
||||
const doc = vscode.window.activeTextEditor?.document;
|
||||
if (!doc || doc.languageId !== "markdown") {
|
||||
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to reject its proposals.");
|
||||
return;
|
||||
}
|
||||
await trackChangesPreviewController.rejectAll(doc);
|
||||
}),
|
||||
);
|
||||
|
||||
// --- F6 machine-landing wiring — now for ANY authorable doc ---
|
||||
// The seam's single machine-landing signal advances the F6 baseline (INV-18);
|
||||
// the seam can now fire on out-of-folder files too, so wire it unconditionally.
|
||||
@@ -342,6 +361,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
trackChangesPreviewController,
|
||||
sidecarRouter,
|
||||
liveProgressUi,
|
||||
editorProposalController,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,14 @@ export interface Proposal {
|
||||
* back-compat with older sidecars) ⇒ a single-range proposal accepted whole.
|
||||
*/
|
||||
granularity?: "block" | "single";
|
||||
/**
|
||||
* F12/#64 (INV-48): the pre-apply text, captured when a proposal is
|
||||
* optimistically applied to the buffer. `replacement` is now in the buffer and
|
||||
* `fp.text` re-anchors to it, so `original` is the only record of what to revert
|
||||
* to (revert-in-place) and what to show struck in the `<del>` half. Absent on a
|
||||
* proposal created but not yet optimistically applied (or older sidecars).
|
||||
*/
|
||||
original?: string;
|
||||
}
|
||||
|
||||
export interface Artifact {
|
||||
@@ -252,6 +260,8 @@ export function serializeArtifact(a: Artifact): string {
|
||||
createdAt: p.createdAt,
|
||||
...(p.turnId !== undefined ? { turnId: p.turnId } : {}),
|
||||
...(p.instruction !== undefined ? { instruction: p.instruction } : {}),
|
||||
...(p.original !== undefined ? { original: p.original } : {}),
|
||||
...(p.granularity !== undefined ? { granularity: p.granularity } : {}),
|
||||
},
|
||||
p,
|
||||
),
|
||||
|
||||
+191
-4
@@ -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 {
|
||||
@@ -89,8 +91,9 @@ export class ProposalController implements vscode.Disposable {
|
||||
id: p.id,
|
||||
anchorStart: resolved === "orphaned" ? null : resolved.start,
|
||||
anchorEnd: resolved === "orphaned" ? null : resolved.end,
|
||||
replaced: fp?.text ?? "",
|
||||
replaced: p.original ?? 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,33 @@ 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)) {
|
||||
// INV-11 (applied path): if an external write mangled the optimistically-applied
|
||||
// text so the fingerprint no longer resolves, refuse finalize — same guard as the
|
||||
// legacy accept path. Direct finalizeInPlace calls (CodeLens Accept gesture where
|
||||
// the user may have edited inside the applied span) bypass this check intentionally.
|
||||
const hit = this.byId(docPath, proposalId);
|
||||
if (hit) {
|
||||
const document = this.openDoc(hit.state);
|
||||
if (document) {
|
||||
const fp = hit.state.artifact.anchors[hit.proposal.anchorId]?.fingerprint;
|
||||
const resolved = fp ? resolve(document.getText(), fp) : "orphaned";
|
||||
if (resolved === "orphaned") {
|
||||
if (!opts?.silent) {
|
||||
void vscode.window.showWarningMessage(
|
||||
"Cowriting: this proposal's target text changed or is missing — undo to restore it, or reject to discard (it is never applied by guess).",
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
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 +210,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 +292,158 @@ 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;
|
||||
// Reload-safety (INV-51/54): a proposal that already carries `original` was
|
||||
// optimistically applied in a PRIOR session — the buffer holds the applied text
|
||||
// and `fp` points at it, but this (fresh) controller's in-memory `applied` set is
|
||||
// empty. Re-applying would recapture `original` from the already-applied buffer
|
||||
// (= the replacement) and CLOBBER the true revert target, breaking Reject. Mark it
|
||||
// applied in memory and stop — `original` is captured exactly once, on first apply.
|
||||
if (proposal.original !== undefined) {
|
||||
state.applied.add(proposalId);
|
||||
this.renderAll(document);
|
||||
return true;
|
||||
}
|
||||
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));
|
||||
|
||||
@@ -37,6 +37,25 @@ export function removeProposal(artifact: Artifact, proposalId: string): boolean
|
||||
return artifact.proposals.length < before;
|
||||
}
|
||||
|
||||
/**
|
||||
* F12/#64 (INV-48): record a proposal as optimistically applied — store the
|
||||
* pre-apply `original` (for revert + the struck `<del>`) and re-point its anchor
|
||||
* fingerprint to the now-in-buffer applied text so `resolve()` finds it. Idempotent
|
||||
* shape: a second call simply overwrites with the same values.
|
||||
*/
|
||||
export function setProposalApplied(
|
||||
artifact: Artifact,
|
||||
proposalId: string,
|
||||
appliedFp: Fingerprint,
|
||||
original: string,
|
||||
): boolean {
|
||||
const p = artifact.proposals.find((x) => x.id === proposalId);
|
||||
if (!p) return false;
|
||||
p.original = original;
|
||||
artifact.anchors[p.anchorId] = { fingerprint: appliedFp };
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Markdown comment body: instruction header + fenced whole-range diff. */
|
||||
export function proposalBody(targetText: string, p: Proposal): string {
|
||||
const header = p.instruction ? `**Claude proposes** — _${p.instruction}_` : "**Claude proposes**";
|
||||
|
||||
+102
-7
@@ -263,6 +263,42 @@ export function wordEditHunks(currentText: string, rewrittenText: string): EditH
|
||||
return hunks;
|
||||
}
|
||||
|
||||
/** F12/#64 (INV-49/52): the editor render of one optimistically-applied proposal. */
|
||||
export interface DecorationPlan {
|
||||
/** buffer ranges of inserted (proposed) text → tinted (INV-52). */
|
||||
insertions: { start: number; end: number }[];
|
||||
/** struck original text shown as a non-editable hint at a buffer offset (INV-52). */
|
||||
deletions: { at: number; text: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* F12/#64 (INV-49): compute the editor decoration plan for a proposal whose applied
|
||||
* text occupies `[anchorStart, anchorStart+replacement.length)` in the buffer. The
|
||||
* SAME word diff the webview uses (`wordEditHunks`) drives it, so both surfaces show
|
||||
* the identical diff. A changed run maps to (a) an insertion range over the run's
|
||||
* applied text and (b) a deletion hint carrying the run's removed original text at
|
||||
* the run start; a pure insertion has no deletion hint; a pure deletion has only a
|
||||
* hint. Pure, vscode-free, deterministic.
|
||||
*/
|
||||
export function decorationPlan(anchorStart: number, original: string, replacement: string): DecorationPlan {
|
||||
const insertions: { start: number; end: number }[] = [];
|
||||
const deletions: { at: number; text: string }[] = [];
|
||||
// Walk the word diff once, tracking the applied-side offset as we consume parts.
|
||||
let appliedOffset = anchorStart;
|
||||
for (const part of diffWordsWithSpace(original, replacement)) {
|
||||
if (part.added) {
|
||||
insertions.push({ start: appliedOffset, end: appliedOffset + part.value.length });
|
||||
appliedOffset += part.value.length;
|
||||
} else if (part.removed) {
|
||||
deletions.push({ at: appliedOffset, text: part.value });
|
||||
// removed text is NOT in the applied buffer → appliedOffset does not advance
|
||||
} else {
|
||||
appliedOffset += part.value.length;
|
||||
}
|
||||
}
|
||||
return { insertions, deletions };
|
||||
}
|
||||
|
||||
const isWs = (c: string): boolean => /\s/.test(c);
|
||||
|
||||
/**
|
||||
@@ -701,6 +737,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 {
|
||||
@@ -716,8 +754,14 @@ function proposalBlockHtml(p: ProposalView, render: (src: string) => string): st
|
||||
const after = `<ins class="cw-add">${safe(p.replacement)}</ins>`;
|
||||
const actions =
|
||||
`<span class="cw-actions">` +
|
||||
`<button class="cw-accept" data-action="accept">✓</button>` +
|
||||
`<button class="cw-reject" data-action="reject">✗</button>` +
|
||||
`<span class="cw-btngroup">` +
|
||||
`<button class="cw-accept" data-action="accept">Accept</button>` +
|
||||
`<button class="cw-caret" data-action="acceptAll" title="Accept all pending proposals">▾</button>` +
|
||||
`</span>` +
|
||||
`<span class="cw-btngroup">` +
|
||||
`<button class="cw-reject" data-action="reject">Reject</button>` +
|
||||
`<button class="cw-caret" data-action="rejectAll" title="Reject all pending proposals">▾</button>` +
|
||||
`</span>` +
|
||||
`</span>`;
|
||||
return `<div class="cw-proposal${unanchored}" data-proposal-id="${md.utils.escapeHtml(p.id)}">${actions}${before}${after}</div>`;
|
||||
}
|
||||
@@ -747,6 +791,26 @@ function renderReviewOp(
|
||||
* INV-34). Deterministic: proposals in the same block are ordered by anchorStart
|
||||
* then id; trailing proposals keep input order.
|
||||
*/
|
||||
/**
|
||||
* F12/#64 (INV-50): `currentText` with every resolved pending proposal's applied
|
||||
* span reverted to its original (`replaced`) — the "landed" text the baseline diff
|
||||
* should run against, so a pending proposal renders ONCE (as a proposal), never also
|
||||
* as a landed change. Reverts high→low so earlier offsets stay valid. Pure. The
|
||||
* preview's summary tally diffs against this too, so the toolbar count matches the
|
||||
* body (a pending change is not double-counted as both a landed add/remove and a
|
||||
* proposal).
|
||||
*/
|
||||
export function landedTextOf(currentText: string, proposals: ProposalView[]): string {
|
||||
const pendingApplied = proposals
|
||||
.filter((p) => p.anchorStart !== null)
|
||||
.sort((a, b) => b.anchorStart! - a.anchorStart!);
|
||||
let landedText = currentText;
|
||||
for (const p of pendingApplied) {
|
||||
landedText = landedText.slice(0, p.anchorStart!) + p.replaced + landedText.slice(p.anchorEnd!);
|
||||
}
|
||||
return landedText;
|
||||
}
|
||||
|
||||
export function renderReview(
|
||||
baselineText: string,
|
||||
currentText: string,
|
||||
@@ -755,8 +819,18 @@ export function renderReview(
|
||||
opts: RenderOptions = {},
|
||||
): string {
|
||||
const render = opts.render ?? defaultRender;
|
||||
const ranges = splitBlocksWithRanges(currentText);
|
||||
const ops = diffBlocks(baselineText, currentText);
|
||||
|
||||
// F12/#64 (INV-50): with optimistic apply the proposed text is already in
|
||||
// `currentText`, so a naive baseline→current diff would render each proposed
|
||||
// change BOTH as a landed diff and as its proposal block. Diff against the
|
||||
// "landed" text (current minus pending proposals) so they render once.
|
||||
const pendingApplied = proposals
|
||||
.filter((p) => p.anchorStart !== null)
|
||||
.sort((a, b) => b.anchorStart! - a.anchorStart!);
|
||||
const landedText = landedTextOf(currentText, proposals);
|
||||
|
||||
const ranges = splitBlocksWithRanges(landedText);
|
||||
const ops = diffBlocks(baselineText, landedText);
|
||||
// #48: right after a PIN (baseline reason "pinned") with no changes since, the
|
||||
// panel is fully clean: no change marks (already absent) AND no authorship
|
||||
// coloring, so the pin reads as "this is my clean starting point". Skip
|
||||
@@ -766,7 +840,28 @@ export function renderReview(
|
||||
// its authorship coloring (F10 INV-33), so this is gated on the pin specifically.
|
||||
const clean = opts.pinned === true && ops.every((o) => o.kind === "unchanged");
|
||||
|
||||
// Associate each resolved proposal with the current-side block index whose range
|
||||
// Map a currentText offset to its landedText offset (account for reverted spans
|
||||
// that precede it; reverts were applied high→low so the cumulative delta is stable).
|
||||
const toLanded = (curOff: number): number => {
|
||||
let delta = 0;
|
||||
for (const p of [...pendingApplied].sort((a, b) => a.anchorStart! - b.anchorStart!)) {
|
||||
if (p.anchorStart! < curOff) delta += p.replaced.length - (p.anchorEnd! - p.anchorStart!);
|
||||
}
|
||||
return curOff + delta;
|
||||
};
|
||||
|
||||
// F12/#64 (INV-49/50): blocks are split from `landedText`, so `blk.start` is a
|
||||
// landedText offset — but `authorSpans` arrive in `currentText` coordinates. A
|
||||
// colored block sitting AFTER a length-changing pending proposal would otherwise
|
||||
// be mis-colored (the offsets diverge by the revert delta). Map the spans into
|
||||
// landedText coordinates once so authorship coloring stays aligned.
|
||||
const landedSpans: AuthorSpan[] = authorSpans.map((s) => ({
|
||||
...s,
|
||||
start: toLanded(s.start),
|
||||
end: toLanded(s.end),
|
||||
}));
|
||||
|
||||
// Associate each resolved proposal with the landedText block index whose range
|
||||
// it anchors into: the largest block with start <= anchorStart (the containing
|
||||
// block, or the nearest preceding block when the anchor sits in a gap). A
|
||||
// resolved anchor before all blocks, and every unresolved proposal, trails.
|
||||
@@ -778,7 +873,7 @@ export function renderReview(
|
||||
const byBlock = new Map<number, ProposalView[]>();
|
||||
const trailing: ProposalView[] = [];
|
||||
for (const p of proposals) {
|
||||
const j = p.anchorStart === null ? -1 : blockOf(p.anchorStart);
|
||||
const j = p.anchorStart === null ? -1 : blockOf(toLanded(p.anchorStart));
|
||||
if (j < 0) {
|
||||
trailing.push(p);
|
||||
continue;
|
||||
@@ -795,7 +890,7 @@ export function renderReview(
|
||||
const blockIndex = op.kind === "removed" ? -1 : ci;
|
||||
const blk = op.kind === "removed" ? undefined : ranges[ci++];
|
||||
const colored = (raw: string): string =>
|
||||
blk && !clean ? colorByAuthor(raw, blk.start, authorSpans, render) : render(raw);
|
||||
blk && !clean ? colorByAuthor(raw, blk.start, landedSpans, render) : render(raw);
|
||||
bodyParts.push(renderReviewOp(op, render, colored, srcAttr(blk)));
|
||||
const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined;
|
||||
if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render));
|
||||
|
||||
@@ -13,7 +13,7 @@ import * as vscode from "vscode";
|
||||
import type { DiffViewController } from "./diffViewController";
|
||||
import type { AttributionController } from "./attributionController";
|
||||
import type { ProposalController } from "./proposalController";
|
||||
import { renderReview, renderPlain, diffBlocks, diffToBlockHunks, type BlockOp } from "./trackChangesModel";
|
||||
import { renderReview, renderPlain, diffBlocks, diffToBlockHunks, landedTextOf, type BlockOp } from "./trackChangesModel";
|
||||
import { buildFingerprint } from "./anchorer";
|
||||
import { isAuthorable } from "./workspacePath";
|
||||
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
|
||||
@@ -44,7 +44,8 @@ type ToolbarMsg =
|
||||
| { type: "pinBaseline" }
|
||||
| { type: "askClaude"; scope: "document" }
|
||||
| { type: "askClaude"; scope: "selection"; start: number; end: number }
|
||||
| { type: "acceptAll" };
|
||||
| { type: "acceptAll" }
|
||||
| { type: "rejectAll" };
|
||||
|
||||
export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
@@ -196,8 +197,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
.acceptById(this.proposals.keyFor(document), m.proposalId)
|
||||
.then(() => this.refresh(document));
|
||||
} else if (m?.type === "reject" && m.proposalId) {
|
||||
this.proposals.rejectById(this.proposals.keyFor(document), m.proposalId);
|
||||
this.refresh(document);
|
||||
void this.proposals.rejectByIdInPlace(this.proposals.keyFor(document), m.proposalId).then(() => this.refresh(document));
|
||||
} else if (m?.type === "pinBaseline") {
|
||||
// F6 baseline store re-render arrives via the onDidChangeBaseline subscription.
|
||||
this.diffView.pin(document);
|
||||
@@ -208,6 +208,8 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
} else if (m?.type === "acceptAll") {
|
||||
// #46 (INV-42): batch-accept every pending proposal on this doc, then report.
|
||||
void this.acceptAll(document);
|
||||
} else if (m?.type === "rejectAll") {
|
||||
void this.rejectAll(document);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +230,17 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
);
|
||||
}
|
||||
|
||||
/** #64 (INV-53): revert every pending proposal on the document; report the count. */
|
||||
async rejectAll(document: vscode.TextDocument): Promise<void> {
|
||||
const { reverted } = await this.proposals.rejectAll(document);
|
||||
this.refresh(document);
|
||||
if (reverted > 0) {
|
||||
void vscode.window.showInformationMessage(
|
||||
`Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* F11 (PUC-3/4): prompt host-side for the instruction (keeps the LLM/secret
|
||||
* surface out of the sealed webview, INV-8/35), run the edit turn, and surface
|
||||
@@ -359,9 +372,13 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
}
|
||||
const spans = this.attribution.spansFor(document);
|
||||
const proposals = this.proposals.listProposals(document);
|
||||
// F12/#64 (INV-50): count added/removed against the LANDED text (current minus
|
||||
// pending proposals), matching the body — a pending change shows once, as a
|
||||
// proposal, and is not also tallied as a landed add/remove.
|
||||
const landedOps = diffBlocks(baselineText, landedTextOf(current, proposals));
|
||||
const summary = {
|
||||
added: ops.filter((o) => o.kind === "added").length,
|
||||
removed: ops.filter((o) => o.kind === "removed").length,
|
||||
added: landedOps.filter((o) => o.kind === "added").length,
|
||||
removed: landedOps.filter((o) => o.kind === "removed").length,
|
||||
proposals: proposals.length,
|
||||
};
|
||||
void panel.webview.postMessage({
|
||||
|
||||
Reference in New Issue
Block a user