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 { useEffect, useMemo, 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 { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";
21import { cn } from "~/lib/utils";
22import { isAbsolutePath } from "@t3tools/shared/path";
23
24import {
25 type FileBreadcrumb,
26 fileBreadcrumbChildren,
27 fileBreadcrumbParent,
28 fileBreadcrumbs,
29} from "./filePath";
30import { useProjectEntriesQuery } from "./projectFilesQueryState";
31
32interface FileBreadcrumbsProps {
33 readonly cwd: string;
34 readonly environmentId: EnvironmentId;
35 readonly onOpenFile: (relativePath: string) => void;
36 readonly projectName: string;
37 readonly relativePath: string;
38 readonly workspaceMutationId: string | null;
39}
40
41function pathLabel(path: string, projectName: string): string {
42 return path.slice(path.lastIndexOf("/") + 1) || projectName;
43}
44
45function BreadcrumbLabel(props: {
46 readonly current?: boolean;
47 readonly label: string;
48 readonly pathLabel: string;
49}) {
50 return (
51 <Tooltip>
52 <TooltipTrigger
53 render={
54 <span
55 className={cn(
56 "block max-w-40 truncate rounded-sm px-0.5",
57 props.current ? "font-medium text-foreground" : "text-muted-foreground",
58 )}
59 />
60 }
61 >
62 {props.label}
63 </TooltipTrigger>
64 <TooltipPopup side="top">{props.pathLabel}</TooltipPopup>
65 </Tooltip>
66 );
67}
68
69function BreadcrumbMenuContent(props: {
70 readonly cwd: string;
71 readonly currentFilePath: string;
72 readonly directoryPath: string;
73 readonly environmentId: EnvironmentId;
74 readonly onDirectoryChange: (path: string) => void;
75 readonly onOpenChange: (open: boolean) => void;
76 readonly onOpenFile: (path: string) => void;
77 readonly projectName: string;
78 readonly rootPath: string;
79 readonly workspaceMutationId: string | null;
80}) {
81 const entriesQuery = useProjectEntriesQuery(props.environmentId, props.cwd, props.directoryPath);
82 useWorkspaceMutationRefresh({
83 mutationId: props.workspaceMutationId,
84 refresh: entriesQuery.refresh,
85 resourceKey: `files:${props.environmentId}:${props.cwd}`,
86 });
87 const { resolvedTheme } = useTheme();
88 const entries = entriesQuery.data?.entries ?? [];
89 const entriesTruncated = entriesQuery.data?.truncated ?? false;
90 const children = useMemo(
91 () => fileBreadcrumbChildren(entries, props.directoryPath),
92 [entries, props.directoryPath],
93 );
94 const directoryAvailable = entriesQuery.data !== null;
95 const parentPath = fileBreadcrumbParent(props.directoryPath);
96 const canGoBack =
97 props.directoryPath !== props.rootPath &&
98 parentPath !== null &&
99 (props.rootPath === "" ||
100 parentPath === props.rootPath ||
101 parentPath.startsWith(`${props.rootPath}/`));
102
103 return (
104 <MenuPopup
105 align="start"
106 side="bottom"
107 onKeyDown={(event) => {
108 if (event.key !== "ArrowLeft" || !canGoBack || parentPath === null) return;
109 event.preventDefault();
110 event.stopPropagation();
111 props.onDirectoryChange(parentPath);
112 }}
113 >
114 {canGoBack && parentPath !== null ? (
115 <>
116 <MenuItem closeOnClick={false} onClick={() => props.onDirectoryChange(parentPath)}>
117 <ArrowLeftIcon />
118 <span className="truncate">Back to {pathLabel(parentPath, props.projectName)}</span>
119 </MenuItem>
120 <MenuSeparator />
121 </>
122 ) : null}
123 <MenuGroup key={props.directoryPath}>
124 {entriesQuery.isPending && entriesQuery.data === null ? (
125 <MenuItem disabled>
126 <Spinner />
127 Loading folder…
128 </MenuItem>
129 ) : entriesQuery.error && entriesQuery.data === null ? (
130 <MenuItem closeOnClick={false} onClick={entriesQuery.refresh}>
131 <RefreshIcon refreshing={entriesQuery.isPending} />
132 <span className="min-w-0 flex-1 truncate">Retry loading folder</span>
133 </MenuItem>
134 ) : !directoryAvailable && !entriesTruncated ? (
135 <MenuItem disabled>This folder is no longer available.</MenuItem>
136 ) : children.length === 0 ? (
137 <MenuItem disabled>
138 {entriesTruncated
139 ? "No entries from this folder are available in the partial workspace index."
140 : "This folder is empty."}
141 </MenuItem>
142 ) : (
143 // Files form a radio group keyed by path so the open file is marked as checked;
144 // directories only navigate the menu, so they stay plain items.
145 <MenuRadioGroup
146 value={props.currentFilePath}
147 onValueChange={(path) => {
148 props.onOpenChange(false);
149 props.onOpenFile(path);
150 }}
151 >
152 {children.map((entry) => {
153 const isCurrentFile = entry.kind === "file" && entry.path === props.currentFilePath;
154 const row = (
155 <>
156 <PierreEntryIcon pathValue={entry.path} kind={entry.kind} theme={resolvedTheme} />
157 <Tooltip>
158 <TooltipTrigger
159 render={
160 <span
161 className={cn(
162 "min-w-0 flex-1 truncate",
163 entry.ignored && "text-muted-foreground",
164 )}
165 />
166 }
167 >
168 {entry.label}
169 </TooltipTrigger>
170 <TooltipPopup side="right">{entry.path}</TooltipPopup>
171 </Tooltip>
172 </>
173 );
174 return entry.kind === "directory" ? (
175 <MenuItem
176 key={entry.path}
177 closeOnClick={false}
178 onClick={() => props.onDirectoryChange(entry.path)}
179 >
180 {row}
181 <ChevronRightIcon />
182 </MenuItem>
183 ) : (
184 <MenuRadioItem
185 key={entry.path}
186 value={entry.path}
187 closeOnClick
188 aria-current={isCurrentFile ? "page" : undefined}
189 >
190 <span className="flex min-w-0 items-center gap-2">{row}</span>
191 </MenuRadioItem>
192 );
193 })}
194 </MenuRadioGroup>
195 )}
196 </MenuGroup>
197 {entriesQuery.error && entriesQuery.data !== null ? (
198 <>
199 <MenuSeparator />
200 <MenuItem closeOnClick={false} onClick={entriesQuery.refresh}>
201 <RefreshIcon refreshing={entriesQuery.isPending} />
202 Refresh failed — retry
203 </MenuItem>
204 </>
205 ) : null}
206 {entriesTruncated ? (
207 <>
208 <MenuSeparator />
209 <MenuItem disabled>Some workspace entries are not shown.</MenuItem>
210 </>
211 ) : null}
212 </MenuPopup>
213 );
214}
215
216function DirectoryBreadcrumb(props: FileBreadcrumbsProps & { readonly crumb: FileBreadcrumb }) {
217 const [open, setOpen] = useState(false);
218 const [directoryPath, setDirectoryPath] = useState(props.crumb.path);
219
220 useEffect(() => {
221 setOpen(false);
222 setDirectoryPath(props.crumb.path);
223 }, [props.crumb.path, props.relativePath]);
224
225 const handleOpenChange = (nextOpen: boolean) => {
226 setOpen(nextOpen);
227 if (nextOpen) setDirectoryPath(props.crumb.path);
228 };
229
230 return (
231 <Menu open={open} onOpenChange={handleOpenChange}>
232 <Tooltip>
233 <TooltipTrigger
234 render={
235 <MenuTrigger
236 render={
237 <button
238 type="button"
239 aria-label={`Browse ${props.crumb.label}`}
240 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-inset focus-visible:ring-ring data-popup-open:bg-accent data-popup-open:text-foreground"
241 />
242 }
243 />
244 }
245 >
246 <span className="block truncate">{props.crumb.label}</span>
247 </TooltipTrigger>
248 <TooltipPopup side="top">{props.crumb.path || props.projectName}</TooltipPopup>
249 </Tooltip>
250 {open ? (
251 <BreadcrumbMenuContent
252 cwd={props.cwd}
253 currentFilePath={props.relativePath}
254 directoryPath={directoryPath}
255 environmentId={props.environmentId}
256 onDirectoryChange={setDirectoryPath}
257 onOpenChange={handleOpenChange}
258 onOpenFile={props.onOpenFile}
259 projectName={props.projectName}
260 rootPath={props.crumb.path}
261 workspaceMutationId={props.workspaceMutationId}
262 />
263 ) : null}
264 </Menu>
265 );
266}
267
268export function FileBreadcrumbs(props: FileBreadcrumbsProps) {
269 const hostPath = isAbsolutePath(props.relativePath);
270 const breadcrumbs = useMemo(
271 () => fileBreadcrumbs(props.projectName, props.relativePath),
272 [props.projectName, props.relativePath],
273 );
274
275 return breadcrumbs.map((crumb, index) => (
276 <div
277 key={crumb.path || "project"}
278 className="flex min-w-0 shrink-0 items-center"
279 data-current-file-crumb={crumb.kind === "file"}
280 >
281 {index > 0 ? (
282 <ChevronRightIcon className="mx-1 size-3.5 shrink-0 text-muted-foreground/60" />
283 ) : null}
284 {crumb.kind === "file" ? (
285 <span aria-current="page">
286 <BreadcrumbLabel current label={crumb.label} pathLabel={crumb.path} />
287 </span>
288 ) : hostPath ? (
289 <BreadcrumbLabel label={crumb.label} pathLabel={crumb.path} />
290 ) : (
291 <DirectoryBreadcrumb {...props} crumb={crumb} />
292 )}
293 </div>
294 ));
295}