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:
@@ -56,8 +56,11 @@ export class AttributionController implements vscode.Disposable {
|
||||
/**
|
||||
* F6 (§6.2/§6.4): the single machine-landing signal. Fired after a real
|
||||
* (non-no-op) seam apply succeeds. INV-9 makes the seam the sole machine-edit
|
||||
* ingress, so this is the sole signal — DiffViewController subscribes to
|
||||
* advance the baseline; no call-site wiring, no future driver can forget it.
|
||||
* ingress, so this is the sole signal. Native-surfaces migration (INV-7/
|
||||
* INV-18 retirement, spec §6.4): DiffViewController no longer subscribes
|
||||
* here to advance the baseline — a landed edit stays a visible
|
||||
* change-since-baseline until commit (head mode) or "Mark Changes as
|
||||
* Reviewed" (snapshot mode). Other consumers may still subscribe.
|
||||
*/
|
||||
private readonly applyEmitter = new vscode.EventEmitter<{ document: vscode.TextDocument }>();
|
||||
readonly onDidApplyAgentEdit: vscode.Event<{ document: vscode.TextDocument }> = this.applyEmitter.event;
|
||||
@@ -291,9 +294,10 @@ export class AttributionController implements vscode.Disposable {
|
||||
);
|
||||
}
|
||||
if (ok && opts?.landBaseline !== false) {
|
||||
// F6 (INV-18): a real machine landing — advance the baseline. F12 (INV-48)
|
||||
// suppresses this for optimistic apply: the proposed text is in the buffer
|
||||
// but the change stays PENDING (baseline at pre-proposal) until accept.
|
||||
// F6 (INV-18, retired as a baseline advance — spec §6.4/INV-7): fire the
|
||||
// machine-landing signal. F12 (INV-48) suppresses this for optimistic
|
||||
// apply: the proposed text is in the buffer but the change stays PENDING
|
||||
// until accept.
|
||||
this.applyEmitter.fire({ document });
|
||||
}
|
||||
return ok;
|
||||
@@ -302,8 +306,10 @@ export class AttributionController implements vscode.Disposable {
|
||||
/**
|
||||
* F12/#64 (INV-51): fire the machine-landing signal WITHOUT applying text — used
|
||||
* by finalize-in-place, where the proposed text already landed in the buffer via
|
||||
* optimistic apply (`landBaseline:false`) and accept only needs to advance the
|
||||
* F6 baseline so the now-accepted change stops reading as pending.
|
||||
* optimistic apply (`landBaseline:false`) and accept marks the landing so the
|
||||
* now-accepted change is recorded (INV-7: the baseline itself no longer
|
||||
* advances on landing — it advances only on commit / "Mark Changes as
|
||||
* Reviewed").
|
||||
*/
|
||||
signalLanded(document: vscode.TextDocument): void {
|
||||
if (this.isTracked(document)) this.applyEmitter.fire({ document });
|
||||
|
||||
+17
-2
@@ -7,11 +7,19 @@
|
||||
* key is a hash of the document URI (the controller derives it), not a
|
||||
* workspace-relative path; an untitled buffer has no durable identity and is
|
||||
* never persisted here (in-memory only — see DiffViewController).
|
||||
*
|
||||
* INV-7 (native-surfaces migration, spec §6.4): the baseline is now either the
|
||||
* git-HEAD blob (never persisted — see GitBaselineAdapter/DiffViewController)
|
||||
* or a snapshot captured on coediting-entry / re-pinned by "Mark Changes as
|
||||
* Reviewed". `"opened"`/`"machine-landing"` are legacy reasons from the
|
||||
* shipped #17/#48 baseline; `normalizeReason` migrates them on load so old
|
||||
* on-disk baselines keep round-tripping under the new vocabulary.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
export type BaselineReason = "opened" | "machine-landing" | "pinned";
|
||||
export type BaselineReason = "entered" | "pinned" | "head";
|
||||
type LegacyBaselineReason = BaselineReason | "opened" | "machine-landing";
|
||||
|
||||
export interface Baseline {
|
||||
/** the document URI (the identity the key is derived from) — for round-trip/debug. */
|
||||
@@ -22,6 +30,12 @@ export interface Baseline {
|
||||
reason: BaselineReason;
|
||||
}
|
||||
|
||||
/** Migrate legacy on-disk reasons to the current vocabulary (§6.4 INV-7). */
|
||||
export function normalizeReason(reason: LegacyBaselineReason): BaselineReason {
|
||||
if (reason === "opened" || reason === "machine-landing") return "entered";
|
||||
return reason;
|
||||
}
|
||||
|
||||
export class BaselineStore {
|
||||
/** @param storageDir absolute VS Code GLOBAL-storage dir (context.globalStorageUri.fsPath). */
|
||||
constructor(private readonly storageDir: string) {}
|
||||
@@ -34,7 +48,8 @@ export class BaselineStore {
|
||||
load(key: string): Baseline | null {
|
||||
const p = this.baselinePath(key);
|
||||
if (!fs.existsSync(p)) return null;
|
||||
return JSON.parse(fs.readFileSync(p, "utf8")) as Baseline;
|
||||
const raw = JSON.parse(fs.readFileSync(p, "utf8")) as Baseline;
|
||||
return { ...raw, reason: normalizeReason(raw.reason) };
|
||||
}
|
||||
|
||||
/** Newest epoch wins — overwrite in place, no history (state-not-history, the F3/F4 precedent). */
|
||||
|
||||
+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);
|
||||
|
||||
@@ -57,7 +57,7 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
|
||||
vscode.languages.registerCodeLensProvider({ language: "markdown" }, this),
|
||||
this.proposals.onDidChangeProposals(({ uri }) => this.scheduleApply(uri)),
|
||||
vscode.window.onDidChangeActiveTextEditor((ed) => ed && this.renderEditor(ed)),
|
||||
// Refresh committed decorations when the baseline advances (pin / machine-landing / open).
|
||||
// Refresh committed decorations when the baseline changes (establish / pin / head-refresh).
|
||||
this.diffView.onDidChangeBaseline(({ uri }) => {
|
||||
const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri);
|
||||
if (ed) this.renderEditor(ed);
|
||||
|
||||
+20
-15
@@ -10,6 +10,7 @@ import { BaselineStore } from "./baselineStore";
|
||||
import { GlobalSidecarStore } from "./globalSidecarStore";
|
||||
import { SidecarRouter } from "./sidecarRouter";
|
||||
import { DiffViewController } from "./diffViewController";
|
||||
import { GitBaselineAdapter } from "./gitBaseline";
|
||||
import { TrackChangesPreviewController } from "./trackChangesPreview";
|
||||
import { LiveProgressUi } from "./liveProgressUi";
|
||||
import { EditorProposalController } from "./editorProposalController";
|
||||
@@ -87,17 +88,21 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
);
|
||||
coeditingRegistry.syncContext(vscode.window.activeTextEditor);
|
||||
|
||||
// --- F6: baseline data layer (Features #17 + #19) — workspace-INDEPENDENT ---
|
||||
// The two-pane vscode.diff VIEW was removed in #34 (F10's rendered preview is
|
||||
// the single review surface); only the baseline store survives, consumed by
|
||||
// F7/F10. It tracks a baseline for ANY diffable doc (file or untitled), so it
|
||||
// is constructed regardless of whether a folder is open. Baseline lives in VS
|
||||
// Code's per-extension GLOBAL storage (always present), never the repo
|
||||
// (INV-19). The machine-landing advance is wired below. The controller
|
||||
// self-wires baseline capture on open.
|
||||
// --- Baseline router (native-surfaces migration, spec §6.4, INV-7) — workspace-
|
||||
// INDEPENDENT --- The two-pane vscode.diff VIEW was removed in #34 (F10's
|
||||
// rendered preview is the single review surface); only the baseline data
|
||||
// layer survives, consumed by F7/F10. It tracks a baseline for ANY diffable
|
||||
// doc (file or untitled), so it is constructed regardless of whether a folder
|
||||
// is open. A SNAPSHOT baseline lives in VS Code's per-extension GLOBAL
|
||||
// storage (always present), never the repo (INV-19); a git-tracked doc's HEAD
|
||||
// baseline is never persisted (INV-7). Baselines are established only for
|
||||
// documents the CoeditingRegistry has entered — see the registry wiring
|
||||
// inside DiffViewController's constructor.
|
||||
const gitBaseline = new GitBaselineAdapter();
|
||||
context.subscriptions.push(gitBaseline);
|
||||
const baselineStorageDir = context.globalStorageUri?.fsPath;
|
||||
const baselineStore = baselineStorageDir ? new BaselineStore(baselineStorageDir) : null;
|
||||
const diffViewController = new DiffViewController(baselineStore);
|
||||
const diffViewController = new DiffViewController(baselineStore, gitBaseline, coeditingRegistry);
|
||||
context.subscriptions.push(diffViewController);
|
||||
|
||||
// F8: the out-of-workspace/untitled authoring sidecar — same GLOBAL storage
|
||||
@@ -176,12 +181,12 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
}),
|
||||
);
|
||||
|
||||
// --- F6 machine-landing wiring — now for ANY authorable doc ---
|
||||
// The seam's single machine-landing signal advances the F6 baseline (INV-18);
|
||||
// the seam can now fire on out-of-folder files too, so wire it unconditionally.
|
||||
context.subscriptions.push(
|
||||
attributionController.onDidApplyAgentEdit((e) => diffViewController.advance(e.document)),
|
||||
);
|
||||
// NOTE (native-surfaces migration, INV-7/INV-18 retirement): the shipped
|
||||
// machine-landing baseline advance (#48) — wiring
|
||||
// attributionController.onDidApplyAgentEdit → diffViewController.advance —
|
||||
// was removed here. A landed Claude edit now stays a visible change-since-
|
||||
// baseline until the file is committed (head mode) or reviewed via
|
||||
// "Mark Changes as Reviewed" (snapshot mode).
|
||||
|
||||
// One SHARED sidecar watcher for both controllers; self-writes are suppressed
|
||||
// centrally in the repo store (only repo `.threads/` sidecars are watched —
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* GitBaselineAdapter — resolves a document's git-HEAD baseline via the built-in
|
||||
* Git extension API (spec §6.4, INV-7). Spike-verified: Repository.show('HEAD',
|
||||
* path) + state.onDidChange work as assumed; the change event is coarse, so
|
||||
* re-reads key off state.HEAD.commit; a nested repo may need openRepository().
|
||||
*
|
||||
* Hardening (Task 2 E2E, host-verified): for a repo that is NOT a workspace
|
||||
* folder (the common case here — F8/#19 precedent, coediting works on any
|
||||
* file), the git extension's own filesystem watcher does not reliably notice
|
||||
* an out-of-band commit (e.g. a `git commit` run from a terminal, or in the
|
||||
* host E2E) — `repo.state.onDidChange` can simply never fire. We additionally
|
||||
* watch `.git/logs/HEAD` (the reflog VS Code itself also watches for
|
||||
* in-workspace repos) with Node's own `fs.watch` and nudge `repo.status()`,
|
||||
* which reliably re-syncs `state.HEAD` and lets the existing onDidChange path
|
||||
* do the rest. Best-effort: any failure here just falls back to the passive
|
||||
* listener.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as vscode from "vscode";
|
||||
|
||||
interface GitRepository {
|
||||
rootUri: vscode.Uri;
|
||||
state: { HEAD?: { commit?: string }; onDidChange: vscode.Event<void> };
|
||||
show(ref: string, path: string): Promise<string>;
|
||||
status(): Promise<void>;
|
||||
}
|
||||
interface GitAPI {
|
||||
repositories: GitRepository[];
|
||||
getRepository(uri: vscode.Uri): GitRepository | null;
|
||||
openRepository(root: vscode.Uri): Promise<GitRepository | null>;
|
||||
onDidOpenRepository: vscode.Event<GitRepository>;
|
||||
}
|
||||
|
||||
export interface HeadBaseline { text: string; commit: string }
|
||||
|
||||
export class GitBaselineAdapter implements vscode.Disposable {
|
||||
private api: GitAPI | null | undefined; // undefined = not resolved yet; null = unavailable
|
||||
private readonly hooked = new WeakSet<GitRepository>();
|
||||
private readonly lastCommit = new Map<string, string>(); // repo root → HEAD commit
|
||||
private readonly emitter = new vscode.EventEmitter<void>();
|
||||
readonly onDidChangeHead = this.emitter.event;
|
||||
private readonly disposables: vscode.Disposable[] = [this.emitter];
|
||||
|
||||
private async gitApi(): Promise<GitAPI | null> {
|
||||
if (this.api !== undefined) return this.api;
|
||||
try {
|
||||
const ext = vscode.extensions.getExtension("vscode.git");
|
||||
if (!ext) return (this.api = null);
|
||||
const exports = ext.isActive ? ext.exports : await ext.activate();
|
||||
this.api = exports.getAPI(1) as GitAPI;
|
||||
this.disposables.push(this.api.onDidOpenRepository((r) => this.hook(r)));
|
||||
for (const r of this.api.repositories) this.hook(r);
|
||||
} catch {
|
||||
this.api = null;
|
||||
}
|
||||
return this.api;
|
||||
}
|
||||
|
||||
/** HEAD blob text + commit for a tracked file; null when untracked / no repo / no HEAD. */
|
||||
async headFor(uri: vscode.Uri): Promise<HeadBaseline | null> {
|
||||
if (uri.scheme !== "file") return null;
|
||||
const git = await this.gitApi();
|
||||
if (!git) return null;
|
||||
let repo = git.getRepository(uri);
|
||||
if (!repo) repo = await this.discoverNested(git, uri);
|
||||
if (!repo) return null;
|
||||
this.hook(repo);
|
||||
try {
|
||||
const text = await repo.show("HEAD", uri.fsPath);
|
||||
return { text, commit: repo.state.HEAD?.commit ?? "" };
|
||||
} catch {
|
||||
return null; // untracked, ignored, or no commit yet → snapshot mode
|
||||
}
|
||||
}
|
||||
|
||||
/** Spike finding: a nested repo may not be auto-opened — walk up for .git, then openRepository. */
|
||||
private async discoverNested(git: GitAPI, uri: vscode.Uri): Promise<GitRepository | null> {
|
||||
let dir = path.dirname(uri.fsPath);
|
||||
for (let i = 0; i < 32; i++) {
|
||||
if (fs.existsSync(path.join(dir, ".git"))) {
|
||||
await git.openRepository(vscode.Uri.file(dir));
|
||||
return git.getRepository(uri);
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Watch a repo; fire onDidChangeHead only when HEAD's commit actually moves. */
|
||||
private hook(repo: GitRepository): void {
|
||||
if (this.hooked.has(repo)) return;
|
||||
this.hooked.add(repo);
|
||||
this.lastCommit.set(repo.rootUri.toString(), repo.state.HEAD?.commit ?? "");
|
||||
this.disposables.push(
|
||||
repo.state.onDidChange(() => {
|
||||
const key = repo.rootUri.toString();
|
||||
const commit = repo.state.HEAD?.commit ?? "";
|
||||
if (this.lastCommit.get(key) !== commit) {
|
||||
this.lastCommit.set(key, commit);
|
||||
this.emitter.fire();
|
||||
}
|
||||
}),
|
||||
);
|
||||
this.watchReflog(repo);
|
||||
}
|
||||
|
||||
/** Best-effort nudge: watch `.git/logs/HEAD` directly and force `repo.status()`
|
||||
* on any write, so an out-of-band commit is picked up even when the git
|
||||
* extension's own watcher doesn't fire (see the class doc). */
|
||||
private watchReflog(repo: GitRepository): void {
|
||||
try {
|
||||
const reflog = path.join(repo.rootUri.fsPath, ".git", "logs", "HEAD");
|
||||
if (!fs.existsSync(reflog)) return;
|
||||
let pending: ReturnType<typeof setTimeout> | undefined;
|
||||
const watcher = fs.watch(reflog, () => {
|
||||
if (pending) clearTimeout(pending);
|
||||
pending = setTimeout(() => void repo.status().catch(() => {}), 150);
|
||||
});
|
||||
this.disposables.push({ dispose: () => watcher.close() });
|
||||
} catch {
|
||||
// best-effort only — the passive repo.state.onDidChange listener still applies.
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const d of this.disposables) d.dispose();
|
||||
}
|
||||
}
|
||||
@@ -380,8 +380,13 @@ export class ProposalController implements vscode.Disposable {
|
||||
|
||||
/**
|
||||
* F12/#64 (INV-51): ACCEPT an optimistically-applied proposal — the text is
|
||||
* already in the buffer, so this only advances the F6 baseline (machine-landing,
|
||||
* via `attribution.signalLanded`) and clears the proposal. No re-application.
|
||||
* already in the buffer, so this only marks the attribution landing (via
|
||||
* `attribution.signalLanded`) and clears the proposal. No re-application.
|
||||
* Native-surfaces migration (INV-7/INV-18 retirement, spec §6.4): this no
|
||||
* longer advances the baseline — a landed proposal stays a visible change-
|
||||
* since-baseline until commit (head mode) or "Mark Changes as Reviewed"
|
||||
* (snapshot mode); nothing here subscribes to `signalLanded` for baseline
|
||||
* purposes anymore (the `onDidApplyAgentEdit → advance` wire was removed).
|
||||
*/
|
||||
async finalizeInPlace(docPath: string, proposalId: string): Promise<boolean> {
|
||||
const hit = this.byId(docPath, proposalId);
|
||||
|
||||
@@ -548,11 +548,14 @@ export interface RenderOptions {
|
||||
/** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */
|
||||
render?: (src: string) => string;
|
||||
/**
|
||||
* #48: the baseline was just PINNED (`reason === "pinned"`). With zero changes
|
||||
* since that pin, the on-render is fully clean — no authorship coloring — so a
|
||||
* pin reads as "this is my clean starting point". Distinct from a baseline
|
||||
* advanced by a machine-landing (accept), which keeps its authorship coloring
|
||||
* (F10 INV-33). Only consulted by `renderReview`.
|
||||
* #48: the baseline was just PINNED (`reason === "pinned"` — "Mark Changes as
|
||||
* Reviewed" in snapshot mode). With zero changes since that pin, the
|
||||
* on-render is fully clean — no authorship coloring — so a pin reads as
|
||||
* "this is my clean starting point". Distinct from an ordinary accepted
|
||||
* proposal, which — since the native-surfaces migration retired the
|
||||
* machine-landing baseline advance (INV-18/INV-7) — stays a visible change-
|
||||
* since-baseline and keeps its authorship coloring (F10 INV-33). Only
|
||||
* consulted by `renderReview`.
|
||||
*/
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
@@ -418,15 +418,15 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
}
|
||||
|
||||
private epochLabel(baseline: { reason: string; capturedAt: string } | undefined): string {
|
||||
if (!baseline) return "opened (no baseline yet)";
|
||||
if (!baseline) return "not coediting (no baseline yet)";
|
||||
const time = new Date(baseline.capturedAt).toLocaleTimeString();
|
||||
switch (baseline.reason) {
|
||||
case "machine-landing":
|
||||
return `Claude landed ${time}`;
|
||||
case "head":
|
||||
return `HEAD ${time}`;
|
||||
case "pinned":
|
||||
return `pinned ${time}`;
|
||||
return `reviewed ${time}`;
|
||||
default:
|
||||
return `opened ${time}`;
|
||||
return `entered ${time}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user