| 1 | import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; |
| 2 | import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; |
| 3 | import * as Cause from "effect/Cause"; |
| 4 | import { AsyncResult } from "effect/reactivity"; |
| 5 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 6 | |
| 7 | import { appAtomRegistry } from "~/rpc/atomRegistry"; |
| 8 | import { projectEnvironment } from "~/state/projects"; |
| 9 | |
| 10 | /** Loads only requested directories; collapsing a folder keeps its children cached. */ |
| 11 | export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { |
| 12 | const [directories, setDirectories] = useState(new Map<string, readonly ProjectEntry[]>()); |
| 13 | const [errors, setErrors] = useState(new Map<string, string>()); |
| 14 | const [pending, setPending] = useState(0); |
| 15 | // First loads only: a refresh keeps showing the cached children, so it gets no row spinner. |
| 16 | const [loadingDirectories, setLoadingDirectories] = useState<ReadonlySet<string>>(new Set()); |
| 17 | const requests = useRef(new Map<string, Promise<void>>()); |
| 18 | const loaded = useRef(new Set<string>()); |
| 19 | const requested = useRef(new Set<string>()); |
| 20 | const active = useRef(true); |
| 21 | const watches = useRef(new Map<string, () => void>()); |
| 22 | const running = useRef(0); |
| 23 | const waiting = useRef<Array<() => void>>([]); |
| 24 | |
| 25 | const load = useCallback( |
| 26 | function loadDirectory(directoryPath: string, refresh = false): Promise<void> { |
| 27 | if (!active.current) return Promise.resolve(); |
| 28 | const existing = requests.current.get(directoryPath); |
| 29 | if (existing) |
| 30 | return refresh ? existing.then(() => loadDirectory(directoryPath, true)) : existing; |
| 31 | if (!refresh && loaded.current.has(directoryPath)) return Promise.resolve(); |
| 32 | loaded.current.add(directoryPath); |
| 33 | requested.current.add(directoryPath); |
| 34 | if (!watches.current.has(directoryPath)) { |
| 35 | const watch = projectEnvironment.watch({ |
| 36 | environmentId, |
| 37 | input: { cwd, relativePath: directoryPath || ".", directory: true }, |
| 38 | }); |
| 39 | watches.current.set( |
| 40 | directoryPath, |
| 41 | appAtomRegistry.subscribe( |
| 42 | watch, |
| 43 | (result) => { |
| 44 | if (AsyncResult.isSuccess(result)) { |
| 45 | queueMicrotask(() => void loadDirectory(directoryPath, true)); |
| 46 | } |
| 47 | }, |
| 48 | { immediate: true }, |
| 49 | ), |
| 50 | ); |
| 51 | } |
| 52 | const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } }); |
| 53 | setPending((count) => count + 1); |
| 54 | if (!refresh) setLoadingDirectories((previous) => new Set(previous).add(directoryPath)); |
| 55 | const request = (async () => { |
| 56 | if (running.current >= 4) |
| 57 | await new Promise<void>((resolve) => waiting.current.push(resolve)); |
| 58 | else running.current++; |
| 59 | try { |
| 60 | if (!active.current) return undefined; |
| 61 | return await executeAtomQuery(appAtomRegistry, atom, { |
| 62 | refresh: true, |
| 63 | reportFailure: false, |
| 64 | reportDefect: false, |
| 65 | }); |
| 66 | } finally { |
| 67 | const next = waiting.current.shift(); |
| 68 | if (next) next(); |
| 69 | else running.current--; |
| 70 | } |
| 71 | })() |
| 72 | .then((result) => { |
| 73 | if (!active.current || !result) return; |
| 74 | if (result._tag === "Success") { |
| 75 | setDirectories((previous) => |
| 76 | new Map(previous).set( |
| 77 | directoryPath, |
| 78 | result.value.entries.filter( |
| 79 | (entry) => |
| 80 | entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath, |
| 81 | ), |
| 82 | ), |
| 83 | ); |
| 84 | setErrors((previous) => { |
| 85 | const next = new Map(previous); |
| 86 | next.delete(directoryPath); |
| 87 | return next; |
| 88 | }); |
| 89 | } else { |
| 90 | loaded.current.delete(directoryPath); |
| 91 | const cause = Cause.squash(result.cause); |
| 92 | setErrors((previous) => |
| 93 | new Map(previous).set( |
| 94 | directoryPath, |
| 95 | cause instanceof Error ? cause.message : "Unable to load folder.", |
| 96 | ), |
| 97 | ); |
| 98 | } |
| 99 | }) |
| 100 | .finally(() => { |
| 101 | requests.current.delete(directoryPath); |
| 102 | if (!active.current) return; |
| 103 | setPending((count) => count - 1); |
| 104 | if (!refresh) |
| 105 | setLoadingDirectories((previous) => { |
| 106 | const next = new Set(previous); |
| 107 | next.delete(directoryPath); |
| 108 | return next; |
| 109 | }); |
| 110 | }); |
| 111 | requests.current.set(directoryPath, request); |
| 112 | return request; |
| 113 | }, |
| 114 | [cwd, environmentId], |
| 115 | ); |
| 116 | |
| 117 | useEffect(() => { |
| 118 | active.current = true; |
| 119 | void load(""); |
| 120 | return () => { |
| 121 | active.current = false; |
| 122 | for (const stop of watches.current.values()) stop(); |
| 123 | watches.current.clear(); |
| 124 | loaded.current.clear(); |
| 125 | }; |
| 126 | }, [load]); |
| 127 | |
| 128 | const entries = useMemo(() => { |
| 129 | const result: ProjectEntry[] = []; |
| 130 | const visit = (path: string) => { |
| 131 | for (const entry of directories.get(path) ?? []) { |
| 132 | result.push(entry); |
| 133 | if (entry.kind === "directory") visit(entry.path); |
| 134 | } |
| 135 | }; |
| 136 | visit(""); |
| 137 | return result; |
| 138 | }, [directories]); |
| 139 | |
| 140 | const reachableDirectories = useMemo( |
| 141 | () => |
| 142 | new Set([ |
| 143 | "", |
| 144 | ...entries.filter((entry) => entry.kind === "directory").map((entry) => entry.path), |
| 145 | ]), |
| 146 | [entries], |
| 147 | ); |
| 148 | |
| 149 | const refresh = useCallback(() => { |
| 150 | // Refresh folders already visited, preserving the current expansion state. |
| 151 | const paths = [...requested.current].filter((path) => reachableDirectories.has(path)); |
| 152 | let next = 0; |
| 153 | const worker = async () => { |
| 154 | while (next < paths.length && active.current) { |
| 155 | const path = paths[next++]; |
| 156 | if (path !== undefined) await load(path, true); |
| 157 | } |
| 158 | }; |
| 159 | for (let index = 0; index < Math.min(4, paths.length); index++) void worker(); |
| 160 | }, [load, reachableDirectories]); |
| 161 | |
| 162 | return { |
| 163 | entries, |
| 164 | load, |
| 165 | refresh, |
| 166 | isPending: pending > 0, |
| 167 | loadingDirectories, |
| 168 | ready: directories.has(""), |
| 169 | error: [...errors].find(([path]) => reachableDirectories.has(path))?.[1] ?? null, |
| 170 | }; |
| 171 | } |