1import { useState } from "react";
2import * as Option from "effect/Option";
3import type { SshDeviceHostConfig } from "@t3tools/contracts";
4import { CheckIcon, ChevronDownIcon, MonitorIcon, XIcon } from "lucide-react";
5import { Button, InlineButton } from "../ui/button";
6import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
7import { Input } from "../ui/input";
8import { Spinner } from "../ui/spinner";
9import {
10 Dialog,
11 DialogPopup,
12 DialogHeader,
13 DialogTitle,
14 DialogDescription,
15 DialogPanel,
16 DialogFooter,
17} from "../ui/dialog";
18import { DeviceHostAvailability } from "../device/DeviceHostAvailability";
19import { useHostConnectionChecks } from "./useHostConnectionChecks";
20import {
21 deviceHostConnectionKey,
22 parseDeviceHostDraft,
23 type DeviceHostCheckTarget,
24} from "./deviceHostConnectionChecks";
25
26export function DeviceHostEditor({
27 host,
28 isNew,
29 targets,
30 busy,
31 onSave,
32 onClose,
33}: {
34 host: SshDeviceHostConfig;
35 isNew: boolean;
36 targets: ReadonlyArray<DeviceHostCheckTarget>;
37 busy: boolean;
38 onSave: (host: SshDeviceHostConfig) => void;
39 onClose: () => void;
40}) {
41 return (
42 <Dialog
43 open
44 onOpenChange={(open) => {
45 if (!open && !busy) onClose();
46 }}
47 >
48 {/* The form owns the draft so a keystroke re-renders it, not the dialog chrome. */}
49 <DialogPopup showCloseButton={!busy}>
50 <DeviceHostEditorForm
51 host={host}
52 isNew={isNew}
53 targets={targets}
54 busy={busy}
55 onSave={onSave}
56 onClose={onClose}
57 />
58 </DialogPopup>
59 </Dialog>
60 );
61}
62
63function DeviceHostEditorForm({
64 host,
65 isNew,
66 targets,
67 busy,
68 onSave,
69 onClose,
70}: {
71 host: SshDeviceHostConfig;
72 isNew: boolean;
73 targets: ReadonlyArray<DeviceHostCheckTarget>;
74 busy: boolean;
75 onSave: (host: SshDeviceHostConfig) => void;
76 onClose: () => void;
77}) {
78 const [draft, setDraft] = useState(host);
79 const [expandedChecks, setExpandedChecks] = useState<{ key: string; open: boolean } | null>(null);
80 const { checks, testConnection } = useHostConnectionChecks(targets);
81 const connectionKey = deviceHostConnectionKey(draft);
82 const results = checks[connectionKey];
83 const checking = Object.values(results ?? {}).some((check) => check.status === "pending");
84 const input = parseDeviceHostDraft({ ...draft, label: draft.label.trim() || draft.target });
85 const valid = Option.isSome(input);
86 const failed = Object.values(results ?? {}).filter((check) => check.status === "failed").length;
87 const checksOpen =
88 targets.length === 1 ||
89 (expandedChecks?.key === connectionKey ? expandedChecks.open : failed > 0);
90 const checkSummary = checking
91 ? "Checking environments…"
92 : results
93 ? failed
94 ? `${failed} of ${targets.length} failed`
95 : "Connection checks passed"
96 : "Check access before saving";
97 return (
98 <form
99 className="flex min-h-0 flex-col"
100 onSubmit={(event) => {
101 event.preventDefault();
102 if (Option.isSome(input) && draft.label.trim() && !busy && !checking) onSave(input.value);
103 }}
104 >
105 <DialogHeader>
106 <DialogTitle>{isNew ? "Add device host" : "Edit device host"}</DialogTitle>
107 <DialogDescription>
108 {targets.length === 1
109 ? `Connect from ${targets[0]?.label}.`
110 : `Connect from ${targets.length} selected environments.`}{" "}
111 Hosts on the same machine are skipped.
112 </DialogDescription>
113 </DialogHeader>
114 <DialogPanel>
115 <label className="block space-y-1.5 text-sm">
116 <span>Name</span>
117 <Input
118 autoFocus
119 required
120 value={draft.label}
121 disabled={busy}
122 onChange={(event) => {
123 const label = event.target.value;
124 setDraft((current) => ({ ...current, label }));
125 }}
126 placeholder="Mac mini"
127 />
128 </label>
129 <label className="block space-y-1.5 text-sm">
130 <span>SSH target</span>
131 <Input
132 required
133 value={draft.target}
134 disabled={busy}
135 onChange={(event) => {
136 const target = event.target.value;
137 setDraft((current) => ({ ...current, target }));
138 }}
139 placeholder="user@host or SSH alias"
140 />
141 </label>
142 <details
143 open={host.port !== undefined || host.identityFile !== undefined || undefined}
144 className="text-sm"
145 >
146 <summary className="cursor-pointer text-muted-foreground">SSH options</summary>
147 <div className="mt-3 grid grid-cols-[minmax(0,1fr)_7rem] gap-3">
148 <label className="block space-y-1.5">
149 <span>Identity file</span>
150 <Input
151 value={draft.identityFile ?? ""}
152 disabled={busy}
153 onChange={(event) => {
154 const identityFile = event.target.value;
155 setDraft((current) => {
156 const { identityFile: _, ...rest } = current;
157 return identityFile ? { ...rest, identityFile } : rest;
158 });
159 }}
160 placeholder="SSH config default"
161 />
162 </label>
163 <label className="block space-y-1.5">
164 <span>Port</span>
165 <Input
166 type="number"
167 min={1}
168 max={65535}
169 value={draft.port ?? ""}
170 disabled={busy}
171 onChange={(event) => {
172 const port = event.target.value;
173 setDraft((current) => {
174 const { port: _, ...rest } = current;
175 return port ? { ...rest, port: Number(port) } : rest;
176 });
177 }}
178 placeholder="Default"
179 />
180 </label>
181 </div>
182 <p className="mt-2 text-xs text-muted-foreground">
183 Optional. Resolved separately on each environment.
184 </p>
185 </details>
186 <Collapsible
187 open={checksOpen}
188 onOpenChange={(open) => setExpandedChecks({ key: connectionKey, open })}
189 render={<div className="rounded-lg border border-border/60" />}
190 >
191 <div className="flex items-center justify-between gap-3 px-3 py-2.5">
192 {targets.length === 1 ? (
193 <p role="status" className="text-xs text-muted-foreground">
194 {checkSummary}
195 </p>
196 ) : (
197 <div className="min-w-0 text-xs [--inline-button-white-space:normal]">
198 <CollapsibleTrigger
199 disabled={!results}
200 aria-label={`${checkSummary}. Per-machine results`}
201 render={<InlineButton tone="muted" />}
202 >
203 <span role="status" className="text-left">
204 {checkSummary}
205 </span>
206 {results ? (
207 <ChevronDownIcon
208 className={`size-3 shrink-0 ${checksOpen ? "rotate-180" : ""}`}
209 />
210 ) : null}
211 </CollapsibleTrigger>
212 </div>
213 )}
214 <Button
215 type="button"
216 size="sm"
217 variant="outline"
218 disabled={busy || checking || !valid}
219 onClick={() => {
220 if (Option.isSome(input)) void testConnection(input.value);
221 }}
222 >
223 {checking ? <Spinner size="xs" /> : null} Test connection
224 </Button>
225 </div>
226 {results ? (
227 <CollapsiblePanel
228 render={<ul className="border-t border-border/60 divide-y divide-border/40" />}
229 >
230 {targets.map((target) => {
231 const result = results[target.environmentId];
232 if (!result) return null;
233 return (
234 <li key={target.environmentId} className="px-3 py-2.5 text-xs">
235 <div className="flex items-center justify-between gap-3">
236 <span className="truncate font-medium">{target.label}</span>
237 <span
238 className={`flex shrink-0 items-center gap-1.5 ${result.status === "failed" ? "text-destructive" : "text-muted-foreground"}`}
239 >
240 {result.status === "pending" ? (
241 <>
242 <Spinner size="xs" /> Checking…
243 </>
244 ) : result.status === "local" ? (
245 <>
246 <MonitorIcon className="size-3" /> Already available locally
247 </>
248 ) : result.status === "failed" ? (
249 <>
250 <XIcon className="size-3" /> Failed
251 </>
252 ) : (
253 <>
254 <CheckIcon className="size-3" /> Connected
255 </>
256 )}
257 </span>
258 </div>
259 {result.status === "connected" ? (
260 <div className="mt-1.5">
261 <DeviceHostAvailability platforms={result.platforms} />
262 </div>
263 ) : null}
264 {result.status === "failed" ? (
265 <details className="mt-1.5 text-muted-foreground">
266 <summary className="cursor-pointer">Show error</summary>
267 <p className="mt-1 max-h-32 overflow-auto whitespace-pre-wrap break-words">
268 {result.error}
269 </p>
270 </details>
271 ) : null}
272 </li>
273 );
274 })}
275 </CollapsiblePanel>
276 ) : null}
277 </Collapsible>
278 </DialogPanel>
279 <DialogFooter>
280 <Button type="button" variant="ghost" disabled={busy} onClick={onClose}>
281 Cancel
282 </Button>
283 <Button type="submit" disabled={busy || checking || !valid || !draft.label.trim()}>
284 {busy ? <Spinner size="xs" /> : null} Save host
285 </Button>
286 </DialogFooter>
287 </form>
288 );
289}