From cf655287119aa6b5c9245e12d3097cb5c8f45597 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 2 Jul 2026 07:18:50 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20native=20diff=20surface=20=E2=80=94=20Q?= =?UTF-8?q?uickDiff=20+=20cowriting-baseline:=20provider=20+=20Review=20Ch?= =?UTF-8?q?anges=20+=20status=20bar=20(spec=20=C2=A76.4,=20INV-13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- package.json | 19 +++- src/extension.ts | 20 +++++ src/scmSurface.ts | 120 ++++++++++++++++++++++++++ src/trackChangesModel.ts | 24 ++++++ test/e2e/suite/baselineRouter.test.ts | 4 + test/e2e/suite/coediting.test.ts | 15 ++++ test/trackChangesModel.test.ts | 18 +++- 7 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 src/scmSurface.ts diff --git a/package.json b/package.json index eb44367..a889b3d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/extension.ts b/src/extension.ts index 410a504..eb73135 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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, }; } diff --git a/src/scmSurface.ts b/src/scmSurface.ts new file mode 100644 index 0000000..9409fee --- /dev/null +++ b/src/scmSurface.ts @@ -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(); + private readonly baselineEmitter = new vscode.EventEmitter(); + private readonly recountTimers = new Map>(); + + 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 { + 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(); + } +} diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts index 23400b3..7c4c3a6 100644 --- a/src/trackChangesModel.ts +++ b/src/trackChangesModel.ts @@ -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. */ diff --git a/test/e2e/suite/baselineRouter.test.ts b/test/e2e/suite/baselineRouter.test.ts index cf80c35..2ba199d 100644 --- a/test/e2e/suite/baselineRouter.test.ts +++ b/test/e2e/suite/baselineRouter.test.ts @@ -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 () { diff --git a/test/e2e/suite/coediting.test.ts b/test/e2e/suite/coediting.test.ts index cd95bbc..ff99efe 100644 --- a/test/e2e/suite/coediting.test.ts +++ b/test/e2e/suite/coediting.test.ts @@ -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); + }); }); diff --git a/test/trackChangesModel.test.ts b/test/trackChangesModel.test.ts index c00f92d..89cae0d 100644 --- a/test/trackChangesModel.test.ts +++ b/test/trackChangesModel.test.ts @@ -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); + }); +});