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
283 changes: 283 additions & 0 deletions apps/web/src/components/files/FileBreadcrumbs.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<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;
}) {
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 (
<MenuPopup
align="start"
side="bottom"
className="w-[min(19rem,var(--available-width))]"
onKeyDown={(event) => {
if (event.key !== "ArrowLeft" || !canGoBack || parentPath === null) return;
event.preventDefault();
event.stopPropagation();
props.onDirectoryChange(parentPath);
}}
>
<MenuGroup>
<MenuGroupLabel className="flex min-w-0 items-center gap-1 px-2 py-1.5">
<span className="truncate text-foreground">
{pathLabel(props.directoryPath, props.projectName)}
</span>
<span className="min-w-0 flex-1 truncate text-right font-normal opacity-70">
{props.directoryPath || props.projectName}
</span>
</MenuGroupLabel>
</MenuGroup>
{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>
<LoaderCircleIcon className="animate-spin" />
Loading folder…
</MenuItem>
) : entriesQuery.error && entriesQuery.data === null ? (
<MenuItem closeOnClick={false} onClick={entriesQuery.refresh}>
<RotateCwIcon />
<span className="min-w-0 flex-1 truncate">Retry loading folder</span>
</MenuItem>
) : !directoryAvailable ? (
<MenuItem disabled>This folder is no longer available.</MenuItem>
) : children.length === 0 ? (
<MenuItem disabled>This folder is empty.</MenuItem>
) : (
children.map((entry) => {
const isCurrentFile = entry.kind === "file" && entry.path === props.currentFilePath;
const containsCurrentFile =
entry.kind === "directory" && props.currentFilePath.startsWith(`${entry.path}/`);
return (
<MenuItem
key={entry.path}
closeOnClick={entry.kind === "file"}
aria-current={isCurrentFile ? "page" : undefined}
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>
{isCurrentFile ? (
<CheckIcon className="text-primary" aria-label="Current file" />
) : entry.kind === "directory" ? (
<ChevronRightIcon
className={cn(containsCurrentFile && "text-primary")}
aria-label={containsCurrentFile ? "Contains current file" : undefined}
/>
) : null}
</MenuItem>
);
})
)}
</MenuGroup>
{entriesQuery.error && entriesQuery.data !== null ? (
<>
<MenuSeparator />
<MenuItem closeOnClick={false} onClick={entriesQuery.refresh}>
<RotateCwIcon />
Refresh failed — retry
</MenuItem>
</>
) : null}
{entriesQuery.data?.truncated ? (
<>
<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="block max-w-40 cursor-pointer truncate rounded-sm px-0.5 text-left text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring data-popup-open:bg-accent data-popup-open:text-foreground"
/>
}
/>
}
>
{props.crumb.label}
</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}
/>
) : null}
</Menu>
);
}

export function FileBreadcrumbs(props: FileBreadcrumbsProps) {
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>
) : (
<DirectoryBreadcrumb {...props} crumb={crumb} />
)}
</div>
));
}
45 changes: 9 additions & 36 deletions apps/web/src/components/files/FilePreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -888,36 +884,13 @@ export default function FilePreviewPanel({
data-file-breadcrumbs
>
<div className="flex h-full w-max min-w-full items-center text-xs">
{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 ? (
<ChevronRight className="mx-1 size-3.5 shrink-0 text-muted-foreground/60" />
) : null}
<Tooltip>
<TooltipTrigger
render={
<span
className={cn(
"max-w-40 truncate",
crumb.kind === "file"
? "font-medium text-foreground"
: "text-muted-foreground",
)}
/>
}
>
{crumb.label}
</TooltipTrigger>
<TooltipPopup side="top" className="max-w-80">
{crumb.path || projectName}
</TooltipPopup>
</Tooltip>
</div>
))}
<FileBreadcrumbs
cwd={cwd}
environmentId={environmentId}
onOpenFile={onOpenFile}
projectName={projectName}
relativePath={relativePath}
/>
</div>
</ScrollArea>
{absolutePath &&
Expand Down
Loading