diff --git a/src/attributionController.ts b/src/attributionController.ts index 470ef21..8744c22 100644 --- a/src/attributionController.ts +++ b/src/attributionController.ts @@ -17,6 +17,7 @@ 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"; /** Test-facing snapshot of live attribution state for a document. */ export interface RenderedSpan { @@ -87,7 +88,7 @@ export class AttributionController implements vscode.Disposable { } private isTracked(document: vscode.TextDocument): boolean { - return document.uri.scheme === "file" && document.uri.fsPath.startsWith(this.rootDir); + return document.uri.scheme === "file" && isUnderRoot(document.uri.fsPath, this.rootDir); } private docPathOf(uri: vscode.Uri): string { return vscode.workspace.asRelativePath(uri, false); diff --git a/src/extension.ts b/src/extension.ts index be53c2c..2bd71bd 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -9,6 +9,7 @@ import { VersionGuard } from "./versionGuard"; import { BaselineStore } from "./baselineStore"; import { DiffViewController } from "./diffViewController"; import { TrackChangesPreviewController } from "./trackChangesPreview"; +import { isUnderRoot, selectionRejection } from "./workspacePath"; const CHANNEL_NAME = "Cowriting (Cline SDK)"; @@ -187,22 +188,28 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef context.subscriptions.push( vscode.commands.registerCommand("cowriting.editSelection", async () => { const editor = vscode.window.activeTextEditor; - if ( - !editor || - editor.selection.isEmpty || - editor.document.uri.scheme !== "file" || - !editor.document.uri.fsPath.startsWith(root) - ) { - void vscode.window.showWarningMessage("Cowriting: select some text in a workspace document first."); + // 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). + 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); return; } + if (!editor) return; // unreachable once reason is null, but narrows the type const instruction = await vscode.window.showInputBox({ prompt: "What should Claude do with the selection?", placeHolder: "e.g. tighten this paragraph", }); if (!instruction) return; if (editor.selection.isEmpty) { - void vscode.window.showWarningMessage("Cowriting: select some text in a workspace document first."); + void vscode.window.showWarningMessage("Cowriting: select some text to send to Claude first."); return; } const document = editor.document; @@ -262,7 +269,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" && doc.uri.fsPath.startsWith(root)) { + if (doc.uri.scheme === "file" && isUnderRoot(doc.uri.fsPath, root)) { threadController.renderAll(doc); attributionController.loadAll(doc); proposalController.renderAll(doc); diff --git a/src/proposalController.ts b/src/proposalController.ts index 6ae714b..6b15570 100644 --- a/src/proposalController.ts +++ b/src/proposalController.ts @@ -16,6 +16,7 @@ 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"; /** Test-facing snapshot of what is currently rendered for a document. */ export interface RenderedProposal { @@ -70,7 +71,7 @@ export class ProposalController implements vscode.Disposable { } private isTracked(document: vscode.TextDocument): boolean { - return document.uri.scheme === "file" && document.uri.fsPath.startsWith(this.rootDir); + return document.uri.scheme === "file" && isUnderRoot(document.uri.fsPath, this.rootDir); } private docPathOf(uri: vscode.Uri): string { return vscode.workspace.asRelativePath(uri, false); diff --git a/src/threadController.ts b/src/threadController.ts index 005e1a5..515b54b 100644 --- a/src/threadController.ts +++ b/src/threadController.ts @@ -14,6 +14,7 @@ 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"; /** Test-facing snapshot of what is currently rendered for a document. */ export interface RenderedThread { @@ -69,7 +70,7 @@ export class ThreadController implements vscode.Disposable { } private isInRoot(uri: vscode.Uri): boolean { - return uri.scheme === "file" && uri.fsPath.startsWith(this.rootDir); + return uri.scheme === "file" && isUnderRoot(uri.fsPath, this.rootDir); } private docPathOf(uri: vscode.Uri): string { diff --git a/src/workspacePath.ts b/src/workspacePath.ts new file mode 100644 index 0000000..5c61292 --- /dev/null +++ b/src/workspacePath.ts @@ -0,0 +1,56 @@ +/** + * workspacePath — pure, vscode-free helpers for the "is this document one + * Cowriting can anchor to?" decision (F2/F3/F4 require a SAVED file under the + * workspace folder, because threads/attribution/proposals persist to a + * `.threads/` sidecar beside that file). + * + * Split out so the membership test and the per-condition warning are + * deterministic and unit-testable with no editor — see `test/workspacePath.test.ts`. + */ +import * as path from "node:path"; + +/** + * Is `fsPath` the workspace root or a path strictly inside it? + * + * Uses a path-separator boundary, NOT a bare `startsWith(root)` — otherwise a + * sibling whose name merely begins with the root's name would falsely match + * (e.g. `.../vscode-cowriting-plugin-content/x` vs root `.../vscode-cowriting-plugin`). + */ +export function isUnderRoot(fsPath: string, root: string): boolean { + return fsPath === root || fsPath.startsWith(root + path.sep); +} + +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; + /** 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). + */ +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 (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)."; + } + return null; +} diff --git a/test/workspacePath.test.ts b/test/workspacePath.test.ts new file mode 100644 index 0000000..55b3a74 --- /dev/null +++ b/test/workspacePath.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { isUnderRoot, selectionRejection } from "../src/workspacePath"; + +describe("isUnderRoot", () => { + const root = "/a/vscode-cowriting-plugin/sandbox"; + + it("accepts the root itself and files inside it", () => { + expect(isUnderRoot(root, root)).toBe(true); + expect(isUnderRoot(`${root}/docs/x.md`, root)).toBe(true); + }); + + it("rejects files outside the root", () => { + expect(isUnderRoot("/a/other/x.md", root)).toBe(false); + }); + + it("rejects a SIBLING whose path is a string-prefix of the root's parent (the prefix-collision bug)", () => { + // The reported case: EDH root is .../vscode-cowriting-plugin/sandbox, the file + // is in the sibling content repo. Plain startsWith on the plugin-repo prefix + // would falsely match; the separator boundary must reject it. + const pluginRoot = "/a/vscode-cowriting-plugin"; + const contentFile = "/a/vscode-cowriting-plugin-content/issues/x.md"; + expect(contentFile.startsWith(pluginRoot)).toBe(true); // the latent bug + expect(isUnderRoot(contentFile, pluginRoot)).toBe(false); // fixed + }); +}); + +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(); + }); + + it("names the missing editor", () => { + const msg = selectionRejection({ ...ok, hasEditor: false }); + expect(msg).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); + }); + + 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); + expect(msg).not.toMatch(/select some text/i); + }); +});