feat: CoeditingRegistry opt-in gate (INV-10, spec §6.4) — enter/stop commands + context keys
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -118,6 +118,16 @@
|
|||||||
"command": "cowriting.proposalRejectMenu",
|
"command": "cowriting.proposalRejectMenu",
|
||||||
"title": "Reject Claude Proposal",
|
"title": "Reject Claude Proposal",
|
||||||
"category": "Cowriting"
|
"category": "Cowriting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.coeditDocument",
|
||||||
|
"title": "✦ Coedit this Document with Claude",
|
||||||
|
"category": "Cowriting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.stopCoediting",
|
||||||
|
"title": "Stop editing with Claude",
|
||||||
|
"category": "Cowriting"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"menus": {
|
"menus": {
|
||||||
@@ -207,6 +217,16 @@
|
|||||||
"command": "cowriting.createThread",
|
"command": "cowriting.createThread",
|
||||||
"when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled)",
|
"when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled)",
|
||||||
"group": "1_cowriting@2"
|
"group": "1_cowriting@2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.coeditDocument",
|
||||||
|
"when": "editorLangId == markdown && !cowriting.isCoediting",
|
||||||
|
"group": "1_cowriting@1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "cowriting.stopCoediting",
|
||||||
|
"when": "editorLangId == markdown && cowriting.isCoediting",
|
||||||
|
"group": "1_cowriting@9"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"comments/commentThread/context": [
|
"comments/commentThread/context": [
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* CoeditingRegistry — the INV-10 activation gate (spec §6.4). Holds the set of
|
||||||
|
* documents the writer has explicitly entered into coediting; every surface
|
||||||
|
* (SCM/QuickDiff, comments, preview annotations, edit commands, decorations)
|
||||||
|
* checks membership before attaching. Persisted in workspaceState so a reload
|
||||||
|
* restores the set (the durable sidecar carries the artifacts; this carries
|
||||||
|
* only the mode).
|
||||||
|
*/
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
|
||||||
|
const STATE_KEY = "cowriting.coeditingSet";
|
||||||
|
|
||||||
|
export class CoeditingRegistry implements vscode.Disposable {
|
||||||
|
private readonly set: Set<string>;
|
||||||
|
private readonly emitter = new vscode.EventEmitter<{ uri: string; coediting: boolean }>();
|
||||||
|
readonly onDidChange = this.emitter.event;
|
||||||
|
/** Set by the SCM surface (Task 3) so syncContext can expose the baseline mode. */
|
||||||
|
baselineModeOf: ((uri: vscode.Uri) => "head" | "snapshot" | undefined) | undefined;
|
||||||
|
|
||||||
|
constructor(private readonly state: vscode.Memento) {
|
||||||
|
this.set = new Set(state.get<string[]>(STATE_KEY, []));
|
||||||
|
}
|
||||||
|
|
||||||
|
enter(uri: vscode.Uri): void {
|
||||||
|
const key = uri.toString();
|
||||||
|
if (this.set.has(key)) return;
|
||||||
|
this.set.add(key);
|
||||||
|
void this.state.update(STATE_KEY, [...this.set]);
|
||||||
|
this.emitter.fire({ uri: key, coediting: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
exit(uri: vscode.Uri): void {
|
||||||
|
const key = uri.toString();
|
||||||
|
if (!this.set.delete(key)) return;
|
||||||
|
void this.state.update(STATE_KEY, [...this.set]);
|
||||||
|
this.emitter.fire({ uri: key, coediting: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
isCoediting(uri: vscode.Uri): boolean {
|
||||||
|
return this.set.has(uri.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): string[] {
|
||||||
|
return [...this.set];
|
||||||
|
}
|
||||||
|
|
||||||
|
syncContext(editor: vscode.TextEditor | undefined): void {
|
||||||
|
const coediting = !!editor && this.isCoediting(editor.document.uri);
|
||||||
|
void vscode.commands.executeCommand("setContext", "cowriting.isCoediting", coediting);
|
||||||
|
const mode = coediting && editor ? this.baselineModeOf?.(editor.document.uri) : undefined;
|
||||||
|
void vscode.commands.executeCommand("setContext", "cowriting.baselineMode", mode ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.emitter.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { TrackChangesPreviewController } from "./trackChangesPreview";
|
|||||||
import { LiveProgressUi } from "./liveProgressUi";
|
import { LiveProgressUi } from "./liveProgressUi";
|
||||||
import { EditorProposalController } from "./editorProposalController";
|
import { EditorProposalController } from "./editorProposalController";
|
||||||
import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath";
|
import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath";
|
||||||
|
import { CoeditingRegistry } from "./coeditingRegistry";
|
||||||
|
|
||||||
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ export interface CowritingApi {
|
|||||||
sidecarRouter: SidecarRouter;
|
sidecarRouter: SidecarRouter;
|
||||||
liveProgressUi: LiveProgressUi;
|
liveProgressUi: LiveProgressUi;
|
||||||
editorProposalController: EditorProposalController;
|
editorProposalController: EditorProposalController;
|
||||||
|
coeditingRegistry: CoeditingRegistry;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
|
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
|
||||||
@@ -59,6 +61,32 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- PUC-7 (native-surfaces migration): the INV-10 opt-in gate ---
|
||||||
|
// Every later native surface (SCM/QuickDiff, comments, preview annotations,
|
||||||
|
// edit commands, decorations) checks isCoediting before attaching. Persisted
|
||||||
|
// in workspaceState so a reload restores the set.
|
||||||
|
const coeditingRegistry = new CoeditingRegistry(context.workspaceState);
|
||||||
|
context.subscriptions.push(coeditingRegistry);
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.commands.registerCommand("cowriting.coeditDocument", () => {
|
||||||
|
const ed = vscode.window.activeTextEditor;
|
||||||
|
if (!ed || ed.document.languageId !== "markdown" || !isAuthorable(ed.document.uri.scheme)) {
|
||||||
|
void vscode.window.showWarningMessage("Cowriting: open a Markdown document to coedit it.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
coeditingRegistry.enter(ed.document.uri);
|
||||||
|
coeditingRegistry.syncContext(ed);
|
||||||
|
}),
|
||||||
|
vscode.commands.registerCommand("cowriting.stopCoediting", () => {
|
||||||
|
const ed = vscode.window.activeTextEditor;
|
||||||
|
if (!ed) return;
|
||||||
|
coeditingRegistry.exit(ed.document.uri);
|
||||||
|
coeditingRegistry.syncContext(ed);
|
||||||
|
}),
|
||||||
|
vscode.window.onDidChangeActiveTextEditor((ed) => coeditingRegistry.syncContext(ed)),
|
||||||
|
);
|
||||||
|
coeditingRegistry.syncContext(vscode.window.activeTextEditor);
|
||||||
|
|
||||||
// --- F6: baseline data layer (Features #17 + #19) — workspace-INDEPENDENT ---
|
// --- F6: baseline data layer (Features #17 + #19) — workspace-INDEPENDENT ---
|
||||||
// The two-pane vscode.diff VIEW was removed in #34 (F10's rendered preview is
|
// The two-pane vscode.diff VIEW was removed in #34 (F10's rendered preview is
|
||||||
// the single review surface); only the baseline store survives, consumed by
|
// the single review surface); only the baseline store survives, consumed by
|
||||||
@@ -362,6 +390,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
sidecarRouter,
|
sidecarRouter,
|
||||||
liveProgressUi,
|
liveProgressUi,
|
||||||
editorProposalController,
|
editorProposalController,
|
||||||
|
coeditingRegistry,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// The pure set logic lives in a vscode-free helper the class wraps, so unit-test the class
|
||||||
|
// with a Memento stub (vitest runs vscode-free; the module imports vscode only for types +
|
||||||
|
// EventEmitter — mock it).
|
||||||
|
vi.mock("vscode", () => ({
|
||||||
|
EventEmitter: class {
|
||||||
|
private handlers: Array<(e: unknown) => void> = [];
|
||||||
|
event = (h: (e: unknown) => void) => { this.handlers.push(h); return { dispose() {} }; };
|
||||||
|
fire(e: unknown) { for (const h of this.handlers) h(e); }
|
||||||
|
dispose() {}
|
||||||
|
},
|
||||||
|
Uri: { parse: (s: string) => ({ toString: () => s }) },
|
||||||
|
commands: { executeCommand: vi.fn() },
|
||||||
|
window: { activeTextEditor: undefined },
|
||||||
|
}));
|
||||||
|
import { CoeditingRegistry } from "../src/coeditingRegistry";
|
||||||
|
|
||||||
|
function memento(): { store: Map<string, unknown> } & { get: any; update: any } {
|
||||||
|
const store = new Map<string, unknown>();
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
get: (k: string, dflt?: unknown) => (store.has(k) ? store.get(k) : dflt),
|
||||||
|
update: (k: string, v: unknown) => { store.set(k, v); return Promise.resolve(); },
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CoeditingRegistry", () => {
|
||||||
|
it("enter/exit flips membership and fires onDidChange", () => {
|
||||||
|
const reg = new CoeditingRegistry(memento() as any);
|
||||||
|
const events: Array<{ uri: string; coediting: boolean }> = [];
|
||||||
|
reg.onDidChange((e) => events.push(e as any));
|
||||||
|
const uri = { toString: () => "file:///a.md" } as any;
|
||||||
|
expect(reg.isCoediting(uri)).toBe(false);
|
||||||
|
reg.enter(uri);
|
||||||
|
expect(reg.isCoediting(uri)).toBe(true);
|
||||||
|
reg.exit(uri);
|
||||||
|
expect(reg.isCoediting(uri)).toBe(false);
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ uri: "file:///a.md", coediting: true },
|
||||||
|
{ uri: "file:///a.md", coediting: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists the set across construction (reload survival)", () => {
|
||||||
|
const m = memento();
|
||||||
|
const reg1 = new CoeditingRegistry(m as any);
|
||||||
|
reg1.enter({ toString: () => "file:///a.md" } as any);
|
||||||
|
const reg2 = new CoeditingRegistry(m as any);
|
||||||
|
expect(reg2.isCoediting({ toString: () => "file:///a.md" } as any)).toBe(true);
|
||||||
|
expect(reg2.list()).toEqual(["file:///a.md"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enter is idempotent (no duplicate events)", () => {
|
||||||
|
const reg = new CoeditingRegistry(memento() as any);
|
||||||
|
const events: unknown[] = [];
|
||||||
|
reg.onDidChange((e) => events.push(e));
|
||||||
|
const uri = { toString: () => "file:///a.md" } as any;
|
||||||
|
reg.enter(uri);
|
||||||
|
reg.enter(uri);
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import * as assert from "node:assert";
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
|
||||||
|
async function api() {
|
||||||
|
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||||
|
return (await ext.activate()) as import("../../../src/extension").CowritingApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
suite("coediting registry (PUC-7)", () => {
|
||||||
|
test("enter → isCoediting true; exit → false; persists in list()", async () => {
|
||||||
|
const { coeditingRegistry } = await api();
|
||||||
|
const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "# t\n\nbody\n" });
|
||||||
|
await vscode.window.showTextDocument(doc);
|
||||||
|
await vscode.commands.executeCommand("cowriting.coeditDocument");
|
||||||
|
assert.strictEqual(coeditingRegistry.isCoediting(doc.uri), true);
|
||||||
|
assert.ok(coeditingRegistry.list().includes(doc.uri.toString()));
|
||||||
|
await vscode.commands.executeCommand("cowriting.stopCoediting");
|
||||||
|
assert.strictEqual(coeditingRegistry.isCoediting(doc.uri), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user