644885c6ec
Surface Claude live output/progress during the asking-Claude status: notification activity line + token count, a Cowriting: Claude OutputChannel streaming assistant text, and a cancellable turn. Pure turnProgress reducer + runEditTurn onProgress/AbortSignal (vscode-free) + both call sites. INV-43..47. Fixes #60
514 lines
22 KiB
TypeScript
514 lines
22 KiB
TypeScript
/**
|
|
* 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, type BlockOp } from "./trackChangesModel";
|
|
import { buildFingerprint } from "./anchorer";
|
|
import { isAuthorable } from "./workspacePath";
|
|
import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn";
|
|
import type { LiveProgressUi } from "./liveProgressUi";
|
|
|
|
/**
|
|
* 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" };
|
|
|
|
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);
|
|
};
|
|
/** 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) {
|
|
this.proposals.rejectById(this.proposals.keyFor(document), m.proposalId);
|
|
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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* #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}.`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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> {
|
|
const instruction = await vscode.window.showInputBox({
|
|
prompt:
|
|
target.kind === "document"
|
|
? "What should Claude do with the document?"
|
|
: "What should Claude do with the selection?",
|
|
placeHolder: "e.g. tighten the prose",
|
|
});
|
|
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);
|
|
const summary = {
|
|
added: ops.filter((o) => o.kind === "added").length,
|
|
removed: ops.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();
|
|
}
|
|
}
|