diff --git a/README.md b/README.md index cd9d5a2..403b223 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,33 @@ the manual smoke, not the sealed-sandbox E2E. Design: `vscode-cowriting-plugin-content/specs/coauthoring-rendered-preview.md`. Live smoke: [`docs/MANUAL-SMOKE-F7.md`](docs/MANUAL-SMOKE-F7.md). +## F8 — Out-of-workspace authoring (Feature #25) + +"Ask Claude to Edit Selection" (and F2 threads / F3 attribution / F4 +propose-accept) now work on **any** document the editor shows — saved in the +workspace folder, saved outside it, or untitled — matching the already-universal +F6 diff and F7 preview. Authoring is no longer gated to in-workspace files, and +the commands are live even with **no folder open**. + +Persistence is **hybrid** (one `SidecarStore` abstraction, routed per-document): + +- an **in-workspace** file keeps its committable `.threads/.json` + sidecar, **byte-for-byte unchanged** (INV-2) — the only home the F5 cross-rung + contract ever sees; +- an **out-of-workspace** file or **untitled** buffer stores its coauthoring + artifact in VS Code **global storage** keyed by `sha256(uri)` (the same home + and key F6's baseline uses, INV-19/24); untitled buffers are **in-memory + only** (lost on reload/save). + +A global-storage artifact is **not a committed file**, so it is **never +cross-rung-shareable** (INV-25), and renaming/moving the file **orphans** its +artifact (the key is the URI hash) — both stated as design contract, not +discovered later. Routing leaves the in-workspace path untouched, so rollback is +a plain PR revert with zero data migration. + +Design: `vscode-cowriting-plugin-content/specs/coauthoring-out-of-workspace.md`. +Live smoke: [`docs/MANUAL-SMOKE-F8.md`](docs/MANUAL-SMOKE-F8.md). + ## Develop - `npm run watch` — rebuild on change. diff --git a/docs/MANUAL-SMOKE-F8.md b/docs/MANUAL-SMOKE-F8.md new file mode 100644 index 0000000..138f1de --- /dev/null +++ b/docs/MANUAL-SMOKE-F8.md @@ -0,0 +1,45 @@ +# Manual smoke — F8 out-of-workspace authoring + +Confirms "Ask Claude to Edit Selection" (+ threads / attribution) work on a file +**outside** the workspace folder and on an **untitled** buffer, while in-workspace +authoring is unchanged. One live turn hits the SDK (the only LLM step). + +Spec: `vscode-cowriting-plugin-content/specs/coauthoring-out-of-workspace.md`. + +## Prereqs +- Build: `npm run build`. +- Launch the Extension Development Host (F5 in VS Code) with this repo's `sandbox/` + opened as the workspace folder. + +## A. Out-of-folder file (PUC-1) +1. Open a markdown file from a DIFFERENT directory (outside `sandbox/`) — e.g. a + sibling repo, or `File > Open` a scratch file under `/tmp`. +2. Select a sentence → Command Palette → **Cowriting: Ask Claude to Edit Selection** + → type an instruction → wait for the proposal (amber range + ✓/✗). +3. Accept (✓). Expect: the text is replaced and shows the Claude attribution tint. +4. Add a coauthoring thread on a selection (**Add Coauthoring Thread on Selection**). +5. Close and reopen the file (or revert). Expect: the thread + attribution are + restored. There is **no** `.threads/` folder beside the file — its state lives + in VS Code global storage (`/sidecars/.json`). + +## B. Untitled buffer (PUC-2) +1. `File > New File` (don't save) → type a few sentences. +2. Select → **Ask Claude to Edit Selection** → instruct → accept. Expect: it works + exactly like A, in-session. +3. Reload the window (Developer: Reload Window). Expect: the untitled buffer's + coauthoring state is **gone** (in-memory only — documented limitation, §6.7). + +## C. In-workspace unchanged (PUC-3) +1. Open a file UNDER `sandbox/`. Repeat the propose→accept→thread loop. +2. Expect: a committable `sandbox/.threads/.json` sidecar is written, exactly + as before F8 (byte-for-byte, INV-2). + +## D. Read-only view declines (PUC-5) +1. Open a Git diff / Output view, select text, run **Ask Claude to Edit Selection**. +2. Expect: a warning that this kind of document can't be edited (not "select some + text"). + +## E. Folder-less (optional) +1. Launch the EDH with **no folder** open. Open an untitled buffer or `File > Open` + any file. Repeat A/B. Expect: authoring works (every doc routes to global + storage); the commands are not stubbed. diff --git a/docs/superpowers/plans/2026-06-11-f8-out-of-workspace.md b/docs/superpowers/plans/2026-06-11-f8-out-of-workspace.md new file mode 100644 index 0000000..1178404 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-f8-out-of-workspace.md @@ -0,0 +1,1341 @@ +# F8 Out-of-Workspace Authoring Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let the human "Ask Claude to Edit Selection" (threads / attribution / propose-accept) on **any** document the editor shows — saved-in-folder, saved-out-of-folder, or untitled — matching the already-universal F6 diff / F7 preview, while keeping in-workspace authoring byte-for-byte unchanged. + +**Architecture:** Introduce one `SidecarStore` storage interface and a `SidecarRouter` façade that routes per-document by `#24`'s `isUnderRoot`: in-workspace `file:` docs keep the committable repo `.threads/.json` sidecar (`CoauthorStore`, **unmodified**, INV-2); out-of-workspace `file:` + `untitled:` docs persist to a global-storage sidecar (`GlobalSidecarStore`, mirroring F6's `BaselineStore`, keyed by `sha256(uri)`; untitled in-memory only). The three authoring controllers depend on the router; their workspace-root gate becomes a shared `isAuthorable` predicate (scheme ∈ {file, untitled}), with `isUnderRoot` demoted from a gate to a routing input. Same `Artifact` shape + `SCHEMA_VERSION=1` either home, so `mergeArtifacts` / the F5 cross-rung contract are untouched (INV-25: global artifacts are single-rung). + +**Tech Stack:** TypeScript, VS Code extension API, Node `fs`/`crypto`, vitest (unit), `@vscode/test-electron` + mocha (host E2E). Spec: `specs/coauthoring-out-of-workspace.md`. + +**Conventions in this codebase (read before starting):** +- New stores are **vscode-free** (Node `fs`/`crypto` only) so they unit-test under vitest — mirror `src/baselineStore.ts` + `test/baselineStore.test.ts`. +- The document key for an in-workspace file is its **repo-relative path** (`docs/x.md`); for everything else it is the **URI string** (`file:///abs/x.md`, `untitled:Untitled-1`). The router's `keyOf` is the single source of that identity. +- `SidecarStore` is the 4-method storage surface (`load`/`save`/`update`/`consumeSelfWrite`) — `CoauthorStore` already conforms structurally (do **not** add `implements` to it; leave it untouched, INV-2). `SidecarRouter implements SidecarStore` and **adds** `keyOf` + `sidecarPath`; the three controllers + their test-facing surfaces are typed to the concrete `SidecarRouter`. +- Run unit tests with `npm test` (vitest). Run host E2E with `npm run test:e2e` (builds + compiles `tsconfig.e2e.json` + launches two EDH passes: one with the fixture workspace, one with no folder). +- After each task: `npm test` green, `npm run typecheck` clean, then commit. + +--- + +## File Structure + +**Create:** +- `src/sidecarStore.ts` — the `SidecarStore` interface (4-method storage surface) + `DocKey`/identity types. +- `src/globalSidecarStore.ts` — `GlobalSidecarStore` (vscode-free): out-of-folder `file:` → `/sidecars/.json`; `untitled:` → in-memory map; `consumeSelfWrite` ⇒ false; `update` with INV-16 throw + anchor prune. +- `src/sidecarRouter.ts` — `SidecarRouter` (vscode-free): `keyOf(identity)`, per-key routing of `load`/`save`/`update`/`consumeSelfWrite`/`sidecarPath`; plus `docIdentity(doc)` extractor. +- `test/globalSidecarStore.test.ts`, `test/sidecarRouter.test.ts` — unit tests. +- `test/e2e/suite/outOfWorkspace.test.ts` — host E2E (out-of-folder file + untitled + in-workspace regression). +- `docs/MANUAL-SMOKE-F8.md` — live smoke runbook. + +**Modify:** +- `src/workspacePath.ts` — add `isAuthorable(scheme)`; rewrite `selectionRejection` to reject only `!isAuthorable` (keep no-editor / empty-selection). `isUnderRoot` retained (router input). +- `src/versionGuard.ts` — `store: CoauthorStore` → `store: SidecarStore`. +- `src/threadController.ts`, `src/attributionController.ts`, `src/proposalController.ts` — constructor `store: CoauthorStore` → `store: SidecarRouter`, `rootDir: string` → `rootDir: string | undefined`; replace `isInRoot`/`isTracked` with `isAuthorable`; replace `docPathOf` with `store.keyOf(docIdentity(document))`; `handleExternalSidecarChange` uses `store.sidecarPath`. +- `src/extension.ts` — construct `GlobalSidecarStore` + `SidecarRouter`; remove the no-root early-return + command stubs (authoring commands live folder-less); `renderIfOpen` gated by `isAuthorable`; widen the seam command lookups; export `sidecarRouter` on `CowritingApi`. +- `test/workspacePath.test.ts` — extend for `isAuthorable` + new `selectionRejection` behavior. +- `test/e2e/suite-no-workspace/noWorkspace.test.ts` — authoring commands are now real (not stubs); `activate` returns an API even with no folder. +- `README.md` — hybrid-persistence + F5 non-shareability note. +- `src/model.ts` — **no change** (Artifact + SCHEMA_VERSION untouched, INV-24). + +--- + +## Task 1: `SidecarStore` interface + +**Files:** +- Create: `src/sidecarStore.ts` + +- [ ] **Step 1: Write the interface** + +```typescript +/** + * SidecarStore — the storage surface the three authoring controllers depend on + * (F8 spec §6.2/§6.4). One per-document `Artifact` keyed by a string `key` (the + * document key — repo-relative path in-workspace, URI string otherwise; the + * router's keyOf is the single source). Two implementations: + * - CoauthorStore (src/store.ts) — the committable repo `.threads/` sidecar, + * keyed by repo-relative path (INV-2, byte-for-byte unchanged; conforms + * structurally — not modified for F8). + * - GlobalSidecarStore (src/globalSidecarStore.ts) — out-of-workspace `file:` + * and `untitled:` docs, in VS Code GLOBAL storage keyed by sha256(uri) + * (INV-19/24/25; untitled in-memory only). + * SidecarRouter (src/sidecarRouter.ts) implements this AND adds keyOf/sidecarPath; + * the controllers receive the router. + */ +import type { Artifact } from "./model"; + +export interface SidecarStore { + /** Load the artifact for `key`, or null if none persisted. */ + load(key: string): Artifact | null; + /** Overwrite the artifact for `key` (newest wins, no history). */ + save(key: string, artifact: Artifact): void; + /** + * Read-modify-write: load (or empty), apply `mutate`, prune anchors referenced + * by no thread/attribution/proposal, persist, return the result. MUST be + * synchronous (the F3 co-ownership contract). Throws on a newer-major sidecar + * (INV-16 backstop). + */ + update(key: string, mutate: (artifact: Artifact) => void): Artifact; + /** + * Decrement-and-report whether `fsPath` is a sidecar this store just wrote + * (repo sidecars only; the global store is outside the `.threads/` watcher so + * its impl is a no-op returning false). + */ + consumeSelfWrite(fsPath: string): boolean; +} +``` + +- [ ] **Step 2: Verify it typechecks** + +Run: `npm run typecheck` +Expected: clean (no references yet). + +- [ ] **Step 3: Commit** + +```bash +git add src/sidecarStore.ts +git commit -m "feat(f8): add SidecarStore storage interface (spec §6.4)" +``` + +--- + +## Task 2: `GlobalSidecarStore` — round-trip for `file:` URIs + +**Files:** +- Create: `src/globalSidecarStore.ts` +- Test: `test/globalSidecarStore.test.ts` + +- [ ] **Step 1: Write the failing test (file URI round-trip + path + missing + self-write)** + +```typescript +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 { createHash } from "node:crypto"; +import { GlobalSidecarStore } from "../src/globalSidecarStore"; +import { emptyArtifact, SCHEMA_VERSION, type Artifact } from "../src/model"; + +let dir: string; +beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "global-sidecar-")); }); +afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); }); + +const FILE_URI = "file:///abs/notes/chapter-1.md"; +const hash = (uri: string) => createHash("sha256").update(uri).digest("hex"); + +function withThread(uri: string): Artifact { + const a = emptyArtifact(uri); + a.anchors["an_1"] = { fingerprint: { text: "hi", before: "", after: "", lineHint: 0 } }; + a.threads.push({ id: "t_1", anchorId: "an_1", status: "open", messages: [] }); + return a; +} + +describe("GlobalSidecarStore — file: URIs persist to /sidecars/.json", () => { + it("returns null for a key with no sidecar", () => { + expect(new GlobalSidecarStore(dir).load(FILE_URI)).toBeNull(); + }); + + it("save then load round-trips the artifact at the hashed path", () => { + const store = new GlobalSidecarStore(dir); + const a = withThread(FILE_URI); + store.save(FILE_URI, a); + const onDisk = path.join(dir, "sidecars", `${hash(FILE_URI)}.json`); + expect(fs.existsSync(onDisk)).toBe(true); + expect(store.load(FILE_URI)).toEqual(a); + }); + + it("sidecarPath returns the hashed disk path for a file: URI", () => { + const store = new GlobalSidecarStore(dir); + expect(store.sidecarPath(FILE_URI)).toBe(path.join(dir, "sidecars", `${hash(FILE_URI)}.json`)); + }); + + it("overwrites in place (newest wins, no history)", () => { + const store = new GlobalSidecarStore(dir); + store.save(FILE_URI, withThread(FILE_URI)); + const empty = emptyArtifact(FILE_URI); + store.save(FILE_URI, empty); + expect(store.load(FILE_URI)).toEqual(empty); + }); + + it("consumeSelfWrite is a no-op returning false (outside the .threads watcher)", () => { + const store = new GlobalSidecarStore(dir); + store.save(FILE_URI, withThread(FILE_URI)); + expect(store.consumeSelfWrite(store.sidecarPath(FILE_URI)!)).toBe(false); + }); + + it("update applies the mutation, prunes unreferenced anchors, and persists", () => { + const store = new GlobalSidecarStore(dir); + const result = store.update(FILE_URI, (a) => { + a.anchors["orphan"] = { fingerprint: { text: "x", before: "", after: "", lineHint: 0 } }; + a.anchors["kept"] = { fingerprint: { text: "y", before: "", after: "", lineHint: 0 } }; + a.threads.push({ id: "t", anchorId: "kept", status: "open", messages: [] }); + }); + expect(Object.keys(result.anchors)).toEqual(["kept"]); // "orphan" pruned + expect(store.load(FILE_URI)!.threads.length).toBe(1); + }); + + it("update throws on a newer-major sidecar (INV-16 backstop)", () => { + const store = new GlobalSidecarStore(dir); + const future = { ...emptyArtifact(FILE_URI), schemaVersion: SCHEMA_VERSION + 1 }; + store.save(FILE_URI, future as Artifact); + expect(() => store.update(FILE_URI, () => {})).toThrow(/INV-16/); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npm test -- globalSidecarStore` +Expected: FAIL ("Cannot find module '../src/globalSidecarStore'"). + +- [ ] **Step 3: Implement `GlobalSidecarStore` (file: branch first; untitled stub in-memory map)** + +```typescript +/** + * GlobalSidecarStore — the out-of-workspace/untitled authoring sidecar (F8 spec + * §6.2/§6.4), mirroring BaselineStore (src/baselineStore.ts): vscode-free (Node + * fs/crypto only), one `Artifact` JSON per document in VS Code's per-extension + * GLOBAL storage, NEVER the repo (INV-19/24). The key passed in is the DOCUMENT + * KEY (the URI string for the docs this store serves): + * - `file:` URI → disk at `/sidecars/.json`. + * - `untitled:` → an in-memory Map only (no durable identity — the F6 + * degrade); lost on reload, and never read by mergeArtifacts (INV-25). + * `consumeSelfWrite` is a no-op (these artifacts are outside the `**/.threads/**` + * watcher, so there is no self-write storm to suppress). + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { createHash } from "node:crypto"; +import { + SCHEMA_VERSION, + emptyArtifact, + isNewerMajor, + serializeArtifact, + type Artifact, +} from "./model"; +import type { SidecarStore } from "./sidecarStore"; + +export class GlobalSidecarStore implements SidecarStore { + /** untitled keys live here only — no durable identity to persist against. */ + private readonly memory = new Map(); + + /** @param storageDir absolute VS Code GLOBAL-storage dir (context.globalStorageUri.fsPath). */ + constructor(private readonly storageDir: string) {} + + private isUntitled(key: string): boolean { + return key.startsWith("untitled:"); + } + + /** Filesystem-safe storage key for a persistable (file:) doc: sha256 of its URI. */ + private storageKey(key: string): string { + return createHash("sha256").update(key).digest("hex"); + } + + /** Disk path for a `file:` key, or undefined for an in-memory untitled key. */ + sidecarPath(key: string): string | undefined { + if (this.isUntitled(key)) return undefined; + return path.join(this.storageDir, "sidecars", `${this.storageKey(key)}.json`); + } + + load(key: string): Artifact | null { + if (this.isUntitled(key)) return this.memory.get(key) ?? null; + const p = this.sidecarPath(key)!; + if (!fs.existsSync(p)) return null; + return JSON.parse(fs.readFileSync(p, "utf8")) as Artifact; + } + + save(key: string, artifact: Artifact): void { + if (this.isUntitled(key)) { + this.memory.set(key, artifact); + return; + } + const p = this.sidecarPath(key)!; + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, serializeArtifact(artifact), "utf8"); + } + + /** See CoauthorStore.update — same INV-16 throw + anchor prune, no self-write mark. */ + update(key: string, mutate: (artifact: Artifact) => void): Artifact { + const artifact = this.load(key) ?? emptyArtifact(key); + if (isNewerMajor(artifact)) { + throw new Error( + `refusing to write ${key}: sidecar schemaVersion ${artifact.schemaVersion} > supported ${SCHEMA_VERSION} (INV-16)`, + ); + } + mutate(artifact); + const referenced = new Set([ + ...artifact.threads.map((t) => t.anchorId), + ...artifact.attributions.map((a) => a.anchorId), + ...artifact.proposals.map((p) => p.anchorId), + ]); + for (const id of Object.keys(artifact.anchors)) { + if (!referenced.has(id)) delete artifact.anchors[id]; + } + this.save(key, artifact); + return artifact; + } + + /** No-op: global artifacts are outside the `**/.threads/**` watcher. */ + consumeSelfWrite(_fsPath: string): boolean { + return false; + } +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `npm test -- globalSidecarStore` +Expected: PASS (all 7). + +- [ ] **Step 5: Commit** + +```bash +git add src/globalSidecarStore.ts test/globalSidecarStore.test.ts +git commit -m "feat(f8): GlobalSidecarStore for file: URIs (sha256 key, INV-19/24)" +``` + +--- + +## Task 3: `GlobalSidecarStore` — untitled in-memory branch + +**Files:** +- Test: `test/globalSidecarStore.test.ts` (append) + +- [ ] **Step 1: Append the failing test** + +```typescript +describe("GlobalSidecarStore — untitled: keys are in-memory only (no disk, lost on reload)", () => { + const UNTITLED = "untitled:Untitled-1"; + + it("save then load round-trips within the same instance (session)", () => { + const store = new GlobalSidecarStore(dir); + const a = emptyArtifact(UNTITLED); + a.threads.push({ id: "t", anchorId: "an", status: "open", messages: [] }); + a.anchors["an"] = { fingerprint: { text: "z", before: "", after: "", lineHint: 0 } }; + store.save(UNTITLED, a); + expect(store.load(UNTITLED)).toEqual(a); + }); + + it("writes NO disk file for an untitled key", () => { + const store = new GlobalSidecarStore(dir); + store.save(UNTITLED, emptyArtifact(UNTITLED)); + expect(store.sidecarPath(UNTITLED)).toBeUndefined(); + expect(fs.existsSync(path.join(dir, "sidecars"))).toBe(false); + }); + + it("a fresh instance (simulated reload) has no untitled state", () => { + const store = new GlobalSidecarStore(dir); + store.save(UNTITLED, emptyArtifact(UNTITLED)); + expect(new GlobalSidecarStore(dir).load(UNTITLED)).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run to verify it passes** (the untitled branch was implemented in Task 2) + +Run: `npm test -- globalSidecarStore` +Expected: PASS (all 10). If any untitled test fails, fix the `isUntitled` branches in `globalSidecarStore.ts` until green. + +- [ ] **Step 3: Commit** + +```bash +git add test/globalSidecarStore.test.ts +git commit -m "test(f8): GlobalSidecarStore untitled in-memory branch" +``` + +--- + +## Task 4: `isAuthorable` + widened `selectionRejection` + +**Files:** +- Modify: `src/workspacePath.ts` +- Test: `test/workspacePath.test.ts` + +- [ ] **Step 1: Write the failing tests (append to `test/workspacePath.test.ts`)** + +```typescript +import { isAuthorable } from "../src/workspacePath"; + +describe("isAuthorable", () => { + it("accepts file: and untitled: schemes", () => { + expect(isAuthorable("file")).toBe(true); + expect(isAuthorable("untitled")).toBe(true); + }); + it("rejects read-only / virtual schemes", () => { + expect(isAuthorable("git")).toBe(false); + expect(isAuthorable("output")).toBe(false); + expect(isAuthorable("cowriting-baseline")).toBe(false); + expect(isAuthorable("")).toBe(false); + }); +}); + +describe("selectionRejection — F8 widened (accepts out-of-folder + untitled)", () => { + const ok = { hasEditor: true, selectionEmpty: false, scheme: "file" }; + + it("accepts an out-of-workspace file (no longer rejected)", () => { + expect(selectionRejection({ ...ok, scheme: "file" })).toBeNull(); + }); + it("accepts an untitled buffer (no longer rejected)", () => { + expect(selectionRejection({ ...ok, scheme: "untitled" })).toBeNull(); + }); + it("still names the missing editor", () => { + expect(selectionRejection({ ...ok, hasEditor: false })).toMatch(/focus a text editor/i); + }); + it("still names the empty selection", () => { + expect(selectionRejection({ ...ok, selectionEmpty: true })).toMatch(/select some text/i); + }); + it("rejects a non-{file,untitled} scheme with its own message (not 'select some text')", () => { + const msg = selectionRejection({ ...ok, scheme: "git" }); + expect(msg).toMatch(/can.?t be edited|read-only|not a file/i); + expect(msg).not.toMatch(/select some text/i); + }); +}); +``` + +Note: the existing `selectionRejection` tests that assert "save this document" for `untitled` and "outside your workspace folder" for an out-of-root file are now **obsolete** — delete those two `it(...)` blocks (the ones at `test/workspacePath.test.ts` matching `/save this document/i` and `/outside your workspace folder/i`). Keep the `isUnderRoot` describe block unchanged. + +- [ ] **Step 2: Run to verify it fails** + +Run: `npm test -- workspacePath` +Expected: FAIL (`isAuthorable` not exported; new selectionRejection expectations unmet). + +- [ ] **Step 3: Implement — replace `selectionRejection` + add `isAuthorable` in `src/workspacePath.ts`** + +Replace the `SelectionContext` interface and `selectionRejection` function (keep `isUnderRoot` exactly as is) with: + +```typescript +/** Documents Cowriting can author on: a saved file OR an unsaved buffer (F8 §6.2). */ +export function isAuthorable(scheme: string): boolean { + return scheme === "file" || scheme === "untitled"; +} + +export interface SelectionContext { + /** Is there an active text editor at all? */ + hasEditor: boolean; + /** Is the active editor's selection empty (no highlight)? */ + selectionEmpty: boolean; + /** The active document's URI scheme (`file`, `untitled`, …). */ + scheme: string; +} + +/** + * Why a selection can't be sent to Claude — or `null` if it can. F8 widened the + * membership: any `file:` or `untitled:` document is authorable (in-workspace + * files persist to the repo `.threads/` sidecar, out-of-workspace/untitled to + * global storage — the router decides). Only a non-{file,untitled} scheme (a + * read-only `git:`/`output:` view), a missing editor, or an empty selection is + * refused — each with its own message (#24's per-condition messaging). + */ +export function selectionRejection(ctx: SelectionContext): string | null { + if (!ctx.hasEditor) { + return "Cowriting: focus a text editor with a selection first."; + } + if (ctx.selectionEmpty) { + return "Cowriting: select some text to send to Claude first."; + } + if (!isAuthorable(ctx.scheme)) { + return "Cowriting: this kind of document can't be edited — Cowriting authors on a file or an untitled buffer, not a read-only view."; + } + return null; +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `npm test -- workspacePath` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/workspacePath.ts test/workspacePath.test.ts +git commit -m "feat(f8): isAuthorable + widened selectionRejection (file|untitled)" +``` + +--- + +## Task 5: `SidecarRouter` — `keyOf` + per-key routing + +**Files:** +- Create: `src/sidecarRouter.ts` +- Test: `test/sidecarRouter.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +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 { createHash } from "node:crypto"; +import { CoauthorStore } from "../src/store"; +import { GlobalSidecarStore } from "../src/globalSidecarStore"; +import { SidecarRouter, docIdentity } from "../src/sidecarRouter"; +import { emptyArtifact } from "../src/model"; + +let root: string; +let globalDir: string; +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "router-root-")); + globalDir = fs.mkdtempSync(path.join(os.tmpdir(), "router-global-")); +}); +afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(globalDir, { recursive: true, force: true }); +}); + +function makeRouter(withRoot = true): SidecarRouter { + return new SidecarRouter( + withRoot ? new CoauthorStore(root) : null, + new GlobalSidecarStore(globalDir), + withRoot ? root : undefined, + ); +} +// minimal duck-typed stand-in for a vscode.TextDocument's identity inputs +const docOf = (uriString: string, fsPath: string, scheme: string) => ({ + uri: { toString: () => uriString, fsPath, scheme }, +}); +const hash = (uri: string) => createHash("sha256").update(uri).digest("hex"); + +describe("SidecarRouter.keyOf — one identity per document", () => { + it("an in-root file: doc → its repo-relative path", () => { + const fsPath = path.join(root, "docs", "x.md"); + const key = makeRouter().keyOf(docIdentity(docOf(`file://${fsPath}`, fsPath, "file"))); + expect(key).toBe(path.join("docs", "x.md")); + }); + it("an out-of-root file: doc → its URI string", () => { + const fsPath = "/elsewhere/y.md"; + const uri = `file://${fsPath}`; + expect(makeRouter().keyOf(docIdentity(docOf(uri, fsPath, "file")))).toBe(uri); + }); + it("an untitled: doc → its URI string", () => { + expect(makeRouter().keyOf(docIdentity(docOf("untitled:Untitled-1", "Untitled-1", "untitled")))).toBe( + "untitled:Untitled-1", + ); + }); + it("root undefined (no folder) → everything is the URI string", () => { + const fsPath = "/anything/z.md"; + const uri = `file://${fsPath}`; + expect(makeRouter(false).keyOf(docIdentity(docOf(uri, fsPath, "file")))).toBe(uri); + }); +}); + +describe("SidecarRouter routing — load/save/update dispatch by key home", () => { + it("an in-root key writes to the repo .threads/ sidecar, not global storage", () => { + const router = makeRouter(); + const key = path.join("docs", "x.md"); + router.save(key, emptyArtifact(key)); + expect(fs.existsSync(path.join(root, ".threads", "docs", "x.md.json"))).toBe(true); + expect(fs.existsSync(path.join(globalDir, "sidecars"))).toBe(false); + expect(router.sidecarPath(key)).toBe(path.join(root, ".threads", "docs", "x.md.json")); + }); + it("an out-of-root file: key writes to global storage, not the repo", () => { + const router = makeRouter(); + const key = "file:///elsewhere/y.md"; + router.save(key, emptyArtifact(key)); + expect(fs.existsSync(path.join(globalDir, "sidecars", `${hash(key)}.json`))).toBe(true); + expect(fs.existsSync(path.join(root, ".threads"))).toBe(false); + expect(router.sidecarPath(key)).toBe(path.join(globalDir, "sidecars", `${hash(key)}.json`)); + }); + it("an untitled: key is in-memory (global store), no disk anywhere", () => { + const router = makeRouter(); + const key = "untitled:Untitled-1"; + router.update(key, (a) => a.threads.push({ id: "t", anchorId: "an", status: "open", messages: [] })); + expect(router.load(key)!.threads.length).toBe(1); + expect(router.sidecarPath(key)).toBeUndefined(); + expect(fs.existsSync(path.join(globalDir, "sidecars"))).toBe(false); + }); + it("round-trips through load after update on an in-root key", () => { + const router = makeRouter(); + const key = path.join("docs", "x.md"); + router.update(key, (a) => a.threads.push({ id: "t", anchorId: "an", status: "open", messages: [] })); + expect(router.load(key)!.threads.length).toBe(1); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npm test -- sidecarRouter` +Expected: FAIL ("Cannot find module '../src/sidecarRouter'"). + +- [ ] **Step 3: Implement `src/sidecarRouter.ts`** + +```typescript +/** + * SidecarRouter — the per-document persistence façade the three authoring + * controllers depend on (F8 spec §6.2/§6.4). Implements SidecarStore and OWNS + * the routing: it computes the single document key (keyOf) and dispatches + * load/save/update/consumeSelfWrite/sidecarPath to the right implementation: + * - an in-workspace `file:` doc → CoauthorStore, keyed by its repo-relative + * path → `.threads/.json` (committable, INV-2; the only home F5's + * cross-rung ever sees). + * - an out-of-workspace `file:` or `untitled:` doc → GlobalSidecarStore, keyed + * by its URI string → global storage (INV-19/24/25; untitled in-memory). + * The membership predicate is `#24`'s isUnderRoot — here a ROUTING input, never + * an authoring gate (that became isAuthorable). vscode-free (takes the extracted + * identity, not a vscode.TextDocument) so it unit-tests. + */ +import * as path from "node:path"; +import { isUnderRoot } from "./workspacePath"; +import type { Artifact } from "./model"; +import type { SidecarStore } from "./sidecarStore"; + +/** The minimal document identity the router routes on (extracted from a TextDocument). */ +export interface DocIdentity { + /** `document.uri.toString()` — the URI string. */ + uri: string; + /** `document.uri.fsPath`. */ + fsPath: string; + /** `document.uri.scheme`. */ + scheme: string; +} + +/** Extract the routing identity from anything URI-shaped (a real vscode.TextDocument fits). */ +export function docIdentity(doc: { uri: { toString(): string; fsPath: string; scheme: string } }): DocIdentity { + return { uri: doc.uri.toString(), fsPath: doc.uri.fsPath, scheme: doc.uri.scheme }; +} + +export class SidecarRouter implements SidecarStore { + constructor( + private readonly repo: SidecarStore | null, + private readonly global: SidecarStore & { sidecarPath(key: string): string | undefined }, + private readonly root: string | undefined, + ) {} + + /** + * The single document key: an in-workspace `file:` doc → its repo-relative + * path (cross-rung-meaningful, the existing sidecar key, INV-2); everything + * else → its URI string (machine-local, self-consistent — INV-25). + */ + keyOf(id: DocIdentity): string { + if (id.scheme === "file" && this.root !== undefined && isUnderRoot(id.fsPath, this.root)) { + return path.relative(this.root, id.fsPath); + } + return id.uri; + } + + /** A key is a global (URI-string) key iff it carries a URI scheme prefix. */ + private isGlobalKey(key: string): boolean { + return key.startsWith("file://") || key.startsWith("untitled:"); + } + private storeFor(key: string): SidecarStore { + if (this.isGlobalKey(key) || this.repo === null) return this.global; + return this.repo; + } + + load(key: string): Artifact | null { + return this.storeFor(key).load(key); + } + save(key: string, artifact: Artifact): void { + this.storeFor(key).save(key, artifact); + } + update(key: string, mutate: (artifact: Artifact) => void): Artifact { + return this.storeFor(key).update(key, mutate); + } + /** Only the repo store self-writes (the `.threads/` watcher); global is a no-op. */ + consumeSelfWrite(fsPath: string): boolean { + return this.repo?.consumeSelfWrite(fsPath) ?? false; + } + + /** + * The sidecar's on-disk path: `.threads/.json` for a repo key, the hashed + * global path for an out-of-folder `file:` key, undefined for untitled. Used by + * the controllers' external-change handler (only repo sidecars are watched) and + * by the E2E to assert the storage home. + */ + sidecarPath(key: string): string | undefined { + if (this.isGlobalKey(key)) return this.global.sidecarPath(key); + if (this.repo === null) return undefined; + // CoauthorStore exposes sidecarPath(docPath): string (structural). + return (this.repo as unknown as { sidecarPath(k: string): string }).sidecarPath(key); + } +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `npm test -- sidecarRouter` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/sidecarRouter.ts test/sidecarRouter.test.ts +git commit -m "feat(f8): SidecarRouter — keyOf + per-document routing (spec §6.2)" +``` + +--- + +## Task 6: `VersionGuard` onto `SidecarStore` + +**Files:** +- Modify: `src/versionGuard.ts` + +- [ ] **Step 1: Re-point the store type** + +In `src/versionGuard.ts`, change the import and field type from `CoauthorStore` to `SidecarStore`: + +```typescript +import type { SidecarStore } from "./sidecarStore"; +// ... +export class VersionGuard { + private readonly warned = new Set(); + + constructor(private readonly store: SidecarStore) {} + // isReadOnly / wasWarned unchanged — they only call store.load(key). +} +``` + +(Replace `import type { CoauthorStore } from "./store";` and `constructor(private readonly store: CoauthorStore)`. The `docPath` params are now document keys — no logic change.) + +- [ ] **Step 2: Verify typecheck** + +Run: `npm run typecheck` +Expected: clean (controllers still pass `CoauthorStore`, which conforms to `SidecarStore` structurally — Task 7 switches them to the router). + +- [ ] **Step 3: Commit** + +```bash +git add src/versionGuard.ts +git commit -m "refactor(f8): VersionGuard depends on SidecarStore not CoauthorStore" +``` + +--- + +## Task 7: Re-point the three controllers onto the router + +**Files:** +- Modify: `src/threadController.ts`, `src/attributionController.ts`, `src/proposalController.ts` + +This task changes three controllers the same way. Each currently: imports `CoauthorStore`, takes `store: CoauthorStore` + `rootDir: string`, gates on `isInRoot`/`isTracked` (`scheme==="file" && isUnderRoot(fsPath, rootDir)`), and computes its per-doc key via `docPathOf(uri) = vscode.workspace.asRelativePath(uri, false)`. After F8 each: takes `store: SidecarRouter` + `rootDir: string | undefined`, gates on `isAuthorable(document.uri.scheme)`, and computes its key via `this.store.keyOf(docIdentity(document))`. + +- [ ] **Step 1: ThreadController — imports + constructor + gate + key** + +In `src/threadController.ts`: + +Replace the imports: +```typescript +import { CoauthorStore } from "./store"; +// ... +import { isUnderRoot } from "./workspacePath"; +``` +with: +```typescript +import { SidecarRouter, docIdentity } from "./sidecarRouter"; +// ... +import { isAuthorable } from "./workspacePath"; +``` + +Change the constructor signature: +```typescript + constructor( + private readonly store: SidecarRouter, + private readonly rootDir: string | undefined, + private readonly guard: VersionGuard, + ) { +``` + +Replace `isInRoot` and `docPathOf`: +```typescript + private isInRoot(uri: vscode.Uri): boolean { + return uri.scheme === "file" && isUnderRoot(uri.fsPath, this.rootDir); + } + + private docPathOf(uri: vscode.Uri): string { + return vscode.workspace.asRelativePath(uri, false); + } +``` +with a single key helper (the controller keys on the document key now): +```typescript + private keyOf(document: vscode.TextDocument): string { + return this.store.keyOf(docIdentity(document)); + } +``` + +Now update every caller in this file: +- `commentingRangeProvider`: `if (!this.isInRoot(document.uri)) return [];` → `if (!isAuthorable(document.uri.scheme)) return [];` +- `currentAuthor()`: `const email = gitUserEmail(this.rootDir);` → `const email = this.rootDir !== undefined ? gitUserEmail(this.rootDir) : undefined;` +- `persist(state)` calls `this.store.update(state.docPath, …)` — leave as is; `state.docPath` now holds the key (see DocState note below). +- `ensureState(document)`: replace `const docPath = this.docPathOf(document.uri);` with `const docPath = this.keyOf(document);` (everywhere `docPathOf(...)` appears in this file, use `this.keyOf(doc)` when a `TextDocument` is available; `state.docPath` already holds the key). +- `createThreadOnSelection`: `!this.isInRoot(editor.document.uri)` → `!isAuthorable(editor.document.uri.scheme)`; and `this.guard.isReadOnly(this.docPathOf(editor.document.uri))` → `this.guard.isReadOnly(this.keyOf(editor.document))`. +- `renderAll(document)`: `const docPath = this.docPathOf(document.uri);` → `const docPath = this.keyOf(document);` +- `handleExternalSidecarChange`: the body compares `this.store.sidecarPath(state.docPath) === p` and finds the doc by `this.docPathOf(d.uri) === state.docPath`. Replace with: + ```typescript + handleExternalSidecarChange(uri: vscode.Uri): void { + const p = uri.fsPath; + for (const state of this.docs.values()) { + if (this.store.sidecarPath(state.docPath) === p) { + const doc = vscode.workspace.textDocuments.find((d) => this.keyOf(d) === state.docPath); + if (doc) this.renderAll(doc); + } + } + } + ``` +- `onDidChange`: `this.docs.get(this.docPathOf(e.document.uri))` → `this.docs.get(this.keyOf(e.document))`. +- `onDidSave`: `this.docs.get(this.docPathOf(document.uri))` → `this.docs.get(this.keyOf(document))`. + +Leave `DocState.docPath`'s field name as `docPath` (it now holds the document key — renaming it ripples needlessly; the comment on the field already says "keyed by docPath"). The `getRendered(docPath)` test surface keeps its name and still accepts the key (the repo-relative path for in-workspace docs, exactly as today). + +- [ ] **Step 2: AttributionController — same transform** + +In `src/attributionController.ts`: +- Imports: `import { CoauthorStore } from "./store";` → `import { SidecarRouter, docIdentity } from "./sidecarRouter";`; `import { isUnderRoot } from "./workspacePath";` → `import { isAuthorable } from "./workspacePath";` +- Constructor: `private readonly store: CoauthorStore,` → `private readonly store: SidecarRouter,`; `private readonly rootDir: string,` → `private readonly rootDir: string | undefined,` +- `isTracked(document)`: + ```typescript + private isTracked(document: vscode.TextDocument): boolean { + return isAuthorable(document.uri.scheme); + } + ``` +- Replace `docPathOf`: + ```typescript + private docPathOf(uri: vscode.Uri): string { + return vscode.workspace.asRelativePath(uri, false); + } + ``` + with: + ```typescript + private keyOf(document: vscode.TextDocument): string { + return this.store.keyOf(docIdentity(document)); + } + ``` +- `currentAuthor()`: `const email = gitUserEmail(this.rootDir);` → `const email = this.rootDir !== undefined ? gitUserEmail(this.rootDir) : undefined;` +- Every `this.docPathOf(.uri)` where a `TextDocument` is in scope → `this.keyOf()`. Specifically: + - `loadAll`: `const docPath = this.docPathOf(document.uri);` → `const docPath = this.keyOf(document);` + - `handleExternalSidecarChange`: + ```typescript + 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.keyOf(d) === s.docPath); + if (doc) this.loadAll(doc); + } + } + } + ``` + - `onDidChange`: `const docPath = this.docPathOf(e.document.uri);` → `const docPath = this.keyOf(e.document);` + - `applyAgentEdit`: `const docPath = this.docPathOf(document.uri);` → `const docPath = this.keyOf(document);` + - `onDidSave`: both `this.docPathOf(document.uri)` occurrences → `this.keyOf(document)`. + - `render`: `this.docs.get(this.docPathOf(document.uri))` → `this.docs.get(this.keyOf(document))`. +- `matchesDisk(document)` reads `document.uri.fsPath` via `fs.statSync` — for an untitled buffer that path is not a real file, `statSync` throws, the `catch` returns `false` (treated as a real edit). Leave unchanged. + +- [ ] **Step 3: ProposalController — same transform** + +In `src/proposalController.ts`: +- Imports: `import { CoauthorStore } from "./store";` → `import { SidecarRouter, docIdentity } from "./sidecarRouter";`; `import { isUnderRoot } from "./workspacePath";` → `import { isAuthorable } from "./workspacePath";` +- Constructor: `private readonly store: CoauthorStore,` → `private readonly store: SidecarRouter,`; `private readonly rootDir: string,` → `private readonly rootDir: string | undefined,` +- `isTracked(document)`: + ```typescript + private isTracked(document: vscode.TextDocument): boolean { + return isAuthorable(document.uri.scheme); + } + ``` +- Replace `docPathOf` with `keyOf` (same as the other two): + ```typescript + private keyOf(document: vscode.TextDocument): string { + return this.store.keyOf(docIdentity(document)); + } + ``` +- Update callers: + - `ensureState`: `const docPath = this.docPathOf(document.uri);` → `const docPath = this.keyOf(document);` + - `propose`: both `this.docPathOf(document.uri)` (the `guard.isReadOnly` guard and `const docPath`) → `this.keyOf(document)`. + - `renderAll`: `const docPath = this.docPathOf(document.uri);` → `const docPath = this.keyOf(document);` + - `handleExternalSidecarChange`: + ```typescript + handleExternalSidecarChange(uri: vscode.Uri): void { + for (const state of this.docs.values()) { + if (this.store.sidecarPath(state.docPath) === uri.fsPath) { + const doc = vscode.workspace.textDocuments.find((d) => this.keyOf(d) === state.docPath); + if (doc) this.renderAll(doc); + } + } + } + ``` + - `onDidChange`: `this.docs.get(this.docPathOf(e.document.uri))` → `this.docs.get(this.keyOf(e.document))`. + - `openDoc(state)`: `vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath)` → `vscode.workspace.textDocuments.find((d) => this.keyOf(d) === state.docPath)`. + +- [ ] **Step 4: Verify typecheck (extension.ts still passes `CoauthorStore` — expected to fail here)** + +Run: `npm run typecheck` +Expected: errors ONLY in `src/extension.ts` (it still constructs controllers with a `CoauthorStore` + non-optional root). Those are fixed in Task 8. The three controller files themselves must typecheck clean — if any controller file reports an error, fix it before proceeding. + +- [ ] **Step 5: Commit** + +```bash +git add src/threadController.ts src/attributionController.ts src/proposalController.ts +git commit -m "refactor(f8): controllers depend on SidecarRouter + isAuthorable (key via keyOf)" +``` + +--- + +## Task 8: Wire the router in `extension.ts`; authoring works folder-less + +**Files:** +- Modify: `src/extension.ts` + +- [ ] **Step 1: Imports + API surface** + +Add imports near the top: +```typescript +import { GlobalSidecarStore } from "./globalSidecarStore"; +import { SidecarRouter } from "./sidecarRouter"; +``` +Change `import { isUnderRoot, selectionRejection } from "./workspacePath";` to: +```typescript +import { isAuthorable, selectionRejection } from "./workspacePath"; +``` +Add `sidecarRouter` to the API interface: +```typescript +export interface CowritingApi { + threadController: ThreadController; + attributionController: AttributionController; + proposalController: ProposalController; + versionGuard: VersionGuard; + diffViewController: DiffViewController; + trackChangesPreviewController: TrackChangesPreviewController; + sidecarRouter: SidecarRouter; +} +``` + +- [ ] **Step 2: Construct the global store next to the baseline store** + +Right after the `baselineStore` construction (the F6 block), add: +```typescript + // F8: the out-of-workspace/untitled authoring sidecar — same GLOBAL storage + // home as the F6 baseline, keyed by sha256(uri) (INV-19/24). Constructed + // workspace-independently; the router falls back to it for any non-in-folder doc. + const globalSidecarStore = new GlobalSidecarStore(baselineStorageDir ?? ""); +``` +(When `baselineStorageDir` is undefined — global storage genuinely unavailable — file writes will throw and authoring degrades to errors-on-write, the F6-equivalent edge; untitled in-memory still works. This matches PUC-5.) + +- [ ] **Step 3: Replace the no-root early-return block with folder-less wiring** + +Replace the entire block from `const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;` through the `return undefined;` and its closing brace (the `if (!root) { … stubs … return undefined; }`) — i.e. lines that early-return on no folder — with: + +```typescript + // --- F8: authoring on ANY document (in-folder, out-of-folder, untitled) --- + // The router routes per-document: in-workspace file: → the committable repo + // `.threads/` sidecar (CoauthorStore); out-of-folder file: + untitled: → the + // global-storage sidecar. Constructed even with NO folder open (everything + // then routes global) — the F6 #19 precedent, now extended to authoring. + const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const coauthorStore = root ? new CoauthorStore(root) : null; + const sidecarRouter = new SidecarRouter(coauthorStore, globalSidecarStore, root); + // F5 (INV-16): one shared guard — newer-major sidecars are read-only. + const versionGuard = new VersionGuard(sidecarRouter); + const threadController = new ThreadController(sidecarRouter, root, versionGuard); + context.subscriptions.push(threadController); + + // --- F3: live attribution (Feature #6) --- + const attributionController = new AttributionController(sidecarRouter, root, versionGuard); + context.subscriptions.push(attributionController); + + // --- F4: propose/accept (Feature #12) --- + const proposalController = new ProposalController(sidecarRouter, attributionController, root, versionGuard); + context.subscriptions.push(proposalController); + + // --- F6 machine-landing wiring — now for ANY authorable doc --- + // The seam's single machine-landing signal advances the F6 baseline (INV-18); + // the seam can now fire on out-of-folder files too, so wire it unconditionally. + context.subscriptions.push( + attributionController.onDidApplyAgentEdit((e) => diffViewController.advance(e.document)), + ); + + // One SHARED sidecar watcher for both controllers; self-writes are suppressed + // centrally in the repo store (only repo `.threads/` sidecars are watched — + // global artifacts live outside the workspace). Harmless when no folder is open. + const watcher = vscode.workspace.createFileSystemWatcher("**/.threads/**/*.json"); + const onSidecar = (uri: vscode.Uri) => { + if (sidecarRouter.consumeSelfWrite(uri.fsPath)) return; + threadController.handleExternalSidecarChange(uri); + attributionController.handleExternalSidecarChange(uri); + proposalController.handleExternalSidecarChange(uri); + }; + watcher.onDidChange(onSidecar); + watcher.onDidCreate(onSidecar); + context.subscriptions.push(watcher); +``` + +(Note: the F6/F7 controllers are constructed ABOVE this block, unchanged. Delete the old `const store = new CoauthorStore(root);`/`new VersionGuard(store)`/`new ThreadController(store, root, …)` etc. lines that this block replaces — they are now superseded.) + +- [ ] **Step 4: Fix the `editSelection` rejection call (it passed `fsPath`/`root`, now gone)** + +The `selectionRejection({...})` call inside `cowriting.editSelection` passes `scheme`, `fsPath`, `root`. Update it to the slimmed context: +```typescript + const reason = selectionRejection({ + hasEditor: !!editor, + selectionEmpty: editor?.selection.isEmpty ?? true, + scheme: editor?.document.uri.scheme ?? "", + }); +``` + +- [ ] **Step 5: Widen `renderIfOpen` to any authorable doc** + +Replace: +```typescript + const renderIfOpen = (doc: vscode.TextDocument) => { + if (doc.uri.scheme === "file" && isUnderRoot(doc.uri.fsPath, root)) { + threadController.renderAll(doc); + attributionController.loadAll(doc); + proposalController.renderAll(doc); + } + }; +``` +with: +```typescript + const renderIfOpen = (doc: vscode.TextDocument) => { + if (isAuthorable(doc.uri.scheme)) { + threadController.renderAll(doc); + attributionController.loadAll(doc); + proposalController.renderAll(doc); + } + }; +``` + +- [ ] **Step 6: Return the full API (including the router)** + +At the end of `activate`, the `return { … }` now always runs (no early `return undefined`). Add `sidecarRouter`: +```typescript + return { + threadController, + attributionController, + proposalController, + versionGuard, + diffViewController, + trackChangesPreviewController, + sidecarRouter, + }; +``` + +- [ ] **Step 7: Verify typecheck + unit tests** + +Run: `npm run typecheck && npm test` +Expected: typecheck clean; all unit tests PASS (130 prior + the new global/router/workspacePath tests). + +- [ ] **Step 8: Commit** + +```bash +git add src/extension.ts +git commit -m "feat(f8): wire SidecarRouter; authoring commands live folder-less (#19 precedent)" +``` + +--- + +## Task 9: Update the no-workspace E2E suite + +**Files:** +- Modify: `test/e2e/suite-no-workspace/noWorkspace.test.ts` + +After F8, with no folder open, `activate()` returns a real API and the authoring commands are real (they route to global storage), not warning stubs. + +- [ ] **Step 1: Rewrite the suite's expectations** + +Replace the body of the three authoring-related tests so they assert the new behavior. Keep the "EDH really has no workspace folder" test and the F6 test unchanged. Replace the two stub tests with: + +```typescript + test("activate returns a real API even with no workspace folder (F8)", async () => { + const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; + const api = await ext.activate(); + assert.ok(api?.proposalController, "no-folder activation returns the authoring API (F8)"); + assert.ok(api?.sidecarRouter, "router exposed"); + }); + + test("all contributed coauthoring commands are registered (real, not stubs)", async () => { + const all = await vscode.commands.getCommands(true); + for (const command of [ + "cowriting.createThread", + "cowriting.reply", + "cowriting.resolveThread", + "cowriting.reopenThread", + "cowriting.editSelection", + "cowriting.toggleAttribution", + "cowriting.applyAgentEdit", + "cowriting.acceptProposal", + "cowriting.rejectProposal", + "cowriting.proposeAgentEdit", + ]) { + assert.ok(all.includes(command), `${command} is registered`); + } + }); + + test("authoring works folder-less: propose→accept on an untitled buffer routes to global storage (F8)", async () => { + const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; + const api = await ext.activate(); + const untitled = await vscode.workspace.openTextDocument({ content: "Edit this scratch sentence please.\n", language: "markdown" }); + await vscode.window.showTextDocument(untitled); + await new Promise((r) => setTimeout(r, 300)); + const key = untitled.uri.toString(); + const target = "Edit this scratch sentence please."; + const start = untitled.getText().indexOf(target); + const id = await vscode.commands.executeCommand("cowriting.proposeAgentEdit", { + uri: key, start, end: start + target.length, newText: "REPLACED scratch sentence.", model: "sonnet", sessionId: "e2e-nf", turnId: "turn-nf", + }); + assert.ok(id, "propose returns an id for an untitled buffer with no folder"); + assert.ok(await api.proposalController.acceptById(key, id!), "accept applies"); + await new Promise((r) => setTimeout(r, 300)); + assert.ok(untitled.getText().includes("REPLACED scratch sentence."), "replacement landed in the untitled buffer"); + assert.strictEqual(api.sidecarRouter.sidecarPath(key), undefined, "untitled artifact is in-memory only (no disk)"); + }); +``` + +Also remove the now-obsolete top-of-file comment claim that the commands are "warning stubs" (replace with a one-line note that F8 made them real). + +- [ ] **Step 2: Verify (built by the E2E run in Task 11; typecheck via the e2e tsconfig)** + +Run: `npm run pretest:e2e` +Expected: builds + `tsc -p tsconfig.e2e.json` compiles clean. + +- [ ] **Step 3: Commit** + +```bash +git add test/e2e/suite-no-workspace/noWorkspace.test.ts +git commit -m "test(f8): no-folder suite — authoring commands are real, route to global" +``` + +--- + +## Task 10: Host E2E — out-of-workspace file + untitled + in-workspace regression + +**Files:** +- Create: `test/e2e/suite/outOfWorkspace.test.ts` + +- [ ] **Step 1: Write the E2E suite** + +```typescript +import * as assert from "assert"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import * as vscode from "vscode"; +import type { CowritingApi } from "../../../src/extension"; + +const WS = process.env.E2E_WORKSPACE!; +const settle = () => new Promise((r) => setTimeout(r, 300)); + +async function getApi(): Promise { + const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; + const api = (await ext.activate()) as CowritingApi; + assert.ok(api?.proposalController && api?.sidecarRouter, "extension exports the authoring API + router"); + return api; +} +async function proposeViaCommand(uri: string, text: string, target: string, newText: string, turnId: string): Promise { + const start = text.indexOf(target); + assert.ok(start >= 0, `text contains "${target}"`); + const id = await vscode.commands.executeCommand("cowriting.proposeAgentEdit", { + uri, start, end: start + target.length, newText, model: "sonnet", sessionId: "e2e-oow", turnId, + }); + assert.ok(id, "propose returns the proposal id"); + return id!; +} + +// F8 host E2E (no LLM): authoring on a file OUTSIDE the workspace folder and on +// an untitled buffer, plus an in-workspace byte-for-byte regression. Runs in the +// WITH-workspace EDH pass (a folder is open; the out-of-folder file lives in a +// temp dir outside it — exactly like diffView's out-of-folder test). +suite("F8 out-of-workspace authoring (host E2E — programmatic seam, no LLM)", () => { + test("out-of-folder file: propose→accept lands Claude-attributed; persisted in GLOBAL storage, no .threads (PUC-1, INV-9/24/25)", async () => { + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "cowriting-oow-")); + const outsidePath = path.join(outsideDir, "sibling.md"); + const TARGET = "The sibling-repo sentence Claude edits."; + fs.writeFileSync(outsidePath, `# Sibling\n\n${TARGET}\n`, "utf8"); + const uri = vscode.Uri.file(outsidePath); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + await settle(); + const api = await getApi(); + const key = uri.toString(); + + const id = await proposeViaCommand(key, doc.getText(), TARGET, "The sibling sentence CLAUDE REWROTE.", "turn-oow1"); + await settle(); + // routed to global storage, never the repo + const sidecar = api.sidecarRouter.sidecarPath(key)!; + assert.ok(sidecar && fs.existsSync(sidecar), `global sidecar exists at ${sidecar}`); + assert.ok(sidecar.includes(`${path.sep}sidecars${path.sep}`), "lives under /sidecars/"); + assert.ok(!sidecar.includes(`${path.sep}.threads${path.sep}`), "NOT in a .threads/ tree (INV-25)"); + assert.ok(!fs.existsSync(path.join(outsideDir, ".threads")), "no .threads/ written beside the out-of-folder file"); + + assert.ok(await api.proposalController.acceptById(key, id), "accept applies via the seam"); + await settle(); + assert.ok(doc.getText().includes("The sibling sentence CLAUDE REWROTE."), "replacement landed"); + const agent = api.attributionController.getSpans(key).find((s) => s.turnId === "turn-oow1"); + assert.ok(agent && agent.authorKind === "agent", "accepted text is Claude-attributed"); + assert.strictEqual(api.proposalController.getRendered(key).length, 0, "proposal gone (INV-13)"); + + // a thread can be opened on the out-of-folder doc + await vscode.window.showTextDocument(doc); + const editor = vscode.window.activeTextEditor!; + editor.selection = new vscode.Selection(doc.positionAt(0), doc.positionAt(8)); + const threadId = await api.threadController.createThreadOnSelection("thread on a sibling file"); + assert.ok(threadId, "a thread opens on the out-of-folder doc"); + + // reload-restore from the GLOBAL sidecar (re-anchor content-based) + fs.writeFileSync(outsidePath, "PREPENDED\n\n" + doc.getText(), "utf8"); + const reloaded = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(reloaded); + await vscode.commands.executeCommand("workbench.action.files.revert"); + await settle(); + api.threadController.renderAll(reloaded); + assert.strictEqual(api.threadController.getRendered(key).length, 1, "thread restored from the global sidecar after reload"); + + fs.rmSync(outsideDir, { recursive: true, force: true }); + }); + + test("untitled buffer: propose→accept works in-session, state in-memory only (PUC-2)", async () => { + const TARGET = "The untitled draft sentence."; + const untitled = await vscode.workspace.openTextDocument({ content: `${TARGET}\n`, language: "markdown" }); + await vscode.window.showTextDocument(untitled); + await settle(); + const api = await getApi(); + const key = untitled.uri.toString(); + assert.strictEqual(untitled.uri.scheme, "untitled", "really untitled"); + + const id = await proposeViaCommand(key, untitled.getText(), TARGET, "The untitled draft, CLAUDE-EDITED.", "turn-oow2"); + await settle(); + assert.strictEqual(api.sidecarRouter.sidecarPath(key), undefined, "untitled artifact is in-memory only (no disk)"); + assert.ok(await api.proposalController.acceptById(key, id), "accept applies"); + await settle(); + assert.ok(untitled.getText().includes("The untitled draft, CLAUDE-EDITED."), "replacement landed in the untitled buffer"); + const agent = api.attributionController.getSpans(key).find((s) => s.turnId === "turn-oow2"); + assert.ok(agent && agent.authorKind === "agent", "untitled accepted text is Claude-attributed"); + }); + + test("in-workspace regression: artifact still lands at /.threads/.json with the repo-relative document.path (INV-2)", async () => { + const DOC_REL = "docs/f8regression.md"; + const TARGET = "An in-folder sentence for the F8 regression."; + const abs = path.join(WS, DOC_REL); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, `# F8 regression\n\n${TARGET}\n`, "utf8"); + const uri = vscode.Uri.file(abs); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + await settle(); + const api = await getApi(); + + // the in-workspace key is the repo-relative path, not the URI string + assert.strictEqual(api.sidecarRouter.keyOf({ uri: uri.toString(), fsPath: abs, scheme: "file" }), DOC_REL, "in-folder key is the repo-relative path"); + const id = await proposeViaCommand(uri.toString(), doc.getText(), TARGET, "An in-folder sentence, EDITED.", "turn-oow3"); + await settle(); + assert.ok(await api.proposalController.acceptById(DOC_REL, id), "accept by the repo-relative key"); + await settle(); + const sidecar = api.sidecarRouter.sidecarPath(DOC_REL)!; + assert.strictEqual(sidecar, path.join(WS, ".threads", "docs", "f8regression.md.json"), "committable .threads/ path unchanged (INV-2)"); + assert.ok(fs.existsSync(sidecar), "sidecar written in the repo"); + const onDisk = JSON.parse(fs.readFileSync(sidecar, "utf8")); + assert.strictEqual(onDisk.document.path, DOC_REL, "document.path is the repo-relative path (byte-for-byte)"); + }); +}); +``` + +- [ ] **Step 2: Commit** + +```bash +git add test/e2e/suite/outOfWorkspace.test.ts +git commit -m "test(f8): host E2E — out-of-folder file + untitled + in-workspace regression" +``` + +--- + +## Task 11: Run the full host E2E suite + +**Files:** none (verification). + +- [ ] **Step 1: Run the E2E** + +Run: `npm run test:e2e` +Expected: BOTH EDH passes green — the with-workspace suite (F2–F7 + the new F8 out-of-workspace suite) and the no-workspace suite (updated for real folder-less authoring). If a test fails, debug per `superpowers:systematic-debugging` (do not weaken assertions to pass). + +- [ ] **Step 2: Commit any fixes** + +```bash +git add -A +git commit -m "fix(f8): make host E2E green" +``` +(Skip if no fixes were needed.) + +--- + +## Task 12: Docs — manual smoke + README hybrid-model note + +**Files:** +- Create: `docs/MANUAL-SMOKE-F8.md` +- Modify: `README.md` + +- [ ] **Step 1: Write `docs/MANUAL-SMOKE-F8.md`** (mirror the F6/F7 smoke docs' structure) + +```markdown +# Manual smoke — F8 out-of-workspace authoring + +Confirms "Ask Claude to Edit Selection" (+ threads/attribution) work on a file +**outside** the workspace folder and on an **untitled** buffer, while in-workspace +authoring is unchanged. One live turn hits the SDK (the only LLM step). + +## Prereqs +- Build: `npm run build`. +- Launch the Extension Development Host (F5 in VS Code) with this repo's `sandbox/` + opened as the workspace folder. + +## A. Out-of-folder file (PUC-1) +1. Open a markdown file from a DIFFERENT directory (outside `sandbox/`) — e.g. a + sibling repo, or `File > Open` a scratch file under `/tmp`. +2. Select a sentence → Command Palette → **Cowriting: Ask Claude to Edit Selection** + → type an instruction → wait for the proposal (amber range + ✓/✗). +3. Accept (✓). Expect: the text is replaced and shows the Claude attribution tint. +4. Add a coauthoring thread on a selection (**Add Coauthoring Thread on Selection**). +5. Close and reopen the file (or revert). Expect: the thread + attribution are + restored. There is **no** `.threads/` folder beside the file — its state lives + in VS Code global storage (`/sidecars/.json`). + +## B. Untitled buffer (PUC-2) +1. `File > New File` (don't save) → type a few sentences. +2. Select → **Ask Claude to Edit Selection** → instruct → accept. Expect: it works + exactly like A, in-session. +3. Reload the window (Developer: Reload Window). Expect: the untitled buffer's + coauthoring state is **gone** (in-memory only — documented limitation, §6.7). + +## C. In-workspace unchanged (PUC-3) +1. Open a file UNDER `sandbox/`. Repeat the propose→accept→thread loop. +2. Expect: a committable `sandbox/.threads/.json` sidecar is written, exactly + as before F8 (byte-for-byte, INV-2). + +## D. Read-only view declines (PUC-5) +1. Open a Git diff / Output view, select text, run **Ask Claude to Edit Selection**. +2. Expect: a warning that this kind of document can't be edited (not "select some + text"). +``` + +- [ ] **Step 2: Add a README note** + +Find the section of `README.md` that lists the coauthoring features (F2–F7) and add an F8 entry. Append this paragraph in the appropriate features area (match the surrounding heading style): + +```markdown +### F8 — Out-of-workspace authoring + +"Ask Claude to Edit Selection" (and threads / attribution / propose-accept) work on +**any** document the editor shows — saved in the workspace folder, saved outside it, +or untitled — matching the universal F6 diff / F7 preview. Persistence is **hybrid**: +an in-workspace file keeps its committable `.threads/.json` sidecar +(byte-for-byte unchanged, INV-2); an out-of-workspace file or untitled buffer stores +its coauthoring artifact in VS Code **global storage** keyed by `sha256(uri)` (untitled +is in-memory only). A global-storage artifact is **not a committed file**, so it is +**never cross-rung-shareable** (INV-25) and renaming the file orphans its artifact — +both stated as design contract (spec `specs/coauthoring-out-of-workspace.md`). +``` + +- [ ] **Step 3: Verify build still clean + commit** + +Run: `npm run typecheck && npm test` +Expected: clean + green. + +```bash +git add docs/MANUAL-SMOKE-F8.md README.md +git commit -m "docs(f8): manual smoke runbook + README hybrid-model/non-shareability note" +``` + +--- + +## Self-Review checklist (run after execution, before PR) + +- **Spec coverage:** §6.2 (SidecarStore+router+isAuthorable) → Tasks 1/4/5; §6.3 (GlobalSidecarStore, document key table) → Tasks 2/3/5; §6.4 (interfaces, selectionRejection, controllers, activation) → Tasks 4/6/7/8/9; §6.8 (unit + host E2E) → Tasks 2/3/4/5/10/11; §6.7 untitled in-memory + non-migration → Tasks 2/3 + docs; INV-24 (hybrid routing) → Tasks 5/7/8 + E2E Task 10; INV-25 (single-rung) → E2E Task 10 (no `.threads/`, no merge); §9 docs → Task 12. +- **`mergeArtifacts` / model.ts:** untouched (verify `git diff --stat` shows no change to `src/model.ts`, `src/mergeArtifacts.ts`, `src/store.ts`). +- **Acceptance (spec §7.3):** out-of-folder + untitled authoring succeed; in-workspace byte-for-byte (Task 10 regression); global artifacts by URI hash (Tasks 2/10); re-anchor + reload restore (Task 10); unit + host E2E green (Task 11). +``` diff --git a/package.json b/package.json index 629ea5b..417a205 100644 --- a/package.json +++ b/package.json @@ -112,12 +112,12 @@ "editor/context": [ { "command": "cowriting.editSelection", - "when": "editorHasSelection && resourceScheme == file", + "when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled)", "group": "1_cowriting@1" }, { "command": "cowriting.createThread", - "when": "editorHasSelection && resourceScheme == file", + "when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled)", "group": "1_cowriting@2" } ], diff --git a/src/attributionController.ts b/src/attributionController.ts index 8744c22..e62b310 100644 --- a/src/attributionController.ts +++ b/src/attributionController.ts @@ -10,14 +10,14 @@ */ import * as fs from "node:fs"; import * as vscode from "vscode"; -import { CoauthorStore } from "./store"; +import { SidecarRouter, docIdentity } from "./sidecarRouter"; import { newId, type AttributionRecord, type Provenance } from "./model"; import { buildFingerprint, resolve, type OffsetRange } from "./anchorer"; import { gitUserEmail } from "./identity"; import { applyChange, coalesce, type LiveSpan } from "./attributionTracker"; import { minimizeReplace, PendingEditRegistry } from "./pendingEdits"; import type { VersionGuard } from "./versionGuard"; -import { isUnderRoot } from "./workspacePath"; +import { isAuthorable } from "./workspacePath"; /** Test-facing snapshot of live attribution state for a document. */ export interface RenderedSpan { @@ -74,8 +74,8 @@ export class AttributionController implements vscode.Disposable { readonly onDidApplyAgentEdit: vscode.Event<{ document: vscode.TextDocument }> = this.applyEmitter.event; constructor( - private readonly store: CoauthorStore, - private readonly rootDir: string, + private readonly store: SidecarRouter, + private readonly rootDir: string | undefined, private readonly guard: VersionGuard, ) { this.disposables.push(this.agentType, this.humanType, this.statusItem, this.output, this.applyEmitter); @@ -88,14 +88,15 @@ export class AttributionController implements vscode.Disposable { } private isTracked(document: vscode.TextDocument): boolean { - return document.uri.scheme === "file" && isUnderRoot(document.uri.fsPath, this.rootDir); + return isAuthorable(document.uri.scheme); } - private docPathOf(uri: vscode.Uri): string { - return vscode.workspace.asRelativePath(uri, false); + /** The single document key (F8): repo-relative path in-workspace, URI string otherwise. */ + private keyOf(document: vscode.TextDocument): string { + return this.store.keyOf(docIdentity(document)); } private currentAuthor(): Provenance { const id = vscode.workspace.getConfiguration("git").get("user.name") || process.env.USER || "human"; - const email = gitUserEmail(this.rootDir); + const email = this.rootDir !== undefined ? gitUserEmail(this.rootDir) : undefined; return { kind: "human", id, ...(email !== undefined ? { email } : {}) }; } private state(docPath: string): DocAttribution { @@ -112,7 +113,7 @@ export class AttributionController implements vscode.Disposable { /** 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 docPath = this.keyOf(document); const s = this.state(docPath); const artifact = this.store.load(docPath); s.spans = []; @@ -143,7 +144,7 @@ export class AttributionController implements vscode.Disposable { 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); + const doc = vscode.workspace.textDocuments.find((d) => this.keyOf(d) === s.docPath); if (doc) this.loadAll(doc); } } @@ -153,7 +154,7 @@ export class AttributionController implements vscode.Disposable { private onDidChange(e: vscode.TextDocumentChangeEvent): void { if (!this.isTracked(e.document) || e.contentChanges.length === 0) return; - const docPath = this.docPathOf(e.document.uri); + const docPath = this.keyOf(e.document); if (!e.document.isDirty && this.matchesDisk(e.document)) { // Disk sync (revert / external reload): buffer now equals the file on // disk — re-resolve, never attribute (PUC-4). A real edit can also @@ -248,7 +249,7 @@ export class AttributionController implements vscode.Disposable { ): Promise { if (!this.isTracked(document)) return false; if (opts?.expectedVersion !== undefined && document.version !== opts.expectedVersion) return false; - const docPath = this.docPathOf(document.uri); + const docPath = this.keyOf(document); const startOffset = document.offsetAt(range.start); const endOffset = document.offsetAt(range.end); const oldText = document.getText(range); @@ -298,8 +299,8 @@ export class AttributionController implements vscode.Disposable { private onDidSave(document: vscode.TextDocument): void { if (!this.isTracked(document)) return; - if (this.guard.isReadOnly(this.docPathOf(document.uri))) return; - const docPath = this.docPathOf(document.uri); + if (this.guard.isReadOnly(this.keyOf(document))) return; + const docPath = this.keyOf(document); const s = this.docs.get(docPath); // Allow save when hadAttributions is true even if spans/orphans are now empty: // that means the user deliberately deleted all attributed text, and we must @@ -351,7 +352,7 @@ export class AttributionController implements vscode.Disposable { private render(document: vscode.TextDocument): void { if (!this.isTracked(document)) return; - const s = this.docs.get(this.docPathOf(document.uri)); + const s = this.docs.get(this.keyOf(document)); const spans = this.visible && s ? s.spans : []; const toRange = (sp: LiveSpan) => new vscode.Range(document.positionAt(sp.start), document.positionAt(sp.end)); diff --git a/src/extension.ts b/src/extension.ts index 2bd71bd..2a2e23e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,9 +7,11 @@ import { ProposalController } from "./proposalController"; import { buildFingerprint } from "./anchorer"; import { VersionGuard } from "./versionGuard"; import { BaselineStore } from "./baselineStore"; +import { GlobalSidecarStore } from "./globalSidecarStore"; +import { SidecarRouter } from "./sidecarRouter"; import { DiffViewController } from "./diffViewController"; import { TrackChangesPreviewController } from "./trackChangesPreview"; -import { isUnderRoot, selectionRejection } from "./workspacePath"; +import { isAuthorable, selectionRejection } from "./workspacePath"; const CHANNEL_NAME = "Cowriting (Cline SDK)"; @@ -20,6 +22,7 @@ export interface CowritingApi { versionGuard: VersionGuard; diffViewController: DiffViewController; trackChangesPreviewController: TrackChangesPreviewController; + sidecarRouter: SidecarRouter; } export function activate(context: vscode.ExtensionContext): CowritingApi | undefined { @@ -59,6 +62,14 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef const diffViewController = new DiffViewController(baselineStore); context.subscriptions.push(diffViewController); + // F8: the out-of-workspace/untitled authoring sidecar — same GLOBAL storage + // home as the F6 baseline, keyed by sha256(uri) (INV-19/24). Constructed + // workspace-independently; the router falls back to it for any non-in-folder + // doc. When globalStorageUri is unavailable, file writes throw (authoring + // degrades to errors-on-write, the F6-equivalent edge) but untitled in-memory + // still works — PUC-5. + const globalSidecarStore = new GlobalSidecarStore(baselineStorageDir ?? ""); + // --- F7: rendered track-changes preview (Feature #21) — workspace-INDEPENDENT --- // Like F6, F7 works on any markdown doc (incl. untitled / out-of-folder), so it // is constructed regardless of an open folder and its command is always live. @@ -69,64 +80,42 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef ); context.subscriptions.push(trackChangesPreviewController); - // --- F2: region-anchored threads (Feature #4) --- + // --- F8: authoring on ANY document (in-folder, out-of-folder, untitled) --- + // The router routes per-document: in-workspace file: → the committable repo + // `.threads/` sidecar (CoauthorStore, INV-2); out-of-folder file: + untitled: + // → the global-storage sidecar (GlobalSidecarStore). Constructed even with NO + // folder open (everything then routes global) — the F6 #19 precedent, now + // extended to authoring (F2 threads, F3 attribution, F4 propose/accept). const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if (!root) { - // No folder open → nothing to anchor against, but every contributed - // command must still exist: leave them unregistered and the palette - // errors with "command not found" (#8). Register warning stubs instead; - // opening a folder reloads the window, re-running activate with a root. - const stub = () => - void vscode.window.showWarningMessage( - "Cowriting: open a folder first — coauthoring anchors threads and attribution to workspace files.", - ); - for (const command of [ - "cowriting.createThread", - "cowriting.reply", - "cowriting.resolveThread", - "cowriting.reopenThread", - "cowriting.editSelection", - "cowriting.toggleAttribution", - "cowriting.applyAgentEdit", - "cowriting.acceptProposal", - "cowriting.rejectProposal", - "cowriting.proposeAgentEdit", - ]) { - context.subscriptions.push(vscode.commands.registerCommand(command, stub)); - } - // F6 (toggleDiffView / pinDiffBaseline) is NOT stubbed here — it works - // without a folder (on untitled buffers and out-of-folder files); its real - // commands were registered above by DiffViewController. - return undefined; - } - const store = new CoauthorStore(root); + const coauthorStore = root ? new CoauthorStore(root) : null; + const sidecarRouter = new SidecarRouter(coauthorStore, globalSidecarStore, root); // F5 (INV-16): one shared guard — newer-major sidecars are read-only, one // warning per doc across the three co-owning controllers. - const versionGuard = new VersionGuard(store); - const threadController = new ThreadController(store, root, versionGuard); + const versionGuard = new VersionGuard(sidecarRouter); + const threadController = new ThreadController(sidecarRouter, root, versionGuard); context.subscriptions.push(threadController); // --- F3: live attribution (Feature #6) --- - const attributionController = new AttributionController(store, root, versionGuard); + const attributionController = new AttributionController(sidecarRouter, root, versionGuard); context.subscriptions.push(attributionController); // --- F4: propose/accept (Feature #12) --- - const proposalController = new ProposalController(store, attributionController, root, versionGuard); + const proposalController = new ProposalController(sidecarRouter, attributionController, root, versionGuard); context.subscriptions.push(proposalController); - // --- F6 machine-landing wiring (with-root only) --- - // The seam's single machine-landing signal advances the baseline (INV-18). - // The seam fires only on workspace files, so this wiring belongs here; the - // DiffViewController itself was constructed above (workspace-independent). + // --- F6 machine-landing wiring — now for ANY authorable doc --- + // The seam's single machine-landing signal advances the F6 baseline (INV-18); + // the seam can now fire on out-of-folder files too, so wire it unconditionally. context.subscriptions.push( attributionController.onDidApplyAgentEdit((e) => diffViewController.advance(e.document)), ); - // One SHARED sidecar watcher for both controllers; self-writes are - // suppressed centrally in the store (the sidecar is co-owned). + // One SHARED sidecar watcher for both controllers; self-writes are suppressed + // centrally in the repo store (only repo `.threads/` sidecars are watched — + // global artifacts live outside the workspace). Harmless when no folder is open. const watcher = vscode.workspace.createFileSystemWatcher("**/.threads/**/*.json"); const onSidecar = (uri: vscode.Uri) => { - if (store.consumeSelfWrite(uri.fsPath)) return; + if (sidecarRouter.consumeSelfWrite(uri.fsPath)) return; threadController.handleExternalSidecarChange(uri); attributionController.handleExternalSidecarChange(uri); proposalController.handleExternalSidecarChange(uri); @@ -188,15 +177,14 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef context.subscriptions.push( vscode.commands.registerCommand("cowriting.editSelection", async () => { const editor = vscode.window.activeTextEditor; - // Each failure names its real reason (no editor / no selection / unsaved / - // outside the workspace folder) — not always "select some text" (#bug: - // an out-of-workspace file was rejected with the no-selection message). + // F8: authoring works on any file: or untitled: doc (the router decides + // where its artifact is stored). Each failure still names its real reason + // (no editor / no selection / a non-{file,untitled} read-only view) — not + // always "select some text" (#24's per-condition messaging). const reason = selectionRejection({ hasEditor: !!editor, selectionEmpty: editor?.selection.isEmpty ?? true, scheme: editor?.document.uri.scheme ?? "", - fsPath: editor?.document.uri.fsPath ?? "", - root, }); if (reason) { void vscode.window.showWarningMessage(reason); @@ -269,7 +257,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef // Render threads + attributions for already-open editors, and on future opens. const renderIfOpen = (doc: vscode.TextDocument) => { - if (doc.uri.scheme === "file" && isUnderRoot(doc.uri.fsPath, root)) { + if (isAuthorable(doc.uri.scheme)) { threadController.renderAll(doc); attributionController.loadAll(doc); proposalController.renderAll(doc); @@ -285,6 +273,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef versionGuard, diffViewController, trackChangesPreviewController, + sidecarRouter, }; } diff --git a/src/globalSidecarStore.ts b/src/globalSidecarStore.ts new file mode 100644 index 0000000..e7c9f4b --- /dev/null +++ b/src/globalSidecarStore.ts @@ -0,0 +1,89 @@ +/** + * GlobalSidecarStore — the out-of-workspace/untitled authoring sidecar (F8 spec + * §6.2/§6.4), mirroring BaselineStore (src/baselineStore.ts): vscode-free (Node + * fs/crypto only), one `Artifact` JSON per document in VS Code's per-extension + * GLOBAL storage, NEVER the repo (INV-19/24). The key passed in is the DOCUMENT + * KEY (the URI string for the docs this store serves): + * - `file:` URI → disk at `/sidecars/.json`. + * - `untitled:` → an in-memory Map only (no durable identity — the F6 + * degrade); lost on reload, and never read by mergeArtifacts (INV-25). + * `consumeSelfWrite` is a no-op (these artifacts are outside the `.threads/` + * watcher, so there is no self-write storm to suppress). + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { createHash } from "node:crypto"; +import { + SCHEMA_VERSION, + emptyArtifact, + isNewerMajor, + serializeArtifact, + type Artifact, +} from "./model"; +import type { SidecarStore } from "./sidecarStore"; + +export class GlobalSidecarStore implements SidecarStore { + /** untitled keys live here only — no durable identity to persist against. */ + private readonly memory = new Map(); + + /** @param storageDir absolute VS Code GLOBAL-storage dir (context.globalStorageUri.fsPath). */ + constructor(private readonly storageDir: string) {} + + private isUntitled(key: string): boolean { + return key.startsWith("untitled:"); + } + + /** Filesystem-safe storage key for a persistable (file:) doc: sha256 of its URI. */ + private storageKey(key: string): string { + return createHash("sha256").update(key).digest("hex"); + } + + /** Disk path for a `file:` key, or undefined for an in-memory untitled key. */ + sidecarPath(key: string): string | undefined { + if (this.isUntitled(key)) return undefined; + return path.join(this.storageDir, "sidecars", `${this.storageKey(key)}.json`); + } + + load(key: string): Artifact | null { + if (this.isUntitled(key)) return this.memory.get(key) ?? null; + const p = this.sidecarPath(key)!; + if (!fs.existsSync(p)) return null; + return JSON.parse(fs.readFileSync(p, "utf8")) as Artifact; + } + + save(key: string, artifact: Artifact): void { + if (this.isUntitled(key)) { + this.memory.set(key, artifact); + return; + } + const p = this.sidecarPath(key)!; + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, serializeArtifact(artifact), "utf8"); + } + + /** See CoauthorStore.update — same INV-16 throw + anchor prune, no self-write mark. */ + update(key: string, mutate: (artifact: Artifact) => void): Artifact { + const artifact = this.load(key) ?? emptyArtifact(key); + if (isNewerMajor(artifact)) { + throw new Error( + `refusing to write ${key}: sidecar schemaVersion ${artifact.schemaVersion} > supported ${SCHEMA_VERSION} (INV-16)`, + ); + } + mutate(artifact); + const referenced = new Set([ + ...artifact.threads.map((t) => t.anchorId), + ...artifact.attributions.map((a) => a.anchorId), + ...artifact.proposals.map((p) => p.anchorId), + ]); + for (const id of Object.keys(artifact.anchors)) { + if (!referenced.has(id)) delete artifact.anchors[id]; + } + this.save(key, artifact); + return artifact; + } + + /** No-op: global artifacts are outside the `.threads/` watcher. */ + consumeSelfWrite(_fsPath: string): boolean { + return false; + } +} diff --git a/src/proposalController.ts b/src/proposalController.ts index 6b15570..14ed4a4 100644 --- a/src/proposalController.ts +++ b/src/proposalController.ts @@ -10,13 +10,13 @@ * Claude-attributed with zero new attribution code. */ import * as vscode from "vscode"; -import { CoauthorStore } from "./store"; +import { SidecarRouter, docIdentity } from "./sidecarRouter"; import { emptyArtifact, type Artifact, type Fingerprint, type Proposal, type Provenance } from "./model"; import { resolve, shift, type OffsetRange } from "./anchorer"; import { addProposal, proposalBody, removeProposal } from "./proposalModel"; import type { AttributionController } from "./attributionController"; import type { VersionGuard } from "./versionGuard"; -import { isUnderRoot } from "./workspacePath"; +import { isAuthorable } from "./workspacePath"; /** Test-facing snapshot of what is currently rendered for a document. */ export interface RenderedProposal { @@ -54,9 +54,9 @@ export class ProposalController implements vscode.Disposable { private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 89); constructor( - private readonly store: CoauthorStore, + private readonly store: SidecarRouter, private readonly attribution: AttributionController, - private readonly rootDir: string, + private readonly rootDir: string | undefined, private readonly guard: VersionGuard, ) { // No commentingRangeProvider: humans never open proposal threads by hand — @@ -71,13 +71,14 @@ export class ProposalController implements vscode.Disposable { } private isTracked(document: vscode.TextDocument): boolean { - return document.uri.scheme === "file" && isUnderRoot(document.uri.fsPath, this.rootDir); + return isAuthorable(document.uri.scheme); } - private docPathOf(uri: vscode.Uri): string { - return vscode.workspace.asRelativePath(uri, false); + /** The single document key (F8): repo-relative path in-workspace, URI string otherwise. */ + private keyOf(document: vscode.TextDocument): string { + return this.store.keyOf(docIdentity(document)); } private ensureState(document: vscode.TextDocument): DocState { - const docPath = this.docPathOf(document.uri); + const docPath = this.keyOf(document); let state = this.docs.get(docPath); if (!state) { state = { @@ -108,8 +109,8 @@ export class ProposalController implements vscode.Disposable { opts?: { turnId?: string; instruction?: string }, ): Promise { if (!this.isTracked(document)) return undefined; - if (this.guard.isReadOnly(this.docPathOf(document.uri))) return undefined; - const docPath = this.docPathOf(document.uri); + if (this.guard.isReadOnly(this.keyOf(document))) return undefined; + const docPath = this.keyOf(document); let proposalId: string | undefined; this.store.update(docPath, (a) => { proposalId = addProposal(a, fp, replacement, author, opts).proposalId; @@ -185,7 +186,7 @@ export class ProposalController implements vscode.Disposable { /** Load + (re)render every pending proposal at its resolved anchor (or flagged). */ renderAll(document: vscode.TextDocument): void { if (!this.isTracked(document)) return; - const docPath = this.docPathOf(document.uri); + const docPath = this.keyOf(document); const state = this.ensureState(document); state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath); for (const vsThread of state.vsThreads.values()) vsThread.dispose(); @@ -212,14 +213,14 @@ export class ProposalController implements vscode.Disposable { handleExternalSidecarChange(uri: vscode.Uri): void { for (const state of this.docs.values()) { if (this.store.sidecarPath(state.docPath) === uri.fsPath) { - const doc = vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath); + const doc = vscode.workspace.textDocuments.find((d) => this.keyOf(d) === state.docPath); if (doc) this.renderAll(doc); } } } private onDidChange(e: vscode.TextDocumentChangeEvent): void { - const state = this.docs.get(this.docPathOf(e.document.uri)); + const state = this.docs.get(this.keyOf(e.document)); 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 }; @@ -298,7 +299,7 @@ export class ProposalController implements vscode.Disposable { // ---- lookups ---------------------------------------------------------------------------- private openDoc(state: DocState): vscode.TextDocument | undefined { - return vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath); + return vscode.workspace.textDocuments.find((d) => this.keyOf(d) === state.docPath); } private byThread(vsThread: vscode.CommentThread): { state: DocState; proposal: Proposal } | undefined { for (const state of this.docs.values()) { diff --git a/src/sidecarRouter.ts b/src/sidecarRouter.ts new file mode 100644 index 0000000..dac326a --- /dev/null +++ b/src/sidecarRouter.ts @@ -0,0 +1,89 @@ +/** + * SidecarRouter — the per-document persistence façade the three authoring + * controllers depend on (F8 spec §6.2/§6.4). Implements SidecarStore and OWNS + * the routing: it computes the single document key (keyOf) and dispatches + * load/save/update/consumeSelfWrite/sidecarPath to the right implementation: + * - an in-workspace `file:` doc → CoauthorStore, keyed by its repo-relative + * path → `.threads/.json` (committable, INV-2; the only home F5's + * cross-rung ever sees). + * - an out-of-workspace `file:` or `untitled:` doc → GlobalSidecarStore, keyed + * by its URI string → global storage (INV-19/24/25; untitled in-memory). + * The membership predicate is `#24`'s isUnderRoot — here a ROUTING input, never + * an authoring gate (that became isAuthorable). vscode-free (takes the extracted + * identity, not a vscode.TextDocument) so it unit-tests. + */ +import * as path from "node:path"; +import { isUnderRoot } from "./workspacePath"; +import type { Artifact } from "./model"; +import type { SidecarStore } from "./sidecarStore"; + +/** The minimal document identity the router routes on (extracted from a TextDocument). */ +export interface DocIdentity { + /** `document.uri.toString()` — the URI string. */ + uri: string; + /** `document.uri.fsPath`. */ + fsPath: string; + /** `document.uri.scheme`. */ + scheme: string; +} + +/** A SidecarStore that can also report a sidecar's on-disk path (CoauthorStore, GlobalSidecarStore). */ +type LocatableStore = SidecarStore & { sidecarPath(key: string): string | undefined }; + +/** Extract the routing identity from anything URI-shaped (a real vscode.TextDocument fits). */ +export function docIdentity(doc: { uri: { toString(): string; fsPath: string; scheme: string } }): DocIdentity { + return { uri: doc.uri.toString(), fsPath: doc.uri.fsPath, scheme: doc.uri.scheme }; +} + +export class SidecarRouter implements SidecarStore { + constructor( + private readonly repo: LocatableStore | null, + private readonly global: LocatableStore, + private readonly root: string | undefined, + ) {} + + /** + * The single document key: an in-workspace `file:` doc → its repo-relative + * path (cross-rung-meaningful, the existing sidecar key, INV-2); everything + * else → its URI string (machine-local, self-consistent — INV-25). + */ + keyOf(id: DocIdentity): string { + if (id.scheme === "file" && this.root !== undefined && isUnderRoot(id.fsPath, this.root)) { + return path.relative(this.root, id.fsPath); + } + return id.uri; + } + + /** A key is a global (URI-string) key iff it carries a URI scheme prefix. */ + private isGlobalKey(key: string): boolean { + return key.startsWith("file://") || key.startsWith("untitled:"); + } + private storeFor(key: string): LocatableStore { + if (this.isGlobalKey(key) || this.repo === null) return this.global; + return this.repo; + } + + load(key: string): Artifact | null { + return this.storeFor(key).load(key); + } + save(key: string, artifact: Artifact): void { + this.storeFor(key).save(key, artifact); + } + update(key: string, mutate: (artifact: Artifact) => void): Artifact { + return this.storeFor(key).update(key, mutate); + } + /** Only the repo store self-writes (the `.threads/` watcher); global is a no-op. */ + consumeSelfWrite(fsPath: string): boolean { + return this.repo?.consumeSelfWrite(fsPath) ?? false; + } + + /** + * The sidecar's on-disk path: `.threads/.json` for a repo key, the hashed + * global path for an out-of-folder `file:` key, undefined for untitled. Used by + * the controllers' external-change handler (only repo sidecars are watched) and + * by the E2E to assert the storage home. + */ + sidecarPath(key: string): string | undefined { + return this.storeFor(key).sidecarPath(key); + } +} diff --git a/src/sidecarStore.ts b/src/sidecarStore.ts new file mode 100644 index 0000000..6116573 --- /dev/null +++ b/src/sidecarStore.ts @@ -0,0 +1,35 @@ +/** + * SidecarStore — the storage surface the three authoring controllers depend on + * (F8 spec §6.2/§6.4). One per-document `Artifact` keyed by a string `key` (the + * document key — repo-relative path in-workspace, URI string otherwise; the + * router's keyOf is the single source). Two implementations: + * - CoauthorStore (src/store.ts) — the committable repo `.threads/` sidecar, + * keyed by repo-relative path (INV-2, byte-for-byte unchanged; conforms + * structurally — not modified for F8). + * - GlobalSidecarStore (src/globalSidecarStore.ts) — out-of-workspace `file:` + * and `untitled:` docs, in VS Code GLOBAL storage keyed by sha256(uri) + * (INV-19/24/25; untitled in-memory only). + * SidecarRouter (src/sidecarRouter.ts) implements this AND adds keyOf/sidecarPath; + * the controllers receive the router. + */ +import type { Artifact } from "./model"; + +export interface SidecarStore { + /** Load the artifact for `key`, or null if none persisted. */ + load(key: string): Artifact | null; + /** Overwrite the artifact for `key` (newest wins, no history). */ + save(key: string, artifact: Artifact): void; + /** + * Read-modify-write: load (or empty), apply `mutate`, prune anchors referenced + * by no thread/attribution/proposal, persist, return the result. MUST be + * synchronous (the F3 co-ownership contract). Throws on a newer-major sidecar + * (INV-16 backstop). + */ + update(key: string, mutate: (artifact: Artifact) => void): Artifact; + /** + * Decrement-and-report whether `fsPath` is a sidecar this store just wrote + * (repo sidecars only; the global store is outside the `.threads/` watcher so + * its impl is a no-op returning false). + */ + consumeSelfWrite(fsPath: string): boolean; +} diff --git a/src/threadController.ts b/src/threadController.ts index 515b54b..d5b7445 100644 --- a/src/threadController.ts +++ b/src/threadController.ts @@ -8,13 +8,13 @@ * orphaned thread (INV-1). */ import * as vscode from "vscode"; -import { CoauthorStore } from "./store"; +import { SidecarRouter, docIdentity } from "./sidecarRouter"; import { emptyArtifact, type Artifact, type Provenance } from "./model"; import { buildFingerprint, resolve, shift, type OffsetRange } from "./anchorer"; import { gitUserEmail } from "./identity"; import { addThread, appendMessage, setStatus } from "./threadModel"; import type { VersionGuard } from "./versionGuard"; -import { isUnderRoot } from "./workspacePath"; +import { isAuthorable } from "./workspacePath"; /** Test-facing snapshot of what is currently rendered for a document. */ export interface RenderedThread { @@ -42,14 +42,14 @@ export class ThreadController implements vscode.Disposable { private readonly docs = new Map(); // keyed by docPath constructor( - private readonly store: CoauthorStore, - private readonly rootDir: string, + private readonly store: SidecarRouter, + private readonly rootDir: string | undefined, private readonly guard: VersionGuard, ) { this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads"); this.controller.commentingRangeProvider = { provideCommentingRanges: (document) => { - if (!this.isInRoot(document.uri)) return []; + if (!isAuthorable(document.uri.scheme)) return []; return [new vscode.Range(0, 0, Math.max(0, document.lineCount - 1), 0)]; }, }; @@ -69,17 +69,14 @@ export class ThreadController implements vscode.Disposable { ); } - private isInRoot(uri: vscode.Uri): boolean { - return uri.scheme === "file" && isUnderRoot(uri.fsPath, this.rootDir); - } - - private docPathOf(uri: vscode.Uri): string { - return vscode.workspace.asRelativePath(uri, false); + /** The single document key (F8): repo-relative path in-workspace, URI string otherwise. */ + private keyOf(document: vscode.TextDocument): string { + return this.store.keyOf(docIdentity(document)); } private currentAuthor(): Provenance { const id = vscode.workspace.getConfiguration("git").get("user.name") || process.env.USER || "human"; - const email = gitUserEmail(this.rootDir); + const email = this.rootDir !== undefined ? gitUserEmail(this.rootDir) : undefined; return { kind: "human", id, ...(email !== undefined ? { email } : {}) }; } @@ -93,7 +90,7 @@ export class ThreadController implements vscode.Disposable { } private ensureState(document: vscode.TextDocument): DocState { - const docPath = this.docPathOf(document.uri); + const docPath = this.keyOf(document); let state = this.docs.get(docPath); if (!state) { state = { @@ -113,8 +110,8 @@ export class ThreadController implements vscode.Disposable { async createThreadOnSelection(firstBody = "New thread"): Promise { const editor = vscode.window.activeTextEditor; - if (!editor || editor.selection.isEmpty || !this.isInRoot(editor.document.uri)) return undefined; - if (this.guard.isReadOnly(this.docPathOf(editor.document.uri))) return undefined; + if (!editor || editor.selection.isEmpty || !isAuthorable(editor.document.uri.scheme)) return undefined; + if (this.guard.isReadOnly(this.keyOf(editor.document))) return undefined; const document = editor.document; const state = this.ensureState(document); const offsets: OffsetRange = { @@ -155,7 +152,7 @@ export class ThreadController implements vscode.Disposable { /** 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 docPath = this.keyOf(document); const state = this.ensureState(document); // fresh artifact from disk (reload / external change) state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath); @@ -182,14 +179,14 @@ export class ThreadController implements vscode.Disposable { const p = uri.fsPath; 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); + const doc = vscode.workspace.textDocuments.find((d) => this.keyOf(d) === state.docPath); if (doc) this.renderAll(doc); } } } private onDidChange(e: vscode.TextDocumentChangeEvent): void { - const state = this.docs.get(this.docPathOf(e.document.uri)); + const state = this.docs.get(this.keyOf(e.document)); 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 }; @@ -205,7 +202,7 @@ export class ThreadController implements vscode.Disposable { } private onDidSave(document: vscode.TextDocument): void { - const state = this.docs.get(this.docPathOf(document.uri)); + const state = this.docs.get(this.keyOf(document)); if (!state || state.live.size === 0) return; if (this.guard.isReadOnly(state.docPath)) return; const text = document.getText(); diff --git a/src/versionGuard.ts b/src/versionGuard.ts index 76fc736..4939a69 100644 --- a/src/versionGuard.ts +++ b/src/versionGuard.ts @@ -8,12 +8,12 @@ */ import * as vscode from "vscode"; import { SCHEMA_VERSION, isNewerMajor } from "./model"; -import type { CoauthorStore } from "./store"; +import type { SidecarStore } from "./sidecarStore"; export class VersionGuard { private readonly warned = new Set(); - constructor(private readonly store: CoauthorStore) {} + constructor(private readonly store: SidecarStore) {} /** True → skip the write path (and warn once per doc). */ isReadOnly(docPath: string): boolean { diff --git a/src/workspacePath.ts b/src/workspacePath.ts index 5c61292..9a0e608 100644 --- a/src/workspacePath.ts +++ b/src/workspacePath.ts @@ -20,6 +20,17 @@ export function isUnderRoot(fsPath: string, root: string): boolean { return fsPath === root || fsPath.startsWith(root + path.sep); } +/** + * Documents Cowriting can author on: a saved file OR an unsaved buffer (F8 §6.2). + * F8 widened membership from "saved file under the workspace folder" to any + * `file:`/`untitled:` doc — the SidecarRouter then routes where its artifact is + * stored (repo `.threads/` in-workspace, global storage otherwise). `isUnderRoot` + * (above) is retained as that ROUTING input, no longer an authoring gate. + */ +export function isAuthorable(scheme: string): boolean { + return scheme === "file" || scheme === "untitled"; +} + export interface SelectionContext { /** Is there an active text editor at all? */ hasEditor: boolean; @@ -27,17 +38,15 @@ export interface SelectionContext { selectionEmpty: boolean; /** The active document's URI scheme (`file`, `untitled`, …). */ scheme: string; - /** The active document's filesystem path (empty if none). */ - fsPath: string; - /** The workspace folder Cowriting is anchored to. */ - root: string; } /** - * Why a selection can't be sent to Claude — or `null` if it can. Each condition - * gets its OWN message so the user learns the real reason instead of always - * being told to "select some text" (the reported bug: an out-of-workspace file - * was rejected with the no-selection message even though text was selected). + * Why a selection can't be sent to Claude — or `null` if it can. F8 widened the + * membership: any `file:` or `untitled:` document is authorable (in-workspace + * files persist to the repo `.threads/` sidecar, out-of-workspace/untitled to + * global storage — the router decides). Only a non-{file,untitled} scheme (a + * read-only `git:`/`output:` view), a missing editor, or an empty selection is + * refused — each with its OWN message (#24's per-condition messaging). */ export function selectionRejection(ctx: SelectionContext): string | null { if (!ctx.hasEditor) { @@ -46,11 +55,8 @@ export function selectionRejection(ctx: SelectionContext): string | null { if (ctx.selectionEmpty) { return "Cowriting: select some text to send to Claude first."; } - if (ctx.scheme !== "file") { - return "Cowriting: save this document to a file first — Cowriting anchors edits to a saved workspace file."; - } - if (!isUnderRoot(ctx.fsPath, ctx.root)) { - return "Cowriting: this file is outside your workspace folder — open a file inside it to ask Claude to edit (Cowriting anchors edits and attribution to files in the workspace)."; + if (!isAuthorable(ctx.scheme)) { + return "Cowriting: this kind of document can't be edited — Cowriting authors on a file or an untitled buffer, not a read-only view."; } return null; } diff --git a/test/e2e/suite-no-workspace/noWorkspace.test.ts b/test/e2e/suite-no-workspace/noWorkspace.test.ts index b6c1cb5..b36ddc0 100644 --- a/test/e2e/suite-no-workspace/noWorkspace.test.ts +++ b/test/e2e/suite-no-workspace/noWorkspace.test.ts @@ -1,21 +1,27 @@ import * as assert from "assert"; import * as vscode from "vscode"; +import type { CowritingApi } from "../../../src/extension"; -// Regression for #8: with NO workspace folder open, activate() used to return -// before registering the F2/F3 commands, so the palette errored with -// "command 'cowriting.editSelection' not found". The fix registers warning -// stubs instead. This suite runs in a second EDH pass launched WITHOUT a -// folder (see runTest.ts). +// F8: with NO workspace folder open, authoring used to be stubbed (#8 registered +// warning stubs and activate() returned undefined). F8 makes the authoring +// commands REAL folder-less (the F6 #19 precedent), routing every doc to global +// storage — so activate() returns a real API and propose→accept works on an +// untitled buffer with no folder. This suite runs in a second EDH pass launched +// WITHOUT a folder (see runTest.ts). -suite("no-workspace activation (#8)", () => { +suite("no-workspace authoring (F8 — real folder-less, #8 lineage)", () => { test("EDH really has no workspace folder", () => { assert.strictEqual(vscode.workspace.workspaceFolders, undefined); }); - test("all contributed coauthoring commands are registered as warning stubs", async () => { + test("activate returns a real API even with no workspace folder (F8)", async () => { const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; - const api = await ext.activate(); - assert.strictEqual(api, undefined, "no-root activation returns no API"); + const api = (await ext.activate()) as CowritingApi; + assert.ok(api?.proposalController, "no-folder activation returns the authoring API (F8)"); + assert.ok(api?.sidecarRouter, "router exposed"); + }); + + test("all contributed coauthoring commands are registered (real, not stubs)", async () => { const all = await vscode.commands.getCommands(true); for (const command of [ "cowriting.createThread", @@ -33,9 +39,32 @@ suite("no-workspace activation (#8)", () => { } }); - test("invoking a stub does not throw (shows a warning instead)", async () => { - await vscode.commands.executeCommand("cowriting.editSelection"); - await vscode.commands.executeCommand("cowriting.createThread"); + test("authoring works folder-less: propose→accept on an untitled buffer routes to global storage (F8)", async () => { + const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; + const api = (await ext.activate()) as CowritingApi; + const untitled = await vscode.workspace.openTextDocument({ + content: "Edit this scratch sentence please.\n", + language: "markdown", + }); + await vscode.window.showTextDocument(untitled); + await new Promise((r) => setTimeout(r, 300)); + const key = untitled.uri.toString(); + const target = "Edit this scratch sentence please."; + const start = untitled.getText().indexOf(target); + const id = await vscode.commands.executeCommand("cowriting.proposeAgentEdit", { + uri: key, + start, + end: start + target.length, + newText: "REPLACED scratch sentence.", + model: "sonnet", + sessionId: "e2e-nf", + turnId: "turn-nf", + }); + assert.ok(id, "propose returns an id for an untitled buffer with no folder"); + assert.ok(await api.proposalController.acceptById(key, id!), "accept applies"); + await new Promise((r) => setTimeout(r, 300)); + assert.ok(untitled.getText().includes("REPLACED scratch sentence."), "replacement landed in the untitled buffer"); + assert.strictEqual(api.sidecarRouter.sidecarPath(key), undefined, "untitled artifact is in-memory only (no disk)"); }); // F6 (#19) is workspace-INDEPENDENT: its commands are real (not stubs) even diff --git a/test/e2e/suite/outOfWorkspace.test.ts b/test/e2e/suite/outOfWorkspace.test.ts new file mode 100644 index 0000000..f75de0c --- /dev/null +++ b/test/e2e/suite/outOfWorkspace.test.ts @@ -0,0 +1,126 @@ +import * as assert from "assert"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import * as vscode from "vscode"; +import type { CowritingApi } from "../../../src/extension"; + +const WS = process.env.E2E_WORKSPACE!; +const settle = () => new Promise((r) => setTimeout(r, 300)); + +async function getApi(): Promise { + const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; + const api = (await ext.activate()) as CowritingApi; + assert.ok(api?.proposalController && api?.sidecarRouter, "extension exports the authoring API + router"); + return api; +} +async function proposeViaCommand(uri: string, text: string, target: string, newText: string, turnId: string): Promise { + const start = text.indexOf(target); + assert.ok(start >= 0, `text contains "${target}"`); + const id = await vscode.commands.executeCommand("cowriting.proposeAgentEdit", { + uri, start, end: start + target.length, newText, model: "sonnet", sessionId: "e2e-oow", turnId, + }); + assert.ok(id, "propose returns the proposal id"); + return id!; +} + +// F8 host E2E (no LLM): authoring on a file OUTSIDE the workspace folder and on +// an untitled buffer, plus an in-workspace byte-for-byte regression. Runs in the +// WITH-workspace EDH pass (a folder is open; the out-of-folder file lives in a +// temp dir outside it — exactly like diffView's out-of-folder test). +suite("F8 out-of-workspace authoring (host E2E — programmatic seam, no LLM)", () => { + test("out-of-folder file: propose→accept lands Claude-attributed; persisted in GLOBAL storage, no .threads (PUC-1, INV-9/24/25)", async () => { + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "cowriting-oow-")); + const outsidePath = path.join(outsideDir, "sibling.md"); + const TARGET = "The sibling-repo sentence Claude edits."; + fs.writeFileSync(outsidePath, `# Sibling\n\n${TARGET}\n`, "utf8"); + const uri = vscode.Uri.file(outsidePath); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + await settle(); + const api = await getApi(); + const key = uri.toString(); + + const id = await proposeViaCommand(key, doc.getText(), TARGET, "The sibling sentence CLAUDE REWROTE.", "turn-oow1"); + await settle(); + // routed to global storage, never the repo + const sidecar = api.sidecarRouter.sidecarPath(key)!; + assert.ok(sidecar && fs.existsSync(sidecar), `global sidecar exists at ${sidecar}`); + assert.ok(sidecar.includes(`${path.sep}sidecars${path.sep}`), "lives under /sidecars/"); + assert.ok(!sidecar.includes(`${path.sep}.threads${path.sep}`), "NOT in a .threads/ tree (INV-25)"); + assert.ok(!fs.existsSync(path.join(outsideDir, ".threads")), "no .threads/ written beside the out-of-folder file"); + + assert.ok(await api.proposalController.acceptById(key, id), "accept applies via the seam"); + await settle(); + assert.ok(doc.getText().includes("The sibling sentence CLAUDE REWROTE."), "replacement landed"); + const agent = api.attributionController.getSpans(key).find((s) => s.turnId === "turn-oow1"); + assert.ok(agent && agent.authorKind === "agent", "accepted text is Claude-attributed"); + assert.strictEqual(api.proposalController.getRendered(key).length, 0, "proposal gone (INV-13)"); + + // a thread can be opened on the out-of-folder doc + await vscode.window.showTextDocument(doc); + const editor = vscode.window.activeTextEditor!; + editor.selection = new vscode.Selection(doc.positionAt(0), doc.positionAt(8)); + const threadId = await api.threadController.createThreadOnSelection("thread on a sibling file"); + assert.ok(threadId, "a thread opens on the out-of-folder doc"); + + // reload-restore from the GLOBAL sidecar (re-anchor content-based) + fs.writeFileSync(outsidePath, "PREPENDED\n\n" + doc.getText(), "utf8"); + const reloaded = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(reloaded); + await vscode.commands.executeCommand("workbench.action.files.revert"); + await settle(); + api.threadController.renderAll(reloaded); + assert.strictEqual(api.threadController.getRendered(key).length, 1, "thread restored from the global sidecar after reload"); + + fs.rmSync(outsideDir, { recursive: true, force: true }); + }); + + test("untitled buffer: propose→accept works in-session, state in-memory only (PUC-2)", async () => { + const TARGET = "The untitled draft sentence."; + const untitled = await vscode.workspace.openTextDocument({ content: `${TARGET}\n`, language: "markdown" }); + await vscode.window.showTextDocument(untitled); + await settle(); + const api = await getApi(); + const key = untitled.uri.toString(); + assert.strictEqual(untitled.uri.scheme, "untitled", "really untitled"); + + const id = await proposeViaCommand(key, untitled.getText(), TARGET, "The untitled draft, CLAUDE-EDITED.", "turn-oow2"); + await settle(); + assert.strictEqual(api.sidecarRouter.sidecarPath(key), undefined, "untitled artifact is in-memory only (no disk)"); + assert.ok(await api.proposalController.acceptById(key, id), "accept applies"); + await settle(); + assert.ok(untitled.getText().includes("The untitled draft, CLAUDE-EDITED."), "replacement landed in the untitled buffer"); + const agent = api.attributionController.getSpans(key).find((s) => s.turnId === "turn-oow2"); + assert.ok(agent && agent.authorKind === "agent", "untitled accepted text is Claude-attributed"); + }); + + test("in-workspace regression: artifact still lands at /.threads/.json with the repo-relative document.path (INV-2)", async () => { + const DOC_REL = "docs/f8regression.md"; + const TARGET = "An in-folder sentence for the F8 regression."; + const abs = path.join(WS, DOC_REL); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, `# F8 regression\n\n${TARGET}\n`, "utf8"); + const uri = vscode.Uri.file(abs); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + await settle(); + const api = await getApi(); + + // the in-workspace key is the repo-relative path, not the URI string + assert.strictEqual( + api.sidecarRouter.keyOf({ uri: uri.toString(), fsPath: abs, scheme: "file" }), + DOC_REL, + "in-folder key is the repo-relative path", + ); + const id = await proposeViaCommand(uri.toString(), doc.getText(), TARGET, "An in-folder sentence, EDITED.", "turn-oow3"); + await settle(); + assert.ok(await api.proposalController.acceptById(DOC_REL, id), "accept by the repo-relative key"); + await settle(); + const sidecar = api.sidecarRouter.sidecarPath(DOC_REL)!; + assert.strictEqual(sidecar, path.join(WS, ".threads", "docs", "f8regression.md.json"), "committable .threads/ path unchanged (INV-2)"); + assert.ok(fs.existsSync(sidecar), "sidecar written in the repo"); + const onDisk = JSON.parse(fs.readFileSync(sidecar, "utf8")); + assert.strictEqual(onDisk.document.path, DOC_REL, "document.path is the repo-relative path (byte-for-byte)"); + }); +}); diff --git a/test/globalSidecarStore.test.ts b/test/globalSidecarStore.test.ts new file mode 100644 index 0000000..e3a0a4f --- /dev/null +++ b/test/globalSidecarStore.test.ts @@ -0,0 +1,103 @@ +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 { createHash } from "node:crypto"; +import { GlobalSidecarStore } from "../src/globalSidecarStore"; +import { emptyArtifact, SCHEMA_VERSION, type Artifact } from "../src/model"; + +let dir: string; +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "global-sidecar-")); +}); +afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); +}); + +const FILE_URI = "file:///abs/notes/chapter-1.md"; +const hash = (uri: string) => createHash("sha256").update(uri).digest("hex"); + +function withThread(uri: string): Artifact { + const a = emptyArtifact(uri); + a.anchors["an_1"] = { fingerprint: { text: "hi", before: "", after: "", lineHint: 0 } }; + a.threads.push({ id: "t_1", anchorId: "an_1", status: "open", messages: [] }); + return a; +} + +describe("GlobalSidecarStore — file: URIs persist to /sidecars/.json", () => { + it("returns null for a key with no sidecar", () => { + expect(new GlobalSidecarStore(dir).load(FILE_URI)).toBeNull(); + }); + + it("save then load round-trips the artifact at the hashed path", () => { + const store = new GlobalSidecarStore(dir); + const a = withThread(FILE_URI); + store.save(FILE_URI, a); + const onDisk = path.join(dir, "sidecars", `${hash(FILE_URI)}.json`); + expect(fs.existsSync(onDisk)).toBe(true); + expect(store.load(FILE_URI)).toEqual(a); + }); + + it("sidecarPath returns the hashed disk path for a file: URI", () => { + const store = new GlobalSidecarStore(dir); + expect(store.sidecarPath(FILE_URI)).toBe(path.join(dir, "sidecars", `${hash(FILE_URI)}.json`)); + }); + + it("overwrites in place (newest wins, no history)", () => { + const store = new GlobalSidecarStore(dir); + store.save(FILE_URI, withThread(FILE_URI)); + const empty = emptyArtifact(FILE_URI); + store.save(FILE_URI, empty); + expect(store.load(FILE_URI)).toEqual(empty); + }); + + it("consumeSelfWrite is a no-op returning false (outside the .threads watcher)", () => { + const store = new GlobalSidecarStore(dir); + store.save(FILE_URI, withThread(FILE_URI)); + expect(store.consumeSelfWrite(store.sidecarPath(FILE_URI)!)).toBe(false); + }); + + it("update applies the mutation, prunes unreferenced anchors, and persists", () => { + const store = new GlobalSidecarStore(dir); + const result = store.update(FILE_URI, (a) => { + a.anchors["orphan"] = { fingerprint: { text: "x", before: "", after: "", lineHint: 0 } }; + a.anchors["kept"] = { fingerprint: { text: "y", before: "", after: "", lineHint: 0 } }; + a.threads.push({ id: "t", anchorId: "kept", status: "open", messages: [] }); + }); + expect(Object.keys(result.anchors)).toEqual(["kept"]); // "orphan" pruned + expect(store.load(FILE_URI)!.threads.length).toBe(1); + }); + + it("update throws on a newer-major sidecar (INV-16 backstop)", () => { + const store = new GlobalSidecarStore(dir); + const future = { ...emptyArtifact(FILE_URI), schemaVersion: SCHEMA_VERSION + 1 }; + store.save(FILE_URI, future as Artifact); + expect(() => store.update(FILE_URI, () => {})).toThrow(/INV-16/); + }); +}); + +describe("GlobalSidecarStore — untitled: keys are in-memory only (no disk, lost on reload)", () => { + const UNTITLED = "untitled:Untitled-1"; + + it("save then load round-trips within the same instance (session)", () => { + const store = new GlobalSidecarStore(dir); + const a = emptyArtifact(UNTITLED); + a.threads.push({ id: "t", anchorId: "an", status: "open", messages: [] }); + a.anchors["an"] = { fingerprint: { text: "z", before: "", after: "", lineHint: 0 } }; + store.save(UNTITLED, a); + expect(store.load(UNTITLED)).toEqual(a); + }); + + it("writes NO disk file for an untitled key", () => { + const store = new GlobalSidecarStore(dir); + store.save(UNTITLED, emptyArtifact(UNTITLED)); + expect(store.sidecarPath(UNTITLED)).toBeUndefined(); + expect(fs.existsSync(path.join(dir, "sidecars"))).toBe(false); + }); + + it("a fresh instance (simulated reload) has no untitled state", () => { + const store = new GlobalSidecarStore(dir); + store.save(UNTITLED, emptyArtifact(UNTITLED)); + expect(new GlobalSidecarStore(dir).load(UNTITLED)).toBeNull(); + }); +}); diff --git a/test/sidecarRouter.test.ts b/test/sidecarRouter.test.ts new file mode 100644 index 0000000..d3e0fe2 --- /dev/null +++ b/test/sidecarRouter.test.ts @@ -0,0 +1,89 @@ +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 { createHash } from "node:crypto"; +import { CoauthorStore } from "../src/store"; +import { GlobalSidecarStore } from "../src/globalSidecarStore"; +import { SidecarRouter, docIdentity } from "../src/sidecarRouter"; +import { emptyArtifact } from "../src/model"; + +let root: string; +let globalDir: string; +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "router-root-")); + globalDir = fs.mkdtempSync(path.join(os.tmpdir(), "router-global-")); +}); +afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(globalDir, { recursive: true, force: true }); +}); + +function makeRouter(withRoot = true): SidecarRouter { + return new SidecarRouter( + withRoot ? new CoauthorStore(root) : null, + new GlobalSidecarStore(globalDir), + withRoot ? root : undefined, + ); +} +// minimal duck-typed stand-in for a vscode.TextDocument's identity inputs +const docOf = (uriString: string, fsPath: string, scheme: string) => ({ + uri: { toString: () => uriString, fsPath, scheme }, +}); +const hash = (uri: string) => createHash("sha256").update(uri).digest("hex"); + +describe("SidecarRouter.keyOf — one identity per document", () => { + it("an in-root file: doc → its repo-relative path", () => { + const fsPath = path.join(root, "docs", "x.md"); + const key = makeRouter().keyOf(docIdentity(docOf(`file://${fsPath}`, fsPath, "file"))); + expect(key).toBe(path.join("docs", "x.md")); + }); + it("an out-of-root file: doc → its URI string", () => { + const fsPath = "/elsewhere/y.md"; + const uri = `file://${fsPath}`; + expect(makeRouter().keyOf(docIdentity(docOf(uri, fsPath, "file")))).toBe(uri); + }); + it("an untitled: doc → its URI string", () => { + expect(makeRouter().keyOf(docIdentity(docOf("untitled:Untitled-1", "Untitled-1", "untitled")))).toBe( + "untitled:Untitled-1", + ); + }); + it("root undefined (no folder) → everything is the URI string", () => { + const fsPath = "/anything/z.md"; + const uri = `file://${fsPath}`; + expect(makeRouter(false).keyOf(docIdentity(docOf(uri, fsPath, "file")))).toBe(uri); + }); +}); + +describe("SidecarRouter routing — load/save/update dispatch by key home", () => { + it("an in-root key writes to the repo .threads/ sidecar, not global storage", () => { + const router = makeRouter(); + const key = path.join("docs", "x.md"); + router.save(key, emptyArtifact(key)); + expect(fs.existsSync(path.join(root, ".threads", "docs", "x.md.json"))).toBe(true); + expect(fs.existsSync(path.join(globalDir, "sidecars"))).toBe(false); + expect(router.sidecarPath(key)).toBe(path.join(root, ".threads", "docs", "x.md.json")); + }); + it("an out-of-root file: key writes to global storage, not the repo", () => { + const router = makeRouter(); + const key = "file:///elsewhere/y.md"; + router.save(key, emptyArtifact(key)); + expect(fs.existsSync(path.join(globalDir, "sidecars", `${hash(key)}.json`))).toBe(true); + expect(fs.existsSync(path.join(root, ".threads"))).toBe(false); + expect(router.sidecarPath(key)).toBe(path.join(globalDir, "sidecars", `${hash(key)}.json`)); + }); + it("an untitled: key is in-memory (global store), no disk anywhere", () => { + const router = makeRouter(); + const key = "untitled:Untitled-1"; + router.update(key, (a) => a.threads.push({ id: "t", anchorId: "an", status: "open", messages: [] })); + expect(router.load(key)!.threads.length).toBe(1); + expect(router.sidecarPath(key)).toBeUndefined(); + expect(fs.existsSync(path.join(globalDir, "sidecars"))).toBe(false); + }); + it("round-trips through load after update on an in-root key", () => { + const router = makeRouter(); + const key = path.join("docs", "x.md"); + router.update(key, (a) => a.threads.push({ id: "t", anchorId: "an", status: "open", messages: [] })); + expect(router.load(key)!.threads.length).toBe(1); + }); +}); diff --git a/test/workspacePath.test.ts b/test/workspacePath.test.ts index 55b3a74..59e11e9 100644 --- a/test/workspacePath.test.ts +++ b/test/workspacePath.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { isUnderRoot, selectionRejection } from "../src/workspacePath"; +import { isAuthorable, isUnderRoot, selectionRejection } from "../src/workspacePath"; describe("isUnderRoot", () => { const root = "/a/vscode-cowriting-plugin/sandbox"; @@ -24,32 +24,37 @@ describe("isUnderRoot", () => { }); }); -describe("selectionRejection", () => { - const root = "/ws"; - const ok = { hasEditor: true, selectionEmpty: false, scheme: "file", fsPath: "/ws/a.md", root }; - - it("returns null when everything is valid", () => { - expect(selectionRejection(ok)).toBeNull(); +describe("isAuthorable", () => { + it("accepts file: and untitled: schemes", () => { + expect(isAuthorable("file")).toBe(true); + expect(isAuthorable("untitled")).toBe(true); }); + it("rejects read-only / virtual schemes", () => { + expect(isAuthorable("git")).toBe(false); + expect(isAuthorable("output")).toBe(false); + expect(isAuthorable("cowriting-baseline")).toBe(false); + expect(isAuthorable("")).toBe(false); + }); +}); +describe("selectionRejection — F8 widened (accepts out-of-folder + untitled)", () => { + const ok = { hasEditor: true, selectionEmpty: false, scheme: "file" }; + + it("accepts an in-folder / out-of-folder file (file: scheme)", () => { + expect(selectionRejection({ ...ok, scheme: "file" })).toBeNull(); + }); + it("accepts an untitled buffer (no longer rejected)", () => { + expect(selectionRejection({ ...ok, scheme: "untitled" })).toBeNull(); + }); it("names the missing editor", () => { - const msg = selectionRejection({ ...ok, hasEditor: false }); - expect(msg).toMatch(/focus a text editor/i); + expect(selectionRejection({ ...ok, hasEditor: false })).toMatch(/focus a text editor/i); }); - it("names the empty selection (only when there IS an editor)", () => { - const msg = selectionRejection({ ...ok, selectionEmpty: true }); - expect(msg).toMatch(/select some text/i); + expect(selectionRejection({ ...ok, selectionEmpty: true })).toMatch(/select some text/i); }); - - it("names an unsaved/non-file document", () => { - const msg = selectionRejection({ ...ok, scheme: "untitled" }); - expect(msg).toMatch(/save this document/i); - }); - - it("names an out-of-workspace file (the reported bug — NOT a selection problem)", () => { - const msg = selectionRejection({ ...ok, fsPath: "/elsewhere/a.md" }); - expect(msg).toMatch(/outside your workspace folder/i); + it("rejects a non-{file,untitled} scheme with its own message (not 'select some text')", () => { + const msg = selectionRejection({ ...ok, scheme: "git" }); + expect(msg).toMatch(/can.?t be edited|read-only|not a file/i); expect(msg).not.toMatch(/select some text/i); }); });