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({...@@ -115,6 +115,7 @@ export default function FileBrowserPanel({
115 ready,115 ready,
116 error,116 error,
117 isPending,117 isPending,
118 loadingDirectories,
118 } = useDirectoryEntries(environmentId, cwd);119 } = useDirectoryEntries(environmentId, cwd);
119 const [query, setQuery] = useState("");120 const [query, setQuery] = useState("");
120 const [expandAll, setExpandAll] = useState(false);121 const [expandAll, setExpandAll] = useState(false);
...@@ -239,6 +240,9 @@ export default function FileBrowserPanel({...@@ -239,6 +240,9 @@ export default function FileBrowserPanel({
239 showEntryContextMenuRef.current = showEntryContextMenu;240 showEntryContextMenuRef.current = showEntryContextMenu;
240 });241 });
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);
242 const treeModelRef = useRef<ReturnType<typeof useFileTree>["model"] | null>(null);246 const treeModelRef = useRef<ReturnType<typeof useFileTree>["model"] | null>(null);
243 const dragMention = useMemo(247 const dragMention = useMemo(
244 () =>248 () =>
...@@ -283,10 +287,22 @@ export default function FileBrowserPanel({...@@ -283,10 +287,22 @@ export default function FileBrowserPanel({
283 }287 }
284 },288 },
285 paths: [],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 search: false,296 search: false,
287 onSearchChange: (value) => setQuery(value ?? ""),297 onSearchChange: (value) => setQuery(value ?? ""),
288 unsafeCSS: PIERRE_TREE_UNSAFE_CSS,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 const search = useFileTreeSearch(model);306 const search = useFileTreeSearch(model);
291 const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) =>307 const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) =>
292 areAllDirectoriesExpanded(currentModel, directoryPaths),308 areAllDirectoriesExpanded(currentModel, directoryPaths),
...@@ -542,7 +558,7 @@ export default function FileBrowserPanel({...@@ -542,7 +558,7 @@ export default function FileBrowserPanel({
542 More matches available. Refine your search.558 More matches available. Refine your search.
543 </div>559 </div>
544 ) : null}560 ) : null}
545 {(isPending || pathSearch.isPending) && (561 {(!ready || pathSearch.isPending) && (
546 <div role="status" className="px-3 py-1 text-xs text-muted-foreground">562 <div role="status" className="px-3 py-1 text-xs text-muted-foreground">
547 Loading files…563 Loading files…
548 </div>564 </div>
apps/web/src/components/files/useDirectoryEntries.ts+12-1
...@@ -11,6 +11,8 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {...@@ -11,6 +11,8 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {
11 const [directories, setDirectories] = useState(new Map<string, readonly ProjectEntry[]>());11 const [directories, setDirectories] = useState(new Map<string, readonly ProjectEntry[]>());
12 const [errors, setErrors] = useState(new Map<string, string>());12 const [errors, setErrors] = useState(new Map<string, string>());
13 const [pending, setPending] = useState(0);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 const requests = useRef(new Map<string, Promise<void>>());16 const requests = useRef(new Map<string, Promise<void>>());
15 const loaded = useRef(new Set<string>());17 const loaded = useRef(new Set<string>());
16 const requested = useRef(new Set<string>());18 const requested = useRef(new Set<string>());
...@@ -28,6 +30,7 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {...@@ -28,6 +30,7 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {
28 requested.current.add(directoryPath);30 requested.current.add(directoryPath);
29 const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } });31 const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } });
30 setPending((count) => count + 1);32 setPending((count) => count + 1);
33 if (!refresh) setLoadingDirectories((previous) => new Set(previous).add(directoryPath));
31 const request = (async () => {34 const request = (async () => {
32 if (running.current >= 4)35 if (running.current >= 4)
33 await new Promise<void>((resolve) => waiting.current.push(resolve));36 await new Promise<void>((resolve) => waiting.current.push(resolve));
...@@ -75,7 +78,14 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {...@@ -75,7 +78,14 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {
75 })78 })
76 .finally(() => {79 .finally(() => {
77 requests.current.delete(directoryPath);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 requests.current.set(directoryPath, request);90 requests.current.set(directoryPath, request);
81 return request;91 return request;
...@@ -130,6 +140,7 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {...@@ -130,6 +140,7 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {
130 load,140 load,
131 refresh,141 refresh,
132 isPending: pending > 0,142 isPending: pending > 0,
143 loadingDirectories,
133 ready: directories.has(""),144 ready: directories.has(""),
134 error: [...errors].find(([path]) => reachableDirectories.has(path))?.[1] ?? null,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,6 +28,10 @@ const T3_FILE_ICON_SPRITE = `
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" />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 <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" />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 </symbol>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 <!-- Lucide Diff icon, ISC license. -->35 <!-- Lucide Diff icon, ISC license. -->
32 <symbol id="t3-file-icon-diff" viewBox="0 0 24 24">36 <symbol id="t3-file-icon-diff" viewBox="0 0 24 24">
33 <path fill="none" style="stroke: light-dark(#199f43, #5ecc71)" stroke-width="2" stroke-linecap="round" d="M12 3v14M5 10h14M5 21h14" />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,6 +11,11 @@ export const PIERRE_TREE_UNSAFE_CSS = `
11 --trees-font-size-override: 12px;11 --trees-font-size-override: 12px;
12 }12 }
13 button[data-type='item'] { border-radius: 5px; }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`;
1520
16/** Host styles that keep a Pierre tree on the active color scheme and foreground. */21/** Host styles that keep a Pierre tree on the active color scheme and foreground. */