| 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,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 | }); |
| 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 | 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 | `; |
| 15 | 20 | ||
| 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. */ |