F2: region-anchored threads on @cline/sdk (Feature #4) #5
@@ -1,3 +1,7 @@
|
||||
node_modules/
|
||||
out/
|
||||
*.vsix
|
||||
.vscode-test/
|
||||
|
||||
# E2E throwaway sidecars (the harness copies the fixture to a tmpdir, but guard anyway)
|
||||
test/e2e/fixtures/workspace/.threads/
|
||||
|
||||
@@ -25,8 +25,28 @@ catalog (a pure, key-free SDK call) in a notification and the
|
||||
3. Press **F5** (or Run → "Run Extension") to launch the Extension Development Host.
|
||||
4. In the new window: **Cmd/Ctrl+Shift+P** → **"Cowriting: Show Cline SDK Info"**.
|
||||
|
||||
## F2 — Region-anchored threads (Feature #4)
|
||||
|
||||
Attach durable, region-anchored discussion threads to any document. Threads
|
||||
render in the native VS Code **Comments** gutter and persist as git-native
|
||||
sidecars under `.threads/<doc-path>.json` (plain, diffable JSON — no server).
|
||||
|
||||
- **Create:** select text → run **"Cowriting: Add Coauthoring Thread on Selection"**
|
||||
(or the Comments gutter "+").
|
||||
- **Reply / Resolve:** use the native Comments reply box and the thread's
|
||||
Resolve/Reopen actions.
|
||||
- **Survives edits, reload, and external change (`git pull`):** a hybrid anchor
|
||||
(durable content fingerprint + live offset tracking) re-resolves the thread.
|
||||
If the anchored text can't be confidently re-found, the thread is shown as
|
||||
**orphaned** at its last-known line — never silently moved.
|
||||
|
||||
Design: `vscode-cowriting-plugin-content/specs/coauthoring-inner-loop.md`. No live
|
||||
`@cline/sdk` turn and no credentials are involved in F2.
|
||||
|
||||
## Develop
|
||||
|
||||
- `npm run watch` — rebuild on change.
|
||||
- `npm test` — run the unit test for the SDK driver.
|
||||
- `npm test` — vitest unit suite (SDK driver, schema, store, anchorer, thread mutations).
|
||||
- `npm run test:e2e` — `@vscode/test-electron` host E2E
|
||||
(create → reply → resolve → persist → reload → re-anchor → orphan).
|
||||
- `npm run typecheck` — type-check without emit.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+1507
File diff suppressed because it is too large
Load Diff
+34
-3
@@ -12,30 +12,61 @@
|
||||
},
|
||||
"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",
|
||||
"watch": "node esbuild.mjs --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"pretest:e2e": "npm run build && tsc -p tsconfig.e2e.json",
|
||||
"test:e2e": "node ./out/test/e2e/runTest.js",
|
||||
"vscode:prepublish": "node esbuild.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cline/sdk": "0.0.46"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mocha": "^10.0.7",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/vscode": "^1.90.0",
|
||||
"@vscode/test-electron": "^2.4.0",
|
||||
"esbuild": "^0.23.0",
|
||||
"glob": "^11.0.0",
|
||||
"mocha": "^10.7.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Anchorer — hybrid anchoring (spec §6.5). The durable FINGERPRINT is the source
|
||||
* of truth for an anchor's location (INV-3); live OFFSET ranges are a within-
|
||||
* session optimization. The resolution ladder is:
|
||||
* exact-unique → context-disambiguated → lineHint tiebreak → orphaned.
|
||||
* It NEVER guesses on an unbreakable tie — it orphans instead (INV-1).
|
||||
*
|
||||
* vscode-free: operates on plain strings and character-offset ranges, so it is
|
||||
* unit-testable in Node. The vscode layer converts OffsetRange <-> vscode.Range.
|
||||
*/
|
||||
import type { Fingerprint } from "./model";
|
||||
|
||||
export interface OffsetRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** A document edit: the half-open range [start,end) was replaced with text of `newLength`. */
|
||||
export interface TextEdit {
|
||||
start: number;
|
||||
end: number;
|
||||
newLength: number;
|
||||
}
|
||||
|
||||
const MAX_CTX_CHARS = 120;
|
||||
const MAX_CTX_LINES = 3;
|
||||
|
||||
function lineNumberAt(doc: string, offset: number): number {
|
||||
let line = 0;
|
||||
const bound = Math.min(offset, doc.length);
|
||||
for (let i = 0; i < bound; i++) if (doc.charCodeAt(i) === 10) line++;
|
||||
return line;
|
||||
}
|
||||
|
||||
function leadingContext(doc: string, start: number): string {
|
||||
let s = doc.slice(Math.max(0, start - MAX_CTX_CHARS), start);
|
||||
const lines = s.split("\n");
|
||||
if (lines.length > MAX_CTX_LINES) s = lines.slice(lines.length - MAX_CTX_LINES).join("\n");
|
||||
return s;
|
||||
}
|
||||
|
||||
function trailingContext(doc: string, end: number): string {
|
||||
let s = doc.slice(end, Math.min(doc.length, end + MAX_CTX_CHARS));
|
||||
const lines = s.split("\n");
|
||||
if (lines.length > MAX_CTX_LINES) s = lines.slice(0, MAX_CTX_LINES).join("\n");
|
||||
return s;
|
||||
}
|
||||
|
||||
export function buildFingerprint(docText: string, range: OffsetRange): Fingerprint {
|
||||
return {
|
||||
text: docText.slice(range.start, range.end),
|
||||
before: leadingContext(docText, range.start),
|
||||
after: trailingContext(docText, range.end),
|
||||
lineHint: lineNumberAt(docText, range.start),
|
||||
};
|
||||
}
|
||||
|
||||
function allIndexesOf(hay: string, needle: string): number[] {
|
||||
if (needle.length === 0) return [];
|
||||
const out: number[] = [];
|
||||
let from = 0;
|
||||
let idx = hay.indexOf(needle, from);
|
||||
while (idx !== -1) {
|
||||
out.push(idx);
|
||||
from = idx + 1;
|
||||
idx = hay.indexOf(needle, from);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function contextMatches(doc: string, i: number, fp: Fingerprint): boolean {
|
||||
const beforeOk = fp.before.length === 0 || doc.slice(Math.max(0, i - fp.before.length), i) === fp.before;
|
||||
const afterStart = i + fp.text.length;
|
||||
const afterOk = fp.after.length === 0 || doc.slice(afterStart, afterStart + fp.after.length) === fp.after;
|
||||
return beforeOk && afterOk;
|
||||
}
|
||||
|
||||
const rangeAt = (i: number, text: string): OffsetRange => ({ start: i, end: i + text.length });
|
||||
|
||||
/**
|
||||
* Re-resolve a fingerprint against current text. Returns the resolved range or
|
||||
* "orphaned" when no confident, unique match exists (INV-1).
|
||||
*/
|
||||
export function resolve(docText: string, fp: Fingerprint): OffsetRange | "orphaned" {
|
||||
const occ = allIndexesOf(docText, fp.text);
|
||||
if (occ.length === 0) return "orphaned";
|
||||
if (occ.length === 1) return rangeAt(occ[0], fp.text);
|
||||
|
||||
// Multiple exact matches: try context disambiguation.
|
||||
const ctx = occ.filter((i) => contextMatches(docText, i, fp));
|
||||
if (ctx.length === 1) return rangeAt(ctx[0], fp.text);
|
||||
|
||||
// Still ambiguous: break ties by proximity to lineHint within the best pool.
|
||||
const pool = ctx.length > 0 ? ctx : occ;
|
||||
const dists = pool.map((i) => ({ i, d: Math.abs(lineNumberAt(docText, i) - fp.lineHint) }));
|
||||
const min = Math.min(...dists.map((x) => x.d));
|
||||
const closest = dists.filter((x) => x.d === min);
|
||||
if (closest.length === 1) return rangeAt(closest[0].i, fp.text);
|
||||
|
||||
// Unbreakable tie — refuse to guess.
|
||||
return "orphaned";
|
||||
}
|
||||
|
||||
/** Maintain a live range across an in-session edit (spec §6.4 `shift`). */
|
||||
export function shift(range: OffsetRange, edit: TextEdit): OffsetRange {
|
||||
const delta = edit.newLength - (edit.end - edit.start);
|
||||
const point = (p: number): number => {
|
||||
if (p <= edit.start) return p;
|
||||
if (p >= edit.end) return p + delta;
|
||||
return edit.start; // inside the replaced span — clamp to its start
|
||||
};
|
||||
return { start: point(range.start), end: point(range.end) };
|
||||
}
|
||||
+31
-15
@@ -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.
|
||||
}
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Versioned coauthoring artifact schema (spec §6.3) — the shared git-native
|
||||
* envelope under every coauthoring capability. `anchors` and the `Provenance`
|
||||
* author field are SHARED primitives (INV-4): F3 (attributions) and F4
|
||||
* (proposals) add arrays that reference the same shapes without a format break.
|
||||
*
|
||||
* vscode-free and pure, so it is unit-testable in Node (mirrors src/cline.ts).
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export const SCHEMA_VERSION = 1 as const;
|
||||
|
||||
/** Anchored text + bounded context + a line tie-breaker (spec §6.3). */
|
||||
export interface Fingerprint {
|
||||
text: string;
|
||||
/** <= ~3 lines / 120 chars leading context. */
|
||||
before: string;
|
||||
/** <= ~3 lines / 120 chars trailing context. */
|
||||
after: string;
|
||||
/** 0-based line of the anchor start; a tie-breaker, NOT truth (INV-3). */
|
||||
lineHint: number;
|
||||
}
|
||||
|
||||
export interface Anchor {
|
||||
fingerprint: Fingerprint;
|
||||
}
|
||||
|
||||
/** The reusable author field (spec §6.3). `agent` only present when kind=agent. */
|
||||
export type Provenance =
|
||||
| { kind: "human"; id: string }
|
||||
| { kind: "agent"; id: string; agent: { sdk: string; model: string; sessionId: string } };
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
author: Provenance;
|
||||
body: string;
|
||||
/** ISO-8601. */
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type ThreadStatus = "open" | "resolved";
|
||||
|
||||
export interface Thread {
|
||||
id: string;
|
||||
anchorId: string;
|
||||
status: ThreadStatus;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export interface Artifact {
|
||||
schemaVersion: number;
|
||||
/** repo-relative path; the sidecar key. */
|
||||
document: { path: string };
|
||||
/** SHARED primitive (INV-4). */
|
||||
anchors: Record<string, Anchor>;
|
||||
threads: Thread[];
|
||||
/** F3 extension point — reuses anchors[]; not implemented in F2. */
|
||||
attributions: unknown[];
|
||||
/** F4 extension point — reuses anchors[] + provenance; not in F2. */
|
||||
proposals: unknown[];
|
||||
}
|
||||
|
||||
export function emptyArtifact(docPath: string): Artifact {
|
||||
return {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
document: { path: docPath },
|
||||
anchors: {},
|
||||
threads: [],
|
||||
attributions: [],
|
||||
proposals: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Stable, generated id (spec §6.3). */
|
||||
export function newId(prefix: string): string {
|
||||
return `${prefix}_${randomUUID()}`;
|
||||
}
|
||||
|
||||
function serializeProvenance(p: Provenance): Record<string, unknown> {
|
||||
return p.kind === "agent"
|
||||
? { kind: p.kind, id: p.id, agent: { sdk: p.agent.sdk, model: p.agent.model, sessionId: p.agent.sessionId } }
|
||||
: { kind: p.kind, id: p.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pretty-printed JSON with STABLE key ordering (anchors sorted by id), so
|
||||
* sidecar diffs stay minimal and merge-friendly (spec §6.3, INV-2). Always
|
||||
* ends with a trailing newline.
|
||||
*/
|
||||
export function serializeArtifact(a: Artifact): string {
|
||||
const canonical = {
|
||||
schemaVersion: a.schemaVersion,
|
||||
document: { path: a.document.path },
|
||||
anchors: Object.fromEntries(
|
||||
Object.keys(a.anchors)
|
||||
.sort()
|
||||
.map((k) => [
|
||||
k,
|
||||
{
|
||||
fingerprint: {
|
||||
text: a.anchors[k].fingerprint.text,
|
||||
before: a.anchors[k].fingerprint.before,
|
||||
after: a.anchors[k].fingerprint.after,
|
||||
lineHint: a.anchors[k].fingerprint.lineHint,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
threads: a.threads.map((t) => ({
|
||||
id: t.id,
|
||||
anchorId: t.anchorId,
|
||||
status: t.status,
|
||||
messages: t.messages.map((m) => ({
|
||||
id: m.id,
|
||||
author: serializeProvenance(m.author),
|
||||
body: m.body,
|
||||
createdAt: m.createdAt,
|
||||
})),
|
||||
})),
|
||||
attributions: a.attributions,
|
||||
proposals: a.proposals,
|
||||
};
|
||||
return JSON.stringify(canonical, null, 2) + "\n";
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* CoauthorStore — load/save the per-document `.threads/` sidecar (spec §6.4).
|
||||
* Git-native, serverless, plain pretty JSON (INV-2). vscode-free (Node fs only),
|
||||
* so it is unit-testable; the FileSystemWatcher that triggers re-anchoring on
|
||||
* external change is wired in the vscode layer (ThreadController, SLICE-4).
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { serializeArtifact, type Artifact } from "./model";
|
||||
|
||||
export class CoauthorStore {
|
||||
/** @param rootDir absolute workspace-folder root that owns the `.threads/` tree. */
|
||||
constructor(private readonly rootDir: string) {}
|
||||
|
||||
/** `.threads/<repo-relative-docPath>.json` (spec §6.3). */
|
||||
sidecarPath(docPath: string): string {
|
||||
return path.join(this.rootDir, ".threads", `${docPath}.json`);
|
||||
}
|
||||
|
||||
load(docPath: string): Artifact | null {
|
||||
const p = this.sidecarPath(docPath);
|
||||
if (!fs.existsSync(p)) return null;
|
||||
return JSON.parse(fs.readFileSync(p, "utf8")) as Artifact;
|
||||
}
|
||||
|
||||
save(docPath: string, artifact: Artifact): void {
|
||||
const p = this.sidecarPath(docPath);
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, serializeArtifact(artifact), "utf8");
|
||||
}
|
||||
}
|
||||
@@ -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,69 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildFingerprint, resolve, shift, type OffsetRange } from "../src/anchorer";
|
||||
|
||||
const DOC = "line0\nline1 needle here\nline2\nneedle again on line3\n";
|
||||
|
||||
describe("buildFingerprint", () => {
|
||||
it("captures the exact text, bounded context, and 0-based lineHint", () => {
|
||||
const start = DOC.indexOf("needle here");
|
||||
const range: OffsetRange = { start, end: start + "needle".length };
|
||||
const fp = buildFingerprint(DOC, range);
|
||||
expect(fp.text).toBe("needle");
|
||||
expect(fp.lineHint).toBe(1);
|
||||
expect(fp.before.endsWith("line1 ")).toBe(true);
|
||||
expect(fp.after.startsWith(" here")).toBe(true);
|
||||
expect(fp.before.length).toBeLessThanOrEqual(120);
|
||||
expect(fp.after.length).toBeLessThanOrEqual(120);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolve", () => {
|
||||
it("exact-unique: returns the single occurrence's range", () => {
|
||||
const fp = { text: "line2", before: "", after: "", lineHint: 0 };
|
||||
const start = DOC.indexOf("line2");
|
||||
expect(resolve(DOC, fp)).toEqual({ start, end: start + 5 });
|
||||
});
|
||||
|
||||
it("context-disambiguated: picks the occurrence whose before/after match", () => {
|
||||
const fp = { text: "needle", before: "line1 ", after: " here", lineHint: 99 };
|
||||
const start = DOC.indexOf("needle here");
|
||||
expect(resolve(DOC, fp)).toEqual({ start, end: start + 6 });
|
||||
});
|
||||
|
||||
it("lineHint tiebreak: when context does not disambiguate, the closest line wins", () => {
|
||||
// both occurrences of 'needle', no usable context, hint points at line 3
|
||||
const fp = { text: "needle", before: "", after: "", lineHint: 3 };
|
||||
const start = DOC.indexOf("needle again");
|
||||
expect(resolve(DOC, fp)).toEqual({ start, end: start + 6 });
|
||||
});
|
||||
|
||||
it("orphaned: returns 'orphaned' when the text is gone", () => {
|
||||
const fp = { text: "absent-text", before: "", after: "", lineHint: 0 };
|
||||
expect(resolve(DOC, fp)).toBe("orphaned");
|
||||
});
|
||||
|
||||
it("orphaned: returns 'orphaned' rather than guessing on an unbreakable tie (INV-1)", () => {
|
||||
const doc = "needle\n....\nneedle\n"; // two identical occurrences, equidistant from hint
|
||||
const fp = { text: "needle", before: "", after: "", lineHint: 1 };
|
||||
expect(resolve(doc, fp)).toBe("orphaned");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shift", () => {
|
||||
it("edit entirely before the range shifts both endpoints by the delta", () => {
|
||||
const r: OffsetRange = { start: 10, end: 15 };
|
||||
// insert 2 chars at offset 0 (replace [0,0) with len 2)
|
||||
expect(shift(r, { start: 0, end: 0, newLength: 2 })).toEqual({ start: 12, end: 17 });
|
||||
});
|
||||
|
||||
it("edit entirely after the range leaves it unchanged", () => {
|
||||
const r: OffsetRange = { start: 2, end: 5 };
|
||||
expect(shift(r, { start: 10, end: 12, newLength: 0 })).toEqual({ start: 2, end: 5 });
|
||||
});
|
||||
|
||||
it("edit overlapping the range clamps the touched endpoints to the edit start", () => {
|
||||
const r: OffsetRange = { start: 5, end: 10 };
|
||||
// replace [3,7) with 1 char (delta = -3)
|
||||
expect(shift(r, { start: 3, end: 7, newLength: 1 })).toEqual({ start: 3, end: 7 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
# Sample Document
|
||||
|
||||
The quick brown fox jumps over the lazy dog.
|
||||
|
||||
A second paragraph with the phrase anchor target inside it.
|
||||
|
||||
Closing line.
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
import * as fs from "fs";
|
||||
import { runTests } from "@vscode/test-electron";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const projectRoot = path.resolve(__dirname, "../../../");
|
||||
const extensionDevelopmentPath = projectRoot;
|
||||
const extensionTestsPath = path.resolve(__dirname, "./suite/index");
|
||||
|
||||
// Copy the fixture workspace to a temp dir so test writes are throwaway.
|
||||
const fixture = path.resolve(projectRoot, "test/e2e/fixtures/workspace");
|
||||
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "cowriting-e2e-"));
|
||||
fs.cpSync(fixture, workspace, { recursive: true });
|
||||
|
||||
try {
|
||||
await runTests({
|
||||
extensionDevelopmentPath,
|
||||
extensionTestsPath,
|
||||
launchArgs: [workspace, "--disable-extensions"],
|
||||
extensionTestsEnv: { E2E_WORKSPACE: workspace },
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("E2E run failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import * as path from "path";
|
||||
import Mocha from "mocha";
|
||||
import { glob } from "glob";
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
const mocha = new Mocha({ ui: "tdd", color: true, timeout: 60000 });
|
||||
const testsRoot = path.resolve(__dirname);
|
||||
const files = await glob("**/*.test.js", { cwd: testsRoot });
|
||||
for (const f of files) mocha.addFile(path.resolve(testsRoot, f));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
mocha.run((failures) => (failures > 0 ? reject(new Error(`${failures} E2E test(s) failed`)) : resolve()));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import * as assert from "assert";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as vscode from "vscode";
|
||||
import type { CowritingApi } from "../../../src/extension";
|
||||
import type { Artifact } from "../../../src/model";
|
||||
|
||||
const WS = process.env.E2E_WORKSPACE!;
|
||||
const DOC_REL = "docs/sample.md";
|
||||
|
||||
function sidecarPath(): string {
|
||||
return path.join(WS, ".threads", "docs", "sample.md.json");
|
||||
}
|
||||
function readSidecar(): Artifact {
|
||||
return JSON.parse(fs.readFileSync(sidecarPath(), "utf8")) as Artifact;
|
||||
}
|
||||
async function openSample(): Promise<vscode.TextDocument> {
|
||||
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
return doc;
|
||||
}
|
||||
|
||||
// Simulate an EXTERNAL change to the document (e.g. a git pull): write the file
|
||||
// on disk, then force the open editor buffer to reload from disk with `revert`
|
||||
// (openTextDocument otherwise returns VS Code's cached in-memory buffer).
|
||||
async function externalWriteAndReload(uri: vscode.Uri, content: string): Promise<vscode.TextDocument> {
|
||||
fs.writeFileSync(uri.fsPath, content, "utf8");
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
await vscode.commands.executeCommand("workbench.action.files.revert");
|
||||
return doc;
|
||||
}
|
||||
async function getApi(): Promise<CowritingApi> {
|
||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||
const api = (await ext.activate()) as CowritingApi;
|
||||
assert.ok(api?.threadController, "extension should export threadController");
|
||||
return api;
|
||||
}
|
||||
|
||||
// Reaches the controller's internal docs map for reply/resolve handlers (which
|
||||
// expect a live vscode.CommentThread). Spec §6.8 "drive the ThreadController and
|
||||
// assert Comments-controller state" fallback.
|
||||
function findVsThread(api: CowritingApi, threadId: string): vscode.CommentThread {
|
||||
const rendered = api.threadController.getRendered(DOC_REL);
|
||||
assert.ok(rendered.some((r) => r.id === threadId), "thread is rendered");
|
||||
return (
|
||||
api.threadController as unknown as {
|
||||
docs: Map<string, { vsThreads: Map<string, vscode.CommentThread> }>;
|
||||
}
|
||||
).docs
|
||||
.get(DOC_REL)!
|
||||
.vsThreads.get(threadId)!;
|
||||
}
|
||||
function setStatusViaApi(api: CowritingApi, threadId: string, status: "open" | "resolved"): void {
|
||||
const vsThread = findVsThread(api, threadId);
|
||||
void vscode.commands.executeCommand(
|
||||
status === "resolved" ? "cowriting.resolveThread" : "cowriting.reopenThread",
|
||||
vsThread,
|
||||
);
|
||||
}
|
||||
|
||||
suite("F2 region-anchored threads (host E2E)", () => {
|
||||
test("create on selection persists a thread sidecar", async () => {
|
||||
const doc = await openSample();
|
||||
const api = await getApi();
|
||||
const start = doc.getText().indexOf("quick brown fox");
|
||||
const editor = vscode.window.activeTextEditor!;
|
||||
editor.selection = new vscode.Selection(doc.positionAt(start), doc.positionAt(start + "quick brown fox".length));
|
||||
|
||||
const threadId = await api.threadController.createThreadOnSelection("First note");
|
||||
assert.ok(threadId, "createThreadOnSelection returns an id");
|
||||
assert.ok(fs.existsSync(sidecarPath()), "sidecar written to disk");
|
||||
|
||||
const art = readSidecar();
|
||||
assert.strictEqual(art.threads.length, 1);
|
||||
assert.strictEqual(art.threads[0].status, "open");
|
||||
assert.strictEqual(art.threads[0].messages[0].body, "First note");
|
||||
const anchorId = art.threads[0].anchorId;
|
||||
assert.strictEqual(art.anchors[anchorId].fingerprint.text, "quick brown fox");
|
||||
|
||||
const rendered = api.threadController.getRendered(DOC_REL);
|
||||
assert.strictEqual(rendered.length, 1);
|
||||
assert.strictEqual(rendered[0].orphaned, false);
|
||||
});
|
||||
|
||||
test("reply appends and resolve flips status, both persisted", async () => {
|
||||
const api = await getApi();
|
||||
const art0 = readSidecar();
|
||||
const threadId = art0.threads[0].id;
|
||||
|
||||
api.threadController.reply({ thread: findVsThread(api, threadId), text: "A reply" } as vscode.CommentReply);
|
||||
const art1 = readSidecar();
|
||||
assert.deepStrictEqual(
|
||||
art1.threads[0].messages.map((m) => m.body),
|
||||
["First note", "A reply"],
|
||||
);
|
||||
|
||||
setStatusViaApi(api, threadId, "resolved");
|
||||
const art2 = readSidecar();
|
||||
assert.strictEqual(art2.threads[0].status, "resolved");
|
||||
});
|
||||
|
||||
test("reload renders the persisted thread at its anchor", async () => {
|
||||
const doc = await openSample();
|
||||
const api = await getApi();
|
||||
api.threadController.renderAll(doc);
|
||||
const rendered = api.threadController.getRendered(DOC_REL);
|
||||
assert.strictEqual(rendered.length, 1);
|
||||
assert.strictEqual(rendered[0].orphaned, false);
|
||||
const anchorLine = doc.positionAt(doc.getText().indexOf("quick brown fox")).line;
|
||||
assert.strictEqual(rendered[0].range.startLine, anchorLine);
|
||||
});
|
||||
|
||||
test("external edit that moves the text re-anchors; deleting it orphans (INV-1)", async () => {
|
||||
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
|
||||
const original = fs.readFileSync(uri.fsPath, "utf8");
|
||||
|
||||
// Move the anchored text down by prepending lines (external change).
|
||||
let doc = await externalWriteAndReload(uri, "PREPENDED LINE ONE\nPREPENDED LINE TWO\n" + original);
|
||||
const api = await getApi();
|
||||
api.threadController.renderAll(doc);
|
||||
let rendered = api.threadController.getRendered(DOC_REL);
|
||||
assert.strictEqual(rendered[0].orphaned, false, "still anchored after a move");
|
||||
const movedLine = doc.positionAt(doc.getText().indexOf("quick brown fox")).line;
|
||||
assert.strictEqual(movedLine, 4, "fox moved from line 2 to line 4 after prepending two lines");
|
||||
assert.strictEqual(rendered[0].range.startLine, movedLine);
|
||||
|
||||
// Now delete the anchored text entirely → must orphan, not move.
|
||||
const deleted =
|
||||
"PREPENDED LINE ONE\nThe slow grey cat\n" +
|
||||
original.replace("The quick brown fox jumps over the lazy dog.", "The slow grey cat naps.");
|
||||
doc = await externalWriteAndReload(uri, deleted);
|
||||
assert.strictEqual(doc.getText().includes("quick brown fox"), false, "anchored text is gone from the buffer");
|
||||
api.threadController.renderAll(doc);
|
||||
rendered = api.threadController.getRendered(DOC_REL);
|
||||
assert.strictEqual(rendered[0].orphaned, true, "deleted anchor must orphan (INV-1)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
SCHEMA_VERSION,
|
||||
emptyArtifact,
|
||||
serializeArtifact,
|
||||
type Artifact,
|
||||
} from "../src/model";
|
||||
|
||||
function sampleArtifact(): Artifact {
|
||||
return {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
document: { path: "docs/spec.md" },
|
||||
anchors: {
|
||||
a2: { fingerprint: { text: "beta", before: "x", after: "y", lineHint: 5 } },
|
||||
a1: { fingerprint: { text: "alpha", before: "", after: "", lineHint: 1 } },
|
||||
},
|
||||
threads: [
|
||||
{
|
||||
id: "t1",
|
||||
anchorId: "a1",
|
||||
status: "open",
|
||||
messages: [
|
||||
{ id: "m1", author: { kind: "human", id: "ben" }, body: "hi", createdAt: "2026-06-10T00:00:00.000Z" },
|
||||
],
|
||||
},
|
||||
],
|
||||
attributions: [],
|
||||
proposals: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("serializeArtifact", () => {
|
||||
it("round-trips through JSON.parse", () => {
|
||||
const a = sampleArtifact();
|
||||
const parsed = JSON.parse(serializeArtifact(a)) as Artifact;
|
||||
expect(parsed).toEqual(a);
|
||||
});
|
||||
|
||||
it("emits stable, sorted anchor keys and a trailing newline regardless of insertion order", () => {
|
||||
const a = sampleArtifact();
|
||||
const out = serializeArtifact(a);
|
||||
expect(out.endsWith("\n")).toBe(true);
|
||||
// a1 must serialize before a2 even though a2 was inserted first
|
||||
expect(out.indexOf('"a1"')).toBeLessThan(out.indexOf('"a2"'));
|
||||
// byte-identical on re-serialize
|
||||
expect(serializeArtifact(JSON.parse(out))).toBe(out);
|
||||
});
|
||||
|
||||
it("emptyArtifact has the shared F3/F4 extension points present and empty", () => {
|
||||
const e = emptyArtifact("docs/x.md");
|
||||
expect(e.schemaVersion).toBe(SCHEMA_VERSION);
|
||||
expect(e.document.path).toBe("docs/x.md");
|
||||
expect(e.anchors).toEqual({});
|
||||
expect(e.threads).toEqual([]);
|
||||
expect(e.attributions).toEqual([]);
|
||||
expect(e.proposals).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildFingerprint, resolve } from "../src/anchorer";
|
||||
|
||||
const ORIGINAL = "intro\nThe quick brown fox\noutro\n";
|
||||
|
||||
describe("re-anchor scenarios (SLICE-4 decision logic)", () => {
|
||||
it("reload, document unchanged: resolves to the same range", () => {
|
||||
const start = ORIGINAL.indexOf("quick brown");
|
||||
const fp = buildFingerprint(ORIGINAL, { start, end: start + "quick brown".length });
|
||||
expect(resolve(ORIGINAL, fp)).toEqual({ start, end: start + "quick brown".length });
|
||||
});
|
||||
|
||||
it("external edit that moves the anchored text down: re-resolves to the new range", () => {
|
||||
const start = ORIGINAL.indexOf("quick brown");
|
||||
const fp = buildFingerprint(ORIGINAL, { start, end: start + "quick brown".length });
|
||||
const edited = "a new first line\nanother line\n" + ORIGINAL;
|
||||
const newStart = edited.indexOf("quick brown");
|
||||
expect(newStart).not.toBe(start);
|
||||
expect(resolve(edited, fp)).toEqual({ start: newStart, end: newStart + "quick brown".length });
|
||||
});
|
||||
|
||||
it("external edit that deletes the anchored text: orphaned, never moved (INV-1)", () => {
|
||||
const start = ORIGINAL.indexOf("quick brown");
|
||||
const fp = buildFingerprint(ORIGINAL, { start, end: start + "quick brown".length });
|
||||
const edited = "intro\nThe lazy dog\noutro\n";
|
||||
expect(resolve(edited, fp)).toBe("orphaned");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { CoauthorStore } from "../src/store";
|
||||
import { emptyArtifact, newId, serializeArtifact, type Artifact } from "../src/model";
|
||||
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "coauthor-store-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function withThread(): Artifact {
|
||||
const a = emptyArtifact("docs/spec.md");
|
||||
a.anchors["a1"] = { fingerprint: { text: "hello", before: "", after: "", lineHint: 0 } };
|
||||
a.threads.push({
|
||||
id: "t1",
|
||||
anchorId: "a1",
|
||||
status: "open",
|
||||
messages: [{ id: newId("m"), author: { kind: "human", id: "ben" }, body: "note", createdAt: "2026-06-10T00:00:00.000Z" }],
|
||||
});
|
||||
return a;
|
||||
}
|
||||
|
||||
describe("CoauthorStore", () => {
|
||||
it("returns null for a document with no sidecar", () => {
|
||||
const store = new CoauthorStore(root);
|
||||
expect(store.load("docs/spec.md")).toBeNull();
|
||||
});
|
||||
|
||||
it("computes the sidecar path as .threads/<docPath>.json", () => {
|
||||
const store = new CoauthorStore(root);
|
||||
expect(store.sidecarPath("docs/spec.md")).toBe(path.join(root, ".threads", "docs", "spec.md.json"));
|
||||
});
|
||||
|
||||
it("save then load round-trips the artifact", () => {
|
||||
const store = new CoauthorStore(root);
|
||||
const a = withThread();
|
||||
store.save("docs/spec.md", a);
|
||||
expect(store.load("docs/spec.md")).toEqual(a);
|
||||
});
|
||||
|
||||
it("writes byte-stable, pretty JSON (re-save is identical)", () => {
|
||||
const store = new CoauthorStore(root);
|
||||
const a = withThread();
|
||||
store.save("docs/spec.md", a);
|
||||
const first = fs.readFileSync(store.sidecarPath("docs/spec.md"), "utf8");
|
||||
store.save("docs/spec.md", store.load("docs/spec.md")!);
|
||||
const second = fs.readFileSync(store.sidecarPath("docs/spec.md"), "utf8");
|
||||
expect(second).toBe(first);
|
||||
expect(first).toBe(serializeArtifact(a));
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"noEmit": false,
|
||||
"outDir": "out",
|
||||
"rootDir": ".",
|
||||
"sourceMap": true,
|
||||
"types": ["node", "mocha", "vscode"]
|
||||
},
|
||||
"include": ["src", "test/e2e"]
|
||||
}
|
||||
@@ -4,5 +4,6 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["test/**/*.test.ts"],
|
||||
exclude: ["test/e2e/**", "node_modules/**"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user