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 ? ( + <> + props.onDirectoryChange(parentPath)}> + + Back to {pathLabel(parentPath, props.projectName)} + + + + ) : null} + + {entriesQuery.isPending && entriesQuery.data === null ? ( + + + Loading folder… + + ) : entriesQuery.error && entriesQuery.data === null ? ( + + + Retry loading folder + + ) : !directoryAvailable ? ( + This folder is no longer available. + ) : children.length === 0 ? ( + This folder is empty. + ) : ( + children.map((entry) => { + const isCurrentFile = entry.kind === "file" && entry.path === props.currentFilePath; + const containsCurrentFile = + entry.kind === "directory" && props.currentFilePath.startsWith(`${entry.path}/`); + return ( + { + if (entry.kind === "directory") { + props.onDirectoryChange(entry.path); + return; + } + props.onOpenChange(false); + props.onOpenFile(entry.path); + }} + > + + + }> + {entry.label} + + + {entry.path} + + + {isCurrentFile ? ( + + ) : entry.kind === "directory" ? ( + + ) : null} + + ); + }) + )} + + {entriesQuery.error && entriesQuery.data !== null ? ( + <> + + + + Refresh failed — retry + + + ) : null} + {entriesQuery.data?.truncated ? ( + <> + + Some workspace entries are not shown. + + ) : 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 ( + + + + } + /> + } + > + {props.crumb.label} + + + {props.crumb.path || props.projectName} + + + {open ? ( + + ) : null} + + ); +} + +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); +}