| author | |
| committer | |
| log | 47a95332a2b8fe47da9fb0af97fd6f0b542a18ee |
| tree | c14925537a46aad8b70df4ebd618c33d620a2e98 |
| parent | fea1af81fdab64a19353224d60b4efa60f929740 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com>
Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>5 files changed, 431 insertions(+), 40 deletions(-)
apps/web/src/components/files/FileBreadcrumbs.tsx created+276| ... | ... | @@ -0,0 +1,276 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { ArrowLeftIcon, ChevronRightIcon, LoaderCircleIcon, RotateCwIcon } from "lucide-react"; | |
| 3 | import { useEffect, useMemo, useState } from "react"; | |
| 4 | ||
| 5 | import { PierreEntryIcon } from "~/components/chat/PierreEntryIcon"; | |
| 6 | import { | |
| 7 | Menu, | |
| 8 | MenuGroup, | |
| 9 | MenuItem, | |
| 10 | MenuPopup, | |
| 11 | MenuSeparator, | |
| 12 | MenuTrigger, | |
| 13 | } from "~/components/ui/menu"; | |
| 14 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; | |
| 15 | import { useTheme } from "~/hooks/useTheme"; | |
| 16 | import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; | |
| 17 | import { cn } from "~/lib/utils"; | |
| 18 | import { isAbsolutePath } from "~/terminal-links"; | |
| 19 | ||
| 20 | import { | |
| 21 | type FileBreadcrumb, | |
| 22 | fileBreadcrumbChildren, | |
| 23 | fileBreadcrumbParent, | |
| 24 | fileBreadcrumbs, | |
| 25 | } from "./filePath"; | |
| 26 | import { useProjectEntriesQuery } from "./projectFilesQueryState"; | |
| 27 | ||
| 28 | interface FileBreadcrumbsProps { | |
| 29 | readonly cwd: string; | |
| 30 | readonly environmentId: EnvironmentId; | |
| 31 | readonly onOpenFile: (relativePath: string) => void; | |
| 32 | readonly projectName: string; | |
| 33 | readonly relativePath: string; | |
| 34 | readonly workspaceMutationId: string | null; | |
| 35 | } | |
| 36 | ||
| 37 | function pathLabel(path: string, projectName: string): string { | |
| 38 | return path.slice(path.lastIndexOf("/") + 1) || projectName; | |
| 39 | } | |
| 40 | ||
| 41 | function BreadcrumbLabel(props: { | |
| 42 | readonly current?: boolean; | |
| 43 | readonly label: string; | |
| 44 | readonly pathLabel: string; | |
| 45 | }) { | |
| 46 | return ( | |
| 47 | <Tooltip> | |
| 48 | <TooltipTrigger | |
| 49 | render={ | |
| 50 | <span | |
| 51 | className={cn( | |
| 52 | "block max-w-40 truncate rounded-sm px-0.5", | |
| 53 | props.current ? "font-medium text-foreground" : "text-muted-foreground", | |
| 54 | )} | |
| 55 | /> | |
| 56 | } | |
| 57 | > | |
| 58 | {props.label} | |
| 59 | </TooltipTrigger> | |
| 60 | <TooltipPopup side="top" className="max-w-80"> | |
| 61 | {props.pathLabel} | |
| 62 | </TooltipPopup> | |
| 63 | </Tooltip> | |
| 64 | ); | |
| 65 | } | |
| 66 | ||
| 67 | function BreadcrumbMenuContent(props: { | |
| 68 | readonly cwd: string; | |
| 69 | readonly currentFilePath: string; | |
| 70 | readonly directoryPath: string; | |
| 71 | readonly environmentId: EnvironmentId; | |
| 72 | readonly onDirectoryChange: (path: string) => void; | |
| 73 | readonly onOpenChange: (open: boolean) => void; | |
| 74 | readonly onOpenFile: (path: string) => void; | |
| 75 | readonly projectName: string; | |
| 76 | readonly rootPath: string; | |
| 77 | readonly workspaceMutationId: string | null; | |
| 78 | }) { | |
| 79 | const entriesQuery = useProjectEntriesQuery(props.environmentId, props.cwd); | |
| 80 | useWorkspaceMutationRefresh({ | |
| 81 | mutationId: props.workspaceMutationId, | |
| 82 | refresh: entriesQuery.refresh, | |
| 83 | resourceKey: `files:${props.environmentId}:${props.cwd}`, | |
| 84 | }); | |
| 85 | const { resolvedTheme } = useTheme(); | |
| 86 | const entries = entriesQuery.data?.entries ?? []; | |
| 87 | const entriesTruncated = entriesQuery.data?.truncated ?? false; | |
| 88 | const children = useMemo( | |
| 89 | () => fileBreadcrumbChildren(entries, props.directoryPath), | |
| 90 | [entries, props.directoryPath], | |
| 91 | ); | |
| 92 | const directoryAvailable = | |
| 93 | props.directoryPath === "" || | |
| 94 | entries.some((entry) => entry.kind === "directory" && entry.path === props.directoryPath); | |
| 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 | className="w-max min-w-32 max-w-[min(19rem,var(--available-width))]" | |
| 108 | onKeyDown={(event) => { | |
| 109 | if (event.key !== "ArrowLeft" || !canGoBack || parentPath === null) return; | |
| 110 | event.preventDefault(); | |
| 111 | event.stopPropagation(); | |
| 112 | props.onDirectoryChange(parentPath); | |
| 113 | }} | |
| 114 | > | |
| 115 | {canGoBack && parentPath !== null ? ( | |
| 116 | <> | |
| 117 | <MenuItem closeOnClick={false} onClick={() => props.onDirectoryChange(parentPath)}> | |
| 118 | <ArrowLeftIcon /> | |
| 119 | <span className="truncate">Back to {pathLabel(parentPath, props.projectName)}</span> | |
| 120 | </MenuItem> | |
| 121 | <MenuSeparator /> | |
| 122 | </> | |
| 123 | ) : null} | |
| 124 | <MenuGroup key={props.directoryPath}> | |
| 125 | {entriesQuery.isPending && entriesQuery.data === null ? ( | |
| 126 | <MenuItem disabled> | |
| 127 | <LoaderCircleIcon className="animate-spin" /> | |
| 128 | Loading folder… | |
| 129 | </MenuItem> | |
| 130 | ) : entriesQuery.error && entriesQuery.data === null ? ( | |
| 131 | <MenuItem closeOnClick={false} onClick={entriesQuery.refresh}> | |
| 132 | <RotateCwIcon /> | |
| 133 | <span className="min-w-0 flex-1 truncate">Retry loading folder</span> | |
| 134 | </MenuItem> | |
| 135 | ) : !directoryAvailable && !entriesTruncated ? ( | |
| 136 | <MenuItem disabled>This folder is no longer available.</MenuItem> | |
| 137 | ) : children.length === 0 ? ( | |
| 138 | <MenuItem disabled> | |
| 139 | {entriesTruncated | |
| 140 | ? "No entries from this folder are available in the partial workspace index." | |
| 141 | : "This folder is empty."} | |
| 142 | </MenuItem> | |
| 143 | ) : ( | |
| 144 | children.map((entry) => { | |
| 145 | const isCurrentFile = entry.kind === "file" && entry.path === props.currentFilePath; | |
| 146 | return ( | |
| 147 | <MenuItem | |
| 148 | key={entry.path} | |
| 149 | closeOnClick={entry.kind === "file"} | |
| 150 | aria-current={isCurrentFile ? "page" : undefined} | |
| 151 | className={cn(isCurrentFile && "bg-foreground/[0.08]")} | |
| 152 | onClick={() => { | |
| 153 | if (entry.kind === "directory") { | |
| 154 | props.onDirectoryChange(entry.path); | |
| 155 | return; | |
| 156 | } | |
| 157 | props.onOpenChange(false); | |
| 158 | props.onOpenFile(entry.path); | |
| 159 | }} | |
| 160 | > | |
| 161 | <PierreEntryIcon pathValue={entry.path} kind={entry.kind} theme={resolvedTheme} /> | |
| 162 | <Tooltip> | |
| 163 | <TooltipTrigger render={<span className="min-w-0 flex-1 truncate" />}> | |
| 164 | {entry.label} | |
| 165 | </TooltipTrigger> | |
| 166 | <TooltipPopup side="right" className="max-w-80"> | |
| 167 | {entry.path} | |
| 168 | </TooltipPopup> | |
| 169 | </Tooltip> | |
| 170 | {entry.kind === "directory" ? <ChevronRightIcon /> : null} | |
| 171 | </MenuItem> | |
| 172 | ); | |
| 173 | }) | |
| 174 | )} | |
| 175 | </MenuGroup> | |
| 176 | {entriesQuery.error && entriesQuery.data !== null ? ( | |
| 177 | <> | |
| 178 | <MenuSeparator /> | |
| 179 | <MenuItem closeOnClick={false} onClick={entriesQuery.refresh}> | |
| 180 | <RotateCwIcon /> | |
| 181 | Refresh failed — retry | |
| 182 | </MenuItem> | |
| 183 | </> | |
| 184 | ) : null} | |
| 185 | {entriesTruncated ? ( | |
| 186 | <> | |
| 187 | <MenuSeparator /> | |
| 188 | <MenuItem disabled>Some workspace entries are not shown.</MenuItem> | |
| 189 | </> | |
| 190 | ) : null} | |
| 191 | </MenuPopup> | |
| 192 | ); | |
| 193 | } | |
| 194 | ||
| 195 | function DirectoryBreadcrumb(props: FileBreadcrumbsProps & { readonly crumb: FileBreadcrumb }) { | |
| 196 | const [open, setOpen] = useState(false); | |
| 197 | const [directoryPath, setDirectoryPath] = useState(props.crumb.path); | |
| 198 | ||
| 199 | useEffect(() => { | |
| 200 | setOpen(false); | |
| 201 | setDirectoryPath(props.crumb.path); | |
| 202 | }, [props.crumb.path, props.relativePath]); | |
| 203 | ||
| 204 | const handleOpenChange = (nextOpen: boolean) => { | |
| 205 | setOpen(nextOpen); | |
| 206 | if (nextOpen) setDirectoryPath(props.crumb.path); | |
| 207 | }; | |
| 208 | ||
| 209 | return ( | |
| 210 | <Menu open={open} onOpenChange={handleOpenChange}> | |
| 211 | <Tooltip> | |
| 212 | <TooltipTrigger | |
| 213 | render={ | |
| 214 | <MenuTrigger | |
| 215 | render={ | |
| 216 | <button | |
| 217 | type="button" | |
| 218 | aria-label={`Browse ${props.crumb.label}`} | |
| 219 | 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" | |
| 220 | /> | |
| 221 | } | |
| 222 | /> | |
| 223 | } | |
| 224 | > | |
| 225 | <span className="block truncate">{props.crumb.label}</span> | |
| 226 | </TooltipTrigger> | |
| 227 | <TooltipPopup side="top" className="max-w-80"> | |
| 228 | {props.crumb.path || props.projectName} | |
| 229 | </TooltipPopup> | |
| 230 | </Tooltip> | |
| 231 | {open ? ( | |
| 232 | <BreadcrumbMenuContent | |
| 233 | cwd={props.cwd} | |
| 234 | currentFilePath={props.relativePath} | |
| 235 | directoryPath={directoryPath} | |
| 236 | environmentId={props.environmentId} | |
| 237 | onDirectoryChange={setDirectoryPath} | |
| 238 | onOpenChange={handleOpenChange} | |
| 239 | onOpenFile={props.onOpenFile} | |
| 240 | projectName={props.projectName} | |
| 241 | rootPath={props.crumb.path} | |
| 242 | workspaceMutationId={props.workspaceMutationId} | |
| 243 | /> | |
| 244 | ) : null} | |
| 245 | </Menu> | |
| 246 | ); | |
| 247 | } | |
| 248 | ||
| 249 | export function FileBreadcrumbs(props: FileBreadcrumbsProps) { | |
| 250 | const hostPath = isAbsolutePath(props.relativePath); | |
| 251 | const breadcrumbs = useMemo( | |
| 252 | () => fileBreadcrumbs(props.projectName, props.relativePath), | |
| 253 | [props.projectName, props.relativePath], | |
| 254 | ); | |
| 255 | ||
| 256 | return breadcrumbs.map((crumb, index) => ( | |
| 257 | <div | |
| 258 | key={crumb.path || "project"} | |
| 259 | className="flex min-w-0 shrink-0 items-center" | |
| 260 | data-current-file-crumb={crumb.kind === "file"} | |
| 261 | > | |
| 262 | {index > 0 ? ( | |
| 263 | <ChevronRightIcon className="mx-1 size-3.5 shrink-0 text-muted-foreground/60" /> | |
| 264 | ) : null} | |
| 265 | {crumb.kind === "file" ? ( | |
| 266 | <span aria-current="page"> | |
| 267 | <BreadcrumbLabel current label={crumb.label} pathLabel={crumb.path} /> | |
| 268 | </span> | |
| 269 | ) : hostPath ? ( | |
| 270 | <BreadcrumbLabel label={crumb.label} pathLabel={crumb.path} /> | |
| 271 | ) : ( | |
| 272 | <DirectoryBreadcrumb {...props} crumb={crumb} /> | |
| 273 | )} | |
| 274 | </div> | |
| 275 | )); | |
| 276 | } |
apps/web/src/components/files/FilePreviewPanel.tsx+10-36| ... | ... | @@ -16,7 +16,7 @@ import { |
| 16 | 16 | squashAtomCommandFailure, |
| 17 | 17 | } from "@t3tools/client-runtime/state/runtime"; |
| 18 | 18 | import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; |
| 19 | import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react"; | |
| 19 | import { Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react"; | |
| 20 | 20 | import * as Schema from "effect/Schema"; |
| 21 | 21 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 22 | 22 | |
| ... | ... | @@ -49,6 +49,7 @@ import { useAtomCommand } from "~/state/use-atom-command"; |
| 49 | 49 | import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; |
| 50 | 50 | |
| 51 | 51 | import FileBrowserPanel from "./FileBrowserPanel"; |
| 52 | import { FileBreadcrumbs } from "./FileBreadcrumbs"; | |
| 52 | 53 | import { FileMarkdownPreview } from "./FileMarkdownPreview"; |
| 53 | 54 | import { |
| 54 | 55 | type FileCommentAnnotationEntry, |
| ... | ... | @@ -63,7 +64,6 @@ import { installFileEditorDismissal } from "./fileEditorDismissal"; |
| 63 | 64 | import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; |
| 64 | 65 | import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; |
| 65 | 66 | import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; |
| 66 | import { fileBreadcrumbs } from "./filePath"; | |
| 67 | 67 | import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode"; |
| 68 | 68 | import { FileSaveCoordinator } from "./fileSaveCoordinator"; |
| 69 | 69 | import { |
| ... | ... | @@ -991,10 +991,6 @@ export default function FilePreviewPanel({ |
| 991 | 991 | isPreviewSupportedInRuntime() && |
| 992 | 992 | isBrowserPreviewFile(relativePath); |
| 993 | 993 | const absolutePath = relativePath ? resolvePathLinkTarget(relativePath, cwd) : null; |
| 994 | const breadcrumbs = useMemo( | |
| 995 | () => (relativePath ? fileBreadcrumbs(projectName, relativePath) : []), | |
| 996 | [projectName, relativePath], | |
| 997 | ); | |
| 998 | 994 | const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId); |
| 999 | 995 | useWorkspaceMutationRefresh({ |
| 1000 | 996 | enabled: relativePath !== null && !isMedia && !isPdf && !selectedFilePending, |
| ... | ... | @@ -1062,36 +1058,14 @@ export default function FilePreviewPanel({ |
| 1062 | 1058 | data-file-breadcrumbs |
| 1063 | 1059 | > |
| 1064 | 1060 | <div className="flex h-full w-max min-w-full items-center text-xs"> |
| 1065 | {breadcrumbs.map((crumb, index) => ( | |
| 1066 | <div | |
| 1067 | key={crumb.path || "project"} | |
| 1068 | className="flex min-w-0 shrink-0 items-center" | |
| 1069 | data-current-file-crumb={crumb.kind === "file"} | |
| 1070 | > | |
| 1071 | {index > 0 ? ( | |
| 1072 | <ChevronRight className="mx-1 size-3.5 shrink-0 text-muted-foreground/60" /> | |
| 1073 | ) : null} | |
| 1074 | <Tooltip> | |
| 1075 | <TooltipTrigger | |
| 1076 | render={ | |
| 1077 | <span | |
| 1078 | className={cn( | |
| 1079 | "max-w-40 truncate", | |
| 1080 | crumb.kind === "file" | |
| 1081 | ? "font-medium text-foreground" | |
| 1082 | : "text-muted-foreground", | |
| 1083 | )} | |
| 1084 | /> | |
| 1085 | } | |
| 1086 | > | |
| 1087 | {crumb.label} | |
| 1088 | </TooltipTrigger> | |
| 1089 | <TooltipPopup side="top" className="max-w-80"> | |
| 1090 | {crumb.path || projectName} | |
| 1091 | </TooltipPopup> | |
| 1092 | </Tooltip> | |
| 1093 | </div> | |
| 1094 | ))} | |
| 1061 | <FileBreadcrumbs | |
| 1062 | cwd={cwd} | |
| 1063 | environmentId={environmentId} | |
| 1064 | onOpenFile={onOpenFile} | |
| 1065 | projectName={projectName} | |
| 1066 | relativePath={relativePath} | |
| 1067 | workspaceMutationId={workspaceMutationId} | |
| 1068 | /> | |
| 1095 | 1069 | </div> |
| 1096 | 1070 | </ScrollArea> |
| 1097 | 1071 | {absolutePath && |
apps/web/src/components/files/filePath.test.ts+50-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | |
| 3 | import { fileBreadcrumbs } from "./filePath"; | |
| 3 | import { fileBreadcrumbChildren, fileBreadcrumbParent, fileBreadcrumbs } from "./filePath"; | |
| 4 | 4 | |
| 5 | 5 | describe("fileBreadcrumbs", () => { |
| 6 | 6 | it("builds project, directory, and file crumbs", () => { |
| ... | ... | @@ -39,3 +39,52 @@ describe("fileBreadcrumbs", () => { |
| 39 | 39 | ]); |
| 40 | 40 | }); |
| 41 | 41 | }); |
| 42 | ||
| 43 | describe("fileBreadcrumbChildren", () => { | |
| 44 | const entries = [ | |
| 45 | { path: "README.md", kind: "file" as const }, | |
| 46 | { path: "src", kind: "directory" as const }, | |
| 47 | { path: "src-old", kind: "directory" as const }, | |
| 48 | { path: "src/index.ts", kind: "file" as const }, | |
| 49 | { path: "src/lib", kind: "directory" as const }, | |
| 50 | { path: "src/lib/file10.ts", kind: "file" as const }, | |
| 51 | { path: "src/lib/file2.ts", kind: "file" as const }, | |
| 52 | { path: "src-old/index.ts", kind: "file" as const }, | |
| 53 | ]; | |
| 54 | ||
| 55 | it("returns only the immediate children of the project root", () => { | |
| 56 | expect(fileBreadcrumbChildren(entries, "")).toEqual([ | |
| 57 | { path: "src", kind: "directory", label: "src" }, | |
| 58 | { path: "src-old", kind: "directory", label: "src-old" }, | |
| 59 | { path: "README.md", kind: "file", label: "README.md" }, | |
| 60 | ]); | |
| 61 | }); | |
| 62 | ||
| 63 | it("honors segment boundaries and sorts folders before files", () => { | |
| 64 | expect(fileBreadcrumbChildren(entries, "src")).toEqual([ | |
| 65 | { path: "src/lib", kind: "directory", label: "lib" }, | |
| 66 | { path: "src/index.ts", kind: "file", label: "index.ts" }, | |
| 67 | ]); | |
| 68 | }); | |
| 69 | ||
| 70 | it("uses natural file-name ordering", () => { | |
| 71 | expect(fileBreadcrumbChildren(entries, "src/lib").map((entry) => entry.label)).toEqual([ | |
| 72 | "file2.ts", | |
| 73 | "file10.ts", | |
| 74 | ]); | |
| 75 | }); | |
| 76 | ||
| 77 | it("returns an empty list for an empty or missing directory", () => { | |
| 78 | expect(fileBreadcrumbChildren(entries, "missing")).toEqual([]); | |
| 79 | }); | |
| 80 | }); | |
| 81 | ||
| 82 | describe("fileBreadcrumbParent", () => { | |
| 83 | it.each([ | |
| 84 | ["src/lib", "src"], | |
| 85 | ["src", ""], | |
| 86 | ["", null], | |
| 87 | ])("returns the parent of %j", (path, expected) => { | |
| 88 | expect(fileBreadcrumbParent(path)).toBe(expected); | |
| 89 | }); | |
| 90 | }); |
apps/web/src/components/files/filePath.ts+32| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import type { ProjectEntry } from "@t3tools/contracts"; | |
| 1 | 2 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; |
| 2 | 3 | |
| 3 | 4 | import { isAbsolutePath } from "~/terminal-links"; |
| ... | ... | @@ -8,6 +9,10 @@ export interface FileBreadcrumb { |
| 8 | 9 | kind: "project" | "directory" | "file"; |
| 9 | 10 | } |
| 10 | 11 | |
| 12 | export interface FileBreadcrumbChild extends ProjectEntry { | |
| 13 | label: string; | |
| 14 | } | |
| 15 | ||
| 11 | 16 | /** |
| 12 | 17 | * Crumbs for a workspace-relative path start at the project. An absolute host |
| 13 | 18 | * path is outside the workspace, so its crumbs start at the filesystem root. |
| ... | ... | @@ -26,3 +31,30 @@ export function fileBreadcrumbs(projectName: string, relativePath: string): File |
| 26 | 31 | })), |
| 27 | 32 | ]; |
| 28 | 33 | } |
| 34 | ||
| 35 | export function fileBreadcrumbChildren( | |
| 36 | entries: readonly ProjectEntry[], | |
| 37 | directoryPath: string, | |
| 38 | ): FileBreadcrumbChild[] { | |
| 39 | const prefix = directoryPath ? `${directoryPath}/` : ""; | |
| 40 | return entries | |
| 41 | .flatMap((entry) => { | |
| 42 | if (!entry.path.startsWith(prefix)) return []; | |
| 43 | const label = entry.path.slice(prefix.length); | |
| 44 | if (!label || label.includes("/")) return []; | |
| 45 | return [{ ...entry, label }]; | |
| 46 | }) | |
| 47 | .toSorted((left, right) => { | |
| 48 | if (left.kind !== right.kind) return left.kind === "directory" ? -1 : 1; | |
| 49 | return left.label.localeCompare(right.label, undefined, { | |
| 50 | numeric: true, | |
| 51 | sensitivity: "base", | |
| 52 | }); | |
| 53 | }); | |
| 54 | } | |
| 55 | ||
| 56 | export function fileBreadcrumbParent(directoryPath: string): string | null { | |
| 57 | if (!directoryPath) return null; | |
| 58 | const separatorIndex = directoryPath.lastIndexOf("/"); | |
| 59 | return separatorIndex === -1 ? "" : directoryPath.slice(0, separatorIndex); | |
| 60 | } |
apps/web/src/components/files/projectFilesQueryState.test.tsx+63-3| ... | ... | @@ -1,4 +1,8 @@ |
| 1 | import { EnvironmentId, type ProjectReadFileResult } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | EnvironmentId, | |
| 3 | type ProjectListEntriesResult, | |
| 4 | type ProjectReadFileResult, | |
| 5 | } from "@t3tools/contracts"; | |
| 2 | 6 | import * as Effect from "effect/Effect"; |
| 3 | 7 | import { Atom, AtomRegistry } from "effect/unstable/reactivity"; |
| 4 | 8 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| ... | ... | @@ -71,7 +75,7 @@ vi.mock("~/state/queries", () => ({ |
| 71 | 75 | })); |
| 72 | 76 | |
| 73 | 77 | import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; |
| 74 | import { useProjectFileQuery } from "./projectFilesQueryState"; | |
| 78 | import { useProjectEntriesQuery, useProjectFileQuery } from "./projectFilesQueryState"; | |
| 75 | 79 | |
| 76 | 80 | const environmentId = EnvironmentId.make("environment-1"); |
| 77 | 81 | |
| ... | ... | @@ -92,13 +96,20 @@ function file(contents: string): ProjectReadFileResult { |
| 92 | 96 | }; |
| 93 | 97 | } |
| 94 | 98 | |
| 99 | function projectEntries(paths: readonly string[]): ProjectListEntriesResult { | |
| 100 | return { | |
| 101 | entries: paths.map((path) => ({ path, kind: "file" })), | |
| 102 | truncated: false, | |
| 103 | }; | |
| 104 | } | |
| 105 | ||
| 95 | 106 | async function flushEffects(): Promise<void> { |
| 96 | 107 | await Promise.resolve(); |
| 97 | 108 | await Promise.resolve(); |
| 98 | 109 | await Promise.resolve(); |
| 99 | 110 | } |
| 100 | 111 | |
| 101 | describe("project file query refresh", () => { | |
| 112 | describe("project query refresh", () => { | |
| 102 | 113 | beforeEach(() => { |
| 103 | 114 | projectMocks.listEntries.mockReset(); |
| 104 | 115 | projectMocks.optimisticFile.mockReset(); |
| ... | ... | @@ -158,6 +169,55 @@ describe("project file query refresh", () => { |
| 158 | 169 | } |
| 159 | 170 | }); |
| 160 | 171 | |
| 172 | it("revalidates cached entries when a workspace mutation is observed after mounting", async () => { | |
| 173 | const requests: Array<ReturnType<typeof deferred<ProjectListEntriesResult>>> = []; | |
| 174 | const entriesAtom = Atom.make( | |
| 175 | Effect.promise(() => { | |
| 176 | const request = deferred<ProjectListEntriesResult>(); | |
| 177 | requests.push(request); | |
| 178 | return request.promise; | |
| 179 | }), | |
| 180 | ).pipe(Atom.swr({ staleTime: 30_000, revalidateOnMount: true })); | |
| 181 | const registry = AtomRegistry.make(); | |
| 182 | const unmount = registry.mount(entriesAtom); | |
| 183 | projectMocks.listEntries.mockReturnValue(entriesAtom); | |
| 184 | atomHooks.registry = registry; | |
| 185 | let renderedPaths: readonly string[] = []; | |
| 186 | ||
| 187 | const render = (mutationId: string | null) => { | |
| 188 | reactHooks.beginRender(); | |
| 189 | const query = useProjectEntriesQuery(environmentId, "/repo"); | |
| 190 | renderedPaths = query.data?.entries.map((entry) => entry.path) ?? []; | |
| 191 | useWorkspaceMutationRefresh({ | |
| 192 | mutationId, | |
| 193 | refresh: query.refresh, | |
| 194 | resourceKey: "files:environment-1:/repo", | |
| 195 | }); | |
| 196 | }; | |
| 197 | ||
| 198 | try { | |
| 199 | await flushEffects(); | |
| 200 | expect(requests).toHaveLength(1); | |
| 201 | requests[0]!.resolve(projectEntries(["src/old.ts"])); | |
| 202 | await flushEffects(); | |
| 203 | ||
| 204 | render("mutation-1"); | |
| 205 | expect(renderedPaths).toEqual(["src/old.ts"]); | |
| 206 | await flushEffects(); | |
| 207 | expect(requests).toHaveLength(2); | |
| 208 | ||
| 209 | requests[1]!.resolve(projectEntries(["src/new.ts"])); | |
| 210 | await flushEffects(); | |
| 211 | render("mutation-1"); | |
| 212 | expect(renderedPaths).toEqual(["src/new.ts"]); | |
| 213 | expect(requests).toHaveLength(2); | |
| 214 | } finally { | |
| 215 | unmount(); | |
| 216 | registry.dispose(); | |
| 217 | atomHooks.registry = null; | |
| 218 | } | |
| 219 | }); | |
| 220 | ||
| 161 | 221 | it("does not issue a file read for a disabled image preview", async () => { |
| 162 | 222 | const requests: Array<ReturnType<typeof deferred<ProjectReadFileResult>>> = []; |
| 163 | 223 | const readAtom = Atom.make( |