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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
@@ -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();
|
||||
});
|
||||
Generated
+1217
-6
File diff suppressed because it is too large
Load Diff
+16
-1
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
```
|
||||
@@ -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 (F2–F6 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");
|
||||
});
|
||||
});
|
||||
@@ -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-->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
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user