diff --git a/media/preview.css b/media/preview.css index cdfdde1..937e63f 100644 --- a/media/preview.css +++ b/media/preview.css @@ -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; } diff --git a/media/preview.ts b/media/preview.ts index b42e8ab..f4769e2 100644 --- a/media/preview.ts +++ b/media/preview.ts @@ -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(".cw-actions button"); if (!btn) return; - const block = btn.closest(".cw-proposal"); - const id = block?.dataset.proposalId; + const id = btn.closest(".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 }); } diff --git a/package.json b/package.json index d020108..e7b4f83 100644 --- a/package.json +++ b/package.json @@ -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": [ diff --git a/src/extension.ts b/src/extension.ts index b5d65a1..b443da0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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. diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts index 57668e9..abec916 100644 --- a/src/trackChangesModel.ts +++ b/src/trackChangesModel.ts @@ -754,8 +754,14 @@ function proposalBlockHtml(p: ProposalView, render: (src: string) => string): st const after = `${safe(p.replacement)}`; const actions = `` + - `` + - `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + ``; return `
${actions}${before}${after}
`; } diff --git a/src/trackChangesPreview.ts b/src/trackChangesPreview.ts index 9456ec0..42f8be0 100644 --- a/src/trackChangesPreview.ts +++ b/src/trackChangesPreview.ts @@ -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 { + 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 diff --git a/test/e2e/suite/f12InlineDiff.test.ts b/test/e2e/suite/f12InlineDiff.test.ts index 192364c..711baf2 100644 --- a/test/e2e/suite/f12InlineDiff.test.ts +++ b/test/e2e/suite/f12InlineDiff.test.ts @@ -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"); + }); +}); diff --git a/test/trackChangesModel.test.ts b/test/trackChangesModel.test.ts index 85efc0d..a6b42f4 100644 --- a/test/trackChangesModel.test.ts +++ b/test/trackChangesModel.test.ts @@ -231,7 +231,7 @@ describe("renderReview", () => { const html = renderReview("hello world", "hello", [], []); expect(html).toMatch(/|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(/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";