diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index ad134abf8904e22a3deaf03f97e62751d0814ecf..d9722303ab72e6dc27bdce3318df09c69cf71a88 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -115,6 +115,7 @@ export default function FileBrowserPanel({ ready, error, isPending, + loadingDirectories, } = useDirectoryEntries(environmentId, cwd); const [query, setQuery] = useState(""); const [expandAll, setExpandAll] = useState(false); @@ -239,6 +240,9 @@ export default function FileBrowserPanel({ showEntryContextMenuRef.current = showEntryContextMenu; }); + // The tree reads decorations at render time; a folder still loading its + // children shows a spinner in its row instead of a banner that shifts the tree. + const loadingDirectoriesRef = useRef(loadingDirectories); const treeModelRef = useRef["model"] | null>(null); const dragMention = useMemo( () => @@ -283,10 +287,22 @@ export default function FileBrowserPanel({ } }, paths: [], + renderRowDecoration: ({ item, row }) => + row.kind === "directory" && + row.isExpanded && + loadingDirectoriesRef.current.has(item.path.replace(/\/$/, "")) + ? { icon: "t3-tree-icon-loading", title: "Loading…" } + : null, search: false, onSearchChange: (value) => setQuery(value ?? ""), unsafeCSS: PIERRE_TREE_UNSAFE_CSS, }); + useEffect(() => { + if (loadingDirectoriesRef.current === loadingDirectories) return; + loadingDirectoriesRef.current = loadingDirectories; + // Re-render the rows with the current options so decorations update. + model.setComposition(model.getComposition()); + }, [loadingDirectories, model]); const search = useFileTreeSearch(model); const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) => areAllDirectoriesExpanded(currentModel, directoryPaths), @@ -542,7 +558,7 @@ export default function FileBrowserPanel({ More matches available. Refine your search. ) : null} - {(isPending || pathSearch.isPending) && ( + {(!ready || pathSearch.isPending) && (
Loading files…
diff --git a/apps/web/src/components/files/useDirectoryEntries.ts b/apps/web/src/components/files/useDirectoryEntries.ts index c7a1db0207cbf1d270cc36403d7564488346f29e..55aa163ee62ddb5cb8cb1701650d825d17d83350 100644 --- a/apps/web/src/components/files/useDirectoryEntries.ts +++ b/apps/web/src/components/files/useDirectoryEntries.ts @@ -11,6 +11,8 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { const [directories, setDirectories] = useState(new Map()); const [errors, setErrors] = useState(new Map()); const [pending, setPending] = useState(0); + // First loads only: a refresh keeps showing the cached children, so it gets no row spinner. + const [loadingDirectories, setLoadingDirectories] = useState>(new Set()); const requests = useRef(new Map>()); const loaded = useRef(new Set()); const requested = useRef(new Set()); @@ -28,6 +30,7 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { requested.current.add(directoryPath); const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } }); setPending((count) => count + 1); + if (!refresh) setLoadingDirectories((previous) => new Set(previous).add(directoryPath)); const request = (async () => { if (running.current >= 4) await new Promise((resolve) => waiting.current.push(resolve)); @@ -75,7 +78,14 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { }) .finally(() => { requests.current.delete(directoryPath); - if (active.current) setPending((count) => count - 1); + if (!active.current) return; + setPending((count) => count - 1); + if (!refresh) + setLoadingDirectories((previous) => { + const next = new Set(previous); + next.delete(directoryPath); + return next; + }); }); requests.current.set(directoryPath, request); return request; @@ -130,6 +140,7 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { load, refresh, isPending: pending > 0, + loadingDirectories, ready: directories.has(""), error: [...errors].find(([path]) => reachableDirectories.has(path))?.[1] ?? null, }; diff --git a/apps/web/src/pierre-icons.ts b/apps/web/src/pierre-icons.ts index ead46e89d6ee0566e7f2350ef027d951aed160e2..d789bd6dfcd449a9c2ec363b7f294467d3ed40ec 100644 --- a/apps/web/src/pierre-icons.ts +++ b/apps/web/src/pierre-icons.ts @@ -28,6 +28,10 @@ const T3_FILE_ICON_SPRITE = ` + + + + diff --git a/apps/web/src/pierre-tree-theme.ts b/apps/web/src/pierre-tree-theme.ts index 1c7bd7f2d53bc32475ef88d1d831da1c6cf3b35d..906b34ba1c510288f4efc346b51d63ea42a6c57c 100644 --- a/apps/web/src/pierre-tree-theme.ts +++ b/apps/web/src/pierre-tree-theme.ts @@ -11,6 +11,11 @@ export const PIERRE_TREE_UNSAFE_CSS = ` --trees-font-size-override: 12px; } button[data-type='item'] { border-radius: 5px; } + svg[data-icon-name='t3-tree-icon-loading'] { opacity: 0.6; } + @media (prefers-reduced-motion: no-preference) { + svg[data-icon-name='t3-tree-icon-loading'] { animation: t3-tree-spin 1s linear infinite; } + } + @keyframes t3-tree-spin { to { transform: rotate(360deg); } } `; /** Host styles that keep a Pierre tree on the active color scheme and foreground. */