| 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 3 | import { |
| 4 | AuthProvidersManageScope, |
| 5 | AuthOrchestrationReadScope, |
| 6 | type EnvironmentId, |
| 7 | type ServerProvider, |
| 8 | } from "@t3tools/contracts"; |
| 9 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 10 | import { AsyncResult } from "effect/reactivity"; |
| 11 | import { useEffect, useRef, useState } from "react"; |
| 12 | import { Alert, View } from "react-native"; |
| 13 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 14 | |
| 15 | import { AppText as Text } from "../../components/AppText"; |
| 16 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 17 | import { ScreenScrollView } from "../../components/ScreenScrollView"; |
| 18 | import { appAtomRegistry } from "../../state/atom-registry"; |
| 19 | import { serverEnvironment } from "../../state/server"; |
| 20 | import { environmentSession, useEnvironmentScope, readEnvironmentScope } from "../../state/session"; |
| 21 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 22 | import { useRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 23 | import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; |
| 24 | import { EnvironmentRoutesSection } from "./EnvironmentRoutesSection"; |
| 25 | import { SettingsActionRow } from "./components/SettingsActionRow"; |
| 26 | import { SettingsScreen } from "./components/SettingsScreen"; |
| 27 | import { SettingsSection } from "./components/SettingsSection"; |
| 28 | import { |
| 29 | canMaintainEnvironment, |
| 30 | canUpdateEnvironmentProvider, |
| 31 | findEnvironmentUpdate, |
| 32 | supportsEnvironmentUpdate, |
| 33 | } from "./environment-maintenance"; |
| 34 | |
| 35 | export function SettingsEnvironmentDetailRouteScreen({ |
| 36 | route, |
| 37 | }: StaticScreenProps<{ |
| 38 | readonly environmentId: EnvironmentId; |
| 39 | }>) { |
| 40 | // Key local request state to the host even when navigation reuses this screen. |
| 41 | return ( |
| 42 | <EnvironmentDetail |
| 43 | key={route.params.environmentId} |
| 44 | environmentId={route.params.environmentId} |
| 45 | /> |
| 46 | ); |
| 47 | } |
| 48 | |
| 49 | function EnvironmentDetail({ environmentId }: { readonly environmentId: EnvironmentId }) { |
| 50 | const insets = useSafeAreaInsets(); |
| 51 | const navigation = useNavigation(); |
| 52 | const connections = useRemoteConnections(); |
| 53 | const environment = connections.connectedEnvironments.find( |
| 54 | (entry) => entry.environmentId === environmentId, |
| 55 | ); |
| 56 | const config = useAtomValue(serverEnvironment.configValueAtom(environmentId)); |
| 57 | const session = useAtomValue(environmentSession.sessionStateValueAtom(environmentId)); |
| 58 | const sessionResult = useAtomValue(environmentSession.sessionStateAtom(environmentId)); |
| 59 | const updateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId)); |
| 60 | const updateServer = useAtomCommand(serverEnvironment.updateServer); |
| 61 | const updateProvider = useAtomCommand(serverEnvironment.updateProvider); |
| 62 | const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders); |
| 63 | const [connectionExpanded, setConnectionExpanded] = useState(false); |
| 64 | const [pending, setPending] = useState<string | null>(null); |
| 65 | const pendingRef = useRef(false); |
| 66 | const [error, setError] = useState<string | null>(null); |
| 67 | const [notice, setNotice] = useState<string | null>(null); |
| 68 | const [release, setRelease] = useState<{ |
| 69 | fromVersion: string; |
| 70 | targetVersion: string | null; |
| 71 | } | null>(null); |
| 72 | const checkController = useRef<AbortController | null>(null); |
| 73 | useEffect(() => () => checkController.current?.abort(), []); |
| 74 | |
| 75 | const connected = environment?.isEnabled === true && environment.connectionState === "connected"; |
| 76 | const allowed = |
| 77 | !AsyncResult.isFailure(sessionResult) && canMaintainEnvironment(session, connected); |
| 78 | const running = updateState.status === "running"; |
| 79 | const providerBusy = |
| 80 | config?.providers.some( |
| 81 | (provider) => |
| 82 | provider.updateState?.status === "running" || provider.updateState?.status === "queued", |
| 83 | ) ?? false; |
| 84 | const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope); |
| 85 | const canRead = useEnvironmentScope(environmentId, AuthOrchestrationReadScope); |
| 86 | const busy = !connected || pending !== null || running || providerBusy; |
| 87 | const disabled = !allowed || busy; |
| 88 | const version = config?.environment.serverVersion; |
| 89 | const checkedRelease = release?.fromVersion === version ? release : null; |
| 90 | const capabilities = config?.environment.capabilities; |
| 91 | |
| 92 | async function run(label: string, action: () => Promise<void>) { |
| 93 | if (pendingRef.current) return; |
| 94 | pendingRef.current = true; |
| 95 | setPending(label); |
| 96 | setError(null); |
| 97 | setNotice(null); |
| 98 | try { |
| 99 | await action(); |
| 100 | } catch (cause) { |
| 101 | setError( |
| 102 | cause instanceof Error ? cause.message : "The action could not be completed. Try again.", |
| 103 | ); |
| 104 | } finally { |
| 105 | pendingRef.current = false; |
| 106 | setPending(null); |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | function requestServerUpdate() { |
| 111 | const targetVersion = checkedRelease?.targetVersion; |
| 112 | if (disabled || !targetVersion || !capabilities || !supportsEnvironmentUpdate(capabilities)) |
| 113 | return; |
| 114 | Alert.alert( |
| 115 | `Update ${environment?.environmentLabel ?? "environment"}?`, |
| 116 | `Install The Knife ${targetVersion}. ${capabilities.serverSelfUpdate === "desktop-managed" ? "The desktop app will close and relaunch." : "The server will restart and reconnect."} Running threads may be interrupted.`, |
| 117 | [ |
| 118 | { text: "Cancel", style: "cancel" }, |
| 119 | { |
| 120 | text: "Update", |
| 121 | onPress: () => |
| 122 | void run("server", async () => { |
| 123 | if ( |
| 124 | AsyncResult.isFailure( |
| 125 | appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId)), |
| 126 | ) || |
| 127 | !canMaintainEnvironment( |
| 128 | appAtomRegistry.get(environmentSession.sessionStateValueAtom(environmentId)), |
| 129 | connected, |
| 130 | ) |
| 131 | ) |
| 132 | return; |
| 133 | const result = await updateServer({ |
| 134 | environmentId, |
| 135 | input: { |
| 136 | targetVersion, |
| 137 | ...(capabilities.serverUpdateThreadContinuation && |
| 138 | config?.settings.continueThreadsAfterServerUpdate |
| 139 | ? { continueRunningThreads: true } |
| 140 | : {}), |
| 141 | }, |
| 142 | }); |
| 143 | if (AsyncResult.isFailure(result)) throw squashAtomCommandFailure(result); |
| 144 | setRelease(null); |
| 145 | setNotice(`Updated to ${result.value.targetVersion}.`); |
| 146 | }), |
| 147 | }, |
| 148 | ], |
| 149 | ); |
| 150 | } |
| 151 | |
| 152 | function requestProviderUpdate(provider: ServerProvider) { |
| 153 | if ( |
| 154 | busy || |
| 155 | !readEnvironmentScope(environmentId, AuthProvidersManageScope) || |
| 156 | !canUpdateEnvironmentProvider(provider) |
| 157 | ) |
| 158 | return; |
| 159 | void run(provider.instanceId, async () => { |
| 160 | const result = await updateProvider({ |
| 161 | environmentId, |
| 162 | input: { |
| 163 | provider: provider.driver, |
| 164 | instanceId: provider.instanceId, |
| 165 | }, |
| 166 | }); |
| 167 | if (AsyncResult.isFailure(result)) throw squashAtomCommandFailure(result); |
| 168 | }); |
| 169 | } |
| 170 | |
| 171 | return ( |
| 172 | <SettingsScreen title={environment?.environmentLabel ?? "Environment"}> |
| 173 | <ScreenScrollView |
| 174 | contentInsetAdjustmentBehavior="automatic" |
| 175 | className="flex-1" |
| 176 | contentContainerClassName="gap-6 px-5 pt-4" |
| 177 | contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} |
| 178 | > |
| 179 | {!environment ? ( |
| 180 | <Text className="text-base text-foreground-muted"> |
| 181 | This environment is no longer saved on this device. |
| 182 | </Text> |
| 183 | ) : ( |
| 184 | <> |
| 185 | <SettingsSection title="Connection"> |
| 186 | <ConnectionEnvironmentRow |
| 187 | environment={environment} |
| 188 | expanded={connectionExpanded} |
| 189 | onToggle={() => setConnectionExpanded((value) => !value)} |
| 190 | onReconnect={connections.onReconnectEnvironment} |
| 191 | onRemove={connections.onRemoveEnvironmentPress} |
| 192 | onSetEnabled={connections.onSetEnvironmentEnabled} |
| 193 | onUpdate={connections.onUpdateEnvironment} |
| 194 | /> |
| 195 | </SettingsSection> |
| 196 | <EnvironmentRoutesSection |
| 197 | environmentId={environmentId} |
| 198 | connected={connected} |
| 199 | onAddRoute={() => |
| 200 | navigation.navigate("SettingsSheet", { |
| 201 | screen: "SettingsContent", |
| 202 | params: { |
| 203 | screen: "SettingsEnvironmentNew", |
| 204 | params: { routeFor: environmentId }, |
| 205 | }, |
| 206 | }) |
| 207 | } |
| 208 | /> |
| 209 | {!connected ? ( |
| 210 | <Text className="px-2 text-sm text-foreground-muted"> |
| 211 | Connect this environment to manage it. |
| 212 | </Text> |
| 213 | ) : !allowed ? ( |
| 214 | <Text className="px-2 text-sm text-foreground-muted"> |
| 215 | {AsyncResult.isFailure(sessionResult) |
| 216 | ? "Could not verify your permissions. Reconnect to try again." |
| 217 | : session === null |
| 218 | ? "Checking permissions…" |
| 219 | : "This connection does not have permission to manage the environment."} |
| 220 | </Text> |
| 221 | ) : null} |
| 222 | {error ? ( |
| 223 | <Text selectable className="px-2 text-sm text-danger-foreground"> |
| 224 | {error} |
| 225 | </Text> |
| 226 | ) : null} |
| 227 | {notice ? <Text className="px-2 text-sm text-foreground-muted">{notice}</Text> : null} |
| 228 | {config ? ( |
| 229 | <> |
| 230 | <SettingsSection title="The Knife"> |
| 231 | <View className="gap-1 p-4"> |
| 232 | <Text className="text-base text-foreground">Version {version}</Text> |
| 233 | {running ? ( |
| 234 | <Text className="text-sm text-foreground-muted"> |
| 235 | {updateState.stage === "resuming" |
| 236 | ? "Restarting and reconnecting…" |
| 237 | : "Downloading update…"} |
| 238 | </Text> |
| 239 | ) : updateState.status === "failed" ? ( |
| 240 | <Text selectable className="text-sm text-danger-foreground"> |
| 241 | {updateState.message} |
| 242 | </Text> |
| 243 | ) : null} |
| 244 | {checkedRelease ? ( |
| 245 | <Text className="text-sm text-foreground-muted"> |
| 246 | {checkedRelease.targetVersion |
| 247 | ? `Version ${checkedRelease.targetVersion} is available.` |
| 248 | : "You are up to date."} |
| 249 | </Text> |
| 250 | ) : null} |
| 251 | {!supportsEnvironmentUpdate(config.environment.capabilities) ? ( |
| 252 | <Text className="text-sm text-foreground-muted"> |
| 253 | {capabilities?.serverSelfUpdate === "desktop-managed" |
| 254 | ? "Update the desktop app on this machine." |
| 255 | : "Update and restart The Knife on this machine."} |
| 256 | </Text> |
| 257 | ) : null} |
| 258 | </View> |
| 259 | <SettingsActionRow |
| 260 | icon="arrow.clockwise" |
| 261 | label="Check for updates" |
| 262 | disabled={disabled} |
| 263 | loading={pending === "check"} |
| 264 | onPress={() => { |
| 265 | if (disabled) return; |
| 266 | void run("check", async () => { |
| 267 | const controller = new AbortController(); |
| 268 | checkController.current = controller; |
| 269 | const timeout = setTimeout(() => controller.abort(), 20_000); |
| 270 | try { |
| 271 | const targetVersion = await findEnvironmentUpdate( |
| 272 | config.environment.serverVersion, |
| 273 | controller.signal, |
| 274 | ); |
| 275 | setRelease({ |
| 276 | fromVersion: config.environment.serverVersion, |
| 277 | targetVersion, |
| 278 | }); |
| 279 | } finally { |
| 280 | clearTimeout(timeout); |
| 281 | checkController.current = null; |
| 282 | } |
| 283 | }); |
| 284 | }} |
| 285 | /> |
| 286 | {checkedRelease?.targetVersion && |
| 287 | supportsEnvironmentUpdate(config.environment.capabilities) ? ( |
| 288 | <SettingsActionRow |
| 289 | icon="arrow.up.circle" |
| 290 | label={`Update to ${checkedRelease.targetVersion}`} |
| 291 | disabled={disabled} |
| 292 | loading={pending === "server" || running} |
| 293 | onPress={requestServerUpdate} |
| 294 | /> |
| 295 | ) : null} |
| 296 | </SettingsSection> |
| 297 | <SettingsSection title="Providers"> |
| 298 | <SettingsActionRow |
| 299 | icon="arrow.clockwise" |
| 300 | label="Refresh providers" |
| 301 | disabled={busy || !canRead} |
| 302 | loading={pending === "refresh"} |
| 303 | onPress={() => { |
| 304 | if (busy || !canRead) return; |
| 305 | void run("refresh", async () => { |
| 306 | if (!readEnvironmentScope(environmentId, AuthOrchestrationReadScope)) |
| 307 | return; |
| 308 | const result = await refreshProviders({ environmentId, input: {} }); |
| 309 | if (AsyncResult.isFailure(result)) throw squashAtomCommandFailure(result); |
| 310 | setNotice("Provider status refreshed."); |
| 311 | }); |
| 312 | }} |
| 313 | /> |
| 314 | {config.providers |
| 315 | .filter((provider) => provider.enabled) |
| 316 | .map((provider) => ( |
| 317 | <View key={provider.instanceId}> |
| 318 | <View className="gap-1 p-4"> |
| 319 | <View className="flex-row items-center gap-2"> |
| 320 | <ProviderIcon provider={provider.driver} size={18} /> |
| 321 | <Text className="min-w-0 flex-1 text-base font-t3-medium text-foreground"> |
| 322 | {provider.displayName ?? provider.driver} |
| 323 | </Text> |
| 324 | </View> |
| 325 | <Text className="text-sm text-foreground-muted"> |
| 326 | {provider.installed |
| 327 | ? (provider.version ?? "Version unknown") |
| 328 | : "Not installed"} |
| 329 | {provider.versionAdvisory?.latestVersion |
| 330 | ? ` · Latest ${provider.versionAdvisory.latestVersion}` |
| 331 | : ""} |
| 332 | </Text> |
| 333 | {provider.updateState && provider.updateState.status !== "idle" ? ( |
| 334 | <Text |
| 335 | selectable |
| 336 | className={ |
| 337 | provider.updateState.status === "failed" |
| 338 | ? "text-sm text-danger-foreground" |
| 339 | : "text-sm text-foreground-muted" |
| 340 | } |
| 341 | > |
| 342 | {provider.updateState.message ?? |
| 343 | `Update ${provider.updateState.status}`} |
| 344 | </Text> |
| 345 | ) : null} |
| 346 | {provider.compatibilityAdvisory?.message ? ( |
| 347 | <Text selectable className="text-sm text-foreground-muted"> |
| 348 | {provider.compatibilityAdvisory.message} |
| 349 | </Text> |
| 350 | ) : null} |
| 351 | {provider.unavailableReason || provider.message ? ( |
| 352 | <Text selectable className="text-sm text-foreground-muted"> |
| 353 | {provider.unavailableReason ?? provider.message} |
| 354 | </Text> |
| 355 | ) : null} |
| 356 | {provider.versionAdvisory?.status === "behind_latest" && |
| 357 | !provider.versionAdvisory.canUpdate ? ( |
| 358 | <Text className="text-sm text-foreground-muted"> |
| 359 | Update this provider on the environment's machine. |
| 360 | </Text> |
| 361 | ) : null} |
| 362 | </View> |
| 363 | {canUpdateEnvironmentProvider(provider) ? ( |
| 364 | <SettingsActionRow |
| 365 | icon="arrow.up.circle" |
| 366 | label={`Update ${provider.displayName ?? provider.driver}`} |
| 367 | disabled={busy || !canManageProviders} |
| 368 | loading={pending === provider.instanceId} |
| 369 | onPress={() => requestProviderUpdate(provider)} |
| 370 | /> |
| 371 | ) : null} |
| 372 | </View> |
| 373 | ))} |
| 374 | </SettingsSection> |
| 375 | </> |
| 376 | ) : null} |
| 377 | </> |
| 378 | )} |
| 379 | </ScreenScrollView> |
| 380 | </SettingsScreen> |
| 381 | ); |
| 382 | } |