F2 SLICE-3: ThreadController on the Comments API + thread mutations (#4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+28
-3
@@ -12,15 +12,40 @@
|
|||||||
},
|
},
|
||||||
"categories": ["Other"],
|
"categories": ["Other"],
|
||||||
"main": "./out/extension.cjs",
|
"main": "./out/extension.cjs",
|
||||||
"activationEvents": [],
|
"activationEvents": ["onStartupFinished"],
|
||||||
"contributes": {
|
"contributes": {
|
||||||
"commands": [
|
"commands": [
|
||||||
{
|
{
|
||||||
"command": "cowriting.showClineSdkInfo",
|
"command": "cowriting.showClineSdkInfo",
|
||||||
"title": "Cowriting: Show Cline SDK Info",
|
"title": "Cowriting: Show Cline SDK Info",
|
||||||
"category": "Cowriting"
|
"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": {
|
"scripts": {
|
||||||
"build": "node esbuild.mjs",
|
"build": "node esbuild.mjs",
|
||||||
|
|||||||
+31
-15
@@ -1,40 +1,56 @@
|
|||||||
import * as vscode from "vscode";
|
import * as vscode from "vscode";
|
||||||
import { fetchSdkSummary } from "./cline";
|
import { fetchSdkSummary } from "./cline";
|
||||||
|
import { CoauthorStore } from "./store";
|
||||||
|
import { ThreadController } from "./threadController";
|
||||||
|
|
||||||
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
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);
|
const output = vscode.window.createOutputChannel(CHANNEL_NAME);
|
||||||
context.subscriptions.push(output);
|
context.subscriptions.push(output);
|
||||||
|
context.subscriptions.push(
|
||||||
const command = vscode.commands.registerCommand(
|
vscode.commands.registerCommand("cowriting.showClineSdkInfo", async () => {
|
||||||
"cowriting.showClineSdkInfo",
|
|
||||||
async () => {
|
|
||||||
try {
|
try {
|
||||||
const summary = await fetchSdkSummary();
|
const summary = await fetchSdkSummary();
|
||||||
output.clear();
|
output.clear();
|
||||||
output.appendLine(`@cline/sdk version: ${summary.version}`);
|
output.appendLine(`@cline/sdk version: ${summary.version}`);
|
||||||
output.appendLine(`Builtin tools (${summary.tools.length}):`);
|
output.appendLine(`Builtin tools (${summary.tools.length}):`);
|
||||||
for (const tool of summary.tools) {
|
for (const tool of summary.tools) output.appendLine(` • ${tool.id} — ${tool.description}`);
|
||||||
output.appendLine(` • ${tool.id} — ${tool.description}`);
|
|
||||||
}
|
|
||||||
output.show(true);
|
output.show(true);
|
||||||
await vscode.window.showInformationMessage(
|
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) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
output.appendLine(`Failed to drive @cline/sdk: ${message}`);
|
output.appendLine(`Failed to drive @cline/sdk: ${message}`);
|
||||||
output.show(true);
|
output.show(true);
|
||||||
await vscode.window.showErrorMessage(
|
await vscode.window.showErrorMessage(`Cowriting: failed to load @cline/sdk — ${message}`);
|
||||||
`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 {
|
export function deactivate(): void {
|
||||||
// Nothing to clean up beyond the disposables registered on the context.
|
// Disposables registered on the context handle cleanup.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
/** sidecar paths we just wrote, to ignore our own watcher events. */
|
||||||
|
private readonly selfWrites = new Set<string>();
|
||||||
|
|
||||||
|
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<string>("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<string | undefined> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user