1import { ConnectionTraceId } from "./ConnectionTraceId";
2import { SymbolView } from "../../components/AppSymbol";
3import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
4import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts";
5import { useAtomValue } from "@effect/atom-react";
6import * as Cause from "effect/Cause";
7import { AsyncResult } from "effect/reactivity";
8import { useCallback, useState } from "react";
9import { Platform, Alert, Pressable, View } from "react-native";
10import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
11
12import { AppText as Text } from "../../components/AppText";
13import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
14import { MaterialButton } from "../../components/MaterialButton";
15import { MaterialIconButton } from "../../components/MaterialIconButton";
16import { ThemedSwitch } from "../../components/ThemedSwitch";
17import { cn } from "../../lib/cn";
18import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
19import { serverEnvironment } from "../../state/server";
20import { ConnectionFormField } from "./ConnectionFormField";
21import { ConnectionStatusDot } from "./ConnectionStatusDot";
22import { environmentStatusText } from "./environmentStatusText";
23
24function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null {
25 if (!environment.isEnabled && environment.connectionState !== "unsupported") {
26 return "Off";
27 }
28 return environmentStatusText({
29 phase: environment.connectionState,
30 error: environment.connectionError,
31 traceId: environment.connectionErrorTraceId,
32 });
33}
34
35export function ConnectionEnvironmentRow(props: {
36 readonly environment: ConnectedEnvironmentSummary;
37 readonly expanded: boolean;
38 readonly opensDetails?: boolean;
39 readonly onToggle: () => void;
40 readonly onReconnect: (environmentId: EnvironmentId) => void;
41 readonly onRemove: (environmentId: EnvironmentId) => void;
42 readonly onSetEnabled: (environmentId: EnvironmentId, enabled: boolean) => void;
43 readonly onUpdate: (
44 environmentId: EnvironmentId,
45 updates: { readonly label: string; readonly displayUrl: string },
46 ) => Promise<AtomCommandResult<unknown, unknown>>;
47}) {
48 const [label, setLabel] = useState(props.environment.environmentLabel);
49 const [url, setUrl] = useState(props.environment.displayUrl);
50 const serverConfig = useAtomValue(
51 serverEnvironment.configValueAtom(props.environment.environmentId),
52 );
53 const unsupported = props.environment.connectionState === "unsupported";
54 const enabled = props.environment.isEnabled && !unsupported;
55 const statusLabel = connectionStatusLabel(props.environment);
56 const statusTraceId = enabled ? props.environment.connectionErrorTraceId : null;
57 // Unsupported is a compatibility note, not a failure, so it stays muted.
58 const hasConnectionFailure = enabled && props.environment.connectionError !== null;
59 const isRetrying =
60 enabled &&
61 (props.environment.connectionState === "connecting" ||
62 props.environment.connectionState === "reconnecting");
63 const handleSave = useCallback(async () => {
64 const result = await props.onUpdate(props.environment.environmentId, {
65 label: label.trim(),
66 displayUrl: url.trim(),
67 });
68 if (AsyncResult.isSuccess(result)) {
69 props.onToggle();
70 return;
71 }
72 const error = Cause.squash(result.cause);
73 Alert.alert(
74 "Could not update environment",
75 error instanceof Error ? error.message : "The environment could not be updated.",
76 );
77 }, [label, url, props]);
78
79 return (
80 <Animated.View layout={LinearTransition.duration(250)} className="bg-grouped-card">
81 <Pressable
82 className="flex-row items-center gap-3 px-4 py-3.5 active:opacity-70"
83 accessibilityRole="button"
84 accessibilityLabel={
85 props.opensDetails ? `Manage ${props.environment.environmentLabel}` : undefined
86 }
87 onPress={props.onToggle}
88 >
89 <View className="flex-1 gap-0.5">
90 <View className="flex-row items-center gap-1.5">
91 <ConnectionStatusDot
92 state={enabled || unsupported ? props.environment.connectionState : "available"}
93 pulse={isRetrying}
94 size={8}
95 />
96 <EnvironmentMachineSymbol
97 kind={resolveEnvironmentMachineKind(serverConfig)}
98 size={14}
99 tintColorClassName="accent-foreground-muted"
100 />
101 <Text
102 className="min-w-0 flex-shrink text-base font-t3-bold leading-snug text-foreground"
103 numberOfLines={1}
104 >
105 {props.environment.environmentLabel}
106 </Text>
107 </View>
108 {!props.environment.isRelayManaged && props.environment.displayUrl.trim() ? (
109 <Text className="text-xs text-foreground-muted" numberOfLines={1}>
110 {props.environment.displayUrl}
111 </Text>
112 ) : null}
113 {statusLabel ? (
114 <Text
115 className={cn(
116 "text-xs",
117 hasConnectionFailure ? "text-danger-foreground" : "text-foreground-muted",
118 )}
119 numberOfLines={props.expanded ? undefined : 1}
120 selectable={props.expanded}
121 >
122 {statusLabel}
123 {statusTraceId ? (
124 <ConnectionTraceId
125 traceId={statusTraceId}
126 tone={hasConnectionFailure ? "danger" : "muted"}
127 activation="longPress"
128 />
129 ) : null}
130 </Text>
131 ) : null}
132 </View>
133
134 <ThemedSwitch
135 style={{ alignSelf: "center" }}
136 disabled={unsupported}
137 onValueChange={(next) => props.onSetEnabled(props.environment.environmentId, next)}
138 value={enabled}
139 />
140 <SymbolView
141 name={props.opensDetails ? "chevron.right" : "chevron.down"}
142 size={12}
143 tintColorClassName="accent-icon-subtle"
144 type="monochrome"
145 style={{
146 transform: [{ rotate: props.expanded ? "180deg" : "0deg" }],
147 }}
148 />
149 </Pressable>
150
151 {props.expanded ? (
152 <Animated.View
153 entering={FadeIn.duration(200)}
154 exiting={FadeOut.duration(150)}
155 className="gap-3 px-4 pb-4"
156 >
157 {props.environment.isRelayManaged ? (
158 <Text className="text-sm text-foreground-muted">
159 Managed by T3 Connect. Tunnel details update automatically.
160 </Text>
161 ) : (
162 <>
163 <ConnectionFormField
164 label="Label"
165 autoCapitalize="words"
166 autoCorrect={false}
167 placeholder="My MacBook"
168 value={label}
169 onChangeText={setLabel}
170 />
171
172 <ConnectionFormField
173 label="URL"
174 autoCapitalize="none"
175 autoCorrect={false}
176 keyboardType="url"
177 placeholder="192.168.1.100:8080"
178 value={url}
179 onChangeText={setUrl}
180 />
181 </>
182 )}
183
184 {Platform.OS === "android" ? (
185 <View className="flex-row items-center justify-end gap-2">
186 {props.environment.isRelayManaged ? null : (
187 <View className="flex-1">
188 <MaterialButton
189 label="Save"
190 tone="primary"
191 fullWidth
192 onPress={() => {
193 void handleSave();
194 }}
195 />
196 </View>
197 )}
198 <MaterialIconButton
199 accessibilityLabel="Reconnect environment"
200 icon="arrow.clockwise"
201 variant="tonal"
202 disabled={!enabled}
203 onPress={() => props.onReconnect(props.environment.environmentId)}
204 />
205 <MaterialIconButton
206 accessibilityLabel="Remove environment"
207 icon="trash"
208 variant="danger"
209 onPress={() => props.onRemove(props.environment.environmentId)}
210 />
211 </View>
212 ) : (
213 <View className="flex-row justify-end gap-2">
214 {props.environment.isRelayManaged ? null : (
215 <Pressable
216 className="min-h-[42px] flex-1 flex-row items-center justify-center gap-1.5 rounded-[14px] bg-primary px-3.5 py-2.5 active:opacity-70"
217 onPress={handleSave}
218 >
219 <SymbolView
220 name="checkmark"
221 size={13}
222 tintColorClassName="accent-primary-foreground"
223 type="monochrome"
224 />
225 <Text className="text-xs font-t3-bold tracking-[0.8px] uppercase text-primary-foreground">
226 Save
227 </Text>
228 </Pressable>
229 )}
230
231 <Pressable
232 className="h-[42px] w-[42px] items-center justify-center rounded-[14px] border border-input-border bg-input active:opacity-70 disabled:opacity-40"
233 disabled={!enabled}
234 onPress={() => props.onReconnect(props.environment.environmentId)}
235 >
236 <SymbolView
237 name="arrow.clockwise"
238 size={14}
239 tintColorClassName="accent-icon-subtle"
240 type="monochrome"
241 />
242 </Pressable>
243
244 <Pressable
245 className="h-[42px] w-[42px] items-center justify-center rounded-[14px] border border-danger-border bg-danger active:opacity-70"
246 onPress={() => props.onRemove(props.environment.environmentId)}
247 >
248 <SymbolView
249 name="trash"
250 size={14}
251 tintColorClassName="accent-danger-foreground"
252 type="monochrome"
253 />
254 </Pressable>
255 </View>
256 )}
257 </Animated.View>
258 ) : null}
259 </Animated.View>
260 );
261}