import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentId, ProjectListEntriesResult, ProjectReadFileResult, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect } from "react"; import { ensureEnvironmentApi } from "~/environmentApi"; import { appAtomRegistry } from "~/rpc/atomRegistry"; const PROJECT_QUERY_STALE_TIME_MS = 30_000; const PROJECT_QUERY_IDLE_TTL_MS = 5 * 60_000; const EMPTY_PROJECT_FILE_PATH = ""; interface OptimisticProjectFile { readonly data: ProjectReadFileResult; readonly confirmed: boolean; } const optimisticProjectFiles = new Map(); class ProjectQueryError extends Data.TaggedError("ProjectQueryError")<{ readonly message: string; readonly cause?: unknown; }> {} function queryError(message: string, cause: unknown): ProjectQueryError { return new ProjectQueryError({ message, cause }); } function entriesKey(environmentId: EnvironmentId, cwd: string): string { return [environmentId, cwd].map(encodeURIComponent).join("|"); } function fileKey(environmentId: EnvironmentId, cwd: string, relativePath: string): string { return [environmentId, cwd, relativePath].map(encodeURIComponent).join("|"); } function keyParts(key: string): string[] { return key.split("|").map(decodeURIComponent); } const projectEntriesQueryAtom = Atom.family((key: string) => Atom.make( Effect.tryPromise({ try: () => { const [environmentId, cwd] = keyParts(key) as [EnvironmentId, string]; return ensureEnvironmentApi(environmentId).projects.listEntries({ cwd }); }, catch: (cause) => queryError("Could not load workspace files.", cause), }), ).pipe( Atom.swr({ staleTime: PROJECT_QUERY_STALE_TIME_MS, revalidateOnMount: true, }), Atom.setIdleTTL(PROJECT_QUERY_IDLE_TTL_MS), Atom.withLabel(`projects:entries:${key}`), ), ); const projectFileQueryAtom = Atom.family((key: string) => Atom.make( Effect.tryPromise({ try: () => { const [environmentId, cwd, relativePath] = keyParts(key) as [EnvironmentId, string, string]; if (relativePath === EMPTY_PROJECT_FILE_PATH) return Promise.resolve(null); return ensureEnvironmentApi(environmentId).projects.readFile({ cwd, relativePath }); }, catch: (cause) => queryError("Could not read workspace file.", cause), }), ).pipe( Atom.swr({ staleTime: PROJECT_QUERY_STALE_TIME_MS, revalidateOnMount: true, }), Atom.setIdleTTL(PROJECT_QUERY_IDLE_TTL_MS), Atom.withLabel(`projects:file:${key}`), ), ); interface ProjectQueryState { readonly data: A | null; readonly error: string | null; readonly isPending: boolean; readonly refresh: () => void; } export function getProjectEntriesQueryAtom(environmentId: EnvironmentId, cwd: string) { return projectEntriesQueryAtom(entriesKey(environmentId, cwd)); } export function getProjectFileQueryAtom( environmentId: EnvironmentId, cwd: string, relativePath: string | null, ) { return projectFileQueryAtom(fileKey(environmentId, cwd, relativePath ?? EMPTY_PROJECT_FILE_PATH)); } export function setProjectFileQueryData( environmentId: EnvironmentId, cwd: string, relativePath: string, contents: string, ): void { const key = fileKey(environmentId, cwd, relativePath); optimisticProjectFiles.set(key, { confirmed: false, data: { relativePath, contents, byteLength: new TextEncoder().encode(contents).byteLength, truncated: false, }, }); } export function getOptimisticProjectFileQueryData( environmentId: EnvironmentId, cwd: string, relativePath: string, ): ProjectReadFileResult | null { return optimisticProjectFiles.get(fileKey(environmentId, cwd, relativePath))?.data ?? null; } export function confirmProjectFileQueryData( environmentId: EnvironmentId, cwd: string, relativePath: string, contents: string, ): boolean { const key = fileKey(environmentId, cwd, relativePath); const optimisticFile = optimisticProjectFiles.get(key); if (optimisticFile?.data.contents !== contents) return false; optimisticProjectFiles.set(key, { ...optimisticFile, confirmed: true }); appAtomRegistry.refresh(getProjectFileQueryAtom(environmentId, cwd, relativePath)); return true; } export function resolveProjectFileQueryData( environmentId: EnvironmentId, cwd: string, relativePath: string | null, data: ProjectReadFileResult | null, ): ProjectReadFileResult | null { if (relativePath === null) return data; return optimisticProjectFiles.get(fileKey(environmentId, cwd, relativePath))?.data ?? data; } export function __resetProjectFileQueryDataForTests(): void { optimisticProjectFiles.clear(); } function errorMessage(result: AsyncResult.AsyncResult): string | null { if (result._tag !== "Failure") return null; const cause = Cause.squash(result.cause); return cause instanceof Error ? cause.message : "Workspace query failed."; } export function useProjectEntriesQuery( environmentId: EnvironmentId, cwd: string, ): ProjectQueryState { const atom = getProjectEntriesQueryAtom(environmentId, cwd); const result = useAtomValue(atom); const refresh = useCallback(() => appAtomRegistry.refresh(atom), [atom]); return { data: Option.getOrNull(AsyncResult.value(result)), error: errorMessage(result), isPending: result.waiting, refresh, }; } export function useProjectFileQuery( environmentId: EnvironmentId, cwd: string, relativePath: string | null, ): ProjectQueryState { const atom = getProjectFileQueryAtom(environmentId, cwd, relativePath); const result = useAtomValue(atom); const refresh = useCallback(() => appAtomRegistry.refresh(atom), [atom]); const data = Option.getOrNull(AsyncResult.value(result)); const optimisticFile = relativePath === null ? undefined : optimisticProjectFiles.get(fileKey(environmentId, cwd, relativePath)); useEffect(() => { if ( relativePath === null || optimisticFile === undefined || !optimisticFile.confirmed || data?.contents !== optimisticFile.data.contents ) { return; } optimisticProjectFiles.delete(fileKey(environmentId, cwd, relativePath)); }, [cwd, data?.contents, environmentId, optimisticFile, relativePath]); return { data: optimisticFile?.data ?? data, error: errorMessage(result), isPending: result.waiting, refresh, }; }