1import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
2import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
3import * as Cause from "effect/Cause";
4import { AsyncResult } from "effect/reactivity";
5import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
7import { appAtomRegistry } from "~/rpc/atomRegistry";
8import { projectEnvironment } from "~/state/projects";
9
10/** Loads only requested directories; collapsing a folder keeps its children cached. */
11export 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}