| author | |
| committer | |
| log | 2587c8060c103245b4f970b925977008f2592d1d |
| tree | c812cd1d3b9451193edde9f110f21183bcd429dd |
| parent | 2db675aeffd9cb1e8b5ad76ddd018433b45b02e9 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>40 files changed, 762 insertions(+), 173 deletions(-)
apps/desktop/src/app/DesktopConnectionCatalogStore.ts+1| ... | @@ -372,6 +372,7 @@ const migrateSavedEnvironmentRecords = Effect.fn( | ... | @@ -372,6 +372,7 @@ const migrateSavedEnvironmentRecords = Effect.fn( |
| 372 | profiles, | 372 | profiles, |
| 373 | credentials, | 373 | credentials, |
| 374 | remoteDpopTokens: [], | 374 | remoteDpopTokens: [], |
| 375 | disabledEnvironmentIds: [], | ||
| 375 | }; | 376 | }; |
| 376 | }); | 377 | }); |
| 377 | 378 |
apps/mobile/src/connection/storage.ts+17-1| ... | @@ -5,6 +5,7 @@ import { | ... | @@ -5,6 +5,7 @@ import { |
| 5 | putRemoteDpopTokenInCatalog, | 5 | putRemoteDpopTokenInCatalog, |
| 6 | registerConnectionInCatalog, | 6 | registerConnectionInCatalog, |
| 7 | removeConnectionFromCatalog, | 7 | removeConnectionFromCatalog, |
| 8 | setConnectionEnabledInCatalog, | ||
| 8 | removeCatalogValue, | 9 | removeCatalogValue, |
| 9 | replaceCatalogValue, | 10 | replaceCatalogValue, |
| 10 | } from "@t3tools/client-runtime/platform"; | 11 | } from "@t3tools/client-runtime/platform"; |
| ... | @@ -21,7 +22,12 @@ import * as Option from "effect/Option"; | ... | @@ -21,7 +22,12 @@ import * as Option from "effect/Option"; |
| 21 | import * as CatalogStore from "./catalog-store"; | 22 | import * as CatalogStore from "./catalog-store"; |
| 22 | 23 | ||
| 23 | function targetPersistenceError( | 24 | function targetPersistenceError( |
| 24 | operation: "list-targets" | "register-connection" | "remove-connection", | 25 | operation: |
| 26 | | "list-targets" | ||
| 27 | | "list-disabled-targets" | ||
| 28 | | "register-connection" | ||
| 29 | | "remove-connection" | ||
| 30 | | "set-connection-enabled", | ||
| 25 | error: ConnectionTransientError, | 31 | error: ConnectionTransientError, |
| 26 | ) { | 32 | ) { |
| 27 | return new ConnectionPersistenceError({ | 33 | return new ConnectionPersistenceError({ |
| ... | @@ -39,6 +45,10 @@ export const connectionStorageLayer = Layer.effectContext( | ... | @@ -39,6 +45,10 @@ export const connectionStorageLayer = Layer.effectContext( |
| 39 | Effect.map((document) => document.targets), | 45 | Effect.map((document) => document.targets), |
| 40 | Effect.mapError((error) => targetPersistenceError("list-targets", error)), | 46 | Effect.mapError((error) => targetPersistenceError("list-targets", error)), |
| 41 | ), | 47 | ), |
| 48 | listDisabled: catalog.read.pipe( | ||
| 49 | Effect.map((document) => document.disabledEnvironmentIds), | ||
| 50 | Effect.mapError((error) => targetPersistenceError("list-disabled-targets", error)), | ||
| 51 | ), | ||
| 42 | }); | 52 | }); |
| 43 | const registrationStore = ConnectionRegistrationStore.of({ | 53 | const registrationStore = ConnectionRegistrationStore.of({ |
| 44 | register: (registration) => | 54 | register: (registration) => |
| ... | @@ -49,6 +59,12 @@ export const connectionStorageLayer = Layer.effectContext( | ... | @@ -49,6 +59,12 @@ export const connectionStorageLayer = Layer.effectContext( |
| 49 | catalog | 59 | catalog |
| 50 | .update((document) => removeConnectionFromCatalog(document, target)) | 60 | .update((document) => removeConnectionFromCatalog(document, target)) |
| 51 | .pipe(Effect.mapError((error) => targetPersistenceError("remove-connection", error))), | 61 | .pipe(Effect.mapError((error) => targetPersistenceError("remove-connection", error))), |
| 62 | setEnabled: (environmentId, enabled) => | ||
| 63 | catalog | ||
| 64 | .update((document) => setConnectionEnabledInCatalog(document, environmentId, enabled)) | ||
| 65 | .pipe( | ||
| 66 | Effect.mapError((error) => targetPersistenceError("set-connection-enabled", error)), | ||
| 67 | ), | ||
| 52 | }); | 68 | }); |
| 53 | const profileStore = ProfileStore.make({ | 69 | const profileStore = ProfileStore.make({ |
| 54 | get: (connectionId) => | 70 | get: (connectionId) => |
apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx+4-2| ... | @@ -45,7 +45,8 @@ function ConfiguredConnectOnboardingRouteScreen() { | ... | @@ -45,7 +45,8 @@ function ConfiguredConnectOnboardingRouteScreen() { |
| 45 | const navigation = useNavigation(); | 45 | const navigation = useNavigation(); |
| 46 | const insets = useSafeAreaInsets(); | 46 | const insets = useSafeAreaInsets(); |
| 47 | const { isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); | 47 | const { isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); |
| 48 | const { connectedEnvironments, onReconnectEnvironment } = useRemoteConnections(); | 48 | const { connectedEnvironments, onSetEnvironmentEnabled, onRemoveEnvironmentPress } = |
| 49 | useRemoteConnections(); | ||
| 49 | const { refreshRelayEnvironments } = useConnectionController(); | 50 | const { refreshRelayEnvironments } = useConnectionController(); |
| 50 | const { connectedCloudEnvironments } = splitEnvironmentSections({ | 51 | const { connectedCloudEnvironments } = splitEnvironmentSections({ |
| 51 | connectedEnvironments, | 52 | connectedEnvironments, |
| ... | @@ -110,7 +111,8 @@ function ConfiguredConnectOnboardingRouteScreen() { | ... | @@ -110,7 +111,8 @@ function ConfiguredConnectOnboardingRouteScreen() { |
| 110 | {isSignedIn ? ( | 111 | {isSignedIn ? ( |
| 111 | <CloudEnvironmentRows | 112 | <CloudEnvironmentRows |
| 112 | connectedCloudEnvironments={connectedCloudEnvironments} | 113 | connectedCloudEnvironments={connectedCloudEnvironments} |
| 113 | onReconnectEnvironment={onReconnectEnvironment} | 114 | onSetEnvironmentEnabled={onSetEnvironmentEnabled} |
| 115 | onRemoveEnvironment={onRemoveEnvironmentPress} | ||
| 114 | showHeader={false} | 116 | showHeader={false} |
| 115 | /> | 117 | /> |
| 116 | ) : ( | 118 | ) : ( |
apps/mobile/src/features/connection/CloudEnvironmentRows.tsx+26-23| ... | @@ -33,7 +33,9 @@ import { type RelayEnvironmentView, useConnectionController } from "./useConnect | ... | @@ -33,7 +33,9 @@ import { type RelayEnvironmentView, useConnectionController } from "./useConnect |
| 33 | 33 | ||
| 34 | interface CloudEnvironmentRowsProps { | 34 | interface CloudEnvironmentRowsProps { |
| 35 | readonly connectedCloudEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>; | 35 | readonly connectedCloudEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>; |
| 36 | readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void; | 36 | readonly onSetEnvironmentEnabled: (environmentId: EnvironmentId, enabled: boolean) => void; |
| 37 | /** Long-press on a saved row. The callback owns the confirm. */ | ||
| 38 | readonly onRemoveEnvironment: (environmentId: EnvironmentId) => void; | ||
| 37 | readonly showcaseAvailableEnvironments?: ReadonlyArray<RelayEnvironmentView>; | 39 | readonly showcaseAvailableEnvironments?: ReadonlyArray<RelayEnvironmentView>; |
| 38 | readonly showcaseSignedIn?: boolean; | 40 | readonly showcaseSignedIn?: boolean; |
| 39 | /** | 41 | /** |
| ... | @@ -97,11 +99,6 @@ function CloudEnvironmentRowsContent( | ... | @@ -97,11 +99,6 @@ function CloudEnvironmentRowsContent( |
| 97 | [controller], | 99 | [controller], |
| 98 | ); | 100 | ); |
| 99 | 101 | ||
| 100 | const handleDisconnectCloudEnvironment = useCallback( | ||
| 101 | (environmentId: EnvironmentId) => controller.removeEnvironment(environmentId), | ||
| 102 | [controller], | ||
| 103 | ); | ||
| 104 | |||
| 105 | const handleToggleCloudError = useCallback((environmentId: string) => { | 102 | const handleToggleCloudError = useCallback((environmentId: string) => { |
| 106 | setExpandedErrorId((current) => (current === environmentId ? null : environmentId)); | 103 | setExpandedErrorId((current) => (current === environmentId ? null : environmentId)); |
| 107 | }, []); | 104 | }, []); |
| ... | @@ -144,8 +141,10 @@ function CloudEnvironmentRowsContent( | ... | @@ -144,8 +141,10 @@ function CloudEnvironmentRowsContent( |
| 144 | key={environment.environmentId} | 141 | key={environment.environmentId} |
| 145 | environment={environment} | 142 | environment={environment} |
| 146 | borderTop={index !== 0} | 143 | borderTop={index !== 0} |
| 147 | onConnect={() => props.onReconnectEnvironment(environment.environmentId)} | 144 | onSetEnabled={(enabled) => |
| 148 | onDisconnect={() => handleDisconnectCloudEnvironment(environment.environmentId)} | 145 | props.onSetEnvironmentEnabled(environment.environmentId, enabled) |
| 146 | } | ||
| 147 | onRemove={() => props.onRemoveEnvironment(environment.environmentId)} | ||
| 149 | errorExpanded={expandedErrorId === environment.environmentId} | 148 | errorExpanded={expandedErrorId === environment.environmentId} |
| 150 | onToggleError={() => handleToggleCloudError(environment.environmentId)} | 149 | onToggleError={() => handleToggleCloudError(environment.environmentId)} |
| 151 | /> | 150 | /> |
| ... | @@ -204,38 +203,42 @@ function CloudEnvironmentRowsContent( | ... | @@ -204,38 +203,42 @@ function CloudEnvironmentRowsContent( |
| 204 | ); | 203 | ); |
| 205 | } | 204 | } |
| 206 | 205 | ||
| 206 | /** | ||
| 207 | * A saved T3 Connect environment. The switch turns it on or off; off keeps the | ||
| 208 | * registration and cache but drops the connection and hides its errors. | ||
| 209 | * Long-press removes it from this device. | ||
| 210 | */ | ||
| 207 | function ConnectedCloudEnvironmentRow(props: { | 211 | function ConnectedCloudEnvironmentRow(props: { |
| 208 | readonly environment: ConnectedEnvironmentSummary; | 212 | readonly environment: ConnectedEnvironmentSummary; |
| 209 | readonly borderTop: boolean; | 213 | readonly borderTop: boolean; |
| 210 | readonly errorExpanded: boolean; | 214 | readonly errorExpanded: boolean; |
| 211 | readonly onConnect: () => void; | 215 | readonly onSetEnabled: (enabled: boolean) => void; |
| 212 | readonly onDisconnect: () => void; | 216 | readonly onRemove: () => void; |
| 213 | readonly onToggleError: () => void; | 217 | readonly onToggleError: () => void; |
| 214 | }) { | 218 | }) { |
| 215 | const serverConfig = useAtomValue( | 219 | const serverConfig = useAtomValue( |
| 216 | serverEnvironment.configValueAtom(props.environment.environmentId), | 220 | serverEnvironment.configValueAtom(props.environment.environmentId), |
| 217 | ); | 221 | ); |
| 222 | const enabled = props.environment.isEnabled; | ||
| 218 | return ( | 223 | return ( |
| 219 | <View> | 224 | <Pressable |
| 225 | accessibilityHint="Long press to remove from this device" | ||
| 226 | onLongPress={props.onRemove} | ||
| 227 | > | ||
| 220 | <CloudEnvironmentRowShell | 228 | <CloudEnvironmentRowShell |
| 221 | borderTop={props.borderTop} | 229 | borderTop={props.borderTop} |
| 222 | connectionError={props.environment.connectionError} | 230 | connectionError={enabled ? props.environment.connectionError : null} |
| 223 | connectionErrorTraceId={props.environment.connectionErrorTraceId} | 231 | connectionErrorTraceId={enabled ? props.environment.connectionErrorTraceId : null} |
| 224 | connectionState={props.environment.connectionState} | 232 | connectionState={enabled ? props.environment.connectionState : "available"} |
| 225 | errorExpanded={props.errorExpanded} | 233 | errorExpanded={props.errorExpanded} |
| 226 | label={props.environment.environmentLabel} | 234 | label={props.environment.environmentLabel} |
| 227 | machine={resolveEnvironmentMachineKind(serverConfig)} | 235 | machine={resolveEnvironmentMachineKind(serverConfig)} |
| 228 | onValueChange={(enabled) => { | 236 | onValueChange={props.onSetEnabled} |
| 229 | if (enabled) { | ||
| 230 | props.onConnect(); | ||
| 231 | return; | ||
| 232 | } | ||
| 233 | props.onDisconnect(); | ||
| 234 | }} | ||
| 235 | onToggleError={props.onToggleError} | 237 | onToggleError={props.onToggleError} |
| 236 | value={props.environment.connectionState !== "available"} | 238 | {...(enabled ? {} : { statusText: "Off" })} |
| 239 | value={enabled} | ||
| 237 | /> | 240 | /> |
| 238 | </View> | 241 | </Pressable> |
| 239 | ); | 242 | ); |
| 240 | } | 243 | } |
| 241 | 244 |
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+18-6| ... | @@ -11,6 +11,7 @@ import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanim | ... | @@ -11,6 +11,7 @@ import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanim |
| 11 | 11 | ||
| 12 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | 12 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; |
| 13 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; | 13 | import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; |
| 14 | import { ThemedSwitch } from "../../components/ThemedSwitch"; | ||
| 14 | import { cn } from "../../lib/cn"; | 15 | import { cn } from "../../lib/cn"; |
| 15 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | 16 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 16 | import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; | 17 | import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; |
| ... | @@ -18,6 +19,9 @@ import { serverEnvironment } from "../../state/server"; | ... | @@ -18,6 +19,9 @@ import { serverEnvironment } from "../../state/server"; |
| 18 | import { ConnectionStatusDot } from "./ConnectionStatusDot"; | 19 | import { ConnectionStatusDot } from "./ConnectionStatusDot"; |
| 19 | 20 | ||
| 20 | function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null { | 21 | function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null { |
| 22 | if (!environment.isEnabled) { | ||
| 23 | return "Off"; | ||
| 24 | } | ||
| 21 | return connectionStatusText({ | 25 | return connectionStatusText({ |
| 22 | phase: environment.connectionState, | 26 | phase: environment.connectionState, |
| 23 | error: environment.connectionError, | 27 | error: environment.connectionError, |
| ... | @@ -31,6 +35,7 @@ export function ConnectionEnvironmentRow(props: { | ... | @@ -31,6 +35,7 @@ export function ConnectionEnvironmentRow(props: { |
| 31 | readonly onToggle: () => void; | 35 | readonly onToggle: () => void; |
| 32 | readonly onReconnect: (environmentId: EnvironmentId) => void; | 36 | readonly onReconnect: (environmentId: EnvironmentId) => void; |
| 33 | readonly onRemove: (environmentId: EnvironmentId) => void; | 37 | readonly onRemove: (environmentId: EnvironmentId) => void; |
| 38 | readonly onSetEnabled: (environmentId: EnvironmentId, enabled: boolean) => void; | ||
| 34 | readonly onUpdate: ( | 39 | readonly onUpdate: ( |
| 35 | environmentId: EnvironmentId, | 40 | environmentId: EnvironmentId, |
| 36 | updates: { readonly label: string; readonly displayUrl: string }, | 41 | updates: { readonly label: string; readonly displayUrl: string }, |
| ... | @@ -41,12 +46,14 @@ export function ConnectionEnvironmentRow(props: { | ... | @@ -41,12 +46,14 @@ export function ConnectionEnvironmentRow(props: { |
| 41 | const serverConfig = useAtomValue( | 46 | const serverConfig = useAtomValue( |
| 42 | serverEnvironment.configValueAtom(props.environment.environmentId), | 47 | serverEnvironment.configValueAtom(props.environment.environmentId), |
| 43 | ); | 48 | ); |
| 49 | const enabled = props.environment.isEnabled; | ||
| 44 | const statusLabel = connectionStatusLabel(props.environment); | 50 | const statusLabel = connectionStatusLabel(props.environment); |
| 45 | const statusTraceId = props.environment.connectionErrorTraceId; | 51 | const statusTraceId = enabled ? props.environment.connectionErrorTraceId : null; |
| 46 | const hasConnectionFailure = props.environment.connectionError !== null; | 52 | const hasConnectionFailure = enabled && props.environment.connectionError !== null; |
| 47 | const isRetrying = | 53 | const isRetrying = |
| 48 | props.environment.connectionState === "connecting" || | 54 | enabled && |
| 49 | props.environment.connectionState === "reconnecting"; | 55 | (props.environment.connectionState === "connecting" || |
| 56 | props.environment.connectionState === "reconnecting"); | ||
| 50 | const handleSave = useCallback(async () => { | 57 | const handleSave = useCallback(async () => { |
| 51 | const result = await props.onUpdate(props.environment.environmentId, { | 58 | const result = await props.onUpdate(props.environment.environmentId, { |
| 52 | label: label.trim(), | 59 | label: label.trim(), |
| ... | @@ -70,7 +77,7 @@ export function ConnectionEnvironmentRow(props: { | ... | @@ -70,7 +77,7 @@ export function ConnectionEnvironmentRow(props: { |
| 70 | onPress={props.onToggle} | 77 | onPress={props.onToggle} |
| 71 | > | 78 | > |
| 72 | <ConnectionStatusDot | 79 | <ConnectionStatusDot |
| 73 | state={props.environment.connectionState} | 80 | state={enabled ? props.environment.connectionState : "available"} |
| 74 | pulse={isRetrying} | 81 | pulse={isRetrying} |
| 75 | size={8} | 82 | size={8} |
| 76 | /> | 83 | /> |
| ... | @@ -125,6 +132,10 @@ export function ConnectionEnvironmentRow(props: { | ... | @@ -125,6 +132,10 @@ export function ConnectionEnvironmentRow(props: { |
| 125 | ) : null} | 132 | ) : null} |
| 126 | </View> | 133 | </View> |
| 127 | 134 | ||
| 135 | <ThemedSwitch | ||
| 136 | onValueChange={(next) => props.onSetEnabled(props.environment.environmentId, next)} | ||
| 137 | value={enabled} | ||
| 138 | /> | ||
| 128 | <SymbolView | 139 | <SymbolView |
| 129 | name="chevron.down" | 140 | name="chevron.down" |
| 130 | size={12} | 141 | size={12} |
| ... | @@ -198,7 +209,8 @@ export function ConnectionEnvironmentRow(props: { | ... | @@ -198,7 +209,8 @@ export function ConnectionEnvironmentRow(props: { |
| 198 | )} | 209 | )} |
| 199 | 210 | ||
| 200 | <Pressable | 211 | <Pressable |
| 201 | className="h-[42px] w-[42px] items-center justify-center rounded-[14px] border border-input-border bg-input active:opacity-70" | 212 | className="h-[42px] w-[42px] items-center justify-center rounded-[14px] border border-input-border bg-input active:opacity-70 disabled:opacity-40" |
| 213 | disabled={!enabled} | ||
| 202 | onPress={() => props.onReconnect(props.environment.environmentId)} | 214 | onPress={() => props.onReconnect(props.environment.environmentId)} |
| 203 | > | 215 | > |
| 204 | <SymbolView | 216 | <SymbolView |
apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx+2| ... | @@ -17,6 +17,7 @@ export function ConnectionsRouteScreen() { | ... | @@ -17,6 +17,7 @@ export function ConnectionsRouteScreen() { |
| 17 | connectedEnvironments, | 17 | connectedEnvironments, |
| 18 | onReconnectEnvironment, | 18 | onReconnectEnvironment, |
| 19 | onRemoveEnvironmentPress, | 19 | onRemoveEnvironmentPress, |
| 20 | onSetEnvironmentEnabled, | ||
| 20 | onUpdateEnvironment, | 21 | onUpdateEnvironment, |
| 21 | } = useRemoteConnections(); | 22 | } = useRemoteConnections(); |
| 22 | const navigation = useNavigation(); | 23 | const navigation = useNavigation(); |
| ... | @@ -74,6 +75,7 @@ export function ConnectionsRouteScreen() { | ... | @@ -74,6 +75,7 @@ export function ConnectionsRouteScreen() { |
| 74 | onToggle={() => handleToggle(environment.environmentId)} | 75 | onToggle={() => handleToggle(environment.environmentId)} |
| 75 | onReconnect={onReconnectEnvironment} | 76 | onReconnect={onReconnectEnvironment} |
| 76 | onRemove={onRemoveEnvironmentPress} | 77 | onRemove={onRemoveEnvironmentPress} |
| 78 | onSetEnabled={onSetEnvironmentEnabled} | ||
| 77 | onUpdate={onUpdateEnvironment} | 79 | onUpdate={onUpdateEnvironment} |
| 78 | /> | 80 | /> |
| 79 | </View> | 81 | </View> |
apps/mobile/src/features/connection/environmentSections.test.ts+1| ... | @@ -15,6 +15,7 @@ function connectedEnvironment( | ... | @@ -15,6 +15,7 @@ function connectedEnvironment( |
| 15 | environmentLabel: input.environmentLabel ?? input.environmentId, | 15 | environmentLabel: input.environmentLabel ?? input.environmentId, |
| 16 | displayUrl: input.displayUrl ?? `https://${input.environmentId}.example.test/`, | 16 | displayUrl: input.displayUrl ?? `https://${input.environmentId}.example.test/`, |
| 17 | isRelayManaged: input.isRelayManaged, | 17 | isRelayManaged: input.isRelayManaged, |
| 18 | isEnabled: input.isEnabled ?? true, | ||
| 18 | connectionState: input.connectionState ?? "connected", | 19 | connectionState: input.connectionState ?? "connected", |
| 19 | connectionError: input.connectionError ?? null, | 20 | connectionError: input.connectionError ?? null, |
| 20 | connectionErrorTraceId: input.connectionErrorTraceId ?? null, | 21 | connectionErrorTraceId: input.connectionErrorTraceId ?? null, |
apps/mobile/src/features/connection/useConnectionController.ts+10| ... | @@ -40,6 +40,10 @@ export function useConnectionController() { | ... | @@ -40,6 +40,10 @@ export function useConnectionController() { |
| 40 | const registerEnvironment = useAtomCommand(environmentCatalog.register, "environment register"); | 40 | const registerEnvironment = useAtomCommand(environmentCatalog.register, "environment register"); |
| 41 | const removeEnvironmentMutation = useAtomCommand(environmentCatalog.remove, "environment remove"); | 41 | const removeEnvironmentMutation = useAtomCommand(environmentCatalog.remove, "environment remove"); |
| 42 | const retryEnvironmentMutation = useAtomCommand(environmentCatalog.retryNow, "environment retry"); | 42 | const retryEnvironmentMutation = useAtomCommand(environmentCatalog.retryNow, "environment retry"); |
| 43 | const setEnvironmentEnabledMutation = useAtomCommand( | ||
| 44 | environmentCatalog.setEnabled, | ||
| 45 | "environment toggle", | ||
| 46 | ); | ||
| 43 | const refreshRelayEnvironments = useAtomCommand( | 47 | const refreshRelayEnvironments = useAtomCommand( |
| 44 | relayEnvironmentDiscovery.refresh, | 48 | relayEnvironmentDiscovery.refresh, |
| 45 | "relay environment refresh", | 49 | "relay environment refresh", |
| ... | @@ -93,6 +97,11 @@ export function useConnectionController() { | ... | @@ -93,6 +97,11 @@ export function useConnectionController() { |
| 93 | (environmentId: EnvironmentId) => retryEnvironmentMutation(environmentId), | 97 | (environmentId: EnvironmentId) => retryEnvironmentMutation(environmentId), |
| 94 | [retryEnvironmentMutation], | 98 | [retryEnvironmentMutation], |
| 95 | ); | 99 | ); |
| 100 | const setEnvironmentEnabled = useCallback( | ||
| 101 | (environmentId: EnvironmentId, enabled: boolean) => | ||
| 102 | setEnvironmentEnabledMutation({ environmentId, enabled }), | ||
| 103 | [setEnvironmentEnabledMutation], | ||
| 104 | ); | ||
| 96 | const updateEnvironment = useCallback( | 105 | const updateEnvironment = useCallback( |
| 97 | ( | 106 | ( |
| 98 | environmentId: EnvironmentId, | 107 | environmentId: EnvironmentId, |
| ... | @@ -120,6 +129,7 @@ export function useConnectionController() { | ... | @@ -120,6 +129,7 @@ export function useConnectionController() { |
| 120 | connectRelayEnvironment, | 129 | connectRelayEnvironment, |
| 121 | removeEnvironment, | 130 | removeEnvironment, |
| 122 | retryEnvironment, | 131 | retryEnvironment, |
| 132 | setEnvironmentEnabled, | ||
| 123 | updateEnvironment, | 133 | updateEnvironment, |
| 124 | refreshRelayEnvironments, | 134 | refreshRelayEnvironments, |
| 125 | }; | 135 | }; |
apps/mobile/src/features/home/workspace-connection-status.test.ts+1| ... | @@ -45,6 +45,7 @@ describe("workspace connection status", () => { | ... | @@ -45,6 +45,7 @@ describe("workspace connection status", () => { |
| 45 | environmentLabel: "Julius’s Mac mini", | 45 | environmentLabel: "Julius’s Mac mini", |
| 46 | displayUrl: "", | 46 | displayUrl: "", |
| 47 | isRelayManaged: false, | 47 | isRelayManaged: false, |
| 48 | isEnabled: true, | ||
| 48 | connectionState: "reconnecting", | 49 | connectionState: "reconnecting", |
| 49 | connectionError: null, | 50 | connectionError: null, |
| 50 | connectionErrorTraceId: null, | 51 | connectionErrorTraceId: null, |
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx+4-1| ... | @@ -28,6 +28,7 @@ export function SettingsEnvironmentsRouteScreen() { | ... | @@ -28,6 +28,7 @@ export function SettingsEnvironmentsRouteScreen() { |
| 28 | connectedEnvironments, | 28 | connectedEnvironments, |
| 29 | onReconnectEnvironment, | 29 | onReconnectEnvironment, |
| 30 | onRemoveEnvironmentPress, | 30 | onRemoveEnvironmentPress, |
| 31 | onSetEnvironmentEnabled, | ||
| 31 | onUpdateEnvironment, | 32 | onUpdateEnvironment, |
| 32 | } = useRemoteConnections(); | 33 | } = useRemoteConnections(); |
| 33 | const navigation = useNavigation(); | 34 | const navigation = useNavigation(); |
| ... | @@ -136,6 +137,7 @@ export function SettingsEnvironmentsRouteScreen() { | ... | @@ -136,6 +137,7 @@ export function SettingsEnvironmentsRouteScreen() { |
| 136 | onToggle={() => handleToggle(environment.environmentId)} | 137 | onToggle={() => handleToggle(environment.environmentId)} |
| 137 | onReconnect={onReconnectEnvironment} | 138 | onReconnect={onReconnectEnvironment} |
| 138 | onRemove={onRemoveEnvironmentPress} | 139 | onRemove={onRemoveEnvironmentPress} |
| 140 | onSetEnabled={onSetEnvironmentEnabled} | ||
| 139 | onUpdate={handleUpdateEnvironment} | 141 | onUpdate={handleUpdateEnvironment} |
| 140 | /> | 142 | /> |
| 141 | </View> | 143 | </View> |
| ... | @@ -163,7 +165,8 @@ export function SettingsEnvironmentsRouteScreen() { | ... | @@ -163,7 +165,8 @@ export function SettingsEnvironmentsRouteScreen() { |
| 163 | user is signed out — the component gates discovery itself. */} | 165 | user is signed out — the component gates discovery itself. */} |
| 164 | <CloudEnvironmentRows | 166 | <CloudEnvironmentRows |
| 165 | connectedCloudEnvironments={connectedCloudEnvironments} | 167 | connectedCloudEnvironments={connectedCloudEnvironments} |
| 166 | onReconnectEnvironment={onReconnectEnvironment} | 168 | onSetEnvironmentEnabled={onSetEnvironmentEnabled} |
| 169 | onRemoveEnvironment={onRemoveEnvironmentPress} | ||
| 167 | {...(SHOWCASE_ENABLED | 170 | {...(SHOWCASE_ENABLED |
| 168 | ? { | 171 | ? { |
| 169 | showcaseAvailableEnvironments: SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS, | 172 | showcaseAvailableEnvironments: SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS, |
apps/mobile/src/features/showcase/showcaseEnvironmentRows.test.ts+1| ... | @@ -17,6 +17,7 @@ function environment( | ... | @@ -17,6 +17,7 @@ function environment( |
| 17 | environmentLabel, | 17 | environmentLabel, |
| 18 | displayUrl, | 18 | displayUrl, |
| 19 | isRelayManaged: false, | 19 | isRelayManaged: false, |
| 20 | isEnabled: true, | ||
| 20 | connectionState: "connected", | 21 | connectionState: "connected", |
| 21 | connectionError: null, | 22 | connectionError: null, |
| 22 | connectionErrorTraceId: null, | 23 | connectionErrorTraceId: null, |
apps/mobile/src/features/showcase/showcaseEnvironmentRows.ts+1| ... | @@ -43,6 +43,7 @@ export const SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS: ReadonlyArray<ConnectedEnvir | ... | @@ -43,6 +43,7 @@ export const SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS: ReadonlyArray<ConnectedEnvir |
| 43 | environmentLabel: "Aurora GPU Pod", | 43 | environmentLabel: "Aurora GPU Pod", |
| 44 | displayUrl: "https://aurora-gpu.t3.sh", | 44 | displayUrl: "https://aurora-gpu.t3.sh", |
| 45 | isRelayManaged: true, | 45 | isRelayManaged: true, |
| 46 | isEnabled: true, | ||
| 46 | connectionState: "connected", | 47 | connectionState: "connected", |
| 47 | connectionError: null, | 48 | connectionError: null, |
| 48 | connectionErrorTraceId: null, | 49 | connectionErrorTraceId: null, |
apps/mobile/src/state/remote-environment-projections.test.ts+1-1| ... | @@ -29,7 +29,7 @@ function presentation( | ... | @@ -29,7 +29,7 @@ function presentation( |
| 29 | serverConfig: ServerConfig | null = null, | 29 | serverConfig: ServerConfig | null = null, |
| 30 | ): EnvironmentPresentation { | 30 | ): EnvironmentPresentation { |
| 31 | return { | 31 | return { |
| 32 | entry: { target: target(environmentId, endpoint), profile: Option.none() }, | 32 | entry: { target: target(environmentId, endpoint), profile: Option.none(), enabled: true }, |
| 33 | connection: { phase: "connected", error: null, traceId: null }, | 33 | connection: { phase: "connected", error: null, traceId: null }, |
| 34 | serverConfig, | 34 | serverConfig, |
| 35 | }; | 35 | }; |
apps/mobile/src/state/remote-runtime-types.ts+2| ... | @@ -13,6 +13,8 @@ export interface ConnectedEnvironmentSummary { | ... | @@ -13,6 +13,8 @@ export interface ConnectedEnvironmentSummary { |
| 13 | readonly environmentLabel: string; | 13 | readonly environmentLabel: string; |
| 14 | readonly displayUrl: string; | 14 | readonly displayUrl: string; |
| 15 | readonly isRelayManaged: boolean; | 15 | readonly isRelayManaged: boolean; |
| 16 | /** False when the user switched the environment off in Settings. */ | ||
| 17 | readonly isEnabled: boolean; | ||
| 16 | readonly connectionState: EnvironmentConnectionPhase; | 18 | readonly connectionState: EnvironmentConnectionPhase; |
| 17 | readonly connectionError: string | null; | 19 | readonly connectionError: string | null; |
| 18 | readonly connectionErrorTraceId: string | null; | 20 | readonly connectionErrorTraceId: string | null; |
apps/mobile/src/state/use-remote-environment-registry.ts+9-2| ... | @@ -94,6 +94,7 @@ export function useRemoteConnectionStatus() { | ... | @@ -94,6 +94,7 @@ export function useRemoteConnectionStatus() { |
| 94 | environmentLabel: environment.environmentLabel, | 94 | environmentLabel: environment.environmentLabel, |
| 95 | displayUrl: environment.displayUrl, | 95 | displayUrl: environment.displayUrl, |
| 96 | isRelayManaged: environment.isRelayManaged, | 96 | isRelayManaged: environment.isRelayManaged, |
| 97 | isEnabled: environment.isEnabled, | ||
| 97 | connectionState: environment.connectionState, | 98 | connectionState: environment.connectionState, |
| 98 | connectionError: environment.connectionError, | 99 | connectionError: environment.connectionError, |
| 99 | connectionErrorTraceId: environment.connectionErrorTraceId, | 100 | connectionErrorTraceId: environment.connectionErrorTraceId, |
| ... | @@ -140,6 +141,11 @@ export function useRemoteConnections() { | ... | @@ -140,6 +141,11 @@ export function useRemoteConnections() { |
| 140 | (environmentId: EnvironmentId) => controller.retryEnvironment(environmentId), | 141 | (environmentId: EnvironmentId) => controller.retryEnvironment(environmentId), |
| 141 | [controller], | 142 | [controller], |
| 142 | ); | 143 | ); |
| 144 | const onSetEnvironmentEnabled = useCallback( | ||
| 145 | (environmentId: EnvironmentId, enabled: boolean) => | ||
| 146 | controller.setEnvironmentEnabled(environmentId, enabled), | ||
| 147 | [controller], | ||
| 148 | ); | ||
| 143 | const onUpdateEnvironment = useCallback( | 149 | const onUpdateEnvironment = useCallback( |
| 144 | ( | 150 | ( |
| 145 | environmentId: EnvironmentId, | 151 | environmentId: EnvironmentId, |
| ... | @@ -157,8 +163,8 @@ export function useRemoteConnections() { | ... | @@ -157,8 +163,8 @@ export function useRemoteConnections() { |
| 157 | return; | 163 | return; |
| 158 | } | 164 | } |
| 159 | Alert.alert( | 165 | Alert.alert( |
| 160 | "Remove environment?", | 166 | "Remove from this device?", |
| 161 | `Disconnect and forget ${environment.environmentLabel} on this device.`, | 167 | `Forget ${environment.environmentLabel} and its cached threads on this device. Switch it off instead to keep it saved.`, |
| 162 | [ | 168 | [ |
| 163 | { text: "Cancel", style: "cancel" }, | 169 | { text: "Cancel", style: "cancel" }, |
| 164 | { | 170 | { |
| ... | @@ -184,6 +190,7 @@ export function useRemoteConnections() { | ... | @@ -184,6 +190,7 @@ export function useRemoteConnections() { |
| 184 | onChangeConnectionPairingUrl, | 190 | onChangeConnectionPairingUrl, |
| 185 | onConnectPress, | 191 | onConnectPress, |
| 186 | onReconnectEnvironment, | 192 | onReconnectEnvironment, |
| 193 | onSetEnvironmentEnabled, | ||
| 187 | onUpdateEnvironment, | 194 | onUpdateEnvironment, |
| 188 | onRemoveEnvironmentPress, | 195 | onRemoveEnvironmentPress, |
| 189 | }; | 196 | }; |
apps/mobile/src/state/workspaceModel.test.ts+1| ... | @@ -36,6 +36,7 @@ function environment( | ... | @@ -36,6 +36,7 @@ function environment( |
| 36 | wsBaseUrl: "wss://environment.example.test", | 36 | wsBaseUrl: "wss://environment.example.test", |
| 37 | }), | 37 | }), |
| 38 | ), | 38 | ), |
| 39 | enabled: true, | ||
| 39 | }, | 40 | }, |
| 40 | connection: { | 41 | connection: { |
| 41 | phase, | 42 | phase, |
apps/mobile/src/state/workspaceModel.ts+9-4| ... | @@ -10,6 +10,7 @@ export interface WorkspaceEnvironment { | ... | @@ -10,6 +10,7 @@ export interface WorkspaceEnvironment { |
| 10 | readonly environmentLabel: string; | 10 | readonly environmentLabel: string; |
| 11 | readonly displayUrl: string; | 11 | readonly displayUrl: string; |
| 12 | readonly isRelayManaged: boolean; | 12 | readonly isRelayManaged: boolean; |
| 13 | readonly isEnabled: boolean; | ||
| 13 | readonly connectionState: EnvironmentConnectionPhase; | 14 | readonly connectionState: EnvironmentConnectionPhase; |
| 14 | readonly connectionError: string | null; | 15 | readonly connectionError: string | null; |
| 15 | readonly connectionErrorTraceId: string | null; | 16 | readonly connectionErrorTraceId: string | null; |
| ... | @@ -38,6 +39,7 @@ export function projectWorkspaceEnvironment( | ... | @@ -38,6 +39,7 @@ export function projectWorkspaceEnvironment( |
| 38 | environmentLabel: environment.label, | 39 | environmentLabel: environment.label, |
| 39 | displayUrl: environment.displayUrl ?? "", | 40 | displayUrl: environment.displayUrl ?? "", |
| 40 | isRelayManaged: environment.relayManaged, | 41 | isRelayManaged: environment.relayManaged, |
| 42 | isEnabled: environment.entry.enabled, | ||
| 41 | connectionState: environment.connection.phase, | 43 | connectionState: environment.connection.phase, |
| 42 | connectionError: environment.connection.error, | 44 | connectionError: environment.connection.error, |
| 43 | connectionErrorTraceId: environment.connection.traceId, | 45 | connectionErrorTraceId: environment.connection.traceId, |
| ... | @@ -78,7 +80,10 @@ export function projectWorkspaceState(input: { | ... | @@ -78,7 +80,10 @@ export function projectWorkspaceState(input: { |
| 78 | readonly environments: ReadonlyArray<WorkspaceEnvironment>; | 80 | readonly environments: ReadonlyArray<WorkspaceEnvironment>; |
| 79 | readonly shellSummary: EnvironmentShellSummary; | 81 | readonly shellSummary: EnvironmentShellSummary; |
| 80 | }): WorkspaceState { | 82 | }): WorkspaceState { |
| 81 | const connectingEnvironments = input.environments.filter( | 83 | // Switched-off environments still count as saved connections, but they do |
| 84 | // not drive the overall connection state or surface their last error. | ||
| 85 | const activeEnvironments = input.environments.filter((environment) => environment.isEnabled); | ||
| 86 | const connectingEnvironments = activeEnvironments.filter( | ||
| 82 | (environment) => | 87 | (environment) => |
| 83 | environment.connectionState === "connecting" || | 88 | environment.connectionState === "connecting" || |
| 84 | environment.connectionState === "reconnecting", | 89 | environment.connectionState === "reconnecting", |
| ... | @@ -91,12 +96,12 @@ export function projectWorkspaceState(input: { | ... | @@ -91,12 +96,12 @@ export function projectWorkspaceState(input: { |
| 91 | hasPendingShellSnapshot: input.shellSummary.hasSynchronizingShell, | 96 | hasPendingShellSnapshot: input.shellSummary.hasSynchronizingShell, |
| 92 | hasReadyEnvironment: | 97 | hasReadyEnvironment: |
| 93 | input.networkStatus !== "offline" && | 98 | input.networkStatus !== "offline" && |
| 94 | input.environments.some((environment) => environment.connectionState === "connected"), | 99 | activeEnvironments.some((environment) => environment.connectionState === "connected"), |
| 95 | hasConnectingEnvironment: connectingEnvironments.length > 0, | 100 | hasConnectingEnvironment: connectingEnvironments.length > 0, |
| 96 | connectingEnvironments, | 101 | connectingEnvironments, |
| 97 | connectionState: overallConnectionState(input.environments, input.networkStatus), | 102 | connectionState: overallConnectionState(activeEnvironments, input.networkStatus), |
| 98 | connectionError: | 103 | connectionError: |
| 99 | input.environments.find((environment) => environment.connectionError !== null) | 104 | activeEnvironments.find((environment) => environment.connectionError !== null) |
| 100 | ?.connectionError ?? null, | 105 | ?.connectionError ?? null, |
| 101 | shellSnapshotError: input.shellSummary.firstError, | 106 | shellSnapshotError: input.shellSummary.firstError, |
| 102 | latestCachedSnapshotReceivedAt: input.shellSummary.latestSnapshotUpdatedAt, | 107 | latestCachedSnapshotReceivedAt: input.shellSummary.latestSnapshotUpdatedAt, |
apps/web/src/components/ServerUpdateAction.tsx+34-8| ... | @@ -4,6 +4,7 @@ import { | ... | @@ -4,6 +4,7 @@ import { |
| 4 | isAtomCommandInterrupted, | 4 | isAtomCommandInterrupted, |
| 5 | squashAtomCommandFailure, | 5 | squashAtomCommandFailure, |
| 6 | } from "@t3tools/client-runtime/state/runtime"; | 6 | } from "@t3tools/client-runtime/state/runtime"; |
| 7 | import { CircleArrowUpIcon } from "lucide-react"; | ||
| 7 | import { type ComponentProps, useRef, useState } from "react"; | 8 | import { type ComponentProps, useRef, useState } from "react"; |
| 8 | 9 | ||
| 9 | import { requestConfirmDialog } from "~/confirmDialog"; | 10 | import { requestConfirmDialog } from "~/confirmDialog"; |
| ... | @@ -44,8 +45,10 @@ export interface ServerUpdateTarget { | ... | @@ -44,8 +45,10 @@ export interface ServerUpdateTarget { |
| 44 | readonly continueThreadsAfterServerUpdate?: boolean; | 45 | readonly continueThreadsAfterServerUpdate?: boolean; |
| 45 | } | 46 | } |
| 46 | 47 | ||
| 47 | type UpdateButtonProps = Pick<ComponentProps<typeof Button>, "variant" | "size"> & { | 48 | type UpdateButtonProps = Pick<ComponentProps<typeof Button>, "variant" | "size" | "className"> & { |
| 48 | readonly label?: string; | 49 | readonly label?: string; |
| 50 | /** "icon" renders a compact icon button with the label in a tooltip. */ | ||
| 51 | readonly appearance?: "button" | "icon"; | ||
| 49 | }; | 52 | }; |
| 50 | 53 | ||
| 51 | function useServerUpdate() { | 54 | function useServerUpdate() { |
| ... | @@ -94,6 +97,7 @@ export function ServerUpdatesAction({ | ... | @@ -94,6 +97,7 @@ export function ServerUpdatesAction({ |
| 94 | label = "Update all", | 97 | label = "Update all", |
| 95 | variant = "outline", | 98 | variant = "outline", |
| 96 | size = "xs", | 99 | size = "xs", |
| 100 | className, | ||
| 97 | }: UpdateButtonProps & { | 101 | }: UpdateButtonProps & { |
| 98 | readonly targets: ReadonlyArray<ServerUpdateTarget>; | 102 | readonly targets: ReadonlyArray<ServerUpdateTarget>; |
| 99 | }) { | 103 | }) { |
| ... | @@ -133,6 +137,7 @@ export function ServerUpdatesAction({ | ... | @@ -133,6 +137,7 @@ export function ServerUpdatesAction({ |
| 133 | <Button | 137 | <Button |
| 134 | size={size} | 138 | size={size} |
| 135 | variant={variant} | 139 | variant={variant} |
| 140 | className={className} | ||
| 136 | disabled={isPending || eligible.length === 0} | 141 | disabled={isPending || eligible.length === 0} |
| 137 | onClick={() => void handleUpdate()} | 142 | onClick={() => void handleUpdate()} |
| 138 | > | 143 | > |
| ... | @@ -191,6 +196,8 @@ export function ServerUpdateAction({ | ... | @@ -191,6 +196,8 @@ export function ServerUpdateAction({ |
| 191 | label = "Update", | 196 | label = "Update", |
| 192 | variant = "outline", | 197 | variant = "outline", |
| 193 | size = "xs", | 198 | size = "xs", |
| 199 | className, | ||
| 200 | appearance = "button", | ||
| 194 | }: Omit<ServerUpdateTarget, "continueThreadsAfterServerUpdate"> & UpdateButtonProps) { | 201 | }: Omit<ServerUpdateTarget, "continueThreadsAfterServerUpdate"> & UpdateButtonProps) { |
| 195 | const isDesktopAppUpdate = selfUpdate === "desktop-managed"; | 202 | const isDesktopAppUpdate = selfUpdate === "desktop-managed"; |
| 196 | const continueThreadsAfterServerUpdate = useEnvironmentSettings( | 203 | const continueThreadsAfterServerUpdate = useEnvironmentSettings( |
| ... | @@ -251,18 +258,37 @@ export function ServerUpdateAction({ | ... | @@ -251,18 +258,37 @@ export function ServerUpdateAction({ |
| 251 | ); | 258 | ); |
| 252 | } | 259 | } |
| 253 | 260 | ||
| 254 | if (selfUpdate === null) { | 261 | const manualCommand = selfUpdate === null ? manualServerUpdateCommand(targetVersion) : null; |
| 255 | const command = manualServerUpdateCommand(targetVersion); | 262 | const actionLabel = manualCommand !== null ? "Copy update command" : label; |
| 263 | const onClick = | ||
| 264 | manualCommand !== null | ||
| 265 | ? () => copyToClipboard(manualCommand, { command: manualCommand }) | ||
| 266 | : () => void handleUpdate(); | ||
| 267 | |||
| 268 | if (appearance === "icon") { | ||
| 256 | return ( | 269 | return ( |
| 257 | <Button size={size} variant={variant} onClick={() => copyToClipboard(command, { command })}> | 270 | <Tooltip> |
| 258 | Copy update command | 271 | <TooltipTrigger |
| 259 | </Button> | 272 | render={ |
| 273 | <Button | ||
| 274 | size="icon-xs" | ||
| 275 | variant="ghost" | ||
| 276 | className={className ?? "text-muted-foreground hover:text-foreground"} | ||
| 277 | aria-label={`${actionLabel} for ${serverLabel}`} | ||
| 278 | onClick={onClick} | ||
| 279 | /> | ||
| 280 | } | ||
| 281 | > | ||
| 282 | <CircleArrowUpIcon className="size-3.5" /> | ||
| 283 | </TooltipTrigger> | ||
| 284 | <TooltipPopup side="top">{actionLabel}</TooltipPopup> | ||
| 285 | </Tooltip> | ||
| 260 | ); | 286 | ); |
| 261 | } | 287 | } |
| 262 | 288 | ||
| 263 | return ( | 289 | return ( |
| 264 | <Button size={size} variant={variant} onClick={() => void handleUpdate()}> | 290 | <Button size={size} variant={variant} className={className} onClick={onClick}> |
| 265 | {label} | 291 | {actionLabel} |
| 266 | </Button> | 292 | </Button> |
| 267 | ); | 293 | ); |
| 268 | } | 294 | } |
apps/web/src/components/settings/ConnectionsSettings.tsx+223-108| ... | @@ -1,5 +1,12 @@ | ... | @@ -1,5 +1,12 @@ |
| 1 | import { ChevronsLeftRightEllipsisIcon, PlusIcon, QrCodeIcon, TerminalIcon } from "lucide-react"; | 1 | import { |
| 2 | ChevronsLeftRightEllipsisIcon, | ||
| 3 | EllipsisIcon, | ||
| 4 | PlusIcon, | ||
| 5 | QrCodeIcon, | ||
| 6 | TerminalIcon, | ||
| 7 | } from "lucide-react"; | ||
| 2 | import { useAtomValue } from "@effect/atom-react"; | 8 | import { useAtomValue } from "@effect/atom-react"; |
| 9 | import { Atom } from "effect/unstable/reactivity"; | ||
| 3 | import { | 10 | import { |
| 4 | type KeyboardEvent, | 11 | type KeyboardEvent, |
| 5 | type ReactNode, | 12 | type ReactNode, |
| ... | @@ -97,6 +104,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | ... | @@ -97,6 +104,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 97 | import { QRCodeSvg } from "../ui/qr-code"; | 104 | import { QRCodeSvg } from "../ui/qr-code"; |
| 98 | import { Spinner } from "../ui/spinner"; | 105 | import { Spinner } from "../ui/spinner"; |
| 99 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | 106 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 107 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; | ||
| 100 | import { Switch } from "../ui/switch"; | 108 | import { Switch } from "../ui/switch"; |
| 101 | import { stackedThreadToast, toastManager } from "../ui/toast"; | 109 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 102 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 110 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| ... | @@ -145,10 +153,16 @@ import { | ... | @@ -145,10 +153,16 @@ import { |
| 145 | useEnvironments, | 153 | useEnvironments, |
| 146 | usePrimaryEnvironment, | 154 | usePrimaryEnvironment, |
| 147 | } from "~/state/environments"; | 155 | } from "~/state/environments"; |
| 156 | import { requestConfirmDialog } from "~/confirmDialog"; | ||
| 148 | import { useAtomCommand } from "../../state/use-atom-command"; | 157 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 149 | import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server"; | 158 | import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server"; |
| 150 | import { ConnectionStatusDot } from "../ConnectionStatusDot"; | 159 | import { ConnectionStatusDot } from "../ConnectionStatusDot"; |
| 151 | import { ServerUpdateAction, ServerUpdateProgress } from "../ServerUpdateAction"; | 160 | import { |
| 161 | ServerUpdateAction, | ||
| 162 | ServerUpdateProgress, | ||
| 163 | ServerUpdatesAction, | ||
| 164 | type ServerUpdateTarget, | ||
| 165 | } from "../ServerUpdateAction"; | ||
| 152 | import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; | 166 | import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; |
| 153 | import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; | 167 | import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; |
| 154 | import { | 168 | import { |
| ... | @@ -1399,29 +1413,31 @@ function NetworkAccessDescription({ | ... | @@ -1399,29 +1413,31 @@ function NetworkAccessDescription({ |
| 1399 | type SavedBackendListRowProps = { | 1413 | type SavedBackendListRowProps = { |
| 1400 | environment: EnvironmentPresentation; | 1414 | environment: EnvironmentPresentation; |
| 1401 | removingEnvironmentId: EnvironmentId | null; | 1415 | removingEnvironmentId: EnvironmentId | null; |
| 1402 | onConnect: (environmentId: EnvironmentId) => void; | 1416 | onSetEnabled: (environmentId: EnvironmentId, enabled: boolean) => void; |
| 1403 | onRemove: (environmentId: EnvironmentId) => void; | 1417 | onRemove: (environment: EnvironmentPresentation) => void; |
| 1404 | }; | 1418 | }; |
| 1405 | 1419 | ||
| 1406 | function SavedBackendListRow({ | 1420 | function SavedBackendListRow({ |
| 1407 | environment, | 1421 | environment, |
| 1408 | removingEnvironmentId, | 1422 | removingEnvironmentId, |
| 1409 | onConnect, | 1423 | onSetEnabled, |
| 1410 | onRemove, | 1424 | onRemove, |
| 1411 | }: SavedBackendListRowProps) { | 1425 | }: SavedBackendListRowProps) { |
| 1412 | const environmentId = environment.environmentId; | 1426 | const environmentId = environment.environmentId; |
| 1427 | const enabled = environment.entry.enabled; | ||
| 1413 | const connectionState = environment.connection.phase; | 1428 | const connectionState = environment.connection.phase; |
| 1414 | const isConnected = connectionState === "connected"; | 1429 | const isConnected = connectionState === "connected"; |
| 1415 | const isConnecting = connectionState === "connecting" || connectionState === "reconnecting"; | 1430 | const isRemoving = removingEnvironmentId === environmentId; |
| 1416 | const stateDotClassName = | 1431 | const stateDotClassName = !enabled |
| 1417 | connectionState === "connected" | 1432 | ? "bg-muted-foreground/40" |
| 1433 | : connectionState === "connected" | ||
| 1418 | ? "bg-success" | 1434 | ? "bg-success" |
| 1419 | : connectionState === "connecting" || connectionState === "reconnecting" | 1435 | : connectionState === "connecting" || connectionState === "reconnecting" |
| 1420 | ? "bg-warning" | 1436 | ? "bg-warning" |
| 1421 | : connectionState === "error" | 1437 | : connectionState === "error" |
| 1422 | ? "bg-destructive" | 1438 | ? "bg-destructive" |
| 1423 | : "bg-muted-foreground/40"; | 1439 | : "bg-muted-foreground/40"; |
| 1424 | const statusTooltip = connectionStatusText(environment.connection); | 1440 | const statusTooltip = enabled ? connectionStatusText(environment.connection) : "Off"; |
| 1425 | const errorTraceId = environment.connection.traceId; | 1441 | const errorTraceId = environment.connection.traceId; |
| 1426 | const { copyToClipboard: copyTraceIdToClipboard } = useCopyToClipboard<{ traceId: string }>({ | 1442 | const { copyToClipboard: copyTraceIdToClipboard } = useCopyToClipboard<{ traceId: string }>({ |
| 1427 | target: "trace ID", | 1443 | target: "trace ID", |
| ... | @@ -1461,6 +1477,7 @@ function SavedBackendListRow({ | ... | @@ -1461,6 +1477,7 @@ function SavedBackendListRow({ |
| 1461 | const metadataBits = [ | 1477 | const metadataBits = [ |
| 1462 | sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null, | 1478 | sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null, |
| 1463 | environment.relayManaged ? "T3 Connect" : null, | 1479 | environment.relayManaged ? "T3 Connect" : null, |
| 1480 | enabled ? null : "Off", | ||
| 1464 | ].filter((value): value is string => value !== null); | 1481 | ].filter((value): value is string => value !== null); |
| 1465 | 1482 | ||
| 1466 | // The WSL backend is a desktop-managed local backend (it surfaces as a bearer | 1483 | // The WSL backend is a desktop-managed local backend (it surfaces as a bearer |
| ... | @@ -1468,9 +1485,16 @@ function SavedBackendListRow({ | ... | @@ -1468,9 +1485,16 @@ function SavedBackendListRow({ |
| 1468 | // environment you connect to or remove here — its lifecycle is driven by the | 1485 | // environment you connect to or remove here — its lifecycle is driven by the |
| 1469 | // WSL on/off + distro picker on this page. | 1486 | // WSL on/off + distro picker on this page. |
| 1470 | const isWslEnvironment = isDesktopLocalConnectionTarget(environment.entry.target); | 1487 | const isWslEnvironment = isDesktopLocalConnectionTarget(environment.entry.target); |
| 1488 | // Only a connected, enabled machine can take a remote update; a switched-off | ||
| 1489 | // one keeps the "update available" note so the icon is not a surprise later. | ||
| 1490 | const showUpdateAction = | ||
| 1491 | enabled && | ||
| 1492 | isConnected && | ||
| 1493 | versionMismatch !== null && | ||
| 1494 | (serverUpdateState.status === "idle" || serverUpdateState.status === "failed"); | ||
| 1471 | 1495 | ||
| 1472 | return ( | 1496 | return ( |
| 1473 | <div className={ITEM_ROW_CLASSNAME}> | 1497 | <div className={cn(ITEM_ROW_CLASSNAME, !enabled && "opacity-60")}> |
| 1474 | <div className={ITEM_ROW_INNER_CLASSNAME}> | 1498 | <div className={ITEM_ROW_INNER_CLASSNAME}> |
| 1475 | <div className="min-w-0 flex-1 space-y-1"> | 1499 | <div className="min-w-0 flex-1 space-y-1"> |
| 1476 | <div className="flex min-h-5 items-center gap-1.5"> | 1500 | <div className="flex min-h-5 items-center gap-1.5"> |
| ... | @@ -1478,7 +1502,7 @@ function SavedBackendListRow({ | ... | @@ -1478,7 +1502,7 @@ function SavedBackendListRow({ |
| 1478 | tooltipText={statusTooltip} | 1502 | tooltipText={statusTooltip} |
| 1479 | dotClassName={stateDotClassName} | 1503 | dotClassName={stateDotClassName} |
| 1480 | pingClassName={ | 1504 | pingClassName={ |
| 1481 | connectionState === "connecting" || connectionState === "reconnecting" | 1505 | enabled && (connectionState === "connecting" || connectionState === "reconnecting") |
| 1482 | ? "bg-warning/60 duration-2000" | 1506 | ? "bg-warning/60 duration-2000" |
| 1483 | : null | 1507 | : null |
| 1484 | } | 1508 | } |
| ... | @@ -1526,7 +1550,7 @@ function SavedBackendListRow({ | ... | @@ -1526,7 +1550,7 @@ function SavedBackendListRow({ |
| 1526 | </TooltipPopup> | 1550 | </TooltipPopup> |
| 1527 | </Tooltip> | 1551 | </Tooltip> |
| 1528 | ) : null} | 1552 | ) : null} |
| 1529 | {environment.connection.error && !resumingServerUpdate ? ( | 1553 | {enabled && environment.connection.error && !resumingServerUpdate ? ( |
| 1530 | <p className="flex min-w-0 items-center gap-2 text-destructive text-xs"> | 1554 | <p className="flex min-w-0 items-center gap-2 text-destructive text-xs"> |
| 1531 | <span className="min-w-0 break-words"> | 1555 | <span className="min-w-0 break-words"> |
| 1532 | {connectionStatusText(environment.connection)} | 1556 | {connectionStatusText(environment.connection)} |
| ... | @@ -1543,9 +1567,8 @@ function SavedBackendListRow({ | ... | @@ -1543,9 +1567,8 @@ function SavedBackendListRow({ |
| 1543 | </p> | 1567 | </p> |
| 1544 | ) : null} | 1568 | ) : null} |
| 1545 | </div> | 1569 | </div> |
| 1546 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> | 1570 | <div className="flex w-full shrink-0 items-center gap-1 sm:w-auto sm:justify-end"> |
| 1547 | {versionMismatch && | 1571 | {showUpdateAction ? ( |
| 1548 | (serverUpdateState.status === "idle" || serverUpdateState.status === "failed") ? ( | ||
| 1549 | <ServerUpdateAction | 1572 | <ServerUpdateAction |
| 1550 | environmentId={environmentId} | 1573 | environmentId={environmentId} |
| 1551 | serverLabel={`${environment.label} server`} | 1574 | serverLabel={`${environment.label} server`} |
| ... | @@ -1553,7 +1576,8 @@ function SavedBackendListRow({ | ... | @@ -1553,7 +1576,8 @@ function SavedBackendListRow({ |
| 1553 | desktopAppUpdate={supportsDesktopAppUpdate(environment.serverConfig)} | 1576 | desktopAppUpdate={supportsDesktopAppUpdate(environment.serverConfig)} |
| 1554 | threadContinuation={supportsServerUpdateThreadContinuation(environment.serverConfig)} | 1577 | threadContinuation={supportsServerUpdateThreadContinuation(environment.serverConfig)} |
| 1555 | targetVersion={versionMismatch.clientVersion} | 1578 | targetVersion={versionMismatch.clientVersion} |
| 1556 | label={serverUpdateState.status === "failed" ? "Retry" : "Update"} | 1579 | label={serverUpdateState.status === "failed" ? "Retry update" : "Update"} |
| 1580 | appearance="icon" | ||
| 1557 | /> | 1581 | /> |
| 1558 | ) : null} | 1582 | ) : null} |
| 1559 | {isWslEnvironment ? ( | 1583 | {isWslEnvironment ? ( |
| ... | @@ -1571,32 +1595,44 @@ function SavedBackendListRow({ | ... | @@ -1571,32 +1595,44 @@ function SavedBackendListRow({ |
| 1571 | </Tooltip> | 1595 | </Tooltip> |
| 1572 | ) : ( | 1596 | ) : ( |
| 1573 | <> | 1597 | <> |
| 1574 | {!isConnected ? ( | 1598 | <Tooltip> |
| 1575 | <Button | 1599 | <TooltipTrigger |
| 1576 | size="xs" | 1600 | render={ |
| 1577 | variant="outline" | 1601 | <Switch |
| 1578 | disabled={removingEnvironmentId === environmentId} | 1602 | size="sm" |
| 1579 | onClick={() => void onRemove(environmentId)} | 1603 | checked={enabled} |
| 1604 | disabled={isRemoving} | ||
| 1605 | aria-label={`${enabled ? "Switch off" : "Switch on"} ${environment.label}`} | ||
| 1606 | onCheckedChange={(checked) => onSetEnabled(environmentId, checked)} | ||
| 1607 | /> | ||
| 1608 | } | ||
| 1609 | /> | ||
| 1610 | <TooltipPopup side="top">{enabled ? "Switch off" : "Switch on"}</TooltipPopup> | ||
| 1611 | </Tooltip> | ||
| 1612 | <Menu> | ||
| 1613 | <MenuTrigger | ||
| 1614 | render={ | ||
| 1615 | <Button | ||
| 1616 | type="button" | ||
| 1617 | variant="ghost" | ||
| 1618 | size="icon-xs" | ||
| 1619 | className="text-muted-foreground hover:text-foreground" | ||
| 1620 | disabled={isRemoving} | ||
| 1621 | aria-label={`More actions for ${environment.label}`} | ||
| 1622 | /> | ||
| 1623 | } | ||
| 1580 | > | 1624 | > |
| 1581 | {removingEnvironmentId === environmentId ? "Removing…" : "Remove"} | 1625 | <EllipsisIcon className="size-3.5" /> |
| 1582 | </Button> | 1626 | </MenuTrigger> |
| 1583 | ) : null} | 1627 | <MenuPopup align="end" className="min-w-52"> |
| 1584 | <Button | 1628 | {errorTraceId ? ( |
| 1585 | size="xs" | 1629 | <MenuItem onClick={() => copyTraceId(errorTraceId)}>Copy trace ID</MenuItem> |
| 1586 | variant="outline" | 1630 | ) : null} |
| 1587 | disabled={isConnecting || removingEnvironmentId === environmentId} | 1631 | <MenuItem variant="destructive" onClick={() => onRemove(environment)}> |
| 1588 | onClick={() => | 1632 | {isRemoving ? "Removing…" : "Remove from this device…"} |
| 1589 | void (isConnected ? onRemove(environmentId) : onConnect(environmentId)) | 1633 | </MenuItem> |
| 1590 | } | 1634 | </MenuPopup> |
| 1591 | > | 1635 | </Menu> |
| 1592 | {isConnected | ||
| 1593 | ? removingEnvironmentId === environmentId | ||
| 1594 | ? "Disconnecting…" | ||
| 1595 | : "Disconnect" | ||
| 1596 | : isConnecting | ||
| 1597 | ? "Connecting…" | ||
| 1598 | : "Connect"} | ||
| 1599 | </Button> | ||
| 1600 | </> | 1636 | </> |
| 1601 | )} | 1637 | )} |
| 1602 | </div> | 1638 | </div> |
| ... | @@ -1781,7 +1817,9 @@ export function ConnectionsSettings() { | ... | @@ -1781,7 +1817,9 @@ export function ConnectionsSettings() { |
| 1781 | reportFailure: false, | 1817 | reportFailure: false, |
| 1782 | }); | 1818 | }); |
| 1783 | const removeEnvironment = useAtomCommand(environmentCatalog.remove, { reportFailure: false }); | 1819 | const removeEnvironment = useAtomCommand(environmentCatalog.remove, { reportFailure: false }); |
| 1784 | const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false }); | 1820 | const setEnvironmentEnabled = useAtomCommand(environmentCatalog.setEnabled, { |
| 1821 | reportFailure: false, | ||
| 1822 | }); | ||
| 1785 | const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; | 1823 | const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; |
| 1786 | const primarySessionState = usePrimarySessionState(); | 1824 | const primarySessionState = usePrimarySessionState(); |
| 1787 | const currentSessionScopes = desktopBridge | 1825 | const currentSessionScopes = desktopBridge |
| ... | @@ -1797,6 +1835,59 @@ export function ConnectionsSettings() { | ... | @@ -1797,6 +1835,59 @@ export function ConnectionsSettings() { |
| 1797 | .toSorted((left, right) => left.label.localeCompare(right.label)), | 1835 | .toSorted((left, right) => left.label.localeCompare(right.label)), |
| 1798 | [environments], | 1836 | [environments], |
| 1799 | ); | 1837 | ); |
| 1838 | // Machines "Update all" can reach: switched on, connected, behind the client | ||
| 1839 | // version, remotely updatable, and not already mid-update. The button only | ||
| 1840 | // renders when this list is non-empty. | ||
| 1841 | const savedServerUpdateStatesAtom = useMemo( | ||
| 1842 | () => | ||
| 1843 | Atom.make((get) => | ||
| 1844 | savedEnvironments.map((environment) => ({ | ||
| 1845 | environment, | ||
| 1846 | updateStatus: get(serverEnvironment.updateStateAtom(environment.environmentId)).status, | ||
| 1847 | })), | ||
| 1848 | ), | ||
| 1849 | [savedEnvironments], | ||
| 1850 | ); | ||
| 1851 | const savedServerUpdateStates = useAtomValue(savedServerUpdateStatesAtom); | ||
| 1852 | const savedServerUpdateTargets = useMemo( | ||
| 1853 | () => | ||
| 1854 | savedServerUpdateStates.flatMap(({ environment, updateStatus }): ServerUpdateTarget[] => { | ||
| 1855 | const mismatch = resolveServerConfigVersionMismatch(environment.serverConfig); | ||
| 1856 | const selfUpdate = resolveServerSelfUpdateCapability(environment.serverConfig); | ||
| 1857 | const desktopAppUpdate = supportsDesktopAppUpdate(environment.serverConfig); | ||
| 1858 | if ( | ||
| 1859 | !mismatch || | ||
| 1860 | updateStatus === "running" || | ||
| 1861 | !environment.entry.enabled || | ||
| 1862 | environment.connection.phase !== "connected" || | ||
| 1863 | isDesktopLocalConnectionTarget(environment.entry.target) || | ||
| 1864 | // Manual-update machines only offer a copy command on their row. | ||
| 1865 | selfUpdate === null || | ||
| 1866 | (selfUpdate === "desktop-managed" && !desktopAppUpdate) | ||
| 1867 | ) { | ||
| 1868 | return []; | ||
| 1869 | } | ||
| 1870 | return [ | ||
| 1871 | { | ||
| 1872 | environmentId: environment.environmentId, | ||
| 1873 | serverLabel: environment.label, | ||
| 1874 | selfUpdate, | ||
| 1875 | desktopAppUpdate, | ||
| 1876 | threadContinuation: supportsServerUpdateThreadContinuation(environment.serverConfig), | ||
| 1877 | continueThreadsAfterServerUpdate: | ||
| 1878 | environment.serverConfig?.settings.continueThreadsAfterServerUpdate ?? false, | ||
| 1879 | targetVersion: mismatch.clientVersion, | ||
| 1880 | }, | ||
| 1881 | ]; | ||
| 1882 | }), | ||
| 1883 | [savedServerUpdateStates], | ||
| 1884 | ); | ||
| 1885 | // Switched-off machines never receive threads, so they stay out of the | ||
| 1886 | // load balancing list. | ||
| 1887 | const loadBalancingEnvironments = useMemo( | ||
| 1888 | () => environments.filter((environment) => environment.entry.enabled), | ||
| 1889 | [environments], | ||
| 1890 | ); | ||
| 1800 | const savedDesktopSshEnvironmentKeys = useMemo(() => { | 1891 | const savedDesktopSshEnvironmentKeys = useMemo(() => { |
| 1801 | const keys = new Set<string>(); | 1892 | const keys = new Set<string>(); |
| 1802 | for (const environment of savedEnvironments) { | 1893 | for (const environment of savedEnvironments) { |
| ... | @@ -2367,28 +2458,42 @@ export function ConnectionsSettings() { | ... | @@ -2367,28 +2458,42 @@ export function ConnectionsSettings() { |
| 2367 | ], | 2458 | ], |
| 2368 | ); | 2459 | ); |
| 2369 | 2460 | ||
| 2370 | const handleConnectSavedBackend = useCallback( | 2461 | const handleSetSavedBackendEnabled = useCallback( |
| 2371 | async (environmentId: EnvironmentId) => { | 2462 | async (environmentId: EnvironmentId, enabled: boolean) => { |
| 2372 | setSavedBackendError(null); | 2463 | setSavedBackendError(null); |
| 2373 | const result = await retryEnvironment(environmentId); | 2464 | const result = await setEnvironmentEnabled({ environmentId, enabled }); |
| 2374 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | 2465 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 2375 | const error = squashAtomCommandFailure(result); | 2466 | const error = squashAtomCommandFailure(result); |
| 2376 | const message = error instanceof Error ? error.message : "Failed to connect backend."; | 2467 | const message = |
| 2468 | error instanceof Error | ||
| 2469 | ? error.message | ||
| 2470 | : `Failed to switch the backend ${enabled ? "on" : "off"}.`; | ||
| 2377 | setSavedBackendError(message); | 2471 | setSavedBackendError(message); |
| 2378 | toastManager.add( | 2472 | toastManager.add( |
| 2379 | stackedThreadToast({ | 2473 | stackedThreadToast({ |
| 2380 | type: "error", | 2474 | type: "error", |
| 2381 | title: "Could not connect backend", | 2475 | title: `Could not switch backend ${enabled ? "on" : "off"}`, |
| 2382 | description: message, | 2476 | description: message, |
| 2383 | }), | 2477 | }), |
| 2384 | ); | 2478 | ); |
| 2385 | } | 2479 | } |
| 2386 | }, | 2480 | }, |
| 2387 | [retryEnvironment], | 2481 | [setEnvironmentEnabled], |
| 2388 | ); | 2482 | ); |
| 2389 | 2483 | ||
| 2484 | // Removing forgets the pairing, credentials, and cached threads on this | ||
| 2485 | // device. Switching off is the reversible path, so removal always confirms. | ||
| 2390 | const handleRemoveSavedBackend = useCallback( | 2486 | const handleRemoveSavedBackend = useCallback( |
| 2391 | async (environmentId: EnvironmentId) => { | 2487 | async (environment: EnvironmentPresentation) => { |
| 2488 | // Fail closed: no mounted confirm host means no removal. | ||
| 2489 | const confirmed = await requestConfirmDialog( | ||
| 2490 | `Remove ${environment.label} from this device?\nThis forgets its pairing, credentials, and cached threads here. Switch it off instead to keep it saved.`, | ||
| 2491 | { variant: "destructive" }, | ||
| 2492 | ); | ||
| 2493 | if (confirmed !== true) { | ||
| 2494 | return; | ||
| 2495 | } | ||
| 2496 | const environmentId = environment.environmentId; | ||
| 2392 | setRemovingSavedEnvironmentId(environmentId); | 2497 | setRemovingSavedEnvironmentId(environmentId); |
| 2393 | setSavedBackendError(null); | 2498 | setSavedBackendError(null); |
| 2394 | const result = await removeEnvironment(environmentId); | 2499 | const result = await removeEnvironment(environmentId); |
| ... | @@ -3524,65 +3629,75 @@ export function ConnectionsSettings() { | ... | @@ -3524,65 +3629,75 @@ export function ConnectionsSettings() { |
| 3524 | <SettingsSection | 3629 | <SettingsSection |
| 3525 | {...searchableSetting("remote-environments")} | 3630 | {...searchableSetting("remote-environments")} |
| 3526 | headerAction={ | 3631 | headerAction={ |
| 3527 | <Dialog | 3632 | <div className="flex items-center gap-1"> |
| 3528 | open={addBackendDialogOpen} | 3633 | {savedServerUpdateTargets.length > 0 ? ( |
| 3529 | onOpenChange={(open) => { | 3634 | <ServerUpdatesAction |
| 3530 | setAddBackendDialogOpen(open); | 3635 | targets={savedServerUpdateTargets} |
| 3531 | if (!open) { | 3636 | variant="ghost" |
| 3532 | setSavedBackendError(null); | 3637 | className="font-normal text-muted-foreground/60 hover:text-muted-foreground" |
| 3533 | } | ||
| 3534 | }} | ||
| 3535 | > | ||
| 3536 | <Tooltip> | ||
| 3537 | <TooltipTrigger | ||
| 3538 | render={ | ||
| 3539 | <DialogTrigger | ||
| 3540 | render={ | ||
| 3541 | <Button | ||
| 3542 | size="xs" | ||
| 3543 | variant="ghost" | ||
| 3544 | className="font-normal text-muted-foreground/60 hover:text-muted-foreground" | ||
| 3545 | aria-label="Add environment" | ||
| 3546 | > | ||
| 3547 | <PlusIcon className="size-3" /> | ||
| 3548 | <span>Add environment</span> | ||
| 3549 | </Button> | ||
| 3550 | } | ||
| 3551 | /> | ||
| 3552 | } | ||
| 3553 | /> | 3638 | /> |
| 3554 | <TooltipPopup side="top">Add environment</TooltipPopup> | 3639 | ) : null} |
| 3555 | </Tooltip> | 3640 | <Dialog |
| 3556 | <DialogPopup className="max-h-[80dvh] sm:max-w-3xl"> | 3641 | open={addBackendDialogOpen} |
| 3557 | <DialogHeader> | 3642 | onOpenChange={(open) => { |
| 3558 | <DialogTitle>Add Environment</DialogTitle> | 3643 | setAddBackendDialogOpen(open); |
| 3559 | <DialogDescription>Pair another environment to this client.</DialogDescription> | 3644 | if (!open) { |
| 3560 | </DialogHeader> | 3645 | setSavedBackendError(null); |
| 3561 | <DialogPanel> | 3646 | } |
| 3562 | <div className="space-y-4"> | 3647 | }} |
| 3563 | <div className="grid gap-3 sm:grid-cols-2"> | 3648 | > |
| 3564 | {renderConnectionModeCard({ | 3649 | <Tooltip> |
| 3565 | mode: "remote", | 3650 | <TooltipTrigger |
| 3566 | title: "Remote link", | 3651 | render={ |
| 3567 | description: "Enter a backend host and pairing code.", | 3652 | <DialogTrigger |
| 3568 | icon: <ChevronsLeftRightEllipsisIcon aria-hidden className="size-4" />, | 3653 | render={ |
| 3569 | })} | 3654 | <Button |
| 3570 | {desktopBridge | 3655 | size="xs" |
| 3571 | ? renderConnectionModeCard({ | 3656 | variant="ghost" |
| 3572 | mode: "ssh", | 3657 | className="font-normal text-muted-foreground/60 hover:text-muted-foreground" |
| 3573 | title: "SSH", | 3658 | aria-label="Add environment" |
| 3574 | description: "Use local SSH config, agent, and tunnels for the backend.", | 3659 | > |
| 3575 | icon: <TerminalIcon aria-hidden className="size-4" />, | 3660 | <PlusIcon className="size-3" /> |
| 3576 | }) | 3661 | <span>Add environment</span> |
| 3577 | : null} | 3662 | </Button> |
| 3663 | } | ||
| 3664 | /> | ||
| 3665 | } | ||
| 3666 | /> | ||
| 3667 | <TooltipPopup side="top">Add environment</TooltipPopup> | ||
| 3668 | </Tooltip> | ||
| 3669 | <DialogPopup className="max-h-[80dvh] sm:max-w-3xl"> | ||
| 3670 | <DialogHeader> | ||
| 3671 | <DialogTitle>Add Environment</DialogTitle> | ||
| 3672 | <DialogDescription>Pair another environment to this client.</DialogDescription> | ||
| 3673 | </DialogHeader> | ||
| 3674 | <DialogPanel> | ||
| 3675 | <div className="space-y-4"> | ||
| 3676 | <div className="grid gap-3 sm:grid-cols-2"> | ||
| 3677 | {renderConnectionModeCard({ | ||
| 3678 | mode: "remote", | ||
| 3679 | title: "Remote link", | ||
| 3680 | description: "Enter a backend host and pairing code.", | ||
| 3681 | icon: <ChevronsLeftRightEllipsisIcon aria-hidden className="size-4" />, | ||
| 3682 | })} | ||
| 3683 | {desktopBridge | ||
| 3684 | ? renderConnectionModeCard({ | ||
| 3685 | mode: "ssh", | ||
| 3686 | title: "SSH", | ||
| 3687 | description: | ||
| 3688 | "Use local SSH config, agent, and tunnels for the backend.", | ||
| 3689 | icon: <TerminalIcon aria-hidden className="size-4" />, | ||
| 3690 | }) | ||
| 3691 | : null} | ||
| 3692 | </div> | ||
| 3693 | <AnimatedHeight> | ||
| 3694 | {savedBackendMode === "ssh" ? renderSshFields() : renderRemoteModeBody()} | ||
| 3695 | </AnimatedHeight> | ||
| 3578 | </div> | 3696 | </div> |
| 3579 | <AnimatedHeight> | 3697 | </DialogPanel> |
| 3580 | {savedBackendMode === "ssh" ? renderSshFields() : renderRemoteModeBody()} | 3698 | </DialogPopup> |
| 3581 | </AnimatedHeight> | 3699 | </Dialog> |
| 3582 | </div> | 3700 | </div> |
| 3583 | </DialogPanel> | ||
| 3584 | </DialogPopup> | ||
| 3585 | </Dialog> | ||
| 3586 | } | 3701 | } |
| 3587 | > | 3702 | > |
| 3588 | {savedEnvironments.map((environment) => ( | 3703 | {savedEnvironments.map((environment) => ( |
| ... | @@ -3590,7 +3705,7 @@ export function ConnectionsSettings() { | ... | @@ -3590,7 +3705,7 @@ export function ConnectionsSettings() { |
| 3590 | key={environment.environmentId} | 3705 | key={environment.environmentId} |
| 3591 | environment={environment} | 3706 | environment={environment} |
| 3592 | removingEnvironmentId={removingSavedEnvironmentId} | 3707 | removingEnvironmentId={removingSavedEnvironmentId} |
| 3593 | onConnect={handleConnectSavedBackend} | 3708 | onSetEnabled={handleSetSavedBackendEnabled} |
| 3594 | onRemove={handleRemoveSavedBackend} | 3709 | onRemove={handleRemoveSavedBackend} |
| 3595 | /> | 3710 | /> |
| 3596 | ))} | 3711 | ))} |
| ... | @@ -3599,7 +3714,7 @@ export function ConnectionsSettings() { | ... | @@ -3599,7 +3714,7 @@ export function ConnectionsSettings() { |
| 3599 | savedEnvironments={savedEnvironments} | 3714 | savedEnvironments={savedEnvironments} |
| 3600 | /> | 3715 | /> |
| 3601 | </SettingsSection> | 3716 | </SettingsSection> |
| 3602 | <LoadBalancingSettings environments={environments} /> | 3717 | <LoadBalancingSettings environments={loadBalancingEnvironments} /> |
| 3603 | </SettingsPageContainer> | 3718 | </SettingsPageContainer> |
| 3604 | ); | 3719 | ); |
| 3605 | } | 3720 | } |
apps/web/src/connection/storage.test.ts+1| ... | @@ -13,6 +13,7 @@ const emptyCatalog = { | ... | @@ -13,6 +13,7 @@ const emptyCatalog = { |
| 13 | profiles: [], | 13 | profiles: [], |
| 14 | credentials: [], | 14 | credentials: [], |
| 15 | remoteDpopTokens: [], | 15 | remoteDpopTokens: [], |
| 16 | disabledEnvironmentIds: [], | ||
| 16 | } as const; | 17 | } as const; |
| 17 | const decodeCatalog = Schema.decodeUnknownSync(Schema.fromJsonString(ConnectionCatalogDocument)); | 18 | const decodeCatalog = Schema.decodeUnknownSync(Schema.fromJsonString(ConnectionCatalogDocument)); |
| 18 | 19 |
apps/web/src/connection/storage.ts+11| ... | @@ -10,6 +10,7 @@ import { | ... | @@ -10,6 +10,7 @@ import { |
| 10 | registerConnectionInCatalog, | 10 | registerConnectionInCatalog, |
| 11 | removeCatalogValue, | 11 | removeCatalogValue, |
| 12 | removeConnectionFromCatalog, | 12 | removeConnectionFromCatalog, |
| 13 | setConnectionEnabledInCatalog, | ||
| 13 | replaceCatalogValue, | 14 | replaceCatalogValue, |
| 14 | } from "@t3tools/client-runtime/platform"; | 15 | } from "@t3tools/client-runtime/platform"; |
| 15 | import { TokenStore } from "@t3tools/client-runtime/authorization"; | 16 | import { TokenStore } from "@t3tools/client-runtime/authorization"; |
| ... | @@ -99,8 +100,10 @@ function catalogError(operation: string, cause: unknown) { | ... | @@ -99,8 +100,10 @@ function catalogError(operation: string, cause: unknown) { |
| 99 | function persistenceError( | 100 | function persistenceError( |
| 100 | operation: | 101 | operation: |
| 101 | | "list-targets" | 102 | | "list-targets" |
| 103 | | "list-disabled-targets" | ||
| 102 | | "register-connection" | 104 | | "register-connection" |
| 103 | | "remove-connection" | 105 | | "remove-connection" |
| 106 | | "set-connection-enabled" | ||
| 104 | | "load-shell" | 107 | | "load-shell" |
| 105 | | "save-shell" | 108 | | "save-shell" |
| 106 | | "load-thread" | 109 | | "load-thread" |
| ... | @@ -377,6 +380,10 @@ export const connectionStorageLayer = Layer.effectContext( | ... | @@ -377,6 +380,10 @@ export const connectionStorageLayer = Layer.effectContext( |
| 377 | Effect.map((document) => document.targets), | 380 | Effect.map((document) => document.targets), |
| 378 | Effect.mapError((cause) => persistenceError("list-targets", cause)), | 381 | Effect.mapError((cause) => persistenceError("list-targets", cause)), |
| 379 | ), | 382 | ), |
| 383 | listDisabled: catalog.read.pipe( | ||
| 384 | Effect.map((document) => document.disabledEnvironmentIds), | ||
| 385 | Effect.mapError((cause) => persistenceError("list-disabled-targets", cause)), | ||
| 386 | ), | ||
| 380 | }); | 387 | }); |
| 381 | const registrationStore = ConnectionRegistrationStore.of({ | 388 | const registrationStore = ConnectionRegistrationStore.of({ |
| 382 | register: (registration) => | 389 | register: (registration) => |
| ... | @@ -387,6 +394,10 @@ export const connectionStorageLayer = Layer.effectContext( | ... | @@ -387,6 +394,10 @@ export const connectionStorageLayer = Layer.effectContext( |
| 387 | catalog | 394 | catalog |
| 388 | .update((document) => removeConnectionFromCatalog(document, target)) | 395 | .update((document) => removeConnectionFromCatalog(document, target)) |
| 389 | .pipe(Effect.mapError((cause) => persistenceError("remove-connection", cause))), | 396 | .pipe(Effect.mapError((cause) => persistenceError("remove-connection", cause))), |
| 397 | setEnabled: (environmentId, enabled) => | ||
| 398 | catalog | ||
| 399 | .update((document) => setConnectionEnabledInCatalog(document, environmentId, enabled)) | ||
| 400 | .pipe(Effect.mapError((cause) => persistenceError("set-connection-enabled", cause))), | ||
| 390 | }); | 401 | }); |
| 391 | const profileStore = ProfileStore.make({ | 402 | const profileStore = ProfileStore.make({ |
| 392 | get: (connectionId) => | 403 | get: (connectionId) => |
apps/web/src/state/shell.test.ts+1| ... | @@ -51,6 +51,7 @@ function catalogState(environmentIds: readonly EnvironmentId[]): EnvironmentCata | ... | @@ -51,6 +51,7 @@ function catalogState(environmentIds: readonly EnvironmentId[]): EnvironmentCata |
| 51 | label: environmentId, | 51 | label: environmentId, |
| 52 | }), | 52 | }), |
| 53 | profile: Option.none(), | 53 | profile: Option.none(), |
| 54 | enabled: true, | ||
| 54 | }, | 55 | }, |
| 55 | ]), | 56 | ]), |
| 56 | ), | 57 | ), |
apps/web/src/state/shell.ts+6-3| ... | @@ -8,7 +8,10 @@ import { | ... | @@ -8,7 +8,10 @@ import { |
| 8 | createShellEnvironmentAtoms, | 8 | createShellEnvironmentAtoms, |
| 9 | type EnvironmentShellState, | 9 | type EnvironmentShellState, |
| 10 | } from "@t3tools/client-runtime/state/shell"; | 10 | } from "@t3tools/client-runtime/state/shell"; |
| 11 | import type { EnvironmentCatalogState } from "@t3tools/client-runtime/state/connections"; | 11 | import { |
| 12 | type EnvironmentCatalogState, | ||
| 13 | enabledEnvironmentIds, | ||
| 14 | } from "@t3tools/client-runtime/state/connections"; | ||
| 12 | import type { EnvironmentId } from "@t3tools/contracts"; | 15 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 13 | import * as Option from "effect/Option"; | 16 | import * as Option from "effect/Option"; |
| 14 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 17 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| ... | @@ -26,7 +29,7 @@ export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { | ... | @@ -26,7 +29,7 @@ export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { |
| 26 | if (Option.isNone(catalog)) { | 29 | if (Option.isNone(catalog)) { |
| 27 | return false; | 30 | return false; |
| 28 | } | 31 | } |
| 29 | for (const environmentId of catalog.value.entries.keys()) { | 32 | for (const environmentId of enabledEnvironmentIds(catalog.value)) { |
| 30 | if (Option.isSome(get(environmentShell.stateValueAtom(environmentId)).snapshot)) { | 33 | if (Option.isSome(get(environmentShell.stateValueAtom(environmentId)).snapshot)) { |
| 31 | continue; | 34 | continue; |
| 32 | } | 35 | } |
| ... | @@ -65,7 +68,7 @@ export function createAllEnvironmentProjectSnapshotsReadyAtom(input: { | ... | @@ -65,7 +68,7 @@ export function createAllEnvironmentProjectSnapshotsReadyAtom(input: { |
| 65 | ) { | 68 | ) { |
| 66 | return false; | 69 | return false; |
| 67 | } | 70 | } |
| 68 | for (const environmentId of catalog.entries.keys()) { | 71 | for (const environmentId of enabledEnvironmentIds(catalog)) { |
| 69 | const shell = get(input.shellStateValueAtom(environmentId)); | 72 | const shell = get(input.shellStateValueAtom(environmentId)); |
| 70 | if (shell.status !== "live" || Option.isNone(shell.snapshot)) return false; | 73 | if (shell.status !== "live" || Option.isNone(shell.snapshot)) return false; |
| 71 | } | 74 | } |
packages/client-runtime/src/connection/catalog.ts+4| ... | @@ -39,6 +39,8 @@ export type ConnectionProfile = typeof ConnectionProfile.Type; | ... | @@ -39,6 +39,8 @@ export type ConnectionProfile = typeof ConnectionProfile.Type; |
| 39 | export interface ConnectionCatalogEntry { | 39 | export interface ConnectionCatalogEntry { |
| 40 | readonly target: ConnectionTarget; | 40 | readonly target: ConnectionTarget; |
| 41 | readonly profile: Option.Option<ConnectionProfile>; | 41 | readonly profile: Option.Option<ConnectionProfile>; |
| 42 | /** False when the user switched the environment off: saved, but never connects. */ | ||
| 43 | readonly enabled: boolean; | ||
| 42 | } | 44 | } |
| 43 | 45 | ||
| 44 | export class BearerConnectionCredential extends Schema.TaggedClass<BearerConnectionCredential>()( | 46 | export class BearerConnectionCredential extends Schema.TaggedClass<BearerConnectionCredential>()( |
| ... | @@ -113,12 +115,14 @@ export function connectionRegistrationCatalogEntry( | ... | @@ -113,12 +115,14 @@ export function connectionRegistrationCatalogEntry( |
| 113 | return { | 115 | return { |
| 114 | target: registration.target, | 116 | target: registration.target, |
| 115 | profile: Option.none(), | 117 | profile: Option.none(), |
| 118 | enabled: true, | ||
| 116 | }; | 119 | }; |
| 117 | case "BearerConnectionRegistration": | 120 | case "BearerConnectionRegistration": |
| 118 | case "SshConnectionRegistration": | 121 | case "SshConnectionRegistration": |
| 119 | return { | 122 | return { |
| 120 | target: registration.target, | 123 | target: registration.target, |
| 121 | profile: Option.some(registration.profile), | 124 | profile: Option.some(registration.profile), |
| 125 | enabled: true, | ||
| 122 | }; | 126 | }; |
| 123 | } | 127 | } |
| 124 | } | 128 | } |
packages/client-runtime/src/connection/onboarding.test.ts+1| ... | @@ -185,6 +185,7 @@ describe("connection onboarding", () => { | ... | @@ -185,6 +185,7 @@ describe("connection onboarding", () => { |
| 185 | wsBaseUrl: "ws://old.example.test/", | 185 | wsBaseUrl: "ws://old.example.test/", |
| 186 | }), | 186 | }), |
| 187 | ), | 187 | ), |
| 188 | enabled: true, | ||
| 188 | }), | 189 | }), |
| 189 | credential: Option.some(new BearerConnectionCredential({ token: "bearer-token" })), | 190 | credential: Option.some(new BearerConnectionCredential({ token: "bearer-token" })), |
| 190 | }); | 191 | }); |
packages/client-runtime/src/connection/presentation.test.ts+1| ... | @@ -33,6 +33,7 @@ const ENTRY: ConnectionCatalogEntry = { | ... | @@ -33,6 +33,7 @@ const ENTRY: ConnectionCatalogEntry = { |
| 33 | wsBaseUrl: "wss://environment.example.test", | 33 | wsBaseUrl: "wss://environment.example.test", |
| 34 | }), | 34 | }), |
| 35 | ), | 35 | ), |
| 36 | enabled: true, | ||
| 36 | }; | 37 | }; |
| 37 | 38 | ||
| 38 | function supervisorState(overrides: Partial<SupervisorConnectionState>): SupervisorConnectionState { | 39 | function supervisorState(overrides: Partial<SupervisorConnectionState>): SupervisorConnectionState { |
packages/client-runtime/src/connection/registry.test.ts+132| ... | @@ -140,6 +140,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( | ... | @@ -140,6 +140,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( |
| 140 | readonly beforeRegistrationRemove?: ( | 140 | readonly beforeRegistrationRemove?: ( |
| 141 | target: ConnectionTarget, | 141 | target: ConnectionTarget, |
| 142 | ) => Effect.Effect<void, Persistence.ConnectionPersistenceError>; | 142 | ) => Effect.Effect<void, Persistence.ConnectionPersistenceError>; |
| 143 | readonly initialDisabled?: ReadonlyArray<EnvironmentId>; | ||
| 143 | }, | 144 | }, |
| 144 | ) { | 145 | ) { |
| 145 | const storedTargets = yield* Ref.make( | 146 | const storedTargets = yield* Ref.make( |
| ... | @@ -176,8 +177,12 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( | ... | @@ -176,8 +177,12 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( |
| 176 | ); | 177 | ); |
| 177 | const disconnectedSshTargets = yield* Ref.make<ReadonlyArray<DesktopSshEnvironmentTarget>>([]); | 178 | const disconnectedSshTargets = yield* Ref.make<ReadonlyArray<DesktopSshEnvironmentTarget>>([]); |
| 178 | 179 | ||
| 180 | const storedDisabled = yield* Ref.make<ReadonlySet<EnvironmentId>>( | ||
| 181 | new Set(options?.initialDisabled ?? []), | ||
| 182 | ); | ||
| 179 | const targetStore = Persistence.ConnectionTargetStore.of({ | 183 | const targetStore = Persistence.ConnectionTargetStore.of({ |
| 180 | list: Ref.get(storedTargets).pipe(Effect.map((targets) => [...targets.values()])), | 184 | list: Ref.get(storedTargets).pipe(Effect.map((targets) => [...targets.values()])), |
| 185 | listDisabled: Ref.get(storedDisabled).pipe(Effect.map((ids) => [...ids])), | ||
| 181 | }); | 186 | }); |
| 182 | const registrationStore = Persistence.ConnectionRegistrationStore.of({ | 187 | const registrationStore = Persistence.ConnectionRegistrationStore.of({ |
| 183 | register: (registration) => | 188 | register: (registration) => |
| ... | @@ -237,6 +242,16 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( | ... | @@ -237,6 +242,16 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( |
| 237 | return next; | 242 | return next; |
| 238 | }); | 243 | }); |
| 239 | }), | 244 | }), |
| 245 | setEnabled: (environmentId, enabled) => | ||
| 246 | Ref.update(storedDisabled, (current) => { | ||
| 247 | const next = new Set(current); | ||
| 248 | if (enabled) { | ||
| 249 | next.delete(environmentId); | ||
| 250 | } else { | ||
| 251 | next.add(environmentId); | ||
| 252 | } | ||
| 253 | return next; | ||
| 254 | }), | ||
| 240 | }); | 255 | }); |
| 241 | const cacheStore = Persistence.EnvironmentCacheStore.of({ | 256 | const cacheStore = Persistence.EnvironmentCacheStore.of({ |
| 242 | loadShell: (environmentId) => | 257 | loadShell: (environmentId) => |
| ... | @@ -405,6 +420,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( | ... | @@ -405,6 +420,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( |
| 405 | profileReadCount, | 420 | profileReadCount, |
| 406 | storedCredentials, | 421 | storedCredentials, |
| 407 | storedRemoteTokens, | 422 | storedRemoteTokens, |
| 423 | storedDisabled, | ||
| 408 | disconnectedSshTargets, | 424 | disconnectedSshTargets, |
| 409 | networkStatus, | 425 | networkStatus, |
| 410 | }; | 426 | }; |
| ... | @@ -653,6 +669,122 @@ describe("EnvironmentRegistry", () => { | ... | @@ -653,6 +669,122 @@ describe("EnvironmentRegistry", () => { |
| 653 | }), | 669 | }), |
| 654 | ); | 670 | ); |
| 655 | 671 | ||
| 672 | it.effect("switching an environment off disconnects it and persists the flag", () => | ||
| 673 | Effect.gen(function* () { | ||
| 674 | const harness = yield* makeHarness([RELAY_TARGET]); | ||
| 675 | |||
| 676 | yield* Effect.gen(function* () { | ||
| 677 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | ||
| 678 | yield* registry.start; | ||
| 679 | yield* awaitConnectionState( | ||
| 680 | registry, | ||
| 681 | RELAY_TARGET.environmentId, | ||
| 682 | (state) => state.phase === "connected", | ||
| 683 | ); | ||
| 684 | |||
| 685 | yield* registry.setEnabled(RELAY_TARGET.environmentId, false); | ||
| 686 | yield* awaitConnectionState( | ||
| 687 | registry, | ||
| 688 | RELAY_TARGET.environmentId, | ||
| 689 | (state) => state.phase === "available", | ||
| 690 | ); | ||
| 691 | |||
| 692 | const entry = (yield* SubscriptionRef.get(registry.entries)).get( | ||
| 693 | RELAY_TARGET.environmentId, | ||
| 694 | ); | ||
| 695 | expect(entry?.enabled).toBe(false); | ||
| 696 | expect((yield* Ref.get(harness.storedDisabled)).has(RELAY_TARGET.environmentId)).toBe(true); | ||
| 697 | expect((yield* Ref.get(harness.storedTargets)).has(RELAY_TARGET.environmentId)).toBe(true); | ||
| 698 | expect(yield* Ref.get(harness.releasedSessions)).toBe(1); | ||
| 699 | |||
| 700 | yield* registry.setEnabled(RELAY_TARGET.environmentId, true); | ||
| 701 | yield* awaitConnectionState( | ||
| 702 | registry, | ||
| 703 | RELAY_TARGET.environmentId, | ||
| 704 | (state) => state.phase === "connected", | ||
| 705 | ); | ||
| 706 | expect((yield* Ref.get(harness.storedDisabled)).has(RELAY_TARGET.environmentId)).toBe( | ||
| 707 | false, | ||
| 708 | ); | ||
| 709 | }).pipe(Effect.provide(harness.layer)); | ||
| 710 | }), | ||
| 711 | ); | ||
| 712 | |||
| 713 | it.effect("re-registering a switched-off environment keeps it off", () => | ||
| 714 | Effect.gen(function* () { | ||
| 715 | const harness = yield* makeHarness([RELAY_TARGET], [], [], { | ||
| 716 | initialDisabled: [RELAY_TARGET.environmentId], | ||
| 717 | }); | ||
| 718 | |||
| 719 | yield* Effect.gen(function* () { | ||
| 720 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | ||
| 721 | yield* registry.start; | ||
| 722 | yield* registry.register( | ||
| 723 | new RelayConnectionRegistration({ | ||
| 724 | target: new RelayConnectionTarget({ ...RELAY_TARGET, label: "Renamed" }), | ||
| 725 | }), | ||
| 726 | ); | ||
| 727 | yield* Effect.yieldNow; | ||
| 728 | |||
| 729 | const entry = (yield* SubscriptionRef.get(registry.entries)).get( | ||
| 730 | RELAY_TARGET.environmentId, | ||
| 731 | ); | ||
| 732 | expect(entry?.target.label).toBe("Renamed"); | ||
| 733 | expect(entry?.enabled).toBe(false); | ||
| 734 | expect(yield* Ref.get(harness.sessions)).toHaveLength(0); | ||
| 735 | }).pipe(Effect.provide(harness.layer)); | ||
| 736 | }), | ||
| 737 | ); | ||
| 738 | |||
| 739 | it.effect("switching an SSH environment off tears down its managed backend", () => | ||
| 740 | Effect.gen(function* () { | ||
| 741 | const harness = yield* makeHarness([SSH_CONNECTION], [SSH_PROFILE]); | ||
| 742 | |||
| 743 | yield* Effect.gen(function* () { | ||
| 744 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | ||
| 745 | yield* registry.start; | ||
| 746 | yield* awaitConnectionState( | ||
| 747 | registry, | ||
| 748 | SSH_CONNECTION.environmentId, | ||
| 749 | (state) => state.phase === "connected", | ||
| 750 | ); | ||
| 751 | |||
| 752 | yield* registry.setEnabled(SSH_CONNECTION.environmentId, false); | ||
| 753 | yield* awaitConnectionState( | ||
| 754 | registry, | ||
| 755 | SSH_CONNECTION.environmentId, | ||
| 756 | (state) => state.phase === "available", | ||
| 757 | ); | ||
| 758 | |||
| 759 | expect(yield* Ref.get(harness.disconnectedSshTargets)).toEqual([SSH_TARGET]); | ||
| 760 | expect((yield* Ref.get(harness.storedTargets)).has(SSH_CONNECTION.environmentId)).toBe( | ||
| 761 | true, | ||
| 762 | ); | ||
| 763 | }).pipe(Effect.provide(harness.layer)); | ||
| 764 | }), | ||
| 765 | ); | ||
| 766 | |||
| 767 | it.effect("does not connect a persisted environment that was switched off", () => | ||
| 768 | Effect.gen(function* () { | ||
| 769 | const harness = yield* makeHarness([RELAY_TARGET], [], [], { | ||
| 770 | initialDisabled: [RELAY_TARGET.environmentId], | ||
| 771 | }); | ||
| 772 | |||
| 773 | yield* Effect.gen(function* () { | ||
| 774 | const registry = yield* EnvironmentRegistry.EnvironmentRegistry; | ||
| 775 | yield* registry.start; | ||
| 776 | yield* Effect.yieldNow; | ||
| 777 | |||
| 778 | const entry = (yield* SubscriptionRef.get(registry.entries)).get( | ||
| 779 | RELAY_TARGET.environmentId, | ||
| 780 | ); | ||
| 781 | expect(entry?.enabled).toBe(false); | ||
| 782 | expect((yield* registry.state(RELAY_TARGET.environmentId)).phase).toBe("available"); | ||
| 783 | expect(yield* Ref.get(harness.sessions)).toHaveLength(0); | ||
| 784 | }).pipe(Effect.provide(harness.layer)); | ||
| 785 | }), | ||
| 786 | ); | ||
| 787 | |||
| 656 | it.effect("moves durable streams to a replacement supervisor", () => | 788 | it.effect("moves durable streams to a replacement supervisor", () => |
| 657 | Effect.gen(function* () { | 789 | Effect.gen(function* () { |
| 658 | const replacement = new RelayConnectionTarget({ | 790 | const replacement = new RelayConnectionTarget({ |
packages/client-runtime/src/connection/registry.ts+88-4| ... | @@ -90,6 +90,18 @@ export class EnvironmentRegistry extends Context.Service< | ... | @@ -90,6 +90,18 @@ export class EnvironmentRegistry extends Context.Service< |
| 90 | | PlatformEnvironmentRemovalError | 90 | | PlatformEnvironmentRemovalError |
| 91 | >; | 91 | >; |
| 92 | readonly retryNow: (environmentId: EnvironmentId) => Effect.Effect<void>; | 92 | readonly retryNow: (environmentId: EnvironmentId) => Effect.Effect<void>; |
| 93 | /** | ||
| 94 | * Switches a saved environment on or off. Off drops the socket, stops the | ||
| 95 | * retry ladder, and persists so the next launch stays off. Registration, | ||
| 96 | * credentials, and cache are untouched. | ||
| 97 | */ | ||
| 98 | readonly setEnabled: ( | ||
| 99 | environmentId: EnvironmentId, | ||
| 100 | enabled: boolean, | ||
| 101 | ) => Effect.Effect< | ||
| 102 | void, | ||
| 103 | EnvironmentNotRegisteredError | Persistence.ConnectionPersistenceError | ||
| 104 | >; | ||
| 93 | readonly state: ( | 105 | readonly state: ( |
| 94 | environmentId: EnvironmentId, | 106 | environmentId: EnvironmentId, |
| 95 | ) => Effect.Effect<SupervisorConnectionState, EnvironmentNotRegisteredError>; | 107 | ) => Effect.Effect<SupervisorConnectionState, EnvironmentNotRegisteredError>; |
| ... | @@ -139,6 +151,7 @@ export const make = Effect.gen(function* () { | ... | @@ -139,6 +151,7 @@ export const make = Effect.gen(function* () { |
| 139 | const wakeups = yield* ConnectionWakeups.ConnectionWakeups; | 151 | const wakeups = yield* ConnectionWakeups.ConnectionWakeups; |
| 140 | const ssh = yield* ClientCapabilities.SshEnvironmentGateway; | 152 | const ssh = yield* ClientCapabilities.SshEnvironmentGateway; |
| 141 | const persistedTargets = yield* storage.list; | 153 | const persistedTargets = yield* storage.list; |
| 154 | const disabledEnvironmentIds = new Set(yield* storage.listDisabled); | ||
| 142 | const initialEntries = new Map( | 155 | const initialEntries = new Map( |
| 143 | yield* Effect.forEach( | 156 | yield* Effect.forEach( |
| 144 | persistedTargets, | 157 | persistedTargets, |
| ... | @@ -149,7 +162,11 @@ export const make = Effect.gen(function* () { | ... | @@ -149,7 +162,11 @@ export const make = Effect.gen(function* () { |
| 149 | : Option.none(); | 162 | : Option.none(); |
| 150 | return [ | 163 | return [ |
| 151 | target.environmentId, | 164 | target.environmentId, |
| 152 | { target, profile } satisfies ConnectionCatalogEntry, | 165 | { |
| 166 | target, | ||
| 167 | profile, | ||
| 168 | enabled: !disabledEnvironmentIds.has(target.environmentId), | ||
| 169 | } satisfies ConnectionCatalogEntry, | ||
| 153 | ] as const; | 170 | ] as const; |
| 154 | }), | 171 | }), |
| 155 | { concurrency: "unbounded" }, | 172 | { concurrency: "unbounded" }, |
| ... | @@ -261,7 +278,9 @@ export const make = Effect.gen(function* () { | ... | @@ -261,7 +278,9 @@ export const make = Effect.gen(function* () { |
| 261 | Scope.provide(scope), | 278 | Scope.provide(scope), |
| 262 | Effect.onError(() => Scope.close(scope, Exit.void)), | 279 | Effect.onError(() => Scope.close(scope, Exit.void)), |
| 263 | ); | 280 | ); |
| 264 | yield* supervisor.connect; | 281 | if (entry.enabled) { |
| 282 | yield* supervisor.connect; | ||
| 283 | } | ||
| 265 | yield* SubscriptionRef.update(serviceScopes, (current) => { | 284 | yield* SubscriptionRef.update(serviceScopes, (current) => { |
| 266 | const next = new Map(current); | 285 | const next = new Map(current); |
| 267 | next.set(environmentId, { entry, supervisor, scope }); | 286 | next.set(environmentId, { entry, supervisor, scope }); |
| ... | @@ -391,14 +410,19 @@ export const make = Effect.gen(function* () { | ... | @@ -391,14 +410,19 @@ export const make = Effect.gen(function* () { |
| 391 | const register = Effect.fn("EnvironmentRegistry.register")(function* ( | 410 | const register = Effect.fn("EnvironmentRegistry.register")(function* ( |
| 392 | registration: ConnectionRegistration, | 411 | registration: ConnectionRegistration, |
| 393 | ) { | 412 | ) { |
| 394 | const entry = connectionRegistrationCatalogEntry(registration); | 413 | const registered = connectionRegistrationCatalogEntry(registration); |
| 395 | const environmentId = entry.target.environmentId; | 414 | const environmentId = registered.target.environmentId; |
| 396 | yield* withLeaseLock( | 415 | yield* withLeaseLock( |
| 397 | environmentId, | 416 | environmentId, |
| 398 | Effect.gen(function* () { | 417 | Effect.gen(function* () { |
| 399 | if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) { | 418 | if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) { |
| 400 | return; | 419 | return; |
| 401 | } | 420 | } |
| 421 | // Editing a saved environment re-registers it; that must not switch a | ||
| 422 | // disabled one back on. | ||
| 423 | const previous = (yield* SubscriptionRef.get(entries)).get(environmentId); | ||
| 424 | const entry: ConnectionCatalogEntry = | ||
| 425 | previous === undefined ? registered : { ...registered, enabled: previous.enabled }; | ||
| 402 | yield* registrations.register(registration); | 426 | yield* registrations.register(registration); |
| 403 | yield* Ref.update(persistedTargetsByEnvironment, (current) => { | 427 | yield* Ref.update(persistedTargetsByEnvironment, (current) => { |
| 404 | const next = new Map(current); | 428 | const next = new Map(current); |
| ... | @@ -630,6 +654,65 @@ export const make = Effect.gen(function* () { | ... | @@ -630,6 +654,65 @@ export const make = Effect.gen(function* () { |
| 630 | Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void), | 654 | Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void), |
| 631 | Effect.withSpan("EnvironmentRegistry.retryNow"), | 655 | Effect.withSpan("EnvironmentRegistry.retryNow"), |
| 632 | ); | 656 | ); |
| 657 | const setEnabled = Effect.fn("EnvironmentRegistry.setEnabled")(function* ( | ||
| 658 | environmentId: EnvironmentId, | ||
| 659 | enabled: boolean, | ||
| 660 | ) { | ||
| 661 | yield* withLeaseLock( | ||
| 662 | environmentId, | ||
| 663 | Effect.gen(function* () { | ||
| 664 | const entry = yield* getEntry(environmentId); | ||
| 665 | if (entry.enabled === enabled) { | ||
| 666 | return; | ||
| 667 | } | ||
| 668 | // Platform-managed environments are reconciled from the host and are | ||
| 669 | // never persisted, so only user-saved ones write the flag. | ||
| 670 | if (!(yield* Ref.get(platformEnvironmentIds)).has(environmentId)) { | ||
| 671 | yield* registrations.setEnabled(environmentId, enabled); | ||
| 672 | } | ||
| 673 | const next: ConnectionCatalogEntry = { ...entry, enabled }; | ||
| 674 | // Update the lease in place so the supervisor keeps its generation and | ||
| 675 | // durable streams; `installEntryLocked` would tear it down instead. | ||
| 676 | const lease = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId); | ||
| 677 | if (lease !== undefined) { | ||
| 678 | yield* SubscriptionRef.update(serviceScopes, (current) => { | ||
| 679 | const nextScopes = new Map(current); | ||
| 680 | nextScopes.set(environmentId, { ...lease, entry: next }); | ||
| 681 | return nextScopes; | ||
| 682 | }); | ||
| 683 | } | ||
| 684 | yield* SubscriptionRef.update(entries, (current) => { | ||
| 685 | const nextEntries = new Map(current); | ||
| 686 | nextEntries.set(environmentId, next); | ||
| 687 | return nextEntries; | ||
| 688 | }); | ||
| 689 | if (lease !== undefined) { | ||
| 690 | yield* enabled ? lease.supervisor.connect : lease.supervisor.disconnect; | ||
| 691 | } else if (enabled) { | ||
| 692 | yield* createServiceScope(next); | ||
| 693 | } | ||
| 694 | // The supervisor only owns the RPC session. A managed SSH backend and | ||
| 695 | // its tunnel outlive it, so switching off tears those down as well. | ||
| 696 | if ( | ||
| 697 | !enabled && | ||
| 698 | entry.target._tag === "SshConnectionTarget" && | ||
| 699 | Option.isSome(entry.profile) && | ||
| 700 | isSshConnectionProfile(entry.profile.value) | ||
| 701 | ) { | ||
| 702 | yield* ssh.disconnect(entry.profile.value.target).pipe( | ||
| 703 | Effect.tapError((error) => | ||
| 704 | Effect.logWarning("Could not disconnect the switched-off SSH environment.", { | ||
| 705 | environmentId, | ||
| 706 | error, | ||
| 707 | }), | ||
| 708 | ), | ||
| 709 | Effect.ignore, | ||
| 710 | ); | ||
| 711 | } | ||
| 712 | }), | ||
| 713 | ); | ||
| 714 | }); | ||
| 715 | |||
| 633 | const state = Effect.fn("EnvironmentRegistry.state")(function* (environmentId: EnvironmentId) { | 716 | const state = Effect.fn("EnvironmentRegistry.state")(function* (environmentId: EnvironmentId) { |
| 634 | const supervisor = yield* acquireSupervisor(environmentId); | 717 | const supervisor = yield* acquireSupervisor(environmentId); |
| 635 | return yield* SubscriptionRef.get(supervisor.state); | 718 | return yield* SubscriptionRef.get(supervisor.state); |
| ... | @@ -669,6 +752,7 @@ export const make = Effect.gen(function* () { | ... | @@ -669,6 +752,7 @@ export const make = Effect.gen(function* () { |
| 669 | remove, | 752 | remove, |
| 670 | removeRelayEnvironments, | 753 | removeRelayEnvironments, |
| 671 | retryNow, | 754 | retryNow, |
| 755 | setEnabled, | ||
| 672 | state, | 756 | state, |
| 673 | stateChanges, | 757 | stateChanges, |
| 674 | run, | 758 | run, |
packages/client-runtime/src/connection/resolver.test.ts+1-1| ... | @@ -45,7 +45,7 @@ function catalogEntry( | ... | @@ -45,7 +45,7 @@ function catalogEntry( |
| 45 | target: ConnectionTarget, | 45 | target: ConnectionTarget, |
| 46 | profile: Option.Option<ConnectionProfile> = Option.none(), | 46 | profile: Option.Option<ConnectionProfile> = Option.none(), |
| 47 | ): ConnectionCatalogEntry { | 47 | ): ConnectionCatalogEntry { |
| 48 | return { target, profile }; | 48 | return { target, profile, enabled: true }; |
| 49 | } | 49 | } |
| 50 | 50 | ||
| 51 | function collectingTracer(spans: Array<string>): Tracer.Tracer { | 51 | function collectingTracer(spans: Array<string>): Tracer.Tracer { |
packages/client-runtime/src/connection/supervisor.test.ts+2| ... | @@ -56,11 +56,13 @@ const RELAY_TARGET = new RelayConnectionTarget({ | ... | @@ -56,11 +56,13 @@ const RELAY_TARGET = new RelayConnectionTarget({ |
| 56 | const TARGET_ENTRY: ConnectionCatalogEntry = { | 56 | const TARGET_ENTRY: ConnectionCatalogEntry = { |
| 57 | target: TARGET, | 57 | target: TARGET, |
| 58 | profile: Option.none(), | 58 | profile: Option.none(), |
| 59 | enabled: true, | ||
| 59 | }; | 60 | }; |
| 60 | 61 | ||
| 61 | const RELAY_ENTRY: ConnectionCatalogEntry = { | 62 | const RELAY_ENTRY: ConnectionCatalogEntry = { |
| 62 | target: RELAY_TARGET, | 63 | target: RELAY_TARGET, |
| 63 | profile: Option.none(), | 64 | profile: Option.none(), |
| 65 | enabled: true, | ||
| 64 | }; | 66 | }; |
| 65 | 67 | ||
| 66 | const PREPARED_CONNECTION: PreparedConnection = { | 68 | const PREPARED_CONNECTION: PreparedConnection = { |
packages/client-runtime/src/platform/persistence.ts+8| ... | @@ -19,8 +19,10 @@ export class ConnectionPersistenceError extends Schema.TaggedError<ConnectionPer | ... | @@ -19,8 +19,10 @@ export class ConnectionPersistenceError extends Schema.TaggedError<ConnectionPer |
| 19 | { | 19 | { |
| 20 | operation: Schema.Literals([ | 20 | operation: Schema.Literals([ |
| 21 | "list-targets", | 21 | "list-targets", |
| 22 | "list-disabled-targets", | ||
| 22 | "register-connection", | 23 | "register-connection", |
| 23 | "remove-connection", | 24 | "remove-connection", |
| 25 | "set-connection-enabled", | ||
| 24 | "load-shell", | 26 | "load-shell", |
| 25 | "save-shell", | 27 | "save-shell", |
| 26 | "load-thread", | 28 | "load-thread", |
| ... | @@ -42,6 +44,8 @@ export class ConnectionTargetStore extends Context.Service< | ... | @@ -42,6 +44,8 @@ export class ConnectionTargetStore extends Context.Service< |
| 42 | ConnectionTargetStore, | 44 | ConnectionTargetStore, |
| 43 | { | 45 | { |
| 44 | readonly list: Effect.Effect<ReadonlyArray<ConnectionTarget>, ConnectionPersistenceError>; | 46 | readonly list: Effect.Effect<ReadonlyArray<ConnectionTarget>, ConnectionPersistenceError>; |
| 47 | /** Saved environments the user switched off. See `ConnectionRegistrationStore.setEnabled`. */ | ||
| 48 | readonly listDisabled: Effect.Effect<ReadonlyArray<EnvironmentId>, ConnectionPersistenceError>; | ||
| 45 | } | 49 | } |
| 46 | >()("@t3tools/client-runtime/platform/persistence/ConnectionTargetStore") {} | 50 | >()("@t3tools/client-runtime/platform/persistence/ConnectionTargetStore") {} |
| 47 | 51 | ||
| ... | @@ -52,6 +56,10 @@ export class ConnectionRegistrationStore extends Context.Service< | ... | @@ -52,6 +56,10 @@ export class ConnectionRegistrationStore extends Context.Service< |
| 52 | registration: ConnectionRegistration, | 56 | registration: ConnectionRegistration, |
| 53 | ) => Effect.Effect<void, ConnectionPersistenceError>; | 57 | ) => Effect.Effect<void, ConnectionPersistenceError>; |
| 54 | readonly remove: (target: ConnectionTarget) => Effect.Effect<void, ConnectionPersistenceError>; | 58 | readonly remove: (target: ConnectionTarget) => Effect.Effect<void, ConnectionPersistenceError>; |
| 59 | readonly setEnabled: ( | ||
| 60 | environmentId: EnvironmentId, | ||
| 61 | enabled: boolean, | ||
| 62 | ) => Effect.Effect<void, ConnectionPersistenceError>; | ||
| 55 | } | 63 | } |
| 56 | >()("@t3tools/client-runtime/platform/persistence/ConnectionRegistrationStore") {} | 64 | >()("@t3tools/client-runtime/platform/persistence/ConnectionRegistrationStore") {} |
| 57 | 65 |
packages/client-runtime/src/platform/storageDocument.test.ts+50| ... | @@ -22,9 +22,11 @@ import { | ... | @@ -22,9 +22,11 @@ import { |
| 22 | putRemoteDpopTokenInCatalog, | 22 | putRemoteDpopTokenInCatalog, |
| 23 | registerConnectionInCatalog, | 23 | registerConnectionInCatalog, |
| 24 | removeConnectionFromCatalog, | 24 | removeConnectionFromCatalog, |
| 25 | setConnectionEnabledInCatalog, | ||
| 25 | } from "./storageDocument.ts"; | 26 | } from "./storageDocument.ts"; |
| 26 | 27 | ||
| 27 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); | 28 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); |
| 29 | const decodeCatalogDocument = Schema.decodeUnknownSync(ConnectionCatalogDocument); | ||
| 28 | 30 | ||
| 29 | const RELAY_TARGET = new RelayConnectionTarget({ | 31 | const RELAY_TARGET = new RelayConnectionTarget({ |
| 30 | environmentId: ENVIRONMENT_ID, | 32 | environmentId: ENVIRONMENT_ID, |
| ... | @@ -203,6 +205,54 @@ describe("ConnectionCatalogDocument", () => { | ... | @@ -203,6 +205,54 @@ describe("ConnectionCatalogDocument", () => { |
| 203 | expect(putRemoteDpopTokenInCatalog(otherRelay, REMOTE_TOKEN)).toBe(otherRelay); | 205 | expect(putRemoteDpopTokenInCatalog(otherRelay, REMOTE_TOKEN)).toBe(otherRelay); |
| 204 | }); | 206 | }); |
| 205 | 207 | ||
| 208 | it("decodes a document written before the disabled list existed", () => { | ||
| 209 | const decoded = decodeCatalogDocument({ | ||
| 210 | schemaVersion: 1, | ||
| 211 | targets: [], | ||
| 212 | profiles: [], | ||
| 213 | credentials: [], | ||
| 214 | remoteDpopTokens: [], | ||
| 215 | }); | ||
| 216 | |||
| 217 | expect(decoded.disabledEnvironmentIds).toEqual([]); | ||
| 218 | }); | ||
| 219 | |||
| 220 | it("switches a saved environment off and back on without touching its records", () => { | ||
| 221 | const registered = registerConnectionInCatalog( | ||
| 222 | EMPTY_CONNECTION_CATALOG_DOCUMENT, | ||
| 223 | new BearerConnectionRegistration({ | ||
| 224 | target: BEARER_TARGET, | ||
| 225 | profile: BEARER_PROFILE, | ||
| 226 | credential: BEARER_CREDENTIAL, | ||
| 227 | }), | ||
| 228 | ); | ||
| 229 | |||
| 230 | const disabled = setConnectionEnabledInCatalog(registered, ENVIRONMENT_ID, false); | ||
| 231 | expect(disabled.disabledEnvironmentIds).toEqual([ENVIRONMENT_ID]); | ||
| 232 | expect(disabled.targets).toEqual(registered.targets); | ||
| 233 | expect(disabled.credentials).toEqual(registered.credentials); | ||
| 234 | // Idempotent: switching off twice stores the id once. | ||
| 235 | expect( | ||
| 236 | setConnectionEnabledInCatalog(disabled, ENVIRONMENT_ID, false).disabledEnvironmentIds, | ||
| 237 | ).toEqual([ENVIRONMENT_ID]); | ||
| 238 | |||
| 239 | expect( | ||
| 240 | setConnectionEnabledInCatalog(disabled, ENVIRONMENT_ID, true).disabledEnvironmentIds, | ||
| 241 | ).toEqual([]); | ||
| 242 | // Re-registering (editing label or URL) keeps the flag. | ||
| 243 | expect( | ||
| 244 | registerConnectionInCatalog( | ||
| 245 | disabled, | ||
| 246 | new BearerConnectionRegistration({ | ||
| 247 | target: BEARER_TARGET, | ||
| 248 | profile: BEARER_PROFILE, | ||
| 249 | credential: BEARER_CREDENTIAL, | ||
| 250 | }), | ||
| 251 | ).disabledEnvironmentIds, | ||
| 252 | ).toEqual([ENVIRONMENT_ID]); | ||
| 253 | expect(removeConnectionFromCatalog(disabled, BEARER_TARGET).disabledEnvironmentIds).toEqual([]); | ||
| 254 | }); | ||
| 255 | |||
| 206 | it("persists the normalized SSH profile beside its target", () => { | 256 | it("persists the normalized SSH profile beside its target", () => { |
| 207 | const target = new SshConnectionTarget({ | 257 | const target = new SshConnectionTarget({ |
| 208 | environmentId: ENVIRONMENT_ID, | 258 | environmentId: ENVIRONMENT_ID, |
packages/client-runtime/src/platform/storageDocument.ts+34| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 1 | import * as Schema from "effect/Schema"; | 3 | import * as Schema from "effect/Schema"; |
| 2 | 4 | ||
| 3 | import { | 5 | import { |
| ... | @@ -20,6 +22,12 @@ export const ConnectionCatalogDocument = Schema.Struct({ | ... | @@ -20,6 +22,12 @@ export const ConnectionCatalogDocument = Schema.Struct({ |
| 20 | profiles: Schema.Array(ConnectionProfile), | 22 | profiles: Schema.Array(ConnectionProfile), |
| 21 | credentials: Schema.Array(StoredConnectionCredential), | 23 | credentials: Schema.Array(StoredConnectionCredential), |
| 22 | remoteDpopTokens: Schema.Array(TokenStore.RemoteDpopAccessToken), | 24 | remoteDpopTokens: Schema.Array(TokenStore.RemoteDpopAccessToken), |
| 25 | // Saved environments the user switched off. They stay registered with their | ||
| 26 | // credentials and cache but never connect until switched back on. Older | ||
| 27 | // documents predate the key, so decoding defaults it to none. | ||
| 28 | disabledEnvironmentIds: Schema.Array(EnvironmentId).pipe( | ||
| 29 | Schema.withDecodingDefaultKey(Effect.succeed([])), | ||
| 30 | ), | ||
| 23 | }); | 31 | }); |
| 24 | export type ConnectionCatalogDocument = typeof ConnectionCatalogDocument.Type; | 32 | export type ConnectionCatalogDocument = typeof ConnectionCatalogDocument.Type; |
| 25 | 33 | ||
| ... | @@ -29,6 +37,7 @@ export const EMPTY_CONNECTION_CATALOG_DOCUMENT: ConnectionCatalogDocument = Obje | ... | @@ -29,6 +37,7 @@ export const EMPTY_CONNECTION_CATALOG_DOCUMENT: ConnectionCatalogDocument = Obje |
| 29 | profiles: [], | 37 | profiles: [], |
| 30 | credentials: [], | 38 | credentials: [], |
| 31 | remoteDpopTokens: [], | 39 | remoteDpopTokens: [], |
| 40 | disabledEnvironmentIds: [], | ||
| 32 | }); | 41 | }); |
| 33 | 42 | ||
| 34 | export function replaceCatalogValue<A>( | 43 | export function replaceCatalogValue<A>( |
| ... | @@ -87,6 +96,11 @@ function removeConnectionMetadata( | ... | @@ -87,6 +96,11 @@ function removeConnectionMetadata( |
| 87 | target.environmentId, | 96 | target.environmentId, |
| 88 | ) | 97 | ) |
| 89 | : document.remoteDpopTokens, | 98 | : document.remoteDpopTokens, |
| 99 | // Re-registration passes `removeRemoteToken: false` and must keep the | ||
| 100 | // switched-off flag; only a real removal clears it. | ||
| 101 | disabledEnvironmentIds: removeRemoteToken | ||
| 102 | ? removeCatalogValue(document.disabledEnvironmentIds, (value) => value, target.environmentId) | ||
| 103 | : document.disabledEnvironmentIds, | ||
| 90 | }; | 104 | }; |
| 91 | } | 105 | } |
| 92 | 106 | ||
| ... | @@ -100,6 +114,8 @@ export function registerConnectionInCatalog( | ... | @@ -100,6 +114,8 @@ export function registerConnectionInCatalog( |
| 100 | ); | 114 | ); |
| 101 | const cleaned = | 115 | const cleaned = |
| 102 | previous === undefined ? document : removeConnectionMetadata(document, previous, false); | 116 | previous === undefined ? document : removeConnectionMetadata(document, previous, false); |
| 117 | // Re-registering (for example editing a label or URL) keeps the disabled | ||
| 118 | // flag; only `setConnectionEnabledInCatalog` or removal changes it. | ||
| 103 | const next: ConnectionCatalogDocument = { | 119 | const next: ConnectionCatalogDocument = { |
| 104 | ...cleaned, | 120 | ...cleaned, |
| 105 | targets: replaceCatalogValue(cleaned.targets, (value) => value.environmentId, target), | 121 | targets: replaceCatalogValue(cleaned.targets, (value) => value.environmentId, target), |
| ... | @@ -140,6 +156,24 @@ export function removeConnectionFromCatalog( | ... | @@ -140,6 +156,24 @@ export function removeConnectionFromCatalog( |
| 140 | return removeConnectionMetadata(document, target, true); | 156 | return removeConnectionMetadata(document, target, true); |
| 141 | } | 157 | } |
| 142 | 158 | ||
| 159 | /** Flips the disabled flag for a saved environment; unknown ids are ignored. */ | ||
| 160 | export function setConnectionEnabledInCatalog( | ||
| 161 | document: ConnectionCatalogDocument, | ||
| 162 | environmentId: EnvironmentId, | ||
| 163 | enabled: boolean, | ||
| 164 | ): ConnectionCatalogDocument { | ||
| 165 | const registered = document.targets.some((target) => target.environmentId === environmentId); | ||
| 166 | const without = removeCatalogValue( | ||
| 167 | document.disabledEnvironmentIds, | ||
| 168 | (value) => value, | ||
| 169 | environmentId, | ||
| 170 | ); | ||
| 171 | return { | ||
| 172 | ...document, | ||
| 173 | disabledEnvironmentIds: registered && !enabled ? [...without, environmentId] : without, | ||
| 174 | }; | ||
| 175 | } | ||
| 176 | |||
| 143 | export function putRemoteDpopTokenInCatalog( | 177 | export function putRemoteDpopTokenInCatalog( |
| 144 | document: ConnectionCatalogDocument, | 178 | document: ConnectionCatalogDocument, |
| 145 | token: TokenStore.RemoteDpopAccessToken, | 179 | token: TokenStore.RemoteDpopAccessToken, |
packages/client-runtime/src/state/connections.ts+25| ... | @@ -20,6 +20,21 @@ export interface EnvironmentCatalogState { | ... | @@ -20,6 +20,21 @@ export interface EnvironmentCatalogState { |
| 20 | readonly entries: ReadonlyMap<EnvironmentIdType, ConnectionCatalogEntry>; | 20 | readonly entries: ReadonlyMap<EnvironmentIdType, ConnectionCatalogEntry>; |
| 21 | } | 21 | } |
| 22 | 22 | ||
| 23 | /** | ||
| 24 | * Environments that take part in the workspace: projects, threads, and shell | ||
| 25 | * summaries only come from these. Disabled environments stay in `entries` so | ||
| 26 | * Settings can list them and switch them back on. | ||
| 27 | */ | ||
| 28 | export function* enabledEnvironmentIds( | ||
| 29 | catalog: EnvironmentCatalogState, | ||
| 30 | ): Generator<EnvironmentIdType> { | ||
| 31 | for (const [environmentId, entry] of catalog.entries) { | ||
| 32 | if (entry.enabled) { | ||
| 33 | yield environmentId; | ||
| 34 | } | ||
| 35 | } | ||
| 36 | } | ||
| 37 | |||
| 23 | const EMPTY_ENVIRONMENT_CATALOG_STATE: EnvironmentCatalogState = Object.freeze({ | 38 | const EMPTY_ENVIRONMENT_CATALOG_STATE: EnvironmentCatalogState = Object.freeze({ |
| 24 | isReady: false, | 39 | isReady: false, |
| 25 | entries: new Map(), | 40 | entries: new Map(), |
| ... | @@ -106,6 +121,15 @@ export function createEnvironmentCatalogAtoms<R, E>( | ... | @@ -106,6 +121,15 @@ export function createEnvironmentCatalogAtoms<R, E>( |
| 106 | Effect.flatMap((registry) => registry.removeRelayEnvironments()), | 121 | Effect.flatMap((registry) => registry.removeRelayEnvironments()), |
| 107 | ), | 122 | ), |
| 108 | }); | 123 | }); |
| 124 | const setEnabled = createRuntimeCommand(runtime, { | ||
| 125 | label: "environment-catalog:set-enabled", | ||
| 126 | scheduler: commandScheduler, | ||
| 127 | concurrency: serial, | ||
| 128 | execute: (input: { readonly environmentId: EnvironmentIdType; readonly enabled: boolean }) => | ||
| 129 | EnvironmentRegistry.EnvironmentRegistry.pipe( | ||
| 130 | Effect.flatMap((registry) => registry.setEnabled(input.environmentId, input.enabled)), | ||
| 131 | ), | ||
| 132 | }); | ||
| 109 | const retryNow = createRuntimeCommand(runtime, { | 133 | const retryNow = createRuntimeCommand(runtime, { |
| 110 | label: "environment-catalog:retry-now", | 134 | label: "environment-catalog:retry-now", |
| 111 | scheduler: commandScheduler, | 135 | scheduler: commandScheduler, |
| ... | @@ -126,5 +150,6 @@ export function createEnvironmentCatalogAtoms<R, E>( | ... | @@ -126,5 +150,6 @@ export function createEnvironmentCatalogAtoms<R, E>( |
| 126 | remove, | 150 | remove, |
| 127 | removeRelayEnvironments, | 151 | removeRelayEnvironments, |
| 128 | retryNow, | 152 | retryNow, |
| 153 | setEnabled, | ||
| 129 | }; | 154 | }; |
| 130 | } | 155 | } |
packages/client-runtime/src/state/entities.test.ts+22-1| ... | @@ -151,7 +151,10 @@ function shellState(snapshot: OrchestrationShellSnapshot): EnvironmentShellState | ... | @@ -151,7 +151,10 @@ function shellState(snapshot: OrchestrationShellSnapshot): EnvironmentShellState |
| 151 | }; | 151 | }; |
| 152 | } | 152 | } |
| 153 | 153 | ||
| 154 | function makeHarness(environmentIds: ReadonlyArray<EnvironmentId> = [ENVIRONMENT_ID]) { | 154 | function makeHarness( |
| 155 | environmentIds: ReadonlyArray<EnvironmentId> = [ENVIRONMENT_ID], | ||
| 156 | disabledEnvironmentIds: ReadonlySet<EnvironmentId> = new Set(), | ||
| 157 | ) { | ||
| 155 | const shellStateAtoms = Atom.family((_environmentId: EnvironmentId) => | 158 | const shellStateAtoms = Atom.family((_environmentId: EnvironmentId) => |
| 156 | Atom.make(AsyncResult.success(shellState(SNAPSHOT))), | 159 | Atom.make(AsyncResult.success(shellState(SNAPSHOT))), |
| 157 | ); | 160 | ); |
| ... | @@ -171,6 +174,7 @@ function makeHarness(environmentIds: ReadonlyArray<EnvironmentId> = [ENVIRONMENT | ... | @@ -171,6 +174,7 @@ function makeHarness(environmentIds: ReadonlyArray<EnvironmentId> = [ENVIRONMENT |
| 171 | wsBaseUrl: "wss://example.test", | 174 | wsBaseUrl: "wss://example.test", |
| 172 | }), | 175 | }), |
| 173 | profile: Option.none(), | 176 | profile: Option.none(), |
| 177 | enabled: !disabledEnvironmentIds.has(environmentId), | ||
| 174 | }, | 178 | }, |
| 175 | ]), | 179 | ]), |
| 176 | ), | 180 | ), |
| ... | @@ -361,6 +365,23 @@ describe("environment entity projections", () => { | ... | @@ -361,6 +365,23 @@ describe("environment entity projections", () => { |
| 361 | } | 365 | } |
| 362 | }); | 366 | }); |
| 363 | 367 | ||
| 368 | it("hides projects and threads of a switched-off environment while keeping its cache", () => { | ||
| 369 | const offEnvironmentId = EnvironmentId.make("off-environment"); | ||
| 370 | const harness = makeHarness([ENVIRONMENT_ID, offEnvironmentId], new Set([offEnvironmentId])); | ||
| 371 | const projects = harness.registry.get(harness.projects.projectsAtom); | ||
| 372 | const threads = harness.registry.get(harness.threadShells.threadShellsAtom); | ||
| 373 | |||
| 374 | expect(projects.every((project) => project.environmentId === ENVIRONMENT_ID)).toBe(true); | ||
| 375 | expect(projects).toHaveLength(2); | ||
| 376 | expect(threads.every((thread) => thread.environmentId === ENVIRONMENT_ID)).toBe(true); | ||
| 377 | expect(threads).toHaveLength(2); | ||
| 378 | // The per-environment atoms still read the cached snapshot, so switching | ||
| 379 | // back on restores the rows without a refetch. | ||
| 380 | expect( | ||
| 381 | harness.registry.get(harness.projects.environmentProjectsAtom(offEnvironmentId)), | ||
| 382 | ).toHaveLength(2); | ||
| 383 | }); | ||
| 384 | |||
| 364 | it("keeps scoped identities and list order across project and environment changes", () => { | 385 | it("keeps scoped identities and list order across project and environment changes", () => { |
| 365 | const remoteEnvironmentId = EnvironmentId.make("remote-environment"); | 386 | const remoteEnvironmentId = EnvironmentId.make("remote-environment"); |
| 366 | const harness = makeHarness([ENVIRONMENT_ID, remoteEnvironmentId]); | 387 | const harness = makeHarness([ENVIRONMENT_ID, remoteEnvironmentId]); |
packages/client-runtime/src/state/projectEntities.ts+2-2| ... | @@ -9,7 +9,7 @@ import { Atom } from "effect/unstable/reactivity"; | ... | @@ -9,7 +9,7 @@ import { Atom } from "effect/unstable/reactivity"; |
| 9 | 9 | ||
| 10 | import type { EnvironmentProject } from "./models.ts"; | 10 | import type { EnvironmentProject } from "./models.ts"; |
| 11 | import { scopeProject } from "./models.ts"; | 11 | import { scopeProject } from "./models.ts"; |
| 12 | import type { EnvironmentCatalogState } from "./connections.ts"; | 12 | import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts"; |
| 13 | import { arrayElementsEqual, parseProjectKey, projectKey, projectRefsEqual } from "./entities.ts"; | 13 | import { arrayElementsEqual, parseProjectKey, projectKey, projectRefsEqual } from "./entities.ts"; |
| 14 | 14 | ||
| 15 | const EMPTY_PROJECTS: ReadonlyArray<OrchestrationProjectShell> = Object.freeze([]); | 15 | const EMPTY_PROJECTS: ReadonlyArray<OrchestrationProjectShell> = Object.freeze([]); |
| ... | @@ -71,7 +71,7 @@ export function createEnvironmentProjectAtoms(input: { | ... | @@ -71,7 +71,7 @@ export function createEnvironmentProjectAtoms(input: { |
| 71 | let previousProjectRefs: ReadonlyArray<ScopedProjectRef> = []; | 71 | let previousProjectRefs: ReadonlyArray<ScopedProjectRef> = []; |
| 72 | const projectRefsAtom = Atom.make((get) => { | 72 | const projectRefsAtom = Atom.make((get) => { |
| 73 | const refs: ScopedProjectRef[] = []; | 73 | const refs: ScopedProjectRef[] = []; |
| 74 | for (const environmentId of get(input.catalogValueAtom).entries.keys()) { | 74 | for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) { |
| 75 | refs.push(...get(environmentProjectRefsAtom(environmentId))); | 75 | refs.push(...get(environmentProjectRefsAtom(environmentId))); |
| 76 | } | 76 | } |
| 77 | if (projectRefsEqual(previousProjectRefs, refs)) { | 77 | if (projectRefsEqual(previousProjectRefs, refs)) { |
packages/client-runtime/src/state/shell.test.ts+1| ... | @@ -20,6 +20,7 @@ function environmentEntry(environmentId: EnvironmentId, label: string) { | ... | @@ -20,6 +20,7 @@ function environmentEntry(environmentId: EnvironmentId, label: string) { |
| 20 | wsBaseUrl: `wss://${environmentId}.example.test`, | 20 | wsBaseUrl: `wss://${environmentId}.example.test`, |
| 21 | }), | 21 | }), |
| 22 | profile: Option.none(), | 22 | profile: Option.none(), |
| 23 | enabled: true, | ||
| 23 | }; | 24 | }; |
| 24 | } | 25 | } |
| 25 | 26 |
packages/client-runtime/src/state/shell.ts+3-3| ... | @@ -24,7 +24,7 @@ import { subscribeDynamic } from "../rpc/client.ts"; | ... | @@ -24,7 +24,7 @@ import { subscribeDynamic } from "../rpc/client.ts"; |
| 24 | import type { RpcSession } from "../rpc/session.ts"; | 24 | import type { RpcSession } from "../rpc/session.ts"; |
| 25 | import { ShellSnapshotLoader } from "./shellSnapshotHttp.ts"; | 25 | import { ShellSnapshotLoader } from "./shellSnapshotHttp.ts"; |
| 26 | import { applyShellStreamEvent } from "./shellReducer.ts"; | 26 | import { applyShellStreamEvent } from "./shellReducer.ts"; |
| 27 | import type { EnvironmentCatalogState } from "./connections.ts"; | 27 | import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts"; |
| 28 | import { followStreamInEnvironment } from "./runtime.ts"; | 28 | import { followStreamInEnvironment } from "./runtime.ts"; |
| 29 | 29 | ||
| 30 | export type EnvironmentShellStatus = "empty" | "cached" | "synchronizing" | "live"; | 30 | export type EnvironmentShellStatus = "empty" | "cached" | "synchronizing" | "live"; |
| ... | @@ -342,7 +342,7 @@ export function createEnvironmentShellSummaryAtom(input: { | ... | @@ -342,7 +342,7 @@ export function createEnvironmentShellSummaryAtom(input: { |
| 342 | let firstError: string | null = null; | 342 | let firstError: string | null = null; |
| 343 | let latestSnapshotUpdatedAt: string | null = null; | 343 | let latestSnapshotUpdatedAt: string | null = null; |
| 344 | 344 | ||
| 345 | for (const environmentId of get(input.catalogValueAtom).entries.keys()) { | 345 | for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) { |
| 346 | const state = get(input.shellStateValueAtom(environmentId)); | 346 | const state = get(input.shellStateValueAtom(environmentId)); |
| 347 | hasSynchronizingShell ||= state.status === "synchronizing"; | 347 | hasSynchronizingShell ||= state.status === "synchronizing"; |
| 348 | hasCachedShell ||= state.status === "cached"; | 348 | hasCachedShell ||= state.status === "cached"; |
| ... | @@ -383,7 +383,7 @@ export function createEnvironmentServerConfigsAtom(input: { | ... | @@ -383,7 +383,7 @@ export function createEnvironmentServerConfigsAtom(input: { |
| 383 | let previousServerConfigs = EMPTY_SERVER_CONFIGS; | 383 | let previousServerConfigs = EMPTY_SERVER_CONFIGS; |
| 384 | return Atom.make((get) => { | 384 | return Atom.make((get) => { |
| 385 | const next = new Map<EnvironmentId, ServerConfig>(); | 385 | const next = new Map<EnvironmentId, ServerConfig>(); |
| 386 | for (const environmentId of get(input.catalogValueAtom).entries.keys()) { | 386 | for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) { |
| 387 | const config = get(input.serverConfigValueAtom(environmentId)); | 387 | const config = get(input.serverConfigValueAtom(environmentId)); |
| 388 | if (config !== null) { | 388 | if (config !== null) { |
| 389 | next.set(environmentId, config); | 389 | next.set(environmentId, config); |
packages/client-runtime/src/state/threadShell.ts+3-3| ... | @@ -11,7 +11,7 @@ import { Atom } from "effect/unstable/reactivity"; | ... | @@ -11,7 +11,7 @@ import { Atom } from "effect/unstable/reactivity"; |
| 11 | 11 | ||
| 12 | import type { EnvironmentThreadShell } from "./models.ts"; | 12 | import type { EnvironmentThreadShell } from "./models.ts"; |
| 13 | import { scopeThreadShell } from "./models.ts"; | 13 | import { scopeThreadShell } from "./models.ts"; |
| 14 | import type { EnvironmentCatalogState } from "./connections.ts"; | 14 | import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts"; |
| 15 | import { | 15 | import { |
| 16 | arrayElementsEqual, | 16 | arrayElementsEqual, |
| 17 | parseProjectRefCollectionKey, | 17 | parseProjectRefCollectionKey, |
| ... | @@ -174,7 +174,7 @@ export function createEnvironmentThreadShellAtoms(input: { | ... | @@ -174,7 +174,7 @@ export function createEnvironmentThreadShellAtoms(input: { |
| 174 | let previousThreadRefs: ReadonlyArray<ScopedThreadRef> = []; | 174 | let previousThreadRefs: ReadonlyArray<ScopedThreadRef> = []; |
| 175 | const threadRefsAtom = Atom.make((get) => { | 175 | const threadRefsAtom = Atom.make((get) => { |
| 176 | const refs: ScopedThreadRef[] = []; | 176 | const refs: ScopedThreadRef[] = []; |
| 177 | for (const environmentId of get(input.catalogValueAtom).entries.keys()) { | 177 | for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) { |
| 178 | refs.push(...get(environmentThreadRefsAtom(environmentId))); | 178 | refs.push(...get(environmentThreadRefsAtom(environmentId))); |
| 179 | } | 179 | } |
| 180 | if (threadRefsEqual(previousThreadRefs, refs)) { | 180 | if (threadRefsEqual(previousThreadRefs, refs)) { |
| ... | @@ -187,7 +187,7 @@ export function createEnvironmentThreadShellAtoms(input: { | ... | @@ -187,7 +187,7 @@ export function createEnvironmentThreadShellAtoms(input: { |
| 187 | let previousThreadShells: ReadonlyArray<EnvironmentThreadShell> = []; | 187 | let previousThreadShells: ReadonlyArray<EnvironmentThreadShell> = []; |
| 188 | const threadShellsAtom = Atom.make((get) => { | 188 | const threadShellsAtom = Atom.make((get) => { |
| 189 | const next: EnvironmentThreadShell[] = []; | 189 | const next: EnvironmentThreadShell[] = []; |
| 190 | for (const environmentId of get(input.catalogValueAtom).entries.keys()) { | 190 | for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) { |
| 191 | for (const thread of get(environmentThreadsAtom(environmentId))) { | 191 | for (const thread of get(environmentThreadsAtom(environmentId))) { |
| 192 | next.push(scopedThread(environmentId, thread)); | 192 | next.push(scopedThread(environmentId, thread)); |
| 193 | } | 193 | } |
packages/client-runtime/src/state/threads-atoms.test.ts+1| ... | @@ -181,6 +181,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? | ... | @@ -181,6 +181,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? |
| 181 | remove: () => Effect.die("Unexpected environment removal"), | 181 | remove: () => Effect.die("Unexpected environment removal"), |
| 182 | removeRelayEnvironments: () => Effect.die("Unexpected environment removal"), | 182 | removeRelayEnvironments: () => Effect.die("Unexpected environment removal"), |
| 183 | retryNow: () => Effect.void, | 183 | retryNow: () => Effect.void, |
| 184 | setEnabled: () => Effect.die("Unexpected environment toggle"), | ||
| 184 | state: () => SubscriptionRef.get(supervisor.state), | 185 | state: () => SubscriptionRef.get(supervisor.state), |
| 185 | stateChanges: () => SubscriptionRef.changes(supervisor.state), | 186 | stateChanges: () => SubscriptionRef.changes(supervisor.state), |
| 186 | run: (_environmentId, effect) => | 187 | run: (_environmentId, effect) => |