Files
vscode-cowriting-plugin/src/trackChangesModel.ts
T
2026-06-12 00:16:57 -07:00

462 lines
17 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 } 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;
}
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>`;
}
function renderOp(op: BlockOp, 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));
}
};
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}">${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). */
export function renderPlain(currentText: string, opts: RenderOptions = {}): string {
const render = opts.render ?? defaultRender;
try {
return render(currentText);
} catch (err) {
return chip(err instanceof Error ? err.message : String(err));
}
}
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,
): 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).
if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render);
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);
let ci = 0; // pointer into ranges; advances for every op with a current-side block
const bodyParts = ops.map((op) => {
const blk = op.kind === "removed" ? undefined : ranges[ci++];
const colored = (raw: string): string =>
blk ? colorByAuthor(raw, blk.start, authorSpans, render) : render(raw);
return 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,
currentText: string,
opts: RenderOptions = {},
): string {
const render = opts.render ?? defaultRender;
return diffBlocks(baselineText, currentText)
.map((op) => renderOp(op, render))
.join("\n");
}