feat(f11): SLICE-3 — Edit Document button + per-hunk proposal path (#43, INV-37)
A whole-document Ask-Claude rewrite is diffed into hunks and surfaced as N independent F4 proposals (one per changed hunk) — reusing the F4 single-range model N times, no new model. Per spec §6.4/§7.2 SLICE-3. - trackChangesModel: pure `diffToHunks(currentText, rewrittenText)` → EditHunk[] (vscode-free, deterministic; diffWordsWithSpace, coalescing adjacent add/remove runs; offsets index currentText). - trackChangesPreview: `runEditAndPropose(document, target, instruction)` — the shared host routine (selection → one single-range propose; document → diff → one propose per hunk; never mutates the doc, INV-10); `askClaude` UI wrapper (host showInputBox keeps LLM/secrets out of the sealed webview, INV-8/35); injectable `editTurn` + `setEditTurnForTest` seam (no LLM in CI); the `askClaude` inbound message branch; `cowriting.editDocument` command for #42 reuse. - package.json: register cowriting.editDocument, palette-guarded on markdown. - webview: ✦ Ask Claude to Edit Document button → { askClaude, scope:"document" }. - unit: diffToHunks fixtures (zero/one/multi-hunk, wholesale, determinism). - host E2E: stubbed multi-hunk rewrite → N matching proposals, doc untouched; editDocument command registered + markdown-guarded. 205 unit + 49 host E2E green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ const summary = document.getElementById("cw-summary")!;
|
|||||||
const legend = document.getElementById("cw-legend")!;
|
const legend = document.getElementById("cw-legend")!;
|
||||||
const annotationsEl = document.getElementById("cw-annotations") as HTMLInputElement | null;
|
const annotationsEl = document.getElementById("cw-annotations") as HTMLInputElement | null;
|
||||||
const pinEl = document.getElementById("cw-pin") as HTMLButtonElement | 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.
|
// F10: the annotations on/off toggle.
|
||||||
annotationsEl?.addEventListener("change", () => {
|
annotationsEl?.addEventListener("change", () => {
|
||||||
@@ -38,6 +39,13 @@ pinEl?.addEventListener("click", () => {
|
|||||||
vscodeApi.postMessage({ type: "pinBaseline" });
|
vscodeApi.postMessage({ type: "pinBaseline" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// F11 (SLICE-3): Ask Claude to Edit Document — post the document-scope intent; the
|
||||||
|
// host prompts for the instruction + runs the turn (INV-8). SLICE-4 makes this
|
||||||
|
// button adaptive (Edit Selection when text is selected in the preview).
|
||||||
|
askEl?.addEventListener("click", () => {
|
||||||
|
vscodeApi.postMessage({ type: "askClaude", scope: "document" });
|
||||||
|
});
|
||||||
|
|
||||||
// F10: delegated ✓/✗ accept/reject of pending proposals (routed back to the F4 seam).
|
// F10: delegated ✓/✗ accept/reject of pending proposals (routed back to the F4 seam).
|
||||||
body.addEventListener("click", (e) => {
|
body.addEventListener("click", (e) => {
|
||||||
const btn = (e.target as HTMLElement)?.closest<HTMLElement>(".cw-actions button");
|
const btn = (e.target as HTMLElement)?.closest<HTMLElement>(".cw-actions button");
|
||||||
|
|||||||
@@ -78,6 +78,11 @@
|
|||||||
"command": "cowriting.showTrackChangesPreview",
|
"command": "cowriting.showTrackChangesPreview",
|
||||||
"title": "Cowriting: Open Review Preview",
|
"title": "Cowriting: Open Review Preview",
|
||||||
"category": "Cowriting"
|
"category": "Cowriting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.editDocument",
|
||||||
|
"title": "Ask Claude to Edit Document",
|
||||||
|
"category": "Cowriting"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"menus": {
|
"menus": {
|
||||||
@@ -101,6 +106,10 @@
|
|||||||
{
|
{
|
||||||
"command": "cowriting.pinDiffBaseline",
|
"command": "cowriting.pinDiffBaseline",
|
||||||
"when": "editorLangId == markdown"
|
"when": "editorLangId == markdown"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.editDocument",
|
||||||
|
"when": "editorLangId == markdown"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"editor/context": [
|
"editor/context": [
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* DOM — `markdown-it` and `diff` are libraries; mermaid runs later in the webview.
|
* DOM — `markdown-it` and `diff` are libraries; mermaid runs later in the webview.
|
||||||
*/
|
*/
|
||||||
import MarkdownIt from "markdown-it";
|
import MarkdownIt from "markdown-it";
|
||||||
import { diffArrays, diffWords } from "diff";
|
import { diffArrays, diffWords, diffWordsWithSpace } from "diff";
|
||||||
import { diffMermaid } from "./mermaidDiff";
|
import { diffMermaid } from "./mermaidDiff";
|
||||||
|
|
||||||
export type BlockType = "prose" | "code" | "mermaid";
|
export type BlockType = "prose" | "code" | "mermaid";
|
||||||
@@ -193,6 +193,50 @@ export function diffBlocks(baselineText: string, currentText: string): BlockOp[]
|
|||||||
return ops;
|
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.
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
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 md = new MarkdownIt({ html: true, linkify: false, breaks: false });
|
const md = new MarkdownIt({ html: true, linkify: false, breaks: false });
|
||||||
// mermaid fences → <pre class="mermaid">SRC</pre> for client-side rendering; all
|
// mermaid fences → <pre class="mermaid">SRC</pre> for client-side rendering; all
|
||||||
// other fences fall through to markdown-it's default (escaped <pre><code>).
|
// other fences fall through to markdown-it's default (escaped <pre><code>).
|
||||||
|
|||||||
+103
-2
@@ -13,7 +13,14 @@ import * as vscode from "vscode";
|
|||||||
import type { DiffViewController } from "./diffViewController";
|
import type { DiffViewController } from "./diffViewController";
|
||||||
import type { AttributionController } from "./attributionController";
|
import type { AttributionController } from "./attributionController";
|
||||||
import type { ProposalController } from "./proposalController";
|
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 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 VIEW_TYPE = "cowriting.trackChangesPreview";
|
||||||
const DEBOUNCE_MS = 150;
|
const DEBOUNCE_MS = 150;
|
||||||
@@ -27,7 +34,9 @@ type ToolbarMsg =
|
|||||||
| { type: "setMode"; mode: "on" | "off" }
|
| { type: "setMode"; mode: "on" | "off" }
|
||||||
| { type: "accept"; proposalId: string }
|
| { type: "accept"; proposalId: string }
|
||||||
| { type: "reject"; proposalId: string }
|
| { type: "reject"; proposalId: string }
|
||||||
| { type: "pinBaseline" };
|
| { type: "pinBaseline" }
|
||||||
|
| { type: "askClaude"; scope: "document" }
|
||||||
|
| { type: "askClaude"; scope: "selection"; start: number; end: number };
|
||||||
|
|
||||||
export class TrackChangesPreviewController implements vscode.Disposable {
|
export class TrackChangesPreviewController implements vscode.Disposable {
|
||||||
private readonly disposables: vscode.Disposable[] = [];
|
private readonly disposables: vscode.Disposable[] = [];
|
||||||
@@ -38,6 +47,14 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
private readonly mode = new Map<string, "on" | "off">();
|
private readonly mode = new Map<string, "on" | "off">();
|
||||||
/** F10 (PUC-6): off-panel indicator of pending proposals on the active doc. */
|
/** F10 (PUC-6): off-panel indicator of pending proposals on the active doc. */
|
||||||
private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 88);
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly diffView: DiffViewController,
|
private readonly diffView: DiffViewController,
|
||||||
@@ -49,6 +66,16 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
vscode.commands.registerCommand("cowriting.showTrackChangesPreview", () =>
|
vscode.commands.registerCommand("cowriting.showTrackChangesPreview", () =>
|
||||||
this.show(vscode.window.activeTextEditor?.document),
|
this.show(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)),
|
vscode.workspace.onDidChangeTextDocument((e) => this.onEdit(e.document)),
|
||||||
this.diffView.onDidChangeBaseline(({ uri }) => this.refreshByUri(uri)),
|
this.diffView.onDidChangeBaseline(({ uri }) => this.refreshByUri(uri)),
|
||||||
this.proposals.onDidChangeProposals(({ uri }) => {
|
this.proposals.onDidChangeProposals(({ uri }) => {
|
||||||
@@ -136,9 +163,78 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
} else if (m?.type === "pinBaseline") {
|
} else if (m?.type === "pinBaseline") {
|
||||||
// F6 baseline store re-render arrives via the onDidChangeBaseline subscription.
|
// F6 baseline store re-render arrives via the onDidChangeBaseline subscription.
|
||||||
this.diffView.pin(document);
|
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();
|
||||||
|
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), { 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), { instruction });
|
||||||
|
if (id) ids.push(id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
private onEdit(document: vscode.TextDocument): void {
|
private onEdit(document: vscode.TextDocument): void {
|
||||||
const key = document.uri.toString();
|
const key = document.uri.toString();
|
||||||
if (!this.panels.has(key)) return;
|
if (!this.panels.has(key)) return;
|
||||||
@@ -257,6 +353,7 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
<div id="cw-header">
|
<div id="cw-header">
|
||||||
<label id="cw-toggle"><input type="checkbox" id="cw-annotations" checked /> Annotations</label>
|
<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-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-epoch">Review</span>
|
||||||
<span id="cw-summary"></span>
|
<span id="cw-summary"></span>
|
||||||
<span id="cw-legend"></span>
|
<span id="cw-legend"></span>
|
||||||
@@ -280,6 +377,10 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
|||||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
|
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
|
||||||
if (doc && this.panels.has(uriString)) this.handleWebviewMessage(doc, m);
|
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;
|
||||||
|
}
|
||||||
getLastModel(uriString: string): BlockOp[] | undefined {
|
getLastModel(uriString: string): BlockOp[] | undefined {
|
||||||
return this.lastModel.get(uriString);
|
return this.lastModel.get(uriString);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,4 +66,50 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () =
|
|||||||
assert.notStrictEqual(entry!.when, "false", "it is no longer hidden (when:false)");
|
assert.notStrictEqual(entry!.when, "false", "it is no longer hidden (when:false)");
|
||||||
assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown");
|
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-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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, test, expect } from "vitest";
|
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", () => {
|
describe("splitBlocks", () => {
|
||||||
it("splits prose paragraphs on blank lines, dropping empties", () => {
|
it("splits prose paragraphs on blank lines, dropping empties", () => {
|
||||||
@@ -320,6 +320,48 @@ describe("renderTrackChanges — intra-diagram mermaid (#22)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// F11 SLICE-2 (INV-36): the pure render layer emits data-src-start/data-src-end
|
// 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,
|
// (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
|
// in BOTH modes. The webview's selection→source mapping walks the DOM to the
|
||||||
|
|||||||
Reference in New Issue
Block a user