2f6008ba2b
Document-edit flow SLICE-2 (specs/coauthoring-document-edit-flow.md §7.2,
INV-39/40/41 — P1 "too much to review"). A whole-document rewrite now proposes
ONE F4 proposal per CHANGED BLOCK (the unit a human reviews), but accepting a
block reconciles attribution at WORD granularity (the unit F3 records). Block =
decision unit; word = attribution unit. Supersedes INV-37's per-word cut for
document edits; selection edits unchanged.
- trackChangesModel.ts: new pure diffToBlockHunks(current, rewritten) — block-key
alignment (reusing diffArrays/diffBlocks keying): an isolated changed block →
one block-aligned hunk → the rewritten block raw (a code/mermaid fence is one
atomic whole-fence hunk, INV-23); insert/delete runs → one gap-span hunk over
the inter-anchor region (separators included) so reconstruction stays exact; a
zero-width gap-span is anchored (INV-41). Also split diffToHunks into the raw,
un-anchored wordEditHunks + the anchoring wrapper (the anchoring could grow an
insertion to overlap an adjacent hunk, corrupting a batch apply — a latent bug
that only surfaced once hunks are applied as a batch).
- trackChangesPreview.ts: runEditAndPropose document branch uses diffToBlockHunks
and tags each proposal granularity:"block".
- model.ts / proposalModel.ts: additive optional Proposal.granularity
("block"|"single"; absent ⇒ single, back-compat — no migration).
- proposalController.ts: accept of a block proposal runs an intra-block word
sub-diff (wordEditHunks, disjoint) and applies one applyAgentEdit per changed
run, descending offset — only the words Claude changed land Claude-attributed;
unchanged spans keep prior authorship (INV-40).
- Tests: diffToBlockHunks unit (reconstruction + fence atomic + add/remove);
f12Review host E2E (M blocks→M proposals, unchanged→none, fence atomic, INV-40
attribution, INV-41 insertion accept); updated the f11 document-path E2E to
per-block (INV-39 supersedes INV-37); MANUAL-SMOKE-F12 §2.
Seam note: pendingEdits.matchEvent resolves one registration per change event, so
INV-40's per-run attribution is sequential applyAgentEdit calls (N undo steps),
not one multi-replace WorkspaceEdit — see transcript Deferred decisions.
214 unit + 69/5 host E2E green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
719 lines
28 KiB
TypeScript
719 lines
28 KiB
TypeScript
/**
|
|
* trackChangesModel — F7 pure, vscode-free render engine (spec §6.2, INV-22/23).
|
|
*
|
|
* `renderTrackChanges(baselineText, currentText)` returns the annotated HTML body
|
|
* for the preview webview: a block-level diff (LCS over normalized blocks) with
|
|
* word-level `<ins>`/`<del>` refinement inside changed PROSE blocks, and CODE /
|
|
* MERMAID fences kept ATOMIC (INV-23 — never word-refined, never partially
|
|
* rendered). Deterministic: same inputs → identical HTML (INV-22). No vscode, no
|
|
* DOM — `markdown-it` and `diff` are libraries; mermaid runs later in the webview.
|
|
*/
|
|
import MarkdownIt from "markdown-it";
|
|
import { diffArrays, diffWords, diffWordsWithSpace } from "diff";
|
|
import { diffMermaid } from "./mermaidDiff";
|
|
|
|
export type BlockType = "prose" | "code" | "mermaid";
|
|
|
|
export interface Block {
|
|
/** Verbatim source of the block (no surrounding blank lines). */
|
|
raw: string;
|
|
/** Normalized match key: lowercased, whitespace-collapsed. */
|
|
key: string;
|
|
type: BlockType;
|
|
}
|
|
|
|
function normalize(raw: string): string {
|
|
return raw.trim().replace(/\s+/g, " ").toLowerCase();
|
|
}
|
|
|
|
function makeBlock(raw: string, type: BlockType): Block {
|
|
return { raw, key: normalize(raw), type };
|
|
}
|
|
|
|
/** Split markdown into top-level blocks; fenced code/mermaid stay whole. */
|
|
export function splitBlocks(text: string): Block[] {
|
|
const lines = text.split(/\r?\n/);
|
|
const blocks: Block[] = [];
|
|
let buf: string[] = [];
|
|
const flushProse = () => {
|
|
const raw = buf.join("\n");
|
|
if (raw.trim()) blocks.push(makeBlock(raw, "prose"));
|
|
buf = [];
|
|
};
|
|
let i = 0;
|
|
while (i < lines.length) {
|
|
const line = lines[i];
|
|
const fence = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/);
|
|
if (fence) {
|
|
flushProse();
|
|
const marker = fence[2][0]; // ` or ~
|
|
const info = fence[3].trim().split(/\s+/)[0].toLowerCase();
|
|
const fenceLines = [line];
|
|
i++;
|
|
while (i < lines.length) {
|
|
fenceLines.push(lines[i]);
|
|
const closed = lines[i].trim().startsWith(marker.repeat(3));
|
|
i++;
|
|
if (closed) break;
|
|
}
|
|
blocks.push(makeBlock(fenceLines.join("\n"), info === "mermaid" ? "mermaid" : "code"));
|
|
continue;
|
|
}
|
|
if (line.trim() === "") {
|
|
flushProse();
|
|
i++;
|
|
continue;
|
|
}
|
|
buf.push(line);
|
|
i++;
|
|
}
|
|
flushProse();
|
|
return blocks;
|
|
}
|
|
|
|
export interface BlockWithRange extends Block {
|
|
/** char offset of the block's first char in the source string. */
|
|
start: number;
|
|
/** char offset one past the block's last char (source.slice(start,end) === raw). */
|
|
end: number;
|
|
}
|
|
|
|
/**
|
|
* Like splitBlocks, but each block carries its [start,end) char range in the
|
|
* SOURCE string (offsets align with document.getText(), so F3 attribution
|
|
* offsets map directly). Lines are tracked with their true source offsets — a
|
|
* trailing CR stays in the line, so a CRLF source keeps correct offsets.
|
|
*/
|
|
export function splitBlocksWithRanges(text: string): BlockWithRange[] {
|
|
const lines: { raw: string; start: number }[] = [];
|
|
let from = 0;
|
|
for (let i = 0; i <= text.length; i++) {
|
|
if (i === text.length || text[i] === "\n") {
|
|
lines.push({ raw: text.slice(from, i), start: from });
|
|
from = i + 1;
|
|
if (i === text.length) break;
|
|
}
|
|
}
|
|
const rangeOf = (lo: number, hi: number): { start: number; end: number } => ({
|
|
start: lines[lo].start,
|
|
end: lines[hi].start + lines[hi].raw.length,
|
|
});
|
|
const out: BlockWithRange[] = [];
|
|
let buf: number[] = [];
|
|
const flushProse = () => {
|
|
if (buf.length) {
|
|
const { start, end } = rangeOf(buf[0], buf[buf.length - 1]);
|
|
const raw = text.slice(start, end);
|
|
if (raw.trim()) out.push({ ...makeBlock(raw, "prose"), start, end });
|
|
}
|
|
buf = [];
|
|
};
|
|
let i = 0;
|
|
while (i < lines.length) {
|
|
const line = lines[i].raw;
|
|
const fence = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/);
|
|
if (fence) {
|
|
flushProse();
|
|
const marker = fence[2][0];
|
|
const info = fence[3].trim().split(/\s+/)[0].toLowerCase();
|
|
const open = i;
|
|
i++;
|
|
while (i < lines.length) {
|
|
const closed = lines[i].raw.trim().startsWith(marker.repeat(3));
|
|
i++;
|
|
if (closed) break;
|
|
}
|
|
const close = i - 1;
|
|
const { start, end } = rangeOf(open, close);
|
|
const raw = text.slice(start, end);
|
|
out.push({ ...makeBlock(raw, info === "mermaid" ? "mermaid" : "code"), start, end });
|
|
continue;
|
|
}
|
|
if (line.trim() === "") {
|
|
flushProse();
|
|
i++;
|
|
continue;
|
|
}
|
|
buf.push(i);
|
|
i++;
|
|
}
|
|
flushProse();
|
|
return out;
|
|
}
|
|
|
|
export type BlockOp =
|
|
| { kind: "unchanged"; block: Block }
|
|
| { kind: "added"; block: Block }
|
|
| { kind: "removed"; block: Block }
|
|
| { kind: "changed"; block: Block; before: Block; atomic: boolean };
|
|
|
|
/**
|
|
* Diff two block sequences. Matching is by normalized key (jsdiff `diffArrays`);
|
|
* adjacent removed-then-added runs are paired element-wise into `changed` ops, the
|
|
* surplus staying `removed` / `added`. A `changed` op is ATOMIC (INV-23) when
|
|
* either side is a code/mermaid fence — rendered whole, never word-refined.
|
|
*/
|
|
export function diffBlocks(baselineText: string, currentText: string): BlockOp[] {
|
|
const before = splitBlocks(baselineText);
|
|
const after = splitBlocks(currentText);
|
|
const changes = diffArrays(
|
|
before.map((b) => b.key),
|
|
after.map((b) => b.key),
|
|
);
|
|
|
|
const ops: BlockOp[] = [];
|
|
let bi = 0; // index into `before`
|
|
let ci = 0; // index into `after`
|
|
for (let n = 0; n < changes.length; n++) {
|
|
const ch = changes[n];
|
|
const count = ch.count ?? ch.value.length;
|
|
if (!ch.added && !ch.removed) {
|
|
for (let k = 0; k < count; k++) ops.push({ kind: "unchanged", block: after[ci++] });
|
|
bi += count;
|
|
continue;
|
|
}
|
|
if (ch.removed) {
|
|
const next = changes[n + 1];
|
|
const addCount = next?.added ? (next.count ?? next.value.length) : 0;
|
|
const paired = Math.min(count, addCount);
|
|
for (let k = 0; k < paired; k++) {
|
|
const beforeBlk = before[bi++];
|
|
const afterBlk = after[ci++];
|
|
const atomic = beforeBlk.type !== "prose" || afterBlk.type !== "prose";
|
|
ops.push({ kind: "changed", block: afterBlk, before: beforeBlk, atomic });
|
|
}
|
|
for (let k = paired; k < count; k++) ops.push({ kind: "removed", block: before[bi++] });
|
|
for (let k = paired; k < addCount; k++) ops.push({ kind: "added", block: after[ci++] });
|
|
if (next?.added) n++; // consumed the paired added run
|
|
continue;
|
|
}
|
|
// a lone added run (no preceding removed)
|
|
for (let k = 0; k < count; k++) ops.push({ kind: "added", block: after[ci++] });
|
|
}
|
|
return ops;
|
|
}
|
|
|
|
/** A contiguous changed region of `currentText` and its replacement (F11). */
|
|
export interface EditHunk {
|
|
/** char offset of the hunk's first changed char in currentText. */
|
|
start: number;
|
|
/** char offset one past the hunk's last changed char (start==end → a pure insertion). */
|
|
end: number;
|
|
/** the text that replaces currentText.slice(start, end). */
|
|
replacement: string;
|
|
}
|
|
|
|
/**
|
|
* F11 (INV-37, §6.4): diff a whole-document rewrite into per-hunk replacement
|
|
* ranges — each becomes one independent F4 single-range proposal (so a document
|
|
* edit surfaces as N independently ✓/✗-able blue blocks, no new model). Pure,
|
|
* vscode-free, deterministic. Offsets index into `currentText`: removed +
|
|
* unchanged parts reconstruct it exactly, so advancing on those yields true
|
|
* source offsets. Adjacent added/removed runs coalesce into one hunk; an
|
|
* unchanged part flushes the open hunk.
|
|
*
|
|
* A PURE insertion (added text between two unchanged regions) would otherwise be
|
|
* a zero-width hunk (start==end) — and F4's fingerprint of empty text can never
|
|
* resolve (anchorer.resolve orphans an empty needle), so the proposal could
|
|
* never be accepted. Every zero-width hunk is therefore anchored to an adjacent
|
|
* source token (`anchorInsertion`): its range absorbs that token and its
|
|
* replacement keeps it, so the net text is identical but `fp.text` is non-empty
|
|
* and resolvable. A zero-width hunk is always bordered by unchanged text (or a
|
|
* doc edge), so the absorbed token is genuinely present in `currentText` and is
|
|
* never part of another hunk.
|
|
*/
|
|
export function diffToHunks(currentText: string, rewrittenText: string): EditHunk[] {
|
|
return wordEditHunks(currentText, rewrittenText).map((h) =>
|
|
h.start === h.end ? anchorInsertion(h, currentText) : h,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The raw word-level diff underlying `diffToHunks`: disjoint, ordered EditHunks
|
|
* that exactly partition the change (a pure insertion stays ZERO-WIDTH at its
|
|
* offset — not anchored). `diffToHunks` adds anchoring on top so each hunk's F4
|
|
* fingerprint resolves; callers that apply hunks directly through the seam
|
|
* (INV-40's intra-block accept) want THESE raw, non-overlapping hunks instead —
|
|
* anchoring can grow an insertion to absorb a token an adjacent hunk also edits,
|
|
* which corrupts a batch apply. Pure, vscode-free, deterministic. Applying these
|
|
* right→left reconstructs `rewrittenText` exactly.
|
|
*/
|
|
export function wordEditHunks(currentText: string, rewrittenText: string): EditHunk[] {
|
|
const hunks: EditHunk[] = [];
|
|
let offset = 0;
|
|
let open: EditHunk | null = null;
|
|
const flush = () => {
|
|
if (open) hunks.push(open);
|
|
open = null;
|
|
};
|
|
for (const part of diffWordsWithSpace(currentText, rewrittenText)) {
|
|
if (part.added) {
|
|
open ??= { start: offset, end: offset, replacement: "" };
|
|
open.replacement += part.value;
|
|
} else if (part.removed) {
|
|
open ??= { start: offset, end: offset, replacement: "" };
|
|
offset += part.value.length;
|
|
open.end = offset;
|
|
} else {
|
|
flush();
|
|
offset += part.value.length;
|
|
}
|
|
}
|
|
flush();
|
|
return hunks;
|
|
}
|
|
|
|
const isWs = (c: string): boolean => /\s/.test(c);
|
|
|
|
/**
|
|
* Grow a zero-width insertion hunk to absorb an adjacent source token so its F4
|
|
* fingerprint has real text to resolve. Prefer the FOLLOWING token (optional
|
|
* leading whitespace + a run of non-whitespace); at end-of-document, fall back to
|
|
* the PRECEDING token. The absorbed text is kept in the replacement, so the
|
|
* applied result is unchanged.
|
|
*/
|
|
function anchorInsertion(hunk: EditHunk, text: string): EditHunk {
|
|
const p = hunk.start;
|
|
if (p < text.length) {
|
|
let e = p;
|
|
while (e < text.length && isWs(text[e])) e++;
|
|
while (e < text.length && !isWs(text[e])) e++;
|
|
if (e === p) e = Math.min(text.length, p + 1);
|
|
return { start: p, end: e, replacement: hunk.replacement + text.slice(p, e) };
|
|
}
|
|
let s = p;
|
|
while (s > 0 && isWs(text[s - 1])) s--;
|
|
while (s > 0 && !isWs(text[s - 1])) s--;
|
|
if (s === p) s = Math.max(0, p - 1);
|
|
return { start: s, end: p, replacement: text.slice(s, p) + hunk.replacement };
|
|
}
|
|
|
|
type BlockAlignKind = "unchanged" | "changed" | "removed" | "added";
|
|
interface BlockAlignOp {
|
|
kind: BlockAlignKind;
|
|
ci?: number; // index into the current blocks
|
|
ni?: number; // index into the rewritten blocks
|
|
}
|
|
|
|
/**
|
|
* Align two block sequences by normalized key (jsdiff `diffArrays`), pairing an
|
|
* adjacent removed-then-added run element-wise into `changed` ops (the surplus
|
|
* staying `removed` / `added`) — the same pairing `diffBlocks` uses, but yielding
|
|
* index ops so the caller can read each side's source range. Pure.
|
|
*/
|
|
function alignBlocks(cur: BlockWithRange[], next: BlockWithRange[]): BlockAlignOp[] {
|
|
const changes = diffArrays(
|
|
cur.map((b) => b.key),
|
|
next.map((b) => b.key),
|
|
);
|
|
const ops: BlockAlignOp[] = [];
|
|
let bi = 0; // current index
|
|
let ci = 0; // rewritten index
|
|
for (let n = 0; n < changes.length; n++) {
|
|
const ch = changes[n];
|
|
const count = ch.count ?? ch.value.length;
|
|
if (!ch.added && !ch.removed) {
|
|
for (let k = 0; k < count; k++) ops.push({ kind: "unchanged", ci: bi++, ni: ci++ });
|
|
continue;
|
|
}
|
|
if (ch.removed) {
|
|
const nx = changes[n + 1];
|
|
const addCount = nx?.added ? (nx.count ?? nx.value.length) : 0;
|
|
const paired = Math.min(count, addCount);
|
|
for (let k = 0; k < paired; k++) ops.push({ kind: "changed", ci: bi++, ni: ci++ });
|
|
for (let k = paired; k < count; k++) ops.push({ kind: "removed", ci: bi++ });
|
|
for (let k = paired; k < addCount; k++) ops.push({ kind: "added", ni: ci++ });
|
|
if (nx?.added) n++;
|
|
continue;
|
|
}
|
|
for (let k = 0; k < count; k++) ops.push({ kind: "added", ni: ci++ });
|
|
}
|
|
return ops;
|
|
}
|
|
|
|
/**
|
|
* #47 (INV-39, §6.4): diff a whole-document rewrite into ONE EditHunk per CHANGED
|
|
* BLOCK — the unit a human reviews — rather than per word (which INV-37/`diffToHunks`
|
|
* did and this supersedes for document edits). Built by aligning both sides into
|
|
* the existing block units (`splitBlocksWithRanges`) and keying with the same diff
|
|
* as `diffBlocks`:
|
|
* - an ISOLATED changed block (1:1, unchanged blocks on both sides) → a
|
|
* block-aligned hunk `[block.start, block.end)` → the rewritten block's raw
|
|
* text. A code/mermaid fence is one such whole-block hunk (atomic, INV-23).
|
|
* - any other changed RUN (block insertions/deletions, or adjacent changes) → one
|
|
* gap-span hunk covering the source between the bounding unchanged blocks → the
|
|
* matching rewritten span (separators included), so reconstruction stays exact.
|
|
* A zero-width gap-span (insert at a seamless boundary) is anchored to an
|
|
* adjacent token (INV-41) so its F4 fingerprint resolves and it accepts.
|
|
* - unchanged blocks (same key AND same raw) → no hunk.
|
|
* Pure, vscode-free, deterministic; same `EditHunk` shape as `diffToHunks` (which
|
|
* is RETAINED as the intra-block sub-diff engine for word-precise accept
|
|
* attribution — INV-40). Applying all hunks right→left reconstructs `rewrittenText`
|
|
* exactly.
|
|
*/
|
|
export function diffToBlockHunks(currentText: string, rewrittenText: string): EditHunk[] {
|
|
const cur = splitBlocksWithRanges(currentText);
|
|
const next = splitBlocksWithRanges(rewrittenText);
|
|
const ops = alignBlocks(cur, next);
|
|
// Same key but different raw (whitespace/case) is still a real content change.
|
|
for (const op of ops) {
|
|
if (op.kind === "unchanged" && cur[op.ci!].raw !== next[op.ni!].raw) op.kind = "changed";
|
|
}
|
|
const hunks: EditHunk[] = [];
|
|
let i = 0;
|
|
while (i < ops.length) {
|
|
if (ops[i].kind === "unchanged") {
|
|
i++;
|
|
continue;
|
|
}
|
|
let j = i;
|
|
while (j < ops.length && ops[j].kind !== "unchanged") j++;
|
|
const run = ops.slice(i, j);
|
|
if (run.length === 1 && run[0].kind === "changed") {
|
|
const c = cur[run[0].ci!];
|
|
hunks.push({ start: c.start, end: c.end, replacement: next[run[0].ni!].raw });
|
|
} else {
|
|
// A run with insertions/deletions: replace the whole inter-anchor gap so the
|
|
// separators reconstruct exactly. Bound by the unchanged blocks on each side
|
|
// (or the document edges).
|
|
const prev = i > 0 ? ops[i - 1] : null;
|
|
const after = j < ops.length ? ops[j] : null;
|
|
const curStart = prev ? cur[prev.ci!].end : 0;
|
|
const curEnd = after ? cur[after.ci!].start : currentText.length;
|
|
const newStart = prev ? next[prev.ni!].end : 0;
|
|
const newEnd = after ? next[after.ni!].start : rewrittenText.length;
|
|
const hunk: EditHunk = { start: curStart, end: curEnd, replacement: rewrittenText.slice(newStart, newEnd) };
|
|
hunks.push(hunk.start === hunk.end ? anchorInsertion(hunk, currentText) : hunk);
|
|
}
|
|
i = j;
|
|
}
|
|
return hunks;
|
|
}
|
|
|
|
const md = new MarkdownIt({ html: true, linkify: false, breaks: false });
|
|
// mermaid fences → <pre class="mermaid">SRC</pre> for client-side rendering; all
|
|
// other fences fall through to markdown-it's default (escaped <pre><code>).
|
|
const defaultFence = md.renderer.rules.fence!.bind(md.renderer.rules);
|
|
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
|
|
const info = tokens[idx].info.trim().split(/\s+/)[0].toLowerCase();
|
|
if (info === "mermaid") {
|
|
return `<pre class="mermaid">${md.utils.escapeHtml(tokens[idx].content.replace(/\n$/, ""))}</pre>\n`;
|
|
}
|
|
return defaultFence(tokens, idx, options, env, self);
|
|
};
|
|
|
|
/** Inner source of a ```mermaid fence (drops the fence lines), or null. */
|
|
function mermaidFenceBody(raw: string): string | null {
|
|
const lines = raw.split(/\r?\n/);
|
|
const open = lines[0]?.match(/^(\s*)(`{3,}|~{3,})\s*(\w+)?/);
|
|
if (!open || open[3]?.toLowerCase() !== "mermaid") return null;
|
|
const body = lines.slice(1);
|
|
if (body.length && /^(\s*)(`{3,}|~{3,})/.test(body[body.length - 1])) body.pop();
|
|
return body.join("\n");
|
|
}
|
|
|
|
/** F7.1 (#22): legend shown beneath an intra-diagram-diffed mermaid block. */
|
|
const MERMAID_LEGEND =
|
|
'<div class="cw-mermaid-legend">' +
|
|
'<span class="cw-leg cw-leg-add">added</span>' +
|
|
'<span class="cw-leg cw-leg-chg">changed</span>' +
|
|
'<span class="cw-leg cw-leg-rem">removed</span>' +
|
|
"</div>";
|
|
|
|
/** Build a markdown string with inline <ins>/<del> from a word-level prose diff. */
|
|
function wordMergedMarkdown(beforeRaw: string, afterRaw: string): string {
|
|
return diffWords(beforeRaw, afterRaw)
|
|
.map((part) => {
|
|
if (part.added) return `<ins>${part.value}</ins>`;
|
|
if (part.removed) return `<del>${part.value}</del>`;
|
|
return part.value;
|
|
})
|
|
.join("");
|
|
}
|
|
|
|
export interface RenderOptions {
|
|
/** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */
|
|
render?: (src: string) => string;
|
|
}
|
|
|
|
function defaultRender(src: string): string {
|
|
return md.render(src);
|
|
}
|
|
|
|
function chip(message: string): string {
|
|
return `<div class="cw-error">Could not render this block: ${md.utils.escapeHtml(message)}</div>`;
|
|
}
|
|
|
|
/**
|
|
* F11 (INV-36): the source-range attributes a live block's wrapping element
|
|
* carries, so a preview selection can map back to a source markdown range. `""`
|
|
* when the block has no live source (a baseline-only deletion / a proposal
|
|
* block) — those are skipped by the selection mapper. Pure + deterministic.
|
|
*/
|
|
function srcAttr(blk: { start: number; end: number } | undefined): string {
|
|
return blk ? ` data-src-start="${blk.start}" data-src-end="${blk.end}"` : "";
|
|
}
|
|
|
|
function renderOp(op: BlockOp, render: (src: string) => string, src = ""): string {
|
|
const safe = (src: string): string => {
|
|
try {
|
|
return render(src);
|
|
} catch (err) {
|
|
return chip(err instanceof Error ? err.message : String(err));
|
|
}
|
|
};
|
|
let cls: string;
|
|
let inner: string;
|
|
let badge = "";
|
|
switch (op.kind) {
|
|
case "unchanged":
|
|
cls = "cw-unchanged";
|
|
inner = safe(op.block.raw);
|
|
break;
|
|
case "added":
|
|
cls = "cw-added";
|
|
inner = safe(op.block.raw);
|
|
if (op.block.type !== "prose") badge = '<span class="cw-badge">added</span>';
|
|
break;
|
|
case "removed":
|
|
cls = "cw-removed";
|
|
inner = safe(op.block.raw);
|
|
break;
|
|
case "changed":
|
|
cls = "cw-changed";
|
|
if (op.atomic) {
|
|
const curBody = op.block.type === "mermaid" ? mermaidFenceBody(op.block.raw) : null;
|
|
const beforeBody = op.before.type === "mermaid" ? mermaidFenceBody(op.before.raw) : null;
|
|
const md =
|
|
curBody !== null && beforeBody !== null
|
|
? diffMermaid(beforeBody, curBody)
|
|
: { kind: "fallback" as const };
|
|
if (md.kind === "augmented") {
|
|
// Re-render the augmented diagram as a mermaid fence; add a legend, drop the badge (INV-29).
|
|
inner = safe("```mermaid\n" + md.source + "\n```") + MERMAID_LEGEND;
|
|
} else {
|
|
inner = safe(op.block.raw); // the NEW block, whole (INV-23 / INV-30 fallback)
|
|
badge = '<span class="cw-badge">changed</span>';
|
|
}
|
|
} else {
|
|
inner = safe(wordMergedMarkdown(op.before.raw, op.block.raw));
|
|
}
|
|
break;
|
|
}
|
|
return `<div class="cw-blk ${cls}"${src}>${badge}${inner}</div>`;
|
|
}
|
|
|
|
export type AuthorKind = "claude" | "human";
|
|
export interface AuthorSpan {
|
|
start: number;
|
|
end: number;
|
|
author: AuthorKind;
|
|
}
|
|
|
|
// Private-Use-Area sentinels (never appear in real content; markdown-it passes
|
|
// them through as plain text). Paired open/close per author.
|
|
const SENT = {
|
|
claude: { open: "", close: "" },
|
|
human: { open: "", close: "" },
|
|
} as const;
|
|
|
|
function isCloseSentinel(m: string): boolean {
|
|
return m === SENT.claude.close || m === SENT.human.close;
|
|
}
|
|
|
|
function authorBadge(authors: Set<AuthorKind>): { cls: string; label: string } | null {
|
|
if (authors.size === 0) return null;
|
|
if (authors.size > 1) return { cls: "cw-mixed", label: "mixed" };
|
|
const only = [...authors][0];
|
|
return only === "claude"
|
|
? { cls: "cw-by-claude", label: "Claude" }
|
|
: { cls: "cw-by-human", label: "You" };
|
|
}
|
|
|
|
/** Inject paired sentinels into a prose block's raw text for the spans clipped to it. */
|
|
function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]): string {
|
|
const inserts: { at: number; marker: string }[] = [];
|
|
for (const s of spans) {
|
|
const lo = Math.max(0, s.start - blockStart);
|
|
const hi = Math.min(raw.length, s.end - blockStart);
|
|
if (hi <= lo) continue;
|
|
inserts.push({ at: lo, marker: SENT[s.author].open });
|
|
inserts.push({ at: hi, marker: SENT[s.author].close });
|
|
}
|
|
// Apply high offset → low so earlier offsets stay valid. At an equal offset
|
|
// (one span's close == the next's open), apply opens BEFORE closes so the
|
|
// close ends up left of the open: "…</span><span>…" for adjacent spans.
|
|
inserts.sort((a, b) => b.at - a.at || (isCloseSentinel(a.marker) ? 1 : -1));
|
|
let out = raw;
|
|
for (const ins of inserts) out = out.slice(0, ins.at) + ins.marker + out.slice(ins.at);
|
|
return out;
|
|
}
|
|
|
|
/** Replace the rendered sentinels with author <span> tags. */
|
|
function sentinelsToSpans(html: string): string {
|
|
return html
|
|
.split(SENT.claude.open).join('<span class="cw-by-claude">')
|
|
.split(SENT.claude.close).join("</span>")
|
|
.split(SENT.human.open).join('<span class="cw-by-human">')
|
|
.split(SENT.human.close).join("</span>");
|
|
}
|
|
|
|
/**
|
|
* Color one prose block's HTML by F3 author spans (PUA-sentinel technique,
|
|
* salvaged from F9 renderAuthorship). `blockStart` is the block's char offset in
|
|
* the source so spans map correctly. Pure; deterministic.
|
|
*/
|
|
export function colorByAuthor(
|
|
raw: string,
|
|
blockStart: number,
|
|
spans: AuthorSpan[],
|
|
render: (src: string) => string,
|
|
): string {
|
|
const overlapping = spans.filter((s) => s.end > blockStart && s.start < blockStart + raw.length);
|
|
const injected = injectSentinels(raw, blockStart, overlapping);
|
|
return sentinelsToSpans(render(injected));
|
|
}
|
|
|
|
/**
|
|
* Off-state body: the current buffer as plain markdown, no annotations (INV-33).
|
|
* Each block is wrapped in a bare `<div data-src-start/end>` so the off-mode
|
|
* preview is still a selection→source mapping surface (INV-36) while staying
|
|
* visually clean — no `cw-` annotation classes. A doc with no blocks (empty /
|
|
* whitespace-only) renders whole. Pure + deterministic.
|
|
*
|
|
* Tradeoff of the locked block-level mapping (§6.7): rendering PER BLOCK (so each
|
|
* carries its offsets) means a markdown construct split across blank-line-
|
|
* separated blocks — a reference-link use and its definition — doesn't resolve
|
|
* across blocks. `renderReview` already rendered per-block; this keeps the two
|
|
* modes consistent rather than faithful-but-different. Characterized in tests.
|
|
*/
|
|
export function renderPlain(currentText: string, opts: RenderOptions = {}): string {
|
|
const render = opts.render ?? defaultRender;
|
|
const safe = (src: string): string => {
|
|
try {
|
|
return render(src);
|
|
} catch (err) {
|
|
return chip(err instanceof Error ? err.message : String(err));
|
|
}
|
|
};
|
|
const blocks = splitBlocksWithRanges(currentText);
|
|
if (blocks.length === 0) return safe(currentText);
|
|
return blocks.map((b) => `<div${srcAttr(b)}>${safe(b.raw)}</div>`).join("\n");
|
|
}
|
|
|
|
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="${md.utils.escapeHtml(p.id)}">${actions}${before}${after}</div>`;
|
|
}
|
|
|
|
function renderReviewOp(
|
|
op: BlockOp,
|
|
render: (src: string) => string,
|
|
colored: (raw: string) => string,
|
|
src: string,
|
|
): string {
|
|
// removed blocks and any changed block (atomic fences diffed whole; non-atomic prose
|
|
// word-merged) render via renderOp — no author sentinels (deletions neutral, spec §6.7).
|
|
// `src` is "" for a removed block (no live source — INV-36).
|
|
if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render, src);
|
|
return `<div class="cw-blk ${op.kind === "added" ? "cw-added" : "cw-unchanged"}"${src}>${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.
|
|
*
|
|
* A resolved proposal renders INLINE, right after the current-side block its anchor
|
|
* falls in (#31) — so "what is Claude proposing, and where?" is answered by
|
|
* position. A proposal whose anchor does not resolve (or falls before the first
|
|
* block) renders as a trailing cw-proposal-unanchored block (never dropped —
|
|
* INV-34). Deterministic: proposals in the same block are ordered by anchorStart
|
|
* then id; trailing proposals keep input order.
|
|
*/
|
|
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);
|
|
|
|
// Associate each resolved proposal with the current-side block index whose range
|
|
// 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
|
|
// resolved anchor before all blocks, and every unresolved proposal, trails.
|
|
const blockOf = (a: number): number => {
|
|
let j = -1;
|
|
for (let k = 0; k < ranges.length && ranges[k].start <= a; k++) j = k;
|
|
return j;
|
|
};
|
|
const byBlock = new Map<number, ProposalView[]>();
|
|
const trailing: ProposalView[] = [];
|
|
for (const p of proposals) {
|
|
const j = p.anchorStart === null ? -1 : blockOf(p.anchorStart);
|
|
if (j < 0) {
|
|
trailing.push(p);
|
|
continue;
|
|
}
|
|
(byBlock.get(j) ?? byBlock.set(j, []).get(j)!).push(p);
|
|
}
|
|
for (const arr of byBlock.values()) {
|
|
arr.sort((a, b) => a.anchorStart! - b.anchorStart! || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
}
|
|
|
|
let ci = 0; // pointer into ranges; advances for every op with a current-side block
|
|
const bodyParts: string[] = [];
|
|
for (const op of ops) {
|
|
const blockIndex = op.kind === "removed" ? -1 : ci;
|
|
const blk = op.kind === "removed" ? undefined : ranges[ci++];
|
|
const colored = (raw: string): string =>
|
|
blk ? colorByAuthor(raw, blk.start, authorSpans, render) : render(raw);
|
|
bodyParts.push(renderReviewOp(op, render, colored, srcAttr(blk)));
|
|
const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined;
|
|
if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render));
|
|
}
|
|
for (const p of trailing) bodyParts.push(proposalBlockHtml(p, render));
|
|
return bodyParts.join("\n");
|
|
}
|
|
|
|
/** Pure entry point: annotated HTML body for the preview (INV-22). */
|
|
export function renderTrackChanges(
|
|
baselineText: string,
|
|
currentText: string,
|
|
opts: RenderOptions = {},
|
|
): string {
|
|
const render = opts.render ?? defaultRender;
|
|
return diffBlocks(baselineText, currentText)
|
|
.map((op) => renderOp(op, render))
|
|
.join("\n");
|
|
}
|