fix(edit-selection): accurate warnings + path-boundary workspace check #24

Merged
benstull merged 1 commits from fix-editselection-guard into main 2026-06-11 19:13:12 +00:00
6 changed files with 133 additions and 12 deletions
Showing only changes of commit 0bdee1c286 - Show all commits
+2 -1
View File
@@ -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);
+16 -9
View File
@@ -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);
+2 -1
View File
@@ -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);
+2 -1
View File
@@ -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 {
+56
View File
@@ -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;
}
+55
View File
@@ -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);
});
});