Files
vscode-cowriting-plugin/src/trackChangesModel.ts
T

1001 lines
40 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;
}
/** F12/#64 (INV-49/52): the editor render of one optimistically-applied proposal. */
export interface DecorationPlan {
/** buffer ranges of inserted (proposed) text → tinted (INV-52). */
insertions: { start: number; end: number }[];
/** struck original text shown as a non-editable hint at a buffer offset (INV-52). */
deletions: { at: number; text: string }[];
}
/**
* F12/#64 (INV-49): compute the editor decoration plan for a proposal whose applied
* text occupies `[anchorStart, anchorStart+replacement.length)` in the buffer. The
* SAME word diff the webview uses (`wordEditHunks`) drives it, so both surfaces show
* the identical diff. A changed run maps to (a) an insertion range over the run's
* applied text and (b) a deletion hint carrying the run's removed original text at
* the run start; a pure insertion has no deletion hint; a pure deletion has only a
* hint. Pure, vscode-free, deterministic.
*/
export function decorationPlan(anchorStart: number, original: string, replacement: string): DecorationPlan {
const insertions: { start: number; end: number }[] = [];
const deletions: { at: number; text: string }[] = [];
// Walk the word diff once, tracking the applied-side offset as we consume parts.
let appliedOffset = anchorStart;
for (const part of diffWordsWithSpace(original, replacement)) {
if (part.added) {
insertions.push({ start: appliedOffset, end: appliedOffset + part.value.length });
appliedOffset += part.value.length;
} else if (part.removed) {
deletions.push({ at: appliedOffset, text: part.value });
// removed text is NOT in the applied buffer → appliedOffset does not advance
} else {
appliedOffset += part.value.length;
}
}
return { insertions, deletions };
}
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`:
* - a CHANGED block (1:1 aligned, even when adjacent to other changed blocks) → 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). So
* a copy-edit pass touching N consecutive paragraphs yields N proposals.
* - a run of block INSERTIONS / DELETIONS → one gap-span hunk covering the source
* between the bounding aligned blocks (unchanged OR changed — both are 1:1
* anchors) → 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";
}
// `changed` and `unchanged` are 1:1 anchors (both sides' offsets are known);
// `added`/`removed` have no counterpart and must be spanned together.
const isAnchor = (op: BlockAlignOp) => op.kind === "unchanged" || op.kind === "changed";
const hunks: EditHunk[] = [];
let i = 0;
while (i < ops.length) {
const op = ops[i];
if (op.kind === "unchanged") {
i++;
continue;
}
if (op.kind === "changed") {
const c = cur[op.ci!];
hunks.push({ start: c.start, end: c.end, replacement: next[op.ni!].raw });
i++;
continue;
}
// A maximal run of added/removed blocks → one gap-span hunk over the source
// between the bounding anchors (or the document edges), replaced with the
// matching rewritten span, so the separators reconstruct exactly.
let j = i;
while (j < ops.length && !isAnchor(ops[j])) j++;
const prev = i > 0 ? ops[i - 1] : null; // an anchor by construction
const after = j < ops.length ? ops[j] : null; // an anchor (or null at EOF)
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;
}
hunks.sort((a, b) => a.start - b.start);
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("");
}
/** The author whose span covers `offset` (half-open [start,end)), or null. */
export function authorAt(offset: number, spans: AuthorSpan[]): AuthorKind | null {
for (const s of spans) if (offset >= s.start && offset < s.end) return s.author;
return null;
}
const insClass = (a: AuthorKind | null): string => `cw-ins-${a ?? "none"}`;
const delClass = (a: AuthorKind | null): string => `cw-del-${a ?? "none"}`;
/**
* Author-colored word diff for a changed PROSE block in the review.
* `afterStart` is the landed-text offset of `afterRaw[0]` so an inserted run's
* offset maps into `spans`. Insertions are colored by the author covering their
* offset; deletions inherit the author of the insertion they are paired with in
* the same contiguous change cluster (adjacency heuristic) — `cw-del-none` when a
* cluster has no insertion. Pure, deterministic.
*/
export function wordDiffByAuthor(
beforeRaw: string,
afterRaw: string,
afterStart: number,
spans: AuthorSpan[],
): string {
const parts = diffWordsWithSpace(beforeRaw, afterRaw);
// First pass: find each change cluster's insertion author (first added run).
// A cluster is a maximal run of added/removed parts bounded by unchanged parts.
let afterOff = afterStart;
const clusterAuthor: (AuthorKind | null)[] = []; // per part index, the cluster's del author
{
let off = afterStart;
let i = 0;
while (i < parts.length) {
const p = parts[i];
if (!p.added && !p.removed) {
clusterAuthor[i] = null;
off += p.value.length;
i++;
continue;
}
// a cluster: scan to its end, capturing the first added run's author
let j = i;
let author: AuthorKind | null = null;
let scanOff = off;
while (j < parts.length && (parts[j].added || parts[j].removed)) {
if (parts[j].added && author === null) author = authorAt(scanOff, spans);
if (parts[j].added) scanOff += parts[j].value.length;
j++;
}
for (let k = i; k < j; k++) clusterAuthor[k] = author;
off = scanOff;
i = j;
}
}
// Second pass: emit.
const out: string[] = [];
parts.forEach((p, i) => {
if (p.added) {
const a = authorAt(afterOff, spans);
out.push(`<ins class="${insClass(a)}">${p.value}</ins>`);
afterOff += p.value.length;
} else if (p.removed) {
out.push(`<del class="${delClass(clusterAuthor[i] ?? null)}">${p.value}</del>`);
} else {
out.push(p.value);
afterOff += p.value.length;
}
});
return out.join("");
}
export interface RenderOptions {
/** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */
render?: (src: string) => string;
/**
* #48: the baseline was just PINNED (`reason === "pinned"`). With zero changes
* since that pin, the on-render is fully clean — no authorship coloring — so a
* pin reads as "this is my clean starting point". Distinct from a baseline
* advanced by a machine-landing (accept), which keeps its authorship coloring
* (F10 INV-33). Only consulted by `renderReview`.
*/
pinned?: boolean;
}
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" };
}
// Markdown emphasis / code delimiters whose RUNS must never be split by an
// injected sentinel — a sentinel between two run chars (e.g. `*|*`) breaks
// markdown-it's delimiter pairing (#33 CASE1).
const isDelimChar = (c: string): boolean => c === "*" || c === "_" || c === "~" || c === "`";
/**
* #33 (CASE1): a sentinel must not land STRICTLY INSIDE a delimiter run. If `at`
* sits between two identical delimiter chars, snap it to the run's start (a
* position outside the run) so the run stays intact. Delimiters are invisible once
* rendered, so snapping only shifts the colored boundary across markup, never over
* visible text.
*/
function clampOffDelimiterRun(raw: string, at: number): number {
if (at <= 0 || at >= raw.length) return at;
if (!(isDelimChar(raw[at]) && raw[at - 1] === raw[at])) return at;
let p = at;
while (p > 0 && raw[p - 1] === raw[at]) p--;
return p;
}
/** 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 = clampOffDelimiterRun(raw, Math.max(0, s.start - blockStart));
const hi = clampOffDelimiterRun(raw, Math.min(raw.length, s.end - blockStart));
if (hi <= lo) continue; // empty (or clamped to empty) span contributes nothing
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;
}
const SENTINEL_OF: Record<string, { author: AuthorKind; open: boolean } | undefined> = {
[SENT.claude.open]: { author: "claude", open: true },
[SENT.claude.close]: { author: "claude", open: false },
[SENT.human.open]: { author: "human", open: true },
[SENT.human.close]: { author: "human", open: false },
};
const ALL_SENTINELS = new RegExp(
`[${SENT.claude.open}${SENT.claude.close}${SENT.human.open}${SENT.human.close}]`,
"g",
);
/**
* #33: token-aware replacement of the rendered author sentinels with `cw-by-*`
* spans. A naive global string-replace (the old approach) could emit a span that
* CROSSES an element boundary — `<span><strong>bo</span>ld</strong>` — when a
* boundary fell inside an emphasis run (CASE3). This walks the rendered HTML and
* emits the author span only around TEXT runs, CLOSING it before any `<tag>` and
* REOPENING it after, so a span is always well-nested within the inline elements
* (one `<span>` segment per text run). Tags are copied verbatim (with any stray
* sentinel stripped, so no Private-Use-Area char ever leaks). Pure, deterministic.
*/
function sentinelsToSpans(html: string): string {
const out: string[] = [];
let current: AuthorKind | null = null; // which author region we're inside
let spanOpen = false; // whether a <span> is currently open in `out`
const openSpan = () => {
if (current && !spanOpen) {
out.push(`<span class="cw-by-${current}">`);
spanOpen = true;
}
};
const closeSpan = () => {
if (spanOpen) {
out.push("</span>");
spanOpen = false;
}
};
for (let i = 0; i < html.length; i++) {
const ch = html[i];
const sentinel = SENTINEL_OF[ch];
if (sentinel) {
if (sentinel.open) current = sentinel.author; // span opens lazily before the next text char
else {
closeSpan();
current = null;
}
continue;
}
if (ch === "<") {
// An HTML tag: never let an author span straddle it (text `<` is escaped to
// &lt;, so a raw `<` is always a real tag). Copy the tag verbatim, sentinel-free.
closeSpan();
const gt = html.indexOf(">", i);
const end = gt === -1 ? html.length - 1 : gt;
out.push(html.slice(i, end + 1).replace(ALL_SENTINELS, ""));
i = end;
continue;
}
openSpan();
out.push(ch);
}
closeSpan();
return out.join("");
}
/**
* 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;
/** F12/#64 (INV-48): the pre-apply original, set after optimistic apply. */
original?: 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">` +
`<span class="cw-btngroup">` +
`<button class="cw-accept" data-action="accept">Accept</button>` +
`<button class="cw-caret" data-action="acceptAll" title="Accept all pending proposals">▾</button>` +
`</span>` +
`<span class="cw-btngroup">` +
`<button class="cw-reject" data-action="reject">Reject</button>` +
`<button class="cw-caret" data-action="rejectAll" title="Reject all pending proposals">▾</button>` +
`</span>` +
`</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,
changedHtml?: string,
): string {
// A non-atomic changed prose block is pre-rendered with author-colored ins/del
// (changedHtml). Removed blocks and atomic changed fences stay neutral via renderOp
// (standalone deletion → neutral, per the adjacency heuristic). `src` is "" for a
// removed block (no live source — INV-36).
if (op.kind === "changed" && !op.atomic && changedHtml !== undefined) {
return `<div class="cw-blk cw-changed"${src}>${changedHtml}</div>`;
}
if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render, src);
// "added": use changedHtml (author-colored word-diff from empty before, all insertions).
// "unchanged": colored() returns plain render(raw) — color means "changed since baseline".
return `<div class="cw-blk ${op.kind === "added" ? "cw-added" : "cw-unchanged"}"${src}>${changedHtml ?? colored(op.block.raw)}</div>`;
}
/**
* On-state body: the F7 baseline diff — added/changed PROSE author-colored via
* wordDiffByAuthor (cw-ins-{author}/cw-del-{author} per token), unchanged blocks render plain,
* deletions struck neutral — 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.
*/
/**
* F12/#64 (INV-50): `currentText` with every resolved pending proposal's applied
* span reverted to its original (`replaced`) — the "landed" text the baseline diff
* should run against, so a pending proposal renders ONCE (as a proposal), never also
* as a landed change. Reverts high→low so earlier offsets stay valid. Pure. The
* preview's summary tally diffs against this too, so the toolbar count matches the
* body (a pending change is not double-counted as both a landed add/remove and a
* proposal).
*/
export function landedTextOf(currentText: string, proposals: ProposalView[]): string {
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!);
}
return landedText;
}
export function renderReview(
baselineText: string,
currentText: string,
authorSpans: AuthorSpan[],
proposals: ProposalView[],
opts: RenderOptions = {},
): string {
const render = opts.render ?? defaultRender;
// 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 the
// "landed" text (current minus pending proposals) so they render once.
const pendingApplied = proposals
.filter((p) => p.anchorStart !== null)
.sort((a, b) => b.anchorStart! - a.anchorStart!);
const landedText = landedTextOf(currentText, proposals);
const ranges = splitBlocksWithRanges(landedText);
const ops = diffBlocks(baselineText, landedText);
// #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
// coloring, so the pin reads as "this is my clean starting point". Suppress
// wordDiffByAuthor for every block in this case; data-src mapping and any pending
// proposals (review actions, not annotations) are kept below.
const clean = opts.pinned === true && ops.every((o) => o.kind === "unchanged");
// 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;
};
// F12/#64 (INV-49/50): blocks are split from `landedText`, so `blk.start` is a
// landedText offset — but `authorSpans` arrive in `currentText` coordinates. A
// colored block sitting AFTER a length-changing pending proposal would otherwise
// be mis-colored (the offsets diverge by the revert delta). Map the spans into
// landedText coordinates once so authorship coloring stays aligned.
const landedSpans: AuthorSpan[] = authorSpans.map((s) => ({
...s,
start: toLanded(s.start),
end: toLanded(s.end),
}));
// Associate each resolved proposal with the landedText 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(toLanded(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++];
// Only ADDED and CHANGED prose blocks are author-colored (they differ since baseline);
// UNCHANGED blocks render plain (color means "changed since baseline").
const colored = (raw: string): string => render(raw);
// A non-atomic changed prose block: author-colored word diff (ins by author,
// del by adjacency). An added block: word diff from empty before — all insertions.
// `blk.start` is the landed offset of the after-side text.
const changedHtml =
blk && !clean
? op.kind === "changed" && !op.atomic
? render(wordDiffByAuthor(op.before.raw, op.block.raw, blk.start, landedSpans))
: op.kind === "added"
? render(wordDiffByAuthor("", op.block.raw, blk.start, landedSpans))
: undefined
: undefined;
bodyParts.push(renderReviewOp(op, render, colored, srcAttr(blk), changedHtml));
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");
}