1import { DeviceToolVersions } from "../device/DeviceToolVersions";
2import { AuthSettingsWriteScope } from "@t3tools/contracts";
3import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
4import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
5import { AppleIcon, AndroidIcon } from "../Icons";
6import { Spinner } from "../ui/spinner";
7import type { EnvironmentId, SshDeviceHostConfig } from "@t3tools/contracts";
8import { randomUUID } from "../../lib/utils";
9import { useState } from "react";
10import { deviceEnvironment, useDeviceState } from "../../state/device";
11import { serverEnvironment } from "../../state/server";
12import { useAtomCommand } from "../../state/use-atom-command";
13import { Button } from "../ui/button";
14import { MoreVertical, PlusIcon } from "lucide-react";
15import { Menu, MenuTrigger, MenuPopup, MenuItem } from "../ui/menu";
16import { SettingsRow } from "./settingsLayout";
17
18import { useSettingsScope } from "./SettingsScopeContext";
19import { toastManager } from "../ui/toast";
20import { updateDeviceHosts } from "./deviceHostsSettings.logic";
21import { DeviceHostEditor } from "./DeviceHostEditor";
22import { useHostConnectionChecks } from "./useHostConnectionChecks";
23import { deviceHostConnectionKey } from "./deviceHostConnectionChecks";
24
25export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null }) {
26 const { scope, environments, connectedEnvironments } = useSettingsScope();
27 const canConfigure = useEnvironmentScope(props.environmentId, AuthSettingsWriteScope);
28 const projectScope = scope.kind === "project" || scope.kind === "checkout";
29 const update = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false });
30 const [editing, setEditing] = useState<SshDeviceHostConfig | null>(null);
31 const [originalHost, setOriginalHost] = useState<SshDeviceHostConfig | null>(null);
32 const [busy, setBusy] = useState(false);
33 const targets = environments.map((environment) => ({
34 environmentId: environment.environmentId,
35 label: environment.label,
36 connected: environment.connection.phase === "connected",
37 }));
38 const { checks, testConnection } = useHostConnectionChecks(targets);
39 const save = async (host: SshDeviceHostConfig, remove = false, original = host) => {
40 if (!props.environmentId || projectScope) return;
41 setBusy(true);
42 try {
43 const results = await Promise.allSettled(
44 environments.map(async (environment) => {
45 if (environment.connection.phase !== "connected" || !environment.serverConfig) {
46 throw new Error("Environment disconnected");
47 }
48 if (!readEnvironmentScope(environment.environmentId, AuthSettingsWriteScope)) {
49 throw new Error("This connection cannot change device settings.");
50 }
51 return update({
52 environmentId: environment.environmentId,
53 input: {
54 patch: {
55 deviceHosts: updateDeviceHosts(
56 environment.serverConfig.settings.deviceHosts,
57 host,
58 remove,
59 original,
60 ),
61 },
62 },
63 });
64 }),
65 );
66 const failed = environments.filter((_, index) => {
67 const result = results[index];
68 return result?.status !== "fulfilled" || result.value._tag === "Failure";
69 });
70 if (failed.length === 0) {
71 setEditing(null);
72 } else {
73 toastManager.add({
74 type: "error",
75 title: "Device hosts not saved on all environments",
76 description: `Could not update ${failed.map((environment) => environment.label).join(", ")}.`,
77 });
78 }
79 } finally {
80 setBusy(false);
81 }
82 };
83 return (
84 <SettingsRow
85 id="device-hosts"
86 title="Device hosts"
87 serverScoped
88 settingKeys={["deviceHosts"]}
89 description="Add remote machines with simulator or emulator runtimes installed, and the selected environments will connect over SSH and set up device tools automatically."
90 control={
91 <Button
92 size="sm"
93 variant="outline"
94 disabled={
95 !canConfigure || projectScope || busy || !props.environmentId || editing !== null
96 }
97 onClick={() => {
98 setOriginalHost(null);
99 setEditing({ id: randomUUID(), label: "", target: "" });
100 }}
101 >
102 <PlusIcon className="size-3.5" /> Add host
103 </Button>
104 }
105 >
106 <div className="pt-3 pb-2">
107 {!props.environmentId ? (
108 <p className="text-sm text-muted-foreground">
109 Connect a selected environment to manage device hosts.
110 </p>
111 ) : (
112 <>
113 {connectedEnvironments.map((environment) => (
114 <div key={environment.environmentId}>
115 {connectedEnvironments.length > 1 ? (
116 <p className="pt-3 pb-1 text-xs font-medium text-muted-foreground">
117 {environment.label}
118 </p>
119 ) : null}
120 <DeviceHostList
121 environmentLabel={environment.label}
122 environmentId={environment.environmentId}
123 hosts={environment.serverConfig?.settings.deviceHosts ?? []}
124 busy={!canConfigure || projectScope || busy}
125 checks={checks}
126 testConnection={async (host) => {
127 const results = await testConnection(host);
128 if (!results) return;
129 const failed = targets.filter(
130 (target) => results[target.environmentId]?.status === "failed",
131 );
132 toastManager.add({
133 type: failed.length ? "error" : "success",
134 title: failed.length
135 ? `${host.label}: ${failed.length} of ${targets.length} environments failed`
136 : `${host.label}: connection checks passed`,
137 description: failed.length
138 ? `Could not connect from ${failed.map((target) => target.label).join(", ")}.`
139 : "Connected or already available locally on each selected environment.",
140 });
141 return results;
142 }}
143 onEdit={(host) => {
144 setOriginalHost(host);
145 setEditing(host);
146 }}
147 onRemove={(host) => void save(host, true)}
148 />
149 </div>
150 ))}
151 {editing ? (
152 <DeviceHostEditor
153 key={editing.id}
154 host={editing}
155 isNew={originalHost === null}
156 targets={targets}
157 busy={busy}
158 onSave={(host) => void save(host, false, originalHost ?? host)}
159 onClose={() => setEditing(null)}
160 />
161 ) : null}
162 </>
163 )}
164 </div>
165 </SettingsRow>
166 );
167}
168
169function DeviceHostList({
170 environmentLabel,
171 environmentId,
172 hosts,
173 busy,
174 onEdit,
175 onRemove,
176 checks,
177 testConnection,
178}: {
179 environmentLabel: string;
180 environmentId: EnvironmentId;
181 hosts: ReadonlyArray<SshDeviceHostConfig>;
182 busy: boolean;
183 onEdit: (host: SshDeviceHostConfig) => void;
184 onRemove: (host: SshDeviceHostConfig) => void;
185 checks: ReturnType<typeof useHostConnectionChecks>["checks"];
186 testConnection: ReturnType<typeof useHostConnectionChecks>["testConnection"];
187}) {
188 const { state } = useDeviceState(environmentId);
189 const retry = useAtomCommand(deviceEnvironment.list);
190 const [retrying, setRetrying] = useState<string | null>(null);
191 return (
192 <>
193 {hosts.length === 0 ? (
194 <p className="py-2 text-sm text-muted-foreground">No device hosts.</p>
195 ) : null}
196 {hosts.map((host) => {
197 const status = state.hostStatuses[host.id];
198 const check = checks[deviceHostConnectionKey(host)]?.[environmentId];
199 const platforms =
200 (check?.status === "connected" ? check.platforms : undefined) ??
201 state.hosts.find((value) => value.id === host.id)?.platforms ??
202 [];
203 const progress =
204 check?.status === "pending"
205 ? "Checking connection…"
206 : status?.status === "installing"
207 ? "Installing device support…"
208 : status?.status === "starting"
209 ? "Connecting…"
210 : null;
211 const error =
212 check?.status === "failed"
213 ? check.error
214 : check?.status === "local"
215 ? undefined
216 : status?.status === "failed"
217 ? status.detail
218 : undefined;
219 return (
220 <div key={host.id} className="flex items-center gap-2 border-t border-border/50 py-2.5">
221 <div className="min-w-0 flex-1">
222 <div className="flex min-w-0 items-center gap-2">
223 <p className="truncate text-sm font-medium">{host.label}</p>
224 {platforms
225 .filter((platform) => platform.available)
226 .map((platform) => (
227 <Tooltip key={platform.platform}>
228 <TooltipTrigger
229 render={
230 <span
231 tabIndex={0}
232 role="img"
233 aria-label={
234 platform.platform === "ios" ? "iOS available" : "Android available"
235 }
236 className="shrink-0 text-muted-foreground"
237 />
238 }
239 >
240 {platform.platform === "ios" ? (
241 // The Apple mark is bottom-heavy; lift it so it does not dip under the label.
242 <AppleIcon className="size-3.5 -translate-y-px" />
243 ) : (
244 <AndroidIcon className="size-3.5" />
245 )}
246 </TooltipTrigger>
247 <TooltipPopup>
248 {platform.platform === "ios" ? "iOS available" : "Android available"}
249 </TooltipPopup>
250 </Tooltip>
251 ))}
252 </div>
253 <p className="truncate text-xs text-muted-foreground">{host.target}</p>
254 <DeviceToolVersions
255 owner={environmentLabel}
256 error={state.hosts.find((value) => value.id === host.id)?.toolInspectionError}
257 tools={
258 state.hosts.find((value) => value.id === host.id)?.tools ??
259 (check?.status === "connected" ? check.tools : undefined)
260 }
261 />
262 {check?.status === "local" ? (
263 <p className="mt-1 text-xs text-muted-foreground">Already available locally</p>
264 ) : null}
265 {error ? (
266 <div className="mt-1" role="status">
267 <details className="text-xs text-destructive">
268 <summary>Connection failed</summary>
269 <p className="mt-1 whitespace-pre-wrap break-words">{error}</p>
270 </details>
271 </div>
272 ) : null}
273 </div>
274 {progress ? (
275 <span
276 role="status"
277 className="inline-flex items-center gap-1.5 text-xs text-muted-foreground"
278 >
279 <Spinner size="xs" />
280 {progress}
281 </span>
282 ) : null}
283 <Menu>
284 <MenuTrigger
285 render={
286 <Button
287 size="icon-sm"
288 variant="ghost-muted"
289 disabled={busy}
290 aria-label={host.label + " options"}
291 />
292 }
293 >
294 <MoreVertical />
295 </MenuTrigger>
296 <MenuPopup align="end">
297 <MenuItem
298 onClick={() => {
299 onEdit(host);
300 }}
301 >
302 Edit
303 </MenuItem>
304 <MenuItem variant="destructive" onClick={() => onRemove(host)}>
305 Remove
306 </MenuItem>
307 </MenuPopup>
308 </Menu>
309 {status?.status === "failed" &&
310 state.supportsHostRetry &&
311 state.hostStatus !== "disabled" ? (
312 <Button
313 size="sm"
314 variant="outline"
315 disabled={busy || retrying !== null}
316 onClick={() => {
317 setRetrying(host.id);
318 void retry({ environmentId, input: { retryHostId: host.id } }).finally(() =>
319 setRetrying(null),
320 );
321 }}
322 >
323 {retrying === host.id ? "Retrying…" : "Retry"}
324 </Button>
325 ) : (
326 <Button
327 size="sm"
328 variant="outline"
329 disabled={busy || progress !== null}
330 onClick={() => void testConnection(host)}
331 >
332 Test connection
333 </Button>
334 )}
335 </div>
336 );
337 })}
338 </>
339 );
340}