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;