F11 — Preview toolbar as the primary interaction surface (#43) #44

Merged
benstull merged 6 commits from f11-preview-toolbar into main 2026-06-12 21:19:02 +00:00
10 changed files with 992 additions and 35 deletions
+30 -2
View File
@@ -14,8 +14,9 @@ catalog (a pure, key-free SDK call) in a notification and the
Features shipped so far: F2 region-anchored threads (Feature #4), F3 live
human/Claude attribution (Feature #6), F4 propose/accept diff flow
(Feature #12), F5 cross-rung sidecar contract (Feature #14), F6 diff-view
toggle (Feature #17), and F10 interactive review — **write left / review
right** (Feature #29).
toggle (Feature #17), F10 interactive review — **write left / review
right** (Feature #29), and F11 — the **preview toolbar as the primary
interaction surface** (Feature #43).
## Architecture
@@ -262,6 +263,33 @@ they are no longer separate user surfaces.
Design: `vscode-cowriting-plugin-content/specs/coauthoring-interactive-review.md`.
Live smoke: [`docs/MANUAL-SMOKE-F10.md`](docs/MANUAL-SMOKE-F10.md).
## F11 — Preview toolbar as the primary interaction surface (Feature #43)
The review preview's **header toolbar** becomes the cockpit for the inner loop.
Beside the existing **Annotations** switch it gains two controls:
- **⌖ Pin baseline** — pins the previewed document's review baseline to now and
clears the change-marks (homes the previously-orphaned `pinDiffBaseline`
command, which is reachable from the palette again too).
- **✦ Ask Claude…** — one **adaptive** button. Its label flips on the preview's
selection: **Edit Selection** when text is selected in the rendered preview,
**Edit Document** otherwise. Clicking it opens a host input box for the
instruction (the LLM turn and prompt stay host-side — the sealed webview gains
no LLM/credential surface), then surfaces the result as F4 proposals — **one**
for a selection (mapped back to its source block-union), or **one per changed
hunk** for a whole-document rewrite (`diffToHunks`), each independently ✓/✗-able.
The pure render layer emits `data-src-start`/`data-src-end` on every block in
**both** modes (INV-36); the webview's only mapping duty is walking a selection
to its nearest `data-src` ancestor. Right-clicking a markdown tab → **Open Review
Preview** is the minimal gateway making the surface reachable end to end (#41/#42
expand it). Edit controls are disabled on a non-authorable doc (reading stays
allowed). No new model, no new persistence — pin via the F6 store, edits via the
F4 propose/accept seam with F3 attribution (INV-35..37).
Design: [`docs/superpowers/specs/2026-06-12-f11-preview-toolbar-interaction-surface.md`](docs/superpowers/specs/2026-06-12-f11-preview-toolbar-interaction-surface.md).
Live smoke: [`docs/MANUAL-SMOKE-F11.md`](docs/MANUAL-SMOKE-F11.md).
## Develop
- `npm run watch` — rebuild on change.
+67
View File
@@ -0,0 +1,67 @@
# Manual smoke — F11 preview toolbar as the primary interaction surface (#43)
F11 makes the rendered preview's **header toolbar** the primary interaction
surface: beside the existing **Annotations** switch it gains a **Pin baseline**
button and a **single adaptive Ask-Claude button** (Edit Selection when text is
selected in the preview, Edit Document otherwise). The webview's *visual*
behavior — the buttons, the live label flip, and the selection→source mapping —
is verified **here**, not in the automated host E2E (the webview is a sealed
sandbox; the host's message→seam wiring is covered by the F11 E2E suite). Run
once per change that touches F11. Live turns hit the SDK; use a trivial
instruction to keep them quick.
## Setup
1. `npm run build`
2. Launch the Extension Development Host (F5 in VS Code, or the Run panel) with
`sandbox/` open.
3. Open a markdown document with a few paragraphs (e.g. copy
`test/e2e/fixtures/workspace/docs/preview.md`).
## Steps
1. **Right-click gateway (PUC-6).** Right-click the markdown editor **tab** (or
use the editor title bar) → **"Cowriting: Open Review Preview"**. The preview
opens beside the editor. (The palette entry and `Ctrl+Alt+R` still work too.)
2. **Three header controls (PUC-1).** The header row shows, left to right: the
**☑ Annotations** checkbox (on), a **⌖ Pin baseline** button, and a **✦ Ask
Claude to Edit Document** button. They restyle with the theme.
3. **Adaptive label — selection (PUC-2).** Select a paragraph **in the preview**
(drag across rendered text). The Ask-Claude button label flips live to **"✦
Ask Claude to Edit Selection"**. Click elsewhere to clear the selection → it
flips back to **"✦ Ask Claude to Edit Document"**.
4. **Edit Selection (PUC-3).** With a paragraph selected in the preview, click
**Ask Claude to Edit Selection** → a host input box appears → type an
instruction (e.g. "tighten this") → submit. A **blue `cw-proposal` block**
appears **inline at that paragraph** with **✓ / ✗** (the selection mapped back
to its source block; the edit ran host-side). The editor does **not** change
(INV-10). Accept with **✓**: the replacement lands; the block clears.
5. **Edit Document (PUC-4).** Clear the selection (button reads "Edit Document").
Click it → instruct (e.g. "fix any typos and tighten") → submit. Claude's
whole-document rewrite surfaces as **one or more** independent blue proposal
blocks (one per changed hunk), each independently **✓ / ✗**-able. Accept some,
reject others — each behaves as an ordinary F10 proposal.
6. **Pin baseline (PUC-5).** Make (or accept) a few changes so the preview shows
change-marks and a non-zero summary. Click **⌖ Pin baseline**. Expect: the
change-marks **clear**, the summary resets to `+0 0`, and the header epoch
reads **`pinned <time>`**. "What changed" now counts from this moment.
7. **Off-state still maps (INV-36).** Toggle **Annotations** off (clean preview).
Selecting a paragraph still flips the button to **Edit Selection** and an edit
still works — the off/clean preview is also a selection→source surface.
8. **Non-authorable doc (PUC-1/7).** Open a markdown doc from a read-only/virtual
source (e.g. a Git diff view, or an Output channel rendered as markdown) and
open the preview. Expect: **Pin baseline** and **Ask-Claude** render
**disabled**; the **Annotations** toggle stays active (reading is allowed).
9. **Theme.** Toggle light / dark / high-contrast (`Ctrl+K Ctrl+T`). The two
toolbar buttons (enabled, hover, disabled states) restyle and stay legible.
10. **Cleanliness.** `git status` shows nothing written to the document, sidecar,
or repo by the toolbar gestures except the edits you explicitly accepted
(INV-20/35).
## Pass criteria
All ten steps behave as described; the adaptive label tracks the live selection;
the selection-scoped edit anchors at the right paragraph; the document edit
produces per-hunk proposals; pin clears the marks; edit controls are inert on a
non-authorable doc; no console errors in the webview devtools; nothing is
persisted by the toolbar beyond the edits you accepted.
@@ -0,0 +1,123 @@
# Implementation Plan: F11 — Preview Toolbar as the Primary Interaction Surface (#43)
**Spec:** `docs/superpowers/specs/2026-06-12-f11-preview-toolbar-interaction-surface.md`
**Anchor:** Feature `benstull/vscode-cowriting-plugin#43` (F11, `type/feature`, `priority/P1`)
**Session:** vscode-cowriting-plugin-0037
This plan transcribes the spec's §7.2 slicing plan into concrete, file-level
tasks. Each slice is independently green (unit + host E2E) before the next. Host
E2E is this app's required tier (no browser/deploy stage — a VS Code extension);
no LLM in CI (edit turns stubbed). The webview's visual rendering, the adaptive
label, and the selection→source DOM lookup are manual-smoke only.
---
## SLICE-1 — Pin baseline button + reachability *(the immediate win)*
Homes the orphaned `cowriting.pinDiffBaseline` command and gives the writer a
reachable Pin control in the preview toolbar.
**Tasks**
1. **Host message routing** (`src/trackChangesPreview.ts`): extract the inline
`onDidReceiveMessage` body into a private `handleWebviewMessage(document, m)`
method; add a `pinBaseline` branch that calls `this.diffView.pin(document)`
(the *previewed* document — not `activeTextEditor`). The existing
`onDidChangeBaseline` subscription already re-renders with cleared marks.
2. **Test seam**: add `receiveMessage(uriString, m)` that resolves the doc and
calls `handleWebviewMessage`, so host E2E can simulate the raw webview message
and exercise the real routing.
3. **Webview** (`media/preview.ts` + `.css`): add a `⌖ Pin baseline` button in
`#cw-header`; click → `postMessage({ type: "pinBaseline" })`; theme-aware CSS.
4. **Shell HTML** (`shellHtml`): add the `<button id="cw-pin">` to the header row.
5. **Reachability** (`package.json`): flip `cowriting.pinDiffBaseline`'s
`commandPalette` `when` from `false` to `editorLangId == markdown`.
**Host E2E** (`test/e2e/suite/trackChangesPreview.test.ts` or new f11 suite):
open a markdown fixture with a divergent baseline → some block marked; simulate
`{type:"pinBaseline"}` via `receiveMessage``getLastModel` shows every block
`unchanged` (marks cleared) and `getBaseline(key).reason === "pinned"`.
**Unit:** none new (pure layer untouched this slice).
---
## SLICE-2 — Block-offset emission *(INV-36 data layer)*
Shared pure helper wrapping each rendered block with `data-src-start`/`-end`
(source char offsets from `BlockWithRange`) in **both** `renderReview` and
`renderPlain`. vscode-free, DOM-free, deterministic (extends INV-22/33). No UI.
**Tasks**
1. `src/trackChangesModel.ts`: a shared internal helper that prepends
`data-src-start="N" data-src-end="M"` to each block's wrapping element, routed
from both render paths using the existing `splitBlocksWithRanges` offsets.
2. Skip blocks with no live-source range (deletion-only / proposal blocks).
**Unit** (`test/trackChangesModel.test.ts`): `data-src-start/end` present and
correct on every block for both modes (offsets equal `BlockWithRange` ranges);
determinism (same inputs → identical HTML).
---
## SLICE-3 — Edit Document button + hunk path *(INV-37 document half)*
**Tasks**
1. `src/trackChangesModel.ts`: pure `diffToHunks(currentText, rewrittenText):
Array<{ start; end; replacement }>` — vscode-free, deterministic.
2. `src/trackChangesPreview.ts`: `runEditAndPropose(document, target, instruction)`
private routine; `askClaude`/`document` branch → host `showInputBox` →
`runEditTurn` over full text → `diffToHunks` → one F4 `propose()` per hunk.
3. `package.json`: register `cowriting.editDocument` (document-scoped), routed
through `runEditAndPropose({kind:"document"})`; for `#42` reuse.
4. Webview: `✦ Ask Claude to Edit Document` button (no-selection state) →
`postMessage({ type: "askClaude", scope: "document" })`.
**Unit:** `diffToHunks` over fixtures (single hunk → one range; multi-hunk →
disjoint ranges + correct replacements; unchanged → zero hunks; whole-doc
replacement → one full-range hunk).
**Host E2E:** simulate `{askClaude, scope:"document"}` with a stubbed multi-hunk
rewrite → **N** proposals matching the hunks.
---
## SLICE-4 — Adaptive Edit Selection *(INV-37 selection half; INV-36 consumer)*
**Tasks**
1. Webview: `selectionchange` listener flips the Ask-Claude label (Edit Selection
⇆ Edit Document); selection→nearest-`data-src` ancestor resolution →
`postMessage({ type:"askClaude", scope:"selection", start, end })`.
2. Host: `askClaude`/`selection` branch → `runEditAndPropose({kind:"range",
start, end})` → one `runEditTurn` → one F4 `propose()` over the block-union.
3. Edge: selection resolving to no live block → fall back to document scope.
**Host E2E:** simulate `{askClaude, scope:"selection", start, end}` with a stubbed
edit turn → exactly **one** proposal over the resolved range, anchored inline.
---
## SLICE-5 — Gateway, edges, tests & docs
**Tasks**
1. `package.json`: add `cowriting.showTrackChangesPreview` to `editor/title` with
`when: editorLangId == markdown` (minimal right-click gateway).
2. Non-authorable disabling: Pin + Ask-Claude controls render disabled when
`!isAuthorable(document)`; annotations toggle stays active.
3. Host E2E: gateway command opens the panel; controls inert on non-authorable.
4. `docs/MANUAL-SMOKE-F11.md` (live smoke script per spec §6.8).
5. README F11 section.
---
## Done = #43 acceptance (spec §7.3)
Preview toolbar hosts annotations checkbox + Pin baseline + single adaptive
Ask-Claude (Edit Selection ⇆ Edit Document) routing through existing F4/F3/F6;
edits surface as proposals (one for a selection, per-hunk for a document
rewrite); a right-click entry opens the preview; the pin command is no longer
orphaned; unit + host E2E green; live smoke performed once.
+13
View File
@@ -59,6 +59,19 @@ pre.mermaid[data-cw-error] { color: var(--vscode-errorForeground); }
#cw-legend .cw-swatch { padding: 0 0.4em; border-radius: 3px; }
#cw-summary .cw-prop { opacity: 0.85; }
/* F11 — preview toolbar buttons (Pin baseline; adaptive Ask Claude). Theme-aware. */
#cw-header button {
cursor: pointer;
font: inherit;
border: 1px solid var(--vscode-button-border, transparent);
border-radius: 3px;
padding: 0.1em 0.55em;
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
}
#cw-header button:hover:not(:disabled) { background: var(--vscode-button-secondaryHoverBackground); }
#cw-header button:disabled { opacity: 0.5; cursor: default; }
/* F10 interactive review — annotations toggle + ✓/✗ proposal blocks. */
#cw-toggle { display: inline-flex; align-items: center; gap: 0.35em; cursor: pointer; }
.cw-proposal {
+61
View File
@@ -18,6 +18,8 @@ interface RenderMessage {
html: string;
epoch?: string;
summary?: { added: number; removed: number; proposals: number };
/** F11: false on a non-authorable doc → Pin + Ask-Claude controls disabled. */
authorable?: boolean;
}
const vscodeApi = acquireVsCodeApi();
@@ -26,12 +28,66 @@ const header = document.getElementById("cw-epoch")!;
const summary = document.getElementById("cw-summary")!;
const legend = document.getElementById("cw-legend")!;
const annotationsEl = document.getElementById("cw-annotations") as HTMLInputElement | null;
const pinEl = document.getElementById("cw-pin") as HTMLButtonElement | null;
const askEl = document.getElementById("cw-ask") as HTMLButtonElement | null;
// F10: the annotations on/off toggle.
annotationsEl?.addEventListener("change", () => {
vscodeApi.postMessage({ type: "setMode", mode: annotationsEl.checked ? "on" : "off" });
});
// F11 (SLICE-1): Pin baseline — post intent; the host pins via the F6 store (INV-35).
pinEl?.addEventListener("click", () => {
vscodeApi.postMessage({ type: "pinBaseline" });
});
// F11 (SLICE-4): the single adaptive Ask-Claude button. Its label flips on
// `selectionchange` (Edit Selection when live text is selected in the preview,
// Edit Document otherwise), and a click resolves the selection to a SOURCE range
// via the nearest `data-src` ancestors (INV-36) — the webview's sole mapping
// duty. A selection that resolves to no live block falls back to document scope.
/** Walk up from a DOM node to the nearest block carrying data-src offsets (INV-36). */
function nearestSrc(node: Node | null): HTMLElement | null {
let el: HTMLElement | null = node instanceof HTMLElement ? node : (node?.parentElement ?? null);
while (el && el !== body) {
if (el.dataset.srcStart !== undefined && el.dataset.srcEnd !== undefined) return el;
el = el.parentElement;
}
return null;
}
/** The source [start,end) union of the live blocks a non-empty body selection touches, or null. */
function selectionSrcRange(): { start: number; end: number } | null {
const sel = window.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null;
const ends = [nearestSrc(sel.anchorNode), nearestSrc(sel.focusNode)].filter(
(e): e is HTMLElement => e !== null,
);
if (ends.length === 0) return null; // selection touches no live-source block
const starts = ends.map((e) => Number(e.dataset.srcStart));
const stops = ends.map((e) => Number(e.dataset.srcEnd));
return { start: Math.min(...starts), end: Math.max(...stops) };
}
function updateAskLabel(): void {
if (!askEl) return;
askEl.textContent = selectionSrcRange()
? "✦ Ask Claude to Edit Selection"
: "✦ Ask Claude to Edit Document";
}
document.addEventListener("selectionchange", updateAskLabel);
askEl?.addEventListener("click", () => {
const range = selectionSrcRange();
if (range) {
vscodeApi.postMessage({ type: "askClaude", scope: "selection", start: range.start, end: range.end });
} else {
vscodeApi.postMessage({ type: "askClaude", scope: "document" });
}
});
// F10: delegated ✓/✗ accept/reject of pending proposals (routed back to the F4 seam).
body.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement)?.closest<HTMLElement>(".cw-actions button");
@@ -69,6 +125,11 @@ window.addEventListener("message", (event: MessageEvent<RenderMessage>) => {
const msg = event.data;
if (msg?.type !== "render") return;
body.innerHTML = msg.html;
updateAskLabel(); // new content clears any selection → reset the adaptive label
// F11 (PUC-1/7): disable edit controls on a non-authorable doc (reading stays on).
const authorable = msg.authorable !== false;
if (pinEl) pinEl.disabled = !authorable;
if (askEl) askEl.disabled = !authorable;
const on = msg.mode === "on";
if (annotationsEl) annotationsEl.checked = on;
// Off-state is a clean preview: hide the review chrome.
+17 -1
View File
@@ -78,6 +78,11 @@
"command": "cowriting.showTrackChangesPreview",
"title": "Cowriting: Open Review Preview",
"category": "Cowriting"
},
{
"command": "cowriting.editDocument",
"title": "Ask Claude to Edit Document",
"category": "Cowriting"
}
],
"menus": {
@@ -100,7 +105,18 @@
},
{
"command": "cowriting.pinDiffBaseline",
"when": "false"
"when": "editorLangId == markdown"
},
{
"command": "cowriting.editDocument",
"when": "editorLangId == markdown"
}
],
"editor/title": [
{
"command": "cowriting.showTrackChangesPreview",
"when": "editorLangId == markdown",
"group": "navigation@9"
}
],
"editor/context": [
+120 -12
View File
@@ -9,7 +9,7 @@
* DOM — `markdown-it` and `diff` are libraries; mermaid runs later in the webview.
*/
import MarkdownIt from "markdown-it";
import { diffArrays, diffWords } from "diff";
import { diffArrays, diffWords, diffWordsWithSpace } from "diff";
import { diffMermaid } from "./mermaidDiff";
export type BlockType = "prose" | "code" | "mermaid";
@@ -193,6 +193,85 @@ export function diffBlocks(baselineText: string, currentText: string): BlockOp[]
return ops;
}
/** A contiguous changed region of `currentText` and its replacement (F11). */
export interface EditHunk {
/** char offset of the hunk's first changed char in currentText. */
start: number;
/** char offset one past the hunk's last changed char (start==end → a pure insertion). */
end: number;
/** the text that replaces currentText.slice(start, end). */
replacement: string;
}
/**
* F11 (INV-37, §6.4): diff a whole-document rewrite into per-hunk replacement
* ranges — each becomes one independent F4 single-range proposal (so a document
* edit surfaces as N independently ✓/✗-able blue blocks, no new model). Pure,
* vscode-free, deterministic. Offsets index into `currentText`: removed +
* unchanged parts reconstruct it exactly, so advancing on those yields true
* source offsets. Adjacent added/removed runs coalesce into one hunk; an
* unchanged part flushes the open hunk.
*
* A PURE insertion (added text between two unchanged regions) would otherwise be
* a zero-width hunk (start==end) — and F4's fingerprint of empty text can never
* resolve (anchorer.resolve orphans an empty needle), so the proposal could
* never be accepted. Every zero-width hunk is therefore anchored to an adjacent
* source token (`anchorInsertion`): its range absorbs that token and its
* replacement keeps it, so the net text is identical but `fp.text` is non-empty
* and resolvable. A zero-width hunk is always bordered by unchanged text (or a
* doc edge), so the absorbed token is genuinely present in `currentText` and is
* never part of another hunk.
*/
export function diffToHunks(currentText: string, rewrittenText: string): EditHunk[] {
const hunks: EditHunk[] = [];
let offset = 0;
let open: EditHunk | null = null;
const flush = () => {
if (open) hunks.push(open.start === open.end ? anchorInsertion(open, currentText) : open);
open = null;
};
for (const part of diffWordsWithSpace(currentText, rewrittenText)) {
if (part.added) {
open ??= { start: offset, end: offset, replacement: "" };
open.replacement += part.value;
} else if (part.removed) {
open ??= { start: offset, end: offset, replacement: "" };
offset += part.value.length;
open.end = offset;
} else {
flush();
offset += part.value.length;
}
}
flush();
return hunks;
}
const isWs = (c: string): boolean => /\s/.test(c);
/**
* Grow a zero-width insertion hunk to absorb an adjacent source token so its F4
* fingerprint has real text to resolve. Prefer the FOLLOWING token (optional
* leading whitespace + a run of non-whitespace); at end-of-document, fall back to
* the PRECEDING token. The absorbed text is kept in the replacement, so the
* applied result is unchanged.
*/
function anchorInsertion(hunk: EditHunk, text: string): EditHunk {
const p = hunk.start;
if (p < text.length) {
let e = p;
while (e < text.length && isWs(text[e])) e++;
while (e < text.length && !isWs(text[e])) e++;
if (e === p) e = Math.min(text.length, p + 1);
return { start: p, end: e, replacement: hunk.replacement + text.slice(p, e) };
}
let s = p;
while (s > 0 && isWs(text[s - 1])) s--;
while (s > 0 && !isWs(text[s - 1])) s--;
if (s === p) s = Math.max(0, p - 1);
return { start: s, end: p, replacement: text.slice(s, p) + hunk.replacement };
}
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>).
@@ -247,7 +326,17 @@ 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 {
/**
* F11 (INV-36): the source-range attributes a live block's wrapping element
* carries, so a preview selection can map back to a source markdown range. `""`
* when the block has no live source (a baseline-only deletion / a proposal
* block) — those are skipped by the selection mapper. Pure + deterministic.
*/
function srcAttr(blk: { start: number; end: number } | undefined): string {
return blk ? ` data-src-start="${blk.start}" data-src-end="${blk.end}"` : "";
}
function renderOp(op: BlockOp, render: (src: string) => string, src = ""): string {
const safe = (src: string): string => {
try {
return render(src);
@@ -293,7 +382,7 @@ function renderOp(op: BlockOp, render: (src: string) => string): string {
}
break;
}
return `<div class="cw-blk ${cls}">${badge}${inner}</div>`;
return `<div class="cw-blk ${cls}"${src}>${badge}${inner}</div>`;
}
export type AuthorKind = "claude" | "human";
@@ -367,14 +456,31 @@ export function colorByAuthor(
return sentinelsToSpans(render(injected));
}
/** Off-state body: the current buffer as plain markdown, no annotations (INV-33). */
/**
* Off-state body: the current buffer as plain markdown, no annotations (INV-33).
* Each block is wrapped in a bare `<div data-src-start/end>` so the off-mode
* preview is still a selection→source mapping surface (INV-36) while staying
* visually clean — no `cw-` annotation classes. A doc with no blocks (empty /
* whitespace-only) renders whole. Pure + deterministic.
*
* Tradeoff of the locked block-level mapping (§6.7): rendering PER BLOCK (so each
* carries its offsets) means a markdown construct split across blank-line-
* separated blocks — a reference-link use and its definition — doesn't resolve
* across blocks. `renderReview` already rendered per-block; this keeps the two
* modes consistent rather than faithful-but-different. Characterized in tests.
*/
export function renderPlain(currentText: string, opts: RenderOptions = {}): string {
const render = opts.render ?? defaultRender;
try {
return render(currentText);
} catch (err) {
return chip(err instanceof Error ? err.message : String(err));
}
const safe = (src: string): string => {
try {
return render(src);
} catch (err) {
return chip(err instanceof Error ? err.message : String(err));
}
};
const blocks = splitBlocksWithRanges(currentText);
if (blocks.length === 0) return safe(currentText);
return blocks.map((b) => `<div${srcAttr(b)}>${safe(b.raw)}</div>`).join("\n");
}
export interface ProposalView {
@@ -411,11 +517,13 @@ function renderReviewOp(
op: BlockOp,
render: (src: string) => string,
colored: (raw: string) => string,
src: string,
): string {
// removed blocks and any changed block (atomic fences diffed whole; non-atomic prose
// word-merged) render via renderOp — no author sentinels (deletions neutral, spec §6.7).
if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render);
return `<div class="cw-blk ${op.kind === "added" ? "cw-added" : "cw-unchanged"}">${colored(op.block.raw)}</div>`;
// `src` is "" for a removed block (no live source — INV-36).
if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render, src);
return `<div class="cw-blk ${op.kind === "added" ? "cw-added" : "cw-unchanged"}"${src}>${colored(op.block.raw)}</div>`;
}
/**
@@ -471,7 +579,7 @@ export function renderReview(
const blk = op.kind === "removed" ? undefined : ranges[ci++];
const colored = (raw: string): string =>
blk ? colorByAuthor(raw, blk.start, authorSpans, render) : render(raw);
bodyParts.push(renderReviewOp(op, render, colored));
bodyParts.push(renderReviewOp(op, render, colored, srcAttr(blk)));
const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined;
if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render));
}
+178 -19
View File
@@ -13,11 +13,32 @@ import * as vscode from "vscode";
import type { DiffViewController } from "./diffViewController";
import type { AttributionController } from "./attributionController";
import type { ProposalController } from "./proposalController";
import { renderReview, renderPlain, diffBlocks, type BlockOp } from "./trackChangesModel";
import { renderReview, renderPlain, diffBlocks, diffToHunks, type BlockOp } from "./trackChangesModel";
import { buildFingerprint } from "./anchorer";
import { isAuthorable } from "./workspacePath";
import type { EditTurnResult } from "./liveTurn";
/** F11: a host edit turn (selection/document text + instruction → rewrite). Injectable for tests. */
type EditTurn = (instruction: string, text: string) => Promise<EditTurnResult>;
/** F11: what an Ask-Claude gesture edits — a resolved selection range, or the whole document. */
type EditTarget = { kind: "range"; start: number; end: number } | { kind: "document" };
const VIEW_TYPE = "cowriting.trackChangesPreview";
const DEBOUNCE_MS = 150;
/**
* Inbound webviewhost messages (intent only the sealed webview never mutates,
* INV-21/35). F10 carried the annotations toggle + / proposal decisions; F11
* adds the toolbar intents (pin baseline / ask Claude).
*/
type ToolbarMsg =
| { type: "setMode"; mode: "on" | "off" }
| { type: "accept"; proposalId: string }
| { type: "reject"; proposalId: string }
| { type: "pinBaseline" }
| { type: "askClaude"; scope: "document" }
| { type: "askClaude"; scope: "selection"; start: number; end: number };
export class TrackChangesPreviewController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
private readonly panels = new Map<string, vscode.WebviewPanel>();
@@ -27,6 +48,19 @@ export class TrackChangesPreviewController implements vscode.Disposable {
private readonly mode = new Map<string, "on" | "off">();
/** F10 (PUC-6): off-panel indicator of pending proposals on the active doc. */
private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 88);
/**
* F11: the host edit turn (INV-8 runs host-side, @cline/sdk loaded lazily and
* never bundled). Injectable so host E2E can stub it (no LLM in CI).
*/
private editTurn: EditTurn = async (instruction, text) => {
const { runEditTurn } = await import("./liveTurn");
return runEditTurn(instruction, text);
};
/** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */
private turnSeq = 0;
private nextTurnSeq(): number {
return ++this.turnSeq;
}
constructor(
private readonly diffView: DiffViewController,
@@ -35,9 +69,22 @@ export class TrackChangesPreviewController implements vscode.Disposable {
private readonly proposals: ProposalController,
) {
this.disposables.push(
vscode.commands.registerCommand("cowriting.showTrackChangesPreview", () =>
this.show(vscode.window.activeTextEditor?.document),
),
// F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri;
// the palette / keybinding pass nothing → fall back to the active editor.
vscode.commands.registerCommand("cowriting.showTrackChangesPreview", (uri?: vscode.Uri) => {
const byUri = uri && vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString());
this.show(byUri || vscode.window.activeTextEditor?.document);
}),
// F11: document-scoped Ask-Claude (also reused by #42's gateway). Edits the
// active markdown doc; the rewrite is diffed into per-hunk F4 proposals.
vscode.commands.registerCommand("cowriting.editDocument", () => {
const doc = vscode.window.activeTextEditor?.document;
if (!doc || !this.isMarkdown(doc)) {
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to ask Claude to edit it.");
return;
}
void this.askClaude(doc, { kind: "document" });
}),
vscode.workspace.onDidChangeTextDocument((e) => this.onEdit(e.document)),
this.diffView.onDidChangeBaseline(({ uri }) => this.refreshByUri(uri)),
this.proposals.onDidChangeProposals(({ uri }) => {
@@ -91,21 +138,10 @@ export class TrackChangesPreviewController implements vscode.Disposable {
null,
this.disposables,
);
// F10: the webview posts the annotations toggle + ✓/✗ proposal decisions back.
// F10/F11: the webview posts the annotations toggle, ✓/✗ proposal decisions,
// and (F11) the toolbar intents (pin baseline / ask Claude) back to the host.
panel.webview.onDidReceiveMessage(
(m: { type?: string; mode?: "on" | "off"; proposalId?: string }) => {
if (m?.type === "setMode" && (m.mode === "on" || m.mode === "off")) {
this.mode.set(key, m.mode);
this.refresh(document);
} else if (m?.type === "accept" && m.proposalId) {
void this.proposals
.acceptById(this.proposals.keyFor(document), m.proposalId)
.then(() => this.refresh(document));
} else if (m?.type === "reject" && m.proposalId) {
this.proposals.rejectById(this.proposals.keyFor(document), m.proposalId);
this.refresh(document);
}
},
(m: ToolbarMsg) => this.handleWebviewMessage(document, m),
null,
this.disposables,
);
@@ -115,6 +151,102 @@ export class TrackChangesPreviewController implements vscode.Disposable {
this.refresh(document);
}
/**
* Route an inbound webview intent through the existing seams (INV-35): the
* annotations toggle + / proposal decisions (F10) and the toolbar gestures
* (F11). Pin targets the PREVIEWED document (`DiffViewController.pin`), not the
* active editor the preview knows its bound doc (§6.7).
*/
private handleWebviewMessage(document: vscode.TextDocument, m: ToolbarMsg): void {
const key = document.uri.toString();
if (m?.type === "setMode" && (m.mode === "on" || m.mode === "off")) {
this.mode.set(key, m.mode);
this.refresh(document);
} else if (m?.type === "accept" && m.proposalId) {
void this.proposals
.acceptById(this.proposals.keyFor(document), m.proposalId)
.then(() => this.refresh(document));
} else if (m?.type === "reject" && m.proposalId) {
this.proposals.rejectById(this.proposals.keyFor(document), m.proposalId);
this.refresh(document);
} else if (m?.type === "pinBaseline") {
// F6 baseline store re-render arrives via the onDidChangeBaseline subscription.
this.diffView.pin(document);
} else if (m?.type === "askClaude") {
const target: EditTarget =
m.scope === "selection" ? { kind: "range", start: m.start, end: m.end } : { kind: "document" };
void this.askClaude(document, target);
}
}
/**
* F11 (PUC-3/4): prompt host-side for the instruction (keeps the LLM/secret
* surface out of the sealed webview, INV-8/35), run the edit turn, and surface
* the result as F4 proposal(s). UI wrapper around `runEditAndPropose`.
*/
private async askClaude(document: vscode.TextDocument, target: EditTarget): Promise<void> {
const instruction = await vscode.window.showInputBox({
prompt:
target.kind === "document"
? "What should Claude do with the document?"
: "What should Claude do with the selection?",
placeHolder: "e.g. tighten the prose",
});
if (!instruction) return;
try {
const ids = await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…" },
() => this.runEditAndPropose(document, target, instruction),
);
if (ids.length === 0) {
void vscode.window.showInformationMessage("Cowriting: Claude proposed no changes.");
} else {
void vscode.window.showInformationMessage(
`Cowriting: Claude proposed ${ids.length} edit${ids.length === 1 ? "" : "s"} — review ✓/✗ in the preview.`,
);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
void vscode.window.showErrorMessage(`Cowriting: Claude edit failed — ${message}`);
}
}
/**
* F11 (INV-35/37): run one host edit turn and record the result as F4
* proposal(s) a SELECTION yields one single-range proposal over the resolved
* block-union; a DOCUMENT rewrite is `diffToHunks`'d into one single-range
* proposal per changed hunk (reusing the F4 single-range model N times, no new
* model). Never mutates the document (INV-10). Returns the created proposal ids.
*/
async runEditAndPropose(
document: vscode.TextDocument,
target: EditTarget,
instruction: string,
): Promise<string[]> {
const full = document.getText();
// One turnId per gesture — the document case's N hunk-proposals all share it,
// so a single rewrite groups as one agent turn (parity with editSelection).
const turnId = `turn-${this.nextTurnSeq()}`;
const provenance = (turn: EditTurnResult) =>
({ kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId } });
if (target.kind === "range") {
const selected = full.slice(target.start, target.end);
const turn = await this.editTurn(instruction, selected);
if (turn.replacement === "" || turn.replacement === selected) return [];
const fp = buildFingerprint(full, { start: target.start, end: target.end });
const id = await this.proposals.propose(document, fp, turn.replacement, provenance(turn), { turnId, instruction });
return id ? [id] : [];
}
const turn = await this.editTurn(instruction, full);
const ids: string[] = [];
for (const h of diffToHunks(full, turn.replacement)) {
const fp = buildFingerprint(full, { start: h.start, end: h.end });
const id = await this.proposals.propose(document, fp, h.replacement, provenance(turn), { turnId, instruction });
if (id) ids.push(id);
}
return ids;
}
private onEdit(document: vscode.TextDocument): void {
const key = document.uri.toString();
if (!this.panels.has(key)) return;
@@ -145,8 +277,10 @@ export class TrackChangesPreviewController implements vscode.Disposable {
const baselineText = baseline?.text ?? current; // no baseline → no change-marks
const ops = diffBlocks(baselineText, current);
this.lastModel.set(key, ops);
// F11 (PUC-1/7): edit controls are inert on a non-authorable doc (reading stays allowed).
const authorable = isAuthorable(document.uri.scheme);
if (mode === "off") {
void panel.webview.postMessage({ type: "render", mode, html: renderPlain(current) });
void panel.webview.postMessage({ type: "render", mode, html: renderPlain(current), authorable });
return;
}
const spans = this.attribution.spansFor(document);
@@ -162,6 +296,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
html: renderReview(baselineText, current, spans, proposals),
epoch: this.epochLabel(baseline),
summary,
authorable,
});
}
@@ -232,6 +367,8 @@ export class TrackChangesPreviewController implements vscode.Disposable {
<body>
<div id="cw-header">
<label id="cw-toggle"><input type="checkbox" id="cw-annotations" checked /> Annotations</label>
<button id="cw-pin" type="button" title="Pin the review baseline to now (clears the change-marks)"> Pin baseline</button>
<button id="cw-ask" type="button" title="Ask Claude to edit (the selection if any, else the whole document)"> Ask Claude to Edit Document</button>
<span id="cw-epoch">Review</span>
<span id="cw-summary"></span>
<span id="cw-legend"></span>
@@ -246,6 +383,28 @@ export class TrackChangesPreviewController implements vscode.Disposable {
isOpen(uriString: string): boolean {
return this.panels.has(uriString);
}
/**
* F11 test seam: deliver an inbound webview message to the real routing, as if
* the sealed webview had posted it. Exercises messageseam wiring without a
* live webview DOM (which is manual-smoke only). No-op if no doc/panel.
*/
receiveMessage(uriString: string, m: ToolbarMsg): void {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
if (doc && this.panels.has(uriString)) this.handleWebviewMessage(doc, m);
}
/** F11 test seam: stub the host edit turn so the document/selection paths run without an LLM. */
setEditTurnForTest(fn: EditTurn): void {
this.editTurn = fn;
}
/**
* F11 (PUC-1/7): whether the previewed doc's edit controls (Pin + Ask-Claude)
* are enabled true only for an authorable doc. The annotations toggle is
* always active (reading is always allowed). False if no panel/doc.
*/
editControlsEnabled(uriString: string): boolean {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
return doc ? isAuthorable(doc.uri.scheme) : false;
}
getLastModel(uriString: string): BlockOp[] | undefined {
return this.lastModel.get(uriString);
}
+233
View File
@@ -0,0 +1,233 @@
import * as assert from "assert";
import * as fs from "fs";
import * as path from "path";
import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension";
const WS = process.env.E2E_WORKSPACE!;
const settle = () => new Promise((r) => setTimeout(r, 400));
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 && api?.diffViewController, "exports preview + diffView");
return api;
}
/** Create + open a fresh markdown doc under WS, returning the doc + its uri key. */
async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> {
const abs = path.join(WS, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, body, "utf8");
const uri = vscode.Uri.file(abs);
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
await settle();
return { doc, key: uri.toString() };
}
// F11 host E2E (no LLM): the preview toolbar is the primary interaction surface.
// The webview posts intent messages; the host routes them through the existing
// F4/F6/F3 seams (INV-35). The webview DOM (real button clicks) is sealed and
// manual-smoke only; here we simulate the inbound messages via `receiveMessage`.
suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () => {
// SLICE-1: the Pin baseline button.
test("pinBaseline message pins the PREVIEWED doc → marks clear, baseline reason is pinned (PUC-5, INV-35)", async () => {
const { doc, key } = await freshDoc("docs/f11pin.md", "# F11 pin\n\nA baseline paragraph that will diverge.\n");
const api = await getApi();
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "panel open");
// Diverge from the opened baseline so the preview carries a real change-mark.
const edit = new vscode.WorkspaceEdit();
edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA freshly typed paragraph that diverges.\n");
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
await settle();
const marked = (api.trackChangesPreviewController.getLastModel(key) ?? []).some((o) => o.kind !== "unchanged");
assert.ok(marked, "the typed paragraph shows as a change before pinning");
// Simulate the webview's Pin baseline button posting its intent.
api.trackChangesPreviewController.receiveMessage(key, { type: "pinBaseline" });
await settle();
const model = api.trackChangesPreviewController.getLastModel(key) ?? [];
assert.ok(model.length > 0 && model.every((o) => o.kind === "unchanged"), "after pin, every block is unchanged");
assert.strictEqual(api.diffViewController.getBaseline(key)?.reason, "pinned", "baseline reason advanced to pinned");
});
// SLICE-1 reachability: the orphaned pin command gets a real palette `when`.
test("pinDiffBaseline is reachable from the command palette (when: editorLangId == markdown)", async () => {
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find(
(m) => m.command === "cowriting.pinDiffBaseline",
);
assert.ok(entry, "pinDiffBaseline has a commandPalette entry");
assert.notStrictEqual(entry!.when, "false", "it is no longer hidden (when:false)");
assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
});
// SLICE-3: Edit Document → a whole-document rewrite diffed into N F4 proposals.
test("runEditAndPropose(document) with a stubbed multi-hunk rewrite → N proposals matching the hunks (PUC-4, INV-37)", async () => {
const { doc, key } = await freshDoc(
"docs/f11doc.md",
"# F11 doc\n\nThe quick brown fox jumps over the lazy dog.\n",
);
const api = await getApi();
const ctl = api.trackChangesPreviewController;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
// Stub the host edit turn (no LLM in CI): rewrite two distinct words.
ctl.setEditTurnForTest(async () => ({
replacement: "# F11 doc\n\nThe quick RED fox jumps over the lazy CAT.\n",
model: "sonnet",
sessionId: "e2e-f11-doc",
}));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "swap brown→RED and dog→CAT");
await settle();
assert.strictEqual(ids.length, 2, "two changed words → two independent proposals");
const views = api.proposalController.listProposals(doc);
assert.ok(
ids.every((id) => views.some((v) => v.id === id)),
"every returned proposal id is a live pending proposal",
);
const replacements = views.map((v) => v.replacement);
assert.ok(replacements.includes("RED") && replacements.includes("CAT"), "proposals carry the per-hunk replacements");
// INV-10: proposing never mutates the document.
assert.ok(doc.getText().includes("brown fox") && doc.getText().includes("lazy dog"), "document unchanged by propose");
void key;
});
// SLICE-4: Edit Selection → one single-range proposal over the resolved block-union.
test("runEditAndPropose(range) with a stubbed turn → exactly one proposal over the resolved range (PUC-3, INV-37)", async () => {
const body = "# F11 sel\n\nThe target paragraph Claude will rewrite.\n\nAnother untouched paragraph.\n";
const { doc, key } = await freshDoc("docs/f11sel.md", body);
const api = await getApi();
const ctl = api.trackChangesPreviewController;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
const target = "The target paragraph Claude will rewrite.";
const start = doc.getText().indexOf(target);
const end = start + target.length;
ctl.setEditTurnForTest(async (_instruction, text) => {
assert.strictEqual(text, target, "the turn receives exactly the selected source range");
return { replacement: "The REWRITTEN paragraph from Claude.", model: "sonnet", sessionId: "e2e-f11-sel" };
});
const ids = await ctl.runEditAndPropose(doc, { kind: "range", start, end }, "rewrite this paragraph");
await settle();
assert.strictEqual(ids.length, 1, "a selection yields exactly one proposal");
const views = api.proposalController.listProposals(doc);
const view = views.find((v) => v.id === ids[0]);
assert.ok(view, "the proposal is live");
assert.strictEqual(view!.replacement, "The REWRITTEN paragraph from Claude.", "carries the turn replacement");
assert.strictEqual(view!.replaced, target, "replaces exactly the selected range");
assert.ok(doc.getText().includes(target), "document unchanged by propose (INV-10)");
void key;
});
// SLICE-4: a no-op turn (Claude returns the input unchanged) produces no proposal.
test("runEditAndPropose(range) where Claude returns the selection unchanged → no proposal", async () => {
const { doc } = await freshDoc("docs/f11noop.md", "# noop\n\nLeave me exactly as I am.\n");
const api = await getApi();
const ctl = api.trackChangesPreviewController;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
const target = "Leave me exactly as I am.";
const start = doc.getText().indexOf(target);
ctl.setEditTurnForTest(async (_i, text) => ({ replacement: text, model: "sonnet", sessionId: "e2e-noop" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "range", start, end: start + target.length }, "no change");
assert.strictEqual(ids.length, 0, "an unchanged replacement proposes nothing");
});
// SLICE-3 (review follow-up): a document rewrite that INSERTS text → an
// acceptable proposal (not a born-orphaned zero-width hunk), and accepting all
// hunks of a multi-hunk rewrite lands the intended document.
test("document rewrite with an insertion → acceptable proposals; accept-all reaches the rewrite", async () => {
const original = "# F11 accept\n\nThe brown fox sleeps.\n";
const rewrite = "# F11 accept\n\nThe brown fox QUIETLY sleeps today.\n";
const { doc } = await freshDoc("docs/f11accept.md", original);
const api = await getApi();
const ctl = api.trackChangesPreviewController;
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
ctl.setEditTurnForTest(async () => ({ replacement: rewrite, model: "sonnet", sessionId: "e2e-accept" }));
const ids = await ctl.runEditAndPropose(doc, { kind: "document" }, "expand the sentence");
await settle();
assert.ok(ids.length >= 1, "the rewrite produced at least one proposal");
// Every proposal must be acceptable (the insertion-anchoring fix): accept each.
for (const id of ids) {
const ok = await api.proposalController.acceptById("docs/f11accept.md", id);
assert.ok(ok, `proposal ${id} is acceptable (not born-orphaned)`);
await settle();
}
assert.strictEqual(doc.getText(), rewrite, "accepting all hunks reconstructs the intended rewrite");
assert.strictEqual(api.proposalController.listProposals(doc).length, 0, "no proposals left pending");
});
// SLICE-3: the document-scoped command exists for #42 reuse, guarded on markdown.
test("cowriting.editDocument is a registered command, palette-guarded on markdown", async () => {
const all = await vscode.commands.getCommands(true);
assert.ok(all.includes("cowriting.editDocument"), "editDocument command registered");
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find(
(m) => m.command === "cowriting.editDocument",
);
assert.ok(entry, "editDocument has a commandPalette entry");
assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
});
// SLICE-5: the minimal right-click gateway lives in editor/title (markdown only).
test("the editor/title gateway opens the preview, and the menu entry is markdown-guarded (PUC-6)", async () => {
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8"));
const entry = (pkg.contributes.menus["editor/title"] as Array<{ command: string; when?: string }>).find(
(m) => m.command === "cowriting.showTrackChangesPreview",
);
assert.ok(entry, "showTrackChangesPreview is in editor/title");
assert.match(entry!.when ?? "", /editorLangId == markdown/, "gateway guarded on markdown");
// the command it invokes opens the panel.
const { key } = await freshDoc("docs/f11gw.md", "# F11 gateway\n\nReachable end to end.\n");
const api = await getApi();
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "gateway command opens the preview");
});
// SLICE-5: edit controls are inert on a non-authorable (read-only scheme) doc.
test("toolbar edit controls are disabled for a non-authorable document (PUC-1/7)", async () => {
const SCHEME = "cwf11ro";
const provider = new (class implements vscode.TextDocumentContentProvider {
onDidChange = undefined;
provideTextDocumentContent(): string {
return "# Read only\n\nThis markdown doc is not authorable.\n";
}
})();
const reg = vscode.workspace.registerTextDocumentContentProvider(SCHEME, provider);
try {
const uri = vscode.Uri.parse(`${SCHEME}:/readonly.md`);
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.languages.setTextDocumentLanguage(doc, "markdown");
await vscode.window.showTextDocument(doc);
await settle();
const api = await getApi();
const key = uri.toString();
assert.strictEqual(doc.languageId, "markdown", "fixture is markdown");
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await settle();
assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "preview opens (reading is always allowed)");
assert.strictEqual(
api.trackChangesPreviewController.editControlsEnabled(key),
false,
"Pin + Ask-Claude controls are disabled on a non-authorable doc",
);
} finally {
reg.dispose();
}
});
});
+150 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, test, expect } from "vitest";
import { splitBlocks, splitBlocksWithRanges, diffBlocks, renderTrackChanges, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel";
import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, renderTrackChanges, colorByAuthor, type AuthorSpan } from "../src/trackChangesModel";
describe("splitBlocks", () => {
it("splits prose paragraphs on blank lines, dropping empties", () => {
@@ -319,3 +319,152 @@ describe("renderTrackChanges — intra-diagram mermaid (#22)", () => {
expect(html).not.toContain("classDef cwAdded");
});
});
// F11 SLICE-3 (INV-37, §6.4): a whole-document rewrite is diffed into per-hunk
// proposal ranges — each an independent F4 single-range proposal. Pure,
// vscode-free, deterministic; offsets index into currentText.
describe("F11 diffToHunks (INV-37)", () => {
test("an identical rewrite → zero hunks", () => {
expect(diffToHunks("The same text.\n", "The same text.\n")).toEqual([]);
});
test("a single changed word → one hunk over exactly that word", () => {
const current = "The quick brown fox.";
const hunks = diffToHunks(current, "The quick red fox.");
expect(hunks).toHaveLength(1);
expect(current.slice(hunks[0].start, hunks[0].end)).toBe("brown");
expect(hunks[0].replacement).toBe("red");
});
test("two disjoint changes → two hunks with correct ranges + replacements", () => {
const current = "one two three four";
const hunks = diffToHunks(current, "one TWO three FOUR");
expect(hunks).toHaveLength(2);
expect(current.slice(hunks[0].start, hunks[0].end)).toBe("two");
expect(hunks[0].replacement).toBe("TWO");
expect(current.slice(hunks[1].start, hunks[1].end)).toBe("four");
expect(hunks[1].replacement).toBe("FOUR");
// disjoint + ordered
expect(hunks[0].end).toBeLessThanOrEqual(hunks[1].start);
});
test("a wholesale replacement (nothing in common) → one full-range hunk", () => {
const current = "alpha";
const hunks = diffToHunks(current, "omega");
expect(hunks).toHaveLength(1);
expect(hunks[0]).toEqual({ start: 0, end: current.length, replacement: "omega" });
});
test("is deterministic — same inputs → identical hunks", () => {
const a = diffToHunks("a b c d", "a B c D");
const b = diffToHunks("a b c d", "a B c D");
expect(a).toEqual(b);
});
/** Apply hunks (right→left so earlier offsets stay valid) to reconstruct the rewrite. */
const applyHunks = (current: string, hunks: ReturnType<typeof diffToHunks>): string => {
let out = current;
for (const h of [...hunks].sort((a, b) => b.start - a.start)) {
out = out.slice(0, h.start) + h.replacement + out.slice(h.end);
}
return out;
};
test("applying the hunks always reconstructs the rewrite (substitute / delete / insert / multi)", () => {
const cases: Array<[string, string]> = [
["The quick brown fox.", "The quick red fox."],
["one two three four", "one TWO three FOUR"],
["alpha", "omega"],
["keep this and drop that", "keep this"],
["one two three", "one INSERTED two three"],
["start middle end", "PREFIX start middle end SUFFIX"],
["unchanged body", "unchanged body"],
];
for (const [current, rewrite] of cases) {
expect(applyHunks(current, diffToHunks(current, rewrite))).toBe(rewrite);
}
});
test("an inserted run anchors to adjacent text — never a zero-width, unacceptable hunk (INV-37)", () => {
// A pure insertion would otherwise produce start==end → an empty fingerprint
// → resolve() orphans it → the proposal can never be accepted. Each hunk must
// span real source text so its F4 fingerprint resolves.
for (const [current, rewrite] of [
["one two three", "one INSERTED two three"],
["tail anchor", "tail anchor APPENDED"],
["lead", "PREPENDED lead"],
] as Array<[string, string]>) {
for (const h of diffToHunks(current, rewrite)) {
expect(h.end).toBeGreaterThan(h.start); // non-zero-width
expect(current.slice(h.start, h.end).length).toBeGreaterThan(0); // real fp.text
}
}
});
});
// F11 SLICE-2 (INV-36): the pure render layer emits data-src-start/data-src-end
// (source char offsets from BlockWithRange) on every LIVE-source rendered block,
// in BOTH modes. The webview's selection→source mapping walks the DOM to the
// nearest data-src ancestor; these offsets are the contract.
describe("F11 data-src emission (INV-36)", () => {
/** Pull every data-src-start/end pair from an HTML string, in document order. */
const srcRanges = (html: string): Array<{ start: number; end: number }> =>
Array.from(html.matchAll(/data-src-start="(\d+)" data-src-end="(\d+)"/g)).map((m) => ({
start: Number(m[1]),
end: Number(m[2]),
}));
test("renderPlain wraps every block with data-src offsets equal to splitBlocksWithRanges (and stays clean)", () => {
const doc = "# Title\n\nFirst para.\n\nSecond para.\n";
const blocks = splitBlocksWithRanges(doc);
const html = renderPlain(doc);
expect(srcRanges(html)).toEqual(blocks.map((b) => ({ start: b.start, end: b.end })));
// off-mode stays the clean preview: no annotation marks.
expect(html).not.toContain("cw-");
// each range slices back to its block's raw source.
for (const b of blocks) expect(doc.slice(b.start, b.end)).toBe(b.raw);
});
test("renderReview emits data-src on each live block; removed + proposal blocks carry none (INV-36)", () => {
// baseline has an extra paragraph that is REMOVED in current; current adds one.
const baseline = "Keep this.\n\nDrop this.\n";
const current = "Keep this.\n\nBrand new.\n";
const proposals: ProposalView[] = [{ id: "p1", anchorStart: 0, anchorEnd: 4, replaced: "Keep", replacement: "Hold" }];
const html = renderReview(baseline, current, [], proposals);
const liveBlocks = splitBlocksWithRanges(current);
// exactly one data-src per LIVE (current-side) block — removed/proposal blocks excluded.
expect(srcRanges(html)).toEqual(liveBlocks.map((b) => ({ start: b.start, end: b.end })));
// the proposal block itself is not a live-source block.
const propIdx = html.indexOf('data-proposal-id="p1"');
const propTag = html.slice(html.lastIndexOf("<div", propIdx), propIdx + 1);
expect(propTag).not.toContain("data-src-start");
});
test("data-src emission is deterministic — same inputs → identical HTML (extends INV-22)", () => {
const a = renderPlain("alpha\n\nbeta\n");
const b = renderPlain("alpha\n\nbeta\n");
expect(a).toBe(b);
const r1 = renderReview("x", "x\n\ny", [], []);
const r2 = renderReview("x", "x\n\ny", [], []);
expect(r1).toBe(r2);
});
// CHARACTERIZATION (conscious tradeoff of the locked block-level mapping, §6.7
// fork 1): both modes render markdown PER BLOCK so each block can carry its
// data-src offsets. A consequence is that markdown constructs whose parts span
// blank-line-separated blocks — a reference-link USE and its DEFINITION — do not
// resolve across blocks (markdown-it sees each block in isolation). renderReview
// already had this; F11 brings the off/clean preview into line with it (both
// per-block) rather than leaving the two modes rendering differently. If
// cross-block fidelity is wanted later, it is a follow-up (source-map driven
// wrapping), not a change to the locked block-level decision.
test("a reference-link definition in a separate block does not resolve (block-level rendering)", () => {
const doc = "See [the spec][ref] for details.\n\n[ref]: https://example.com/spec\n";
const html = renderPlain(doc);
// the link is NOT resolved to an <a href> — the [text][ref] is rendered literally.
expect(html).not.toContain('href="https://example.com/spec"');
expect(html).toContain("[the spec][ref]");
// both modes agree (renderReview is likewise per-block) — the consistency point.
expect(renderReview(doc, doc, [], [])).not.toContain('href="https://example.com/spec"');
});
});