425 lines · 15.9 KB · TypeScript
RawDownload
import type {  DevicePlatform,  DeviceServiceState,  DeviceSummary,  ScopedThreadRef,} from "@t3tools/contracts";import {  ChevronLeft,  Home,  Power,  RotateCcw,  SlidersHorizontal,  Smartphone,  Square,  X,} from "lucide-react";import { useEffect, useMemo, useState } from "react"; import { useRightPanelStore, type RightPanelSurface } from "~/rightPanelStore";import { Button } from "~/components/ui/button";import { DiscoveryList, DiscoveryListRow } from "~/components/ui/discovery-list";import { Dialog } from "~/components/ui/dialog";import { WizardPopup } from "~/components/ui/wizard";import { Spinner } from "~/components/ui/spinner";import { Toggle } from "~/components/ui/toggle";import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";import { cn } from "~/lib/utils";import { deviceEnvironment, useDeviceHubAccess, useDeviceState } from "~/state/device";import { formatEnvironmentQueryError } from "~/state/query";import { useAtomCommand } from "~/state/use-atom-command";import { DeviceStreamView, type DeviceStreamHandle } from "./DeviceStreamView";import { DeviceLoadingView } from "./DeviceLoadingView";import { DeviceSetup } from "./DeviceSetup";import { DeviceToolsPanel } from "./DeviceToolsPanel";import { PreviewPanelShell, type PreviewPanelMode } from "../preview/PreviewPanelShell"; const platformLabel = (platform: DevicePlatform) =>  platform === "ios" ? "iOS Simulators" : "Android Emulators"; const deviceKey = (device: Pick<DeviceSummary, "hostId" | "id">) =>  `${device.hostId}\u0000${device.id}`; /** Each surface owns one host/device; only the visible surface streams. */export function DevicePanel(props: {  readonly mode: PreviewPanelMode;  readonly threadRef: ScopedThreadRef;  readonly surface: Extract<RightPanelSurface, { kind: "device" }>;  readonly visible: boolean;  readonly onDismissSetup: () => void;}) {  const { environmentId, threadId } = props.threadRef;  const { state, loaded } = useDeviceState(environmentId);  const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false });  const open = useAtomCommand(deviceEnvironment.open);  const close = useAtomCommand(deviceEnvironment.close);  const [operationError, setOperationError] = useState<string | null>(null);  const [pendingDevice, setPendingDevice] = useState<DeviceSummary | null>(null);  const pendingDeviceKey = pendingDevice ? deviceKey(pendingDevice) : null;  const [handle, setHandle] = useState<DeviceStreamHandle | null>(null);  const [toolsOpen, setToolsOpen] = useState(false);  const [axOverlay, setAxOverlay] = useState(false);  const access = useDeviceHubAccess(environmentId);   const hostDisabled = state.hostStatus === "disabled";   // Opening setup never grants permission to install or start helpers.  useEffect(() => {    if (!props.visible || !loaded || hostDisabled) return;    void list({ environmentId, input: {} });  }, [environmentId, list, loaded, props.visible, hostDisabled]);   const sessions = useMemo(    () => state.sessions.filter((session) => session.threadId === threadId),    [state.sessions, threadId],  );  const activeSession = props.surface.target    ? sessions.find(        (session) =>          session.deviceId === props.surface.target?.deviceId &&          session.hostId === props.surface.target.hostId,      )    : undefined;  const activeDevice = activeSession    ? state.devices.find(        (device) => device.hostId === activeSession.hostId && device.id === activeSession.deviceId,      )    : undefined;   const grouped = useMemo(() => groupDevices(state), [state]);   const selectDevice = async (value: string) => {    const device = state.devices.find((candidate) => deviceKey(candidate) === value);    if (!device) return;    setOperationError(null);    setPendingDevice(device);    try {      const result = await open({        environmentId,        input: {          threadId,          hostId: device.hostId,          deviceId: device.id,          platform: device.platform,        },      });      if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause));      else        useRightPanelStore.getState().openDevice(props.threadRef, {          hostId: result.value.hostId,          deviceId: result.value.deviceId,          platform: device.platform,          name: device.name,        });    } finally {      setPendingDevice(null);    }  };   const closeActive = (powerOff: boolean) => {    if (!powerOff) {      useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id);      return;    }    if (!activeSession) return;    setOperationError(null);    void close({      environmentId,      input: {        threadId,        hostId: activeSession.hostId,        deviceId: activeSession.deviceId,        shutdown: powerOff,      },    }).then((result) => {      if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause));      else useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id);    });  };   const bootingDevices =    state.bootingDevices?.filter((device) => device.threadId === threadId) ?? [];  const hostReady = Object.values(state.hostStatuses).some((host) => host.status === "ready");  const hostBusy =    !hostReady &&    Object.values(state.hostStatuses).some(      (host) => host.status === "installing" || host.status === "starting",    );  const unavailablePlatforms = state.hosts.flatMap((host) =>    host.platforms      .filter((platform) => !platform.available)      .map((platform) => ({ ...platform, hostId: host.id, hostLabel: host.label })),  );   if (loaded && (!state.onboardingCompleted || hostDisabled)) {    return (      <Dialog        open={props.visible}        onOpenChange={(isOpen) => {          if (!isOpen) props.onDismissSetup();        }}      >        <WizardPopup>          <DeviceSetup environmentId={environmentId} state={state} />        </WizardPopup>      </Dialog>    );  }   return (    <PreviewPanelShell mode={props.mode}>      <div className="flex h-9 shrink-0 items-center gap-1.5 border-b px-2">        <span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">          {props.surface.target            ? `${state.hosts.find((host) => host.id === props.surface.target?.hostId)?.label ?? "Device host"} · ${activeDevice?.version ?? props.surface.target.platform}`            : (pendingDevice?.name ?? "Choose a device")}        </span>        {activeDevice ? (          <>            <DeviceButton              label="Home"              onClick={() => handle?.pressButton("home")}              disabled={!handle?.inputConnected}            >              <Home />            </DeviceButton>            {activeDevice.platform === "android" ? (              <>                <DeviceButton                  label="Back"                  onClick={() => handle?.pressButton("back")}                  disabled={!handle?.inputConnected}                >                  <ChevronLeft />                </DeviceButton>                <DeviceButton                  label="Recents"                  onClick={() => handle?.pressButton("recents")}                  disabled={!handle?.inputConnected}                >                  <Square />                </DeviceButton>              </>            ) : (              <DeviceButton                label="Rotate"                onClick={() => handle?.rotate()}                disabled={!handle?.inputConnected}              >                <RotateCcw />              </DeviceButton>            )}            <Toggle              aria-label="Tools"              variant="ghost"              size="xs"              pressed={toolsOpen}              onPressedChange={(pressed) => setToolsOpen(Boolean(pressed))}            >              <SlidersHorizontal />            </Toggle>            <DeviceButton label="Power off" onClick={() => closeActive(true)}>              <Power />            </DeviceButton>            <DeviceButton label="Close" onClick={() => closeActive(false)}>              <X />            </DeviceButton>          </>        ) : null}      </div>      {hostReady && state.hostStatusDetail ? (        <div          role="status"          className="whitespace-pre-line border-b px-3 py-2 text-xs text-muted-foreground"        >          {state.hostStatusDetail}        </div>      ) : null}      {bootingDevices.length > 0 ? (        <div role="status" className="border-b px-3 py-2 text-xs text-muted-foreground">          Starting {bootingDevices.map((device) => device.name).join(", ")}… This can take a minute.        </div>      ) : null}      {operationError ? (        <div          role="alert"          className="flex items-start gap-2 border-b bg-destructive/5 px-3 py-2 text-xs text-destructive"        >          <p className="min-w-0 flex-1 whitespace-pre-wrap break-words">{operationError}</p>          <Button            size="icon-xs"            variant="ghost"            aria-label="Dismiss device error"            onClick={() => setOperationError(null)}          >            <X className="size-3" />          </Button>        </div>      ) : null}      <div className="@container relative flex min-h-0 flex-1">        {activeDevice && activeSession ? (          <>            <div className="relative min-h-0 min-w-0 flex-1">              <DeviceStreamView                key={deviceKey(activeDevice)}                environmentId={environmentId}                platform={activeDevice.platform}                deviceName={activeDevice.name}                deviceDescription={`${state.hosts.find((host) => host.id === activeDevice.hostId)?.label ?? "Device host"} · ${activeDevice.version}`}                deviceId={activeDevice.id}                hostId={activeDevice.hostId}                visible={props.visible}                axOverlay={axOverlay}                onHandle={setHandle}              />            </div>            {toolsOpen ? (              <DeviceToolsPanel                key={deviceKey(activeDevice)}                environmentId={environmentId}                device={activeDevice}                access={access}                axOverlay={axOverlay}                onAxOverlayChange={setAxOverlay}                onClose={() => setToolsOpen(false)}                className="absolute inset-y-0 right-0 z-10 w-full max-w-72 border-l shadow-lg @[560px]:static @[560px]:w-72 @[560px]:shrink-0 @[560px]:shadow-none"              />            ) : null}          </>        ) : pendingDevice || hostBusy || !loaded ? (          <DeviceLoadingView            name={pendingDevice?.name ?? "Devices"}            description={              pendingDevice                ? `${state.hosts.find((host) => host.id === pendingDevice.hostId)?.label ?? "Device host"} · ${pendingDevice.version}`                : ""            }            stage="opening"            message={              pendingDevice                ? pendingDevice.booted                  ? "Opening device…"                  : "Starting device…"                : state.hostStatus === "installing"                  ? "Installing device support…"                  : "Finding devices…"            }          />        ) : (          <div className="flex size-full flex-col overflow-y-auto px-5 py-8 text-sm text-muted-foreground">            <div              className={cn(                "mx-auto flex w-full max-w-xl flex-col gap-6",                grouped.length === 0 && "my-auto items-center text-center",              )}            >              {grouped.length === 0 ? (                <>                  <Smartphone className="size-6 opacity-60" />                  <p className="max-w-sm">                    {state.hostStatus === "failed"                      ? (state.hostStatusDetail ?? "The device hub failed to start.")                      : "No simulators or emulators were found on this environment."}                  </p>                </>              ) : null}              {hostReady && grouped.length > 0 ? (                <div className="w-full space-y-6 text-left">                  {grouped.map((group) => (                    <section key={group.platform} className="space-y-3">                      <div className="flex items-center gap-2 text-sm text-muted-foreground">                        <Smartphone className="size-4 shrink-0" />                        <h3 className="font-medium">{platformLabel(group.platform)}</h3>                      </div>                      <DiscoveryList>                        {group.devices.map((device) => (                          <DiscoveryListRow                            key={deviceKey(device)}                            icon={                              <span className="grid size-8 shrink-0 place-items-center rounded-md border border-border/60">                                <Smartphone className="size-4" />                              </span>                            }                            title={device.name}                            description={`${state.hosts.find((host) => host.id === device.hostId)?.label} · ${device.version} · ${device.booted ? "Running" : "Stopped"}`}                            disabled={pendingDeviceKey !== null}                            aria-label={`${device.booted ? "Open" : "Start"} ${device.name}`}                            onClick={() => void selectDevice(deviceKey(device))}                            action={                              pendingDeviceKey === deviceKey(device) ? (                                <Spinner className="size-3" />                              ) : (                                <span className="text-xs text-muted-foreground">                                  {device.booted ? "Open" : "Start"}                                </span>                              )                            }                          />                        ))}                      </DiscoveryList>                    </section>                  ))}                </div>              ) : null}              {hostReady &&              !state.devices.some((device) => device.platform === "android") &&              !unavailablePlatforms.some((platform) => platform.platform === "android") ? (                <p className="max-w-sm text-xs">                  No Android virtual devices found. Create one in Android Studio's Device Manager,                  then refresh.                </p>              ) : null}              {loaded && !hostBusy ? (                <Button                  className="self-start"                  variant={grouped.length > 0 ? "ghost" : "outline"}                  size="sm"                  onClick={() => void list({ environmentId, input: {} })}                >                  Refresh devices                </Button>              ) : null}            </div>          </div>        )}      </div>    </PreviewPanelShell>  );} function DeviceButton(props: {  readonly label: string;  readonly onClick: () => void;  readonly disabled?: boolean;  readonly children: React.ReactNode;}) {  return (    <Tooltip>      <TooltipTrigger        render={          <Button            size="icon-xs"            variant="ghost-muted"            aria-label={props.label}            onClick={props.onClick}            disabled={props.disabled ?? false}          />        }      >        {props.children}      </TooltipTrigger>      <TooltipPopup>{props.label}</TooltipPopup>    </Tooltip>  );} function groupDevices(state: DeviceServiceState) {  const groups: Array<{ platform: DevicePlatform; devices: DeviceSummary[] }> = [];  for (const platform of ["ios", "android"] as const) {    const devices = state.devices      .filter((device) => device.platform === platform)      .toSorted((a, b) => Number(b.booted) - Number(a.booted) || a.name.localeCompare(b.name));    if (devices.length > 0) groups.push({ platform, devices });  }  return groups;}