diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 265fcdcd9031..2059aa7d1746 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 { fileTreeEntryLocation, type FileTreeEntryInfo, isRootPath } from "./filePath"; import { labelForRoot, type ProjectFileRoot, @@ -55,11 +55,6 @@ interface FileBrowserPanelProps { workspaceMutationId: string | null; } -interface TreeEntryInfo { - readonly relativePath: string; - readonly root?: string; -} - function treePath(entry: ProjectEntry): string { return entry.kind === "directory" ? `${entry.path}/` : entry.path; } @@ -207,13 +202,17 @@ export default function FileBrowserPanel({ const treePaths: string[] = []; const directoryPaths: string[] = []; const entryKinds = new Map(); - const entryInfo = new Map(); + const entryInfo = new Map(); for (const entry of entries) { const label = rootLabels && entry.root ? labelForRoot(rootLabels, entry.root) : undefined; 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, @@ -225,11 +224,20 @@ export default function FileBrowserPanel({ return { treePaths, directoryPaths, entryKinds, entryInfo }; }, [entries, rootLabels]); const entryKindsRef = useRef>(entryKinds); - const entryInfoRef = useRef>(entryInfo); + const entryInfoRef = useRef>(entryInfo); const previousTreePathsRef = useRef(null); const syncingSelectionRef = useRef(false); const treeSelectionPathRef = useRef(null); const handledRevealRef = useRef<{ path: string; revealId: number } | null>(null); + // A row the tree synthesized above a nested multi-root label has no folder + // of its own, so it resolves to nothing rather than to a path under cwd. + const locateEntry = (path: string) => { + const treePath = path.replace(/\/$/, ""); + const info = + entryInfoRef.current.get(treePath) ?? (rootLabels ? undefined : { relativePath: treePath }); + return info ? fileTreeEntryLocation(info, cwd) : null; + }; + const locateEntryRef = useRef(locateEntry); // The tree renders rows in shadow DOM and its anchor rect is unreliable, so // capture the right-click position ourselves; contextmenu is a composed @@ -249,19 +257,23 @@ export default function FileBrowserPanel({ context: TreeContextMenuOpenContext, ) => { const api = readLocalApi(); - if (!api) { + const location = locateEntry(item.path); + if (!api || !location) { context.close(); return; } - const relativePath = item.path.replace(/\/$/, ""); - const mention = serializeComposerFileLink(relativePath); + const mention = serializeComposerFileLink(location.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: location.filePath, + workspaceRoot: location.workspaceRoot, + }; const fileMenuItems = fileContextMenu.buildItems(fileTarget); try { const clicked = await api.contextMenu.show( @@ -284,7 +296,11 @@ export default function FileBrowserPanel({ if (clicked === "copy-mention") { try { await writeTextToClipboard(mention); - toastManager.add({ type: "success", title: "Mention copied", description: relativePath }); + toastManager.add({ + type: "success", + title: "Mention copied", + description: location.mentionPath, + }); } catch (error) { toastManager.add({ type: "error", @@ -320,6 +336,7 @@ export default function FileBrowserPanel({ const showEntryContextMenuRef = useRef(showEntryContextMenu); useEffect(() => { showEntryContextMenuRef.current = showEntryContextMenu; + locateEntryRef.current = locateEntry; }); const treeModelRef = useRef["model"] | null>(null); @@ -327,6 +344,7 @@ export default function FileBrowserPanel({ () => createFileTreeDragMentionController({ deselect: (path) => treeModelRef.current?.getItem(path)?.deselect(), + mentionPath: (path) => locateEntryRef.current(path)?.mentionPath ?? null, }), [], ); diff --git a/apps/web/src/components/files/filePath.test.ts b/apps/web/src/components/files/filePath.test.ts index a9731ceb058e..bf01c834099e 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, + fileTreeEntryLocation, isRootPath, } from "./filePath"; @@ -110,3 +111,37 @@ describe("isRootPath", () => { expect(isRootPath(undefined, "/work/app")).toBe(false); }); }); + +describe("fileTreeEntryLocation", () => { + const cwd = "/dev/scope-test-four"; + + it("resolves a single-root row against the workspace", () => { + expect(fileTreeEntryLocation({ relativePath: "src/app.ts" }, cwd)).toEqual({ + mentionPath: "src/app.ts", + filePath: "src/app.ts", + workspaceRoot: cwd, + }); + }); + + it("resolves a multi-root row against its folder, not its label", () => { + // `../notes` shows as `notes`, but does not live at `/notes`. + expect(fileTreeEntryLocation({ relativePath: "ideas.md", root: "/dev/notes/" }, cwd)).toEqual({ + mentionPath: "/dev/notes/ideas.md", + filePath: "ideas.md", + workspaceRoot: "/dev/notes", + }); + }); + + it("targets a folder's own row from its parent", () => { + expect(fileTreeEntryLocation({ relativePath: "", root: `${cwd}/notes/2026` }, cwd)).toEqual({ + mentionPath: `${cwd}/notes/2026`, + filePath: "2026", + workspaceRoot: `${cwd}/notes/`, + }); + expect(fileTreeEntryLocation({ relativePath: "", root: "C:\\dev\\notes" }, cwd)).toEqual({ + mentionPath: "C:\\dev\\notes", + filePath: "notes", + workspaceRoot: "C:\\dev\\", + }); + }); +}); diff --git a/apps/web/src/components/files/filePath.ts b/apps/web/src/components/files/filePath.ts index d518c797f368..7afcda62bbad 100644 --- a/apps/web/src/components/files/filePath.ts +++ b/apps/web/src/components/files/filePath.ts @@ -66,3 +66,48 @@ export function isRootPath(roots: readonly string[] | undefined, path: string): const trimmed = path.replace(/[\\/]+$/, ""); return roots?.some((root) => root.replace(/[\\/]+$/, "") === trimmed) ?? false; } + +/** A files-panel row's path inside its folder; `root` is set for rows of a multi-root tree. */ +export interface FileTreeEntryInfo { + readonly relativePath: string; + readonly root?: string; +} + +export interface FileTreeEntryLocation { + /** What a chat mention links to: relative to the workspace, or absolute for a multi-root row. */ + readonly mentionPath: string; + /** File-action target, as `useFileContextMenu` takes it. */ + readonly filePath: string; + readonly workspaceRoot: string; +} + +/** + * Where a files-panel row lives on disk. A multi-root row resolves against its + * own folder, never the tree label, since a label is only a display name and + * the folder may sit anywhere. Its mention is absolute, like `@` search's. + */ +export function fileTreeEntryLocation( + info: FileTreeEntryInfo, + cwd: string, +): FileTreeEntryLocation | null { + if (info.root === undefined) { + if (!info.relativePath) return null; + return { mentionPath: info.relativePath, filePath: info.relativePath, workspaceRoot: cwd }; + } + const root = info.root.replace(/[\\/]+$/, ""); + if (info.relativePath) { + return { + mentionPath: `${root}/${info.relativePath}`, + filePath: info.relativePath, + workspaceRoot: root, + }; + } + // The folder's own row: file actions target it from its parent directory. + const separatorIndex = Math.max(root.lastIndexOf("/"), root.lastIndexOf("\\")); + if (separatorIndex === -1) return null; + return { + mentionPath: root, + filePath: root.slice(separatorIndex + 1), + workspaceRoot: root.slice(0, separatorIndex + 1), + }; +} diff --git a/apps/web/src/components/files/fileTreeDragMention.test.ts b/apps/web/src/components/files/fileTreeDragMention.test.ts index 812501ab5d14..0a8b5ea098a3 100644 --- a/apps/web/src/components/files/fileTreeDragMention.test.ts +++ b/apps/web/src/components/files/fileTreeDragMention.test.ts @@ -101,6 +101,20 @@ describe("createFileTreeDragMentionController", () => { expect(transfer.getData(COMPOSER_MENTION_DRAG_TYPE)).toBe("[app.ts](src/app.ts)"); }); + it("links each dragged row to the path the host resolves it to", () => { + const controller = createFileTreeDragMentionController({ + deselect: () => {}, + mentionPath: (path) => (path === "notes/" ? null : `/dev/${path}`), + }); + controller.handleSelectionChange(["notes/ideas.md", "notes/"]); + const transfer = makeTransfer(); + controller.handleDragStart({ + dataTransfer: transfer, + composedPath: () => [rowNode("notes/ideas.md")], + }); + expect(transfer.getData(COMPOSER_MENTION_DRAG_TYPE)).toBe("[ideas.md](/dev/notes/ideas.md)"); + }); + it("does not deselect anything when no drag was started", () => { const deselected: Array = []; const controller = createFileTreeDragMentionController({ diff --git a/apps/web/src/components/files/fileTreeDragMention.ts b/apps/web/src/components/files/fileTreeDragMention.ts index 7c17639a649d..255089325b6a 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; + /** Path the dragged row's mention links to, or null to leave it out. Defaults to the tree path. */ + mentionPath?(treePath: string): string | null; } export interface FileTreeDragMentionController { @@ -74,7 +76,10 @@ 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 mentionPath = host.mentionPath ? host.mentionPath(path) : path; + return mentionPath === null ? null : composerMentionFromTreePath(mentionPath); + }) .filter((mention): mention is string => mention !== null); if (mentions.length === 0) { return;