1import { useAtomValue } from "@effect/atom-react";
2import { parseScopedProjectKey, scopedProjectKey } from "@t3tools/client-runtime/environment";
3import type { EnvironmentId, ProjectCloneSnapshot, ScopedProjectRef } from "@t3tools/contracts";
4import * as Option from "effect/Option";
5import { AsyncResult, Atom } from "effect/reactivity";
6
7import { serverEnvironment } from "./server";
8import { sourceControlEnvironment } from "./sourceControl";
9
10/**
11 * `"pending"` while the environment's clone stream has not delivered its
12 * first list yet: the draft opens right after a clone starts, and a caller
13 * that treated the gap as "no clone" would enable Start for a moment.
14 */
15export type ProjectCloneState = ProjectCloneSnapshot | "pending" | null;
16
17const EMPTY_CLONES: ReadonlyArray<ProjectCloneSnapshot> = [];
18const EMPTY_CLONE_ATOM = Atom.make<ProjectCloneState>(null).pipe(
19 Atom.withLabel("mobile-project-clone:empty"),
20);
21
22/**
23 * Latest clone list an environment has streamed, `"pending"` until the first
24 * one, and never subscribed on servers that predate clone tracking.
25 */
26const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId) =>
27 Atom.make((get): ReadonlyArray<ProjectCloneSnapshot> | "pending" => {
28 const config = get(serverEnvironment.configValueAtom(environmentId));
29 if (config?.environment.capabilities.projectCloneTracking !== true) return EMPTY_CLONES;
30 const result = get(sourceControlEnvironment.projectClones({ environmentId, input: {} }));
31 // A failed subscription must not hold Start forever. Treating it as
32 // "no clone tracked" lets the server's own dispatch guard decide; the
33 // registry re-establishes the stream on reconnect.
34 if (result._tag === "Failure") return EMPTY_CLONES;
35 return Option.getOrElse(AsyncResult.value(result), () => "pending" as const);
36 }).pipe(Atom.withLabel(`mobile-project-clones:${environmentId}`)),
37);
38
39/** The environment's tracked clones, empty until the stream's first list. */
40export const environmentProjectCloneListAtom = Atom.family((environmentId: EnvironmentId) =>
41 Atom.make((get): ReadonlyArray<ProjectCloneSnapshot> => {
42 const clones = get(environmentProjectClonesAtom(environmentId));
43 return clones === "pending" ? EMPTY_CLONES : clones;
44 }).pipe(Atom.withLabel(`mobile-project-clone-list:${environmentId}`)),
45);
46
47const projectCloneAtom = Atom.family((key: string) => {
48 const ref = parseScopedProjectKey(key);
49 return Atom.make((get): ProjectCloneState => {
50 if (ref === null) return null;
51 const clones = get(environmentProjectClonesAtom(ref.environmentId));
52 if (clones === "pending") return "pending";
53 return clones.find((clone) => clone.projectId === ref.projectId) ?? null;
54 }).pipe(Atom.withLabel(`mobile-project-clone:${key}`));
55});
56
57/**
58 * The tracked clone for a project: `"pending"` before the stream's first
59 * list, null once it finished or never started.
60 */
61export function useProjectClone(ref: ScopedProjectRef | null): ProjectCloneState {
62 return useAtomValue(ref === null ? EMPTY_CLONE_ATOM : projectCloneAtom(scopedProjectKey(ref)));
63}