| author | |
| committer | |
| log | 5785df19ea748be0755244582cb70d785f95f830 |
| tree | 4bd32f6da50faf069ff774a3459f6c57dbc12e67 |
| parent | b000eac171318bf0130865387ae33523eecd3019 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 38 insertions(+), 2 deletions(-)
apps/web/src/components/files/FileBrowserPanel.tsx+17-1| ... | ... | @@ -115,6 +115,7 @@ export default function FileBrowserPanel({ |
| 115 | 115 | ready, |
| 116 | 116 | error, |
| 117 | 117 | isPending, |
| 118 | loadingDirectories, | |
| 118 | 119 | } = useDirectoryEntries(environmentId, cwd); |
| 119 | 120 | const [query, setQuery] = useState(""); |
| 120 | 121 | const [expandAll, setExpandAll] = useState(false); |
| ... | ... | @@ -239,6 +240,9 @@ export default function FileBrowserPanel({ |
| 239 | 240 | showEntryContextMenuRef.current = showEntryContextMenu; |
| 240 | 241 | }); |
| 241 | 242 | |
| 243 | // The tree reads decorations at render time; a folder still loading its | |
| 244 | // children shows a spinner in its row instead of a banner that shifts the tree. | |
| 245 | const loadingDirectoriesRef = useRef(loadingDirectories); | |
| 242 | 246 | const treeModelRef = useRef<ReturnType<typeof useFileTree>["model"] | null>(null); |
| 243 | 247 | const dragMention = useMemo( |
| 244 | 248 | () => |
| ... | ... | @@ -283,10 +287,22 @@ export default function FileBrowserPanel({ |
| 283 | 287 | } |
| 284 | 288 | }, |
| 285 | 289 | paths: [], |
| 290 | renderRowDecoration: ({ item, row }) => | |
| 291 | row.kind === "directory" && | |
| 292 | row.isExpanded && | |
| 293 | loadingDirectoriesRef.current.has(item.path.replace(/\/$/, "")) | |
| 294 | ? { icon: "t3-tree-icon-loading", title: "Loading…" } | |
| 295 | : null, | |
| 286 | 296 | search: false, |
| 287 | 297 | onSearchChange: (value) => setQuery(value ?? ""), |
| 288 | 298 | unsafeCSS: PIERRE_TREE_UNSAFE_CSS, |
| 289 | 299 | }); |
| 300 | useEffect(() => { | |
| 301 | if (loadingDirectoriesRef.current === loadingDirectories) return; | |
| 302 | loadingDirectoriesRef.current = loadingDirectories; | |
| 303 | // Re-render the rows with the current options so decorations update. | |
| 304 | model.setComposition(model.getComposition()); | |
| 305 | }, [loadingDirectories, model]); | |
| 290 | 306 | const search = useFileTreeSearch(model); |
| 291 | 307 | const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) => |
| 292 | 308 | areAllDirectoriesExpanded(currentModel, directoryPaths), |
| ... | ... | @@ -542,7 +558,7 @@ export default function FileBrowserPanel({ |
| 542 | 558 | More matches available. Refine your search. |
| 543 | 559 | </div> |
| 544 | 560 | ) : null} |
| 545 | {(isPending || pathSearch.isPending) && ( | |
| 561 | {(!ready || pathSearch.isPending) && ( | |
| 546 | 562 | <div role="status" className="px-3 py-1 text-xs text-muted-foreground"> |
| 547 | 563 | Loading files… |
| 548 | 564 | </div> |
apps/web/src/components/files/useDirectoryEntries.ts+12-1| ... | ... | @@ -11,6 +11,8 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { |
| 11 | 11 | const [directories, setDirectories] = useState(new Map<string, readonly ProjectEntry[]>()); |
| 12 | 12 | const [errors, setErrors] = useState(new Map<string, string>()); |
| 13 | 13 | const [pending, setPending] = useState(0); |
| 14 | // First loads only: a refresh keeps showing the cached children, so it gets no row spinner. | |
| 15 | const [loadingDirectories, setLoadingDirectories] = useState<ReadonlySet<string>>(new Set()); | |
| 14 | 16 | const requests = useRef(new Map<string, Promise<void>>()); |
| 15 | 17 | const loaded = useRef(new Set<string>()); |
| 16 | 18 | const requested = useRef(new Set<string>()); |
| ... | ... | @@ -28,6 +30,7 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { |
| 28 | 30 | requested.current.add(directoryPath); |
| 29 | 31 | const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } }); |
| 30 | 32 | setPending((count) => count + 1); |
| 33 | if (!refresh) setLoadingDirectories((previous) => new Set(previous).add(directoryPath)); | |
| 31 | 34 | const request = (async () => { |
| 32 | 35 | if (running.current >= 4) |
| 33 | 36 | await new Promise<void>((resolve) => waiting.current.push(resolve)); |
| ... | ... | @@ -75,7 +78,14 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { |
| 75 | 78 | }) |
| 76 | 79 | .finally(() => { |
| 77 | 80 | requests.current.delete(directoryPath); |
| 78 | if (active.current) setPending((count) => count - 1); | |
| 81 | if (!active.current) return; | |
| 82 | setPending((count) => count - 1); | |
| 83 | if (!refresh) | |
| 84 | setLoadingDirectories((previous) => { | |
| 85 | const next = new Set(previous); | |
| 86 | next.delete(directoryPath); | |
| 87 | return next; | |
| 88 | }); | |
| 79 | 89 | }); |
| 80 | 90 | requests.current.set(directoryPath, request); |
| 81 | 91 | return request; |
| ... | ... | @@ -130,6 +140,7 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { |
| 130 | 140 | load, |
| 131 | 141 | refresh, |
| 132 | 142 | isPending: pending > 0, |
| 143 | loadingDirectories, | |
| 133 | 144 | ready: directories.has(""), |
| 134 | 145 | error: [...errors].find(([path]) => reachableDirectories.has(path))?.[1] ?? null, |
| 135 | 146 | }; |
apps/web/src/pierre-icons.ts+4| ... | ... | @@ -28,6 +28,10 @@ const T3_FILE_ICON_SPRITE = ` |
| 28 | 28 | <path fill="#f9ad00" d="M30 10.75h-8.749V2H30Zm-9.626 0h-8.75V2h8.75Zm-9.625 0H2V2h8.749ZM30 20.375h-8.749v-8.75H30Z" /> |
| 29 | 29 | <path fill="currentColor" d="M20.374 20.375h-8.75v-8.75h8.75Zm0 9.625h-8.75v-8.75h8.75ZM30 30h-8.749v-8.75H30Zm-19.251 0H2v-8.75h8.749Z" /> |
| 30 | 30 | </symbol> |
| 31 | <!-- Lucide Loader Circle icon, ISC license. --> | |
| 32 | <symbol id="t3-tree-icon-loading" viewBox="0 0 24 24"> | |
| 33 | <path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M21 12a9 9 0 1 1-6.219-8.56" /> | |
| 34 | </symbol> | |
| 31 | 35 | <!-- Lucide Diff icon, ISC license. --> |
| 32 | 36 | <symbol id="t3-file-icon-diff" viewBox="0 0 24 24"> |
| 33 | 37 | <path fill="none" style="stroke: light-dark(#199f43, #5ecc71)" stroke-width="2" stroke-linecap="round" d="M12 3v14M5 10h14M5 21h14" /> |
apps/web/src/pierre-tree-theme.ts+5| ... | ... | @@ -11,6 +11,11 @@ export const PIERRE_TREE_UNSAFE_CSS = ` |
| 11 | 11 | --trees-font-size-override: 12px; |
| 12 | 12 | } |
| 13 | 13 | button[data-type='item'] { border-radius: 5px; } |
| 14 | svg[data-icon-name='t3-tree-icon-loading'] { opacity: 0.6; } | |
| 15 | @media (prefers-reduced-motion: no-preference) { | |
| 16 | svg[data-icon-name='t3-tree-icon-loading'] { animation: t3-tree-spin 1s linear infinite; } | |
| 17 | } | |
| 18 | @keyframes t3-tree-spin { to { transform: rotate(360deg); } } | |
| 14 | 19 | `; |
| 15 | 20 | |
| 16 | 21 | /** Host styles that keep a Pierre tree on the active color scheme and foreground. */ |