Remote Orca Servers: one list instead of six sections; SSH Hosts: bulk remove that stays removed (#31082)

* feat(settings): one-list Remote Orca Servers page and bulk-manageable SSH Hosts

Remote Orca Servers: replace the three mode cards, Advanced fold, duplicate
managed/SSH-access lists and troubleshooting accordion with a single list
(one row per server: status, one action, overflow menu), an always-visible
'New projects open on' row, and Share / Cloud VM cards that open dialogs.

SSH Hosts: group by source, filter, checkbox + shift-range selection, one
confirm for bulk remove (skipping hosts with a managed server or
workspaces), bulk Test. 'Sync ~/.ssh/config' no longer clears removal
tombstones; 'Restore removed hosts (n)' is a separate confirmed action.

* fix(settings): address review findings on servers and SSH hosts redesign

- SSH filter stays clearable when the list shrinks under the threshold
- Cmd/Ctrl-A only selects hosts when pressed in the list itself
- group legacy no-source hosts with main's import predicate (now shared)
- capture the shift-range anchor before queuing the selection update
- blank server names de-duplicate instead of colliding
- Share dialog Done refreshes link count and resets name editing
- Install / SSH-route dialogs reload SSH hosts when opened
- default-host row shows the selector when the default is a WSL host
- managed 'Not running' no longer masks connecting/revoked/update states
- servers without a dropped transport can be re-checked

* test(e2e): WSL host spec matches repo.rm ending a removed project's shells

repo.rm now stops the project's terminals on the host (#29964), so the
'orphan left by older builds' setup can no longer be built; use a shell
only the host knows of instead. Resolve the project path so macOS
/private/var matches what the host reports.

* fix(settings): carry 'Orca on <host>' naming and paired-twice warning into the new server list

Ports #31099 and #31100 into the redesigned rows, install dialog and SSH
host dialogs.

* test(e2e): keep the WSL spec's project path as created
This commit is contained in:
Neil
2026-10-20 05:22:39 -07:00
committed by GitHub
parent a2e0f20b1e
commit 49a2203b0e
111 changed files with 8136 additions and 3087 deletions
+1 -1
View File
@@ -112,7 +112,7 @@ describe('managed server CLI verbs', () => {
verdict: 'live',
code: 'orcad_stop_active_environment',
reason:
'This server is the default host for new projects. Choose another one in Advanced before stopping it.'
'This server is the default host for new projects. Choose another one under New projects open on before stopping it.'
}
await expect(run('environment stop', client(refusal), [['yes', true]])).rejects.toMatchObject({
code: 'managed_server_refused',
@@ -126,7 +126,7 @@ export function registerRuntimeEnvironmentConnectivityHandlers({
const environment = resolveEnvironment(getUserDataPath(), args.selector)
if (store.getSettings().activeRuntimeEnvironmentId === environment.id) {
throw new Error(
'This server is the default host for new projects. Choose another one in Advanced before removing it.'
'This server is the default host for new projects. Choose another one under New projects open on before removing it.'
)
}
// A closed gate leaves persisted WSL hosts untouched on disk.
@@ -233,7 +233,7 @@ describe('runtimeEnvironments:remove by host kind', () => {
useHost('wsl')
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '')
expect(() => handler('runtimeEnvironments:remove')(null, { selector: 'wsl:ubuntu' })).toThrow(
'This server is the default host for new projects. Choose another one in Advanced before removing it.'
'This server is the default host for new projects. Choose another one under New projects open on before removing it.'
)
})
@@ -527,7 +527,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
)
expect(() => remove(null, { selector: added.environment.id })).toThrow(
'Choose another one in Advanced before removing it.'
'Choose another one under New projects open on before removing it.'
)
expect(activeRuntimeEnvironmentId).toBe(added.environment.id)
expect(store.updateSettings).not.toHaveBeenCalled()
+1 -1
View File
@@ -193,7 +193,7 @@ export function registerWslHostHandlers(args: WslHostHandlerArgs): void {
ipcMain.handle('wslHosts:remove', async (_event, request: { environmentId: string }) => {
if (args.store.getSettings().activeRuntimeEnvironmentId === request.environmentId) {
throw new Error(
'This server is the default host for new projects. Choose another one in Advanced before removing it.'
'This server is the default host for new projects. Choose another one under New projects open on before removing it.'
)
}
return wsl.remove(request.environmentId)
@@ -55,7 +55,7 @@ export function stopManagedOrcadEnvironment(
return refuse(
'live',
'orcad_stop_active_environment',
'This server is the default host for new projects. Choose another one in Advanced before stopping it.'
'This server is the default host for new projects. Choose another one under New projects open on before stopping it.'
)
}
const stopped = await stopRemote(userDataPath, environment, deployment, args.signal)
@@ -89,7 +89,7 @@ export function forgetManagedOrcadEnvironment(
return refuse(
'live',
'orcad_forget_active_environment',
'This server is the default host for new projects. Choose another one in Advanced before forgetting it.'
'This server is the default host for new projects. Choose another one under New projects open on before forgetting it.'
)
}
await unlinkEnvironmentLocally(userDataPath, environment, deployment, policy)
@@ -2,6 +2,7 @@ import type { Store } from '../../persistence'
import type { SshRepoReadoption, SshTarget } from '../../../shared/ssh-types'
import { RUNTIME_OWNED_SSH_TARGET_ID_PREFIX } from '../../../shared/execution-host'
import { normalizeSshConfigAlias } from '../../../shared/ssh-config-alias'
import { isLegacyConfigImportTarget } from '../../../shared/ssh-target-source'
import { loadUserSshConfig, sshConfigHostsToTargets } from '../config/ssh-config-parser'
import { SshTargetOrcadClaims } from '../targets/ssh-target-orcad-claims'
import {
@@ -285,13 +286,3 @@ export function allowsDirectSshRelay(target: SshTarget): boolean {
}
return !target.orcadFence || target.orcadFence.sourceChangedAt !== undefined
}
function isLegacyConfigImportTarget(target: SshTarget): boolean {
const alias = target.configHost ?? target.label
// Why: legacy manual and imported targets both lack `source`. Only adopt the
// old import shape, where the SSH alias was kept as label/configHost while
// host stored the resolved HostName; otherwise preserve the user's target.
return Boolean(
alias && target.label === alias && target.configHost === alias && target.host !== alias
)
}
@@ -19,8 +19,8 @@ describe('CloudVmSetupGuide', () => {
act(() => root.render(<CloudVmSetupGuide />))
expect(container.textContent).toContain('Create a Cloud VM')
expect(container.textContent).toContain('Create a workspace')
expect(container.textContent).toContain('Cloud VMs')
expect(container.textContent).toContain('environment recipe')
const button = container.querySelector('button')
if (!button) {
@@ -1,40 +1,35 @@
import type React from 'react'
import { ArrowUpRight, Cloud } from 'lucide-react'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { Button } from '../ui/button'
export function CloudVmSetupGuide(): React.JSX.Element {
/** Header of the Cloud VMs card: what they are and where their recipes are set up. */
export function CloudVmSetupGuide({
refreshAction
}: {
refreshAction?: React.ReactNode
}): React.JSX.Element {
const openSettingsTarget = useAppStore((state) => state.openSettingsTarget)
return (
<section className="space-y-3" data-settings-section="cloud-vm-setup">
<div className="space-y-1">
<div className="flex items-center gap-3 px-4 py-3" data-settings-section="cloud-vm-setup">
<Cloud className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
{translate('auto.components.settings.CloudVmSetupGuide.title', 'Create a Cloud VM')}
{translate('auto.components.settings.CloudVmSetupGuide.cardTitle', 'Cloud VMs')}
</div>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.CloudVmSetupGuide.description',
'Cloud VMs are created from environment recipes when you create a workspace.'
'auto.components.settings.CloudVmSetupGuide.cardDescription',
'Created per workspace from an environment recipe.'
)}
</p>
</div>
<ol className="ml-4 list-decimal space-y-1 text-xs text-muted-foreground">
<li>
{translate(
'auto.components.settings.CloudVmSetupGuide.setupRecipe',
'Set up an environment recipe for your cloud provider.'
)}
</li>
<li>
{translate(
'auto.components.settings.CloudVmSetupGuide.createWorkspace',
'Create a workspace and select that recipe under Run on.'
)}
</li>
</ol>
{refreshAction}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
openSettingsTarget({
@@ -44,11 +39,9 @@ export function CloudVmSetupGuide(): React.JSX.Element {
})
}
>
{translate(
'auto.components.settings.CloudVmSetupGuide.openSetup',
'Set up environment recipes'
)}
{translate('auto.components.settings.CloudVmSetupGuide.setUpRecipes', 'Set up recipes')}
<ArrowUpRight />
</Button>
</section>
</div>
)
}
@@ -10,6 +10,13 @@ import {
getVisibleEphemeralVmRuntimes
} from './EphemeralVmRuntimesSection'
// Why: this file stubs `window`, which the Radix tooltip timers need.
vi.mock('../ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: () => null
}))
const toastMocks = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn()
@@ -170,18 +177,17 @@ describe('EphemeralVmRuntimesSection', () => {
await vi.waitFor(() => expect(toastMocks.success).toHaveBeenCalled())
})
it('shows the empty state when no runtime needs cleanup', async () => {
window.api.ephemeralVm.listRuntimes = vi
it('shows only the setup header when no runtime needs cleanup', async () => {
const listRuntimes = vi
.fn()
.mockResolvedValue([makeRuntime({ status: 'cleaned', cleanupStatus: 'succeeded' })])
window.api.ephemeralVm.listRuntimes = listRuntimes
const container = await renderSection()
await vi.waitFor(() =>
expect(container.textContent).toContain(
'No Cloud VM runtimes yet. Create one from a workspace using an environment recipe.'
)
)
await vi.waitFor(() => expect(listRuntimes).toHaveBeenCalled())
expect(container.textContent).toContain('Cloud VMs')
expect(container.textContent).not.toContain('Fix Login Race')
})
it('surfaces cleanup hook failures returned by the cleanup IPC', async () => {
@@ -7,8 +7,10 @@ import { EPHEMERAL_VM_CLEANUP_STOPPED_ERROR } from '../../../../shared/ephemeral
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
import { Button } from '../ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { EphemeralVmCleanupStopDialog } from './EphemeralVmCleanupStopDialog'
import { EphemeralVmRuntimeRow } from './EphemeralVmRuntimeRow'
import { CloudVmSetupGuide } from './CloudVmSetupGuide'
function hasCleanupFailed(runtime: EphemeralVmRuntimeRecord): boolean {
return (
@@ -71,11 +73,7 @@ export function getEphemeralVmRuntimeStatusLabel(runtime: EphemeralVmRuntimeReco
return runtime.status
}
export function EphemeralVmRuntimesSection({
active = true
}: {
active?: boolean
}): React.JSX.Element {
export function EphemeralVmRuntimesSection(): React.JSX.Element {
const [runtimes, setRuntimes] = useState<EphemeralVmRuntimeRecord[]>([])
const [isLoading, setIsLoading] = useState(true)
const [cleaningId, setCleaningId] = useState<string | null>(null)
@@ -114,14 +112,12 @@ export function EphemeralVmRuntimesSection({
)
useEffect(() => {
if (active) {
void refresh()
}
}, [active, refresh])
void refresh()
}, [refresh])
const hasRunningCleanup = runtimes.some(isCleanupRunning) || cleaningId !== null
useEffect(() => {
if (!active || !hasRunningCleanup) {
if (!hasRunningCleanup) {
return
}
let cancelled = false
@@ -139,7 +135,7 @@ export function EphemeralVmRuntimesSection({
clearTimeout(timer)
}
}
}, [active, hasRunningCleanup, refresh])
}, [hasRunningCleanup, refresh])
const cleanupRuntime = async (runtime: EphemeralVmRuntimeRecord): Promise<void> => {
setCleaningId(runtime.id)
@@ -254,77 +250,61 @@ export function EphemeralVmRuntimesSection({
const hasRuntimes = runtimes.length > 0
return (
<div className="space-y-3 pt-2" data-settings-section="temporary-vm-runtimes">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 space-y-0.5">
<div className="text-sm font-medium">
{translate(
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmTitle',
'Cloud VM runtimes'
)}
</div>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.EphemeralVmRuntimesSection.description',
'Recipe-created runtimes are workspace-owned. Clean up stale entries after crashes, failed creates, or manual recovery.'
)}
</p>
</div>
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label={translate(
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmRefresh',
'Refresh Cloud VM runtimes'
)}
title={translate(
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmRefresh',
'Refresh Cloud VM runtimes'
)}
onClick={() => void refresh()}
disabled={isLoading || cleaningId !== null}
>
{isLoading ? <Loader2 className="animate-spin" /> : <RefreshCw />}
</Button>
</div>
<div className="rounded-lg border border-border/50 bg-card/30">
{!hasRuntimes ? (
<div className="px-3 py-4 text-sm text-muted-foreground">
{isLoading
? translate(
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmLoading',
'Checking Cloud VM runtimes…'
)
: translate(
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmEmptyWithSetup',
'No Cloud VM runtimes yet. Create one from a workspace using an environment recipe.'
<section
className="scroll-mt-8 rounded-lg border border-border bg-card"
data-settings-section="temporary-vm-runtimes"
>
<CloudVmSetupGuide
refreshAction={
hasRuntimes ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmRefresh',
'Refresh Cloud VM runtimes'
)}
onClick={() => void refresh()}
disabled={isLoading || cleaningId !== null}
>
{isLoading ? <Loader2 className="animate-spin" /> : <RefreshCw />}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate(
'auto.components.settings.EphemeralVmRuntimesSection.cloudVmRefreshLabel',
'Refresh Cloud VMs'
)}
</div>
) : (
<div className="divide-y divide-border/50">
{runtimes.map((runtime) => (
<EphemeralVmRuntimeRow
key={runtime.id}
runtime={runtime}
statusLabel={getEphemeralVmRuntimeStatusLabel(runtime)}
cleanupFailed={hasCleanupFailed(runtime)}
cleanupRunning={isCleanupRunning(runtime) || cleaningId === runtime.id}
isStopping={stoppingId === runtime.id}
disabled={
isLoading ||
(cleaningId !== null && cleaningId !== runtime.id) ||
(stoppingId !== null && stoppingId !== runtime.id)
}
onCleanup={() => void cleanupRuntime(runtime)}
onStopCleanup={() => setPendingStop(runtime)}
onCopyCleanupCommand={() => void copyCleanupCommand(runtime)}
/>
))}
</div>
)}
</div>
</TooltipContent>
</Tooltip>
) : null
}
/>
{hasRuntimes ? (
<div className="divide-y divide-border border-t border-border">
{runtimes.map((runtime) => (
<EphemeralVmRuntimeRow
key={runtime.id}
runtime={runtime}
statusLabel={getEphemeralVmRuntimeStatusLabel(runtime)}
cleanupFailed={hasCleanupFailed(runtime)}
cleanupRunning={isCleanupRunning(runtime) || cleaningId === runtime.id}
isStopping={stoppingId === runtime.id}
disabled={
isLoading ||
(cleaningId !== null && cleaningId !== runtime.id) ||
(stoppingId !== null && stoppingId !== runtime.id)
}
onCleanup={() => void cleanupRuntime(runtime)}
onStopCleanup={() => setPendingStop(runtime)}
onCopyCleanupCommand={() => void copyCleanupCommand(runtime)}
/>
))}
</div>
) : null}
<EphemeralVmCleanupStopDialog
runtime={pendingStop}
isStopping={stoppingId !== null}
@@ -335,6 +315,6 @@ export function EphemeralVmRuntimesSection({
}
}}
/>
</div>
</section>
)
}
@@ -1,357 +0,0 @@
import { Loader2, RefreshCw } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import {
ORCAD_RECOVERY_CHANGED_STATE_CODE,
type OrcadManagedRuntimeStatus
} from '../../../../shared/orcad-managed-runtime'
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
import type { ManagedOrcadPreloadApi } from '../../../../preload/api/managed-orcad-api'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
import { Button } from '../ui/button'
import {
managedServerOutcomeLabel,
migrationPhaseLabel,
recoveryLabel,
terminalCensusLabel
} from './managed-server-copy'
type ManagedServerAction =
| 'update'
| 'rollback'
| 'recover'
| 'restoreSnapshot'
| 'stop'
| 'cancelStop'
| 'forget'
type ManagedServerRowProps = {
api: ManagedOrcadPreloadApi
environment: PublicKnownRuntimeEnvironment
/** The SSH host's current label; a manually deployed server's own name can differ from it. */
hostLabel?: string
onChanged: () => void
}
/** Turns a refusal or deferral into the one line the user acts on. */
function outcomeMessage(result: Parameters<typeof managedServerOutcomeLabel>[0]): string | null {
return result.outcome === 'deferred' ||
result.outcome === 'refused' ||
result.outcome === 'failed'
? managedServerOutcomeLabel(result)
: null
}
// Why: a status that never loaded is unknown, not exited; only a loaded status may say "Not running".
function versionLabel(
status: OrcadManagedRuntimeStatus | null,
statusError: string | null
): string {
if (status) {
return (
status.activeVersion ??
translate('auto.components.settings.managedServers.row.notRunning', 'Not running')
)
}
return statusError
? translate('auto.components.settings.managedServers.row.statusUnknown', 'Status unknown')
: translate('auto.components.settings.managedServers.row.checking', 'Checking…')
}
export function ManagedServerRow({
api,
environment,
hostLabel,
onChanged
}: ManagedServerRowProps): React.JSX.Element {
const mountedRef = useMountedRef()
const [status, setStatus] = useState<OrcadManagedRuntimeStatus | null>(null)
const [statusError, setStatusError] = useState<string | null>(null)
const [busy, setBusy] = useState<ManagedServerAction | 'status' | null>(null)
const [confirmingStop, setConfirmingStop] = useState(false)
const [confirmingForget, setConfirmingForget] = useState(false)
const [confirmingRestore, setConfirmingRestore] = useState(false)
const loadStatus = useCallback(async () => {
setBusy('status')
try {
const next = await api.getStatus({ selector: environment.id })
if (mountedRef.current) {
setStatus(next)
setStatusError(null)
}
} catch (error) {
if (mountedRef.current) {
setStatusError(error instanceof Error ? error.message : String(error))
}
} finally {
if (mountedRef.current) {
setBusy(null)
}
}
}, [api, environment.id, mountedRef])
useEffect(() => {
void loadStatus()
}, [loadStatus])
const run = async (action: ManagedServerAction): Promise<void> => {
setBusy(action)
try {
const selector = { selector: environment.id }
const result =
action === 'update'
? await api.update(selector)
: action === 'rollback'
? await api.rollback(selector)
: action === 'recover' || action === 'restoreSnapshot'
? await api.recover({
...selector,
acceptChangedState: action === 'restoreSnapshot'
})
: action === 'stop'
? await api.stop(selector)
: action === 'forget'
? await api.forget(selector)
: await api.cancelStop(selector)
const message = outcomeMessage(result)
if (message) {
toast.message(message)
}
if (
mountedRef.current &&
action === 'recover' &&
result.outcome === 'refused' &&
'code' in result &&
result.code === ORCAD_RECOVERY_CHANGED_STATE_CODE
) {
setConfirmingRestore(true)
}
onChanged()
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error))
} finally {
if (mountedRef.current) {
setBusy(null)
}
void loadStatus()
}
}
const recovery = status?.recovery ?? null
const disabled = busy !== null
return (
<div className="space-y-2 px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate text-sm font-medium">
{translate('auto.components.settings.managedServers.row.title', 'Orca on {{host}}', {
host: hostLabel ?? environment.name
})}
</div>
<span className="shrink-0 text-[11px] text-muted-foreground">
{versionLabel(status, statusError)}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="ml-auto size-7"
disabled={disabled}
aria-label={translate(
'auto.components.settings.managedServers.row.refresh',
'Refresh status'
)}
onClick={() => void loadStatus()}
>
{busy === 'status' ? (
<Loader2 className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
)}
</Button>
</div>
{statusError ? <p className="text-xs text-destructive">{statusError}</p> : null}
{statusError && confirmingForget ? (
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.managedServers.row.forgetDescription',
'Unlinks this server here without contacting its host. If the host comes back, the server may still be running there.'
)}
</p>
) : null}
{status ? (
<div className="space-y-0.5 text-xs text-muted-foreground">
<p>{terminalCensusLabel(status.terminals)}</p>
{recovery ? <p>{recoveryLabel(recovery)}</p> : null}
{status.migration ? (
<p>
{translate(
'auto.components.settings.managedServers.row.migration',
'Migration: {{phase}}',
{
phase: migrationPhaseLabel(status.migration.phase)
}
)}
</p>
) : null}
{status.deferredUpdate ? (
<p>
{translate(
'auto.components.settings.managedServers.row.deferredVersion',
'Update to {{version}} deferred.',
{ version: status.deferredUpdate.candidateVersion }
)}{' '}
{managedServerOutcomeLabel(status.deferredUpdate)}
</p>
) : null}
</div>
) : null}
<div className="flex flex-wrap items-center gap-1">
<Button
type="button"
size="xs"
variant="outline"
disabled={disabled}
onClick={() => void run('update')}
>
{translate('auto.components.settings.managedServers.row.update', 'Update')}
</Button>
{status?.rollbackAvailable ? (
<Button
type="button"
size="xs"
variant="outline"
disabled={disabled}
onClick={() => void run('rollback')}
>
{translate('auto.components.settings.managedServers.row.rollback', 'Roll back')}
</Button>
) : null}
{recovery && confirmingRestore ? (
<>
<Button
type="button"
size="xs"
variant="destructive"
disabled={disabled}
onClick={() => {
setConfirmingRestore(false)
void run('restoreSnapshot')
}}
>
{translate(
'auto.components.settings.managedServers.row.confirmRestore',
'Restore snapshot and restart'
)}
</Button>
<Button
type="button"
size="xs"
variant="ghost"
onClick={() => setConfirmingRestore(false)}
>
{translate('auto.components.settings.managedServers.row.keepState', 'Keep state')}
</Button>
</>
) : recovery ? (
<Button
type="button"
size="xs"
variant="outline"
disabled={disabled}
onClick={() => void run('recover')}
>
{translate('auto.components.settings.managedServers.row.recover', 'Recover')}
</Button>
) : null}
{recovery?.operation === 'decommission' ? (
<Button
type="button"
size="xs"
variant="outline"
disabled={disabled}
onClick={() => void run('cancelStop')}
>
{translate('auto.components.settings.managedServers.row.cancelStop', 'Cancel stop')}
</Button>
) : null}
{confirmingStop ? (
<>
<Button
type="button"
size="xs"
variant="destructive"
disabled={disabled}
onClick={() => {
setConfirmingStop(false)
void run('stop')
}}
>
{translate(
'auto.components.settings.managedServers.row.confirmStop',
'Stop and remove server'
)}
</Button>
<Button
type="button"
size="xs"
variant="ghost"
onClick={() => setConfirmingStop(false)}
>
{translate('auto.components.settings.managedServers.row.keep', 'Keep running')}
</Button>
</>
) : (
<Button
type="button"
size="xs"
variant="ghost"
disabled={disabled}
onClick={() => setConfirmingStop(true)}
>
{translate('auto.components.settings.managedServers.row.stop', 'Stop…')}
</Button>
)}
{/* Only once the host failed to answer: a reachable host is stopped, not forgotten. */}
{statusError && confirmingForget ? (
<>
<Button
type="button"
size="xs"
variant="destructive"
disabled={disabled}
onClick={() => {
setConfirmingForget(false)
void run('forget')
}}
>
{translate(
'auto.components.settings.managedServers.row.confirmForget',
'Forget on this desktop'
)}
</Button>
<Button
type="button"
size="xs"
variant="ghost"
onClick={() => setConfirmingForget(false)}
>
{translate('auto.components.settings.managedServers.row.keepLinked', 'Keep linked')}
</Button>
</>
) : statusError ? (
<Button
type="button"
size="xs"
variant="ghost"
disabled={disabled}
onClick={() => setConfirmingForget(true)}
>
{translate('auto.components.settings.managedServers.row.forget', 'Forget…')}
</Button>
) : null}
</div>
</div>
)
}
@@ -1,248 +0,0 @@
import { Loader2 } from 'lucide-react'
import { useCallback, useEffect, useId, useState } from 'react'
import { toast } from 'sonner'
import type { OrcadManagedPendingMigrationRow } from '../../../../shared/orcad-managed-runtime'
import {
getOrcadSshDeployment,
type PublicKnownRuntimeEnvironment
} from '../../../../shared/runtime-environments'
import type { SshTarget } from '../../../../shared/ssh-types'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { ManagedServerRow } from './ManagedServerRow'
import { RuntimeSshAccessControl } from './RuntimeSshAccessControl'
import { SshTargetSelect } from './SshTargetSelect'
import { WslHostsSection } from './WslHostsSection'
import { managedServerOutcomeLabel, migrationPhaseLabel } from './managed-server-copy'
type ManagedServersSectionProps = {
environments: PublicKnownRuntimeEnvironment[]
onChanged: () => void
}
export function ManagedServersSection({
environments,
onChanged
}: ManagedServersSectionProps): React.JSX.Element | null {
const api = window.api.runtimeEnvironments.managedOrcad
const mountedRef = useMountedRef()
const [targets, setTargets] = useState<SshTarget[]>([])
const [pending, setPending] = useState<OrcadManagedPendingMigrationRow[]>([])
const [name, setName] = useState('')
const [targetId, setTargetId] = useState('')
const [deploying, setDeploying] = useState(false)
const [resumingId, setResumingId] = useState<string | null>(null)
const [loadFailed, setLoadFailed] = useState(false)
const hostFieldId = useId()
const nameFieldId = useId()
const reload = useCallback(async () => {
if (!api) {
return
}
const [nextTargets, nextPending] = await Promise.all([
window.api.ssh.listTargets(),
api.listPendingMigrations()
])
if (mountedRef.current) {
setTargets(nextTargets)
setPending(nextPending)
setLoadFailed(false)
}
}, [api, mountedRef])
useEffect(() => {
void reload().catch(() => mountedRef.current && setLoadFailed(true))
}, [mountedRef, reload])
if (!api) {
return null
}
// Why not WSL hosts: they have their own section and none of the SSH lifecycle actions.
const managed = environments.filter(
(environment) => environment.orcadDeployment && environment.orcadDeployment.kind !== 'wsl'
)
const paired = environments.filter((environment) => !environment.orcadDeployment)
// Why: a host that already runs, or is moving to, a managed server can't take another.
const busyTargetIds = new Set([
...managed.map((environment) => getOrcadSshDeployment(environment)?.sshTargetId),
...pending.map((row) => row.sshTargetId)
])
const emptyTargets = targets.filter(
(target) => !target.orcadFence && !busyTargetIds.has(target.id)
)
const deploy = async (): Promise<void> => {
setDeploying(true)
try {
const result = await api.deploy({ name: name.trim(), sshTargetId: targetId })
if (result.outcome === 'deferred') {
toast.message(managedServerOutcomeLabel(result))
} else {
setName('')
setTargetId('')
}
onChanged()
await reload()
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error))
} finally {
if (mountedRef.current) {
setDeploying(false)
}
}
}
const resume = async (row: OrcadManagedPendingMigrationRow): Promise<void> => {
if (resumingId) {
return
}
setResumingId(row.migrationId)
try {
const result = await api.convertSshHost({ sshTargetId: row.sshTargetId, name: row.name })
if (result.outcome !== 'converted') {
toast.message(managedServerOutcomeLabel(result))
}
onChanged()
await reload()
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error))
} finally {
if (mountedRef.current) {
setResumingId(null)
}
}
}
return (
<div className="space-y-4" data-settings-section="managed-servers">
<div className="space-y-0.5">
<div className="text-sm font-medium">
{translate('auto.components.settings.managedServers.title', 'Orca on your SSH hosts')}
</div>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.managedServers.description',
'Orca installs itself on an empty SSH host and keeps itself up to date.'
)}
</p>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.managedServers.reassurance',
'Runs on each host, reached over your own SSH connection. Your code and files stay where they are. Only Orca’s list of projects, tabs and terminals moves to the host.'
)}
</p>
{loadFailed ? (
<p className="text-xs text-destructive">
{translate(
'auto.components.settings.managedServers.loadFailed',
'Orca couldn’t load SSH hosts and unfinished moves. Reopen Settings to try again.'
)}
</p>
) : null}
</div>
<div className="flex flex-wrap items-end gap-2">
<div className="min-w-0 flex-1 space-y-1">
<Label htmlFor={hostFieldId}>
{translate('auto.components.settings.managedServers.deploy.host', 'SSH host')}
</Label>
<SshTargetSelect
id={hostFieldId}
targets={emptyTargets}
value={targetId}
onChange={setTargetId}
placeholder={translate(
'auto.components.settings.managedServers.deploy.hostPlaceholder',
'Choose an empty SSH host'
)}
/>
</div>
<div className="min-w-0 flex-1 space-y-1">
<Label htmlFor={nameFieldId}>
{translate('auto.components.settings.managedServers.deploy.name', 'Server name')}
</Label>
<Input id={nameFieldId} value={name} onChange={(event) => setName(event.target.value)} />
</div>
<Button
type="button"
disabled={deploying || targetId === '' || name.trim() === ''}
onClick={() => void deploy()}
>
{deploying ? <Loader2 className="animate-spin" /> : null}
{deploying
? translate('auto.components.settings.managedServers.deploy.running', 'Deploying…')
: translate('auto.components.settings.managedServers.deploy.submit', 'Deploy server')}
</Button>
</div>
{pending.length > 0 ? (
<div className="divide-y divide-border rounded-md border border-border">
{pending.map((row) => (
<div key={row.migrationId} className="flex items-center gap-2 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{row.name}</div>
<p className="text-xs text-muted-foreground">{migrationPhaseLabel(row.phase)}</p>
</div>
<Button
type="button"
size="xs"
variant="outline"
disabled={resumingId !== null}
onClick={() => void resume(row)}
>
{resumingId === row.migrationId ? <Loader2 className="animate-spin" /> : null}
{translate('auto.components.settings.managedServers.pending.resume', 'Resume')}
</Button>
</div>
))}
</div>
) : null}
{managed.length > 0 ? (
<div className="divide-y divide-border rounded-md border border-border">
{managed.map((environment) => (
<ManagedServerRow
key={environment.id}
api={api}
environment={environment}
hostLabel={
targets.find(
(target) => target.id === getOrcadSshDeployment(environment)?.sshTargetId
)?.label
}
onChanged={onChanged}
/>
))}
</div>
) : null}
<WslHostsSection onChanged={onChanged} />
{paired.length > 0 ? (
<div className="space-y-1">
<Label>
{translate(
'auto.components.settings.managedServers.access.title',
'SSH access for paired servers'
)}
</Label>
<div className="divide-y divide-border rounded-md border border-border">
{paired.map((environment) => (
<RuntimeSshAccessControl
key={environment.id}
api={api}
environment={environment}
targets={targets}
onChanged={onChanged}
/>
))}
</div>
</div>
) : null}
</div>
)
}
@@ -1,7 +1,6 @@
import { Loader2, RefreshCw, Trash2 } from 'lucide-react'
import { Loader2 } from 'lucide-react'
import type { RuntimeAccessGrant } from '../../../../shared/runtime-access-grants'
import { Button } from '../ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { translate } from '@/i18n/i18n'
function formatAccessTimestamp(timestamp: number): string {
@@ -13,13 +12,23 @@ function formatAccessTimestamp(timestamp: number): string {
}).format(new Date(timestamp))
}
// Why: main names each link "Runtime <date>"; show it as a link, not a runtime.
function grantDisplayName(grant: RuntimeAccessGrant): string {
return /^Runtime \d/.test(grant.name)
? translate('auto.components.settings.RuntimeAccessGrantList.linkName', 'Link · {{value0}}', {
value0: new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(
new Date(grant.createdAt)
)
})
: grant.name
}
type RuntimeAccessGrantListProps = {
className?: string
grants: RuntimeAccessGrant[]
currentGrantId: string | null
isLoading: boolean
revokingGrantId: string | null
onRefresh: () => void
onRevoke: (grant: RuntimeAccessGrant) => void
}
@@ -29,48 +38,21 @@ export function RuntimeAccessGrantList({
currentGrantId,
isLoading,
revokingGrantId,
onRefresh,
onRevoke
}: RuntimeAccessGrantListProps): React.JSX.Element {
return (
<div className={className}>
<div className="mb-2 flex items-center justify-between gap-3">
<h3 className="text-sm font-medium">
{translate(
'auto.components.settings.RuntimeAccessGrantList.f031182867',
'Shared Server Access'
)}
{translate('auto.components.settings.RuntimeAccessGrantList.activeLinks', 'Active links')}
</h3>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onRefresh}
disabled={isLoading}
aria-label={translate(
'auto.components.settings.RuntimeAccessGrantList.27cf8507ad',
'Refresh shared access'
)}
>
<RefreshCw className={isLoading ? 'animate-spin' : undefined} />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate(
'auto.components.settings.RuntimeAccessGrantList.27cf8507ad',
'Refresh shared access'
)}
</TooltipContent>
</Tooltip>
</div>
{grants.length === 0 ? (
{grants.length === 0 && isLoading ? null : grants.length === 0 ? (
<p className="text-muted-foreground text-sm">
{translate(
'auto.components.settings.RuntimeAccessGrantList.fd83b94095',
'No shared server access yet.'
'auto.components.settings.RuntimeAccessGrantList.noActiveLinks',
'No active links.'
)}
</p>
) : (
@@ -85,7 +67,7 @@ export function RuntimeAccessGrantList({
>
<div className="min-w-0 space-y-0.5">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">{grant.name}</span>
<span className="truncate text-sm font-medium">{grantDisplayName(grant)}</span>
{isCurrent ? (
<span className="text-muted-foreground shrink-0 text-xs">
{translate(
@@ -97,10 +79,11 @@ export function RuntimeAccessGrantList({
</div>
<div className="text-muted-foreground text-xs">
{translate(
'auto.components.settings.RuntimeAccessGrantList.87b16cd11d',
'Created'
'auto.components.settings.RuntimeAccessGrantList.createdAt',
'Created {{value0}}',
{ value0: formatAccessTimestamp(grant.createdAt) }
)}
{formatAccessTimestamp(grant.createdAt)} ·{' '}
{' · '}
{grant.lastSeenAt
? translate(
'auto.components.settings.RuntimeAccessGrantList.b18d1764ef',
@@ -113,35 +96,21 @@ export function RuntimeAccessGrantList({
)}
</div>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive shrink-0"
onClick={() => onRevoke(grant)}
disabled={isRevoking}
aria-label={translate(
'auto.components.settings.RuntimeAccessGrantList.6f6d5188ed',
'Revoke {{value0}}',
{ value0: grant.name }
)}
>
{isRevoking ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Trash2 className="size-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate(
'auto.components.settings.RuntimeAccessGrantList.68ec21309f',
'Revoke access'
)}
</TooltipContent>
</Tooltip>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => onRevoke(grant)}
disabled={isRevoking}
aria-label={translate(
'auto.components.settings.RuntimeAccessGrantList.6f6d5188ed',
'Revoke {{value0}}',
{ value0: grantDisplayName(grant) }
)}
>
{isRevoking ? <Loader2 className="animate-spin" /> : null}
{translate('auto.components.settings.RuntimeAccessGrantList.revoke', 'Revoke')}
</Button>
</div>
)
})}
@@ -149,10 +118,6 @@ export function RuntimeAccessGrantList({
)}
{grants.length > 0 ? (
<p className="text-muted-foreground mt-3 text-xs">
{translate(
'auto.components.settings.RuntimeAccessGrantList.8b82879581',
'Anyone with an active grant can connect until you revoke it. Revoking shared access disconnects active clients immediately.'
)}{' '}
{translate(
'auto.components.settings.RuntimeAccessGrantList.sshAccessCaveat',
"Revoking here doesn't stop someone with SSH access to this computer from pairing again. To cut them off, remove their SSH access."
@@ -290,12 +290,8 @@ describe('RuntimeEnvironmentsPane host details', () => {
})
it('explains that the choice only picks the host for new projects', () => {
expect(getActiveServerModeDescription(true)).toContain(
'New projects are created on this computer'
)
expect(getActiveServerModeDescription(true)).toContain('stay on their own host')
expect(getActiveServerModeDescription(false)).toContain('default Host')
expect(getActiveServerModeDescription(false)).toContain('paired Orca runtime')
expect(getActiveServerModeDescription(true)).toBe('Existing projects stay where they are.')
expect(getActiveServerModeDescription(false)).toContain('new projects on the chosen server')
})
it('blocks removing the active server independently of local-runtime availability', () => {
@@ -2,28 +2,26 @@ import { useEffect, useRef, useState } from 'react'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { SearchableSetting } from './SearchableSetting'
import { EphemeralVmRuntimesSection } from './EphemeralVmRuntimesSection'
import { ManagedServersSection } from './ManagedServersSection'
import { CloudVmSetupGuide } from './CloudVmSetupGuide'
import {
getRuntimeEnvironmentsSearchEntry,
getWebRuntimeEnvironmentsSearchEntry
} from './runtime-environments-search'
import { isRuntimeEnvironmentRemovalBlocked } from './runtime-environment-host-details'
import { RuntimeServersConnectSection } from './runtime-servers-connect-section'
import { RuntimeActiveServerSection } from './runtime-active-server-section'
import {
RuntimeEnvironmentRemoveDialog,
RuntimeEnvironmentSwitchDialog
} from './runtime-environment-dialogs'
import {
RuntimeServerShareSection,
RuntimeServerTroubleshooting,
RuntimeServerWorkflowPicker,
type RemoteServerWorkflow
} from './runtime-server-workflow-sections'
import { RuntimeAddServerDialog } from './runtime-add-server-dialog'
import { RuntimeDefaultHostRow } from './runtime-default-host-row'
import { RuntimeInstallServerDialog } from './runtime-install-server-dialog'
import { RuntimeServerList } from './runtime-server-list'
import { useRuntimeServersPage } from './runtime-servers-page-context'
import { RuntimeShareComputerCard } from './runtime-share-computer-card'
import { WslHostsSection } from './WslHostsSection'
import { useManagedServerCatalog } from './use-managed-server-catalog'
import { useRuntimeEnvironmentCatalog } from './use-runtime-environment-catalog'
import { useRuntimeEnvironmentConnectionActions } from './use-runtime-environment-connection-actions'
import { useRuntimeEnvironmentMutationActions } from './use-runtime-environment-mutation-actions'
@@ -57,14 +55,10 @@ export function RuntimeEnvironmentsPane({
allowLocalRuntime = true,
addServerIntentSignal
}: RuntimeEnvironmentsPaneProps): React.JSX.Element {
const { dialog, setDialog, setListEmpty } = useRuntimeServersPage()
const [pendingSwitchValue, setPendingSwitchValue] = useState<string | null>(null)
const [pendingRemove, setPendingRemove] = useState<PublicKnownRuntimeEnvironment | null>(null)
const [addServerFormOpen, setAddServerFormOpen] = useState(false)
const [shareServerFormOpen, setShareServerFormOpen] = useState(true)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [workflow, setWorkflow] = useState<RemoteServerWorkflow>('connect')
const remoteServerUpdates = useAppStore((state) => state.remoteServerUpdates)
const remoteServerUpdatesChecking = useAppStore((state) => state.remoteServerUpdatesChecking)
const remoteServerUpdatesRunning = useAppStore((state) => state.remoteServerUpdatesRunning)
const refreshRemoteServerUpdates = useAppStore((state) => state.refreshRemoteServerUpdates)
const setRemoteServerUpdateDialogOpen = useAppStore(
@@ -79,15 +73,23 @@ export function RuntimeEnvironmentsPane({
mountedRef,
loadEnvironments
} = useRuntimeEnvironmentCatalog()
const managedCatalog = useManagedServerCatalog(environments, () => void loadEnvironments())
const getEnvironmentLabel = (value: string): string => {
if (value === LOCAL_RUNTIME_VALUE) {
return 'Local desktop'
return translate('auto.components.settings.serverList.thisComputer', 'This computer')
}
if (value === NO_RUNTIME_VALUE) {
return 'No server connected'
return translate(
'auto.components.settings.RuntimeEnvironmentsPane.b07070ed3c',
'No server connected'
)
}
return environments.find((environment) => environment.id === value)?.name ?? 'remote server'
// Why the store: the default may be a WSL host, which the listed servers leave out.
return (
useAppStore.getState().runtimeEnvironments.find((environment) => environment.id === value)
?.name ?? 'remote server'
)
}
const {
connectingId,
@@ -123,7 +125,7 @@ export function RuntimeEnvironmentsPane({
settings,
allowLocalRuntime,
mountedRef,
setAddServerFormOpen,
setAddServerFormOpen: (open) => setDialog(open ? 'add' : null),
loadEnvironments,
connectEnvironment
})
@@ -140,10 +142,20 @@ export function RuntimeEnvironmentsPane({
return
}
consumedAddServerIntentSignalRef.current = addServerIntentSignal
// Why: composer deep-links should land on the existing pairing form, not just
// the server list.
setAddServerFormOpen(true)
}, [addServerIntentSignal])
// Why: composer deep-links should land on the pairing form, not just the server list.
setDialog('add')
}, [addServerIntentSignal, setDialog])
const managedReload = managedCatalog.reload
useEffect(() => {
// Why: SSH hosts and unfinished moves change on the SSH Hosts page while this one is mounted.
if (dialog === 'install') {
void managedReload().catch(() => {})
}
}, [dialog, managedReload])
const listEmpty = environments.length === 0 && managedCatalog.pending.length === 0
useEffect(() => {
setListEmpty(listEmpty)
}, [listEmpty, setListEmpty])
const activeValue =
settings.activeRuntimeEnvironmentId ??
@@ -160,12 +172,11 @@ export function RuntimeEnvironmentsPane({
const searchEntry = canGeneratePairingUrl
? getRuntimeEnvironmentsSearchEntry()
: getWebRuntimeEnvironmentsSearchEntry()
const visibleWorkflow: RemoteServerWorkflow = addServerFormOpen ? 'connect' : workflow
const openRemoveDialog = (environment: PublicKnownRuntimeEnvironment): void => {
setRemoveError(null)
setPendingRemove(environment)
const reloadAll = (): void => {
void loadEnvironments()
void managedCatalog.reload().catch(() => {})
}
const confirmSwitch = (): void => {
const value = pendingSwitchValue
if (!value) {
@@ -177,6 +188,10 @@ export function RuntimeEnvironmentsPane({
}
})
}
const requestSwitch = (value: string): void => {
setSwitchError(null)
setPendingSwitchValue(value)
}
const confirmRemove = (): void => {
const environment = pendingRemove
if (!environment) {
@@ -194,87 +209,93 @@ export function RuntimeEnvironmentsPane({
title={searchEntry.title}
description={searchEntry.description}
keywords={searchEntry.keywords}
className="space-y-4 py-2"
className="max-w-none space-y-6"
>
<RuntimeServerWorkflowPicker
canGeneratePairingUrl={canGeneratePairingUrl}
visibleWorkflow={visibleWorkflow}
onCloseAddServerForm={closeAddServerForm}
onWorkflowChange={setWorkflow}
/>
<RuntimeServersConnectSection
visible={visibleWorkflow === 'connect'}
<RuntimeServerList
environments={environments}
detailsByEnvironmentId={detailsByEnvironmentId}
activeRuntimeEnvironmentId={settings.activeRuntimeEnvironmentId}
addServerFormOpen={addServerFormOpen}
name={name}
pairingCode={pairingCode}
addServerFailure={addServerFailure}
isBusy={isBusy}
remoteServerUpdates={remoteServerUpdates}
remoteServerUpdatesChecking={remoteServerUpdatesChecking}
remoteServerUpdatesRunning={remoteServerUpdatesRunning}
connectingId={connectingId}
switchingValue={switchingValue}
disconnectingId={disconnectingId}
removingId={removingId}
onOpenAddServerForm={() => setAddServerFormOpen(true)}
onCloseAddServerForm={closeAddServerForm}
isBusy={isBusy}
managedCatalog={managedCatalog}
onAddServer={() => setDialog('add')}
onInstallServer={() => setDialog('install')}
onOpenUpdate={() => setRemoteServerUpdateDialogOpen(true)}
onConnect={(environment) => void connectEnvironment(environment)}
onDisconnect={(environment) => void disconnectEnvironment(environment)}
onRemove={(environment) => {
setRemoveError(null)
setPendingRemove(environment)
}}
onMakeDefault={(environment) => requestSwitch(environment.id)}
onCheckForUpdates={(options) => {
setRemoteServerUpdateDialogOpen(true)
void refreshRemoteServerUpdates(options)
}}
onRecheck={() => void loadEnvironments()}
rechecking={isLoading}
onChanged={reloadAll}
/>
{managedCatalog.loadFailed ? (
<p className="text-xs text-destructive">
{translate(
'auto.components.settings.managedServers.loadFailed',
'Orca couldn’t load SSH hosts and unfinished moves. Reopen Settings to try again.'
)}
</p>
) : null}
{/* Why gated on the managed API: WSL hosts, like managed servers, need the desktop. */}
{managedCatalog.api ? <WslHostsSection onChanged={reloadAll} /> : null}
<RuntimeDefaultHostRow
allowLocalRuntime={allowLocalRuntime}
activeValue={activeValue}
environments={environments}
isBusy={isBusy}
onValueChange={requestSwitch}
/>
<div className="space-y-3 border-t border-border pt-6">
{canGeneratePairingUrl ? <RuntimeShareComputerCard /> : null}
<EphemeralVmRuntimesSection />
</div>
<RuntimeAddServerDialog
open={dialog === 'add'}
name={name}
accessLink={pairingCode}
busy={isBusy}
failure={addServerFailure}
onNameChange={setName}
onPairingCodeChange={(value) => {
onAccessLinkChange={(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}
onClose={closeAddServerForm}
onSubmit={(allowLoopback) => void addEnvironment(allowLoopback)}
/>
<div className={cn('space-y-5 pt-2', visibleWorkflow !== 'cloud-vm' && 'hidden')}>
<CloudVmSetupGuide />
<EphemeralVmRuntimesSection active={visibleWorkflow === 'cloud-vm'} />
</div>
<RuntimeActiveServerSection
visible={visibleWorkflow === 'connect'}
advancedOpen={advancedOpen}
allowLocalRuntime={allowLocalRuntime}
localRuntimeValue={LOCAL_RUNTIME_VALUE}
noRuntimeValue={NO_RUNTIME_VALUE}
activeValue={activeValue}
environments={environments}
detailsByEnvironmentId={detailsByEnvironmentId}
isBusy={isBusy}
isLoading={isLoading}
onToggleAdvanced={() => setAdvancedOpen((current) => !current)}
onValueChange={(value) => {
setSwitchError(null)
setPendingSwitchValue(value)
}}
onRefresh={() => void loadEnvironments()}
/>
{visibleWorkflow === 'share' && canGeneratePairingUrl ? (
<RuntimeServerShareSection
shareServerFormOpen={shareServerFormOpen}
onToggleShareServerForm={() => setShareServerFormOpen((open) => !open)}
{managedCatalog.api ? (
<RuntimeInstallServerDialog
open={dialog === 'install'}
api={managedCatalog.api}
emptyTargets={managedCatalog.emptyTargets}
loadFailed={managedCatalog.loadFailed}
onOpenChange={(open) => setDialog(open ? 'install' : null)}
onDeployed={async () => {
void loadEnvironments()
await managedCatalog.reload()
}}
/>
) : null}
{visibleWorkflow === 'connect' ? (
<ManagedServersSection
environments={environments}
onChanged={() => void loadEnvironments()}
/>
) : null}
{visibleWorkflow === 'connect' ? <RuntimeServerTroubleshooting /> : null}
<RuntimeEnvironmentSwitchDialog
pendingSwitchValue={pendingSwitchValue}
switchingValue={switchingValue}
@@ -40,7 +40,7 @@ describe('RuntimeHostAccessForm', () => {
it('blocks accidental loopback and explains the recovery path', () => {
const markup = renderForm('ws://127.0.0.1:6768')
expect(markup).toContain('This link points back to this device')
expect(markup).toContain('I am using an SSH tunnel')
expect(markup).toContain('I reach this address through an SSH tunnel')
expect(markup).toContain('disabled')
})
@@ -1,4 +1,4 @@
import { ChevronDown, Loader2, Plus } from 'lucide-react'
import { Loader2, Plus } from 'lucide-react'
import { useMemo, useState } from 'react'
import { parseHostAccessLink } from '../../../../shared/remote-pairing-address'
import { Badge } from '../ui/badge'
@@ -11,13 +11,21 @@ import {
translateHostAccessLinkError,
translateRemotePairingEndpointKind
} from '@/lib/remote-pairing-copy'
import { defaultServerNameForAccessLink } from './runtime-server-default-name'
import {
RuntimeHostAccessFailureAlert,
type RuntimeHostAccessFailure
} from './RuntimeHostAccessFailureAlert'
// Why untranslated: a literal shell command.
const HEADLESS_SERVE_COMMAND = 'orca serve --pairing-address <address>'
const NO_TAKEN_NAMES: readonly { name: string }[] = []
type RuntimeHostAccessFormProps = {
name: string
/** Names already in use, so the blank-name default shown matches what will be saved. */
takenNames?: readonly { name: string }[]
accessLink: string
busy: boolean
failure: RuntimeHostAccessFailure | null
@@ -29,6 +37,7 @@ type RuntimeHostAccessFormProps = {
export function RuntimeHostAccessForm({
name,
takenNames = NO_TAKEN_NAMES,
accessLink,
busy,
failure,
@@ -51,11 +60,12 @@ export function RuntimeHostAccessForm({
: loopbackBlocked
? 'runtime-server-loopback-error'
: 'runtime-server-access-link-help'
const canSubmit = name.trim() !== '' && parsed.ok && !loopbackBlocked && !busy
const canSubmit = parsed.ok && !loopbackBlocked && !busy
const defaultName = defaultServerNameForAccessLink(accessLink, takenNames)
return (
<form
className="space-y-4 rounded-lg border border-border/50 bg-muted/20 p-4"
className="space-y-4"
onSubmit={(event) => {
event.preventDefault()
if (canSubmit) {
@@ -63,90 +73,38 @@ export function RuntimeHostAccessForm({
}
}}
>
<div className="space-y-2 rounded-md border border-border/60 bg-background/60 p-3">
<div className="text-sm font-medium">
{translate(
'auto.components.settings.RuntimeHostAccessForm.getLink',
'Get an access link from the other host'
<div className="space-y-2">
<Label htmlFor="runtime-server-access-link">
{translate('auto.components.settings.RuntimeHostAccessForm.accessLink', 'Access link')}
</Label>
<Input
id="runtime-server-access-link"
aria-invalid={inputError || loopbackBlocked || failure !== null}
aria-describedby={describedBy}
value={accessLink}
disabled={busy}
onChange={(event) => {
setAllowLoopback(false)
onAccessLinkChange(event.target.value)
}}
placeholder={translate(
'auto.components.settings.RuntimeHostAccessForm.accessLinkPlaceholder',
'orca://pair?code=...'
)}
</div>
<ol className="ml-4 list-decimal space-y-1 text-xs text-muted-foreground">
<li>
{translate(
'auto.components.settings.RuntimeHostAccessForm.stepOpenShare',
'Open Settings → Remote Orca Servers → Share this host.'
)}
</li>
<li>
{translate(
'auto.components.settings.RuntimeHostAccessForm.stepChooseAddress',
'Choose Another device and select a reachable address.'
)}
</li>
<li>
{translate(
'auto.components.settings.RuntimeHostAccessForm.stepCopyLink',
'Generate the link, then copy the “Pair another Orca client” link.'
)}
</li>
</ol>
</div>
<div className="grid gap-3 sm:grid-cols-[minmax(0,180px)_minmax(0,1fr)]">
<div className="space-y-2">
<Label htmlFor="runtime-server-name">
{translate('auto.components.settings.RuntimeHostAccessForm.name', 'Name in Orca')}
</Label>
<Input
id="runtime-server-name"
value={name}
disabled={busy}
onChange={(event) => onNameChange(event.target.value)}
placeholder={translate(
'auto.components.settings.RuntimeHostAccessForm.namePlaceholder',
'Linux workstation'
)}
autoFocus
/>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RuntimeHostAccessForm.nameHelp',
'This only changes how the computer appears in Orca.'
)}
className="min-w-0 font-mono"
autoFocus
/>
<p id="runtime-server-access-link-help" className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RuntimeHostAccessForm.accessLinkHelp',
'Orca shows the destination before connecting. Credentials stay hidden.'
)}
</p>
{inputError ? (
<p id="runtime-server-access-link-error" className="text-xs text-destructive">
{parsed.ok ? null : translateHostAccessLinkError(parsed.kind)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="runtime-server-access-link">
{translate('auto.components.settings.RuntimeHostAccessForm.accessLink', 'Access link')}
</Label>
<Input
id="runtime-server-access-link"
aria-invalid={inputError || loopbackBlocked || failure !== null}
aria-describedby={describedBy}
value={accessLink}
disabled={busy}
onChange={(event) => {
setAllowLoopback(false)
onAccessLinkChange(event.target.value)
}}
placeholder={translate(
'auto.components.settings.RuntimeHostAccessForm.accessLinkPlaceholder',
'orca://pair?code=...'
)}
className="min-w-0 font-mono"
/>
<p id="runtime-server-access-link-help" className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RuntimeHostAccessForm.accessLinkHelp',
'Orca shows the destination before connecting. Credentials stay hidden.'
)}
</p>
{inputError ? (
<p id="runtime-server-access-link-error" className="text-xs text-destructive">
{parsed.ok ? null : translateHostAccessLinkError(parsed.kind)}
</p>
) : null}
</div>
) : null}
</div>
{parsed.ok ? (
@@ -189,51 +147,60 @@ export function RuntimeHostAccessForm({
</p>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RuntimeHostAccessForm.loopbackRecovery',
'On the other computer, create a new link using Another device and choose its Tailscale or LAN address.'
'auto.components.settings.RuntimeHostAccessForm.loopbackRecoveryTunnel',
'On the other computer, open Share this computer and create a link for Another device with its Tailscale or LAN address. If you reach it through an SSH tunnel, turn on the option below.'
)}
</p>
<details className="pt-1 text-xs">
<summary className="cursor-pointer font-medium">
{translate(
'auto.components.settings.RuntimeHostAccessForm.connectionDetails',
'Connection details'
)}
</summary>
<dl className="mt-2 grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-muted-foreground">
<dt>
{translate(
'auto.components.settings.RuntimeHostAccessForm.destination',
'Link destination'
)}
</dt>
<dd className="font-mono text-foreground">{parsed.value.displayEndpoint}</dd>
<dt>
{translate(
'auto.components.settings.RuntimeHostAccessForm.endpointKind',
'Endpoint kind'
)}
</dt>
<dd className="text-foreground">
{translateRemotePairingEndpointKind(parsed.value.endpointKind)}
</dd>
<dt>
{translate(
'auto.components.settings.RuntimeHostAccessForm.networkConnection',
'Network connection'
)}
</dt>
<dd className="text-foreground">
{translate(
'auto.components.settings.RuntimeHostAccessForm.notAttempted',
'Not attempted'
)}
</dd>
</dl>
</details>
</div>
) : null}
{parsed.ok && parsed.value.endpointKind === 'loopback' ? (
<label className="flex items-start gap-2 text-sm">
<Checkbox
className="mt-0.5"
checked={allowLoopback}
disabled={busy}
onCheckedChange={(checked) => setAllowLoopback(checked === true)}
/>
<span className="space-y-0.5">
<span className="block">
{translate(
'auto.components.settings.RuntimeHostAccessForm.sshTunnelReach',
'I reach this address through an SSH tunnel'
)}
</span>
<span className="block text-xs text-muted-foreground">
{translate(
'auto.components.settings.RuntimeHostAccessForm.sshTunnelHelp',
'Keep the tunnel active while using this connection.'
)}
</span>
</span>
</label>
) : null}
<div className="space-y-2">
<Label htmlFor="runtime-server-name">
{translate('auto.components.settings.RuntimeHostAccessForm.nameLabel', 'Name')}{' '}
<span className="font-normal text-muted-foreground">
{translate('auto.components.settings.RuntimeHostAccessForm.optional', '(optional)')}
</span>
</Label>
<Input
id="runtime-server-name"
value={name}
disabled={busy}
onChange={(event) => onNameChange(event.target.value)}
placeholder={
defaultName ||
translate(
'auto.components.settings.RuntimeHostAccessForm.namePlaceholder',
'Linux workstation'
)
}
/>
</div>
{failure ? (
<RuntimeHostAccessFailureAlert
id="runtime-server-verification-error"
@@ -242,42 +209,15 @@ export function RuntimeHostAccessForm({
/>
) : null}
<details className="group text-xs">
<summary className="flex cursor-pointer list-none items-center gap-1 font-medium text-muted-foreground">
{translate('auto.components.settings.RuntimeHostAccessForm.advanced', 'Advanced')}
<ChevronDown className="size-3.5 transition-transform group-open:rotate-180" />
</summary>
{parsed.ok && parsed.value.endpointKind === 'loopback' ? (
<label className="mt-3 flex items-start gap-2 rounded-md border border-border/60 p-3">
<Checkbox
checked={allowLoopback}
disabled={busy}
onCheckedChange={(checked) => setAllowLoopback(checked === true)}
/>
<span className="space-y-1">
<span className="block font-medium text-foreground">
{translate(
'auto.components.settings.RuntimeHostAccessForm.sshTunnel',
'I am using an SSH tunnel to this local address'
)}
</span>
<span className="block text-muted-foreground">
{translate(
'auto.components.settings.RuntimeHostAccessForm.sshTunnelHelp',
'Keep the tunnel active while using this connection.'
)}
</span>
</span>
</label>
) : (
<p className="mt-2 text-muted-foreground">
{translate(
'auto.components.settings.RuntimeHostAccessForm.headlessHelp',
'Using headless orca serve? Run orca serve --pairing-address <reachable-host> on the other computer.'
)}
</p>
)}
</details>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RuntimeHostAccessForm.headlessHelpPrefix',
'Running Orca without a window? On that computer, run'
)}{' '}
<code className="whitespace-nowrap rounded bg-muted px-1 py-0.5 font-mono text-2xs text-foreground">
{HEADLESS_SERVE_COMMAND}
</code>
</p>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={onCancel} disabled={busy}>
@@ -287,10 +227,10 @@ export function RuntimeHostAccessForm({
{busy ? <Loader2 className="animate-spin" /> : <Plus />}
{tunnelOverrideEnabled
? translate(
'auto.components.settings.RuntimeHostAccessForm.addWithTunnel',
'Add host using tunnel'
'auto.components.settings.RuntimeHostAccessForm.addServerWithTunnel',
'Add server using tunnel'
)
: translate('auto.components.settings.RuntimeHostAccessForm.addHost', 'Add host')}
: translate('auto.components.settings.RuntimeHostAccessForm.addServer', 'Add server')}
</Button>
</div>
</form>
@@ -1,8 +1,10 @@
import { Loader2, RefreshCw } from 'lucide-react'
import { Link2, Loader2, RefreshCw } from 'lucide-react'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { describeNetworkAddress } from './network-address-label'
import { AddressPicker, type AddressOption } from '../network/AddressPicker'
import { parseServerShareAddress } from '../../../../shared/network/server-share-address'
import { GeneratedUrlRow, UnavailableUrlRow } from './RuntimePairingGeneratedUrlRows'
@@ -48,8 +50,43 @@ export function RuntimePairingGeneratorForm({
}: RuntimePairingGeneratorFormProps): React.JSX.Element {
const options: AddressOption[] = networkInterfaces.map((networkInterface) => ({
value: networkInterface.address,
label: `${networkInterface.name} (${networkInterface.address})`
label: describeNetworkAddress(networkInterface.name, networkInterface.address)
}))
const intentOptions: { value: RuntimePairingIntent; label: string; description: string }[] = [
{
value: 'another',
label: translate(
'auto.components.settings.RuntimePairingUrlGenerator.anotherDevice',
'Another device'
),
description: translate(
'auto.components.settings.RuntimePairingUrlGenerator.anotherDeviceHelp',
'Tailscale, LAN, or another reachable address'
)
},
{
value: 'local',
label: translate(
'auto.components.settings.RuntimePairingUrlGenerator.thisComputer',
'This computer'
),
description: translate(
'auto.components.settings.RuntimePairingUrlGenerator.localOnlyHelp',
'A browser or Orca client on this computer'
)
},
{
value: 'custom',
label: translate(
'auto.components.settings.RuntimePairingUrlGenerator.customAddress',
'Custom address'
),
description: translate(
'auto.components.settings.RuntimePairingUrlGenerator.customAddressHelp',
'SSH tunnel, reverse proxy, or custom hostname'
)
}
]
const generatedIsCurrent = generatedAddress === selectedAddress
const staleGeneratedLink = generatedAddress !== null && !generatedIsCurrent
const customAddressResult =
@@ -60,81 +97,38 @@ export function RuntimePairingGeneratorForm({
return (
<>
<div className="space-y-3">
<fieldset className="space-y-2">
<legend className="text-sm font-medium">
<div className="space-y-2">
<div id="runtime-pairing-intent-label" className="text-sm font-medium">
{translate(
'auto.components.settings.RuntimePairingUrlGenerator.intentQuestion',
'Where will this link be opened?'
'auto.components.settings.RuntimePairingUrlGenerator.whoOpens',
'Who will open the link'
)}
</legend>
<div className="grid gap-2 sm:grid-cols-3">
{(
[
[
'another',
translate(
'auto.components.settings.RuntimePairingUrlGenerator.anotherDevice',
'Another device'
),
translate(
'auto.components.settings.RuntimePairingUrlGenerator.anotherDeviceHelp',
'Tailscale, LAN, or another reachable address'
)
],
[
'local',
translate(
'auto.components.settings.RuntimePairingUrlGenerator.localOnly',
'This computer only'
),
translate(
'auto.components.settings.RuntimePairingUrlGenerator.localOnlyHelp',
'A browser or Orca client on this computer'
)
],
[
'custom',
translate(
'auto.components.settings.RuntimePairingUrlGenerator.customAddress',
'Custom address'
),
translate(
'auto.components.settings.RuntimePairingUrlGenerator.customAddressHelp',
'SSH tunnel, reverse proxy, or custom hostname'
)
]
] as const
).map(([value, label, description]) => (
<label
key={value}
className="flex cursor-pointer gap-2 rounded-md border border-border p-3 has-[:checked]:border-ring has-[:checked]:ring-1 has-[:checked]:ring-ring"
>
<input
type="radio"
name="runtime-pairing-intent"
value={value}
checked={intent === value}
onChange={() => onIntentChange(value)}
className="mt-0.5"
/>
<span className="space-y-1">
<span className="block text-xs font-medium">
{label}
{value === 'another' ? (
<span className="ml-1.5 text-[11px] text-muted-foreground">
{translate(
'auto.components.settings.RuntimePairingUrlGenerator.recommended',
'Recommended'
)}
</span>
) : null}
</span>
<span className="block text-[11px] text-muted-foreground">{description}</span>
</span>
</label>
))}
</div>
</fieldset>
<ToggleGroup
type="single"
variant="segmented"
size="sm"
spacing={1}
className="w-full"
aria-labelledby="runtime-pairing-intent-label"
value={intent}
onValueChange={(value) => {
const next = intentOptions.find((option) => option.value === value)
if (next) {
onIntentChange(next.value)
}
}}
>
{intentOptions.map((option) => (
<ToggleGroupItem key={option.value} value={option.value} className="flex-1">
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
<p className="text-xs text-muted-foreground">
{intentOptions.find((option) => option.value === intent)?.description}
</p>
</div>
{intent === 'local' ? (
<div className="rounded-md border border-border/60 bg-muted/30 p-3 text-xs">
@@ -194,8 +188,8 @@ export function RuntimePairingGeneratorForm({
<div className="space-y-1">
<Label id="runtime-pairing-address-label" htmlFor="runtime-pairing-address">
{translate(
'auto.components.settings.RuntimePairingUrlGenerator.de77eb1b65',
'Connection address'
'auto.components.settings.RuntimePairingUrlGenerator.addressLabel',
'Address'
)}
</Label>
<div className="flex flex-wrap items-center gap-2">
@@ -299,19 +293,14 @@ export function RuntimePairingGeneratorForm({
{staleGeneratedLink && selectedAddress !== '' ? (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs">
{translate(
'auto.components.settings.RuntimePairingUrlGenerator.staleAddress',
'The connection address changed. Generate a new link for {{address}}.',
'auto.components.settings.RuntimePairingUrlGenerator.staleAddressCreate',
'The connection address changed. Create a new link for {{address}}.',
{ address: selectedAddress }
)}
</div>
) : null}
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RuntimePairingUrlGenerator.pairedRunsAsYou',
'A paired client can run commands as you on this computer.'
)}
</p>
{/* Why no warning here: the Share dialog's description already says the link runs as you. */}
<div className="flex items-center justify-end gap-3">
<Button
type="button"
size="sm"
@@ -319,10 +308,10 @@ export function RuntimePairingGeneratorForm({
onClick={onGenerate}
disabled={isGeneratingPairing || !canGenerate}
>
{isGeneratingPairing ? <Loader2 className="animate-spin" /> : <RefreshCw />}
{isGeneratingPairing ? <Loader2 className="animate-spin" /> : <Link2 />}
{translate(
'auto.components.settings.RuntimePairingUrlGenerator.8de0f84fff',
'Generate Access Link'
'auto.components.settings.RuntimePairingUrlGenerator.createLink',
'Create link'
)}
</Button>
</div>
@@ -331,8 +320,8 @@ export function RuntimePairingGeneratorForm({
{generatedIsCurrent && webClientUrl ? (
<GeneratedUrlRow
label={translate(
'auto.components.settings.RuntimePairingUrlGenerator.6b9ca3e69b',
'Open in browser'
'auto.components.settings.RuntimePairingUrlGenerator.browserLinkLabel',
'Browser link'
)}
description={translate(
'auto.components.settings.RuntimePairingUrlGenerator.1ca2e5194d',
@@ -345,8 +334,8 @@ export function RuntimePairingGeneratorForm({
) : generatedIsCurrent && runtimePairingUrl ? (
<UnavailableUrlRow
label={translate(
'auto.components.settings.RuntimePairingUrlGenerator.6b9ca3e69b',
'Open in browser'
'auto.components.settings.RuntimePairingUrlGenerator.browserLinkLabel',
'Browser link'
)}
description={translate(
'auto.components.settings.RuntimePairingUrlGenerator.f7cafdc9f3',
@@ -358,12 +347,12 @@ export function RuntimePairingGeneratorForm({
{generatedIsCurrent && runtimePairingUrl ? (
<GeneratedUrlRow
label={translate(
'auto.components.settings.RuntimePairingUrlGenerator.2e5c4e3c93',
'Pair another Orca client'
'auto.components.settings.RuntimePairingUrlGenerator.accessLinkLabel',
'Access link'
)}
description={translate(
'auto.components.settings.RuntimePairingUrlGenerator.849825e829',
'Paste this pairing URL into another Orca client.'
'auto.components.settings.RuntimePairingUrlGenerator.accessLinkHelp',
'Paste this into Add server on the other Orca.'
)}
value={runtimePairingUrl}
copied={copiedTarget === 'pairing'}
@@ -415,7 +415,6 @@ export function RuntimePairingUrlGenerator({
currentGrantId={runtimePairingDeviceId}
isLoading={isLoadingAccessGrants}
revokingGrantId={revokingGrantId}
onRefresh={() => void loadRuntimeAccessGrants({ showToastOnError: true })}
onRevoke={(grant) => void revokeRuntimeAccess(grant)}
/>
</div>
@@ -133,8 +133,8 @@ function RuntimeRepairForm({
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.settings.RuntimeRepairDialog.description',
'{{name}} no longer accepts this computer. On {{name}}, open Settings → Remote Orca Servers → Share this host, create a link for another Orca client, then paste it here.',
'auto.components.settings.RuntimeRepairDialog.descriptionShareComputer',
'{{name}} no longer accepts this computer. On {{name}}, open Settings → Remote Orca Servers → Share this computer, create a link for another Orca, then paste it here.',
{ name }
)}
</DialogDescription>
@@ -18,6 +18,8 @@ type RuntimeSshAccessControlProps = {
environment: PublicKnownRuntimeEnvironment
targets: SshTarget[]
onChanged: () => void
/** Rendered before the submit button, e.g. a dialog's Cancel. */
footerStart?: React.ReactNode
}
/** Reaches an already-paired server through an SSH tunnel instead of its network address. */
@@ -25,9 +27,11 @@ export function RuntimeSshAccessControl({
api,
environment,
targets,
onChanged
onChanged,
footerStart
}: RuntimeSshAccessControlProps): React.JSX.Element {
const [targetId, setTargetId] = useState('')
// Why: with a single SSH host there is nothing to choose.
const [targetId, setTargetId] = useState(targets.length === 1 ? (targets[0]?.id ?? '') : '')
const [remotePort, setRemotePort] = useState(DEFAULT_REMOTE_PORT)
const [busy, setBusy] = useState(false)
const targetFieldId = useId()
@@ -61,43 +65,42 @@ export function RuntimeSshAccessControl({
})
: api.unlinkSshAccess({ selector: environment.id, requestId })
return (
<div className="flex items-center gap-2 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{environment.name}</div>
<p className="text-xs text-muted-foreground">
{pending.operation === 'link'
? translate(
'auto.components.settings.managedServers.access.pendingLink',
'Connecting through SSH didn’t finish.'
)
: translate(
'auto.components.settings.managedServers.access.pendingUnlink',
'Stopping SSH access didn’t finish.'
)}
</p>
</div>
<Button
type="button"
size="xs"
variant="outline"
disabled={busy}
onClick={() => void run(retry)}
>
{translate('auto.components.settings.managedServers.access.retry', 'Retry')}
</Button>
{pending.operation === 'link' ? (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2">
<p className="min-w-0 flex-1 text-xs text-muted-foreground">
{pending.operation === 'link'
? translate(
'auto.components.settings.managedServers.access.pendingLink',
'Connecting through SSH didn’t finish.'
)
: translate(
'auto.components.settings.managedServers.access.pendingUnlink',
'Stopping SSH access didn’t finish.'
)}
</p>
<div className="ml-auto flex shrink-0 items-center gap-1">
{pending.operation === 'link' ? (
<Button
type="button"
size="xs"
variant="ghost"
disabled={busy}
onClick={() =>
void run(() => api.unlinkSshAccess({ selector: environment.id, requestId }))
}
>
{translate('auto.components.settings.managedServers.access.cancelLink', 'Cancel')}
</Button>
) : null}
<Button
type="button"
size="xs"
variant="outline"
disabled={busy}
onClick={() =>
void run(() => api.unlinkSshAccess({ selector: environment.id, requestId }))
}
onClick={() => void run(retry)}
>
{translate('auto.components.settings.managedServers.access.cancelLink', 'Cancel')}
{translate('auto.components.settings.managedServers.access.retry', 'Retry')}
</Button>
) : null}
</div>
</div>
)
}
@@ -117,20 +120,27 @@ export function RuntimeSshAccessControl({
}
if (linked) {
const linkedTarget = targets.find((target) => target.id === linked.sshTargetId)
return (
<div className="flex items-center gap-2 px-4 py-3">
<div className="min-w-0 flex-1 truncate text-sm">
{translate('auto.components.settings.managedServers.access.linked', '{{name}} via SSH', {
name: environment.name
})}
</div>
<div className="flex flex-wrap items-center gap-3">
<p className="min-w-0 flex-1 text-sm text-muted-foreground">
{translate(
'auto.components.settings.managedServers.access.linkedRoute',
'Reached through {{host}}, server port {{port}}.',
{
host: linkedTarget?.label ?? linked.sshTargetId,
port: linked.remotePort
}
)}
</p>
<Button
type="button"
size="xs"
size="sm"
variant="outline"
disabled={busy}
onClick={() => void submit()}
>
{busy ? <Loader2 className="animate-spin" /> : null}
{translate('auto.components.settings.managedServers.access.unlink', 'Stop using SSH')}
</Button>
</div>
@@ -139,21 +149,31 @@ export function RuntimeSshAccessControl({
const port = Number(remotePort)
const valid = targetId !== '' && Number.isInteger(port) && port >= 1 && port <= 65_535
return (
<div className="flex flex-wrap items-end gap-2 px-4 py-3">
<div className="min-w-0 flex-1 space-y-1">
<Label htmlFor={targetFieldId}>{environment.name}</Label>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault()
if (!busy && valid) {
void submit()
}
}}
>
<div className="space-y-2">
<Label htmlFor={targetFieldId}>
{translate('auto.components.settings.managedServers.deploy.host', 'SSH host')}
</Label>
<SshTargetSelect
id={targetFieldId}
targets={targets}
value={targetId}
onChange={setTargetId}
placeholder={translate(
'auto.components.settings.managedServers.access.target',
'Reach through SSH host'
'auto.components.settings.managedServers.access.chooseHost',
'Choose a host'
)}
/>
</div>
<div className="w-24 space-y-1">
<div className="w-32 space-y-2">
<Label htmlFor={portFieldId}>
{translate('auto.components.settings.managedServers.access.port', 'Server port')}
</Label>
@@ -164,12 +184,15 @@ export function RuntimeSshAccessControl({
onChange={(event) => setRemotePort(event.target.value)}
/>
</div>
<Button type="button" disabled={busy || !valid} onClick={() => void submit()}>
{busy ? <Loader2 className="animate-spin" /> : null}
{busy
? translate('auto.components.settings.managedServers.access.linking', 'Connecting…')
: translate('auto.components.settings.managedServers.access.link', 'Use SSH')}
</Button>
</div>
<div className="flex justify-end gap-2">
{footerStart}
<Button type="submit" size="sm" disabled={busy || !valid}>
{busy ? <Loader2 className="animate-spin" /> : null}
{busy
? translate('auto.components.settings.managedServers.access.linking', 'Connecting…')
: translate('auto.components.settings.managedServers.access.connect', 'Connect')}
</Button>
</div>
</form>
)
}
@@ -1,4 +1,4 @@
import { Loader2 } from 'lucide-react'
import { Info, Loader2 } from 'lucide-react'
import { Button } from '../ui/button'
import {
Dialog,
@@ -10,11 +10,18 @@ import {
} from '../ui/dialog'
import { translate } from '@/i18n/i18n'
// Up to ten names show in full; a longer list shows the first eight and a count.
const MAX_FULL_TARGET_LABELS = 10
const COLLAPSED_TARGET_LABELS = 8
type SshDestructiveActionDialogProps = {
open: boolean
title: string
description: string
targetLabel?: string
/** Several names, one per line; long lists collapse past the first few. */
targetLabels?: readonly string[]
notice?: string
actionLabel: string
busyLabel?: string
isBusy?: boolean
@@ -27,12 +34,18 @@ export function SshDestructiveActionDialog({
title,
description,
targetLabel,
targetLabels,
notice,
actionLabel,
busyLabel,
isBusy = false,
onOpenChange,
onConfirm
}: SshDestructiveActionDialogProps): React.JSX.Element {
const labels = targetLabels ?? []
const shownLabels =
labels.length > MAX_FULL_TARGET_LABELS ? labels.slice(0, COLLAPSED_TARGET_LABELS) : labels
const hiddenLabelCount = labels.length - shownLabels.length
return (
<Dialog
open={open}
@@ -45,16 +58,42 @@ export function SshDestructiveActionDialog({
>
<DialogContent className="max-w-sm sm:max-w-sm" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-sm">{title}</DialogTitle>
<DialogDescription className="text-xs">{description}</DialogDescription>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{targetLabel ? (
<div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs">
<div className="break-all text-muted-foreground">{targetLabel}</div>
<div className="scrollbar-sleek max-h-32 overflow-y-auto rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs">
<div className="break-words text-muted-foreground">{targetLabel}</div>
</div>
) : null}
{shownLabels.length > 0 ? (
<ul className="scrollbar-sleek max-h-56 overflow-y-auto rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs text-muted-foreground">
{shownLabels.map((label, index) => (
<li key={`${index}:${label}`} className="break-words py-0.5">
{label}
</li>
))}
{hiddenLabelCount > 0 ? (
<li className="py-0.5">
{translate(
'auto.components.settings.SshDestructiveActionDialog.moreTargets',
'+{{count}} more',
{ count: hiddenLabelCount }
)}
</li>
) : null}
</ul>
) : null}
{notice ? (
<p className="flex items-start gap-2 text-xs text-muted-foreground">
<Info className="mt-0.5 size-3 shrink-0" />
{notice}
</p>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isBusy}>
{translate('auto.components.settings.SshDestructiveActionDialog.895b216267', 'Cancel')}
@@ -1,103 +0,0 @@
import { useState } from 'react'
import type { SshTarget } from '../../../../shared/ssh-types'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
import { Button } from '../ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '../ui/dialog'
import { SshHostDeltaMoveDialog } from './SshHostDeltaMoveDialog'
/** The two ways out for a host an older build changed: move its additions, or keep the server's. */
export function SshHostChangedActions({
target,
onChanged
}: {
target: SshTarget
onChanged: () => void
}): React.JSX.Element | null {
const api = window.api.runtimeEnvironments.managedOrcad
const mountedRef = useMountedRef()
const [moving, setMoving] = useState(false)
const [keeping, setKeeping] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
if (!api || !target.orcadFence?.sourceChangedAt) {
return null
}
const keepServerVersion = async (): Promise<void> => {
setBusy(true)
setError(null)
try {
await api.keepServerVersion({ sshTargetId: target.id })
if (mountedRef.current) {
setKeeping(false)
}
onChanged()
} catch (cause) {
if (mountedRef.current) {
setError(cause instanceof Error ? cause.message : String(cause))
}
} finally {
if (mountedRef.current) {
setBusy(false)
}
}
}
return (
<div className="flex flex-wrap justify-end gap-2 px-1">
<Button type="button" size="xs" variant="outline" onClick={() => setMoving(true)}>
{translate('auto.components.settings.deltaMove.action', 'Move the new projects…')}
</Button>
<Button
type="button"
size="xs"
variant="ghost"
onClick={() => {
setError(null)
setKeeping(true)
}}
>
{translate('auto.components.settings.keepServer.action', 'Keep the server’s version…')}
</Button>
<SshHostDeltaMoveDialog
api={api}
target={moving ? target : null}
onClose={() => setMoving(false)}
onFinished={onChanged}
/>
<Dialog open={keeping} onOpenChange={(open) => !open && !busy && setKeeping(false)}>
<DialogContent showCloseButton={!busy}>
<DialogHeader>
<DialogTitle>
{translate('auto.components.settings.keepServer.title', 'Keep the server’s version')}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.settings.keepServer.description',
'This host keeps using its Orca server as it is. What the older version of Orca added or changed here is not moved; it stays only in this computer’s saved settings until they are cleaned up.'
)}
</DialogDescription>
</DialogHeader>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<DialogFooter>
<Button variant="ghost" onClick={() => setKeeping(false)} disabled={busy}>
{translate('auto.components.settings.keepServer.cancel', 'Cancel')}
</Button>
<Button onClick={() => void keepServerVersion()} disabled={busy}>
{translate(
'auto.components.settings.keepServer.confirm',
'Keep the server’s version'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,88 @@
import { useState } from 'react'
import type { SshTarget } from '../../../../shared/ssh-types'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
import { Button } from '../ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '../ui/dialog'
import { getManagedOrcadApi } from './use-managed-server-catalog'
/** An older build changed this host; it is offered moving those additions or keeping the server’s. */
export function hasSshHostSourceChanged(target: SshTarget): boolean {
return Boolean(getManagedOrcadApi() && target.orcadFence?.sourceChangedAt)
}
export function SshHostKeepServerVersionDialog({
target,
open,
onOpenChange,
onChanged
}: {
target: SshTarget
open: boolean
onOpenChange: (open: boolean) => void
onChanged: () => void
}): React.JSX.Element | null {
const api = getManagedOrcadApi()
const mountedRef = useMountedRef()
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const setKeeping = (next: boolean): void => {
setError(null)
onOpenChange(next)
}
if (!api || !target.orcadFence?.sourceChangedAt) {
return null
}
const keepServerVersion = async (): Promise<void> => {
setBusy(true)
setError(null)
try {
await api.keepServerVersion({ sshTargetId: target.id })
if (mountedRef.current) {
setKeeping(false)
}
onChanged()
} catch (cause) {
if (mountedRef.current) {
setError(cause instanceof Error ? cause.message : String(cause))
}
} finally {
if (mountedRef.current) {
setBusy(false)
}
}
}
return (
<Dialog open={open} onOpenChange={(open) => !open && !busy && setKeeping(false)}>
<DialogContent showCloseButton={!busy}>
<DialogHeader>
<DialogTitle>
{translate('auto.components.settings.keepServer.title', 'Keep the server’s version')}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.settings.keepServer.description',
'This host keeps using its Orca server as it is. What the older version of Orca added or changed here is not moved; it stays only in this computer’s saved settings until they are cleaned up.'
)}
</DialogDescription>
</DialogHeader>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<DialogFooter>
<Button variant="ghost" onClick={() => setKeeping(false)} disabled={busy}>
{translate('auto.components.settings.keepServer.cancel', 'Cancel')}
</Button>
<Button onClick={() => void keepServerVersion()} disabled={busy}>
{translate('auto.components.settings.keepServer.confirm', 'Keep the server’s version')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,10 +1,10 @@
// @vitest-environment happy-dom
import { act, type ReactNode } from 'react'
import { act, useState, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SshManagedServerStatus, SshTarget } from '../../../../shared/ssh-types'
import { SshHostMoveAnywayAction } from './SshHostMoveAnywayAction'
import { canMoveSshHostAnyway, SshHostMoveAnywayDialog } from './SshHostMoveAnywayAction'
vi.mock('../ui/dialog', () => ({
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
@@ -44,13 +44,36 @@ afterEach(() => {
document.body.innerHTML = ''
})
// The row menu's entry, reduced to the one button that opens the dialog.
function Harness({
status,
onChanged
}: {
status: SshManagedServerStatus
onChanged: () => void
}): React.JSX.Element {
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(true)}>Move anyway…</button>
<SshHostMoveAnywayDialog
target={target}
status={status}
open={open}
onOpenChange={setOpen}
onChanged={onChanged}
/>
</>
)
}
async function render(status: SshManagedServerStatus, onChanged = vi.fn()) {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
roots.push(root)
await act(async () => {
root.render(<SshHostMoveAnywayAction target={target} status={status} onChanged={onChanged} />)
root.render(<Harness status={status} onChanged={onChanged} />)
})
return container
}
@@ -61,10 +84,11 @@ function button(container: HTMLElement, text: string): HTMLButtonElement {
describe('Move anyway', () => {
it('is offered only for a refusal that saved state alone causes', async () => {
expect((await render({ ...refused, canLeaveBehind: undefined })).textContent).toBe('')
expect(canMoveSshHostAnyway({ ...refused, canLeaveBehind: undefined })).toBe(false)
expect(
(await render({ kind: 'relay', reason: 'relay_terminals_live', terminals: 2 })).textContent
).toBe('')
canMoveSshHostAnyway({ kind: 'relay', reason: 'relay_terminals_live', terminals: 2 })
).toBe(false)
expect(canMoveSshHostAnyway(refused)).toBe(true)
})
it('names what stays behind, then reconnects with consent recorded', async () => {
@@ -12,25 +12,43 @@ import {
DialogHeader,
DialogTitle
} from '../ui/dialog'
import { getManagedOrcadApi } from './use-managed-server-catalog'
/** Offered only for a refusal that saved state alone causes. */
export function canMoveSshHostAnyway(status: SshManagedServerStatus | undefined): boolean {
return Boolean(
getManagedOrcadApi() &&
status?.kind === 'relay' &&
status.reason === 'refused' &&
status.canLeaveBehind
)
}
/**
* The way out of a move refused only by saved state: move the host now and keep what can't move on
* this computer. Unsaved drafts are never deleted, and live terminals still have to be closed.
*/
export function SshHostMoveAnywayAction({
export function SshHostMoveAnywayDialog({
target,
status,
open,
onOpenChange,
onChanged
}: {
target: SshTarget
status: SshManagedServerStatus | undefined
open: boolean
onOpenChange: (open: boolean) => void
onChanged: () => void
}): React.JSX.Element | null {
const api = window.api.runtimeEnvironments.managedOrcad
const api = getManagedOrcadApi()
const mountedRef = useMountedRef()
const [open, setOpen] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const setOpen = (next: boolean): void => {
setError(null)
onOpenChange(next)
}
if (!api || status?.kind !== 'relay' || status.reason !== 'refused' || !status.canLeaveBehind) {
return null
}
@@ -55,53 +73,40 @@ export function SshHostMoveAnywayAction({
}
}
return (
<div className="flex flex-wrap justify-end gap-2 px-1">
<Button
type="button"
size="xs"
variant="outline"
onClick={() => {
setError(null)
setOpen(true)
}}
>
{translate('auto.components.settings.moveAnyway.action', 'Move anyway…')}
</Button>
<Dialog open={open} onOpenChange={(next) => !next && !busy && setOpen(false)}>
<DialogContent showCloseButton={!busy}>
<DialogHeader>
<DialogTitle>
{translate('auto.components.settings.moveAnyway.title', 'Move this host anyway')}
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-2">
<p>
{translate(
'auto.components.settings.moveAnyway.description',
'Orca sets itself up on {{host}} without the saved items below. They stay on this computer and don’t appear on {{host}}. Unsaved edits are kept, never deleted.',
{ host: target.label }
)}
</p>
<p>{sshHostServerReassurance(target.label)}</p>
</div>
</DialogDescription>
</DialogHeader>
{status.detail ? (
<pre className="scrollbar-sleek max-h-60 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-background px-3 py-2 font-mono text-[11px] leading-relaxed text-muted-foreground">
{status.detail}
</pre>
) : null}
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)} disabled={busy}>
{translate('auto.components.settings.moveAnyway.cancel', 'Cancel')}
</Button>
<Button onClick={() => void moveAnyway()} disabled={busy}>
{translate('auto.components.settings.moveAnyway.confirm', 'Move anyway')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<Dialog open={open} onOpenChange={(next) => !next && !busy && setOpen(false)}>
<DialogContent showCloseButton={!busy}>
<DialogHeader>
<DialogTitle>
{translate('auto.components.settings.moveAnyway.title', 'Move this host anyway')}
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-2">
<p>
{translate(
'auto.components.settings.moveAnyway.description',
'Orca sets itself up on {{host}} without the saved items below. They stay on this computer and don’t appear on {{host}}. Unsaved edits are kept, never deleted.',
{ host: target.label }
)}
</p>
<p>{sshHostServerReassurance(target.label)}</p>
</div>
</DialogDescription>
</DialogHeader>
{status.detail ? (
<pre className="scrollbar-sleek max-h-60 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-background px-3 py-2 font-mono text-[11px] leading-relaxed text-muted-foreground">
{status.detail}
</pre>
) : null}
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)} disabled={busy}>
{translate('auto.components.settings.moveAnyway.cancel', 'Cancel')}
</Button>
<Button onClick={() => void moveAnyway()} disabled={busy}>
{translate('auto.components.settings.moveAnyway.confirm', 'Move anyway')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,11 +1,11 @@
// @vitest-environment happy-dom
import { act, type ReactNode } from 'react'
import { act, useState, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OrcadManagedRestartResult } from '../../../../shared/orcad-managed-runtime'
import type { SshManagedServerStatus } from '../../../../shared/ssh-types'
import { SshHostRestartServerAction } from './SshHostRestartServerAction'
import { canRestartSshHostServer, SshHostRestartServerDialog } from './SshHostRestartServerAction'
vi.mock('../ui/dialog', () => ({
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
@@ -39,13 +39,35 @@ afterEach(() => {
document.body.innerHTML = ''
})
// The row menu's entry, reduced to the one button that opens the dialog.
function Harness({
status,
onChanged
}: {
status: SshManagedServerStatus
onChanged: () => void
}): React.JSX.Element {
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(true)}>Restart Orca’s server…</button>
<SshHostRestartServerDialog
status={status}
open={open}
onOpenChange={setOpen}
onChanged={onChanged}
/>
</>
)
}
async function render(status: SshManagedServerStatus, onChanged = vi.fn()) {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
roots.push(root)
await act(async () => {
root.render(<SshHostRestartServerAction status={status} onChanged={onChanged} />)
root.render(<Harness status={status} onChanged={onChanged} />)
})
return container
}
@@ -54,9 +76,10 @@ function button(container: HTMLElement, text: string): HTMLButtonElement {
return [...container.querySelectorAll('button')].find((entry) => entry.textContent === text)!
}
describe('Restart managed server', () => {
describe('Restart Orca’s server', () => {
it('is offered only when the launching SSH session proved ended', async () => {
expect((await render({ kind: 'managed', environmentId: 'env-1' })).textContent).toBe('')
expect(canRestartSshHostServer({ kind: 'managed', environmentId: 'env-1' })).toBe(false)
expect(canRestartSshHostServer(ended)).toBe(true)
})
it('confirms first, then restarts and refreshes', async () => {
@@ -12,26 +12,39 @@ import {
DialogHeader,
DialogTitle
} from '../ui/dialog'
import { getManagedOrcadApi } from './use-managed-server-catalog'
/** Offered only when the launching SSH session proved ended. */
export function canRestartSshHostServer(status: SshManagedServerStatus | undefined): boolean {
return Boolean(getManagedOrcadApi() && status?.kind === 'managed' && status.launchSessionEnded)
}
/**
* Restarts a macOS host's managed server from this connection, so its terminals get local network
* access back (#25934). Main refuses while any terminal runs; it never ends them.
*/
export function SshHostRestartServerAction({
export function SshHostRestartServerDialog({
status,
open,
onOpenChange,
onChanged
}: {
status: SshManagedServerStatus | undefined
open: boolean
onOpenChange: (open: boolean) => void
onChanged: () => void
}): React.JSX.Element | null {
const api = window.api.runtimeEnvironments.managedOrcad
const api = getManagedOrcadApi()
const mountedRef = useMountedRef()
const [open, setOpen] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
if (!api || status?.kind !== 'managed' || !status.launchSessionEnded) {
return null
}
const setOpen = (next: boolean): void => {
setError(null)
onOpenChange(next)
}
const restart = async (): Promise<void> => {
setBusy(true)
setError(null)
@@ -57,43 +70,30 @@ export function SshHostRestartServerAction({
}
}
return (
<div className="flex flex-wrap justify-end gap-2 px-1">
<Button
type="button"
size="xs"
variant="outline"
onClick={() => {
setError(null)
setOpen(true)
}}
>
{translate('auto.components.settings.restartServer.action', 'Restart Orca’s server…')}
</Button>
<Dialog open={open} onOpenChange={(next) => !next && !busy && setOpen(false)}>
<DialogContent showCloseButton={!busy}>
<DialogHeader>
<DialogTitle>
{translate('auto.components.settings.restartServer.title', 'Restart Orca’s server')}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.settings.restartServer.description',
'Orca restarts its server on this host from your current connection. It restarts only when no terminals are running on the host, because terminals that are still open stay cut off. Close them first.'
)}
</DialogDescription>
</DialogHeader>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)} disabled={busy}>
{translate('auto.components.settings.restartServer.cancel', 'Cancel')}
</Button>
<Button onClick={() => void restart()} disabled={busy}>
{translate('auto.components.settings.restartServer.confirm', 'Restart')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<Dialog open={open} onOpenChange={(next) => !next && !busy && setOpen(false)}>
<DialogContent showCloseButton={!busy}>
<DialogHeader>
<DialogTitle>
{translate('auto.components.settings.restartServer.title', 'Restart Orca’s server')}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.settings.restartServer.description',
'Orca restarts its server on this host from your current connection. It restarts only when no terminals are running on the host, because terminals that are still open stay cut off. Close them first.'
)}
</DialogDescription>
</DialogHeader>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)} disabled={busy}>
{translate('auto.components.settings.restartServer.cancel', 'Cancel')}
</Button>
<Button onClick={() => void restart()} disabled={busy}>
{translate('auto.components.settings.restartServer.confirm', 'Restart')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -18,7 +18,7 @@ export function isManagedServerSshHost(
return target.orcadFence !== undefined || target.orcadProvisioning !== undefined
}
/** Sends a managed server's host to Managed servers instead of a removal main would refuse. */
/** Sends a host Orca runs on to its server row instead of a removal main would refuse. */
export function SshManagedHostRemoveDialog({
label,
onClose
@@ -44,8 +44,8 @@ export function SshManagedHostRemoveDialog({
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.settings.sshManagedHostRemove.description',
'Orca runs on {{label}}. Under Orca on your SSH hosts, use Stop… to remove Orca from the host, or Forget… if the host is gone. Then you can remove this SSH host.',
'auto.components.settings.sshManagedHostRemove.descriptionServers',
'Orca runs on {{label}}. In Remote Orca Servers, open the ⋯ menu on Orca on {{label}} and choose Stop… to remove Orca from the host, or Forget… if the host is gone. Then you can remove this SSH host.',
{ label: label ?? '' }
)}
</DialogDescription>
@@ -56,8 +56,8 @@ export function SshManagedHostRemoveDialog({
</Button>
<Button onClick={openManagedServers}>
{translate(
'auto.components.settings.sshManagedHostRemove.open',
'Open Orca on your SSH hosts'
'auto.components.settings.sshManagedHostRemove.openServers',
'Open Remote Orca Servers'
)}
</Button>
</DialogFooter>
@@ -4,7 +4,7 @@ import { act, type ReactNode } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { resetSshConfigPassiveSyncForTests } from '../../lib/ssh-config-passive-sync'
import type { SshTarget } from '../../../../shared/ssh-types'
import type { SshConnectionState, SshTarget } from '../../../../shared/ssh-types'
import { setRendererUiLanguage } from '@/i18n/i18n'
import { TooltipProvider } from '@/components/ui/tooltip'
import { SshPane } from './SshPane'
@@ -15,19 +15,23 @@ const mocks = vi.hoisted(() => ({
recordReadoptions: vi.fn(),
recordInteraction: vi.fn(),
requestRemove: vi.fn(),
openSettingsTarget: vi.fn()
requestBulkRemove: vi.fn(),
openSettingsTarget: vi.fn(),
connectionStates: new Map<string, SshConnectionState>()
}))
vi.mock('sonner', () => ({
toast: Object.assign(mocks.toast, { success: mocks.success, error: vi.fn() })
}))
vi.mock('@/store', () => {
const state = {
sshConnectionStates: new Map(),
sshConnectionStates: mocks.connectionStates,
recordFeatureInteraction: mocks.recordInteraction,
setSshTargetsMetadata: vi.fn(),
clearRemovedSshTargetState: vi.fn(),
recordSshRepoReadoptions: mocks.recordReadoptions,
openSettingsTarget: mocks.openSettingsTarget
openSettingsTarget: mocks.openSettingsTarget,
repos: [],
worktreesByRepo: {}
}
return {
useAppStore: Object.assign((select: (value: typeof state) => unknown) => select(state), {
@@ -46,10 +50,35 @@ vi.mock('./SshTargetDestructiveActions', () => ({
children: (actions: {
busyActionForTarget: () => undefined
requestRemove: () => void
requestBulkRemove: () => void
}) => ReactNode
}) => children({ busyActionForTarget: () => undefined, requestRemove: mocks.requestRemove })
}) =>
children({
busyActionForTarget: () => undefined,
requestRemove: mocks.requestRemove,
requestBulkRemove: mocks.requestBulkRemove
})
}))
vi.mock('./SshTargetServerStatus', () => ({ SshTargetServerStatus: () => null }))
vi.mock('./SshTargetServerStatus', () => ({
SshTargetServerStatusLine: () => null,
SshTargetServerMenuItems: () => null,
SshTargetServerDialogs: () => null
}))
// Radix menus open on pointer events happy-dom doesn't model; render items as plain buttons.
vi.mock('../ui/dropdown-menu', () => {
const passthrough = ({ children }: { children?: ReactNode }): ReactNode => children
return {
DropdownMenu: passthrough,
DropdownMenuTrigger: passthrough,
DropdownMenuContent: passthrough,
DropdownMenuSeparator: () => null,
DropdownMenuItem: ({ children, onSelect }: { children?: ReactNode; onSelect?: () => void }) => (
<button role="menuitem" onClick={() => onSelect?.()}>
{children}
</button>
)
}
})
vi.mock('./SshTargetsEmptyState', () => ({ SshTargetsEmptyState: () => null }))
let root: ReturnType<typeof createRoot> | null = null
@@ -64,6 +93,7 @@ afterEach(async () => {
root = null
Reflect.deleteProperty(window, 'api')
resetSshConfigPassiveSyncForTests()
mocks.connectionStates.clear()
vi.clearAllMocks()
await setRendererUiLanguage('en')
})
@@ -74,7 +104,9 @@ describe('SSH import count notices', () => {
async (language) => {
await setRendererUiLanguage(language)
const importConfig = vi.fn(
async (): Promise<{ targets: SshTarget[]; repoReadoptions: never[] }> => ({
async (_args?: {
reAdopt?: boolean
}): Promise<{ targets: SshTarget[]; repoReadoptions: never[] }> => ({
targets: [],
repoReadoptions: []
})
@@ -92,7 +124,9 @@ describe('SSH import count notices', () => {
expect(importConfig).toHaveBeenCalledTimes(1)
expect(mocks.success).not.toHaveBeenCalled()
const button = Array.from(container.querySelectorAll('button')).find((entry) =>
entry.textContent?.includes(language === 'ko' ? '가져오기' : 'Import')
entry.textContent?.includes(
language === 'ko' ? '~/.ssh/config 동기화' : 'Sync ~/.ssh/config'
)
)
expect(button).toBeTruthy()
for (const count of [0, 1, 2]) {
@@ -110,7 +144,11 @@ describe('SSH import count notices', () => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
if (count === 0) {
expect(mocks.toast).toHaveBeenLastCalledWith('~/.ssh/config already in sync')
expect(mocks.toast).toHaveBeenLastCalledWith(
language === 'ko'
? '~/.ssh/config가 이미 동기화되어 있습니다.'
: '~/.ssh/config already in sync'
)
expect(mocks.success).not.toHaveBeenCalled()
} else {
expect(mocks.success).toHaveBeenLastCalledWith(
@@ -121,6 +159,12 @@ describe('SSH import count notices', () => {
}
}
expect(importConfig).toHaveBeenCalledTimes(4)
// Sync must never lift the tombstones of hosts removed in Orca.
expect(importConfig.mock.calls.slice(1)).toEqual([
[{ reAdopt: false }],
[{ reAdopt: false }],
[{ reAdopt: false }]
])
expect(mocks.recordInteraction).toHaveBeenCalledTimes(3)
}
)
@@ -128,7 +172,7 @@ describe('SSH import count notices', () => {
// #28901: main refuses to remove a managed server's host, which surfaced as a raw IPC error.
describe('removing a managed server host', () => {
it('points to Managed servers instead of a removal main refuses', async () => {
it('points to the host’s Orca server row instead of a removal main refuses', async () => {
const removeTarget = vi.fn()
const managed: SshTarget = {
id: 'box',
@@ -158,16 +202,18 @@ describe('removing a managed server host', () => {
</TooltipProvider>
)
})
const remove = container.querySelector<HTMLButtonElement>('button[aria-label="Remove target"]')
const remove = Array.from(container.querySelectorAll('[role="menuitem"]')).find(
(item) => item.textContent === 'Remove…'
)
expect(remove).toBeTruthy()
await act(async () => {
remove?.click()
remove?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(mocks.requestRemove).not.toHaveBeenCalled()
expect(removeTarget).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('Orca runs on Build box')
const open = Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent === 'Open Orca on your SSH hosts'
(button) => button.textContent === 'Open Remote Orca Servers'
)
await act(async () => {
open?.click()
@@ -179,3 +225,183 @@ describe('removing a managed server host', () => {
})
})
})
describe('bulk removal from SSH settings', () => {
const host = (id: string, extra: Partial<SshTarget> = {}): SshTarget => ({
id,
label: id,
host: `${id}.example.test`,
port: 22,
username: 'dev',
source: 'ssh-config',
...extra
})
async function renderPane(targets: SshTarget[]): Promise<HTMLDivElement> {
Object.defineProperty(window, 'api', {
configurable: true,
value: {
ssh: {
importConfig: async () => ({ targets: [], repoReadoptions: [] }),
listTargets: async () => targets,
removeTarget: vi.fn(),
testConnection: async ({ targetId }: { targetId: string }) =>
targetId === 'beta' ? { success: false, error: 'timed out' } : { success: true }
}
}
})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root?.render(
<TooltipProvider>
<SshPane />
</TooltipProvider>
)
})
return container
}
function checkbox(host: HTMLElement, label: string): HTMLElement {
return host.querySelector<HTMLElement>(`[aria-label="Select ${label}"]`)!
}
it('confirms the plain hosts once and skips the managed server host', async () => {
const pane = await renderPane([
host('alpha'),
host('beta'),
host('gamma', { orcadFence: { environmentId: 'env-gamma' } })
])
await act(async () => checkbox(pane, 'alpha').click())
// Shift-click selects the range alpha..gamma.
await act(async () => {
checkbox(pane, 'gamma').dispatchEvent(
new MouseEvent('click', { bubbles: true, shiftKey: true })
)
})
expect(pane.textContent).toContain('3 selected')
const remove = Array.from(pane.querySelectorAll('button')).find(
(button) => button.textContent === 'Remove 3'
)
await act(async () => remove?.click())
expect(mocks.requestRemove).not.toHaveBeenCalled()
expect(mocks.requestBulkRemove).toHaveBeenCalledWith({
targets: [
{ id: 'alpha', label: 'alpha' },
{ id: 'beta', label: 'beta' }
],
connectedCount: 0
})
})
it('tests the selection and reports it in one toast', async () => {
mocks.connectionStates.set('gamma', {
targetId: 'gamma',
status: 'connected',
error: null,
reconnectAttempt: 0
})
const pane = await renderPane([host('alpha'), host('beta'), host('gamma')])
await act(async () => checkbox(pane, 'alpha').click())
await act(async () => checkbox(pane, 'beta').click())
await act(async () => checkbox(pane, 'gamma').click())
const test = Array.from(pane.querySelectorAll('button')).find(
(button) => button.textContent === 'Test'
)
await act(async () => test?.click())
// A connected host is skipped, as on its own row.
expect(mocks.toast).toHaveBeenCalledWith('1 reachable · 1 failed · 1 already connected', {
description: 'beta: timed out'
})
})
it('drops hosts the filter hides from the selection, so they are never removed unseen', async () => {
const names = ['alpha', 'beta', 'c3', 'c4', 'c5', 'c6', 'c7', 'c8', 'c9']
const pane = await renderPane(names.map((name) => host(name)))
await act(async () => checkbox(pane, 'alpha').click())
await act(async () => checkbox(pane, 'beta').click())
expect(pane.textContent).toContain('2 selected')
const filter = pane.querySelector<HTMLInputElement>('input[aria-label="Filter hosts"]')!
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(
filter,
'beta'
)
filter.dispatchEvent(new Event('input', { bubbles: true }))
})
expect(pane.textContent).toContain('1 selected')
const remove = Array.from(pane.querySelectorAll('button')).find(
(button) => button.textContent === 'Remove 1'
)
await act(async () => remove?.click())
expect(mocks.requestRemove).toHaveBeenCalledWith(expect.objectContaining({ id: 'beta' }))
expect(mocks.requestBulkRemove).not.toHaveBeenCalled()
// When a filter hides the last selected host, the bar says so instead of just vanishing.
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(filter, 'c3')
filter.dispatchEvent(new Event('input', { bubbles: true }))
})
expect(pane.textContent).not.toContain('selected ')
expect(pane.textContent).toContain('1 hidden host deselected')
})
it('keeps an active filter clearable when a reload drops the list under the threshold', async () => {
const names = ['alpha', 'beta', 'c3', 'c4', 'c5', 'c6', 'c7', 'c8', 'c9']
let listed = names.map((name) => host(name))
Object.defineProperty(window, 'api', {
configurable: true,
value: {
ssh: {
importConfig: async () => ({ targets: [], repoReadoptions: [] }),
listTargets: async () => listed
}
}
})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root?.render(<SshPane />)
})
const filter = () =>
container?.querySelector<HTMLInputElement>('input[aria-label="Filter hosts"]') ?? null
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(
filter(),
'zzz'
)
filter()?.dispatchEvent(new Event('input', { bubbles: true }))
})
listed = listed.slice(0, 3)
const sync = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent === 'Sync ~/.ssh/config'
)
await act(async () => sync?.click())
expect(container.querySelectorAll('[data-ssh-target-card]')).toHaveLength(0)
expect(filter()?.value).toBe('zzz')
})
it('groups hosts by where they came from', async () => {
const pane = await renderPane([host('alpha'), host('mine', { source: 'manual' })])
const groups = Array.from(pane.querySelectorAll('[data-ssh-target-group]')).map((group) => [
group.getAttribute('data-ssh-target-group'),
group.querySelectorAll('[data-ssh-target-card]').length
])
expect(groups).toEqual([
['ssh-config', 1],
['manual', 1]
])
})
it('clears the selection on Escape', async () => {
const pane = await renderPane([host('alpha'), host('beta')])
await act(async () => checkbox(pane, 'alpha').click())
expect(pane.textContent).toContain('1 selected')
await act(async () => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
})
expect(pane.textContent).not.toContain('selected')
})
})
+116 -135
View File
@@ -1,15 +1,25 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Plus, Upload } from 'lucide-react'
import type { SshTarget } from '../../../../shared/ssh-types'
import { useAppStore } from '@/store'
import { toastSshConnectFailure } from '@/ssh/ssh-connect-failure-toast'
import { useMountedRef } from '@/hooks/useMountedRef'
import { Button } from '../ui/button'
import { SshTargetCard } from './SshTargetCard'
import { SshTargetServerStatus } from './SshTargetServerStatus'
import { SshTargetList } from './SshTargetList'
import { SshTargetsEmptyState } from './SshTargetsEmptyState'
import { SshTargetDestructiveActions } from './SshTargetDestructiveActions'
import { SshHostsHeaderActions, SshHostsListToolbar } from './SshPaneToolbar'
import { SshHostsHeaderPortal } from './ssh-hosts-header-slot'
import { useSshTargetTests } from './use-ssh-target-tests'
import { SshRestoreRemovedHostsDialog } from './SshRestoreRemovedHostsDialog'
import { useSshConfigSync } from './use-ssh-config-sync'
import { useSshTargetSelection } from './use-ssh-target-selection'
import {
countSshTargetsBySource,
filterSshTargets,
SSH_TARGET_FILTER_THRESHOLD,
sshTargetDisplayOrder
} from './ssh-target-list-model'
import { useSshBulkRemove } from './use-ssh-bulk-remove'
import { SshTargetForm, EMPTY_FORM, type EditingTarget } from './SshTargetForm'
import { getEditingTargetForSshTarget } from './ssh-target-draft'
import { buildSshTargetSavePayload } from './ssh-target-save-payload'
@@ -39,7 +49,7 @@ export function SshPane({ addTargetIntentSignal }: SshPaneProps): React.JSX.Elem
// Why: gates the submit button and the Enter path so a double click cannot
// land two addTarget/updateTarget writes for one draft.
const [saving, setSaving] = useState(false)
const [testingIds, setTestingIds] = useState<Set<string>>(new Set())
const { testingIds, testOne, testMany, bulkTesting } = useSshTargetTests()
// Why: when a target still has workspaces, route removal through the shared
// workspace-aware HostRemoveDialog (same as the sidebar) instead of the plain
// confirm, so the user chooses to delete or keep them rather than silently
@@ -50,6 +60,25 @@ export function SshPane({ addTargetIntentSignal }: SshPaneProps): React.JSX.Elem
} | null>(null)
const [managedHostRemoveLabel, setManagedHostRemoveLabel] = useState<string | null>(null)
const mountedRef = useMountedRef()
const [filter, setFilter] = useState('')
const [restoreOpen, setRestoreOpen] = useState(false)
const visibleTargets = useMemo(() => filterSshTargets(targets, filter), [targets, filter])
const visibleIds = useMemo(() => sshTargetDisplayOrder(visibleTargets), [visibleTargets])
const selection = useSshTargetSelection(visibleIds)
// Hosts a filter change deselected; said once in the bulk slot so the bar doesn't just vanish.
const [hiddenDeselected, setHiddenDeselected] = useState(0)
if (hiddenDeselected > 0 && selection.selected.size > 0) {
setHiddenDeselected(0)
}
const changeFilter = (value: string): void => {
setFilter(value)
// Why: a host the filter hides must not stay selected and be removed unseen.
const nextVisible = new Set(filterSshTargets(targets, value).map((target) => target.id))
const dropped = [...selection.selected].filter((id) => !nextVisible.has(id)).length
selection.retain([...nextVisible])
setHiddenDeselected((prev) => (value.trim() === '' ? 0 : prev + dropped))
}
const totalBySource = useMemo(() => countSshTargetsBySource(targets), [targets])
const setSshTargetsMetadata = useAppStore((s) => s.setSshTargetsMetadata)
const clearRemovedSshTargetState = useAppStore((s) => s.clearRemovedSshTargetState)
@@ -97,6 +126,7 @@ export function SshPane({ addTargetIntentSignal }: SshPaneProps): React.JSX.Elem
setShowForm(true)
}, [])
useSshAddTargetIntent(addTargetIntentSignal, openAddTargetForm)
const { syncing, sync } = useSshConfigSync(loadTargets)
const handleSave = async (): Promise<void> => {
const savePayload = buildSshTargetSavePayload(form)
@@ -168,12 +198,16 @@ export function SshPane({ addTargetIntentSignal }: SshPaneProps): React.JSX.Elem
requestPlainRemove(target)
}
const removeTargetQuietly = async (id: string): Promise<void> => {
await window.api.ssh.removeTarget({ id })
// Why: a deleted passphrase-gated target may still have deferred
// reconnect metadata; clear it so focused SSH tabs stop retrying it.
clearRemovedSshTargetState(id)
}
const handleRemove = async (id: string): Promise<void> => {
try {
await window.api.ssh.removeTarget({ id })
// Why: a deleted passphrase-gated target may still have deferred
// reconnect metadata; clear it so focused SSH tabs stop retrying it.
clearRemovedSshTargetState(id)
await removeTargetQuietly(id)
if (mountedRef.current) {
toast.success(translate('auto.components.settings.SshPane.a0237eb1ca', 'Target removed'))
}
@@ -190,6 +224,14 @@ export function SshPane({ addTargetIntentSignal }: SshPaneProps): React.JSX.Elem
}
}
const { requestRemoveSelected, finishBulkRemove } = useSshBulkRemove({
targets,
selected: selection.selected,
clearSelection: selection.clear,
reload: loadTargets,
requestSingleRemove: requestRemoveTarget
})
const handleEdit = (target: SshTarget): void => {
setEditingId(target.id)
setForm(getEditingTargetForSshTarget(target))
@@ -222,77 +264,6 @@ export function SshPane({ addTargetIntentSignal }: SshPaneProps): React.JSX.Elem
}
}
const handleTest = async (targetId: string): Promise<void> => {
setTestingIds((prev) => new Set(prev).add(targetId))
try {
const result = await window.api.ssh.testConnection({ targetId })
recordFeatureInteraction('ssh')
if (mountedRef.current) {
if (result.success) {
toast.success(
translate('auto.components.settings.SshPane.81d08bcddf', 'Connection successful')
)
} else {
toast.error(
result.error ??
translate('auto.components.settings.SshPane.0cda732f43', 'Connection test failed')
)
}
}
} catch (err) {
if (mountedRef.current) {
toast.error(
err instanceof Error
? err.message
: translate('auto.components.settings.SshPane.68c13b4589', 'Test failed')
)
}
} finally {
if (mountedRef.current) {
setTestingIds((prev) => {
const next = new Set(prev)
next.delete(targetId)
return next
})
}
}
}
const handleImport = async (): Promise<void> => {
try {
// Why: the explicit Import action re-adopts every ~/.ssh/config host,
// including ones the user previously deleted — clear tombstones so a
// deliberate re-import can bring them back.
const result = await window.api.ssh.importConfig({ reAdopt: true })
useAppStore.getState().recordSshRepoReadoptions(result.repoReadoptions)
recordFeatureInteraction('ssh')
if (mountedRef.current) {
if (result.targets.length === 0) {
toast('~/.ssh/config already in sync')
} else {
toast.success(
translate(
result.targets.length === 1
? 'auto.components.settings.SshPane.syncedServerSingular'
: 'auto.components.settings.SshPane.syncedServerPlural',
result.targets.length === 1 ? 'Synced {{count}} server' : 'Synced {{count}} servers',
{ count: result.targets.length }
)
)
}
}
await loadTargets()
} catch (err) {
if (mountedRef.current) {
toast.error(
err instanceof Error
? err.message
: translate('auto.components.settings.SshPane.f495689b82', 'Import failed')
)
}
}
}
const cancelForm = (): void => {
setShowForm(false)
setEditingId(null)
@@ -300,69 +271,79 @@ export function SshPane({ addTargetIntentSignal }: SshPaneProps): React.JSX.Elem
}
return (
<div className="space-y-4">
{/* Header row */}
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<p className="text-sm font-medium">
{translate('auto.components.settings.SshPane.94c5284560', 'SSH hosts')}
</p>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.SshPane.a7d28dff81',
'Add an existing machine over SSH so projects and workspaces can run there.'
)}
</p>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Button
variant="outline"
size="xs"
onClick={() => void handleImport()}
className="gap-1.5"
>
<Upload className="size-3" />
{translate('auto.components.settings.SshPane.51d7dba44d', 'Import')}
</Button>
<Button variant="outline" size="xs" onClick={openAddTargetForm} className="gap-1.5">
<Plus className="size-3" />
{translate('auto.components.settings.SshPane.639ceb3698', 'Add Target')}
</Button>
</div>
</div>
<SshTargetDestructiveActions onRemove={handleRemove}>
{({ busyActionForTarget, requestRemove }) => (
<div className="space-y-3">
<SshHostsHeaderPortal>
<SshHostsHeaderActions
syncing={syncing}
onSync={() => void sync()}
onAdd={openAddTargetForm}
onRestoreRemoved={() => setRestoreOpen(true)}
/>
</SshHostsHeaderPortal>
<SshTargetDestructiveActions
onRemove={handleRemove}
removeQuietly={removeTargetQuietly}
onBulkRemoveFinished={finishBulkRemove}
>
{({ busyActionForTarget, requestRemove, requestBulkRemove }) => (
<>
{/* Target list */}
<SshHostsListToolbar
totalCount={targets.length}
visibleCount={visibleTargets.length}
selectedCount={selection.selected.size}
allVisibleSelected={visibleIds.every((id) => selection.selected.has(id))}
// Why: a reload can drop the list under the threshold; an active filter must stay clearable.
showFilter={targets.length > SSH_TARGET_FILTER_THRESHOLD || filter !== ''}
filter={filter}
busy={[...selection.selected].some((id) => busyActionForTarget(id) === 'remove')}
testing={bulkTesting}
hiddenDeselectedCount={hiddenDeselected}
onFilterChange={changeFilter}
onSelectVisible={() => selection.setGroup(visibleIds, true)}
onClearSelection={selection.clear}
onTestSelected={() =>
void testMany(targets.filter((target) => selection.selected.has(target.id)))
}
onRemoveSelected={() => requestRemoveSelected(requestBulkRemove, requestRemove)}
/>
{targets.length === 0 ? (
<SshTargetsEmptyState />
) : visibleTargets.length === 0 ? (
<p className="px-1 py-4 text-center text-xs text-muted-foreground">
{translate('auto.components.settings.SshPane.noFilterMatch', 'No hosts match.')}
</p>
) : (
<div className="space-y-2">
{targets.map((target) => (
<div key={target.id} className="space-y-1">
<SshTargetCard
target={target}
state={sshConnectionStates.get(target.id)}
testing={testingIds.has(target.id)}
busyAction={busyActionForTarget(target.id)}
onConnect={handleConnect}
onDisconnect={handleDisconnect}
onTest={handleTest}
onEdit={handleEdit}
onRemove={(id) =>
requestRemoveTarget({ id, label: target.label }, requestRemove)
}
/>
<SshTargetServerStatus target={target} onChanged={() => void loadTargets()} />
</div>
))}
</div>
<SshTargetList
targets={visibleTargets}
totalBySource={totalBySource}
connectionStates={sshConnectionStates}
testingIds={testingIds}
busyActionForTarget={busyActionForTarget}
selection={selection}
onConnect={handleConnect}
onDisconnect={handleDisconnect}
onTest={testOne}
onEdit={handleEdit}
onRemove={(target) => requestRemoveTarget(target, requestRemove)}
onServerChanged={() => void loadTargets()}
/>
)}
</>
)}
</SshTargetDestructiveActions>
<SshRestoreRemovedHostsDialog
open={restoreOpen}
busy={syncing}
onOpenChange={setRestoreOpen}
onConfirm={async () => {
await sync({ restoreRemoved: true })
if (mountedRef.current) {
setRestoreOpen(false)
}
}}
/>
{/* Why: modal keeps the form in viewport over long host lists (STA-3067). */}
<SshTargetForm
open={showForm}
@@ -0,0 +1,275 @@
import { useRef, useState } from 'react'
import {
Loader2,
MoreHorizontal,
PlugZap,
Plus,
RefreshCw,
RotateCcw,
Search,
Trash2,
X
} from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { ImeInput } from '@/lib/ime-text-field'
import { Button } from '../ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '../ui/dropdown-menu'
/** Section-header actions: sync, add, and the rare restore behind ⋯. */
export function SshHostsHeaderActions({
syncing,
onSync,
onAdd,
onRestoreRemoved
}: {
syncing: boolean
onSync: () => void
onAdd: () => void
onRestoreRemoved: () => void
}): React.JSX.Element {
// null while the menu's count is loading; the menu reads ~/.ssh/config only when opened.
const [removedCount, setRemovedCount] = useState<number | null>(null)
// Why: a quick close-and-reopen must not let the older read land over the newer one.
const requestRef = useRef(0)
const loadRemovedCount = (): void => {
const request = ++requestRef.current
setRemovedCount(null)
const apply = (count: number): void => {
if (request === requestRef.current) {
setRemovedCount(count)
}
}
window.api.ssh
.listConfigHosts({ refresh: true })
.then((result) => apply(result.hosts.filter((h) => h.previouslyRemoved).length))
// Why: an unreadable config shouldn't block restore; the dialog explains what it finds.
.catch(() => apply(-1))
}
// An ellipsis promises a dialog, so only the enabled item carries one.
const restoreLabel =
removedCount === null
? translate(
'auto.components.settings.SshPaneToolbar.restoreRemovedLoading',
'Restore removed hosts'
)
: removedCount === 0
? translate(
'auto.components.settings.SshPaneToolbar.restoreRemovedNone',
'No removed hosts to restore'
)
: removedCount < 0
? translate(
'auto.components.settings.SshPaneToolbar.restoreRemoved',
'Restore removed hosts…'
)
: translate(
'auto.components.settings.SshPaneToolbar.restoreRemovedCount',
'Restore removed hosts ({{count}})…',
{ count: removedCount }
)
return (
<>
<Button variant="outline" size="sm" onClick={onSync} disabled={syncing}>
{syncing ? <Loader2 className="animate-spin" /> : <RefreshCw />}
{translate('auto.components.settings.SshPaneToolbar.sync', 'Sync ~/.ssh/config')}
</Button>
<Button variant="outline" size="sm" onClick={onAdd}>
<Plus />
{translate('auto.components.settings.SshPane.addHost', 'Add host')}
</Button>
<DropdownMenu modal={false} onOpenChange={(open) => open && loadRemovedCount()}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
aria-label={translate(
'auto.components.settings.SshPaneToolbar.more',
'More SSH host actions'
)}
>
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={onRestoreRemoved}
disabled={syncing || removedCount === null || removedCount === 0}
>
<RotateCcw />
{restoreLabel}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)
}
type SshHostsListToolbarProps = {
totalCount: number
visibleCount: number
selectedCount: number
allVisibleSelected: boolean
showFilter: boolean
filter: string
busy: boolean
testing: boolean
/** Selected hosts a filter change hid and deselected. */
hiddenDeselectedCount: number
onFilterChange: (value: string) => void
onSelectVisible: () => void
onClearSelection: () => void
onTestSelected: () => void
onRemoveSelected: () => void
}
/**
* The row above the list. The filter stays put while selecting, so "filter, select all, remove"
* works; only the right side swaps to bulk actions, whose controls keep fixed slots.
*/
export function SshHostsListToolbar(props: SshHostsListToolbarProps): React.JSX.Element {
const filtering = props.filter.trim() !== ''
return (
<div className="flex min-h-8 flex-wrap items-center justify-between gap-x-3 gap-y-2">
<div className="min-w-48 flex-1">
{props.showFilter ? (
<SshHostFilterField value={props.filter} onChange={props.onFilterChange} />
) : (
<span className="text-xs text-muted-foreground">
{translate('auto.components.settings.SshPaneToolbar.hostCount', '{{count}} hosts', {
count: props.totalCount
})}
</span>
)}
</div>
{props.selectedCount > 0 ? (
<div className="ml-auto flex shrink-0 items-center gap-1">
<span className="mr-1 text-xs font-medium tabular-nums" aria-live="polite">
{translate('auto.components.settings.SshPaneToolbar.selected', '{{count}} selected', {
count: props.selectedCount
})}
</span>
<Button
variant="ghost"
size="xs"
onClick={props.onSelectVisible}
disabled={props.allVisibleSelected}
>
{filtering
? translate(
'auto.components.settings.SshPaneToolbar.selectMatching',
'Select all {{count}} matching',
{ count: props.visibleCount }
)
: translate(
'auto.components.settings.SshPaneToolbar.selectAll',
'Select all {{count}}',
{ count: props.totalCount }
)}
</Button>
<Button variant="ghost" size="xs" onClick={props.onClearSelection}>
{translate('auto.components.settings.SshPaneToolbar.clear', 'Clear')}
</Button>
<Button
variant="outline"
size="sm"
className="ml-1"
onClick={props.onTestSelected}
disabled={props.busy || props.testing}
>
{props.testing ? <Loader2 className="animate-spin" /> : <PlugZap />}
{translate('auto.components.settings.SshPaneToolbar.testSelected', 'Test')}
</Button>
<Button
variant="destructive"
size="sm"
onClick={props.onRemoveSelected}
disabled={props.busy}
>
<Trash2 />
<span className="tabular-nums">
{translate(
'auto.components.settings.SshPaneToolbar.removeSelected',
'Remove {{count}}',
{ count: props.selectedCount }
)}
</span>
</Button>
</div>
) : props.hiddenDeselectedCount > 0 ? (
<span className="ml-auto text-xs text-muted-foreground" aria-live="polite">
{props.hiddenDeselectedCount === 1
? translate(
'auto.components.settings.SshPaneToolbar.hiddenDeselectedSingular',
'{{count}} hidden host deselected',
{ count: 1 }
)
: translate(
'auto.components.settings.SshPaneToolbar.hiddenDeselectedPlural',
'{{count}} hidden hosts deselected',
{ count: props.hiddenDeselectedCount }
)}
</span>
) : null}
</div>
)
}
function SshHostFilterField({
value,
onChange
}: {
value: string
onChange: (value: string) => void
}): React.JSX.Element {
const inputRef = useRef<HTMLInputElement>(null)
const label = translate('auto.components.settings.SshPaneToolbar.filter', 'Filter hosts')
// Why not <Input>: the icon and clear button live inside the field, which the primitive's
// fixed padding can't host; this mirrors its border, fill and focus ring on the wrapper.
return (
<div className="flex h-8 max-w-64 items-center gap-2 rounded-md border border-input px-2.5 shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 dark:bg-input/30">
<Search className="size-3.5 shrink-0 text-muted-foreground" />
<ImeInput
ref={inputRef}
value={value}
aria-label={label}
placeholder={label}
className="h-full min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground/60"
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape' && value !== '' && !event.nativeEvent.isComposing) {
// Why: the first Escape clears the text, not the selection or Settings.
event.preventDefault()
event.stopPropagation()
onChange('')
}
}}
/>
{value !== '' ? (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="-mr-1.5"
aria-label={translate(
'auto.components.settings.SshPaneToolbar.clearFilter',
'Clear filter'
)}
// Why: clearing unmounts this button; keep focus in the field being typed in.
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
onChange('')
inputRef.current?.focus()
}}
>
<X />
</Button>
) : null}
</div>
)
}
@@ -0,0 +1,125 @@
import { useEffect, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { Button } from '../ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '../ui/dialog'
type RemovedAliases =
| { status: 'loading' }
| { status: 'ready'; aliases: string[] }
| { status: 'unavailable' }
/** Confirms bringing back ~/.ssh/config hosts that were removed in Orca, naming them first. */
export function SshRestoreRemovedHostsDialog({
open,
busy,
onOpenChange,
onConfirm
}: {
open: boolean
busy: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void | Promise<void>
}): React.JSX.Element {
const [removed, setRemoved] = useState<RemovedAliases>({ status: 'loading' })
useEffect(() => {
if (!open) {
return
}
let cancelled = false
// Why: a restore closes this dialog from outside, so a reopen must not show the last list.
setRemoved({ status: 'loading' })
window.api.ssh
.listConfigHosts({ refresh: true })
.then((result) => {
if (!cancelled) {
const aliases = result.hosts.filter((host) => host.previouslyRemoved)
setRemoved({ status: 'ready', aliases: aliases.map((host) => host.alias) })
}
})
// Why: the list only informs the confirm; restore still works when it can't be read.
.catch(() => !cancelled && setRemoved({ status: 'unavailable' }))
return () => {
cancelled = true
}
}, [open])
const aliases = removed.status === 'ready' ? removed.aliases : []
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (busy) {
return
}
if (!next) {
setRemoved({ status: 'loading' })
}
onOpenChange(next)
}}
>
<DialogContent className="max-w-sm sm:max-w-sm" showCloseButton={false}>
<DialogHeader>
<DialogTitle>
{translate(
'auto.components.settings.SshRestoreRemovedHosts.title',
'Restore removed hosts?'
)}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.settings.SshRestoreRemovedHosts.description',
'Hosts from ~/.ssh/config that you removed in Orca come back in Settings and in every host picker.'
)}
</DialogDescription>
</DialogHeader>
{removed.status === 'unavailable' ? null : (
<div className="scrollbar-sleek max-h-32 overflow-y-auto rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs text-muted-foreground">
{removed.status === 'loading' ? (
<Loader2 className="size-3 animate-spin" />
) : aliases.length > 0 ? (
<span className="break-all">{aliases.join(', ')}</span>
) : (
translate(
'auto.components.settings.SshRestoreRemovedHosts.none',
'No removed hosts from ~/.ssh/config.'
)
)}
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setRemoved({ status: 'loading' })
onOpenChange(false)
}}
disabled={busy}
>
{translate('auto.components.settings.SshDestructiveActionDialog.895b216267', 'Cancel')}
</Button>
<Button
onClick={() => void onConfirm()}
disabled={
busy ||
removed.status === 'loading' ||
(removed.status === 'ready' && aliases.length === 0)
}
className="gap-1.5"
>
{busy ? <Loader2 className="size-3 animate-spin" /> : null}
{translate('auto.components.settings.SshRestoreRemovedHosts.confirm', 'Restore')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -2,13 +2,18 @@
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SshTargetCard } from './SshTargetCard'
import { TooltipProvider } from '../ui/tooltip'
import type { SshConnectionState, SshTarget } from '../../../../shared/ssh-types'
beforeEach(() => {
Object.assign(window, { api: { runtimeEnvironments: {} } })
})
afterEach(() => {
document.body.innerHTML = ''
Reflect.deleteProperty(window, 'api')
})
const target: SshTarget = {
@@ -97,3 +102,37 @@ describe('an SSH host its managed server does not serve', () => {
expect(container.textContent).not.toContain('This SSH host isn’t supported')
})
})
describe('the compact SSH host row', () => {
it('stays quiet while disconnected and names the key file only', async () => {
const container = await renderCard(undefined, {
...target,
identityFile: '/Users/me/.ssh/id_ed25519'
})
expect(container.textContent).toContain('deploy@build-01.internal:22 · id_ed25519')
expect(container.textContent).not.toContain('Disconnected')
expect(container.textContent).toContain('Connect')
})
it('shows a live status and offers Disconnect when connected', async () => {
const container = await renderCard({
targetId: 'target-1',
status: 'connected',
error: null,
reconnectAttempt: 0
})
expect(container.textContent).toContain('Connected')
expect(container.textContent).toContain('Disconnect')
})
it('marks a host held by a managed server with a chip', async () => {
const container = await renderCard(undefined, {
...target,
orcadFence: { environmentId: 'env-1' }
})
expect(container.textContent).toContain('Orca server')
})
})
@@ -1,24 +1,41 @@
import { useCallback, useRef, useState } from 'react'
import { Loader2, MonitorSmartphone, Pencil, Server, ServerOff, Trash2 } from 'lucide-react'
import { Loader2, MoreHorizontal, Pencil, PlugZap, Trash2 } from 'lucide-react'
import type {
SshConnectionState,
SshConnectionStatus,
SshTarget
} from '../../../../shared/ssh-types'
import { Badge } from '../ui/badge'
import { Button } from '../ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { Checkbox } from '../ui/checkbox'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '../ui/dropdown-menu'
import { isSshTargetConnecting, type SshTargetBusyAction } from './ssh-target-action-state'
import { sshIdentityFileName } from './ssh-target-list-model'
import { isManagedServerSshHost } from './SshManagedHostRemoveDialog'
import {
SshTargetServerDialogs,
SshTargetServerMenuItems,
SshTargetServerStatusLine,
type SshTargetServerDialog
} from './SshTargetServerStatus'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
// ── Shared status helpers ────────────────────────────────────────────
export const STATUS_LABELS: Record<SshConnectionStatus, string> = {
disconnected: 'Disconnected',
connecting: 'Connecting\u2026',
connecting: 'Connecting…',
'auth-failed': 'Auth failed',
'deploying-relay': 'Deploying relay\u2026',
'deploying-relay': 'Deploying relay…',
connected: 'Connected',
reconnecting: 'Reconnecting\u2026',
reconnecting: 'Reconnecting…',
'reconnection-failed': 'Reconnection failed',
get error() {
return translate('auto.components.settings.SshTargetCard.18968ede9e', 'Error')
@@ -28,15 +45,15 @@ export const STATUS_LABELS: Record<SshConnectionStatus, string> = {
export function statusColor(status: SshConnectionStatus): string {
switch (status) {
case 'connected':
return 'bg-emerald-500'
return 'bg-status-success'
case 'connecting':
case 'deploying-relay':
case 'reconnecting':
return 'bg-yellow-500'
return 'bg-status-warning'
case 'auth-failed':
case 'reconnection-failed':
case 'error':
return 'bg-red-500'
return 'bg-destructive'
case 'disconnected':
return 'bg-muted-foreground/40'
}
@@ -49,32 +66,43 @@ type SshTargetCardProps = {
state: SshConnectionState | undefined
testing: boolean
busyAction?: SshTargetBusyAction
selected?: boolean
onSelectClick?: (targetId: string, shiftKey: boolean) => void
onConnect: (targetId: string) => void | Promise<void>
onDisconnect: (targetId: string) => void | Promise<void>
onTest: (targetId: string) => void | Promise<void>
onEdit: (target: SshTarget) => void
onRemove: (targetId: string) => void
onServerChanged?: () => void
}
/** One compact host row: select, name and endpoint, live status, one primary action, and a menu. */
export function SshTargetCard({
target,
state,
testing,
busyAction,
selected = false,
onSelectClick,
onConnect,
onDisconnect,
onTest,
onEdit,
onRemove
onRemove,
onServerChanged
}: SshTargetCardProps): React.JSX.Element {
const status: SshConnectionStatus = state?.status ?? 'disconnected'
const [actionInFlight, setActionInFlight] = useState<'connect' | 'disconnect' | null>(null)
const [serverDialog, setServerDialog] = useState<SshTargetServerDialog | null>(null)
const hasActionInFlight = actionInFlight !== null || busyAction !== undefined
const removeInFlight = busyAction === 'remove'
const mountedRef = useRef(true)
const endpoint = target.username
? `${target.username}@${target.host}:${target.port}`
: `${target.host}:${target.port}`
const keyName = sshIdentityFileName(target.identityFile)
const servedByOrcaServer =
isManagedServerSshHost(target) || state?.managedServer?.kind === 'managed'
const handleCardRef = useCallback((node: HTMLDivElement | null): void => {
// Why: SSH target actions can resolve after the card is removed; the root
@@ -104,140 +132,180 @@ export function SshTargetCard({
void Promise.resolve(onDisconnect(target.id)).finally(clearActionInFlight)
}
const renderSecondaryIconActions = (): React.JSX.Element => (
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onEdit(target)}
className="size-7"
disabled={hasActionInFlight}
aria-label={translate(
'auto.components.settings.SshTargetCard.3d8af2949f',
'Edit target'
)}
>
<Pencil className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate('auto.components.settings.SshTargetCard.3d8af2949f', 'Edit target')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onRemove(target.id)}
className="size-7 text-muted-foreground hover:text-red-400"
disabled={hasActionInFlight}
aria-label={
removeInFlight
? translate('auto.components.settings.SshTargetCard.3d21a22d0e', 'Removing target')
: translate('auto.components.settings.SshTargetCard.7f7b3d7ab4', 'Remove target')
}
>
{removeInFlight ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Trash2 className="size-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate('auto.components.settings.SshTargetCard.7f7b3d7ab4', 'Remove target')}
</TooltipContent>
</Tooltip>
</div>
)
const renderStatus = (): React.JSX.Element | null => {
if (removeInFlight || testing) {
return (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
{removeInFlight
? translate('auto.components.settings.SshTargetCard.removing', 'Removing…')
: translate('auto.components.settings.SshTargetCard.testing', 'Testing…')}
</span>
)
}
// Disconnected is the resting state of every saved host, so it stays unlabeled.
if (status === 'disconnected') {
return null
}
return (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className={cn('size-1.5 shrink-0 rounded-full', statusColor(status))} />
{STATUS_LABELS[status]}
</span>
)
}
const renderPrimaryAction = (): React.JSX.Element => {
if (status === 'connected') {
return (
<Button variant="ghost" size="xs" onClick={handleDisconnect} disabled={hasActionInFlight}>
{translate('auto.components.settings.SshTargetCard.4c86f30877', 'Disconnect')}
</Button>
)
}
if (isSshTargetConnecting(status)) {
return (
<Button variant="ghost" size="xs" disabled>
{translate('auto.components.settings.SshTargetCard.1810b51482', 'Connecting')}
</Button>
)
}
const button = (
<Button variant="ghost" size="xs" onClick={handleConnect} disabled={hasActionInFlight}>
{actionInFlight === 'connect' ? <Loader2 className="size-3 animate-spin" /> : null}
{translate('auto.components.settings.SshTargetCard.ec6543cee9', 'Connect')}
</Button>
)
if (status !== 'disconnected' || actionInFlight === 'connect') {
return button
}
// Why: a column of identical "Connect" words on resting rows scans as noise; it appears on
// hover or keyboard focus and keeps its width so nothing shifts.
return (
<span className="opacity-0 transition-opacity group-hover/row:opacity-100 group-has-[:focus-visible]/row:opacity-100">
{button}
</span>
)
}
return (
<div
ref={handleCardRef}
data-ssh-target-card=""
data-ssh-target-label={target.label}
className="flex items-center gap-3 rounded-lg border border-border/50 bg-card/40 px-4 py-3"
data-selected={selected ? 'true' : undefined}
className="group/row flex items-start gap-3 px-3 py-2 transition-colors hover:bg-accent/40 data-[selected=true]:bg-accent/60 has-[[data-slot=checkbox]:focus-visible]:ring-2 has-[[data-slot=checkbox]:focus-visible]:ring-ring/60 has-[[data-slot=checkbox]:focus-visible]:ring-inset"
>
<Server className="size-4 shrink-0 text-muted-foreground" />
{onSelectClick ? (
<Checkbox
className="mt-0.5"
checked={selected}
aria-label={translate(
'auto.components.settings.SshTargetCard.select',
'Select {{label}}',
{
label: target.label
}
)}
onClick={(event) => {
// Why: Radix toggles on click with no modifier info; selection owns the state instead.
event.preventDefault()
onSelectClick(target.id, event.shiftKey)
}}
/>
) : null}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="flex min-w-0 items-baseline gap-2">
<span className="truncate text-sm font-medium">{target.label}</span>
<span className={`size-2 shrink-0 rounded-full ${statusColor(status)}`} />
<span className="text-[11px] text-muted-foreground">{STATUS_LABELS[status]}</span>
{servedByOrcaServer ? (
<Badge variant="hostContext" className="self-center">
{translate('auto.components.settings.SshTargetCard.orcaServer', 'Orca server')}
</Badge>
) : null}
{/* Why the separator sits outside the mono spans: a monospace "·" reads visibly wider. */}
<span className="min-w-0 truncate text-xs text-muted-foreground">
<span className="font-mono">{endpoint}</span>
{keyName ? (
<>
{' '}
<span className="px-0.5">·</span> <span className="font-mono">{keyName}</span>
</>
) : null}
</span>
</div>
<p className="truncate text-xs text-muted-foreground">
{endpoint}
{target.identityFile ? ` \u2022 ${target.identityFile}` : ''}
</p>
{/* Why not truncate: host key failures put the remedy (`ssh-keygen -R <host>`) at the end,
and a one-line clamp with no tooltip made it unreachable even on hover. */}
{/* An unserved host's status line below says why, translated; this is its English twin. */}
{state?.error && state.managedServer?.kind !== 'relay' ? (
<p className="mt-0.5 text-xs text-red-400 [overflow-wrap:anywhere]">{state.error}</p>
<p className="mt-0.5 text-xs text-destructive [overflow-wrap:anywhere]">{state.error}</p>
) : null}
<SshTargetServerStatusLine target={target} state={state} />
</div>
<div className="flex shrink-0 items-center gap-1">
{status === 'connected' ? (
<>
{renderSecondaryIconActions()}
<div className="flex shrink-0 items-center gap-2">
{renderStatus()}
{renderPrimaryAction()}
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="xs"
onClick={handleDisconnect}
className="gap-1.5"
disabled={hasActionInFlight}
>
<ServerOff className="size-3" />
{translate('auto.components.settings.SshTargetCard.4c86f30877', 'Disconnect')}
</Button>
</>
) : isSshTargetConnecting(status) ? (
<>
{renderSecondaryIconActions()}
<Button variant="ghost" size="xs" disabled className="gap-1.5">
<Loader2 className="size-3 animate-spin" />
{translate('auto.components.settings.SshTargetCard.1810b51482', 'Connecting')}
</Button>
</>
) : (
<>
{renderSecondaryIconActions()}
<Button
variant="ghost"
size="xs"
onClick={() => onTest(target.id)}
disabled={testing || hasActionInFlight}
className="gap-1.5"
>
{testing ? (
<Loader2 className="size-3 animate-spin" />
) : (
<MonitorSmartphone className="size-3" />
size="icon-xs"
disabled={removeInFlight}
aria-label={translate(
'auto.components.settings.SshTargetCard.moreActions',
'More actions for {{label}}',
{ label: target.label }
)}
{translate('auto.components.settings.SshTargetCard.0e53e9f8e8', 'Test')}
</Button>
<Button
variant="ghost"
size="xs"
onClick={handleConnect}
className="gap-1.5"
disabled={hasActionInFlight}
>
{actionInFlight === 'connect' ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Server className="size-3" />
)}
{translate('auto.components.settings.SshTargetCard.ec6543cee9', 'Connect')}
<MoreHorizontal />
</Button>
</>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-44">
<DropdownMenuItem
disabled={
testing ||
hasActionInFlight ||
status === 'connected' ||
isSshTargetConnecting(status)
}
onSelect={() => void onTest(target.id)}
>
<PlugZap />
{translate(
'auto.components.settings.SshTargetCard.testConnection',
'Test connection'
)}
</DropdownMenuItem>
<DropdownMenuItem disabled={hasActionInFlight} onSelect={() => onEdit(target)}>
<Pencil />
{translate('auto.components.settings.SshTargetCard.edit', 'Edit…')}
</DropdownMenuItem>
<SshTargetServerMenuItems
target={target}
state={state}
onOpenDialog={setServerDialog}
/>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
disabled={hasActionInFlight}
onSelect={() => onRemove(target.id)}
>
<Trash2 />
{translate('auto.components.settings.SshTargetCard.remove', 'Remove…')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<SshTargetServerDialogs
target={target}
state={state}
dialog={serverDialog}
onDialogChange={setServerDialog}
onChanged={() => onServerChanged?.()}
/>
</div>
)
}
@@ -0,0 +1,128 @@
// @vitest-environment happy-dom
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
SshTargetDestructiveActions,
type SshBulkRemoveRequest,
type SshBulkRemoveResult
} from './SshTargetDestructiveActions'
vi.mock('../ui/dialog', () => ({
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
open ? <div>{children}</div> : null,
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
DialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>
}))
const roots: Root[] = []
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => root.unmount())
}
document.body.innerHTML = ''
})
function button(text: string): HTMLButtonElement | undefined {
return [...document.querySelectorAll('button')].find((entry) => entry.textContent === text)
}
async function renderBulk(
request: SshBulkRemoveRequest,
removeQuietly: (id: string) => Promise<void>,
onFinished: (result: SshBulkRemoveResult) => void
): Promise<void> {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
roots.push(root)
await act(async () => {
root.render(
<SshTargetDestructiveActions
onRemove={vi.fn()}
removeQuietly={removeQuietly}
onBulkRemoveFinished={onFinished}
>
{({ requestBulkRemove }) => (
<button onClick={() => requestBulkRemove(request)}>open bulk</button>
)}
</SshTargetDestructiveActions>
)
})
await act(async () => button('open bulk')?.click())
}
describe('bulk SSH host removal', () => {
it('lists the hosts in one confirm and removes them one by one with progress', async () => {
const order: string[] = []
let releaseFirst: () => void = () => {}
const refusal = new Error('refused')
const removeQuietly = vi.fn(async (id: string) => {
order.push(id)
if (id === 'a') {
await new Promise<void>((resolve) => {
releaseFirst = resolve
})
}
if (id === 'b') {
throw refusal
}
})
const onFinished = vi.fn()
await renderBulk(
{
targets: [
{ id: 'a', label: 'alpha' },
{ id: 'b', label: 'beta' },
{ id: 'c', label: 'gamma' }
],
connectedCount: 1
},
removeQuietly,
onFinished
)
expect(document.body.textContent).toContain('Remove 3 SSH hosts?')
expect([...document.querySelectorAll('li')].map((item) => item.textContent)).toEqual([
'alpha',
'beta',
'gamma'
])
expect(document.body.textContent).toContain('1 host is connected')
expect(document.body.textContent).toContain('later syncs won’t bring them back')
await act(async () => {
button('Remove 3')?.click()
})
expect(document.body.textContent).toContain('Removing 1 of 3…')
expect(removeQuietly).toHaveBeenCalledTimes(1)
await act(async () => releaseFirst())
expect(order).toEqual(['a', 'b', 'c'])
expect(onFinished).toHaveBeenCalledTimes(1)
expect(onFinished).toHaveBeenCalledWith({
removed: [
{ id: 'a', label: 'alpha' },
{ id: 'c', label: 'gamma' }
],
failed: [{ target: { id: 'b', label: 'beta' }, error: refusal }]
})
expect(document.body.textContent).not.toContain('Remove 3 SSH hosts?')
})
})
describe('the bulk confirm list', () => {
it('shows the first eight names and a count past ten', async () => {
const targets = Array.from({ length: 12 }, (_, index) => ({
id: `h${index}`,
label: `host-${index}`
}))
await renderBulk({ targets, connectedCount: 0 }, vi.fn(), vi.fn())
const items = [...document.querySelectorAll('li')].map((item) => item.textContent)
expect(items).toHaveLength(9)
expect(items.at(-1)).toBe('+4 more')
})
})
@@ -7,21 +7,40 @@ import { translate } from '@/i18n/i18n'
type PendingTargetAction = { id: string; label: string }
export type SshBulkRemoveRequest = {
targets: PendingTargetAction[]
/** Connected hosts lose their remote terminals; the confirm says how many. */
connectedCount: number
}
export type SshBulkRemoveResult = {
removed: PendingTargetAction[]
failed: { target: PendingTargetAction; error: unknown }[]
}
type SshTargetDestructiveActionsRenderProps = {
busyActionForTarget: (targetId: string) => SshTargetBusyAction | undefined
requestRemove: (target: PendingTargetAction) => void
requestBulkRemove: (request: SshBulkRemoveRequest) => void
}
type SshTargetDestructiveActionsProps = {
onRemove: (targetId: string) => Promise<void>
/** One removal inside a bulk run; throws on failure and leaves reporting to the summary. */
removeQuietly?: (targetId: string) => Promise<void>
onBulkRemoveFinished?: (result: SshBulkRemoveResult) => void | Promise<void>
children: (actions: SshTargetDestructiveActionsRenderProps) => ReactNode
}
export function SshTargetDestructiveActions({
onRemove,
removeQuietly,
onBulkRemoveFinished,
children
}: SshTargetDestructiveActionsProps): React.JSX.Element {
const [pendingRemove, setPendingRemove] = useState<PendingTargetAction | null>(null)
const [pendingBulk, setPendingBulk] = useState<SshBulkRemoveRequest | null>(null)
const [bulkProgress, setBulkProgress] = useState<number | null>(null)
const mountedRef = useMountedRef()
// Why: a confirmed removal keeps running after the dialog click, so this
// state blocks an overlapping removal of the same target.
@@ -78,6 +97,37 @@ export function SshTargetDestructiveActions({
}
}
const runBulkRemove = async (): Promise<void> => {
if (!pendingBulk || bulkProgress !== null) {
return
}
// Why sequential: each removal tears down the host's PTYs and rewrites the same store file.
const claimed = pendingBulk.targets.filter((target) => beginTargetAction(target.id, 'remove'))
const result: SshBulkRemoveResult = { removed: [], failed: [] }
const remove = removeQuietly ?? onRemove
try {
for (const [index, target] of claimed.entries()) {
if (mountedRef.current) {
setBulkProgress(index + 1)
}
try {
await remove(target.id)
result.removed.push(target)
} catch (err) {
result.failed.push({ target, error: err })
} finally {
finishTargetAction(target.id)
}
}
} finally {
if (mountedRef.current) {
setBulkProgress(null)
setPendingBulk(null)
}
}
await onBulkRemoveFinished?.(result)
}
const pendingRemoveIsBusy =
pendingRemove !== null && targetActionsInFlight.get(pendingRemove.id) === 'remove'
const actions: SshTargetDestructiveActionsRenderProps = {
@@ -86,8 +136,17 @@ export function SshTargetDestructiveActions({
if (!targetActionsInFlightRef.current.has(target.id)) {
setPendingRemove(target)
}
},
requestBulkRemove: (request) => {
const targets = request.targets.filter(
(target) => !targetActionsInFlightRef.current.has(target.id)
)
if (targets.length > 0 && bulkProgress === null) {
setPendingBulk({ ...request, targets })
}
}
}
const bulkCount = pendingBulk?.targets.length ?? 0
return (
<>
@@ -119,6 +178,60 @@ export function SshTargetDestructiveActions({
runConfirmedTargetAction(pendingRemove, 'remove', onRemove, () => setPendingRemove(null))
}
/>
<SshDestructiveActionDialog
open={!!pendingBulk}
title={
bulkCount === 1
? translate(
'auto.components.settings.SshTargetDestructiveActions.bulkTitleSingular',
'Remove {{count}} SSH host?',
{ count: bulkCount }
)
: translate(
'auto.components.settings.SshTargetDestructiveActions.bulkTitlePlural',
'Remove {{count}} SSH hosts?',
{ count: bulkCount }
)
}
description={translate(
'auto.components.settings.SshTargetDestructiveActions.bulkDescription',
'Removed hosts disappear from host pickers in workspaces, automations and the sidebar. Your ~/.ssh/config is not changed, and later syncs won’t bring them back.'
)}
targetLabels={pendingBulk?.targets.map((target) => target.label)}
notice={
!pendingBulk || pendingBulk.connectedCount === 0
? undefined
: pendingBulk.connectedCount === 1
? translate(
'auto.components.settings.SshTargetDestructiveActions.bulkConnectedSingular',
'{{count}} host is connected. Its remote terminals will be closed.',
{ count: 1 }
)
: translate(
'auto.components.settings.SshTargetDestructiveActions.bulkConnectedPlural',
'{{count}} hosts are connected. Their remote terminals will be closed.',
{ count: pendingBulk.connectedCount }
)
}
actionLabel={translate(
'auto.components.settings.SshTargetDestructiveActions.bulkAction',
'Remove {{count}}',
{ count: bulkCount }
)}
busyLabel={translate(
'auto.components.settings.SshTargetDestructiveActions.bulkProgress',
'Removing {{done}} of {{count}}…',
{ done: bulkProgress ?? 0, count: bulkCount }
)}
isBusy={bulkProgress !== null}
onOpenChange={(open) => {
if (!open && bulkProgress === null) {
setPendingBulk(null)
}
}}
onConfirm={runBulkRemove}
/>
</>
)
}
@@ -60,7 +60,7 @@ describe('SshTargetForm', () => {
)
expect(document.querySelector('#ssh-target-host')).not.toBeNull()
expect(document.querySelector('#ssh-target-label')).not.toBeNull()
expect(document.body.textContent).toContain('Add Target')
expect(document.body.textContent).toContain('Add host')
expect(document.body.textContent).not.toContain('Editing')
act(() => root.unmount())
})
@@ -110,7 +110,7 @@ describe('SshTargetForm', () => {
const root = await renderForm({ onSave, onOpenChange })
await act(async () => {
button('Add Target').dispatchEvent(new MouseEvent('click', { bubbles: true }))
button('Add host').dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(onSave).toHaveBeenCalledOnce()
@@ -151,7 +151,7 @@ describe('SshTargetForm', () => {
const onSave = vi.fn()
const root = await renderForm({ saving: true, onSave })
expect(button('Add Target').disabled).toBe(true)
expect(button('Add host').disabled).toBe(true)
// Why: Enter submits past a disabled button, so the form itself must gate too.
await act(async () => {
document
@@ -253,7 +253,7 @@ export function SshTargetForm({
<Button type="submit" size="sm" disabled={saving}>
{isEditing
? translate('auto.components.settings.SshTargetForm.a62b4cb39a', 'Save Changes')
: translate('auto.components.settings.SshTargetForm.9518545cb6', 'Add Target')}
: translate('auto.components.settings.SshTargetForm.addHost', 'Add host')}
</Button>
</DialogFooter>
</form>
@@ -0,0 +1,41 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from 'vitest'
import { isListOwnSelectAllTarget } from './SshTargetList'
afterEach(() => {
document.body.innerHTML = ''
})
function mount(html: string): HTMLElement {
const list = document.createElement('div')
list.innerHTML = html
document.body.append(list)
return list
}
describe('⌘/Ctrl-A on the SSH host list', () => {
it('selects hosts from a row control inside the list', () => {
const list = mount('<button id="row">Connect</button>')
expect(isListOwnSelectAllTarget(list, list.querySelector('#row'))).toBe(true)
})
// React bubbles keydown through portals, so a row dialog's event reaches the list handler.
it('ignores events from portalled dialogs and menus outside the list', () => {
const list = mount('<button>Connect</button>')
const dialog = document.createElement('div')
dialog.setAttribute('role', 'dialog')
dialog.innerHTML = '<button id="inside">Restart</button>'
document.body.append(dialog)
expect(isListOwnSelectAllTarget(list, dialog.querySelector('#inside'))).toBe(false)
})
it('leaves text selection alone in fields, menus and detail text inside the list', () => {
const list = mount(
'<input id="field" /><div role="menu"><div id="item">Edit</div></div><pre id="detail">log</pre>'
)
for (const id of ['field', 'item', 'detail']) {
expect(isListOwnSelectAllTarget(list, list.querySelector(`#${id}`))).toBe(false)
}
})
})
@@ -0,0 +1,155 @@
import type { SshConnectionState, SshTarget } from '../../../../shared/ssh-types'
import { translate } from '@/i18n/i18n'
import { Checkbox } from '../ui/checkbox'
import type { SshTargetBusyAction } from './ssh-target-action-state'
import {
groupSshTargetsBySource,
sshTargetGroupCheckState,
type SshTargetGroup,
type SshTargetGroupId
} from './ssh-target-list-model'
import { SshTargetCard } from './SshTargetCard'
import type { SshTargetSelection } from './use-ssh-target-selection'
type SshTargetListProps = {
targets: readonly SshTarget[]
/** Unfiltered group sizes, so a filtered group reads "2 of 17" rather than shrinking silently. */
totalBySource: Record<SshTargetGroupId, number>
connectionStates: ReadonlyMap<string, SshConnectionState>
testingIds: ReadonlySet<string>
busyActionForTarget: (targetId: string) => SshTargetBusyAction | undefined
selection: SshTargetSelection
onConnect: (targetId: string) => void | Promise<void>
onDisconnect: (targetId: string) => void | Promise<void>
onTest: (targetId: string) => void | Promise<void>
onEdit: (target: SshTarget) => void
onRemove: (target: SshTarget) => void
onServerChanged: () => void
}
function groupTitle(group: SshTargetGroup): React.JSX.Element {
if (group.id === 'manual') {
return <>{translate('auto.components.settings.SshTargetList.addedInOrca', 'Added in Orca')}</>
}
return <>{translate('auto.components.settings.SshTargetList.fromConfig', 'From ~/.ssh/config')}</>
}
const NOT_LIST_CONTEXT = '[role="dialog"], [role="alertdialog"], [role="menu"], pre, textarea'
/**
* ⌘/Ctrl-A selects hosts only from the list's own row controls. React bubbles keydown through
* portals, so a row's dialog, menu, or copyable detail text would otherwise lose select-all-text.
*/
export function isListOwnSelectAllTarget(list: Element, target: EventTarget | null): boolean {
if (!(target instanceof Element) || !list.contains(target)) {
return false
}
if (
target instanceof HTMLInputElement ||
(target instanceof HTMLElement && target.isContentEditable)
) {
return false
}
return target.closest(NOT_LIST_CONTEXT) === null
}
/** Hosts grouped by where they came from, each group selectable as a whole. */
export function SshTargetList({
targets,
totalBySource,
connectionStates,
testingIds,
busyActionForTarget,
selection,
onConnect,
onDisconnect,
onTest,
onEdit,
onRemove,
onServerChanged
}: SshTargetListProps): React.JSX.Element {
const groups = groupSshTargetsBySource(targets)
const isMac = navigator.userAgent.includes('Mac')
return (
<div
className="space-y-3"
onKeyDown={(event) => {
const mod = isMac ? event.metaKey : event.ctrlKey
if (
mod &&
event.key.toLowerCase() === 'a' &&
isListOwnSelectAllTarget(event.currentTarget, event.target)
) {
event.preventDefault()
selection.setGroup(
targets.map((target) => target.id),
true
)
}
}}
>
{groups.map((group) => {
const ids = group.targets.map((target) => target.id)
const checkState = sshTargetGroupCheckState(selection.selected, ids)
return (
<section
key={group.id}
data-ssh-target-group={group.id}
className="overflow-hidden rounded-lg border border-border/60"
>
<div className="flex items-center gap-3 border-b border-border/60 bg-muted/30 px-3 py-2 has-[[data-slot=checkbox]:focus-visible]:ring-2 has-[[data-slot=checkbox]:focus-visible]:ring-ring/60 has-[[data-slot=checkbox]:focus-visible]:ring-inset">
<Checkbox
checked={checkState}
aria-label={
group.id === 'manual'
? translate(
'auto.components.settings.SshTargetList.selectManualGroup',
'Select all hosts added in Orca'
)
: translate(
'auto.components.settings.SshTargetList.selectConfigGroup',
'Select all hosts from ~/.ssh/config'
)
}
onCheckedChange={() => selection.setGroup(ids, checkState !== true)}
/>
<span className="text-xs font-medium text-foreground">
{groupTitle(group)}
<span className="ml-1.5 font-normal text-muted-foreground tabular-nums">
·{' '}
{group.targets.length < totalBySource[group.id]
? translate(
'auto.components.settings.SshTargetList.filteredCount',
'{{count}} of {{total}}',
{ count: group.targets.length, total: totalBySource[group.id] }
)
: group.targets.length}
</span>
</span>
</div>
<div className="divide-y divide-border/40">
{group.targets.map((target) => (
<SshTargetCard
key={target.id}
target={target}
state={connectionStates.get(target.id)}
testing={testingIds.has(target.id)}
busyAction={busyActionForTarget(target.id)}
selected={selection.selected.has(target.id)}
onSelectClick={selection.click}
onConnect={onConnect}
onDisconnect={onDisconnect}
onTest={onTest}
onEdit={onEdit}
onRemove={() => onRemove(target)}
onServerChanged={onServerChanged}
/>
))}
</div>
</section>
)
})}
</div>
)
}
@@ -1,37 +1,43 @@
import { ChevronDown } from 'lucide-react'
import type { SshTarget } from '../../../../shared/ssh-types'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import type { SshConnectionState, SshTarget } from '../../../../shared/ssh-types'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { useAppStore } from '@/store'
import { Button } from '../ui/button'
import { DropdownMenuItem, DropdownMenuSeparator } from '../ui/dropdown-menu'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog'
import { sshHostServerStatusLine } from '@/ssh/ssh-host-server-status-copy'
import { SshHostChangedActions } from './SshHostChangedActions'
import { SshHostMoveAnywayAction } from './SshHostMoveAnywayAction'
import {
hasSshHostSourceChanged,
SshHostKeepServerVersionDialog
} from './SshHostKeepServerVersionDialog'
import { SshHostDeltaMoveDialog } from './SshHostDeltaMoveDialog'
import { SshHostNodeMirrorAction } from './SshHostNodeMirrorAction'
import { SshHostRestartServerAction } from './SshHostRestartServerAction'
import { canMoveSshHostAnyway, SshHostMoveAnywayDialog } from './SshHostMoveAnywayAction'
import { canRestartSshHostServer, SshHostRestartServerDialog } from './SshHostRestartServerAction'
import { getManagedOrcadApi } from './use-managed-server-catalog'
/** Whether this SSH host's managed Orca server serves it, and why not when it doesn't. */
export function SshTargetServerStatus({
export type SshTargetServerDialog =
| 'details'
| 'delta-move'
| 'keep-server'
| 'restart'
| 'move-anyway'
/** Whether Orca on this SSH host serves it, and why not when it doesn't. */
export function SshTargetServerStatusLine({
target,
onChanged
state
}: {
target: SshTarget
onChanged: () => void
state: SshConnectionState | undefined
}): React.JSX.Element | null {
const state = useAppStore((s) => s.sshConnectionStates.get(target.id))
const line = sshHostServerStatusLine(target, state)
if (!line) {
return null
}
const mirrorAction = (
<SshHostNodeMirrorAction targetId={target.id} status={state?.managedServer} />
)
return (
<div className="space-y-1">
<div className="mt-0.5 flex flex-wrap items-center gap-2">
<p
className={cn(
'px-1 text-xs',
'text-xs',
line.tone === 'muted' && 'text-muted-foreground',
line.tone === 'warning' && 'text-status-warning',
line.tone === 'destructive' && 'text-destructive'
@@ -40,34 +46,124 @@ export function SshTargetServerStatus({
{line.text}
</p>
{line.hint ? <p className="px-1 text-xs text-muted-foreground">{line.hint}</p> : null}
{line.detail ? (
<Collapsible>
{/* Why one row: the recovery sits beside Details, so it reads as part of this failure. */}
<div className="flex flex-wrap items-center justify-between gap-2 pr-1">
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="xs" className="group">
{translate('auto.components.settings.sshHostServer.failureDetails', 'Details')}
<ChevronDown className="transition-transform group-data-[state=open]:rotate-180" />
</Button>
</CollapsibleTrigger>
{mirrorAction}
</div>
<CollapsibleContent>
<pre className="scrollbar-sleek max-h-72 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-background px-3 py-2 font-mono text-[11px] leading-relaxed text-muted-foreground">
{line.detail}
</pre>
</CollapsibleContent>
</Collapsible>
) : (
<div className="flex justify-end px-1 empty:hidden">{mirrorAction}</div>
)}
<SshHostChangedActions target={target} onChanged={onChanged} />
<SshHostRestartServerAction status={state?.managedServer} onChanged={onChanged} />
<SshHostMoveAnywayAction
target={target}
status={state?.managedServer}
onChanged={onChanged}
/>
{/* Why inline, not in the menu: it is the way out of this failure. */}
<SshHostNodeMirrorAction targetId={target.id} status={state?.managedServer} />
</div>
)
}
/** The managed-server actions this host offers right now, as row-menu entries. */
export function SshTargetServerMenuItems({
target,
state,
onOpenDialog
}: {
target: SshTarget
state: SshConnectionState | undefined
onOpenDialog: (dialog: SshTargetServerDialog) => void
}): React.JSX.Element | null {
const status = state?.managedServer
const hasDetail = Boolean(sshHostServerStatusLine(target, state)?.detail)
const sourceChanged = hasSshHostSourceChanged(target)
const canRestart = canRestartSshHostServer(status)
const canMoveAnyway = canMoveSshHostAnyway(status)
if (!hasDetail && !sourceChanged && !canRestart && !canMoveAnyway) {
return null
}
return (
<>
<DropdownMenuSeparator />
{sourceChanged ? (
<>
<DropdownMenuItem onSelect={() => onOpenDialog('delta-move')}>
{translate('auto.components.settings.deltaMove.action', 'Move the new projects…')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onOpenDialog('keep-server')}>
{translate('auto.components.settings.keepServer.action', 'Keep the server’s version…')}
</DropdownMenuItem>
</>
) : null}
{canRestart ? (
<DropdownMenuItem onSelect={() => onOpenDialog('restart')}>
{translate('auto.components.settings.restartServer.action', 'Restart Orca’s server…')}
</DropdownMenuItem>
) : null}
{canMoveAnyway ? (
<DropdownMenuItem onSelect={() => onOpenDialog('move-anyway')}>
{translate('auto.components.settings.moveAnyway.action', 'Move anyway…')}
</DropdownMenuItem>
) : null}
{hasDetail ? (
<DropdownMenuItem onSelect={() => onOpenDialog('details')}>
{translate('auto.components.settings.sshHostServer.failureDetails', 'Details')}
</DropdownMenuItem>
) : null}
</>
)
}
/** Lives outside the row menu so a dialog outlasts the menu that opened it. */
export function SshTargetServerDialogs({
target,
state,
dialog,
onDialogChange,
onChanged
}: {
target: SshTarget
state: SshConnectionState | undefined
dialog: SshTargetServerDialog | null
onDialogChange: (dialog: SshTargetServerDialog | null) => void
onChanged: () => void
}): React.JSX.Element {
const api = getManagedOrcadApi()
const status = state?.managedServer
const detail = sshHostServerStatusLine(target, state)?.detail
const openChange =
(which: SshTargetServerDialog) =>
(open: boolean): void =>
onDialogChange(open ? which : null)
return (
<>
{api ? (
<SshHostDeltaMoveDialog
api={api}
target={dialog === 'delta-move' ? target : null}
onClose={() => onDialogChange(null)}
onFinished={onChanged}
/>
) : null}
<SshHostKeepServerVersionDialog
target={target}
open={dialog === 'keep-server'}
onOpenChange={openChange('keep-server')}
onChanged={onChanged}
/>
<SshHostRestartServerDialog
status={status}
open={dialog === 'restart'}
onOpenChange={openChange('restart')}
onChanged={onChanged}
/>
<SshHostMoveAnywayDialog
target={target}
status={status}
open={dialog === 'move-anyway'}
onOpenChange={openChange('move-anyway')}
onChanged={onChanged}
/>
<Dialog open={dialog === 'details' && Boolean(detail)} onOpenChange={openChange('details')}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
{translate('auto.components.settings.sshHostServer.failureDetails', 'Details')}
</DialogTitle>
</DialogHeader>
<pre className="scrollbar-sleek max-h-96 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-background px-3 py-2 font-mono text-[11px] leading-relaxed text-muted-foreground">
{detail}
</pre>
</DialogContent>
</Dialog>
</>
)
}
@@ -1,4 +1,6 @@
import { useState } from 'react'
import { Plus, TerminalSquare, Trash2 } from 'lucide-react'
import type { WslHostStatus } from '../../../../shared/wsl-host-types'
import { toast } from 'sonner'
import {
isWslHostRuntimeEnvironment,
@@ -18,12 +20,48 @@ import {
} from '../sidebar/use-wsl-hosts-enabled'
import {
getWslHostRowDetail,
getWslHostStatusLabel,
getWslHostServerFilesMissingLabel,
getWslHostsTurnedOffLabel
} from '../sidebar/wsl-host-row-status'
import { Badge } from '../ui/badge'
import { Button } from '../ui/button'
import { DropdownMenuItem } from '../ui/dropdown-menu'
import { managedServerOutcomeLabel } from './managed-server-copy'
import { ServerListRow, ServerRowChip, type ServerRowStatus } from './runtime-server-list-row'
function getWslHostRowStatus(
status: WslHostStatus | undefined,
unavailable: boolean
): ServerRowStatus | null {
if (unavailable) {
return null
}
const label = getWslHostStatusLabel(status)
switch (status?.state) {
case 'ready':
return {
label: translate(
'auto.components.settings.RuntimeEnvironmentsPane.serverConnected',
'Connected'
),
tone: 'success'
}
case 'connecting':
case 'reconnecting':
return label ? { label, tone: 'muted', busy: true } : null
case 'failed':
return label ? { label, tone: 'destructive' } : null
case 'unverifiable':
return label ? { label, tone: 'warning' } : null
case 'distro-stopped':
case 'idle':
return label ? { label, tone: 'muted' } : null
case 'closed':
case undefined:
return null
}
}
/** Settings → Orca servers: the WSL distros added as hosts (Windows, experimental). */
export function WslHostsSection({
@@ -69,8 +107,8 @@ export function WslHostsSection({
}
return (
<div className="space-y-2" data-settings-section="wsl-hosts">
<div className="flex items-start justify-between gap-4">
<div className="scroll-mt-8 space-y-2" data-settings-section="wsl-hosts">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-2 text-sm font-medium">
{translate('auto.components.settings.wslHosts.title', 'WSL hosts')}
@@ -94,6 +132,7 @@ export function WslHostsSection({
disabled={serverFilesMissing}
onClick={() => setDialogMode('wsl')}
>
<Plus />
{translate('auto.components.settings.wslHosts.add', 'Add WSL distro')}
</Button>
) : (
@@ -108,35 +147,40 @@ export function WslHostsSection({
)}
</div>
{hosts.length > 0 ? (
<div className="divide-y divide-border rounded-md border border-border">
<div className="divide-y divide-border rounded-lg border border-border bg-card">
{hosts.map((environment) => {
const status = statuses[environment.id]
const ready = status?.state === 'ready'
const deferred = unavailableLabel ? null : status?.deferredUpdate
const connectDisabled =
unavailableLabel !== null || connectingId !== null || status?.state === 'connecting'
return (
<div key={environment.id} className="flex items-center gap-2 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{environment.name}</div>
<p className="truncate text-xs text-muted-foreground">
{unavailableLabel ?? getWslHostRowDetail(status)}
{!unavailableLabel && status?.detail ? ` — ${status.detail}` : ''}
</p>
{deferred ? (
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.managedServers.row.deferredVersion',
'Update to {{version}} deferred.',
{ version: deferred.candidateVersion }
)}{' '}
{managedServerOutcomeLabel({
outcome: 'deferred',
code: deferred.code
})}
</p>
) : null}
</div>
{deferred ? (
<>
<ServerListRow
key={environment.id}
sectionId={environment.id}
name={environment.name}
icon={<TerminalSquare className="size-4" />}
chips={
<ServerRowChip>
{translate('auto.components.settings.wslHosts.chip', 'WSL')}
</ServerRowChip>
}
meta={
<span className="truncate">
{deferred
? `${translate(
'auto.components.settings.managedServers.row.deferredVersion',
'Update to {{version}} deferred.',
{ version: deferred.candidateVersion }
)} ${managedServerOutcomeLabel({ outcome: 'deferred', code: deferred.code })}`
: `${unavailableLabel ?? getWslHostRowDetail(status)}${
!unavailableLabel && status?.detail ? ` — ${status.detail}` : ''
}`}
</span>
}
status={getWslHostRowStatus(status, unavailableLabel !== null)}
primaryAction={
deferred ? (
<Button
type="button"
size="xs"
@@ -146,45 +190,50 @@ export function WslHostsSection({
>
{translate('auto.components.settings.managedServers.row.update', 'Update')}
</Button>
{deferred.forceable ? (
<Button
type="button"
size="xs"
variant="outline"
) : ready && !unavailableLabel ? null : (
<Button
type="button"
size="xs"
variant="ghost"
disabled={connectDisabled}
onClick={() => void connect(environment.id)}
>
{connectingId === environment.id ? (
<LoadingSpinner className="size-3" />
) : null}
{translate('auto.components.settings.wslHosts.connect', 'Connect')}
</Button>
)
}
menuItems={
<>
{deferred?.forceable ? (
<DropdownMenuItem
disabled={connectingId !== null}
onClick={() => void connect(environment.id, true)}
onSelect={() => void connect(environment.id, true)}
>
{translate('auto.components.settings.wslHosts.forceUpdate', 'Force update')}
</Button>
</DropdownMenuItem>
) : null}
{deferred && !(ready && !unavailableLabel) ? (
<DropdownMenuItem
disabled={connectDisabled}
onSelect={() => void connect(environment.id)}
>
{translate('auto.components.settings.wslHosts.connect', 'Connect')}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
variant="destructive"
disabled={connectingId !== null}
onSelect={() => setRemoving(environment)}
>
<Trash2 />
{translate('auto.components.settings.serverList.remove', 'Remove…')}
</DropdownMenuItem>
</>
) : null}
{ready && !unavailableLabel ? null : (
<Button
type="button"
size="xs"
variant="outline"
disabled={
unavailableLabel !== null ||
connectingId !== null ||
status?.state === 'connecting'
}
onClick={() => void connect(environment.id)}
>
{connectingId === environment.id ? <LoadingSpinner className="size-3" /> : null}
{translate('auto.components.settings.wslHosts.connect', 'Connect')}
</Button>
)}
<Button
type="button"
size="xs"
variant="ghost"
disabled={connectingId !== null}
onClick={() => setRemoving(environment)}
>
{translate('auto.components.settings.wslHosts.remove', 'Remove')}
</Button>
</div>
}
/>
)
})}
</div>
@@ -93,7 +93,7 @@ describe('CLI source-control integration card account scope', () => {
expect(rendered.textContent).toContain('Connected')
expect(rendered.textContent).toContain(`Account scope: ${LOCAL_HOST_LABEL}`)
expect(rendered.textContent).toContain(
'Credentials and account checks for this provider are owned by this desktop client. To use a server, change Settings > Remote Orca Servers > Advanced > Default host for new projects.'
'Credentials and account checks for this provider are owned by this desktop client. To use a server, change Settings > Remote Orca Servers > New projects open on.'
)
await act(async () => {
Array.from(rendered.querySelectorAll('button'))
@@ -122,7 +122,7 @@ describe('CLI source-control integration card account scope', () => {
expect(rendered.textContent).toContain('GitLab')
expect(rendered.textContent).toContain('Account scope: Remote server: runtime-1')
expect(rendered.textContent).toContain(
'Credentials and account checks for this provider are owned by this remote server. To use another host, change Settings > Remote Orca Servers > Advanced > Default host for new projects.'
'Credentials and account checks for this provider are owned by this remote server. To use another host, change Settings > Remote Orca Servers > New projects open on.'
)
expect(rendered.textContent).toContain('glab auth login')
})
@@ -37,7 +37,7 @@ describe('managed server action refusals', () => {
verdict: 'live'
})
).toBe(
'Not done: this server is the default host for new projects. Choose another one in Advanced before stopping it.'
'Not done: this server is the default host for new projects. Choose another one under New projects open on before stopping it.'
)
})
@@ -143,8 +143,8 @@ export function managedServerOutcomeLabel(result: {
const code = result.code ?? ''
if (code === 'orcad_stop_active_environment') {
return translate(
'auto.components.settings.managedServers.outcome.activeServer',
'Not done: this server is the default host for new projects. Choose another one in Advanced before stopping it.'
'auto.components.settings.managedServers.outcome.defaultServer',
'Not done: this server is the default host for new projects. Choose another one under New projects open on before stopping it.'
)
}
if (result.verdict === 'live' || LIVE_TERMINAL_CODES.has(code)) {
@@ -6,9 +6,50 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { OrcadManagedRuntimeStatus } from '../../../../shared/orcad-managed-runtime'
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
import type { ManagedOrcadPreloadApi } from '../../../../preload/api/managed-orcad-api'
import { ManagedServerRow } from './ManagedServerRow'
import {
ManagedServerConfirmations,
ManagedServerDetailLines,
ManagedServerMenuItems,
ManagedServerPrimaryAction,
managedServerTitle
} from './managed-server-row-parts'
import { useManagedServerLifecycle } from './use-managed-server-lifecycle'
vi.mock('sonner', () => ({ toast: { message: vi.fn(), error: vi.fn() } }))
// Why: menu items render inline as buttons so the row's actions can be clicked directly.
vi.mock('../ui/dropdown-menu', () => ({
DropdownMenuSeparator: () => null,
DropdownMenuItem: ({
children,
onSelect
}: {
children: React.ReactNode
onSelect?: () => void
}) => <button onClick={onSelect}>{children}</button>
}))
function ManagedRowHarness({
api,
environment
}: {
api: ManagedOrcadPreloadApi
environment: PublicKnownRuntimeEnvironment
}): React.JSX.Element {
const lifecycle = useManagedServerLifecycle({
api,
environment,
onChanged: () => {}
})
return (
<>
<span>{managedServerTitle(environment.name)}</span>
<ManagedServerDetailLines lifecycle={lifecycle} />
<ManagedServerPrimaryAction lifecycle={lifecycle} />
<ManagedServerConfirmations lifecycle={lifecycle} />
<ManagedServerMenuItems lifecycle={lifecycle} />
</>
)
}
const roots: Root[] = []
afterEach(() => {
@@ -46,7 +87,11 @@ async function render(
const recover = vi.fn(async (args: { acceptChangedState?: boolean }) =>
args.acceptChangedState
? { outcome: 'none' }
: { outcome: 'refused', verdict: 'unverifiable', code: 'orcad_recovery_changed_state' }
: {
outcome: 'refused',
verdict: 'unverifiable',
code: 'orcad_recovery_changed_state'
}
)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the row calls only getStatus, stop, forget and recover here.
const api = {
@@ -62,13 +107,13 @@ async function render(
const root = createRoot(container)
roots.push(root)
await act(async () => {
root.render(<ManagedServerRow api={api} environment={environment} onChanged={() => {}} />)
root.render(<ManagedRowHarness api={api} environment={environment} />)
})
return { container, stop, forget, recover }
}
const button = (container: HTMLElement, label: string) =>
[...container.querySelectorAll('button')].find((entry) => entry.textContent === label)
[...container.querySelectorAll('button')].find((entry) => entry.textContent?.trim() === label)
describe('managed server row', () => {
it('reports unknown terminals, the running migration and the deferred update', async () => {
@@ -0,0 +1,323 @@
import { ArrowUpCircle, RefreshCw, RotateCcw, ServerOff, Trash2, Wrench } from 'lucide-react'
import type { OrcadManagedRuntimeStatus } from '../../../../shared/orcad-managed-runtime'
import { translate } from '@/i18n/i18n'
import { LoadingSpinner } from '../LoadingSpinner'
import { Button } from '../ui/button'
import { DropdownMenuItem, DropdownMenuSeparator } from '../ui/dropdown-menu'
import {
managedServerOutcomeLabel,
migrationPhaseLabel,
recoveryLabel,
terminalCensusLabel
} from './managed-server-copy'
import type { ManagedServerLifecycle } from './use-managed-server-lifecycle'
/** What a server Orca installed over SSH is called: where it runs, not "managed server". */
export function managedServerTitle(hostLabel: string): string {
return translate('auto.components.settings.managedServers.row.title', 'Orca on {{host}}', {
host: hostLabel
})
}
// Why: a status that never loaded is unknown, not exited; only a loaded status may say "Not running".
export function managedServerVersionLabel(
status: OrcadManagedRuntimeStatus | null,
statusError: string | null
): string {
if (status) {
return (
status.activeVersion ??
translate('auto.components.settings.managedServers.row.notRunning', 'Not running')
)
}
return statusError
? translate('auto.components.settings.managedServers.row.statusUnknown', 'Status unknown')
: translate('auto.components.settings.managedServers.row.checking', 'Checking…')
}
/** The one managed-server fact that should replace the row's meta line, if any. */
export function getManagedServerSummary(lifecycle: ManagedServerLifecycle): string | null {
const { status, recovery } = lifecycle
if (recovery) {
return recoveryLabel(recovery)
}
if (status?.migration) {
return translate(
'auto.components.settings.managedServers.row.migration',
'Migration: {{phase}}',
{
phase: migrationPhaseLabel(status.migration.phase)
}
)
}
if (status?.deferredUpdate) {
return translate(
'auto.components.settings.managedServers.row.deferredVersion',
'Update to {{version}} deferred.',
{ version: status.deferredUpdate.candidateVersion }
)
}
return null
}
export function ManagedServerDetailLines({
lifecycle
}: {
lifecycle: ManagedServerLifecycle
}): React.JSX.Element {
const { status, statusError, recovery } = lifecycle
return (
<div className="space-y-0.5 text-xs text-muted-foreground">
<p>
{translate(
'auto.components.settings.managedServers.row.orcaVersion',
'Orca on this host: {{version}}',
{
version: managedServerVersionLabel(status, statusError)
}
)}
</p>
{statusError ? <p className="text-destructive">{statusError}</p> : null}
{status ? (
<>
<p>{terminalCensusLabel(status.terminals)}</p>
{recovery ? <p>{recoveryLabel(recovery)}</p> : null}
{status.migration ? (
<p>
{translate(
'auto.components.settings.managedServers.row.migration',
'Migration: {{phase}}',
{ phase: migrationPhaseLabel(status.migration.phase) }
)}
</p>
) : null}
{status.deferredUpdate ? (
<p>
{translate(
'auto.components.settings.managedServers.row.deferredVersion',
'Update to {{version}} deferred.',
{ version: status.deferredUpdate.candidateVersion }
)}{' '}
{managedServerOutcomeLabel(status.deferredUpdate)}
</p>
) : null}
</>
) : null}
</div>
)
}
export function managedServerHasPrimaryAction(lifecycle: ManagedServerLifecycle): boolean {
const { recovery } = lifecycle
return (
recovery !== null && (recovery.operation === 'decommission' || !lifecycle.confirmingRestore)
)
}
/** Recovery takes the row's primary slot; Cancel stop wins while a stop is half done. */
export function ManagedServerPrimaryAction({
lifecycle
}: {
lifecycle: ManagedServerLifecycle
}): React.JSX.Element | null {
const { recovery, busy, run } = lifecycle
if (!recovery || !managedServerHasPrimaryAction(lifecycle)) {
return null
}
const cancelStop = recovery.operation === 'decommission'
return (
<Button
type="button"
size="xs"
variant="outline"
disabled={busy !== null}
onClick={() => void run(cancelStop ? 'cancelStop' : 'recover')}
>
{busy === 'recover' || busy === 'cancelStop' ? <LoadingSpinner className="size-3" /> : null}
{cancelStop
? translate('auto.components.settings.managedServers.row.cancelStop', 'Cancel stop')
: translate('auto.components.settings.managedServers.row.recover', 'Recover')}
</Button>
)
}
function ConfirmStrip({
message,
confirmLabel,
keepLabel,
disabled,
onConfirm,
onKeep
}: {
message: string | null
confirmLabel: string
keepLabel: string
disabled: boolean
onConfirm: () => void
onKeep: () => void
}): React.JSX.Element {
return (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2">
{message ? <p className="min-w-0 flex-1 text-xs text-muted-foreground">{message}</p> : null}
<div className="ml-auto flex shrink-0 items-center gap-1">
<Button type="button" size="xs" variant="ghost" onClick={onKeep}>
{keepLabel}
</Button>
<Button
type="button"
size="xs"
variant="destructive"
disabled={disabled}
onClick={onConfirm}
>
{confirmLabel}
</Button>
</div>
</div>
)
}
export function managedServerHasConfirmation(lifecycle: ManagedServerLifecycle): boolean {
return (
lifecycle.confirmingStop ||
(lifecycle.statusError !== null && lifecycle.confirmingForget) ||
(lifecycle.recovery !== null && lifecycle.confirmingRestore)
)
}
/** Second-step confirmations; they stay on the row until answered. */
export function ManagedServerConfirmations({
lifecycle
}: {
lifecycle: ManagedServerLifecycle
}): React.JSX.Element | null {
const { recovery, statusError, busy, run } = lifecycle
const disabled = busy !== null
if (lifecycle.confirmingStop) {
return (
<ConfirmStrip
message={translate(
'auto.components.settings.managedServers.row.stopOrcaDescription',
'Stops Orca on the SSH host and removes it from this list.'
)}
confirmLabel={translate(
'auto.components.settings.managedServers.row.confirmStop',
'Stop and remove server'
)}
keepLabel={translate('auto.components.settings.managedServers.row.keep', 'Keep running')}
disabled={disabled}
onConfirm={() => {
lifecycle.setConfirmingStop(false)
void run('stop')
}}
onKeep={() => lifecycle.setConfirmingStop(false)}
/>
)
}
// Only once the host failed to answer: a reachable host is stopped, not forgotten.
if (statusError && lifecycle.confirmingForget) {
return (
<ConfirmStrip
message={translate(
'auto.components.settings.managedServers.row.forgetDescription',
'Unlinks this server here without contacting its host. If the host comes back, the server may still be running there.'
)}
confirmLabel={translate(
'auto.components.settings.managedServers.row.confirmForget',
'Forget on this desktop'
)}
keepLabel={translate(
'auto.components.settings.managedServers.row.keepLinked',
'Keep linked'
)}
disabled={disabled}
onConfirm={() => {
lifecycle.setConfirmingForget(false)
void run('forget')
}}
onKeep={() => lifecycle.setConfirmingForget(false)}
/>
)
}
if (recovery && lifecycle.confirmingRestore) {
return (
<ConfirmStrip
message={recoveryLabel(recovery)}
confirmLabel={translate(
'auto.components.settings.managedServers.row.confirmRestore',
'Restore snapshot and restart'
)}
keepLabel={translate('auto.components.settings.managedServers.row.keepState', 'Keep state')}
disabled={disabled}
onConfirm={() => {
lifecycle.setConfirmingRestore(false)
void run('restoreSnapshot')
}}
onKeep={() => lifecycle.setConfirmingRestore(false)}
/>
)
}
return null
}
export function ManagedServerMenuItems({
lifecycle
}: {
lifecycle: ManagedServerLifecycle
}): React.JSX.Element {
const { status, statusError, recovery, busy, run } = lifecycle
const disabled = busy !== null
return (
<>
<DropdownMenuItem disabled={disabled} onSelect={() => void lifecycle.loadStatus()}>
<RefreshCw />
{translate('auto.components.settings.managedServers.row.refresh', 'Refresh status')}
</DropdownMenuItem>
<DropdownMenuItem disabled={disabled} onSelect={() => void run('update')}>
<ArrowUpCircle />
{translate(
'auto.components.settings.managedServers.row.updateOrcaOnHost',
'Update Orca on this host'
)}
</DropdownMenuItem>
{status?.rollbackAvailable ? (
<DropdownMenuItem disabled={disabled} onSelect={() => void run('rollback')}>
<RotateCcw />
{translate('auto.components.settings.managedServers.row.rollback', 'Roll back')}
</DropdownMenuItem>
) : null}
{/* Why: while a stop is half done, Cancel stop holds the primary slot and Recover lives here. */}
{recovery?.operation === 'decommission' ? (
<DropdownMenuItem disabled={disabled} onSelect={() => void run('recover')}>
<Wrench />
{translate('auto.components.settings.managedServers.row.recover', 'Recover')}
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
disabled={disabled}
onSelect={() => {
lifecycle.setConfirmingForget(false)
lifecycle.setConfirmingStop(true)
}}
>
<ServerOff />
{translate('auto.components.settings.managedServers.row.stop', 'Stop…')}
</DropdownMenuItem>
{statusError ? (
<DropdownMenuItem
variant="destructive"
disabled={disabled}
onSelect={() => {
lifecycle.setConfirmingStop(false)
lifecycle.setConfirmingForget(true)
}}
>
<Trash2 />
{translate('auto.components.settings.managedServers.row.forget', 'Forget…')}
</DropdownMenuItem>
) : null}
</>
)
}
@@ -0,0 +1,35 @@
import { classifyRemotePairingHostname } from '../../../../shared/remote-pairing-address'
import { translate } from '@/i18n/i18n'
/** "Tailscale · 100.x.y.z" instead of the interface name ("utun6"), which means nothing to most people. */
export function describeNetworkAddress(interfaceName: string, address: string): string {
switch (classifyRemotePairingHostname(address)) {
case 'tailscale':
return translate(
'auto.components.settings.networkAddress.tailscale',
'Tailscale · {{address}}',
{
address
}
)
case 'lan':
return translate(
'auto.components.settings.networkAddress.lan',
'Local network · {{address}}',
{
address
}
)
case 'loopback':
return translate(
'auto.components.settings.networkAddress.loopback',
'This computer · {{address}}',
{
address
}
)
case 'public':
case 'custom':
return `${interfaceName} · ${address}`
}
}
@@ -14,7 +14,7 @@ describe('getProviderAccountScope', () => {
expect(getProviderAccountScope({ activeRuntimeEnvironmentId })).toEqual({
label: LOCAL_HOST_LABEL,
description:
'Credentials and account checks for this provider are owned by this desktop client. To use a server, change Settings > Remote Orca Servers > Advanced > Default host for new projects.'
'Credentials and account checks for this provider are owned by this desktop client. To use a server, change Settings > Remote Orca Servers > New projects open on.'
})
}
})
@@ -23,7 +23,7 @@ describe('getProviderAccountScope', () => {
expect(getProviderAccountScope({ activeRuntimeEnvironmentId: ' env-1 ' })).toEqual({
label: 'Remote server: env-1',
description:
'Credentials and account checks for this provider are owned by this remote server. To use another host, change Settings > Remote Orca Servers > Advanced > Default host for new projects.'
'Credentials and account checks for this provider are owned by this remote server. To use another host, change Settings > Remote Orca Servers > New projects open on.'
})
})
@@ -53,12 +53,12 @@ export function getProviderAccountScope(
description:
target.kind === 'environment'
? translate(
'auto.components.settings.providerAccountScope.remoteServerCredentials',
'Credentials and account checks for this provider are owned by this remote server. To use another host, change Settings > Remote Orca Servers > Advanced > Default host for new projects.'
'auto.components.settings.providerAccountScope.remoteServerCredentialsNewProjects',
'Credentials and account checks for this provider are owned by this remote server. To use another host, change Settings > Remote Orca Servers > New projects open on.'
)
: translate(
'auto.components.settings.providerAccountScope.localCredentials',
'Credentials and account checks for this provider are owned by this desktop client. To use a server, change Settings > Remote Orca Servers > Advanced > Default host for new projects.'
'auto.components.settings.providerAccountScope.localCredentialsNewProjects',
'Credentials and account checks for this provider are owned by this desktop client. To use a server, change Settings > Remote Orca Servers > New projects open on.'
)
}
}
@@ -1,211 +0,0 @@
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<string, RuntimeHostDetails>
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 (
<div data-settings-section="default-runtime" className={!visible ? 'hidden' : undefined}>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onToggleAdvanced}
className="-ml-2 text-xs"
aria-expanded={advancedOpen}
aria-controls="runtime-server-advanced-content"
>
{translate('auto.components.settings.RuntimeEnvironmentsPane.advanced', 'Advanced')}
<ChevronDown className={cn('size-4 transition-transform', advancedOpen && 'rotate-180')} />
</Button>
<div
id="runtime-server-advanced-content"
className={cn(
'grid overflow-hidden transition-[grid-template-rows] duration-200 ease-out',
advancedOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
)}
aria-hidden={!advancedOpen}
inert={!advancedOpen}
>
<div className="min-h-0">
<div
className={cn(
'space-y-2 px-1 pt-3 pb-1 transition-[opacity,transform] duration-150 ease-out',
advancedOpen
? 'translate-y-0 opacity-100 delay-200'
: '-translate-y-1 opacity-0 delay-0'
)}
>
<div className="space-y-1">
<Label id="runtime-active-server-label">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.64b6bea541',
'Default host for new projects'
)}
</Label>
<p className="text-xs text-muted-foreground">
{getActiveServerModeDescription(allowLocalRuntime)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Select
value={activeValue}
onValueChange={(value) => {
if (value !== activeValue) {
onValueChange(value)
}
}}
disabled={isBusy}
>
<SelectTrigger
size="sm"
className="min-w-[260px]"
aria-labelledby="runtime-active-server-label"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{allowLocalRuntime ? (
<SelectItem value={localRuntimeValue}>
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.78692becbd',
'Local desktop'
)}
</SelectItem>
) : environments.length === 0 ? (
<SelectItem value={noRuntimeValue} disabled>
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.b07070ed3c',
'No server connected'
)}
</SelectItem>
) : null}
{environments.map((environment) => (
<SelectItem key={environment.id} value={environment.id}>
{environment.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label={translate(
'auto.components.settings.RuntimeEnvironmentsPane.6ce4664003',
'Refresh servers'
)}
title={translate(
'auto.components.settings.RuntimeEnvironmentsPane.6ce4664003',
'Refresh servers'
)}
onClick={onRefresh}
disabled={isLoading || isBusy}
>
{isLoading ? <Loader2 className="animate-spin" /> : <RefreshCw />}
</Button>
</div>
{environments.length > 0 ? (
<div className="space-y-2 pt-2">
<div className="text-xs font-medium">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.serverDetails',
'Server details'
)}
</div>
<div className="space-y-1 rounded-lg border border-border/50 bg-card/30 p-2">
{environments.map((environment) => {
const details = detailsByEnvironmentId[environment.id]
return (
<div
key={environment.id}
className="grid gap-1 rounded-md px-2 py-1.5 text-[11px] text-muted-foreground sm:grid-cols-[minmax(0,9rem)_minmax(0,1fr)]"
>
<div className="truncate font-medium text-foreground">
{environment.name}
</div>
<div className="min-w-0 space-y-0.5">
<div className="truncate font-mono">
{environment.endpoints[0]?.endpoint ??
translate(
'auto.components.settings.RuntimeEnvironmentsPane.6ef71985da',
'No endpoint'
)}
</div>
{details?.runtimeStatus ? (
<div className="truncate">
{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)}
</div>
) : null}
{getHostModelCapabilitySummary(details?.runtimeStatus) ? (
<div className="truncate">
{getHostModelCapabilitySummary(details?.runtimeStatus)}
</div>
) : null}
</div>
</div>
)
})}
</div>
</div>
) : null}
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,66 @@
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog'
import { RuntimeHostAccessForm } from './RuntimeHostAccessForm'
import type { RuntimeHostAccessFailure } from './RuntimeHostAccessFailureAlert'
type RuntimeAddServerDialogProps = {
open: boolean
name: string
accessLink: string
busy: boolean
failure: RuntimeHostAccessFailure | null
onNameChange: (value: string) => void
onAccessLinkChange: (value: string) => void
onClose: () => void
onSubmit: (allowLoopback: boolean) => void
}
export function RuntimeAddServerDialog({
open,
name,
accessLink,
busy,
failure,
onNameChange,
onAccessLinkChange,
onClose,
onSubmit
}: RuntimeAddServerDialogProps): React.JSX.Element {
const takenNames = useAppStore((state) => state.runtimeEnvironments)
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) {
onClose()
}
}}
>
<DialogContent scrollable>
<DialogHeader>
<DialogTitle>
{translate('auto.components.settings.serverList.addTitle', 'Add server')}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.settings.serverList.addDescriptionShort',
'Paste the access link from Share this computer on the other machine.'
)}
</DialogDescription>
</DialogHeader>
<RuntimeHostAccessForm
name={name}
takenNames={takenNames}
accessLink={accessLink}
busy={busy}
failure={failure}
onNameChange={onNameChange}
onAccessLinkChange={onAccessLinkChange}
onCancel={onClose}
onSubmit={onSubmit}
/>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,51 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
import { useAppStore } from '@/store'
import { RuntimeDefaultHostRow } from './runtime-default-host-row'
import { LOCAL_RUNTIME_VALUE } from './runtime-environment-selection'
const initialState = useAppStore.getInitialState()
afterEach(() => {
cleanup()
useAppStore.setState(initialState, true)
})
const wslHost: PublicKnownRuntimeEnvironment = {
id: 'wsl:ubuntu',
name: 'Ubuntu',
createdAt: 1,
updatedAt: 1,
lastUsedAt: null,
runtimeId: null,
endpoints: [{ id: 'ws', kind: 'websocket', label: 'WSL', endpoint: 'ws://127.0.0.1:7001' }],
preferredEndpointId: 'ws'
}
function renderRow(activeValue: string): void {
render(
<RuntimeDefaultHostRow
allowLocalRuntime
activeValue={activeValue}
environments={[]}
isBusy={false}
onValueChange={vi.fn()}
/>
)
}
it('says new projects stay here when nothing else can be chosen', () => {
renderRow(LOCAL_RUNTIME_VALUE)
expect(screen.queryByText(/until you add a server/)).not.toBeNull()
expect(screen.queryByRole('combobox')).toBeNull()
})
// The default can be a host the server list leaves out; it must stay visible and changeable.
it('shows the selector for a default that is not this computer', () => {
useAppStore.setState({ runtimeEnvironments: [wslHost] })
renderRow(wslHost.id)
expect(screen.queryByText(/until you add a server/)).toBeNull()
expect(screen.getByRole('combobox').textContent).toContain('Ubuntu')
})
@@ -0,0 +1,102 @@
import { Monitor, Server } from 'lucide-react'
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { getActiveServerModeDescription } from './runtime-environment-host-details'
import { LOCAL_RUNTIME_VALUE, NO_RUNTIME_VALUE } from './runtime-environment-selection'
type RuntimeDefaultHostRowProps = {
allowLocalRuntime: boolean
activeValue: string
environments: PublicKnownRuntimeEnvironment[]
isBusy: boolean
onValueChange: (value: string) => void
}
/** "New projects open on": the default host for project creation, always in view. */
export function RuntimeDefaultHostRow({
allowLocalRuntime,
activeValue,
environments,
isBusy,
onValueChange
}: RuntimeDefaultHostRowProps): React.JSX.Element {
const savedEnvironments = useAppStore((state) => state.runtimeEnvironments)
// Why: the default can be a host this list leaves out (a WSL distro); show it so it can be changed.
const unlistedActive =
activeValue !== LOCAL_RUNTIME_VALUE &&
activeValue !== NO_RUNTIME_VALUE &&
!environments.some((environment) => environment.id === activeValue)
? savedEnvironments.find((environment) => environment.id === activeValue)
: undefined
const options = unlistedActive ? [...environments, unlistedActive] : environments
if (options.length === 0 && allowLocalRuntime) {
// Why a quiet line, not the control: nothing to choose yet, but deep links still land here.
return (
<p
data-settings-section="default-runtime"
className="scroll-mt-8 text-xs text-muted-foreground"
>
{translate(
'auto.components.settings.serverList.defaultHostEmptyLine',
'New projects open on this computer until you add a server.'
)}
</p>
)
}
return (
<div
data-settings-section="default-runtime"
className="flex scroll-mt-8 flex-wrap items-center justify-between gap-3"
>
<div className="min-w-0 space-y-0.5">
<div id="runtime-active-server-label" className="text-sm font-medium">
{translate(
'auto.components.settings.serverList.defaultHostLabel',
'New projects open on'
)}
</div>
<p className="text-xs text-muted-foreground">
{getActiveServerModeDescription(allowLocalRuntime)}
</p>
</div>
<div className="shrink-0">
<Select
value={activeValue}
onValueChange={(value) => {
if (value !== activeValue) {
onValueChange(value)
}
}}
disabled={isBusy}
>
<SelectTrigger size="sm" className="w-64" aria-labelledby="runtime-active-server-label">
<SelectValue />
</SelectTrigger>
<SelectContent>
{allowLocalRuntime ? (
<SelectItem value={LOCAL_RUNTIME_VALUE}>
<Monitor />
{translate('auto.components.settings.serverList.thisComputer', 'This computer')}
</SelectItem>
) : options.length === 0 ? (
<SelectItem value={NO_RUNTIME_VALUE} disabled>
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.b07070ed3c',
'No server connected'
)}
</SelectItem>
) : null}
{options.map((environment) => (
<SelectItem key={environment.id} value={environment.id}>
<Server />
{environment.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)
}
@@ -103,8 +103,8 @@ export function RuntimeEnvironmentRemoveDialog({
<DialogDescription>
{removingActiveServer
? translate(
'auto.components.settings.RuntimeEnvironmentsPane.removeActiveServerDescription',
'This server is the default host for new projects. Choose another one in Advanced before removing it. Existing host sessions are left alone.'
'auto.components.settings.RuntimeEnvironmentsPane.removeDefaultServerDescription',
'This server is the default host for new projects. Choose another one under New projects open on before removing it. Existing host sessions are left alone.'
)
: translate(
'auto.components.settings.RuntimeEnvironmentsPane.ed3e3f069d',
@@ -155,12 +155,12 @@ function getHostModelCapabilityLabel(capability: string): string {
export function getActiveServerModeDescription(allowLocalRuntime: boolean): string {
return allowLocalRuntime
? translate(
'auto.components.settings.RuntimeEnvironmentsPane.3f67e8078a',
'New projects are created on this computer unless you choose a saved server here. Existing projects, files and terminals stay on their own host.'
'auto.components.settings.serverList.defaultHostHelp',
'Existing projects stay where they are.'
)
: translate(
'auto.components.settings.RuntimeEnvironmentsPane.2c85efb3e8',
'Selecting a saved server makes this browser use that paired Orca runtime as its default Host.'
'auto.components.settings.serverList.defaultHostWebHelp',
'This browser opens new projects on the chosen server.'
)
}
@@ -240,17 +240,3 @@ export function getRuntimeServerConnectionLabel(state: RuntimeServerConnectionSt
)
}
}
export function getRuntimeServerDotClass(state: RuntimeServerConnectionState): string {
switch (state) {
case 'connected':
return 'bg-emerald-500'
case 'runtime-unavailable':
case 'checking':
case 'workspace-window-closed':
case 'reconnecting':
return 'bg-yellow-500'
case 'disconnected':
return 'bg-muted-foreground/40'
}
}
@@ -11,8 +11,8 @@ export const getRuntimeEnvironmentsSearchEntry = createLocalizedCatalog(
'Remote Orca Servers'
),
description: translate(
'auto.components.settings.runtime.environments.search.4575341c77',
'Add a saved remote Orca server, generate a pairing URL, or choose the default host for new projects.'
'auto.components.settings.runtime.environments.search.addShareDefault',
'Add a remote Orca server, share this computer, or choose where new projects open.'
),
keywords: [
...translateSearchKeyword(
@@ -77,12 +77,24 @@ export const getRuntimeEnvironmentsSearchEntry = createLocalizedCatalog(
'Orca on your SSH hosts'
),
...translateSearchKeyword('auto.components.settings.managedServers.search.keywordSsh', 'ssh'),
...translateSearchKeyword(
'auto.components.settings.runtime.environments.search.shareComputerKeyword',
'share this computer'
),
...translateSearchKeyword(
'auto.components.settings.runtime.environments.search.accessLinkKeyword',
'access link'
),
...translateSearchKeyword(
'auto.components.settings.runtime.environments.search.installKeyword',
'install server'
),
// Why: the section's former name, so users who knew it can still find it.
...translateSearchKeyword(
'auto.components.settings.managedServers.search.keywordFormerName',
'managed servers'
),
// Why: Share this host mounts the machine name field, so its keywords open this pane.
// Why: Share this computer mounts the machine name field, so its keywords open this pane.
...getMachineNameSearchKeywords()
]
})
@@ -0,0 +1,236 @@
import { useEffect, useId, useRef, useState } from 'react'
import { toast } from 'sonner'
import type { ManagedOrcadPreloadApi } from '../../../../preload/api/managed-orcad-api'
import type { SshTarget } from '../../../../shared/ssh-types'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { LoadingSpinner } from '../LoadingSpinner'
import { Button } from '../ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '../ui/dialog'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { managedServerOutcomeLabel } from './managed-server-copy'
import { SshTargetSelect } from './SshTargetSelect'
type RuntimeInstallServerDialogProps = {
open: boolean
api: ManagedOrcadPreloadApi
emptyTargets: SshTarget[]
loadFailed: boolean
onOpenChange: (open: boolean) => void
onDeployed: () => Promise<void> | void
}
/** Install a managed Orca server on an SSH host that doesn't run one yet. */
export function RuntimeInstallServerDialog({
open,
api,
emptyTargets,
loadFailed,
onOpenChange,
onDeployed
}: RuntimeInstallServerDialogProps): React.JSX.Element {
const mountedRef = useMountedRef()
const openSettingsTarget = useAppStore((state) => state.openSettingsTarget)
const [name, setName] = useState('')
const [targetId, setTargetId] = useState('')
const [deploying, setDeploying] = useState(false)
const openRef = useRef(open)
// Why: bumps on every open, so a background install only resets the session it started in.
const sessionRef = useRef(0)
useEffect(() => {
openRef.current = open
if (open) {
sessionRef.current += 1
}
}, [open])
const hostFieldId = useId()
const nameFieldId = useId()
const targetLabel = emptyTargets.find((target) => target.id === targetId)?.label ?? ''
const serverName = name.trim() || targetLabel
const canSubmit = !deploying && targetId !== '' && serverName !== ''
const deploy = async (): Promise<void> => {
const session = sessionRef.current
setDeploying(true)
try {
const result = await api.deploy({ name: serverName, sshTargetId: targetId })
if (result.outcome === 'deferred') {
toast.message(managedServerOutcomeLabel(result))
} else {
toast.success(
translate(
'auto.components.settings.serverList.setUpDone',
'Orca is set up on {{name}}.',
{
name: result.environment.name
}
)
)
// Why: leave a form the user reopened since this install started alone.
const sameSession = session === sessionRef.current
if (mountedRef.current && (!openRef.current || sameSession)) {
setName('')
setTargetId('')
if (openRef.current) {
onOpenChange(false)
}
}
}
await onDeployed()
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error))
} finally {
if (mountedRef.current) {
setDeploying(false)
}
}
}
const handleOpenChange = (next: boolean): void => {
if (!next && deploying) {
toast.message(
translate(
'auto.components.settings.serverList.settingUpInBackground',
'Setting up Orca on {{name}} in the background. It appears in the list when it’s ready.',
{ name: serverName }
)
)
}
onOpenChange(next)
}
return (
// Why closable mid-deploy: an SSH install can take minutes; it finishes in the background.
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault()
if (canSubmit) {
void deploy()
}
}}
>
<DialogHeader>
<DialogTitle>
{translate(
'auto.components.settings.serverList.setUpOnSshHost',
'Set up Orca on an SSH host'
)}
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-2">
<p>
{translate(
'auto.components.settings.managedServers.description',
'Orca installs itself on an empty SSH host and keeps itself up to date.'
)}
</p>
{/* Why: this is the moment something moves, so say what does and doesn't. */}
<p>
{translate(
'auto.components.settings.managedServers.reassurance',
'Runs on each host, reached over your own SSH connection. Your code and files stay where they are. Only Orca’s list of projects, tabs and terminals moves to the host.'
)}
</p>
</div>
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor={hostFieldId}>
{translate('auto.components.settings.managedServers.deploy.host', 'SSH host')}
</Label>
<SshTargetSelect
id={hostFieldId}
targets={emptyTargets}
value={targetId}
onChange={setTargetId}
placeholder={translate(
'auto.components.settings.serverList.installHostPlaceholder',
'Choose an SSH host without Orca'
)}
/>
<p className="text-xs text-muted-foreground">
{emptyTargets.length === 0
? translate(
'auto.components.settings.serverList.installNoHosts',
'No SSH hosts without an Orca server.'
)
: translate(
'auto.components.settings.serverList.installHostHelp',
'Only hosts without an Orca server.'
)}{' '}
<button
type="button"
className="cursor-pointer text-foreground underline underline-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => {
onOpenChange(false)
openSettingsTarget({ pane: 'ssh', repoId: null })
}}
>
{translate('auto.components.settings.serverList.addSshHost', 'Add an SSH host')}
</button>
</p>
{loadFailed ? (
<p className="text-xs text-destructive">
{translate(
'auto.components.settings.managedServers.loadFailed',
'Orca couldn’t load SSH hosts and unfinished moves. Reopen Settings to try again.'
)}
</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor={nameFieldId}>
{translate('auto.components.settings.managedServers.deploy.name', 'Server name')}{' '}
<span className="font-normal text-muted-foreground">
{translate('auto.components.settings.RuntimeHostAccessForm.optional', '(optional)')}
</span>
</Label>
<Input
id={nameFieldId}
value={name}
placeholder={
targetLabel ||
translate(
'auto.components.settings.serverList.installNamePlaceholder',
'Same as the SSH host'
)
}
onChange={(event) => setName(event.target.value)}
/>
</div>
<DialogFooter>
<Button type="button" variant="ghost" size="sm" onClick={() => handleOpenChange(false)}>
{deploying
? translate('auto.components.settings.serverList.close', 'Close')
: translate('auto.components.settings.serverList.cancel', 'Cancel')}
</Button>
<Button type="submit" size="sm" disabled={!canSubmit}>
{deploying ? <LoadingSpinner className="size-3.5" /> : null}
{deploying
? translate(
'auto.components.settings.sshHostServer.deploying',
'Setting up Orca on {{host}}…',
{
host: targetLabel
}
)
: translate('auto.components.settings.serverList.setUp', 'Set up')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../../../shared/pairing'
import { defaultServerNameForAccessLink } from './runtime-server-default-name'
function link(endpoint: string): string {
return encodePairingOffer({
v: PAIRING_OFFER_VERSION,
endpoint,
deviceToken: 'token',
publicKeyB64: 'key',
scope: 'runtime'
})
}
describe('default server name', () => {
it('uses the host when nothing else is called that', () => {
expect(defaultServerNameForAccessLink(link('ws://100.76.32.125:6768'))).toBe('100.76.32.125')
})
it('adds the port when a server on the same host already took the bare name', () => {
expect(
defaultServerNameForAccessLink(link('ws://127.0.0.1:7002'), [{ name: '127.0.0.1' }])
).toBe('127.0.0.1:7002')
})
it('numbers the name when host and host:port are both taken', () => {
expect(
defaultServerNameForAccessLink(link('ws://100.76.32.125:6768'), [
{ name: '100.76.32.125' },
{ name: '100.76.32.125:6768' }
])
).toBe('100.76.32.125 (2)')
})
it('returns nothing for an invalid link', () => {
expect(defaultServerNameForAccessLink('not-a-link')).toBe('')
})
})
@@ -0,0 +1,34 @@
import { parseHostAccessLink } from '../../../../shared/remote-pairing-address'
import { findCollidingEnvironmentName } from '../../../../shared/runtime-environment-name-collision'
/**
* The name a server gets when the user leaves Name blank: the link's host, then host:port, then
* "host (n)" — two servers on one machine (or one Tailscale IP) must not collide.
*/
export function defaultServerNameForAccessLink(
accessLink: string,
existing: readonly { name: string }[] = []
): string {
const parsed = parseHostAccessLink(accessLink)
if (!parsed.ok) {
return ''
}
const endpoint = parsed.value.displayEndpoint
let host = endpoint
try {
host = new URL(`ws://${endpoint}`).hostname.replace(/^\[|\]$/g, '')
} catch {
// Not URL-shaped; the endpoint itself is the best name.
}
for (const candidate of host === endpoint ? [host] : [host, endpoint]) {
if (!findCollidingEnvironmentName(existing, candidate)) {
return candidate
}
}
for (let suffix = 2; ; suffix += 1) {
const candidate = `${host} (${suffix})`
if (!findCollidingEnvironmentName(existing, candidate)) {
return candidate
}
}
}
@@ -0,0 +1,171 @@
import type React from 'react'
import { MoreHorizontal, Server } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { LoadingSpinner } from '../LoadingSpinner'
import { Button } from '../ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '../ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
export type ServerRowStatusTone = 'success' | 'warning' | 'destructive' | 'muted'
export type ServerRowStatus = {
label: string
tone: ServerRowStatusTone
busy?: boolean
}
const STATUS_DOT_CLASS: Record<ServerRowStatusTone, string> = {
success: 'bg-status-success',
warning: 'bg-status-warning',
destructive: 'bg-destructive',
muted: 'bg-muted-foreground'
}
const STATUS_LABEL_CLASS: Record<ServerRowStatusTone, string> = {
success: 'text-muted-foreground',
warning: 'text-muted-foreground',
destructive: 'text-destructive',
muted: 'text-muted-foreground'
}
export function ServerRowChip({ children }: { children: React.ReactNode }): React.JSX.Element {
return (
<span className="shrink-0 rounded border border-border bg-accent px-1.5 py-px text-3xs leading-4 text-muted-foreground">
{children}
</span>
)
}
export function ServerRowStatusLabel({ status }: { status: ServerRowStatus }): React.JSX.Element {
return (
<span className="flex min-w-0 items-center gap-2 text-xs">
{status.busy ? (
<LoadingSpinner className="size-3 shrink-0 text-muted-foreground" />
) : (
<span className={cn('size-2 shrink-0 rounded-full', STATUS_DOT_CLASS[status.tone])} />
)}
<span className={cn('truncate', STATUS_LABEL_CLASS[status.tone])}>{status.label}</span>
</span>
)
}
type ServerListRowProps = {
sectionId?: string
name: string
/** Who the host is (OS · machine), kept beside the name whatever the meta line says. */
descriptor?: string | null
icon?: React.ReactNode
chips?: React.ReactNode
meta?: React.ReactNode
status: ServerRowStatus | null
primaryAction?: React.ReactNode
/** DropdownMenu items; null keeps the slot so columns stay aligned. */
menuItems?: React.ReactNode
menuDisabled?: boolean
/** Shown below the row while present: inline details, confirmations, pending operations. */
footer?: React.ReactNode
}
/**
* One saved server: name and one meta line on the left; fixed-width status, primary action and
* ⋯ slots on the right so rows line up whatever their state.
*/
export function ServerListRow({
sectionId,
name,
descriptor,
icon,
chips,
meta,
status,
primaryAction,
menuItems,
menuDisabled = false,
footer
}: ServerListRowProps): React.JSX.Element {
return (
<div data-settings-section={sectionId} className="scroll-mt-8">
<div className="flex min-h-14 items-center gap-3 px-4 py-2.5">
<span className="shrink-0 text-muted-foreground">
{icon ?? <Server className="size-4" />}
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium">{name}</span>
{chips}
{descriptor ? (
<span className="truncate text-xs text-muted-foreground">{descriptor}</span>
) : null}
</div>
{meta ? (
<div className="flex min-w-0 items-center gap-1 text-xs text-muted-foreground">
{meta}
</div>
) : null}
</div>
<div className="w-32 shrink-0">
{status ? <ServerRowStatusLabel status={status} /> : null}
</div>
<div className="flex w-24 shrink-0 justify-end">{primaryAction}</div>
<div className="flex w-6 shrink-0 justify-end">
{menuItems ? (
<DropdownMenu modal={false}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
disabled={menuDisabled}
aria-label={translate(
'auto.components.settings.serverList.moreActions',
'More actions for {{name}}',
{ name }
)}
>
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate(
'auto.components.settings.serverList.moreActionsTooltip',
'More actions'
)}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="min-w-52">
{menuItems}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
{footer ? <div className="space-y-2 px-4 pb-3 pl-11">{footer}</div> : null}
</div>
)
}
/** The inline "Details" toggle at the end of a meta line. */
export function ServerRowDetailsToggle({
expanded,
onToggle
}: {
expanded: boolean
onToggle: () => void
}): React.JSX.Element {
return (
<button
type="button"
className="shrink-0 cursor-pointer rounded-sm text-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-expanded={expanded}
onClick={onToggle}
>
{expanded
? translate('auto.components.settings.serverList.hideDetails', 'Hide details')
: translate('auto.components.settings.serverList.details', 'Details')}
</button>
)
}
@@ -0,0 +1,197 @@
import { Link2, Server, TerminalSquare } from 'lucide-react'
import type { ComponentProps } from 'react'
import {
getOrcadSshDeployment,
type PublicKnownRuntimeEnvironment
} from '../../../../shared/runtime-environments'
import type { ManagedOrcadPreloadApi } from '../../../../preload/api/managed-orcad-api'
import type { RemoteServerUpdateEntry } from '@/runtime/remote-server-update-coordinator'
import type { UpdateCheckOptions } from '../../../../shared/update-status-types'
import { translate } from '@/i18n/i18n'
import { duplicatePairedServerNames } from '@/lib/duplicate-paired-servers'
import { LoadingSpinner } from '../LoadingSpinner'
import { Button } from '../ui/button'
import { migrationPhaseLabel } from './managed-server-copy'
import { managedServerTitle } from './managed-server-row-parts'
import type { RuntimeHostDetails } from './runtime-environment-host-details'
import { RuntimeServerRow } from './runtime-server-row'
import { ServerListRow } from './runtime-server-list-row'
import type { ManagedServerCatalog } from './use-managed-server-catalog'
import { useManagedServerLifecycle } from './use-managed-server-lifecycle'
type RowProps = ComponentProps<typeof RuntimeServerRow>
function ManagedRuntimeServerRow({
api,
onChanged,
...props
}: Omit<RowProps, 'managed'> & {
api: ManagedOrcadPreloadApi
onChanged: () => void
}): React.JSX.Element {
const lifecycle = useManagedServerLifecycle({ api, environment: props.environment, onChanged })
return <RuntimeServerRow {...props} managed={lifecycle} onChanged={onChanged} />
}
type RuntimeServerListProps = {
environments: PublicKnownRuntimeEnvironment[]
detailsByEnvironmentId: Record<string, RuntimeHostDetails>
activeRuntimeEnvironmentId: string | null | undefined
remoteServerUpdates: Map<string, RemoteServerUpdateEntry>
remoteServerUpdatesRunning: boolean
connectingId: string | null
switchingValue: string | null
disconnectingId: string | null
removingId: string | null
isBusy: boolean
managedCatalog: ManagedServerCatalog
onAddServer: () => void
onInstallServer: () => void
onOpenUpdate: () => void
onConnect: (environment: PublicKnownRuntimeEnvironment) => void
onDisconnect: (environment: PublicKnownRuntimeEnvironment) => void
onRemove: (environment: PublicKnownRuntimeEnvironment) => void
onMakeDefault: (environment: PublicKnownRuntimeEnvironment) => void
onCheckForUpdates: (options: UpdateCheckOptions) => void
onRecheck: () => void
rechecking: boolean
onChanged: () => void
}
/** Every saved server once: paired, managed over SSH, and managed moves still in progress. */
export function RuntimeServerList({
environments,
detailsByEnvironmentId,
activeRuntimeEnvironmentId,
remoteServerUpdates,
remoteServerUpdatesRunning,
connectingId,
switchingValue,
disconnectingId,
removingId,
isBusy,
managedCatalog,
onAddServer,
onInstallServer,
onOpenUpdate,
onConnect,
onDisconnect,
onRemove,
onMakeDefault,
onCheckForUpdates,
onRecheck,
rechecking,
onChanged
}: RuntimeServerListProps): React.JSX.Element {
const { api, pending, targets, resumingId, resume, reload } = managedCatalog
if (environments.length === 0 && pending.length === 0) {
return (
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-border px-6 py-10 text-center">
<span className="flex size-10 items-center justify-center rounded-lg bg-accent text-muted-foreground">
<Server className="size-5" />
</span>
<div className="space-y-1">
<div className="text-sm font-medium">
{translate('auto.components.settings.serverList.noSavedServers', 'No saved servers')}
</div>
<p className="max-w-sm text-xs text-muted-foreground">
{translate(
'auto.components.settings.serverList.emptyDescription',
'Pair a dev box, a cloud machine, or a desktop at home. Its sessions keep running there when this computer sleeps.'
)}
</p>
</div>
<div className="flex flex-wrap justify-center gap-2">
<Button type="button" size="sm" onClick={onAddServer}>
<Link2 />
{translate('auto.components.settings.serverList.pasteAccessLink', 'Paste access link')}
</Button>
{api ? (
<Button type="button" variant="outline" size="sm" onClick={onInstallServer}>
<TerminalSquare />
{translate(
'auto.components.settings.serverList.setUpOnSshHost',
'Set up Orca on an SSH host'
)}
</Button>
) : null}
</div>
</div>
)
}
const firstManagedId = environments.find((environment) => getOrcadSshDeployment(environment))?.id
return (
<div
data-settings-section={firstManagedId ? undefined : 'managed-servers'}
className="divide-y divide-border rounded-lg border border-border bg-card"
>
{environments.map((environment) => {
const rowProps = {
environment,
duplicateOfNames: duplicatePairedServerNames(environments, environment.id),
details: detailsByEnvironmentId[environment.id],
isActive: activeRuntimeEnvironmentId === environment.id,
remoteUpdate: remoteServerUpdates.get(environment.id),
remoteServerUpdatesRunning,
connecting: connectingId === environment.id,
switching: switchingValue === environment.id,
disconnecting: disconnectingId === environment.id,
removing: removingId === environment.id,
isBusy,
sshAccessApi: api ?? null,
sshTargets: targets,
onOpenUpdate,
onConnect,
onDisconnect,
onRemove,
onMakeDefault,
onCheckForUpdates,
onRefreshSshTargets: () => void reload().catch(() => {}),
onRecheck,
rechecking
}
const row =
api && getOrcadSshDeployment(environment) ? (
<ManagedRuntimeServerRow {...rowProps} api={api} onChanged={onChanged} />
) : (
<RuntimeServerRow {...rowProps} onChanged={onChanged} />
)
// Why the wrapper: deep links to "managed-servers" land on the first managed row.
return environment.id === firstManagedId ? (
<div key={environment.id} data-settings-section="managed-servers" className="scroll-mt-8">
{row}
</div>
) : (
<div key={environment.id}>{row}</div>
)
})}
{pending.map((row) => (
<ServerListRow
key={row.migrationId}
name={managedServerTitle(
targets.find((target) => target.id === row.sshTargetId)?.label ?? row.name
)}
meta={<span className="truncate">{migrationPhaseLabel(row.phase)}</span>}
status={{
label: translate('auto.components.settings.serverList.movePaused', 'Move paused'),
tone: 'muted'
}}
primaryAction={
<Button
type="button"
size="xs"
variant="outline"
disabled={resumingId !== null}
onClick={() => void resume(row)}
>
{resumingId === row.migrationId ? <LoadingSpinner className="size-3" /> : null}
{translate('auto.components.settings.managedServers.pending.resume', 'Resume')}
</Button>
}
/>
))}
</div>
)
}
@@ -0,0 +1,167 @@
import { useState } from 'react'
import { Copy } from 'lucide-react'
import { toast } from 'sonner'
import { classifyRemotePairingHostname } from '../../../../shared/remote-pairing-address'
import type { RemoteServerUpdateEntry } from '@/runtime/remote-server-update-coordinator'
import { translate } from '@/i18n/i18n'
import { Button } from '../ui/button'
import {
getHostModelCapabilitySummary,
getRuntimeCapabilitiesSummary,
type RuntimeHostDetails
} from './runtime-environment-host-details'
import { getRemoteServerManualUpdateHelp } from './RemoteServerUpdateStatus'
type RuntimeServerRowDetailsProps = {
endpoint: string | null
details: RuntimeHostDetails | undefined
remoteUpdate: RemoteServerUpdateEntry | undefined
/** The raw error, shown as-is for copying; the lead sentence explains it. */
problem: string | null
sshRoute: string | null
unreachable: boolean
children?: React.ReactNode
}
function endpointKind(
endpoint: string | null
): ReturnType<typeof classifyRemotePairingHostname> | null {
if (!endpoint) {
return null
}
try {
return classifyRemotePairingHostname(new URL(endpoint).hostname)
} catch {
return null
}
}
/** The row's expanded diagnostics: a plain lead, the raw error to copy, and where to look next. */
export function RuntimeServerRowDetails({
endpoint,
details,
remoteUpdate,
problem,
sshRoute,
unreachable,
children
}: RuntimeServerRowDetailsProps): React.JSX.Element {
// Why collapsed: the raw error is for copying into a bug report, not for reading first.
const [errorOpen, setErrorOpen] = useState(false)
const runtimeStatus = details?.runtimeStatus ?? null
const hostModelSummary = getHostModelCapabilitySummary(runtimeStatus)
const updateError = remoteUpdate?.phase === 'failed' ? remoteUpdate.error : null
const rawError = [problem, updateError].filter(Boolean).join('\n')
const kind = unreachable ? endpointKind(endpoint) : null
const lead = unreachable
? translate(
'auto.components.settings.serverList.unreachableLead',
'Orca couldn’t reach this server.'
)
: updateError
? translate(
'auto.components.settings.serverList.updateFailedLead',
'The last update didn’t finish.'
)
: null
return (
<div className="space-y-2 rounded-md border border-border bg-muted/30 px-3 py-2.5 text-xs">
{lead ? <p className="text-foreground">{lead}</p> : null}
{unreachable ? (
<p className="text-muted-foreground">
{translate(
'auto.components.settings.serverList.unreachableHint',
'Check that Orca is running on the server and that this computer can reach its address.'
)}
{kind === 'tailscale'
? ` ${translate(
'auto.components.settings.serverList.tailscaleTip',
'This is a Tailscale address, so both computers need to be signed in to the same tailnet.'
)}`
: kind === 'loopback'
? ` ${translate(
'auto.components.settings.serverList.loopbackTip',
'A 127.0.0.1 address works only through an SSH tunnel.'
)}`
: ''}
</p>
) : null}
{remoteUpdate?.phase === 'manual' ? (
<p className="text-muted-foreground">{getRemoteServerManualUpdateHelp(remoteUpdate)}</p>
) : null}
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1 text-muted-foreground">
<dt>{translate('auto.components.settings.serverList.address', 'Address')}</dt>
<dd className="truncate font-mono text-foreground">
{endpoint ??
translate('auto.components.settings.RuntimeEnvironmentsPane.6ef71985da', 'No endpoint')}
</dd>
{sshRoute ? (
<>
<dt>{translate('auto.components.settings.serverList.route', 'Route')}</dt>
<dd className="truncate text-foreground">{sshRoute}</dd>
</>
) : null}
{runtimeStatus ? (
<>
<dt>{translate('auto.components.settings.serverList.protocol', 'Protocol')}</dt>
<dd className="truncate text-foreground">
{runtimeStatus.runtimeProtocolVersion ?? runtimeStatus.protocolVersion ?? 0}
{runtimeStatus.hostPlatform ? ` · ${runtimeStatus.hostPlatform}` : ''}
</dd>
<dt>{translate('auto.components.settings.serverList.capabilities', 'Capabilities')}</dt>
<dd className="truncate text-foreground">
{getRuntimeCapabilitiesSummary(runtimeStatus)}
</dd>
</>
) : null}
</dl>
{hostModelSummary ? <p className="text-muted-foreground">{hostModelSummary}</p> : null}
{children}
{rawError ? (
<div className="space-y-1.5">
{errorOpen ? (
<pre className="max-h-32 overflow-y-auto whitespace-pre-wrap break-words rounded bg-muted px-2 py-1.5 font-mono text-2xs text-muted-foreground scrollbar-sleek">
{rawError}
</pre>
) : null}
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="xs"
aria-expanded={errorOpen}
onClick={() => setErrorOpen((value) => !value)}
>
{errorOpen
? translate('auto.components.settings.serverList.hideError', 'Hide error')
: translate('auto.components.settings.serverList.showError', 'Show error')}
</Button>
<Button
type="button"
variant="outline"
size="xs"
onClick={() => {
void window.api.ui.writeClipboardText(rawError).then(
() =>
toast.success(
translate('auto.components.settings.serverList.copied', 'Error copied')
),
() =>
toast.error(
translate(
'auto.components.settings.serverList.copyFailed',
'Couldn’t copy the error.'
)
)
)
}}
>
<Copy />
{translate('auto.components.settings.serverList.copyError', 'Copy error')}
</Button>
</div>
</div>
) : null}
</div>
)
}
@@ -0,0 +1,118 @@
import {
ArrowUpCircle,
Info,
RefreshCw,
Server,
ServerOff,
Star,
Trash2,
Waypoints
} from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { getUpdateCheckClickOptions, getUpdateCheckHint } from '@/lib/update-check-click-options'
import type { UpdateCheckOptions } from '../../../../shared/update-status-types'
import { DropdownMenuItem, DropdownMenuSeparator } from '../ui/dropdown-menu'
type RuntimeServerRowMenuProps = {
showUpdate: boolean
showConnect: boolean
showDisconnect: boolean
showMakeDefault: boolean
/** null hides the item; otherwise whether the route is already linked. */
sshAccessLinked: boolean | null
detailsExpanded: boolean
actionBusy: boolean
isBusy: boolean
connectDisabled: boolean
/** Managed-server lifecycle items, placed between setup and removal. */
managedItems?: React.ReactNode
onUpdate: () => void
onConnect: () => void
onDisconnect: () => void
onMakeDefault: () => void
onCheckForUpdates?: (options: UpdateCheckOptions) => void
onSshAccess: () => void
onToggleDetails: () => void
onRemove: () => void
}
/** Order: state change → preference → setup → destructive. */
export function RuntimeServerRowMenuItems({
showUpdate,
showConnect,
showDisconnect,
showMakeDefault,
sshAccessLinked,
detailsExpanded,
actionBusy,
isBusy,
connectDisabled,
managedItems,
onUpdate,
onConnect,
onDisconnect,
onMakeDefault,
onCheckForUpdates,
onSshAccess,
onToggleDetails,
onRemove
}: RuntimeServerRowMenuProps): React.JSX.Element {
return (
<>
{showUpdate ? (
<DropdownMenuItem onSelect={onUpdate}>
<ArrowUpCircle />
{translate('auto.components.settings.serverList.updateMenu', 'Update…')}
</DropdownMenuItem>
) : null}
{showDisconnect ? (
<DropdownMenuItem disabled={actionBusy} onSelect={onDisconnect}>
<ServerOff />
{translate('auto.components.settings.RuntimeEnvironmentsPane.disconnect', 'Disconnect')}
</DropdownMenuItem>
) : null}
{showConnect ? (
<DropdownMenuItem disabled={actionBusy || connectDisabled} onSelect={onConnect}>
<Server />
{translate('auto.components.settings.RuntimeEnvironmentsPane.connect', 'Connect')}
</DropdownMenuItem>
) : null}
{showMakeDefault ? (
<DropdownMenuItem disabled={isBusy} onSelect={onMakeDefault}>
<Star />
{translate('auto.components.settings.serverList.makeDefault', 'Open new projects here')}
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
{sshAccessLinked !== null ? (
<DropdownMenuItem onSelect={onSshAccess}>
<Waypoints />
{sshAccessLinked
? translate('auto.components.settings.serverList.sshAccessLinked', 'SSH route…')
: translate('auto.components.settings.serverList.sshAccess', 'Connect through SSH…')}
</DropdownMenuItem>
) : null}
{onCheckForUpdates ? (
<DropdownMenuItem
title={getUpdateCheckHint()}
onClick={(event) => onCheckForUpdates(getUpdateCheckClickOptions(event))}
>
<RefreshCw />
{translate('auto.components.settings.serverList.checkForUpdates', 'Check for updates')}
</DropdownMenuItem>
) : null}
<DropdownMenuItem onSelect={onToggleDetails}>
<Info />
{detailsExpanded
? translate('auto.components.settings.serverList.hideDetails', 'Hide details')
: translate('auto.components.settings.serverList.details', 'Details')}
</DropdownMenuItem>
<DropdownMenuSeparator />
{managedItems}
<DropdownMenuItem variant="destructive" disabled={isBusy} onSelect={onRemove}>
<Trash2 />
{translate('auto.components.settings.serverList.remove', 'Remove…')}
</DropdownMenuItem>
</>
)
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import { describeNetworkAddress } from './network-address-label'
import { getRuntimeServerProblemLine } from './runtime-server-row-meta'
const quietRow = {
accessRevoked: false,
problem: 'Timed out waiting for the remote Orca runtime.',
probeFailed: true,
compatibilityBlocked: false
}
describe('runtime server problem line', () => {
it('says when an unreachable server was last seen instead of repeating the failure', () => {
const now = Date.UTC(2026, 9, 20)
expect(
getRuntimeServerProblemLine({
environment: { lastUsedAt: now - 3 * 86_400_000 },
row: quietRow,
remoteUpdate: undefined,
managedStatusError: null,
managedSummary: null,
address: '100.88.12.5:6768',
now
})
).toEqual({ text: 'Last seen 3 days ago · 100.88.12.5:6768', destructive: false })
})
it('says a server was never connected rather than still waiting', () => {
expect(
getRuntimeServerProblemLine({
environment: { lastUsedAt: null },
row: quietRow,
remoteUpdate: undefined,
managedStatusError: null,
managedSummary: null,
address: '100.88.12.5:6768'
})?.text
).toBe('Never connected · 100.88.12.5:6768')
})
})
describe('a server paired twice', () => {
it('names the twin ahead of reachability, since one entry must go', () => {
expect(
getRuntimeServerProblemLine({
environment: { lastUsedAt: null },
row: quietRow,
remoteUpdate: undefined,
managedStatusError: null,
managedSummary: null,
address: '100.88.12.5:6768',
duplicateOfNames: ['Studio relay']
})
).toEqual({
text: 'Same server as Studio relay, paired twice. Remove one so its projects can be edited.',
destructive: true
})
})
})
describe('network address label', () => {
it('names Tailscale and LAN addresses instead of the interface', () => {
expect(describeNetworkAddress('utun6', '100.108.226.109')).toBe('Tailscale · 100.108.226.109')
expect(describeNetworkAddress('en0', '192.168.1.20')).toBe('Local network · 192.168.1.20')
expect(describeNetworkAddress('eth0', '203.0.113.5')).toBe('eth0 · 203.0.113.5')
})
})
@@ -0,0 +1,116 @@
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
import { getOrcadSshDeployment } from '../../../../shared/runtime-environments'
import type { SshTarget } from '../../../../shared/ssh-types'
import { translate } from '@/i18n/i18n'
import { formatUiRelativeTime } from '@/i18n/relative-time-format'
import type { RemoteServerUpdateEntry } from '@/runtime/remote-server-update-coordinator'
import type { RuntimeServerRowState } from './runtime-server-row-state'
export type RuntimeServerProblemLine = { text: string; destructive: boolean }
/** How the row reaches the server when it isn't its plain network address. */
export function getRuntimeServerRoute(
environment: PublicKnownRuntimeEnvironment,
targets: SshTarget[]
): string | null {
const targetLabel = (targetId: string): string =>
targets.find((target) => target.id === targetId)?.label ?? targetId
const deployment = getOrcadSshDeployment(environment)
if (deployment) {
// Why not the host: the row's title already says "Orca on <host>".
return translate('auto.components.settings.serverList.overYourSsh', 'Over your SSH connection')
}
if (environment.sshAccess) {
return translate(
'auto.components.settings.serverList.throughSsh',
'Through {{host}} → port {{port}}',
{
host: targetLabel(environment.sshAccess.sshTargetId),
port: environment.sshAccess.remotePort
}
)
}
return environment.connectionDependency === 'ssh-tunnel'
? translate(
'auto.components.settings.RuntimeEnvironmentsPane.sshTunnelRequired',
'SSH tunnel required'
)
: null
}
/**
* The one line that replaces the row's info line, if anything needs saying.
* Why "Last seen": the status column already says it's unreachable; the line says since when.
*/
export function getRuntimeServerProblemLine({
environment,
row,
remoteUpdate,
managedStatusError,
managedSummary,
address,
duplicateOfNames = [],
now = Date.now()
}: {
environment: Pick<PublicKnownRuntimeEnvironment, 'lastUsedAt'>
row: Pick<
RuntimeServerRowState,
'accessRevoked' | 'problem' | 'probeFailed' | 'compatibilityBlocked'
>
remoteUpdate: RemoteServerUpdateEntry | undefined
managedStatusError: string | null
managedSummary: string | null
address: string | null
/** Other saved servers with this one's host key: the same machine paired twice. */
duplicateOfNames?: readonly string[]
now?: number
}): RuntimeServerProblemLine | null {
if (row.accessRevoked) {
return { text: row.problem ?? '', destructive: true }
}
// Why before other problems: the twin makes this server's projects uneditable until one goes.
if (duplicateOfNames.length > 0) {
return {
text: translate(
'auto.components.settings.RuntimeEnvironmentsPane.pairedTwice',
'Same server as {{value0}}, paired twice. Remove one so its projects can be edited.',
{ value0: duplicateOfNames[0] }
),
destructive: true
}
}
if (managedStatusError) {
return { text: managedStatusError, destructive: true }
}
if (managedSummary) {
return { text: managedSummary, destructive: false }
}
if (remoteUpdate?.phase === 'failed') {
return {
text: translate('auto.components.settings.RemoteServerUpdateStatus.failed', 'Update failed'),
destructive: true
}
}
if (row.probeFailed) {
const when = environment.lastUsedAt
? translate('auto.components.settings.serverList.lastSeen', 'Last seen {{when}}', {
when: formatUiRelativeTime(environment.lastUsedAt - now)
})
: translate('auto.components.settings.serverList.neverConnected', 'Never connected')
// Why the address: it tells otherwise identical unreachable rows apart.
return { text: address ? `${when} · ${address}` : when, destructive: false }
}
if (row.problem) {
return { text: row.problem, destructive: row.compatibilityBlocked }
}
if (remoteUpdate?.phase === 'manual') {
return {
text: translate(
'auto.components.settings.serverList.manualUpdate',
'Update Orca on the server host'
),
destructive: false
}
}
return null
}
@@ -1,8 +1,11 @@
import { describe, expect, it } from 'vitest'
import type { RemoteServerUpdateEntry } from '@/runtime/remote-server-update-coordinator'
import {
formatRuntimeEndpointAddress,
getRuntimeServerAvailableUpdateText,
getRuntimeServerStatusIndicator,
getRuntimeServerVersionFooter
getRuntimeServerVersionText,
type RuntimeServerStatusInput
} from './runtime-server-row-presentation'
function updateEntry(patch: Partial<RemoteServerUpdateEntry>): RemoteServerUpdateEntry {
@@ -22,55 +25,112 @@ function updateEntry(patch: Partial<RemoteServerUpdateEntry>): RemoteServerUpdat
}
}
function statusInput(patch: Partial<RuntimeServerStatusInput>): RuntimeServerStatusInput {
return {
connectionState: 'disconnected',
accessRevoked: false,
compatibilityBlocked: false,
probeFailed: false,
connecting: false,
disconnecting: false,
remoteUpdate: undefined,
...patch
}
}
describe('runtime server status indicator', () => {
it('names a revoked host as revoked, not disconnected', () => {
expect(getRuntimeServerStatusIndicator('disconnected', true)).toEqual({
expect(getRuntimeServerStatusIndicator(statusInput({ accessRevoked: true }))).toEqual({
label: 'Access revoked',
labelClassName: 'text-destructive',
dotClassName: 'bg-destructive'
tone: 'destructive'
})
})
it('says unreachable, not offline, when the probe got no answer', () => {
expect(getRuntimeServerStatusIndicator(statusInput({ probeFailed: true })).label).toBe(
'Unreachable'
)
expect(getRuntimeServerStatusIndicator(statusInput({})).label).toBe('Disconnected')
})
it('keeps the connection label otherwise', () => {
expect(getRuntimeServerStatusIndicator('disconnected', false).label).toBe('Disconnected')
expect(getRuntimeServerStatusIndicator('connected', false).label).toBe('Connected')
expect(getRuntimeServerStatusIndicator(statusInput({ connectionState: 'connected' }))).toEqual({
label: 'Connected',
tone: 'success'
})
expect(getRuntimeServerStatusIndicator(statusInput({ connectionState: 'checking' }))).toEqual({
label: 'Checking…',
tone: 'muted',
busy: true
})
})
it('shows the update stage while an update runs', () => {
expect(
getRuntimeServerStatusIndicator(
statusInput({
connectionState: 'connected',
remoteUpdate: updateEntry({ phase: 'downloading' })
})
).label
).toBe('Downloading…')
})
it('flags an incompatible server as needing an update', () => {
expect(getRuntimeServerStatusIndicator(statusInput({ compatibilityBlocked: true }))).toEqual({
label: 'Needs update',
tone: 'destructive'
})
})
})
describe('runtime server version footer', () => {
it('shows the last verified version without the Offline badge when revoked', () => {
describe('managed server not running', () => {
it('says Not running only when nothing more specific applies', () => {
expect(getRuntimeServerStatusIndicator(statusInput({ managedNotRunning: true })).label).toBe(
'Not running'
)
expect(
getRuntimeServerVersionFooter(updateEntry({}), {
accessRevoked: true,
lastKnownVersion: '1.4.229'
})
).toEqual({ text: 'Orca v1.4.229 (last seen)', showUpdateStatus: false })
})
it('hides the footer when revoked and no version was ever verified', () => {
getRuntimeServerStatusIndicator(statusInput({ managedNotRunning: true, connecting: true }))
.label
).toBe('Connecting…')
expect(
getRuntimeServerVersionFooter(updateEntry({}), {
accessRevoked: true,
lastKnownVersion: null
})
).toBeNull()
})
it('keeps the offline footer for a host that is actually unreachable', () => {
getRuntimeServerStatusIndicator(statusInput({ managedNotRunning: true, accessRevoked: true }))
.label
).toBe('Access revoked')
expect(
getRuntimeServerVersionFooter(updateEntry({}), {
accessRevoked: false,
lastKnownVersion: '1.4.229'
})
).toEqual({ text: 'Orca version unavailable', showUpdateStatus: true })
})
it('reports the live version and update status when the probe answered', () => {
getRuntimeServerStatusIndicator(
statusInput({ managedNotRunning: true, compatibilityBlocked: true })
).label
).toBe('Needs update')
expect(
getRuntimeServerVersionFooter(updateEntry({ phase: 'current', currentVersion: '1.5.0' }), {
accessRevoked: true,
lastKnownVersion: '1.4.229'
})
).toEqual({ text: 'Orca v1.5.0', showUpdateStatus: true })
getRuntimeServerStatusIndicator(
statusInput({ managedNotRunning: true, remoteUpdate: updateEntry({ phase: 'restarting' }) })
).label
).toBe('Restarting…')
})
})
describe('runtime server meta line', () => {
it('falls back to the last verified version when the probe failed', () => {
expect(getRuntimeServerVersionText(updateEntry({}), '1.4.229')).toBe('v1.4.229')
expect(getRuntimeServerVersionText(updateEntry({}), null)).toBeNull()
expect(getRuntimeServerVersionText(updateEntry({ currentVersion: '1.5.0' }), '1.4.0')).toBe(
'v1.5.0'
)
})
it('names the available version only for an available update', () => {
expect(
getRuntimeServerAvailableUpdateText(
updateEntry({ phase: 'available', targetVersion: '1.9.2' })
)
).toBe('v1.9.2 available')
expect(getRuntimeServerAvailableUpdateText(updateEntry({ phase: 'current' }))).toBeNull()
})
it('shows host:port for a ws endpoint', () => {
expect(formatRuntimeEndpointAddress('ws://100.88.1.4:6768')).toBe('100.88.1.4:6768')
expect(formatRuntimeEndpointAddress('not a url')).toBe('not a url')
expect(formatRuntimeEndpointAddress(undefined)).toBeNull()
})
})
@@ -1,75 +1,142 @@
import { translate } from '@/i18n/i18n'
import type { RemoteServerUpdateEntry } from '@/runtime/remote-server-update-coordinator'
import { isConnectedRuntimeHostState } from '@/runtime/runtime-host-connection-state'
import type {
RemoteServerUpdateEntry,
RemoteServerUpdatePhase
} from '@/runtime/remote-server-update-coordinator'
import {
getRuntimeServerConnectionLabel,
getRuntimeServerDotClass,
type RuntimeServerConnectionState
} from './runtime-environment-host-details'
import { getRemoteServerUpdatePhaseLabel } from './RemoteServerUpdateStatus'
import type { ServerRowStatus } from './runtime-server-list-row'
export type RuntimeServerStatusIndicator = {
label: string
labelClassName: string
dotClassName: string
const UPDATING_PHASES: ReadonlySet<RemoteServerUpdatePhase> = new Set([
'queued',
'checking-update',
'downloading',
'restarting'
])
export function isRemoteServerUpdateInFlight(entry: RemoteServerUpdateEntry | undefined): boolean {
return entry !== undefined && UPDATING_PHASES.has(entry.phase)
}
export function getRuntimeServerStatusIndicator(
connectionState: RuntimeServerConnectionState,
export type RuntimeServerStatusInput = {
connectionState: RuntimeServerConnectionState
accessRevoked: boolean
): RuntimeServerStatusIndicator {
compatibilityBlocked: boolean
/** The latest probe failed with an error rather than being a user disconnect. */
probeFailed: boolean
connecting: boolean
disconnecting: boolean
remoteUpdate: RemoteServerUpdateEntry | undefined
/** A managed server whose loaded status has no active version. */
managedNotRunning?: boolean
}
/** One status per row; it replaces the old stack of "Disconnected", "Offline" and "Status unavailable". */
export function getRuntimeServerStatusIndicator(input: RuntimeServerStatusInput): ServerRowStatus {
// Why: the host answered and refused our token, so "Disconnected" would misstate a reachable host.
if (accessRevoked) {
if (input.accessRevoked) {
return {
label: translate(
'auto.components.settings.RuntimeEnvironmentsPane.serverAccessRevoked',
'Access revoked'
),
labelClassName: 'text-destructive',
dotClassName: 'bg-destructive'
tone: 'destructive'
}
}
return {
label: getRuntimeServerConnectionLabel(connectionState),
labelClassName: 'text-muted-foreground',
dotClassName: getRuntimeServerDotClass(connectionState)
if (input.remoteUpdate && isRemoteServerUpdateInFlight(input.remoteUpdate)) {
return {
label: getRemoteServerUpdatePhaseLabel(input.remoteUpdate.phase),
tone: 'muted',
busy: true
}
}
}
export type RuntimeServerVersionFooter = {
text: string
showUpdateStatus: boolean
}
export function getRuntimeServerVersionFooter(
entry: RemoteServerUpdateEntry,
options: { accessRevoked: boolean; lastKnownVersion: string | null | undefined }
): RuntimeServerVersionFooter | null {
// Why: a revoked host refuses the version probe; "Offline" would contradict the revoked verdict.
if (options.accessRevoked && entry.phase === 'offline') {
const lastKnownVersion = options.lastKnownVersion?.trim()
return lastKnownVersion
? {
text: translate(
'auto.components.settings.RuntimeEnvironmentsPane.orcaVersionLastSeen',
'Orca v{{value0}} (last seen)',
{ value0: lastKnownVersion }
),
showUpdateStatus: false
}
: null
if (input.connecting) {
return {
label: translate('auto.components.settings.serverList.connecting', 'Connecting…'),
tone: 'muted',
busy: true
}
}
return {
text: entry.currentVersion
? translate(
'auto.components.settings.RuntimeEnvironmentsPane.orcaVersion',
'Orca v{{value0}}',
{
value0: entry.currentVersion
if (input.disconnecting) {
return {
label: translate('auto.components.settings.serverList.disconnecting', 'Disconnecting…'),
tone: 'muted',
busy: true
}
}
if (input.compatibilityBlocked) {
return {
label: translate('auto.components.settings.serverList.needsUpdate', 'Needs update'),
tone: 'destructive'
}
}
// Why after the states above: revoked, updating, connecting or incompatible say more than this.
if (input.managedNotRunning && !isConnectedRuntimeHostState(input.connectionState)) {
return {
label: translate('auto.components.settings.managedServers.row.notRunning', 'Not running'),
tone: 'muted'
}
}
const label = getRuntimeServerConnectionLabel(input.connectionState)
switch (input.connectionState) {
case 'connected':
return { label, tone: 'success' }
case 'checking':
return { label, tone: 'muted', busy: true }
case 'reconnecting':
return { label, tone: 'warning', busy: true }
case 'runtime-unavailable':
case 'workspace-window-closed':
return { label, tone: 'warning' }
case 'disconnected':
// Why "Unreachable": a probe that got no answer doesn't prove the host is down.
return input.probeFailed
? {
label: translate('auto.components.settings.serverList.unreachable', 'Unreachable'),
tone: 'muted'
}
)
: translate(
'auto.components.settings.RuntimeEnvironmentsPane.versionUnavailable',
'Orca version unavailable'
),
showUpdateStatus: true
: { label, tone: 'muted' }
}
}
/** The version part of the meta line; null when no version was ever verified. */
export function getRuntimeServerVersionText(
entry: RemoteServerUpdateEntry | undefined,
lastKnownVersion: string | null | undefined
): string | null {
const version = entry?.currentVersion?.trim() || lastKnownVersion?.trim()
return version
? translate('auto.components.settings.serverList.version', 'v{{version}}', {
version
})
: null
}
export function getRuntimeServerAvailableUpdateText(
entry: RemoteServerUpdateEntry | undefined
): string | null {
if (entry?.phase !== 'available') {
return null
}
return entry.targetVersion
? translate('auto.components.settings.serverList.updateAvailable', 'v{{version}} available', {
version: entry.targetVersion
})
: translate('auto.components.settings.RemoteServerUpdateStatus.available', 'Update available')
}
/** host:port of a ws:// endpoint; the raw string when it isn't a URL. */
export function formatRuntimeEndpointAddress(endpoint: string | undefined): string | null {
if (!endpoint) {
return null
}
try {
return new URL(endpoint).host || endpoint
} catch {
return endpoint
}
}
@@ -0,0 +1,130 @@
import {
runtimePairingRepairRoute,
type PublicKnownRuntimeEnvironment,
type RuntimePairingRepairRoute
} from '../../../../shared/runtime-environments'
import { resolveHostDisplay } from '../../../../shared/host-display-resolution'
import { lastVerifiedRuntimeStatus } from '../../../../shared/runtime-host-status'
import type { RuntimeStatus } from '../../../../shared/runtime-types'
import { translate } from '@/i18n/i18n'
import {
isConnectedRuntimeHostState,
runtimeHostConnectionStateForEntry
} from '@/runtime/runtime-host-connection-state'
import { useAppStore } from '@/store'
import {
evaluateHostDetails,
getHostDetailsDescription,
getRuntimeServerConnectionState,
isRuntimeServerTransportConnected,
type RuntimeHostDetails,
type RuntimeServerConnectionState
} from './runtime-environment-host-details'
export type RuntimeServerRowState = {
effectiveDetails: RuntimeHostDetails | undefined
connectionState: RuntimeServerConnectionState
isReachable: boolean
accessRevoked: boolean
repairRoute: RuntimePairingRepairRoute
/** What went wrong, in full; the row truncates it to one line. */
problem: string | null
probeFailed: boolean
compatibilityBlocked: boolean
/** Last answered status, kept across a lost probe. */
descriptorStatus: RuntimeStatus | null | undefined
hostDescriptorText: string | null
title: string
}
export function useRuntimeServerRowState(
environment: PublicKnownRuntimeEnvironment,
details: RuntimeHostDetails | undefined
): RuntimeServerRowState {
const runtimeStatusEntry = useAppStore((state) =>
state.runtimeStatusByEnvironmentId.get(environment.id)
)
// Why the shared verdict and not `entry.status`: an unverifiable probe nulls it while the
// transport is still up, and this row then read "error" and offered Connect for a host that
// RepositoryHostSetupsSection -- which already derives through this same function -- was
// showing as reachable. One host, two surfaces, opposite answers. A probe that did not come
// back is not a host that went away (docs/reference/ssh-execution-boundary.md).
const entryReachable =
runtimeStatusEntry !== undefined &&
isConnectedRuntimeHostState(runtimeHostConnectionStateForEntry(runtimeStatusEntry))
const effectiveDetails: RuntimeHostDetails | undefined = runtimeStatusEntry
? {
...(details ?? {
status: entryReachable ? 'ready' : 'error',
runtimeStatus: null,
compatibility: null,
error: null
}),
status: entryReachable ? 'ready' : 'error',
runtimeStatus: runtimeStatusEntry.status,
compatibility: runtimeStatusEntry.status
? evaluateHostDetails(runtimeStatusEntry.status)
: null,
remoteControl:
runtimeStatusEntry.remoteControl ?? runtimeStatusEntry.status?.remoteControl ?? null
}
: details
const snapshot = runtimeStatusEntry?.snapshot
const accessRevoked =
snapshot?.verification === 'blocked' &&
!snapshot.retired &&
snapshot.blockedReason === 'access-revoked'
const repairRoute = runtimePairingRepairRoute(environment)
const connectionState =
details?.status === 'loading' && !runtimeStatusEntry?.status
? 'checking'
: getRuntimeServerConnectionState(effectiveDetails)
const isReachable = isRuntimeServerTransportConnected(connectionState)
// Why: the snapshot keeps the last answered status across a lost probe; `status` is only the latest answer.
const descriptorStatus = lastVerifiedRuntimeStatus(runtimeStatusEntry)
const hostDisplay = resolveHostDisplay({
name: environment.name,
machineName: descriptorStatus?.machineName,
platform: descriptorStatus?.hostPlatform
})
const compatibilityBlocked = !accessRevoked && effectiveDetails?.compatibility?.kind === 'blocked'
return {
effectiveDetails,
connectionState,
isReachable,
accessRevoked,
repairRoute,
problem: accessRevoked
? getRevokedDescription(environment.name, repairRoute)
: getHostDetailsDescription(effectiveDetails),
probeFailed:
!isReachable && effectiveDetails?.status === 'error' && Boolean(effectiveDetails.error),
compatibilityBlocked,
descriptorStatus,
hostDescriptorText: hostDisplay.descriptorLine,
title: hostDisplay.title
}
}
function getRevokedDescription(name: string, repairRoute: RuntimePairingRepairRoute): string {
if (repairRoute === 'ssh') {
// Why: Orca pairs a managed server over SSH by itself; it reaches here only when that failed.
return translate(
'auto.components.settings.RuntimeEnvironmentsPane.managedAccessRevoked',
"{{value0}} no longer accepts this computer, and Orca couldn't pair with it again over SSH. Connect to try again.",
{ value0: name }
)
}
if (repairRoute === 'connect') {
return translate(
'auto.components.settings.RuntimeEnvironmentsPane.connectAccessRevoked',
'{{value0}} no longer accepts this computer. Connect to try again.',
{ value0: name }
)
}
return translate(
'auto.components.settings.RuntimeEnvironmentsPane.accessRevoked',
'{{value0}} no longer accepts this computer. Re-pair it with a new access link.',
{ value0: name }
)
}
@@ -14,8 +14,24 @@ import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-e
import type { RuntimeStatus } from '../../../../shared/runtime-types'
import type { RemoteServerUpdateEntry } from '@/runtime/remote-server-update-coordinator'
import { useAppStore } from '@/store'
import { TooltipProvider } from '../ui/tooltip'
import { RuntimeServerRow } from './runtime-server-row'
// Why: menu items render inline as buttons so the row's menu actions are queryable.
vi.mock('../ui/dropdown-menu', () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuSeparator: () => null,
DropdownMenuItem: ({
children,
onSelect
}: {
children: React.ReactNode
onSelect?: () => void
}) => <button onClick={onSelect}>{children}</button>
}))
const ENVIRONMENT_ID = 'env-a'
const initialState = useAppStore.getInitialState()
@@ -68,23 +84,25 @@ function renderRow(
duplicateOfNames: readonly string[] = []
): void {
render(
<RuntimeServerRow
environment={environment}
duplicateOfNames={duplicateOfNames}
details={undefined}
isActive={false}
remoteUpdate={remoteUpdate}
remoteServerUpdatesRunning={false}
connecting={false}
switching={false}
disconnecting={false}
removing={false}
isBusy={false}
onOpenUpdate={vi.fn()}
onDisconnect={vi.fn()}
onConnect={vi.fn()}
onRemove={vi.fn()}
/>
<TooltipProvider>
<RuntimeServerRow
environment={environment}
duplicateOfNames={duplicateOfNames}
details={undefined}
isActive={false}
remoteUpdate={remoteUpdate}
remoteServerUpdatesRunning={false}
connecting={false}
switching={false}
disconnecting={false}
removing={false}
isBusy={false}
onOpenUpdate={vi.fn()}
onDisconnect={vi.fn()}
onConnect={vi.fn()}
onRemove={vi.fn()}
/>
</TooltipProvider>
)
}
@@ -133,6 +151,46 @@ it('still offers Disconnect for a verified host', () => {
expect(screen.queryByRole('button', { name: /disconnect/i })).not.toBeNull()
})
it('shows one status for an unreachable host instead of a stack of signals', () => {
setEntry({
status: null,
checkedAt: 2,
snapshot: snapshot({ verification: 'unavailable', transport: 'disconnected' })
})
render(
<TooltipProvider>
<RuntimeServerRow
environment={environment}
details={{
status: 'error',
runtimeStatus: null,
compatibility: null,
error: 'Timed out waiting for the remote Orca runtime.'
}}
isActive={false}
remoteUpdate={undefined}
remoteServerUpdatesRunning={false}
connecting={false}
switching={false}
disconnecting={false}
removing={false}
isBusy={false}
onOpenUpdate={vi.fn()}
onDisconnect={vi.fn()}
onConnect={vi.fn()}
onRemove={vi.fn()}
/>
</TooltipProvider>
)
expect(screen.queryByText('Unreachable')).not.toBeNull()
expect(screen.queryByText('Status unavailable')).toBeNull()
expect(screen.queryByText('Never connected · x')).not.toBeNull()
// Why Retry, not Connect: the app already tried and got no answer.
expect(screen.getAllByRole('button', { name: /^retry$/i })).toHaveLength(1)
expect(screen.queryByRole('button', { name: /^connect$/i })).toBeNull()
expect(screen.queryByRole('button', { name: /re-pair/i })).toBeNull()
})
it('keeps the machine under the label once the host is unreachable', () => {
const status = { ...answeredStatus(), machineName: 'Studio', hostPlatform: 'darwin' as const }
setEntry({
@@ -182,9 +240,10 @@ it('tells one revoked story instead of a disconnected one', () => {
expect(screen.queryByText('Access revoked')).not.toBeNull()
expect(screen.queryByText('Disconnected')).toBeNull()
expect(screen.queryByText('Unreachable')).toBeNull()
expect(screen.queryByText('Status unavailable')).toBeNull()
expect(screen.queryByText('Offline')).toBeNull()
expect(screen.queryByText('Orca v1.4.229 (last seen)')).not.toBeNull()
expect(screen.queryByText(/Re-pair it with a new access link/)).not.toBeNull()
expect(screen.queryByRole('button', { name: /re-pair/i })).not.toBeNull()
expect(screen.queryByRole('button', { name: /^connect$/i })).toBeNull()
})
@@ -1,42 +1,57 @@
import { useState } from 'react'
import { AlertTriangle, Link2, Loader2, Server, ServerOff, Trash2 } from 'lucide-react'
import { Link2 } from 'lucide-react'
import {
runtimePairingRepairRoute,
getOrcadSshDeployment,
type PublicKnownRuntimeEnvironment
} from '../../../../shared/runtime-environments'
import type { SshTarget } from '../../../../shared/ssh-types'
import type { ManagedOrcadPreloadApi } from '../../../../preload/api/managed-orcad-api'
import type { RemoteServerUpdateEntry } from '@/runtime/remote-server-update-coordinator'
import type { UpdateCheckOptions } from '../../../../shared/update-status-types'
import { getRuntimeServerProblemLine, getRuntimeServerRoute } from './runtime-server-row-meta'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { resolveHostDisplay } from '../../../../shared/host-display-resolution'
import { lastVerifiedRuntimeStatus } from '../../../../shared/runtime-host-status'
import {
isConnectedRuntimeHostState,
runtimeHostConnectionStateForEntry
} from '@/runtime/runtime-host-connection-state'
import { useAppStore } from '@/store'
import { LoadingSpinner } from '../LoadingSpinner'
import { Button } from '../ui/button'
import type { RuntimeHostDetails } from './runtime-environment-host-details'
import {
getHostDetailsDescription,
getHostDetailsSummary,
evaluateHostDetails,
getRuntimeServerConnectionState,
isRuntimeServerTransportConnected,
type RuntimeHostDetails
} from './runtime-environment-host-details'
import {
getRemoteServerManualUpdateHelp,
RemoteServerUpdateStatus
} from './RemoteServerUpdateStatus'
import { RuntimeRepairDialog } from './RuntimeRepairDialog'
import {
formatRuntimeEndpointAddress,
getRuntimeServerAvailableUpdateText,
getRuntimeServerStatusIndicator,
getRuntimeServerVersionFooter
getRuntimeServerVersionText,
isRemoteServerUpdateInFlight
} from './runtime-server-row-presentation'
import { RuntimeRepairDialog } from './RuntimeRepairDialog'
import { RuntimeServerRowDetails } from './runtime-server-row-details'
import { RuntimeServerRowMenuItems } from './runtime-server-row-menu'
import { useRuntimeServerRowState } from './runtime-server-row-state'
import {
ServerListRow,
ServerRowChip,
ServerRowDetailsToggle,
type ServerRowStatus
} from './runtime-server-list-row'
import { RuntimeSshAccessControl } from './RuntimeSshAccessControl'
import { RuntimeSshAccessDialog } from './runtime-ssh-access-dialog'
import {
getManagedServerSummary,
ManagedServerConfirmations,
ManagedServerDetailLines,
managedServerHasConfirmation,
ManagedServerMenuItems,
managedServerHasPrimaryAction,
ManagedServerPrimaryAction,
managedServerTitle
} from './managed-server-row-parts'
import type { ManagedServerLifecycle } from './use-managed-server-lifecycle'
const NO_SSH_TARGETS: SshTarget[] = []
const NO_DUPLICATES: readonly string[] = []
type RuntimeServerRowProps = {
environment: PublicKnownRuntimeEnvironment
/** Other saved servers with this one's host key: the same machine paired twice. */
duplicateOfNames: readonly string[]
duplicateOfNames?: readonly string[]
details: RuntimeHostDetails | undefined
isActive: boolean
remoteUpdate: RemoteServerUpdateEntry | undefined
@@ -46,15 +61,27 @@ type RuntimeServerRowProps = {
disconnecting: boolean
removing: boolean
isBusy: boolean
/** The SSH-access API and hosts; absent where managed servers aren't available (web). */
sshAccessApi?: Pick<ManagedOrcadPreloadApi, 'linkSshAccess' | 'unlinkSshAccess'> | null
sshTargets?: SshTarget[]
managed?: ManagedServerLifecycle | null
onOpenUpdate: () => void
onDisconnect: (environment: PublicKnownRuntimeEnvironment) => void
onConnect: (environment: PublicKnownRuntimeEnvironment) => void
onRemove: (environment: PublicKnownRuntimeEnvironment) => void
onMakeDefault?: (environment: PublicKnownRuntimeEnvironment) => void
onCheckForUpdates?: (options: UpdateCheckOptions) => void
onChanged?: () => void
/** Re-reads SSH hosts; the route dialog lists them. */
onRefreshSshTargets?: () => void
/** Re-probes every server; offered when the host answered but Orca didn't. */
onRecheck?: () => void
rechecking?: boolean
}
export function RuntimeServerRow({
environment,
duplicateOfNames,
duplicateOfNames = NO_DUPLICATES,
details,
isActive,
remoteUpdate,
@@ -64,245 +91,267 @@ export function RuntimeServerRow({
disconnecting,
removing,
isBusy,
sshAccessApi = null,
sshTargets = NO_SSH_TARGETS,
managed = null,
onOpenUpdate,
onDisconnect,
onConnect,
onRemove
onRemove,
onMakeDefault,
onCheckForUpdates,
onChanged,
onRefreshSshTargets,
onRecheck,
rechecking = false
}: RuntimeServerRowProps): React.JSX.Element {
const runtimeStatusEntry = useAppStore((state) =>
state.runtimeStatusByEnvironmentId.get(environment.id)
)
// Why the shared verdict and not `entry.status`: an unverifiable probe nulls it while the
// transport is still up, and this row then read "error" and offered Connect for a host that
// RepositoryHostSetupsSection -- which already derives through this same function -- was
// showing as reachable. One host, two surfaces, opposite answers. A probe that did not come
// back is not a host that went away (docs/reference/ssh-execution-boundary.md).
const entryReachable =
runtimeStatusEntry !== undefined &&
isConnectedRuntimeHostState(runtimeHostConnectionStateForEntry(runtimeStatusEntry))
const effectiveDetails = runtimeStatusEntry
? {
...(details ?? {
status: entryReachable ? ('ready' as const) : ('error' as const),
runtimeStatus: null,
compatibility: null,
error: null
}),
status: entryReachable ? ('ready' as const) : ('error' as const),
runtimeStatus: runtimeStatusEntry.status,
compatibility: runtimeStatusEntry.status
? evaluateHostDetails(runtimeStatusEntry.status)
: null,
remoteControl:
runtimeStatusEntry.remoteControl ?? runtimeStatusEntry.status?.remoteControl ?? null
}
: details
const [repairOpen, setRepairOpen] = useState(false)
const snapshot = runtimeStatusEntry?.snapshot
const accessRevoked =
snapshot?.verification === 'blocked' &&
!snapshot.retired &&
snapshot.blockedReason === 'access-revoked'
const repairRoute = runtimePairingRepairRoute(environment)
const repairable = repairRoute === 'access-link'
const detailsDescription = !accessRevoked
? getHostDetailsDescription(effectiveDetails)
: repairRoute === 'ssh'
? // Why: Orca pairs a managed server over SSH by itself; it reaches here only when that failed.
translate(
'auto.components.settings.RuntimeEnvironmentsPane.managedAccessRevoked',
"{{value0}} no longer accepts this computer, and Orca couldn't pair with it again over SSH. Connect to try again.",
{ value0: environment.name }
)
: repairRoute === 'connect'
? translate(
'auto.components.settings.RuntimeEnvironmentsPane.connectAccessRevoked',
'{{value0}} no longer accepts this computer. Connect to try again.',
{ value0: environment.name }
)
: translate(
'auto.components.settings.RuntimeEnvironmentsPane.accessRevoked',
'{{value0}} no longer accepts this computer. Re-pair it with a new access link.',
{ value0: environment.name }
)
const connectionState =
details?.status === 'loading' && !runtimeStatusEntry?.status
? 'checking'
: getRuntimeServerConnectionState(effectiveDetails)
// A connected host exposes Disconnect; otherwise Connect.
const isReachable = isRuntimeServerTransportConnected(connectionState)
const [sshDialogOpen, setSshDialogOpen] = useState(false)
const [expanded, setExpanded] = useState(false)
const row = useRuntimeServerRowState(environment, details)
const actionBusy = connecting || switching || disconnecting || removing
// Why: the snapshot keeps the last answered status across a lost probe; `status` is only the latest answer.
const descriptorStatus = lastVerifiedRuntimeStatus(runtimeStatusEntry)
const hostDisplay = resolveHostDisplay({
name: environment.name,
machineName: descriptorStatus?.machineName,
platform: descriptorStatus?.hostPlatform
})
const hostDescriptorText = hostDisplay.descriptorLine
const statusIndicator = getRuntimeServerStatusIndicator(connectionState, accessRevoked)
const versionFooter = remoteUpdate
? getRuntimeServerVersionFooter(remoteUpdate, {
accessRevoked,
lastKnownVersion: descriptorStatus?.appVersion
})
: null
// Why: when revoked, the red line replaces the summary; "Status unavailable" would contradict it.
const rowSummary =
environment.connectionDependency === 'ssh-tunnel'
? translate(
'auto.components.settings.RuntimeEnvironmentsPane.sshTunnelRequired',
'SSH tunnel required'
)
: isActive
? translate(
'auto.components.settings.RuntimeEnvironmentsPane.activeServerRowHelp',
'Default host for new projects. Existing projects stay on their own host.'
)
: accessRevoked
? null
: getHostDetailsSummary(effectiveDetails)
const repairable = row.repairRoute === 'access-link'
// Why: the revoked host rejects this token on every Connect; Re-pair is the only way back.
const showConnect = !(accessRevoked && repairable)
const canConnect = !(row.accessRevoked && repairable)
const updateActionable = remoteUpdate?.phase === 'available' || remoteUpdate?.phase === 'failed'
const deployment = getOrcadSshDeployment(environment)
const sshAccess = environment.sshAccess
const pendingSshAccess = environment.pendingSshAccessOperation
// Why: the transport is up but Orca didn't answer; only a fresh probe can tell more.
const needsRecheck =
row.connectionState === 'runtime-unavailable' ||
row.connectionState === 'workspace-window-closed'
const primary: 'managed' | 'repair' | 'update' | 'connect' | 'retry' | 'recheck' | null =
managed && managedServerHasPrimaryAction(managed)
? 'managed'
: row.accessRevoked && repairable
? 'repair'
: updateActionable
? 'update'
: // Why: the first probe hasn't answered yet; Connect would race it.
!row.isReachable && canConnect && row.connectionState !== 'checking'
? row.probeFailed
? 'retry'
: 'connect'
: needsRecheck && onRecheck
? 'recheck'
: null
const status: ServerRowStatus = getRuntimeServerStatusIndicator({
connectionState: row.connectionState,
accessRevoked: row.accessRevoked,
compatibilityBlocked: row.compatibilityBlocked,
probeFailed: row.probeFailed,
connecting,
disconnecting,
remoteUpdate,
managedNotRunning: Boolean(managed?.status && !managed.status.activeVersion)
})
const endpoint =
environment.endpoints.find((entry) => entry.id === environment.preferredEndpointId)?.endpoint ??
environment.endpoints[0]?.endpoint ??
null
const sshRoute = getRuntimeServerRoute(environment, sshTargets)
const address = formatRuntimeEndpointAddress(endpoint ?? undefined)
const problemLine = getRuntimeServerProblemLine({
environment,
row,
remoteUpdate,
managedStatusError: managed?.statusError ?? null,
managedSummary: managed ? getManagedServerSummary(managed) : null,
address,
duplicateOfNames
})
const versionText = getRuntimeServerVersionText(
remoteUpdate,
managed?.status?.activeVersion ?? row.descriptorStatus?.appVersion
)
const availableText = getRuntimeServerAvailableUpdateText(remoteUpdate)
const infoParts = [sshRoute ?? address, versionText].filter((part): part is string =>
Boolean(part)
)
const meta = (
<>
{problemLine ? (
<span className={cn('truncate', problemLine.destructive && 'text-destructive')}>
{problemLine.text}
</span>
) : (
infoParts.map((part, index) => (
<span key={part} className="flex min-w-0 shrink items-center gap-1">
{index > 0 ? <span aria-hidden>·</span> : null}
<span className="truncate">{part}</span>
</span>
))
)}
{!problemLine && availableText ? (
<span className="flex shrink-0 items-center gap-1 text-status-warning">
<span aria-hidden className="text-muted-foreground">
·
</span>
{availableText}
</span>
) : null}
{problemLine ? (
<>
<span aria-hidden>·</span>
<ServerRowDetailsToggle expanded={expanded} onToggle={() => setExpanded((v) => !v)} />
</>
) : null}
</>
)
const primaryAction =
primary === 'managed' && managed ? (
<ManagedServerPrimaryAction lifecycle={managed} />
) : primary === 'repair' ? (
<Button
type="button"
variant="outline"
size="xs"
onClick={() => setRepairOpen(true)}
disabled={actionBusy}
>
<Link2 />
{translate('auto.components.settings.RuntimeEnvironmentsPane.repair', 'Re-pair…')}
</Button>
) : primary === 'update' ? (
<Button
type="button"
variant="outline"
size="xs"
onClick={onOpenUpdate}
disabled={remoteServerUpdatesRunning || isRemoteServerUpdateInFlight(remoteUpdate)}
>
{translate('auto.components.settings.RuntimeEnvironmentsPane.updateServer', 'Update')}
</Button>
) : primary === 'recheck' ? (
<Button type="button" variant="ghost" size="xs" onClick={onRecheck} disabled={rechecking}>
{rechecking ? <LoadingSpinner className="size-3" /> : null}
{translate('auto.components.settings.serverList.retry', 'Retry')}
</Button>
) : primary === 'connect' || primary === 'retry' ? (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => onConnect(environment)}
disabled={actionBusy || row.connectionState === 'checking'}
>
{connecting ? <LoadingSpinner className="size-3" /> : null}
{primary === 'retry'
? translate('auto.components.settings.serverList.retry', 'Retry')
: translate('auto.components.settings.RuntimeEnvironmentsPane.connect', 'Connect')}
</Button>
) : null
const showSshAccess = sshAccessApi !== null && !deployment && !pendingSshAccess
const footer =
pendingSshAccess || expanded || (managed && managedServerHasConfirmation(managed)) ? (
<>
{pendingSshAccess && sshAccessApi ? (
<RuntimeSshAccessControl
api={sshAccessApi}
environment={environment}
targets={sshTargets}
onChanged={() => onChanged?.()}
/>
) : null}
{managed ? <ManagedServerConfirmations lifecycle={managed} /> : null}
{expanded ? (
<RuntimeServerRowDetails
endpoint={endpoint}
details={row.effectiveDetails}
remoteUpdate={remoteUpdate}
problem={row.problem ?? problemLine?.text ?? null}
sshRoute={sshRoute}
unreachable={row.probeFailed}
>
{managed ? <ManagedServerDetailLines lifecycle={managed} /> : null}
</RuntimeServerRowDetails>
) : null}
</>
) : null
return (
<div data-settings-section={environment.id} className="flex items-center gap-3 px-4 py-3">
<Server className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate text-sm font-medium">{hostDisplay.title}</div>
<span className={cn('size-2 shrink-0 rounded-full', statusIndicator.dotClassName)} />
<span className={cn('text-[11px]', statusIndicator.labelClassName)}>
{statusIndicator.label}
</span>
{/* Why: the red "Access revoked" label already carries the warning. */}
{!accessRevoked && effectiveDetails?.compatibility?.kind === 'blocked' ? (
<AlertTriangle className="size-3.5 shrink-0 text-destructive" />
) : effectiveDetails?.status === 'loading' ? (
<Loader2 className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
) : null}
</div>
{hostDescriptorText ? (
<p className="truncate text-xs text-muted-foreground">{hostDescriptorText}</p>
) : null}
{rowSummary ? <p className="truncate text-xs text-muted-foreground">{rowSummary}</p> : null}
{duplicateOfNames.length > 0 ? (
<p className="mt-0.5 text-xs text-destructive">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.pairedTwice',
'Same server as {{value0}}, paired twice. Remove one so its projects can be edited.',
{ value0: duplicateOfNames[0] }
)}
</p>
) : null}
{detailsDescription ? (
<p
className={cn(
'mt-0.5 truncate text-xs',
accessRevoked || effectiveDetails?.compatibility?.kind === 'blocked'
? 'text-destructive'
: 'text-muted-foreground'
)}
>
{detailsDescription}
</p>
) : null}
{remoteUpdate && versionFooter ? (
<div className="mt-1 flex flex-wrap items-center gap-2">
<span className="text-[11px] text-muted-foreground">{versionFooter.text}</span>
{versionFooter.showUpdateStatus ? (
<RemoteServerUpdateStatus entry={remoteUpdate} compact />
<>
<ServerListRow
sectionId={environment.id}
name={
deployment
? managedServerTitle(
sshTargets.find((target) => target.id === deployment.sshTargetId)?.label ??
environment.name
)
: row.title
}
descriptor={row.hostDescriptorText}
chips={
<>
{isActive ? (
<ServerRowChip>
{translate('auto.components.settings.serverList.defaultChip', 'Default')}
</ServerRowChip>
) : null}
</div>
) : null}
{remoteUpdate?.phase === 'manual' ? (
<p className="mt-1 text-xs text-muted-foreground">
{getRemoteServerManualUpdateHelp(remoteUpdate)}
</p>
) : null}
{remoteUpdate?.phase === 'failed' && remoteUpdate.error ? (
<p className="mt-1 text-xs text-destructive">{remoteUpdate.error}</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
{remoteUpdate?.phase === 'available' || remoteUpdate?.phase === 'failed' ? (
<Button
type="button"
variant="ghost"
size="xs"
onClick={onOpenUpdate}
disabled={remoteServerUpdatesRunning}
>
{translate('auto.components.settings.RuntimeEnvironmentsPane.updateServer', 'Update')}
</Button>
) : null}
{accessRevoked && repairable ? (
<Button
type="button"
variant="outline"
size="xs"
onClick={() => setRepairOpen(true)}
disabled={actionBusy}
>
<Link2 />
{translate('auto.components.settings.RuntimeEnvironmentsPane.repair', 'Re-pair…')}
</Button>
) : null}
{isReachable ? (
<Button
type="button"
variant="ghost"
size="xs"
className="gap-1.5"
onClick={() => onDisconnect(environment)}
disabled={actionBusy}
>
{disconnecting ? (
<Loader2 className="size-3 animate-spin" />
) : (
<ServerOff className="size-3" />
)}
{translate('auto.components.settings.RuntimeEnvironmentsPane.disconnect', 'Disconnect')}
</Button>
) : showConnect ? (
<Button
type="button"
variant="ghost"
size="xs"
className="gap-1.5"
onClick={() => onConnect(environment)}
disabled={actionBusy || connectionState === 'checking'}
>
{connecting ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Server className="size-3" />
)}
{translate('auto.components.settings.RuntimeEnvironmentsPane.connect', 'Connect')}
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onRemove(environment)}
className="size-7 text-muted-foreground hover:text-red-400"
disabled={isBusy}
aria-label={translate(
'auto.components.settings.RuntimeEnvironmentsPane.aeb26635d2',
'Remove {{value0}}',
{ value0: environment.name }
)}
>
{removing ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
</Button>
</div>
{!deployment && (sshAccess || environment.connectionDependency === 'ssh-tunnel') ? (
<ServerRowChip>
{translate('auto.components.settings.serverList.sshTunnelChip', 'SSH tunnel')}
</ServerRowChip>
) : null}
</>
}
meta={meta}
status={status}
primaryAction={primaryAction}
menuItems={
<RuntimeServerRowMenuItems
showUpdate={updateActionable && primary !== 'update'}
showConnect={
!row.isReachable && canConnect && primary !== 'connect' && primary !== 'retry'
}
showDisconnect={row.isReachable}
showMakeDefault={Boolean(onMakeDefault) && !isActive}
sshAccessLinked={showSshAccess ? Boolean(sshAccess) : null}
detailsExpanded={expanded}
actionBusy={actionBusy}
isBusy={isBusy}
connectDisabled={row.connectionState === 'checking'}
managedItems={managed ? <ManagedServerMenuItems lifecycle={managed} /> : null}
onUpdate={onOpenUpdate}
onConnect={() => onConnect(environment)}
onDisconnect={() => onDisconnect(environment)}
onMakeDefault={() => onMakeDefault?.(environment)}
onCheckForUpdates={onCheckForUpdates}
onSshAccess={() => {
onRefreshSshTargets?.()
setSshDialogOpen(true)
}}
onToggleDetails={() => setExpanded((v) => !v)}
onRemove={() => onRemove(environment)}
/>
}
footer={footer}
/>
<RuntimeRepairDialog
open={repairOpen}
onOpenChange={setRepairOpen}
environmentId={environment.id}
name={environment.name}
/>
</div>
{showSshAccess && sshAccessApi ? (
<RuntimeSshAccessDialog
open={sshDialogOpen}
api={sshAccessApi}
environment={environment}
targets={sshTargets}
onOpenChange={setSshDialogOpen}
onChanged={() => onChanged?.()}
/>
) : null}
</>
)
}
@@ -1,34 +0,0 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
vi.mock('./RuntimePairingUrlGenerator', () => ({
RuntimePairingUrlGenerator: () => <div data-testid="pairing-url-generator" />
}))
vi.mock('./MachineNameField', () => ({
MachineNameField: ({ id }: { id?: string }) => (
<div data-testid="machine-name-field" data-id={id} />
)
}))
import { RuntimeServerShareSection } from './runtime-server-workflow-sections'
describe('RuntimeServerShareSection', () => {
afterEach(() => cleanup())
it('names this host before the link generator other devices will use to reach it', () => {
render(
<RuntimeServerShareSection shareServerFormOpen={false} onToggleShareServerForm={vi.fn()} />
)
const field = screen.getByTestId('machine-name-field')
expect(field).toHaveAttribute('data-id', 'runtime-share-machine-name')
const generator = screen.getByTestId('pairing-url-generator')
expect(field.compareDocumentPosition(generator) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(screen.getByText('Share this Orca server')).toBeVisible()
})
})
@@ -1,226 +0,0 @@
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'
import { MachineNameField } from './MachineNameField'
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 (
<div
role="group"
aria-label={translate(
'auto.components.settings.RuntimeEnvironmentsPane.workflow',
'Remote server workflow'
)}
className={cn('grid gap-2 sm:grid-cols-2', canGeneratePairingUrl && 'sm:grid-cols-3')}
>
{(
[
[
'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]) => (
<button
key={value}
type="button"
aria-pressed={visibleWorkflow === value}
onClick={() => {
if (value !== 'connect') {
onCloseAddServerForm()
}
onWorkflowChange(value)
}}
className={cn(
'rounded-lg border p-3 text-left transition-colors',
visibleWorkflow === value
? 'border-ring bg-accent text-accent-foreground'
: 'border-border hover:bg-accent'
)}
>
<span className="block text-sm font-medium">{label}</span>
<span
className={cn(
'mt-1 block text-xs',
visibleWorkflow === value ? 'text-accent-foreground' : 'text-muted-foreground'
)}
>
{description}
</span>
</button>
))}
</div>
)
}
export function RuntimeServerShareSection({
shareServerFormOpen,
onToggleShareServerForm
}: {
shareServerFormOpen: boolean
onToggleShareServerForm: () => void
}): React.JSX.Element {
return (
<div className="space-y-3 pt-2">
<div className="space-y-0.5">
<div className="text-sm font-medium">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.advertiseThisApp',
'Advertise this app as a server'
)}
</div>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.advertiseThisAppHelp',
'Create access links for browsers, mobile clients, or another Orca client to connect back to this running app.'
)}
</p>
</div>
<MachineNameField id="runtime-share-machine-name" />
<div className="overflow-hidden rounded-lg border border-border/50 bg-card/30">
<div className="flex flex-wrap items-center justify-between gap-3 px-3 py-2.5">
<div className="min-w-0 space-y-0.5">
<div className="text-sm font-medium">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.6e1280ca55',
'Share this Orca server'
)}
</div>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.84b9b2be05',
'Create a revocable access grant so a browser or another Orca client can connect.'
)}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
onClick={onToggleShareServerForm}
>
<Share2 />
{shareServerFormOpen
? translate(
'auto.components.settings.RuntimeEnvironmentsPane.54dee18f5c',
'Hide Form'
)
: translate(
'auto.components.settings.RuntimeEnvironmentsPane.3595fd1948',
'New Link'
)}
</Button>
</div>
<div className="border-t border-border/40 px-3 py-3">
<RuntimePairingUrlGenerator
framed={false}
showHeader={false}
showGeneratorForm={shareServerFormOpen}
/>
</div>
</div>
</div>
)
}
export function RuntimeServerTroubleshooting(): React.JSX.Element {
return (
<details className="group rounded-lg border border-border/60">
<summary className="flex cursor-pointer list-none items-center gap-2 p-4 text-sm font-medium">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.troubleshootWorkflow',
'Connection troubleshooting'
)}
<ChevronDown className="ml-auto size-4 transition-transform group-open:rotate-180" />
</summary>
<div className="space-y-4 border-t border-border/50 p-4">
<div className="space-y-1">
<div className="text-sm font-medium">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.troubleshootTitle',
'Create a new link on the other host'
)}
</div>
<p className="text-xs text-muted-foreground">
{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.'
)}
</p>
</div>
<ol className="ml-4 list-decimal space-y-1 text-xs text-muted-foreground">
<li>
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.troubleshootStepShare',
'On the other computer, open Share this host.'
)}
</li>
<li>
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.troubleshootStepAddress',
'Choose Another device and select its Tailscale or LAN address.'
)}
</li>
<li>
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.troubleshootStepRegenerate',
'Generate a new access link and use only the newest link here.'
)}
</li>
</ol>
<div className="rounded-md border border-border/60 bg-muted/30 p-3 text-xs">
{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.'
)}
</div>
</div>
</details>
)
}
@@ -1,190 +0,0 @@
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 } from './RuntimeHostAccessForm'
import type { RuntimeHostAccessFailure } from './RuntimeHostAccessFailureAlert'
import { RuntimeServerRow } from './runtime-server-row'
import { duplicatePairedServerNames } from '@/lib/duplicate-paired-servers'
type RuntimeServersConnectSectionProps = {
visible: boolean
environments: PublicKnownRuntimeEnvironment[]
detailsByEnvironmentId: Record<string, RuntimeHostDetails>
activeRuntimeEnvironmentId: string | null | undefined
addServerFormOpen: boolean
name: string
pairingCode: string
addServerFailure: RuntimeHostAccessFailure | null
isBusy: boolean
remoteServerUpdates: Map<string, RemoteServerUpdateEntry>
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<void>
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 (
<div className={cn('space-y-3', !visible && 'hidden')}>
<div
data-settings-section="remote-server-updates"
className="flex items-center justify-between gap-3"
>
<div className="min-w-0 space-y-0.5">
<div className="text-sm font-medium">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.connectToRemoteServers',
'Connect to remote servers'
)}
</div>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.connectToRemoteServersHelp',
'Pair another Orca runtime, then connect or disconnect it here.'
)}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{environments.length > 0 ? (
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
title={updateCheckHint}
onClick={(event) => {
onOpenUpdateDialog()
void refreshRemoteServerUpdates(getUpdateCheckClickOptions(event))
}}
disabled={remoteServerUpdatesChecking && remoteServerUpdates.size === 0}
>
{remoteServerUpdatesChecking || remoteServerUpdatesRunning ? (
<Loader2 className="animate-spin" />
) : (
<RefreshCw />
)}
{remoteServerUpdatesRunning
? translate(
'auto.components.settings.RuntimeEnvironmentsPane.updatingServers',
'Updating servers…'
)
: translate(
'auto.components.settings.RuntimeEnvironmentsPane.reviewServerUpdates',
'Check for Server Updates'
)}
</Button>
) : null}
{addServerFormOpen ? null : (
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
onClick={onOpenAddServerForm}
disabled={isBusy}
>
<Plus />
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.9bee6bbeeb',
'Add Server'
)}
</Button>
)}
</div>
</div>
{addServerFormOpen ? (
<RuntimeHostAccessForm
name={name}
accessLink={pairingCode}
busy={isBusy}
failure={addServerFailure}
onNameChange={onNameChange}
onAccessLinkChange={onPairingCodeChange}
onCancel={onCloseAddServerForm}
onSubmit={onAddEnvironment}
/>
) : null}
<div className="rounded-lg border border-border/50 bg-card/30">
{environments.length === 0 ? (
<div className="px-3 py-4 text-sm text-muted-foreground">
{translate(
'auto.components.settings.RuntimeEnvironmentsPane.9a3758d983',
'No saved servers.'
)}
</div>
) : (
<div className="divide-y divide-border/50">
{environments.map((environment) => (
<RuntimeServerRow
key={environment.id}
environment={environment}
duplicateOfNames={duplicatePairedServerNames(environments, environment.id)}
details={detailsByEnvironmentId[environment.id]}
isActive={activeRuntimeEnvironmentId === environment.id}
remoteUpdate={remoteServerUpdates.get(environment.id)}
remoteServerUpdatesRunning={remoteServerUpdatesRunning}
connecting={connectingId === environment.id}
switching={switchingValue === environment.id}
disconnecting={disconnectingId === environment.id}
removing={removingId === environment.id}
isBusy={isBusy}
onOpenUpdate={onOpenUpdateDialog}
onConnect={onConnect}
onDisconnect={onDisconnect}
onRemove={onRemove}
/>
))}
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,152 @@
import { ArrowUpRight, ChevronDown, Cloud, Link2, Plus, TerminalSquare } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { LoadingSpinner } from '../LoadingSpinner'
import { Button } from '../ui/button'
import { ButtonGroup } from '../ui/button-group'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '../ui/dropdown-menu'
import { useRuntimeServersPage } from './runtime-servers-page-context'
import { getManagedOrcadApi } from './use-managed-server-catalog'
function AddServerMenuItem({
icon,
label,
description,
trailing,
onSelect
}: {
icon: React.ReactNode
label: string
description: string
trailing?: React.ReactNode
onSelect: () => void
}): React.JSX.Element {
return (
<DropdownMenuItem className="items-start" onSelect={onSelect}>
<span className="mt-0.5">{icon}</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1">
{label}
{trailing}
</span>
<span className="block text-2xs text-muted-foreground">{description}</span>
</span>
</DropdownMenuItem>
)
}
/** Section-header actions for Remote Orca Servers: update-all and the Add server split button. */
export function RuntimeServersHeaderActions(): React.JSX.Element | null {
const { setDialog, listEmpty } = useRuntimeServersPage()
const remoteServerUpdates = useAppStore((state) => state.remoteServerUpdates)
const remoteServerUpdatesChecking = useAppStore((state) => state.remoteServerUpdatesChecking)
const remoteServerUpdatesRunning = useAppStore((state) => state.remoteServerUpdatesRunning)
const openSettingsTarget = useAppStore((state) => state.openSettingsTarget)
const setRemoteServerUpdateDialogOpen = useAppStore(
(state) => state.setRemoteServerUpdateDialogOpen
)
const canInstall = getManagedOrcadApi() !== undefined
let availableCount = 0
for (const entry of remoteServerUpdates.values()) {
if (entry.phase === 'available') {
availableCount += 1
}
}
return (
<div data-settings-section="remote-server-updates" className="flex items-center gap-2">
{/* Why ≥2: a single update is one click on its own row. */}
{remoteServerUpdatesRunning || availableCount >= 2 ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={remoteServerUpdatesChecking && remoteServerUpdates.size === 0}
onClick={() => setRemoteServerUpdateDialogOpen(true)}
>
{remoteServerUpdatesRunning ? <LoadingSpinner className="size-3.5" /> : null}
{remoteServerUpdatesRunning
? translate(
'auto.components.settings.RuntimeEnvironmentsPane.updatingServers',
'Updating servers…'
)
: translate('auto.components.settings.serverList.updateAll', 'Update all ({{count}})', {
count: availableCount
})}
</Button>
) : null}
{listEmpty ? null : (
<ButtonGroup>
<Button type="button" variant="outline" size="sm" onClick={() => setDialog('add')}>
<Plus />
{translate('auto.components.settings.serverList.addServer', 'Add server')}
</Button>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label={translate(
'auto.components.settings.serverList.moreWaysToAdd',
'More ways to add a server'
)}
>
<ChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<AddServerMenuItem
icon={<Link2 />}
label={translate(
'auto.components.settings.serverList.pasteAccessLink',
'Paste access link'
)}
description={translate(
'auto.components.settings.serverList.pasteAccessLinkHelp',
'Pair with Orca already running on another machine.'
)}
onSelect={() => setDialog('add')}
/>
{canInstall ? (
<AddServerMenuItem
icon={<TerminalSquare />}
label={translate(
'auto.components.settings.serverList.setUpOnSshHost',
'Set up Orca on an SSH host'
)}
description={translate(
'auto.components.settings.managedServers.description',
'Orca installs itself on an empty SSH host and keeps itself up to date.'
)}
onSelect={() => setDialog('install')}
/>
) : null}
<AddServerMenuItem
icon={<Cloud />}
label={translate('auto.components.settings.serverList.cloudVm', 'Cloud VM')}
trailing={<ArrowUpRight className="ml-auto" />}
description={translate(
'auto.components.settings.serverList.cloudVmHelp',
'Created per workspace from an environment recipe.'
)}
onSelect={() =>
openSettingsTarget({
pane: 'experimental',
repoId: null,
sectionId: 'ephemeral-vms'
})
}
/>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
)}
</div>
)
}
@@ -0,0 +1,40 @@
import { createContext, useContext, useMemo, useState } from 'react'
export type RuntimeServersDialog = 'add' | 'install'
type RuntimeServersPageState = {
dialog: RuntimeServersDialog | null
setDialog: (dialog: RuntimeServersDialog | null) => void
/** The list shows its empty state, which carries its own add actions. */
listEmpty: boolean
setListEmpty: (empty: boolean) => void
}
const RuntimeServersPageContext = createContext<RuntimeServersPageState | null>(null)
/**
* Why: the add/install actions sit in the settings section header, outside the pane that owns
* the dialogs, so both read one shared open state.
*/
export function RuntimeServersPageProvider({
children
}: {
children: React.ReactNode
}): React.JSX.Element {
const [dialog, setDialog] = useState<RuntimeServersDialog | null>(null)
const [listEmpty, setListEmpty] = useState(false)
const value = useMemo(() => ({ dialog, setDialog, listEmpty, setListEmpty }), [dialog, listEmpty])
return (
<RuntimeServersPageContext.Provider value={value}>
{children}
</RuntimeServersPageContext.Provider>
)
}
export function useRuntimeServersPage(): RuntimeServersPageState {
const value = useContext(RuntimeServersPageContext)
if (!value) {
throw new Error('useRuntimeServersPage must be used inside RuntimeServersPageProvider')
}
return value
}
@@ -0,0 +1,32 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { RuntimeShareComputerCard } from './runtime-share-computer-card'
vi.mock('./RuntimePairingUrlGenerator', () => ({ RuntimePairingUrlGenerator: () => null }))
vi.mock('./MachineNameField', () => ({ MachineNameField: () => <div>machine-name-field</div> }))
vi.mock('./use-published-machine-name', () => ({ usePublishedMachineName: () => 'Studio' }))
const listRuntimeAccessGrants = vi.fn(async () => ({ grants: [{ deviceId: 'a' }] }))
beforeEach(() => {
listRuntimeAccessGrants.mockClear()
Object.assign(window, { api: { ...window.api, mobile: { listRuntimeAccessGrants } } })
})
afterEach(cleanup)
// Done and Esc must close the same way: reset the name editor and recount links.
it('closes through Done like any other dismissal', async () => {
render(<RuntimeShareComputerCard />)
await waitFor(() => expect(screen.queryByText('1 active link')).not.toBeNull())
fireEvent.click(screen.getByRole('button', { name: /Share…/ }))
fireEvent.click(await screen.findByRole('button', { name: 'Edit' }))
expect(screen.queryByText('machine-name-field')).not.toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Done' }))
await waitFor(() => expect(listRuntimeAccessGrants).toHaveBeenCalledTimes(2))
fireEvent.click(screen.getByRole('button', { name: /Share…/ }))
expect(await screen.findByRole('button', { name: 'Edit' })).not.toBeNull()
expect(screen.queryByText('machine-name-field')).toBeNull()
})
@@ -0,0 +1,140 @@
import { useCallback, useEffect, useState } from 'react'
import { MonitorSmartphone, Share2 } from 'lucide-react'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { Button } from '../ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '../ui/dialog'
import { MachineNameField } from './MachineNameField'
import { RuntimePairingUrlGenerator } from './RuntimePairingUrlGenerator'
import { usePublishedMachineName } from './use-published-machine-name'
/** Desktop only: lets other devices pair with this computer. */
export function RuntimeShareComputerCard(): React.JSX.Element {
const [open, setOpen] = useState(false)
const [editingName, setEditingName] = useState(false)
const [activeLinkCount, setActiveLinkCount] = useState<number | null>(null)
const mountedRef = useMountedRef()
const machineName = useAppStore((state) => state.settings?.machineName ?? '')
const publishedMachineName = usePublishedMachineName(machineName)
const loadActiveLinkCount = useCallback(async () => {
try {
const result = await window.api.mobile.listRuntimeAccessGrants()
if (mountedRef.current) {
setActiveLinkCount(result.grants.length)
}
} catch {
// Why: the count is a hint; the dialog reports load failures where they can be acted on.
}
}, [mountedRef])
useEffect(() => {
void loadActiveLinkCount()
}, [loadActiveLinkCount])
const handleOpenChange = (next: boolean): void => {
setOpen(next)
if (!next) {
setEditingName(false)
void loadActiveLinkCount()
}
}
return (
<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
<MonitorSmartphone className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
{translate('auto.components.settings.serverList.shareTitle', 'Share this computer')}
</div>
<p className="truncate text-xs text-muted-foreground">
{publishedMachineName
? translate(
'auto.components.settings.serverList.shareDescriptionNamed',
'Let a browser, phone, or another Orca work on {{name}}.',
{ name: publishedMachineName }
)
: translate(
'auto.components.settings.serverList.shareDescription',
'Let a browser, phone, or another Orca work on this computer.'
)}
</p>
</div>
{activeLinkCount ? (
<span className="shrink-0 text-xs text-muted-foreground">
{activeLinkCount === 1
? translate('auto.components.settings.serverList.oneActiveLink', '1 active link')
: translate(
'auto.components.settings.serverList.activeLinks',
'{{value0}} active links',
{
value0: activeLinkCount
}
)}
</span>
) : null}
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
<Share2 />
{translate('auto.components.settings.serverList.shareButton', 'Share…')}
</Button>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-xl" scrollable>
<DialogHeader>
<DialogTitle>
{translate('auto.components.settings.serverList.shareTitle', 'Share this computer')}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.settings.serverList.shareDialogDescriptionRuns',
'Anyone with the link can run commands as you on this computer until you revoke it.'
)}
</DialogDescription>
</DialogHeader>
<RuntimePairingUrlGenerator framed={false} showHeader={false} />
{editingName ? (
<MachineNameField id="runtime-share-machine-name" />
) : (
<div className="flex items-center gap-2 border-t border-border pt-3 text-xs text-muted-foreground">
<span className="min-w-0 truncate">
{translate(
'auto.components.settings.serverList.shownAs',
'Shown to others as {{name}}',
{
name:
publishedMachineName ??
translate('auto.components.settings.serverList.thisComputer', 'This computer')
}
)}
</span>
<span aria-hidden>·</span>
<button
type="button"
className="shrink-0 cursor-pointer rounded-sm text-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setEditingName(true)}
>
{translate('auto.components.settings.serverList.editName', 'Edit')}
</button>
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleOpenChange(false)}
>
{translate('auto.components.settings.serverList.done', 'Done')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,59 @@
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
import type { SshTarget } from '../../../../shared/ssh-types'
import type { ManagedOrcadPreloadApi } from '../../../../preload/api/managed-orcad-api'
import { translate } from '@/i18n/i18n'
import { Button } from '../ui/button'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog'
import { RuntimeSshAccessControl } from './RuntimeSshAccessControl'
export function RuntimeSshAccessDialog({
open,
api,
environment,
targets,
onOpenChange,
onChanged
}: {
open: boolean
api: Pick<ManagedOrcadPreloadApi, 'linkSshAccess' | 'unlinkSshAccess'>
environment: PublicKnownRuntimeEnvironment
targets: SshTarget[]
onOpenChange: (open: boolean) => void
onChanged: () => void
}): React.JSX.Element {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{translate(
'auto.components.settings.serverList.sshDialogTitle',
'Connect {{name}} through SSH',
{ name: environment.name }
)}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.settings.serverList.sshDialogDescription',
'Use this when the server isn’t reachable directly. Orca opens a tunnel through an SSH host.'
)}
</DialogDescription>
</DialogHeader>
<RuntimeSshAccessControl
api={api}
environment={environment}
targets={targets}
onChanged={() => {
onChanged()
onOpenChange(false)
}}
footerStart={
<Button type="button" variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
{translate('auto.components.settings.serverList.cancel', 'Cancel')}
</Button>
}
/>
</DialogContent>
</Dialog>
)
}
@@ -1,60 +1,73 @@
import { DeveloperPermissionsPane } from './DeveloperPermissionsPane'
import { PrivacyPane } from './PrivacyPane'
import { RuntimeEnvironmentsPane } from './RuntimeEnvironmentsPane'
import { RuntimeServersHeaderActions } from './runtime-servers-header-actions'
import { RuntimeServersPageProvider } from './runtime-servers-page-context'
import { SshPane } from './SshPane'
import { SshHostsHeaderSlot, SshHostsHeaderSlotProvider } from './ssh-hosts-header-slot'
import { SettingsSection } from './SettingsSection'
import { translate } from '@/i18n/i18n'
import type { SettingsRenderContext } from './settings-render-context'
export function renderServersSettingsSection(context: SettingsRenderContext): React.JSX.Element {
const { model, navigation, view } = context
const mounted = view.isSectionMounted('servers')
return (
<SettingsSection
id="servers"
title={translate('auto.components.settings.Settings.bd0181eeca', 'Remote Orca Servers')}
badge="Beta"
description={
model.isWebClient
? translate(
'auto.components.settings.Settings.7686cb5c36',
'Connect this browser to a saved Orca server.'
)
: translate(
'auto.components.settings.Settings.b5ee17826b',
'Pair remote Orca runtimes for persistent sessions, richer remote state, and web or mobile handoff.'
)
}
searchEntries={navigation.getSectionSearchEntries('servers')}
>
{view.isSectionMounted('servers') ? (
<RuntimeEnvironmentsPane
settings={model.settings}
setActiveRuntimeEnvironmentPreference={model.setActiveRuntimeEnvironmentPreference}
canGeneratePairingUrl={!model.isWebClient}
allowLocalRuntime={!model.isWebClient}
addServerIntentSignal={model.remoteServerAddIntentSignal}
/>
) : null}
</SettingsSection>
<RuntimeServersPageProvider>
<SettingsSection
id="servers"
title={translate('auto.components.settings.Settings.bd0181eeca', 'Remote Orca Servers')}
badge="Beta"
description={
model.isWebClient
? translate(
'auto.components.settings.Settings.7686cb5c36',
'Connect this browser to a saved Orca server.'
)
: translate(
'auto.components.settings.Settings.remoteServersDescription',
'Run Orca on another machine and work on it from here.'
)
}
searchEntries={navigation.getSectionSearchEntries('servers')}
headerAction={mounted ? <RuntimeServersHeaderActions /> : undefined}
// Why unframed: the server list and cards are the page's own surfaces; a band around them nests cards.
bodyClassName="rounded-none border-0 bg-transparent px-0 py-0 shadow-none"
>
{mounted ? (
<RuntimeEnvironmentsPane
settings={model.settings}
setActiveRuntimeEnvironmentPreference={model.setActiveRuntimeEnvironmentPreference}
canGeneratePairingUrl={!model.isWebClient}
allowLocalRuntime={!model.isWebClient}
addServerIntentSignal={model.remoteServerAddIntentSignal}
/>
) : null}
</SettingsSection>
</RuntimeServersPageProvider>
)
}
export function renderSshSettingsSection(context: SettingsRenderContext): React.JSX.Element | null {
const { model, navigation, view } = context
const mounted = view.isSectionMounted('ssh')
return model.showDesktopOnlySettings ? (
<SettingsSection
id="ssh"
title={translate('auto.components.settings.Settings.9b02492d1f', 'SSH Hosts')}
description={translate(
'auto.components.settings.Settings.c2ee313198',
'Use existing machines over SSH for files, terminals, Git, and workspaces.'
)}
searchEntries={navigation.getSectionSearchEntries('ssh')}
>
{view.isSectionMounted('ssh') ? (
<SshPane addTargetIntentSignal={model.sshHostAddIntentSignal} />
) : null}
</SettingsSection>
<SshHostsHeaderSlotProvider>
<SettingsSection
id="ssh"
title={translate('auto.components.settings.Settings.9b02492d1f', 'SSH Hosts')}
description={translate(
'auto.components.settings.Settings.c2ee313198',
'Use existing machines over SSH for files, terminals, Git, and workspaces.'
)}
searchEntries={navigation.getSectionSearchEntries('ssh')}
headerAction={mounted ? <SshHostsHeaderSlot /> : undefined}
// Why unframed: the host groups are the page's own surfaces; a band around them nests cards.
bodyClassName="rounded-none border-0 bg-transparent px-0 py-0 shadow-none"
>
{mounted ? <SshPane addTargetIntentSignal={model.sshHostAddIntentSignal} /> : null}
</SettingsSection>
</SshHostsHeaderSlotProvider>
) : null
}
@@ -0,0 +1,73 @@
import { translate } from '@/i18n/i18n'
import type { SshBulkRemovePlan } from './ssh-target-list-model'
export type SshBulkRemoveSummary = {
tone: 'success' | 'neutral' | 'error'
title: string
description?: string
}
/** One toast for a whole bulk removal: counts first, then each host that needs a separate step. */
export function summarizeSshBulkRemove(args: {
removedCount: number
skipped: SshBulkRemovePlan['skipped']
failed: { label: string; message: string }[]
}): SshBulkRemoveSummary {
const parts: string[] = []
if (args.removedCount > 0) {
parts.push(
translate('auto.components.settings.sshBulkRemove.removedCount', '{{count}} removed', {
count: args.removedCount
})
)
}
if (args.skipped.length > 0) {
parts.push(
translate('auto.components.settings.sshBulkRemove.skippedCount', '{{count}} skipped', {
count: args.skipped.length
})
)
}
if (args.failed.length > 0) {
parts.push(
translate('auto.components.settings.sshBulkRemove.failedCount', '{{count}} failed', {
count: args.failed.length
})
)
}
const lines = [
...args.skipped.map(({ target, reason }) =>
reason === 'managed-server'
? translate(
'auto.components.settings.sshBulkRemove.skippedManaged',
'{{label}} has an Orca server.',
{ label: target.label }
)
: translate(
'auto.components.settings.sshBulkRemove.skippedWorkspaces',
'{{label}} has workspaces.',
{ label: target.label }
)
),
...args.failed.map(({ label, message }) => `${label}: ${message}`)
]
if (args.skipped.length > 0) {
lines.push(
translate(
'auto.components.settings.sshBulkRemove.skippedHint',
'Remove skipped hosts one at a time to choose what happens to them.'
)
)
}
const tone =
args.removedCount === 0 && args.failed.length > 0
? 'error'
: args.failed.length > 0 || args.skipped.length > 0
? 'neutral'
: 'success'
return {
tone,
title: parts.join(' · '),
...(lines.length > 0 ? { description: lines.join(' ') } : {})
}
}
@@ -0,0 +1,45 @@
import { createContext, useContext, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
type SshHostsHeaderSlotState = {
slot: HTMLElement | null
setSlot: (node: HTMLElement | null) => void
}
const SshHostsHeaderSlotContext = createContext<SshHostsHeaderSlotState | null>(null)
/**
* Why a portal slot: Sync/Add/⋯ sit in the settings section header, but their state (sync,
* restore, the add form) lives in SshPane; portalling keeps one owner instead of lifting it all.
*/
export function SshHostsHeaderSlotProvider({
children
}: {
children: React.ReactNode
}): React.JSX.Element {
const [slot, setSlot] = useState<HTMLElement | null>(null)
const value = useMemo(() => ({ slot, setSlot }), [slot])
return (
<SshHostsHeaderSlotContext.Provider value={value}>
{children}
</SshHostsHeaderSlotContext.Provider>
)
}
export function SshHostsHeaderSlot(): React.JSX.Element | null {
const context = useContext(SshHostsHeaderSlotContext)
return context ? <div ref={context.setSlot} className="flex items-center gap-1.5" /> : null
}
/** Renders inline when no section header provides a slot (tests, other hosts of the pane). */
export function SshHostsHeaderPortal({
children
}: {
children: React.ReactNode
}): React.JSX.Element | null {
const context = useContext(SshHostsHeaderSlotContext)
if (!context) {
return <div className="flex justify-end gap-1.5">{children}</div>
}
return context.slot ? createPortal(children, context.slot) : null
}
@@ -0,0 +1,179 @@
import { describe, expect, it } from 'vitest'
import type { SshTarget } from '../../../../shared/ssh-types'
import {
applySshTargetSelectionClick,
filterSshTargets,
groupSshTargetsBySource,
planSshBulkRemove,
sshIdentityFileName,
sshTargetDisplayOrder,
sshTargetGroupCheckState
} from './ssh-target-list-model'
import { summarizeSshBulkRemove } from './ssh-bulk-remove-summary'
const host = (id: string, extra: Partial<SshTarget> = {}): SshTarget => ({
id,
label: id,
host: `${id}.example.test`,
port: 22,
username: 'dev',
source: 'ssh-config',
...extra
})
describe('groupSshTargetsBySource', () => {
it('groups a legacy target without a source by the config-sync rule', () => {
const groups = groupSshTargetsBySource([
// The old import shape: alias kept as label/configHost, host is the resolved HostName.
host('legacy-import', {
source: undefined,
label: 'box',
configHost: 'box',
host: '10.0.0.5'
}),
// A legacy manual target lacks `source` too, but doesn't match that shape.
host('legacy-manual', { source: undefined, label: 'My box', host: 'box.example.test' }),
host('mine', { source: 'manual' }),
host('synced', { source: 'ssh-config' })
])
expect(groups.map((group) => [group.id, group.targets.map((t) => t.id)])).toEqual([
['ssh-config', ['legacy-import', 'synced']],
['manual', ['legacy-manual', 'mine']]
])
})
it('omits an empty group', () => {
expect(groupSshTargetsBySource([host('mine', { source: 'manual' })]).map((g) => g.id)).toEqual([
'manual'
])
})
})
describe('filterSshTargets', () => {
it('matches label, host and user case-insensitively', () => {
const targets = [host('alpha'), host('beta', { host: 'Build.internal' })]
expect(filterSshTargets(targets, 'BUILD').map((t) => t.id)).toEqual(['beta'])
expect(filterSshTargets(targets, ' ').map((t) => t.id)).toEqual(['alpha', 'beta'])
})
})
describe('applySshTargetSelectionClick', () => {
const orderedIds = ['a', 'b', 'c', 'd']
it('toggles a single row without Shift', () => {
const next = applySshTargetSelectionClick({
selected: new Set(['a']),
orderedIds,
anchorId: 'a',
clickedId: 'c',
shiftKey: false
})
expect([...next]).toEqual(['a', 'c'])
})
it('selects the visible range between the anchor and the Shift-clicked row', () => {
const next = applySshTargetSelectionClick({
selected: new Set(['d']),
orderedIds,
anchorId: 'd',
clickedId: 'b',
shiftKey: true
})
expect([...next].sort()).toEqual(['b', 'c', 'd'])
})
it('clears the range when the Shift-clicked row was selected', () => {
const next = applySshTargetSelectionClick({
selected: new Set(['a', 'b', 'c']),
orderedIds,
anchorId: 'a',
clickedId: 'c',
shiftKey: true
})
expect([...next]).toEqual([])
})
it('falls back to one row when the anchor is filtered out', () => {
const next = applySshTargetSelectionClick({
selected: new Set(),
orderedIds,
anchorId: 'gone',
clickedId: 'c',
shiftKey: true
})
expect([...next]).toEqual(['c'])
})
})
describe('Shift-click over grouped rows', () => {
it('walks the rendered order, so a range never jumps across groups', () => {
const targets = [
host('cfg-a', { source: 'ssh-config' }),
host('man-b', { source: 'manual' }),
host('cfg-c', { source: 'ssh-config' })
]
const orderedIds = sshTargetDisplayOrder(targets)
expect(orderedIds).toEqual(['cfg-a', 'cfg-c', 'man-b'])
const next = applySshTargetSelectionClick({
selected: new Set(['cfg-a']),
orderedIds,
anchorId: 'cfg-a',
clickedId: 'cfg-c',
shiftKey: true
})
expect([...next].sort()).toEqual(['cfg-a', 'cfg-c'])
})
})
describe('sshTargetGroupCheckState', () => {
it('is tri-state', () => {
expect(sshTargetGroupCheckState(new Set(), ['a', 'b'])).toBe(false)
expect(sshTargetGroupCheckState(new Set(['a']), ['a', 'b'])).toBe('indeterminate')
expect(sshTargetGroupCheckState(new Set(['a', 'b', 'x']), ['a', 'b'])).toBe(true)
})
})
describe('planSshBulkRemove and its summary', () => {
it('skips hosts that need their own decision and names them in one summary', () => {
const plan = planSshBulkRemove(
[host('a'), host('prod-bastion'), host('dev'), host('b')],
(target) =>
target.id === 'prod-bastion' ? 'managed-server' : target.id === 'dev' ? 'workspaces' : null
)
expect(plan.removable.map((t) => t.id)).toEqual(['a', 'b'])
const summary = summarizeSshBulkRemove({
removedCount: 2,
skipped: plan.skipped,
failed: []
})
expect(summary.tone).toBe('neutral')
expect(summary.title).toBe('2 removed · 2 skipped')
expect(summary.description).toContain('prod-bastion has an Orca server.')
expect(summary.description).toContain('dev has workspaces.')
})
it('reports an all-failed run as an error', () => {
const summary = summarizeSshBulkRemove({
removedCount: 0,
skipped: [],
failed: [{ label: 'a', message: 'boom' }]
})
expect(summary).toEqual({ tone: 'error', title: '1 failed', description: 'a: boom' })
})
it('is a plain success when every host was removed', () => {
expect(summarizeSshBulkRemove({ removedCount: 3, skipped: [], failed: [] })).toEqual({
tone: 'success',
title: '3 removed'
})
})
})
describe('sshIdentityFileName', () => {
it('shows only the key file name on any platform', () => {
expect(sshIdentityFileName('/Users/me/.ssh/id_rsa')).toBe('id_rsa')
expect(sshIdentityFileName('C:\\Users\\me\\.ssh\\id_ed25519')).toBe('id_ed25519')
expect(sshIdentityFileName(' ')).toBeNull()
expect(sshIdentityFileName(undefined)).toBeNull()
})
})
@@ -0,0 +1,127 @@
import type { SshTarget } from '../../../../shared/ssh-types'
import { isSshConfigSourcedTarget } from '../../../../shared/ssh-target-source'
export type SshTargetGroupId = 'ssh-config' | 'manual'
export type SshTargetGroup = { id: SshTargetGroupId; targets: SshTarget[] }
/** Above this many hosts the list gets a filter field. */
export const SSH_TARGET_FILTER_THRESHOLD = 8
/** Config-synced hosts first; a legacy target without `source` is grouped by config sync's rule. */
export function groupSshTargetsBySource(targets: readonly SshTarget[]): SshTargetGroup[] {
const fromConfig = targets.filter((target) => isSshConfigSourcedTarget(target))
const manual = targets.filter((target) => !isSshConfigSourcedTarget(target))
const groups: SshTargetGroup[] = [
{ id: 'ssh-config', targets: fromConfig },
{ id: 'manual', targets: manual }
]
return groups.filter((group) => group.targets.length > 0)
}
/** Ids in the order rows render (grouped), which is the order a Shift-click range walks. */
export function sshTargetDisplayOrder(targets: readonly SshTarget[]): string[] {
return groupSshTargetsBySource(targets).flatMap((group) => group.targets.map((t) => t.id))
}
export function countSshTargetsBySource(
targets: readonly SshTarget[]
): Record<SshTargetGroupId, number> {
const groups = groupSshTargetsBySource(targets)
return {
'ssh-config': groups.find((group) => group.id === 'ssh-config')?.targets.length ?? 0,
manual: groups.find((group) => group.id === 'manual')?.targets.length ?? 0
}
}
export function filterSshTargets(targets: readonly SshTarget[], query: string): SshTarget[] {
const needle = query.trim().toLowerCase()
if (!needle) {
return [...targets]
}
return targets.filter((target) =>
[target.label, target.host, target.username, target.configHost]
.filter((value): value is string => typeof value === 'string')
.some((value) => value.toLowerCase().includes(needle))
)
}
/**
* Applies a checkbox click. With Shift and a visible anchor, every row between the anchor and the
* clicked row takes the clicked row's new state, like a file manager.
*/
export function applySshTargetSelectionClick(args: {
selected: ReadonlySet<string>
orderedIds: readonly string[]
anchorId: string | null
clickedId: string
shiftKey: boolean
}): Set<string> {
const next = new Set(args.selected)
const select = !args.selected.has(args.clickedId)
const anchorIndex = args.anchorId ? args.orderedIds.indexOf(args.anchorId) : -1
const clickedIndex = args.orderedIds.indexOf(args.clickedId)
const range =
args.shiftKey && anchorIndex !== -1 && clickedIndex !== -1
? args.orderedIds.slice(
Math.min(anchorIndex, clickedIndex),
Math.max(anchorIndex, clickedIndex) + 1
)
: [args.clickedId]
for (const id of range) {
if (select) {
next.add(id)
} else {
next.delete(id)
}
}
return next
}
export function sshTargetGroupCheckState(
selected: ReadonlySet<string>,
ids: readonly string[]
): boolean | 'indeterminate' {
const count = ids.filter((id) => selected.has(id)).length
if (count === 0) {
return false
}
return count === ids.length ? true : 'indeterminate'
}
export type SshBulkRemoveSkipReason = 'managed-server' | 'workspaces'
export type SshBulkRemovePlan = {
removable: SshTarget[]
skipped: { target: SshTarget; reason: SshBulkRemoveSkipReason }[]
}
/**
* Hosts whose single removal needs its own decision (a managed server main refuses to drop, or
* workspaces to keep or delete) are skipped, so one confirm never decides those silently.
*/
export function planSshBulkRemove(
targets: readonly SshTarget[],
classify: (target: SshTarget) => SshBulkRemoveSkipReason | null
): SshBulkRemovePlan {
const plan: SshBulkRemovePlan = { removable: [], skipped: [] }
for (const target of targets) {
const reason = classify(target)
if (reason) {
plan.skipped.push({ target, reason })
} else {
plan.removable.push(target)
}
}
return plan
}
/** The key file's name; the full path is in the edit form. */
export function sshIdentityFileName(identityFile: string | undefined): string | null {
const trimmed = identityFile?.trim()
if (!trimmed) {
return null
}
const parts = trimmed.split(/[\\/]/)
return parts.at(-1) || trimmed
}
@@ -161,7 +161,7 @@ describe('LinearIntegrationCard account scope', () => {
expect(rendered.querySelector('[data-settings-section="integrations-linear"]')).not.toBeNull()
expect(rendered.textContent).toContain(`Account scope: ${LOCAL_HOST_LABEL}`)
expect(rendered.textContent).toContain(
'Credentials and account checks for this provider are owned by this desktop client. To use a server, change Settings > Remote Orca Servers > Advanced > Default host for new projects.'
'Credentials and account checks for this provider are owned by this desktop client. To use a server, change Settings > Remote Orca Servers > New projects open on.'
)
expect(rendered.textContent).toContain('Open Remote Servers')
expect(rendered.textContent).toContain('Add access with a Personal API key')
@@ -182,7 +182,7 @@ describe('LinearIntegrationCard account scope', () => {
expect(rendered.textContent).toContain('Account scope: Remote server: runtime-1')
expect(rendered.textContent).toContain(
'Credentials and account checks for this provider are owned by this remote server. To use another host, change Settings > Remote Orca Servers > Advanced > Default host for new projects.'
'Credentials and account checks for this provider are owned by this remote server. To use another host, change Settings > Remote Orca Servers > New projects open on.'
)
await act(async () => {
Array.from(rendered.querySelectorAll('button'))
@@ -0,0 +1,93 @@
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import type { OrcadManagedPendingMigrationRow } from '../../../../shared/orcad-managed-runtime'
import {
getOrcadSshDeployment,
type PublicKnownRuntimeEnvironment
} from '../../../../shared/runtime-environments'
import type { SshTarget } from '../../../../shared/ssh-types'
import type { ManagedOrcadPreloadApi } from '../../../../preload/api/managed-orcad-api'
import { useMountedRef } from '@/hooks/useMountedRef'
import { isWebClientLocation } from '@/lib/web-client-location'
import { managedServerOutcomeLabel } from './managed-server-copy'
export type ManagedServerCatalog = {
/** Absent in the web client, which can't reach SSH hosts. */
api: ManagedOrcadPreloadApi | undefined
targets: SshTarget[]
/** SSH hosts that don't already run, or move to, a managed server. */
emptyTargets: SshTarget[]
pending: OrcadManagedPendingMigrationRow[]
loadFailed: boolean
resumingId: string | null
reload: () => Promise<void>
resume: (row: OrcadManagedPendingMigrationRow) => Promise<void>
}
/** The managed-server API, or undefined where it can't work: the web client's fallback API stubs it. */
export function getManagedOrcadApi(): ManagedOrcadPreloadApi | undefined {
return isWebClientLocation() ? undefined : window.api.runtimeEnvironments.managedOrcad
}
export function useManagedServerCatalog(
environments: PublicKnownRuntimeEnvironment[],
onChanged: () => void
): ManagedServerCatalog {
const api = getManagedOrcadApi()
const mountedRef = useMountedRef()
const [targets, setTargets] = useState<SshTarget[]>([])
const [pending, setPending] = useState<OrcadManagedPendingMigrationRow[]>([])
const [resumingId, setResumingId] = useState<string | null>(null)
const [loadFailed, setLoadFailed] = useState(false)
const reload = useCallback(async () => {
if (!api) {
return
}
const [nextTargets, nextPending] = await Promise.all([
window.api.ssh.listTargets(),
api.listPendingMigrations()
])
if (mountedRef.current) {
setTargets(nextTargets)
setPending(nextPending)
setLoadFailed(false)
}
}, [api, mountedRef])
useEffect(() => {
void reload().catch(() => mountedRef.current && setLoadFailed(true))
}, [mountedRef, reload])
// Why: a host that already runs, or is moving to, a managed server can't take another.
const busyTargetIds = new Set([
...environments.map((environment) => getOrcadSshDeployment(environment)?.sshTargetId),
...pending.map((row) => row.sshTargetId)
])
const emptyTargets = targets.filter(
(target) => !target.orcadFence && !busyTargetIds.has(target.id)
)
const resume = async (row: OrcadManagedPendingMigrationRow): Promise<void> => {
if (!api || resumingId) {
return
}
setResumingId(row.migrationId)
try {
const result = await api.convertSshHost({ sshTargetId: row.sshTargetId, name: row.name })
if (result.outcome !== 'converted') {
toast.message(managedServerOutcomeLabel(result))
}
onChanged()
await reload()
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error))
} finally {
if (mountedRef.current) {
setResumingId(null)
}
}
}
return { api, targets, emptyTargets, pending, loadFailed, resumingId, reload, resume }
}
@@ -0,0 +1,143 @@
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import {
ORCAD_RECOVERY_CHANGED_STATE_CODE,
type OrcadManagedRuntimeStatus
} from '../../../../shared/orcad-managed-runtime'
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
import type { ManagedOrcadPreloadApi } from '../../../../preload/api/managed-orcad-api'
import { useMountedRef } from '@/hooks/useMountedRef'
import { managedServerOutcomeLabel } from './managed-server-copy'
export type ManagedServerAction =
| 'update'
| 'rollback'
| 'recover'
| 'restoreSnapshot'
| 'stop'
| 'cancelStop'
| 'forget'
export type ManagedServerLifecycle = {
status: OrcadManagedRuntimeStatus | null
statusError: string | null
recovery: OrcadManagedRuntimeStatus['recovery']
busy: ManagedServerAction | 'status' | null
confirmingStop: boolean
confirmingForget: boolean
confirmingRestore: boolean
setConfirmingStop: (value: boolean) => void
setConfirmingForget: (value: boolean) => void
setConfirmingRestore: (value: boolean) => void
loadStatus: () => Promise<void>
run: (action: ManagedServerAction) => Promise<void>
}
/** Turns a refusal or deferral into the one line the user acts on. */
function outcomeMessage(result: Parameters<typeof managedServerOutcomeLabel>[0]): string | null {
return result.outcome === 'deferred' ||
result.outcome === 'refused' ||
result.outcome === 'failed'
? managedServerOutcomeLabel(result)
: null
}
/** Status and lifecycle actions for a server Orca deployed and owns over SSH. */
export function useManagedServerLifecycle({
api,
environment,
onChanged
}: {
api: ManagedOrcadPreloadApi
environment: Pick<PublicKnownRuntimeEnvironment, 'id'>
onChanged: () => void
}): ManagedServerLifecycle {
const mountedRef = useMountedRef()
const [status, setStatus] = useState<OrcadManagedRuntimeStatus | null>(null)
const [statusError, setStatusError] = useState<string | null>(null)
const [busy, setBusy] = useState<ManagedServerAction | 'status' | null>(null)
const [confirmingStop, setConfirmingStop] = useState(false)
const [confirmingForget, setConfirmingForget] = useState(false)
const [confirmingRestore, setConfirmingRestore] = useState(false)
const loadStatus = useCallback(async () => {
setBusy('status')
try {
const next = await api.getStatus({ selector: environment.id })
if (mountedRef.current) {
setStatus(next)
setStatusError(null)
}
} catch (error) {
if (mountedRef.current) {
setStatusError(error instanceof Error ? error.message : String(error))
}
} finally {
if (mountedRef.current) {
setBusy(null)
}
}
}, [api, environment.id, mountedRef])
useEffect(() => {
void loadStatus()
}, [loadStatus])
const run = async (action: ManagedServerAction): Promise<void> => {
setBusy(action)
try {
const selector = { selector: environment.id }
const result =
action === 'update'
? await api.update(selector)
: action === 'rollback'
? await api.rollback(selector)
: action === 'recover' || action === 'restoreSnapshot'
? await api.recover({
...selector,
acceptChangedState: action === 'restoreSnapshot'
})
: action === 'stop'
? await api.stop(selector)
: action === 'forget'
? await api.forget(selector)
: await api.cancelStop(selector)
const message = outcomeMessage(result)
if (message) {
toast.message(message)
}
if (
mountedRef.current &&
action === 'recover' &&
result.outcome === 'refused' &&
'code' in result &&
result.code === ORCAD_RECOVERY_CHANGED_STATE_CODE
) {
setConfirmingRestore(true)
}
onChanged()
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error))
} finally {
if (mountedRef.current) {
setBusy(null)
}
void loadStatus()
}
}
return {
status,
statusError,
recovery: status?.recovery ?? null,
busy,
confirmingStop,
confirmingForget,
confirmingRestore,
setConfirmingStop,
setConfirmingForget,
setConfirmingRestore,
loadStatus,
run
}
}
@@ -32,6 +32,19 @@ type RuntimeEnvironmentCatalog = {
}) => Promise<void>
}
// Why module scope: reopening Settings shows the last failed probe while the quiet re-probe runs,
// instead of flashing every row back to "Checking…".
const lastSettledDetails = new Map<string, RuntimeHostDetails>()
// Why only failures: a cached answer must never claim "Connected"; live status comes from the store.
function isCacheableDetails(details: RuntimeHostDetails): boolean {
return details.status === 'error' && details.remoteControl?.state !== 'ready'
}
export function forgetCachedRuntimeHostDetails(environmentId: string): void {
lastSettledDetails.delete(environmentId)
}
export function useRuntimeEnvironmentCatalog(): RuntimeEnvironmentCatalog {
const savedEnvironments = useAppStore((state) => state.runtimeEnvironments)
const environments = useMemo(
@@ -41,7 +54,21 @@ export function useRuntimeEnvironmentCatalog(): RuntimeEnvironmentCatalog {
const [isLoading, setIsLoading] = useState(false)
const [detailsByEnvironmentId, setDetailsByEnvironmentId] = useState<
Record<string, RuntimeHostDetails>
>({})
>(() => Object.fromEntries(lastSettledDetails))
useEffect(() => {
for (const environmentId of lastSettledDetails.keys()) {
if (!(environmentId in detailsByEnvironmentId)) {
lastSettledDetails.delete(environmentId)
}
}
for (const [environmentId, details] of Object.entries(detailsByEnvironmentId)) {
if (isCacheableDetails(details)) {
lastSettledDetails.set(environmentId, details)
} else {
lastSettledDetails.delete(environmentId)
}
}
}, [detailsByEnvironmentId])
const mountedRef = useMountedRef()
const loadEnvironments = useCallback(
@@ -1,17 +1,20 @@
import { useState, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
import { useState, type MutableRefObject } from 'react'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
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 { defaultServerNameForAccessLink } from './runtime-server-default-name'
import { forgetCachedRuntimeHostDetails } from './use-runtime-environment-catalog'
import type { RuntimeHostAccessFailure } from './RuntimeHostAccessFailureAlert'
type RuntimeEnvironmentMutationActionParams = {
settings: GlobalSettings
allowLocalRuntime: boolean
mountedRef: MutableRefObject<boolean>
setAddServerFormOpen: Dispatch<SetStateAction<boolean>>
setAddServerFormOpen: (open: boolean) => void
loadEnvironments: (verified?: {
environmentId: string
runtimeStatus: RuntimeStatus
@@ -45,8 +48,10 @@ export function useRuntimeEnvironmentMutationActions({
}
const addEnvironment = async (allowLoopback: boolean): Promise<void> => {
const trimmedName = name.trim()
const trimmedPairingCode = pairingCode.trim()
const trimmedName =
name.trim() ||
defaultServerNameForAccessLink(trimmedPairingCode, useAppStore.getState().runtimeEnvironments)
if (!trimmedName || !trimmedPairingCode) {
toast.error(
translate(
@@ -100,8 +105,8 @@ export function useRuntimeEnvironmentMutationActions({
if (mountedRef.current) {
toast.success(
translate(
'auto.components.settings.RuntimeEnvironmentsPane.7b5986c8df',
'Connected to {{value0}}. To create new projects there by default, choose it under Advanced > Default host for new projects.',
'auto.components.settings.RuntimeEnvironmentsPane.connectedNewProjectsHint',
'Connected to {{value0}}. To create new projects there by default, choose it under New projects open on.',
{ value0: result.environment.name }
)
)
@@ -138,14 +143,15 @@ export function useRuntimeEnvironmentMutationActions({
if (mountedRef.current) {
setRemoveError(
translate(
'auto.components.settings.RuntimeEnvironmentsPane.removeActiveServerBlocked',
'This server is the default host for new projects. Choose another one in Advanced before removing it.'
'auto.components.settings.RuntimeEnvironmentsPane.removeDefaultServerBlocked',
'This server is the default host for new projects. Choose another one under New projects open on before removing it.'
)
)
}
return false
}
await window.api.runtimeEnvironments.remove({ selector: environment.id })
forgetCachedRuntimeHostDetails(environment.id)
await loadEnvironments()
if (mountedRef.current) {
toast.success(
@@ -0,0 +1,103 @@
import { useRef } from 'react'
import { toast } from 'sonner'
import type { SshTarget } from '../../../../shared/ssh-types'
import { useAppStore } from '@/store'
import { getAllWorktreesFromState } from '@/store/selectors'
import { useMountedRef } from '@/hooks/useMountedRef'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
import { translate } from '@/i18n/i18n'
import { resolveSshHostRemoval } from '../sidebar/ssh-host-remove-resolution'
import { isManagedServerSshHost } from './SshManagedHostRemoveDialog'
import type { SshBulkRemoveRequest, SshBulkRemoveResult } from './SshTargetDestructiveActions'
import { planSshBulkRemove, type SshBulkRemovePlan } from './ssh-target-list-model'
import { summarizeSshBulkRemove } from './ssh-bulk-remove-summary'
type PlainRemoveTarget = { id: string; label: string }
/** Splits a selection into one confirmed removal plus skips, and reports the run in one toast. */
export function useSshBulkRemove(args: {
targets: readonly SshTarget[]
selected: ReadonlySet<string>
clearSelection: () => void
reload: () => Promise<void>
requestSingleRemove: (
target: SshTarget,
requestPlainRemove: (target: PlainRemoveTarget) => void
) => void
}): {
requestRemoveSelected: (
requestBulkRemove: (request: SshBulkRemoveRequest) => void,
requestPlainRemove: (target: PlainRemoveTarget) => void
) => void
finishBulkRemove: (result: SshBulkRemoveResult) => Promise<void>
} {
const mountedRef = useMountedRef()
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
// Why a ref: skips are decided when Remove is clicked and reported once the confirmed run ends.
const skippedRef = useRef<SshBulkRemovePlan['skipped']>([])
const finishBulkRemove = async (result: SshBulkRemoveResult): Promise<void> => {
const summary = summarizeSshBulkRemove({
removedCount: result.removed.length,
skipped: skippedRef.current,
failed: result.failed.map(({ target, error }) => ({
label: target.label,
message: extractIpcErrorMessage(
error,
translate('auto.components.settings.SshPane.c2a69510e3', 'Failed to remove target')
)
}))
})
skippedRef.current = []
if (result.removed.length > 0) {
recordFeatureInteraction('ssh')
await args.reload()
}
if (!mountedRef.current) {
return
}
args.clearSelection()
const notify =
summary.tone === 'success' ? toast.success : summary.tone === 'error' ? toast.error : toast
notify(summary.title, summary.description ? { description: summary.description } : undefined)
}
const requestRemoveSelected = (
requestBulkRemove: (request: SshBulkRemoveRequest) => void,
requestPlainRemove: (target: PlainRemoveTarget) => void
): void => {
const chosen = args.targets.filter((target) => args.selected.has(target.id))
// Why: a single host keeps its own dialogs (managed server, workspaces) instead of a skip.
if (chosen.length === 1) {
args.requestSingleRemove(chosen[0], requestPlainRemove)
return
}
const state = useAppStore.getState()
const worktrees = getAllWorktreesFromState(state)
const plan = planSshBulkRemove(chosen, (target) => {
if (isManagedServerSshHost(target)) {
return 'managed-server'
}
const resolution = resolveSshHostRemoval({
targetId: target.id,
repos: state.repos,
worktrees,
sshConnectionStates: state.sshConnectionStates
})
return resolution.workspaceCount > 0 ? 'workspaces' : null
})
skippedRef.current = plan.skipped
if (plan.removable.length === 0) {
void finishBulkRemove({ removed: [], failed: [] })
return
}
requestBulkRemove({
targets: plan.removable.map((target) => ({ id: target.id, label: target.label })),
connectedCount: plan.removable.filter(
(target) => state.sshConnectionStates.get(target.id)?.status === 'connected'
).length
})
}
return { requestRemoveSelected, finishBulkRemove }
}
@@ -0,0 +1,64 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
/**
* The explicit ~/.ssh/config actions. `restoreRemoved` is the only path that lifts the tombstones
* of hosts removed in Orca; a plain sync keeps them removed so bulk removal sticks.
*/
export function useSshConfigSync(onSynced: () => Promise<void>): {
syncing: boolean
sync: (options?: { restoreRemoved?: boolean }) => Promise<void>
} {
const mountedRef = useMountedRef()
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
const [syncing, setSyncing] = useState(false)
const sync = async (options?: { restoreRemoved?: boolean }): Promise<void> => {
setSyncing(true)
try {
const result = await window.api.ssh.importConfig({
reAdopt: options?.restoreRemoved === true
})
useAppStore.getState().recordSshRepoReadoptions(result.repoReadoptions)
recordFeatureInteraction('ssh')
if (mountedRef.current) {
if (result.targets.length === 0) {
toast(
translate(
'auto.components.settings.SshPane.alreadyInSync',
'~/.ssh/config already in sync'
)
)
} else {
toast.success(
translate(
result.targets.length === 1
? 'auto.components.settings.SshPane.syncedServerSingular'
: 'auto.components.settings.SshPane.syncedServerPlural',
result.targets.length === 1 ? 'Synced {{count}} server' : 'Synced {{count}} servers',
{ count: result.targets.length }
)
)
}
}
await onSynced()
} catch (err) {
if (mountedRef.current) {
toast.error(
err instanceof Error
? err.message
: translate('auto.components.settings.SshPane.f495689b82', 'Import failed')
)
}
} finally {
if (mountedRef.current) {
setSyncing(false)
}
}
}
return { syncing, sync }
}
@@ -0,0 +1,89 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { hasVisibleOverlay } from '@/lib/visible-overlay'
import { applySshTargetSelectionClick } from './ssh-target-list-model'
export type SshTargetSelection = {
selected: ReadonlySet<string>
click: (targetId: string, shiftKey: boolean) => void
setGroup: (ids: readonly string[], select: boolean) => void
/** Drops every id not in `ids`; call when the filter changes what is visible. */
retain: (ids: readonly string[]) => void
clear: () => void
}
/**
* Multi-select over the visible SSH host rows, in display order. Only visible ids count, so a
* bulk action can never reach a host the filter hides or a reload removed.
*/
export function useSshTargetSelection(visibleIds: readonly string[]): SshTargetSelection {
const [rawSelected, setRawSelected] = useState<ReadonlySet<string>>(new Set())
const anchorRef = useRef<string | null>(null)
const selected = useMemo(() => {
const visible = new Set(visibleIds)
return new Set([...rawSelected].filter((id) => visible.has(id)))
}, [visibleIds, rawSelected])
const click = useCallback(
(targetId: string, shiftKey: boolean): void => {
// Why read first: the updater may run later (or twice in StrictMode), after the anchor moved.
const anchorId = anchorRef.current
setRawSelected((prev) =>
applySshTargetSelectionClick({
selected: prev,
orderedIds: visibleIds,
anchorId,
clickedId: targetId,
shiftKey
})
)
anchorRef.current = targetId
},
[visibleIds]
)
const setGroup = useCallback((ids: readonly string[], select: boolean): void => {
setRawSelected((prev) => {
const next = new Set(prev)
for (const id of ids) {
if (select) {
next.add(id)
} else {
next.delete(id)
}
}
return next
})
}, [])
const retain = useCallback((ids: readonly string[]): void => {
const keep = new Set(ids)
setRawSelected((prev) => new Set([...prev].filter((id) => keep.has(id))))
}, [])
const clear = useCallback((): void => {
anchorRef.current = null
setRawSelected(new Set())
}, [])
const hasSelection = selected.size > 0
useEffect(() => {
if (!hasSelection) {
return
}
// Why capture: Settings closes on a bubbling Escape; with rows selected, Escape clears them first.
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key !== 'Escape' || event.isComposing || hasVisibleOverlay()) {
return
}
// A filter with text owns the first Escape (it clears the text).
if (event.target instanceof HTMLInputElement && event.target.value !== '') {
return
}
event.preventDefault()
clear()
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [clear, hasSelection])
return { selected, click, setGroup, retain, clear }
}
@@ -0,0 +1,150 @@
import { useState } from 'react'
import { toast } from 'sonner'
import type { SshTarget } from '../../../../shared/ssh-types'
import { useAppStore } from '@/store'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
import { isSshTargetConnecting } from './ssh-target-action-state'
type TestOutcome = { ok: true } | { ok: false; message: string }
// Why a cap: each test opens its own SSH session, and a select-all over a big config would dial them all.
const BULK_TEST_CONCURRENCY = 4
/** Connection tests for one host (its own toast) or many (one summary toast). */
export function useSshTargetTests(): {
testingIds: ReadonlySet<string>
testOne: (targetId: string) => Promise<void>
testMany: (targets: readonly SshTarget[]) => Promise<void>
bulkTesting: boolean
} {
const mountedRef = useMountedRef()
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
const [testingIds, setTestingIds] = useState<ReadonlySet<string>>(new Set())
const [bulkTesting, setBulkTesting] = useState(false)
const setTesting = (ids: readonly string[], testing: boolean): void => {
if (!mountedRef.current) {
return
}
setTestingIds((prev) => {
const next = new Set(prev)
for (const id of ids) {
if (testing) {
next.add(id)
} else {
next.delete(id)
}
}
return next
})
}
const runTest = async (targetId: string): Promise<TestOutcome> => {
setTesting([targetId], true)
try {
const result = await window.api.ssh.testConnection({ targetId })
recordFeatureInteraction('ssh')
return result.success
? { ok: true }
: {
ok: false,
message:
result.error ??
translate('auto.components.settings.SshPane.0cda732f43', 'Connection test failed')
}
} catch (err) {
return {
ok: false,
message:
err instanceof Error
? err.message
: translate('auto.components.settings.SshPane.68c13b4589', 'Test failed')
}
} finally {
setTesting([targetId], false)
}
}
const testOne = async (targetId: string): Promise<void> => {
const outcome = await runTest(targetId)
if (!mountedRef.current) {
return
}
if (outcome.ok) {
toast.success(
translate('auto.components.settings.SshPane.81d08bcddf', 'Connection successful')
)
} else {
toast.error(outcome.message)
}
}
const testMany = async (targets: readonly SshTarget[]): Promise<void> => {
if (bulkTesting) {
return
}
// Same rule as a row's Test: a connected or connecting host has nothing to test.
const states = useAppStore.getState().sshConnectionStates
const isLive = (target: SshTarget): boolean => {
const status = states.get(target.id)?.status ?? 'disconnected'
return status === 'connected' || isSshTargetConnecting(status)
}
const skippedConnected = targets.filter(isLive).length
const queue = targets.filter((target) => !isLive(target) && !testingIds.has(target.id))
setBulkTesting(true)
const failed: { label: string; message: string }[] = []
let passed = 0
const worker = async (): Promise<void> => {
for (let target = queue.shift(); target; target = queue.shift()) {
const outcome = await runTest(target.id)
if (outcome.ok) {
passed += 1
} else {
failed.push({ label: target.label, message: outcome.message })
}
}
}
try {
await Promise.all(Array.from({ length: BULK_TEST_CONCURRENCY }, worker))
} finally {
if (mountedRef.current) {
setBulkTesting(false)
}
}
if (!mountedRef.current || passed + failed.length + skippedConnected === 0) {
return
}
const title = [
passed > 0
? translate('auto.components.settings.sshBulkTest.passed', '{{count}} reachable', {
count: passed
})
: null,
failed.length > 0
? translate('auto.components.settings.sshBulkTest.failed', '{{count}} failed', {
count: failed.length
})
: null,
skippedConnected > 0
? translate(
'auto.components.settings.sshBulkTest.alreadyConnected',
'{{count}} already connected',
{ count: skippedConnected }
)
: null
]
.filter((part): part is string => part !== null)
.join(' · ')
const description = failed.map(({ label, message }) => `${label}: ${message}`).join(' ')
if (failed.length === 0) {
toast.success(title)
} else if (passed === 0) {
toast.error(title, { description })
} else {
toast(title, { description })
}
}
return { testingIds, testOne, testMany, bulkTesting }
}
@@ -211,8 +211,8 @@ export function RemoteServerFields({
/>
<p id="add-server-pairing-code-help" className="text-xs text-muted-foreground">
{translate(
'auto.components.sidebar.AddRemoteHostDialog.pairingHelpSuffix',
'Create this under Settings → Remote Orca Servers → Share this host on the other computer.'
'auto.components.sidebar.AddRemoteHostDialog.pairingHelpShareComputer',
'Create this under Settings → Remote Orca Servers → Share this computer on the other computer.'
)}
</p>
{inputError ? (
@@ -60,7 +60,7 @@ export function buildRemoteSettingsSections(
),
description: isWebClient
? 'Connect this browser to a saved Orca server.'
: 'Pair remote Orca runtimes for persistent sessions, richer remote state, and web or mobile handoff.',
: 'Run Orca on another machine and work on it from here.',
icon: Server,
searchEntries: [runtimeEnvironmentsSearchEntry],
group: 'remote',
+139 -3
View File
@@ -1186,6 +1186,13 @@
"a58ba464ad": "Skill location",
"f00d6aa9b5": "WSL is not available on this machine."
},
"CloudVmSetupGuide": {
"createWorkspace": "Create a workspace and select that recipe under Run on.",
"description": "Cloud VMs are created from environment recipes when you create a workspace.",
"openSetup": "Set up environment recipes",
"setupRecipe": "Set up an environment recipe for your cloud provider.",
"title": "Create a Cloud VM"
},
"CommitMessageAiPane": {
"841ed9884a": "Used by repositories that have not customized Source Control AI."
},
@@ -1199,6 +1206,10 @@
"EphemeralVmRuntimesSection": {
"cleaned": "Cleaned up temporary VM runtime.",
"cleanupFailedToast": "Couldn’t clean up temporary VM runtime.",
"cloudVmEmptyWithSetup": "No Cloud VM runtimes yet. Create one from a workspace using an environment recipe.",
"cloudVmLoading": "Checking Cloud VM runtimes…",
"cloudVmTitle": "Cloud VM runtimes",
"description": "Recipe-created runtimes are workspace-owned. Clean up stale entries after crashes, failed creates, or manual recovery.",
"empty": "No temporary VM runtimes need cleanup.",
"loadFailed": "Couldn’t load temporary VM runtimes.",
"loading": "Checking temporary VM runtimes…",
@@ -1459,28 +1470,107 @@
"ccb07dd027": "Saved",
"e57dde9d93": "Unsaved changes"
},
"RuntimeAccessGrantList": {
"27cf8507ad": "Refresh shared access",
"68ec21309f": "Revoke access",
"87b16cd11d": "Created",
"8b82879581": "Anyone with an active grant can connect until you revoke it. Revoking shared access disconnects active clients immediately.",
"f031182867": "Shared Server Access",
"fd83b94095": "No shared server access yet."
},
"RuntimeEnvironmentsPane": {
"0ef838094a": "Protocol {{value0}}",
"163671f7b5": "Run",
"1826bd0608": "Saved Servers",
"2c85efb3e8": "Selecting a saved server makes this browser use that paired Orca runtime as its default Host.",
"3595fd1948": "New Link",
"3f67e8078a": "New projects are created on this computer unless you choose a saved server here. Existing projects, files and terminals stay on their own host.",
"54dee18f5c": "Hide Form",
"54ebacc600": "Server name",
"55fcc964cd": "on the server and paste the printed pairing URL.",
"64b6bea541": "Default host for new projects",
"6ce4664003": "Refresh servers",
"6e1280ca55": "Share this Orca server",
"78692becbd": "Local desktop",
"7b5986c8df": "Connected to {{value0}}. To create new projects there by default, choose it under Advanced > Default host for new projects.",
"84b9b2be05": "Create a revocable access grant so a browser or another Orca client can connect.",
"8cf8790697": "Saved servers route this browser through a paired Orca runtime.",
"960e901ae4": "orca serve --pairing-address <host>",
"9a3758d983": "No saved servers.",
"9bc9b83474": "Pairing code",
"9bee6bbeeb": "Add Server",
"9f7665a01b": "Removing the active server first switches Orca back to Local desktop. Existing host sessions are left alone.",
"activeServerRowHelp": "Default host for new projects. Existing projects stay on their own host.",
"advanced": "Advanced",
"advertiseThisApp": "Advertise this app as a server",
"advertiseThisAppHelp": "Create access links for browsers, mobile clients, or another Orca client to connect back to this running app.",
"aeb26635d2": "Remove {{value0}}",
"b2fda48c39": "Removing the active server disconnects this browser from that host. Existing host sessions are left alone.",
"c3d772c514": "orca://pair?code=...",
"cloudVmWorkflow": "Cloud VM",
"cloudVmWorkflowHelp": "Manage recipe-created cloud machines",
"connectToRemoteServers": "Connect to remote servers",
"connectToRemoteServersHelp": "Pair another Orca runtime, then connect or disconnect it here.",
"connectWorkflow": "Connect to a host",
"connectWorkflowHelp": "This app joins another machine",
"e038625857": "Dev box",
"f3a3d6d834": "{{value0}} capabilities",
"f75ce1c7a5": "Local keeps today's desktop behavior. Saved servers route supported client calls through the remote runtime.",
"orcaVersion": "Orca v{{value0}}",
"orcaVersionLastSeen": "Orca v{{value0}} (last seen)",
"removeActiveServerBlocked": "This server is the default host for new projects. Choose another one in Advanced before removing it.",
"removeActiveServerDescription": "This server is the default host for new projects. Choose another one in Advanced before removing it. Existing host sessions are left alone.",
"reviewServerUpdates": "Check for Server Updates",
"serverDetails": "Server details",
"shareWorkflow": "Share this host",
"shareWorkflowHelp": "Other devices join this machine",
"troubleshootDescription": "A link that uses 127.0.0.1 points back to the device opening it, not the computer that created it.",
"troubleshootStepAddress": "Choose Another device and select its Tailscale or LAN address.",
"troubleshootStepRegenerate": "Generate a new access link and use only the newest link here.",
"troubleshootStepShare": "On the other computer, open Share this host.",
"troubleshootTitle": "Create a new link on the other host",
"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.",
"troubleshootWorkflow": "Connection troubleshooting",
"updateAvailableOne": "1 update available",
"updatesAvailable": "{{value0}} updates available"
"updatesAvailable": "{{value0}} updates available",
"versionUnavailable": "Orca version unavailable",
"workflow": "Remote server workflow"
},
"RuntimeHostAccessForm": {
"addHost": "Add host",
"addWithTunnel": "Add host using tunnel",
"advanced": "Advanced",
"connectionDetails": "Connection details",
"endpointKind": "Endpoint kind",
"getLink": "Get an access link from the other host",
"headlessHelp": "Using headless orca serve? Run orca serve --pairing-address <reachable-host> on the other computer.",
"loopbackRecovery": "On the other computer, create a new link using Another device and choose its Tailscale or LAN address.",
"name": "Name in Orca",
"nameHelp": "This only changes how the computer appears in Orca.",
"networkConnection": "Network connection",
"notAttempted": "Not attempted",
"sshTunnel": "I am using an SSH tunnel to this local address",
"stepChooseAddress": "Choose Another device and select a reachable address.",
"stepCopyLink": "Generate the link, then copy the “Pair another Orca client” link.",
"stepOpenShare": "Open Settings → Remote Orca Servers → Share this host."
},
"RuntimePairingUrlGenerator": {
"279e0dcb57": "127.0.0.1 only works on this computer. Use a LAN, Tailscale, or custom address for another device.",
"2e5c4e3c93": "Pair another Orca client",
"6b9ca3e69b": "Open in browser",
"849825e829": "Paste this pairing URL into another Orca client.",
"8de0f84fff": "Generate Access Link",
"add-custom": "Add custom address…",
"b91e36a986": "web",
"de6d5cff95": "This computer ("
"de6d5cff95": "This computer (",
"intentQuestion": "Where will this link be opened?",
"localOnly": "This computer only",
"pairedRunsAsYou": "A paired client can run commands as you on this computer.",
"recommended": "Recommended",
"staleAddress": "The connection address changed. Generate a new link for {{address}}."
},
"RuntimeRepairDialog": {
"description": "{{name}} no longer accepts this computer. On {{name}}, open Settings → Remote Orca Servers → Share this host, create a link for another Orca client, then paste it here."
},
"Settings": {
"084d8fac5b": "Privacy & Security",
@@ -1489,6 +1579,7 @@
"23c6874fdf": "AI Capabilities",
"8bd117d669": "Interface",
"9abb9be3bc": "Set Up",
"b5ee17826b": "Pair remote Orca runtimes for persistent sessions, richer remote state, and web or mobile handoff.",
"dd72ed437a": "Choose which task providers appear in the Tasks page and sidebar.",
"e1578cd4bc": "Workflows",
"mobile_group": "Mobile"
@@ -1516,13 +1607,19 @@
"fde7ff2cc3": "packages/web shared/ui"
},
"SshPane": {
"51d7dba44d": "Import",
"639ceb3698": "Add Target",
"94c5284560": "Targets",
"a7d28dff81": "Add a remote host to connect to it in Orca.",
"syncedServerPlural": "Synced {{count}} servers",
"syncedServerSingular": "Synced {{count}} server"
},
"SshTargetCard": {
"0e53e9f8e8": "Test",
"3d21a22d0e": "Removing target",
"3d8af2949f": "Edit target",
"47e94bd6ba": "connected",
"7f7b3d7ab4": "Remove target",
"f0871e6bfb": "connect"
},
"SshTargetForm": {
@@ -1530,9 +1627,13 @@
"29af933cd5": "New SSH Target",
"4a342f44c1": "Advanced Connection",
"92f80edbfd": "Remote Terminal Persistence",
"9518545cb6": "Add Target",
"e9609ddca6": "Proxy, jump host, and connection reuse",
"f2331ce599": "Edit SSH Target"
},
"SshTargetList": {
"selectGroup": "Select all in this group"
},
"TerminalAppearanceSection": {
"16c471ee03": "on",
"1b79379d4f": "Control inactive pane dimming and split divider thickness.",
@@ -1788,6 +1889,24 @@
}
}
},
"managedServers": {
"access": {
"link": "Use SSH",
"linked": "{{name}} via SSH",
"target": "Reach through SSH host",
"title": "SSH access for paired servers"
},
"deploy": {
"hostPlaceholder": "Choose an empty SSH host",
"running": "Deploying…",
"submit": "Deploy server"
},
"outcome": {
"activeServer": "Not done: this server is the default host for new projects. Choose another one in Advanced before stopping it."
},
"row": {},
"title": "Orca on your SSH hosts"
},
"mobile": {
"settings": {
"search": {
@@ -1796,7 +1915,9 @@
}
},
"providerAccountScope": {
"localMac": "Local Mac"
"localCredentials": "Credentials and account checks for this provider are owned by this desktop client. To use a server, change Settings > Remote Orca Servers > Advanced > Default host for new projects.",
"localMac": "Local Mac",
"remoteServerCredentials": "Credentials and account checks for this provider are owned by this remote server. To use another host, change Settings > Remote Orca Servers > Advanced > Default host for new projects."
},
"repository": {
"search": {
@@ -1808,6 +1929,14 @@
"vm": "vm"
}
},
"runtime": {
"environments": {
"search": {
"4575341c77": "Add a saved remote Orca server, generate a pairing URL, or choose the default host for new projects."
}
}
},
"serverList": {},
"settingOwnership": {
"clientDefaultProjectScopes": "Client default + project scopes",
"terminalQuickCommands": "Commands are saved on this client, then scoped globally or to a project setup so they run from the selected terminal context."
@@ -1828,6 +1957,10 @@
"olderTerminals_one": "{{count}} terminal started by an older version of Orca is still running on this host, and it doesn’t stop on its own. Close it in that version of Orca or end it on the host, then reconnect.",
"olderTerminals_other": "{{count}} terminals started by an older version of Orca are still running on this host, and they don’t stop on their own. Close them in that version of Orca or end them on the host, then reconnect."
},
"sshManagedHostRemove": {
"description": "Orca runs on {{label}}. Under Orca on your SSH hosts, use Stop… to remove Orca from the host, or Forget… if the host is gone. Then you can remove this SSH host.",
"open": "Open Orca on your SSH hosts"
},
"task": {
"tracker": {
"integration": {
@@ -1892,6 +2025,9 @@
"imported_other": "Imported {{value0}} themes",
"over_limit_one": "Importing these themes would exceed the {{value0}} custom terminal theme limit. Deselect 1 new theme and try again.",
"over_limit_other": "Importing these themes would exceed the {{value0}} custom terminal theme limit. Deselect {{value1}} new themes and try again."
},
"wslHosts": {
"remove": "Remove"
}
},
"shared": {
+218 -21
View File
@@ -6709,7 +6709,8 @@
"sshConfigPickerAddAllEmpty": "Add all to Orca",
"identityFileFromConfigHint": "Left empty on purpose: Orca uses every key ~/.ssh/config resolves for {{value0}}. Type a path to use just that key.",
"sshConfigPickerRetry": "Try again",
"sshConfigPickerResolving": "Reading…"
"sshConfigPickerResolving": "Reading…",
"pairingHelpShareComputer": "Create this under Settings → Remote Orca Servers → Share this computer on the other computer."
},
"ForgetSshWorkspaceDialog": {
"reconnectFailed": "Reconnection failed",
@@ -8644,7 +8645,12 @@
"27cf8507ad": "Refresh shared access",
"f031182867": "Shared Server Access",
"df142657a5": "Not used yet",
"b18d1764ef": "Last used {{value0}}"
"b18d1764ef": "Last used {{value0}}",
"linkName": "Link · {{value0}}",
"activeLinks": "Active links",
"noActiveLinks": "No active links.",
"createdAt": "Created {{value0}}",
"revoke": "Revoke"
},
"RuntimeEnvironmentsPane": {
"aeb26635d2": "Remove {{value0}}",
@@ -8750,7 +8756,10 @@
"repair": "Re-pair…",
"serverAccessRevoked": "Access revoked",
"orcaVersionLastSeen": "Orca v{{value0}} (last seen)",
"pairedTwice": "Same server as {{value0}}, paired twice. Remove one so its projects can be edited."
"pairedTwice": "Same server as {{value0}}, paired twice. Remove one so its projects can be edited.",
"removeDefaultServerDescription": "This server is the default host for new projects. Choose another one under New projects open on before removing it. Existing host sessions are left alone.",
"connectedNewProjectsHint": "Connected to {{value0}}. To create new projects there by default, choose it under New projects open on.",
"removeDefaultServerBlocked": "This server is the default host for new projects. Choose another one under New projects open on before removing it."
},
"RuntimePairingGeneratedUrlRows": {
"0495f68959": "Copy {{value0}}"
@@ -8803,7 +8812,15 @@
"localLinkHelp": "This link only works in a browser or Orca client running on this computer.",
"noExternalAddress": "No address for another device was found. Connect this computer to a LAN or Tailscale, refresh, or choose Custom address.",
"staleAddress": "The connection address changed. Generate a new link for {{address}}.",
"customInvalid": "Enter a valid host, host:port, IPv6 address, or ws(s):// URL."
"customInvalid": "Enter a valid host, host:port, IPv6 address, or ws(s):// URL.",
"thisComputer": "This computer",
"whoOpens": "Who will open the link",
"addressLabel": "Address",
"createLink": "Create link",
"browserLinkLabel": "Browser link",
"accessLinkLabel": "Access link",
"accessLinkHelp": "Paste this into Add server on the other Orca.",
"staleAddressCreate": "The connection address changed. Create a new link for {{address}}."
},
"Settings": {
"3bf149e873": "Project Settings > {{value0}}",
@@ -8881,7 +8898,8 @@
"devDescription": "Dev-only tools for exercising UI states.",
"linearTitle": "Linear",
"linearDescription": "How Linear works in Orca, setup checklist, agent skill, and example prompts.",
"tasksDescription": "Connect providers, install the Linear skill, and choose what appears in Tasks."
"tasksDescription": "Connect providers, install the Linear skill, and choose what appears in Tasks.",
"remoteServersDescription": "Run Orca on another machine and work on it from here."
},
"SettingsFormControls": {
"42a4d15a30": "No matching fonts.",
@@ -9005,7 +9023,8 @@
"2ef2b2674b": "Delete {{value0}}"
},
"SshDestructiveActionDialog": {
"895b216267": "Cancel"
"895b216267": "Cancel",
"moreTargets": "+{{count}} more"
},
"SshPane": {
"c0f1c80166": "No SSH targets configured.",
@@ -9028,7 +9047,10 @@
"f1fc50dad2": "Failed to load SSH targets",
"0cda732f43": "Connection test failed",
"syncedServerSingular": "Synced {{count}} server",
"syncedServerPlural": "Synced {{count}} servers"
"syncedServerPlural": "Synced {{count}} servers",
"noFilterMatch": "No hosts match.",
"addHost": "Add host",
"alreadyInSync": "~/.ssh/config already in sync"
},
"SshPassphraseDialog": {
"d5a234456f": "Cancel",
@@ -9059,11 +9081,26 @@
"3d8af2949f": "Edit target",
"18968ede9e": "Error",
"f0871e6bfb": "connect",
"47e94bd6ba": "connected"
"47e94bd6ba": "connected",
"removing": "Removing…",
"testing": "Testing…",
"select": "Select {{label}}",
"orcaServer": "Orca server",
"moreActions": "More actions for {{label}}",
"testConnection": "Test connection",
"edit": "Edit…",
"remove": "Remove…"
},
"SshTargetDestructiveActions": {
"3bb0cf0ee4": "This will remove the target and end any active remote terminals.",
"4808966c41": "Remove SSH Target"
"4808966c41": "Remove SSH Target",
"bulkTitleSingular": "Remove {{count}} SSH host?",
"bulkTitlePlural": "Remove {{count}} SSH hosts?",
"bulkDescription": "Removed hosts disappear from host pickers in workspaces, automations and the sidebar. Your ~/.ssh/config is not changed, and later syncs won’t bring them back.",
"bulkAction": "Remove {{count}}",
"bulkProgress": "Removing {{done}} of {{count}}…",
"bulkConnectedSingular": "{{count}} host is connected. Its remote terminals will be closed.",
"bulkConnectedPlural": "{{count}} hosts are connected. Their remote terminals will be closed."
},
"SshTargetForm": {
"fea9cb402e": "Cancel",
@@ -9099,7 +9136,8 @@
"addTitle": "Add SSH host",
"editDescription": "Update connection details for this machine. Changes apply on next connect.",
"addDescription": "Add a persistent machine you can log into over SSH.",
"editingPrefix": "Editing"
"editingPrefix": "Editing",
"addHost": "Add host"
},
"TasksPane": {
"f71d8a9dd3": "Task Providers",
@@ -10969,7 +11007,11 @@
"4575341c77": "Add a saved remote Orca server, generate a pairing URL, or choose the default host for new projects.",
"defaultHostKeyword": "default host",
"newProjectsKeyword": "new projects",
"activeServerKeyword": "active server"
"activeServerKeyword": "active server",
"addShareDefault": "Add a remote Orca server, share this computer, or choose where new projects open.",
"shareComputerKeyword": "share this computer",
"accessLinkKeyword": "access link",
"installKeyword": "install server"
}
}
},
@@ -11560,7 +11602,9 @@
"localMac": "Local Mac",
"localCredentials": "Credentials and account checks for this provider are owned by this desktop client. To use a server, change Settings > Remote Orca Servers > Advanced > Default host for new projects.",
"remoteServerRateLimit": "{{value0}} API budget is fetched from the CLI on this remote server. Choose another host in the Host menu at the top of Settings to see its budget.",
"localRateLimit": "{{value0}} API budget is fetched from the CLI on this desktop client. Choose a server in the Host menu at the top of Settings to see its budget."
"localRateLimit": "{{value0}} API budget is fetched from the CLI on this desktop client. Choose a server in the Host menu at the top of Settings to see its budget.",
"remoteServerCredentialsNewProjects": "Credentials and account checks for this provider are owned by this remote server. To use another host, change Settings > Remote Orca Servers > New projects open on.",
"localCredentialsNewProjects": "Credentials and account checks for this provider are owned by this desktop client. To use a server, change Settings > Remote Orca Servers > New projects open on."
},
"settingOwnership": {
"clientDefault": "Client default",
@@ -11780,7 +11824,8 @@
"stoppingCleanup": "Stopping…",
"stopCleanup": "Stop cleanup",
"cleanupStopped": "Cleanup stopped",
"stopCleanupFailed": "Couldn’t stop Cloud VM cleanup."
"stopCleanupFailed": "Couldn’t stop Cloud VM cleanup.",
"cloudVmRefreshLabel": "Refresh Cloud VMs"
},
"ephemeralVms": {
"search": {
@@ -12398,14 +12443,24 @@
"saveFailed": "Could not save the host",
"saveFailedHelp": "The host was verified, but Orca could not save it. Check the name and local settings storage, then try again.",
"differentServer": "This link is for a different server",
"differentServerHelp": "This pairing link is for a different server. Create the link on the server you are re-pairing."
"differentServerHelp": "This pairing link is for a different server. Create the link on the server you are re-pairing.",
"loopbackRecoveryTunnel": "On the other computer, open Share this computer and create a link for Another device with its Tailscale or LAN address. If you reach it through an SSH tunnel, turn on the option below.",
"sshTunnelReach": "I reach this address through an SSH tunnel",
"addServerWithTunnel": "Add server using tunnel",
"addServer": "Add server",
"nameLabel": "Name",
"optional": "(optional)",
"headlessHelpPrefix": "Running Orca without a window? On that computer, run"
},
"CloudVmSetupGuide": {
"title": "Create a Cloud VM",
"description": "Cloud VMs are created from environment recipes when you create a workspace.",
"setupRecipe": "Set up an environment recipe for your cloud provider.",
"createWorkspace": "Create a workspace and select that recipe under Run on.",
"openSetup": "Set up environment recipes"
"openSetup": "Set up environment recipes",
"cardTitle": "Cloud VMs",
"cardDescription": "Created per workspace from an environment recipe.",
"setUpRecipes": "Set up recipes"
},
"ReleaseChannelSection": {
"switchFailed": "Could not switch to that build.",
@@ -12720,7 +12775,9 @@
},
"sshManagedHostRemove": {
"description": "Orca runs on {{label}}. Under Orca on your SSH hosts, use Stop… to remove Orca from the host, or Forget… if the host is gone. Then you can remove this SSH host.",
"open": "Open Orca on your SSH hosts"
"open": "Open Orca on your SSH hosts",
"descriptionServers": "Orca runs on {{label}}. In Remote Orca Servers, open the ⋯ menu on Orca on {{label}} and choose Stop… to remove Orca from the host, or Forget… if the host is gone. Then you can remove this SSH host.",
"openServers": "Open Remote Orca Servers"
},
"managedServers": {
"row": {
@@ -12743,7 +12800,10 @@
"confirmForget": "Forget on this desktop",
"keepLinked": "Keep linked",
"forgetDescription": "Unlinks this server here without contacting its host. If the host comes back, the server may still be running there.",
"title": "Orca on {{host}}"
"title": "Orca on {{host}}",
"orcaVersion": "Orca on this host: {{version}}",
"stopOrcaDescription": "Stops Orca on the SSH host and removes it from this list.",
"updateOrcaOnHost": "Update Orca on this host"
},
"title": "Orca on your SSH hosts",
"description": "Orca installs itself on an empty SSH host and keeps itself up to date.",
@@ -12768,7 +12828,10 @@
"pendingLink": "Connecting through SSH didn’t finish.",
"pendingUnlink": "Stopping SSH access didn’t finish.",
"retry": "Retry",
"cancelLink": "Cancel"
"cancelLink": "Cancel",
"linkedRoute": "Reached through {{host}}, server port {{port}}.",
"connect": "Connect",
"chooseHost": "Choose a host"
},
"search": {
"title": "Orca on your SSH hosts",
@@ -12821,7 +12884,8 @@
"unverifiable": "Not done: Orca couldn’t confirm what is running on this server. Try again when the host is reachable.",
"migration": "Not done: a move onto this server is unfinished. Resume it first.",
"deferred": "Not done yet: the server isn’t ready for this. Try again later.",
"failed": "Orca couldn’t finish this on the server. Try again."
"failed": "Orca couldn’t finish this on the server. Try again.",
"defaultServer": "Not done: this server is the default host for new projects. Choose another one under New projects open on before stopping it."
},
"reassurance": "Runs on each host, reached over your own SSH connection. Your code and files stay where they are. Only Orca’s list of projects, tabs and terminals moves to the host."
},
@@ -12989,7 +13053,8 @@
"connect": "Connect",
"remove": "Remove",
"forceUpdate": "Force update",
"turnOn": "Turn on in Experimental"
"turnOn": "Turn on in Experimental",
"chip": "WSL"
},
"restartServer": {
"action": "Restart Orca’s server…",
@@ -13005,7 +13070,8 @@
"description": "{{name}} no longer accepts this computer. On {{name}}, open Settings → Remote Orca Servers → Share this host, create a link for another Orca client, then paste it here.",
"submit": "Re-pair",
"success": "Re-paired with {{name}}",
"failed": "Re-pair failed"
"failed": "Re-pair failed",
"descriptionShareComputer": "{{name}} no longer accepts this computer. On {{name}}, open Settings → Remote Orca Servers → Share this computer, create a link for another Orca, then paste it here."
},
"usageCredentialHost": {
"outdated": "Update Orca on {{host}} to save this key there.",
@@ -13039,6 +13105,137 @@
"query": "Mirror URL cannot have a query or fragment.",
"invalid": "Enter a valid mirror URL."
}
},
"SshPaneToolbar": {
"filter": "Filter hosts",
"sync": "Sync ~/.ssh/config",
"more": "More SSH host actions",
"restoreRemoved": "Restore removed hosts…",
"selected": "{{count}} selected",
"selectAll": "Select all {{count}}",
"clear": "Clear",
"removeSelected": "Remove {{count}}",
"restoreRemovedCount": "Restore removed hosts ({{count}})…",
"hostCount": "{{count}} hosts",
"selectMatching": "Select all {{count}} matching",
"testSelected": "Test",
"clearFilter": "Clear filter",
"restoreRemovedLoading": "Restore removed hosts",
"restoreRemovedNone": "No removed hosts to restore",
"hiddenDeselectedSingular": "{{count}} hidden host deselected",
"hiddenDeselectedPlural": "{{count}} hidden hosts deselected"
},
"SshRestoreRemovedHosts": {
"title": "Restore removed hosts?",
"description": "Hosts from ~/.ssh/config that you removed in Orca come back in Settings and in every host picker.",
"none": "No removed hosts from ~/.ssh/config.",
"confirm": "Restore"
},
"SshTargetList": {
"addedInOrca": "Added in Orca",
"fromConfig": "From ~/.ssh/config",
"selectGroup": "Select all in this group",
"filteredCount": "{{count}} of {{total}}",
"selectManualGroup": "Select all hosts added in Orca",
"selectConfigGroup": "Select all hosts from ~/.ssh/config"
},
"serverList": {
"addTitle": "Add server",
"installNoHosts": "No SSH hosts without an Orca server.",
"installHostHelp": "Only hosts without an Orca server.",
"addSshHost": "Add an SSH host",
"cancel": "Cancel",
"moreActions": "More actions for {{name}}",
"moreActionsTooltip": "More actions",
"hideDetails": "Hide details",
"details": "Details",
"address": "Address",
"route": "Route",
"protocol": "Protocol",
"capabilities": "Capabilities",
"copied": "Error copied",
"copyFailed": "Couldn’t copy the error.",
"copyError": "Copy error",
"updateMenu": "Update…",
"makeDefault": "Open new projects here",
"sshAccessLinked": "SSH route…",
"sshAccess": "Connect through SSH…",
"remove": "Remove…",
"connecting": "Connecting…",
"disconnecting": "Disconnecting…",
"needsUpdate": "Needs update",
"unreachable": "Unreachable",
"version": "v{{version}}",
"updateAvailable": "v{{version}} available",
"throughSsh": "Through {{host}} → port {{port}}",
"manualUpdate": "Update Orca on the server host",
"defaultChip": "Default",
"sshTunnelChip": "SSH tunnel",
"sshDialogTitle": "Connect {{name}} through SSH",
"sshDialogDescription": "Use this when the server isn’t reachable directly. Orca opens a tunnel through an SSH host.",
"defaultHostLabel": "New projects open on",
"thisComputer": "This computer",
"defaultHostHelp": "Existing projects stay where they are.",
"defaultHostWebHelp": "This browser opens new projects on the chosen server.",
"updateAll": "Update all ({{count}})",
"addServer": "Add server",
"moreWaysToAdd": "More ways to add a server",
"pasteAccessLink": "Paste access link",
"checkForUpdates": "Check for updates",
"emptyDescription": "Pair a dev box, a cloud machine, or a desktop at home. Its sessions keep running there when this computer sleeps.",
"movePaused": "Move paused",
"shareTitle": "Share this computer",
"shareDescriptionNamed": "Let a browser, phone, or another Orca work on {{name}}.",
"shareDescription": "Let a browser, phone, or another Orca work on this computer.",
"shareButton": "Share…",
"done": "Done",
"unreachableLead": "Orca couldn’t reach this server.",
"updateFailedLead": "The last update didn’t finish.",
"unreachableHint": "Check that Orca is running on the server and that this computer can reach its address.",
"tailscaleTip": "This is a Tailscale address, so both computers need to be signed in to the same tailnet.",
"loopbackTip": "A 127.0.0.1 address works only through an SSH tunnel.",
"lastSeen": "Last seen {{when}}",
"retry": "Retry",
"pasteAccessLinkHelp": "Pair with Orca already running on another machine.",
"cloudVm": "Cloud VM",
"cloudVmHelp": "Created per workspace from an environment recipe.",
"addDescriptionShort": "Paste the access link from Share this computer on the other machine.",
"installHostPlaceholder": "Choose an SSH host without Orca",
"oneActiveLink": "1 active link",
"activeLinks": "{{value0}} active links",
"shareDialogDescriptionRuns": "Anyone with the link can run commands as you on this computer until you revoke it.",
"shownAs": "Shown to others as {{name}}",
"editName": "Edit",
"close": "Close",
"noSavedServers": "No saved servers",
"hideError": "Hide error",
"showError": "Show error",
"neverConnected": "Never connected",
"defaultHostEmptyLine": "New projects open on this computer until you add a server.",
"installNamePlaceholder": "Same as the SSH host",
"setUpDone": "Orca is set up on {{name}}.",
"settingUpInBackground": "Setting up Orca on {{name}} in the background. It appears in the list when it’s ready.",
"setUpOnSshHost": "Set up Orca on an SSH host",
"setUp": "Set up",
"overYourSsh": "Over your SSH connection"
},
"sshBulkRemove": {
"removedCount": "{{count}} removed",
"skippedCount": "{{count}} skipped",
"failedCount": "{{count}} failed",
"skippedManaged": "{{label}} has an Orca server.",
"skippedWorkspaces": "{{label}} has workspaces.",
"skippedHint": "Remove skipped hosts one at a time to choose what happens to them."
},
"sshBulkTest": {
"passed": "{{count}} reachable",
"failed": "{{count}} failed",
"alreadyConnected": "{{count}} already connected"
},
"networkAddress": {
"tailscale": "Tailscale · {{address}}",
"lan": "Local network · {{address}}",
"loopback": "This computer · {{address}}"
}
},
"right": {
+218 -21
View File
@@ -6455,7 +6455,8 @@
"sshConfigPickerAddAllEmpty": "Agregar todo a Orca",
"identityFileFromConfigHint": "Dejado vacío a propósito: Orca usa todas las claves ~/.ssh/config resuelve para {{value0}}. Escriba una ruta para usar solo esa clave.",
"sshConfigPickerRetry": "Probar de nuevo",
"sshConfigPickerResolving": "Lectura…"
"sshConfigPickerResolving": "Lectura…",
"pairingHelpShareComputer": "Créalo en Ajustes → Servidores remotos de Orca → Compartir este equipo en el otro equipo."
},
"ForgetSshWorkspaceDialog": {
"reconnectFailed": "Error de reconexión",
@@ -8316,7 +8317,12 @@
"27cf8507ad": "Actualizar acceso compartido",
"f031182867": "Acceso compartido al servidor",
"df142657a5": "Aún no se ha usado",
"b18d1764ef": "Usado por última vez {{value0}}"
"b18d1764ef": "Usado por última vez {{value0}}",
"linkName": "Enlace · {{value0}}",
"activeLinks": "Enlaces activos",
"noActiveLinks": "No hay enlaces activos.",
"createdAt": "Creado {{value0}}",
"revoke": "Revocar"
},
"RuntimeEnvironmentsPane": {
"aeb26635d2": "Quitar {{value0}}",
@@ -8422,7 +8428,10 @@
"repair": "Re-pair…",
"serverAccessRevoked": "Access revoked",
"orcaVersionLastSeen": "Orca v{{value0}} (last seen)",
"pairedTwice": "Mismo servidor que {{value0}}, emparejado dos veces. Quita uno para poder editar sus proyectos."
"pairedTwice": "Mismo servidor que {{value0}}, emparejado dos veces. Quita uno para poder editar sus proyectos.",
"removeDefaultServerDescription": "Este servidor es el host predeterminado para proyectos nuevos. Elige otro en «Los proyectos nuevos se abren en» antes de quitarlo. Las sesiones existentes no se modifican.",
"connectedNewProjectsHint": "Conectado a {{value0}}. Para crear proyectos nuevos allí de forma predeterminada, elígelo en «Los proyectos nuevos se abren en».",
"removeDefaultServerBlocked": "Este servidor es el host predeterminado para proyectos nuevos. Elige otro en «Los proyectos nuevos se abren en» antes de quitarlo."
},
"RuntimePairingGeneratedUrlRows": {
"0495f68959": "Copiar {{value0}}"
@@ -8475,7 +8484,15 @@
"localLinkHelp": "Este enlace solo funciona en un navegador o cliente Orca que se ejecute en este equipo.",
"noExternalAddress": "No se encontró una dirección para otro dispositivo. Conecta este equipo a una LAN o a Tailscale, actualiza o elige Dirección personalizada.",
"staleAddress": "La dirección de conexión cambió. Genera un enlace nuevo para {{address}}.",
"customInvalid": "Introduce un host, host:puerto, dirección IPv6 o URL ws(s):// válidos."
"customInvalid": "Introduce un host, host:puerto, dirección IPv6 o URL ws(s):// válidos.",
"thisComputer": "Este equipo",
"whoOpens": "Quién abrirá el enlace",
"addressLabel": "Dirección",
"createLink": "Crear enlace",
"browserLinkLabel": "Enlace para navegador",
"accessLinkLabel": "Enlace de acceso",
"accessLinkHelp": "Pégalo en «Añadir servidor» en el otro Orca.",
"staleAddressCreate": "La dirección de conexión cambió. Crea un enlace nuevo para {{address}}."
},
"Settings": {
"3bf149e873": "Configuración del proyecto > {{value0}}",
@@ -8553,7 +8570,8 @@
"devDescription": "Herramientas solo de desarrollo para probar estados de la UI.",
"linearTitle": "Linear",
"linearDescription": "Cómo funciona Linear en Orca, lista de configuración, habilidad del agente y ejemplos de prompts.",
"tasksDescription": "Conecta proveedores, instala la habilidad de Linear y elige qué aparece en Tareas."
"tasksDescription": "Conecta proveedores, instala la habilidad de Linear y elige qué aparece en Tareas.",
"remoteServersDescription": "Ejecuta Orca en otra máquina y trabaja en ella desde aquí."
},
"SettingsFormControls": {
"42a4d15a30": "No hay fuentes coincidentes.",
@@ -8677,7 +8695,8 @@
"2ef2b2674b": "Eliminar {{value0}}"
},
"SshDestructiveActionDialog": {
"895b216267": "Cancelar"
"895b216267": "Cancelar",
"moreTargets": "{{count}} más"
},
"SshPane": {
"c0f1c80166": "No hay destinos SSH configurados.",
@@ -8700,7 +8719,10 @@
"f1fc50dad2": "No se pudieron cargar destinos SSH",
"0cda732f43": "La prueba de conexión falló",
"syncedServerSingular": "Sincronización completada: {{count}} host",
"syncedServerPlural": "Sincronización completada: {{count}} hosts"
"syncedServerPlural": "Sincronización completada: {{count}} hosts",
"noFilterMatch": "Ningún host coincide.",
"addHost": "Añadir host",
"alreadyInSync": "~/.ssh/config ya está sincronizado"
},
"SshPassphraseDialog": {
"d5a234456f": "Cancelar",
@@ -8731,11 +8753,26 @@
"3d8af2949f": "Editar destino",
"18968ede9e": "Error",
"f0871e6bfb": "conectar",
"47e94bd6ba": "conectado"
"47e94bd6ba": "conectado",
"removing": "Eliminando…",
"testing": "Probando…",
"select": "Seleccionar {{label}}",
"orcaServer": "Servidor de Orca",
"moreActions": "Más acciones para {{label}}",
"testConnection": "Probar conexión",
"edit": "Editar…",
"remove": "Eliminar…"
},
"SshTargetDestructiveActions": {
"3bb0cf0ee4": "Esto eliminará el destino y finalizará todos los terminales remotos activos.",
"4808966c41": "Eliminar destino SSH"
"4808966c41": "Eliminar destino SSH",
"bulkTitleSingular": "¿Eliminar {{count}} host SSH?",
"bulkTitlePlural": "¿Eliminar {{count}} hosts SSH?",
"bulkDescription": "Los hosts eliminados desaparecen de los selectores de hosts de espacios de trabajo, automatizaciones y la barra lateral. Tu ~/.ssh/config no cambia y las siguientes sincronizaciones no los volverán a añadir.",
"bulkAction": "Eliminar {{count}}",
"bulkProgress": "Eliminando {{done}} de {{count}}…",
"bulkConnectedSingular": "{{count}} host está conectado. Sus terminales remotos se cerrarán.",
"bulkConnectedPlural": "{{count}} hosts están conectados. Sus terminales remotos se cerrarán."
},
"SshTargetForm": {
"fea9cb402e": "Cancelar",
@@ -8771,7 +8808,8 @@
"addDescription": "Agrega una máquina persistente en la que puedas iniciar sesión por SSH.",
"editingPrefix": "Editando",
"allowRemoteCliControl": "Permitir que la orca CLI de este host controle Orca",
"allowRemoteCliControlHelp": "Activado de forma predeterminada en los hosts nuevos. Permite que los comandos orca ejecutados en este host abran y controlen pestañas del navegador de Orca en este escritorio, solo en los espacios de trabajo de este host y solo mientras esté conectado, y que las terminales iniciadas por una versión anterior de Orca en este host ejecuten comandos de orquestación (check, inbox, send, ask, reply) para su propio trabajo supervisado. No pueden leer tus archivos, tu portapapeles ni tus cookies. Cuando está desactivado, solo llegan al propio servidor de Orca de este host."
"allowRemoteCliControlHelp": "Activado de forma predeterminada en los hosts nuevos. Permite que los comandos orca ejecutados en este host abran y controlen pestañas del navegador de Orca en este escritorio, solo en los espacios de trabajo de este host y solo mientras esté conectado, y que las terminales iniciadas por una versión anterior de Orca en este host ejecuten comandos de orquestación (check, inbox, send, ask, reply) para su propio trabajo supervisado. No pueden leer tus archivos, tu portapapeles ni tus cookies. Cuando está desactivado, solo llegan al propio servidor de Orca de este host.",
"addHost": "Añadir host"
},
"TasksPane": {
"f71d8a9dd3": "Proveedores de tareas",
@@ -10634,7 +10672,11 @@
"4575341c77": "Añade un servidor remoto de Orca guardado, genera una URL de emparejamiento o elige el host predeterminado para proyectos nuevos.",
"defaultHostKeyword": "host predeterminado",
"newProjectsKeyword": "proyectos nuevos",
"activeServerKeyword": "servidor activo"
"activeServerKeyword": "servidor activo",
"addShareDefault": "Añade un servidor remoto de Orca, comparte este equipo o elige dónde se abren los proyectos nuevos.",
"shareComputerKeyword": "compartir este equipo",
"accessLinkKeyword": "enlace de acceso",
"installKeyword": "instalar servidor"
}
}
},
@@ -11289,7 +11331,9 @@
"localMac": "Local Mac",
"localCredentials": "Las credenciales y comprobaciones de cuenta de este proveedor pertenecen a este cliente de escritorio. Para usar un servidor, cambia Ajustes > Servidores remotos de Orca > Avanzado > Host predeterminado para proyectos nuevos.",
"remoteServerRateLimit": "El presupuesto de API de {{value0}} se obtiene de la CLI en este servidor remoto. Elige otro host en el menú Host de la parte superior de Ajustes para ver su presupuesto.",
"localRateLimit": "El presupuesto de API de {{value0}} se obtiene de la CLI en este cliente de escritorio. Elige un servidor en el menú Host de la parte superior de Ajustes para ver su presupuesto."
"localRateLimit": "El presupuesto de API de {{value0}} se obtiene de la CLI en este cliente de escritorio. Elige un servidor en el menú Host de la parte superior de Ajustes para ver su presupuesto.",
"remoteServerCredentialsNewProjects": "Las credenciales y comprobaciones de cuenta de este proveedor pertenecen a este servidor remoto. Para usar otro host, cambia Ajustes > Servidores remotos de Orca > Los proyectos nuevos se abren en.",
"localCredentialsNewProjects": "Las credenciales y comprobaciones de cuenta de este proveedor pertenecen a este cliente de escritorio. Para usar un servidor, cambia Ajustes > Servidores remotos de Orca > Los proyectos nuevos se abren en."
},
"settingOwnership": {
"clientDefault": "Predeterminado del cliente",
@@ -11509,7 +11553,8 @@
"stoppingCleanup": "Parada…",
"stopCleanup": "detener la limpieza",
"cleanupStopped": "Limpieza detenida",
"stopCleanupFailed": "No se pudo detener la limpieza de Cloud VM."
"stopCleanupFailed": "No se pudo detener la limpieza de Cloud VM.",
"cloudVmRefreshLabel": "Actualizar Cloud VMs"
},
"ephemeralVms": {
"search": {
@@ -12114,14 +12159,24 @@
"saveFailed": "No se pudo guardar el host",
"saveFailedHelp": "El anfitrión fue verificado, pero Orca no pudo salvarlo. Verifique el nombre y el almacenamiento de la configuración local y vuelva a intentarlo.",
"differentServer": "This link is for a different server",
"differentServerHelp": "This pairing link is for a different server. Create the link on the server you are re-pairing."
"differentServerHelp": "This pairing link is for a different server. Create the link on the server you are re-pairing.",
"loopbackRecoveryTunnel": "En el otro equipo, abre «Compartir este equipo» y crea un enlace para «Otro dispositivo» con su dirección de Tailscale o LAN. Si accedes mediante un túnel SSH, activa la opción de abajo.",
"sshTunnelReach": "Accedo a esta dirección mediante un túnel SSH",
"addServerWithTunnel": "Añadir servidor con túnel",
"addServer": "Añadir servidor",
"nameLabel": "Nombre",
"optional": "(opcional)",
"headlessHelpPrefix": "¿Ejecutas Orca sin ventana? En ese equipo, ejecuta"
},
"CloudVmSetupGuide": {
"title": "Crear una VM en la nube",
"description": "Las VMs en la nube se crean a partir de recetas de entorno al crear un espacio de trabajo.",
"setupRecipe": "Configura una receta de entorno para tu proveedor de nube.",
"createWorkspace": "Crea un espacio de trabajo y selecciona esa receta en Ejecutar en.",
"openSetup": "Configurar recetas de entorno"
"openSetup": "Configurar recetas de entorno",
"cardTitle": "Cloud VMs",
"cardDescription": "Se crean por espacio de trabajo a partir de una receta de entorno.",
"setUpRecipes": "Configurar recetas"
},
"GlobalWorktreeVisibilitySourcesSetting": {
"saveFailed": "No se pudieron guardar los valores predeterminados de visibilidad.",
@@ -12483,7 +12538,9 @@
},
"sshManagedHostRemove": {
"description": "Orca se ejecuta en {{label}}. En Orca en tus hosts SSH, usa Detener… para quitar Orca del host, u Olvidar… si el host ya no existe. Después podrás eliminar este host SSH.",
"open": "Abrir Orca en tus hosts SSH"
"open": "Abrir Orca en tus hosts SSH",
"descriptionServers": "Orca se ejecuta en {{label}}. En Servidores remotos de Orca, abre el menú ⋯ de Orca en {{label}} y elige «Detener…» para quitar Orca del host, o «Olvidar…» si el host ya no existe. Después podrás quitar este host SSH.",
"openServers": "Abrir Servidores remotos de Orca"
},
"managedServers": {
"row": {
@@ -12506,7 +12563,10 @@
"confirmForget": "Olvidar en este escritorio",
"keepLinked": "Mantener vinculado",
"forgetDescription": "Desvincula este servidor aquí sin contactar con su host. Si el host vuelve, es posible que el servidor siga ejecutándose allí.",
"title": "Orca en {{host}}"
"title": "Orca en {{host}}",
"orcaVersion": "Orca en este host: {{version}}",
"stopOrcaDescription": "Detiene Orca en el host SSH y lo quita de esta lista.",
"updateOrcaOnHost": "Actualizar Orca en este host"
},
"title": "Orca en tus hosts SSH",
"description": "Orca se instala en un host SSH vacío y se mantiene actualizado.",
@@ -12531,7 +12591,10 @@
"pendingLink": "La conexión por SSH no terminó.",
"pendingUnlink": "La desactivación del acceso SSH no terminó.",
"retry": "Reintentar",
"cancelLink": "Cancelar"
"cancelLink": "Cancelar",
"linkedRoute": "Se accede mediante {{host}}, puerto del servidor {{port}}.",
"connect": "Conectar",
"chooseHost": "Elige un host"
},
"search": {
"title": "Orca en tus hosts SSH",
@@ -12584,7 +12647,8 @@
"unverifiable": "No se hizo: Orca no pudo confirmar qué se ejecuta en este servidor. Inténtalo de nuevo cuando el host esté disponible.",
"migration": "No se hizo: un movimiento a este servidor quedó sin terminar. Reanúdalo primero.",
"deferred": "Aún no se hizo: el servidor no está listo para esto. Inténtalo más tarde.",
"failed": "Orca no pudo completar esto en el servidor. Inténtalo de nuevo."
"failed": "Orca no pudo completar esto en el servidor. Inténtalo de nuevo.",
"defaultServer": "No se hizo: este servidor es el host predeterminado para proyectos nuevos. Elige otro en «Los proyectos nuevos se abren en» antes de detenerlo."
},
"reassurance": "Se ejecuta en cada host y se accede a través de tu propia conexión SSH. Tu código y tus archivos se quedan donde están. Solo la lista de proyectos, pestañas y terminales de Orca pasa al host."
},
@@ -12699,14 +12763,16 @@
"connect": "Conectar",
"remove": "Quitar",
"turnOn": "Activar en Experimental",
"forceUpdate": "Forzar actualización"
"forceUpdate": "Forzar actualización",
"chip": "WSL"
},
"RuntimeRepairDialog": {
"title": "Re-pair {{name}}",
"description": "{{name}} no longer accepts this computer. On {{name}}, open Settings → Remote Orca Servers → Share this host, create a link for another Orca client, then paste it here.",
"submit": "Re-pair",
"success": "Re-paired with {{name}}",
"failed": "Re-pair failed"
"failed": "Re-pair failed",
"descriptionShareComputer": "{{name}} ya no acepta este equipo. En {{name}}, abre Ajustes → Servidores remotos de Orca → Compartir este equipo, crea un enlace para otro Orca y pégalo aquí."
},
"usageCredentialHost": {
"outdated": "Actualiza Orca en {{host}} para guardar esta clave allí.",
@@ -12749,6 +12815,137 @@
"terminalsRunning": "{{count}} terminales siguen en ejecución en este host. Ciérralos y luego reinicia el servidor de Orca.",
"cancel": "Cancelar",
"confirm": "Reiniciar"
},
"SshPaneToolbar": {
"filter": "Filtrar hosts",
"sync": "Sincronizar ~/.ssh/config",
"more": "Más acciones de hosts SSH",
"restoreRemoved": "Restaurar hosts eliminados…",
"selected": "{{count}} seleccionados",
"selectAll": "Seleccionar los {{count}}",
"clear": "Borrar selección",
"removeSelected": "Eliminar {{count}}",
"restoreRemovedCount": "Restaurar hosts eliminados ({{count}})…",
"hostCount": "{{count}} hosts",
"selectMatching": "Seleccionar los {{count}} que coinciden",
"testSelected": "Probar",
"clearFilter": "Borrar filtro",
"restoreRemovedLoading": "Restaurar hosts eliminados",
"restoreRemovedNone": "No hay hosts eliminados que restaurar",
"hiddenDeselectedSingular": "{{count}} host oculto deseleccionado",
"hiddenDeselectedPlural": "{{count}} hosts ocultos deseleccionados"
},
"SshRestoreRemovedHosts": {
"title": "¿Restaurar los hosts eliminados?",
"description": "Los hosts de ~/.ssh/config que eliminaste en Orca vuelven a Ajustes y a todos los selectores de hosts.",
"none": "No hay hosts eliminados de ~/.ssh/config.",
"confirm": "Restaurar"
},
"SshTargetList": {
"addedInOrca": "Añadidos en Orca",
"fromConfig": "De ~/.ssh/config",
"selectGroup": "Seleccionar todo este grupo",
"filteredCount": "{{count}} de {{total}}",
"selectManualGroup": "Seleccionar todos los hosts añadidos en Orca",
"selectConfigGroup": "Seleccionar todos los hosts de ~/.ssh/config"
},
"sshBulkRemove": {
"removedCount": "{{count}} eliminados",
"skippedCount": "{{count}} omitidos",
"failedCount": "{{count}} con error",
"skippedManaged": "{{label}} tiene un servidor de Orca.",
"skippedWorkspaces": "{{label}} tiene espacios de trabajo.",
"skippedHint": "Elimina los hosts omitidos de uno en uno para elegir qué pasa con ellos."
},
"sshBulkTest": {
"passed": "{{count}} accesibles",
"failed": "{{count}} con error",
"alreadyConnected": "{{count}} ya conectados"
},
"serverList": {
"addTitle": "Añadir servidor",
"installNoHosts": "No hay hosts SSH sin un servidor Orca.",
"installHostHelp": "Solo hosts sin un servidor Orca.",
"addSshHost": "Añadir un host SSH",
"cancel": "Cancelar",
"moreActions": "Más acciones para {{name}}",
"moreActionsTooltip": "Más acciones",
"hideDetails": "Ocultar detalles",
"details": "Detalles",
"address": "Dirección",
"route": "Ruta",
"protocol": "Protocolo",
"capabilities": "Funciones",
"copied": "Error copiado",
"copyFailed": "No se pudo copiar el error.",
"copyError": "Copiar error",
"updateMenu": "Actualizar…",
"makeDefault": "Abrir proyectos nuevos aquí",
"sshAccessLinked": "Ruta SSH…",
"sshAccess": "Conectar mediante SSH…",
"remove": "Quitar…",
"connecting": "Conectando…",
"disconnecting": "Desconectando…",
"needsUpdate": "Necesita actualización",
"unreachable": "Inaccesible",
"version": "v{{version}}",
"updateAvailable": "v{{version}} disponible",
"throughSsh": "Mediante {{host}} → puerto {{port}}",
"manualUpdate": "Actualiza Orca en el host del servidor",
"defaultChip": "Predeterminado",
"sshTunnelChip": "Túnel SSH",
"sshDialogTitle": "Conectar {{name}} mediante SSH",
"sshDialogDescription": "Úsalo cuando no se pueda acceder al servidor directamente. Orca abre un túnel a través de un host SSH.",
"defaultHostLabel": "Los proyectos nuevos se abren en",
"thisComputer": "Este equipo",
"defaultHostHelp": "Los proyectos existentes se quedan donde están.",
"defaultHostWebHelp": "Este navegador abre los proyectos nuevos en el servidor elegido.",
"updateAll": "Actualizar todo ({{count}})",
"addServer": "Añadir servidor",
"moreWaysToAdd": "Más formas de añadir un servidor",
"pasteAccessLink": "Pegar enlace de acceso",
"checkForUpdates": "Buscar actualizaciones",
"emptyDescription": "Empareja un equipo de desarrollo, una máquina en la nube o un escritorio en casa. Sus sesiones siguen ejecutándose allí cuando este equipo entra en reposo.",
"movePaused": "Traslado en pausa",
"shareTitle": "Compartir este equipo",
"shareDescriptionNamed": "Permite que un navegador, un teléfono u otro Orca trabajen en {{name}}.",
"shareDescription": "Permite que un navegador, un teléfono u otro Orca trabajen en este equipo.",
"shareButton": "Compartir…",
"done": "Listo",
"unreachableLead": "Orca no pudo acceder a este servidor.",
"updateFailedLead": "La última actualización no terminó.",
"unreachableHint": "Comprueba que Orca se esté ejecutando en el servidor y que este equipo pueda acceder a su dirección.",
"tailscaleTip": "Es una dirección de Tailscale, así que ambos equipos deben haber iniciado sesión en la misma tailnet.",
"loopbackTip": "Una dirección 127.0.0.1 solo funciona a través de un túnel SSH.",
"lastSeen": "Visto por última vez {{when}}",
"retry": "Reintentar",
"pasteAccessLinkHelp": "Empareja con Orca ya en ejecución en otra máquina.",
"cloudVm": "Cloud VM",
"cloudVmHelp": "Se crean por espacio de trabajo a partir de una receta de entorno.",
"addDescriptionShort": "Pega el enlace de acceso de «Compartir este equipo» en la otra máquina.",
"installHostPlaceholder": "Elige un host SSH sin Orca",
"oneActiveLink": "1 enlace activo",
"activeLinks": "{{value0}} enlaces activos",
"shareDialogDescriptionRuns": "Cualquiera con el enlace puede ejecutar comandos como tú en este equipo hasta que lo revoques.",
"shownAs": "Se muestra a otros como {{name}}",
"editName": "Editar",
"close": "Cerrar",
"neverConnected": "Nunca conectado",
"noSavedServers": "No hay servidores guardados",
"hideError": "Ocultar error",
"showError": "Mostrar error",
"installNamePlaceholder": "Igual que el host SSH",
"defaultHostEmptyLine": "Los proyectos nuevos se abren en este equipo hasta que añadas un servidor.",
"setUpDone": "Orca está configurado en {{name}}.",
"settingUpInBackground": "Configurando Orca en {{name}} en segundo plano. Aparecerá en la lista cuando esté listo.",
"setUpOnSshHost": "Configurar Orca en un host SSH",
"setUp": "Configurar",
"overYourSsh": "Mediante tu conexión SSH"
},
"networkAddress": {
"tailscale": "Tailscale · {{address}}",
"lan": "Red local · {{address}}",
"loopback": "Este equipo · {{address}}"
}
},
"right": {

Some files were not shown because too many files have changed in this diff Show More