feat: baseline router — git HEAD + snapshot per INV-7/D13/D14; retire machine-landing advance (spec §6.4)
Task 2 of the native-surfaces migration plan. GitBaselineAdapter resolves a
document's HEAD blob via the built-in vscode.git extension (hardened with a
.git/logs/HEAD watch that nudges repo.status(), since the git extension's own
watcher doesn't reliably notice out-of-band commits on non-workspace-folder
repos in the E2E host). DiffViewController is reworked into a baseline router:
head mode (git-tracked, never persisted, re-read on commit) vs snapshot mode
(captured on CoeditingRegistry entry, re-pinned by "Mark Changes as Reviewed"
— renamed from cowriting.pinDiffBaseline, D14). The shipped machine-landing
baseline advance (#48/INV-18) is retired: a landed Claude edit now stays a
visible change-since-baseline until commit or review (INV-7/D21). Legacy
on-disk reasons ("opened"/"machine-landing") migrate to "entered" via
BaselineStore.normalizeReason on load.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+126
-43
@@ -1,9 +1,16 @@
|
||||
/**
|
||||
* DiffViewController — F6 baseline data layer (spec §6.4/§6.7). Owns the baseline
|
||||
* lifecycle (initialize at first sight / advance at every machine landing / pin
|
||||
* on demand) and serves it to F7/F10 via `getBaseline` + the additive
|
||||
* `onDidChangeBaseline` event. A pure data layer: never mutates the document,
|
||||
* sidecar, or attribution state (INV-19).
|
||||
* DiffViewController — the baseline router (native-surfaces migration, spec
|
||||
* §6.4, INV-7). Resolves each coedited document's review baseline to one of two
|
||||
* modes: **head** (git-tracked — the baseline is the `HEAD` blob, re-read on
|
||||
* every commit, never persisted) or **snapshot** (untracked/no-repo — captured
|
||||
* on coediting-entry, re-pinned on demand by "Mark Changes as Reviewed").
|
||||
* Baselines are established ONLY for documents the CoeditingRegistry has
|
||||
* entered (INV-10) — opening a file no longer captures one (the retired
|
||||
* `ensureBaseline`/#17 behavior). The shipped machine-landing advance (#48,
|
||||
* INV-18) is retired too: a landed Claude edit stays a visible change-since-
|
||||
* baseline until the file is committed (head mode) or reviewed (snapshot
|
||||
* mode) — spec INV-7's note, D21. A pure data layer: never mutates the
|
||||
* document, sidecar, or attribution state (INV-19).
|
||||
*
|
||||
* The F6 two-pane `vscode.diff` *view* (toggle UI + the `cowriting-baseline:`
|
||||
* virtual document) was removed in #34 once F10 made the rendered preview the
|
||||
@@ -12,37 +19,57 @@
|
||||
* The baseline works on ANY text document, not just workspace files — it needs no
|
||||
* `.threads/` sidecar, only a stable doc identity + a storage home. So it has
|
||||
* its OWN diffable predicate (any `file:` or `untitled:` doc), decoupled from
|
||||
* F2's workspace `isTracked`. Persistable docs (`file:`) keep their baseline in
|
||||
* VS Code's per-extension GLOBAL storage keyed by a hash of the document URI;
|
||||
* untitled buffers have no durable identity, so their baseline is in-memory
|
||||
* only (lost on reload) — the same degrade the storage-unavailable path uses.
|
||||
* F2's workspace `isTracked`. Persistable docs (`file:`) keep their SNAPSHOT
|
||||
* baseline in VS Code's per-extension GLOBAL storage keyed by a hash of the
|
||||
* document URI (INV-7: head-mode baselines are storage-free — never
|
||||
* persisted); untitled buffers have no durable identity, so their baseline is
|
||||
* in-memory only (lost on reload) — the same degrade the storage-unavailable
|
||||
* path uses.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import * as vscode from "vscode";
|
||||
import { BaselineStore, type Baseline, type BaselineReason } from "./baselineStore";
|
||||
import type { GitBaselineAdapter } from "./gitBaseline";
|
||||
import type { CoeditingRegistry } from "./coeditingRegistry";
|
||||
|
||||
export class DiffViewController implements vscode.Disposable {
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
/** Source of truth for the baseline, keyed by `document.uri.toString()`. */
|
||||
private readonly baselines = new Map<string, Baseline>();
|
||||
/** F7 (additive): fires on every baseline capture (open / advance / pin) so
|
||||
* the track-changes preview refreshes without polling. Carries the real
|
||||
* document URI. */
|
||||
/** Which mode each established document resolved to (INV-7). */
|
||||
private readonly modes = new Map<string, "head" | "snapshot">();
|
||||
/** F7 (additive): fires on every baseline capture (establish / head-refresh /
|
||||
* pin) so the track-changes preview refreshes without polling. Carries the
|
||||
* real document URI. */
|
||||
private readonly onDidChangeBaselineEmitter = new vscode.EventEmitter<{ uri: string }>();
|
||||
readonly onDidChangeBaseline = this.onDidChangeBaselineEmitter.event;
|
||||
private storageWarned = false;
|
||||
|
||||
constructor(private readonly store: BaselineStore | null) {
|
||||
constructor(
|
||||
private readonly store: BaselineStore | null,
|
||||
private readonly git: GitBaselineAdapter,
|
||||
registry: CoeditingRegistry,
|
||||
) {
|
||||
this.disposables.push(
|
||||
this.onDidChangeBaselineEmitter,
|
||||
vscode.commands.registerCommand("cowriting.pinDiffBaseline", () =>
|
||||
vscode.commands.registerCommand("cowriting.markReviewed", () =>
|
||||
this.pinCommand(vscode.window.activeTextEditor),
|
||||
),
|
||||
// F6 captures a baseline for any diffable doc the moment it is first seen,
|
||||
// independent of the workspace gate (so "opened" is the open-time text).
|
||||
vscode.workspace.onDidOpenTextDocument((d) => this.ensureBaseline(d)),
|
||||
// INV-10: a baseline is established only once its document ENTERS
|
||||
// coediting; exiting keeps the stored baseline as-is (PUC-7).
|
||||
registry.onDidChange(({ uri, coediting }) => {
|
||||
if (!coediting) return;
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
|
||||
if (doc) void this.establish(doc);
|
||||
}),
|
||||
// A commit on a watched repo may advance a head-mode baseline (D13).
|
||||
git.onDidChangeHead(() => void this.refreshHeadBaselines()),
|
||||
);
|
||||
for (const d of vscode.workspace.textDocuments) this.ensureBaseline(d);
|
||||
// Restore already-coediting documents (e.g. after an extension host reload).
|
||||
for (const key of registry.list()) {
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key);
|
||||
if (doc) void this.establish(doc);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- diffability / identity --------------------------------------------------------
|
||||
@@ -51,7 +78,7 @@ export class DiffViewController implements vscode.Disposable {
|
||||
private isDiffable(document: vscode.TextDocument): boolean {
|
||||
return document.uri.scheme === "file" || document.uri.scheme === "untitled";
|
||||
}
|
||||
/** Only `file:` docs have a durable identity to persist a baseline against. */
|
||||
/** Only `file:` docs have a durable identity to persist a SNAPSHOT baseline against. */
|
||||
private isPersistable(document: vscode.TextDocument): boolean {
|
||||
return document.uri.scheme === "file";
|
||||
}
|
||||
@@ -64,36 +91,66 @@ export class DiffViewController implements vscode.Disposable {
|
||||
return createHash("sha256").update(uriKey).digest("hex");
|
||||
}
|
||||
|
||||
// ---- baseline lifecycle (§6.4) -----------------------------------------------------
|
||||
// ---- baseline lifecycle (§6.4, INV-7) -----------------------------------------------
|
||||
|
||||
/** First sight of a diffable doc: load the stored baseline, else capture `opened`. */
|
||||
ensureBaseline(document: vscode.TextDocument): void {
|
||||
/**
|
||||
* Resolve this document's baseline mode and (re)establish its baseline.
|
||||
* git-tracked (a HEAD blob resolves) → **head** mode, baseline = HEAD, never
|
||||
* persisted. Otherwise → **snapshot** mode: load a previously-stored
|
||||
* snapshot, else capture the buffer now (`"entered"`). Called once when a
|
||||
* document enters coediting (registry.enter) and at startup for documents
|
||||
* already coediting.
|
||||
*/
|
||||
async establish(document: vscode.TextDocument): Promise<void> {
|
||||
if (!this.isDiffable(document)) return;
|
||||
const key = this.uriKey(document);
|
||||
if (this.baselines.has(key)) return;
|
||||
const head = await this.git.headFor(document.uri);
|
||||
if (head) {
|
||||
this.modes.set(key, "head");
|
||||
this.setBaseline(key, head.text, "head");
|
||||
return;
|
||||
}
|
||||
this.modes.set(key, "snapshot");
|
||||
if (this.store && this.isPersistable(document)) {
|
||||
try {
|
||||
const stored = this.store.load(this.storageKey(key));
|
||||
if (stored) {
|
||||
this.baselines.set(key, stored);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
this.warnStorageOnce();
|
||||
const stored = this.tryLoad(key); // existing load path, legacy reasons normalized
|
||||
if (stored) {
|
||||
this.baselines.set(key, stored);
|
||||
this.fire(key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.capture(document, "opened");
|
||||
this.capture(document, "entered");
|
||||
}
|
||||
|
||||
/** Machine landing (INV-18): re-capture so landed text never shows as a change. */
|
||||
advance(document: vscode.TextDocument): void {
|
||||
if (!this.isDiffable(document)) return;
|
||||
this.capture(document, "machine-landing");
|
||||
/** The mode a document's baseline resolved to, or undefined before `establish`. */
|
||||
modeOf(uriString: string): "head" | "snapshot" | undefined {
|
||||
return this.modes.get(uriString);
|
||||
}
|
||||
|
||||
/** Human pin: baseline := now; the preview's change-marks empty (left = right). */
|
||||
/** Re-read HEAD for every head-mode document; a commit advances the baseline (D13). */
|
||||
private async refreshHeadBaselines(): Promise<void> {
|
||||
for (const [key, mode] of this.modes) {
|
||||
if (mode !== "head") continue;
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key);
|
||||
if (!doc) continue;
|
||||
const head = await this.git.headFor(doc.uri);
|
||||
if (head && head.text !== this.baselines.get(key)?.text) {
|
||||
this.setBaseline(key, head.text, "head"); // commit advanced the baseline (D13)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Human pin ("Mark Changes as Reviewed", D14): snapshot mode only — a
|
||||
* git-tracked (head-mode) baseline advances only by commit. */
|
||||
pin(document: vscode.TextDocument): void {
|
||||
if (!this.isDiffable(document)) return;
|
||||
const key = this.uriKey(document);
|
||||
if (this.modes.get(key) !== "snapshot") {
|
||||
void vscode.window.showWarningMessage(
|
||||
"Cowriting: this document is git-tracked — commit to advance the baseline.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.capture(document, "pinned");
|
||||
}
|
||||
|
||||
@@ -103,12 +160,38 @@ export class DiffViewController implements vscode.Disposable {
|
||||
const baseline: Baseline = { uri: key, text: document.getText(), capturedAt: new Date().toISOString(), reason };
|
||||
this.baselines.set(key, baseline);
|
||||
if (this.store && this.isPersistable(document)) {
|
||||
try {
|
||||
this.store.save(this.storageKey(key), baseline);
|
||||
} catch {
|
||||
this.warnStorageOnce();
|
||||
}
|
||||
this.trySave(key, baseline);
|
||||
}
|
||||
this.fire(key);
|
||||
}
|
||||
|
||||
/** Set a baseline from resolved text directly (head mode — no document buffer read). */
|
||||
private setBaseline(key: string, text: string, reason: BaselineReason): void {
|
||||
const baseline: Baseline = { uri: key, text, capturedAt: new Date().toISOString(), reason };
|
||||
this.baselines.set(key, baseline);
|
||||
// head-mode baselines are storage-free (INV-7); persist only snapshots.
|
||||
if (reason !== "head" && this.store) this.trySave(key, baseline);
|
||||
this.fire(key);
|
||||
}
|
||||
|
||||
private tryLoad(key: string): Baseline | null {
|
||||
try {
|
||||
return this.store!.load(this.storageKey(key));
|
||||
} catch {
|
||||
this.warnStorageOnce();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private trySave(key: string, baseline: Baseline): void {
|
||||
try {
|
||||
this.store!.save(this.storageKey(key), baseline);
|
||||
} catch {
|
||||
this.warnStorageOnce();
|
||||
}
|
||||
}
|
||||
|
||||
private fire(key: string): void {
|
||||
this.onDidChangeBaselineEmitter.fire({ uri: key });
|
||||
}
|
||||
|
||||
@@ -122,7 +205,7 @@ export class DiffViewController implements vscode.Disposable {
|
||||
|
||||
private pinCommand(editor: vscode.TextEditor | undefined): void {
|
||||
if (!editor || !this.isDiffable(editor.document)) {
|
||||
void vscode.window.showWarningMessage("Cowriting: focus a text editor to pin its review baseline.");
|
||||
void vscode.window.showWarningMessage("Cowriting: focus a text editor to mark its changes as reviewed.");
|
||||
return;
|
||||
}
|
||||
this.pin(editor.document);
|
||||
|
||||
Reference in New Issue
Block a user