From bc34bdf89cf22c69c1919918a257934d6d2e2dbb Mon Sep 17 00:00:00 2001 From: Rahat Hameed Date: Fri, 2 Oct 2026 15:12:15 +0500 Subject: [PATCH] fix(web): files panel right-click actions and drags use the folder's real path In a multi-root files tree, a file's tree path starts with its root's label, and the right-click menu joined that path to the workspace folder. That only named the file when the root sat directly in the workspace folder under its own name, so Reveal, Open, Open with, Copy mention and Add to chat pointed at the wrong file for a listed folder outside the workspace folder, a nested listed folder, or an isolated run's worktree. Dragging a row into the composer built its mention from the same tree path. Both now resolve each entry through its root, the way left-click does, and mention it by full path, as @ search does for files in another root. Fixes #335 Co-Authored-By: Claude Opus 5.5 --- .../src/components/files/FileBrowserPanel.tsx | 38 ++++++++++-- .../web/src/components/files/filePath.test.ts | 61 +++++++++++++++++++ apps/web/src/components/files/filePath.ts | 32 ++++++++++ .../files/fileTreeDragMention.test.ts | 15 +++++ .../components/files/fileTreeDragMention.ts | 9 ++- 5 files changed, 148 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 265fcdcd9031..e9d93684378c 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -22,7 +22,7 @@ import { readLocalApi } from "~/localApi"; import { T3_PIERRE_ICONS } from "~/pierre-icons"; import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme"; -import { isRootPath } from "./filePath"; +import { fileTreeEntryTarget, isRootPath } from "./filePath"; import { labelForRoot, type ProjectFileRoot, @@ -210,10 +210,15 @@ export default function FileBrowserPanel({ const entryInfo = new Map(); for (const entry of entries) { const label = rootLabels && entry.root ? labelForRoot(rootLabels, entry.root) : undefined; + // A root's own node is the root itself, so its path within the root is "". const relativePath = - label !== undefined && entry.path.startsWith(`${label}/`) - ? entry.path.slice(label.length + 1) - : entry.path; + label === undefined + ? entry.path + : entry.path === label + ? "" + : entry.path.startsWith(`${label}/`) + ? entry.path.slice(label.length + 1) + : entry.path; entryKinds.set(entry.path, entry.kind); entryInfo.set(entry.path, { relativePath, @@ -226,6 +231,21 @@ export default function FileBrowserPanel({ }, [entries, rootLabels]); const entryKindsRef = useRef>(entryKinds); const entryInfoRef = useRef>(entryInfo); + // What a row's right-click actions and drag mention act on: its real file, + // resolved through its root rather than the label its tree path starts with. + const entryTarget = (treePath: string) => { + const info = entryInfoRef.current.get(treePath); + return fileTreeEntryTarget({ + treePath, + cwd, + root: info?.root, + relativePath: info?.relativePath, + }); + }; + const entryTargetRef = useRef(entryTarget); + useEffect(() => { + entryTargetRef.current = entryTarget; + }); const previousTreePathsRef = useRef(null); const syncingSelectionRef = useRef(false); const treeSelectionPathRef = useRef(null); @@ -254,14 +274,19 @@ export default function FileBrowserPanel({ return; } const relativePath = item.path.replace(/\/$/, ""); - const mention = serializeComposerFileLink(relativePath); + const target = entryTarget(relativePath); + const mention = serializeComposerFileLink(target.mentionPath); const pointer = contextMenuPointerRef.current; const pointerIsFresh = pointer !== null && performance.now() - pointer.at < 1000; const anchorRect = context.anchorElement.getBoundingClientRect(); const position = pointerIsFresh ? { x: pointer.x, y: pointer.y } : { x: anchorRect.left, y: anchorRect.bottom }; - const fileTarget = { environmentId, filePath: relativePath, workspaceRoot: cwd }; + const fileTarget = { + environmentId, + filePath: target.filePath, + workspaceRoot: target.workspaceRoot, + }; const fileMenuItems = fileContextMenu.buildItems(fileTarget); try { const clicked = await api.contextMenu.show( @@ -327,6 +352,7 @@ export default function FileBrowserPanel({ () => createFileTreeDragMentionController({ deselect: (path) => treeModelRef.current?.getItem(path)?.deselect(), + mentionPath: (path) => entryTargetRef.current(path).mentionPath, }), [], ); diff --git a/apps/web/src/components/files/filePath.test.ts b/apps/web/src/components/files/filePath.test.ts index a9731ceb058e..c5581a8cf20d 100644 --- a/apps/web/src/components/files/filePath.test.ts +++ b/apps/web/src/components/files/filePath.test.ts @@ -4,6 +4,7 @@ import { fileBreadcrumbChildren, fileBreadcrumbParent, fileBreadcrumbs, + fileTreeEntryTarget, isRootPath, } from "./filePath"; @@ -110,3 +111,63 @@ describe("isRootPath", () => { expect(isRootPath(undefined, "/work/app")).toBe(false); }); }); + +describe("fileTreeEntryTarget", () => { + const cwd = "/home/user/dev/project"; + + it("keeps the tree path under cwd for a single-root tree", () => { + expect( + fileTreeEntryTarget({ + treePath: "src/index.ts", + cwd, + root: undefined, + relativePath: undefined, + }), + ).toEqual({ workspaceRoot: cwd, filePath: "src/index.ts", mentionPath: "src/index.ts" }); + }); + + it("resolves a file in a root outside cwd through that root", () => { + expect( + fileTreeEntryTarget({ + treePath: "notes/ideas.md", + cwd, + root: "/home/user/dev/notes", + relativePath: "ideas.md", + }), + ).toEqual({ + workspaceRoot: "/home/user/dev/notes", + filePath: "ideas.md", + mentionPath: "/home/user/dev/notes/ideas.md", + }); + }); + + it("resolves a file in a nested root through that root, not its label", () => { + expect( + fileTreeEntryTarget({ + treePath: "2026/plan.md", + cwd, + root: `${cwd}/notes/2026`, + relativePath: "plan.md", + }), + ).toEqual({ + workspaceRoot: `${cwd}/notes/2026`, + filePath: "plan.md", + mentionPath: `${cwd}/notes/2026/plan.md`, + }); + }); + + it("names a root's own node from its parent folder", () => { + expect( + fileTreeEntryTarget({ + treePath: "notes", + cwd, + root: "/home/user/dev/notes/", + relativePath: "", + }), + ).toEqual({ + workspaceRoot: "/home/user/dev", + filePath: "notes", + mentionPath: "/home/user/dev/notes", + }); + }); +}); diff --git a/apps/web/src/components/files/filePath.ts b/apps/web/src/components/files/filePath.ts index d518c797f368..6902b297af5d 100644 --- a/apps/web/src/components/files/filePath.ts +++ b/apps/web/src/components/files/filePath.ts @@ -66,3 +66,35 @@ export function isRootPath(roots: readonly string[] | undefined, path: string): const trimmed = path.replace(/[\\/]+$/, ""); return roots?.some((root) => root.replace(/[\\/]+$/, "") === trimmed) ?? false; } + +/** + * What a files tree entry's right-click actions act on. In a multi-root tree an + * entry's tree path starts with its root's label, which only names a real path + * when the root sits directly in `cwd` under that name, so a rooted entry + * resolves through its root instead. A root's own node (`relativePath` "") is + * named from its parent folder, since the file actions take a relative path. + */ +export function fileTreeEntryTarget(input: { + readonly treePath: string; + readonly cwd: string; + readonly root: string | undefined; + readonly relativePath: string | undefined; +}) { + if (input.root === undefined || input.relativePath === undefined) { + return { workspaceRoot: input.cwd, filePath: input.treePath, mentionPath: input.treePath }; + } + const root = input.root.replace(/[\\/]+$/, ""); + if (input.relativePath === "") { + const separatorIndex = Math.max(root.lastIndexOf("/"), root.lastIndexOf("\\")); + return { + workspaceRoot: root.slice(0, separatorIndex) || root.slice(0, separatorIndex + 1), + filePath: root.slice(separatorIndex + 1), + mentionPath: root, + }; + } + return { + workspaceRoot: root, + filePath: input.relativePath, + mentionPath: `${root}/${input.relativePath}`, + }; +} diff --git a/apps/web/src/components/files/fileTreeDragMention.test.ts b/apps/web/src/components/files/fileTreeDragMention.test.ts index 812501ab5d14..e5552ff750b6 100644 --- a/apps/web/src/components/files/fileTreeDragMention.test.ts +++ b/apps/web/src/components/files/fileTreeDragMention.test.ts @@ -28,6 +28,21 @@ describe("createFileTreeDragMentionController", () => { expect(controller.isDragInProgress()).toBe(true); }); + it("mentions a row by the path the host resolves it to", () => { + const controller = createFileTreeDragMentionController({ + deselect: () => {}, + mentionPath: (treePath) => `/home/user/dev/${treePath}`, + }); + const transfer = makeTransfer(); + controller.handleDragStart({ + dataTransfer: transfer, + composedPath: () => [rowNode("notes/ideas.md")], + }); + expect(transfer.getData(COMPOSER_MENTION_DRAG_TYPE)).toBe( + "[ideas.md](/home/user/dev/notes/ideas.md)", + ); + }); + it("strips the trailing slash from directory rows", () => { const controller = createFileTreeDragMentionController({ deselect: () => {} }); const transfer = makeTransfer(); diff --git a/apps/web/src/components/files/fileTreeDragMention.ts b/apps/web/src/components/files/fileTreeDragMention.ts index 7c17639a649d..7dc6c6cc87e1 100644 --- a/apps/web/src/components/files/fileTreeDragMention.ts +++ b/apps/web/src/components/files/fileTreeDragMention.ts @@ -15,6 +15,8 @@ export interface FileTreeDragStartEvent { export interface FileTreeDragMentionHost { /** Drop the tree's gesture-applied selection of the dragged row. */ deselect(treePath: string): void; + /** The path a dragged row is mentioned by; its tree path when omitted. */ + mentionPath?(treePath: string): string; } export interface FileTreeDragMentionController { @@ -74,7 +76,12 @@ export function createFileTreeDragMentionController( // part of the current selection drags the whole selection. const dragged = selection.includes(itemPath) ? selection : [itemPath]; const mentions = dragged - .map((path) => composerMentionFromTreePath(path)) + .map((path) => { + const treePath = path.replace(/\/+$/, ""); + return composerMentionFromTreePath( + treePath && host.mentionPath ? host.mentionPath(treePath) : treePath, + ); + }) .filter((mention): mention is string => mention !== null); if (mentions.length === 0) { return;