1import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
2import {
3 type EnvironmentId,
4 type ProjectListEntriesResult,
5 ProjectReadFileError,
6 type ProjectReadFileResult,
7} from "@t3tools/contracts";
8import * as Cause from "effect/Cause";
9import * as Option from "effect/Option";
10import * as Schema from "effect/Schema";
11import { AsyncResult, Atom } from "effect/reactivity";
12import { useCallback } from "react";
13
14import { appAtomRegistry } from "~/rpc/atomRegistry";
15import { useFilesystemReadAccess } from "~/state/filesystem";
16import { projectEnvironment } from "~/state/projects";
17import { useProjectPathSearch } from "~/state/queries";
18import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
19
20const EMPTY_PROJECT_FILE_PATH = "";
21const EMPTY_PROJECT_ENTRIES_QUERY_ATOM = Atom.make(
22 AsyncResult.initial<ProjectListEntriesResult, never>(false),
23);
24const EMPTY_PROJECT_FILE_QUERY_ATOM = Atom.make(
25 AsyncResult.initial<ProjectReadFileResult, never>(false),
26).pipe(Atom.withLabel("project-file-query:empty"));
27/** A pending in-app write to the file, overlaying the query until confirmed. */
28export function optimisticFileAtom(
29 environmentId: EnvironmentId,
30 cwd: string,
31 relativePath: string,
32) {
33 return projectEnvironment.optimisticFile({ environmentId, cwd, relativePath });
34}
35
36// Dirty contents must survive a closed preview, including failed or unauthorized saves.
37const unsavedFileMounts = new Map<ReturnType<typeof optimisticFileAtom>, () => void>();
38
39function releaseUnsavedFile(atom: ReturnType<typeof optimisticFileAtom>): void {
40 const unmount = unsavedFileMounts.get(atom);
41 unsavedFileMounts.delete(atom);
42 unmount?.();
43}
44
45interface ProjectQueryState<A> {
46 readonly data: A | null;
47 readonly error: string | null;
48 readonly isPending: boolean;
49 readonly refresh: () => void;
50}
51
52interface ProjectFileQueryState extends ProjectQueryState<ProjectReadFileResult> {
53 readonly readError: ProjectReadFileError | null;
54 /** The path exists but is not a regular file, typically a directory. */
55 readonly isNotFile: boolean;
56}
57
58function getProjectEntriesQueryAtom(
59 environmentId: EnvironmentId,
60 cwd: string,
61 directoryPath?: string,
62) {
63 return projectEnvironment.listEntries({
64 environmentId,
65 input: { cwd, ...(directoryPath !== undefined ? { directoryPath } : {}) },
66 });
67}
68
69export function getProjectFileQueryAtom(
70 environmentId: EnvironmentId,
71 cwd: string,
72 relativePath: string | null,
73) {
74 return projectEnvironment.readFile({
75 environmentId,
76 input: { cwd, relativePath: relativePath ?? EMPTY_PROJECT_FILE_PATH },
77 });
78}
79
80export function setProjectFileQueryData(
81 environmentId: EnvironmentId,
82 cwd: string,
83 relativePath: string,
84 contents: string,
85): void {
86 const atom = optimisticFileAtom(environmentId, cwd, relativePath);
87 if (!unsavedFileMounts.has(atom)) {
88 unsavedFileMounts.set(atom, appAtomRegistry.mount(atom));
89 }
90 appAtomRegistry.set(atom, {
91 confirmedAgainst: undefined,
92 data: {
93 relativePath,
94 contents,
95 byteLength: new TextEncoder().encode(contents).byteLength,
96 truncated: false,
97 },
98 });
99}
100
101export function getOptimisticProjectFileQueryData(
102 environmentId: EnvironmentId,
103 cwd: string,
104 relativePath: string,
105): ProjectReadFileResult | null {
106 return appAtomRegistry.get(optimisticFileAtom(environmentId, cwd, relativePath))?.data ?? null;
107}
108
109/** The contents the Files panel shows, read outside React so it is current within a frame. */
110export function getProjectFileContents(
111 environmentId: EnvironmentId,
112 cwd: string,
113 relativePath: string,
114): string | undefined {
115 const optimistic = getOptimisticProjectFileQueryData(environmentId, cwd, relativePath);
116 if (optimistic) return optimistic.contents;
117 const result = appAtomRegistry.get(getProjectFileQueryAtom(environmentId, cwd, relativePath));
118 return Option.getOrUndefined(AsyncResult.value(result))?.contents;
119}
120
121export function getUnsavedProjectFileQueryData(
122 environmentId: EnvironmentId,
123 cwd: string,
124 relativePath: string,
125): ProjectReadFileResult | null {
126 const optimistic = appAtomRegistry.get(optimisticFileAtom(environmentId, cwd, relativePath));
127 return optimistic?.confirmedAgainst === undefined ? (optimistic?.data ?? null) : null;
128}
129
130export function confirmProjectFileQueryData(
131 environmentId: EnvironmentId,
132 cwd: string,
133 relativePath: string,
134 contents: string,
135): boolean {
136 const atom = optimisticFileAtom(environmentId, cwd, relativePath);
137 const optimisticFile = appAtomRegistry.get(atom);
138 if (optimisticFile?.data.contents !== contents) return false;
139
140 const queryAtom = getProjectFileQueryAtom(environmentId, cwd, relativePath);
141 const confirmed = {
142 ...optimisticFile,
143 confirmedAgainst: appAtomRegistry.get(queryAtom),
144 };
145 appAtomRegistry.set(atom, confirmed);
146 releaseUnsavedFile(atom);
147 appAtomRegistry.refresh(queryAtom);
148 void executeAtomQuery(appAtomRegistry, queryAtom, {
149 reportDefect: false,
150 reportFailure: false,
151 }).then((result) => {
152 if (result._tag === "Success" && appAtomRegistry.get(atom) === confirmed) {
153 appAtomRegistry.set(atom, null);
154 }
155 });
156 return true;
157}
158
159export function resolveProjectFileQueryData(
160 environmentId: EnvironmentId,
161 cwd: string,
162 relativePath: string | null,
163 data: ProjectReadFileResult | null,
164): ProjectReadFileResult | null {
165 if (relativePath === null) return data;
166 return appAtomRegistry.get(optimisticFileAtom(environmentId, cwd, relativePath))?.data ?? data;
167}
168
169export function clearProjectFileQueryData(
170 environmentId: EnvironmentId,
171 cwd: string,
172 relativePath: string,
173): void {
174 const atom = optimisticFileAtom(environmentId, cwd, relativePath);
175 appAtomRegistry.set(atom, null);
176 releaseUnsavedFile(atom);
177}
178
179function failureCause<A>(result: AsyncResult.AsyncResult<A, unknown>): unknown {
180 return result._tag === "Failure" ? Cause.squash(result.cause) : null;
181}
182
183function errorMessage(cause: unknown): string | null {
184 if (cause === null) return null;
185 return cause instanceof Error ? cause.message : "Workspace query failed.";
186}
187
188const isProjectReadFileError = Schema.is(ProjectReadFileError);
189
190export function useProjectEntriesQuery(
191 environmentId: EnvironmentId,
192 cwd: string,
193 directoryPath?: string,
194): ProjectQueryState<ProjectListEntriesResult> {
195 const fileAccess = useFilesystemReadAccess(environmentId);
196 const { canReadFiles } = fileAccess;
197 const atom = canReadFiles
198 ? getProjectEntriesQueryAtom(environmentId, cwd, directoryPath)
199 : EMPTY_PROJECT_ENTRIES_QUERY_ATOM;
200 const result = useAtomValue(atom);
201 const refreshAtom = useAtomRefresh(atom);
202 const refresh = useCallback(() => refreshAtom(), [refreshAtom]);
203 return {
204 data: Option.getOrNull(AsyncResult.value(result)),
205 error: fileAccess.isPending
206 ? null
207 : canReadFiles
208 ? errorMessage(failureCause(result))
209 : (fileAccess.error ?? "This connection cannot read host files."),
210 isPending: fileAccess.isPending || result.waiting,
211 refresh,
212 };
213}
214
215/**
216 * Backing query for the project file picker: a debounced, bounded, file-only
217 * server search. An empty query is a valid request — the index answers it
218 * with frecency-ordered files, so the picker's initial view is recent files
219 * without transferring the full workspace listing. `matchedQuery` is the
220 * query the returned entries were computed for, so the caller can highlight
221 * against results instead of half-typed input.
222 */
223export function useProjectFilePickerQuery(
224 environmentId: EnvironmentId,
225 cwd: string,
226 query: string,
227 limit: number,
228 options?: { readonly imageOnly?: boolean },
229) {
230 const search = useProjectPathSearch(
231 {
232 environmentId,
233 cwd,
234 query,
235 kind: "file",
236 ...(options?.imageOnly ? { imageOnly: true } : {}),
237 },
238 limit,
239 { allowEmptyQuery: true },
240 );
241
242 return {
243 entries: search.isPending ? [] : search.entries,
244 error: search.error,
245 isPending: search.isPending,
246 matchedQuery: search.searchedQuery,
247 };
248}
249
250export function useProjectFileQuery(
251 environmentId: EnvironmentId,
252 cwd: string,
253 relativePath: string | null,
254 enabled = true,
255): ProjectFileQueryState {
256 // The caller decides what to read. A media path is not skipped here: a folder
257 // named `assets.png` is only knowable as a folder from the read failure.
258 const fileAccess = useFilesystemReadAccess(environmentId);
259 const { canReadFiles } = fileAccess;
260 const isQueryEnabled = enabled;
261 const atom =
262 enabled && canReadFiles
263 ? getProjectFileQueryAtom(environmentId, cwd, relativePath)
264 : EMPTY_PROJECT_FILE_QUERY_ATOM;
265 const result = useAtomValue(atom);
266 const refreshAtom = useAtomRefresh(atom);
267 const refresh = useCallback(() => refreshAtom(), [refreshAtom]);
268 const optimisticResult = useAtomValue(
269 optimisticFileAtom(environmentId, cwd, relativePath ?? EMPTY_PROJECT_FILE_PATH),
270 );
271 const optimisticFile = relativePath === null ? null : optimisticResult;
272 const cause = failureCause(result);
273 const readError = isProjectReadFileError(cause) ? cause : null;
274 const data = readError === null ? Option.getOrNull(AsyncResult.value(result)) : null;
275
276 return {
277 data: canReadFiles ? (optimisticFile?.data ?? data) : null,
278 error:
279 !isQueryEnabled || fileAccess.isPending
280 ? null
281 : canReadFiles
282 ? errorMessage(cause)
283 : (fileAccess.error ?? "This connection cannot read host files."),
284 isPending: isQueryEnabled && (fileAccess.isPending || result.waiting),
285 readError,
286 isNotFile: readError?.failure === "path_not_file",
287 refresh,
288 };
289}