Open History to see the last commit
1import { pipe } from "effect/Function";
2import { ChevronsLeftRightEllipsisIcon, PlusIcon, TerminalIcon } from "lucide-react";
3import { useAtomValue } from "@effect/atom-react";
4import { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from "react";
5import {
6 type DesktopDiscoveredSshHost,
7 type DesktopSshEnvironmentTarget,
8} from "@t3tools/contracts";
9import {
10 RelayConnectionRegistration,
11 RelayConnectionTarget,
12} from "@t3tools/client-runtime/connection";
13import {
14 isAtomCommandInterrupted,
15 squashAtomCommandFailure,
16} from "@t3tools/client-runtime/state/runtime";
17import * as Option from "effect/Option";
18
19import { cn } from "../../lib/utils";
20import { formatDesktopSshTarget } from "./EnvironmentRow";
21import { Input } from "../ui/input";
22import { CommandShortcut } from "../ui/command";
23import {
24 Autocomplete,
25 AutocompleteEmpty,
26 AutocompleteInput,
27 AutocompleteItem,
28 AutocompleteList,
29 AutocompletePopup,
30} from "../ui/autocomplete";
31import {
32 Dialog,
33 DialogDescription,
34 DialogFooter,
35 DialogHeader,
36 DialogPanel,
37 DialogPopup,
38 DialogTitle,
39 DialogTrigger,
40} from "../ui/dialog";
41import { stackedThreadToast, toastManager } from "../ui/toast";
42import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
43import { Alert, AlertDescription } from "../ui/alert";
44import { Button } from "../ui/button";
45import { AnimatedHeight } from "../AnimatedHeight";
46import { environmentCatalog } from "~/connection/catalog";
47import {
48 connectPairing as connectPairingAtom,
49 connectSshEnvironment as connectSshEnvironmentAtom,
50} from "~/connection/onboarding";
51import { useEnvironmentQuery } from "~/state/query";
52import { desktopSshHostsStateAtom, filterDiscoveredSshHosts } from "~/state/desktopSshHosts";
53import { type EnvironmentPresentation, useRelayEnvironmentDiscovery } from "~/state/environments";
54import { useAtomCommand } from "../../state/use-atom-command";
55import { primaryServerKeybindingsAtom } from "~/state/server";
56import {
57 resolveShortcutCommand,
58 shortcutLabelForCommand,
59 threadJumpCommandForIndex,
60 threadJumpIndexFromCommand,
61} from "../../keybindings";
62import {
63 parseManualDesktopSshTarget,
64 parsePairingUrlFields,
65 parseRemotePairingFields,
66 formatDesktopSshConnectionError,
67} from "./ConnectionsSettings.logic";
68
69const EMPTY_DISCOVERED_SSH_HOSTS: ReadonlyArray<DesktopDiscoveredSshHost> = [];
70
71export function AddEnvironmentDialog({
72 open,
73 routeTarget,
74 error,
75 savedEnvironments,
76 onOpenChange,
77 onErrorChange,
78}: {
79 readonly open: boolean;
80 readonly routeTarget: EnvironmentPresentation | null;
81 readonly error: string | null;
82 readonly savedEnvironments: ReadonlyArray<EnvironmentPresentation>;
83 readonly onOpenChange: (open: boolean) => void;
84 readonly onErrorChange: (error: string | null) => void;
85}) {
86 const desktopBridge = window.desktopBridge;
87 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
88 const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false });
89 const connectSshEnvironment = useAtomCommand(connectSshEnvironmentAtom, {
90 reportFailure: false,
91 });
92 const registerEnvironment = useAtomCommand(environmentCatalog.register, {
93 reportFailure: false,
94 });
95 const relayDiscoveryState = useRelayEnvironmentDiscovery();
96 const savedDesktopSshEnvironmentKeys = (() => {
97 const keys = new Set<string>();
98 for (const environment of savedEnvironments) {
99 const profile = environment.entry.profile;
100 if (
101 environment.entry.target._tag !== "SshConnectionTarget" ||
102 Option.isNone(profile) ||
103 profile.value._tag !== "SshConnectionProfile"
104 ) {
105 continue;
106 }
107 const target = profile.value.target;
108 keys.add(target.alias);
109 keys.add(formatDesktopSshTarget(target));
110 }
111 return keys;
112 })();
113 const [savedBackendMode, setSavedBackendMode] = useState<"remote" | "ssh">("remote");
114 const [savedBackendHost, setSavedBackendHost] = useState("");
115 const [savedBackendPairingCode, setSavedBackendPairingCode] = useState("");
116 const [savedBackendSshHost, setSavedBackendSshHost] = useState("");
117 const [savedBackendSshUsername, setSavedBackendSshUsername] = useState("");
118 const [savedBackendSshPort, setSavedBackendSshPort] = useState("");
119 const [sshHostSuggestionsOpen, setSshHostSuggestionsOpen] = useState(false);
120 const highlightedSshHostRef = useRef<DesktopDiscoveredSshHost | undefined>(undefined);
121 const [isAddingSavedBackend, setIsAddingSavedBackend] = useState(false);
122 const savedBackendSessionRef = useRef(0);
123 const isSshDiscoveryActive = desktopBridge !== undefined && open && savedBackendMode === "ssh";
124 const desktopSshHosts = useEnvironmentQuery(
125 isSshDiscoveryActive ? desktopSshHostsStateAtom : null,
126 );
127 // The discovery atom is kept alive across dialog opens, so re-read SSH config
128 // each time the SSH tab is shown; stale hosts stay visible while it refreshes.
129 const refreshDesktopSshHosts = desktopSshHosts.refresh;
130 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- rereads desktop SSH config through the bridge each time the SSH tab is shown
131 useEffect(() => {
132 if (isSshDiscoveryActive) refreshDesktopSshHosts();
133 }, [isSshDiscoveryActive, refreshDesktopSshHosts]);
134 const discoveredSshHosts = desktopSshHosts.data ?? EMPTY_DISCOVERED_SSH_HOSTS;
135 const unsavedDiscoveredSshHosts = discoveredSshHosts.filter((target) => {
136 const address = formatDesktopSshTarget(target);
137 return (
138 !savedDesktopSshEnvironmentKeys.has(target.alias) &&
139 (target.source === "ssh-config" || !savedDesktopSshEnvironmentKeys.has(address))
140 );
141 });
142 const filteredDiscoveredSshHosts = filterDiscoveredSshHosts(
143 unsavedDiscoveredSshHosts,
144 savedBackendSshHost,
145 );
146 const isLoadingDiscoveredSshHosts = desktopSshHosts.isPending && desktopSshHosts.data === null;
147 const discoveredSshHostsError = desktopSshHosts.error;
148 const hasSshHostSuggestionContent =
149 desktopBridge !== undefined &&
150 (isLoadingDiscoveredSshHosts || unsavedDiscoveredSshHosts.length > 0);
151 const handleOpenChange = (nextOpen: boolean) => {
152 if (!nextOpen) {
153 savedBackendSessionRef.current += 1;
154 setSavedBackendMode("remote");
155 setSavedBackendHost("");
156 setSavedBackendPairingCode("");
157 setSavedBackendSshHost("");
158 setSavedBackendSshUsername("");
159 setSavedBackendSshPort("");
160 }
161 onOpenChange(nextOpen);
162 };
163 const connectSavedBackendSshTarget = async (target: DesktopSshEnvironmentTarget) => {
164 const session = savedBackendSessionRef.current;
165 setIsAddingSavedBackend(true);
166 onErrorChange(null);
167 const result = await connectSshEnvironment({
168 target,
169 label: "",
170 ...(routeTarget ? { expectedEnvironmentId: routeTarget.environmentId } : {}),
171 });
172 if (result._tag === "Failure") {
173 if (session === savedBackendSessionRef.current && !isAtomCommandInterrupted(result)) {
174 pipe(squashAtomCommandFailure(result), formatDesktopSshConnectionError, onErrorChange);
175 }
176 setIsAddingSavedBackend(false);
177 return;
178 }
179
180 handleOpenChange(false);
181 toastManager.add({
182 type: "success",
183 title: routeTarget ? "Route added" : "Environment connected",
184 description: routeTarget
185 ? `${routeTarget.label} can now be reached over SSH ${target.alias}.`
186 : `${target.alias} is ready over an SSH-managed tunnel.`,
187 });
188 setIsAddingSavedBackend(false);
189 };
190
191 const handleAddSavedBackend = async () => {
192 if (savedBackendMode === "ssh") {
193 let target: DesktopSshEnvironmentTarget;
194 try {
195 target = parseManualDesktopSshTarget({
196 host: savedBackendSshHost,
197 username: savedBackendSshUsername,
198 port: savedBackendSshPort,
199 });
200 } catch (error) {
201 onErrorChange(formatDesktopSshConnectionError(error));
202 return;
203 }
204
205 await connectSavedBackendSshTarget(target);
206 return;
207 }
208
209 setIsAddingSavedBackend(true);
210 onErrorChange(null);
211 let remotePairingInput: ReturnType<typeof parseRemotePairingFields>;
212 try {
213 remotePairingInput = parseRemotePairingFields({
214 host: savedBackendHost,
215 pairingCode: savedBackendPairingCode,
216 });
217 } catch (error) {
218 const message = error instanceof Error ? error.message : "Failed to add backend.";
219 onErrorChange(message);
220 toastManager.add(
221 stackedThreadToast({
222 type: "error",
223 title: "Could not add backend",
224 description: message,
225 }),
226 );
227 setIsAddingSavedBackend(false);
228 return;
229 }
230
231 const result = await connectPairing({
232 ...remotePairingInput,
233 ...(routeTarget ? { expectedEnvironmentId: routeTarget.environmentId } : {}),
234 });
235 if (result._tag === "Failure") {
236 if (!isAtomCommandInterrupted(result)) {
237 const error = squashAtomCommandFailure(result);
238 const message = error instanceof Error ? error.message : "Failed to add backend.";
239 onErrorChange(message);
240 toastManager.add(
241 stackedThreadToast({
242 type: "error",
243 title: "Could not add backend",
244 description: message,
245 }),
246 );
247 }
248 setIsAddingSavedBackend(false);
249 return;
250 }
251
252 handleOpenChange(false);
253 toastManager.add(
254 routeTarget
255 ? {
256 type: "success",
257 title: "Route added",
258 description: `${routeTarget.label} now has another way to connect.`,
259 }
260 : {
261 type: "success",
262 title: "Backend added",
263 description: "The environment is saved and will reconnect on app startup.",
264 },
265 );
266 setIsAddingSavedBackend(false);
267 };
268
269 const handleSavedBackendSshFieldKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
270 if (event.nativeEvent.isComposing || event.keyCode === 229) return;
271 if (event.key === "Enter" && savedBackendSshHost.trim().length > 0) {
272 event.preventDefault();
273 void handleAddSavedBackend();
274 }
275 };
276
277 const handleSelectSshHostSuggestion = async (target: DesktopDiscoveredSshHost) => {
278 if (isAddingSavedBackend || !desktopBridge) return;
279
280 const session = savedBackendSessionRef.current;
281 setIsAddingSavedBackend(true);
282 onErrorChange(null);
283 setSavedBackendSshHost(target.alias);
284 let resolved: DesktopSshEnvironmentTarget;
285 try {
286 resolved = await desktopBridge.resolveSshHost(target.alias);
287 } catch (error) {
288 if (session === savedBackendSessionRef.current) {
289 onErrorChange(formatDesktopSshConnectionError(error));
290 }
291 setIsAddingSavedBackend(false);
292 return;
293 }
294 if (session !== savedBackendSessionRef.current) {
295 setIsAddingSavedBackend(false);
296 return;
297 }
298 setSavedBackendSshUsername(resolved.username ?? "");
299 setSavedBackendSshPort(resolved.port === null ? "" : String(resolved.port));
300 await connectSavedBackendSshTarget(resolved);
301 };
302
303 const handleSavedBackendSshHostKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
304 if (event.nativeEvent.isComposing || event.keyCode === 229) return;
305
306 const isSshHostPopupVisible = sshHostSuggestionsOpen && hasSshHostSuggestionContent;
307 if (isSshHostPopupVisible) {
308 const command = resolveShortcutCommand(event, keybindings, {
309 platform: navigator.platform,
310 context: { modelPickerOpen: false },
311 });
312 const index = threadJumpIndexFromCommand(command ?? "");
313 const target = index === null ? undefined : filteredDiscoveredSshHosts[index];
314 if (target) {
315 event.preventDefault();
316 event.stopPropagation();
317 setSshHostSuggestionsOpen(false);
318 void handleSelectSshHostSuggestion(target);
319 return;
320 }
321
322 if (event.key === "Escape") {
323 event.preventDefault();
324 event.stopPropagation();
325 return;
326 }
327 }
328
329 const hasHighlightedSshHost =
330 isSshHostPopupVisible && highlightedSshHostRef.current !== undefined;
331 if (
332 !event.defaultPrevented &&
333 !hasHighlightedSshHost &&
334 event.key === "Enter" &&
335 savedBackendSshHost.trim().length > 0
336 ) {
337 event.preventDefault();
338 void handleAddSavedBackend();
339 }
340 };
341
342 const handleSavedBackendHostChange = (value: string) => {
343 const parsedPairingUrl = parsePairingUrlFields(value);
344 if (parsedPairingUrl) {
345 setSavedBackendHost(parsedPairingUrl.host);
346 setSavedBackendPairingCode(parsedPairingUrl.pairingCode);
347 return;
348 }
349 setSavedBackendHost(value);
350 };
351 const renderConnectionModeCard = (input: {
352 readonly mode: "remote" | "ssh";
353 readonly title: string;
354 readonly description: string;
355 readonly icon?: ReactNode;
356 }) => {
357 const selected = savedBackendMode === input.mode;
358 return (
359 <button
360 type="button"
361 aria-pressed={selected}
362 className={cn(
363 "group flex min-h-24 items-start gap-3 rounded-lg border p-4 text-left",
364 selected ? "border-ring surface-selected" : "border-border hover:surface-ghost-hover",
365 )}
366 disabled={isAddingSavedBackend}
367 onClick={() => {
368 setSavedBackendMode(input.mode);
369 }}
370 >
371 {input.icon ? (
372 <span
373 className={cn(
374 "mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border",
375 selected
376 ? "border-ring surface-selected text-strong-foreground"
377 : "border-transparent surface-well text-muted-foreground group-hover:text-strong-foreground",
378 )}
379 >
380 {input.icon}
381 </span>
382 ) : null}
383 <span className="min-w-0">
384 <span className="block text-label text-strong-foreground">{input.title}</span>
385 <span className="mt-1 block text-meta leading-relaxed text-muted-foreground">
386 {input.description}
387 </span>
388 </span>
389 </button>
390 );
391 };
392
393 const renderRemoteFields = () => (
394 <div className="space-y-3">
395 <div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_10rem]">
396 <label className="block">
397 <span className="mb-1.5 block text-meta text-strong-foreground">Host</span>
398 <Input
399 value={savedBackendHost}
400 onChange={(event) => handleSavedBackendHostChange(event.target.value)}
401 placeholder="backend.example.com"
402 disabled={isAddingSavedBackend}
403 spellCheck={false}
404 />
405 </label>
406 <label className="block">
407 <span className="mb-1.5 block text-meta text-strong-foreground">Pairing code</span>
408 <Input
409 value={savedBackendPairingCode}
410 onChange={(event) => setSavedBackendPairingCode(event.target.value)}
411 placeholder="PAIRCODE"
412 disabled={isAddingSavedBackend}
413 spellCheck={false}
414 />
415 </label>
416 </div>
417 <div>
418 <span className="mt-1 block text-caption text-muted-foreground">
419 Paste a full pairing URL here to fill both fields automatically.
420 </span>
421 </div>
422 </div>
423 );
424 const relayRouteOffer =
425 routeTarget !== null &&
426 !routeTarget.relayManaged &&
427 relayDiscoveryState.environments.has(routeTarget.environmentId)
428 ? relayDiscoveryState.environments.get(routeTarget.environmentId)!.environment
429 : null;
430 const addRelayRoute = async () => {
431 if (relayRouteOffer === null || routeTarget === null) return;
432 setIsAddingSavedBackend(true);
433 const result = await registerEnvironment(
434 new RelayConnectionRegistration({
435 target: new RelayConnectionTarget({
436 environmentId: relayRouteOffer.environmentId,
437 label: routeTarget.label,
438 }),
439 }),
440 );
441 setIsAddingSavedBackend(false);
442 if (result._tag === "Failure") {
443 if (!isAtomCommandInterrupted(result)) {
444 const error = squashAtomCommandFailure(result);
445 onErrorChange(error instanceof Error ? error.message : "Could not add the route.");
446 }
447 return;
448 }
449 handleOpenChange(false);
450 toastManager.add({
451 type: "success",
452 title: "Route added",
453 description: `${routeTarget.label} falls back to T3 Connect when its other routes are unreachable.`,
454 });
455 };
456 const renderRemoteModeBody = () => (
457 <div className="space-y-4">
458 {relayRouteOffer !== null ? (
459 <div className="flex items-center justify-between gap-3 rounded-md border border-border px-3 py-2">
460 <p className="text-meta text-muted-foreground">
461 This machine is on your T3 Connect account. Use it as a fallback route.
462 </p>
463 <Button
464 size="xs"
465 variant="secondary"
466 disabled={isAddingSavedBackend}
467 onClick={() => void addRelayRoute()}
468 >
469 Add T3 Connect
470 </Button>
471 </div>
472 ) : null}
473 {renderRemoteFields()}
474 {error ? <p className="text-meta text-destructive">{error}</p> : null}
475 </div>
476 );
477 const renderSshFields = () => (
478 <div className="space-y-4">
479 <div className="space-y-3">
480 <div className="block">
481 <label
482 htmlFor="saved-backend-ssh-host"
483 className="mb-1.5 block text-meta text-strong-foreground"
484 >
485 SSH host or alias
486 </label>
487 <Autocomplete
488 items={filteredDiscoveredSshHosts}
489 itemToStringValue={(target) => target.alias}
490 mode="none"
491 openOnInputClick
492 open={sshHostSuggestionsOpen}
493 onOpenChange={setSshHostSuggestionsOpen}
494 onItemHighlighted={(target) => {
495 highlightedSshHostRef.current = target;
496 }}
497 value={savedBackendSshHost}
498 onValueChange={(value, eventDetails) => {
499 setSavedBackendSshHost(value);
500 if (eventDetails.reason !== "item-press") return;
501
502 const target = filteredDiscoveredSshHosts.find((host) => host.alias === value);
503 if (target) void handleSelectSshHostSuggestion(target);
504 }}
505 >
506 <AutocompleteInput
507 id="saved-backend-ssh-host"
508 onKeyDown={handleSavedBackendSshHostKeyDown}
509 placeholder="Search hosts or type devbox"
510 disabled={isAddingSavedBackend}
511 spellCheck={false}
512 />
513 {hasSshHostSuggestionContent ? (
514 <AutocompletePopup>
515 {isLoadingDiscoveredSshHosts ? (
516 <div className="px-3 py-2 text-meta text-muted-foreground">Loading hosts…</div>
517 ) : filteredDiscoveredSshHosts.length > 0 ? (
518 <AutocompleteList className="max-h-72">
519 {filteredDiscoveredSshHosts.map((target, index) => {
520 const address = formatDesktopSshTarget(target);
521 const shortcutCommand = index < 9 ? threadJumpCommandForIndex(index) : null;
522 const shortcutLabel = shortcutCommand
523 ? shortcutLabelForCommand(keybindings, shortcutCommand, navigator.platform)
524 : null;
525 return (
526 <AutocompleteItem
527 key={`${target.alias}:${target.hostname}:${target.port ?? ""}`}
528 value={target}
529 className="h-8 min-h-8 whitespace-nowrap"
530 >
531 <span className="min-w-0 truncate text-label">{target.alias}</span>
532 {address !== target.alias ? (
533 <span className="min-w-0 flex-1 truncate text-meta text-muted-foreground">
534 {address}
535 </span>
536 ) : (
537 <span className="flex-1" />
538 )}
539 {shortcutLabel ? (
540 <CommandShortcut className="shrink-0">{shortcutLabel}</CommandShortcut>
541 ) : null}
542 </AutocompleteItem>
543 );
544 })}
545 </AutocompleteList>
546 ) : (
547 <AutocompleteEmpty className="break-all">
548 No hosts match "{savedBackendSshHost.trim()}".
549 </AutocompleteEmpty>
550 )}
551 </AutocompletePopup>
552 ) : null}
553 </Autocomplete>
554 </div>
555 <div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_7rem]">
556 <label className="block">
557 <span className="mb-1.5 block text-meta text-strong-foreground">Username</span>
558 <Input
559 value={savedBackendSshUsername}
560 onChange={(event) => setSavedBackendSshUsername(event.target.value)}
561 onKeyDown={handleSavedBackendSshFieldKeyDown}
562 placeholder="root"
563 disabled={isAddingSavedBackend}
564 spellCheck={false}
565 />
566 </label>
567 <label className="block">
568 <span className="mb-1.5 block text-meta text-strong-foreground">Port</span>
569 <Input
570 value={savedBackendSshPort}
571 onChange={(event) => setSavedBackendSshPort(event.target.value)}
572 onKeyDown={handleSavedBackendSshFieldKeyDown}
573 placeholder="22"
574 inputMode="numeric"
575 disabled={isAddingSavedBackend}
576 spellCheck={false}
577 />
578 </label>
579 </div>
580 {error || discoveredSshHostsError ? (
581 <Alert variant="error">
582 <AlertDescription>{error ?? discoveredSshHostsError}</AlertDescription>
583 </Alert>
584 ) : null}
585 </div>
586 </div>
587 );
588
589 return (
590 <Dialog open={open} onOpenChange={handleOpenChange}>
591 <Tooltip>
592 <TooltipTrigger
593 render={
594 <DialogTrigger
595 render={
596 <Button size="standard" variant="secondary" aria-label="Add environment">
597 <PlusIcon className="size-3" />
598 <span>Add environment</span>
599 </Button>
600 }
601 />
602 }
603 />
604 <TooltipPopup side="top">Add environment</TooltipPopup>
605 </Tooltip>
606 <DialogPopup className="max-h-[80dvh] sm:max-w-3xl">
607 <DialogHeader>
608 <DialogTitle>
609 {routeTarget ? `Add a route to ${routeTarget.label}` : "Add environment"}
610 </DialogTitle>
611 <DialogDescription>
612 {routeTarget
613 ? "Pair this machine again over another address, such as its Tailscale name. It joins the existing routes instead of adding a second machine."
614 : "Pair another environment to this client."}
615 </DialogDescription>
616 </DialogHeader>
617 <DialogPanel>
618 <div className="space-y-4">
619 <div className={cn("grid gap-3", desktopBridge && "sm:grid-cols-2")}>
620 {renderConnectionModeCard({
621 mode: "remote",
622 title: "Remote link",
623 description: "Enter a backend host and pairing code.",
624 icon: <ChevronsLeftRightEllipsisIcon aria-hidden className="size-4" />,
625 })}
626 {desktopBridge
627 ? renderConnectionModeCard({
628 mode: "ssh",
629 title: "SSH",
630 description: "Use local SSH config, agent, and tunnels for the backend.",
631 icon: <TerminalIcon aria-hidden className="size-4" />,
632 })
633 : null}
634 </div>
635 <AnimatedHeight>
636 {savedBackendMode === "ssh" ? renderSshFields() : renderRemoteModeBody()}
637 </AnimatedHeight>
638 </div>
639 <DialogFooter variant="bare" className="-mx-5 -mb-4">
640 <Button
641 size="standard"
642 variant="secondary"
643 disabled={isAddingSavedBackend}
644 onClick={() => handleOpenChange(false)}
645 >
646 Cancel
647 </Button>
648 <Button
649 size="standard"
650 disabled={isAddingSavedBackend}
651 onClick={() => void handleAddSavedBackend()}
652 >
653 <PlusIcon className="size-3.5" />
654 {isAddingSavedBackend ? "Adding…" : routeTarget ? "Add route" : "Add environment"}
655 </Button>
656 </DialogFooter>
657 </DialogPanel>
658 </DialogPopup>
659 </Dialog>
660 );
661}