/** * 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 { CoauthorStore } from "./store"; 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"; /** 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 }; } interface DocState { docPath: string; uri: vscode.Uri; artifact: Artifact; /** thread id -> vscode thread. */ vsThreads: Map; /** thread id -> live offset range (within-session optimization, INV-3). */ live: Map; /** thread id -> orphaned flag for the current render. */ orphaned: Map; } export class ThreadController implements vscode.Disposable { private readonly controller: vscode.CommentController; private readonly disposables: vscode.Disposable[] = []; private readonly docs = new Map(); // keyed by docPath constructor( private readonly store: CoauthorStore, private readonly rootDir: string, private readonly guard: VersionGuard, ) { this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads"); this.controller.commentingRangeProvider = { provideCommentingRanges: (document) => { if (!this.isInRoot(document.uri)) return []; return [new vscode.Range(0, 0, Math.max(0, document.lineCount - 1), 0)]; }, }; 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"), ), vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)), vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)), ); } private isInRoot(uri: vscode.Uri): boolean { return uri.scheme === "file" && uri.fsPath.startsWith(this.rootDir); } private docPathOf(uri: vscode.Uri): string { return vscode.workspace.asRelativePath(uri, false); } private currentAuthor(): Provenance { const id = vscode.workspace.getConfiguration("git").get("user.name") || process.env.USER || "human"; const email = gitUserEmail(this.rootDir); 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.docPathOf(document.uri); 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(), }; this.docs.set(docPath, state); } return state; } // ---- PUC-1: create on selection ------------------------------------------------- async createThreadOnSelection(firstBody = "New thread"): Promise { const editor = vscode.window.activeTextEditor; if (!editor || editor.selection.isEmpty || !this.isInRoot(editor.document.uri)) return undefined; if (this.guard.isReadOnly(this.docPathOf(editor.document.uri))) 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 { threadId } = addThread(state.artifact, fp, { author: this.currentAuthor(), body: firstBody }); this.persist(state); this.renderThread(document, state, threadId, offsets, false); 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; if (this.guard.isReadOnly(state.docPath)) return; appendMessage(state.artifact, threadId, { author: this.currentAuthor(), body: r.text }); this.persist(state); this.refreshComments(r.thread, state, threadId); } 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; vsThread.contextValue = status; } // ---- 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 { const docPath = this.docPathOf(document.uri); 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(); 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.docPathOf(d.uri) === state.docPath); if (doc) this.renderAll(doc); } } } private onDidChange(e: vscode.TextDocumentChangeEvent): void { const state = this.docs.get(this.docPathOf(e.document.uri)); 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.docPathOf(document.uri)); 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, ): void { 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.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); } 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; } // ---- 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 }, }); } return out; } dispose(): void { for (const d of this.disposables) d.dispose(); } }