diff --git a/package.json b/package.json index 5e07a3c..55fcc91 100644 --- a/package.json +++ b/package.json @@ -12,15 +12,40 @@ }, "categories": ["Other"], "main": "./out/extension.cjs", - "activationEvents": [], + "activationEvents": ["onStartupFinished"], "contributes": { "commands": [ { "command": "cowriting.showClineSdkInfo", "title": "Cowriting: Show Cline SDK Info", "category": "Cowriting" - } - ] + }, + { + "command": "cowriting.createThread", + "title": "Cowriting: Add Coauthoring Thread on Selection", + "category": "Cowriting" + }, + { "command": "cowriting.reply", "title": "Reply", "category": "Cowriting" }, + { "command": "cowriting.resolveThread", "title": "Resolve Thread", "category": "Cowriting" }, + { "command": "cowriting.reopenThread", "title": "Reopen Thread", "category": "Cowriting" } + ], + "menus": { + "comments/commentThread/context": [ + { "command": "cowriting.reply", "group": "inline", "when": "commentController == cowriting.threads" } + ], + "comments/commentThread/title": [ + { + "command": "cowriting.resolveThread", + "group": "inline", + "when": "commentController == cowriting.threads && commentThread =~ /^open$/" + }, + { + "command": "cowriting.reopenThread", + "group": "inline", + "when": "commentController == cowriting.threads && commentThread =~ /^resolved$/" + } + ] + } }, "scripts": { "build": "node esbuild.mjs", diff --git a/src/extension.ts b/src/extension.ts index 1f10d70..8671a27 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,40 +1,56 @@ import * as vscode from "vscode"; import { fetchSdkSummary } from "./cline"; +import { CoauthorStore } from "./store"; +import { ThreadController } from "./threadController"; const CHANNEL_NAME = "Cowriting (Cline SDK)"; -export function activate(context: vscode.ExtensionContext): void { +export interface CowritingApi { + threadController: ThreadController; +} + +export function activate(context: vscode.ExtensionContext): CowritingApi | undefined { + // --- POC command (Feature #2), unchanged --- const output = vscode.window.createOutputChannel(CHANNEL_NAME); context.subscriptions.push(output); - - const command = vscode.commands.registerCommand( - "cowriting.showClineSdkInfo", - async () => { + context.subscriptions.push( + vscode.commands.registerCommand("cowriting.showClineSdkInfo", async () => { try { const summary = await fetchSdkSummary(); output.clear(); output.appendLine(`@cline/sdk version: ${summary.version}`); output.appendLine(`Builtin tools (${summary.tools.length}):`); - for (const tool of summary.tools) { - output.appendLine(` • ${tool.id} — ${tool.description}`); - } + for (const tool of summary.tools) output.appendLine(` • ${tool.id} — ${tool.description}`); output.show(true); await vscode.window.showInformationMessage( - `Cline SDK ${summary.version} loaded — ${summary.tools.length} builtin tools. See the "${CHANNEL_NAME}" output channel.` + `Cline SDK ${summary.version} loaded — ${summary.tools.length} builtin tools. See the "${CHANNEL_NAME}" output channel.`, ); } catch (err) { const message = err instanceof Error ? err.message : String(err); output.appendLine(`Failed to drive @cline/sdk: ${message}`); output.show(true); - await vscode.window.showErrorMessage( - `Cowriting: failed to load @cline/sdk — ${message}` - ); + await vscode.window.showErrorMessage(`Cowriting: failed to load @cline/sdk — ${message}`); } - } + }), ); - context.subscriptions.push(command); + + // --- F2: region-anchored threads (Feature #4) --- + const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!root) return undefined; // no workspace → nothing to anchor against + const store = new CoauthorStore(root); + const threadController = new ThreadController(store, root); + context.subscriptions.push(threadController); + + // Render threads for already-open editors, and on future opens. + const renderIfOpen = (doc: vscode.TextDocument) => { + if (doc.uri.scheme === "file" && doc.uri.fsPath.startsWith(root)) threadController.renderAll(doc); + }; + vscode.workspace.textDocuments.forEach(renderIfOpen); + context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen)); + + return { threadController }; } export function deactivate(): void { - // Nothing to clean up beyond the disposables registered on the context. + // Disposables registered on the context handle cleanup. } diff --git a/src/threadController.ts b/src/threadController.ts new file mode 100644 index 0000000..0631440 --- /dev/null +++ b/src/threadController.ts @@ -0,0 +1,291 @@ +/** + * 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 + * FileSystemWatcher-driven external re-anchoring (SLICE-4). 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 { addThread, appendMessage, setStatus } from "./threadModel"; + +/** 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 + /** sidecar paths we just wrote, to ignore our own watcher events. */ + private readonly selfWrites = new Set(); + + constructor(private readonly store: CoauthorStore, private readonly rootDir: string) { + 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)), + ); + + // Re-anchor on external change to any sidecar (a git pull, a manual edit). + const watcher = vscode.workspace.createFileSystemWatcher("**/.threads/**/*.json"); + const onSidecar = (uri: vscode.Uri) => this.onExternalSidecarChange(uri); + watcher.onDidChange(onSidecar); + watcher.onDidCreate(onSidecar); + this.disposables.push(watcher); + } + + 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"; + return { kind: "human", id }; + } + + private persist(state: DocState): void { + this.selfWrites.add(this.store.sidecarPath(state.docPath)); + this.store.save(state.docPath, state.artifact); + } + + 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; + 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; + 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; + 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); + } + } + } + + private onExternalSidecarChange(uri: vscode.Uri): void { + const p = uri.fsPath; + if (this.selfWrites.has(p)) { + this.selfWrites.delete(p); + return; // our own write + } + 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; + 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(); + } +} diff --git a/src/threadModel.ts b/src/threadModel.ts new file mode 100644 index 0000000..cded93a --- /dev/null +++ b/src/threadModel.ts @@ -0,0 +1,48 @@ +/** + * vscode-free mutations on the Artifact (spec §6.5). Keeping thread logic here + * (not in the vscode controller) keeps it unit-testable; ThreadController is a + * thin adapter that calls these and persists via CoauthorStore. + */ +import { + newId, + type Artifact, + type Fingerprint, + type Provenance, + type ThreadStatus, +} from "./model"; + +export interface NewMessage { + author: Provenance; + body: string; +} + +function message(m: NewMessage) { + return { id: newId("m"), author: m.author, body: m.body, createdAt: new Date().toISOString() }; +} + +/** Lazily-created anchor + thread for a new region discussion (PUC-1). */ +export function addThread( + artifact: Artifact, + fingerprint: Fingerprint, + first: NewMessage, +): { threadId: string; anchorId: string } { + const anchorId = newId("a"); + const threadId = newId("t"); + artifact.anchors[anchorId] = { fingerprint }; + artifact.threads.push({ id: threadId, anchorId, status: "open", messages: [message(first)] }); + return { threadId, anchorId }; +} + +/** Append a reply (PUC-2). Throws if the thread is unknown. */ +export function appendMessage(artifact: Artifact, threadId: string, m: NewMessage): void { + const t = artifact.threads.find((x) => x.id === threadId); + if (!t) throw new Error(`unknown thread id: ${threadId}`); + t.messages.push(message(m)); +} + +/** Resolve/reopen a thread (PUC-2). Throws if the thread is unknown. */ +export function setStatus(artifact: Artifact, threadId: string, status: ThreadStatus): void { + const t = artifact.threads.find((x) => x.id === threadId); + if (!t) throw new Error(`unknown thread id: ${threadId}`); + t.status = status; +} diff --git a/test/threadModel.test.ts b/test/threadModel.test.ts new file mode 100644 index 0000000..ab6dd4d --- /dev/null +++ b/test/threadModel.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { emptyArtifact, type Provenance } from "../src/model"; +import { addThread, appendMessage, setStatus } from "../src/threadModel"; + +const BEN: Provenance = { kind: "human", id: "ben" }; +const fp = { text: "anchored", before: "a", after: "b", lineHint: 3 }; + +describe("addThread", () => { + it("adds an anchor + an open thread with the first message and returns their ids", () => { + const a = emptyArtifact("docs/x.md"); + const { threadId, anchorId } = addThread(a, fp, { author: BEN, body: "first" }); + expect(Object.keys(a.anchors)).toContain(anchorId); + expect(a.anchors[anchorId].fingerprint).toEqual(fp); + const t = a.threads.find((x) => x.id === threadId)!; + expect(t.anchorId).toBe(anchorId); + expect(t.status).toBe("open"); + expect(t.messages).toHaveLength(1); + expect(t.messages[0].body).toBe("first"); + expect(t.messages[0].author).toEqual(BEN); + expect(t.messages[0].id).toMatch(/^m_/); + expect(t.messages[0].createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); +}); + +describe("appendMessage", () => { + it("appends a message to the named thread", () => { + const a = emptyArtifact("docs/x.md"); + const { threadId } = addThread(a, fp, { author: BEN, body: "first" }); + appendMessage(a, threadId, { author: BEN, body: "reply" }); + const t = a.threads.find((x) => x.id === threadId)!; + expect(t.messages.map((m) => m.body)).toEqual(["first", "reply"]); + }); + + it("throws for an unknown thread id", () => { + const a = emptyArtifact("docs/x.md"); + expect(() => appendMessage(a, "nope", { author: BEN, body: "x" })).toThrow(/nope/); + }); +}); + +describe("setStatus", () => { + it("flips a thread between open and resolved", () => { + const a = emptyArtifact("docs/x.md"); + const { threadId } = addThread(a, fp, { author: BEN, body: "first" }); + setStatus(a, threadId, "resolved"); + expect(a.threads[0].status).toBe("resolved"); + setStatus(a, threadId, "open"); + expect(a.threads[0].status).toBe("open"); + }); +});