1import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
2import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
3import * as Cause from "effect/Cause";
4import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
5
6import { appAtomRegistry } from "../../state/atom-registry";
7import { projectEnvironment } from "../../state/projects";
8import { useDebouncedValue } from "../../state/queries";
9import { useEnvironmentQuery } from "../../state/query";
10
11export function useFileTreeEntries(input: {
12 readonly cwd: string | null;
13 readonly environmentId: EnvironmentId | null;
14 readonly searchQuery: string;
15}) {
16 const { cwd, environmentId } = input;
17 const searching = input.searchQuery.trim().length > 0;
18 const query = input.searchQuery.trim().slice(0, 256);
19 const debouncedQuery = useDebouncedValue(query, 200);
20 const root = useEnvironmentQuery(
21 cwd !== null && environmentId !== null
22 ? projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath: "" } })
23 : null,
24 );
25 const search = useEnvironmentQuery(
26 searching && debouncedQuery.length > 0 && cwd !== null && environmentId !== null
27 ? projectEnvironment.searchEntries({
28 environmentId,
29 input: { cwd, query: debouncedQuery, limit: 200 },
30 })
31 : null,
32 );
33 const [, render] = useReducer((value: number) => value + 1, 0);
34 const [entriesRevision, entriesChanged] = useReducer((value: number) => value + 1, 0);
35 const refreshVersion = useRef(0);
36 const directories = useMemo(
37 () => ({
38 cwd,
39 environmentId,
40 entries: new Map<string, ReadonlyArray<ProjectEntry>>(),
41 requested: new Set<string>(),
42 pending: new Map<string, AbortController>(),
43 errors: new Map<string, string>(),
44 }),
45 [cwd, environmentId],
46 );
47 const [refreshingDirectories, setRefreshingDirectories] = useState<typeof directories | null>(
48 null,
49 );
50 useEffect(
51 () => () => {
52 refreshVersion.current++;
53 for (const controller of directories.pending.values()) controller.abort();
54 directories.pending.clear();
55 },
56 [directories],
57 );
58 const loadDirectory = useCallback(
59 (directoryPath: string, refresh = false) => {
60 if (
61 cwd === null ||
62 environmentId === null ||
63 (!refresh && directories.entries.has(directoryPath)) ||
64 directories.pending.has(directoryPath)
65 ) {
66 return;
67 }
68 const controller = new AbortController();
69 directories.requested.add(directoryPath);
70 directories.pending.set(directoryPath, controller);
71 directories.errors.delete(directoryPath);
72 render();
73 const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } });
74 appAtomRegistry.refresh(atom);
75 return executeAtomQuery(appAtomRegistry, atom, {
76 signal: controller.signal,
77 reportFailure: false,
78 reportDefect: false,
79 }).then((result) => {
80 if (controller.signal.aborted) return;
81 directories.pending.delete(directoryPath);
82 if (result._tag === "Success") {
83 directories.entries.set(
84 directoryPath,
85 result.value.entries.filter(
86 (entry) =>
87 entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath,
88 ),
89 );
90 entriesChanged();
91 } else {
92 const error = Cause.squash(result.cause);
93 directories.errors.set(
94 directoryPath,
95 error instanceof Error ? error.message : "Files unavailable",
96 );
97 }
98 render();
99 });
100 },
101 [cwd, directories, environmentId],
102 );
103 const { refresh: refreshRoot, data: rootData } = root;
104 const { refresh: refreshSearch, data: searchData } = search;
105 const snapshot = useMemo(() => {
106 const merged = new Map<string, ProjectEntry>();
107 if (searching) {
108 for (const entry of searchData?.entries ?? []) merged.set(entry.path, entry);
109 }
110 const reachableDirectories = new Set<string>();
111 const visit = (items: ReadonlyArray<ProjectEntry>) => {
112 for (const entry of items) {
113 merged.set(entry.path, entry);
114 if (entry.kind === "directory") {
115 reachableDirectories.add(entry.path);
116 visit(directories.entries.get(entry.path) ?? []);
117 }
118 }
119 };
120 visit((rootData?.entries ?? []).filter((entry) => !entry.path.includes("/")));
121 return { revision: entriesRevision, entries: [...merged.values()], reachableDirectories };
122 }, [directories, entriesRevision, rootData, searchData, searching]);
123
124 const refresh = useCallback(() => {
125 refreshRoot();
126 if (searching) refreshSearch();
127 const paths = new Set(
128 [...directories.requested].filter((path) => snapshot.reachableDirectories.has(path)),
129 );
130 for (const controller of directories.pending.values()) controller.abort();
131 directories.pending.clear();
132 directories.errors.clear();
133 const version = ++refreshVersion.current;
134 setRefreshingDirectories(directories);
135 const remaining = paths.values();
136 const worker = async () => {
137 while (version === refreshVersion.current) {
138 const next = remaining.next();
139 if (next.done) return;
140 await loadDirectory(next.value, true);
141 }
142 };
143 const queries =
144 cwd !== null && environmentId !== null
145 ? [
146 projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath: "" } }),
147 ...(searching
148 ? [
149 projectEnvironment.searchEntries({
150 environmentId,
151 input: { cwd, query: debouncedQuery, limit: 200 },
152 }),
153 ]
154 : []),
155 ]
156 : [];
157 const work = [
158 ...queries.map((atom) =>
159 executeAtomQuery(appAtomRegistry, atom, { reportFailure: false, reportDefect: false }),
160 ),
161 ...Array.from({ length: Math.min(4, paths.size) }, () => worker()),
162 ];
163 render();
164 return Promise.all(work).finally(() => {
165 if (version === refreshVersion.current) setRefreshingDirectories(null);
166 });
167 }, [
168 cwd,
169 debouncedQuery,
170 environmentId,
171 directories,
172 loadDirectory,
173 refreshRoot,
174 refreshSearch,
175 searching,
176 snapshot.reachableDirectories,
177 ]);
178
179 return {
180 entries: snapshot.entries,
181 isRefreshing: refreshingDirectories === directories,
182 error:
183 root.error ??
184 (searching ? search.error : null) ??
185 [...directories.errors].find(([path]) => snapshot.reachableDirectories.has(path))?.[1] ??
186 null,
187 isPending:
188 root.isPending ||
189 directories.pending.size > 0 ||
190 (searching && (query !== debouncedQuery || search.isPending)),
191 searchTruncated: searching && (search.data?.truncated ?? false),
192 loadedDirectories: new Set(directories.entries.keys()),
193 loadingDirectories: new Set(directories.pending.keys()),
194 loadDirectory,
195 refresh,
196 };
197}