F6: diff-view toggle (Feature #17) #18
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
// Replaced by the real implementation in Task 4 (toggle UX, SLICE-3).
|
||||
private toggle(_editor: vscode.TextEditor | undefined): void {
|
||||
/* stub */
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const d of this.disposables) d.dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user