F6: diff-view toggle (Feature #17) #18
@@ -13,7 +13,8 @@ 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).
|
||||
(Feature #12), F5 cross-rung sidecar contract (Feature #14), F6 diff-view
|
||||
toggle (Feature #17).
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -131,6 +132,32 @@ record per the contract; git push/pull is the transport, no re-homing ever.
|
||||
|
||||
Design: `vscode-cowriting-plugin-content/specs/coauthoring-cross-rung-format.md`.
|
||||
|
||||
## F6 — Diff-view toggle (Feature #17)
|
||||
|
||||
**Cmd/Ctrl-Alt-D** (or **Cowriting: Toggle Diff View**) flips a tracked
|
||||
document into a native `vscode.diff` against a **coauthoring baseline** — the
|
||||
readonly baseline on the left, your **live, editable** document on the right
|
||||
(so you keep writing inside the diff; toggling again closes it). The diff
|
||||
answers "what did *I* change?" in one keystroke instead of git archaeology.
|
||||
|
||||
- **Machine-factored baseline (INV-18):** the baseline initializes when a doc
|
||||
is first tracked and **advances automatically at every machine landing**
|
||||
(every successful `applyAgentEdit` seam apply — INV-9). So text Claude landed
|
||||
never shows as a change; everything the diff shows is operator-authored by
|
||||
construction — no attribution filtering.
|
||||
- **Pin on demand:** **Cowriting: Pin Diff Baseline to Now** resets the
|
||||
baseline to the current buffer for a deliberate "review my next pass" epoch;
|
||||
the diff tab title names the epoch (`opened` / `Claude landed` / `pinned`).
|
||||
- **Pure view, repo-free (INV-19):** the baseline snapshot lives in VS Code
|
||||
workspace storage, **never** the repo — `.threads/`, the cross-rung contract
|
||||
(INV-14..17), and `SCHEMA_VERSION` are untouched. Storage-unavailable
|
||||
degrades to in-memory baselines + one warning.
|
||||
- **No LLM in CI:** host E2E (`test/e2e/suite/diffView.test.ts`) drives the same
|
||||
programmatic seam ingress (propose + accept) the F4 suite uses.
|
||||
|
||||
Design: `vscode-cowriting-plugin-content/specs/coauthoring-diff-view.md`.
|
||||
Live smoke: [`docs/MANUAL-SMOKE-F6.md`](docs/MANUAL-SMOKE-F6.md).
|
||||
|
||||
## Develop
|
||||
|
||||
- `npm run watch` — rebuild on change.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Manual smoke — F6 diff-view toggle (live)
|
||||
|
||||
Pre-req: a real machine (not CI). Step 4 uses a live Claude turn — Claude Code
|
||||
installed + signed in (the `claude-code` provider rides that login, INV-8). The
|
||||
rest of F6 needs no credentials and no network.
|
||||
|
||||
1. `npm run build`, launch the extension (F5 in VS Code opens the committed
|
||||
`sandbox/` playground), open `playground.md`.
|
||||
2. Edit a sentence by hand, then **Cmd/Ctrl-Alt-D** (or run **Cowriting: Toggle
|
||||
Diff View**). ✅ A diff opens: the readonly baseline on the left, your live
|
||||
document on the right; the tab title reads `playground.md — my changes since
|
||||
opened <time>`. Your hand edit shows as a change.
|
||||
3. Keep typing **in the diff's right pane**. ✅ Editing continues in place;
|
||||
decorations/threads still work. Toggle again. ✅ The diff tab closes and the
|
||||
normal editor is back.
|
||||
4. Select a sentence → right-click → **Ask Claude to Edit Selection** → give an
|
||||
instruction → **✓ Accept Proposal**. Now toggle the diff. ✅ The accepted
|
||||
text is **not** shown as a change (the baseline advanced past the landing —
|
||||
INV-18); only your own subsequent edits show.
|
||||
5. Run **Cowriting: Pin Diff Baseline to Now**. ✅ The diff empties (baseline =
|
||||
now); the title reads `pinned <time>`. Type → your changes show against the
|
||||
pin.
|
||||
6. Reload the window (Developer: Reload Window). ✅ Toggle: the baseline is the
|
||||
same as before the reload (persisted in workspace storage, not the repo —
|
||||
INV-19; `git status` shows nothing new).
|
||||
7. Toggle on a file outside the workspace folder. ✅ A warning, no diff.
|
||||
File diff suppressed because it is too large
Load Diff
+18
-1
@@ -73,6 +73,16 @@
|
||||
"command": "cowriting.proposeAgentEdit",
|
||||
"title": "Propose Agent Edit (internal seam)",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.toggleDiffView",
|
||||
"title": "Cowriting: Toggle Diff View",
|
||||
"category": "Cowriting"
|
||||
},
|
||||
{
|
||||
"command": "cowriting.pinDiffBaseline",
|
||||
"title": "Cowriting: Pin Diff Baseline to Now",
|
||||
"category": "Cowriting"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
@@ -135,7 +145,14 @@
|
||||
"when": "commentController == cowriting.proposals"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"keybindings": [
|
||||
{
|
||||
"command": "cowriting.toggleDiffView",
|
||||
"key": "ctrl+alt+d",
|
||||
"when": "editorTextFocus"
|
||||
}
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node esbuild.mjs",
|
||||
|
||||
@@ -63,12 +63,21 @@ export class AttributionController implements vscode.Disposable {
|
||||
private readonly output = vscode.window.createOutputChannel("Cowriting Attribution");
|
||||
private visible = true;
|
||||
|
||||
/**
|
||||
* F6 (§6.2/§6.4): the single machine-landing signal. Fired after a real
|
||||
* (non-no-op) seam apply succeeds. INV-9 makes the seam the sole machine-edit
|
||||
* ingress, so this is the sole signal — DiffViewController subscribes to
|
||||
* advance the baseline; no call-site wiring, no future driver can forget it.
|
||||
*/
|
||||
private readonly applyEmitter = new vscode.EventEmitter<{ document: vscode.TextDocument }>();
|
||||
readonly onDidApplyAgentEdit: vscode.Event<{ document: vscode.TextDocument }> = this.applyEmitter.event;
|
||||
|
||||
constructor(
|
||||
private readonly store: CoauthorStore,
|
||||
private readonly rootDir: string,
|
||||
private readonly guard: VersionGuard,
|
||||
) {
|
||||
this.disposables.push(this.agentType, this.humanType, this.statusItem, this.output);
|
||||
this.disposables.push(this.agentType, this.humanType, this.statusItem, this.output, this.applyEmitter);
|
||||
this.disposables.push(
|
||||
vscode.commands.registerCommand("cowriting.toggleAttribution", () => this.toggle()),
|
||||
vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
|
||||
@@ -275,6 +284,12 @@ export class AttributionController implements vscode.Disposable {
|
||||
"(host minimized differently?) — the edit may be mis-attributed (INV-9).",
|
||||
);
|
||||
}
|
||||
if (ok) {
|
||||
// F6 (INV-18): a real machine landing — signal the baseline to advance so
|
||||
// this text never shows as a change in the diff view. Fire regardless of
|
||||
// attribution-match bookkeeping above; the landing happened either way.
|
||||
this.applyEmitter.fire({ document });
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* BaselineStore — load/save one diff-view baseline JSON per document (F6 §6.3).
|
||||
* The CoauthorStore shape (src/store.ts): vscode-free (Node fs only, unit-
|
||||
* testable), one file per docPath. INV-19: the storage dir is VS Code's
|
||||
* per-workspace extension storage, NEVER the repo — so the baseline can never
|
||||
* be committed, merged, or read by another rung, and the sidecar / cross-rung
|
||||
* contract (INV-14..17) are untouched by construction.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
export type BaselineReason = "opened" | "machine-landing" | "pinned";
|
||||
|
||||
export interface Baseline {
|
||||
docPath: string;
|
||||
/** full document text captured at the epoch (from the buffer, not disk — §6.3). */
|
||||
text: string;
|
||||
capturedAt: string;
|
||||
reason: BaselineReason;
|
||||
}
|
||||
|
||||
export class BaselineStore {
|
||||
/** @param storageDir absolute VS Code workspace-storage dir (context.storageUri.fsPath). */
|
||||
constructor(private readonly storageDir: string) {}
|
||||
|
||||
/** `<storageDir>/baselines/<repo-relative-docPath>.json` (§6.3). */
|
||||
baselinePath(docPath: string): string {
|
||||
return path.join(this.storageDir, "baselines", `${docPath}.json`);
|
||||
}
|
||||
|
||||
load(docPath: string): Baseline | null {
|
||||
const p = this.baselinePath(docPath);
|
||||
if (!fs.existsSync(p)) return null;
|
||||
return JSON.parse(fs.readFileSync(p, "utf8")) as Baseline;
|
||||
}
|
||||
|
||||
/** Newest epoch wins — overwrite in place, no history (state-not-history, the F3/F4 precedent). */
|
||||
save(docPath: string, baseline: Baseline): void {
|
||||
const p = this.baselinePath(docPath);
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, JSON.stringify(baseline, null, 2) + "\n", "utf8");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* DiffViewController — F6 diff-view toggle (spec §6.2/§6.4). Owns the baseline
|
||||
* lifecycle (initialize at first track / advance at every machine landing / pin
|
||||
* on demand), serves the baseline as a readonly `cowriting-baseline:` virtual
|
||||
* document, and toggles a native vscode.diff (baseline left, the LIVE document
|
||||
* right). A pure view: never mutates the document, sidecar, or attribution
|
||||
* state (INV-19). Baselines persist via the vscode-free BaselineStore; if
|
||||
* storage is unavailable the controller degrades to in-memory baselines + one
|
||||
* warning (reload survival is lost; the toggle still works) — §6.5 PUC-5.
|
||||
*/
|
||||
import * as path from "node:path";
|
||||
import * as vscode from "vscode";
|
||||
import { BaselineStore, type Baseline, type BaselineReason } from "./baselineStore";
|
||||
|
||||
export const BASELINE_SCHEME = "cowriting-baseline";
|
||||
|
||||
export class DiffViewController implements vscode.Disposable {
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
/** Source of truth for the content provider; mirrors what the store persists. */
|
||||
private readonly baselines = new Map<string, Baseline>();
|
||||
private readonly onDidChangeEmitter = new vscode.EventEmitter<vscode.Uri>();
|
||||
private storageWarned = false;
|
||||
|
||||
constructor(
|
||||
private readonly store: BaselineStore | null,
|
||||
private readonly rootDir: string,
|
||||
) {
|
||||
const provider: vscode.TextDocumentContentProvider = {
|
||||
onDidChange: this.onDidChangeEmitter.event,
|
||||
provideTextDocumentContent: (uri) => {
|
||||
const docPath = this.docPathFromBaselineUri(uri);
|
||||
return this.baselines.get(docPath)?.text ?? "";
|
||||
},
|
||||
};
|
||||
this.disposables.push(
|
||||
this.onDidChangeEmitter,
|
||||
vscode.workspace.registerTextDocumentContentProvider(BASELINE_SCHEME, provider),
|
||||
vscode.commands.registerCommand("cowriting.toggleDiffView", () =>
|
||||
this.toggle(vscode.window.activeTextEditor),
|
||||
),
|
||||
vscode.commands.registerCommand("cowriting.pinDiffBaseline", () =>
|
||||
this.pinCommand(vscode.window.activeTextEditor),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- tracking / uri helpers --------------------------------------------------------
|
||||
|
||||
private isTracked(document: vscode.TextDocument): boolean {
|
||||
return document.uri.scheme === "file" && document.uri.fsPath.startsWith(this.rootDir);
|
||||
}
|
||||
private docPathOf(uri: vscode.Uri): string {
|
||||
return vscode.workspace.asRelativePath(uri, false);
|
||||
}
|
||||
/** The readonly virtual-doc URI whose content the provider serves for this doc. */
|
||||
private baselineUri(docPath: string): vscode.Uri {
|
||||
return vscode.Uri.from({ scheme: BASELINE_SCHEME, path: "/" + docPath });
|
||||
}
|
||||
private docPathFromBaselineUri(uri: vscode.Uri): string {
|
||||
return uri.path.replace(/^\//, "");
|
||||
}
|
||||
|
||||
// ---- baseline lifecycle (§6.4) -----------------------------------------------------
|
||||
|
||||
/** First sight of a tracked doc: load the stored baseline, else capture `opened`. */
|
||||
ensureBaseline(document: vscode.TextDocument): void {
|
||||
if (!this.isTracked(document)) return;
|
||||
const docPath = this.docPathOf(document.uri);
|
||||
if (this.baselines.has(docPath)) return;
|
||||
if (this.store) {
|
||||
try {
|
||||
const stored = this.store.load(docPath);
|
||||
if (stored) {
|
||||
this.baselines.set(docPath, stored);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
this.warnStorageOnce();
|
||||
}
|
||||
}
|
||||
this.capture(document, "opened");
|
||||
}
|
||||
|
||||
/** Machine landing (INV-18): re-capture so landed text never shows as a change. */
|
||||
advance(document: vscode.TextDocument): void {
|
||||
if (!this.isTracked(document)) return;
|
||||
this.capture(document, "machine-landing");
|
||||
}
|
||||
|
||||
/** Human pin: baseline := now; the open diff visibly empties (left = right). */
|
||||
pin(document: vscode.TextDocument): void {
|
||||
if (!this.isTracked(document)) return;
|
||||
this.capture(document, "pinned");
|
||||
}
|
||||
|
||||
/** Capture buffer text at this epoch, persist, and refresh any open diff's left side. */
|
||||
private capture(document: vscode.TextDocument, reason: BaselineReason): void {
|
||||
const docPath = this.docPathOf(document.uri);
|
||||
const baseline: Baseline = {
|
||||
docPath,
|
||||
text: document.getText(),
|
||||
capturedAt: new Date().toISOString(),
|
||||
reason,
|
||||
};
|
||||
this.baselines.set(docPath, baseline);
|
||||
if (this.store) {
|
||||
try {
|
||||
this.store.save(docPath, baseline);
|
||||
} catch {
|
||||
this.warnStorageOnce();
|
||||
}
|
||||
}
|
||||
// An open diff re-requests the left side when its baseline URI changes.
|
||||
this.onDidChangeEmitter.fire(this.baselineUri(docPath));
|
||||
}
|
||||
|
||||
private warnStorageOnce(): void {
|
||||
if (this.storageWarned) return;
|
||||
this.storageWarned = true;
|
||||
void vscode.window.showWarningMessage(
|
||||
"Cowriting: diff-view storage is unavailable — baselines are kept in memory only and won't survive a reload.",
|
||||
);
|
||||
}
|
||||
|
||||
// ---- commands (toggle implemented in SLICE-3, Task 4) ------------------------------
|
||||
|
||||
private pinCommand(editor: vscode.TextEditor | undefined): void {
|
||||
if (!editor || !this.isTracked(editor.document)) {
|
||||
void vscode.window.showWarningMessage("Cowriting: open a tracked workspace document to pin its diff baseline.");
|
||||
return;
|
||||
}
|
||||
this.pin(editor.document);
|
||||
}
|
||||
|
||||
// ---- test-facing surface (§6.4) ----------------------------------------------------
|
||||
|
||||
getBaseline(docPath: string): { text: string; reason: BaselineReason; capturedAt: string } | undefined {
|
||||
const b = this.baselines.get(docPath);
|
||||
return b ? { text: b.text, reason: b.reason, capturedAt: b.capturedAt } : undefined;
|
||||
}
|
||||
/** Absolute on-disk path of this doc's persisted baseline, or undefined if in-memory. */
|
||||
baselineFilePath(docPath: string): string | undefined {
|
||||
return this.store?.baselinePath(docPath);
|
||||
}
|
||||
|
||||
// ---- toggle UX (§6.5 PUC-1) --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* If this doc's baseline diff is the active/open tab → close it and reveal the
|
||||
* normal editor; if the active editor is a tracked doc with no diff open →
|
||||
* open vscode.diff (baseline left, the live document right). Untracked → warn,
|
||||
* no diff.
|
||||
*/
|
||||
private async toggle(editor: vscode.TextEditor | undefined): Promise<void> {
|
||||
if (!editor || !this.isTracked(editor.document)) {
|
||||
void vscode.window.showWarningMessage(
|
||||
"Cowriting: open a tracked workspace document to toggle its diff view.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const document = editor.document;
|
||||
const docPath = this.docPathOf(document.uri);
|
||||
const openTab = this.findDiffTab(document.uri);
|
||||
if (openTab) {
|
||||
await vscode.window.tabGroups.close(openTab);
|
||||
await vscode.window.showTextDocument(document, { preview: false });
|
||||
return;
|
||||
}
|
||||
this.ensureBaseline(document);
|
||||
const baseline = this.baselines.get(docPath)!;
|
||||
const title = `${path.basename(docPath)} — my changes since ${this.epochLabel(baseline)}`;
|
||||
await vscode.commands.executeCommand(
|
||||
"vscode.diff",
|
||||
this.baselineUri(docPath),
|
||||
document.uri,
|
||||
title,
|
||||
{ preview: false },
|
||||
);
|
||||
}
|
||||
|
||||
/** The open baseline-diff tab for this document, if any. */
|
||||
private findDiffTab(modified: vscode.Uri): vscode.Tab | undefined {
|
||||
for (const group of vscode.window.tabGroups.all) {
|
||||
for (const tab of group.tabs) {
|
||||
const input = tab.input;
|
||||
if (
|
||||
input instanceof vscode.TabInputTextDiff &&
|
||||
input.original.scheme === BASELINE_SCHEME &&
|
||||
input.modified.toString() === modified.toString()
|
||||
) {
|
||||
return tab;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Human-readable epoch for the diff tab title (§5 / §6.5). */
|
||||
private epochLabel(baseline: Baseline): string {
|
||||
const time = new Date(baseline.capturedAt).toLocaleTimeString();
|
||||
switch (baseline.reason) {
|
||||
case "opened":
|
||||
return `opened ${time}`;
|
||||
case "machine-landing":
|
||||
return `Claude landed ${time}`;
|
||||
case "pinned":
|
||||
return `pinned ${time}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-facing (§6.4): is this doc's baseline diff currently open in any tab
|
||||
* group? The diff's `modified` side is the document's own file: URI.
|
||||
*/
|
||||
isDiffOpen(docPath: string): boolean {
|
||||
return this.findDiffTab(vscode.Uri.file(path.join(this.rootDir, docPath))) !== undefined;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const d of this.disposables) d.dispose();
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -6,6 +6,8 @@ import { AttributionController } from "./attributionController";
|
||||
import { ProposalController } from "./proposalController";
|
||||
import { buildFingerprint } from "./anchorer";
|
||||
import { VersionGuard } from "./versionGuard";
|
||||
import { BaselineStore } from "./baselineStore";
|
||||
import { DiffViewController } from "./diffViewController";
|
||||
|
||||
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
||||
|
||||
@@ -14,6 +16,7 @@ export interface CowritingApi {
|
||||
attributionController: AttributionController;
|
||||
proposalController: ProposalController;
|
||||
versionGuard: VersionGuard;
|
||||
diffViewController: DiffViewController;
|
||||
}
|
||||
|
||||
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
|
||||
@@ -63,6 +66,8 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
"cowriting.acceptProposal",
|
||||
"cowriting.rejectProposal",
|
||||
"cowriting.proposeAgentEdit",
|
||||
"cowriting.toggleDiffView",
|
||||
"cowriting.pinDiffBaseline",
|
||||
]) {
|
||||
context.subscriptions.push(vscode.commands.registerCommand(command, stub));
|
||||
}
|
||||
@@ -83,6 +88,18 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
const proposalController = new ProposalController(store, attributionController, root, versionGuard);
|
||||
context.subscriptions.push(proposalController);
|
||||
|
||||
// --- F6: diff-view toggle (Feature #17) ---
|
||||
// Baseline lives in VS Code workspace storage, never the repo (INV-19).
|
||||
// storageUri can be undefined in odd host states → in-memory fallback (§6.5).
|
||||
const storageDir = context.storageUri?.fsPath;
|
||||
const baselineStore = storageDir ? new BaselineStore(storageDir) : null;
|
||||
const diffViewController = new DiffViewController(baselineStore, root);
|
||||
context.subscriptions.push(diffViewController);
|
||||
// The seam's single machine-landing signal advances the baseline (INV-18).
|
||||
context.subscriptions.push(
|
||||
attributionController.onDidApplyAgentEdit((e) => diffViewController.advance(e.document)),
|
||||
);
|
||||
|
||||
// One SHARED sidecar watcher for both controllers; self-writes are
|
||||
// suppressed centrally in the store (the sidecar is co-owned).
|
||||
const watcher = vscode.workspace.createFileSystemWatcher("**/.threads/**/*.json");
|
||||
@@ -228,12 +245,13 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
threadController.renderAll(doc);
|
||||
attributionController.loadAll(doc);
|
||||
proposalController.renderAll(doc);
|
||||
diffViewController.ensureBaseline(doc);
|
||||
}
|
||||
};
|
||||
vscode.workspace.textDocuments.forEach(renderIfOpen);
|
||||
context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen));
|
||||
|
||||
return { threadController, attributionController, proposalController, versionGuard };
|
||||
return { threadController, attributionController, proposalController, versionGuard, diffViewController };
|
||||
}
|
||||
|
||||
export function deactivate(): void {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { BaselineStore, type Baseline } from "../src/baselineStore";
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "baseline-store-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function sample(docPath = "notes/chapter-1.md"): Baseline {
|
||||
return { docPath, text: "hello\nworld\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "opened" };
|
||||
}
|
||||
|
||||
describe("BaselineStore", () => {
|
||||
it("returns null for a document with no baseline", () => {
|
||||
expect(new BaselineStore(dir).load("notes/chapter-1.md")).toBeNull();
|
||||
});
|
||||
|
||||
it("computes the baseline path as baselines/<docPath>.json", () => {
|
||||
const store = new BaselineStore(dir);
|
||||
expect(store.baselinePath("notes/chapter-1.md")).toBe(
|
||||
path.join(dir, "baselines", "notes", "chapter-1.md.json"),
|
||||
);
|
||||
});
|
||||
|
||||
it("save then load round-trips the baseline (including nested docPaths)", () => {
|
||||
const store = new BaselineStore(dir);
|
||||
const b = sample();
|
||||
store.save(b.docPath, b);
|
||||
expect(store.load(b.docPath)).toEqual(b);
|
||||
});
|
||||
|
||||
it("overwrites in place: the newest epoch wins, no history kept", () => {
|
||||
const store = new BaselineStore(dir);
|
||||
store.save("d.md", { docPath: "d.md", text: "v1", capturedAt: "2026-06-11T00:00:00.000Z", reason: "opened" });
|
||||
store.save("d.md", { docPath: "d.md", text: "v2", capturedAt: "2026-06-11T00:01:00.000Z", reason: "pinned" });
|
||||
expect(store.load("d.md")).toEqual({ docPath: "d.md", text: "v2", capturedAt: "2026-06-11T00:01:00.000Z", reason: "pinned" });
|
||||
});
|
||||
|
||||
it("writes pretty JSON with a trailing newline", () => {
|
||||
const store = new BaselineStore(dir);
|
||||
const b = sample("d.md");
|
||||
store.save("d.md", b);
|
||||
const raw = fs.readFileSync(store.baselinePath("d.md"), "utf8");
|
||||
expect(raw).toBe(JSON.stringify(b, null, 2) + "\n");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
# Diff view fixture
|
||||
|
||||
The baseline opening paragraph stays put so the diff suite can anchor on it.
|
||||
|
||||
A target sentence Claude will rewrite via the seam.
|
||||
|
||||
A closing paragraph for the operator to edit by hand.
|
||||
@@ -28,6 +28,8 @@ suite("no-workspace activation (#8)", () => {
|
||||
"cowriting.acceptProposal",
|
||||
"cowriting.rejectProposal",
|
||||
"cowriting.proposeAgentEdit",
|
||||
"cowriting.toggleDiffView",
|
||||
"cowriting.pinDiffBaseline",
|
||||
]) {
|
||||
assert.ok(all.includes(command), `${command} is registered`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
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 DOC_REL = "docs/diffview.md";
|
||||
|
||||
async function openDoc(): Promise<vscode.TextDocument> {
|
||||
const uri = vscode.Uri.file(path.join(WS, DOC_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?.diffViewController, "extension exports diffViewController");
|
||||
return api;
|
||||
}
|
||||
const settle = () => new Promise((r) => setTimeout(r, 300));
|
||||
|
||||
// Order-dependent (F2–F4 pattern): later tests consume earlier state. Owns
|
||||
// docs/diffview.md exclusively.
|
||||
suite("F6 diff-view toggle (host E2E — programmatic seam ingress, no LLM)", () => {
|
||||
const TARGET = "A target sentence Claude will rewrite via the seam.";
|
||||
const REPLACEMENT = "A SENTENCE CLAUDE REWROTE via the seam.";
|
||||
|
||||
test("opening a tracked doc captures an `opened` baseline equal to the buffer (INV-18)", async () => {
|
||||
const doc = await openDoc();
|
||||
const api = await getApi();
|
||||
// renderIfOpen on open already called ensureBaseline; assert it captured.
|
||||
const baseline = api.diffViewController.getBaseline(DOC_REL);
|
||||
assert.ok(baseline, "baseline captured on first sight");
|
||||
assert.strictEqual(baseline!.reason, "opened");
|
||||
assert.strictEqual(baseline!.text, doc.getText(), "baseline = open-time buffer");
|
||||
});
|
||||
|
||||
test("toggle opens a diff tab (original scheme cowriting-baseline) over the live doc (PUC-1)", async () => {
|
||||
const api = await getApi();
|
||||
assert.strictEqual(api.diffViewController.isDiffOpen(DOC_REL), false, "no diff open yet");
|
||||
await vscode.commands.executeCommand("cowriting.toggleDiffView");
|
||||
await settle();
|
||||
assert.strictEqual(api.diffViewController.isDiffOpen(DOC_REL), true, "diff tab open");
|
||||
// The active tab is a TextDiff whose original is the baseline scheme.
|
||||
const active = vscode.window.tabGroups.activeTabGroup.activeTab;
|
||||
assert.ok(active && active.input instanceof vscode.TabInputTextDiff, "active tab is a diff");
|
||||
assert.strictEqual(
|
||||
(active!.input as vscode.TabInputTextDiff).original.scheme,
|
||||
"cowriting-baseline",
|
||||
"left side served by the baseline provider",
|
||||
);
|
||||
});
|
||||
|
||||
test("typing leaves the baseline unchanged while the buffer diverges", async () => {
|
||||
const doc = await openDoc();
|
||||
const api = await getApi();
|
||||
const before = api.diffViewController.getBaseline(DOC_REL)!.text;
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.insert(doc.uri, new vscode.Position(0, 0), "OPERATOR ADDED LINE\n");
|
||||
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
|
||||
await settle();
|
||||
assert.strictEqual(api.diffViewController.getBaseline(DOC_REL)!.text, before, "baseline unchanged by typing");
|
||||
assert.notStrictEqual(doc.getText(), before, "buffer diverged");
|
||||
});
|
||||
|
||||
test("accepting a proposal advances the baseline past the landed text (PUC-2, 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");
|
||||
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
|
||||
uri: doc.uri.toString(),
|
||||
start,
|
||||
end: start + TARGET.length,
|
||||
newText: REPLACEMENT,
|
||||
model: "sonnet",
|
||||
sessionId: "e2e-diff",
|
||||
turnId: "turn-d1",
|
||||
});
|
||||
assert.ok(id, "propose returns an id");
|
||||
assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam");
|
||||
await settle();
|
||||
const baseline = api.diffViewController.getBaseline(DOC_REL)!;
|
||||
assert.strictEqual(baseline.reason, "machine-landing", "baseline advanced on the landing");
|
||||
assert.ok(baseline.text.includes(REPLACEMENT), "landed text is in the baseline (won't show as a change)");
|
||||
assert.ok(!baseline.text.includes(TARGET), "old target gone from the baseline too");
|
||||
assert.strictEqual(baseline.text, doc.getText(), "baseline == buffer right after the landing");
|
||||
});
|
||||
|
||||
test("an operator edit after the landing makes baseline ≠ buffer (the operator delta)", async () => {
|
||||
const doc = await openDoc();
|
||||
const api = await getApi();
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.insert(doc.uri, new vscode.Position(0, 0), "POST-LANDING OPERATOR LINE\n");
|
||||
assert.ok(await vscode.workspace.applyEdit(edit));
|
||||
await settle();
|
||||
assert.notStrictEqual(
|
||||
api.diffViewController.getBaseline(DOC_REL)!.text,
|
||||
doc.getText(),
|
||||
"operator changes show against the advanced baseline",
|
||||
);
|
||||
});
|
||||
|
||||
test("pin resets the baseline to now: baseline == buffer, reason pinned (PUC-3)", async () => {
|
||||
const doc = await openDoc();
|
||||
const api = await getApi();
|
||||
await vscode.commands.executeCommand("cowriting.pinDiffBaseline");
|
||||
await settle();
|
||||
const baseline = api.diffViewController.getBaseline(DOC_REL)!;
|
||||
assert.strictEqual(baseline.reason, "pinned");
|
||||
assert.strictEqual(baseline.text, doc.getText(), "pinned baseline == current buffer (diff empties)");
|
||||
});
|
||||
|
||||
test("the baseline is persisted on disk under the storage dir with the expected content (PUC-4)", async () => {
|
||||
const api = await getApi();
|
||||
const p = api.diffViewController.baselineFilePath(DOC_REL);
|
||||
assert.ok(p, "storage-backed baseline path is available");
|
||||
assert.ok(fs.existsSync(p!), `baseline file exists at ${p}`);
|
||||
const onDisk = JSON.parse(fs.readFileSync(p!, "utf8"));
|
||||
assert.strictEqual(onDisk.docPath, DOC_REL);
|
||||
assert.strictEqual(onDisk.reason, "pinned", "last epoch (pin) persisted");
|
||||
assert.strictEqual(onDisk.text, api.diffViewController.getBaseline(DOC_REL)!.text, "on-disk == in-memory");
|
||||
// INV-19: nothing leaked into the repo's .threads sidecar tree.
|
||||
assert.ok(!p!.includes(`${path.sep}.threads${path.sep}`), "baseline is NOT in the sidecar tree");
|
||||
});
|
||||
|
||||
test("toggle again closes the diff tab and reveals the normal editor (PUC-1)", async () => {
|
||||
const api = await getApi();
|
||||
if (!api.diffViewController.isDiffOpen(DOC_REL)) {
|
||||
await vscode.commands.executeCommand("cowriting.toggleDiffView");
|
||||
await settle();
|
||||
}
|
||||
assert.strictEqual(api.diffViewController.isDiffOpen(DOC_REL), true, "diff open before close");
|
||||
await vscode.commands.executeCommand("cowriting.toggleDiffView");
|
||||
await settle();
|
||||
assert.strictEqual(api.diffViewController.isDiffOpen(DOC_REL), false, "diff tab closed");
|
||||
// "Normal editor is back": the file is the active text editor, and no
|
||||
// baseline-diff tab for it remains in any group.
|
||||
assert.strictEqual(
|
||||
vscode.window.activeTextEditor?.document.uri.toString(),
|
||||
vscode.Uri.file(path.join(WS, DOC_REL)).toString(),
|
||||
"the normal editor for the doc is active after closing the diff",
|
||||
);
|
||||
});
|
||||
|
||||
test("toggling on an untracked doc warns and opens no diff (PUC-5)", async () => {
|
||||
await getApi();
|
||||
const untracked = await vscode.workspace.openTextDocument({ content: "scratch", language: "markdown" });
|
||||
await vscode.window.showTextDocument(untracked);
|
||||
await settle();
|
||||
await vscode.commands.executeCommand("cowriting.toggleDiffView");
|
||||
await settle();
|
||||
// No baseline-diff tab for an untitled doc anywhere.
|
||||
const anyDiff = vscode.window.tabGroups.all.some((g) =>
|
||||
g.tabs.some(
|
||||
(t) =>
|
||||
t.input instanceof vscode.TabInputTextDiff &&
|
||||
t.input.original.scheme === "cowriting-baseline" &&
|
||||
t.input.modified.toString() === untracked.uri.toString(),
|
||||
),
|
||||
);
|
||||
assert.strictEqual(anyDiff, false, "no diff opened for the untracked doc");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user