F3: live human/Claude attribution on @cline/sdk claude-code (Feature #6) #7
+6
-1
@@ -27,9 +27,14 @@
|
|||||||
},
|
},
|
||||||
{ "command": "cowriting.reply", "title": "Reply", "category": "Cowriting" },
|
{ "command": "cowriting.reply", "title": "Reply", "category": "Cowriting" },
|
||||||
{ "command": "cowriting.resolveThread", "title": "Resolve Thread", "category": "Cowriting" },
|
{ "command": "cowriting.resolveThread", "title": "Resolve Thread", "category": "Cowriting" },
|
||||||
{ "command": "cowriting.reopenThread", "title": "Reopen Thread", "category": "Cowriting" }
|
{ "command": "cowriting.reopenThread", "title": "Reopen Thread", "category": "Cowriting" },
|
||||||
|
{ "command": "cowriting.toggleAttribution", "title": "Cowriting: Toggle Attribution", "category": "Cowriting" },
|
||||||
|
{ "command": "cowriting.applyAgentEdit", "title": "Apply Agent Edit (internal seam)", "category": "Cowriting" }
|
||||||
],
|
],
|
||||||
"menus": {
|
"menus": {
|
||||||
|
"commandPalette": [
|
||||||
|
{ "command": "cowriting.applyAgentEdit", "when": "false" }
|
||||||
|
],
|
||||||
"comments/commentThread/context": [
|
"comments/commentThread/context": [
|
||||||
{ "command": "cowriting.reply", "group": "inline", "when": "commentController == cowriting.threads" }
|
{ "command": "cowriting.reply", "group": "inline", "when": "commentController == cowriting.threads" }
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
/**
|
||||||
|
* AttributionController — the thin editor-facing layer for F3 (spec §6.2).
|
||||||
|
* Wires the pure AttributionTracker + PendingEditRegistry + Store/Anchorer to
|
||||||
|
* the editor: human typing → human spans, seam edits → agent spans (INV-9),
|
||||||
|
* decorations (Claude tint / human gutter border / toggle), save-time
|
||||||
|
* persistence, load/external-change resolve-or-orphan (INV-1/INV-6), and the
|
||||||
|
* orphan status-bar count. Sidecar self-write suppression and the shared
|
||||||
|
* FileSystemWatcher live in CoauthorStore / extension.ts (the sidecar is
|
||||||
|
* co-owned with ThreadController).
|
||||||
|
*/
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
import { CoauthorStore } from "./store";
|
||||||
|
import { newId, type AttributionRecord, type Provenance } from "./model";
|
||||||
|
import { buildFingerprint, resolve, type OffsetRange } from "./anchorer";
|
||||||
|
import { applyChange, coalesce, type LiveSpan } from "./attributionTracker";
|
||||||
|
import { PendingEditRegistry } from "./pendingEdits";
|
||||||
|
|
||||||
|
/** Test-facing snapshot of live attribution state for a document. */
|
||||||
|
export interface RenderedSpan {
|
||||||
|
id: string;
|
||||||
|
authorKind: "human" | "agent";
|
||||||
|
authorId: string;
|
||||||
|
turnId?: string;
|
||||||
|
range: { start: number; end: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocAttribution {
|
||||||
|
docPath: string;
|
||||||
|
spans: LiveSpan[];
|
||||||
|
/** persisted records whose fingerprints failed to resolve (PUC-4). */
|
||||||
|
orphans: AttributionRecord[];
|
||||||
|
/** record metadata per live span id (updatedAt bookkeeping). */
|
||||||
|
records: Map<string, AttributionRecord>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AGENT_DECO: vscode.DecorationRenderOptions = {
|
||||||
|
backgroundColor: "rgba(99, 102, 241, 0.18)",
|
||||||
|
overviewRulerColor: "rgba(99, 102, 241, 0.8)",
|
||||||
|
overviewRulerLane: vscode.OverviewRulerLane.Right,
|
||||||
|
};
|
||||||
|
const HUMAN_DECO: vscode.DecorationRenderOptions = {
|
||||||
|
borderColor: "rgba(16, 185, 129, 0.8)",
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderWidth: "0 0 0 2px",
|
||||||
|
};
|
||||||
|
|
||||||
|
export class AttributionController implements vscode.Disposable {
|
||||||
|
private readonly disposables: vscode.Disposable[] = [];
|
||||||
|
private readonly docs = new Map<string, DocAttribution>();
|
||||||
|
private readonly pending = new PendingEditRegistry();
|
||||||
|
private readonly agentType = vscode.window.createTextEditorDecorationType(AGENT_DECO);
|
||||||
|
private readonly humanType = vscode.window.createTextEditorDecorationType(HUMAN_DECO);
|
||||||
|
private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 90);
|
||||||
|
private readonly output = vscode.window.createOutputChannel("Cowriting Attribution");
|
||||||
|
private visible = true;
|
||||||
|
|
||||||
|
constructor(private readonly store: CoauthorStore, private readonly rootDir: string) {
|
||||||
|
this.disposables.push(this.agentType, this.humanType, this.statusItem, this.output);
|
||||||
|
this.disposables.push(
|
||||||
|
vscode.commands.registerCommand("cowriting.toggleAttribution", () => this.toggle()),
|
||||||
|
vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
|
||||||
|
vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)),
|
||||||
|
vscode.window.onDidChangeActiveTextEditor(() => this.renderActive()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isTracked(document: vscode.TextDocument): boolean {
|
||||||
|
return document.uri.scheme === "file" && document.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 state(docPath: string): DocAttribution {
|
||||||
|
let s = this.docs.get(docPath);
|
||||||
|
if (!s) {
|
||||||
|
s = { docPath, spans: [], orphans: [], records: new Map() };
|
||||||
|
this.docs.set(docPath, s);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PUC-4: load / resolve-or-orphan ---------------------------------------------
|
||||||
|
|
||||||
|
/** Load the sidecar and re-resolve every attribution (live span | orphan). */
|
||||||
|
loadAll(document: vscode.TextDocument): void {
|
||||||
|
if (!this.isTracked(document)) return;
|
||||||
|
const docPath = this.docPathOf(document.uri);
|
||||||
|
const s = this.state(docPath);
|
||||||
|
const artifact = this.store.load(docPath);
|
||||||
|
s.spans = [];
|
||||||
|
s.orphans = [];
|
||||||
|
s.records.clear();
|
||||||
|
if (artifact) {
|
||||||
|
const text = document.getText();
|
||||||
|
for (const rec of artifact.attributions) {
|
||||||
|
const fp = artifact.anchors[rec.anchorId]?.fingerprint;
|
||||||
|
const resolved = fp ? resolve(text, fp) : "orphaned";
|
||||||
|
if (resolved === "orphaned") {
|
||||||
|
s.orphans.push(rec);
|
||||||
|
} else {
|
||||||
|
s.spans.push({
|
||||||
|
id: rec.id, start: resolved.start, end: resolved.end,
|
||||||
|
author: rec.author, turnId: rec.turnId, createdAt: rec.createdAt,
|
||||||
|
});
|
||||||
|
s.records.set(rec.id, rec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.spans = coalesce(s.spans);
|
||||||
|
}
|
||||||
|
this.render(document);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared-watcher entry point (extension.ts): a sidecar changed externally. */
|
||||||
|
handleExternalSidecarChange(uri: vscode.Uri): void {
|
||||||
|
for (const s of this.docs.values()) {
|
||||||
|
if (this.store.sidecarPath(s.docPath) === uri.fsPath) {
|
||||||
|
const doc = vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === s.docPath);
|
||||||
|
if (doc) this.loadAll(doc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PUC-1/PUC-3: live tracking ---------------------------------------------------
|
||||||
|
|
||||||
|
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
|
||||||
|
if (!this.isTracked(e.document) || e.contentChanges.length === 0) return;
|
||||||
|
const docPath = this.docPathOf(e.document.uri);
|
||||||
|
if (!e.document.isDirty) {
|
||||||
|
// Disk sync (revert / reload): re-resolve, never attribute (PUC-4).
|
||||||
|
this.loadAll(e.document);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const s = this.state(docPath);
|
||||||
|
for (const change of e.contentChanges) {
|
||||||
|
const edit = {
|
||||||
|
start: change.rangeOffset,
|
||||||
|
end: change.rangeOffset + change.rangeLength,
|
||||||
|
newLength: change.text.length,
|
||||||
|
};
|
||||||
|
const hit = this.pending.match(docPath, { start: edit.start, end: edit.end, text: change.text });
|
||||||
|
const author = hit ? hit.provenance : this.currentAuthor();
|
||||||
|
s.spans = applyChange(s.spans, edit, author, {
|
||||||
|
newId: () => newId("at"),
|
||||||
|
now: () => new Date().toISOString(),
|
||||||
|
turnId: hit?.turnId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.render(e.document);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the seam (INV-9) ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ONLY machine-edit ingress: register the exact expected change, then
|
||||||
|
* apply it as a WorkspaceEdit. Returns false (applying nothing) on a stale
|
||||||
|
* document version or a rejected edit — never partial-applies (spec §6.9).
|
||||||
|
* The pending edit is unregistered unconditionally afterwards (a no-op when
|
||||||
|
* `match` already consumed it), so a no-op edit never leaks a registration.
|
||||||
|
*/
|
||||||
|
async applyAgentEdit(
|
||||||
|
document: vscode.TextDocument,
|
||||||
|
range: vscode.Range,
|
||||||
|
newText: string,
|
||||||
|
provenance: Provenance,
|
||||||
|
opts?: { expectedVersion?: number; turnId?: string },
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!this.isTracked(document)) return false;
|
||||||
|
if (opts?.expectedVersion !== undefined && document.version !== opts.expectedVersion) return false;
|
||||||
|
const docPath = this.docPathOf(document.uri);
|
||||||
|
const pendingEdit = {
|
||||||
|
docPath,
|
||||||
|
start: document.offsetAt(range.start),
|
||||||
|
end: document.offsetAt(range.end),
|
||||||
|
newText,
|
||||||
|
provenance,
|
||||||
|
turnId: opts?.turnId,
|
||||||
|
};
|
||||||
|
this.pending.register(pendingEdit);
|
||||||
|
const we = new vscode.WorkspaceEdit();
|
||||||
|
we.replace(document.uri, range, newText);
|
||||||
|
const ok = await vscode.workspace.applyEdit(we);
|
||||||
|
this.pending.unregister(pendingEdit);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PUC-4: persistence on save ----------------------------------------------------
|
||||||
|
|
||||||
|
private onDidSave(document: vscode.TextDocument): void {
|
||||||
|
if (!this.isTracked(document)) return;
|
||||||
|
const docPath = this.docPathOf(document.uri);
|
||||||
|
const s = this.docs.get(docPath);
|
||||||
|
if (!s || (s.spans.length === 0 && s.orphans.length === 0)) return;
|
||||||
|
const text = document.getText();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const records: AttributionRecord[] = [];
|
||||||
|
const anchorOf = new Map<string, OffsetRange>();
|
||||||
|
for (const span of s.spans) {
|
||||||
|
const prev = s.records.get(span.id);
|
||||||
|
const rec: AttributionRecord = {
|
||||||
|
id: span.id,
|
||||||
|
anchorId: prev?.anchorId ?? newId("a"),
|
||||||
|
author: span.author,
|
||||||
|
createdAt: span.createdAt,
|
||||||
|
updatedAt: prev ? prev.updatedAt : now,
|
||||||
|
...(span.turnId !== undefined ? { turnId: span.turnId } : {}),
|
||||||
|
};
|
||||||
|
anchorOf.set(rec.anchorId, { start: span.start, end: span.end });
|
||||||
|
records.push(rec);
|
||||||
|
s.records.set(span.id, rec);
|
||||||
|
}
|
||||||
|
this.store.update(docPath, (a) => {
|
||||||
|
for (const rec of records) {
|
||||||
|
const range = anchorOf.get(rec.anchorId)!;
|
||||||
|
const fp = buildFingerprint(text, range);
|
||||||
|
const prevFp = a.anchors[rec.anchorId]?.fingerprint;
|
||||||
|
if (prevFp && JSON.stringify(prevFp) !== JSON.stringify(fp)) rec.updatedAt = now;
|
||||||
|
a.anchors[rec.anchorId] = { fingerprint: fp };
|
||||||
|
}
|
||||||
|
// Orphans ride along unchanged (recoverable, spec §6.9): their records
|
||||||
|
// stay in attributions[], so update()'s prune keeps their anchors too.
|
||||||
|
a.attributions = [...records, ...s.orphans];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PUC-5: rendering ----------------------------------------------------------------
|
||||||
|
|
||||||
|
private toggle(): void {
|
||||||
|
this.visible = !this.visible;
|
||||||
|
this.renderActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderActive(): void {
|
||||||
|
const editor = vscode.window.activeTextEditor;
|
||||||
|
if (editor) this.render(editor.document);
|
||||||
|
}
|
||||||
|
|
||||||
|
private render(document: vscode.TextDocument): void {
|
||||||
|
const editor = vscode.window.visibleTextEditors.find((e) => e.document === document);
|
||||||
|
if (!editor || !this.isTracked(document)) return;
|
||||||
|
const s = this.docs.get(this.docPathOf(document.uri));
|
||||||
|
const spans = this.visible && s ? s.spans : [];
|
||||||
|
const toRange = (sp: LiveSpan) =>
|
||||||
|
new vscode.Range(document.positionAt(sp.start), document.positionAt(sp.end));
|
||||||
|
editor.setDecorations(this.agentType, spans.filter((x) => x.author.kind === "agent").map(toRange));
|
||||||
|
editor.setDecorations(this.humanType, spans.filter((x) => x.author.kind === "human").map(toRange));
|
||||||
|
this.renderStatus(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderStatus(s: DocAttribution | undefined): void {
|
||||||
|
const n = s?.orphans.length ?? 0;
|
||||||
|
if (n === 0) {
|
||||||
|
this.statusItem.hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.statusItem.text = `$(warning) ${n} orphaned attribution${n === 1 ? "" : "s"}`;
|
||||||
|
this.statusItem.tooltip = "Cowriting: attribution anchors that no longer resolve (see output channel)";
|
||||||
|
this.statusItem.show();
|
||||||
|
this.output.clear();
|
||||||
|
for (const o of s!.orphans) this.output.appendLine(`orphaned ${o.id} (${o.author.kind}:${o.author.id})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- test-facing surface ---------------------------------------------------------------
|
||||||
|
|
||||||
|
getSpans(docPath: string): RenderedSpan[] {
|
||||||
|
const s = this.docs.get(docPath);
|
||||||
|
if (!s) return [];
|
||||||
|
return s.spans.map((sp) => ({
|
||||||
|
id: sp.id,
|
||||||
|
authorKind: sp.author.kind,
|
||||||
|
authorId: sp.author.id,
|
||||||
|
turnId: sp.turnId,
|
||||||
|
range: { start: sp.start, end: sp.end },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
getOrphanCount(docPath: string): number {
|
||||||
|
return this.docs.get(docPath)?.orphans.length ?? 0;
|
||||||
|
}
|
||||||
|
isVisible(): boolean {
|
||||||
|
return this.visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
for (const d of this.disposables) d.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
-3
@@ -2,11 +2,13 @@ import * as vscode from "vscode";
|
|||||||
import { fetchSdkSummary } from "./cline";
|
import { fetchSdkSummary } from "./cline";
|
||||||
import { CoauthorStore } from "./store";
|
import { CoauthorStore } from "./store";
|
||||||
import { ThreadController } from "./threadController";
|
import { ThreadController } from "./threadController";
|
||||||
|
import { AttributionController } from "./attributionController";
|
||||||
|
|
||||||
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
||||||
|
|
||||||
export interface CowritingApi {
|
export interface CowritingApi {
|
||||||
threadController: ThreadController;
|
threadController: ThreadController;
|
||||||
|
attributionController: AttributionController;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
|
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
|
||||||
@@ -41,14 +43,55 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
const threadController = new ThreadController(store, root);
|
const threadController = new ThreadController(store, root);
|
||||||
context.subscriptions.push(threadController);
|
context.subscriptions.push(threadController);
|
||||||
|
|
||||||
// Render threads for already-open editors, and on future opens.
|
// --- F3: live attribution (Feature #6) ---
|
||||||
|
const attributionController = new AttributionController(store, root);
|
||||||
|
context.subscriptions.push(attributionController);
|
||||||
|
|
||||||
|
// One SHARED sidecar watcher for both controllers; self-writes are
|
||||||
|
// suppressed centrally in the store (the sidecar is co-owned).
|
||||||
|
const watcher = vscode.workspace.createFileSystemWatcher("**/.threads/**/*.json");
|
||||||
|
const onSidecar = (uri: vscode.Uri) => {
|
||||||
|
if (store.consumeSelfWrite(uri.fsPath)) return;
|
||||||
|
threadController.handleExternalSidecarChange(uri);
|
||||||
|
attributionController.handleExternalSidecarChange(uri);
|
||||||
|
};
|
||||||
|
watcher.onDidChange(onSidecar);
|
||||||
|
watcher.onDidCreate(onSidecar);
|
||||||
|
context.subscriptions.push(watcher);
|
||||||
|
|
||||||
|
// The seam as a command (INV-9): the only machine-edit ingress, hidden from
|
||||||
|
// the palette — for the host E2E harness and future SDK turn plumbing.
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.commands.registerCommand(
|
||||||
|
"cowriting.applyAgentEdit",
|
||||||
|
(args: {
|
||||||
|
uri: string; start: number; end: number; newText: string;
|
||||||
|
model?: string; sessionId?: string; turnId?: string;
|
||||||
|
}) => {
|
||||||
|
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === args.uri);
|
||||||
|
if (!doc) return Promise.resolve(false);
|
||||||
|
const range = new vscode.Range(doc.positionAt(args.start), doc.positionAt(args.end));
|
||||||
|
const provenance = {
|
||||||
|
kind: "agent" as const,
|
||||||
|
id: "claude",
|
||||||
|
agent: { sdk: "@cline/sdk", model: args.model ?? "sonnet", sessionId: args.sessionId ?? "" },
|
||||||
|
};
|
||||||
|
return attributionController.applyAgentEdit(doc, range, args.newText, provenance, { turnId: args.turnId });
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render threads + attributions for already-open editors, and on future opens.
|
||||||
const renderIfOpen = (doc: vscode.TextDocument) => {
|
const renderIfOpen = (doc: vscode.TextDocument) => {
|
||||||
if (doc.uri.scheme === "file" && doc.uri.fsPath.startsWith(root)) threadController.renderAll(doc);
|
if (doc.uri.scheme === "file" && doc.uri.fsPath.startsWith(root)) {
|
||||||
|
threadController.renderAll(doc);
|
||||||
|
attributionController.loadAll(doc);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
vscode.workspace.textDocuments.forEach(renderIfOpen);
|
vscode.workspace.textDocuments.forEach(renderIfOpen);
|
||||||
context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen));
|
context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen));
|
||||||
|
|
||||||
return { threadController };
|
return { threadController, attributionController };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deactivate(): void {
|
export function deactivate(): void {
|
||||||
|
|||||||
+5
-1
@@ -24,7 +24,11 @@ export class PendingEditRegistry {
|
|||||||
this.pending.push(edit);
|
this.pending.push(edit);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove a registration that failed to apply. */
|
/**
|
||||||
|
* Remove a registration that failed to apply. Identity-based (`===`), so
|
||||||
|
* callers must keep the exact registered object; it is a no-op when `match`
|
||||||
|
* already consumed the registration.
|
||||||
|
*/
|
||||||
unregister(edit: PendingEdit): void {
|
unregister(edit: PendingEdit): void {
|
||||||
this.pending = this.pending.filter((p) => p !== edit);
|
this.pending = this.pending.filter((p) => p !== edit);
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-3
@@ -1,17 +1,31 @@
|
|||||||
/**
|
/**
|
||||||
* CoauthorStore — load/save the per-document `.threads/` sidecar (spec §6.4).
|
* CoauthorStore — load/save the per-document `.threads/` sidecar (spec §6.4).
|
||||||
* Git-native, serverless, plain pretty JSON (INV-2). vscode-free (Node fs only),
|
* 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
|
* so it is unit-testable; the SHARED FileSystemWatcher that triggers
|
||||||
* external change is wired in the vscode layer (ThreadController, SLICE-4).
|
* re-anchoring on external change is wired in the vscode layer (extension.ts),
|
||||||
|
* with self-write suppression centralized here because two controllers
|
||||||
|
* (ThreadController, AttributionController) co-own the sidecar (F3 §6.2).
|
||||||
*/
|
*/
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { emptyArtifact, serializeArtifact, type Artifact } from "./model";
|
import { emptyArtifact, serializeArtifact, type Artifact } from "./model";
|
||||||
|
|
||||||
export class CoauthorStore {
|
export class CoauthorStore {
|
||||||
|
/** sidecar paths this store just wrote, so the shared watcher can ignore them. */
|
||||||
|
private readonly selfWrites = new Set<string>();
|
||||||
|
|
||||||
/** @param rootDir absolute workspace-folder root that owns the `.threads/` tree. */
|
/** @param rootDir absolute workspace-folder root that owns the `.threads/` tree. */
|
||||||
constructor(private readonly rootDir: string) {}
|
constructor(private readonly rootDir: string) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete-and-report: true iff `fsPath` was a sidecar we just wrote via
|
||||||
|
* `update`. The shared watcher (extension.ts) calls this to suppress
|
||||||
|
* self-write events before fanning out to the controllers.
|
||||||
|
*/
|
||||||
|
consumeSelfWrite(fsPath: string): boolean {
|
||||||
|
return this.selfWrites.delete(fsPath);
|
||||||
|
}
|
||||||
|
|
||||||
/** `.threads/<repo-relative-docPath>.json` (spec §6.3). */
|
/** `.threads/<repo-relative-docPath>.json` (spec §6.3). */
|
||||||
sidecarPath(docPath: string): string {
|
sidecarPath(docPath: string): string {
|
||||||
return path.join(this.rootDir, ".threads", `${docPath}.json`);
|
return path.join(this.rootDir, ".threads", `${docPath}.json`);
|
||||||
@@ -34,7 +48,8 @@ export class CoauthorStore {
|
|||||||
* mutates only its own section against a FRESH load, so ThreadController and
|
* mutates only its own section against a FRESH load, so ThreadController and
|
||||||
* AttributionController never clobber each other. After the mutation, anchors
|
* AttributionController never clobber each other. After the mutation, anchors
|
||||||
* referenced by neither threads nor attributions are pruned (proposals are
|
* referenced by neither threads nor attributions are pruned (proposals are
|
||||||
* still empty in F3 — revisit in F4).
|
* still empty in F3 — revisit in F4). `mutate` MUST be synchronous: the
|
||||||
|
* read-modify-write (and the self-write mark) completes within this call.
|
||||||
*/
|
*/
|
||||||
update(docPath: string, mutate: (artifact: Artifact) => void): Artifact {
|
update(docPath: string, mutate: (artifact: Artifact) => void): Artifact {
|
||||||
const artifact = this.load(docPath) ?? emptyArtifact(docPath);
|
const artifact = this.load(docPath) ?? emptyArtifact(docPath);
|
||||||
@@ -46,6 +61,7 @@ export class CoauthorStore {
|
|||||||
for (const id of Object.keys(artifact.anchors)) {
|
for (const id of Object.keys(artifact.anchors)) {
|
||||||
if (!referenced.has(id)) delete artifact.anchors[id];
|
if (!referenced.has(id)) delete artifact.anchors[id];
|
||||||
}
|
}
|
||||||
|
this.selfWrites.add(this.sidecarPath(docPath));
|
||||||
this.save(docPath, artifact);
|
this.save(docPath, artifact);
|
||||||
return artifact;
|
return artifact;
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-18
@@ -1,9 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* ThreadController — the thin editor-facing layer (spec §6.4). Wires
|
* ThreadController — the thin editor-facing layer (spec §6.4). Wires
|
||||||
* CoauthorStore + Anchorer + threadModel to vscode.comments: create-on-
|
* CoauthorStore + Anchorer + threadModel to vscode.comments: create-on-
|
||||||
* selection, reply, resolve, render, live-range tracking, reload, and
|
* selection, reply, resolve, render, live-range tracking, reload, and external
|
||||||
* FileSystemWatcher-driven external re-anchoring (SLICE-4). Threads are NEVER
|
* re-anchoring (SLICE-4) — driven by the SHARED sidecar watcher in extension.ts
|
||||||
* silently moved — an unresolvable anchor renders as an orphaned thread (INV-1).
|
* via handleExternalSidecarChange (self-writes are suppressed in CoauthorStore).
|
||||||
|
* Threads are NEVER silently moved — an unresolvable anchor renders as an
|
||||||
|
* orphaned thread (INV-1).
|
||||||
*/
|
*/
|
||||||
import * as vscode from "vscode";
|
import * as vscode from "vscode";
|
||||||
import { CoauthorStore } from "./store";
|
import { CoauthorStore } from "./store";
|
||||||
@@ -35,8 +37,6 @@ export class ThreadController implements vscode.Disposable {
|
|||||||
private readonly controller: vscode.CommentController;
|
private readonly controller: vscode.CommentController;
|
||||||
private readonly disposables: vscode.Disposable[] = [];
|
private readonly disposables: vscode.Disposable[] = [];
|
||||||
private readonly docs = new Map<string, DocState>(); // keyed by docPath
|
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) {
|
constructor(private readonly store: CoauthorStore, private readonly rootDir: string) {
|
||||||
this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads");
|
this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads");
|
||||||
@@ -60,13 +60,6 @@ export class ThreadController implements vscode.Disposable {
|
|||||||
vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
|
vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)),
|
||||||
vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)),
|
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 {
|
private isInRoot(uri: vscode.Uri): boolean {
|
||||||
@@ -83,7 +76,6 @@ export class ThreadController implements vscode.Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private persist(state: DocState): void {
|
private persist(state: DocState): void {
|
||||||
this.selfWrites.add(this.store.sidecarPath(state.docPath));
|
|
||||||
this.store.update(state.docPath, (a) => {
|
this.store.update(state.docPath, (a) => {
|
||||||
a.threads = state.artifact.threads;
|
a.threads = state.artifact.threads;
|
||||||
for (const t of state.artifact.threads) {
|
for (const t of state.artifact.threads) {
|
||||||
@@ -174,12 +166,9 @@ export class ThreadController implements vscode.Disposable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private onExternalSidecarChange(uri: vscode.Uri): void {
|
/** Shared-watcher entry point (extension.ts): a sidecar changed externally. */
|
||||||
|
handleExternalSidecarChange(uri: vscode.Uri): void {
|
||||||
const p = uri.fsPath;
|
const p = uri.fsPath;
|
||||||
if (this.selfWrites.has(p)) {
|
|
||||||
this.selfWrites.delete(p);
|
|
||||||
return; // our own write
|
|
||||||
}
|
|
||||||
for (const state of this.docs.values()) {
|
for (const state of this.docs.values()) {
|
||||||
if (this.store.sidecarPath(state.docPath) === p) {
|
if (this.store.sidecarPath(state.docPath) === p) {
|
||||||
const doc = vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath);
|
const doc = vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath);
|
||||||
|
|||||||
Reference in New Issue
Block a user