From d59f22b22fe93f7f2c8d328a119820e064891d84 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Fri, 12 Jun 2026 00:09:45 -0700 Subject: [PATCH] F10 SLICE-2: add ProposalView + renderReview combined on-state render (#29) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/trackChangesModel.ts | 70 ++++++++++++++++++++++++++++++++++ test/trackChangesModel.test.ts | 40 +++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts index 8e8929a..6c28726 100644 --- a/src/trackChangesModel.ts +++ b/src/trackChangesModel.ts @@ -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 ? `${safe(p.replaced)}` : ""; + const after = `${safe(p.replacement)}`; + const actions = + `` + + `` + + `` + + ``; + return `
${actions}${before}${after}
`; +} + +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 / + return `
${colored(op.block.raw)}
`; +} + +/** + * 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). */ export function renderTrackChanges( baselineText: string, diff --git a/test/trackChangesModel.test.ts b/test/trackChangesModel.test.ts index 6476723..0a27195 100644 --- a/test/trackChangesModel.test.ts +++ b/test/trackChangesModel.test.ts @@ -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(/[^<]*world[^<]*<\/ins>|cw-by-human/); + }); + test("renderReview: deletion since baseline renders struck del/cw-del", () => { + const html = renderReview("hello world", "hello", [], []); + expect(html).toMatch(/|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(/]*>[^<]*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"; describe("renderTrackChanges — intra-diagram mermaid (#22)", () => {