| 1 | import { CheckIcon } from "lucide-react"; |
| 2 | import { type ReactNode, useCallback, useMemo, useRef, useState } from "react"; |
| 3 | import { |
| 4 | AuthProvidersManageScope, |
| 5 | type EnvironmentId, |
| 6 | type ServerProvider, |
| 7 | } from "@t3tools/contracts"; |
| 8 | import { |
| 9 | isAtomCommandInterrupted, |
| 10 | squashAtomCommandFailure, |
| 11 | type AtomCommandResult, |
| 12 | } from "@t3tools/client-runtime/state/runtime"; |
| 13 | |
| 14 | import { cn } from "~/lib/utils"; |
| 15 | import { serverEnvironment } from "~/state/server"; |
| 16 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; |
| 17 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 18 | import { useLocalEnvironmentUpdateGroups } from "./ProviderUpdateLaunchNotification.environments"; |
| 19 | import { |
| 20 | collectProviderUpdateOutcomeSnapshots, |
| 21 | firstRejectedProviderUpdateMessage, |
| 22 | getProviderUpdateProgressToastView, |
| 23 | getProviderUpdateSidebarPillView, |
| 24 | isTerminalProviderUpdatePhase, |
| 25 | resolveEnvironmentUpdateRowStatus, |
| 26 | type LocalEnvironmentUpdateGroup, |
| 27 | type LocalProviderUpdateOutcome, |
| 28 | type ProviderUpdateRowStatus, |
| 29 | type ProviderUpdateRowStatusKind, |
| 30 | type ProviderUpdateToastView, |
| 31 | } from "./ProviderUpdateLaunchNotification.logic"; |
| 32 | import { Button } from "./ui/button"; |
| 33 | import { Spinner } from "./ui/spinner"; |
| 34 | |
| 35 | type ProviderUpdateCommandResult = AtomCommandResult< |
| 36 | { readonly providers: ReadonlyArray<ServerProvider> }, |
| 37 | unknown |
| 38 | >; |
| 39 | |
| 40 | /** |
| 41 | * Map one targeted instance's update command result into the settled-outcome |
| 42 | * shape the multi-backend reducers consume: a non-interrupted failure becomes a |
| 43 | * rejection carrying its message; a success carries the post-update snapshot of |
| 44 | * the targeted instance (null when the backend did not report it). |
| 45 | */ |
| 46 | function toProviderUpdateOutcome(input: { |
| 47 | readonly environmentId: EnvironmentId; |
| 48 | readonly isPrimary: boolean; |
| 49 | readonly target: { |
| 50 | readonly driver: ServerProvider["driver"]; |
| 51 | readonly instanceId: ServerProvider["instanceId"]; |
| 52 | }; |
| 53 | readonly result: ProviderUpdateCommandResult; |
| 54 | }): PromiseSettledResult<LocalProviderUpdateOutcome> { |
| 55 | if (input.result._tag === "Failure") { |
| 56 | if (isAtomCommandInterrupted(input.result)) { |
| 57 | // An interrupted dispatch (e.g. superseded) is neither a success nor a |
| 58 | // hard failure — surface it as a non-contributing, non-rejecting outcome. |
| 59 | return { |
| 60 | status: "fulfilled", |
| 61 | value: { |
| 62 | environmentId: input.environmentId, |
| 63 | isPrimary: input.isPrimary, |
| 64 | driver: input.target.driver, |
| 65 | instanceId: input.target.instanceId, |
| 66 | provider: null, |
| 67 | }, |
| 68 | }; |
| 69 | } |
| 70 | const error = squashAtomCommandFailure(input.result); |
| 71 | return { |
| 72 | status: "rejected", |
| 73 | reason: error instanceof Error ? error : new Error("Provider update failed."), |
| 74 | }; |
| 75 | } |
| 76 | |
| 77 | const provider = |
| 78 | input.result.value.providers.find( |
| 79 | (candidate) => candidate.instanceId === input.target.instanceId, |
| 80 | ) ?? null; |
| 81 | return { |
| 82 | status: "fulfilled", |
| 83 | value: { |
| 84 | environmentId: input.environmentId, |
| 85 | isPrimary: input.isPrimary, |
| 86 | driver: input.target.driver, |
| 87 | instanceId: input.target.instanceId, |
| 88 | provider, |
| 89 | }, |
| 90 | }; |
| 91 | } |
| 92 | |
| 93 | // Transport-hang safety net. The dispatch's `finally` clears the spinner and the |
| 94 | // in-flight guard on completion, so this only matters if a request never resolves |
| 95 | // at all (e.g. the socket drops mid-flight without surfacing an error). Keep it |
| 96 | // well beyond the server's own update timeout (5 min) so a legitimately slow |
| 97 | // update (npm installs routinely run tens of seconds) is never cut off and left |
| 98 | // showing a dead, unresponsive Update button. |
| 99 | const PENDING_EXPIRY_MS = 6 * 60_000; |
| 100 | |
| 101 | function rowToneClass(kind: ProviderUpdateRowStatusKind): string { |
| 102 | switch (kind) { |
| 103 | case "failed": |
| 104 | return "text-destructive"; |
| 105 | case "unchanged": |
| 106 | return "text-warning"; |
| 107 | case "success": |
| 108 | return "text-success"; |
| 109 | default: |
| 110 | return "text-muted-foreground"; |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | function EnvironmentUpdateRow({ |
| 115 | group, |
| 116 | status, |
| 117 | onUpdate, |
| 118 | }: { |
| 119 | readonly group: LocalEnvironmentUpdateGroup; |
| 120 | readonly status: ProviderUpdateRowStatus; |
| 121 | readonly onUpdate: () => void; |
| 122 | }) { |
| 123 | const canManageProviders = useEnvironmentScope(group.environmentId, AuthProvidersManageScope); |
| 124 | let trailing: ReactNode; |
| 125 | switch (status.kind) { |
| 126 | case "loading": |
| 127 | trailing = <Spinner size="md" tone="muted" />; |
| 128 | break; |
| 129 | case "success": |
| 130 | trailing = <CheckIcon aria-hidden="true" className="size-4 text-success" />; |
| 131 | break; |
| 132 | case "failed": |
| 133 | case "unchanged": |
| 134 | trailing = ( |
| 135 | <Button size="xs" variant="outline" disabled={!canManageProviders} onClick={onUpdate}> |
| 136 | Retry |
| 137 | </Button> |
| 138 | ); |
| 139 | break; |
| 140 | default: |
| 141 | trailing = ( |
| 142 | <Button size="xs" variant="outline" disabled={!canManageProviders} onClick={onUpdate}> |
| 143 | Update |
| 144 | </Button> |
| 145 | ); |
| 146 | break; |
| 147 | } |
| 148 | |
| 149 | return ( |
| 150 | <div className="flex items-center justify-between gap-3 py-0.5"> |
| 151 | <div className="flex min-w-0 flex-col"> |
| 152 | <span className="truncate font-medium text-foreground">{group.label}</span> |
| 153 | <span className={cn("truncate text-xs", rowToneClass(status.kind))}>{status.text}</span> |
| 154 | {!canManageProviders ? ( |
| 155 | <span className="text-xs text-muted-foreground"> |
| 156 | This connection cannot manage provider accounts. |
| 157 | </span> |
| 158 | ) : null} |
| 159 | </div> |
| 160 | <div className="shrink-0">{trailing}</div> |
| 161 | </div> |
| 162 | ); |
| 163 | } |
| 164 | |
| 165 | /** |
| 166 | * The launch popover's body when WSL is present: one row per local environment |
| 167 | * (Windows + WSL), each with its own "update all" trigger that targets only |
| 168 | * that environment's backend. |
| 169 | */ |
| 170 | export function ProviderUpdateEnvironmentRows({ |
| 171 | onInteract, |
| 172 | }: { |
| 173 | /** Called the first time the user triggers an update, so the host can stop refreshing the prompt. */ |
| 174 | readonly onInteract?: () => void; |
| 175 | }) { |
| 176 | const { groups } = useLocalEnvironmentUpdateGroups(); |
| 177 | const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { |
| 178 | reportFailure: false, |
| 179 | }); |
| 180 | const groupByEnvironment = useMemo( |
| 181 | () => new Map(groups.map((group) => [group.environmentId, group] as const)), |
| 182 | [groups], |
| 183 | ); |
| 184 | |
| 185 | // Only surface results that land after this popover opened. |
| 186 | const visibleAfterIsoRef = useRef<string>(new Date().toISOString()); |
| 187 | |
| 188 | // Synchronous re-entry guard. setPendingEnvironments is an async state update, |
| 189 | // and PENDING_EXPIRY_MS can clear the spinner while a request is still in |
| 190 | // flight, so a rapid double-click (or a click after the expiry fires mid- |
| 191 | // request) would otherwise dispatch a second full round of updates. A ref |
| 192 | // updates synchronously, so we can bail before doing any work. |
| 193 | const inFlightEnvironmentsRef = useRef<Set<EnvironmentId>>(new Set()); |
| 194 | |
| 195 | // Monotonic per-environment request version. Bumped on each dispatch and |
| 196 | // captured locally, so an attempt that was superseded -- e.g. one that already |
| 197 | // tripped the expiry safety net and was retried -- detects it is no longer |
| 198 | // current and skips every state write when it finally resolves, instead of |
| 199 | // clobbering the newer attempt's spinner/result/error or its in-flight guard. |
| 200 | const requestVersionRef = useRef<Map<EnvironmentId, number>>(new Map()); |
| 201 | |
| 202 | const [pendingEnvironments, setPendingEnvironments] = useState<ReadonlySet<EnvironmentId>>( |
| 203 | () => new Set(), |
| 204 | ); |
| 205 | const [errorByEnvironment, setErrorByEnvironment] = useState<ReadonlyMap<EnvironmentId, string>>( |
| 206 | () => new Map(), |
| 207 | ); |
| 208 | const [resultByEnvironment, setResultByEnvironment] = useState< |
| 209 | ReadonlyMap<EnvironmentId, ProviderUpdateToastView> |
| 210 | >(() => new Map()); |
| 211 | |
| 212 | const clearPending = useCallback((environmentId: EnvironmentId) => { |
| 213 | setPendingEnvironments((previous) => { |
| 214 | if (!previous.has(environmentId)) { |
| 215 | return previous; |
| 216 | } |
| 217 | const next = new Set(previous); |
| 218 | next.delete(environmentId); |
| 219 | return next; |
| 220 | }); |
| 221 | }, []); |
| 222 | |
| 223 | const handleUpdate = useCallback( |
| 224 | async (environmentId: EnvironmentId) => { |
| 225 | if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; |
| 226 | const group = groupByEnvironment.get(environmentId); |
| 227 | if (!group || group.candidates.length === 0) { |
| 228 | return; |
| 229 | } |
| 230 | if (inFlightEnvironmentsRef.current.has(environmentId)) { |
| 231 | return; |
| 232 | } |
| 233 | inFlightEnvironmentsRef.current.add(environmentId); |
| 234 | const requestVersion = (requestVersionRef.current.get(environmentId) ?? 0) + 1; |
| 235 | requestVersionRef.current.set(environmentId, requestVersion); |
| 236 | const isCurrentRequest = () => |
| 237 | requestVersionRef.current.get(environmentId) === requestVersion; |
| 238 | onInteract?.(); |
| 239 | const providerCount = group.candidates.length; |
| 240 | const targets = group.candidates.map((candidate) => ({ |
| 241 | driver: candidate.driver, |
| 242 | instanceId: candidate.instanceId, |
| 243 | })); |
| 244 | |
| 245 | setPendingEnvironments((previous) => new Set(previous).add(environmentId)); |
| 246 | setErrorByEnvironment((previous) => { |
| 247 | if (!previous.has(environmentId)) { |
| 248 | return previous; |
| 249 | } |
| 250 | const next = new Map(previous); |
| 251 | next.delete(environmentId); |
| 252 | return next; |
| 253 | }); |
| 254 | setResultByEnvironment((previous) => { |
| 255 | if (!previous.has(environmentId)) { |
| 256 | return previous; |
| 257 | } |
| 258 | const next = new Map(previous); |
| 259 | next.delete(environmentId); |
| 260 | return next; |
| 261 | }); |
| 262 | |
| 263 | const expiry = setTimeout(() => { |
| 264 | // A newer attempt may have superseded this one; if so, leave its state |
| 265 | // untouched. |
| 266 | if (!isCurrentRequest()) { |
| 267 | return; |
| 268 | } |
| 269 | // The request is presumed dead (see PENDING_EXPIRY_MS). Clear the |
| 270 | // spinner AND the in-flight guard together so the row never strands on a |
| 271 | // dead Update button, and surface feedback so the timeout is visible |
| 272 | // rather than silently reverting to idle. |
| 273 | inFlightEnvironmentsRef.current.delete(environmentId); |
| 274 | clearPending(environmentId); |
| 275 | setErrorByEnvironment((previous) => |
| 276 | new Map(previous).set(environmentId, "Update timed out — try again."), |
| 277 | ); |
| 278 | }, PENDING_EXPIRY_MS); |
| 279 | try { |
| 280 | // Dispatch each candidate's update to this environment's own backend and |
| 281 | // normalize every settled outcome into the multi-backend reducer shape. |
| 282 | const results = await Promise.all( |
| 283 | targets.map(async (target): Promise<PromiseSettledResult<LocalProviderUpdateOutcome>> => { |
| 284 | try { |
| 285 | const result = await updateProvider({ |
| 286 | environmentId, |
| 287 | input: { provider: target.driver, instanceId: target.instanceId }, |
| 288 | }); |
| 289 | return toProviderUpdateOutcome({ |
| 290 | environmentId, |
| 291 | isPrimary: group.isPrimary, |
| 292 | target, |
| 293 | result, |
| 294 | }); |
| 295 | } catch (error) { |
| 296 | return { |
| 297 | status: "rejected", |
| 298 | reason: error instanceof Error ? error : new Error("Provider update failed."), |
| 299 | }; |
| 300 | } |
| 301 | }), |
| 302 | ); |
| 303 | if (!isCurrentRequest()) { |
| 304 | // A newer attempt superseded this one while it was in flight; leave |
| 305 | // the newer attempt's state intact. |
| 306 | return; |
| 307 | } |
| 308 | // The request resolved (not a transport hang), so clear any stale |
| 309 | // timeout error the expiry may have set -- otherwise a late success |
| 310 | // would be masked, since an error takes priority in the row status. |
| 311 | setErrorByEnvironment((previous) => { |
| 312 | if (!previous.has(environmentId)) { |
| 313 | return previous; |
| 314 | } |
| 315 | const next = new Map(previous); |
| 316 | next.delete(environmentId); |
| 317 | return next; |
| 318 | }); |
| 319 | if (results.length === 0) { |
| 320 | setErrorByEnvironment((previous) => |
| 321 | new Map(previous).set( |
| 322 | environmentId, |
| 323 | "This environment isn’t connected — try again once it reconnects.", |
| 324 | ), |
| 325 | ); |
| 326 | return; |
| 327 | } |
| 328 | const rejectedMessage = firstRejectedProviderUpdateMessage(results); |
| 329 | if (rejectedMessage) { |
| 330 | setErrorByEnvironment((previous) => |
| 331 | new Map(previous).set(environmentId, rejectedMessage), |
| 332 | ); |
| 333 | return; |
| 334 | } |
| 335 | const view = getProviderUpdateProgressToastView({ |
| 336 | providers: collectProviderUpdateOutcomeSnapshots(results), |
| 337 | providerCount, |
| 338 | }); |
| 339 | // Only persist a terminal outcome. A non-terminal ("running"/"initial") |
| 340 | // view means this dispatch could not confirm completion — e.g. a snapshot |
| 341 | // came back without its targeted instance (collectProviderUpdateOutcome- |
| 342 | // Snapshots drops null providers), which happens when the command is |
| 343 | // interrupted as a second backend connects and supersedes the in-flight |
| 344 | // update. A stored view never re-polls, so persisting it would pin the |
| 345 | // row's spinner forever once the pending flag expires. Drop it and let |
| 346 | // the live per-environment provider state (pill) plus the pending expiry |
| 347 | // drive the row, so it self-heals to whatever the backend actually did. |
| 348 | if (isTerminalProviderUpdatePhase(view.phase)) { |
| 349 | setResultByEnvironment((previous) => new Map(previous).set(environmentId, view)); |
| 350 | } |
| 351 | } catch (error) { |
| 352 | if (isCurrentRequest()) { |
| 353 | setErrorByEnvironment((previous) => |
| 354 | new Map(previous).set( |
| 355 | environmentId, |
| 356 | error instanceof Error ? error.message : "Provider update failed.", |
| 357 | ), |
| 358 | ); |
| 359 | } |
| 360 | } finally { |
| 361 | clearTimeout(expiry); |
| 362 | // Only the current attempt owns the shared spinner and in-flight guard; |
| 363 | // a superseded attempt resolving late must not clear a newer one's. |
| 364 | if (isCurrentRequest()) { |
| 365 | clearPending(environmentId); |
| 366 | inFlightEnvironmentsRef.current.delete(environmentId); |
| 367 | } |
| 368 | } |
| 369 | }, |
| 370 | [clearPending, groupByEnvironment, onInteract, updateProvider], |
| 371 | ); |
| 372 | |
| 373 | const rows = groups |
| 374 | .map((group) => ({ |
| 375 | group, |
| 376 | status: resolveEnvironmentUpdateRowStatus({ |
| 377 | group, |
| 378 | error: errorByEnvironment.get(group.environmentId), |
| 379 | result: resultByEnvironment.get(group.environmentId), |
| 380 | // Derive the live pill from the candidates this row is actually |
| 381 | // tracking, not every provider in the environment. Otherwise an |
| 382 | // unrelated provider's recent success (or one candidate succeeding while |
| 383 | // another was interrupted) makes the pill report success and hides the |
| 384 | // Update action for candidates that are still outdated. |
| 385 | pill: getProviderUpdateSidebarPillView(group.candidates, { |
| 386 | visibleAfterIso: visibleAfterIsoRef.current, |
| 387 | }), |
| 388 | isPending: pendingEnvironments.has(group.environmentId), |
| 389 | }), |
| 390 | })) |
| 391 | .filter(({ group, status }) => group.candidates.length > 0 || status.kind !== "idle"); |
| 392 | |
| 393 | if (rows.length === 0) { |
| 394 | return null; |
| 395 | } |
| 396 | |
| 397 | return ( |
| 398 | <div className="mt-0.5 flex flex-col gap-1"> |
| 399 | {rows.map(({ group, status }) => ( |
| 400 | <EnvironmentUpdateRow |
| 401 | key={group.environmentId} |
| 402 | group={group} |
| 403 | status={status} |
| 404 | onUpdate={() => handleUpdate(group.environmentId)} |
| 405 | /> |
| 406 | ))} |
| 407 | </div> |
| 408 | ); |
| 409 | } |