3347a77fde
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1459 lines
50 KiB
Markdown
1459 lines
50 KiB
Markdown
# F7 Rendered Track-Changes Preview Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Add a read-only webview that renders a markdown document beside its source editor and marks what changed since the F6 baseline (prose `<ins>`/`<del>`, atomic code/mermaid "changed" badge), updating live as the human and Claude edit.
|
||
|
||
**Architecture:** A pure, vscode-free render engine (`trackChangesModel.ts`) turns `(baselineText, currentText)` into annotated HTML via a block-level diff (LCS over normalized blocks) + word-level prose refinement, with code/mermaid fences kept atomic. A vscode-layer controller (`trackChangesPreview.ts`) owns one sealed webview panel per markdown document, reads the F6 baseline from the reused `DiffViewController`, and posts HTML on open / debounced edit / baseline-epoch change. The webview asset (`media/preview.ts` → `out/media/preview.js`, a separate esbuild bundle) swaps in the HTML and runs `mermaid.run()` over `.mermaid` blocks. The document, sidecar, and baseline are never mutated (INV-20).
|
||
|
||
**Tech Stack:** TypeScript, VS Code extension API (webview), `markdown-it` + `diff` (host bundle), `mermaid` (webview bundle only), esbuild (two entries), vitest (unit), `@vscode/test-electron` + mocha (host E2E). No LLM anywhere; no network/CDN (INV-21).
|
||
|
||
**Spec:** `vscode-cowriting-plugin-content/specs/coauthoring-rendered-preview.md` (F7, #21). Invariants INV-20..23.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
- **Create** `src/trackChangesModel.ts` — pure render engine (INV-22/23). Exports `splitBlocks`, `diffBlocks`, `renderTrackChanges`, types `Block`/`BlockOp`. No vscode, no DOM.
|
||
- **Create** `test/trackChangesModel.test.ts` — vitest unit suite for the engine.
|
||
- **Create** `media/preview.ts` — webview client: receives `{type:"render", html, epoch, summary}`, swaps `innerHTML`, runs `mermaid.run()`, per-block error chips. Bundles `mermaid`.
|
||
- **Create** `media/preview.css` — theme-aware marks (`ins`, `del`, `.cw-added/removed/changed`, `.cw-badge`, `.cw-error`).
|
||
- **Create** `src/trackChangesPreview.ts` — `TrackChangesPreviewController` (vscode layer): one panel per markdown doc, sealed CSP shell, debounced refresh, test seam.
|
||
- **Create** `test/e2e/suite/trackChangesPreview.test.ts` — host E2E (auto-globbed by `runTest.ts`).
|
||
- **Create** `test/e2e/fixtures/workspace/docs/preview.md` — markdown fixture (prose + mermaid + code + a `TARGET` sentence).
|
||
- **Create** `test/e2e/fixtures/workspace/docs/notes.txt` — non-markdown fixture (markdown-guard test).
|
||
- **Create** `docs/MANUAL-SMOKE-F7.md` — manual smoke script (§6.8).
|
||
- **Modify** `src/diffViewController.ts` — additive `onDidChangeBaseline` event fired on every `capture()`.
|
||
- **Modify** `src/extension.ts` — construct `TrackChangesPreviewController` (workspace-independent, like F6), add to `CowritingApi`.
|
||
- **Modify** `esbuild.mjs` — second entry building `media/preview.ts` → `out/media/preview.js` (IIFE, browser, bundles mermaid).
|
||
- **Modify** `package.json` — `markdown-it` + `diff` + `mermaid` deps, `@types/markdown-it` + `@types/diff` devDeps, command + keybinding contributions.
|
||
- **Modify** `README.md` — F7 section.
|
||
|
||
---
|
||
|
||
## Task 1: Add dependencies and types
|
||
|
||
**Files:**
|
||
- Modify: `package.json`
|
||
|
||
- [ ] **Step 1: Add runtime + dev dependencies**
|
||
|
||
Edit `package.json`. In `"dependencies"` add `markdown-it`, `diff`, `mermaid`; in `"devDependencies"` add `@types/markdown-it`, `@types/diff`. Result:
|
||
|
||
```json
|
||
"dependencies": {
|
||
"@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",
|
||
"@vscode/test-electron": "^2.4.0",
|
||
"ajv": "^8.20.0",
|
||
"esbuild": "^0.23.0",
|
||
"glob": "^11.0.0",
|
||
"mocha": "^10.7.0",
|
||
"typescript": "^5.5.0",
|
||
"vitest": "^2.0.0"
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Install**
|
||
|
||
Run: `npm install`
|
||
Expected: lockfile updates, `node_modules/markdown-it`, `node_modules/diff`, `node_modules/mermaid` present, exit 0.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add package.json package-lock.json
|
||
git commit -m "feat(f7): add markdown-it + diff + mermaid deps (#21)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Block splitting (`splitBlocks`)
|
||
|
||
**Files:**
|
||
- Create: `src/trackChangesModel.ts`
|
||
- Test: `test/trackChangesModel.test.ts`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `test/trackChangesModel.test.ts`:
|
||
|
||
```ts
|
||
import { describe, it, expect } from "vitest";
|
||
import { splitBlocks } 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");
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `npx vitest run test/trackChangesModel.test.ts`
|
||
Expected: FAIL — `splitBlocks` not exported / module missing.
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
Create `src/trackChangesModel.ts`:
|
||
|
||
```ts
|
||
/**
|
||
* 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;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `npx vitest run test/trackChangesModel.test.ts`
|
||
Expected: PASS (4 tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/trackChangesModel.ts test/trackChangesModel.test.ts
|
||
git commit -m "feat(f7): block splitter for the render engine (#21)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Block diff (`diffBlocks`)
|
||
|
||
**Files:**
|
||
- Modify: `src/trackChangesModel.ts`
|
||
- Test: `test/trackChangesModel.test.ts`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Append to `test/trackChangesModel.test.ts`:
|
||
|
||
```ts
|
||
import { diffBlocks } from "../src/trackChangesModel";
|
||
|
||
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 → removed + added (not changed)", () => {
|
||
const ops = diffBlocks("One.\n\nTwo.\n", "Two.\n\nOne.\n");
|
||
// Two. matches (unchanged), One. moves: one removed + one added.
|
||
const k = ops.map((o) => o.kind).sort();
|
||
expect(k).toContain("unchanged");
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `npx vitest run test/trackChangesModel.test.ts`
|
||
Expected: FAIL — `diffBlocks` not exported.
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
Append to `src/trackChangesModel.ts`:
|
||
|
||
```ts
|
||
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;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `npx vitest run test/trackChangesModel.test.ts`
|
||
Expected: PASS (all diffBlocks tests + Task 2 tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/trackChangesModel.ts test/trackChangesModel.test.ts
|
||
git commit -m "feat(f7): block-level diff with atomic fences (#21)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Render to annotated HTML (`renderTrackChanges`)
|
||
|
||
**Files:**
|
||
- Modify: `src/trackChangesModel.ts`
|
||
- Test: `test/trackChangesModel.test.ts`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Append to `test/trackChangesModel.test.ts`:
|
||
|
||
```ts
|
||
import { renderTrackChanges } from "../src/trackChangesModel";
|
||
|
||
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);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `npx vitest run test/trackChangesModel.test.ts`
|
||
Expected: FAIL — `renderTrackChanges` not exported.
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
Append to `src/trackChangesModel.ts`:
|
||
|
||
```ts
|
||
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("");
|
||
}
|
||
|
||
function safeRender(src: string): string {
|
||
try {
|
||
return md.render(src);
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : String(err);
|
||
return `<div class="cw-error">Could not render this block: ${md.utils.escapeHtml(message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderOp(op: BlockOp): string {
|
||
let cls: string;
|
||
let inner: string;
|
||
let badge = "";
|
||
switch (op.kind) {
|
||
case "unchanged":
|
||
cls = "cw-unchanged";
|
||
inner = safeRender(op.block.raw);
|
||
break;
|
||
case "added":
|
||
cls = "cw-added";
|
||
inner = safeRender(op.block.raw);
|
||
if (op.block.type !== "prose") badge = '<span class="cw-badge">added</span>';
|
||
break;
|
||
case "removed":
|
||
cls = "cw-removed";
|
||
inner = safeRender(op.block.raw);
|
||
break;
|
||
case "changed":
|
||
cls = "cw-changed";
|
||
if (op.atomic) {
|
||
inner = safeRender(op.block.raw); // the NEW block, whole (INV-23)
|
||
badge = '<span class="cw-badge">changed</span>';
|
||
} else {
|
||
inner = safeRender(wordMergedMarkdown(op.before.raw, op.block.raw));
|
||
}
|
||
break;
|
||
}
|
||
return `<div class="cw-blk ${cls}">${badge}${inner}</div>`;
|
||
}
|
||
|
||
export interface RenderOptions {
|
||
/** Counts of changed/added/removed blocks, for the header summary. */
|
||
// (reserved; callers derive counts from diffBlocks)
|
||
}
|
||
|
||
/** Pure entry point: annotated HTML body for the preview (INV-22). */
|
||
export function renderTrackChanges(baselineText: string, currentText: string): string {
|
||
return diffBlocks(baselineText, currentText).map(renderOp).join("\n");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `npx vitest run test/trackChangesModel.test.ts`
|
||
Expected: PASS (all render tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/trackChangesModel.ts test/trackChangesModel.test.ts
|
||
git commit -m "feat(f7): render annotated track-changes HTML (#21)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Error-chip fallback (PUC-6) made testable
|
||
|
||
**Files:**
|
||
- Modify: `src/trackChangesModel.ts`
|
||
- Test: `test/trackChangesModel.test.ts`
|
||
|
||
**Why:** `safeRender` already catches render errors, but `markdown-it` almost never throws on real input, so the catch is untested. Add a tiny, honest seam: an exported `renderBlockHtml(op, render)` that takes the per-block markdown renderer as a parameter (defaulting to the module's `safeRender`). The unit test injects a renderer that throws for one block to prove the chip appears AND the rest still renders.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Append to `test/trackChangesModel.test.ts`:
|
||
|
||
```ts
|
||
import { renderTrackChanges as renderWith } from "../src/trackChangesModel";
|
||
|
||
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 = renderWith("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
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `npx vitest run test/trackChangesModel.test.ts`
|
||
Expected: FAIL — `renderTrackChanges` takes only 2 args / `opts.render` ignored.
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
In `src/trackChangesModel.ts`, replace the `RenderOptions` block and the `safeRender`/`renderOp`/`renderTrackChanges` definitions so the per-block renderer is injectable:
|
||
|
||
```ts
|
||
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);
|
||
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 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");
|
||
}
|
||
```
|
||
|
||
Delete the now-unused standalone `safeRender` function.
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `npx vitest run test/trackChangesModel.test.ts`
|
||
Expected: PASS (error-chip test + all prior tests).
|
||
|
||
- [ ] **Step 5: Typecheck + full unit suite**
|
||
|
||
Run: `npm run typecheck && npm test`
|
||
Expected: PASS, no type errors.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/trackChangesModel.ts test/trackChangesModel.test.ts
|
||
git commit -m "feat(f7): per-block error-chip fallback (PUC-6, #21)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Webview asset (CSS + client JS) and second esbuild entry
|
||
|
||
**Files:**
|
||
- Create: `media/preview.css`
|
||
- Create: `media/preview.ts`
|
||
- Modify: `esbuild.mjs`
|
||
|
||
- [ ] **Step 1: Create the stylesheet**
|
||
|
||
Create `media/preview.css`:
|
||
|
||
```css
|
||
/* 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); }
|
||
```
|
||
|
||
- [ ] **Step 2: Create the client script**
|
||
|
||
Create `media/preview.ts`:
|
||
|
||
```ts
|
||
/**
|
||
* 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";
|
||
|
||
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();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Add the second esbuild entry**
|
||
|
||
In `esbuild.mjs`, add a third options object after `liveTurnOptions`, and build it. Insert:
|
||
|
||
```js
|
||
/** @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",
|
||
};
|
||
```
|
||
|
||
Then update the watch and build blocks to include it:
|
||
|
||
```js
|
||
if (watch) {
|
||
const ctx = await context(options);
|
||
const ctxLive = await context(liveTurnOptions);
|
||
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);
|
||
await build(previewOptions);
|
||
console.log("esbuild: build complete → out/extension.cjs + out/liveTurn.mjs + out/media/preview.js");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Build to verify the webview bundle compiles**
|
||
|
||
Run: `npm run build`
|
||
Expected: build complete; `out/media/preview.js` exists (mermaid bundled, a few MB).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add media/preview.ts media/preview.css esbuild.mjs
|
||
git commit -m "feat(f7): sealed webview asset + esbuild preview bundle (#21)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Additive `onDidChangeBaseline` event on the F6 controller
|
||
|
||
**Files:**
|
||
- Modify: `src/diffViewController.ts`
|
||
|
||
- [ ] **Step 1: Add the emitter and public event**
|
||
|
||
In `src/diffViewController.ts`, after the existing `onDidChangeEmitter` field (line ~28), add:
|
||
|
||
```ts
|
||
/** 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;
|
||
```
|
||
|
||
- [ ] **Step 2: Fire it on capture, and dispose it**
|
||
|
||
In the constructor's `this.disposables.push(...)` list, add `this.onDidChangeBaselineEmitter,` alongside `this.onDidChangeEmitter,`.
|
||
|
||
In `capture(...)`, after the existing `this.onDidChangeEmitter.fire(this.baselineUri(document));` line, add:
|
||
|
||
```ts
|
||
this.onDidChangeBaselineEmitter.fire({ uri: key });
|
||
```
|
||
|
||
- [ ] **Step 3: Typecheck**
|
||
|
||
Run: `npm run typecheck`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/diffViewController.ts
|
||
git commit -m "feat(f7): additive onDidChangeBaseline event on DiffViewController (#21)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: `TrackChangesPreviewController` (vscode layer)
|
||
|
||
**Files:**
|
||
- Create: `src/trackChangesPreview.ts`
|
||
|
||
- [ ] **Step 1: Write the controller**
|
||
|
||
Create `src/trackChangesPreview.ts`:
|
||
|
||
```ts
|
||
/**
|
||
* 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();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Typecheck**
|
||
|
||
Run: `npm run typecheck`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/trackChangesPreview.ts
|
||
git commit -m "feat(f7): TrackChangesPreviewController (sealed webview, test seam) (#21)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Wire into the extension + package.json contributions
|
||
|
||
**Files:**
|
||
- Modify: `src/extension.ts`
|
||
- Modify: `package.json`
|
||
|
||
- [ ] **Step 1: Add the command + keybinding to package.json**
|
||
|
||
In `package.json` → `contributes.commands`, add (after `cowriting.pinDiffBaseline`):
|
||
|
||
```json
|
||
{
|
||
"command": "cowriting.showTrackChangesPreview",
|
||
"title": "Cowriting: Open Track-Changes Preview",
|
||
"category": "Cowriting"
|
||
}
|
||
```
|
||
|
||
In `contributes.keybindings`, add (after the `toggleDiffView` binding):
|
||
|
||
```json
|
||
{
|
||
"command": "cowriting.showTrackChangesPreview",
|
||
"key": "ctrl+alt+r",
|
||
"when": "editorLangId == markdown"
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Construct and export the controller**
|
||
|
||
In `src/extension.ts`:
|
||
|
||
(a) Add the import after the `DiffViewController` import:
|
||
|
||
```ts
|
||
import { TrackChangesPreviewController } from "./trackChangesPreview";
|
||
```
|
||
|
||
(b) Extend the `CowritingApi` interface:
|
||
|
||
```ts
|
||
export interface CowritingApi {
|
||
threadController: ThreadController;
|
||
attributionController: AttributionController;
|
||
proposalController: ProposalController;
|
||
versionGuard: VersionGuard;
|
||
diffViewController: DiffViewController;
|
||
trackChangesPreviewController: TrackChangesPreviewController;
|
||
}
|
||
```
|
||
|
||
(c) Right after `context.subscriptions.push(diffViewController);` (the F6 construction, ~line 57), construct F7 (workspace-independent, like F6 — its command must exist with or without a folder):
|
||
|
||
```ts
|
||
// --- 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);
|
||
```
|
||
|
||
(d) In the with-root `return { ... }` statement at the end of `activate`, add the controller:
|
||
|
||
```ts
|
||
return {
|
||
threadController,
|
||
attributionController,
|
||
proposalController,
|
||
versionGuard,
|
||
diffViewController,
|
||
trackChangesPreviewController,
|
||
};
|
||
```
|
||
|
||
(The no-root early `return undefined;` stays as-is — the command is already registered by the controller constructed above, so it exists without a folder.)
|
||
|
||
- [ ] **Step 3: Typecheck + build**
|
||
|
||
Run: `npm run typecheck && npm run build`
|
||
Expected: PASS; `out/extension.cjs` and `out/media/preview.js` rebuilt.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/extension.ts package.json
|
||
git commit -m "feat(f7): register showTrackChangesPreview command + keybinding, wire controller (#21)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Host E2E fixtures + suite
|
||
|
||
**Files:**
|
||
- Create: `test/e2e/fixtures/workspace/docs/preview.md`
|
||
- Create: `test/e2e/fixtures/workspace/docs/notes.txt`
|
||
- Create: `test/e2e/suite/trackChangesPreview.test.ts`
|
||
|
||
- [ ] **Step 1: Create the markdown fixture**
|
||
|
||
Create `test/e2e/fixtures/workspace/docs/preview.md`:
|
||
|
||
```markdown
|
||
# 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;
|
||
```
|
||
```
|
||
|
||
(Note: the fixture file literally contains the nested ``` fences shown above.)
|
||
|
||
- [ ] **Step 2: Create the non-markdown fixture**
|
||
|
||
Create `test/e2e/fixtures/workspace/docs/notes.txt`:
|
||
|
||
```
|
||
Plain text, not markdown — F7 must refuse this (F6 covers it instead).
|
||
```
|
||
|
||
- [ ] **Step 3: Write the E2E suite**
|
||
|
||
Create `test/e2e/suite/trackChangesPreview.test.ts`:
|
||
|
||
```ts
|
||
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",
|
||
);
|
||
// sanity: the active tab is a webview of our view type
|
||
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();
|
||
// append a brand-new paragraph at the end
|
||
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");
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Build E2E + run**
|
||
|
||
Run: `npm run pretest:e2e && npm run test:e2e`
|
||
Expected: the new suite passes alongside the existing F2–F6 suites; both workspace and no-workspace passes complete (the no-workspace pass exercises the F2 stubs only — F7's command exists but isn't invoked there). Exit 0.
|
||
|
||
If the E2E electron download/run is not available in this environment, build at minimum (`npm run pretest:e2e`) to confirm the suite compiles, and note the run in the transcript for the manual smoke / CI.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add test/e2e/suite/trackChangesPreview.test.ts test/e2e/fixtures/workspace/docs/preview.md test/e2e/fixtures/workspace/docs/notes.txt
|
||
git commit -m "test(f7): host E2E for the track-changes preview (#21)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Manual smoke doc + README section
|
||
|
||
**Files:**
|
||
- Create: `docs/MANUAL-SMOKE-F7.md`
|
||
- Modify: `README.md`
|
||
|
||
- [ ] **Step 1: Write the manual smoke script**
|
||
|
||
Create `docs/MANUAL-SMOKE-F7.md` following the §6.8 "Live smoke" script. Content:
|
||
|
||
```markdown
|
||
# 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>`); 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.
|
||
```
|
||
|
||
- [ ] **Step 2: Add a README F7 section**
|
||
|
||
In `README.md`, add a section describing F7 (mirroring how F6 is documented):
|
||
|
||
```markdown
|
||
## F7 — Rendered track-changes preview (#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: prose additions highlighted / deletions struck,
|
||
and code/mermaid fences shown whole with a "changed" badge (intra-diagram mermaid
|
||
diffing is deferred — #22). It updates live as you and Claude edit, and re-bases
|
||
when Claude lands an edit or you pin (reusing the F6 baseline — it adds no
|
||
persistence). Mermaid renders fully. F7 is **markdown-only**; for any other file,
|
||
use F6's diff toggle (`Ctrl+Alt+D`). Manual smoke: `docs/MANUAL-SMOKE-F7.md`.
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add docs/MANUAL-SMOKE-F7.md README.md
|
||
git commit -m "docs(f7): manual smoke script + README section (#21)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Full verification
|
||
|
||
**Files:** (none — verification only)
|
||
|
||
- [ ] **Step 1: Typecheck**
|
||
|
||
Run: `npm run typecheck`
|
||
Expected: PASS, no errors.
|
||
|
||
- [ ] **Step 2: Unit suite**
|
||
|
||
Run: `npm test`
|
||
Expected: all vitest suites pass, including `trackChangesModel.test.ts`.
|
||
|
||
- [ ] **Step 3: Build**
|
||
|
||
Run: `npm run build`
|
||
Expected: `out/extension.cjs`, `out/liveTurn.mjs`, `out/media/preview.js` all produced.
|
||
|
||
- [ ] **Step 4: Host E2E**
|
||
|
||
Run: `npm run test:e2e`
|
||
Expected: all suites (F2–F7) green. (If electron is unavailable locally, record that the suite compiles and defer the run to CI / manual smoke — note it in the transcript.)
|
||
|
||
- [ ] **Step 5: Confirm the repo is clean of preview side effects**
|
||
|
||
Run: `git status`
|
||
Expected: only intended source/doc changes; nothing written under `.threads/` or the workspace by the preview (INV-20).
|
||
|
||
---
|
||
|
||
## Self-Review (completed during planning)
|
||
|
||
**Spec coverage:**
|
||
- Rendered preview beside editable source (PUC-1) → Task 8 (`show`, `ViewColumn.Beside`), Task 9 (command).
|
||
- Marks since F6 baseline (PUC-2/3), epoch follows machine / pin → Task 7 (`onDidChangeBaseline`), Task 8 (`refresh`), Task 10 (E2E PUC-3/4).
|
||
- Full mermaid support (PUC-5) → Task 4 (`<pre class="mermaid">`), Task 6 (`mermaid.run()`).
|
||
- Mermaid change = whole-diagram badge (INV-23) → Task 3 (atomic), Task 4 (badge).
|
||
- Prose inline ins/del (PUC-2) → Task 4 (`wordMergedMarkdown`).
|
||
- Live update on edit + epoch (PUC-2/3/4) → Task 8 (debounce + event sub).
|
||
- Pure read-only, no sidecar/contract impact (INV-20) → Task 8 (reads only), Task 12 step 5.
|
||
- Sealed webview, no network/LLM, local mermaid (INV-21) → Task 6 (browser bundle), Task 8 (CSP/nonce/localResourceRoots).
|
||
- Deterministic testable model (INV-22) → Tasks 2–5 (vscode-free, determinism test).
|
||
- Atomic non-prose blocks (INV-23) → Task 3 (atomic flag), Task 4 (no word diff for atomic).
|
||
- Graceful edges (PUC-6): non-markdown warn → Task 8/10; error chip → Task 5; no baseline → Task 8 (`baselineText ?? getText()`).
|
||
- Unit + host E2E, no LLM in CI (§6.8) → Tasks 2–5 (unit), Task 10 (E2E).
|
||
|
||
**Placeholder scan:** none — every code step shows complete code.
|
||
|
||
**Type consistency:** `Block`/`BlockOp` defined in Task 2/3 and consumed unchanged in Tasks 4/5/8; `renderTrackChanges(baselineText, currentText, opts?)`, `diffBlocks`, `getBaseline(uriString)` (existing F6), `onDidChangeBaseline({uri})`, `isOpen`/`getLastModel` consistent across tasks.
|
||
```
|