Skip to content
Source
Sign inSign up

FileBreadcrumbs.tsx

345 lines12.0 KB

1import { RefreshIcon } from "~/components/ui/refresh-icon";
2import { Spinner } from "~/components/ui/spinner";
3import type { EnvironmentId } from "@t3tools/contracts";
4import { ArrowLeftIcon, ChevronRightIcon } from "lucide-react";
5import { useState } from "react";
6​
7import { PierreEntryIcon } from "~/components/chat/PierreEntryIcon";
8import {
9 Menu,
10 MenuGroup,
11 MenuItem,
12 MenuPopup,
13 MenuRadioGroup,
14 MenuRadioItem,
15 MenuSeparator,
16 MenuTrigger,
17} from "~/components/ui/menu";
18import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
19import { useTheme } from "~/hooks/useTheme";
20import { cn } from "~/lib/utils";
21import { isHostPath } from "@t3tools/shared/path";
22​
23import {
24 breadcrumbPathOf,
25 type FileBreadcrumb,
26 fileBreadcrumbChildren,
27 fileBreadcrumbParent,
28 fileBreadcrumbs,
29 isListableHostFolder,
30} from "./filePath";
31import { useProjectEntriesQuery } from "./projectFilesQueryState";
32​
33interface FileBreadcrumbsProps {
34 readonly cwd: string;
35 readonly environmentId: EnvironmentId;
36 /** Navigates to a folder; an empty path is the workspace root. */
37 readonly onNavigateFolder: (relativePath: string) => void;
38 readonly onOpenFile: (relativePath: string) => void;
39 readonly projectName: string;
40 /** Current path; an empty path is the workspace root. */
41 readonly relativePath: string;
42 /** Deepest visible path, including descendants retained after navigating up. */
43 readonly trail: string;
44 readonly workspaceMutationId: string | null;
46​
47function pathLabel(path: string, projectName: string): string {
48 return path.slice(path.lastIndexOf("/") + 1) || projectName;
50​
51function BreadcrumbLabel(props: {
52 readonly current?: boolean;
53 readonly label: string;
54 readonly pathLabel: string;
55}) {
56 return (
57 <Tooltip>
58 <TooltipTrigger
59 render={
60 <span
61 className={cn(
62 "block max-w-40 truncate rounded-sm px-0.5",
63 props.current ? "font-medium text-foreground" : "text-muted-foreground",
64 )}
65 />
66 }
67 >
68 {props.label}
69 </TooltipTrigger>
70 <TooltipPopup side="top">{props.pathLabel}</TooltipPopup>
71 </Tooltip>
72 );
74​
75function BreadcrumbMenuContent(props: {
76 readonly cwd: string;
77 readonly currentFilePath: string;
78 readonly directoryPath: string;
79 readonly environmentId: EnvironmentId;
80 readonly onDirectoryChange: (path: string) => void;
81 readonly onOpenChange: (open: boolean) => void;
82 readonly onOpenFile: (path: string) => void;
83 readonly projectName: string;
84 readonly rootPath: string;
85 readonly workspaceMutationId: string | null;
86}) {
87 const entriesQuery = useProjectEntriesQuery(
88 props.environmentId,
89 props.cwd,
90 props.directoryPath,
91 props.workspaceMutationId,
92 );
93 const { resolvedTheme } = useTheme();
94 const entries = entriesQuery.data?.entries ?? [];
95 const entriesTruncated = entriesQuery.data?.truncated ?? false;
96 const children = fileBreadcrumbChildren(entries, props.directoryPath);
97 const directoryAvailable = entriesQuery.data !== null;
98 const parentPath = fileBreadcrumbParent(props.directoryPath);
99 const canGoBack =
100 props.directoryPath !== props.rootPath &&
101 parentPath !== null &&
102 (props.rootPath === "" ||
103 parentPath === props.rootPath ||
104 parentPath.startsWith(`${props.rootPath}/`));
105​
106 return (
107 <MenuPopup
108 align="start"
109 side="bottom"
110 onKeyDown={(event) => {
111 if (event.key !== "ArrowLeft" || !canGoBack || parentPath === null) return;
112 event.preventDefault();
113 event.stopPropagation();
114 props.onDirectoryChange(parentPath);
115 }}
116 >
117 {canGoBack && parentPath !== null ? (
118 <>
119 <MenuItem closeOnClick={false} onClick={() => props.onDirectoryChange(parentPath)}>
120 <ArrowLeftIcon />
121 <span className="truncate">Back to {pathLabel(parentPath, props.projectName)}</span>
122 </MenuItem>
123 <MenuSeparator />
124 </>
125 ) : null}
126 <MenuGroup key={props.directoryPath}>
127 {entriesQuery.isPending && entriesQuery.data === null ? (
128 <MenuItem disabled>
129 <Spinner />
130 Loading folder…
131 </MenuItem>
132 ) : entriesQuery.error && entriesQuery.data === null ? (
133 <MenuItem closeOnClick={false} onClick={entriesQuery.refresh}>
134 <RefreshIcon refreshing={entriesQuery.isPending} />
135 <span className="min-w-0 flex-1 truncate">Retry loading folder</span>
136 </MenuItem>
137 ) : !directoryAvailable && !entriesTruncated ? (
138 <MenuItem disabled>This folder is no longer available.</MenuItem>
139 ) : children.length === 0 ? (
140 <MenuItem disabled>
141 {entriesTruncated
142 ? "No entries from this folder are available in the partial workspace index."
143 : "This folder is empty."}
144 </MenuItem>
145 ) : (
146 <MenuRadioGroup
147 value={props.currentFilePath}
148 onValueChange={(path) => {
149 props.onOpenChange(false);
150 props.onOpenFile(path);
151 }}
152 >
153 {children.map((entry) => {
154 const isCurrentFile = entry.kind === "file" && entry.path === props.currentFilePath;
155 const row = (
156 <>
157 <PierreEntryIcon pathValue={entry.path} kind={entry.kind} theme={resolvedTheme} />
158 <Tooltip>
159 <TooltipTrigger
160 render={
161 <span
162 className={cn(
163 "min-w-0 flex-1 truncate",
164 entry.ignored && "text-muted-foreground",
165 )}
166 />
167 }
168 >
169 {entry.label}
170 </TooltipTrigger>
171 <TooltipPopup side="right">{entry.path}</TooltipPopup>
172 </Tooltip>
173 </>
174 );
175 return entry.kind === "directory" ? (
176 <MenuItem
177 key={entry.path}
178 closeOnClick={false}
179 onClick={() => props.onDirectoryChange(entry.path)}
180 >
181 {row}
182 <ChevronRightIcon />
183 </MenuItem>
184 ) : (
185 <MenuRadioItem
186 key={entry.path}
187 value={entry.path}
188 closeOnClick
189 aria-current={isCurrentFile ? "page" : undefined}
190 >
191 <span className="flex min-w-0 items-center gap-2">{row}</span>
192 </MenuRadioItem>
193 );
194 })}
195 </MenuRadioGroup>
196 )}
197 </MenuGroup>
198 {entriesQuery.error && entriesQuery.data !== null ? (
199 <>
200 <MenuSeparator />
201 <MenuItem closeOnClick={false} onClick={entriesQuery.refresh}>
202 <RefreshIcon refreshing={entriesQuery.isPending} />
203 Refresh failed. Retry
204 </MenuItem>
205 </>
206 ) : null}
207 {entriesTruncated ? (
208 <>
209 <MenuSeparator />
210 <MenuItem disabled>Some workspace entries are not shown.</MenuItem>
211 </>
212 ) : null}
213 </MenuPopup>
214 );
216​
217function FolderContentsMenu(props: FileBreadcrumbsProps & { readonly crumb: FileBreadcrumb }) {
218 const [open, setOpen] = useState(false);
219 const [directoryPath, setDirectoryPath] = useState(props.crumb.path);
220 const [shownFor, setShownFor] = useState({
221 crumbPath: props.crumb.path,
222 relativePath: props.relativePath,
223 });
224 if (shownFor.crumbPath !== props.crumb.path || shownFor.relativePath !== props.relativePath) {
225 setShownFor({ crumbPath: props.crumb.path, relativePath: props.relativePath });
226 setOpen(false);
227 setDirectoryPath(props.crumb.path);
228 }
229​
230 const handleOpenChange = (nextOpen: boolean) => {
231 setOpen(nextOpen);
232 if (nextOpen) setDirectoryPath(props.crumb.path);
233 };
234​
235 return (
236 <Menu open={open} onOpenChange={handleOpenChange}>
237 <MenuTrigger
238 render={
239 <button
240 type="button"
241 aria-label={`Browse ${props.crumb.label}`}
242 className="relative mx-0.5 flex h-5 w-4 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground/60 outline-none pointer-coarse:after:absolute pointer-coarse:after:-inset-3 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring data-popup-open:bg-accent data-popup-open:text-foreground"
243 />
244 }
245 >
246 <ChevronRightIcon className="size-3.5" />
247 </MenuTrigger>
248 {open ? (
249 <BreadcrumbMenuContent
250 cwd={props.cwd}
251 currentFilePath={props.relativePath}
252 directoryPath={directoryPath}
253 environmentId={props.environmentId}
254 onDirectoryChange={setDirectoryPath}
255 onOpenChange={handleOpenChange}
256 onOpenFile={props.onOpenFile}
257 projectName={props.projectName}
258 rootPath={props.crumb.path}
259 workspaceMutationId={props.workspaceMutationId}
260 />
261 ) : null}
262 </Menu>
263 );
265​
266function FolderBreadcrumb(props: {
267 readonly crumb: FileBreadcrumb;
268 /** Whether this crumb is below the current folder. */
269 readonly descendant: boolean;
270 readonly onNavigate: (path: string) => void;
271 readonly projectName: string;
272}) {
273 return (
274 <Tooltip>
275 <TooltipTrigger
276 render={
277 <button
278 type="button"
279 onClick={() => props.onNavigate(props.crumb.path)}
280 className={cn(
281 "relative block max-w-40 cursor-pointer rounded-sm px-0.5 text-left outline-none pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:-inset-y-3 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
282 props.descendant ? "text-muted-foreground/70" : "text-muted-foreground",
283 )}
284 />
285 }
286 >
287 <span className="block truncate">{props.crumb.label}</span>
288 </TooltipTrigger>
289 <TooltipPopup side="top">{props.crumb.path || props.projectName}</TooltipPopup>
290 </Tooltip>
291 );
293​
294/** Host paths outside the workspace omit contents menus; the browser already lists their destination. */
295export function FileBreadcrumbs(props: FileBreadcrumbsProps) {
296 const hostPath = isHostPath(props.relativePath);
297 const breadcrumbs = fileBreadcrumbs(props.projectName, props.trail);
298 const currentPath = breadcrumbPathOf(props.relativePath);
299 const currentIndex = breadcrumbs.findIndex((crumb) => crumb.path === currentPath);
300​
301 return (
302 <nav aria-label="File path" className="flex h-full">
303 <ol className="flex items-center">
304 {breadcrumbs.map((crumb, index) => {
305 const current = index === currentIndex;
306 return (
307 <li
308 key={crumb.path || "project"}
309 className="flex min-w-0 shrink-0 items-center"
310 data-file-crumb
311 data-current-file-crumb={current}
312 >
313 {current ? (
314 <span aria-current="page">
315 <BreadcrumbLabel
316 current
317 label={crumb.label}
318 pathLabel={crumb.path || props.projectName}
319 />
320 </span>
321 ) : hostPath && !isListableHostFolder(crumb.path) ? (
322 <BreadcrumbLabel label={crumb.label} pathLabel={crumb.path} />
323 ) : (
324 <FolderBreadcrumb
325 crumb={crumb}
326 descendant={index > currentIndex}
327 onNavigate={props.onNavigateFolder}
328 projectName={props.projectName}
329 />
330 )}
331 {index === breadcrumbs.length - 1 ? null : hostPath ? (
332 <ChevronRightIcon
333 aria-hidden
334 className="mx-1 size-3.5 shrink-0 text-muted-foreground/60"
335 />
336 ) : (
337 <FolderContentsMenu {...props} crumb={crumb} />
338 )}
339 </li>
340 );
341 })}
342 </ol>
343 </nav>
344 );