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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user