diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts index 02dc7ea..84dbfa8 100644 --- a/src/trackChangesModel.ts +++ b/src/trackChangesModel.ts @@ -793,8 +793,22 @@ 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 + // `currentText` with every resolved pending proposal reverted to its original, + // so the landed diff excludes pending proposals; they render once, as proposals. + 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!); + } + + 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 @@ -804,7 +818,17 @@ 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; + }; + + // 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. @@ -816,7 +840,7 @@ export function renderReview( const byBlock = new Map(); 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; diff --git a/test/e2e/suite/f12InlineDiff.test.ts b/test/e2e/suite/f12InlineDiff.test.ts index a1d6366..192364c 100644 --- a/test/e2e/suite/f12InlineDiff.test.ts +++ b/test/e2e/suite/f12InlineDiff.test.ts @@ -117,6 +117,18 @@ suite("F12 inline diff — finalize / revert in place (#64, INV-51)", () => { }); }); +suite("F12 inline diff — INV-50 listProposals.replaced", () => { + test("listProposals reports the original as `replaced` after optimistic apply", async () => { + const { doc } = await freshDoc("docs/f12-replaced.md", "# R\n\nbrown here.\n"); + const api = await getApi(); + const ctl = api.trackChangesPreviewController; + ctl.setEditTurnForTest(async () => ({ replacement: "# R\n\nred here.\n", model: "m", sessionId: "s" })); + await ctl.runEditAndPropose(doc, { kind: "document" }, "x"); + await settle(); await settle(); + assert.strictEqual(api.proposalController.listProposals(doc)[0].replaced, "brown here."); + }); +}); + suite("F12 inline diff — editor surface (#64, INV-48/52)", () => { test("proposing optimistically applies into the editor and the buffer is the accepted result", async () => { const { doc } = await freshDoc("docs/f12-editor.md", "# E\n\nThe brown fox runs.\n"); diff --git a/test/trackChangesModel.test.ts b/test/trackChangesModel.test.ts index 0b396fa..800c590 100644 --- a/test/trackChangesModel.test.ts +++ b/test/trackChangesModel.test.ts @@ -375,6 +375,23 @@ describe("renderReview", () => { const b = renderReview(doc, doc, [], proposals); expect(a).toBe(b); }); + + test("renderReview renders an applied proposal ONCE, not also as a landed diff (INV-50)", () => { + const baseline = "# T\n\nThe brown fox.\n"; + const current = "# T\n\nThe red fox.\n"; // proposal already optimistically applied + const proposals: ProposalView[] = [{ + id: "pr_1", + anchorStart: current.indexOf("The red fox."), + anchorEnd: current.indexOf("The red fox.") + "The red fox.".length, + replaced: "The brown fox.", // original + replacement: "The red fox.", + }]; + const html = renderReview(baseline, current, [], proposals); + // exactly one proposal block + expect((html.match(/cw-proposal/g) ?? []).length).toBe(1); + // the applied paragraph is NOT also emitted as a word-merged changed block + expect(html).not.toContain("brown"); // no double-render of the change as a baseline diff + }); }); import { renderTrackChanges as rtc2 } from "../src/trackChangesModel";