F12: inline editable proposed-change diff in the Markdown editor + Accept/Reject control parity (#64) #66
@@ -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<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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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("<del>brown</del>"); // no double-render of the change as a baseline diff
|
||||
});
|
||||
});
|
||||
|
||||
import { renderTrackChanges as rtc2 } from "../src/trackChangesModel";
|
||||
|
||||
Reference in New Issue
Block a user