Skip to content
Source
Sign inSign up

useFileTreeEntries.ts

573 lines21.9 KB

1import { useAtomMount, useAtomSubscribe } from "@effect/atom-react";
2import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
3import { ProjectListEntriesError, type EnvironmentId, type ProjectEntry } from "@t3tools/contracts";
4import * as Cause from "effect/Cause";
5import * as Schema from "effect/Schema";
6import { useLayoutEffect, useRef, useState } from "react";
7import { Atom } from "effect/reactivity";
8​
9import { environmentCatalog } from "../../connection/catalog";
10import { appAtomRegistry } from "../../state/atom-registry";
11import { projectEnvironment } from "../../state/projects";
12import { useDebouncedValue } from "../../state/queries";
13import { useEnvironmentQuery } from "../../state/query";
14​
15const isProjectListEntriesError = Schema.is(ProjectListEntriesError);
16​
17function createDirectories() {
18 return {
19 target: {},
20 entries: new Map<string, ReadonlyArray<ProjectEntry>>(),
21 errors: new Map<string, string>(),
22 completeDirectories: new Set<string>(),
23 listingCompletedAt: new Map<string, number>(),
24 discoveries: new Map<string, ProjectEntry>(),
25 rootListing: null as { readonly timestamp: number } | null,
26 validatedDirectories: new Set<string>(),
27 loading: new Set<string>(),
28 expandedPaths: new Set<string>(),
29 selectedPath: null as string | null,
30 revealedSelection: { path: null as string | null, ancestors: new Set<string>() },
31 isRefreshing: false,
32 };
34​
35function boundDirectories(current: ReturnType<typeof createDirectories>, preserveVisible = true) {
36 const visible = (path: string) => {
37 const parts = path.split("/");
38 return parts.every((_, index) =>
39 current.expandedPaths.has(parts.slice(0, index + 1).join("/")),
40 );
41 };
42 const inactive = [...new Set([...current.entries.keys(), ...current.errors.keys()])].filter(
43 (path) => !preserveVisible || !visible(path),
44 );
45 const ownedPaths = [
46 ...current.entries.keys(),
47 ...current.expandedPaths,
48 current.selectedPath,
49 ].filter((path) => path !== null);
50 const discoveries = new Map(
51 [...current.discoveries].filter(([path]) =>
52 ownedPaths.some((owned) => owned === path || owned.startsWith(`${path}/`)),
53 ),
54 );
55 if (inactive.length <= 64)
56 return discoveries.size === current.discoveries.size ? current : { ...current, discoveries };
57 const evicted = new Set(inactive.slice(0, -64));
58 // Evict listing data, not the user's expansion and selection. A missing parent reloads on return.
59 return {
60 ...current,
61 discoveries,
62 entries: new Map([...current.entries].filter(([path]) => !evicted.has(path))),
63 errors: new Map([...current.errors].filter(([path]) => !evicted.has(path))),
64 completeDirectories: new Set(
65 [...current.completeDirectories].filter((path) => !evicted.has(path)),
66 ),
67 validatedDirectories: new Set(
68 [...current.validatedDirectories].filter((path) => !evicted.has(path)),
69 ),
70 listingCompletedAt: new Map(
71 [...current.listingCompletedAt].filter(([path]) => !evicted.has(path)),
72 ),
73 };
75​
76function contradictsListing(path: string, parent: string, entries: ReadonlyArray<ProjectEntry>) {
77 if (parent !== "" && !path.startsWith(`${parent}/`)) return false;
78 const child = path.slice(parent === "" ? 0 : parent.length + 1).split("/")[0];
79 const childPath = parent === "" ? child : `${parent}/${child}`;
80 const entry = entries.find((item) => item.path === childPath);
81 return !entry || (path !== childPath && entry.kind !== "directory");
83​
84function createRequests() {
85 return {
86 active: true,
87 refreshVersion: 0,
88 pending: new Map<string, AbortController>(),
89 };
91​
92function getRequests(
93 requestsByTarget: WeakMap<object, ReturnType<typeof createRequests>>,
94 target: object,
95) {
96 let requests = requestsByTarget.get(target);
97 if (!requests) {
98 requests = createRequests();
99 requestsByTarget.set(target, requests);
100 }
101 return requests;
103const noSessionGeneration = Atom.make(0);
104​
105export function useFileTreeEntries(input: {
106 readonly cwd: string | null;
107 readonly environmentId: EnvironmentId | null;
108 readonly searchQuery: string;
109}) {
110 const { cwd, environmentId } = input;
111 const searching = input.searchQuery.trim().length > 0;
112 const query = input.searchQuery.trim().slice(0, 256);
113 const debouncedQuery = useDebouncedValue(query, 200);
114 const root = useEnvironmentQuery(
115 cwd !== null && environmentId !== null
116 ? projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath: "" } })
117 : null,
118 );
119 const { refresh: refreshRoot } = root;
120 const search = useEnvironmentQuery(
121 searching && debouncedQuery.length > 0 && cwd !== null && environmentId !== null
122 ? projectEnvironment.searchEntries({
123 environmentId,
124 input: { cwd, query: debouncedQuery, limit: 200 },
125 })
126 : null,
127 );
128 const workspaceKey = JSON.stringify([environmentId, cwd]);
129 const [cache, setCache] = useState(() => ({
130 key: workspaceKey,
131 workspaces: new Map([[workspaceKey, createDirectories()]]),
132 }));
133 const savedDirectories = cache.workspaces.get(workspaceKey) ?? createDirectories();
134 // Requests belong to a visit, so returning cannot revive an old completion.
135 const directories =
136 cache.key === workspaceKey
137 ? savedDirectories
138 : {
139 ...savedDirectories,
140 target: {},
141 validatedDirectories: new Set<string>(),
142 errors: new Map<string, string>(),
143 loading: new Set<string>(),
144 isRefreshing: false,
145 };
146 if (cache.key !== workspaceKey) {
147 const workspaces = new Map(cache.workspaces);
148 const previous = workspaces.get(cache.key);
149 if (previous) {
150 workspaces.set(
151 cache.key,
152 boundDirectories(
153 {
154 ...previous,
155 errors: new Map(),
156 loading: new Set(),
157 isRefreshing: false,
158 },
159 false,
160 ),
161 );
162 }
163 workspaces.delete(workspaceKey);
164 workspaces.set(workspaceKey, directories);
165 if (workspaces.size > 8) workspaces.delete(workspaces.keys().next().value!);
166 setCache({ key: workspaceKey, workspaces });
167 }
168 const setDirectories = (
169 update: (current: ReturnType<typeof createDirectories>) => ReturnType<typeof createDirectories>,
170 ) =>
171 setCache((current) => {
172 const previous = current.workspaces.get(workspaceKey);
173 if (!previous) return current;
174 const next = boundDirectories(update(previous));
175 if (next === previous) return current;
176 return { ...current, workspaces: new Map(current.workspaces).set(workspaceKey, next) };
177 });
178 const { target } = directories;
179 const requestsByTarget = useRef(new WeakMap<object, ReturnType<typeof createRequests>>());
180 // Cancel the old target before consumers' passive effects start new directory loads.
181 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- loads the root and aborts the old target's directory requests before consumers start new loads
182 useLayoutEffect(() => {
183 const requests = getRequests(requestsByTarget.current, target);
184 requests.active = true;
185 refreshRoot();
186 return () => {
187 requests.active = false;
188 requests.refreshVersion++;
189 for (const controller of requests.pending.values()) controller.abort();
190 requests.pending.clear();
191 setDirectories((current) =>
192 current.target === target
193 ? { ...current, loading: new Set(), isRefreshing: false }
194 : current,
195 );
196 };
197 }, [target, setDirectories, requestsByTarget, refreshRoot]);
198 const updateDirectories = (
199 update: (current: ReturnType<typeof createDirectories>) => ReturnType<typeof createDirectories>,
200 ) => setDirectories((current) => (current.target === target ? update(current) : current));
201 const loadDirectory = (directoryPath: string, refresh = false) => {
202 const requests = getRequests(requestsByTarget.current, target);
203 if (
204 !requests.active ||
205 missingRoot ||
206 cwd === null ||
207 environmentId === null ||
208 (!refresh && directories.validatedDirectories.has(directoryPath)) ||
209 requests.pending.has(directoryPath)
210 ) {
211 return;
212 }
213 const controller = new AbortController();
214 requests.pending.set(directoryPath, controller);
215 updateDirectories((current) => {
216 const errors = new Map(current.errors);
217 errors.delete(directoryPath);
218 return { ...current, errors, loading: new Set(current.loading).add(directoryPath) };
219 });
220 const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } });
221 appAtomRegistry.refresh(atom);
222 return executeAtomQuery(appAtomRegistry, atom, {
223 signal: controller.signal,
224 reportFailure: false,
225 reportDefect: false,
226 }).then((result) => {
227 if (controller.signal.aborted) return;
228 requests.pending.delete(directoryPath);
229 updateDirectories((current) => {
230 const validatedDirectories = new Set(current.validatedDirectories).add(directoryPath);
231 const loading = new Set(current.loading);
232 loading.delete(directoryPath);
233 if (result._tag === "Success") {
234 const entries = new Map(current.entries);
235 entries.delete(directoryPath);
236 entries.set(
237 directoryPath,
238 result.value.entries.filter(
239 (entry) =>
240 entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath,
241 ),
242 );
243 const completeDirectories = new Set(current.completeDirectories);
244 if (result.value.truncated) completeDirectories.delete(directoryPath);
245 else completeDirectories.add(directoryPath);
246 return {
247 ...current,
248 entries,
249 loading,
250 completeDirectories,
251 discoveries: result.value.truncated
252 ? current.discoveries
253 : new Map(
254 [...current.discoveries].filter(
255 ([path]) => !contradictsListing(path, directoryPath, result.value.entries),
256 ),
257 ),
258 validatedDirectories,
259 listingCompletedAt: new Map(current.listingCompletedAt).set(
260 directoryPath,
261 result.timestamp,
262 ),
263 };
264 }
265 const error = Cause.squash(result.cause);
266 return {
267 ...current,
268 loading,
269 validatedDirectories,
270 errors: new Map(current.errors).set(
271 directoryPath,
272 error instanceof Error ? error.message : "Files unavailable",
273 ),
274 };
275 });
276 });
277 };
278 const missingRoot =
279 isProjectListEntriesError(root.failure) && root.failure.failure === "workspace_root_not_found";
280 const rootData = missingRoot ? null : root.data;
281 const rootListing = root.successResult ?? directories.rootListing;
282 if (
283 missingRoot &&
284 (directories.entries.size > 0 ||
285 directories.errors.size > 0 ||
286 directories.loading.size > 0 ||
287 directories.expandedPaths.size > 0 ||
288 directories.selectedPath !== null ||
289 directories.discoveries.size > 0)
290 ) {
291 setDirectories(() => createDirectories());
292 }
293 const { refresh: refreshSearch, data: searchData, successResult: searchResult } = search;
294 // A folder read fails at once while its environment is unreachable; read it again once a new
295 // session is up rather than leaving the error until someone refreshes.
296 const sessionGeneration =
297 environmentId === null
298 ? noSessionGeneration
299 : environmentCatalog.sessionGenerationAtom(environmentId);
300 // A listener alone does not compute the atom, so nothing would follow the connection.
301 useAtomMount(sessionGeneration);
302 useAtomSubscribe(sessionGeneration, () => {
303 for (const path of directories.errors.keys()) void loadDirectory(path, true);
304 });
305 const snapshot = (() => {
306 const merged = new Map(missingRoot ? [] : directories.discoveries);
307 if (searching && !missingRoot) {
308 for (const entry of searchData?.entries ?? []) merged.set(entry.path, entry);
309 }
310 const reachableDirectories = new Set<string>();
311 const visit = (items: ReadonlyArray<ProjectEntry>) => {
312 for (const entry of items) {
313 merged.set(entry.path, entry);
314 if (entry.kind === "directory") {
315 reachableDirectories.add(entry.path);
316 visit(directories.entries.get(entry.path) ?? []);
317 }
318 }
319 };
320 if (!missingRoot) {
321 const discoveries = new Map(directories.discoveries);
322 // Search can return only leaves; their inferred parents still own loaded children.
323 for (const entry of directories.discoveries.values()) {
324 const parts = entry.path.split("/");
325 for (let index = 1; index < parts.length; index++) {
326 const path = parts.slice(0, index).join("/");
327 if (!discoveries.has(path)) discoveries.set(path, { kind: "directory", path });
328 }
329 }
330 visit([...discoveries.values()]);
331 }
332 visit((rootData?.entries ?? []).filter((entry) => !entry.path.includes("/")));
333 return {
334 entries: [...merged.values()],
335 reachableDirectories,
336 searchResultPaths:
337 searching && !missingRoot
338 ? new Set((searchData?.entries ?? []).map((entry) => entry.path))
339 : null,
340 };
341 })();
342​
343 if (rootData) {
344 const deleted = (path: string, directory = false) => {
345 const parts = path.split("/");
346 for (let index = 0; index < parts.length; index++) {
347 const parent = parts.slice(0, index).join("/");
348 const listing = index === 0 ? rootData.entries : directories.entries.get(parent);
349 const child = parts.slice(0, index + 1).join("/");
350 const complete =
351 index === 0 ? !rootData.truncated : directories.completeDirectories.has(parent);
352 const entry = listing?.find((item) => item.path === child);
353 if (complete && listing && !entry) {
354 const listingCompletedAt =
355 index === 0 && directories.rootListing !== rootListing
356 ? rootListing?.timestamp
357 : directories.listingCompletedAt.get(parent);
358 // Cached queries keep their completion time across query switches.
359 // Equal-millisecond responses cannot prove a path postdates the listing.
360 const retained =
361 !(index === 0 && directories.rootListing !== rootListing) &&
362 [...directories.discoveries.keys()].some(
363 (path) => path === child || path.startsWith(`${child}/`),
364 );
365 const discovered =
366 retained ||
367 (searching &&
368 query === debouncedQuery &&
369 searchResult !== null &&
370 searchResult.timestamp > (listingCompletedAt ?? -Infinity) &&
371 searchData?.entries.some(
372 (item) => item.path === child || item.path.startsWith(`${child}/`),
373 ));
374 if (!discovered) return true;
375 }
376 if (entry && (directory || index < parts.length - 1) && entry.kind !== "directory")
377 return true;
378 }
379 return false;
380 };
381 const reconciled = (() => {
382 const current = directories;
383 const discoveries = new Map(current.discoveries);
384 if (current.rootListing !== rootListing && !rootData.truncated) {
385 for (const path of discoveries.keys()) {
386 if (contradictsListing(path, "", rootData.entries)) discoveries.delete(path);
387 }
388 }
389 if (searching && query === debouncedQuery && searchResult !== null) {
390 const ownedPaths = [
391 ...current.entries.keys(),
392 ...current.expandedPaths,
393 current.selectedPath,
394 ];
395 for (const entry of searchData?.entries ?? []) {
396 if (
397 ownedPaths.some((path) => path === entry.path || path?.startsWith(`${entry.path}/`)) &&
398 !deleted(entry.path, entry.kind === "directory")
399 )
400 discoveries.set(entry.path, entry);
401 }
402 }
403 const entries = new Map([...current.entries].filter(([path]) => !deleted(path, true)));
404 const errors = new Map([...current.errors].filter(([path]) => !deleted(path, true)));
405 const expandedPaths = new Set(
406 [...current.expandedPaths].filter((path) => !deleted(path, true)),
407 );
408 const selectedPath =
409 current.selectedPath !== null && deleted(current.selectedPath)
410 ? null
411 : current.selectedPath;
412 if (
413 entries.size === current.entries.size &&
414 errors.size === current.errors.size &&
415 expandedPaths.size === current.expandedPaths.size &&
416 selectedPath === current.selectedPath &&
417 current.rootListing === rootListing &&
418 discoveries.size === current.discoveries.size &&
419 [...discoveries].every(([path, entry]) => current.discoveries.get(path) === entry)
420 )
421 return current;
422 const completeDirectories = new Set(
423 [...current.completeDirectories].filter((path) => entries.has(path)),
424 );
425 return {
426 ...current,
427 rootListing,
428 discoveries,
429 listingCompletedAt: new Map(
430 [...current.listingCompletedAt].filter(([path]) => path === "" || entries.has(path)),
431 ).set(
432 "",
433 current.rootListing === rootListing
434 ? (current.listingCompletedAt.get("") ?? -Infinity)
435 : (rootListing?.timestamp ?? -Infinity),
436 ),
437 entries,
438 errors,
439 expandedPaths,
440 selectedPath,
441 completeDirectories,
442 validatedDirectories: new Set(
443 [...current.validatedDirectories].filter((path) => !deleted(path, true)),
444 ),
445 };
446 })();
447 if (reconciled !== directories) setDirectories(() => reconciled);
448 }
449​
450 const refresh = () => {
451 const requests = getRequests(requestsByTarget.current, target);
452 if (!requests.active) return Promise.resolve();
453 refreshRoot();
454 if (searching && !missingRoot) refreshSearch();
455 const paths = new Set(
456 [...directories.entries.keys(), ...directories.errors.keys(), ...directories.loading].filter(
457 (path) => snapshot.reachableDirectories.has(path),
458 ),
459 );
460 for (const controller of requests.pending.values()) controller.abort();
461 requests.pending.clear();
462 const version = ++requests.refreshVersion;
463 updateDirectories((current) => ({
464 ...current,
465 errors: new Map(),
466 loading: new Set(),
467 isRefreshing: true,
468 }));
469 const remaining = paths.values();
470 const worker = async () => {
471 while (requests.active && version === requests.refreshVersion) {
472 const next = remaining.next();
473 if (next.done) return;
474 await loadDirectory(next.value, true);
475 }
476 };
477 const queries =
478 cwd !== null && environmentId !== null
479 ? [
480 projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath: "" } }),
481 ...(searching && !missingRoot
482 ? [
483 projectEnvironment.searchEntries({
484 environmentId,
485 input: { cwd, query: debouncedQuery, limit: 200 },
486 }),
487 ]
488 : []),
489 ]
490 : [];
491 const work = [
492 ...queries.map((atom) =>
493 executeAtomQuery(appAtomRegistry, atom, { reportFailure: false, reportDefect: false }),
494 ),
495 ...Array.from({ length: Math.min(4, paths.size) }, () => worker()),
496 ];
497 return Promise.all(work).finally(() => {
498 if (version === requests.refreshVersion) {
499 updateDirectories((current) => ({ ...current, isRefreshing: false }));
500 }
501 });
502 };
503​
504 const revealSelection = (path: string | null) => {
505 if (missingRoot) return;
506 updateDirectories((current) => {
507 const previous = current.revealedSelection;
508 const handled = previous.path === path ? previous.ancestors : new Set<string>();
509 const parts = path?.split("/").filter(Boolean) ?? [];
510 const ancestors = parts
511 .slice(1)
512 .map((_, index) => parts.slice(0, index + 1).join("/"))
513 .filter(
514 (ancestor) =>
515 !handled.has(ancestor) &&
516 snapshot.entries.some(
517 (entry) =>
518 (entry.kind === "directory" && entry.path === ancestor) ||
519 (entry.path === path && entry.path.startsWith(`${ancestor}/`)),
520 ),
521 );
522 if (previous.path === path && ancestors.length === 0) return current;
523 return {
524 ...current,
525 discoveries: new Map([
526 ...current.discoveries,
527 ...snapshot.entries
528 .filter((entry) => entry.path === path)
529 .map((entry) => [entry.path, entry] as const),
530 ]),
531 selectedPath: path,
532 revealedSelection: { path, ancestors: new Set([...handled, ...ancestors]) },
533 expandedPaths: new Set([...current.expandedPaths, ...ancestors]),
534 };
535 });
536 };
537​
538 const setExpandedPaths = (update: (current: ReadonlySet<string>) => ReadonlySet<string>) =>
539 setDirectories((current) => {
540 const expandedPaths = update(current.expandedPaths);
541 return expandedPaths === current.expandedPaths
542 ? current
543 : { ...current, expandedPaths: new Set(expandedPaths) };
544 });
545​
546 return {
547 revealSelection,
548 expandedPaths: directories.expandedPaths,
549 setExpandedPaths,
550 selectedPath: directories.selectedPath,
551 selectPath: (selectedPath: string) =>
552 updateDirectories((current) => ({ ...current, selectedPath })),
553 entries: snapshot.entries,
554 searchResultPaths: snapshot.searchResultPaths,
555 isRefreshing: directories.isRefreshing,
556 error:
557 root.error ??
558 (searching ? search.error : null) ??
559 [...directories.errors].find(([path]) => snapshot.reachableDirectories.has(path))?.[1] ??
560 null,
561 isPending:
562 !missingRoot &&
563 (root.isPending ||
564 directories.loading.size > 0 ||
565 (searching && (query !== debouncedQuery || search.isPending))),
566 searchTruncated: !missingRoot && searching && (search.data?.truncated ?? false),
567 loadedDirectories: new Set(directories.entries.keys()),
568 loadingDirectories: directories.loading,
569 loadDirectory: (path: string, retry = false) =>
570 loadDirectory(path, retry && directories.errors.has(path)),
571 refresh,
572 };