278 lines · 9.6 KB · TypeScript
RawDownload
import { RefreshIcon } from "~/components/ui/refresh-icon";import { Spinner } from "~/components/ui/spinner";import type { EnvironmentId } from "@t3tools/contracts";import { ArrowLeftIcon, ChevronRightIcon } from "lucide-react";import { useEffect, useMemo, useState } from "react"; import { PierreEntryIcon } from "~/components/chat/PierreEntryIcon";import {  Menu,  MenuGroup,  MenuItem,  MenuPopup,  MenuSeparator,  MenuTrigger,} from "~/components/ui/menu";import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";import { useTheme } from "~/hooks/useTheme";import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";import { cn } from "~/lib/utils";import { isAbsolutePath } from "~/terminal-links"; 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;  readonly workspaceMutationId: string | null;} 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 (    <Tooltip>      <TooltipTrigger        render={          <span            className={cn(              "block max-w-40 truncate rounded-sm px-0.5",              props.current ? "font-medium text-foreground" : "text-muted-foreground",            )}          />        }      >        {props.label}      </TooltipTrigger>      <TooltipPopup side="top" className="max-w-80">        {props.pathLabel}      </TooltipPopup>    </Tooltip>  );} 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;  readonly workspaceMutationId: string | null;}) {  const entriesQuery = useProjectEntriesQuery(props.environmentId, props.cwd);  useWorkspaceMutationRefresh({    mutationId: props.workspaceMutationId,    refresh: entriesQuery.refresh,    resourceKey: `files:${props.environmentId}:${props.cwd}`,  });  const { resolvedTheme } = useTheme();  const entries = entriesQuery.data?.entries ?? [];  const entriesTruncated = entriesQuery.data?.truncated ?? false;  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 (    <MenuPopup      align="start"      side="bottom"      className="w-max min-w-32 max-w-[min(19rem,var(--available-width))]"      onKeyDown={(event) => {        if (event.key !== "ArrowLeft" || !canGoBack || parentPath === null) return;        event.preventDefault();        event.stopPropagation();        props.onDirectoryChange(parentPath);      }}    >      {canGoBack && parentPath !== null ? (        <>          <MenuItem closeOnClick={false} onClick={() => props.onDirectoryChange(parentPath)}>            <ArrowLeftIcon />            <span className="truncate">Back to {pathLabel(parentPath, props.projectName)}</span>          </MenuItem>          <MenuSeparator />        </>      ) : null}      <MenuGroup key={props.directoryPath}>        {entriesQuery.isPending && entriesQuery.data === null ? (          <MenuItem disabled>            <Spinner />            Loading folder…          </MenuItem>        ) : entriesQuery.error && entriesQuery.data === null ? (          <MenuItem closeOnClick={false} onClick={entriesQuery.refresh}>            <RefreshIcon refreshing={entriesQuery.isPending} />            <span className="min-w-0 flex-1 truncate">Retry loading folder</span>          </MenuItem>        ) : !directoryAvailable && !entriesTruncated ? (          <MenuItem disabled>This folder is no longer available.</MenuItem>        ) : children.length === 0 ? (          <MenuItem disabled>            {entriesTruncated              ? "No entries from this folder are available in the partial workspace index."              : "This folder is empty."}          </MenuItem>        ) : (          children.map((entry) => {            const isCurrentFile = entry.kind === "file" && entry.path === props.currentFilePath;            return (              <MenuItem                key={entry.path}                closeOnClick={entry.kind === "file"}                aria-current={isCurrentFile ? "page" : undefined}                className={cn(isCurrentFile && "bg-foreground/[0.08]")}                onClick={() => {                  if (entry.kind === "directory") {                    props.onDirectoryChange(entry.path);                    return;                  }                  props.onOpenChange(false);                  props.onOpenFile(entry.path);                }}              >                <PierreEntryIcon pathValue={entry.path} kind={entry.kind} theme={resolvedTheme} />                <Tooltip>                  <TooltipTrigger render={<span className="min-w-0 flex-1 truncate" />}>                    {entry.label}                  </TooltipTrigger>                  <TooltipPopup side="right" className="max-w-80">                    {entry.path}                  </TooltipPopup>                </Tooltip>                {entry.kind === "directory" ? <ChevronRightIcon /> : null}              </MenuItem>            );          })        )}      </MenuGroup>      {entriesQuery.error && entriesQuery.data !== null ? (        <>          <MenuSeparator />          <MenuItem closeOnClick={false} onClick={entriesQuery.refresh}>            <RefreshIcon refreshing={entriesQuery.isPending} />            Refresh failed — retry          </MenuItem>        </>      ) : null}      {entriesTruncated ? (        <>          <MenuSeparator />          <MenuItem disabled>Some workspace entries are not shown.</MenuItem>        </>      ) : null}    </MenuPopup>  );} 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 (    <Menu open={open} onOpenChange={handleOpenChange}>      <Tooltip>        <TooltipTrigger          render={            <MenuTrigger              render={                <button                  type="button"                  aria-label={`Browse ${props.crumb.label}`}                  className="relative block max-w-40 cursor-pointer rounded-sm px-0.5 text-left text-muted-foreground outline-none pointer-coarse:after:-inset-y-3 pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring data-popup-open:bg-accent data-popup-open:text-foreground"                />              }            />          }        >          <span className="block truncate">{props.crumb.label}</span>        </TooltipTrigger>        <TooltipPopup side="top" className="max-w-80">          {props.crumb.path || props.projectName}        </TooltipPopup>      </Tooltip>      {open ? (        <BreadcrumbMenuContent          cwd={props.cwd}          currentFilePath={props.relativePath}          directoryPath={directoryPath}          environmentId={props.environmentId}          onDirectoryChange={setDirectoryPath}          onOpenChange={handleOpenChange}          onOpenFile={props.onOpenFile}          projectName={props.projectName}          rootPath={props.crumb.path}          workspaceMutationId={props.workspaceMutationId}        />      ) : null}    </Menu>  );} export function FileBreadcrumbs(props: FileBreadcrumbsProps) {  const hostPath = isAbsolutePath(props.relativePath);  const breadcrumbs = useMemo(    () => fileBreadcrumbs(props.projectName, props.relativePath),    [props.projectName, props.relativePath],  );   return breadcrumbs.map((crumb, index) => (    <div      key={crumb.path || "project"}      className="flex min-w-0 shrink-0 items-center"      data-current-file-crumb={crumb.kind === "file"}    >      {index > 0 ? (        <ChevronRightIcon className="mx-1 size-3.5 shrink-0 text-muted-foreground/60" />      ) : null}      {crumb.kind === "file" ? (        <span aria-current="page">          <BreadcrumbLabel current label={crumb.label} pathLabel={crumb.path} />        </span>      ) : hostPath ? (        <BreadcrumbLabel label={crumb.label} pathLabel={crumb.path} />      ) : (        <DirectoryBreadcrumb {...props} crumb={crumb} />      )}    </div>  ));}