F12: inline editable proposed-change diff in the Markdown editor + Accept/Reject control parity (#64) #66

Merged
benstull merged 12 commits from s58-inline-editor-diff into main 2026-06-26 15:28:15 +00:00
8 changed files with 118 additions and 9 deletions
Showing only changes of commit f4594daa6f - Show all commits
+5
View File
@@ -89,6 +89,11 @@ pre.mermaid[data-cw-error] { color: var(--vscode-errorForeground); }
}
.cw-accept:hover { background: var(--vscode-testing-iconPassed, #2ea043); color: #fff; }
.cw-reject:hover { background: var(--vscode-errorForeground, #f14c4c); color: #fff; }
.cw-btngroup { display: inline-flex; }
.cw-btngroup .cw-caret { border-left: none; padding: 0.1em 0.25em; }
.cw-actions .cw-accept { font-weight: 600; }
.cw-accept:hover, .cw-btngroup:has(.cw-accept) .cw-caret:hover { background: var(--vscode-testing-iconPassed, #2ea043); color: #fff; }
.cw-reject:hover, .cw-btngroup:has(.cw-reject) .cw-caret:hover { background: var(--vscode-errorForeground, #f14c4c); color: #fff; }
/* F7.1 (#22) intra-diagram mermaid diff legend. */
.cw-mermaid-legend { display: flex; gap: 0.6rem; font-size: 0.75em; opacity: 0.85; margin: 0.2rem 0 0.6rem; }
+4 -3
View File
@@ -94,13 +94,14 @@ acceptAllEl?.addEventListener("click", () => {
vscodeApi.postMessage({ type: "acceptAll" });
});
// F10: delegated ✓/✗ accept/reject of pending proposals (routed back to the F4 seam).
// F12/#64: Accept/Reject (+ caret → accept-all/reject-all) on a proposal block.
body.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement)?.closest<HTMLElement>(".cw-actions button");
if (!btn) return;
const block = btn.closest<HTMLElement>(".cw-proposal");
const id = block?.dataset.proposalId;
const id = btn.closest<HTMLElement>(".cw-proposal")?.dataset.proposalId;
const action = btn.dataset.action;
if (action === "acceptAll") return void vscodeApi.postMessage({ type: "acceptAll" });
if (action === "rejectAll") return void vscodeApi.postMessage({ type: "rejectAll" });
if (id && (action === "accept" || action === "reject")) {
vscodeApi.postMessage({ type: action, proposalId: id });
}
+27
View File
@@ -113,6 +113,21 @@
"command": "cowriting.acceptAllProposals",
"title": "Accept All Claude Proposals",
"category": "Cowriting"
},
{
"command": "cowriting.rejectAllProposals",
"title": "Reject All Claude Proposals",
"category": "Cowriting"
},
{
"command": "cowriting.proposalAcceptMenu",
"title": "Accept Claude Proposal",
"category": "Cowriting"
},
{
"command": "cowriting.proposalRejectMenu",
"title": "Reject Claude Proposal",
"category": "Cowriting"
}
],
"menus": {
@@ -160,6 +175,18 @@
{
"command": "cowriting.acceptAllProposals",
"when": "editorLangId == markdown"
},
{
"command": "cowriting.rejectAllProposals",
"when": "editorLangId == markdown"
},
{
"command": "cowriting.proposalAcceptMenu",
"when": "false"
},
{
"command": "cowriting.proposalRejectMenu",
"when": "false"
}
],
"editor/title": [
+12
View File
@@ -144,6 +144,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.
+8 -2
View File
@@ -754,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>`;
}
+16 -3
View File
@@ -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[] = [];
@@ -190,8 +191,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);
@@ -202,6 +202,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);
}
}
@@ -222,6 +224,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"}.`,
);
}
}
/**
* Where the inline Ask-Claude input anchors: a range edit pins to its selection;
* a whole-document edit pins to the editor's cursor line if this document is the
+33
View File
@@ -164,3 +164,36 @@ suite("F12 inline diff — editor surface (#64, INV-48/52)", () => {
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "cleared");
});
});
suite("F12 inline diff — control parity (#64, INV-53)", () => {
test("reject from the webview reverts in place; rejectAll clears every proposal", async () => {
const { doc, key } = await freshDoc("docs/f12-parity.md", "# P\n\nuno aaa.\n\ndos bbb.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
ctl.setEditTurnForTest(async () => ({ replacement: "# P\n\nuno AAA.\n\ndos BBB.\n", model: "m", sessionId: "s" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "up");
await settle(); await settle();
assert.ok(doc.getText().includes("AAA") && doc.getText().includes("BBB"));
// reject ONE via the webview intent → that block reverts, the other stays applied
ctl.receiveMessage(key, { type: "reject", proposalId: ids[0] });
await settle(); await settle();
assert.ok(doc.getText().includes("uno aaa.") && doc.getText().includes("BBB"), "one reverted, one applied");
// rejectAll via the command → all gone, document restored
ctl.receiveMessage(key, { type: "rejectAll" });
await settle(); await settle();
assert.strictEqual(doc.getText(), "# P\n\nuno aaa.\n\ndos bbb.\n", "document restored");
assert.strictEqual(api.proposalController.listProposals(doc).length, 0);
});
test("cowriting.rejectAllProposals is registered + markdown-guarded", async () => {
const all = await vscode.commands.getCommands(true);
assert.ok(all.includes("cowriting.rejectAllProposals"));
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find(
(m) => m.command === "cowriting.rejectAllProposals",
);
assert.ok(entry && /editorLangId == markdown/.test(entry.when ?? ""), "palette-guarded on markdown");
});
});
+13 -1
View File
@@ -231,7 +231,7 @@ describe("renderReview", () => {
const html = renderReview("hello world", "hello", [], []);
expect(html).toMatch(/<del>|cw-del/);
});
test("renderReview: a pending proposal renders a blue block with data-proposal-id and ✓/✗ actions", () => {
test("renderReview: a pending proposal renders a blue block with data-proposal-id and Accept/Reject actions", () => {
const proposals: ProposalView[] = [{ id: "p1", anchorStart: 0, anchorEnd: 5, replaced: "hello", replacement: "goodbye" }];
const html = renderReview("hello", "hello", [], proposals);
expect(html).toContain('class="cw-proposal"');
@@ -412,6 +412,18 @@ describe("renderReview", () => {
// the Claude coloring wraps "stable" exactly — not shifted by the proposal's length delta.
expect(html).toMatch(/<span class="cw-by-claude">stable<\/span>/);
});
test("proposalBlockHtml renders Accept/Reject controls with dropdown carets (#64)", () => {
const html = renderReview("a\n", "b\n", [], [
{ id: "pr_1", anchorStart: 0, anchorEnd: 1, replaced: "a", replacement: "b" },
]);
expect(html).toMatch(/data-action="accept"/);
expect(html).toMatch(/data-action="reject"/);
expect(html).toMatch(/data-action="acceptAll"/);
expect(html).toMatch(/data-action="rejectAll"/);
expect(html).toMatch(/Accept/);
expect(html).toMatch(/Reject/);
});
});
import { renderTrackChanges as rtc2 } from "../src/trackChangesModel";