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 = {},
|
opts: RenderOptions = {},
|
||||||
): string {
|
): string {
|
||||||
const render = opts.render ?? defaultRender;
|
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
|
// #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
|
// 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
|
// 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.
|
// 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");
|
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
|
// 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
|
||||||
// resolved anchor before all blocks, and every unresolved proposal, trails.
|
// resolved anchor before all blocks, and every unresolved proposal, trails.
|
||||||
@@ -816,7 +840,7 @@ export function renderReview(
|
|||||||
const byBlock = new Map<number, ProposalView[]>();
|
const byBlock = new Map<number, ProposalView[]>();
|
||||||
const trailing: ProposalView[] = [];
|
const trailing: ProposalView[] = [];
|
||||||
for (const p of proposals) {
|
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) {
|
if (j < 0) {
|
||||||
trailing.push(p);
|
trailing.push(p);
|
||||||
continue;
|
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)", () => {
|
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 () => {
|
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");
|
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);
|
const b = renderReview(doc, doc, [], proposals);
|
||||||
expect(a).toBe(b);
|
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";
|
import { renderTrackChanges as rtc2 } from "../src/trackChangesModel";
|
||||||
|
|||||||
Reference in New Issue
Block a user