feat: gate every surface on CoeditingRegistry (INV-10) — commenting ranges re-assigned on gate change (spec §6.4 v0.2.1)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-07-02 07:41:13 -07:00
parent cf65528711
commit a323b827a8
18 changed files with 171 additions and 18 deletions
+40 -6
View File
@@ -15,6 +15,7 @@ import { gitUserEmail } from "./identity";
import { addThread, appendMessage, setStatus } from "./threadModel";
import type { VersionGuard } from "./versionGuard";
import { isAuthorable } from "./workspacePath";
import type { CoeditingRegistry } from "./coeditingRegistry";
/** Test-facing snapshot of what is currently rendered for a document. */
export interface RenderedThread {
@@ -41,18 +42,24 @@ export class ThreadController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
private readonly docs = new Map<string, DocState>(); // keyed by docPath
/** 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,
) {
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(
@@ -66,6 +73,20 @@ export class ThreadController implements vscode.Disposable {
),
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));
}
}),
);
}
@@ -111,6 +132,7 @@ export class ThreadController implements vscode.Disposable {
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);
@@ -152,6 +174,7 @@ export class ThreadController implements vscode.Disposable {
/** 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)
@@ -269,6 +292,17 @@ 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();
}
// ---- test-facing surface --------------------------------------------------------
getRendered(docPath: string): RenderedThread[] {