| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 2 | import { Spinner } from "~/components/ui/spinner"; |
| 3 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 4 | import { ArrowLeftIcon, ChevronRightIcon } from "lucide-react"; |
| 5 | import { useEffect, useMemo, useState } from "react"; |
| 6 | |
| 7 | import { PierreEntryIcon } from "~/components/chat/PierreEntryIcon"; |
| 8 | import { |
| 9 | Menu, |
| 10 | MenuGroup, |
| 11 | MenuItem, |
| 12 | MenuPopup, |
| 13 | MenuRadioGroup, |
| 14 | MenuRadioItem, |
| 15 | MenuSeparator, |
| 16 | MenuTrigger, |
| 17 | } from "~/components/ui/menu"; |
| 18 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| 19 | import { useTheme } from "~/hooks/useTheme"; |
| 20 | import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; |
| 21 | import { cn } from "~/lib/utils"; |
| 22 | import { isAbsolutePath } from "@t3tools/shared/path"; |
| 23 | |
| 24 | import { |
| 25 | type FileBreadcrumb, |
| 26 | fileBreadcrumbChildren, |
| 27 | fileBreadcrumbParent, |
| 28 | fileBreadcrumbs, |
| 29 | } from "./filePath"; |
| 30 | import { useProjectEntriesQuery } from "./projectFilesQueryState"; |
| 31 | |
| 32 | interface 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 | |
| 41 | function pathLabel(path: string, projectName: string): string { |
| 42 | return path.slice(path.lastIndexOf("/") + 1) || projectName; |
| 43 | } |
| 44 | |
| 45 | function 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 | |
| 69 | function 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 | |
| 216 | function 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 | |
| 268 | export 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 | } |