From 7387366e28aa7e3055a9c9421d89a4a6c6ecb04f Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 13:11:39 -0700 Subject: [PATCH] refactor(f8): controllers depend on SidecarRouter + isAuthorable (key via keyOf) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §6.4: constructor store CoauthorStore→SidecarRouter, rootDir→string|undefined; isInRoot/isTracked gate → isAuthorable(scheme); per-doc key via store.keyOf(docIdentity). Seam + artifact logic unchanged. currentAuthor omits git email when no root (fail-open). Co-Authored-By: Claude Opus 4.8 --- src/attributionController.ts | 31 ++++++++++++++++--------------- src/proposalController.ts | 29 +++++++++++++++-------------- src/threadController.ts | 35 ++++++++++++++++------------------- 3 files changed, 47 insertions(+), 48 deletions(-) diff --git a/src/attributionController.ts b/src/attributionController.ts index 8744c22..e62b310 100644 --- a/src/attributionController.ts +++ b/src/attributionController.ts @@ -10,14 +10,14 @@ */ import * as fs from "node:fs"; import * as vscode from "vscode"; -import { CoauthorStore } from "./store"; +import { SidecarRouter, docIdentity } from "./sidecarRouter"; import { newId, type AttributionRecord, type Provenance } from "./model"; import { buildFingerprint, resolve, type OffsetRange } from "./anchorer"; import { gitUserEmail } from "./identity"; import { applyChange, coalesce, type LiveSpan } from "./attributionTracker"; import { minimizeReplace, PendingEditRegistry } from "./pendingEdits"; import type { VersionGuard } from "./versionGuard"; -import { isUnderRoot } from "./workspacePath"; +import { isAuthorable } from "./workspacePath"; /** Test-facing snapshot of live attribution state for a document. */ export interface RenderedSpan { @@ -74,8 +74,8 @@ export class AttributionController implements vscode.Disposable { readonly onDidApplyAgentEdit: vscode.Event<{ document: vscode.TextDocument }> = this.applyEmitter.event; constructor( - private readonly store: CoauthorStore, - private readonly rootDir: string, + private readonly store: SidecarRouter, + private readonly rootDir: string | undefined, private readonly guard: VersionGuard, ) { this.disposables.push(this.agentType, this.humanType, this.statusItem, this.output, this.applyEmitter); @@ -88,14 +88,15 @@ export class AttributionController implements vscode.Disposable { } private isTracked(document: vscode.TextDocument): boolean { - return document.uri.scheme === "file" && isUnderRoot(document.uri.fsPath, this.rootDir); + return isAuthorable(document.uri.scheme); } - private docPathOf(uri: vscode.Uri): string { - return vscode.workspace.asRelativePath(uri, false); + /** 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("user.name") || process.env.USER || "human"; - const email = gitUserEmail(this.rootDir); + const email = this.rootDir !== undefined ? gitUserEmail(this.rootDir) : undefined; return { kind: "human", id, ...(email !== undefined ? { email } : {}) }; } private state(docPath: string): DocAttribution { @@ -112,7 +113,7 @@ export class AttributionController implements vscode.Disposable { /** Load the sidecar and re-resolve every attribution (live span | orphan). */ loadAll(document: vscode.TextDocument): void { if (!this.isTracked(document)) return; - const docPath = this.docPathOf(document.uri); + const docPath = this.keyOf(document); const s = this.state(docPath); const artifact = this.store.load(docPath); s.spans = []; @@ -143,7 +144,7 @@ export class AttributionController implements vscode.Disposable { handleExternalSidecarChange(uri: vscode.Uri): void { for (const s of this.docs.values()) { if (this.store.sidecarPath(s.docPath) === uri.fsPath) { - const doc = vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === s.docPath); + const doc = vscode.workspace.textDocuments.find((d) => this.keyOf(d) === s.docPath); if (doc) this.loadAll(doc); } } @@ -153,7 +154,7 @@ export class AttributionController implements vscode.Disposable { private onDidChange(e: vscode.TextDocumentChangeEvent): void { if (!this.isTracked(e.document) || e.contentChanges.length === 0) return; - const docPath = this.docPathOf(e.document.uri); + const docPath = this.keyOf(e.document); if (!e.document.isDirty && this.matchesDisk(e.document)) { // Disk sync (revert / external reload): buffer now equals the file on // disk — re-resolve, never attribute (PUC-4). A real edit can also @@ -248,7 +249,7 @@ export class AttributionController implements vscode.Disposable { ): Promise { if (!this.isTracked(document)) return false; if (opts?.expectedVersion !== undefined && document.version !== opts.expectedVersion) return false; - const docPath = this.docPathOf(document.uri); + const docPath = this.keyOf(document); const startOffset = document.offsetAt(range.start); const endOffset = document.offsetAt(range.end); const oldText = document.getText(range); @@ -298,8 +299,8 @@ export class AttributionController implements vscode.Disposable { private onDidSave(document: vscode.TextDocument): void { if (!this.isTracked(document)) return; - if (this.guard.isReadOnly(this.docPathOf(document.uri))) return; - const docPath = this.docPathOf(document.uri); + if (this.guard.isReadOnly(this.keyOf(document))) return; + const docPath = this.keyOf(document); const s = this.docs.get(docPath); // Allow save when hadAttributions is true even if spans/orphans are now empty: // that means the user deliberately deleted all attributed text, and we must @@ -351,7 +352,7 @@ export class AttributionController implements vscode.Disposable { private render(document: vscode.TextDocument): void { if (!this.isTracked(document)) return; - const s = this.docs.get(this.docPathOf(document.uri)); + const s = this.docs.get(this.keyOf(document)); const spans = this.visible && s ? s.spans : []; const toRange = (sp: LiveSpan) => new vscode.Range(document.positionAt(sp.start), document.positionAt(sp.end)); diff --git a/src/proposalController.ts b/src/proposalController.ts index 6b15570..14ed4a4 100644 --- a/src/proposalController.ts +++ b/src/proposalController.ts @@ -10,13 +10,13 @@ * Claude-attributed with zero new attribution code. */ import * as vscode from "vscode"; -import { CoauthorStore } from "./store"; +import { SidecarRouter, docIdentity } from "./sidecarRouter"; import { emptyArtifact, type Artifact, type Fingerprint, type Proposal, type Provenance } from "./model"; import { resolve, shift, type OffsetRange } from "./anchorer"; import { addProposal, proposalBody, removeProposal } from "./proposalModel"; import type { AttributionController } from "./attributionController"; import type { VersionGuard } from "./versionGuard"; -import { isUnderRoot } from "./workspacePath"; +import { isAuthorable } from "./workspacePath"; /** Test-facing snapshot of what is currently rendered for a document. */ export interface RenderedProposal { @@ -54,9 +54,9 @@ export class ProposalController implements vscode.Disposable { private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 89); constructor( - private readonly store: CoauthorStore, + private readonly store: SidecarRouter, private readonly attribution: AttributionController, - private readonly rootDir: string, + private readonly rootDir: string | undefined, private readonly guard: VersionGuard, ) { // No commentingRangeProvider: humans never open proposal threads by hand — @@ -71,13 +71,14 @@ export class ProposalController implements vscode.Disposable { } private isTracked(document: vscode.TextDocument): boolean { - return document.uri.scheme === "file" && isUnderRoot(document.uri.fsPath, this.rootDir); + return isAuthorable(document.uri.scheme); } - private docPathOf(uri: vscode.Uri): string { - return vscode.workspace.asRelativePath(uri, false); + /** 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 ensureState(document: vscode.TextDocument): DocState { - const docPath = this.docPathOf(document.uri); + const docPath = this.keyOf(document); let state = this.docs.get(docPath); if (!state) { state = { @@ -108,8 +109,8 @@ export class ProposalController implements vscode.Disposable { opts?: { turnId?: string; instruction?: string }, ): Promise { if (!this.isTracked(document)) return undefined; - if (this.guard.isReadOnly(this.docPathOf(document.uri))) return undefined; - const docPath = this.docPathOf(document.uri); + if (this.guard.isReadOnly(this.keyOf(document))) return undefined; + const docPath = this.keyOf(document); let proposalId: string | undefined; this.store.update(docPath, (a) => { proposalId = addProposal(a, fp, replacement, author, opts).proposalId; @@ -185,7 +186,7 @@ export class ProposalController implements vscode.Disposable { /** Load + (re)render every pending proposal at its resolved anchor (or flagged). */ renderAll(document: vscode.TextDocument): void { if (!this.isTracked(document)) return; - const docPath = this.docPathOf(document.uri); + const docPath = this.keyOf(document); const state = this.ensureState(document); state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath); for (const vsThread of state.vsThreads.values()) vsThread.dispose(); @@ -212,14 +213,14 @@ export class ProposalController implements vscode.Disposable { handleExternalSidecarChange(uri: vscode.Uri): void { for (const state of this.docs.values()) { if (this.store.sidecarPath(state.docPath) === uri.fsPath) { - const doc = vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath); + 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.docPathOf(e.document.uri)); + 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 }; @@ -298,7 +299,7 @@ export class ProposalController implements vscode.Disposable { // ---- lookups ---------------------------------------------------------------------------- private openDoc(state: DocState): vscode.TextDocument | undefined { - return vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath); + return vscode.workspace.textDocuments.find((d) => this.keyOf(d) === state.docPath); } private byThread(vsThread: vscode.CommentThread): { state: DocState; proposal: Proposal } | undefined { for (const state of this.docs.values()) { diff --git a/src/threadController.ts b/src/threadController.ts index 515b54b..d5b7445 100644 --- a/src/threadController.ts +++ b/src/threadController.ts @@ -8,13 +8,13 @@ * orphaned thread (INV-1). */ import * as vscode from "vscode"; -import { CoauthorStore } from "./store"; +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 { isUnderRoot } from "./workspacePath"; +import { isAuthorable } from "./workspacePath"; /** Test-facing snapshot of what is currently rendered for a document. */ export interface RenderedThread { @@ -42,14 +42,14 @@ export class ThreadController implements vscode.Disposable { private readonly docs = new Map(); // keyed by docPath constructor( - private readonly store: CoauthorStore, - private readonly rootDir: string, + private readonly store: SidecarRouter, + private readonly rootDir: string | undefined, private readonly guard: VersionGuard, ) { this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads"); this.controller.commentingRangeProvider = { provideCommentingRanges: (document) => { - if (!this.isInRoot(document.uri)) return []; + if (!isAuthorable(document.uri.scheme)) return []; return [new vscode.Range(0, 0, Math.max(0, document.lineCount - 1), 0)]; }, }; @@ -69,17 +69,14 @@ export class ThreadController implements vscode.Disposable { ); } - private isInRoot(uri: vscode.Uri): boolean { - return uri.scheme === "file" && isUnderRoot(uri.fsPath, this.rootDir); - } - - private docPathOf(uri: vscode.Uri): string { - return vscode.workspace.asRelativePath(uri, false); + /** 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("user.name") || process.env.USER || "human"; - const email = gitUserEmail(this.rootDir); + const email = this.rootDir !== undefined ? gitUserEmail(this.rootDir) : undefined; return { kind: "human", id, ...(email !== undefined ? { email } : {}) }; } @@ -93,7 +90,7 @@ export class ThreadController implements vscode.Disposable { } private ensureState(document: vscode.TextDocument): DocState { - const docPath = this.docPathOf(document.uri); + const docPath = this.keyOf(document); let state = this.docs.get(docPath); if (!state) { state = { @@ -113,8 +110,8 @@ export class ThreadController implements vscode.Disposable { 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; + if (!editor || editor.selection.isEmpty || !isAuthorable(editor.document.uri.scheme)) return undefined; + if (this.guard.isReadOnly(this.keyOf(editor.document))) return undefined; const document = editor.document; const state = this.ensureState(document); const offsets: OffsetRange = { @@ -155,7 +152,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 { - const docPath = this.docPathOf(document.uri); + 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); @@ -182,14 +179,14 @@ export class ThreadController implements vscode.Disposable { 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); + 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.docPathOf(e.document.uri)); + 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 }; @@ -205,7 +202,7 @@ export class ThreadController implements vscode.Disposable { } private onDidSave(document: vscode.TextDocument): void { - const state = this.docs.get(this.docPathOf(document.uri)); + 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();