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:
Ben Stull
2026-06-12 00:09:45 -07:00
parent 0311ad89ba
commit d59f22b22f
2 changed files with 110 additions and 0 deletions
+70
View File
@@ -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). */
export function renderTrackChanges(
baselineText: string,