Skip to content
Source
Sign inSign up

ConnectionEnvironmentRow.tsx

320 lines12.5 KB

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