1import {
2 resolveEnvironmentMachineKind,
3 type EnvironmentMachineKind,
4 type EnvironmentId,
5 type ServerConfig,
6} from "@t3tools/contracts";
7import * as Option from "effect/Option";
8import { AsyncResult, Atom } from "effect/reactivity";
9
10import { AVAILABLE_CONNECTION_STATE, type SupervisorConnectionState } from "../connection/model.ts";
11import {
12 connectionCatalogDisplayUrl,
13 presentEnvironmentConnection,
14 type EnvironmentConnectionPhase,
15 type EnvironmentPresentation,
16} from "../connection/presentation.ts";
17import type { EnvironmentCatalogState } from "./connections.ts";
18import { hasRelayRoute } from "../connection/routes.ts";
19
20function mapsEqual<K, V>(left: ReadonlyMap<K, V>, right: ReadonlyMap<K, V>): boolean {
21 if (left.size !== right.size) {
22 return false;
23 }
24 for (const [key, value] of left) {
25 if (right.get(key) !== value) {
26 return false;
27 }
28 }
29 return true;
30}
31
32export function createEnvironmentPresentationAtoms<E>(input: {
33 readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>;
34 readonly stateAtom: (
35 environmentId: EnvironmentId,
36 ) => Atom.Atom<AsyncResult.AsyncResult<SupervisorConnectionState, E>>;
37 /** Authoritative live server config, including streamed provider/settings updates. */
38 readonly serverConfigValueAtom: (environmentId: EnvironmentId) => Atom.Atom<ServerConfig | null>;
39}) {
40 const presentationAtom = Atom.family((environmentId: EnvironmentId) =>
41 Atom.make((get) => {
42 const entry = get(input.catalogValueAtom).entries.get(environmentId);
43 if (entry === undefined) {
44 return null;
45 }
46 const state = Option.getOrElse(
47 AsyncResult.value(get(input.stateAtom(environmentId))),
48 () => AVAILABLE_CONNECTION_STATE,
49 );
50 return {
51 entry,
52 connection:
53 entry.unsupportedReason === undefined
54 ? presentEnvironmentConnection(state)
55 : { phase: "unsupported", error: entry.unsupportedReason, traceId: null },
56 serverConfig: get(input.serverConfigValueAtom(environmentId)),
57 } satisfies EnvironmentPresentation;
58 }).pipe(Atom.withLabel(`environment-presentation:${environmentId}`)),
59 );
60
61 let previous: ReadonlyMap<EnvironmentId, EnvironmentPresentation> = new Map();
62 const presentationsAtom = Atom.make((get) => {
63 const next = new Map<EnvironmentId, EnvironmentPresentation>();
64 for (const environmentId of get(input.catalogValueAtom).entries.keys()) {
65 const presentation = get(presentationAtom(environmentId));
66 if (presentation !== null) {
67 next.set(environmentId, presentation);
68 }
69 }
70 if (mapsEqual(previous, next)) {
71 return previous;
72 }
73 previous = next;
74 return previous;
75 }).pipe(Atom.withLabel("environment-presentations"));
76
77 return {
78 presentationAtom,
79 presentationsAtom,
80 };
81}
82
83export interface EnvironmentConnectionSummary {
84 readonly environmentId: EnvironmentId;
85 readonly environmentLabel: string;
86 readonly displayUrl: string;
87 readonly isRelayManaged: boolean;
88 readonly isEnabled: boolean;
89 readonly connectionState: EnvironmentConnectionPhase;
90 readonly connectionError: string | null;
91 readonly connectionErrorTraceId: string | null;
92}
93
94export function projectEnvironmentConnectionSummary(
95 environmentId: EnvironmentId,
96 environment: EnvironmentPresentation,
97): EnvironmentConnectionSummary {
98 return {
99 environmentId,
100 environmentLabel: environment.entry.target.label,
101 displayUrl: connectionCatalogDisplayUrl(environment.entry) ?? "",
102 isRelayManaged: hasRelayRoute(environment.entry),
103 isEnabled: environment.entry.enabled,
104 connectionState: environment.connection.phase,
105 connectionError: environment.connection.error,
106 connectionErrorTraceId: environment.connection.traceId,
107 };
108}
109
110// Keep list membership and connection chrome independent of provider/config refreshes.
111export function createEnvironmentSummaryAtoms(input: {
112 readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>;
113 readonly presentationAtom: (
114 environmentId: EnvironmentId,
115 ) => Atom.Atom<EnvironmentPresentation | null>;
116}) {
117 const environmentIdsAtom = Atom.make((get) => {
118 const next = [...get(input.catalogValueAtom).entries.keys()];
119 const previous = Option.getOrNull(get.self<ReadonlyArray<EnvironmentId>>());
120 return previous !== null &&
121 previous.length === next.length &&
122 next.every((id, index) => id === previous[index])
123 ? previous
124 : next;
125 });
126 const identitiesAtom = Atom.make((get) => {
127 const next = [...get(input.catalogValueAtom).entries].map(([environmentId, entry]) => ({
128 environmentId,
129 label: entry.target.label,
130 }));
131 const previous = Option.getOrNull(
132 get.self<ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }>>(),
133 );
134 return previous !== null &&
135 previous.length === next.length &&
136 next.every(
137 (value, index) =>
138 value.environmentId === previous[index]?.environmentId &&
139 value.label === previous[index]?.label,
140 )
141 ? previous
142 : next;
143 });
144 const environmentAtom = Atom.family((environmentId: EnvironmentId) =>
145 Atom.make((get) => {
146 const presentation = get(input.presentationAtom(environmentId));
147 if (presentation === null) return null;
148 const next = projectEnvironmentConnectionSummary(environmentId, presentation);
149 const previous = Option.getOrNull(get.self<EnvironmentConnectionSummary | null>());
150 // Provider refreshes and transport heartbeats do not change connection UI.
151 if (
152 previous !== null &&
153 previous.environmentId === next.environmentId &&
154 previous.environmentLabel === next.environmentLabel &&
155 previous.displayUrl === next.displayUrl &&
156 previous.isRelayManaged === next.isRelayManaged &&
157 previous.isEnabled === next.isEnabled &&
158 previous.connectionState === next.connectionState &&
159 previous.connectionError === next.connectionError &&
160 previous.connectionErrorTraceId === next.connectionErrorTraceId
161 )
162 return previous;
163 return next;
164 }),
165 );
166 const environmentsAtom = Atom.make((get) => {
167 const next: Array<EnvironmentConnectionSummary> = [];
168 for (const environmentId of get(input.catalogValueAtom).entries.keys()) {
169 const environment = get(environmentAtom(environmentId));
170 if (environment !== null) next.push(environment);
171 }
172 const previous = Option.getOrNull(get.self<ReadonlyArray<EnvironmentConnectionSummary>>());
173 return previous !== null &&
174 previous.length === next.length &&
175 next.every((value, index) => value === previous[index])
176 ? previous
177 : next;
178 }).pipe(Atom.withLabel("environment-connection-summaries"));
179 const connectedEnvironmentIdsAtom = Atom.make((get) => {
180 const next = get(environmentIdsAtom).filter(
181 (id) => get(environmentAtom(id))?.connectionState === "connected",
182 );
183 const previous = Option.getOrNull(get.self<ReadonlyArray<EnvironmentId>>());
184 return previous !== null &&
185 previous.length === next.length &&
186 next.every((id, index) => id === previous[index])
187 ? previous
188 : next;
189 });
190 const machineByIdAtom = Atom.make((get) => {
191 const next = new Map(
192 get(environmentIdsAtom).map(
193 (environmentId) =>
194 [
195 environmentId,
196 resolveEnvironmentMachineKind(
197 get(input.presentationAtom(environmentId))?.serverConfig ?? null,
198 ),
199 ] as const,
200 ),
201 );
202 const previous = Option.getOrNull(
203 get.self<ReadonlyMap<EnvironmentId, EnvironmentMachineKind>>(),
204 );
205 return previous !== null && mapsEqual(previous, next) ? previous : next;
206 });
207 const pullRequestsSupportedAtom = Atom.make((get) =>
208 get(environmentIdsAtom).some(
209 (environmentId) =>
210 get(input.presentationAtom(environmentId))?.serverConfig?.environment.capabilities
211 .pullRequests === true,
212 ),
213 );
214 return {
215 environmentIdsAtom,
216 connectedEnvironmentIdsAtom,
217 identitiesAtom,
218 environmentsAtom,
219 machineByIdAtom,
220 pullRequestsSupportedAtom,
221 };
222}