Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 32 additions & 6 deletions apps/web/src/components/files/FileBrowserPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -210,10 +210,15 @@ export default function FileBrowserPanel({
const entryInfo = new Map<string, TreeEntryInfo>();
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,
Expand All @@ -226,6 +231,21 @@ export default function FileBrowserPanel({
}, [entries, rootLabels]);
const entryKindsRef = useRef<ReadonlyMap<string, ProjectEntry["kind"]>>(entryKinds);
const entryInfoRef = useRef<ReadonlyMap<string, TreeEntryInfo>>(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<readonly string[] | null>(null);
const syncingSelectionRef = useRef(false);
const treeSelectionPathRef = useRef<string | null>(null);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -327,6 +352,7 @@ export default function FileBrowserPanel({
() =>
createFileTreeDragMentionController({
deselect: (path) => treeModelRef.current?.getItem(path)?.deselect(),
mentionPath: (path) => entryTargetRef.current(path).mentionPath,
}),
[],
);
Expand Down
61 changes: 61 additions & 0 deletions apps/web/src/components/files/filePath.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
fileBreadcrumbChildren,
fileBreadcrumbParent,
fileBreadcrumbs,
fileTreeEntryTarget,
isRootPath,
} from "./filePath";

Expand Down Expand Up @@ -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",
});
});
});
32 changes: 32 additions & 0 deletions apps/web/src/components/files/filePath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
};
}
15 changes: 15 additions & 0 deletions apps/web/src/components/files/fileTreeDragMention.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/components/files/fileTreeDragMention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
Loading