From 8d3a420b7bc599d040bc86367fcb035d57e85a12 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:18:02 -0700 Subject: [PATCH] Split runtime environments pane layers (#17198) * Split speech session lifecycle * Split terminal output scheduler pipeline * Split mobile browser pane modules * Prune resolved max-lines suppressions * Split pane tree equalization logic * Extract mobile troubleshoot screen styles * Split external automation manager * Split main window service attachments * Split hosted review creation checks * Split automation dispatch event handling * Split settings navigation metadata * Split daemon initialization lifecycle * Split GitLab item dialog * Split relay dispatcher layers * Split mobile host screen * Retarget mobile view settings source test * Split runtime file client layers * Split ports panel layers * Split runtime environments pane layers * Fix F3-speech for #17123 * Fix F1-cycle for #17131 * Fix F4-navtest for #17157 * Fix F2-allowlist for #17161 --- config/max-lines-baseline.txt | 1 - .../settings/RuntimeEnvironmentsPane.tsx | 1676 +++-------------- .../runtime-active-server-section.tsx | 211 +++ .../settings/runtime-environment-dialogs.tsx | 140 ++ .../runtime-environment-host-details.ts | 197 ++ .../settings/runtime-environment-selection.ts | 2 + .../settings/runtime-server-row.tsx | 194 ++ .../runtime-server-workflow-sections.tsx | 224 +++ .../runtime-servers-connect-section.tsx | 187 ++ .../use-runtime-environment-catalog.ts | 163 ++ ...-runtime-environment-connection-actions.ts | 212 +++ ...se-runtime-environment-mutation-actions.ts | 194 ++ 12 files changed, 1935 insertions(+), 1466 deletions(-) create mode 100644 src/renderer/src/components/settings/runtime-active-server-section.tsx create mode 100644 src/renderer/src/components/settings/runtime-environment-dialogs.tsx create mode 100644 src/renderer/src/components/settings/runtime-environment-host-details.ts create mode 100644 src/renderer/src/components/settings/runtime-environment-selection.ts create mode 100644 src/renderer/src/components/settings/runtime-server-row.tsx create mode 100644 src/renderer/src/components/settings/runtime-server-workflow-sections.tsx create mode 100644 src/renderer/src/components/settings/runtime-servers-connect-section.tsx create mode 100644 src/renderer/src/components/settings/use-runtime-environment-catalog.ts create mode 100644 src/renderer/src/components/settings/use-runtime-environment-connection-actions.ts create mode 100644 src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index e9919831588..4cad7a7f794 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -44,7 +44,6 @@ inline src/renderer/src/components/editor/MarkdownPreview.tsx inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx inline src/renderer/src/components/settings/AccountsPane.tsx -inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx inline src/renderer/src/components/settings/Settings.tsx inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx inline src/renderer/src/components/status-bar/StatusBar.tsx diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx index 3778e1165d7..0877289f33e 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx @@ -1,70 +1,44 @@ -/* eslint-disable max-lines -- Why: the server settings pane keeps active - server selection, saved server mutation, and confirmation dialogs together so - the state transitions stay auditable. */ -import { - AlertTriangle, - ChevronDown, - Loader2, - Plus, - RefreshCw, - Server, - ServerOff, - Share2, - Trash2 -} from 'lucide-react' -import { useCallback, useEffect, useRef, useState } from 'react' -import { toast } from 'sonner' -import { useMountedRef } from '@/hooks/useMountedRef' +import { useEffect, useRef, useState } from 'react' import type { GlobalSettings } from '../../../../shared/global-settings-types' -import { - isUserManagedRuntimeEnvironment, - type PublicKnownRuntimeEnvironment -} from '../../../../shared/runtime-environments' -import type { RuntimeStatus } from '../../../../shared/runtime-types' -import { - describeRuntimeCompatBlock, - evaluateRuntimeCompat, - type RuntimeCompatVerdict -} from '../../../../shared/protocol-compat' -import { - MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, - PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, - RUNTIME_PROTOCOL_VERSION, - TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, - WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY -} from '../../../../shared/protocol-version' -import { Button } from '../ui/button' -import { Label } from '../ui/label' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import { useAppStore } from '@/store' +import { cn } from '@/lib/utils' import { SearchableSetting } from './SearchableSetting' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '../ui/dialog' -import { RuntimePairingUrlGenerator } from './RuntimePairingUrlGenerator' import { EphemeralVmRuntimesSection } from './EphemeralVmRuntimesSection' import { CloudVmSetupGuide } from './CloudVmSetupGuide' import { getRuntimeEnvironmentsSearchEntry, getWebRuntimeEnvironmentsSearchEntry } from './runtime-environments-search' -import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client' -import { useAppStore } from '@/store' -import { translate } from '@/i18n/i18n' -import { cn } from '@/lib/utils' -import { getUpdateCheckClickOptions, getUpdateCheckHint } from '@/lib/update-check-click-options' +import { isRuntimeEnvironmentRemovalBlocked } from './runtime-environment-host-details' +import { RuntimeServersConnectSection } from './runtime-servers-connect-section' +import { RuntimeActiveServerSection } from './runtime-active-server-section' import { - getRemoteServerManualUpdateHelp, - RemoteServerUpdateStatus -} from './RemoteServerUpdateStatus' -import { RuntimeHostAccessForm, type RuntimeHostAccessFailure } from './RuntimeHostAccessForm' + RuntimeEnvironmentRemoveDialog, + RuntimeEnvironmentSwitchDialog +} from './runtime-environment-dialogs' +import { + RuntimeServerShareSection, + RuntimeServerTroubleshooting, + RuntimeServerWorkflowPicker, + type RemoteServerWorkflow +} from './runtime-server-workflow-sections' +import { useRuntimeEnvironmentCatalog } from './use-runtime-environment-catalog' +import { useRuntimeEnvironmentConnectionActions } from './use-runtime-environment-connection-actions' +import { useRuntimeEnvironmentMutationActions } from './use-runtime-environment-mutation-actions' +import { LOCAL_RUNTIME_VALUE, NO_RUNTIME_VALUE } from './runtime-environment-selection' -const LOCAL_RUNTIME_VALUE = '__local__' -const NO_RUNTIME_VALUE = '__none__' +export { + evaluateHostDetails, + getActiveServerModeDescription, + getHostDetailsDescription, + getHostDetailsSummary, + getHostModelCapabilitySummary, + getRuntimeCapabilitiesSummary, + getRuntimeServerConnectionState, + isRuntimeEnvironmentRemovalBlocked +} from './runtime-environment-host-details' +export type { RuntimeHostDetails } from './runtime-environment-host-details' type RuntimeEnvironmentsPaneProps = { settings: GlobalSettings @@ -74,190 +48,6 @@ type RuntimeEnvironmentsPaneProps = { addServerIntentSignal?: number } -export type RuntimeHostDetails = { - status: 'loading' | 'ready' | 'error' - runtimeStatus: RuntimeStatus | null - compatibility: RuntimeCompatVerdict | null - error: string | null -} - -export function evaluateHostDetails(status: RuntimeStatus): RuntimeCompatVerdict { - return evaluateRuntimeCompat({ - clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, - minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, - serverProtocolVersion: status.runtimeProtocolVersion ?? status.protocolVersion, - serverMinCompatibleClientProtocolVersion: - status.minCompatibleRuntimeClientVersion ?? status.minCompatibleMobileVersion - }) -} - -export function getHostDetailsSummary(details: RuntimeHostDetails | undefined): string { - if (!details || details.status === 'loading') { - return translate('auto.components.settings.RuntimeEnvironmentsPane.5120beaac6', 'Checking…') - } - if (details.status === 'error') { - return translate( - 'auto.components.settings.RuntimeEnvironmentsPane.c8791efc45', - 'Status unavailable' - ) - } - if (details.compatibility?.kind === 'blocked') { - return details.compatibility.reason === 'client-too-old' - ? translate('auto.components.settings.RuntimeEnvironmentsPane.62ac182a27', 'Update client') - : translate('auto.components.settings.RuntimeEnvironmentsPane.86ed75bec8', 'Update server') - } - return translate('auto.components.settings.RuntimeEnvironmentsPane.9a91c4a0eb', 'Compatible') -} - -export function getHostDetailsDescription(details: RuntimeHostDetails | undefined): string | null { - if (!details || details.status === 'loading') { - return null - } - if (details.status === 'error') { - return details.error - } - if (details.compatibility?.kind === 'blocked') { - return describeRuntimeCompatBlock(details.compatibility) - } - return null -} - -export function getRuntimeCapabilitiesSummary(status: RuntimeStatus | null | undefined): string { - const capabilities = status?.capabilities ?? [] - if (capabilities.length === 0) { - return translate( - 'auto.components.settings.RuntimeEnvironmentsPane.4b5c6d7e8f', - 'No capabilities reported' - ) - } - const visibleCapabilities = capabilities.slice(0, 3).join(', ') - const hiddenCount = capabilities.length - 3 - return hiddenCount > 0 ? `${visibleCapabilities} +${hiddenCount}` : visibleCapabilities -} - -export function getHostModelCapabilitySummary( - status: RuntimeStatus | null | undefined -): string | null { - if (!status) { - return null - } - const capabilities = status.capabilities - if (!capabilities) { - return translate( - 'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilityUnknown', - 'Host model support: checking server capabilities' - ) - } - const missing = [ - PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, - TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, - WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY - ].filter((capability) => !capabilities.includes(capability)) - if (missing.length === 0) { - return translate( - 'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilitySupported', - 'Host model support: ready' - ) - } - const missingLabels = missing.map(getHostModelCapabilityLabel) - return translate( - 'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilityMissing', - 'Host model support: update server for {{value0}}', - { value0: missingLabels.join(', ') } - ) -} - -function getHostModelCapabilityLabel(capability: string): string { - switch (capability) { - case PROJECT_HOST_SETUP_RUNTIME_CAPABILITY: - return translate( - 'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilityProjectSetup', - 'project setup' - ) - case TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY: - return translate( - 'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilityTaskSourceContext', - 'task source context' - ) - case WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY: - return translate( - 'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilityWorkspaceRunContext', - 'workspace run context' - ) - default: - return capability - } -} - -export function getActiveServerModeDescription(allowLocalRuntime: boolean): string { - return allowLocalRuntime - ? translate( - 'auto.components.settings.RuntimeEnvironmentsPane.3f67e8078a', - 'Use this computer by default. Choose a saved server only when you want supported projects, files, terminals, provider checks, and browser/mobile handoff to run through that server.' - ) - : translate( - 'auto.components.settings.RuntimeEnvironmentsPane.2c85efb3e8', - 'Selecting a saved server makes this browser use that paired Orca runtime as its default Host.' - ) -} - -export function isRuntimeEnvironmentRemovalBlocked( - activeRuntimeEnvironmentId: string | null | undefined, - environmentId: string -): boolean { - return activeRuntimeEnvironmentId === environmentId -} - -type RuntimeServerConnectionState = 'connected' | 'checking' | 'disconnected' -type RemoteServerWorkflow = 'connect' | 'cloud-vm' | 'share' - -export function getRuntimeServerConnectionState( - details: RuntimeHostDetails | undefined -): RuntimeServerConnectionState { - if (!details || details.status === 'loading') { - return 'checking' - } - if (details.status !== 'ready' || details.compatibility?.kind === 'blocked') { - return 'disconnected' - } - // Why: an attached, reachable, compatible host is "Connected" (and exposes - // Disconnect). Whether it is the default *active* server is a separate concept, - // surfaced by the Advanced > Active Server selector and the row's help text — - // it must not change this connection label, or the dot/label/button disagree. - return 'connected' -} - -function getRuntimeServerConnectionLabel(state: RuntimeServerConnectionState): string { - switch (state) { - case 'connected': - return translate( - 'auto.components.settings.RuntimeEnvironmentsPane.serverConnected', - 'Connected' - ) - case 'checking': - return translate( - 'auto.components.settings.RuntimeEnvironmentsPane.serverChecking', - 'Checking…' - ) - case 'disconnected': - return translate( - 'auto.components.settings.RuntimeEnvironmentsPane.serverDisconnected', - 'Disconnected' - ) - } -} - -function getRuntimeServerDotClass(state: RuntimeServerConnectionState): string { - switch (state) { - case 'connected': - return 'bg-emerald-500' - case 'checking': - return 'bg-yellow-500' - case 'disconnected': - return 'bg-muted-foreground/40' - } -} - export function RuntimeEnvironmentsPane({ settings, setActiveRuntimeEnvironmentPreference, @@ -265,27 +55,12 @@ export function RuntimeEnvironmentsPane({ allowLocalRuntime = true, addServerIntentSignal }: RuntimeEnvironmentsPaneProps): React.JSX.Element { - const [environments, setEnvironments] = useState([]) - const [isLoading, setIsLoading] = useState(false) - const [isSaving, setIsSaving] = useState(false) - const [detailsByEnvironmentId, setDetailsByEnvironmentId] = useState< - Record - >({}) - const [connectingId, setConnectingId] = useState(null) - const [switchingValue, setSwitchingValue] = useState(null) - const [removingId, setRemovingId] = useState(null) - const [disconnectingId, setDisconnectingId] = useState(null) const [pendingSwitchValue, setPendingSwitchValue] = useState(null) const [pendingRemove, setPendingRemove] = useState(null) const [addServerFormOpen, setAddServerFormOpen] = useState(false) const [shareServerFormOpen, setShareServerFormOpen] = useState(true) const [advancedOpen, setAdvancedOpen] = useState(false) const [workflow, setWorkflow] = useState('connect') - const [switchError, setSwitchError] = useState(null) - const [removeError, setRemoveError] = useState(null) - const [name, setName] = useState('') - const [pairingCode, setPairingCode] = useState('') - const [addServerFailure, setAddServerFailure] = useState(null) const remoteServerUpdates = useAppStore((state) => state.remoteServerUpdates) const remoteServerUpdatesChecking = useAppStore((state) => state.remoteServerUpdatesChecking) const remoteServerUpdatesRunning = useAppStore((state) => state.remoteServerUpdatesRunning) @@ -294,137 +69,63 @@ export function RuntimeEnvironmentsPane({ (state) => state.setRemoteServerUpdateDialogOpen ) const consumedAddServerIntentSignalRef = useRef(0) - const mountedRef = useMountedRef() - const updateCheckHint = getUpdateCheckHint() - const activeValue = - settings.activeRuntimeEnvironmentId ?? - (allowLocalRuntime ? LOCAL_RUNTIME_VALUE : NO_RUNTIME_VALUE) - const isBusy = - isSaving || - connectingId !== null || - switchingValue !== null || - removingId !== null || - disconnectingId !== null - const removingActiveServer = pendingRemove - ? isRuntimeEnvironmentRemovalBlocked(settings.activeRuntimeEnvironmentId, pendingRemove.id) - : false - const searchEntry = canGeneratePairingUrl - ? getRuntimeEnvironmentsSearchEntry() - : getWebRuntimeEnvironmentsSearchEntry() + const { + environments, + isLoading, + detailsByEnvironmentId, + setDetailsByEnvironmentId, + mountedRef, + loadEnvironments + } = useRuntimeEnvironmentCatalog() - const loadEnvironments = useCallback( - async (verified?: { environmentId: string; runtimeStatus: RuntimeStatus }): Promise => { - if (mountedRef.current) { - setIsLoading(true) - } - try { - const nextEnvironments = await window.api.runtimeEnvironments.list() - const visibleEnvironments = nextEnvironments.filter(isUserManagedRuntimeEnvironment) - // Why: drop store status for servers no longer saved so stale hosts don't - // linger in the sidebar registry. - useAppStore.getState().setRuntimeEnvironments(nextEnvironments) - if (verified) { - useAppStore.getState().setRuntimeEnvironmentStatus(verified.environmentId, { - status: verified.runtimeStatus, - checkedAt: Date.now() - }) - } - if (mountedRef.current) { - setEnvironments(visibleEnvironments) - setDetailsByEnvironmentId((current) => { - const next: Record = {} - for (const environment of visibleEnvironments) { - next[environment.id] = - verified?.environmentId === environment.id - ? { - status: 'ready', - runtimeStatus: verified.runtimeStatus, - compatibility: evaluateHostDetails(verified.runtimeStatus), - error: null - } - : (current[environment.id] ?? { - status: 'loading', - runtimeStatus: null, - compatibility: null, - error: null - }) - } - return next - }) - } - await Promise.allSettled( - visibleEnvironments - .filter((environment) => environment.id !== verified?.environmentId) - .map(async (environment) => { - try { - const response = await window.api.runtimeEnvironments.getStatus({ - selector: environment.id, - timeoutMs: 10_000 - }) - const runtimeStatus = unwrapRuntimeRpcResult(response) - // Why: feed the live status into the store so sidebar host pickers - // reflect manual refreshes, not just the settings pane. - useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { - status: runtimeStatus, - checkedAt: Date.now() - }) - if (!mountedRef.current) { - return - } - setDetailsByEnvironmentId((current) => ({ - ...current, - [environment.id]: { - status: 'ready', - runtimeStatus, - compatibility: evaluateHostDetails(runtimeStatus), - error: null - } - })) - } catch (error) { - // Why: record the failed probe (null status) so the sidebar can - // distinguish unreachable from never-checked. - useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { - status: null, - checkedAt: Date.now() - }) - if (!mountedRef.current) { - return - } - setDetailsByEnvironmentId((current) => ({ - ...current, - [environment.id]: { - status: 'error', - runtimeStatus: null, - compatibility: null, - error: error instanceof Error ? error.message : String(error) - } - })) - } - }) - ) - } catch (error) { - if (mountedRef.current) { - toast.error( - error instanceof Error - ? error.message - : translate( - 'auto.components.settings.RuntimeEnvironmentsPane.e6410d72c3', - 'Failed to load runtime environments.' - ) - ) - } - } finally { - if (mountedRef.current) { - setIsLoading(false) - } - } - }, - [mountedRef] - ) - - useEffect(() => { - void loadEnvironments() - }, [loadEnvironments]) + const getEnvironmentLabel = (value: string): string => { + if (value === LOCAL_RUNTIME_VALUE) { + return 'Local desktop' + } + if (value === NO_RUNTIME_VALUE) { + return 'No server connected' + } + return environments.find((environment) => environment.id === value)?.name ?? 'remote server' + } + const { + connectingId, + switchingValue, + disconnectingId, + switchError, + setSwitchError, + connectEnvironment, + disconnectEnvironment, + switchToValue + } = useRuntimeEnvironmentConnectionActions({ + allowLocalRuntime, + mountedRef, + setDetailsByEnvironmentId, + setActiveRuntimeEnvironmentPreference, + getEnvironmentLabel + }) + const { + isSaving, + removingId, + removeError, + setRemoveError, + name, + setName, + pairingCode, + setPairingCode, + addServerFailure, + setAddServerFailure, + closeAddServerForm, + addEnvironment, + removeEnvironment + } = useRuntimeEnvironmentMutationActions({ + environments, + settings, + allowLocalRuntime, + mountedRef, + setAddServerFormOpen, + loadEnvironments, + connectEnvironment + }) const environmentIdsKey = environments.map((environment) => environment.id).join('\n') useEffect(() => { @@ -443,327 +144,50 @@ export function RuntimeEnvironmentsPane({ setAddServerFormOpen(true) }, [addServerIntentSignal]) - const closeAddServerForm = (): void => { - if (isSaving) { - return - } - setAddServerFormOpen(false) - setName('') - setPairingCode('') - setAddServerFailure(null) - } - - const addEnvironment = async (allowLoopback: boolean): Promise => { - const trimmedName = name.trim() - const trimmedPairingCode = pairingCode.trim() - if (!trimmedName || !trimmedPairingCode) { - toast.error( - translate( - 'auto.components.settings.RuntimeEnvironmentsPane.0c55a47480', - 'Name and pairing code are required.' - ) - ) - return - } - const duplicate = environments.find( - (environment) => environment.name.trim().toLowerCase() === trimmedName.toLowerCase() - ) - if (duplicate) { - toast.error( - translate( - 'auto.components.settings.RuntimeEnvironmentsPane.5ef712f407', - 'A server named "{{value0}}" already exists.', - { value0: duplicate.name } - ) - ) - return - } - setAddServerFailure(null) - setIsSaving(true) - try { - const result = await window.api.runtimeEnvironments.verifyAndAddFromPairingCode({ - name: trimmedName, - pairingCode: trimmedPairingCode, - allowLoopback - }) - if (!result.ok) { - if (mountedRef.current) { - setAddServerFailure({ kind: result.kind, message: result.message }) - } - return - } - if (mountedRef.current) { - setName('') - setPairingCode('') - } - await loadEnvironments({ - environmentId: result.environment.id, - runtimeStatus: result.runtimeStatus - }) - if (!allowLocalRuntime) { - const connected = await connectEnvironment(result.environment) - if (!connected) { - await window.api.runtimeEnvironments.remove({ selector: result.environment.id }) - await loadEnvironments() - return - } - } else { - if (mountedRef.current) { - toast.success( - translate( - 'auto.components.settings.RuntimeEnvironmentsPane.7b5986c8df', - 'Connected to {{value0}}. Use Advanced > Active Server to make it the default.', - { value0: result.environment.name } - ) - ) - } - } - if (mountedRef.current) { - setAddServerFormOpen(false) - } - } catch (error) { - if (mountedRef.current) { - toast.error( - error instanceof Error - ? error.message - : translate( - 'auto.components.settings.RuntimeEnvironmentsPane.6cb6eae14f', - 'Failed to save runtime environment.' - ) - ) - } - } finally { - if (mountedRef.current) { - setIsSaving(false) - } - } - } - - const removeEnvironment = async ( - environment: PublicKnownRuntimeEnvironment - ): Promise => { - setRemovingId(environment.id) - setRemoveError(null) - try { - if (isRuntimeEnvironmentRemovalBlocked(settings.activeRuntimeEnvironmentId, environment.id)) { - if (mountedRef.current) { - setRemoveError( - translate( - 'auto.components.settings.RuntimeEnvironmentsPane.removeActiveServerBlocked', - 'Choose another Active Server in Advanced before removing this server.' - ) - ) - } - return false - } - await window.api.runtimeEnvironments.remove({ selector: environment.id }) - await loadEnvironments() - if (mountedRef.current) { - toast.success( - translate( - 'auto.components.settings.RuntimeEnvironmentsPane.b5b5114cb0', - 'Removed {{value0}}.', - { value0: environment.name } - ) - ) - } - return true - } catch (error) { - const message = - error instanceof Error ? error.message : 'Failed to remove runtime environment.' - if (mountedRef.current) { - setRemoveError(message) - toast.error(message) - } - return false - } finally { - if (mountedRef.current) { - setRemovingId(null) - } - } - } - - const disconnectEnvironment = async ( - environment: PublicKnownRuntimeEnvironment - ): Promise => { - setDisconnectingId(environment.id) - setSwitchError(null) - try { - await window.api.runtimeEnvironments.disconnect({ selector: environment.id }) - // Why: disconnect is non-destructive; keep the saved server but show the - // user that this live client is no longer attached to it. - useAppStore.getState().setRuntimeEnvironmentStatus( - environment.id, - { - status: null, - checkedAt: Date.now() - }, - { suppressDisconnectToast: true } - ) - if (mountedRef.current) { - setDetailsByEnvironmentId((current) => ({ - ...current, - [environment.id]: { - status: 'error', - runtimeStatus: null, - compatibility: null, - error: null - } - })) - toast.success( - translate( - 'auto.components.settings.RuntimeEnvironmentsPane.disconnectedServer', - 'Disconnected from {{value0}}.', - { value0: environment.name } - ) - ) - } - return true - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to disconnect server.' - if (mountedRef.current) { - setSwitchError(message) - toast.error(message) - } - return false - } finally { - if (mountedRef.current) { - setDisconnectingId(null) - } - } - } - - const connectEnvironment = async ( - environment: PublicKnownRuntimeEnvironment - ): Promise => { - setConnectingId(environment.id) - setSwitchError(null) - try { - const response = await window.api.runtimeEnvironments.connect({ - selector: environment.id, - timeoutMs: 15_000 - }) - const runtimeStatus = unwrapRuntimeRpcResult(response) - const compatibility = evaluateHostDetails(runtimeStatus) - // Why: row Connect is reachability only. The Advanced selector is the - // explicit default-host control and should be the only active-server path. - useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { - status: runtimeStatus, - checkedAt: Date.now() - }) - if (mountedRef.current) { - setDetailsByEnvironmentId((current) => ({ - ...current, - [environment.id]: { - status: 'ready', - runtimeStatus, - compatibility, - error: null - } - })) - } - if (compatibility.kind === 'blocked') { - const message = describeRuntimeCompatBlock(compatibility) - if (mountedRef.current) { - setSwitchError(message) - toast.error(message) - } - return false - } - const store = useAppStore.getState() - // Why: Connect is not the Active Server selector anymore, but connected - // hosts should still contribute their projects/workspaces to the sidebar. - const repos = await store.fetchRuntimeEnvironmentRepos(environment.id) - await Promise.all(repos.map((repo) => useAppStore.getState().fetchWorktrees(repo.id))) - await useAppStore.getState().fetchWorktreeLineage() - if (mountedRef.current) { - toast.success( - translate( - 'auto.components.settings.RuntimeEnvironmentsPane.runtimeReachable', - '{{value0}} is reachable.', - { value0: environment.name } - ) - ) - } - return true - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to connect server.' - useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { - status: null, - checkedAt: Date.now() - }) - if (mountedRef.current) { - setDetailsByEnvironmentId((current) => ({ - ...current, - [environment.id]: { - status: 'error', - runtimeStatus: null, - compatibility: null, - error: message - } - })) - setSwitchError(message) - toast.error(message) - } - return false - } finally { - if (mountedRef.current) { - setConnectingId(null) - } - } - } - - const switchToValue = async (value: string): Promise => { - if (value === NO_RUNTIME_VALUE) { - return false - } - setSwitchingValue(value) - setSwitchError(null) - try { - const switched = await setActiveRuntimeEnvironmentPreference( - allowLocalRuntime && value === LOCAL_RUNTIME_VALUE ? null : value - ) - if (switched) { - if (mountedRef.current) { - toast.success( - translate( - 'auto.components.settings.RuntimeEnvironmentsPane.99ac81fb43', - 'Switched to {{value0}}.', - { value0: getEnvironmentLabel(value) } - ) - ) - } - return true - } - if (mountedRef.current) { - setSwitchError('Could not switch servers. Fix the issue and try again.') - } - return false - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to switch servers.' - if (mountedRef.current) { - setSwitchError(message) - toast.error(message) - } - return false - } finally { - if (mountedRef.current) { - setSwitchingValue(null) - } - } - } - - const getEnvironmentLabel = (value: string): string => { - if (value === LOCAL_RUNTIME_VALUE) { - return 'Local desktop' - } - if (value === NO_RUNTIME_VALUE) { - return 'No server connected' - } - return environments.find((environment) => environment.id === value)?.name ?? 'remote server' - } + const activeValue = + settings.activeRuntimeEnvironmentId ?? + (allowLocalRuntime ? LOCAL_RUNTIME_VALUE : NO_RUNTIME_VALUE) + const isBusy = + isSaving || + connectingId !== null || + switchingValue !== null || + removingId !== null || + disconnectingId !== null + const removingActiveServer = pendingRemove + ? isRuntimeEnvironmentRemovalBlocked(settings.activeRuntimeEnvironmentId, pendingRemove.id) + : false + const searchEntry = canGeneratePairingUrl + ? getRuntimeEnvironmentsSearchEntry() + : getWebRuntimeEnvironmentsSearchEntry() const visibleWorkflow: RemoteServerWorkflow = addServerFormOpen ? 'connect' : workflow + const openRemoveDialog = (environment: PublicKnownRuntimeEnvironment): void => { + setRemoveError(null) + setPendingRemove(environment) + } + const confirmSwitch = (): void => { + const value = pendingSwitchValue + if (!value) { + return + } + void switchToValue(value).then((switched) => { + if (switched && mountedRef.current) { + setPendingSwitchValue(null) + } + }) + } + const confirmRemove = (): void => { + const environment = pendingRemove + if (!environment) { + return + } + void removeEnvironment(environment).then((removed) => { + if (removed && mountedRef.current) { + setPendingRemove(null) + } + }) + } + return ( -
- {( - [ - [ - 'connect', - translate( - 'auto.components.settings.RuntimeEnvironmentsPane.connectWorkflow', - 'Connect to a host' - ), - translate( - 'auto.components.settings.RuntimeEnvironmentsPane.connectWorkflowHelp', - 'This app joins another machine' - ) - ], - [ - 'share', - translate( - 'auto.components.settings.RuntimeEnvironmentsPane.shareWorkflow', - 'Share this host' - ), - translate( - 'auto.components.settings.RuntimeEnvironmentsPane.shareWorkflowHelp', - 'Other devices join this machine' - ) - ], - [ - 'cloud-vm', - translate( - 'auto.components.settings.RuntimeEnvironmentsPane.cloudVmWorkflow', - 'Cloud VM' - ), - translate( - 'auto.components.settings.RuntimeEnvironmentsPane.cloudVmWorkflowHelp', - 'Manage recipe-created cloud machines' - ) - ] - ] as const - ) - .filter(([value]) => value !== 'share' || canGeneratePairingUrl) - .map(([value, label, description]) => ( - - ))} -
+ -
-
-
-
- {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.connectToRemoteServers', - 'Connect to remote servers' - )} -
-

- {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.connectToRemoteServersHelp', - 'Pair another Orca runtime, then connect or disconnect it here.' - )} -

-
-
- {environments.length > 0 ? ( - - ) : null} - {addServerFormOpen ? null : ( - - )} -
-
- - {addServerFormOpen ? ( - { - setPairingCode(value) - setAddServerFailure(null) - }} - onCancel={closeAddServerForm} - onSubmit={(allowLoopback) => void addEnvironment(allowLoopback)} - /> - ) : null} - -
- {environments.length === 0 ? ( -
- {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.9a3758d983', - 'No saved servers.' - )} -
- ) : ( -
- {environments.map((environment) => ( -
- {(() => { - const details = detailsByEnvironmentId[environment.id] - const detailsDescription = getHostDetailsDescription(details) - const isActive = settings.activeRuntimeEnvironmentId === environment.id - const connectionState = getRuntimeServerConnectionState(details) - const remoteUpdate = remoteServerUpdates.get(environment.id) - // A connected host exposes Disconnect; otherwise Connect. - const isReachable = connectionState === 'connected' - const actionBusy = - connectingId === environment.id || - switchingValue === environment.id || - disconnectingId === environment.id || - removingId === environment.id - return ( - <> - -
-
-
{environment.name}
- - - {getRuntimeServerConnectionLabel(connectionState)} - - {details?.compatibility?.kind === 'blocked' ? ( - - ) : details?.status === 'loading' ? ( - - ) : null} -
-

- {environment.connectionDependency === 'ssh-tunnel' - ? translate( - 'auto.components.settings.RuntimeEnvironmentsPane.sshTunnelRequired', - 'SSH tunnel required' - ) - : isActive - ? translate( - 'auto.components.settings.RuntimeEnvironmentsPane.activeServerRowHelp', - 'Active server for server-routed projects, terminals, and provider checks.' - ) - : getHostDetailsSummary(details)} -

- {detailsDescription ? ( -

- {detailsDescription} -

- ) : null} - {remoteUpdate ? ( -
- - {remoteUpdate.currentVersion - ? translate( - 'auto.components.settings.RuntimeEnvironmentsPane.orcaVersion', - 'Orca v{{value0}}', - { value0: remoteUpdate.currentVersion } - ) - : translate( - 'auto.components.settings.RuntimeEnvironmentsPane.versionUnavailable', - 'Orca version unavailable' - )} - - -
- ) : null} - {remoteUpdate?.phase === 'manual' ? ( -

- {getRemoteServerManualUpdateHelp(remoteUpdate)} -

- ) : null} - {remoteUpdate?.phase === 'failed' && remoteUpdate.error ? ( -

{remoteUpdate.error}

- ) : null} -
-
- {remoteUpdate?.phase === 'available' || - remoteUpdate?.phase === 'failed' ? ( - - ) : null} - {isReachable ? ( - - ) : ( - - )} - -
- - ) - })()} -
- ))} -
- )} -
-
+ setAddServerFormOpen(true)} + onCloseAddServerForm={closeAddServerForm} + onNameChange={setName} + onPairingCodeChange={(value) => { + setPairingCode(value) + setAddServerFailure(null) + }} + onAddEnvironment={(allowLoopback) => void addEnvironment(allowLoopback)} + onOpenUpdateDialog={() => setRemoteServerUpdateDialogOpen(true)} + refreshRemoteServerUpdates={refreshRemoteServerUpdates} + onConnect={(environment) => void connectEnvironment(environment)} + onDisconnect={(environment) => void disconnectEnvironment(environment)} + onRemove={openRemoveDialog} + />
-
- - -
-
-
-
- -

- {getActiveServerModeDescription(allowLocalRuntime)} -

-
-
- - -
- {environments.length > 0 ? ( -
-
- {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.serverDetails', - 'Server details' - )} -
-
- {environments.map((environment) => { - const details = detailsByEnvironmentId[environment.id] - return ( -
-
- {environment.name} -
-
-
- {environment.endpoints[0]?.endpoint ?? - translate( - 'auto.components.settings.RuntimeEnvironmentsPane.6ef71985da', - 'No endpoint' - )} -
- {details?.runtimeStatus ? ( -
- {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.0ef838094a', - 'Protocol {{value0}}', - { - value0: - details.runtimeStatus?.runtimeProtocolVersion ?? - details.runtimeStatus?.protocolVersion ?? - 0 - } - )} - {details.runtimeStatus.hostPlatform - ? ` · ${details.runtimeStatus.hostPlatform}` - : ''} - {' · '} - {getRuntimeCapabilitiesSummary(details.runtimeStatus)} -
- ) : null} - {getHostModelCapabilitySummary(details?.runtimeStatus) ? ( -
- {getHostModelCapabilitySummary(details?.runtimeStatus)} -
- ) : null} -
-
- ) - })} -
-
- ) : null} -
-
-
-
+ setAdvancedOpen((current) => !current)} + onValueChange={(value) => { + setSwitchError(null) + setPendingSwitchValue(value) + }} + onRefresh={() => void loadEnvironments()} + /> {visibleWorkflow === 'share' && canGeneratePairingUrl ? ( -
-
-
- {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.advertiseThisApp', - 'Advertise this app as a server' - )} -
-

- {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.advertiseThisAppHelp', - 'Create access links for browsers, mobile clients, or another Orca client to connect back to this running app.' - )} -

-
-
-
-
-
- {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.6e1280ca55', - 'Share this Orca server' - )} -
-

- {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.84b9b2be05', - 'Create a revocable access grant so a browser or another Orca client can connect.' - )} -

-
- -
-
- -
-
-
+ setShareServerFormOpen((open) => !open)} + /> ) : null} - {visibleWorkflow === 'connect' ? ( -
- - {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.troubleshootWorkflow', - 'Connection troubleshooting' - )} - - -
-
-
- {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.troubleshootTitle', - 'Create a new link on the other host' - )} -
-

- {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.troubleshootDescription', - 'A link that uses 127.0.0.1 points back to the device opening it, not the computer that created it.' - )} -

-
-
    -
  1. - {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.troubleshootStepShare', - 'On the other computer, open Share this host.' - )} -
  2. -
  3. - {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.troubleshootStepAddress', - 'Choose Another device and select its Tailscale or LAN address.' - )} -
  4. -
  5. - {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.troubleshootStepRegenerate', - 'Generate a new access link and use only the newest link here.' - )} -
  6. -
-
- {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.troubleshootTunnel', - 'Using an SSH local forward? Return to Connect to a host, paste the loopback link, then enable “I am using an SSH tunnel” under Advanced.' - )} -
-
-
- ) : null} + {visibleWorkflow === 'connect' ? : null} - { if (!open && switchingValue === null) { setSwitchError(null) setPendingSwitchValue(null) } }} - > - - - - {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.d570c35a99', - 'Switch Server' - )} - - - {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.b2290ed203', - 'Orca will focus this host and load its projects. Existing terminals and browser tabs on other hosts stay alive.' - )} - - - {pendingSwitchValue ? ( -
-
- {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.05e0fc3ebf', - 'Switch to' - )} -
-
- {getEnvironmentLabel(pendingSwitchValue)} -
-
- ) : null} - {switchError ?

{switchError}

: null} - - - - -
-
+ onCancel={() => { + setSwitchError(null) + setPendingSwitchValue(null) + }} + onConfirm={confirmSwitch} + /> - { if (!open && removingId === null) { setRemoveError(null) setPendingRemove(null) } }} - > - - - - {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.bb90dd6487', - 'Remove Server' - )} - - - {removingActiveServer - ? translate( - 'auto.components.settings.RuntimeEnvironmentsPane.removeActiveServerDescription', - 'Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.' - ) - : translate( - 'auto.components.settings.RuntimeEnvironmentsPane.ed3e3f069d', - 'This removes the saved server from Orca. It does not change the active server.' - )} - - - {pendingRemove ? ( -
-
{pendingRemove.name}
-
- {pendingRemove.endpoints[0]?.endpoint ?? - translate( - 'auto.components.settings.RuntimeEnvironmentsPane.6ef71985da', - 'No endpoint' - )} -
-
- ) : null} - {removeError ?

{removeError}

: null} - - - - -
-
+ onCancel={() => { + setRemoveError(null) + setPendingRemove(null) + }} + onConfirm={confirmRemove} + />
) } diff --git a/src/renderer/src/components/settings/runtime-active-server-section.tsx b/src/renderer/src/components/settings/runtime-active-server-section.tsx new file mode 100644 index 00000000000..0a7c4265999 --- /dev/null +++ b/src/renderer/src/components/settings/runtime-active-server-section.tsx @@ -0,0 +1,211 @@ +import { ChevronDown, Loader2, RefreshCw } from 'lucide-react' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { Button } from '../ui/button' +import { Label } from '../ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { + getActiveServerModeDescription, + getHostModelCapabilitySummary, + getRuntimeCapabilitiesSummary, + type RuntimeHostDetails +} from './runtime-environment-host-details' + +type RuntimeActiveServerSectionProps = { + visible: boolean + advancedOpen: boolean + allowLocalRuntime: boolean + localRuntimeValue: string + noRuntimeValue: string + activeValue: string + environments: PublicKnownRuntimeEnvironment[] + detailsByEnvironmentId: Record + isBusy: boolean + isLoading: boolean + onToggleAdvanced: () => void + onValueChange: (value: string) => void + onRefresh: () => void +} + +export function RuntimeActiveServerSection({ + visible, + advancedOpen, + allowLocalRuntime, + localRuntimeValue, + noRuntimeValue, + activeValue, + environments, + detailsByEnvironmentId, + isBusy, + isLoading, + onToggleAdvanced, + onValueChange, + onRefresh +}: RuntimeActiveServerSectionProps): React.JSX.Element { + return ( +
+ + +
+
+
+
+ +

+ {getActiveServerModeDescription(allowLocalRuntime)} +

+
+
+ + +
+ {environments.length > 0 ? ( +
+
+ {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.serverDetails', + 'Server details' + )} +
+
+ {environments.map((environment) => { + const details = detailsByEnvironmentId[environment.id] + return ( +
+
+ {environment.name} +
+
+
+ {environment.endpoints[0]?.endpoint ?? + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.6ef71985da', + 'No endpoint' + )} +
+ {details?.runtimeStatus ? ( +
+ {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.0ef838094a', + 'Protocol {{value0}}', + { + value0: + details.runtimeStatus?.runtimeProtocolVersion ?? + details.runtimeStatus?.protocolVersion ?? + 0 + } + )} + {details.runtimeStatus.hostPlatform + ? ` · ${details.runtimeStatus.hostPlatform}` + : ''} + {' · '} + {getRuntimeCapabilitiesSummary(details.runtimeStatus)} +
+ ) : null} + {getHostModelCapabilitySummary(details?.runtimeStatus) ? ( +
+ {getHostModelCapabilitySummary(details?.runtimeStatus)} +
+ ) : null} +
+
+ ) + })} +
+
+ ) : null} +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/runtime-environment-dialogs.tsx b/src/renderer/src/components/settings/runtime-environment-dialogs.tsx new file mode 100644 index 00000000000..990f9d5d713 --- /dev/null +++ b/src/renderer/src/components/settings/runtime-environment-dialogs.tsx @@ -0,0 +1,140 @@ +import { Loader2, Trash2 } from 'lucide-react' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import { translate } from '@/i18n/i18n' +import { Button } from '../ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '../ui/dialog' + +export function RuntimeEnvironmentSwitchDialog({ + pendingSwitchValue, + switchingValue, + switchError, + getEnvironmentLabel, + onOpenChange, + onCancel, + onConfirm +}: { + pendingSwitchValue: string | null + switchingValue: string | null + switchError: string | null + getEnvironmentLabel: (value: string) => string + onOpenChange: (open: boolean) => void + onCancel: () => void + onConfirm: () => void +}): React.JSX.Element { + return ( + + + + + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.d570c35a99', + 'Switch Server' + )} + + + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.b2290ed203', + 'Orca will focus this host and load its projects. Existing terminals and browser tabs on other hosts stay alive.' + )} + + + {pendingSwitchValue ? ( +
+
+ {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.05e0fc3ebf', + 'Switch to' + )} +
+
+ {getEnvironmentLabel(pendingSwitchValue)} +
+
+ ) : null} + {switchError ?

{switchError}

: null} + + + + +
+
+ ) +} + +export function RuntimeEnvironmentRemoveDialog({ + pendingRemove, + removingId, + removeError, + removingActiveServer, + onOpenChange, + onCancel, + onConfirm +}: { + pendingRemove: PublicKnownRuntimeEnvironment | null + removingId: string | null + removeError: string | null + removingActiveServer: boolean + onOpenChange: (open: boolean) => void + onCancel: () => void + onConfirm: () => void +}): React.JSX.Element { + return ( + + + + + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.bb90dd6487', + 'Remove Server' + )} + + + {removingActiveServer + ? translate( + 'auto.components.settings.RuntimeEnvironmentsPane.removeActiveServerDescription', + 'Choose another Active Server in Advanced before removing this server. Existing host sessions are left alone.' + ) + : translate( + 'auto.components.settings.RuntimeEnvironmentsPane.ed3e3f069d', + 'This removes the saved server from Orca. It does not change the active server.' + )} + + + {pendingRemove ? ( +
+
{pendingRemove.name}
+
+ {pendingRemove.endpoints[0]?.endpoint ?? + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.6ef71985da', + 'No endpoint' + )} +
+
+ ) : null} + {removeError ?

{removeError}

: null} + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/settings/runtime-environment-host-details.ts b/src/renderer/src/components/settings/runtime-environment-host-details.ts new file mode 100644 index 00000000000..03030733e97 --- /dev/null +++ b/src/renderer/src/components/settings/runtime-environment-host-details.ts @@ -0,0 +1,197 @@ +import { translate } from '@/i18n/i18n' +import { + describeRuntimeCompatBlock, + evaluateRuntimeCompat, + type RuntimeCompatVerdict +} from '../../../../shared/protocol-compat' +import { + MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + RUNTIME_PROTOCOL_VERSION, + TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import type { RuntimeStatus } from '../../../../shared/runtime-types' + +export type RuntimeHostDetails = { + status: 'loading' | 'ready' | 'error' + runtimeStatus: RuntimeStatus | null + compatibility: RuntimeCompatVerdict | null + error: string | null +} + +export function evaluateHostDetails(status: RuntimeStatus): RuntimeCompatVerdict { + return evaluateRuntimeCompat({ + clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + serverProtocolVersion: status.runtimeProtocolVersion ?? status.protocolVersion, + serverMinCompatibleClientProtocolVersion: + status.minCompatibleRuntimeClientVersion ?? status.minCompatibleMobileVersion + }) +} + +export function getHostDetailsSummary(details: RuntimeHostDetails | undefined): string { + if (!details || details.status === 'loading') { + return translate('auto.components.settings.RuntimeEnvironmentsPane.5120beaac6', 'Checking…') + } + if (details.status === 'error') { + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.c8791efc45', + 'Status unavailable' + ) + } + if (details.compatibility?.kind === 'blocked') { + return details.compatibility.reason === 'client-too-old' + ? translate('auto.components.settings.RuntimeEnvironmentsPane.62ac182a27', 'Update client') + : translate('auto.components.settings.RuntimeEnvironmentsPane.86ed75bec8', 'Update server') + } + return translate('auto.components.settings.RuntimeEnvironmentsPane.9a91c4a0eb', 'Compatible') +} + +export function getHostDetailsDescription(details: RuntimeHostDetails | undefined): string | null { + if (!details || details.status === 'loading') { + return null + } + if (details.status === 'error') { + return details.error + } + if (details.compatibility?.kind === 'blocked') { + return describeRuntimeCompatBlock(details.compatibility) + } + return null +} + +export function getRuntimeCapabilitiesSummary(status: RuntimeStatus | null | undefined): string { + const capabilities = status?.capabilities ?? [] + if (capabilities.length === 0) { + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.4b5c6d7e8f', + 'No capabilities reported' + ) + } + const visibleCapabilities = capabilities.slice(0, 3).join(', ') + const hiddenCount = capabilities.length - 3 + return hiddenCount > 0 ? `${visibleCapabilities} +${hiddenCount}` : visibleCapabilities +} + +export function getHostModelCapabilitySummary( + status: RuntimeStatus | null | undefined +): string | null { + if (!status) { + return null + } + const capabilities = status.capabilities + if (!capabilities) { + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilityUnknown', + 'Host model support: checking server capabilities' + ) + } + const missing = [ + PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, + TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, + WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY + ].filter((capability) => !capabilities.includes(capability)) + if (missing.length === 0) { + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilitySupported', + 'Host model support: ready' + ) + } + const missingLabels = missing.map(getHostModelCapabilityLabel) + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilityMissing', + 'Host model support: update server for {{value0}}', + { value0: missingLabels.join(', ') } + ) +} + +function getHostModelCapabilityLabel(capability: string): string { + switch (capability) { + case PROJECT_HOST_SETUP_RUNTIME_CAPABILITY: + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilityProjectSetup', + 'project setup' + ) + case TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY: + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilityTaskSourceContext', + 'task source context' + ) + case WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY: + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.hostModelCapabilityWorkspaceRunContext', + 'workspace run context' + ) + default: + return capability + } +} + +export function getActiveServerModeDescription(allowLocalRuntime: boolean): string { + return allowLocalRuntime + ? translate( + 'auto.components.settings.RuntimeEnvironmentsPane.3f67e8078a', + 'Use this computer by default. Choose a saved server only when you want supported projects, files, terminals, provider checks, and browser/mobile handoff to run through that server.' + ) + : translate( + 'auto.components.settings.RuntimeEnvironmentsPane.2c85efb3e8', + 'Selecting a saved server makes this browser use that paired Orca runtime as its default Host.' + ) +} + +export function isRuntimeEnvironmentRemovalBlocked( + activeRuntimeEnvironmentId: string | null | undefined, + environmentId: string +): boolean { + return activeRuntimeEnvironmentId === environmentId +} + +export type RuntimeServerConnectionState = 'connected' | 'checking' | 'disconnected' + +export function getRuntimeServerConnectionState( + details: RuntimeHostDetails | undefined +): RuntimeServerConnectionState { + if (!details || details.status === 'loading') { + return 'checking' + } + if (details.status !== 'ready' || details.compatibility?.kind === 'blocked') { + return 'disconnected' + } + // Why: an attached, reachable, compatible host is "Connected" (and exposes + // Disconnect). Whether it is the default *active* server is a separate concept, + // surfaced by the Advanced > Active Server selector and the row's help text — + // it must not change this connection label, or the dot/label/button disagree. + return 'connected' +} + +export function getRuntimeServerConnectionLabel(state: RuntimeServerConnectionState): string { + switch (state) { + case 'connected': + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.serverConnected', + 'Connected' + ) + case 'checking': + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.serverChecking', + 'Checking…' + ) + case 'disconnected': + return translate( + 'auto.components.settings.RuntimeEnvironmentsPane.serverDisconnected', + 'Disconnected' + ) + } +} + +export function getRuntimeServerDotClass(state: RuntimeServerConnectionState): string { + switch (state) { + case 'connected': + return 'bg-emerald-500' + case 'checking': + return 'bg-yellow-500' + case 'disconnected': + return 'bg-muted-foreground/40' + } +} diff --git a/src/renderer/src/components/settings/runtime-environment-selection.ts b/src/renderer/src/components/settings/runtime-environment-selection.ts new file mode 100644 index 00000000000..19fb57c096a --- /dev/null +++ b/src/renderer/src/components/settings/runtime-environment-selection.ts @@ -0,0 +1,2 @@ +export const LOCAL_RUNTIME_VALUE = '__local__' +export const NO_RUNTIME_VALUE = '__none__' diff --git a/src/renderer/src/components/settings/runtime-server-row.tsx b/src/renderer/src/components/settings/runtime-server-row.tsx new file mode 100644 index 00000000000..965e99d1ffb --- /dev/null +++ b/src/renderer/src/components/settings/runtime-server-row.tsx @@ -0,0 +1,194 @@ +import { AlertTriangle, Loader2, Server, ServerOff, Trash2 } from 'lucide-react' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import type { RemoteServerUpdateEntry } from '@/runtime/remote-server-update-coordinator' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { Button } from '../ui/button' +import { + getHostDetailsDescription, + getHostDetailsSummary, + getRuntimeServerConnectionLabel, + getRuntimeServerConnectionState, + getRuntimeServerDotClass, + type RuntimeHostDetails +} from './runtime-environment-host-details' +import { + getRemoteServerManualUpdateHelp, + RemoteServerUpdateStatus +} from './RemoteServerUpdateStatus' + +type RuntimeServerRowProps = { + environment: PublicKnownRuntimeEnvironment + details: RuntimeHostDetails | undefined + isActive: boolean + remoteUpdate: RemoteServerUpdateEntry | undefined + remoteServerUpdatesRunning: boolean + connecting: boolean + switching: boolean + disconnecting: boolean + removing: boolean + isBusy: boolean + onOpenUpdate: () => void + onDisconnect: (environment: PublicKnownRuntimeEnvironment) => void + onConnect: (environment: PublicKnownRuntimeEnvironment) => void + onRemove: (environment: PublicKnownRuntimeEnvironment) => void +} + +export function RuntimeServerRow({ + environment, + details, + isActive, + remoteUpdate, + remoteServerUpdatesRunning, + connecting, + switching, + disconnecting, + removing, + isBusy, + onOpenUpdate, + onDisconnect, + onConnect, + onRemove +}: RuntimeServerRowProps): React.JSX.Element { + const detailsDescription = getHostDetailsDescription(details) + const connectionState = getRuntimeServerConnectionState(details) + // A connected host exposes Disconnect; otherwise Connect. + const isReachable = connectionState === 'connected' + const actionBusy = connecting || switching || disconnecting || removing + + return ( +
+ +
+
+
{environment.name}
+ + + {getRuntimeServerConnectionLabel(connectionState)} + + {details?.compatibility?.kind === 'blocked' ? ( + + ) : details?.status === 'loading' ? ( + + ) : null} +
+

+ {environment.connectionDependency === 'ssh-tunnel' + ? translate( + 'auto.components.settings.RuntimeEnvironmentsPane.sshTunnelRequired', + 'SSH tunnel required' + ) + : isActive + ? translate( + 'auto.components.settings.RuntimeEnvironmentsPane.activeServerRowHelp', + 'Active server for server-routed projects, terminals, and provider checks.' + ) + : getHostDetailsSummary(details)} +

+ {detailsDescription ? ( +

+ {detailsDescription} +

+ ) : null} + {remoteUpdate ? ( +
+ + {remoteUpdate.currentVersion + ? translate( + 'auto.components.settings.RuntimeEnvironmentsPane.orcaVersion', + 'Orca v{{value0}}', + { value0: remoteUpdate.currentVersion } + ) + : translate( + 'auto.components.settings.RuntimeEnvironmentsPane.versionUnavailable', + 'Orca version unavailable' + )} + + +
+ ) : null} + {remoteUpdate?.phase === 'manual' ? ( +

+ {getRemoteServerManualUpdateHelp(remoteUpdate)} +

+ ) : null} + {remoteUpdate?.phase === 'failed' && remoteUpdate.error ? ( +

{remoteUpdate.error}

+ ) : null} +
+
+ {remoteUpdate?.phase === 'available' || remoteUpdate?.phase === 'failed' ? ( + + ) : null} + {isReachable ? ( + + ) : ( + + )} + +
+
+ ) +} diff --git a/src/renderer/src/components/settings/runtime-server-workflow-sections.tsx b/src/renderer/src/components/settings/runtime-server-workflow-sections.tsx new file mode 100644 index 00000000000..43211977c6d --- /dev/null +++ b/src/renderer/src/components/settings/runtime-server-workflow-sections.tsx @@ -0,0 +1,224 @@ +import { ChevronDown, Share2 } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { Button } from '../ui/button' +import { RuntimePairingUrlGenerator } from './RuntimePairingUrlGenerator' + +export type RemoteServerWorkflow = 'connect' | 'cloud-vm' | 'share' + +export function RuntimeServerWorkflowPicker({ + canGeneratePairingUrl, + visibleWorkflow, + onCloseAddServerForm, + onWorkflowChange +}: { + canGeneratePairingUrl: boolean + visibleWorkflow: RemoteServerWorkflow + onCloseAddServerForm: () => void + onWorkflowChange: (workflow: RemoteServerWorkflow) => void +}): React.JSX.Element { + return ( +
+ {( + [ + [ + 'connect', + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.connectWorkflow', + 'Connect to a host' + ), + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.connectWorkflowHelp', + 'This app joins another machine' + ) + ], + [ + 'share', + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.shareWorkflow', + 'Share this host' + ), + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.shareWorkflowHelp', + 'Other devices join this machine' + ) + ], + [ + 'cloud-vm', + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.cloudVmWorkflow', + 'Cloud VM' + ), + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.cloudVmWorkflowHelp', + 'Manage recipe-created cloud machines' + ) + ] + ] as const + ) + .filter(([value]) => value !== 'share' || canGeneratePairingUrl) + .map(([value, label, description]) => ( + + ))} +
+ ) +} + +export function RuntimeServerShareSection({ + shareServerFormOpen, + onToggleShareServerForm +}: { + shareServerFormOpen: boolean + onToggleShareServerForm: () => void +}): React.JSX.Element { + return ( +
+
+
+ {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.advertiseThisApp', + 'Advertise this app as a server' + )} +
+

+ {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.advertiseThisAppHelp', + 'Create access links for browsers, mobile clients, or another Orca client to connect back to this running app.' + )} +

+
+
+
+
+
+ {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.6e1280ca55', + 'Share this Orca server' + )} +
+

+ {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.84b9b2be05', + 'Create a revocable access grant so a browser or another Orca client can connect.' + )} +

+
+ +
+
+ +
+
+
+ ) +} + +export function RuntimeServerTroubleshooting(): React.JSX.Element { + return ( +
+ + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.troubleshootWorkflow', + 'Connection troubleshooting' + )} + + +
+
+
+ {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.troubleshootTitle', + 'Create a new link on the other host' + )} +
+

+ {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.troubleshootDescription', + 'A link that uses 127.0.0.1 points back to the device opening it, not the computer that created it.' + )} +

+
+
    +
  1. + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.troubleshootStepShare', + 'On the other computer, open Share this host.' + )} +
  2. +
  3. + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.troubleshootStepAddress', + 'Choose Another device and select its Tailscale or LAN address.' + )} +
  4. +
  5. + {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.troubleshootStepRegenerate', + 'Generate a new access link and use only the newest link here.' + )} +
  6. +
+
+ {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.troubleshootTunnel', + 'Using an SSH local forward? Return to Connect to a host, paste the loopback link, then enable “I am using an SSH tunnel” under Advanced.' + )} +
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/runtime-servers-connect-section.tsx b/src/renderer/src/components/settings/runtime-servers-connect-section.tsx new file mode 100644 index 00000000000..90084aa0962 --- /dev/null +++ b/src/renderer/src/components/settings/runtime-servers-connect-section.tsx @@ -0,0 +1,187 @@ +import { Loader2, Plus, RefreshCw } from 'lucide-react' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import type { UpdateCheckOptions } from '../../../../shared/update-status-types' +import type { RemoteServerUpdateEntry } from '@/runtime/remote-server-update-coordinator' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { getUpdateCheckClickOptions, getUpdateCheckHint } from '@/lib/update-check-click-options' +import { Button } from '../ui/button' +import type { RuntimeHostDetails } from './runtime-environment-host-details' +import { RuntimeHostAccessForm, type RuntimeHostAccessFailure } from './RuntimeHostAccessForm' +import { RuntimeServerRow } from './runtime-server-row' + +type RuntimeServersConnectSectionProps = { + visible: boolean + environments: PublicKnownRuntimeEnvironment[] + detailsByEnvironmentId: Record + activeRuntimeEnvironmentId: string | null | undefined + addServerFormOpen: boolean + name: string + pairingCode: string + addServerFailure: RuntimeHostAccessFailure | null + isBusy: boolean + remoteServerUpdates: Map + remoteServerUpdatesChecking: boolean + remoteServerUpdatesRunning: boolean + connectingId: string | null + switchingValue: string | null + disconnectingId: string | null + removingId: string | null + onOpenAddServerForm: () => void + onCloseAddServerForm: () => void + onNameChange: (value: string) => void + onPairingCodeChange: (value: string) => void + onAddEnvironment: (allowLoopback: boolean) => void + onOpenUpdateDialog: () => void + refreshRemoteServerUpdates: (options?: UpdateCheckOptions) => Promise + onConnect: (environment: PublicKnownRuntimeEnvironment) => void + onDisconnect: (environment: PublicKnownRuntimeEnvironment) => void + onRemove: (environment: PublicKnownRuntimeEnvironment) => void +} + +export function RuntimeServersConnectSection({ + visible, + environments, + detailsByEnvironmentId, + activeRuntimeEnvironmentId, + addServerFormOpen, + name, + pairingCode, + addServerFailure, + isBusy, + remoteServerUpdates, + remoteServerUpdatesChecking, + remoteServerUpdatesRunning, + connectingId, + switchingValue, + disconnectingId, + removingId, + onOpenAddServerForm, + onCloseAddServerForm, + onNameChange, + onPairingCodeChange, + onAddEnvironment, + onOpenUpdateDialog, + refreshRemoteServerUpdates, + onConnect, + onDisconnect, + onRemove +}: RuntimeServersConnectSectionProps): React.JSX.Element { + const updateCheckHint = getUpdateCheckHint() + return ( +
+
+
+
+ {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.connectToRemoteServers', + 'Connect to remote servers' + )} +
+

+ {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.connectToRemoteServersHelp', + 'Pair another Orca runtime, then connect or disconnect it here.' + )} +

+
+
+ {environments.length > 0 ? ( + + ) : null} + {addServerFormOpen ? null : ( + + )} +
+
+ + {addServerFormOpen ? ( + + ) : null} + +
+ {environments.length === 0 ? ( +
+ {translate( + 'auto.components.settings.RuntimeEnvironmentsPane.9a3758d983', + 'No saved servers.' + )} +
+ ) : ( +
+ {environments.map((environment) => ( + + ))} +
+ )} +
+
+ ) +} diff --git a/src/renderer/src/components/settings/use-runtime-environment-catalog.ts b/src/renderer/src/components/settings/use-runtime-environment-catalog.ts new file mode 100644 index 00000000000..57b6c25d892 --- /dev/null +++ b/src/renderer/src/components/settings/use-runtime-environment-catalog.ts @@ -0,0 +1,163 @@ +import { + useCallback, + useEffect, + useState, + type Dispatch, + type MutableRefObject, + type SetStateAction +} from 'react' +import { toast } from 'sonner' +import { useMountedRef } from '@/hooks/useMountedRef' +import { translate } from '@/i18n/i18n' +import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client' +import { useAppStore } from '@/store' +import { + isUserManagedRuntimeEnvironment, + type PublicKnownRuntimeEnvironment +} from '../../../../shared/runtime-environments' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import { evaluateHostDetails, type RuntimeHostDetails } from './runtime-environment-host-details' + +type RuntimeEnvironmentCatalog = { + environments: PublicKnownRuntimeEnvironment[] + isLoading: boolean + detailsByEnvironmentId: Record + setDetailsByEnvironmentId: Dispatch>> + mountedRef: MutableRefObject + loadEnvironments: (verified?: { + environmentId: string + runtimeStatus: RuntimeStatus + }) => Promise +} + +export function useRuntimeEnvironmentCatalog(): RuntimeEnvironmentCatalog { + const [environments, setEnvironments] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [detailsByEnvironmentId, setDetailsByEnvironmentId] = useState< + Record + >({}) + const mountedRef = useMountedRef() + + const loadEnvironments = useCallback( + async (verified?: { environmentId: string; runtimeStatus: RuntimeStatus }): Promise => { + if (mountedRef.current) { + setIsLoading(true) + } + try { + const nextEnvironments = await window.api.runtimeEnvironments.list() + const visibleEnvironments = nextEnvironments.filter(isUserManagedRuntimeEnvironment) + // Why: drop store status for servers no longer saved so stale hosts don't + // linger in the sidebar registry. + useAppStore.getState().setRuntimeEnvironments(nextEnvironments) + if (verified) { + useAppStore.getState().setRuntimeEnvironmentStatus(verified.environmentId, { + status: verified.runtimeStatus, + checkedAt: Date.now() + }) + } + if (mountedRef.current) { + setEnvironments(visibleEnvironments) + setDetailsByEnvironmentId((current) => { + const next: Record = {} + for (const environment of visibleEnvironments) { + next[environment.id] = + verified?.environmentId === environment.id + ? { + status: 'ready', + runtimeStatus: verified.runtimeStatus, + compatibility: evaluateHostDetails(verified.runtimeStatus), + error: null + } + : (current[environment.id] ?? { + status: 'loading', + runtimeStatus: null, + compatibility: null, + error: null + }) + } + return next + }) + } + await Promise.allSettled( + visibleEnvironments + .filter((environment) => environment.id !== verified?.environmentId) + .map(async (environment) => { + try { + const response = await window.api.runtimeEnvironments.getStatus({ + selector: environment.id, + timeoutMs: 10_000 + }) + const runtimeStatus = unwrapRuntimeRpcResult(response) + // Why: feed the live status into the store so sidebar host pickers + // reflect manual refreshes, not just the settings pane. + useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { + status: runtimeStatus, + checkedAt: Date.now() + }) + if (!mountedRef.current) { + return + } + setDetailsByEnvironmentId((current) => ({ + ...current, + [environment.id]: { + status: 'ready', + runtimeStatus, + compatibility: evaluateHostDetails(runtimeStatus), + error: null + } + })) + } catch (error) { + // Why: record the failed probe (null status) so the sidebar can + // distinguish unreachable from never-checked. + useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { + status: null, + checkedAt: Date.now() + }) + if (!mountedRef.current) { + return + } + setDetailsByEnvironmentId((current) => ({ + ...current, + [environment.id]: { + status: 'error', + runtimeStatus: null, + compatibility: null, + error: error instanceof Error ? error.message : String(error) + } + })) + } + }) + ) + } catch (error) { + if (mountedRef.current) { + toast.error( + error instanceof Error + ? error.message + : translate( + 'auto.components.settings.RuntimeEnvironmentsPane.e6410d72c3', + 'Failed to load runtime environments.' + ) + ) + } + } finally { + if (mountedRef.current) { + setIsLoading(false) + } + } + }, + [mountedRef] + ) + + useEffect(() => { + void loadEnvironments() + }, [loadEnvironments]) + + return { + environments, + isLoading, + detailsByEnvironmentId, + setDetailsByEnvironmentId, + mountedRef, + loadEnvironments + } +} diff --git a/src/renderer/src/components/settings/use-runtime-environment-connection-actions.ts b/src/renderer/src/components/settings/use-runtime-environment-connection-actions.ts new file mode 100644 index 00000000000..3a0e90f4b62 --- /dev/null +++ b/src/renderer/src/components/settings/use-runtime-environment-connection-actions.ts @@ -0,0 +1,212 @@ +import { useState, type Dispatch, type MutableRefObject, type SetStateAction } from 'react' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client' +import { useAppStore } from '@/store' +import { describeRuntimeCompatBlock } from '../../../../shared/protocol-compat' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import { evaluateHostDetails, type RuntimeHostDetails } from './runtime-environment-host-details' +import { LOCAL_RUNTIME_VALUE, NO_RUNTIME_VALUE } from './runtime-environment-selection' + +type RuntimeEnvironmentConnectionActionParams = { + allowLocalRuntime: boolean + mountedRef: MutableRefObject + setDetailsByEnvironmentId: Dispatch>> + setActiveRuntimeEnvironmentPreference: (environmentId: string | null) => Promise + getEnvironmentLabel: (value: string) => string +} + +export function useRuntimeEnvironmentConnectionActions({ + allowLocalRuntime, + mountedRef, + setDetailsByEnvironmentId, + setActiveRuntimeEnvironmentPreference, + getEnvironmentLabel +}: RuntimeEnvironmentConnectionActionParams) { + const [connectingId, setConnectingId] = useState(null) + const [switchingValue, setSwitchingValue] = useState(null) + const [disconnectingId, setDisconnectingId] = useState(null) + const [switchError, setSwitchError] = useState(null) + + const disconnectEnvironment = async ( + environment: PublicKnownRuntimeEnvironment + ): Promise => { + setDisconnectingId(environment.id) + setSwitchError(null) + try { + await window.api.runtimeEnvironments.disconnect({ selector: environment.id }) + // Why: disconnect is non-destructive; keep the saved server but show the + // user that this live client is no longer attached to it. + useAppStore.getState().setRuntimeEnvironmentStatus( + environment.id, + { + status: null, + checkedAt: Date.now() + }, + { suppressDisconnectToast: true } + ) + if (mountedRef.current) { + setDetailsByEnvironmentId((current) => ({ + ...current, + [environment.id]: { + status: 'error', + runtimeStatus: null, + compatibility: null, + error: null + } + })) + toast.success( + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.disconnectedServer', + 'Disconnected from {{value0}}.', + { value0: environment.name } + ) + ) + } + return true + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to disconnect server.' + if (mountedRef.current) { + setSwitchError(message) + toast.error(message) + } + return false + } finally { + if (mountedRef.current) { + setDisconnectingId(null) + } + } + } + + const connectEnvironment = async ( + environment: PublicKnownRuntimeEnvironment + ): Promise => { + setConnectingId(environment.id) + setSwitchError(null) + try { + const response = await window.api.runtimeEnvironments.connect({ + selector: environment.id, + timeoutMs: 15_000 + }) + const runtimeStatus = unwrapRuntimeRpcResult(response) + const compatibility = evaluateHostDetails(runtimeStatus) + // Why: row Connect is reachability only. The Advanced selector is the + // explicit default-host control and should be the only active-server path. + useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { + status: runtimeStatus, + checkedAt: Date.now() + }) + if (mountedRef.current) { + setDetailsByEnvironmentId((current) => ({ + ...current, + [environment.id]: { + status: 'ready', + runtimeStatus, + compatibility, + error: null + } + })) + } + if (compatibility.kind === 'blocked') { + const message = describeRuntimeCompatBlock(compatibility) + if (mountedRef.current) { + setSwitchError(message) + toast.error(message) + } + return false + } + const store = useAppStore.getState() + // Why: Connect is not the Active Server selector anymore, but connected + // hosts should still contribute their projects/workspaces to the sidebar. + const repos = await store.fetchRuntimeEnvironmentRepos(environment.id) + await Promise.all(repos.map((repo) => useAppStore.getState().fetchWorktrees(repo.id))) + await useAppStore.getState().fetchWorktreeLineage() + if (mountedRef.current) { + toast.success( + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.runtimeReachable', + '{{value0}} is reachable.', + { value0: environment.name } + ) + ) + } + return true + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to connect server.' + useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { + status: null, + checkedAt: Date.now() + }) + if (mountedRef.current) { + setDetailsByEnvironmentId((current) => ({ + ...current, + [environment.id]: { + status: 'error', + runtimeStatus: null, + compatibility: null, + error: message + } + })) + setSwitchError(message) + toast.error(message) + } + return false + } finally { + if (mountedRef.current) { + setConnectingId(null) + } + } + } + + const switchToValue = async (value: string): Promise => { + if (value === NO_RUNTIME_VALUE) { + return false + } + setSwitchingValue(value) + setSwitchError(null) + try { + const switched = await setActiveRuntimeEnvironmentPreference( + allowLocalRuntime && value === LOCAL_RUNTIME_VALUE ? null : value + ) + if (switched) { + if (mountedRef.current) { + toast.success( + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.99ac81fb43', + 'Switched to {{value0}}.', + { value0: getEnvironmentLabel(value) } + ) + ) + } + return true + } + if (mountedRef.current) { + setSwitchError('Could not switch servers. Fix the issue and try again.') + } + return false + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to switch servers.' + if (mountedRef.current) { + setSwitchError(message) + toast.error(message) + } + return false + } finally { + if (mountedRef.current) { + setSwitchingValue(null) + } + } + } + + return { + connectingId, + switchingValue, + disconnectingId, + switchError, + setSwitchError, + connectEnvironment, + disconnectEnvironment, + switchToValue + } +} diff --git a/src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts b/src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts new file mode 100644 index 00000000000..0a9b83d14b7 --- /dev/null +++ b/src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts @@ -0,0 +1,194 @@ +import { useState, type Dispatch, type MutableRefObject, type SetStateAction } from 'react' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import { isRuntimeEnvironmentRemovalBlocked } from './runtime-environment-host-details' +import type { RuntimeHostAccessFailure } from './RuntimeHostAccessForm' + +type RuntimeEnvironmentMutationActionParams = { + environments: PublicKnownRuntimeEnvironment[] + settings: GlobalSettings + allowLocalRuntime: boolean + mountedRef: MutableRefObject + setAddServerFormOpen: Dispatch> + loadEnvironments: (verified?: { + environmentId: string + runtimeStatus: RuntimeStatus + }) => Promise + connectEnvironment: (environment: PublicKnownRuntimeEnvironment) => Promise +} + +export function useRuntimeEnvironmentMutationActions({ + environments, + settings, + allowLocalRuntime, + mountedRef, + setAddServerFormOpen, + loadEnvironments, + connectEnvironment +}: RuntimeEnvironmentMutationActionParams) { + const [isSaving, setIsSaving] = useState(false) + const [removingId, setRemovingId] = useState(null) + const [removeError, setRemoveError] = useState(null) + const [name, setName] = useState('') + const [pairingCode, setPairingCode] = useState('') + const [addServerFailure, setAddServerFailure] = useState(null) + + const closeAddServerForm = (): void => { + if (isSaving) { + return + } + setAddServerFormOpen(false) + setName('') + setPairingCode('') + setAddServerFailure(null) + } + + const addEnvironment = async (allowLoopback: boolean): Promise => { + const trimmedName = name.trim() + const trimmedPairingCode = pairingCode.trim() + if (!trimmedName || !trimmedPairingCode) { + toast.error( + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.0c55a47480', + 'Name and pairing code are required.' + ) + ) + return + } + const duplicate = environments.find( + (environment) => environment.name.trim().toLowerCase() === trimmedName.toLowerCase() + ) + if (duplicate) { + toast.error( + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.5ef712f407', + 'A server named "{{value0}}" already exists.', + { value0: duplicate.name } + ) + ) + return + } + setAddServerFailure(null) + setIsSaving(true) + try { + const result = await window.api.runtimeEnvironments.verifyAndAddFromPairingCode({ + name: trimmedName, + pairingCode: trimmedPairingCode, + allowLoopback + }) + if (!result.ok) { + if (mountedRef.current) { + setAddServerFailure({ kind: result.kind, message: result.message }) + } + return + } + if (mountedRef.current) { + setName('') + setPairingCode('') + } + await loadEnvironments({ + environmentId: result.environment.id, + runtimeStatus: result.runtimeStatus + }) + if (!allowLocalRuntime) { + const connected = await connectEnvironment(result.environment) + if (!connected) { + await window.api.runtimeEnvironments.remove({ selector: result.environment.id }) + await loadEnvironments() + return + } + } else { + if (mountedRef.current) { + toast.success( + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.7b5986c8df', + 'Connected to {{value0}}. Use Advanced > Active Server to make it the default.', + { value0: result.environment.name } + ) + ) + } + } + if (mountedRef.current) { + setAddServerFormOpen(false) + } + } catch (error) { + if (mountedRef.current) { + toast.error( + error instanceof Error + ? error.message + : translate( + 'auto.components.settings.RuntimeEnvironmentsPane.6cb6eae14f', + 'Failed to save runtime environment.' + ) + ) + } + } finally { + if (mountedRef.current) { + setIsSaving(false) + } + } + } + + const removeEnvironment = async ( + environment: PublicKnownRuntimeEnvironment + ): Promise => { + setRemovingId(environment.id) + setRemoveError(null) + try { + if (isRuntimeEnvironmentRemovalBlocked(settings.activeRuntimeEnvironmentId, environment.id)) { + if (mountedRef.current) { + setRemoveError( + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.removeActiveServerBlocked', + 'Choose another Active Server in Advanced before removing this server.' + ) + ) + } + return false + } + await window.api.runtimeEnvironments.remove({ selector: environment.id }) + await loadEnvironments() + if (mountedRef.current) { + toast.success( + translate( + 'auto.components.settings.RuntimeEnvironmentsPane.b5b5114cb0', + 'Removed {{value0}}.', + { value0: environment.name } + ) + ) + } + return true + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to remove runtime environment.' + if (mountedRef.current) { + setRemoveError(message) + toast.error(message) + } + return false + } finally { + if (mountedRef.current) { + setRemovingId(null) + } + } + } + + return { + isSaving, + removingId, + removeError, + setRemoveError, + name, + setName, + pairingCode, + setPairingCode, + addServerFailure, + setAddServerFailure, + closeAddServerForm, + addEnvironment, + removeEnvironment + } +}