64e993992d
F6 §6.5 PUC-1. Detects the baseline diff tab (original scheme cowriting-baseline, modified == the doc) to close-and-reveal; else opens vscode.diff with an epoch title. Live document on the right — editor state preserved by construction. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
223 lines
8.2 KiB
TypeScript
223 lines
8.2 KiB
TypeScript
/**
|
|
* 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();
|
|
}
|
|
}
|