1import { CheckIcon } from "lucide-react";
2import { type ReactNode, useCallback, useMemo, useRef, useState } from "react";
3import {
4 AuthProvidersManageScope,
5 type EnvironmentId,
6 type ServerProvider,
7} from "@t3tools/contracts";
8import {
9 isAtomCommandInterrupted,
10 squashAtomCommandFailure,
11 type AtomCommandResult,
12} from "@t3tools/client-runtime/state/runtime";
13
14import { cn } from "~/lib/utils";
15import { serverEnvironment } from "~/state/server";
16import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
17import { useAtomCommand } from "~/state/use-atom-command";
18import { useLocalEnvironmentUpdateGroups } from "./ProviderUpdateLaunchNotification.environments";
19import {
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";
32import { Button } from "./ui/button";
33import { Spinner } from "./ui/spinner";
34
35type 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 */
46function 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.
99const PENDING_EXPIRY_MS = 6 * 60_000;
100
101function 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
114function 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 */
170export 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}