Skip to content
Source
Sign inSign up
Open History to see the last commit

useDirectoryEntries.ts

191 lines7.9 KB

1import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
2import { useAtomMount, useAtomSubscribe } from "@effect/atom-react";
3import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
4import * as Cause from "effect/Cause";
5import { useEffect, useRef, useState } from "react";
6​
7import { environmentCatalog } from "~/connection/catalog";
8import { appAtomRegistry } from "~/rpc/atomRegistry";
9import { projectEnvironment } from "~/state/projects";
10​
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(1);
15 const [loadingDirectories, setLoadingDirectories] = useState<ReadonlySet<string>>(
16 () => new Set([""]),
17 );
18 // Source of truth for `directories`, so a listing can purge folders it no longer holds.
19 const listings = useRef(new Map<string, readonly ProjectEntry[]>());
20 const requests = useRef(new Map<string, Promise<void>>());
21 const detachedRequests = useRef(new Map<string, Promise<void>>());
22 const followUpRefreshes = useRef(new Map<string, Promise<void>>());
23 const loaded = useRef(new Set<string>());
24 const requested = useRef(new Set<string>());
25 const active = useRef(true);
26 const running = useRef(0);
27 const waiting = useRef<Array<() => void>>([]);
28​
29 const startRequest = (directoryPath: string, refresh: boolean): Promise<void> => {
30 loaded.current.add(directoryPath);
31 requested.current.add(directoryPath);
32 const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } });
33 const detached = detachedRequests.current.get(directoryPath);
34 const request = (async () => {
35 // A shared query still waiting would join the removed folder's read.
36 if (detached) await detached;
37 if (running.current >= 4) await new Promise<void>((resolve) => waiting.current.push(resolve));
38 else running.current++;
39 const result = active.current
40 ? await executeAtomQuery(appAtomRegistry, atom, {
41 refresh: true,
42 reportFailure: false,
43 reportDefect: false,
44 })
45 : undefined;
46 const next = waiting.current.shift();
47 if (next) next();
48 else running.current--;
49 return result;
50 })()
51 .then((result) => {
52 // A request is dropped from `requests` when its folder disappears, so its read is stale.
53 if (!active.current || !result || requests.current.get(directoryPath) !== request) return;
54 if (result._tag === "Success") {
55 const children = result.value.entries.filter(
56 (entry) =>
57 entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath,
58 );
59 const next = new Map(listings.current).set(directoryPath, children);
60 const childDirectories = new Set(
61 children.filter((entry) => entry.kind === "directory").map((entry) => entry.path),
62 );
63 const prefix = directoryPath === "" ? "" : `${directoryPath}/`;
64 const removed = [...requested.current].filter((path) => {
65 if (path === directoryPath || !path.startsWith(prefix)) return false;
66 const slash = path.indexOf("/", prefix.length);
67 return !childDirectories.has(slash === -1 ? path : path.slice(0, slash));
68 });
69 for (const path of removed) {
70 next.delete(path);
71 loaded.current.delete(path);
72 requested.current.delete(path);
73 const detachedRequest = requests.current.get(path);
74 if (detachedRequest) detachedRequests.current.set(path, detachedRequest);
75 requests.current.delete(path);
76 followUpRefreshes.current.delete(path);
77 }
78 listings.current = next;
79 setDirectories(next);
80 if (removed.length > 0)
81 setLoadingDirectories((previous) => {
82 const remaining = new Set(previous);
83 for (const path of removed) remaining.delete(path);
84 return remaining;
85 });
86 setErrors((previous) => {
87 const remaining = new Map(previous);
88 for (const path of [directoryPath, ...removed]) remaining.delete(path);
89 return remaining;
90 });
91 } else {
92 loaded.current.delete(directoryPath);
93 const cause = Cause.squash(result.cause);
94 setErrors((previous) =>
95 new Map(previous).set(
96 directoryPath,
97 cause instanceof Error ? cause.message : "Unable to load folder.",
98 ),
99 );
100 }
101 })
102 .finally(() => {
103 if (detachedRequests.current.get(directoryPath) === request)
104 detachedRequests.current.delete(directoryPath);
105 const current = requests.current.get(directoryPath) === request;
106 if (current) requests.current.delete(directoryPath);
107 if (!active.current) return;
108 setPending((count) => count - 1);
109 if (!refresh && current)
110 setLoadingDirectories((previous) => {
111 const next = new Set(previous);
112 next.delete(directoryPath);
113 return next;
114 });
115 });
116 requests.current.set(directoryPath, request);
117 return request;
118 };
119​
120 const load = function loadDirectory(directoryPath: string, refresh = false): Promise<void> {
121 const existing = requests.current.get(directoryPath);
122 if (existing) {
123 if (!refresh) return existing;
124 let followUp = followUpRefreshes.current.get(directoryPath);
125 if (!followUp) {
126 followUp = existing.then(() => {
127 if (followUpRefreshes.current.get(directoryPath) !== followUp) return;
128 followUpRefreshes.current.delete(directoryPath);
129 if (active.current && requested.current.has(directoryPath))
130 return loadDirectory(directoryPath, true);
131 });
132 followUpRefreshes.current.set(directoryPath, followUp);
133 }
134 return followUp;
135 }
136 if (!refresh && loaded.current.has(directoryPath)) return Promise.resolve();
137 setPending((count) => count + 1);
138 if (!refresh) setLoadingDirectories((previous) => new Set(previous).add(directoryPath));
139 return startRequest(directoryPath, refresh);
140 };
141​
142 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- starts the root directory read on mount and marks the hook inactive so late reads are ignored
143 useEffect(() => {
144 active.current = true;
145 if (!requests.current.has("") && !loaded.current.has("")) void startRequest("", false);
146 return () => {
147 active.current = false;
148 };
149 }, [startRequest]);
150​
151 // A folder read fails at once while its environment is unreachable; read it again once a new
152 // session is up rather than leaving the error until someone retries by hand.
153 const sessionGeneration = environmentCatalog.sessionGenerationAtom(environmentId);
154 // A listener alone does not compute the atom, so nothing would follow the connection.
155 useAtomMount(sessionGeneration);
156 useAtomSubscribe(sessionGeneration, () => {
157 for (const path of errors.keys()) void load(path);
158 });
159​
160 const entries: ProjectEntry[] = [];
161 const visit = (path: string) => {
162 for (const entry of directories.get(path) ?? []) {
163 entries.push(entry);
164 if (entry.kind === "directory") visit(entry.path);
165 }
166 };
167 visit("");
168​
169 const reachableDirectories = new Set([
170 "",
171 ...entries.filter((entry) => entry.kind === "directory").map((entry) => entry.path),
172 ]);
173​
174 const refresh = () => {
175 const paths = [...requested.current].filter((path) => reachableDirectories.has(path));
176 for (const path of paths) {
177 if (!active.current) break;
178 void load(path, true);
179 }
180 };
181​
182 return {
183 entries,
184 load,
185 refresh,
186 isPending: pending > 0,
187 loadingDirectories,
188 ready: directories.has(""),
189 error: [...errors].find(([path]) => reachableDirectories.has(path))?.[1] ?? null,
190 };