| 1 | import { |
| 2 | resolveEnvironmentMachineKind, |
| 3 | type EnvironmentMachineKind, |
| 4 | type EnvironmentId, |
| 5 | type ServerConfig, |
| 6 | } from "@t3tools/contracts"; |
| 7 | import * as Option from "effect/Option"; |
| 8 | import { AsyncResult, Atom } from "effect/reactivity"; |
| 9 | |
| 10 | import { AVAILABLE_CONNECTION_STATE, type SupervisorConnectionState } from "../connection/model.ts"; |
| 11 | import { |
| 12 | connectionCatalogDisplayUrl, |
| 13 | presentEnvironmentConnection, |
| 14 | type EnvironmentConnectionPhase, |
| 15 | type EnvironmentPresentation, |
| 16 | } from "../connection/presentation.ts"; |
| 17 | import type { EnvironmentCatalogState } from "./connections.ts"; |
| 18 | import { hasRelayRoute } from "../connection/routes.ts"; |
| 19 | |
| 20 | function 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 | |
| 32 | export 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 | |
| 83 | export 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 | |
| 94 | export 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. |
| 111 | export 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 | } |