Native surfaces migration — evolve the extension onto VS Code's native review surfaces (9-task plan, spec v0.2.1) (#72)
This commit was merged in pull request #72.
This commit is contained in:
@@ -21,6 +21,7 @@ import { minimizeReplace, PendingEditRegistry } from "./pendingEdits";
|
||||
import type { VersionGuard } from "./versionGuard";
|
||||
import { isAuthorable } from "./workspacePath";
|
||||
import type { AuthorSpan } from "./trackChangesModel";
|
||||
import type { CoeditingRegistry } from "./coeditingRegistry";
|
||||
|
||||
/** Test-facing snapshot of live attribution state for a document. */
|
||||
export interface RenderedSpan {
|
||||
@@ -56,8 +57,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;
|
||||
@@ -66,12 +70,35 @@ export class AttributionController implements vscode.Disposable {
|
||||
private readonly store: SidecarRouter,
|
||||
private readonly rootDir: string | undefined,
|
||||
private readonly guard: VersionGuard,
|
||||
private readonly registry: CoeditingRegistry,
|
||||
) {
|
||||
this.disposables.push(this.statusItem, this.output, this.applyEmitter);
|
||||
this.disposables.push(
|
||||
vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
|
||||
vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)),
|
||||
vscode.window.onDidChangeActiveTextEditor(() => this.renderActive()),
|
||||
// PUC-7 restore-on-enter: `loadAll` is the only path that (re)populates
|
||||
// s.spans/s.orphans from the sidecar — without this, a document's FIRST
|
||||
// entry into coediting (registry.enter(), extension.ts) never runs it,
|
||||
// so pre-existing committed authorship never surfaces until the next
|
||||
// edit. Mirrors ThreadController's own registry subscription; `loadAll`
|
||||
// already gates on isCoediting, and exit intentionally does nothing here
|
||||
// (no data to wipe — EditorProposalController's own gate clears the
|
||||
// decorations that read spansFor). Registered before ProposalController/
|
||||
// EditorProposalController (construction order in extension.ts) so their
|
||||
// renders see fresh spans on the same enter transition.
|
||||
this.registry.onDidChange(({ uri, coediting }) => {
|
||||
if (!coediting) {
|
||||
// Finding 5 (final whole-branch review): exit hides the status-bar
|
||||
// item too — otherwise a stale "N orphaned attributions" warning
|
||||
// for a doc that's no longer even coediting stays pinned in the bar
|
||||
// until some unrelated render happens to fire for it.
|
||||
if (vscode.window.activeTextEditor?.document.uri.toString() === uri) this.statusItem.hide();
|
||||
return;
|
||||
}
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
|
||||
if (doc) this.loadAll(doc);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,6 +128,7 @@ export class AttributionController implements vscode.Disposable {
|
||||
/** Load the sidecar and re-resolve every attribution (live span | orphan). */
|
||||
loadAll(document: vscode.TextDocument): void {
|
||||
if (!this.isTracked(document)) return;
|
||||
if (!this.registry.isCoediting(document.uri)) return;
|
||||
const docPath = this.keyOf(document);
|
||||
const s = this.state(docPath);
|
||||
const artifact = this.store.load(docPath);
|
||||
@@ -142,6 +170,7 @@ export class AttributionController implements vscode.Disposable {
|
||||
|
||||
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
|
||||
if (!this.isTracked(e.document) || e.contentChanges.length === 0) return;
|
||||
if (!this.registry.isCoediting(e.document.uri)) return;
|
||||
const docPath = this.keyOf(e.document);
|
||||
if (!e.document.isDirty && this.matchesDisk(e.document)) {
|
||||
// Disk sync (revert / external reload): buffer now equals the file on
|
||||
@@ -291,9 +320,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 +332,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). */
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* CoeditingRegistry — the INV-10 activation gate (spec §6.4). Holds the set of
|
||||
* documents the writer has explicitly entered into coediting; every surface
|
||||
* (SCM/QuickDiff, comments, preview annotations, edit commands, decorations)
|
||||
* checks membership before attaching. Persisted in workspaceState so a reload
|
||||
* restores the set (the durable sidecar carries the artifacts; this carries
|
||||
* only the mode).
|
||||
*/
|
||||
import * as vscode from "vscode";
|
||||
|
||||
const STATE_KEY = "cowriting.coeditingSet";
|
||||
|
||||
export class CoeditingRegistry implements vscode.Disposable {
|
||||
private readonly set: Set<string>;
|
||||
private readonly emitter = new vscode.EventEmitter<{ uri: string; coediting: boolean }>();
|
||||
readonly onDidChange = this.emitter.event;
|
||||
/** Set by the SCM surface (Task 3) so syncContext can expose the baseline mode. */
|
||||
baselineModeOf: ((uri: vscode.Uri) => "head" | "snapshot" | undefined) | undefined;
|
||||
|
||||
constructor(private readonly state: vscode.Memento) {
|
||||
this.set = new Set(state.get<string[]>(STATE_KEY, []));
|
||||
}
|
||||
|
||||
enter(uri: vscode.Uri): void {
|
||||
const key = uri.toString();
|
||||
if (this.set.has(key)) return;
|
||||
this.set.add(key);
|
||||
void this.state.update(STATE_KEY, [...this.set]);
|
||||
this.emitter.fire({ uri: key, coediting: true });
|
||||
}
|
||||
|
||||
exit(uri: vscode.Uri): void {
|
||||
const key = uri.toString();
|
||||
if (!this.set.delete(key)) return;
|
||||
void this.state.update(STATE_KEY, [...this.set]);
|
||||
this.emitter.fire({ uri: key, coediting: false });
|
||||
}
|
||||
|
||||
isCoediting(uri: vscode.Uri): boolean {
|
||||
return this.set.has(uri.toString());
|
||||
}
|
||||
|
||||
list(): string[] {
|
||||
return [...this.set];
|
||||
}
|
||||
|
||||
syncContext(editor: vscode.TextEditor | undefined): void {
|
||||
const coediting = !!editor && this.isCoediting(editor.document.uri);
|
||||
void vscode.commands.executeCommand("setContext", "cowriting.isCoediting", coediting);
|
||||
const mode = coediting && editor ? this.baselineModeOf?.(editor.document.uri) : undefined;
|
||||
void vscode.commands.executeCommand("setContext", "cowriting.baselineMode", mode ?? "");
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.emitter.dispose();
|
||||
}
|
||||
}
|
||||
+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);
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* EditFlow — the host edit-turn flow (F11/F12), extracted from the review
|
||||
* webview controller ahead of its sunset (native-surfaces migration, spec
|
||||
* §6.10). Owns the low-level turn→proposal(s) cut (`runEditAndPropose`,
|
||||
* INV-39/40): never mutates the document directly — every turn lands as one or
|
||||
* more F4 proposals via `ProposalController.propose` (INV-10). Reachable, from
|
||||
* Task 6 on, from the thread controller (`ThreadController.runMakeEdit`) —
|
||||
* vscode-API-only, no webview state. The `cowriting.editDocument` command lives
|
||||
* in extension.ts (Finding-1 fix) so it can hand off to
|
||||
* `ThreadController.askClaude()`. Task 8 deleted this class's own
|
||||
* `askClaude`/`askEditInstruction` (the review webview's toolbar ask) once
|
||||
* their only caller — the webview — was sunset alongside them.
|
||||
*/
|
||||
import * as vscode from "vscode";
|
||||
import type { ProposalController } from "./proposalController";
|
||||
import { diffToBlockHunks } from "./trackChangesModel";
|
||||
import { buildFingerprint } from "./anchorer";
|
||||
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
|
||||
|
||||
/**
|
||||
* F11: a host edit turn (selection/document text + instruction → rewrite).
|
||||
* Injectable for tests. #60: accepts optional turn options (onProgress/signal);
|
||||
* the arg is optional so existing test stubs that ignore it stay valid.
|
||||
*/
|
||||
type EditTurn = (instruction: string, text: string, opts?: RunEditTurnOptions) => Promise<EditTurnResult>;
|
||||
/** F11: what an Ask-Claude gesture edits — a resolved selection range, or the whole document. */
|
||||
export type EditTarget = { kind: "range"; start: number; end: number } | { kind: "document" };
|
||||
|
||||
export class EditFlow implements vscode.Disposable {
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
/**
|
||||
* F11: the host edit turn (INV-8 — runs host-side, @cline/sdk loaded lazily and
|
||||
* never bundled). Injectable so host E2E can stub it (no LLM in CI).
|
||||
*/
|
||||
private editTurn: EditTurn = async (instruction, text, opts) => {
|
||||
const { runEditTurn } = await import("./liveTurn");
|
||||
return runEditTurn(instruction, text, opts);
|
||||
};
|
||||
/** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */
|
||||
private turnSeq = 0;
|
||||
private nextTurnSeq(): number {
|
||||
return ++this.turnSeq;
|
||||
}
|
||||
|
||||
constructor(private readonly proposals: ProposalController) {}
|
||||
|
||||
/**
|
||||
* F11/F12 (INV-35/39): run one host edit turn and record the result as F4
|
||||
* proposal(s) — a SELECTION yields one single-range proposal over the resolved
|
||||
* block-union; a DOCUMENT rewrite is `diffToBlockHunks`'d into one proposal per
|
||||
* changed BLOCK (#47, INV-39 supersedes INV-37's per-word cut), each tagged
|
||||
* `granularity:"block"` so accept reconciles attribution per word (INV-40).
|
||||
* Never mutates the document (INV-10). Returns the created proposal ids.
|
||||
*/
|
||||
async runEditAndPropose(
|
||||
document: vscode.TextDocument,
|
||||
target: EditTarget,
|
||||
instruction: string,
|
||||
opts?: RunEditTurnOptions,
|
||||
): Promise<string[]> {
|
||||
const full = document.getText();
|
||||
// One turnId per gesture — the document case's N hunk-proposals all share it,
|
||||
// so a single rewrite groups as one agent turn (parity with editSelection).
|
||||
const turnId = `turn-${this.nextTurnSeq()}`;
|
||||
const provenance = (turn: EditTurnResult) =>
|
||||
({ kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId } });
|
||||
if (target.kind === "range") {
|
||||
const selected = full.slice(target.start, target.end);
|
||||
const turn = await this.editTurn(instruction, selected, opts);
|
||||
if (turn.replacement === "" || turn.replacement === selected) return [];
|
||||
const fp = buildFingerprint(full, { start: target.start, end: target.end });
|
||||
const id = await this.proposals.propose(document, fp, turn.replacement, provenance(turn), { turnId, instruction });
|
||||
return id ? [id] : [];
|
||||
}
|
||||
const turn = await this.editTurn(instruction, full, opts);
|
||||
const ids: string[] = [];
|
||||
// #47 (INV-39, supersedes INV-37): a document rewrite is cut at BLOCK
|
||||
// granularity — one proposal per changed block (the unit a human reviews) —
|
||||
// not per word. Each is tagged `granularity:"block"` so accept reconciles
|
||||
// attribution per word inside the block (INV-40).
|
||||
for (const h of diffToBlockHunks(full, turn.replacement)) {
|
||||
const fp = buildFingerprint(full, { start: h.start, end: h.end });
|
||||
const id = await this.proposals.propose(document, fp, h.replacement, provenance(turn), {
|
||||
turnId,
|
||||
instruction,
|
||||
granularity: "block",
|
||||
});
|
||||
if (id) ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// ---- test seam (§6.4) ----
|
||||
/** F11 test seam: stub the host edit turn so the document/selection paths run without an LLM. */
|
||||
setEditTurnForTest(fn: EditTurn): void {
|
||||
this.editTurn = fn;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const d of this.disposables) d.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import * as vscode from "vscode";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
/**
|
||||
* The multi-line instruction input for "Ask Claude to Edit" — BOTH the selection
|
||||
* and the whole-document case. A small focused webview (a tall, resizable
|
||||
* textarea + Send) opened in a split pane BELOW the document — not a comment
|
||||
* thread, and not the top QuickInput (which is single-line only; VS Code has no
|
||||
* multi-line input at the command-palette location). For a selection edit the
|
||||
* document stays in the pane above with the selection still highlighted (VS
|
||||
* Code's inactive-selection style), so the user can see exactly what Claude will
|
||||
* edit; the caller has already captured the selection, so we never touch it.
|
||||
*
|
||||
* The split collapses when the input is submitted or cancelled, and focus is
|
||||
* handed back to the document so the collapsing split doesn't reveal the bottom
|
||||
* panel. `header` names the scope ("Ask Claude to Edit This Selection" /
|
||||
* "…This Document").
|
||||
*
|
||||
* The webview only collects text and posts it to the host — no SDK or secret
|
||||
* surface lives in it (INV-8/35); the sealed CSP allows no network. Because a
|
||||
* webview CAN read its own textarea, Cancel / Escape confirms ONLY when there is
|
||||
* text to lose (closing the tab is an explicit dismiss, no prompt). Resolves with
|
||||
* the typed instruction, or `undefined` if cancelled / closed / left empty.
|
||||
*/
|
||||
export async function promptEditInstruction(header: string): Promise<string | undefined> {
|
||||
// Remember the document editor we came from so we can hand focus back to it
|
||||
// when the input closes — otherwise, when the empty split group below collapses,
|
||||
// focus falls into the bottom panel (Output / Debug Console / …) and it pops
|
||||
// open. Restoring editor focus leaves whatever panel state the user had untouched.
|
||||
const source = vscode.window.activeTextEditor;
|
||||
// Open a split editor group BELOW the current one and host the input there, so
|
||||
// it sits under the document instead of covering it as a tab. The new group is
|
||||
// empty, so disposing the panel on submit/cancel leaves it empty and VS Code
|
||||
// collapses the split (the `workbench.editor.closeEmptyGroups` default). Falls
|
||||
// back to a tab in the active group if the split command is unavailable.
|
||||
try {
|
||||
await vscode.commands.executeCommand("workbench.action.newGroupBelow");
|
||||
} catch {
|
||||
/* no split — the panel opens as a tab in the active group instead */
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const panel = vscode.window.createWebviewPanel(
|
||||
"cowriting.askClaudeInput",
|
||||
header,
|
||||
{ viewColumn: vscode.ViewColumn.Active, preserveFocus: false },
|
||||
{ enableScripts: true, retainContextWhenHidden: false },
|
||||
);
|
||||
|
||||
let settled = false;
|
||||
const done = (value: string | undefined): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(value);
|
||||
panel.dispose();
|
||||
// Hand focus back to the originating document so the collapsing split
|
||||
// doesn't leave focus in (and reveal) the bottom panel.
|
||||
if (source) {
|
||||
void vscode.window.showTextDocument(source.document, {
|
||||
viewColumn: source.viewColumn ?? vscode.ViewColumn.One,
|
||||
preserveFocus: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
panel.webview.onDidReceiveMessage((m: { type?: string; text?: string }) => {
|
||||
const text = (m?.text ?? "").trim();
|
||||
if (m?.type === "submit") {
|
||||
done(text ? text : undefined);
|
||||
} else if (m?.type === "cancel") {
|
||||
// Confirm only if there's something to lose (we can read the textarea here).
|
||||
if (!text) {
|
||||
done(undefined);
|
||||
return;
|
||||
}
|
||||
void vscode.window
|
||||
.showWarningMessage("Discard your Ask-Claude instruction?", { modal: true }, "Discard")
|
||||
.then((pick) => {
|
||||
if (pick === "Discard") done(undefined);
|
||||
// else: leave the panel open so the operator can keep editing.
|
||||
});
|
||||
}
|
||||
});
|
||||
// Closing the tab is an explicit dismiss — cancel without a prompt.
|
||||
panel.onDidDispose(() => done(undefined));
|
||||
panel.webview.html = htmlFor(header);
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!);
|
||||
}
|
||||
|
||||
function htmlFor(header: string): string {
|
||||
const nonce = randomBytes(16).toString("base64");
|
||||
// Sealed CSP: no network; inline style only; the one script is nonce-gated.
|
||||
const csp = `default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';`;
|
||||
const title = escapeHtml(header);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="${csp}" />
|
||||
<title>${title}</title>
|
||||
<style>
|
||||
body { padding: 14px 16px; font-family: var(--vscode-font-family); color: var(--vscode-foreground); }
|
||||
h2 { font-size: 13px; font-weight: 600; margin: 0 0 10px; }
|
||||
textarea {
|
||||
width: 100%; min-height: 160px; resize: vertical; box-sizing: border-box;
|
||||
background: var(--vscode-input-background); color: var(--vscode-input-foreground);
|
||||
border: 1px solid var(--vscode-input-border, transparent); border-radius: 4px; padding: 8px;
|
||||
font-family: var(--vscode-editor-font-family); font-size: var(--vscode-editor-font-size); line-height: 1.4;
|
||||
}
|
||||
textarea::placeholder { color: var(--vscode-input-placeholderForeground); }
|
||||
textarea:focus { outline: 1px solid var(--vscode-focusBorder); border-color: var(--vscode-focusBorder); }
|
||||
.row { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; gap: 12px; }
|
||||
.hint { color: var(--vscode-descriptionForeground); font-size: 12px; }
|
||||
button {
|
||||
background: var(--vscode-button-background); color: var(--vscode-button-foreground);
|
||||
border: none; padding: 6px 16px; border-radius: 4px; cursor: pointer; font-size: 13px;
|
||||
}
|
||||
button:hover { background: var(--vscode-button-hoverBackground); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>${title}</h2>
|
||||
<textarea id="inst" placeholder="e.g. tighten the intro, add a conclusion, and fix the heading levels" autofocus></textarea>
|
||||
<div class="row">
|
||||
<span class="hint">⌘↵ / Ctrl+↵ to send · Esc to cancel</span>
|
||||
<button id="send">Send to Claude</button>
|
||||
</div>
|
||||
<script nonce="${nonce}">
|
||||
const api = acquireVsCodeApi();
|
||||
const ta = document.getElementById('inst');
|
||||
const focus = () => ta.focus();
|
||||
focus();
|
||||
window.addEventListener('focus', focus);
|
||||
const submit = () => api.postMessage({ type: 'submit', text: ta.value });
|
||||
const cancel = () => api.postMessage({ type: 'cancel', text: ta.value });
|
||||
document.getElementById('send').addEventListener('click', submit);
|
||||
ta.addEventListener('keydown', (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); submit(); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); cancel(); }
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import type { DiffViewController } from "./diffViewController";
|
||||
import type { AttributionController } from "./attributionController";
|
||||
import { decorationPlan, wordEditHunks, authorAt } from "./trackChangesModel";
|
||||
import { isAuthorable } from "./workspacePath";
|
||||
import type { CoeditingRegistry } from "./coeditingRegistry";
|
||||
|
||||
export class EditorProposalController implements vscode.Disposable, vscode.CodeLensProvider {
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
@@ -51,13 +52,14 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
|
||||
private readonly proposals: ProposalController,
|
||||
private readonly diffView: DiffViewController,
|
||||
private readonly attribution: AttributionController,
|
||||
private readonly registry: CoeditingRegistry,
|
||||
) {
|
||||
this.disposables.push(
|
||||
this.insDeco.human, this.insDeco.claude, this.delDeco.human, this.delDeco.claude, this.delDeco.none, this.lensEmitter,
|
||||
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);
|
||||
@@ -67,6 +69,13 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
|
||||
const ed = vscode.window.activeTextEditor;
|
||||
if (ed && e.document === ed.document) this.scheduleRender(ed);
|
||||
}),
|
||||
// INV-10: re-render (and refresh CodeLens) on every gate change — exit clears
|
||||
// decorations/CodeLens via renderEditor's own gate check, re-entry redraws.
|
||||
this.registry.onDidChange(({ uri }) => {
|
||||
const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri);
|
||||
if (ed) this.renderEditor(ed);
|
||||
this.lensEmitter.fire();
|
||||
}),
|
||||
// the four QuickPick-backed menu commands
|
||||
vscode.commands.registerCommand("cowriting.proposalAcceptMenu", (id?: string) => this.menu("accept", id)),
|
||||
vscode.commands.registerCommand("cowriting.proposalRejectMenu", (id?: string) => this.menu("reject", id)),
|
||||
@@ -106,6 +115,9 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
|
||||
private async onProposalsChanged(uri: string): Promise<void> {
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
|
||||
if (!doc || doc.languageId !== "markdown" || !isAuthorable(doc.uri.scheme)) return;
|
||||
// INV-10: a doc that exited coediting between the schedule and this tick gets
|
||||
// no optimistic-apply/decorations/CodeLens.
|
||||
if (!this.registry.isCoediting(doc.uri)) return;
|
||||
const key = this.proposals.keyFor(doc);
|
||||
for (const v of this.proposals.listProposals(doc)) {
|
||||
if (v.anchorStart !== null && !this.proposals.isApplied(key, v.id)) {
|
||||
@@ -125,6 +137,8 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
|
||||
for (const a of ["human", "claude", "none"] as const) editor.setDecorations(this.delDeco[a], []);
|
||||
};
|
||||
if (doc.languageId !== "markdown") return clearAll();
|
||||
// INV-10: a non-entered doc gets no decorations at all.
|
||||
if (!this.registry.isCoediting(doc.uri)) return clearAll();
|
||||
const key = this.proposals.keyFor(doc);
|
||||
const ins: Record<"human" | "claude", vscode.Range[]> = { human: [], claude: [] };
|
||||
const del: Record<"human" | "claude" | "none", vscode.DecorationOptions[]> = { human: [], claude: [], none: [] };
|
||||
@@ -202,18 +216,33 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
|
||||
}
|
||||
}
|
||||
|
||||
/** CodeLensProvider: a `Accept ▾` / `Reject ▾` pair above each applied block. */
|
||||
/**
|
||||
* CodeLensProvider (Task 8, PUC-2 wording): a `✓ Keep` / `✗ Reject` pair above
|
||||
* each applied block, plus — when ≥2 proposals are pending on the doc — a
|
||||
* top-of-file `✓ Keep all (N)` / `✗ Reject all` pair routed directly at the
|
||||
* batch commands (no QuickPick detour for the common "review is done" case).
|
||||
*/
|
||||
provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] {
|
||||
if (document.languageId !== "markdown") return [];
|
||||
if (!this.registry.isCoediting(document.uri)) return [];
|
||||
const key = this.proposals.keyFor(document);
|
||||
const applied = this.proposals
|
||||
.listProposals(document)
|
||||
.filter((v) => v.anchorStart !== null && this.proposals.isApplied(key, v.id));
|
||||
const lenses: vscode.CodeLens[] = [];
|
||||
for (const v of this.proposals.listProposals(document)) {
|
||||
if (v.anchorStart === null || !this.proposals.isApplied(key, v.id)) continue;
|
||||
const pos = document.positionAt(v.anchorStart);
|
||||
if (applied.length >= 2) {
|
||||
const top = new vscode.Range(0, 0, 0, 0);
|
||||
lenses.push(
|
||||
new vscode.CodeLens(top, { title: `✓ Keep all (${applied.length})`, command: "cowriting.acceptAllProposals" }),
|
||||
new vscode.CodeLens(top, { title: "✗ Reject all", command: "cowriting.rejectAllProposals" }),
|
||||
);
|
||||
}
|
||||
for (const v of applied) {
|
||||
const pos = document.positionAt(v.anchorStart!);
|
||||
const line = new vscode.Range(pos.line, 0, pos.line, 0);
|
||||
lenses.push(
|
||||
new vscode.CodeLens(line, { title: "Accept ▾", command: "cowriting.proposalAcceptMenu", arguments: [v.id] }),
|
||||
new vscode.CodeLens(line, { title: "Reject ▾", command: "cowriting.proposalRejectMenu", arguments: [v.id] }),
|
||||
new vscode.CodeLens(line, { title: "✓ Keep", command: "cowriting.proposalAcceptMenu", arguments: [v.id] }),
|
||||
new vscode.CodeLens(line, { title: "✗ Reject", command: "cowriting.proposalRejectMenu", arguments: [v.id] }),
|
||||
);
|
||||
}
|
||||
return lenses;
|
||||
|
||||
+269
-141
@@ -10,10 +10,15 @@ import { BaselineStore } from "./baselineStore";
|
||||
import { GlobalSidecarStore } from "./globalSidecarStore";
|
||||
import { SidecarRouter } from "./sidecarRouter";
|
||||
import { DiffViewController } from "./diffViewController";
|
||||
import { TrackChangesPreviewController } from "./trackChangesPreview";
|
||||
import { GitBaselineAdapter } from "./gitBaseline";
|
||||
import { EditFlow } from "./editFlow";
|
||||
import { LiveProgressUi } from "./liveProgressUi";
|
||||
import { EditorProposalController } from "./editorProposalController";
|
||||
import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath";
|
||||
import { isAuthorable, routeEdit } from "./workspacePath";
|
||||
import { CoeditingRegistry } from "./coeditingRegistry";
|
||||
import { ScmSurfaceController } from "./scmSurface";
|
||||
import type MarkdownIt from "markdown-it";
|
||||
import { cowritingMarkdownItPlugin, type AnnotationInputs } from "./previewAnnotations";
|
||||
|
||||
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
||||
|
||||
@@ -23,10 +28,19 @@ export interface CowritingApi {
|
||||
proposalController: ProposalController;
|
||||
versionGuard: VersionGuard;
|
||||
diffViewController: DiffViewController;
|
||||
trackChangesPreviewController: TrackChangesPreviewController;
|
||||
editFlow: EditFlow;
|
||||
sidecarRouter: SidecarRouter;
|
||||
liveProgressUi: LiveProgressUi;
|
||||
editorProposalController: EditorProposalController;
|
||||
coeditingRegistry: CoeditingRegistry;
|
||||
scmSurfaceController: ScmSurfaceController;
|
||||
/** Task 7 test seam: the REAL production `inputsFor`, so E2E exercises the
|
||||
* actual registry/diffView/attribution/proposals/config wiring (not a
|
||||
* hand-rolled reimplementation of it). */
|
||||
previewAnnotationHost: { inputsFor(env: unknown): AnnotationInputs | undefined };
|
||||
/** Task 7 (D3/D21): the markdown-it plugin factory VS Code's built-in preview
|
||||
* loads via `markdown.markdownItPlugins` (package.json). */
|
||||
extendMarkdownIt: (md: unknown) => unknown;
|
||||
}
|
||||
|
||||
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
|
||||
@@ -59,19 +73,66 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
}),
|
||||
);
|
||||
|
||||
// --- 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.
|
||||
// --- PUC-7 (native-surfaces migration): the INV-10 opt-in gate ---
|
||||
// Every later native surface (SCM/QuickDiff, comments, preview annotations,
|
||||
// edit commands, decorations) checks isCoediting before attaching. Persisted
|
||||
// in workspaceState so a reload restores the set.
|
||||
const coeditingRegistry = new CoeditingRegistry(context.workspaceState);
|
||||
context.subscriptions.push(coeditingRegistry);
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cowriting.coeditDocument", () => {
|
||||
const ed = vscode.window.activeTextEditor;
|
||||
if (!ed || ed.document.languageId !== "markdown" || !isAuthorable(ed.document.uri.scheme)) {
|
||||
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to coedit it.");
|
||||
return;
|
||||
}
|
||||
coeditingRegistry.enter(ed.document.uri);
|
||||
coeditingRegistry.syncContext(ed);
|
||||
}),
|
||||
vscode.commands.registerCommand("cowriting.stopCoediting", () => {
|
||||
const ed = vscode.window.activeTextEditor;
|
||||
if (!ed) return;
|
||||
coeditingRegistry.exit(ed.document.uri);
|
||||
coeditingRegistry.syncContext(ed);
|
||||
}),
|
||||
vscode.window.onDidChangeActiveTextEditor((ed) => coeditingRegistry.syncContext(ed)),
|
||||
);
|
||||
coeditingRegistry.syncContext(vscode.window.activeTextEditor);
|
||||
|
||||
// --- 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);
|
||||
|
||||
// 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
|
||||
@@ -92,39 +153,51 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
// F5 (INV-16): one shared guard — newer-major sidecars are read-only, one
|
||||
// warning per doc across the three co-owning controllers.
|
||||
const versionGuard = new VersionGuard(sidecarRouter);
|
||||
const threadController = new ThreadController(sidecarRouter, root, versionGuard);
|
||||
context.subscriptions.push(threadController);
|
||||
|
||||
// --- F3: live attribution (Feature #6) ---
|
||||
const attributionController = new AttributionController(sidecarRouter, root, versionGuard);
|
||||
const attributionController = new AttributionController(sidecarRouter, root, versionGuard, coeditingRegistry);
|
||||
context.subscriptions.push(attributionController);
|
||||
|
||||
// --- F4: propose/accept (Feature #12) — constructed before the preview so F10
|
||||
// can route ✓/✗ through it ---
|
||||
const proposalController = new ProposalController(sidecarRouter, attributionController, root, versionGuard);
|
||||
// --- F4: propose/accept (Feature #12) ---
|
||||
const proposalController = new ProposalController(
|
||||
sidecarRouter,
|
||||
attributionController,
|
||||
root,
|
||||
versionGuard,
|
||||
coeditingRegistry,
|
||||
);
|
||||
context.subscriptions.push(proposalController);
|
||||
|
||||
// --- F7/F10: the review preview is the single interactive review surface ---
|
||||
// Workspace-INDEPENDENT (works on any markdown doc, reuses the F6 baseline,
|
||||
// INV-20). Constructed AFTER attribution (reads F3 spans) and proposals (routes
|
||||
// F4 accept/reject from the webview ✓/✗).
|
||||
const trackChangesPreviewController = new TrackChangesPreviewController(
|
||||
diffViewController,
|
||||
context.extensionUri,
|
||||
attributionController,
|
||||
proposalController,
|
||||
liveProgressUi,
|
||||
);
|
||||
context.subscriptions.push(trackChangesPreviewController);
|
||||
// --- F11/F12 (native-surfaces migration, spec §6.10): the edit flow — the
|
||||
// `cowriting.editDocument` command, the instruction-prompt/progress-wrapped
|
||||
// "ask Claude" gesture (INV-10 gate), and the turn→proposal(s) cut
|
||||
// (`runEditAndPropose`, INV-39/40). Constructed BEFORE the thread controller,
|
||||
// which consumes it too (Task 6: the comment-loop's "make this edit" offer
|
||||
// calls runEditAndPropose). ---
|
||||
const editFlow = new EditFlow(proposalController);
|
||||
context.subscriptions.push(editFlow);
|
||||
|
||||
// --- F2/F10 (Task 6, D19/D10/D8): comments-first ask + the comment→reply→
|
||||
// offer→proposal loop. Consumes editFlow (offer → pending proposals) and
|
||||
// liveProgressUi (the shared "asking Claude…" notification + output channel). ---
|
||||
const threadController = new ThreadController(sidecarRouter, root, versionGuard, coeditingRegistry, editFlow, liveProgressUi);
|
||||
context.subscriptions.push(threadController);
|
||||
|
||||
// --- F12 (#64): the editor surface — optimistic-apply proposals into the buffer,
|
||||
// decorate the diff, and provide Accept ▾/Reject ▾ CodeLens (INV-48/49/52/53). ---
|
||||
const editorProposalController = new EditorProposalController(proposalController, diffViewController, attributionController);
|
||||
const editorProposalController = new EditorProposalController(
|
||||
proposalController,
|
||||
diffViewController,
|
||||
attributionController,
|
||||
coeditingRegistry,
|
||||
);
|
||||
context.subscriptions.push(editorProposalController);
|
||||
|
||||
// #46 (INV-42): accept every pending proposal on the active doc in one gesture
|
||||
// (also reachable from the preview toolbar's "Accept all" button). Reuses the
|
||||
// batched F4 seam + reports applied-vs-skipped.
|
||||
// (also reachable from the top-of-file "Keep all (N)" CodeLens, Task 8 PUC-2).
|
||||
// Reuses the batched F4 seam + reports applied-vs-skipped, routing directly to
|
||||
// ProposalController (native-surfaces migration: no preview-controller
|
||||
// indirection).
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cowriting.acceptAllProposals", async () => {
|
||||
const doc = vscode.window.activeTextEditor?.document;
|
||||
@@ -132,11 +205,17 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to accept its proposals.");
|
||||
return;
|
||||
}
|
||||
await trackChangesPreviewController.acceptAll(doc);
|
||||
const { applied, skipped } = await proposalController.acceptAllProposals(doc);
|
||||
if (applied === 0 && skipped === 0) return;
|
||||
const skipNote = skipped > 0 ? `, ${skipped} skipped (target text changed — undo or reject)` : "";
|
||||
void vscode.window.showInformationMessage(
|
||||
`Cowriting: accepted ${applied} proposal${applied === 1 ? "" : "s"}${skipNote}.`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
// #64 (INV-53): reject every pending proposal on the active doc in one gesture.
|
||||
// #64 (INV-53): reject every pending proposal on the active doc in one gesture
|
||||
// (native-surfaces migration: routes directly to ProposalController).
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cowriting.rejectAllProposals", async () => {
|
||||
const doc = vscode.window.activeTextEditor?.document;
|
||||
@@ -144,16 +223,21 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to reject its proposals.");
|
||||
return;
|
||||
}
|
||||
await trackChangesPreviewController.rejectAll(doc);
|
||||
const { reverted } = await proposalController.rejectAll(doc);
|
||||
if (reverted > 0) {
|
||||
void vscode.window.showInformationMessage(
|
||||
`Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}.`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// --- 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 —
|
||||
@@ -216,114 +300,58 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
),
|
||||
);
|
||||
|
||||
// F3 SLICE-5 → F4 SLICE-4: the live turn — selection + instruction → one
|
||||
// claude-code SDK turn (liveTurn.ts, INV-8) → a PENDING PROPOSAL (F4,
|
||||
// INV-10); the applyAgentEdit seam (INV-9) now fires only on accept.
|
||||
// Task 6 (D19): the comments-first ask replaces the old inline turn
|
||||
// plumbing here — the ask now IS a comment. threadController.askClaude()
|
||||
// opens a focused comment box on the current selection (or, with no
|
||||
// selection, a top-anchored whole-document thread); the comment→reply→
|
||||
// offer→proposal loop (ThreadController.respondInThread/makeThreadEdit)
|
||||
// takes it from there. Kept as its own command (rather than folded into
|
||||
// cowriting.edit) for the host E2E harness and the internal seams.
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cowriting.editSelection", async () => {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
// F8: authoring works on any file: or untitled: doc (the router decides
|
||||
// where its artifact is stored). Each failure still names its real reason
|
||||
// (no editor / no selection / a non-{file,untitled} read-only view) — not
|
||||
// always "select some text" (#24's per-condition messaging).
|
||||
const reason = selectionRejection({
|
||||
hasEditor: !!editor,
|
||||
selectionEmpty: editor?.selection.isEmpty ?? true,
|
||||
scheme: editor?.document.uri.scheme ?? "",
|
||||
});
|
||||
if (reason) {
|
||||
void vscode.window.showWarningMessage(reason);
|
||||
await threadController.askClaude();
|
||||
}),
|
||||
);
|
||||
|
||||
// Code-review fix (Finding 1, session native-surfaces-exec): `cowriting.editDocument`
|
||||
// used to delegate to `EditFlow.askClaude(doc, {kind:"document"})`, which prompted via
|
||||
// `askEditInstruction` — a rejecting stub after Task 6 sunset the input webview
|
||||
// (spec §6.10; both `askClaude`/`askEditInstruction` were deleted from EditFlow in
|
||||
// Task 8 once their only caller, the review webview, died too). Reached with no
|
||||
// selection (the common case for `cowriting.edit`), that was a SILENT dead end: the
|
||||
// reject fired before EditFlow.askClaude's own try/catch and was `void`'d, so no
|
||||
// toast, no thread, nothing. This command now resolves
|
||||
// its target document exactly as before (a tab's clicked URI, falling back to the
|
||||
// active editor), focuses it, and hands off to the SAME comments-first ask as
|
||||
// `cowriting.editSelection` — `ThreadController.askClaude()` top-anchors a
|
||||
// whole-document ask when the (now-focused) editor's selection is empty (D19). Kept
|
||||
// registered + hidden from the palette for the host E2E harness and #42's
|
||||
// tab-targeting behavior; `EditFlow.runEditAndPropose` (untouched) stays the E2E seam
|
||||
// for driving a turn without the native comment UI — see f12Reach.test.ts /
|
||||
// f11Toolbar.test.ts / f12Accept.test.ts / f12Review.test.ts.
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cowriting.editDocument", async (uri?: vscode.Uri) => {
|
||||
const doc = uri
|
||||
? vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString()) ??
|
||||
(await vscode.workspace.openTextDocument(uri))
|
||||
: vscode.window.activeTextEditor?.document;
|
||||
if (!doc || doc.languageId !== "markdown") {
|
||||
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to ask Claude to edit it.");
|
||||
return;
|
||||
}
|
||||
if (!editor) return; // unreachable once reason is null, but narrows the type
|
||||
const document = editor.document;
|
||||
const selection = editor.selection; // non-empty (selectionRejection guaranteed it)
|
||||
// Capture the selection text + anchor BEFORE prompting — the inline prompt
|
||||
// moves the cursor to the document top (where its box anchors), which would
|
||||
// otherwise collapse the selection we act on (spec §6.5 PUC-1: anchor
|
||||
// captured pre-turn so mid-turn edits can't skew it).
|
||||
const selectedText = document.getText(selection);
|
||||
const fp = buildFingerprint(document.getText(), {
|
||||
start: document.offsetAt(selection.start),
|
||||
end: document.offsetAt(selection.end),
|
||||
});
|
||||
// The instruction prompt is the multi-line split-below webview box (shared
|
||||
// with the document case, via the preview controller); the document above
|
||||
// keeps the selection highlighted while it's open. selectedText/fp were
|
||||
// captured above, so moving focus to the box doesn't affect what we edit.
|
||||
const instruction = await trackChangesPreviewController.askEditInstruction(
|
||||
"Ask Claude to Edit This Selection",
|
||||
);
|
||||
if (!instruction) return;
|
||||
const turnId = `turn-${Date.now().toString(36)}`;
|
||||
try {
|
||||
await vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: "Cowriting: asking Claude…",
|
||||
cancellable: true,
|
||||
},
|
||||
async (progress, token) => {
|
||||
const { runEditTurn } = await import("./liveTurn");
|
||||
const ui = liveProgressUi.begin(instruction, progress, token);
|
||||
let turn;
|
||||
try {
|
||||
turn = await runEditTurn(instruction, selectedText, {
|
||||
onProgress: ui.onProgress,
|
||||
signal: ui.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
// #60 (INV-47): a user cancel surfaces as "cancelled", not a failure.
|
||||
if (token.isCancellationRequested) {
|
||||
void vscode.window.showInformationMessage("Cowriting: Claude edit cancelled.");
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (turn.replacement === "") {
|
||||
void vscode.window.showWarningMessage(
|
||||
"Cowriting: Claude returned an empty replacement — nothing was proposed.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (turn.replacement === selectedText) {
|
||||
void vscode.window.showInformationMessage(
|
||||
"Cowriting: Claude proposed no change to the selection.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// F4 (INV-10): the turn ends in a PROPOSAL, not a buffer mutation.
|
||||
// The seam now fires only on accept (ProposalController, INV-9).
|
||||
const id = await proposalController.propose(
|
||||
document,
|
||||
fp,
|
||||
turn.replacement,
|
||||
{
|
||||
kind: "agent",
|
||||
id: "claude",
|
||||
agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId },
|
||||
},
|
||||
{ turnId, instruction },
|
||||
);
|
||||
if (id) {
|
||||
void vscode.window.showInformationMessage(
|
||||
"Cowriting: Claude proposed an edit — review the diff at the highlighted range (✓ accept / ✗ reject).",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
void vscode.window.showErrorMessage(`Cowriting: Claude edit failed — ${message}`);
|
||||
if (vscode.window.activeTextEditor?.document.uri.toString() !== doc.uri.toString()) {
|
||||
await vscode.window.showTextDocument(doc);
|
||||
}
|
||||
await threadController.askClaude();
|
||||
}),
|
||||
);
|
||||
|
||||
// The single user-facing "Ask Claude to Edit" gesture (one command, one
|
||||
// keybinding, one menu entry). It routes to the selection flow (editSelection)
|
||||
// or the whole-document flow (editDocument) by selection/context — see
|
||||
// routeEdit. The two underlying commands stay registered for the host E2E
|
||||
// harness and the internal seams, but are hidden from the palette.
|
||||
// routeEdit. Both underlying commands now end at ThreadController.askClaude()
|
||||
// (comments-first ask, D19); they stay registered for the host E2E harness and
|
||||
// the internal seams, but are hidden from the palette.
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cowriting.edit", async (uri?: vscode.Uri) => {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
@@ -341,9 +369,47 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
}),
|
||||
);
|
||||
|
||||
// Task 8 (native-surfaces migration, spec §6.10): the minimal gateway replacing
|
||||
// the sunset review-webview's #41 right-click entries. Resolves the clicked
|
||||
// doc (or falls back to the active editor, mirroring editDocument/#41), enters
|
||||
// coediting if not already (so a fresh doc gets a baseline to review against —
|
||||
// no-op if already entered or not authorable), then hands off to VS Code's
|
||||
// OWN "Open Preview to the Side" — the built-in preview IS the review surface
|
||||
// now (Task 7's annotations render inside it).
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cowriting.openReviewPreview", async (uri?: vscode.Uri) => {
|
||||
const doc = uri
|
||||
? vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString()) ??
|
||||
(await vscode.workspace.openTextDocument(uri))
|
||||
: vscode.window.activeTextEditor?.document;
|
||||
if (!doc || doc.languageId !== "markdown") {
|
||||
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to review it.");
|
||||
return;
|
||||
}
|
||||
if (vscode.window.activeTextEditor?.document.uri.toString() !== doc.uri.toString()) {
|
||||
await vscode.window.showTextDocument(doc);
|
||||
}
|
||||
if (!coeditingRegistry.isCoediting(doc.uri) && isAuthorable(doc.uri.scheme)) {
|
||||
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||
}
|
||||
await vscode.commands.executeCommand("markdown.showPreviewToSide", doc.uri);
|
||||
}),
|
||||
);
|
||||
|
||||
// Render threads + attributions for already-open editors, and on future opens.
|
||||
// INV-10: gated on the registry too — belt-and-suspenders with the per-controller
|
||||
// gates (renderAll/loadAll already early-return), but keeps the intent explicit
|
||||
// at the call site and avoids the no-op calls for a non-entered doc.
|
||||
const renderIfOpen = (doc: vscode.TextDocument) => {
|
||||
if (isAuthorable(doc.uri.scheme)) {
|
||||
if (isAuthorable(doc.uri.scheme) && coeditingRegistry.isCoediting(doc.uri)) {
|
||||
// Finding 3 (final whole-branch review): DiffViewController only
|
||||
// establishes a baseline on the registry's enter TRANSITION and, at
|
||||
// startup, for documents already open. A doc that is a PERSISTED
|
||||
// coediting member but wasn't open yet at either of those moments (e.g.
|
||||
// lazily restored after a window reload) reaches this handler with no
|
||||
// baseline/mode — establish it now so QuickDiff/Review-Changes/Mark-
|
||||
// Reviewed never see a silent empty-baseline gap.
|
||||
if (diffViewController.modeOf(doc.uri.toString()) === undefined) void diffViewController.establish(doc);
|
||||
threadController.renderAll(doc);
|
||||
attributionController.loadAll(doc);
|
||||
proposalController.renderAll(doc);
|
||||
@@ -352,16 +418,78 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
||||
vscode.workspace.textDocuments.forEach(renderIfOpen);
|
||||
context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen));
|
||||
|
||||
// --- Task 7 (D3/D21, PUC-3): annotations in the BUILT-IN Markdown preview ---
|
||||
// `env.currentDocument` (VS Code's markdown-it render env) is an OBSERVED
|
||||
// preview-engine behavior, not a documented contract, so `lastCoeditedUri`
|
||||
// is the fallback that keeps single-doc correctness when it's absent — the
|
||||
// last editor that WAS coediting when it lost focus (e.g. focus moved to the
|
||||
// preview pane itself) stays the annotation target until a different
|
||||
// coedited doc becomes active.
|
||||
let lastCoeditedUri: string | undefined;
|
||||
const trackLastCoedited = (ed: vscode.TextEditor | undefined) => {
|
||||
if (ed && coeditingRegistry.isCoediting(ed.document.uri)) lastCoeditedUri = ed.document.uri.toString();
|
||||
};
|
||||
trackLastCoedited(vscode.window.activeTextEditor);
|
||||
context.subscriptions.push(
|
||||
vscode.window.onDidChangeActiveTextEditor(trackLastCoedited),
|
||||
// The active editor doesn't itself change when `cowriting.coeditDocument`
|
||||
// enters IT into coediting — re-check on every gate change too, so the
|
||||
// very first render after entering already has an annotation target.
|
||||
coeditingRegistry.onDidChange(() => trackLastCoedited(vscode.window.activeTextEditor)),
|
||||
);
|
||||
|
||||
const previewAnnotationHost = {
|
||||
inputsFor(env: unknown): AnnotationInputs | undefined {
|
||||
const envUri = (env as { currentDocument?: { toString(): string } } | undefined)?.currentDocument?.toString();
|
||||
// Finding 1 (final whole-branch review): the `lastCoeditedUri` fallback
|
||||
// is for an ABSENT env only (env.currentDocument didn't resolve) — a
|
||||
// present envUri is authoritative for the doc actually being previewed
|
||||
// and must never fall back to a different (or since-exited) document's
|
||||
// inputs. Either way, the winning key must currently be a coediting
|
||||
// member (INV-10) — an exited doc's stale `lastCoeditedUri` fails this
|
||||
// check too, closing the "preview stays annotated after stopCoediting"
|
||||
// gap.
|
||||
const key = envUri === undefined ? lastCoeditedUri : envUri;
|
||||
if (!key || !coeditingRegistry.list().includes(key)) return undefined;
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key);
|
||||
if (!doc) return undefined;
|
||||
const baseline = diffViewController.getBaseline(key);
|
||||
return {
|
||||
baselineText: baseline?.text,
|
||||
baselineReason: baseline?.reason,
|
||||
spans: attributionController.spansFor(doc),
|
||||
proposals: proposalController.listProposals(doc),
|
||||
enabled: vscode.workspace.getConfiguration("cowriting").get<boolean>("annotations", true),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Toggle: flips `cowriting.annotations` + refreshes the built-in preview
|
||||
// (VS Code re-invokes extendMarkdownIt's plugin on the next render — no
|
||||
// extension-side re-render call exists beyond the standard refresh command).
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cowriting.toggleAnnotations", async () => {
|
||||
const config = vscode.workspace.getConfiguration("cowriting");
|
||||
const current = config.get<boolean>("annotations", true);
|
||||
await config.update("annotations", !current, vscode.ConfigurationTarget.Global);
|
||||
await vscode.commands.executeCommand("markdown.preview.refresh");
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
threadController,
|
||||
attributionController,
|
||||
proposalController,
|
||||
versionGuard,
|
||||
diffViewController,
|
||||
trackChangesPreviewController,
|
||||
editFlow,
|
||||
sidecarRouter,
|
||||
liveProgressUi,
|
||||
editorProposalController,
|
||||
coeditingRegistry,
|
||||
scmSurfaceController,
|
||||
previewAnnotationHost,
|
||||
extendMarkdownIt: (md: unknown) => cowritingMarkdownItPlugin(md as MarkdownIt, previewAnnotationHost),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* previewAnnotations — Task 7 (D3/D21, PUC-3): a pure, vscode-free markdown-it
|
||||
* plugin that annotates the BUILT-IN VS Code Markdown preview with the F10
|
||||
* authorship/change-mark vocabulary (`.cw-ins-claude` / `.cw-ins-human` /
|
||||
* `.cw-del`) — the native-surfaces migration's replacement for the sunset
|
||||
* webview's review body (spec §6.4/§6.10). Reuses the shipped sentinel
|
||||
* discipline (`injectSentinels`/`sentinelsToSpans`, #33/#47-hardened) instead
|
||||
* of inventing a new one: `annotateSource` computes PUA-sentinel-wrapped
|
||||
* markdown SOURCE (never HTML) against the F6/F7 baseline + F3 spans + F4
|
||||
* pending proposals; `cowritingMarkdownItPlugin` wires that transform into the
|
||||
* shared `markdown-it` instance (the SAME instance VS Code's built-in preview
|
||||
* uses via `markdown.markdownItPlugins`) as (a) a core rule — swap `state.src`
|
||||
* before `normalize` — and (b) a full-render wrapper that turns surviving
|
||||
* sentinel pairs into `<span class="cw-…">`.
|
||||
*
|
||||
* Implementation note (resolved drift from the migration plan's Task 7 sketch,
|
||||
* which described (b) as "a text renderer rule"): `sentinelsToSpans` is a
|
||||
* stateful, single-pass walk over one HTML string — installing it as a
|
||||
* PER-TOKEN `renderer.rules.text` hook would reset that state at every text
|
||||
* token boundary and silently break the "survives emphasis/tag boundaries"
|
||||
* invariant (#33 CASE3) for any span whose sentinel-open and sentinel-close
|
||||
* land in DIFFERENT text tokens (e.g. either side of a `**bold**` run — the
|
||||
* exact scenario CASE3 exists to cover). Wrapping `md.renderer.render` instead
|
||||
* runs the walk ONCE over the fully-assembled HTML, exactly like the F9/F10
|
||||
* `colorByAuthor` precedent (`render(injected)` — a whole-block HTML string,
|
||||
* not a token fragment) — this is what actually delivers the stated invariant,
|
||||
* robust to callers that invoke `md.render()` directly or `md.parse()` +
|
||||
* `md.renderer.render()` separately (VS Code's own scroll-sync `data-line`
|
||||
* injection uses the latter).
|
||||
*
|
||||
* `host.inputsFor(env)` is the ONE thin, vscode-touching seam (owned by
|
||||
* `extension.ts`): `env.currentDocument` is an OBSERVED VS Code preview-engine
|
||||
* behavior, not a documented contract, so the host falls back to the last
|
||||
* actively-coedited document when it is absent (see extension.ts's
|
||||
* `lastCoeditedUri`).
|
||||
*
|
||||
* Deliberate choice: the mermaid-fence rendering (the `options.highlight`
|
||||
* override installed below) applies to EVERY markdown preview, coedited or
|
||||
* not — it is not gated on coediting state. Gating it would make a diagram's
|
||||
* rendering flip (mermaid <-> plain fence) the instant a doc enters or exits
|
||||
* coediting, which is a worse surprise than always rendering mermaid diagrams
|
||||
* as diagrams; only the change-tracking OVERLAY on top (`buildMermaidQueue`'s
|
||||
* diff augmentation) is gated on having a baseline to diff against.
|
||||
*/
|
||||
import type MarkdownIt from "markdown-it";
|
||||
import { diffMermaid } from "./mermaidDiff";
|
||||
import {
|
||||
diffBlocks,
|
||||
injectSentinels,
|
||||
landedTextOf,
|
||||
MERMAID_LEGEND,
|
||||
mermaidFenceBody,
|
||||
sentinelsToSpans,
|
||||
splitBlocksWithRanges,
|
||||
wordEditHunks,
|
||||
type AuthorSpan,
|
||||
type ProposalView,
|
||||
type TaggedSpan,
|
||||
} from "./trackChangesModel";
|
||||
|
||||
export interface AnnotationInputs {
|
||||
baselineText: string | undefined;
|
||||
baselineReason: "entered" | "pinned" | "head" | undefined;
|
||||
spans: AuthorSpan[];
|
||||
proposals: ProposalView[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject the sentinel vocabulary into `text` block-by-block (INV-23: code/
|
||||
* mermaid fences stay atomic — never sentinel-injected, never word-refined) so
|
||||
* the delimiter-run + block-marker safety `injectSentinels` carries applies
|
||||
* PER BLOCK, not just at the very top of the whole document (its clamps are
|
||||
* anchored to a block's own start, matching every other caller — `colorByAuthor`
|
||||
* is always invoked per block too).
|
||||
*/
|
||||
function injectAllSentinels(text: string, marks: TaggedSpan[]): string {
|
||||
if (marks.length === 0) return text;
|
||||
const blocks = splitBlocksWithRanges(text);
|
||||
if (blocks.length === 0) return text;
|
||||
let out = "";
|
||||
let cursor = 0;
|
||||
for (const b of blocks) {
|
||||
out += text.slice(cursor, b.start); // preserve inter-block gaps (blank lines) verbatim
|
||||
if (b.type === "prose") {
|
||||
const blockMarks = marks.filter((m) => m.end > b.start && m.start < b.end);
|
||||
out += blockMarks.length ? injectSentinels(b.raw, b.start, blockMarks) : b.raw;
|
||||
} else {
|
||||
out += b.raw; // INV-23: fences are never annotated
|
||||
}
|
||||
cursor = b.end;
|
||||
}
|
||||
out += text.slice(cursor); // trailing content after the last block
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure: markdown source -> sentinel-annotated source (authorship + change +
|
||||
* proposal marks vs the F6/F7 baseline). Skips everything when annotations are
|
||||
* disabled, or (the #48 rule) once the baseline was just PINNED with zero
|
||||
* LANDED diff — pending F4 proposals are unaffected by that gate (matching the
|
||||
* shipped preview's "pin→clean, proposals still show", #48/INV-33).
|
||||
*/
|
||||
export function annotateSource(src: string, inputs: AnnotationInputs): string {
|
||||
if (!inputs.enabled) return src;
|
||||
const proposals = inputs.proposals ?? [];
|
||||
const baselineText = inputs.baselineText ?? src; // no baseline → no change-marks
|
||||
const landedText = landedTextOf(src, proposals);
|
||||
const pinnedZeroDiff = inputs.baselineReason === "pinned" && landedText === baselineText;
|
||||
|
||||
const insSpans: TaggedSpan[] = [];
|
||||
const deletions: { at: number; text: string }[] = [];
|
||||
const appliedRanges: { start: number; end: number }[] = [];
|
||||
|
||||
// (c) pending F4 proposals already sit applied in `src` (F12 optimistic
|
||||
// apply, INV-48) — mark the applied range ins-<author> (`p.author` — a
|
||||
// proposal defaults to "claude" per `ProposalView.author`, but a human can
|
||||
// also be the proposer, e.g. a human-authored suggestion routed through the
|
||||
// same F4 pending-proposal path) and resurface the pre-apply original as a
|
||||
// deletion at the same point.
|
||||
for (const p of proposals) {
|
||||
if (p.anchorStart === null || p.anchorEnd === null) continue;
|
||||
appliedRanges.push({ start: p.anchorStart, end: p.anchorEnd });
|
||||
if (p.anchorEnd > p.anchorStart) insSpans.push({ start: p.anchorStart, end: p.anchorEnd, tag: p.author ?? "claude" });
|
||||
const original = p.original ?? p.replaced;
|
||||
if (original) deletions.push({ at: p.anchorStart, text: original });
|
||||
}
|
||||
|
||||
// (a)/(b) committed changes-since-baseline. Each hunk (wordEditHunks(src,
|
||||
// baseline) — start/end in SRC coords, `replacement` = the baseline-side text
|
||||
// for that span) yields (a) insertion marks from the F3 `spans` CLIPPED to the
|
||||
// hunk's changed range — a hunk may straddle more than one author (e.g. two
|
||||
// adjacent words typed by different authors with nothing committed between
|
||||
// them), so spans are clipped individually, never the whole hunk tagged with
|
||||
// one author — and (b) the baseline's dropped text reinserted at the hunk
|
||||
// start. A hunk owned by an applied pending proposal is already marked above.
|
||||
if (!pinnedZeroDiff && landedText !== baselineText) {
|
||||
for (const h of wordEditHunks(src, baselineText)) {
|
||||
if (appliedRanges.some((r) => h.start < r.end && h.end > r.start)) continue;
|
||||
for (const s of inputs.spans) {
|
||||
const start = Math.max(s.start, h.start);
|
||||
const end = Math.min(s.end, h.end);
|
||||
if (end > start) insSpans.push({ start, end, tag: s.author });
|
||||
}
|
||||
if (h.replacement) deletions.push({ at: h.start, text: h.replacement });
|
||||
}
|
||||
}
|
||||
|
||||
if (insSpans.length === 0 && deletions.length === 0) return src;
|
||||
|
||||
// Splice deletion reinsertions into the source high→low (so earlier offsets
|
||||
// stay valid), shifting every mark recorded so far and appending a "del" mark
|
||||
// over the freshly-inserted text.
|
||||
const marks: TaggedSpan[] = insSpans.map((s) => ({ ...s }));
|
||||
let text = src;
|
||||
for (const d of [...deletions].sort((a, b) => b.at - a.at)) {
|
||||
text = text.slice(0, d.at) + d.text + text.slice(d.at);
|
||||
for (const m of marks) {
|
||||
if (m.start >= d.at) m.start += d.text.length;
|
||||
if (m.end >= d.at) m.end += d.text.length;
|
||||
}
|
||||
marks.push({ start: d.at, end: d.at + d.text.length, tag: "del" });
|
||||
}
|
||||
|
||||
return injectAllSentinels(text, marks);
|
||||
}
|
||||
|
||||
/** One `buildMermaidQueue` entry: a changed mermaid fence's own body (for
|
||||
* matching, `mermaidFenceBody` normalization — no trailing newline) paired
|
||||
* with its `diffMermaid`-augmented replacement source. `consumed` is reset by
|
||||
* the `render` wrapper (see `cowritingMarkdownItPlugin`) so the SAME entries
|
||||
* can back a re-render without a fresh parse. */
|
||||
interface MermaidQueueEntry {
|
||||
readonly curBody: string;
|
||||
readonly source: string;
|
||||
consumed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-render queue of mermaid-fence overrides: one entry per `changed` atomic
|
||||
* mermaid op (Task 7 §2.6 parity: reuses `diffBlocks`' baseline/current block
|
||||
* pairing, the SAME pairing `renderReview`/`renderOp` used in the sunset
|
||||
* webview, so a changed mermaid fence is re-emitted through `diffMermaid`
|
||||
* exactly as it was there — INV-23 fences stay atomic, INV-29..31 styling
|
||||
* directives) that has a diffable before/current pair AND a successful
|
||||
* `diffMermaid` augmentation. `[]` when there is no baseline to diff against,
|
||||
* annotations are disabled, or the landed text equals the baseline (covers
|
||||
* the #48 pinned-zero-diff case too, since `diffBlocks` on identical text
|
||||
* yields no `changed` ops).
|
||||
*
|
||||
* Diffed against `landedText` (proposals reverted), matching the "committed
|
||||
* change since baseline" boundary `annotateSource`'s own `wordEditHunks(src,
|
||||
* baselineText)` gate already draws (a pending, not-yet-accepted proposal
|
||||
* isn't a landed change) — a fence a pending proposal's anchor overlaps is
|
||||
* left unaugmented, same as the rest of that gate.
|
||||
*
|
||||
* NOT positional: `src` (what markdown-it actually parses fences from) and
|
||||
* `landedText` (what this function diffs) can disagree on mermaid-fence COUNT
|
||||
* or ORDER — e.g. a pending proposal optimistically applied in `src` (F12,
|
||||
* INV-48) inserts a whole new mermaid fence that `landedText` (proposals
|
||||
* reverted) doesn't have, or `annotateSource`'s committed-deletion reinsert
|
||||
* splices an extra fence into the rendered source. `options.highlight`
|
||||
* (`cowritingMarkdownItPlugin`) matches each queue entry to a fence by BODY,
|
||||
* not by position — a fence with no matching entry renders verbatim (the
|
||||
* queue is a bag of `changed` fences to find, not a strict per-fence
|
||||
* override list), so misalignment degrades to verbatim exactly as intended,
|
||||
* never substitutes the wrong diagram's augmentation into the wrong fence.
|
||||
*
|
||||
* Pure, vscode-free (INV-6).
|
||||
*/
|
||||
function buildMermaidQueue(src: string, inputs: AnnotationInputs): MermaidQueueEntry[] {
|
||||
if (!inputs.enabled || inputs.baselineText === undefined) return [];
|
||||
const baselineText = inputs.baselineText;
|
||||
const landedText = landedTextOf(src, inputs.proposals ?? []);
|
||||
if (landedText === baselineText) return [];
|
||||
const entries: MermaidQueueEntry[] = [];
|
||||
for (const op of diffBlocks(baselineText, landedText)) {
|
||||
if (op.kind !== "changed" || op.block.type !== "mermaid") continue; // unchanged/added/removed: no override needed
|
||||
const curBody = mermaidFenceBody(op.block.raw);
|
||||
const beforeBody = op.before.type === "mermaid" ? mermaidFenceBody(op.before.raw) : null;
|
||||
if (curBody === null || beforeBody === null) continue;
|
||||
const result = diffMermaid(beforeBody, curBody);
|
||||
if (result.kind === "augmented") entries.push({ curBody, source: result.source, consumed: false }); // INV-30 fallback: verbatim
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* markdown-it plugin factory: the host supplies `AnnotationInputs` per render
|
||||
* (`host.inputsFor(env)`; `undefined` = no coediting context for this render —
|
||||
* source passed through untouched). Installs (a) a core rule BEFORE `normalize`
|
||||
* that swaps `state.src` for `annotateSource(...)`, and (b) a full-render
|
||||
* wrapper (see module docstring) that turns surviving sentinel pairs into
|
||||
* `cw-…` spans. Also teaches ```mermaid fences to render as `<pre class="mermaid">`
|
||||
* (Q4/Task 7 Step 2.6) via `options.highlight` — the same extension point the
|
||||
* built-in preview's own fence rule already calls for syntax highlighting — so
|
||||
* the bundled `media/preview-mermaid.js` previewScript has something to run
|
||||
* mermaid over (the built-in preview does not render mermaid on its own). A
|
||||
* CHANGED mermaid fence's source is re-emitted through `diffMermaid` first
|
||||
* (`buildMermaidQueue`, Task 7 §2.6 parity) so mermaid renders the intra-
|
||||
* diagram diff (INV-29..31), not just the current diagram. `options.highlight`
|
||||
* has no `env`/`state` parameter of its own, so the per-render queue is
|
||||
* threaded through a closure variable set by the core rule (which DOES see
|
||||
* `state.env`) and consumed BY BODY MATCH, not fence position (see
|
||||
* `buildMermaidQueue`), by `highlight`: each fence's `code` is matched against
|
||||
* unconsumed queue entries in order, the first match is consumed (so a later
|
||||
* fence with the same body still finds its own entry), and a fence with no
|
||||
* match renders verbatim WITHOUT consuming anything (so a later fence can
|
||||
* still match). markdown-it's `parse` (core rules) always completes before
|
||||
* `renderer.render` invokes `highlight`, and a single md instance never
|
||||
* renders concurrently, so the closure handoff itself is safe — but VS Code
|
||||
* may call `md.parse()` and `md.renderer.render()` separately and re-render
|
||||
* cached tokens without a fresh parse (the same caller pattern the module
|
||||
* docstring notes for scroll-sync), which would otherwise replay a queue whose
|
||||
* entries were already marked consumed by the prior render; the `render`
|
||||
* wrapper resets every entry's `consumed` flag so each render pass starts with
|
||||
* the full queue available again.
|
||||
*/
|
||||
export function cowritingMarkdownItPlugin(
|
||||
md: MarkdownIt,
|
||||
host: { inputsFor(env: unknown): AnnotationInputs | undefined },
|
||||
): MarkdownIt {
|
||||
let mermaidQueue: MermaidQueueEntry[] = [];
|
||||
|
||||
md.core.ruler.before("normalize", "cowriting-annotate", (state) => {
|
||||
const inputs = host.inputsFor(state.env);
|
||||
mermaidQueue = inputs ? buildMermaidQueue(state.src, inputs) : [];
|
||||
if (!inputs) return;
|
||||
state.src = annotateSource(state.src, inputs);
|
||||
});
|
||||
|
||||
const baseRender = md.renderer.render.bind(md.renderer);
|
||||
md.renderer.render = (tokens, options, env) => {
|
||||
for (const entry of mermaidQueue) entry.consumed = false; // re-render-without-reparse safety net
|
||||
return sentinelsToSpans(baseRender(tokens, options, env), "ins");
|
||||
};
|
||||
|
||||
const baseHighlight = md.options.highlight;
|
||||
md.options.highlight = (code, lang, attrs) => {
|
||||
if (lang?.trim().toLowerCase() === "mermaid") {
|
||||
const normalized = code.replace(/\n+$/, ""); // token.content keeps a trailing LF; mermaidFenceBody doesn't
|
||||
const entry = mermaidQueue.find((e) => !e.consumed && e.curBody === normalized);
|
||||
if (entry) entry.consumed = true;
|
||||
const legend = entry ? MERMAID_LEGEND : "";
|
||||
return `<pre class="mermaid" style="all: unset;">${escapeMermaidSource(entry?.source ?? code)}</pre>${legend}`;
|
||||
}
|
||||
return baseHighlight?.(code, lang, attrs) ?? md.utils.escapeHtml(code);
|
||||
};
|
||||
|
||||
return md;
|
||||
}
|
||||
|
||||
/** Minimal HTML-escape for a mermaid fence body (no markdown parsing — verbatim diagram source). */
|
||||
function escapeMermaidSource(source: string): string {
|
||||
return source
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\n+$/, "");
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import type { AttributionController } from "./attributionController";
|
||||
import type { VersionGuard } from "./versionGuard";
|
||||
import { isAuthorable } from "./workspacePath";
|
||||
import { wordEditHunks, type ProposalView } from "./trackChangesModel";
|
||||
import type { CoeditingRegistry } from "./coeditingRegistry";
|
||||
|
||||
/** Test-facing snapshot of what is currently rendered for a document. */
|
||||
export interface RenderedProposal {
|
||||
@@ -56,11 +57,36 @@ export class ProposalController implements vscode.Disposable {
|
||||
private readonly attribution: AttributionController,
|
||||
private readonly rootDir: string | undefined,
|
||||
private readonly guard: VersionGuard,
|
||||
private readonly registry: CoeditingRegistry,
|
||||
) {
|
||||
this.disposables.push(this.statusItem);
|
||||
this.disposables.push(this.onDidChangeProposalsEmitter);
|
||||
this.disposables.push(
|
||||
vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
|
||||
// PUC-7 restore-on-enter (mirrors ThreadController/AttributionController):
|
||||
// `renderAll` is the only path that recomputes state.live/unresolved AND
|
||||
// fires onDidChangeProposals — the event EditorProposalController listens
|
||||
// to for optimistic-apply (decorations/CodeLens). Without this, a FIRST
|
||||
// entry into coediting (registry.enter()) never fires it, so pre-existing
|
||||
// pending proposals stay un-applied/undecorated until the next edit.
|
||||
// `renderAll` already gates on isCoediting; exit intentionally does
|
||||
// nothing here (it clears only the live/unresolved recompute, not the
|
||||
// persisted artifact — EditorProposalController's own gate hides the
|
||||
// decorations). Registered after AttributionController's own subscription
|
||||
// (construction order in extension.ts) so decorateCommitted's spansFor
|
||||
// read sees fresh attribution on the same enter transition.
|
||||
this.registry.onDidChange(({ uri, coediting }) => {
|
||||
if (!coediting) {
|
||||
// Finding 5 (final whole-branch review): exit hides the status-bar
|
||||
// item too — otherwise a stale "N stale proposals" warning for a
|
||||
// doc that's no longer even coediting stays pinned in the bar until
|
||||
// some unrelated render happens to fire for it.
|
||||
if (vscode.window.activeTextEditor?.document.uri.toString() === uri) this.statusItem.hide();
|
||||
return;
|
||||
}
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
|
||||
if (doc) this.renderAll(doc);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -380,8 +406,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);
|
||||
@@ -454,7 +485,13 @@ export class ProposalController implements vscode.Disposable {
|
||||
|
||||
// ---- PUC-4: load / external change / resolve-or-flag --------------------------------
|
||||
|
||||
/** Load + (re)render every pending proposal at its resolved anchor (or flagged). */
|
||||
/** Load + (re)render every pending proposal at its resolved anchor (or flagged).
|
||||
* INV-10: the RENDER portion (live/unresolved recompute, the status bar, and
|
||||
* the onDidChangeProposals fire that drives F12's optimistic-apply/CodeLens/
|
||||
* decorations) is gated on coediting — `propose()` itself stays ungated (it
|
||||
* always persists), and so does the state LOAD below: `acceptById`/`rejectById`
|
||||
* key off `this.docs` (populated here) regardless of coediting, since deciding
|
||||
* an already-persisted proposal is not a "rendering" surface. */
|
||||
renderAll(document: vscode.TextDocument): void {
|
||||
if (!this.isTracked(document)) return;
|
||||
const docPath = this.keyOf(document);
|
||||
@@ -462,6 +499,7 @@ export class ProposalController implements vscode.Disposable {
|
||||
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
|
||||
state.live.clear();
|
||||
state.unresolved.clear();
|
||||
if (!this.registry.isCoediting(document.uri)) return;
|
||||
const text = document.getText();
|
||||
for (const proposal of state.artifact.proposals) {
|
||||
const fp = state.artifact.anchors[proposal.anchorId]?.fingerprint;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+293
-16
@@ -15,6 +15,10 @@ import { gitUserEmail } from "./identity";
|
||||
import { addThread, appendMessage, setStatus } from "./threadModel";
|
||||
import type { VersionGuard } from "./versionGuard";
|
||||
import { isAuthorable } from "./workspacePath";
|
||||
import type { CoeditingRegistry } from "./coeditingRegistry";
|
||||
import type { EditFlow, EditTarget } from "./editFlow";
|
||||
import type { LiveProgressUi } from "./liveProgressUi";
|
||||
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
|
||||
|
||||
/** Test-facing snapshot of what is currently rendered for a document. */
|
||||
export interface RenderedThread {
|
||||
@@ -22,8 +26,22 @@ export interface RenderedThread {
|
||||
status: "open" | "resolved";
|
||||
orphaned: boolean;
|
||||
range: { startLine: number; endLine: number };
|
||||
/** Finding 2 (final whole-branch review): the raw `contextValue` VS Code's
|
||||
* `comments/commentThread/*` `when`-clauses key on — exposed directly so
|
||||
* E2E can assert the status token survives an offer transition without a
|
||||
* DOM/UI query. */
|
||||
contextValue: string;
|
||||
}
|
||||
|
||||
/** D10/PUC-8: every human comment on a coedited doc runs this reply turn. */
|
||||
const REPLY_PROMPT = (ask: string): string =>
|
||||
"Reply conversationally (2-4 sentences) to this remark about the text, then, if the remark implies " +
|
||||
"an edit, state the edit you would make. Remark: " +
|
||||
ask;
|
||||
|
||||
/** F6.x/Task 6: the injectable low-level turn runner behind respondInThread. */
|
||||
type TurnRunner = (instruction: string, context: string, opts?: RunEditTurnOptions) => Promise<EditTurnResult>;
|
||||
|
||||
interface DocState {
|
||||
docPath: string;
|
||||
uri: vscode.Uri;
|
||||
@@ -34,25 +52,45 @@ interface DocState {
|
||||
live: Map<string, OffsetRange>;
|
||||
/** thread id -> orphaned flag for the current render. */
|
||||
orphaned: Map<string, boolean>;
|
||||
/** Finding 2 (final whole-branch review): thread id -> in-flight offer-flow
|
||||
* token ("offer" once a machine reply lands, "offerdone" once spent),
|
||||
* tracked SEPARATELY from the open/resolved/orphaned status so setting one
|
||||
* never clobbers the other — both are folded into one space-separated
|
||||
* `contextValue` string by `applyContextValue` (package.json when-clauses
|
||||
* match either token with an unanchored regex). Ephemeral: reset on every
|
||||
* render (a fresh vsThread never carries a stale offer). */
|
||||
offerToken: Map<string, "offer" | "offerdone">;
|
||||
}
|
||||
|
||||
export class ThreadController implements vscode.Disposable {
|
||||
private readonly controller: vscode.CommentController;
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
private readonly docs = new Map<string, DocState>(); // keyed by docPath
|
||||
/** thread -> the pending machine "offer" (D8): the ask it answered, ready to become an edit. */
|
||||
private readonly offers = new WeakMap<vscode.CommentThread, { ask: string; threadId: string }>();
|
||||
/** Test seam (Task 6): stub the reply-loop turn so E2E never spawns a real @cline/sdk agent. */
|
||||
private turnRunner: TurnRunner | undefined;
|
||||
|
||||
/** Kept as ONE object; re-assigned on every registry change so VS Code re-queries
|
||||
* (spec §6.4 v0.2.1 — reassignment is the API's only "ranges changed" signal). */
|
||||
private readonly rangeProvider: vscode.CommentingRangeProvider = {
|
||||
provideCommentingRanges: (document) => {
|
||||
if (!isAuthorable(document.uri.scheme)) return [];
|
||||
if (!this.registry.isCoediting(document.uri)) return [];
|
||||
return [new vscode.Range(0, 0, Math.max(0, document.lineCount - 1), 0)];
|
||||
},
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly store: SidecarRouter,
|
||||
private readonly rootDir: string | undefined,
|
||||
private readonly guard: VersionGuard,
|
||||
private readonly registry: CoeditingRegistry,
|
||||
private readonly editFlow: EditFlow,
|
||||
private readonly liveProgressUi: LiveProgressUi,
|
||||
) {
|
||||
this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads");
|
||||
this.controller.commentingRangeProvider = {
|
||||
provideCommentingRanges: (document) => {
|
||||
if (!isAuthorable(document.uri.scheme)) return [];
|
||||
return [new vscode.Range(0, 0, Math.max(0, document.lineCount - 1), 0)];
|
||||
},
|
||||
};
|
||||
this.controller.commentingRangeProvider = this.rangeProvider;
|
||||
this.disposables.push(this.controller);
|
||||
|
||||
this.disposables.push(
|
||||
@@ -64,8 +102,30 @@ export class ThreadController implements vscode.Disposable {
|
||||
vscode.commands.registerCommand("cowriting.reopenThread", (t: vscode.CommentThread) =>
|
||||
this.setThreadStatus(t, "open"),
|
||||
),
|
||||
// D19 (spec §6.4 v0.2.1): the comments-first "Ask Claude" gesture — a
|
||||
// focused comment box, not a webview.
|
||||
vscode.commands.registerCommand("cowriting.askClaude", () => this.askClaude()),
|
||||
// D8: turn a machine "offer" thread into pending F4 proposal(s) (INV-5).
|
||||
vscode.commands.registerCommand(
|
||||
"cowriting.makeThreadEdit",
|
||||
(arg: { thread: vscode.CommentThread } | vscode.CommentThread) => this.makeEditFromThreadArg(arg),
|
||||
),
|
||||
vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
|
||||
vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)),
|
||||
// INV-10 (PUC-7): force VS Code to re-query commenting ranges on every gate
|
||||
// change, and hide/restore rendered threads — exit hides only (dispose the
|
||||
// rendered vsThreads), re-enter restores (renderAll reloads from the
|
||||
// sidecar, which is the durable source of truth regardless of gate state).
|
||||
this.registry.onDidChange(({ uri, coediting }) => {
|
||||
this.controller.commentingRangeProvider = this.rangeProvider;
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
|
||||
if (!doc) return;
|
||||
if (coediting) {
|
||||
this.renderAll(doc);
|
||||
} else {
|
||||
this.disposeRendered(this.keyOf(doc));
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,17 +160,44 @@ export class ThreadController implements vscode.Disposable {
|
||||
vsThreads: new Map(),
|
||||
live: new Map(),
|
||||
orphaned: new Map(),
|
||||
offerToken: new Map(),
|
||||
};
|
||||
this.docs.set(docPath, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
// ---- D19: comments-first ask -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* D19 (spec §6.4 v0.2.1): "Ask Claude" opens a focused comment box on the
|
||||
* active editor's selection, or, when there is no selection, a top-anchored
|
||||
* whole-document thread. The ask itself IS a comment — respondInThread (D10)
|
||||
* takes it from there once the human submits it via cowriting.reply.
|
||||
*/
|
||||
async askClaude(): Promise<void> {
|
||||
const ed = vscode.window.activeTextEditor;
|
||||
if (!ed || !this.registry.isCoediting(ed.document.uri)) {
|
||||
void vscode.window.showWarningMessage("Run ✦ Coedit this Document with Claude first.");
|
||||
return;
|
||||
}
|
||||
if (ed.selection.isEmpty) {
|
||||
// whole-document ask → top-anchored thread (D19)
|
||||
ed.selection = new vscode.Selection(0, 0, 0, 0);
|
||||
ed.revealRange(new vscode.Range(0, 0, 0, 0));
|
||||
}
|
||||
// Spike finding: workbench.action.addComment is the only path that opens the
|
||||
// comment widget WITH its input focused (no thread.focus() API exists).
|
||||
this.controller.commentingRangeProvider = this.rangeProvider; // defensive refresh
|
||||
await vscode.commands.executeCommand("workbench.action.addComment");
|
||||
}
|
||||
|
||||
// ---- PUC-1: create on selection -------------------------------------------------
|
||||
|
||||
async createThreadOnSelection(firstBody = "New thread"): Promise<string | undefined> {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor || editor.selection.isEmpty || !isAuthorable(editor.document.uri.scheme)) return undefined;
|
||||
if (!this.registry.isCoediting(editor.document.uri)) return undefined;
|
||||
if (this.guard.isReadOnly(this.keyOf(editor.document))) return undefined;
|
||||
const document = editor.document;
|
||||
const state = this.ensureState(document);
|
||||
@@ -119,22 +206,175 @@ export class ThreadController implements vscode.Disposable {
|
||||
end: document.offsetAt(editor.selection.end),
|
||||
};
|
||||
const fp = buildFingerprint(document.getText(), offsets);
|
||||
const { threadId } = addThread(state.artifact, fp, { author: this.currentAuthor(), body: firstBody });
|
||||
const author = this.currentAuthor();
|
||||
const { threadId } = addThread(state.artifact, fp, { author, body: firstBody });
|
||||
this.persist(state);
|
||||
this.renderThread(document, state, threadId, offsets, false);
|
||||
const vsThread = this.renderThread(document, state, threadId, offsets, false);
|
||||
// D10: the first message of a new thread is also "a comment on a coedited
|
||||
// doc" — run the same respond loop as a reply. Finding 2 guard: only for a
|
||||
// human-authored message (never re-trigger the loop off the machine's own
|
||||
// words — see reply() below for the matching guard).
|
||||
if (author.kind !== "agent") {
|
||||
void this.respondInThread(vsThread, state, threadId, firstBody);
|
||||
}
|
||||
return threadId;
|
||||
}
|
||||
|
||||
// ---- PUC-2: reply / resolve -----------------------------------------------------
|
||||
|
||||
reply(r: vscode.CommentReply): void {
|
||||
const threadId = this.threadIdOf(r.thread);
|
||||
const state = this.stateOfThread(r.thread);
|
||||
if (!threadId || !state) return;
|
||||
const vsThread = r.thread;
|
||||
if (!isAuthorable(vsThread.uri.scheme) || !this.registry.isCoediting(vsThread.uri)) return;
|
||||
const document = vscode.workspace.textDocuments.find((d) => d.uri.toString() === vsThread.uri.toString());
|
||||
if (!document) return;
|
||||
const state = this.ensureState(document);
|
||||
if (this.guard.isReadOnly(state.docPath)) return;
|
||||
appendMessage(state.artifact, threadId, { author: this.currentAuthor(), body: r.text });
|
||||
const author = this.currentAuthor();
|
||||
let threadId = this.threadIdOf(vsThread);
|
||||
if (threadId) {
|
||||
appendMessage(state.artifact, threadId, { author, body: r.text });
|
||||
} else {
|
||||
// D10/D19: a brand-new thread created via the native "+"/comment-widget
|
||||
// gesture (not via createThreadOnSelection) — VS Code hands us a real,
|
||||
// still-unregistered CommentThread; register it now.
|
||||
const range = vsThread.range ?? new vscode.Range(0, 0, 0, 0);
|
||||
const offsets: OffsetRange = {
|
||||
start: document.offsetAt(range.start),
|
||||
end: document.offsetAt(range.end),
|
||||
};
|
||||
const fp = buildFingerprint(document.getText(), offsets);
|
||||
const created = addThread(state.artifact, fp, { author, body: r.text });
|
||||
threadId = created.threadId;
|
||||
state.vsThreads.set(threadId, vsThread);
|
||||
state.live.set(threadId, offsets);
|
||||
state.orphaned.set(threadId, false);
|
||||
// Finding 2 (final whole-branch review): a thread born via the native
|
||||
// "+" gesture skipped renderThread entirely, so it never got a status
|
||||
// contextValue at all — Resolve/Reopen never appeared for it. Stamp one
|
||||
// now, same as every other thread.
|
||||
this.applyContextValue(state, threadId, vsThread);
|
||||
}
|
||||
this.persist(state);
|
||||
this.refreshComments(r.thread, state, threadId);
|
||||
this.refreshComments(vsThread, state, threadId);
|
||||
// D10: every HUMAN comment on a coedited doc runs a turn. Finding 2 guard:
|
||||
// `cowriting.reply` is the ingress for both a genuine human reply and (in
|
||||
// principle) a re-dispatched machine message — never loop the machine's own
|
||||
// reply back through respondInThread (it would double-turn on Claude's words
|
||||
// and re-flip an already-"offer" thread).
|
||||
if (author.kind !== "agent") {
|
||||
void this.respondInThread(vsThread, state, threadId, r.text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* D10/PUC-8: run one reply turn for `ask`, then append the machine's reply
|
||||
* (INV-8 onBehalfOf provenance) and flip the thread into an "offer" — ready
|
||||
* for makeThreadEdit to cut it into pending proposal(s) (D8, INV-5).
|
||||
*/
|
||||
private async respondInThread(
|
||||
vsThread: vscode.CommentThread,
|
||||
state: DocState,
|
||||
threadId: string,
|
||||
ask: string,
|
||||
): Promise<void> {
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === vsThread.uri.toString());
|
||||
if (!doc) return;
|
||||
const contextText = this.threadContextText(doc, vsThread);
|
||||
try {
|
||||
await vscode.window.withProgress(
|
||||
{ location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…", cancellable: true },
|
||||
async (progress, token) => {
|
||||
const run = this.turnRunner ?? (await import("./liveTurn")).runEditTurn;
|
||||
const ui = this.liveProgressUi.begin(ask, progress, token);
|
||||
let turn: EditTurnResult;
|
||||
try {
|
||||
turn = await run(REPLY_PROMPT(ask), contextText, { onProgress: ui.onProgress, signal: ui.signal });
|
||||
} catch (err) {
|
||||
if (token.isCancellationRequested) return;
|
||||
throw err;
|
||||
}
|
||||
// machine reply, onBehalfOf provenance (INV-8)
|
||||
appendMessage(state.artifact, threadId, {
|
||||
author: {
|
||||
kind: "agent",
|
||||
id: "claude",
|
||||
agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId, onBehalfOf: this.currentAuthor() },
|
||||
},
|
||||
body: turn.replacement,
|
||||
});
|
||||
this.persist(state);
|
||||
this.refreshComments(vsThread, state, threadId);
|
||||
// Finding 2: fold "offer" in alongside the status token — see
|
||||
// `applyContextValue` — instead of clobbering it (when-key:
|
||||
// `commentThread =~ /\boffer\b/`).
|
||||
state.offerToken.set(threadId, "offer");
|
||||
this.applyContextValue(state, threadId, vsThread);
|
||||
this.offers.set(vsThread, { ask, threadId });
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
void vscode.window.showErrorMessage(`Cowriting: Claude reply failed — ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** The passage a ranged thread discusses, or the whole document for a top-anchored (D19) thread. */
|
||||
private threadContextText(doc: vscode.TextDocument, vsThread: vscode.CommentThread): string {
|
||||
const range = vsThread.range;
|
||||
if (range && !range.isEmpty) return doc.getText(range);
|
||||
return doc.getText();
|
||||
}
|
||||
|
||||
/** D19: a ranged thread targets its range; a top-anchored (whole-doc-ask) thread targets the document. */
|
||||
private targetOf(doc: vscode.TextDocument, vsThread: vscode.CommentThread): EditTarget {
|
||||
const range = vsThread.range;
|
||||
if (!range || range.isEmpty) return { kind: "document" };
|
||||
return { kind: "range", start: doc.offsetAt(range.start), end: doc.offsetAt(range.end) };
|
||||
}
|
||||
|
||||
// ---- D8: offer -> pending proposal(s) (INV-5) -------------------------------------
|
||||
|
||||
private async makeEditFromThreadArg(arg: { thread: vscode.CommentThread } | vscode.CommentThread): Promise<void> {
|
||||
const vsThread = "thread" in arg ? arg.thread : arg;
|
||||
await this.runMakeEdit(vsThread);
|
||||
}
|
||||
|
||||
private async runMakeEdit(vsThread: vscode.CommentThread): Promise<string[]> {
|
||||
const offer = this.offers.get(vsThread);
|
||||
if (!offer || !this.registry.isCoediting(vsThread.uri)) return [];
|
||||
const doc = await vscode.workspace.openTextDocument(vsThread.uri);
|
||||
const target = this.targetOf(doc, vsThread);
|
||||
const ids = await this.editFlow.runEditAndPropose(doc, target, offer.ask);
|
||||
// Finding 2 (final whole-branch review): fold "offerdone" in alongside the
|
||||
// status token rather than clobbering it — see `applyContextValue`.
|
||||
const state = this.stateOfThread(vsThread);
|
||||
if (state) {
|
||||
state.offerToken.set(offer.threadId, "offerdone");
|
||||
this.applyContextValue(state, offer.threadId, vsThread);
|
||||
}
|
||||
// Finding 3 (seam hygiene): drop the offer once it's spent — otherwise the
|
||||
// `makeThreadEdit(threadId, docPath)` test seam (or a stray second UI click,
|
||||
// now dead per contextValue but still a valid direct call) could re-run the
|
||||
// same edit a second time. A future offer on this thread is a fresh
|
||||
// respondInThread() call, which re-populates the WeakMap entry.
|
||||
this.offers.delete(vsThread);
|
||||
if (ids.length) {
|
||||
void vscode.window.showInformationMessage(
|
||||
`Cowriting: ${ids.length} pending change${ids.length === 1 ? "" : "s"} landed in the buffer — ✓ Keep / ✗ Reject there.`,
|
||||
);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Test seam (Task 6): run the offer for a thread without the UI button. */
|
||||
async makeThreadEdit(threadId: string, docPath: string): Promise<string[]> {
|
||||
const vsThread = this.docs.get(docPath)?.vsThreads.get(threadId);
|
||||
if (!vsThread) return [];
|
||||
return this.runMakeEdit(vsThread);
|
||||
}
|
||||
|
||||
/** Test seam (Task 6): stub the reply-loop turn so E2E never spawns a real @cline/sdk agent. */
|
||||
setTurnRunnerForTest(fn: TurnRunner): void {
|
||||
this.turnRunner = fn;
|
||||
}
|
||||
|
||||
private setThreadStatus(vsThread: vscode.CommentThread, status: "open" | "resolved"): void {
|
||||
@@ -145,13 +385,14 @@ export class ThreadController implements vscode.Disposable {
|
||||
setStatus(state.artifact, threadId, status);
|
||||
this.persist(state);
|
||||
vsThread.state = status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved;
|
||||
vsThread.contextValue = status;
|
||||
this.applyContextValue(state, threadId, vsThread); // preserves any in-flight offer token
|
||||
}
|
||||
|
||||
// ---- PUC-3/4: render, reload, re-anchor -----------------------------------------
|
||||
|
||||
/** Load + (re)render every thread for a document at its resolved anchor (or orphaned). */
|
||||
renderAll(document: vscode.TextDocument): void {
|
||||
if (!this.registry.isCoediting(document.uri)) return;
|
||||
const docPath = this.keyOf(document);
|
||||
const state = this.ensureState(document);
|
||||
// fresh artifact from disk (reload / external change)
|
||||
@@ -160,6 +401,7 @@ export class ThreadController implements vscode.Disposable {
|
||||
state.vsThreads.clear();
|
||||
state.live.clear();
|
||||
state.orphaned.clear();
|
||||
state.offerToken.clear();
|
||||
const text = document.getText();
|
||||
for (const thread of state.artifact.threads) {
|
||||
const fp = state.artifact.anchors[thread.anchorId]?.fingerprint;
|
||||
@@ -225,7 +467,7 @@ export class ThreadController implements vscode.Disposable {
|
||||
threadId: string,
|
||||
offsets: OffsetRange,
|
||||
orphaned: boolean,
|
||||
): void {
|
||||
): vscode.CommentThread {
|
||||
const thread = state.artifact.threads.find((t) => t.id === threadId)!;
|
||||
const range = new vscode.Range(document.positionAt(offsets.start), document.positionAt(offsets.end));
|
||||
const vsThread = this.controller.createCommentThread(
|
||||
@@ -234,12 +476,34 @@ export class ThreadController implements vscode.Disposable {
|
||||
thread.messages.map((m) => this.toComment(m.body, m.author.id)),
|
||||
);
|
||||
vsThread.label = orphaned ? "⚠ Orphaned thread (anchor not found)" : undefined;
|
||||
vsThread.contextValue = orphaned ? "orphaned" : thread.status;
|
||||
vsThread.state = thread.status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved;
|
||||
vsThread.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed;
|
||||
state.vsThreads.set(threadId, vsThread);
|
||||
state.live.set(threadId, offsets);
|
||||
state.orphaned.set(threadId, orphaned);
|
||||
state.offerToken.delete(threadId); // a freshly-created vsThread never carries a stale offer
|
||||
this.applyContextValue(state, threadId, vsThread);
|
||||
return vsThread;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finding 2 (final whole-branch review): `contextValue` is the ONE string
|
||||
* VS Code's `comments/commentThread/*` `when`-clauses can key on, but two
|
||||
* INDEPENDENT things need to show through it — the open/resolved/orphaned
|
||||
* status (Resolve/Reopen) and the offer-flow state (makeThreadEdit). Folding
|
||||
* both into one space-separated string (`"open offer"`, `"resolved"`, …)
|
||||
* with package.json `when`s matched by an unanchored `=~ /\btoken\b/` regex
|
||||
* lets either flip without erasing the other — setting the offer flag alone
|
||||
* used to stomp the status token entirely (every human comment now triggers
|
||||
* a reply, so every thread on a coedited doc lost its Resolve/Reopen menu
|
||||
* the moment the machine answered).
|
||||
*/
|
||||
private applyContextValue(state: DocState, threadId: string, vsThread: vscode.CommentThread): void {
|
||||
const orphaned = !!state.orphaned.get(threadId);
|
||||
const thread = state.artifact.threads.find((t) => t.id === threadId);
|
||||
const statusToken = orphaned ? "orphaned" : (thread?.status ?? "open");
|
||||
const offerToken = state.offerToken.get(threadId);
|
||||
vsThread.contextValue = offerToken ? `${statusToken} ${offerToken}` : statusToken;
|
||||
}
|
||||
|
||||
private refreshComments(vsThread: vscode.CommentThread, state: DocState, threadId: string): void {
|
||||
@@ -269,6 +533,18 @@ export class ThreadController implements vscode.Disposable {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** PUC-7 (INV-10): exit hides only — dispose the rendered vsThreads for a doc
|
||||
* without touching its sidecar-backed artifact (re-enter restores via renderAll). */
|
||||
private disposeRendered(docPath: string): void {
|
||||
const state = this.docs.get(docPath);
|
||||
if (!state) return;
|
||||
for (const vsThread of state.vsThreads.values()) vsThread.dispose();
|
||||
state.vsThreads.clear();
|
||||
state.live.clear();
|
||||
state.orphaned.clear();
|
||||
state.offerToken.clear();
|
||||
}
|
||||
|
||||
// ---- test-facing surface --------------------------------------------------------
|
||||
|
||||
getRendered(docPath: string): RenderedThread[] {
|
||||
@@ -283,6 +559,7 @@ export class ThreadController implements vscode.Disposable {
|
||||
status: t.status,
|
||||
orphaned: !!state.orphaned.get(id),
|
||||
range: { startLine: r ? r.start.line : 0, endLine: r ? r.end.line : 0 },
|
||||
contextValue: typeof vsThread.contextValue === "string" ? vsThread.contextValue : "",
|
||||
});
|
||||
}
|
||||
return out;
|
||||
|
||||
+94
-36
@@ -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. */
|
||||
@@ -445,8 +469,13 @@ md.renderer.rules.fence = (tokens, idx, options, env, self) => {
|
||||
return defaultFence(tokens, idx, options, env, self);
|
||||
};
|
||||
|
||||
/** Inner source of a ```mermaid fence (drops the fence lines), or null. */
|
||||
function mermaidFenceBody(raw: string): string | null {
|
||||
/**
|
||||
* Inner source of a ```mermaid fence (drops the fence lines), or null. Exported
|
||||
* so `previewAnnotations.ts` can pair a `diffBlocks` mermaid `changed` op's
|
||||
* before/current fence bodies for `diffMermaid` without reinventing this
|
||||
* extraction (Task 7 §2.6 parity).
|
||||
*/
|
||||
export function mermaidFenceBody(raw: string): string | null {
|
||||
const lines = raw.split(/\r?\n/);
|
||||
const open = lines[0]?.match(/^(\s*)(`{3,}|~{3,})\s*(\w+)?/);
|
||||
if (!open || open[3]?.toLowerCase() !== "mermaid") return null;
|
||||
@@ -455,8 +484,12 @@ function mermaidFenceBody(raw: string): string | null {
|
||||
return body.join("\n");
|
||||
}
|
||||
|
||||
/** F7.1 (#22): legend shown beneath an intra-diagram-diffed mermaid block. */
|
||||
const MERMAID_LEGEND =
|
||||
/**
|
||||
* F7.1 (#22): legend shown beneath an intra-diagram-diffed mermaid block.
|
||||
* Exported for reuse by `previewAnnotations.ts`'s built-in-preview mermaid
|
||||
* diff wiring (Task 7 §2.6 parity — same legend markup, both surfaces).
|
||||
*/
|
||||
export const MERMAID_LEGEND =
|
||||
'<div class="cw-mermaid-legend">' +
|
||||
'<span class="cw-leg cw-leg-add">added</span>' +
|
||||
'<span class="cw-leg cw-leg-chg">changed</span>' +
|
||||
@@ -548,11 +581,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;
|
||||
}
|
||||
@@ -631,15 +667,33 @@ export interface AuthorSpan {
|
||||
author: AuthorKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task 7 (preview annotations, native-surfaces migration): the sentinel
|
||||
* vocabulary generalizes beyond per-author spans to a plain "del" tag — a
|
||||
* re-surfaced baseline deletion has no author variant (CSS carries one fixed
|
||||
* `.cw-del`, the F10 vocabulary). `injectSentinels`/`sentinelsToSpans` are
|
||||
* exported so `previewAnnotations.ts` can drive the SAME token-aware
|
||||
* discipline (#33/#47) for its 3-way ins-claude/ins-human/del marks;
|
||||
* `colorByAuthor` (F9/F10) keeps its narrower per-author call, unaffected.
|
||||
*/
|
||||
export type SentinelTag = AuthorKind | "del";
|
||||
export interface TaggedSpan {
|
||||
start: number;
|
||||
end: number;
|
||||
tag: SentinelTag;
|
||||
}
|
||||
|
||||
// Private-Use-Area sentinels (never appear in real content; markdown-it passes
|
||||
// them through as plain text). Paired open/close per author.
|
||||
const SENT = {
|
||||
// them through as plain text). Paired open/close per tag.
|
||||
const SENT: Record<SentinelTag, { open: string; close: string }> = {
|
||||
claude: { open: "", close: "" },
|
||||
human: { open: "", close: "" },
|
||||
} as const;
|
||||
del: { open: "", close: "" },
|
||||
};
|
||||
const SENT_TAGS = Object.keys(SENT) as SentinelTag[];
|
||||
|
||||
function isCloseSentinel(m: string): boolean {
|
||||
return m === SENT.claude.close || m === SENT.human.close;
|
||||
return SENT_TAGS.some((t) => SENT[t].close === m);
|
||||
}
|
||||
|
||||
// Markdown emphasis / code delimiters whose RUNS must never be split by an
|
||||
@@ -685,8 +739,8 @@ function blockContentStart(raw: string): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Inject paired sentinels into a prose block's raw text for the spans clipped to it. */
|
||||
function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]): string {
|
||||
/** Inject paired sentinels into a block's raw text for the (tagged) spans clipped to it. */
|
||||
export function injectSentinels(raw: string, blockStart: number, spans: TaggedSpan[]): string {
|
||||
const inserts: { at: number; marker: string }[] = [];
|
||||
// Open sentinels must not precede block-level markdown markers (heading/list/
|
||||
// blockquote syntax at position 0) — a PUA char before "## " prevents markdown-it
|
||||
@@ -696,8 +750,8 @@ function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]):
|
||||
const lo = Math.max(minOpen, clampOffDelimiterRun(raw, Math.max(0, s.start - blockStart)));
|
||||
const hi = clampOffDelimiterRun(raw, Math.min(raw.length, s.end - blockStart));
|
||||
if (hi <= lo) continue; // empty (or clamped to empty) span contributes nothing
|
||||
inserts.push({ at: lo, marker: SENT[s.author].open });
|
||||
inserts.push({ at: hi, marker: SENT[s.author].close });
|
||||
inserts.push({ at: lo, marker: SENT[s.tag].open });
|
||||
inserts.push({ at: hi, marker: SENT[s.tag].close });
|
||||
}
|
||||
// Apply high offset → low so earlier offsets stay valid. At an equal offset
|
||||
// (one span's close == the next's open), apply opens BEFORE closes so the
|
||||
@@ -708,34 +762,34 @@ function injectSentinels(raw: string, blockStart: number, spans: AuthorSpan[]):
|
||||
return out;
|
||||
}
|
||||
|
||||
const SENTINEL_OF: Record<string, { author: AuthorKind; open: boolean } | undefined> = {
|
||||
[SENT.claude.open]: { author: "claude", open: true },
|
||||
[SENT.claude.close]: { author: "claude", open: false },
|
||||
[SENT.human.open]: { author: "human", open: true },
|
||||
[SENT.human.close]: { author: "human", open: false },
|
||||
};
|
||||
const ALL_SENTINELS = new RegExp(
|
||||
`[${SENT.claude.open}${SENT.claude.close}${SENT.human.open}${SENT.human.close}]`,
|
||||
"g",
|
||||
);
|
||||
const SENTINEL_OF: Record<string, { tag: SentinelTag; open: boolean } | undefined> = {};
|
||||
for (const tag of SENT_TAGS) {
|
||||
SENTINEL_OF[SENT[tag].open] = { tag, open: true };
|
||||
SENTINEL_OF[SENT[tag].close] = { tag, open: false };
|
||||
}
|
||||
const ALL_SENTINELS = new RegExp(`[${SENT_TAGS.map((t) => SENT[t].open + SENT[t].close).join("")}]`, "g");
|
||||
|
||||
/**
|
||||
* #33: token-aware replacement of the rendered author sentinels with `cw-ins-*`
|
||||
* spans. A naive global string-replace (the old approach) could emit a span that
|
||||
* #33: token-aware replacement of the rendered sentinels with `cw-*` spans. A
|
||||
* naive global string-replace (the old approach) could emit a span that
|
||||
* CROSSES an element boundary — `<span><strong>bo</span>ld</strong>` — when a
|
||||
* boundary fell inside an emphasis run (CASE3). This walks the rendered HTML and
|
||||
* emits the author span only around TEXT runs, CLOSING it before any `<tag>` and
|
||||
* emits the span only around TEXT runs, CLOSING it before any `<tag>` and
|
||||
* REOPENING it after, so a span is always well-nested within the inline elements
|
||||
* (one `<span>` segment per text run). Tags are copied verbatim (with any stray
|
||||
* sentinel stripped, so no Private-Use-Area char ever leaks). Pure, deterministic.
|
||||
* sentinel stripped, so no Private-Use-Area char ever leaks). `kind` picks the
|
||||
* author-class prefix (`cw-by-*`/`cw-ins-*`); the tag-less "del" mark (Task 7,
|
||||
* no author variant) always renders the fixed `cw-del` regardless of `kind`.
|
||||
* Pure, deterministic.
|
||||
*/
|
||||
function sentinelsToSpans(html: string, kind: "by" | "ins" = "by"): string {
|
||||
export function sentinelsToSpans(html: string, kind: "by" | "ins" = "by"): string {
|
||||
const out: string[] = [];
|
||||
let current: AuthorKind | null = null; // which author region we're inside
|
||||
let current: SentinelTag | null = null; // which region we're inside
|
||||
let spanOpen = false; // whether a <span> is currently open in `out`
|
||||
const classFor = (tag: SentinelTag): string => (tag === "del" ? "cw-del" : `cw-${kind}-${tag}`);
|
||||
const openSpan = () => {
|
||||
if (current && !spanOpen) {
|
||||
out.push(`<span class="cw-${kind}-${current}">`);
|
||||
out.push(`<span class="${classFor(current)}">`);
|
||||
spanOpen = true;
|
||||
}
|
||||
};
|
||||
@@ -749,7 +803,7 @@ function sentinelsToSpans(html: string, kind: "by" | "ins" = "by"): string {
|
||||
const ch = html[i];
|
||||
const sentinel = SENTINEL_OF[ch];
|
||||
if (sentinel) {
|
||||
if (sentinel.open) current = sentinel.author; // span opens lazily before the next text char
|
||||
if (sentinel.open) current = sentinel.tag; // span opens lazily before the next text char
|
||||
else {
|
||||
closeSpan();
|
||||
current = null;
|
||||
@@ -786,7 +840,11 @@ export function colorByAuthor(
|
||||
kind: "by" | "ins" = "by",
|
||||
): string {
|
||||
const overlapping = spans.filter((s) => s.end > blockStart && s.start < blockStart + raw.length);
|
||||
const injected = injectSentinels(raw, blockStart, overlapping);
|
||||
const injected = injectSentinels(
|
||||
raw,
|
||||
blockStart,
|
||||
overlapping.map((s) => ({ start: s.start, end: s.end, tag: s.author })),
|
||||
);
|
||||
return sentinelsToSpans(render(injected), kind);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,537 +0,0 @@
|
||||
/**
|
||||
* TrackChangesPreviewController — F7 vscode layer (spec §6.2/§6.4). Owns one
|
||||
* sealed webview panel per markdown document, beside the source editor. On open /
|
||||
* debounced edit / F6 baseline-epoch change it reads the baseline (from the
|
||||
* reused DiffViewController) + the live buffer, runs the pure render engine, and
|
||||
* posts the HTML. Pure read-only: never mutates the document, sidecar, or
|
||||
* baseline (INV-20). The webview is sealed: local assets only, strict CSP,
|
||||
* per-load nonce, no network (INV-21).
|
||||
*/
|
||||
import * as path from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import * as vscode from "vscode";
|
||||
import type { DiffViewController } from "./diffViewController";
|
||||
import type { AttributionController } from "./attributionController";
|
||||
import type { ProposalController } from "./proposalController";
|
||||
import { renderReview, renderPlain, diffBlocks, diffToBlockHunks, landedTextOf, type BlockOp } from "./trackChangesModel";
|
||||
import { buildFingerprint } from "./anchorer";
|
||||
import { isAuthorable } from "./workspacePath";
|
||||
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
|
||||
import type { LiveProgressUi } from "./liveProgressUi";
|
||||
import { promptEditInstruction } from "./editInstructionInput";
|
||||
|
||||
/**
|
||||
* F11: a host edit turn (selection/document text + instruction → rewrite).
|
||||
* Injectable for tests. #60: accepts optional turn options (onProgress/signal);
|
||||
* the arg is optional so existing test stubs that ignore it stay valid.
|
||||
*/
|
||||
type EditTurn = (instruction: string, text: string, opts?: RunEditTurnOptions) => Promise<EditTurnResult>;
|
||||
/** F11: what an Ask-Claude gesture edits — a resolved selection range, or the whole document. */
|
||||
type EditTarget = { kind: "range"; start: number; end: number } | { kind: "document" };
|
||||
|
||||
const VIEW_TYPE = "cowriting.trackChangesPreview";
|
||||
const DEBOUNCE_MS = 150;
|
||||
|
||||
/**
|
||||
* Inbound webview→host messages (intent only — the sealed webview never mutates,
|
||||
* INV-21/35). F10 carried the annotations toggle + ✓/✗ proposal decisions; F11
|
||||
* adds the toolbar intents (pin baseline / ask Claude).
|
||||
*/
|
||||
type ToolbarMsg =
|
||||
| { type: "setMode"; mode: "on" | "off" }
|
||||
| { type: "accept"; proposalId: string }
|
||||
| { type: "reject"; proposalId: string }
|
||||
| { type: "pinBaseline" }
|
||||
| { type: "askClaude"; scope: "document" }
|
||||
| { type: "askClaude"; scope: "selection"; start: number; end: number }
|
||||
| { type: "acceptAll" }
|
||||
| { type: "rejectAll" };
|
||||
|
||||
export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
private readonly panels = new Map<string, vscode.WebviewPanel>();
|
||||
private readonly lastModel = new Map<string, BlockOp[]>();
|
||||
private readonly debounces = new Map<string, NodeJS.Timeout>();
|
||||
/** F10: per-panel annotations mode — on (default) shows review marks, off is clean. */
|
||||
private readonly mode = new Map<string, "on" | "off">();
|
||||
/** F10 (PUC-6): off-panel indicator of pending proposals on the active doc. */
|
||||
private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 88);
|
||||
/**
|
||||
* F11: the host edit turn (INV-8 — runs host-side, @cline/sdk loaded lazily and
|
||||
* never bundled). Injectable so host E2E can stub it (no LLM in CI).
|
||||
*/
|
||||
private editTurn: EditTurn = async (instruction, text, opts) => {
|
||||
const { runEditTurn } = await import("./liveTurn");
|
||||
return runEditTurn(instruction, text, opts);
|
||||
};
|
||||
/**
|
||||
* The instruction prompt (the multi-line split-below webview box) for BOTH the
|
||||
* selection and document cases. A field so host E2E can stub it — the webview
|
||||
* DOM can't run in CI (mirrors `editTurn`). Also used by the editor's
|
||||
* `cowriting.editSelection` command (via this controller).
|
||||
*/
|
||||
askEditInstruction: (header: string) => Promise<string | undefined> = promptEditInstruction;
|
||||
/** Monotonic per-session counter minting a stable turnId for each Ask-Claude gesture. */
|
||||
private turnSeq = 0;
|
||||
private nextTurnSeq(): number {
|
||||
return ++this.turnSeq;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly diffView: DiffViewController,
|
||||
private readonly extensionUri: vscode.Uri,
|
||||
private readonly attribution: AttributionController,
|
||||
private readonly proposals: ProposalController,
|
||||
private readonly liveProgressUi: LiveProgressUi,
|
||||
) {
|
||||
this.disposables.push(
|
||||
// F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri;
|
||||
// the palette / keybinding pass nothing → fall back to the active editor.
|
||||
// #41: the explorer/tab right-click also passes the clicked Uri, which may
|
||||
// not be an open document yet — open it so we preview the clicked file, not
|
||||
// whatever happens to be the active editor.
|
||||
vscode.commands.registerCommand("cowriting.showTrackChangesPreview", async (uri?: vscode.Uri) => {
|
||||
if (uri) {
|
||||
const open = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString());
|
||||
this.show(open ?? (await vscode.workspace.openTextDocument(uri)));
|
||||
return;
|
||||
}
|
||||
this.show(vscode.window.activeTextEditor?.document);
|
||||
}),
|
||||
// F11: document-scoped Ask-Claude (also reused by #42's reach gateways).
|
||||
// Edits a markdown doc; the rewrite is diffed into F4 proposals.
|
||||
// #42 (INV-38): the editor/title/context (tab) entry passes the clicked
|
||||
// tab's resource Uri — target THAT document, opening it if it isn't already
|
||||
// an open buffer (mirrors showTrackChangesPreview's #41 resolution); the
|
||||
// palette / keybinding / editor/context pass nothing → the active editor.
|
||||
vscode.commands.registerCommand("cowriting.editDocument", async (uri?: vscode.Uri) => {
|
||||
const doc = uri
|
||||
? vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString()) ??
|
||||
(await vscode.workspace.openTextDocument(uri))
|
||||
: vscode.window.activeTextEditor?.document;
|
||||
if (!doc || !this.isMarkdown(doc)) {
|
||||
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to ask Claude to edit it.");
|
||||
return;
|
||||
}
|
||||
void this.askClaude(doc, { kind: "document" });
|
||||
}),
|
||||
vscode.workspace.onDidChangeTextDocument((e) => this.onEdit(e.document)),
|
||||
this.diffView.onDidChangeBaseline(({ uri }) => this.refreshByUri(uri)),
|
||||
this.proposals.onDidChangeProposals(({ uri }) => {
|
||||
this.refreshByUri(uri);
|
||||
this.updateStatus(uri);
|
||||
}),
|
||||
this.statusItem,
|
||||
);
|
||||
this.statusItem.command = "cowriting.showTrackChangesPreview";
|
||||
}
|
||||
|
||||
private isMarkdown(document: vscode.TextDocument): boolean {
|
||||
return document.languageId === "markdown";
|
||||
}
|
||||
|
||||
/** Open or reveal the preview for a markdown document (PUC-1). */
|
||||
show(document: vscode.TextDocument | undefined): void {
|
||||
if (!document || !this.isMarkdown(document)) {
|
||||
void vscode.window.showWarningMessage(
|
||||
"Cowriting: open a Markdown document to use the track-changes preview (F6 covers other files).",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const key = document.uri.toString();
|
||||
const existing = this.panels.get(key);
|
||||
if (existing) {
|
||||
existing.reveal(vscode.ViewColumn.Beside);
|
||||
this.refresh(document);
|
||||
return;
|
||||
}
|
||||
const name = path.basename(document.uri.path) || "untitled";
|
||||
const panel = vscode.window.createWebviewPanel(
|
||||
VIEW_TYPE,
|
||||
`Review: ${name}`,
|
||||
{ viewColumn: vscode.ViewColumn.Beside, preserveFocus: true },
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: false,
|
||||
localResourceRoots: [vscode.Uri.joinPath(this.extensionUri, "out", "media")],
|
||||
},
|
||||
);
|
||||
panel.webview.html = this.shellHtml(panel.webview);
|
||||
panel.onDidDispose(
|
||||
() => {
|
||||
this.panels.delete(key);
|
||||
this.lastModel.delete(key);
|
||||
this.mode.delete(key);
|
||||
// A panel is gone: re-show the off-panel indicator if proposals remain.
|
||||
this.updateStatus(key);
|
||||
},
|
||||
null,
|
||||
this.disposables,
|
||||
);
|
||||
// F10/F11: the webview posts the annotations toggle, ✓/✗ proposal decisions,
|
||||
// and (F11) the toolbar intents (pin baseline / ask Claude) back to the host.
|
||||
panel.webview.onDidReceiveMessage(
|
||||
(m: ToolbarMsg) => this.handleWebviewMessage(document, m),
|
||||
null,
|
||||
this.disposables,
|
||||
);
|
||||
this.panels.set(key, panel);
|
||||
// A panel is now open for this doc — the off-panel indicator is redundant.
|
||||
this.hideStatus();
|
||||
this.refresh(document);
|
||||
}
|
||||
|
||||
/**
|
||||
* Route an inbound webview intent through the existing seams (INV-35): the
|
||||
* annotations toggle + ✓/✗ proposal decisions (F10) and the toolbar gestures
|
||||
* (F11). Pin targets the PREVIEWED document (`DiffViewController.pin`), not the
|
||||
* active editor — the preview knows its bound doc (§6.7).
|
||||
*/
|
||||
private handleWebviewMessage(document: vscode.TextDocument, m: ToolbarMsg): void {
|
||||
const key = document.uri.toString();
|
||||
if (m?.type === "setMode" && (m.mode === "on" || m.mode === "off")) {
|
||||
this.mode.set(key, m.mode);
|
||||
this.refresh(document);
|
||||
} else if (m?.type === "accept" && m.proposalId) {
|
||||
void this.proposals
|
||||
.acceptById(this.proposals.keyFor(document), m.proposalId)
|
||||
.then(() => this.refresh(document));
|
||||
} else if (m?.type === "reject" && m.proposalId) {
|
||||
void this.proposals.rejectByIdInPlace(this.proposals.keyFor(document), m.proposalId).then(() => this.refresh(document));
|
||||
} else if (m?.type === "pinBaseline") {
|
||||
// F6 baseline store re-render arrives via the onDidChangeBaseline subscription.
|
||||
this.diffView.pin(document);
|
||||
} else if (m?.type === "askClaude") {
|
||||
const target: EditTarget =
|
||||
m.scope === "selection" ? { kind: "range", start: m.start, end: m.end } : { kind: "document" };
|
||||
void this.askClaude(document, target);
|
||||
} else if (m?.type === "acceptAll") {
|
||||
// #46 (INV-42): batch-accept every pending proposal on this doc, then report.
|
||||
void this.acceptAll(document);
|
||||
} else if (m?.type === "rejectAll") {
|
||||
void this.rejectAll(document);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #46 (INV-42): apply every pending proposal on the document through the F4
|
||||
* accept seam (orphan-skip) and report the applied-vs-skipped tally. No
|
||||
* confirmation dialog — VS Code undo restores (parity with single accept).
|
||||
* Public so the `cowriting.acceptAllProposals` command can reach it for the
|
||||
* active doc (not only the webview button).
|
||||
*/
|
||||
async acceptAll(document: vscode.TextDocument): Promise<void> {
|
||||
const { applied, skipped } = await this.proposals.acceptAllProposals(document);
|
||||
this.refresh(document);
|
||||
if (applied === 0 && skipped === 0) return;
|
||||
const skipNote = skipped > 0 ? `, ${skipped} skipped (target text changed — undo or reject)` : "";
|
||||
void vscode.window.showInformationMessage(
|
||||
`Cowriting: accepted ${applied} proposal${applied === 1 ? "" : "s"}${skipNote}.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** #64 (INV-53): revert every pending proposal on the document; report the count. */
|
||||
async rejectAll(document: vscode.TextDocument): Promise<void> {
|
||||
const { reverted } = await this.proposals.rejectAll(document);
|
||||
this.refresh(document);
|
||||
if (reverted > 0) {
|
||||
void vscode.window.showInformationMessage(
|
||||
`Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* F11 (PUC-3/4): prompt host-side for the instruction (keeps the LLM/secret
|
||||
* surface out of the sealed webview, INV-8/35), run the edit turn, and surface
|
||||
* the result as F4 proposal(s). UI wrapper around `runEditAndPropose`.
|
||||
*/
|
||||
private async askClaude(document: vscode.TextDocument, target: EditTarget): Promise<void> {
|
||||
// Both scopes use the same multi-line split-below webview box; only the header
|
||||
// (and the downstream proposal logic) differs. For a selection the document
|
||||
// above keeps the selection highlighted while the box is open.
|
||||
const header =
|
||||
target.kind === "document" ? "Ask Claude to Edit This Document" : "Ask Claude to Edit This Selection";
|
||||
const instruction = await this.askEditInstruction(header);
|
||||
if (!instruction) return;
|
||||
try {
|
||||
const ids = await vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: "Cowriting: asking Claude…",
|
||||
cancellable: true,
|
||||
},
|
||||
async (progress, token) => {
|
||||
const ui = this.liveProgressUi.begin(instruction, progress, token);
|
||||
try {
|
||||
return await this.runEditAndPropose(document, target, instruction, {
|
||||
onProgress: ui.onProgress,
|
||||
signal: ui.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
// #60 (INV-47): a user cancel proposes nothing (the benign empty path).
|
||||
if (token.isCancellationRequested) return [] as string[];
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
if (ids.length === 0) {
|
||||
void vscode.window.showInformationMessage("Cowriting: Claude proposed no changes.");
|
||||
} else {
|
||||
void vscode.window.showInformationMessage(
|
||||
`Cowriting: Claude proposed ${ids.length} edit${ids.length === 1 ? "" : "s"} — review ✓/✗ in the preview.`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
void vscode.window.showErrorMessage(`Cowriting: Claude edit failed — ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* F11/F12 (INV-35/39): run one host edit turn and record the result as F4
|
||||
* proposal(s) — a SELECTION yields one single-range proposal over the resolved
|
||||
* block-union; a DOCUMENT rewrite is `diffToBlockHunks`'d into one proposal per
|
||||
* changed BLOCK (#47, INV-39 supersedes INV-37's per-word cut), each tagged
|
||||
* `granularity:"block"` so accept reconciles attribution per word (INV-40).
|
||||
* Never mutates the document (INV-10). Returns the created proposal ids.
|
||||
*/
|
||||
async runEditAndPropose(
|
||||
document: vscode.TextDocument,
|
||||
target: EditTarget,
|
||||
instruction: string,
|
||||
opts?: RunEditTurnOptions,
|
||||
): Promise<string[]> {
|
||||
const full = document.getText();
|
||||
// One turnId per gesture — the document case's N hunk-proposals all share it,
|
||||
// so a single rewrite groups as one agent turn (parity with editSelection).
|
||||
const turnId = `turn-${this.nextTurnSeq()}`;
|
||||
const provenance = (turn: EditTurnResult) =>
|
||||
({ kind: "agent" as const, id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId } });
|
||||
if (target.kind === "range") {
|
||||
const selected = full.slice(target.start, target.end);
|
||||
const turn = await this.editTurn(instruction, selected, opts);
|
||||
if (turn.replacement === "" || turn.replacement === selected) return [];
|
||||
const fp = buildFingerprint(full, { start: target.start, end: target.end });
|
||||
const id = await this.proposals.propose(document, fp, turn.replacement, provenance(turn), { turnId, instruction });
|
||||
return id ? [id] : [];
|
||||
}
|
||||
const turn = await this.editTurn(instruction, full, opts);
|
||||
const ids: string[] = [];
|
||||
// #47 (INV-39, supersedes INV-37): a document rewrite is cut at BLOCK
|
||||
// granularity — one proposal per changed block (the unit a human reviews) —
|
||||
// not per word. Each is tagged `granularity:"block"` so accept reconciles
|
||||
// attribution per word inside the block (INV-40).
|
||||
for (const h of diffToBlockHunks(full, turn.replacement)) {
|
||||
const fp = buildFingerprint(full, { start: h.start, end: h.end });
|
||||
const id = await this.proposals.propose(document, fp, h.replacement, provenance(turn), {
|
||||
turnId,
|
||||
instruction,
|
||||
granularity: "block",
|
||||
});
|
||||
if (id) ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private onEdit(document: vscode.TextDocument): void {
|
||||
const key = document.uri.toString();
|
||||
if (!this.panels.has(key)) return;
|
||||
const pending = this.debounces.get(key);
|
||||
if (pending) clearTimeout(pending);
|
||||
this.debounces.set(
|
||||
key,
|
||||
setTimeout(() => {
|
||||
this.debounces.delete(key);
|
||||
this.refresh(document);
|
||||
}, DEBOUNCE_MS),
|
||||
);
|
||||
}
|
||||
|
||||
private refreshByUri(uri: string): void {
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
|
||||
if (doc) this.refresh(doc);
|
||||
}
|
||||
|
||||
/** Recompute the model + post HTML to the panel for its current mode (no-op if no panel). */
|
||||
refresh(document: vscode.TextDocument): void {
|
||||
const key = document.uri.toString();
|
||||
const panel = this.panels.get(key);
|
||||
if (!panel) return;
|
||||
const mode = this.mode.get(key) ?? "on";
|
||||
const current = document.getText();
|
||||
const baseline = this.diffView.getBaseline(key);
|
||||
const baselineText = baseline?.text ?? current; // no baseline → no change-marks
|
||||
const ops = diffBlocks(baselineText, current);
|
||||
this.lastModel.set(key, ops);
|
||||
// F11 (PUC-1/7): edit controls are inert on a non-authorable doc (reading stays allowed).
|
||||
const authorable = isAuthorable(document.uri.scheme);
|
||||
if (mode === "off") {
|
||||
void panel.webview.postMessage({ type: "render", mode, html: renderPlain(current), authorable });
|
||||
return;
|
||||
}
|
||||
const spans = this.attribution.spansFor(document);
|
||||
const proposals = this.proposals.listProposals(document);
|
||||
// F12/#64 (INV-50): count added/removed against the LANDED text (current minus
|
||||
// pending proposals), matching the body — a pending change shows once, as a
|
||||
// proposal, and is not also tallied as a landed add/remove.
|
||||
const landedOps = diffBlocks(baselineText, landedTextOf(current, proposals));
|
||||
const summary = {
|
||||
added: landedOps.filter((o) => o.kind === "added").length,
|
||||
removed: landedOps.filter((o) => o.kind === "removed").length,
|
||||
proposals: proposals.length,
|
||||
};
|
||||
void panel.webview.postMessage({
|
||||
type: "render",
|
||||
mode,
|
||||
html: renderReview(baselineText, current, spans, proposals, { pinned: baseline?.reason === "pinned" }),
|
||||
epoch: this.epochLabel(baseline),
|
||||
summary,
|
||||
authorable,
|
||||
});
|
||||
}
|
||||
|
||||
/** F10 (PUC-6): off-panel proposal indicator on the active doc. Hidden when a panel is open. */
|
||||
private updateStatus(uri: string): void {
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
|
||||
if (!doc) {
|
||||
this.hideStatus();
|
||||
return;
|
||||
}
|
||||
const n = this.proposals.listProposals(doc).length;
|
||||
if (n === 0 || this.panels.has(uri)) {
|
||||
this.hideStatus();
|
||||
return;
|
||||
}
|
||||
this.statusItem.text = `$(comment-discussion) ${n} Claude proposal${n === 1 ? "" : "s"}`;
|
||||
this.statusItem.tooltip = "Cowriting: open the review preview to accept/reject Claude's proposals";
|
||||
this.statusItem.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the off-panel indicator AND clear its text, so the `statusText()` seam
|
||||
* is honest: a hidden indicator reports `undefined` (not its stale last value).
|
||||
*/
|
||||
private hideStatus(): void {
|
||||
this.statusItem.text = "";
|
||||
this.statusItem.hide();
|
||||
}
|
||||
|
||||
private epochLabel(baseline: { reason: string; capturedAt: string } | undefined): string {
|
||||
if (!baseline) return "opened (no baseline yet)";
|
||||
const time = new Date(baseline.capturedAt).toLocaleTimeString();
|
||||
switch (baseline.reason) {
|
||||
case "machine-landing":
|
||||
return `Claude landed ${time}`;
|
||||
case "pinned":
|
||||
return `pinned ${time}`;
|
||||
default:
|
||||
return `opened ${time}`;
|
||||
}
|
||||
}
|
||||
|
||||
private shellHtml(webview: vscode.Webview): string {
|
||||
const nonce = randomBytes(16).toString("base64");
|
||||
const scriptUri = webview.asWebviewUri(
|
||||
vscode.Uri.joinPath(this.extensionUri, "out", "media", "preview.js"),
|
||||
);
|
||||
const styleUri = webview.asWebviewUri(
|
||||
vscode.Uri.joinPath(this.extensionUri, "out", "media", "preview.css"),
|
||||
);
|
||||
// Sealed CSP (INV-21): no network. 'unsafe-inline' style is required only for
|
||||
// mermaid's dynamically injected <style> tags; scripts are nonce-gated and
|
||||
// strictly local (no remote/CDN script source).
|
||||
const csp =
|
||||
`default-src 'none'; ` +
|
||||
`img-src ${webview.cspSource} data:; ` +
|
||||
`font-src ${webview.cspSource}; ` +
|
||||
`style-src ${webview.cspSource} 'unsafe-inline'; ` +
|
||||
`script-src 'nonce-${nonce}';`;
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="${csp}" />
|
||||
<link href="${styleUri}" rel="stylesheet" />
|
||||
<title>Track changes</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="cw-header">
|
||||
<label id="cw-toggle"><input type="checkbox" id="cw-annotations" checked /> Annotations</label>
|
||||
<button id="cw-pin" type="button" title="Pin the review baseline to now (clears the change-marks)">⌖ Pin baseline</button>
|
||||
<button id="cw-ask" type="button" title="Ask Claude to edit (the selection if any, else the whole document)">✦ Ask Claude to Edit Document</button>
|
||||
<button id="cw-acceptall" type="button" hidden title="Accept every pending Claude proposal on this document">✓✓ Accept all</button>
|
||||
<span id="cw-epoch">Review</span>
|
||||
<span id="cw-summary"></span>
|
||||
<span id="cw-legend"></span>
|
||||
</div>
|
||||
<div id="cw-body"></div>
|
||||
<script nonce="${nonce}" src="${scriptUri}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ---- test seam (§6.4) ----
|
||||
isOpen(uriString: string): boolean {
|
||||
return this.panels.has(uriString);
|
||||
}
|
||||
/**
|
||||
* F11 test seam: deliver an inbound webview message to the real routing, as if
|
||||
* the sealed webview had posted it. Exercises message→seam wiring without a
|
||||
* live webview DOM (which is manual-smoke only). No-op if no doc/panel.
|
||||
*/
|
||||
receiveMessage(uriString: string, m: ToolbarMsg): void {
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
|
||||
if (doc && this.panels.has(uriString)) this.handleWebviewMessage(doc, m);
|
||||
}
|
||||
/** F11 test seam: stub the host edit turn so the document/selection paths run without an LLM. */
|
||||
setEditTurnForTest(fn: EditTurn): void {
|
||||
this.editTurn = fn;
|
||||
}
|
||||
/**
|
||||
* F11 (PUC-1/7): whether the previewed doc's edit controls (Pin + Ask-Claude)
|
||||
* are enabled — true only for an authorable doc. The annotations toggle is
|
||||
* always active (reading is always allowed). False if no panel/doc.
|
||||
*/
|
||||
editControlsEnabled(uriString: string): boolean {
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
|
||||
return doc ? isAuthorable(doc.uri.scheme) : false;
|
||||
}
|
||||
getLastModel(uriString: string): BlockOp[] | undefined {
|
||||
return this.lastModel.get(uriString);
|
||||
}
|
||||
/** F10 test seam: the review HTML the panel would post for a doc (on-state). */
|
||||
renderHtmlFor(uriString: string): string {
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
|
||||
if (!doc) return "";
|
||||
const current = doc.getText();
|
||||
const baseline = this.diffView.getBaseline(uriString);
|
||||
return renderReview(
|
||||
baseline?.text ?? current,
|
||||
current,
|
||||
this.attribution.spansFor(doc),
|
||||
this.proposals.listProposals(doc),
|
||||
{ pinned: baseline?.reason === "pinned" },
|
||||
);
|
||||
}
|
||||
/** F10: current annotations mode for a panel (default on). */
|
||||
getMode(uriString: string): "on" | "off" {
|
||||
return this.mode.get(uriString) ?? "on";
|
||||
}
|
||||
/** F10: set the annotations mode and re-render (the programmatic twin of the header toggle). */
|
||||
setMode(uriString: string, mode: "on" | "off"): void {
|
||||
this.mode.set(uriString, mode);
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
|
||||
if (doc) this.refresh(doc);
|
||||
}
|
||||
/** F10 test seam (SLICE-4 E2E): the off-panel status-bar indicator text, if shown. */
|
||||
statusText(): string | undefined {
|
||||
return this.statusItem.text || undefined;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const t of this.debounces.values()) clearTimeout(t);
|
||||
for (const p of this.panels.values()) p.dispose();
|
||||
for (const d of this.disposables) d.dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user