1import {
2 DndContext,
3 type DragEndEvent,
4 KeyboardSensor,
5 PointerSensor,
6 closestCenter,
7 useSensor,
8 useSensors,
9} from "@dnd-kit/core";
10import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers";
11import {
12 SortableContext,
13 arrayMove,
14 sortableKeyboardCoordinates,
15 useSortable,
16 verticalListSortingStrategy,
17} from "@dnd-kit/sortable";
18import { CSS } from "@dnd-kit/utilities";
19import {
20 type ConnectionRoute,
21 connectionRouteAddress,
22 connectionRouteId,
23 connectionRouteLabel,
24 connectionRoutes,
25 isLearned,
26} from "@t3tools/client-runtime/connection";
27import { GripVerticalIcon, PlusIcon, XIcon } from "lucide-react";
28import { useState } from "react";
29
30import { requestConfirmDialog } from "~/confirmDialog";
31import { environmentCatalog } from "~/connection/catalog";
32import { cn } from "~/lib/utils";
33import type { EnvironmentPresentation } from "~/state/environments";
34import { usePreparedConnection } from "~/state/session";
35import { useAtomCommand } from "~/state/use-atom-command";
36import { Button } from "../ui/button";
37import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
38
39/**
40 * The ways this client reaches one saved machine, preferred first. Drag to
41 * reorder; the first route that answers is used and the connection moves back
42 * up when a better one is reachable again. Shown under the machine's row.
43 */
44export function EnvironmentRoutesList({
45 environment,
46 onAddRoute,
47}: {
48 readonly environment: EnvironmentPresentation;
49 readonly onAddRoute: () => void;
50}) {
51 const saved = connectionRoutes(environment.entry);
52 const savedIds = saved.map((route) => connectionRouteId(route.target));
53 // A dropped order shows until the catalog matches it, so the row does not
54 // jump back while the reorder is being saved.
55 const [pending, setPending] = useState<ReadonlyArray<string> | null>(null);
56 const order: Array<string> =
57 pending !== null &&
58 pending.length === savedIds.length &&
59 pending.some((id, index) => id !== savedIds[index])
60 ? [...pending]
61 : savedIds;
62 const byId = new Map(saved.map((route) => [connectionRouteId(route.target), route]));
63 const routes = order.flatMap((id) => byId.get(id) ?? []);
64
65 const prepared = usePreparedConnection(environment.environmentId);
66 const activeRouteId =
67 prepared._tag === "Some" && environment.connection.phase === "connected"
68 ? connectionRouteId(prepared.value.target)
69 : null;
70 const reorder = useAtomCommand(environmentCatalog.reorderRoutes, "Reorder routes");
71 const removeRoute = useAtomCommand(environmentCatalog.removeRoute, "Remove route");
72 const sensors = useSensors(
73 useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
74 useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
75 );
76
77 // Removing a paired route forgets its credential, so it asks first, like
78 // on mobile. No mounted confirm host means no removal.
79 const confirmRemove = async (route: ConnectionRoute) => {
80 const address = connectionRouteAddress(route);
81 const confirmed = await requestConfirmDialog(
82 `Remove ${connectionRouteLabel(route)} route?${address === null ? "" : `\n${address}`}`,
83 { variant: "destructive" },
84 );
85 if (confirmed !== true) return;
86 await removeRoute({
87 environmentId: environment.environmentId,
88 routeId: connectionRouteId(route.target),
89 });
90 };
91
92 const handleDragEnd = (event: DragEndEvent) => {
93 if (event.over === null || event.active.id === event.over.id) return;
94 const next = arrayMove(
95 order,
96 order.indexOf(String(event.active.id)),
97 order.indexOf(String(event.over.id)),
98 );
99 setPending(next);
100 void reorder({ environmentId: environment.environmentId, routeIds: next }).then((result) => {
101 if (result._tag === "Failure") setPending(null);
102 });
103 };
104
105 return (
106 <div className="mt-2 border-t border-border/50 py-2">
107 <DndContext
108 sensors={sensors}
109 collisionDetection={closestCenter}
110 modifiers={[restrictToVerticalAxis, restrictToParentElement]}
111 onDragEnd={handleDragEnd}
112 >
113 <SortableContext items={order} strategy={verticalListSortingStrategy}>
114 <ol aria-label={`Routes to ${environment.label}, preferred first`}>
115 {routes.map((route, index) => (
116 <SortableRouteRow
117 key={connectionRouteId(route.target)}
118 route={route}
119 position={index + 1}
120 inUse={connectionRouteId(route.target) === activeRouteId}
121 // The last route goes with the machine; that is "Remove from
122 // this device", not a route action. A learned route would be
123 // learned again, so it is only reordered.
124 removable={routes.length > 1 && !isLearned(route)}
125 onRemove={() => void confirmRemove(route)}
126 />
127 ))}
128 </ol>
129 </SortableContext>
130 </DndContext>
131 <div className="-ml-1.5 pt-1">
132 <Button size="xs" variant="ghost-muted" onClick={onAddRoute}>
133 <PlusIcon className="size-3" />
134 Add route
135 </Button>
136 </div>
137 </div>
138 );
139}
140
141function SortableRouteRow({
142 route,
143 position,
144 inUse,
145 removable,
146 onRemove,
147}: {
148 readonly route: ConnectionRoute;
149 readonly position: number;
150 readonly inUse: boolean;
151 readonly removable: boolean;
152 readonly onRemove: () => void;
153}) {
154 const id = connectionRouteId(route.target);
155 const label = connectionRouteLabel(route);
156 const address = connectionRouteAddress(route);
157 const {
158 attributes,
159 listeners,
160 setNodeRef,
161 setActivatorNodeRef,
162 transform,
163 transition,
164 isDragging,
165 } = useSortable({ id });
166
167 return (
168 <li
169 ref={setNodeRef}
170 style={{ transform: CSS.Translate.toString(transform), transition }}
171 className={cn(
172 "flex items-center gap-2 py-2",
173 isDragging && "relative z-10 rounded-md bg-background shadow-md",
174 )}
175 >
176 <div className="min-w-0 flex-1">
177 <p className="flex items-center gap-1.5 text-xs font-medium text-foreground">
178 {label}
179 {inUse ? (
180 <span className="rounded-sm bg-success/12 px-1 text-2xs font-normal text-success-foreground">
181 In use
182 </span>
183 ) : null}
184 </p>
185 {address !== null ? (
186 <p className="truncate text-2xs text-muted-foreground">
187 {address}
188 {isLearned(route) ? " · found automatically" : ""}
189 </p>
190 ) : null}
191 </div>
192 <button
193 type="button"
194 ref={setActivatorNodeRef}
195 {...attributes}
196 {...listeners}
197 aria-label={`Reorder ${label}, position ${position}`}
198 className="flex size-6 shrink-0 cursor-grab touch-none items-center justify-center rounded text-muted-foreground/70 outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring active:cursor-grabbing"
199 >
200 <GripVerticalIcon className="size-3.5" />
201 </button>
202 {removable ? (
203 <Tooltip>
204 <TooltipTrigger
205 render={
206 <Button
207 type="button"
208 variant="ghost-muted"
209 size="icon-xs"
210 aria-label={`Remove ${label} route`}
211 onClick={onRemove}
212 />
213 }
214 >
215 <XIcon className="size-3" />
216 </TooltipTrigger>
217 <TooltipPopup side="top">Remove route</TooltipPopup>
218 </Tooltip>
219 ) : null}
220 </li>
221 );
222}