| 1 | import { DeviceHostUpdates } from "./DeviceHostUpdates"; |
| 2 | import type { |
| 3 | DevicePlatform, |
| 4 | DeviceServiceState, |
| 5 | DeviceSummary, |
| 6 | ScopedThreadRef, |
| 7 | } from "@t3tools/contracts"; |
| 8 | import { Smartphone, X } from "lucide-react"; |
| 9 | import { useEffect, useMemo, useState } from "react"; |
| 10 | |
| 11 | import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; |
| 12 | import { useRightPanelStore, type RightPanelSurface } from "~/rightPanelStore"; |
| 13 | import { Button } from "~/components/ui/button"; |
| 14 | import { DiscoveryList, DiscoveryListRow } from "~/components/ui/discovery-list"; |
| 15 | import { Dialog } from "~/components/ui/dialog"; |
| 16 | import { WizardPopup } from "~/components/ui/wizard"; |
| 17 | import { Spinner } from "~/components/ui/spinner"; |
| 18 | import { cn } from "~/lib/utils"; |
| 19 | import { deviceEnvironment, useDeviceState } from "~/state/device"; |
| 20 | import { formatEnvironmentQueryError } from "~/state/query"; |
| 21 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 22 | import { DeviceLoadingView } from "./DeviceLoadingView"; |
| 23 | import { DeviceSetup } from "./DeviceSetup"; |
| 24 | import { DeviceWorkspace } from "./DeviceWorkspace"; |
| 25 | import { PreviewPanelShell, type PreviewPanelMode } from "../preview/PreviewPanelShell"; |
| 26 | |
| 27 | const platformLabel = (platform: DevicePlatform) => |
| 28 | platform === "ios" ? "iOS Simulators" : "Android Emulators"; |
| 29 | |
| 30 | const deviceKey = (device: Pick<DeviceSummary, "hostId" | "id">) => |
| 31 | `${device.hostId}\u0000${device.id}`; |
| 32 | |
| 33 | /** Each surface owns one host/device; only the visible surface streams. */ |
| 34 | export function DevicePanel(props: { |
| 35 | readonly mode: PreviewPanelMode; |
| 36 | readonly threadRef: ScopedThreadRef; |
| 37 | readonly surface: Extract<RightPanelSurface, { kind: "device" }>; |
| 38 | readonly visible: boolean; |
| 39 | readonly onDismissSetup: () => void; |
| 40 | }) { |
| 41 | const { environmentId, threadId } = props.threadRef; |
| 42 | const { state, loaded } = useDeviceState(environmentId); |
| 43 | const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false }); |
| 44 | const open = useAtomCommand(deviceEnvironment.open); |
| 45 | const close = useAtomCommand(deviceEnvironment.close); |
| 46 | const [operationError, setOperationError] = useState<string | null>(null); |
| 47 | const [pendingDevice, setPendingDevice] = useState<DeviceSummary | null>(null); |
| 48 | const pendingDeviceKey = pendingDevice ? deviceKey(pendingDevice) : null; |
| 49 | |
| 50 | const hostDisabled = state.hostStatus === "disabled"; |
| 51 | |
| 52 | // Opening setup never grants permission to install or start helpers. |
| 53 | useEffect(() => { |
| 54 | if (!props.visible || !loaded || hostDisabled) return; |
| 55 | void list({ environmentId, input: {} }); |
| 56 | }, [environmentId, list, loaded, props.visible, hostDisabled]); |
| 57 | |
| 58 | const sessions = useMemo( |
| 59 | () => state.sessions.filter((session) => session.threadId === threadId), |
| 60 | [state.sessions, threadId], |
| 61 | ); |
| 62 | const activeSession = props.surface.target |
| 63 | ? sessions.find( |
| 64 | (session) => |
| 65 | session.deviceId === props.surface.target?.deviceId && |
| 66 | session.hostId === props.surface.target.hostId, |
| 67 | ) |
| 68 | : undefined; |
| 69 | const activeDevice = activeSession |
| 70 | ? state.devices.find( |
| 71 | (device) => device.hostId === activeSession.hostId && device.id === activeSession.deviceId, |
| 72 | ) |
| 73 | : undefined; |
| 74 | |
| 75 | const grouped = useMemo(() => groupDevices(state), [state]); |
| 76 | |
| 77 | const selectDevice = async (value: string) => { |
| 78 | const device = state.devices.find((candidate) => deviceKey(candidate) === value); |
| 79 | if (!device) return; |
| 80 | setOperationError(null); |
| 81 | setPendingDevice(device); |
| 82 | try { |
| 83 | const result = await open({ |
| 84 | environmentId, |
| 85 | input: { |
| 86 | threadId, |
| 87 | hostId: device.hostId, |
| 88 | deviceId: device.id, |
| 89 | platform: device.platform, |
| 90 | }, |
| 91 | }); |
| 92 | if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause)); |
| 93 | else |
| 94 | useRightPanelStore.getState().openDevice(props.threadRef, { |
| 95 | hostId: result.value.hostId, |
| 96 | deviceId: result.value.deviceId, |
| 97 | platform: device.platform, |
| 98 | name: device.name, |
| 99 | }); |
| 100 | } finally { |
| 101 | setPendingDevice(null); |
| 102 | } |
| 103 | }; |
| 104 | |
| 105 | // Floating the device closes the panel, like the browser's floating preview. |
| 106 | const floatActive = () => { |
| 107 | if (!activeDevice) return; |
| 108 | usePreviewMiniPlayerStore.getState().open(props.threadRef, { |
| 109 | kind: "device", |
| 110 | hostId: activeDevice.hostId, |
| 111 | deviceId: activeDevice.id, |
| 112 | platform: activeDevice.platform, |
| 113 | name: activeDevice.name, |
| 114 | }); |
| 115 | useRightPanelStore.getState().close(props.threadRef); |
| 116 | }; |
| 117 | |
| 118 | const closeActive = (powerOff: boolean) => { |
| 119 | if (!powerOff) { |
| 120 | useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id); |
| 121 | return; |
| 122 | } |
| 123 | if (!activeSession) return; |
| 124 | setOperationError(null); |
| 125 | void close({ |
| 126 | environmentId, |
| 127 | input: { |
| 128 | threadId, |
| 129 | hostId: activeSession.hostId, |
| 130 | deviceId: activeSession.deviceId, |
| 131 | shutdown: powerOff, |
| 132 | }, |
| 133 | }).then((result) => { |
| 134 | if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause)); |
| 135 | else useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id); |
| 136 | }); |
| 137 | }; |
| 138 | |
| 139 | const bootingDevices = |
| 140 | state.bootingDevices?.filter((device) => device.threadId === threadId) ?? []; |
| 141 | const hostReady = Object.values(state.hostStatuses).some((host) => host.status === "ready"); |
| 142 | const hostBusy = |
| 143 | !hostReady && |
| 144 | Object.values(state.hostStatuses).some( |
| 145 | (host) => host.status === "installing" || host.status === "starting", |
| 146 | ); |
| 147 | const unavailablePlatforms = state.hosts.flatMap((host) => |
| 148 | host.platforms |
| 149 | .filter((platform) => !platform.available) |
| 150 | .map((platform) => ({ ...platform, hostId: host.id, hostLabel: host.label })), |
| 151 | ); |
| 152 | |
| 153 | if (loaded && (!state.onboardingCompleted || hostDisabled)) { |
| 154 | return ( |
| 155 | <Dialog |
| 156 | open={props.visible} |
| 157 | onOpenChange={(isOpen) => { |
| 158 | if (!isOpen) props.onDismissSetup(); |
| 159 | }} |
| 160 | > |
| 161 | <WizardPopup> |
| 162 | <DeviceSetup environmentId={environmentId} state={state} /> |
| 163 | </WizardPopup> |
| 164 | </Dialog> |
| 165 | ); |
| 166 | } |
| 167 | |
| 168 | return ( |
| 169 | <PreviewPanelShell mode={props.mode}> |
| 170 | {hostReady && !activeDevice && state.hostStatusDetail ? ( |
| 171 | <div |
| 172 | role="status" |
| 173 | className="whitespace-pre-line border-b px-3 py-2 text-xs text-muted-foreground" |
| 174 | > |
| 175 | {state.hostStatusDetail} |
| 176 | </div> |
| 177 | ) : null} |
| 178 | <DeviceHostUpdates state={state} environmentId={environmentId} /> |
| 179 | {bootingDevices.length > 0 ? ( |
| 180 | <div role="status" className="border-b px-3 py-2 text-xs text-muted-foreground"> |
| 181 | Starting {bootingDevices.map((device) => device.name).join(", ")}… This can take a minute. |
| 182 | </div> |
| 183 | ) : null} |
| 184 | {operationError ? ( |
| 185 | <div |
| 186 | role="alert" |
| 187 | className="flex items-start gap-2 border-b bg-destructive/5 px-3 py-2 text-xs text-destructive" |
| 188 | > |
| 189 | <p className="min-w-0 flex-1 whitespace-pre-wrap break-words">{operationError}</p> |
| 190 | <Button |
| 191 | size="icon-xs" |
| 192 | variant="ghost" |
| 193 | aria-label="Dismiss device error" |
| 194 | onClick={() => setOperationError(null)} |
| 195 | > |
| 196 | <X className="size-3" /> |
| 197 | </Button> |
| 198 | </div> |
| 199 | ) : null} |
| 200 | <div className="@container relative flex min-h-0 flex-1"> |
| 201 | {activeDevice && activeSession ? ( |
| 202 | <DeviceWorkspace |
| 203 | key={`${environmentId}\u0000${deviceKey(activeDevice)}`} |
| 204 | environmentId={environmentId} |
| 205 | device={activeDevice} |
| 206 | hostLabel={ |
| 207 | state.hosts.find((host) => host.id === activeDevice.hostId)?.label ?? "Device host" |
| 208 | } |
| 209 | hostDiagnostics={state.hostStatusDetail} |
| 210 | visible={props.visible} |
| 211 | onFloat={floatActive} |
| 212 | onClose={() => closeActive(false)} |
| 213 | onPowerOff={() => closeActive(true)} |
| 214 | /> |
| 215 | ) : pendingDevice || hostBusy || !loaded ? ( |
| 216 | <DeviceLoadingView |
| 217 | name={pendingDevice?.name ?? "Devices"} |
| 218 | description={ |
| 219 | pendingDevice |
| 220 | ? `${state.hosts.find((host) => host.id === pendingDevice.hostId)?.label ?? "Device host"} · ${pendingDevice.version}` |
| 221 | : "" |
| 222 | } |
| 223 | stage="opening" |
| 224 | message={ |
| 225 | pendingDevice |
| 226 | ? pendingDevice.booted |
| 227 | ? "Opening device…" |
| 228 | : "Starting device…" |
| 229 | : state.hostStatus === "installing" |
| 230 | ? (state.hostStatusDetail ?? "Installing device support…") |
| 231 | : "Finding devices…" |
| 232 | } |
| 233 | /> |
| 234 | ) : ( |
| 235 | <div className="scrollbar-gutter-both flex size-full flex-col overflow-y-auto px-5 py-8 text-sm text-muted-foreground"> |
| 236 | <div |
| 237 | className={cn( |
| 238 | "mx-auto flex w-full max-w-xl flex-col gap-6", |
| 239 | grouped.length === 0 && "my-auto items-center text-center", |
| 240 | )} |
| 241 | > |
| 242 | {grouped.length === 0 ? ( |
| 243 | <> |
| 244 | <Smartphone className="size-6 opacity-60" /> |
| 245 | <p className="max-w-sm"> |
| 246 | {state.hostStatus === "failed" |
| 247 | ? (state.hostStatusDetail ?? "The device hub failed to start.") |
| 248 | : "No simulators or emulators were found on this environment."} |
| 249 | </p> |
| 250 | </> |
| 251 | ) : null} |
| 252 | {hostReady && grouped.length > 0 ? ( |
| 253 | <div className="w-full space-y-6 text-left"> |
| 254 | {grouped.map((group) => ( |
| 255 | <section key={group.platform} className="space-y-3"> |
| 256 | <div className="flex items-center gap-2 text-sm text-muted-foreground"> |
| 257 | <Smartphone className="size-4 shrink-0" /> |
| 258 | <h3 className="font-medium">{platformLabel(group.platform)}</h3> |
| 259 | </div> |
| 260 | <DiscoveryList> |
| 261 | {group.devices.map((device) => ( |
| 262 | <DiscoveryListRow |
| 263 | key={deviceKey(device)} |
| 264 | icon={ |
| 265 | <span className="grid size-8 shrink-0 place-items-center rounded-md border border-border/60"> |
| 266 | <Smartphone className="size-4" /> |
| 267 | </span> |
| 268 | } |
| 269 | title={device.name} |
| 270 | description={`${state.hosts.find((host) => host.id === device.hostId)?.label} · ${device.version} · ${device.booted ? "Running" : "Stopped"}`} |
| 271 | disabled={pendingDeviceKey !== null} |
| 272 | aria-label={`${device.booted ? "Open" : "Start"} ${device.name}`} |
| 273 | onClick={() => void selectDevice(deviceKey(device))} |
| 274 | action={ |
| 275 | pendingDeviceKey === deviceKey(device) ? ( |
| 276 | <Spinner size="xs" /> |
| 277 | ) : ( |
| 278 | <span className="text-xs text-muted-foreground"> |
| 279 | {device.booted ? "Open" : "Start"} |
| 280 | </span> |
| 281 | ) |
| 282 | } |
| 283 | /> |
| 284 | ))} |
| 285 | </DiscoveryList> |
| 286 | </section> |
| 287 | ))} |
| 288 | </div> |
| 289 | ) : null} |
| 290 | {hostReady && |
| 291 | !state.devices.some((device) => device.platform === "android") && |
| 292 | !unavailablePlatforms.some((platform) => platform.platform === "android") ? ( |
| 293 | <p className="max-w-sm text-xs"> |
| 294 | No Android virtual devices found. Create one in Android Studio's Device Manager, |
| 295 | then refresh. |
| 296 | </p> |
| 297 | ) : null} |
| 298 | {loaded && !hostBusy ? ( |
| 299 | <Button |
| 300 | className={grouped.length > 0 ? "self-start" : "self-center"} |
| 301 | variant={grouped.length > 0 ? "ghost" : "outline"} |
| 302 | size="sm" |
| 303 | onClick={() => void list({ environmentId, input: {} })} |
| 304 | > |
| 305 | Refresh devices |
| 306 | </Button> |
| 307 | ) : null} |
| 308 | </div> |
| 309 | </div> |
| 310 | )} |
| 311 | </div> |
| 312 | </PreviewPanelShell> |
| 313 | ); |
| 314 | } |
| 315 | |
| 316 | function groupDevices(state: DeviceServiceState) { |
| 317 | const groups: Array<{ platform: DevicePlatform; devices: DeviceSummary[] }> = []; |
| 318 | for (const platform of ["ios", "android"] as const) { |
| 319 | const devices = state.devices |
| 320 | .filter((device) => device.platform === platform) |
| 321 | .toSorted((a, b) => Number(b.booted) - Number(a.booted) || a.name.localeCompare(b.name)); |
| 322 | if (devices.length > 0) groups.push({ platform, devices }); |
| 323 | } |
| 324 | return groups; |
| 325 | } |