diff --git a/apps/web/src/components/files/FileBreadcrumbs.tsx b/apps/web/src/components/files/FileBreadcrumbs.tsx
new file mode 100644
index 000000000000..a09bfdde3771
--- /dev/null
+++ b/apps/web/src/components/files/FileBreadcrumbs.tsx
@@ -0,0 +1,283 @@
+import type { EnvironmentId } from "@t3tools/contracts";
+import {
+ ArrowLeftIcon,
+ CheckIcon,
+ ChevronRightIcon,
+ LoaderCircleIcon,
+ RotateCwIcon,
+} from "lucide-react";
+import { useEffect, useMemo, useState } from "react";
+
+import { PierreEntryIcon } from "~/components/chat/PierreEntryIcon";
+import {
+ Menu,
+ MenuGroup,
+ MenuGroupLabel,
+ MenuItem,
+ MenuPopup,
+ MenuSeparator,
+ MenuTrigger,
+} from "~/components/ui/menu";
+import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
+import { useTheme } from "~/hooks/useTheme";
+import { cn } from "~/lib/utils";
+
+import {
+ type FileBreadcrumb,
+ fileBreadcrumbChildren,
+ fileBreadcrumbParent,
+ fileBreadcrumbs,
+} from "./filePath";
+import { useProjectEntriesQuery } from "./projectFilesQueryState";
+
+interface FileBreadcrumbsProps {
+ readonly cwd: string;
+ readonly environmentId: EnvironmentId;
+ readonly onOpenFile: (relativePath: string) => void;
+ readonly projectName: string;
+ readonly relativePath: string;
+}
+
+function pathLabel(path: string, projectName: string): string {
+ return path.slice(path.lastIndexOf("/") + 1) || projectName;
+}
+
+function BreadcrumbLabel(props: {
+ readonly current?: boolean;
+ readonly label: string;
+ readonly pathLabel: string;
+}) {
+ return (
+
+
+ }
+ >
+ {props.label}
+
+
+ {props.pathLabel}
+
+
+ );
+}
+
+function BreadcrumbMenuContent(props: {
+ readonly cwd: string;
+ readonly currentFilePath: string;
+ readonly directoryPath: string;
+ readonly environmentId: EnvironmentId;
+ readonly onDirectoryChange: (path: string) => void;
+ readonly onOpenChange: (open: boolean) => void;
+ readonly onOpenFile: (path: string) => void;
+ readonly projectName: string;
+ readonly rootPath: string;
+}) {
+ const entriesQuery = useProjectEntriesQuery(props.environmentId, props.cwd);
+ const { resolvedTheme } = useTheme();
+ const entries = entriesQuery.data?.entries ?? [];
+ const children = useMemo(
+ () => fileBreadcrumbChildren(entries, props.directoryPath),
+ [entries, props.directoryPath],
+ );
+ const directoryAvailable =
+ props.directoryPath === "" ||
+ entries.some((entry) => entry.kind === "directory" && entry.path === props.directoryPath);
+ const parentPath = fileBreadcrumbParent(props.directoryPath);
+ const canGoBack =
+ props.directoryPath !== props.rootPath &&
+ parentPath !== null &&
+ (props.rootPath === "" ||
+ parentPath === props.rootPath ||
+ parentPath.startsWith(`${props.rootPath}/`));
+
+ return (
+ {
+ if (event.key !== "ArrowLeft" || !canGoBack || parentPath === null) return;
+ event.preventDefault();
+ event.stopPropagation();
+ props.onDirectoryChange(parentPath);
+ }}
+ >
+
+
+
+ {pathLabel(props.directoryPath, props.projectName)}
+
+
+ {props.directoryPath || props.projectName}
+
+
+
+ {canGoBack && parentPath !== null ? (
+ <>
+
+
+ >
+ ) : null}
+
+ {entriesQuery.isPending && entriesQuery.data === null ? (
+
+ ) : entriesQuery.error && entriesQuery.data === null ? (
+
+ ) : !directoryAvailable ? (
+
+ ) : children.length === 0 ? (
+
+ ) : (
+ children.map((entry) => {
+ const isCurrentFile = entry.kind === "file" && entry.path === props.currentFilePath;
+ const containsCurrentFile =
+ entry.kind === "directory" && props.currentFilePath.startsWith(`${entry.path}/`);
+ return (
+
+ );
+ })
+ )}
+
+ {entriesQuery.error && entriesQuery.data !== null ? (
+ <>
+
+
+ >
+ ) : null}
+ {entriesQuery.data?.truncated ? (
+ <>
+
+
+ >
+ ) : null}
+
+ );
+}
+
+function DirectoryBreadcrumb(props: FileBreadcrumbsProps & { readonly crumb: FileBreadcrumb }) {
+ const [open, setOpen] = useState(false);
+ const [directoryPath, setDirectoryPath] = useState(props.crumb.path);
+
+ useEffect(() => {
+ setOpen(false);
+ setDirectoryPath(props.crumb.path);
+ }, [props.crumb.path, props.relativePath]);
+
+ const handleOpenChange = (nextOpen: boolean) => {
+ setOpen(nextOpen);
+ if (nextOpen) setDirectoryPath(props.crumb.path);
+ };
+
+ return (
+
+ );
+}
+
+export function FileBreadcrumbs(props: FileBreadcrumbsProps) {
+ const breadcrumbs = useMemo(
+ () => fileBreadcrumbs(props.projectName, props.relativePath),
+ [props.projectName, props.relativePath],
+ );
+
+ return breadcrumbs.map((crumb, index) => (
+
+ {index > 0 ? (
+
+ ) : null}
+ {crumb.kind === "file" ? (
+
+
+
+ ) : (
+
+ )}
+
+ ));
+}
diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx
index f9ebb3076288..04f478318374 100644
--- a/apps/web/src/components/files/FilePreviewPanel.tsx
+++ b/apps/web/src/components/files/FilePreviewPanel.tsx
@@ -12,7 +12,7 @@ import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
-import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react";
+import { Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react";
import * as Schema from "effect/Schema";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -42,6 +42,7 @@ import { useAtomCommand } from "~/state/use-atom-command";
import { useAtomQueryRunner } from "~/state/use-atom-query-runner";
import FileBrowserPanel from "./FileBrowserPanel";
+import { FileBreadcrumbs } from "./FileBreadcrumbs";
import { FileMarkdownPreview } from "./FileMarkdownPreview";
import {
type FileCommentAnnotationEntry,
@@ -56,7 +57,6 @@ import { installFileEditorDismissal } from "./fileEditorDismissal";
import { resolveCenteredFileLineScrollTop } from "./fileLineReveal";
import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation";
import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision";
-import { fileBreadcrumbs } from "./filePath";
import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode";
import { FileSaveCoordinator } from "./fileSaveCoordinator";
import {
@@ -818,10 +818,6 @@ export default function FilePreviewPanel({
const canOpenInBrowser =
relativePath !== null && isPreviewSupportedInRuntime() && isBrowserPreviewFile(relativePath);
const absolutePath = relativePath ? resolvePathLinkTarget(relativePath, cwd) : null;
- const breadcrumbs = useMemo(
- () => (relativePath ? fileBreadcrumbs(projectName, relativePath) : []),
- [projectName, relativePath],
- );
const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId);
useWorkspaceMutationRefresh({
enabled: relativePath !== null && !isImage && !selectedFilePending,
@@ -888,36 +884,13 @@ export default function FilePreviewPanel({
data-file-breadcrumbs
>
- {breadcrumbs.map((crumb, index) => (
-
- {index > 0 ? (
-
- ) : null}
-
-
- }
- >
- {crumb.label}
-
-
- {crumb.path || projectName}
-
-
-
- ))}
+
{absolutePath &&
diff --git a/apps/web/src/components/files/filePath.test.ts b/apps/web/src/components/files/filePath.test.ts
index 702181d2d2d7..9d086a29e489 100644
--- a/apps/web/src/components/files/filePath.test.ts
+++ b/apps/web/src/components/files/filePath.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vite-plus/test";
-import { fileBreadcrumbs } from "./filePath";
+import { fileBreadcrumbChildren, fileBreadcrumbParent, fileBreadcrumbs } from "./filePath";
describe("fileBreadcrumbs", () => {
it("builds project, directory, and file crumbs", () => {
@@ -21,3 +21,52 @@ describe("fileBreadcrumbs", () => {
]);
});
});
+
+describe("fileBreadcrumbChildren", () => {
+ const entries = [
+ { path: "README.md", kind: "file" as const },
+ { path: "src", kind: "directory" as const },
+ { path: "src-old", kind: "directory" as const },
+ { path: "src/index.ts", kind: "file" as const },
+ { path: "src/lib", kind: "directory" as const },
+ { path: "src/lib/file10.ts", kind: "file" as const },
+ { path: "src/lib/file2.ts", kind: "file" as const },
+ { path: "src-old/index.ts", kind: "file" as const },
+ ];
+
+ it("returns only the immediate children of the project root", () => {
+ expect(fileBreadcrumbChildren(entries, "")).toEqual([
+ { path: "src", kind: "directory", label: "src" },
+ { path: "src-old", kind: "directory", label: "src-old" },
+ { path: "README.md", kind: "file", label: "README.md" },
+ ]);
+ });
+
+ it("honors segment boundaries and sorts folders before files", () => {
+ expect(fileBreadcrumbChildren(entries, "src")).toEqual([
+ { path: "src/lib", kind: "directory", label: "lib" },
+ { path: "src/index.ts", kind: "file", label: "index.ts" },
+ ]);
+ });
+
+ it("uses natural file-name ordering", () => {
+ expect(fileBreadcrumbChildren(entries, "src/lib").map((entry) => entry.label)).toEqual([
+ "file2.ts",
+ "file10.ts",
+ ]);
+ });
+
+ it("returns an empty list for an empty or missing directory", () => {
+ expect(fileBreadcrumbChildren(entries, "missing")).toEqual([]);
+ });
+});
+
+describe("fileBreadcrumbParent", () => {
+ it.each([
+ ["src/lib", "src"],
+ ["src", ""],
+ ["", null],
+ ])("returns the parent of %j", (path, expected) => {
+ expect(fileBreadcrumbParent(path)).toBe(expected);
+ });
+});
diff --git a/apps/web/src/components/files/filePath.ts b/apps/web/src/components/files/filePath.ts
index 66ede58119b8..e7f5444e7389 100644
--- a/apps/web/src/components/files/filePath.ts
+++ b/apps/web/src/components/files/filePath.ts
@@ -1,9 +1,15 @@
+import type { ProjectEntry } from "@t3tools/contracts";
+
export interface FileBreadcrumb {
label: string;
path: string;
kind: "project" | "directory" | "file";
}
+export interface FileBreadcrumbChild extends ProjectEntry {
+ label: string;
+}
+
export function fileBreadcrumbs(projectName: string, relativePath: string): FileBreadcrumb[] {
const parts = relativePath.split("/").filter(Boolean);
return [
@@ -15,3 +21,30 @@ export function fileBreadcrumbs(projectName: string, relativePath: string): File
})),
];
}
+
+export function fileBreadcrumbChildren(
+ entries: readonly ProjectEntry[],
+ directoryPath: string,
+): FileBreadcrumbChild[] {
+ const prefix = directoryPath ? `${directoryPath}/` : "";
+ return entries
+ .flatMap((entry) => {
+ if (!entry.path.startsWith(prefix)) return [];
+ const label = entry.path.slice(prefix.length);
+ if (!label || label.includes("/")) return [];
+ return [{ ...entry, label }];
+ })
+ .toSorted((left, right) => {
+ if (left.kind !== right.kind) return left.kind === "directory" ? -1 : 1;
+ return left.label.localeCompare(right.label, undefined, {
+ numeric: true,
+ sensitivity: "base",
+ });
+ });
+}
+
+export function fileBreadcrumbParent(directoryPath: string): string | null {
+ if (!directoryPath) return null;
+ const separatorIndex = directoryPath.lastIndexOf("/");
+ return separatorIndex === -1 ? "" : directoryPath.slice(0, separatorIndex);
+}