397 lines · 14.5 KB · TypeScript
RawDownload
import { CheckIcon } from "lucide-react";import { type ReactNode, useCallback, useMemo, useRef, useState } from "react";import type { EnvironmentId, ServerProvider } from "@t3tools/contracts";import {  isAtomCommandInterrupted,  squashAtomCommandFailure,  type AtomCommandResult,} from "@t3tools/client-runtime/state/runtime"; import { cn } from "~/lib/utils";import { serverEnvironment } from "~/state/server";import { useAtomCommand } from "~/state/use-atom-command";import { useLocalEnvironmentUpdateGroups } from "./ProviderUpdateLaunchNotification.environments";import {  collectProviderUpdateOutcomeSnapshots,  firstRejectedProviderUpdateMessage,  getProviderUpdateProgressToastView,  getProviderUpdateSidebarPillView,  isTerminalProviderUpdatePhase,  resolveEnvironmentUpdateRowStatus,  type LocalEnvironmentUpdateGroup,  type LocalProviderUpdateOutcome,  type ProviderUpdateRowStatus,  type ProviderUpdateRowStatusKind,  type ProviderUpdateToastView,} from "./ProviderUpdateLaunchNotification.logic";import { Button } from "./ui/button";import { Spinner } from "./ui/spinner"; type ProviderUpdateCommandResult = AtomCommandResult<  { readonly providers: ReadonlyArray<ServerProvider> },  unknown>; /** * Map one targeted instance's update command result into the settled-outcome * shape the multi-backend reducers consume: a non-interrupted failure becomes a * rejection carrying its message; a success carries the post-update snapshot of * the targeted instance (null when the backend did not report it). */function toProviderUpdateOutcome(input: {  readonly environmentId: EnvironmentId;  readonly isPrimary: boolean;  readonly target: {    readonly driver: ServerProvider["driver"];    readonly instanceId: ServerProvider["instanceId"];  };  readonly result: ProviderUpdateCommandResult;}): PromiseSettledResult<LocalProviderUpdateOutcome> {  if (input.result._tag === "Failure") {    if (isAtomCommandInterrupted(input.result)) {      // An interrupted dispatch (e.g. superseded) is neither a success nor a      // hard failure — surface it as a non-contributing, non-rejecting outcome.      return {        status: "fulfilled",        value: {          environmentId: input.environmentId,          isPrimary: input.isPrimary,          driver: input.target.driver,          instanceId: input.target.instanceId,          provider: null,        },      };    }    const error = squashAtomCommandFailure(input.result);    return {      status: "rejected",      reason: error instanceof Error ? error : new Error("Provider update failed."),    };  }   const provider =    input.result.value.providers.find(      (candidate) => candidate.instanceId === input.target.instanceId,    ) ?? null;  return {    status: "fulfilled",    value: {      environmentId: input.environmentId,      isPrimary: input.isPrimary,      driver: input.target.driver,      instanceId: input.target.instanceId,      provider,    },  };} // Transport-hang safety net. The dispatch's `finally` clears the spinner and the// in-flight guard on completion, so this only matters if a request never resolves// at all (e.g. the socket drops mid-flight without surfacing an error). Keep it// well beyond the server's own update timeout (5 min) so a legitimately slow// update (npm installs routinely run tens of seconds) is never cut off and left// showing a dead, unresponsive Update button.const PENDING_EXPIRY_MS = 6 * 60_000; function rowToneClass(kind: ProviderUpdateRowStatusKind): string {  switch (kind) {    case "failed":      return "text-destructive";    case "unchanged":      return "text-warning";    case "success":      return "text-success";    default:      return "text-muted-foreground";  }} function EnvironmentUpdateRow({  group,  status,  onUpdate,}: {  readonly group: LocalEnvironmentUpdateGroup;  readonly status: ProviderUpdateRowStatus;  readonly onUpdate: () => void;}) {  let trailing: ReactNode;  switch (status.kind) {    case "loading":      trailing = <Spinner className="size-4 text-muted-foreground" />;      break;    case "success":      trailing = <CheckIcon aria-hidden="true" className="size-4 text-success" />;      break;    case "failed":    case "unchanged":      trailing = (        <Button size="xs" variant="outline" onClick={onUpdate}>          Retry        </Button>      );      break;    default:      trailing = (        <Button size="xs" onClick={onUpdate}>          Update        </Button>      );      break;  }   return (    <div className="flex items-center justify-between gap-3 py-0.5">      <div className="flex min-w-0 flex-col">        <span className="truncate font-medium text-foreground">{group.label}</span>        <span className={cn("truncate text-xs", rowToneClass(status.kind))}>{status.text}</span>      </div>      <div className="shrink-0">{trailing}</div>    </div>  );} /** * The launch popover's body when WSL is present: one row per local environment * (Windows + WSL), each with its own "update all" trigger that targets only * that environment's backend. */export function ProviderUpdateEnvironmentRows({  onInteract,}: {  /** Called the first time the user triggers an update, so the host can stop refreshing the prompt. */  readonly onInteract?: () => void;}) {  const { groups } = useLocalEnvironmentUpdateGroups();  const updateProvider = useAtomCommand(serverEnvironment.updateProvider, {    reportFailure: false,  });  const groupByEnvironment = useMemo(    () => new Map(groups.map((group) => [group.environmentId, group] as const)),    [groups],  );   // Only surface results that land after this popover opened.  const visibleAfterIsoRef = useRef<string>(new Date().toISOString());   // Synchronous re-entry guard. setPendingEnvironments is an async state update,  // and PENDING_EXPIRY_MS can clear the spinner while a request is still in  // flight, so a rapid double-click (or a click after the expiry fires mid-  // request) would otherwise dispatch a second full round of updates. A ref  // updates synchronously, so we can bail before doing any work.  const inFlightEnvironmentsRef = useRef<Set<EnvironmentId>>(new Set());   // Monotonic per-environment request version. Bumped on each dispatch and  // captured locally, so an attempt that was superseded -- e.g. one that already  // tripped the expiry safety net and was retried -- detects it is no longer  // current and skips every state write when it finally resolves, instead of  // clobbering the newer attempt's spinner/result/error or its in-flight guard.  const requestVersionRef = useRef<Map<EnvironmentId, number>>(new Map());   const [pendingEnvironments, setPendingEnvironments] = useState<ReadonlySet<EnvironmentId>>(    () => new Set(),  );  const [errorByEnvironment, setErrorByEnvironment] = useState<ReadonlyMap<EnvironmentId, string>>(    () => new Map(),  );  const [resultByEnvironment, setResultByEnvironment] = useState<    ReadonlyMap<EnvironmentId, ProviderUpdateToastView>  >(() => new Map());   const clearPending = useCallback((environmentId: EnvironmentId) => {    setPendingEnvironments((previous) => {      if (!previous.has(environmentId)) {        return previous;      }      const next = new Set(previous);      next.delete(environmentId);      return next;    });  }, []);   const handleUpdate = useCallback(    async (environmentId: EnvironmentId) => {      const group = groupByEnvironment.get(environmentId);      if (!group || group.candidates.length === 0) {        return;      }      if (inFlightEnvironmentsRef.current.has(environmentId)) {        return;      }      inFlightEnvironmentsRef.current.add(environmentId);      const requestVersion = (requestVersionRef.current.get(environmentId) ?? 0) + 1;      requestVersionRef.current.set(environmentId, requestVersion);      const isCurrentRequest = () =>        requestVersionRef.current.get(environmentId) === requestVersion;      onInteract?.();      const providerCount = group.candidates.length;      const targets = group.candidates.map((candidate) => ({        driver: candidate.driver,        instanceId: candidate.instanceId,      }));       setPendingEnvironments((previous) => new Set(previous).add(environmentId));      setErrorByEnvironment((previous) => {        if (!previous.has(environmentId)) {          return previous;        }        const next = new Map(previous);        next.delete(environmentId);        return next;      });      setResultByEnvironment((previous) => {        if (!previous.has(environmentId)) {          return previous;        }        const next = new Map(previous);        next.delete(environmentId);        return next;      });       const expiry = setTimeout(() => {        // A newer attempt may have superseded this one; if so, leave its state        // untouched.        if (!isCurrentRequest()) {          return;        }        // The request is presumed dead (see PENDING_EXPIRY_MS). Clear the        // spinner AND the in-flight guard together so the row never strands on a        // dead Update button, and surface feedback so the timeout is visible        // rather than silently reverting to idle.        inFlightEnvironmentsRef.current.delete(environmentId);        clearPending(environmentId);        setErrorByEnvironment((previous) =>          new Map(previous).set(environmentId, "Update timed out — try again."),        );      }, PENDING_EXPIRY_MS);      try {        // Dispatch each candidate's update to this environment's own backend and        // normalize every settled outcome into the multi-backend reducer shape.        const results = await Promise.all(          targets.map(async (target): Promise<PromiseSettledResult<LocalProviderUpdateOutcome>> => {            try {              const result = await updateProvider({                environmentId,                input: { provider: target.driver, instanceId: target.instanceId },              });              return toProviderUpdateOutcome({                environmentId,                isPrimary: group.isPrimary,                target,                result,              });            } catch (error) {              return {                status: "rejected",                reason: error instanceof Error ? error : new Error("Provider update failed."),              };            }          }),        );        if (!isCurrentRequest()) {          // A newer attempt superseded this one while it was in flight; leave          // the newer attempt's state intact.          return;        }        // The request resolved (not a transport hang), so clear any stale        // timeout error the expiry may have set -- otherwise a late success        // would be masked, since an error takes priority in the row status.        setErrorByEnvironment((previous) => {          if (!previous.has(environmentId)) {            return previous;          }          const next = new Map(previous);          next.delete(environmentId);          return next;        });        if (results.length === 0) {          setErrorByEnvironment((previous) =>            new Map(previous).set(              environmentId,              "This environment isn’t connected — try again once it reconnects.",            ),          );          return;        }        const rejectedMessage = firstRejectedProviderUpdateMessage(results);        if (rejectedMessage) {          setErrorByEnvironment((previous) =>            new Map(previous).set(environmentId, rejectedMessage),          );          return;        }        const view = getProviderUpdateProgressToastView({          providers: collectProviderUpdateOutcomeSnapshots(results),          providerCount,        });        // Only persist a terminal outcome. A non-terminal ("running"/"initial")        // view means this dispatch could not confirm completion — e.g. a snapshot        // came back without its targeted instance (collectProviderUpdateOutcome-        // Snapshots drops null providers), which happens when the command is        // interrupted as a second backend connects and supersedes the in-flight        // update. A stored view never re-polls, so persisting it would pin the        // row's spinner forever once the pending flag expires. Drop it and let        // the live per-environment provider state (pill) plus the pending expiry        // drive the row, so it self-heals to whatever the backend actually did.        if (isTerminalProviderUpdatePhase(view.phase)) {          setResultByEnvironment((previous) => new Map(previous).set(environmentId, view));        }      } catch (error) {        if (isCurrentRequest()) {          setErrorByEnvironment((previous) =>            new Map(previous).set(              environmentId,              error instanceof Error ? error.message : "Provider update failed.",            ),          );        }      } finally {        clearTimeout(expiry);        // Only the current attempt owns the shared spinner and in-flight guard;        // a superseded attempt resolving late must not clear a newer one's.        if (isCurrentRequest()) {          clearPending(environmentId);          inFlightEnvironmentsRef.current.delete(environmentId);        }      }    },    [clearPending, groupByEnvironment, onInteract, updateProvider],  );   const rows = groups    .map((group) => ({      group,      status: resolveEnvironmentUpdateRowStatus({        group,        error: errorByEnvironment.get(group.environmentId),        result: resultByEnvironment.get(group.environmentId),        // Derive the live pill from the candidates this row is actually        // tracking, not every provider in the environment. Otherwise an        // unrelated provider's recent success (or one candidate succeeding while        // another was interrupted) makes the pill report success and hides the        // Update action for candidates that are still outdated.        pill: getProviderUpdateSidebarPillView(group.candidates, {          visibleAfterIso: visibleAfterIsoRef.current,        }),        isPending: pendingEnvironments.has(group.environmentId),      }),    }))    .filter(({ group, status }) => group.candidates.length > 0 || status.kind !== "idle");   if (rows.length === 0) {    return null;  }   return (    <div className="mt-0.5 flex flex-col gap-1">      {rows.map(({ group, status }) => (        <EnvironmentUpdateRow          key={group.environmentId}          group={group}          status={status}          onUpdate={() => handleUpdate(group.environmentId)}        />      ))}    </div>  );}