import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts"; import type { ServerUpdateStage, ServerUpdateState } from "@t3tools/client-runtime/state/server"; import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { type ComponentProps, useRef, useState } from "react"; import { requestConfirmDialog } from "~/confirmDialog"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useEnvironmentSettings } from "~/hooks/useSettings"; import { serverEnvironment } from "~/state/server"; import { useAtomCommand } from "~/state/use-atom-command"; import { manualServerUpdateCommand } from "~/versionSkew"; import { Button } from "./ui/button"; import { toastManager } from "./ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; // The wire "installing" stage is a sub-second launcher handoff, so the UI // folds it into the download phase; everything after the handoff is the // restart the user is actually waiting through. const UPDATE_STAGE_LABELS: Record = { downloading: "Downloading…", installing: "Downloading…", resuming: "Restarting…", }; const pendingUpdateEnvironmentIds = new Set(); export function serverUpdateStageLabel(stage: ServerUpdateStage): string { return UPDATE_STAGE_LABELS[stage]; } function updateFailureMessage(error: unknown): string { return error instanceof Error ? error.message : "Server update failed."; } export interface ServerUpdateTarget { readonly environmentId: EnvironmentId; readonly serverLabel: string; readonly selfUpdate: ServerSelfUpdateCapability | null; readonly desktopAppUpdate?: boolean; readonly threadContinuation?: boolean; readonly targetVersion: string; readonly continueThreadsAfterServerUpdate?: boolean; } type UpdateButtonProps = Pick, "variant" | "size"> & { readonly label?: string; }; function useServerUpdate() { const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false }); return async (target: ServerUpdateTarget, failureTitle = "Server update failed") => { const { environmentId, serverLabel, selfUpdate, targetVersion } = target; if (pendingUpdateEnvironmentIds.has(environmentId)) return; pendingUpdateEnvironmentIds.add(environmentId); try { const result = await updateServer({ environmentId, input: { targetVersion, ...(target.threadContinuation && target.continueThreadsAfterServerUpdate ? { continueRunningThreads: true } : {}), }, }); if (result._tag === "Failure") { if (isAtomCommandInterrupted(result)) return; throw squashAtomCommandFailure(result); } toastManager.add({ type: "success", title: `${serverLabel} updated`, description: selfUpdate === "desktop-managed" ? `Desktop app relaunched on ${result.value.targetVersion}.` : `Reconnected on t3@${result.value.targetVersion}.`, }); } catch (error) { toastManager.add({ type: "error", title: failureTitle, description: updateFailureMessage(error), }); } finally { pendingUpdateEnvironmentIds.delete(environmentId); } }; } /** Updates eligible machines independently; manual paths remain in the machine list. */ export function ServerUpdatesAction({ targets, label = "Update all", variant = "outline", size = "xs", }: UpdateButtonProps & { readonly targets: ReadonlyArray; }) { const update = useServerUpdate(); const pending = useRef(false); const [isPending, setIsPending] = useState(false); const eligible = targets.filter( (target) => target.selfUpdate !== null && (target.selfUpdate !== "desktop-managed" || target.desktopAppUpdate), ); const handleUpdate = async () => { if (pending.current) return; pending.current = true; setIsPending(true); try { const available = eligible.filter( (target) => !pendingUpdateEnvironmentIds.has(target.environmentId), ); const desktopTargets = available.filter((target) => target.selfUpdate === "desktop-managed"); if (desktopTargets.length > 0) { const confirmed = (await requestConfirmDialog( `Update the T3 Code desktop apps on ${desktopTargets.map((target) => target.serverLabel).join(", ")}? They will close and relaunch on those machines.`, )) ?? true; if (!confirmed) return; } await Promise.all( available.map((target) => update(target, `${target.serverLabel} update failed`)), ); } finally { pending.current = false; setIsPending(false); } }; return ( ); } /** * One-row status for an in-flight server update: "Downloading…" then * "Restarting…". The update is a wait, not a warning: a single pulsing dot * and label, no step rail, no versions. Failure turns the row red with the * rollback reason. */ export function ServerUpdateProgress({ state, }: { readonly state: Exclude; }) { if (state.status === "failed") { return (
} /> {state.message}
); } return (
); } /** * Offers the update path advertised by a version-skewed server. Self-updates * delegate their full lifecycle to client-runtime so this component can * unmount during reconnect without losing operation state. */ export function ServerUpdateAction({ environmentId, serverLabel, selfUpdate, desktopAppUpdate = false, threadContinuation = false, targetVersion, label = "Update", variant = "outline", size = "xs", }: Omit & UpdateButtonProps) { const isDesktopAppUpdate = selfUpdate === "desktop-managed"; const continueThreadsAfterServerUpdate = useEnvironmentSettings( environmentId, (settings) => settings.continueThreadsAfterServerUpdate, ); const update = useServerUpdate(); const { copyToClipboard } = useCopyToClipboard<{ command: string }>({ target: "update command", onCopy: ({ command }) => { toastManager.add({ type: "success", title: "Update command copied", description: `Run \`${command}\` on ${serverLabel} to update it.`, }); }, onError: (error) => { toastManager.add({ type: "error", title: "Could not copy update command", description: error.message, }); }, }); const handleUpdate = async () => { if (pendingUpdateEnvironmentIds.has(environmentId)) { return; } if (isDesktopAppUpdate) { // No themed host mounted (undefined) means proceed: the click itself // was the request. This is the only confirmation in the flow; the // remote machine installs without asking anyone there. const confirmed = (await requestConfirmDialog( `Update the T3 Code desktop app that runs the ${serverLabel}? It will close and relaunch on that machine.`, )) ?? true; if (!confirmed) { return; } } await update({ environmentId, serverLabel, selfUpdate, desktopAppUpdate, threadContinuation, targetVersion, continueThreadsAfterServerUpdate, }); }; if (selfUpdate === "desktop-managed" && !desktopAppUpdate) { return ( Update the desktop app on that machine to update this server. ); } if (selfUpdate === null) { const command = manualServerUpdateCommand(targetVersion); return ( ); } return ( ); }