fix(edit-selection): accurate per-condition warnings + path-boundary workspace check
cowriting.editSelection collapsed four distinct failures (no editor / no selection / unsaved / outside the workspace folder) into one misleading "select some text in a workspace document first" warning — so editing a selection in a file OUTSIDE the EDH workspace root (e.g. a content-repo file while the EDH opens sandbox/) was rejected as if no text were selected. - New pure, vscode-free src/workspacePath.ts: isUnderRoot() (separator-bounded membership, fixing a latent startsWith() prefix collision where a sibling whose name prefixes the root — e.g. vscode-cowriting-plugin-content vs vscode-cowriting-plugin — falsely matched) + selectionRejection() (one message per condition). Unit-tested (8 cases), incl. the reported bug. - Wire isUnderRoot into all five membership checks: extension.ts (editSelection guard + renderIfOpen) and the thread/attribution/proposal controllers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user