feat: native diff surface — QuickDiff + cowriting-baseline: provider + Review Changes + status bar (spec §6.4, INV-13)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-07-02 07:18:50 -07:00
parent 447a1170ce
commit cf65528711
7 changed files with 218 additions and 2 deletions
+18 -1
View File
@@ -87,7 +87,14 @@
{
"command": "cowriting.markReviewed",
"title": "Mark Changes as Reviewed",
"category": "Cowriting"
"category": "Cowriting",
"icon": "$(check-all)"
},
{
"command": "cowriting.reviewChanges",
"title": "Review Changes",
"category": "Cowriting",
"icon": "$(git-compare)"
},
{
"command": "cowriting.showTrackChangesPreview",
@@ -182,6 +189,16 @@
}
],
"editor/title": [
{
"command": "cowriting.reviewChanges",
"when": "resourceLangId == markdown && cowriting.isCoediting",
"group": "navigation@1"
},
{
"command": "cowriting.markReviewed",
"when": "resourceLangId == markdown && cowriting.isCoediting && cowriting.baselineMode == snapshot",
"group": "navigation@4"
},
{
"command": "cowriting.showTrackChangesPreview",
"when": "editorLangId == markdown",
+20
View File
@@ -16,6 +16,7 @@ import { LiveProgressUi } from "./liveProgressUi";
import { EditorProposalController } from "./editorProposalController";
import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath";
import { CoeditingRegistry } from "./coeditingRegistry";
import { ScmSurfaceController } from "./scmSurface";
const CHANNEL_NAME = "Cowriting (Cline SDK)";
@@ -30,6 +31,7 @@ export interface CowritingApi {
liveProgressUi: LiveProgressUi;
editorProposalController: EditorProposalController;
coeditingRegistry: CoeditingRegistry;
scmSurfaceController: ScmSurfaceController;
}
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
@@ -105,6 +107,23 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
const diffViewController = new DiffViewController(baselineStore, gitBaseline, coeditingRegistry);
context.subscriptions.push(diffViewController);
// Task 1 hook: expose the resolved baseline mode ("head"/"snapshot") to the
// registry's context-key sync, and re-sync `cowriting.baselineMode` whenever
// a baseline is (re)captured (entry, head-refresh, pin) — the "Mark Changes
// as Reviewed" menu entry (snapshot-only, Task 3) is gated on that key.
coeditingRegistry.baselineModeOf = (uri) => diffViewController.modeOf(uri.toString());
context.subscriptions.push(
diffViewController.onDidChangeBaseline(() => coeditingRegistry.syncContext(vscode.window.activeTextEditor)),
);
// --- Task 3: the native diff surface (spec §6.4/§5, INV-13) — QuickDiff
// gutter bars, the cowriting-baseline: content provider, "Review Changes",
// and the status-bar change count. Constructed after diffViewController
// (consumes getBaseline/modeOf/onDidChangeBaseline) and the registry
// (consumes isCoediting/onDidChange), gated on INV-10. ---
const scmSurfaceController = new ScmSurfaceController(coeditingRegistry, diffViewController);
context.subscriptions.push(scmSurfaceController);
// F8: the out-of-workspace/untitled authoring sidecar — same GLOBAL storage
// home as the F6 baseline, keyed by sha256(uri) (INV-19/24). Constructed
// workspace-independently; the router falls back to it for any non-in-folder
@@ -396,6 +415,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
liveProgressUi,
editorProposalController,
coeditingRegistry,
scmSurfaceController,
};
}
+120
View File
@@ -0,0 +1,120 @@
/**
* ScmSurfaceController — the native diff surface (spec §6.4/§5, INV-13).
* Owns: the "Cowriting" SourceControl + QuickDiffProvider (gutter bars), the
* cowriting-baseline: TextDocumentContentProvider, the "Review Changes" native
* diff command, and the "✦ Coediting · N changes" status-bar item. Gated on
* the CoeditingRegistry (INV-10): non-entered docs resolve no original.
*/
import * as path from "node:path";
import * as vscode from "vscode";
import type { CoeditingRegistry } from "./coeditingRegistry";
import type { DiffViewController } from "./diffViewController";
import { countLineHunks } from "./trackChangesModel";
export const BASELINE_SCHEME = "cowriting-baseline";
export function baselineUriFor(docUri: vscode.Uri): vscode.Uri {
const name = path.basename(docUri.path) || "untitled.md";
return vscode.Uri.from({ scheme: BASELINE_SCHEME, path: `/${name}`, query: encodeURIComponent(docUri.toString()) });
}
export class ScmSurfaceController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
private readonly statusItem: vscode.StatusBarItem;
private readonly counts = new Map<string, number>();
private readonly baselineEmitter = new vscode.EventEmitter<vscode.Uri>();
private readonly recountTimers = new Map<string, ReturnType<typeof setTimeout>>();
constructor(
private readonly registry: CoeditingRegistry,
private readonly diffView: DiffViewController,
) {
this.statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
this.statusItem.command = "cowriting.reviewChanges";
this.statusItem.tooltip = "Cowriting — click to review changes (baseline ⟷ live)";
this.disposables.push(this.statusItem, this.baselineEmitter);
this.disposables.push(
vscode.workspace.registerTextDocumentContentProvider(BASELINE_SCHEME, {
onDidChange: this.baselineEmitter.event,
provideTextDocumentContent: (uri) =>
this.diffView.getBaseline(decodeURIComponent(uri.query))?.text ?? "",
}),
);
const sc = vscode.scm.createSourceControl("cowriting", "✦ Cowriting");
sc.quickDiffProvider = {
provideOriginalResource: (uri: vscode.Uri) =>
this.registry.isCoediting(uri) && this.diffView.getBaseline(uri.toString())
? baselineUriFor(uri)
: null,
};
this.disposables.push(sc);
this.disposables.push(
vscode.commands.registerCommand("cowriting.reviewChanges", () => this.openReview()),
this.diffView.onDidChangeBaseline(({ uri }) => {
this.baselineEmitter.fire(baselineUriFor(vscode.Uri.parse(uri)));
this.scheduleRecount(uri);
}),
this.registry.onDidChange(({ uri, coediting }) => {
if (coediting) this.scheduleRecount(uri);
this.refreshStatus();
}),
vscode.workspace.onDidChangeTextDocument((e) => {
if (this.registry.isCoediting(e.document.uri)) this.scheduleRecount(e.document.uri.toString());
}),
vscode.window.onDidChangeActiveTextEditor(() => this.refreshStatus()),
);
this.refreshStatus();
}
changeCount(uriString: string): number {
return this.counts.get(uriString) ?? 0;
}
private async openReview(): Promise<void> {
const ed = vscode.window.activeTextEditor;
if (!ed || !this.registry.isCoediting(ed.document.uri)) {
void vscode.window.showWarningMessage("Cowriting: not coediting this document.");
return;
}
this.baselineEmitter.fire(baselineUriFor(ed.document.uri));
await vscode.commands.executeCommand(
"vscode.diff",
baselineUriFor(ed.document.uri),
ed.document.uri,
`${path.basename(ed.document.uri.path)} — Coediting (baseline ⟷ live)`,
{ preview: true },
);
}
private scheduleRecount(uriString: string): void {
const prev = this.recountTimers.get(uriString);
if (prev !== undefined) clearTimeout(prev);
this.recountTimers.set(uriString, setTimeout(() => {
this.recountTimers.delete(uriString);
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
const baseline = this.diffView.getBaseline(uriString);
if (!doc || !baseline) return;
this.counts.set(uriString, countLineHunks(baseline.text, doc.getText()));
this.refreshStatus();
}, 150));
}
private refreshStatus(): void {
const ed = vscode.window.activeTextEditor;
if (!ed || !this.registry.isCoediting(ed.document.uri)) {
this.statusItem.hide();
return;
}
const n = this.counts.get(ed.document.uri.toString()) ?? 0;
this.statusItem.text = `$(sparkle) Coediting · ${n} change${n === 1 ? "" : "s"}`;
this.statusItem.show();
}
dispose(): void {
for (const t of this.recountTimers.values()) clearTimeout(t);
for (const d of this.disposables) d.dispose();
}
}
+24
View File
@@ -193,6 +193,30 @@ export function diffBlocks(baselineText: string, currentText: string): BlockOp[]
return ops;
}
/**
* #64/Task 3 (INV-6): the number of line-grain hunks between `oldText` and
* `newText` — a run of one or more contiguous non-equal lines counts as ONE
* hunk (the status-bar "N changes" count). Same LCS walk `diffBlocks` uses
* (jsdiff `diffArrays`), at line grain instead of block grain. Pure,
* vscode-free, deterministic.
*/
export function countLineHunks(oldText: string, newText: string): number {
const before = oldText.split(/\r?\n/);
const after = newText.split(/\r?\n/);
const changes = diffArrays(before, after);
let hunks = 0;
let inHunk = false;
for (const ch of changes) {
if (ch.added || ch.removed) {
if (!inHunk) hunks++;
inHunk = true;
} else {
inHunk = false;
}
}
return hunks;
}
/** A contiguous changed region of `currentText` and its replacement (F11). */
export interface EditHunk {
/** char offset of the hunk's first changed char in currentText. */
+4
View File
@@ -16,10 +16,14 @@ suite("baseline router (PUC-1, INV-7/D13/D14)", () => {
assert.strictEqual(api.diffViewController.modeOf(doc.uri.toString()), "snapshot");
assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.text, "one\n");
await ed.edit((b) => b.insert(new vscode.Position(1, 0), "two\n"));
// Task 3 (INV-13): the status-bar/SCM change count tracks the live edit.
await settleUntil(() => api.scmSurfaceController.changeCount(doc.uri.toString()) === 1);
await vscode.commands.executeCommand("cowriting.markReviewed");
const b = api.diffViewController.getBaseline(doc.uri.toString());
assert.strictEqual(b?.text, "one\ntwo\n");
assert.strictEqual(b?.reason, "pinned");
// A pin re-baselines to the clean current text, so the change count resets.
await settleUntil(() => api.scmSurfaceController.changeCount(doc.uri.toString()) === 0);
});
test("head mode: baseline = HEAD; commit advances it", async function () {
+15
View File
@@ -1,5 +1,6 @@
import * as assert from "node:assert";
import * as vscode from "vscode";
import { activateApi, settle } from "./helpers";
async function api() {
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
@@ -17,4 +18,18 @@ suite("coediting registry (PUC-7)", () => {
await vscode.commands.executeCommand("cowriting.stopCoediting");
assert.strictEqual(coeditingRegistry.isCoediting(doc.uri), false);
});
// Task 3 (INV-10): a document that was never entered into coediting gets no
// native-diff surface at all. QuickDiff's `provideOriginalResource` isn't
// directly queryable from a host-E2E test, so this asserts the gate INPUTS
// structurally: `isCoediting` stays false and the SCM change count — which
// `ScmSurfaceController` only ever populates for a coediting doc — stays 0.
test("non-entered doc: no coediting, no change count (structural, INV-10)", async () => {
const api = await activateApi();
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "untouched\n" });
await vscode.window.showTextDocument(doc);
await settle();
assert.strictEqual(api.coeditingRegistry.isCoediting(doc.uri), false);
assert.strictEqual(api.scmSurfaceController.changeCount(doc.uri.toString()), 0);
});
});
+17 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, test, expect } from "vitest";
import MarkdownIt from "markdown-it";
import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, diffToBlockHunks, renderTrackChanges, colorByAuthor, authorAt, wordDiffByAuthor, type AuthorSpan } from "../src/trackChangesModel";
import { splitBlocks, splitBlocksWithRanges, diffBlocks, diffToHunks, diffToBlockHunks, renderTrackChanges, colorByAuthor, authorAt, wordDiffByAuthor, countLineHunks, type AuthorSpan } from "../src/trackChangesModel";
const md = new MarkdownIt({ html: true, linkify: false, breaks: false });
describe("splitBlocks", () => {
@@ -825,3 +825,19 @@ describe("F11 data-src emission (INV-36)", () => {
expect(renderReview(doc, doc, [], [])).not.toContain('href="https://example.com/spec"');
});
});
describe("countLineHunks", () => {
it("0 for identical text", () => {
expect(countLineHunks("a\nb\n", "a\nb\n")).toBe(0);
});
it("1 for one contiguous change", () => {
expect(countLineHunks("a\nb\nc\n", "a\nX\nc\n")).toBe(1);
});
it("2 for two separated changes", () => {
expect(countLineHunks("a\nb\nc\nd\ne\n", "a\nX\nc\nd\nY\n")).toBe(2);
});
it("counts pure insertions and deletions", () => {
expect(countLineHunks("a\n", "a\nb\n")).toBe(1);
expect(countLineHunks("a\nb\n", "a\n")).toBe(1);
});
});