Merge pull request 'F7: rendered track-changes markdown preview (#21)' (#23) from f7-rendered-preview into main

This commit was merged in pull request #23.
This commit is contained in:
2026-06-11 15:50:33 +00:00
16 changed files with 3571 additions and 10 deletions
+32
View File
@@ -164,6 +164,38 @@ instead of git archaeology.
Design: `vscode-cowriting-plugin-content/specs/coauthoring-diff-view.md`.
Live smoke: [`docs/MANUAL-SMOKE-F6.md`](docs/MANUAL-SMOKE-F6.md).
## F7 — Rendered track-changes preview (Feature #21)
**`Ctrl+Alt+R`** (or **Cowriting: Open Track-Changes Preview**) opens a
read-only webview **beside** a **Markdown** editor that renders the document and
marks what changed since the F6 baseline — the "track changes" / "suggesting
mode" altitude rather than a raw-text split-diff:
- **Prose** additions are highlighted (`<ins>`), deletions struck (`<del>`),
refined to the word.
- **Code and mermaid fences** are diffed **whole** (atomic, INV-23): a changed or
added one renders fully with a small **"changed"** badge; a removed one renders
struck. Mermaid fences render as **diagrams** (mermaid runs in the webview).
Intra-diagram node/edge diffing is deferred (#22).
- It **updates live** as you and Claude edit (debounced), and **re-bases** when
Claude lands an edit (baseline advances, INV-18) or you pin — so accepted text
drops its marks. It **reuses the F6 baseline** and adds **no** persistence
(pure read-only, INV-20).
- The webview is **sealed** (INV-21): local bundled assets only, strict CSP with
a per-load nonce, no network/CDN, no LLM. Mermaid is bundled into the
**webview** asset only, never the extension-host bundle.
- The render engine (`src/trackChangesModel.ts`) is a **pure, vscode-free**
function (INV-22), unit-tested with no editor and no webview; host E2E
(`test/e2e/suite/trackChangesPreview.test.ts`) drives the same programmatic
propose/accept seam with no LLM.
F7 is **markdown-only**; for any other file (incl. code), use F6's diff toggle
(`Ctrl+Alt+D`). The webview's visual rendering (mermaid, theming) is verified by
the manual smoke, not the sealed-sandbox E2E.
Design: `vscode-cowriting-plugin-content/specs/coauthoring-rendered-preview.md`.
Live smoke: [`docs/MANUAL-SMOKE-F7.md`](docs/MANUAL-SMOKE-F7.md).
## Develop
- `npm run watch` — rebuild on change.
+38
View File
@@ -0,0 +1,38 @@
# Manual smoke — F7 rendered track-changes preview (#21)
The webview's *visual* rendering (mermaid, theming) is verified here, not in the
automated host E2E (the webview is a sealed sandbox — §6.8). Run once per change
that touches F7.
## Setup
1. `npm run build`
2. Launch the Extension Development Host (F5 in VS Code, or the Run panel).
3. Open a folder and a markdown document containing prose, a `mermaid` fenced
diagram, and a `ts` code fence (e.g. copy
`test/e2e/fixtures/workspace/docs/preview.md`).
## Steps
1. **Open the preview.** Run **"Cowriting: Open Track-Changes Preview"** (or
`Ctrl+Alt+R`). A preview opens beside the editor, rendering the document.
Header reads `Track changes since opened …`, summary `+0 0`. The mermaid
diagram renders as a diagram; the code fence renders as highlighted code.
2. **Edit prose.** In the source editor, change a word in a paragraph. The
preview updates (≈150 ms): the old word struck (`<del>`), the new word
highlighted (`<ins>`); the summary increments.
3. **Edit the mermaid.** Change `a --> b` to `a --> c`. The preview re-renders the
**new** diagram with a **"changed"** badge at its top-right.
4. **Ask Claude + accept.** Select the target sentence → "Ask Claude to Edit
Selection" → accept the proposal. The accepted text **drops its marks** (the
baseline advanced; PUC-3).
5. **Pin.** Run "Cowriting: Pin Diff Baseline to Now". All marks clear (baseline
== now; PUC-4).
6. **Theme.** Toggle light/dark (`Ctrl+K Ctrl+T`). Marks and the diagram restyle
to the theme.
7. **Cleanliness.** `git status` shows nothing changed by the preview (INV-20).
## Pass criteria
All seven steps behave as described; no console errors in the webview devtools;
nothing written to the document, sidecar, or repo.
File diff suppressed because it is too large Load Diff
+19 -2
View File
@@ -30,13 +30,30 @@ const liveTurnOptions = {
logLevel: "info",
};
/** @type {import('esbuild').BuildOptions} */
const previewOptions = {
entryPoints: ["media/preview.ts"],
outfile: "out/media/preview.js",
bundle: true,
// The webview is a browser context; mermaid is bundled IN (and ONLY in) this
// asset so it never bloats the extension-host bundle (the @cline/sdk size
// discipline). No externals — everything is shipped to the sealed webview.
platform: "browser",
format: "iife",
target: "es2020",
sourcemap: true,
logLevel: "info",
};
if (watch) {
const ctx = await context(options);
const ctxLive = await context(liveTurnOptions);
await Promise.all([ctx.watch(), ctxLive.watch()]);
const ctxPreview = await context(previewOptions);
await Promise.all([ctx.watch(), ctxLive.watch(), ctxPreview.watch()]);
console.log("esbuild: watching…");
} else {
await build(options);
await build(liveTurnOptions);
console.log("esbuild: build complete → out/extension.cjs + out/liveTurn.mjs");
await build(previewOptions);
console.log("esbuild: build complete → out/extension.cjs + out/liveTurn.mjs + out/media/preview.js");
}
+53
View File
@@ -0,0 +1,53 @@
/* F7 track-changes preview — theme-aware via VS Code webview CSS variables. */
body {
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-foreground);
background: var(--vscode-editor-background);
padding: 0 1.2rem 2rem;
line-height: 1.5;
}
#cw-header {
position: sticky;
top: 0;
background: var(--vscode-editor-background);
border-bottom: 1px solid var(--vscode-panel-border);
padding: 0.5rem 0;
font-size: 0.85em;
opacity: 0.85;
display: flex;
gap: 1rem;
}
#cw-summary .cw-add { color: var(--vscode-gitDecoration-addedResourceForeground); }
#cw-summary .cw-del { color: var(--vscode-gitDecoration-deletedResourceForeground); }
.cw-blk { position: relative; }
ins, .cw-added {
background: var(--vscode-diffEditor-insertedTextBackground, rgba(0, 255, 0, 0.15));
text-decoration: none;
}
del, .cw-removed {
background: var(--vscode-diffEditor-removedTextBackground, rgba(255, 0, 0, 0.15));
text-decoration: line-through;
opacity: 0.7;
}
.cw-changed { outline: 2px solid var(--vscode-diffEditor-insertedTextBackground, rgba(0, 255, 0, 0.25)); outline-offset: 2px; }
.cw-badge {
position: absolute;
top: 0;
right: 0;
font-size: 0.7em;
padding: 0 0.4em;
border-radius: 3px;
background: var(--vscode-badge-background);
color: var(--vscode-badge-foreground);
}
.cw-error {
border: 1px solid var(--vscode-inputValidation-errorBorder);
background: var(--vscode-inputValidation-errorBackground);
color: var(--vscode-errorForeground);
padding: 0.3rem 0.6rem;
border-radius: 3px;
font-size: 0.85em;
}
pre.mermaid { text-align: center; background: transparent; }
pre.mermaid[data-cw-error] { color: var(--vscode-errorForeground); }
+54
View File
@@ -0,0 +1,54 @@
/**
* F7 preview webview client (sealed sandbox, INV-21). Receives annotated HTML
* from the extension host and swaps it in; runs mermaid over `.mermaid` blocks
* (mermaid needs a DOM, so it runs here, not in the host). Bundled by esbuild as
* a standalone IIFE → out/media/preview.js, so mermaid never enters the host
* bundle. No network, no LLM.
*/
import mermaid from "mermaid";
// Imported so esbuild emits the sibling out/media/preview.css (the controller
// links it into the sealed shell via asWebviewUri).
import "./preview.css";
interface RenderMessage {
type: "render";
html: string;
epoch: string;
summary: { added: number; removed: number; changed: number };
}
const body = document.getElementById("cw-body")!;
const header = document.getElementById("cw-epoch")!;
const summary = document.getElementById("cw-summary")!;
function themeFor(): "dark" | "default" {
return document.body.classList.contains("vscode-dark") ||
document.body.classList.contains("vscode-high-contrast")
? "dark"
: "default";
}
async function renderMermaid(): Promise<void> {
const nodes = Array.from(body.querySelectorAll<HTMLElement>("pre.mermaid"));
if (nodes.length === 0) return;
mermaid.initialize({ startOnLoad: false, theme: themeFor(), securityLevel: "strict" });
try {
await mermaid.run({ nodes });
} catch {
// mermaid.run already marks failed nodes; ensure a visible chip per failure.
for (const n of nodes) {
if (!n.querySelector("svg")) n.setAttribute("data-cw-error", "true");
}
}
}
window.addEventListener("message", (event: MessageEvent<RenderMessage>) => {
const msg = event.data;
if (msg?.type !== "render") return;
body.innerHTML = msg.html;
header.textContent = `Track changes since ${msg.epoch}`;
summary.innerHTML =
`<span class="cw-add">+${msg.summary.added + msg.summary.changed}</span> ` +
`<span class="cw-del">${msg.summary.removed + msg.summary.changed}</span>`;
void renderMermaid();
});
+1217 -6
View File
File diff suppressed because it is too large Load Diff
+16 -1
View File
@@ -83,6 +83,11 @@
"command": "cowriting.pinDiffBaseline",
"title": "Cowriting: Pin Diff Baseline to Now",
"category": "Cowriting"
},
{
"command": "cowriting.showTrackChangesPreview",
"title": "Cowriting: Open Track-Changes Preview",
"category": "Cowriting"
}
],
"menus": {
@@ -151,6 +156,11 @@
"command": "cowriting.toggleDiffView",
"key": "ctrl+alt+d",
"when": "editorTextFocus"
},
{
"command": "cowriting.showTrackChangesPreview",
"key": "ctrl+alt+r",
"when": "editorLangId == markdown"
}
]
},
@@ -165,9 +175,14 @@
"vscode:prepublish": "node esbuild.mjs"
},
"dependencies": {
"@cline/sdk": "0.0.46"
"@cline/sdk": "0.0.46",
"diff": "^7.0.0",
"markdown-it": "^14.1.0",
"mermaid": "^11.0.0"
},
"devDependencies": {
"@types/diff": "^7.0.0",
"@types/markdown-it": "^14.1.0",
"@types/mocha": "^10.0.7",
"@types/node": "^22.0.0",
"@types/vscode": "^1.90.0",
+7
View File
@@ -26,6 +26,11 @@ export class DiffViewController implements vscode.Disposable {
/** Source of truth for the content provider, keyed by `document.uri.toString()`. */
private readonly baselines = new Map<string, Baseline>();
private readonly onDidChangeEmitter = new vscode.EventEmitter<vscode.Uri>();
/** F7 (additive): fires on every baseline capture (open / advance / pin) so
* the track-changes preview refreshes without polling. Mirrors the internal
* content-provider change signal but carries the real document URI. */
private readonly onDidChangeBaselineEmitter = new vscode.EventEmitter<{ uri: string }>();
readonly onDidChangeBaseline = this.onDidChangeBaselineEmitter.event;
private storageWarned = false;
constructor(private readonly store: BaselineStore | null) {
@@ -38,6 +43,7 @@ export class DiffViewController implements vscode.Disposable {
};
this.disposables.push(
this.onDidChangeEmitter,
this.onDidChangeBaselineEmitter,
vscode.workspace.registerTextDocumentContentProvider(BASELINE_SCHEME, provider),
vscode.commands.registerCommand("cowriting.toggleDiffView", () =>
this.toggle(vscode.window.activeTextEditor),
@@ -123,6 +129,7 @@ export class DiffViewController implements vscode.Disposable {
}
// An open diff re-requests the left side when its baseline URI changes.
this.onDidChangeEmitter.fire(this.baselineUri(document));
this.onDidChangeBaselineEmitter.fire({ uri: key });
}
private warnStorageOnce(): void {
+20 -1
View File
@@ -8,6 +8,7 @@ import { buildFingerprint } from "./anchorer";
import { VersionGuard } from "./versionGuard";
import { BaselineStore } from "./baselineStore";
import { DiffViewController } from "./diffViewController";
import { TrackChangesPreviewController } from "./trackChangesPreview";
const CHANNEL_NAME = "Cowriting (Cline SDK)";
@@ -17,6 +18,7 @@ export interface CowritingApi {
proposalController: ProposalController;
versionGuard: VersionGuard;
diffViewController: DiffViewController;
trackChangesPreviewController: TrackChangesPreviewController;
}
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
@@ -56,6 +58,16 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
const diffViewController = new DiffViewController(baselineStore);
context.subscriptions.push(diffViewController);
// --- F7: rendered track-changes preview (Feature #21) — workspace-INDEPENDENT ---
// Like F6, F7 works on any markdown doc (incl. untitled / out-of-folder), so it
// is constructed regardless of an open folder and its command is always live.
// It reuses the F6 baseline (INV-20) and adds no persistence.
const trackChangesPreviewController = new TrackChangesPreviewController(
diffViewController,
context.extensionUri,
);
context.subscriptions.push(trackChangesPreviewController);
// --- F2: region-anchored threads (Feature #4) ---
const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!root) {
@@ -259,7 +271,14 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
vscode.workspace.textDocuments.forEach(renderIfOpen);
context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen));
return { threadController, attributionController, proposalController, versionGuard, diffViewController };
return {
threadController,
attributionController,
proposalController,
versionGuard,
diffViewController,
trackChangesPreviewController,
};
}
export function deactivate(): void {
+209
View File
@@ -0,0 +1,209 @@
/**
* 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";
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 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);
};
/** 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) {
inner = safe(op.block.raw); // the NEW block, whole (INV-23)
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>`;
}
/** 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");
}
+186
View File
@@ -0,0 +1,186 @@
/**
* TrackChangesPreviewController — F7 vscode layer (spec §6.2/§6.4). Owns one
* sealed webview panel per markdown document, beside the source editor. On open /
* debounced edit / F6 baseline-epoch change it reads the baseline (from the
* reused DiffViewController) + the live buffer, runs the pure render engine, and
* posts the HTML. Pure read-only: never mutates the document, sidecar, or
* baseline (INV-20). The webview is sealed: local assets only, strict CSP,
* per-load nonce, no network (INV-21).
*/
import * as path from "node:path";
import { randomBytes } from "node:crypto";
import * as vscode from "vscode";
import type { DiffViewController } from "./diffViewController";
import { renderTrackChanges, diffBlocks, type BlockOp } from "./trackChangesModel";
const VIEW_TYPE = "cowriting.trackChangesPreview";
const DEBOUNCE_MS = 150;
export class TrackChangesPreviewController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
private readonly panels = new Map<string, vscode.WebviewPanel>();
private readonly lastModel = new Map<string, BlockOp[]>();
private readonly debounces = new Map<string, NodeJS.Timeout>();
constructor(
private readonly diffView: DiffViewController,
private readonly extensionUri: vscode.Uri,
) {
this.disposables.push(
vscode.commands.registerCommand("cowriting.showTrackChangesPreview", () =>
this.show(vscode.window.activeTextEditor?.document),
),
vscode.workspace.onDidChangeTextDocument((e) => this.onEdit(e.document)),
this.diffView.onDidChangeBaseline(({ uri }) => this.refreshByUri(uri)),
);
}
private isMarkdown(document: vscode.TextDocument): boolean {
return document.languageId === "markdown";
}
/** Open or reveal the preview for a markdown document (PUC-1). */
show(document: vscode.TextDocument | undefined): void {
if (!document || !this.isMarkdown(document)) {
void vscode.window.showWarningMessage(
"Cowriting: open a Markdown document to use the track-changes preview (F6 covers other files).",
);
return;
}
const key = document.uri.toString();
const existing = this.panels.get(key);
if (existing) {
existing.reveal(vscode.ViewColumn.Beside);
this.refresh(document);
return;
}
const name = path.basename(document.uri.path) || "untitled";
const panel = vscode.window.createWebviewPanel(
VIEW_TYPE,
`Track changes: ${name}`,
{ viewColumn: vscode.ViewColumn.Beside, preserveFocus: true },
{
enableScripts: true,
retainContextWhenHidden: false,
localResourceRoots: [vscode.Uri.joinPath(this.extensionUri, "out", "media")],
},
);
panel.webview.html = this.shellHtml(panel.webview);
panel.onDidDispose(
() => {
this.panels.delete(key);
this.lastModel.delete(key);
},
null,
this.disposables,
);
this.panels.set(key, panel);
this.refresh(document);
}
private onEdit(document: vscode.TextDocument): void {
const key = document.uri.toString();
if (!this.panels.has(key)) return;
const pending = this.debounces.get(key);
if (pending) clearTimeout(pending);
this.debounces.set(
key,
setTimeout(() => {
this.debounces.delete(key);
this.refresh(document);
}, DEBOUNCE_MS),
);
}
private refreshByUri(uri: string): void {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
if (doc) this.refresh(doc);
}
/** Recompute the model + post HTML to the panel (no-op if no panel). */
refresh(document: vscode.TextDocument): void {
const key = document.uri.toString();
const panel = this.panels.get(key);
if (!panel) return;
const baseline = this.diffView.getBaseline(key);
const baselineText = baseline?.text ?? document.getText(); // no baseline → no marks
const current = document.getText();
const ops = diffBlocks(baselineText, current);
this.lastModel.set(key, ops);
const summary = {
added: ops.filter((o) => o.kind === "added").length,
removed: ops.filter((o) => o.kind === "removed").length,
changed: ops.filter((o) => o.kind === "changed").length,
};
const epoch = this.epochLabel(baseline);
void panel.webview.postMessage({
type: "render",
html: renderTrackChanges(baselineText, current),
epoch,
summary,
});
}
private epochLabel(baseline: { reason: string; capturedAt: string } | undefined): string {
if (!baseline) return "opened (no baseline yet)";
const time = new Date(baseline.capturedAt).toLocaleTimeString();
switch (baseline.reason) {
case "machine-landing":
return `Claude landed ${time}`;
case "pinned":
return `pinned ${time}`;
default:
return `opened ${time}`;
}
}
private shellHtml(webview: vscode.Webview): string {
const nonce = randomBytes(16).toString("base64");
const scriptUri = webview.asWebviewUri(
vscode.Uri.joinPath(this.extensionUri, "out", "media", "preview.js"),
);
const styleUri = webview.asWebviewUri(
vscode.Uri.joinPath(this.extensionUri, "out", "media", "preview.css"),
);
// Sealed CSP (INV-21): no network. 'unsafe-inline' style is required only for
// mermaid's dynamically injected <style> tags; scripts are nonce-gated and
// strictly local (no remote/CDN script source).
const csp =
`default-src 'none'; ` +
`img-src ${webview.cspSource} data:; ` +
`font-src ${webview.cspSource}; ` +
`style-src ${webview.cspSource} 'unsafe-inline'; ` +
`script-src 'nonce-${nonce}';`;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="${csp}" />
<link href="${styleUri}" rel="stylesheet" />
<title>Track changes</title>
</head>
<body>
<div id="cw-header">
<span id="cw-epoch">Track changes</span>
<span id="cw-summary"></span>
</div>
<div id="cw-body"></div>
<script nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>`;
}
// ---- test seam (§6.4) ----
isOpen(uriString: string): boolean {
return this.panels.has(uriString);
}
getLastModel(uriString: string): BlockOp[] | undefined {
return this.lastModel.get(uriString);
}
dispose(): void {
for (const t of this.debounces.values()) clearTimeout(t);
for (const p of this.panels.values()) p.dispose();
for (const d of this.disposables) d.dispose();
}
}
@@ -0,0 +1 @@
Plain text, not markdown — F7 must refuse this (F6 covers it instead).
@@ -0,0 +1,14 @@
# Preview fixture
A target sentence Claude will rewrite via the seam.
Some stable prose that does not change during the test run.
```mermaid
flowchart LR
a --> b
```
```ts
const stable = true;
```
+108
View File
@@ -0,0 +1,108 @@
import * as assert from "assert";
import * as path from "path";
import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension";
const WS = process.env.E2E_WORKSPACE!;
const DOC_REL = "docs/preview.md";
const docUri = () => vscode.Uri.file(path.join(WS, DOC_REL)).toString();
async function openDoc(rel = DOC_REL): Promise<vscode.TextDocument> {
const uri = vscode.Uri.file(path.join(WS, rel));
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
return doc;
}
async function getApi(): Promise<CowritingApi> {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
const api = (await ext.activate()) as CowritingApi;
assert.ok(api?.trackChangesPreviewController, "extension exports trackChangesPreviewController");
return api;
}
const settle = () => new Promise((r) => setTimeout(r, 400));
const kinds = (api: CowritingApi) =>
(api.trackChangesPreviewController.getLastModel(docUri()) ?? []).map((o) => o.kind);
// Order-dependent (F2F6 pattern): later tests consume earlier state.
suite("F7 track-changes preview (host E2E — markdown only, programmatic seam, no LLM)", () => {
const TARGET = "A target sentence Claude will rewrite via the seam.";
const REPLACEMENT = "A SENTENCE CLAUDE REWROTE via the seam.";
test("open on a markdown doc → panel open, opened baseline shows no changes (PUC-1)", async () => {
const doc = await openDoc();
const api = await getApi();
assert.strictEqual(api.trackChangesPreviewController.isOpen(docUri()), false, "no panel yet");
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(docUri()), true, "panel open");
const model = api.trackChangesPreviewController.getLastModel(docUri());
assert.ok(model && model.length > 0, "a model was computed");
assert.ok(
model!.every((o) => o.kind === "unchanged"),
"baseline == buffer at open → every block unchanged",
);
void doc;
});
test("typing produces a changed/added block (PUC-2)", async () => {
const doc = await openDoc();
const api = await getApi();
const edit = new vscode.WorkspaceEdit();
const end = doc.positionAt(doc.getText().length);
edit.insert(doc.uri, end, "\n\nA freshly typed paragraph.\n");
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
await settle();
assert.ok(
kinds(api).some((k) => k === "added" || k === "changed"),
"an added/changed block appears after typing",
);
});
test("accepting a proposal advances the baseline; the landed block goes unchanged (PUC-3, INV-18)", async () => {
const doc = await openDoc();
const api = await getApi();
const start = doc.getText().indexOf(TARGET);
assert.ok(start >= 0, "fixture contains the target sentence");
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
uri: doc.uri.toString(),
start,
end: start + TARGET.length,
newText: REPLACEMENT,
model: "sonnet",
sessionId: "e2e-f7",
turnId: "turn-f7-1",
});
assert.ok(id, "propose returns an id");
assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam");
await settle();
// The baseline advanced to include REPLACEMENT, so the buffer == baseline for
// that block → it is NOT marked. Confirm the replacement is not flagged as a change.
const model = api.trackChangesPreviewController.getLastModel(docUri()) ?? [];
const replacementMarked = model.some(
(o) => o.kind !== "unchanged" && o.block.raw.includes("CLAUDE REWROTE"),
);
assert.ok(!replacementMarked, "the just-landed text renders unmarked (baseline advanced)");
});
test("pin resets the baseline to now → preview shows no marks (PUC-4)", async () => {
const doc = await openDoc();
const api = await getApi();
await vscode.commands.executeCommand("cowriting.pinDiffBaseline");
await settle();
assert.ok(
kinds(api).every((k) => k === "unchanged"),
"after pin, every block is unchanged (baseline == buffer)",
);
void doc;
});
test("a non-markdown doc → command warns and opens no panel (PUC-6, markdown-only)", async () => {
const txt = await openDoc("docs/notes.txt");
const api = await getApi();
const key = txt.uri.toString();
assert.notStrictEqual(txt.languageId, "markdown", "fixture is not markdown");
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), false, "no panel for non-markdown");
});
});
+139
View File
@@ -0,0 +1,139 @@
import { describe, it, expect } from "vitest";
import { splitBlocks, diffBlocks, renderTrackChanges } from "../src/trackChangesModel";
describe("splitBlocks", () => {
it("splits prose paragraphs on blank lines, dropping empties", () => {
const blocks = splitBlocks("# Heading\n\nFirst para.\n\nSecond para.\n");
expect(blocks.map((b) => b.type)).toEqual(["prose", "prose", "prose"]);
expect(blocks[0].raw).toBe("# Heading");
expect(blocks[2].raw).toBe("Second para.");
});
it("keeps a fenced code block whole and tags it `code`", () => {
const text = "Intro.\n\n```ts\nconst a = 1;\n\nconst b = 2;\n```\n\nOutro.\n";
const blocks = splitBlocks(text);
expect(blocks.map((b) => b.type)).toEqual(["prose", "code", "prose"]);
expect(blocks[1].raw).toBe("```ts\nconst a = 1;\n\nconst b = 2;\n```");
});
it("tags a mermaid fence `mermaid`", () => {
const blocks = splitBlocks("```mermaid\nflowchart LR\n a-->b\n```\n");
expect(blocks).toHaveLength(1);
expect(blocks[0].type).toBe("mermaid");
});
it("normalizes the key (whitespace-collapsed) for matching", () => {
const blocks = splitBlocks("Hello world\n");
expect(blocks[0].key).toBe("hello world");
});
});
describe("diffBlocks", () => {
const kinds = (base: string, cur: string) => diffBlocks(base, cur).map((o) => o.kind);
it("unchanged doc → all unchanged", () => {
const doc = "Alpha.\n\nBravo.\n";
expect(kinds(doc, doc)).toEqual(["unchanged", "unchanged"]);
});
it("a pure addition → an added op", () => {
const ops = diffBlocks("Alpha.\n", "Alpha.\n\nBravo.\n");
expect(ops.map((o) => o.kind)).toEqual(["unchanged", "added"]);
});
it("a pure deletion → a removed op", () => {
const ops = diffBlocks("Alpha.\n\nBravo.\n", "Alpha.\n");
expect(ops.map((o) => o.kind)).toEqual(["unchanged", "removed"]);
});
it("a prose modification → a non-atomic changed op", () => {
const ops = diffBlocks("The quick fox.\n", "The slow fox.\n");
expect(ops).toHaveLength(1);
expect(ops[0].kind).toBe("changed");
expect(ops[0].kind === "changed" && ops[0].atomic).toBe(false);
});
it("a code-fence change → an ATOMIC changed op (INV-23)", () => {
const base = "```ts\nconst a = 1;\n```\n";
const cur = "```ts\nconst a = 2;\n```\n";
const ops = diffBlocks(base, cur);
expect(ops).toHaveLength(1);
expect(ops[0].kind).toBe("changed");
expect(ops[0].kind === "changed" && ops[0].atomic).toBe(true);
});
it("a mermaid-fence change → an ATOMIC changed op", () => {
const base = "```mermaid\nflowchart LR\n a-->b\n```\n";
const cur = "```mermaid\nflowchart LR\n a-->c\n```\n";
const ops = diffBlocks(base, cur);
expect(ops[0].kind).toBe("changed");
expect(ops[0].kind === "changed" && ops[0].atomic).toBe(true);
});
it("a reorder → keeps a matched block unchanged", () => {
const ops = diffBlocks("One.\n\nTwo.\n", "Two.\n\nOne.\n");
const k = ops.map((o) => o.kind);
expect(k).toContain("unchanged");
});
});
describe("renderTrackChanges", () => {
it("wraps each block in a cw-blk div with its kind class", () => {
const html = renderTrackChanges("Alpha.\n", "Alpha.\n\nBravo.\n");
expect(html).toContain('class="cw-blk cw-unchanged"');
expect(html).toContain('class="cw-blk cw-added"');
});
it("emits inline <ins>/<del> for a prose modification", () => {
const html = renderTrackChanges("The quick fox.\n", "The slow fox.\n");
expect(html).toContain("<ins>");
expect(html).toContain("<del>");
expect(html).toContain("cw-changed");
});
it("renders a changed CODE fence atomically: cw-changed, no inline ins/del", () => {
const html = renderTrackChanges("```ts\nconst a = 1;\n```\n", "```ts\nconst a = 2;\n```\n");
expect(html).toContain("cw-changed");
expect(html).not.toContain("<ins>");
expect(html).not.toContain("<del>");
expect(html).toContain("cw-badge");
});
it('emits <pre class="mermaid"> for a mermaid fence + a changed badge', () => {
const html = renderTrackChanges(
"```mermaid\nflowchart LR\n a-->b\n```\n",
"```mermaid\nflowchart LR\n a-->c\n```\n",
);
expect(html).toContain('<pre class="mermaid">');
expect(html).toContain("a--&gt;c"); // current source, escaped
expect(html).toContain("cw-badge");
});
it("unchanged doc renders with no marks", () => {
const html = renderTrackChanges("Alpha.\n", "Alpha.\n");
expect(html).not.toContain("cw-added");
expect(html).not.toContain("cw-removed");
expect(html).not.toContain("cw-changed");
});
it("is deterministic (same inputs → identical HTML) (INV-22)", () => {
const a = renderTrackChanges("X.\n\nY.\n", "X.\n\nZ.\n");
const b = renderTrackChanges("X.\n\nY.\n", "X.\n\nZ.\n");
expect(a).toBe(b);
});
});
describe("error chip (PUC-6)", () => {
it("a block whose render throws becomes an error chip; the rest still renders", () => {
const throwing = (src: string) => {
if (src.includes("BOOM")) throw new Error("kaboom");
return `<p>${src}</p>`;
};
const html = renderTrackChanges("Good one.\n\nBOOM here.\n", "Good one.\n\nBOOM here.\n", {
render: throwing,
});
expect(html).toContain("cw-error");
expect(html).toContain("kaboom");
expect(html).toContain("<p>Good one.</p>"); // the healthy block still rendered
});
});