0d69a29228
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
499 lines
25 KiB
TypeScript
499 lines
25 KiB
TypeScript
import * as vscode from "vscode";
|
|
import { fetchSdkSummary } from "./cline";
|
|
import { CoauthorStore } from "./store";
|
|
import { ThreadController } from "./threadController";
|
|
import { AttributionController } from "./attributionController";
|
|
import { ProposalController } from "./proposalController";
|
|
import { buildFingerprint } from "./anchorer";
|
|
import { VersionGuard } from "./versionGuard";
|
|
import { BaselineStore } from "./baselineStore";
|
|
import { GlobalSidecarStore } from "./globalSidecarStore";
|
|
import { SidecarRouter } from "./sidecarRouter";
|
|
import { DiffViewController } from "./diffViewController";
|
|
import { GitBaselineAdapter } from "./gitBaseline";
|
|
import { EditFlow } from "./editFlow";
|
|
import { LiveProgressUi } from "./liveProgressUi";
|
|
import { EditorProposalController } from "./editorProposalController";
|
|
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)";
|
|
|
|
export interface CowritingApi {
|
|
threadController: ThreadController;
|
|
attributionController: AttributionController;
|
|
proposalController: ProposalController;
|
|
versionGuard: VersionGuard;
|
|
diffViewController: DiffViewController;
|
|
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 {
|
|
// --- POC command (Feature #2), unchanged ---
|
|
const output = vscode.window.createOutputChannel(CHANNEL_NAME);
|
|
context.subscriptions.push(output);
|
|
|
|
// #60: shared live-progress UI (notification activity line + "Cowriting: Claude"
|
|
// OutputChannel) for both Ask-Claude entry points.
|
|
const liveProgressUi = new LiveProgressUi();
|
|
context.subscriptions.push(liveProgressUi);
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand("cowriting.showClineSdkInfo", async () => {
|
|
try {
|
|
const summary = await fetchSdkSummary();
|
|
output.clear();
|
|
output.appendLine(`@cline/sdk version: ${summary.version}`);
|
|
output.appendLine(`Builtin tools (${summary.tools.length}):`);
|
|
for (const tool of summary.tools) output.appendLine(` • ${tool.id} — ${tool.description}`);
|
|
output.show(true);
|
|
await vscode.window.showInformationMessage(
|
|
`Cline SDK ${summary.version} loaded — ${summary.tools.length} builtin tools. See the "${CHANNEL_NAME}" output channel.`,
|
|
);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
output.appendLine(`Failed to drive @cline/sdk: ${message}`);
|
|
output.show(true);
|
|
await vscode.window.showErrorMessage(`Cowriting: failed to load @cline/sdk — ${message}`);
|
|
}
|
|
}),
|
|
);
|
|
|
|
// --- 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, 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
|
|
// doc. When globalStorageUri is unavailable, file writes throw (authoring
|
|
// degrades to errors-on-write, the F6-equivalent edge) but untitled in-memory
|
|
// still works — PUC-5.
|
|
const globalSidecarStore = new GlobalSidecarStore(baselineStorageDir ?? "");
|
|
|
|
// --- F8: authoring on ANY document (in-folder, out-of-folder, untitled) ---
|
|
// The router routes per-document: in-workspace file: → the committable repo
|
|
// `.threads/` sidecar (CoauthorStore, INV-2); out-of-folder file: + untitled:
|
|
// → the global-storage sidecar (GlobalSidecarStore). Constructed even with NO
|
|
// folder open (everything then routes global) — the F6 #19 precedent, now
|
|
// extended to authoring (F2 threads, F3 attribution, F4 propose/accept).
|
|
const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
|
const coauthorStore = root ? new CoauthorStore(root) : null;
|
|
const sidecarRouter = new SidecarRouter(coauthorStore, globalSidecarStore, root);
|
|
// 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);
|
|
|
|
// --- F3: live attribution (Feature #6) ---
|
|
const attributionController = new AttributionController(sidecarRouter, root, versionGuard, coeditingRegistry);
|
|
context.subscriptions.push(attributionController);
|
|
|
|
// --- F4: propose/accept (Feature #12) ---
|
|
const proposalController = new ProposalController(
|
|
sidecarRouter,
|
|
attributionController,
|
|
root,
|
|
versionGuard,
|
|
coeditingRegistry,
|
|
);
|
|
context.subscriptions.push(proposalController);
|
|
|
|
// --- 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,
|
|
coeditingRegistry,
|
|
);
|
|
context.subscriptions.push(editorProposalController);
|
|
|
|
// #46 (INV-42): accept every pending proposal on the active doc in one gesture
|
|
// (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;
|
|
if (!doc || doc.languageId !== "markdown") {
|
|
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to accept its proposals.");
|
|
return;
|
|
}
|
|
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
|
|
// (native-surfaces migration: routes directly to ProposalController).
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand("cowriting.rejectAllProposals", async () => {
|
|
const doc = vscode.window.activeTextEditor?.document;
|
|
if (!doc || doc.languageId !== "markdown") {
|
|
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to reject its proposals.");
|
|
return;
|
|
}
|
|
const { reverted } = await proposalController.rejectAll(doc);
|
|
if (reverted > 0) {
|
|
void vscode.window.showInformationMessage(
|
|
`Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}.`,
|
|
);
|
|
}
|
|
}),
|
|
);
|
|
|
|
// 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 —
|
|
// global artifacts live outside the workspace). Harmless when no folder is open.
|
|
const watcher = vscode.workspace.createFileSystemWatcher("**/.threads/**/*.json");
|
|
const onSidecar = (uri: vscode.Uri) => {
|
|
if (sidecarRouter.consumeSelfWrite(uri.fsPath)) return;
|
|
threadController.handleExternalSidecarChange(uri);
|
|
attributionController.handleExternalSidecarChange(uri);
|
|
proposalController.handleExternalSidecarChange(uri);
|
|
};
|
|
watcher.onDidChange(onSidecar);
|
|
watcher.onDidCreate(onSidecar);
|
|
context.subscriptions.push(watcher);
|
|
|
|
// The seam as a command (INV-9): the only machine-edit ingress, hidden from
|
|
// the palette — for the host E2E harness and future SDK turn plumbing.
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand(
|
|
"cowriting.applyAgentEdit",
|
|
(args: {
|
|
uri: string; start: number; end: number; newText: string;
|
|
model?: string; sessionId?: string; turnId?: string;
|
|
}) => {
|
|
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === args.uri);
|
|
if (!doc) return Promise.resolve(false);
|
|
const range = new vscode.Range(doc.positionAt(args.start), doc.positionAt(args.end));
|
|
const provenance = {
|
|
kind: "agent" as const,
|
|
id: "claude",
|
|
agent: { sdk: "@cline/sdk", model: args.model ?? "sonnet", sessionId: args.sessionId ?? "" },
|
|
};
|
|
return attributionController.applyAgentEdit(doc, range, args.newText, provenance, { turnId: args.turnId });
|
|
},
|
|
),
|
|
);
|
|
|
|
// The propose ingress as a command (spec §6.4): records a pending proposal,
|
|
// NEVER touches the document (INV-10) — for the host E2E harness.
|
|
context.subscriptions.push(
|
|
vscode.commands.registerCommand(
|
|
"cowriting.proposeAgentEdit",
|
|
(args: {
|
|
uri: string; start: number; end: number; newText: string;
|
|
model?: string; sessionId?: string; turnId?: string; instruction?: string;
|
|
}) => {
|
|
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === args.uri);
|
|
if (!doc) return Promise.resolve(undefined);
|
|
const fp = buildFingerprint(doc.getText(), { start: args.start, end: args.end });
|
|
const provenance = {
|
|
kind: "agent" as const,
|
|
id: "claude",
|
|
agent: { sdk: "@cline/sdk", model: args.model ?? "sonnet", sessionId: args.sessionId ?? "" },
|
|
};
|
|
return proposalController.propose(doc, fp, args.newText, provenance, {
|
|
turnId: args.turnId,
|
|
instruction: args.instruction,
|
|
});
|
|
},
|
|
),
|
|
);
|
|
|
|
// 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 () => {
|
|
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 (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. 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;
|
|
const route = routeEdit({
|
|
hasUri: !!uri,
|
|
uriMatchesActiveEditor: !!uri && editor?.document.uri.toString() === uri.toString(),
|
|
hasActiveEditor: !!editor,
|
|
selectionEmpty: editor?.selection.isEmpty ?? true,
|
|
});
|
|
if (route === "selection") {
|
|
await vscode.commands.executeCommand("cowriting.editSelection");
|
|
} else {
|
|
await vscode.commands.executeCommand("cowriting.editDocument", uri);
|
|
}
|
|
}),
|
|
);
|
|
|
|
// 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) && 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);
|
|
}
|
|
};
|
|
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,
|
|
editFlow,
|
|
sidecarRouter,
|
|
liveProgressUi,
|
|
editorProposalController,
|
|
coeditingRegistry,
|
|
scmSurfaceController,
|
|
previewAnnotationHost,
|
|
extendMarkdownIt: (md: unknown) => cowritingMarkdownItPlugin(md as MarkdownIt, previewAnnotationHost),
|
|
};
|
|
}
|
|
|
|
export function deactivate(): void {
|
|
// Disposables registered on the context handle cleanup.
|
|
}
|