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
2 changed files with 32 additions and 1 deletions
Showing only changes of commit 38053239fa - Show all commits
+12 -1
View File
@@ -828,6 +828,17 @@ export function renderReview(
return curOff + delta; 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 // Associate each resolved proposal with the landedText block index whose range
// it anchors into: the largest block with start <= anchorStart (the containing // 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 // block, or the nearest preceding block when the anchor sits in a gap). A
@@ -857,7 +868,7 @@ export function renderReview(
const blockIndex = op.kind === "removed" ? -1 : ci; const blockIndex = op.kind === "removed" ? -1 : ci;
const blk = op.kind === "removed" ? undefined : ranges[ci++]; const blk = op.kind === "removed" ? undefined : ranges[ci++];
const colored = (raw: string): string => 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))); bodyParts.push(renderReviewOp(op, render, colored, srcAttr(blk)));
const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined; const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined;
if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render)); if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render));
+20
View File
@@ -392,6 +392,26 @@ describe("renderReview", () => {
// the applied paragraph is NOT also emitted as a word-merged changed block // 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 expect(html).not.toContain("<del>brown</del>"); // no double-render of the change as a baseline diff
}); });
test("renderReview keeps author coloring aligned on a block AFTER a length-changing pending proposal (INV-49/50)", () => {
// block 1 has a pending proposal whose applied text is MUCH LONGER than its
// original; block 2 carries a Claude authorship span (in currentText coords).
const baseline = "one short.\n\ntwo stable here.\n";
const current = "one MUCH LONGER REPLACED TEXT.\n\ntwo stable here.\n";
const proposals: ProposalView[] = [{
id: "pr_1",
anchorStart: current.indexOf("one MUCH LONGER REPLACED TEXT."),
anchorEnd: current.indexOf("one MUCH LONGER REPLACED TEXT.") + "one MUCH LONGER REPLACED TEXT.".length,
replaced: "one short.",
replacement: "one MUCH LONGER REPLACED TEXT.",
original: "one short.",
}];
const sStart = current.indexOf("stable");
const authorSpans = [{ start: sStart, end: sStart + "stable".length, author: "claude" as const }];
const html = renderReview(baseline, current, authorSpans, proposals);
// the Claude coloring wraps "stable" exactly — not shifted by the proposal's length delta.
expect(html).toMatch(/<span class="cw-by-claude">stable<\/span>/);
});
}); });
import { renderTrackChanges as rtc2 } from "../src/trackChangesModel"; import { renderTrackChanges as rtc2 } from "../src/trackChangesModel";