authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-08 22:04:47-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 22:04:47-07:00
log5785df19ea748be0755244582cb70d785f95f830
tree4bd32f6da50faf069ff774a3459f6c57dbc12e67
parentb000eac171318bf0130865387ae33523eecd3019
signature Signed by PGP key B5690EEEBB952194

fix(web): show a row spinner instead of a banner when expanding a folder (#17378)


4 files changed, 38 insertions(+), 2 deletions(-)

apps/web/src/components/files/FileBrowserPanel.tsx+17-1
......@@ -115,6 +115,7 @@ export default function FileBrowserPanel({
115115 ready,
116116 error,
117117 isPending,
118 loadingDirectories,
118119 } = useDirectoryEntries(environmentId, cwd);
119120 const [query, setQuery] = useState("");
120121 const [expandAll, setExpandAll] = useState(false);
......@@ -239,6 +240,9 @@ export default function FileBrowserPanel({
239240 showEntryContextMenuRef.current = showEntryContextMenu;
240241 });
241242
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);
242246 const treeModelRef = useRef<ReturnType<typeof useFileTree>["model"] | null>(null);
243247 const dragMention = useMemo(
244248 () =>
......@@ -283,10 +287,22 @@ export default function FileBrowserPanel({
283287 }
284288 },
285289 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,
286296 search: false,
287297 onSearchChange: (value) => setQuery(value ?? ""),
288298 unsafeCSS: PIERRE_TREE_UNSAFE_CSS,
289299 });
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]);
290306 const search = useFileTreeSearch(model);
291307 const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) =>
292308 areAllDirectoriesExpanded(currentModel, directoryPaths),
......@@ -542,7 +558,7 @@ export default function FileBrowserPanel({
542558 More matches available. Refine your search.
543559 </div>
544560 ) : null}
545 {(isPending || pathSearch.isPending) && (
561 {(!ready || pathSearch.isPending) && (
546562 <div role="status" className="px-3 py-1 text-xs text-muted-foreground">
547563 Loading files…
548564 </div>
apps/web/src/components/files/useDirectoryEntries.ts+12-1
......@@ -11,6 +11,8 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {
1111 const [directories, setDirectories] = useState(new Map<string, readonly ProjectEntry[]>());
1212 const [errors, setErrors] = useState(new Map<string, string>());
1313 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());
1416 const requests = useRef(new Map<string, Promise<void>>());
1517 const loaded = useRef(new Set<string>());
1618 const requested = useRef(new Set<string>());
......@@ -28,6 +30,7 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {
2830 requested.current.add(directoryPath);
2931 const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } });
3032 setPending((count) => count + 1);
33 if (!refresh) setLoadingDirectories((previous) => new Set(previous).add(directoryPath));
3134 const request = (async () => {
3235 if (running.current >= 4)
3336 await new Promise<void>((resolve) => waiting.current.push(resolve));
......@@ -75,7 +78,14 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {
7578 })
7679 .finally(() => {
7780 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 });
7989 });
8090 requests.current.set(directoryPath, request);
8191 return request;
......@@ -130,6 +140,7 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {
130140 load,
131141 refresh,
132142 isPending: pending > 0,
143 loadingDirectories,
133144 ready: directories.has(""),
134145 error: [...errors].find(([path]) => reachableDirectories.has(path))?.[1] ?? null,
135146 };
apps/web/src/pierre-icons.ts+4
......@@ -28,6 +28,10 @@ const T3_FILE_ICON_SPRITE = `
2828 <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" />
2929 <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" />
3030 </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>
3135 <!-- Lucide Diff icon, ISC license. -->
3236 <symbol id="t3-file-icon-diff" viewBox="0 0 24 24">
3337 <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 = `
1111 --trees-font-size-override: 12px;
1212 }
1313 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); } }
1419`;
1520
1621/** Host styles that keep a Pierre tree on the active color scheme and foreground. */