Skip to content
Closed
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
50 changes: 34 additions & 16 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 { fileTreeEntryLocation, type FileTreeEntryInfo, isRootPath } from "./filePath";
import {
labelForRoot,
type ProjectFileRoot,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -207,13 +202,17 @@ export default function FileBrowserPanel({
const treePaths: string[] = [];
const directoryPaths: string[] = [];
const entryKinds = new Map<string, ProjectEntry["kind"]>();
const entryInfo = new Map<string, TreeEntryInfo>();
const entryInfo = new Map<string, FileTreeEntryInfo>();
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;
Comment on lines +213 to +215
entryKinds.set(entry.path, entry.kind);
entryInfo.set(entry.path, {
relativePath,
Expand All @@ -225,11 +224,20 @@ export default function FileBrowserPanel({
return { treePaths, directoryPaths, entryKinds, entryInfo };
}, [entries, rootLabels]);
const entryKindsRef = useRef<ReadonlyMap<string, ProjectEntry["kind"]>>(entryKinds);
const entryInfoRef = useRef<ReadonlyMap<string, TreeEntryInfo>>(entryInfo);
const entryInfoRef = useRef<ReadonlyMap<string, FileTreeEntryInfo>>(entryInfo);
const previousTreePathsRef = useRef<readonly string[] | null>(null);
const syncingSelectionRef = useRef(false);
const treeSelectionPathRef = useRef<string | null>(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
Expand All @@ -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(
Expand All @@ -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",
Expand Down Expand Up @@ -320,13 +336,15 @@ export default function FileBrowserPanel({
const showEntryContextMenuRef = useRef(showEntryContextMenu);
useEffect(() => {
showEntryContextMenuRef.current = showEntryContextMenu;
locateEntryRef.current = locateEntry;
});

const treeModelRef = useRef<ReturnType<typeof useFileTree>["model"] | null>(null);
const dragMention = useMemo(
() =>
createFileTreeDragMentionController({
deselect: (path) => treeModelRef.current?.getItem(path)?.deselect(),
mentionPath: (path) => locateEntryRef.current(path)?.mentionPath ?? null,
}),
[],
);
Expand Down
35 changes: 35 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,
fileTreeEntryLocation,
isRootPath,
} from "./filePath";

Expand Down Expand Up @@ -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 `<cwd>/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\\",
});
});
});
45 changes: 45 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,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),
};
}
14 changes: 14 additions & 0 deletions apps/web/src/components/files/fileTreeDragMention.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = [];
const controller = createFileTreeDragMentionController({
Expand Down
7 changes: 6 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;
/** 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 {
Expand Down Expand Up @@ -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;
Expand Down
Loading