572 lines
26 KiB
TypeScript
572 lines
26 KiB
TypeScript
/**
|
|
* ThreadController — the thin editor-facing layer (spec §6.4). Wires
|
|
* CoauthorStore + Anchorer + threadModel to vscode.comments: create-on-
|
|
* selection, reply, resolve, render, live-range tracking, reload, and external
|
|
* re-anchoring (SLICE-4) — driven by the SHARED sidecar watcher in extension.ts
|
|
* via handleExternalSidecarChange (self-writes are suppressed in CoauthorStore).
|
|
* Threads are NEVER silently moved — an unresolvable anchor renders as an
|
|
* orphaned thread (INV-1).
|
|
*/
|
|
import * as vscode from "vscode";
|
|
import { SidecarRouter, docIdentity } from "./sidecarRouter";
|
|
import { emptyArtifact, type Artifact, type Provenance } from "./model";
|
|
import { buildFingerprint, resolve, shift, type OffsetRange } from "./anchorer";
|
|
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 {
|
|
id: string;
|
|
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;
|
|
artifact: Artifact;
|
|
/** thread id -> vscode thread. */
|
|
vsThreads: Map<string, vscode.CommentThread>;
|
|
/** thread id -> live offset range (within-session optimization, INV-3). */
|
|
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 = this.rangeProvider;
|
|
this.disposables.push(this.controller);
|
|
|
|
this.disposables.push(
|
|
vscode.commands.registerCommand("cowriting.createThread", () => this.createThreadOnSelection()),
|
|
vscode.commands.registerCommand("cowriting.reply", (r: vscode.CommentReply) => this.reply(r)),
|
|
vscode.commands.registerCommand("cowriting.resolveThread", (t: vscode.CommentThread) =>
|
|
this.setThreadStatus(t, "resolved"),
|
|
),
|
|
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));
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** The single document key (F8): repo-relative path in-workspace, URI string otherwise. */
|
|
private keyOf(document: vscode.TextDocument): string {
|
|
return this.store.keyOf(docIdentity(document));
|
|
}
|
|
|
|
private currentAuthor(): Provenance {
|
|
const id = vscode.workspace.getConfiguration("git").get<string>("user.name") || process.env.USER || "human";
|
|
const email = this.rootDir !== undefined ? gitUserEmail(this.rootDir) : undefined;
|
|
return { kind: "human", id, ...(email !== undefined ? { email } : {}) };
|
|
}
|
|
|
|
private persist(state: DocState): void {
|
|
this.store.update(state.docPath, (a) => {
|
|
a.threads = state.artifact.threads;
|
|
for (const t of state.artifact.threads) {
|
|
a.anchors[t.anchorId] = state.artifact.anchors[t.anchorId];
|
|
}
|
|
});
|
|
}
|
|
|
|
private ensureState(document: vscode.TextDocument): DocState {
|
|
const docPath = this.keyOf(document);
|
|
let state = this.docs.get(docPath);
|
|
if (!state) {
|
|
state = {
|
|
docPath,
|
|
uri: document.uri,
|
|
artifact: this.store.load(docPath) ?? emptyArtifact(docPath),
|
|
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);
|
|
const offsets: OffsetRange = {
|
|
start: document.offsetAt(editor.selection.start),
|
|
end: document.offsetAt(editor.selection.end),
|
|
};
|
|
const fp = buildFingerprint(document.getText(), offsets);
|
|
const author = this.currentAuthor();
|
|
const { threadId } = addThread(state.artifact, fp, { author, body: firstBody });
|
|
this.persist(state);
|
|
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 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;
|
|
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(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 {
|
|
const threadId = this.threadIdOf(vsThread);
|
|
const state = this.stateOfThread(vsThread);
|
|
if (!threadId || !state) return;
|
|
if (this.guard.isReadOnly(state.docPath)) return;
|
|
setStatus(state.artifact, threadId, status);
|
|
this.persist(state);
|
|
vsThread.state = status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved;
|
|
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)
|
|
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
|
|
for (const vsThread of state.vsThreads.values()) vsThread.dispose();
|
|
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;
|
|
const resolved = fp ? resolve(text, fp) : "orphaned";
|
|
if (resolved === "orphaned") {
|
|
const line = fp ? Math.min(fp.lineHint, Math.max(0, document.lineCount - 1)) : 0;
|
|
const off = document.offsetAt(new vscode.Position(line, 0));
|
|
this.renderThread(document, state, thread.id, { start: off, end: off }, true);
|
|
} else {
|
|
this.renderThread(document, state, thread.id, resolved, false);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Shared-watcher entry point (extension.ts): a sidecar changed externally. */
|
|
handleExternalSidecarChange(uri: vscode.Uri): void {
|
|
const p = uri.fsPath;
|
|
for (const state of this.docs.values()) {
|
|
if (this.store.sidecarPath(state.docPath) === p) {
|
|
const doc = vscode.workspace.textDocuments.find((d) => this.keyOf(d) === state.docPath);
|
|
if (doc) this.renderAll(doc);
|
|
}
|
|
}
|
|
}
|
|
|
|
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
|
|
const state = this.docs.get(this.keyOf(e.document));
|
|
if (!state || state.live.size === 0) return;
|
|
for (const change of e.contentChanges) {
|
|
const edit = { start: change.rangeOffset, end: change.rangeOffset + change.rangeLength, newLength: change.text.length };
|
|
for (const [id, range] of state.live) {
|
|
const next = shift(range, edit);
|
|
state.live.set(id, next);
|
|
const vsThread = state.vsThreads.get(id);
|
|
if (vsThread && !state.orphaned.get(id)) {
|
|
vsThread.range = new vscode.Range(e.document.positionAt(next.start), e.document.positionAt(next.end));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private onDidSave(document: vscode.TextDocument): void {
|
|
const state = this.docs.get(this.keyOf(document));
|
|
if (!state || state.live.size === 0) return;
|
|
if (this.guard.isReadOnly(state.docPath)) return;
|
|
const text = document.getText();
|
|
let changed = false;
|
|
for (const thread of state.artifact.threads) {
|
|
if (state.orphaned.get(thread.id)) continue;
|
|
const range = state.live.get(thread.id);
|
|
if (!range) continue;
|
|
state.artifact.anchors[thread.anchorId] = { fingerprint: buildFingerprint(text, range) };
|
|
changed = true;
|
|
}
|
|
if (changed) this.persist(state);
|
|
}
|
|
|
|
// ---- rendering helpers ----------------------------------------------------------
|
|
|
|
private renderThread(
|
|
document: vscode.TextDocument,
|
|
state: DocState,
|
|
threadId: string,
|
|
offsets: OffsetRange,
|
|
orphaned: boolean,
|
|
): 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(
|
|
document.uri,
|
|
range,
|
|
thread.messages.map((m) => this.toComment(m.body, m.author.id)),
|
|
);
|
|
vsThread.label = orphaned ? "⚠ Orphaned thread (anchor not found)" : undefined;
|
|
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 {
|
|
const thread = state.artifact.threads.find((t) => t.id === threadId)!;
|
|
vsThread.comments = thread.messages.map((m) => this.toComment(m.body, m.author.id));
|
|
}
|
|
|
|
private toComment(body: string, authorName: string): vscode.Comment {
|
|
return {
|
|
body: new vscode.MarkdownString(body),
|
|
mode: vscode.CommentMode.Preview,
|
|
author: { name: authorName },
|
|
};
|
|
}
|
|
|
|
private threadIdOf(vsThread: vscode.CommentThread): string | undefined {
|
|
for (const state of this.docs.values()) {
|
|
for (const [id, t] of state.vsThreads) if (t === vsThread) return id;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
private stateOfThread(vsThread: vscode.CommentThread): DocState | undefined {
|
|
for (const state of this.docs.values()) {
|
|
for (const t of state.vsThreads.values()) if (t === vsThread) return state;
|
|
}
|
|
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[] {
|
|
const state = this.docs.get(docPath);
|
|
if (!state) return [];
|
|
const out: RenderedThread[] = [];
|
|
for (const [id, vsThread] of state.vsThreads) {
|
|
const t = state.artifact.threads.find((x) => x.id === id)!;
|
|
const r = vsThread.range;
|
|
out.push({
|
|
id,
|
|
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;
|
|
}
|
|
|
|
dispose(): void {
|
|
for (const d of this.disposables) d.dispose();
|
|
}
|
|
}
|