| 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,7 +16,7 @@ import { |
| 16 | squashAtomCommandFailure, | 16 | squashAtomCommandFailure, |
| 17 | } from "@t3tools/client-runtime/state/runtime"; | 17 | } from "@t3tools/client-runtime/state/runtime"; |
| 18 | import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; | 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 | import * as Schema from "effect/Schema"; | 20 | import * as Schema from "effect/Schema"; |
| 21 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 21 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 22 | 22 | ||
| ... | @@ -49,6 +49,7 @@ import { useAtomCommand } from "~/state/use-atom-command"; | ... | @@ -49,6 +49,7 @@ import { useAtomCommand } from "~/state/use-atom-command"; |
| 49 | import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; | 49 | import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; |
| 50 | 50 | ||
| 51 | import FileBrowserPanel from "./FileBrowserPanel"; | 51 | import FileBrowserPanel from "./FileBrowserPanel"; |
| 52 | import { FileBreadcrumbs } from "./FileBreadcrumbs"; | ||
| 52 | import { FileMarkdownPreview } from "./FileMarkdownPreview"; | 53 | import { FileMarkdownPreview } from "./FileMarkdownPreview"; |
| 53 | import { | 54 | import { |
| 54 | type FileCommentAnnotationEntry, | 55 | type FileCommentAnnotationEntry, |
| ... | @@ -63,7 +64,6 @@ import { installFileEditorDismissal } from "./fileEditorDismissal"; | ... | @@ -63,7 +64,6 @@ import { installFileEditorDismissal } from "./fileEditorDismissal"; |
| 63 | import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; | 64 | import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; |
| 64 | import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; | 65 | import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; |
| 65 | import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; | 66 | import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; |
| 66 | import { fileBreadcrumbs } from "./filePath"; | ||
| 67 | import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode"; | 67 | import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode"; |
| 68 | import { FileSaveCoordinator } from "./fileSaveCoordinator"; | 68 | import { FileSaveCoordinator } from "./fileSaveCoordinator"; |
| 69 | import { | 69 | import { |
| ... | @@ -991,10 +991,6 @@ export default function FilePreviewPanel({ | ... | @@ -991,10 +991,6 @@ export default function FilePreviewPanel({ |
| 991 | isPreviewSupportedInRuntime() && | 991 | isPreviewSupportedInRuntime() && |
| 992 | isBrowserPreviewFile(relativePath); | 992 | isBrowserPreviewFile(relativePath); |
| 993 | const absolutePath = relativePath ? resolvePathLinkTarget(relativePath, cwd) : null; | 993 | const absolutePath = relativePath ? resolvePathLinkTarget(relativePath, cwd) : null; |
| 994 | const breadcrumbs = useMemo( | ||
| 995 | () => (relativePath ? fileBreadcrumbs(projectName, relativePath) : []), | ||
| 996 | [projectName, relativePath], | ||
| 997 | ); | ||
| 998 | const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId); | 994 | const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId); |
| 999 | useWorkspaceMutationRefresh({ | 995 | useWorkspaceMutationRefresh({ |
| 1000 | enabled: relativePath !== null && !isMedia && !isPdf && !selectedFilePending, | 996 | enabled: relativePath !== null && !isMedia && !isPdf && !selectedFilePending, |
| ... | @@ -1062,36 +1058,14 @@ export default function FilePreviewPanel({ | ... | @@ -1062,36 +1058,14 @@ export default function FilePreviewPanel({ |
| 1062 | data-file-breadcrumbs | 1058 | data-file-breadcrumbs |
| 1063 | > | 1059 | > |
| 1064 | <div className="flex h-full w-max min-w-full items-center text-xs"> | 1060 | <div className="flex h-full w-max min-w-full items-center text-xs"> |
| 1065 | {breadcrumbs.map((crumb, index) => ( | 1061 | <FileBreadcrumbs |
| 1066 | <div | 1062 | cwd={cwd} |
| 1067 | key={crumb.path || "project"} | 1063 | environmentId={environmentId} |
| 1068 | className="flex min-w-0 shrink-0 items-center" | 1064 | onOpenFile={onOpenFile} |
| 1069 | data-current-file-crumb={crumb.kind === "file"} | 1065 | projectName={projectName} |
| 1070 | > | 1066 | relativePath={relativePath} |
| 1071 | {index > 0 ? ( | 1067 | workspaceMutationId={workspaceMutationId} |
| 1072 | <ChevronRight className="mx-1 size-3.5 shrink-0 text-muted-foreground/60" /> | 1068 | /> |
| 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 | ))} | ||
| 1095 | </div> | 1069 | </div> |
| 1096 | </ScrollArea> | 1070 | </ScrollArea> |
| 1097 | {absolutePath && | 1071 | {absolutePath && |
apps/web/src/components/files/filePath.test.ts+50-1| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 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 | describe("fileBreadcrumbs", () => { | 5 | describe("fileBreadcrumbs", () => { |
| 6 | it("builds project, directory, and file crumbs", () => { | 6 | it("builds project, directory, and file crumbs", () => { |
| ... | @@ -39,3 +39,52 @@ describe("fileBreadcrumbs", () => { | ... | @@ -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,3 +1,4 @@ |
| 1 | import type { ProjectEntry } from "@t3tools/contracts"; | ||
| 1 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; | 2 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; |
| 2 | 3 | ||
| 3 | import { isAbsolutePath } from "~/terminal-links"; | 4 | import { isAbsolutePath } from "~/terminal-links"; |
| ... | @@ -8,6 +9,10 @@ export interface FileBreadcrumb { | ... | @@ -8,6 +9,10 @@ export interface FileBreadcrumb { |
| 8 | kind: "project" | "directory" | "file"; | 9 | kind: "project" | "directory" | "file"; |
| 9 | } | 10 | } |
| 10 | 11 | ||
| 12 | export interface FileBreadcrumbChild extends ProjectEntry { | ||
| 13 | label: string; | ||
| 14 | } | ||
| 15 | |||
| 11 | /** | 16 | /** |
| 12 | * Crumbs for a workspace-relative path start at the project. An absolute host | 17 | * Crumbs for a workspace-relative path start at the project. An absolute host |
| 13 | * path is outside the workspace, so its crumbs start at the filesystem root. | 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,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,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 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 3 | import { Atom, AtomRegistry } from "effect/unstable/reactivity"; | 7 | import { Atom, AtomRegistry } from "effect/unstable/reactivity"; |
| 4 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 8 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| ... | @@ -71,7 +75,7 @@ vi.mock("~/state/queries", () => ({ | ... | @@ -71,7 +75,7 @@ vi.mock("~/state/queries", () => ({ |
| 71 | })); | 75 | })); |
| 72 | 76 | ||
| 73 | import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; | 77 | import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; |
| 74 | import { useProjectFileQuery } from "./projectFilesQueryState"; | 78 | import { useProjectEntriesQuery, useProjectFileQuery } from "./projectFilesQueryState"; |
| 75 | 79 | ||
| 76 | const environmentId = EnvironmentId.make("environment-1"); | 80 | const environmentId = EnvironmentId.make("environment-1"); |
| 77 | 81 | ||
| ... | @@ -92,13 +96,20 @@ function file(contents: string): ProjectReadFileResult { | ... | @@ -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 | async function flushEffects(): Promise<void> { | 106 | async function flushEffects(): Promise<void> { |
| 96 | await Promise.resolve(); | 107 | await Promise.resolve(); |
| 97 | await Promise.resolve(); | 108 | await Promise.resolve(); |
| 98 | await Promise.resolve(); | 109 | await Promise.resolve(); |
| 99 | } | 110 | } |
| 100 | 111 | ||
| 101 | describe("project file query refresh", () => { | 112 | describe("project query refresh", () => { |
| 102 | beforeEach(() => { | 113 | beforeEach(() => { |
| 103 | projectMocks.listEntries.mockReset(); | 114 | projectMocks.listEntries.mockReset(); |
| 104 | projectMocks.optimisticFile.mockReset(); | 115 | projectMocks.optimisticFile.mockReset(); |
| ... | @@ -158,6 +169,55 @@ describe("project file query refresh", () => { | ... | @@ -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 | it("does not issue a file read for a disabled image preview", async () => { | 221 | it("does not issue a file read for a disabled image preview", async () => { |
| 162 | const requests: Array<ReturnType<typeof deferred<ProjectReadFileResult>>> = []; | 222 | const requests: Array<ReturnType<typeof deferred<ProjectReadFileResult>>> = []; |
| 163 | const readAtom = Atom.make( | 223 | const readAtom = Atom.make( |