F7: rendered track-changes markdown preview (#21) #23
@@ -164,6 +164,38 @@ instead of git archaeology.
|
|||||||
Design: `vscode-cowriting-plugin-content/specs/coauthoring-diff-view.md`.
|
Design: `vscode-cowriting-plugin-content/specs/coauthoring-diff-view.md`.
|
||||||
Live smoke: [`docs/MANUAL-SMOKE-F6.md`](docs/MANUAL-SMOKE-F6.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
|
## Develop
|
||||||
|
|
||||||
- `npm run watch` — rebuild on change.
|
- `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",
|
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) {
|
if (watch) {
|
||||||
const ctx = await context(options);
|
const ctx = await context(options);
|
||||||
const ctxLive = await context(liveTurnOptions);
|
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…");
|
console.log("esbuild: watching…");
|
||||||
} else {
|
} else {
|
||||||
await build(options);
|
await build(options);
|
||||||
await build(liveTurnOptions);
|
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",
|
"command": "cowriting.pinDiffBaseline",
|
||||||
"title": "Cowriting: Pin Diff Baseline to Now",
|
"title": "Cowriting: Pin Diff Baseline to Now",
|
||||||
"category": "Cowriting"
|
"category": "Cowriting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.showTrackChangesPreview",
|
||||||
|
"title": "Cowriting: Open Track-Changes Preview",
|
||||||
|
"category": "Cowriting"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"menus": {
|
"menus": {
|
||||||
@@ -151,6 +156,11 @@
|
|||||||
"command": "cowriting.toggleDiffView",
|
"command": "cowriting.toggleDiffView",
|
||||||
"key": "ctrl+alt+d",
|
"key": "ctrl+alt+d",
|
||||||
"when": "editorTextFocus"
|
"when": "editorTextFocus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.showTrackChangesPreview",
|
||||||
|
"key": "ctrl+alt+r",
|
||||||
|
"when": "editorLangId == markdown"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -165,9 +175,14 @@
|
|||||||
"vscode:prepublish": "node esbuild.mjs"
|
"vscode:prepublish": "node esbuild.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": {
|
"devDependencies": {
|
||||||
|
"@types/diff": "^7.0.0",
|
||||||
|
"@types/markdown-it": "^14.1.0",
|
||||||
"@types/mocha": "^10.0.7",
|
"@types/mocha": "^10.0.7",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/vscode": "^1.90.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()`. */
|
/** Source of truth for the content provider, keyed by `document.uri.toString()`. */
|
||||||
private readonly baselines = new Map<string, Baseline>();
|
private readonly baselines = new Map<string, Baseline>();
|
||||||
private readonly onDidChangeEmitter = new vscode.EventEmitter<vscode.Uri>();
|
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;
|
private storageWarned = false;
|
||||||
|
|
||||||
constructor(private readonly store: BaselineStore | null) {
|
constructor(private readonly store: BaselineStore | null) {
|
||||||
@@ -38,6 +43,7 @@ export class DiffViewController implements vscode.Disposable {
|
|||||||
};
|
};
|
||||||
this.disposables.push(
|
this.disposables.push(
|
||||||
this.onDidChangeEmitter,
|
this.onDidChangeEmitter,
|
||||||
|
this.onDidChangeBaselineEmitter,
|
||||||
vscode.workspace.registerTextDocumentContentProvider(BASELINE_SCHEME, provider),
|
vscode.workspace.registerTextDocumentContentProvider(BASELINE_SCHEME, provider),
|
||||||
vscode.commands.registerCommand("cowriting.toggleDiffView", () =>
|
vscode.commands.registerCommand("cowriting.toggleDiffView", () =>
|
||||||
this.toggle(vscode.window.activeTextEditor),
|
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.
|
// An open diff re-requests the left side when its baseline URI changes.
|
||||||
this.onDidChangeEmitter.fire(this.baselineUri(document));
|
this.onDidChangeEmitter.fire(this.baselineUri(document));
|
||||||
|
this.onDidChangeBaselineEmitter.fire({ uri: key });
|
||||||
}
|
}
|
||||||
|
|
||||||
private warnStorageOnce(): void {
|
private warnStorageOnce(): void {
|
||||||
|
|||||||
+20
-1
@@ -8,6 +8,7 @@ import { buildFingerprint } from "./anchorer";
|
|||||||
import { VersionGuard } from "./versionGuard";
|
import { VersionGuard } from "./versionGuard";
|
||||||
import { BaselineStore } from "./baselineStore";
|
import { BaselineStore } from "./baselineStore";
|
||||||
import { DiffViewController } from "./diffViewController";
|
import { DiffViewController } from "./diffViewController";
|
||||||
|
import { TrackChangesPreviewController } from "./trackChangesPreview";
|
||||||
|
|
||||||
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ export interface CowritingApi {
|
|||||||
proposalController: ProposalController;
|
proposalController: ProposalController;
|
||||||
versionGuard: VersionGuard;
|
versionGuard: VersionGuard;
|
||||||
diffViewController: DiffViewController;
|
diffViewController: DiffViewController;
|
||||||
|
trackChangesPreviewController: TrackChangesPreviewController;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
|
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);
|
const diffViewController = new DiffViewController(baselineStore);
|
||||||
context.subscriptions.push(diffViewController);
|
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) ---
|
// --- F2: region-anchored threads (Feature #4) ---
|
||||||
const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||||
if (!root) {
|
if (!root) {
|
||||||
@@ -259,7 +271,14 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
vscode.workspace.textDocuments.forEach(renderIfOpen);
|
vscode.workspace.textDocuments.forEach(renderIfOpen);
|
||||||
context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(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 {
|
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