F10 SLICE-2: add ProposalView + renderReview combined on-state render (#29)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -410,6 +410,76 @@ export function renderPlain(currentText: string, opts: RenderOptions = {}): stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProposalView {
|
||||||
|
id: string;
|
||||||
|
/** resolved offsets in currentText; null when the anchor did not resolve. */
|
||||||
|
anchorStart: number | null;
|
||||||
|
anchorEnd: number | null;
|
||||||
|
/** the text the proposal would replace (fp.text), for the struck "before". */
|
||||||
|
replaced: string;
|
||||||
|
/** the proposed replacement text. */
|
||||||
|
replacement: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function proposalBlockHtml(p: ProposalView, render: (src: string) => string): string {
|
||||||
|
const safe = (src: string): string => {
|
||||||
|
try {
|
||||||
|
return render(src);
|
||||||
|
} catch (err) {
|
||||||
|
return chip(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const unanchored = p.anchorStart === null ? " cw-proposal-unanchored" : "";
|
||||||
|
const before = p.replaced ? `<del class="cw-del">${safe(p.replaced)}</del>` : "";
|
||||||
|
const after = `<ins class="cw-add">${safe(p.replacement)}</ins>`;
|
||||||
|
const actions =
|
||||||
|
`<span class="cw-actions">` +
|
||||||
|
`<button class="cw-accept" data-action="accept">✓</button>` +
|
||||||
|
`<button class="cw-reject" data-action="reject">✗</button>` +
|
||||||
|
`</span>`;
|
||||||
|
return `<div class="cw-proposal${unanchored}" data-proposal-id="${p.id}">${actions}${before}${after}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReviewOp(
|
||||||
|
op: BlockOp,
|
||||||
|
render: (src: string) => string,
|
||||||
|
colored: (raw: string) => string,
|
||||||
|
): string {
|
||||||
|
if (op.kind === "removed") return renderOp(op, render);
|
||||||
|
if (op.kind === "changed" && op.atomic) return renderOp(op, render);
|
||||||
|
if (op.kind === "changed") return renderOp(op, render); // word-merged <ins>/<del>
|
||||||
|
return `<div class="cw-blk ${op.kind === "added" ? "cw-added" : "cw-unchanged"}">${colored(op.block.raw)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On-state body (INV-33): the F7 baseline diff — added/changed PROSE author-colored
|
||||||
|
* via colorByAuthor (F9 sentinels), deletions struck — overlaid with F4 pending
|
||||||
|
* proposals as blue cw-proposal blocks (✓/✗). One pass, pure, vscode-free.
|
||||||
|
* Resolved proposals append after the diff body; unresolved ones append as trailing
|
||||||
|
* cw-proposal-unanchored blocks (never dropped — INV-34).
|
||||||
|
*/
|
||||||
|
export function renderReview(
|
||||||
|
baselineText: string,
|
||||||
|
currentText: string,
|
||||||
|
authorSpans: AuthorSpan[],
|
||||||
|
proposals: ProposalView[],
|
||||||
|
opts: RenderOptions = {},
|
||||||
|
): string {
|
||||||
|
const render = opts.render ?? defaultRender;
|
||||||
|
const ranges = splitBlocksWithRanges(currentText);
|
||||||
|
const ops = diffBlocks(baselineText, currentText);
|
||||||
|
const colored = (raw: string): string => {
|
||||||
|
const blk = ranges.find((r) => r.raw === raw);
|
||||||
|
if (!blk) return render(raw);
|
||||||
|
return colorByAuthor(raw, blk.start, authorSpans, render);
|
||||||
|
};
|
||||||
|
const bodyParts = ops.map((op) => renderReviewOp(op, render, colored));
|
||||||
|
const anchored = proposals.filter((p) => p.anchorStart !== null);
|
||||||
|
const unanchored = proposals.filter((p) => p.anchorStart === null);
|
||||||
|
const proposalParts = [...anchored, ...unanchored].map((p) => proposalBlockHtml(p, render));
|
||||||
|
return [...bodyParts, ...proposalParts].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
/** Pure entry point: annotated HTML body for the preview (INV-22). */
|
/** Pure entry point: annotated HTML body for the preview (INV-22). */
|
||||||
export function renderTrackChanges(
|
export function renderTrackChanges(
|
||||||
baselineText: string,
|
baselineText: string,
|
||||||
|
|||||||
@@ -257,6 +257,46 @@ describe("renderPlain", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { renderReview, type ProposalView } from "../src/trackChangesModel";
|
||||||
|
|
||||||
|
describe("renderReview", () => {
|
||||||
|
test("renderReview: human addition since baseline renders green ins / cw-by-human", () => {
|
||||||
|
const html = renderReview("hello", "hello world", [{ start: 6, end: 11, author: "human" }], []);
|
||||||
|
expect(html).toMatch(/<ins>[^<]*world[^<]*<\/ins>|cw-by-human/);
|
||||||
|
});
|
||||||
|
test("renderReview: deletion since baseline renders struck del/cw-del", () => {
|
||||||
|
const html = renderReview("hello world", "hello", [], []);
|
||||||
|
expect(html).toMatch(/<del>|cw-del/);
|
||||||
|
});
|
||||||
|
test("renderReview: a pending proposal renders a blue block with data-proposal-id and ✓/✗ 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"');
|
||||||
|
expect(html).toContain('data-proposal-id="p1"');
|
||||||
|
expect(html).toContain("cw-actions");
|
||||||
|
expect(html).toContain("goodbye");
|
||||||
|
expect(html).toMatch(/<del[^>]*>[^<]*hello[^<]*<\/del>|cw-del/);
|
||||||
|
});
|
||||||
|
test("renderReview: an unresolved proposal renders as a trailing block (never dropped)", () => {
|
||||||
|
const proposals: ProposalView[] = [{ id: "p2", anchorStart: null, anchorEnd: null, replaced: "x", replacement: "y" }];
|
||||||
|
const html = renderReview("a", "a", [], proposals);
|
||||||
|
expect(html).toContain('data-proposal-id="p2"');
|
||||||
|
expect(html).toContain("cw-proposal-unanchored");
|
||||||
|
});
|
||||||
|
test("renderReview is deterministic (same inputs → identical HTML)", () => {
|
||||||
|
const a = renderReview("hello", "hello world", [{ start: 6, end: 11, author: "human" }], []);
|
||||||
|
const b = renderReview("hello", "hello world", [{ start: 6, end: 11, author: "human" }], []);
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
test("renderReview: an atomic mermaid change is diffed whole (no inner author sentinels)", () => {
|
||||||
|
const base = "```mermaid\nflowchart LR\n A --> B\n```";
|
||||||
|
const cur = "```mermaid\nflowchart LR\n A --> C\n```";
|
||||||
|
const html = renderReview(base, cur, [], []);
|
||||||
|
expect(html).toContain("mermaid");
|
||||||
|
expect(html).not.toContain("cw-by-");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
import { renderTrackChanges as rtc2 } from "../src/trackChangesModel";
|
import { renderTrackChanges as rtc2 } from "../src/trackChangesModel";
|
||||||
|
|
||||||
describe("renderTrackChanges — intra-diagram mermaid (#22)", () => {
|
describe("renderTrackChanges — intra-diagram mermaid (#22)", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user