From 49a2203b0eb77ded8f30694c373ff1dd7aeec6b9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:22:39 -0700 Subject: [PATCH] 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 ' 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 --- src/cli/handlers/managed-server.test.ts | 2 +- ...ntime-environment-connectivity-handlers.ts | 2 +- .../runtime-environment-host-dispatch.test.ts | 2 +- .../runtime-environments-pairing.test.ts | 2 +- src/main/ipc/wsl-host-handlers.ts | 2 +- .../lifecycle/orcad-runtime-decommission.ts | 4 +- .../ssh/connection/ssh-connection-store.ts | 11 +- .../settings/CloudVmSetupGuide.test.tsx | 4 +- .../components/settings/CloudVmSetupGuide.tsx | 43 +- .../EphemeralVmRuntimesSection.test.tsx | 20 +- .../settings/EphemeralVmRuntimesSection.tsx | 144 ++--- .../components/settings/ManagedServerRow.tsx | 357 ------------ .../settings/ManagedServersSection.tsx | 248 -------- .../settings/RuntimeAccessGrantList.tsx | 107 ++-- .../settings/RuntimeEnvironmentsPane.test.ts | 8 +- .../settings/RuntimeEnvironmentsPane.tsx | 203 ++++--- .../settings/RuntimeHostAccessForm.test.tsx | 2 +- .../settings/RuntimeHostAccessForm.tsx | 268 ++++----- .../settings/RuntimePairingGeneratorForm.tsx | 181 +++--- .../settings/RuntimePairingUrlGenerator.tsx | 1 - .../settings/RuntimeRepairDialog.tsx | 4 +- .../settings/RuntimeSshAccessControl.tsx | 127 ++-- .../settings/SshDestructiveActionDialog.tsx | 49 +- .../settings/SshHostChangedActions.tsx | 103 ---- .../SshHostKeepServerVersionDialog.tsx | 88 +++ .../settings/SshHostMoveAnywayAction.test.tsx | 36 +- .../settings/SshHostMoveAnywayAction.tsx | 107 ++-- .../SshHostRestartServerAction.test.tsx | 33 +- .../settings/SshHostRestartServerAction.tsx | 80 +-- .../settings/SshManagedHostRemoveDialog.tsx | 10 +- .../src/components/settings/SshPane.test.tsx | 252 +++++++- .../src/components/settings/SshPane.tsx | 251 ++++---- .../components/settings/SshPaneToolbar.tsx | 275 +++++++++ .../settings/SshRestoreRemovedHostsDialog.tsx | 125 ++++ .../settings/SshTargetCard.test.tsx | 41 +- .../src/components/settings/SshTargetCard.tsx | 306 ++++++---- .../SshTargetDestructiveActions.test.tsx | 128 ++++ .../settings/SshTargetDestructiveActions.tsx | 113 ++++ .../settings/SshTargetForm.test.tsx | 6 +- .../src/components/settings/SshTargetForm.tsx | 2 +- .../settings/SshTargetList.test.tsx | 41 ++ .../src/components/settings/SshTargetList.tsx | 155 +++++ .../settings/SshTargetServerStatus.tsx | 188 ++++-- .../components/settings/WslHostsSection.tsx | 169 ++++-- ...-source-control-integration-cards.test.tsx | 4 +- .../settings/managed-server-copy.test.ts | 2 +- .../settings/managed-server-copy.ts | 4 +- ....tsx => managed-server-row-parts.test.tsx} | 53 +- .../settings/managed-server-row-parts.tsx | 323 +++++++++++ .../settings/network-address-label.ts | 35 ++ .../settings/provider-account-scope.test.ts | 4 +- .../settings/provider-account-scope.ts | 8 +- .../runtime-active-server-section.tsx | 211 ------- .../settings/runtime-add-server-dialog.tsx | 66 +++ .../runtime-default-host-row.test.tsx | 51 ++ .../settings/runtime-default-host-row.tsx | 102 ++++ .../settings/runtime-environment-dialogs.tsx | 4 +- .../runtime-environment-host-details.ts | 22 +- .../settings/runtime-environments-search.ts | 18 +- .../runtime-install-server-dialog.tsx | 236 ++++++++ .../runtime-server-default-name.test.ts | 38 ++ .../settings/runtime-server-default-name.ts | 34 ++ .../settings/runtime-server-list-row.tsx | 171 ++++++ .../settings/runtime-server-list.tsx | 197 +++++++ .../settings/runtime-server-row-details.tsx | 167 ++++++ .../settings/runtime-server-row-menu.tsx | 118 ++++ .../settings/runtime-server-row-meta.test.ts | 67 +++ .../settings/runtime-server-row-meta.ts | 116 ++++ .../runtime-server-row-presentation.test.ts | 134 +++-- .../runtime-server-row-presentation.ts | 173 ++++-- .../settings/runtime-server-row-state.ts | 130 +++++ ...time-server-row-unverifiable-host.test.tsx | 95 ++- .../settings/runtime-server-row.tsx | 547 ++++++++++-------- .../runtime-server-workflow-sections.test.tsx | 34 -- .../runtime-server-workflow-sections.tsx | 226 -------- .../runtime-servers-connect-section.tsx | 190 ------ .../runtime-servers-header-actions.tsx | 152 +++++ .../settings/runtime-servers-page-context.tsx | 40 ++ .../runtime-share-computer-card.test.tsx | 32 + .../settings/runtime-share-computer-card.tsx | 140 +++++ .../settings/runtime-ssh-access-dialog.tsx | 59 ++ ...ings-remote-security-section-renderers.tsx | 93 +-- .../settings/ssh-bulk-remove-summary.ts | 73 +++ .../settings/ssh-hosts-header-slot.tsx | 45 ++ .../settings/ssh-target-list-model.test.ts | 179 ++++++ .../settings/ssh-target-list-model.ts | 127 ++++ .../task-tracker-integration-cards.test.tsx | 4 +- .../settings/use-managed-server-catalog.ts | 93 +++ .../settings/use-managed-server-lifecycle.ts | 143 +++++ .../use-runtime-environment-catalog.ts | 29 +- ...se-runtime-environment-mutation-actions.ts | 20 +- .../settings/use-ssh-bulk-remove.ts | 103 ++++ .../settings/use-ssh-config-sync.ts | 64 ++ .../settings/use-ssh-target-selection.ts | 89 +++ .../settings/use-ssh-target-tests.ts | 150 +++++ .../sidebar/AddRemoteHostFields.tsx | 4 +- .../settings-navigation-remote-sections.ts | 2 +- .../src/i18n/en-runtime-required.json | 142 ++++- src/renderer/src/i18n/locales/en.json | 239 +++++++- src/renderer/src/i18n/locales/es.json | 239 +++++++- src/renderer/src/i18n/locales/fr.json | 239 +++++++- src/renderer/src/i18n/locales/ja.json | 239 +++++++- src/renderer/src/i18n/locales/ko.json | 239 +++++++- src/renderer/src/i18n/locales/zh.json | 239 +++++++- src/shared/ssh-target-source.test.ts | 25 + src/shared/ssh-target-source.ts | 24 + tests/e2e/ephemeral-vm-cleanup-retry.spec.ts | 10 +- tests/e2e/helpers/ssh-config-host-picker.ts | 4 +- tests/e2e/ssh-config-host-import.spec.ts | 19 +- tests/e2e/ssh-host-form-modal.spec.ts | 16 +- tests/e2e/wsl-host-fake-transport.spec.ts | 28 +- 111 files changed, 8136 insertions(+), 3087 deletions(-) delete mode 100644 src/renderer/src/components/settings/ManagedServerRow.tsx delete mode 100644 src/renderer/src/components/settings/ManagedServersSection.tsx delete mode 100644 src/renderer/src/components/settings/SshHostChangedActions.tsx create mode 100644 src/renderer/src/components/settings/SshHostKeepServerVersionDialog.tsx create mode 100644 src/renderer/src/components/settings/SshPaneToolbar.tsx create mode 100644 src/renderer/src/components/settings/SshRestoreRemovedHostsDialog.tsx create mode 100644 src/renderer/src/components/settings/SshTargetDestructiveActions.test.tsx create mode 100644 src/renderer/src/components/settings/SshTargetList.test.tsx create mode 100644 src/renderer/src/components/settings/SshTargetList.tsx rename src/renderer/src/components/settings/{ManagedServerRow.test.tsx => managed-server-row-parts.test.tsx} (79%) create mode 100644 src/renderer/src/components/settings/managed-server-row-parts.tsx create mode 100644 src/renderer/src/components/settings/network-address-label.ts delete mode 100644 src/renderer/src/components/settings/runtime-active-server-section.tsx create mode 100644 src/renderer/src/components/settings/runtime-add-server-dialog.tsx create mode 100644 src/renderer/src/components/settings/runtime-default-host-row.test.tsx create mode 100644 src/renderer/src/components/settings/runtime-default-host-row.tsx create mode 100644 src/renderer/src/components/settings/runtime-install-server-dialog.tsx create mode 100644 src/renderer/src/components/settings/runtime-server-default-name.test.ts create mode 100644 src/renderer/src/components/settings/runtime-server-default-name.ts create mode 100644 src/renderer/src/components/settings/runtime-server-list-row.tsx create mode 100644 src/renderer/src/components/settings/runtime-server-list.tsx create mode 100644 src/renderer/src/components/settings/runtime-server-row-details.tsx create mode 100644 src/renderer/src/components/settings/runtime-server-row-menu.tsx create mode 100644 src/renderer/src/components/settings/runtime-server-row-meta.test.ts create mode 100644 src/renderer/src/components/settings/runtime-server-row-meta.ts create mode 100644 src/renderer/src/components/settings/runtime-server-row-state.ts delete mode 100644 src/renderer/src/components/settings/runtime-server-workflow-sections.test.tsx delete mode 100644 src/renderer/src/components/settings/runtime-server-workflow-sections.tsx delete mode 100644 src/renderer/src/components/settings/runtime-servers-connect-section.tsx create mode 100644 src/renderer/src/components/settings/runtime-servers-header-actions.tsx create mode 100644 src/renderer/src/components/settings/runtime-servers-page-context.tsx create mode 100644 src/renderer/src/components/settings/runtime-share-computer-card.test.tsx create mode 100644 src/renderer/src/components/settings/runtime-share-computer-card.tsx create mode 100644 src/renderer/src/components/settings/runtime-ssh-access-dialog.tsx create mode 100644 src/renderer/src/components/settings/ssh-bulk-remove-summary.ts create mode 100644 src/renderer/src/components/settings/ssh-hosts-header-slot.tsx create mode 100644 src/renderer/src/components/settings/ssh-target-list-model.test.ts create mode 100644 src/renderer/src/components/settings/ssh-target-list-model.ts create mode 100644 src/renderer/src/components/settings/use-managed-server-catalog.ts create mode 100644 src/renderer/src/components/settings/use-managed-server-lifecycle.ts create mode 100644 src/renderer/src/components/settings/use-ssh-bulk-remove.ts create mode 100644 src/renderer/src/components/settings/use-ssh-config-sync.ts create mode 100644 src/renderer/src/components/settings/use-ssh-target-selection.ts create mode 100644 src/renderer/src/components/settings/use-ssh-target-tests.ts create mode 100644 src/shared/ssh-target-source.test.ts create mode 100644 src/shared/ssh-target-source.ts diff --git a/src/cli/handlers/managed-server.test.ts b/src/cli/handlers/managed-server.test.ts index 57d9b35c8cb..a0b33fc1c11 100644 --- a/src/cli/handlers/managed-server.test.ts +++ b/src/cli/handlers/managed-server.test.ts @@ -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', diff --git a/src/main/ipc/runtime-environments/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environments/runtime-environment-connectivity-handlers.ts index 7d01e11eb1b..7030eb4e7df 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-connectivity-handlers.ts @@ -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. diff --git a/src/main/ipc/runtime-environments/runtime-environment-host-dispatch.test.ts b/src/main/ipc/runtime-environments/runtime-environment-host-dispatch.test.ts index ac94a6c39bd..351970868f0 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-host-dispatch.test.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-host-dispatch.test.ts @@ -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.' ) }) diff --git a/src/main/ipc/runtime-environments/runtime-environments-pairing.test.ts b/src/main/ipc/runtime-environments/runtime-environments-pairing.test.ts index f22850206db..28571e27f0f 100644 --- a/src/main/ipc/runtime-environments/runtime-environments-pairing.test.ts +++ b/src/main/ipc/runtime-environments/runtime-environments-pairing.test.ts @@ -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() diff --git a/src/main/ipc/wsl-host-handlers.ts b/src/main/ipc/wsl-host-handlers.ts index 527aa8e6ecb..7333fc723c4 100644 --- a/src/main/ipc/wsl-host-handlers.ts +++ b/src/main/ipc/wsl-host-handlers.ts @@ -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) diff --git a/src/main/managed-server/lifecycle/orcad-runtime-decommission.ts b/src/main/managed-server/lifecycle/orcad-runtime-decommission.ts index 8285d04e741..9ecd9545d97 100644 --- a/src/main/managed-server/lifecycle/orcad-runtime-decommission.ts +++ b/src/main/managed-server/lifecycle/orcad-runtime-decommission.ts @@ -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) diff --git a/src/main/ssh/connection/ssh-connection-store.ts b/src/main/ssh/connection/ssh-connection-store.ts index 36cba4bd26a..7219685923d 100644 --- a/src/main/ssh/connection/ssh-connection-store.ts +++ b/src/main/ssh/connection/ssh-connection-store.ts @@ -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 - ) -} diff --git a/src/renderer/src/components/settings/CloudVmSetupGuide.test.tsx b/src/renderer/src/components/settings/CloudVmSetupGuide.test.tsx index b347dda0892..909ce279863 100644 --- a/src/renderer/src/components/settings/CloudVmSetupGuide.test.tsx +++ b/src/renderer/src/components/settings/CloudVmSetupGuide.test.tsx @@ -19,8 +19,8 @@ describe('CloudVmSetupGuide', () => { act(() => root.render()) - 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) { diff --git a/src/renderer/src/components/settings/CloudVmSetupGuide.tsx b/src/renderer/src/components/settings/CloudVmSetupGuide.tsx index 9ed1a794657..7dc3994564c 100644 --- a/src/renderer/src/components/settings/CloudVmSetupGuide.tsx +++ b/src/renderer/src/components/settings/CloudVmSetupGuide.tsx @@ -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 ( -
-
+
+ +
- {translate('auto.components.settings.CloudVmSetupGuide.title', 'Create a Cloud VM')} + {translate('auto.components.settings.CloudVmSetupGuide.cardTitle', 'Cloud VMs')}

{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.' )}

-
    -
  1. - {translate( - 'auto.components.settings.CloudVmSetupGuide.setupRecipe', - 'Set up an environment recipe for your cloud provider.' - )} -
  2. -
  3. - {translate( - 'auto.components.settings.CloudVmSetupGuide.createWorkspace', - 'Create a workspace and select that recipe under Run on.' - )} -
  4. -
+ {refreshAction} -
+ ) } diff --git a/src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx b/src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx index 97879230416..a44dcbd0344 100644 --- a/src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx +++ b/src/renderer/src/components/settings/EphemeralVmRuntimesSection.test.tsx @@ -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 () => { diff --git a/src/renderer/src/components/settings/EphemeralVmRuntimesSection.tsx b/src/renderer/src/components/settings/EphemeralVmRuntimesSection.tsx index 4db35f6c721..06e18efcf64 100644 --- a/src/renderer/src/components/settings/EphemeralVmRuntimesSection.tsx +++ b/src/renderer/src/components/settings/EphemeralVmRuntimesSection.tsx @@ -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([]) const [isLoading, setIsLoading] = useState(true) const [cleaningId, setCleaningId] = useState(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 => { setCleaningId(runtime.id) @@ -254,77 +250,61 @@ export function EphemeralVmRuntimesSection({ const hasRuntimes = runtimes.length > 0 return ( -
-
-
-
- {translate( - 'auto.components.settings.EphemeralVmRuntimesSection.cloudVmTitle', - 'Cloud VM runtimes' - )} -
-

- {translate( - 'auto.components.settings.EphemeralVmRuntimesSection.description', - 'Recipe-created runtimes are workspace-owned. Clean up stale entries after crashes, failed creates, or manual recovery.' - )} -

-
- -
- -
- {!hasRuntimes ? ( -
- {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.' +
+ + + + + + {translate( + 'auto.components.settings.EphemeralVmRuntimesSection.cloudVmRefreshLabel', + 'Refresh Cloud VMs' )} -
- ) : ( -
- {runtimes.map((runtime) => ( - void cleanupRuntime(runtime)} - onStopCleanup={() => setPendingStop(runtime)} - onCopyCleanupCommand={() => void copyCleanupCommand(runtime)} - /> - ))} -
- )} -
+ + + ) : null + } + /> + {hasRuntimes ? ( +
+ {runtimes.map((runtime) => ( + void cleanupRuntime(runtime)} + onStopCleanup={() => setPendingStop(runtime)} + onCopyCleanupCommand={() => void copyCleanupCommand(runtime)} + /> + ))} +
+ ) : null} -
+ ) } diff --git a/src/renderer/src/components/settings/ManagedServerRow.tsx b/src/renderer/src/components/settings/ManagedServerRow.tsx deleted file mode 100644 index 35033f70620..00000000000 --- a/src/renderer/src/components/settings/ManagedServerRow.tsx +++ /dev/null @@ -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[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(null) - const [statusError, setStatusError] = useState(null) - const [busy, setBusy] = useState(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 => { - 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 ( -
-
-
- {translate('auto.components.settings.managedServers.row.title', 'Orca on {{host}}', { - host: hostLabel ?? environment.name - })} -
- - {versionLabel(status, statusError)} - - -
- {statusError ?

{statusError}

: null} - {statusError && confirmingForget ? ( -

- {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.' - )} -

- ) : null} - {status ? ( -
-

{terminalCensusLabel(status.terminals)}

- {recovery ?

{recoveryLabel(recovery)}

: null} - {status.migration ? ( -

- {translate( - 'auto.components.settings.managedServers.row.migration', - 'Migration: {{phase}}', - { - phase: migrationPhaseLabel(status.migration.phase) - } - )} -

- ) : null} - {status.deferredUpdate ? ( -

- {translate( - 'auto.components.settings.managedServers.row.deferredVersion', - 'Update to {{version}} deferred.', - { version: status.deferredUpdate.candidateVersion } - )}{' '} - {managedServerOutcomeLabel(status.deferredUpdate)} -

- ) : null} -
- ) : null} -
- - {status?.rollbackAvailable ? ( - - ) : null} - {recovery && confirmingRestore ? ( - <> - - - - ) : recovery ? ( - - ) : null} - {recovery?.operation === 'decommission' ? ( - - ) : null} - {confirmingStop ? ( - <> - - - - ) : ( - - )} - {/* Only once the host failed to answer: a reachable host is stopped, not forgotten. */} - {statusError && confirmingForget ? ( - <> - - - - ) : statusError ? ( - - ) : null} -
-
- ) -} diff --git a/src/renderer/src/components/settings/ManagedServersSection.tsx b/src/renderer/src/components/settings/ManagedServersSection.tsx deleted file mode 100644 index f77dec0bf09..00000000000 --- a/src/renderer/src/components/settings/ManagedServersSection.tsx +++ /dev/null @@ -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([]) - const [pending, setPending] = useState([]) - const [name, setName] = useState('') - const [targetId, setTargetId] = useState('') - const [deploying, setDeploying] = useState(false) - const [resumingId, setResumingId] = useState(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 => { - 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 => { - 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 ( -
-
-
- {translate('auto.components.settings.managedServers.title', 'Orca on your SSH hosts')} -
-

- {translate( - 'auto.components.settings.managedServers.description', - 'Orca installs itself on an empty SSH host and keeps itself up to date.' - )} -

-

- {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.' - )} -

- {loadFailed ? ( -

- {translate( - 'auto.components.settings.managedServers.loadFailed', - 'Orca couldn’t load SSH hosts and unfinished moves. Reopen Settings to try again.' - )} -

- ) : null} -
- -
-
- - -
-
- - setName(event.target.value)} /> -
- -
- - {pending.length > 0 ? ( -
- {pending.map((row) => ( -
-
-
{row.name}
-

{migrationPhaseLabel(row.phase)}

-
- -
- ))} -
- ) : null} - - {managed.length > 0 ? ( -
- {managed.map((environment) => ( - target.id === getOrcadSshDeployment(environment)?.sshTargetId - )?.label - } - onChanged={onChanged} - /> - ))} -
- ) : null} - - - - {paired.length > 0 ? ( -
- -
- {paired.map((environment) => ( - - ))} -
-
- ) : null} -
- ) -} diff --git a/src/renderer/src/components/settings/RuntimeAccessGrantList.tsx b/src/renderer/src/components/settings/RuntimeAccessGrantList.tsx index 25d198eee4e..01b3bf114b1 100644 --- a/src/renderer/src/components/settings/RuntimeAccessGrantList.tsx +++ b/src/renderer/src/components/settings/RuntimeAccessGrantList.tsx @@ -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 "; 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 (

- {translate( - 'auto.components.settings.RuntimeAccessGrantList.f031182867', - 'Shared Server Access' - )} + {translate('auto.components.settings.RuntimeAccessGrantList.activeLinks', 'Active links')}

- - - - - - {translate( - 'auto.components.settings.RuntimeAccessGrantList.27cf8507ad', - 'Refresh shared access' - )} - -
- {grants.length === 0 ? ( + {grants.length === 0 && isLoading ? null : grants.length === 0 ? (

{translate( - 'auto.components.settings.RuntimeAccessGrantList.fd83b94095', - 'No shared server access yet.' + 'auto.components.settings.RuntimeAccessGrantList.noActiveLinks', + 'No active links.' )}

) : ( @@ -85,7 +67,7 @@ export function RuntimeAccessGrantList({ >
- {grant.name} + {grantDisplayName(grant)} {isCurrent ? ( {translate( @@ -97,10 +79,11 @@ export function RuntimeAccessGrantList({
{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({ )}
- - - - - - {translate( - 'auto.components.settings.RuntimeAccessGrantList.68ec21309f', - 'Revoke access' - )} - - +
) })} @@ -149,10 +118,6 @@ export function RuntimeAccessGrantList({ )} {grants.length > 0 ? (

- {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." diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts index 25f4ff3a718..2460ac7ea57 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts @@ -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', () => { diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx index 622b3ec9748..453d1e15c1b 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx @@ -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(null) const [pendingRemove, setPendingRemove] = useState(null) - const [addServerFormOpen, setAddServerFormOpen] = useState(false) - const [shareServerFormOpen, setShareServerFormOpen] = useState(true) - const [advancedOpen, setAdvancedOpen] = useState(false) - const [workflow, setWorkflow] = useState('connect') const 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" > - - - 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 ? ( +

+ {translate( + 'auto.components.settings.managedServers.loadFailed', + 'Orca couldn’t load SSH hosts and unfinished moves. Reopen Settings to try again.' + )} +

+ ) : null} + + {/* Why gated on the managed API: WSL hosts, like managed servers, need the desktop. */} + {managedCatalog.api ? : null} + + + +
+ {canGeneratePairingUrl ? : null} + +
+ + { + 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)} /> -
- - -
- - setAdvancedOpen((current) => !current)} - onValueChange={(value) => { - setSwitchError(null) - setPendingSwitchValue(value) - }} - onRefresh={() => void loadEnvironments()} - /> - - {visibleWorkflow === 'share' && canGeneratePairingUrl ? ( - setShareServerFormOpen((open) => !open)} + {managedCatalog.api ? ( + setDialog(open ? 'install' : null)} + onDeployed={async () => { + void loadEnvironments() + await managedCatalog.reload() + }} /> ) : null} - {visibleWorkflow === 'connect' ? ( - void loadEnvironments()} - /> - ) : null} - - {visibleWorkflow === 'connect' ? : null} - { 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') }) diff --git a/src/renderer/src/components/settings/RuntimeHostAccessForm.tsx b/src/renderer/src/components/settings/RuntimeHostAccessForm.tsx index 5d4c358c229..fc9075f918c 100644 --- a/src/renderer/src/components/settings/RuntimeHostAccessForm.tsx +++ b/src/renderer/src/components/settings/RuntimeHostAccessForm.tsx @@ -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
' + +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 (
{ event.preventDefault() if (canSubmit) { @@ -63,90 +73,38 @@ export function RuntimeHostAccessForm({ } }} > -
-
- {translate( - 'auto.components.settings.RuntimeHostAccessForm.getLink', - 'Get an access link from the other host' +
+ + { + setAllowLoopback(false) + onAccessLinkChange(event.target.value) + }} + placeholder={translate( + 'auto.components.settings.RuntimeHostAccessForm.accessLinkPlaceholder', + 'orca://pair?code=...' )} -
-
    -
  1. - {translate( - 'auto.components.settings.RuntimeHostAccessForm.stepOpenShare', - 'Open Settings → Remote Orca Servers → Share this host.' - )} -
  2. -
  3. - {translate( - 'auto.components.settings.RuntimeHostAccessForm.stepChooseAddress', - 'Choose Another device and select a reachable address.' - )} -
  4. -
  5. - {translate( - 'auto.components.settings.RuntimeHostAccessForm.stepCopyLink', - 'Generate the link, then copy the “Pair another Orca client” link.' - )} -
  6. -
-
- -
-
- - onNameChange(event.target.value)} - placeholder={translate( - 'auto.components.settings.RuntimeHostAccessForm.namePlaceholder', - 'Linux workstation' - )} - autoFocus - /> -

- {translate( - 'auto.components.settings.RuntimeHostAccessForm.nameHelp', - 'This only changes how the computer appears in Orca.' - )} + className="min-w-0 font-mono" + autoFocus + /> +

+ {inputError ? ( + -
-
- - { - setAllowLoopback(false) - onAccessLinkChange(event.target.value) - }} - placeholder={translate( - 'auto.components.settings.RuntimeHostAccessForm.accessLinkPlaceholder', - 'orca://pair?code=...' - )} - className="min-w-0 font-mono" - /> - - {inputError ? ( - - ) : null} -
+ ) : null}
{parsed.ok ? ( @@ -189,51 +147,60 @@ export function RuntimeHostAccessForm({

{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.' )}

-
- - {translate( - 'auto.components.settings.RuntimeHostAccessForm.connectionDetails', - 'Connection details' - )} - -
-
- {translate( - 'auto.components.settings.RuntimeHostAccessForm.destination', - 'Link destination' - )} -
-
{parsed.value.displayEndpoint}
-
- {translate( - 'auto.components.settings.RuntimeHostAccessForm.endpointKind', - 'Endpoint kind' - )} -
-
- {translateRemotePairingEndpointKind(parsed.value.endpointKind)} -
-
- {translate( - 'auto.components.settings.RuntimeHostAccessForm.networkConnection', - 'Network connection' - )} -
-
- {translate( - 'auto.components.settings.RuntimeHostAccessForm.notAttempted', - 'Not attempted' - )} -
-
-
) : null} + {parsed.ok && parsed.value.endpointKind === 'loopback' ? ( + + ) : null} + +
+ + onNameChange(event.target.value)} + placeholder={ + defaultName || + translate( + 'auto.components.settings.RuntimeHostAccessForm.namePlaceholder', + 'Linux workstation' + ) + } + /> +
+ {failure ? ( ) : null} -
- - {translate('auto.components.settings.RuntimeHostAccessForm.advanced', 'Advanced')} - - - {parsed.ok && parsed.value.endpointKind === 'loopback' ? ( - - ) : ( -

- {translate( - 'auto.components.settings.RuntimeHostAccessForm.headlessHelp', - 'Using headless orca serve? Run orca serve --pairing-address on the other computer.' - )} -

- )} -
+

+ {translate( + 'auto.components.settings.RuntimeHostAccessForm.headlessHelpPrefix', + 'Running Orca without a window? On that computer, run' + )}{' '} + + {HEADLESS_SERVE_COMMAND} + +

diff --git a/src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx b/src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx index 8fed6b1f1d1..ed7b64c56c0 100644 --- a/src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx +++ b/src/renderer/src/components/settings/RuntimePairingGeneratorForm.tsx @@ -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 ( <>
-
- +
+
{translate( - 'auto.components.settings.RuntimePairingUrlGenerator.intentQuestion', - 'Where will this link be opened?' + 'auto.components.settings.RuntimePairingUrlGenerator.whoOpens', + 'Who will open the link' )} - -
- {( - [ - [ - '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]) => ( - - ))}
-
+ { + const next = intentOptions.find((option) => option.value === value) + if (next) { + onIntentChange(next.value) + } + }} + > + {intentOptions.map((option) => ( + + {option.label} + + ))} + +

+ {intentOptions.find((option) => option.value === intent)?.description} +

+
{intent === 'local' ? (
@@ -194,8 +188,8 @@ export function RuntimePairingGeneratorForm({
@@ -299,19 +293,14 @@ export function RuntimePairingGeneratorForm({ {staleGeneratedLink && selectedAddress !== '' ? (
{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 } )}
) : null} -
-

- {translate( - 'auto.components.settings.RuntimePairingUrlGenerator.pairedRunsAsYou', - 'A paired client can run commands as you on this computer.' - )} -

+ {/* Why no warning here: the Share dialog's description already says the link runs as you. */} +
@@ -331,8 +320,8 @@ export function RuntimePairingGeneratorForm({ {generatedIsCurrent && webClientUrl ? ( void loadRuntimeAccessGrants({ showToastOnError: true })} onRevoke={(grant) => void revokeRuntimeAccess(grant)} />
diff --git a/src/renderer/src/components/settings/RuntimeRepairDialog.tsx b/src/renderer/src/components/settings/RuntimeRepairDialog.tsx index 5faed2753bc..7817a231c82 100644 --- a/src/renderer/src/components/settings/RuntimeRepairDialog.tsx +++ b/src/renderer/src/components/settings/RuntimeRepairDialog.tsx @@ -133,8 +133,8 @@ function RuntimeRepairForm({ {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 } )} diff --git a/src/renderer/src/components/settings/RuntimeSshAccessControl.tsx b/src/renderer/src/components/settings/RuntimeSshAccessControl.tsx index 1b3573346d0..364d842c0f1 100644 --- a/src/renderer/src/components/settings/RuntimeSshAccessControl.tsx +++ b/src/renderer/src/components/settings/RuntimeSshAccessControl.tsx @@ -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 ( -
-
-
{environment.name}
-

- {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.' - )} -

-
- - {pending.operation === 'link' ? ( +
+

+ {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.' + )} +

+
+ {pending.operation === 'link' ? ( + + ) : null} - ) : null} +
) } @@ -117,20 +120,27 @@ export function RuntimeSshAccessControl({ } if (linked) { + const linkedTarget = targets.find((target) => target.id === linked.sshTargetId) return ( -
-
- {translate('auto.components.settings.managedServers.access.linked', '{{name}} via SSH', { - name: environment.name - })} -
+
+

+ {translate( + 'auto.components.settings.managedServers.access.linkedRoute', + 'Reached through {{host}}, server port {{port}}.', + { + host: linkedTarget?.label ?? linked.sshTargetId, + port: linked.remotePort + } + )} +

@@ -139,21 +149,31 @@ export function RuntimeSshAccessControl({ const port = Number(remotePort) const valid = targetId !== '' && Number.isInteger(port) && port >= 1 && port <= 65_535 return ( -
-
- +
{ + event.preventDefault() + if (!busy && valid) { + void submit() + } + }} + > +
+
-
+
@@ -164,12 +184,15 @@ export function RuntimeSshAccessControl({ onChange={(event) => setRemotePort(event.target.value)} />
- -
+
+ {footerStart} + +
+
) } diff --git a/src/renderer/src/components/settings/SshDestructiveActionDialog.tsx b/src/renderer/src/components/settings/SshDestructiveActionDialog.tsx index a903c7f1d8c..7f9230f1a45 100644 --- a/src/renderer/src/components/settings/SshDestructiveActionDialog.tsx +++ b/src/renderer/src/components/settings/SshDestructiveActionDialog.tsx @@ -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 ( - {title} - {description} + {title} + {description} {targetLabel ? ( -
-
{targetLabel}
+
+
{targetLabel}
) : null} + {shownLabels.length > 0 ? ( +
    + {shownLabels.map((label, index) => ( +
  • + {label} +
  • + ))} + {hiddenLabelCount > 0 ? ( +
  • + {translate( + 'auto.components.settings.SshDestructiveActionDialog.moreTargets', + '+{{count}} more', + { count: hiddenLabelCount } + )} +
  • + ) : null} +
+ ) : null} + + {notice ? ( +

+ + {notice} +

+ ) : null} + - - setMoving(false)} - onFinished={onChanged} - /> - !open && !busy && setKeeping(false)}> - - - - {translate('auto.components.settings.keepServer.title', 'Keep the server’s version')} - - - {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.' - )} - - - {error ?

{error}

: null} - - - - -
-
-
- ) -} diff --git a/src/renderer/src/components/settings/SshHostKeepServerVersionDialog.tsx b/src/renderer/src/components/settings/SshHostKeepServerVersionDialog.tsx new file mode 100644 index 00000000000..b9b11a5197e --- /dev/null +++ b/src/renderer/src/components/settings/SshHostKeepServerVersionDialog.tsx @@ -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(null) + const setKeeping = (next: boolean): void => { + setError(null) + onOpenChange(next) + } + if (!api || !target.orcadFence?.sourceChangedAt) { + return null + } + const keepServerVersion = async (): Promise => { + 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 ( + !open && !busy && setKeeping(false)}> + + + + {translate('auto.components.settings.keepServer.title', 'Keep the server’s version')} + + + {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.' + )} + + + {error ?

{error}

: null} + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/settings/SshHostMoveAnywayAction.test.tsx b/src/renderer/src/components/settings/SshHostMoveAnywayAction.test.tsx index 36709549d06..4ceb05733e1 100644 --- a/src/renderer/src/components/settings/SshHostMoveAnywayAction.test.tsx +++ b/src/renderer/src/components/settings/SshHostMoveAnywayAction.test.tsx @@ -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 ( + <> + + + + ) +} + 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() + root.render() }) 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 () => { diff --git a/src/renderer/src/components/settings/SshHostMoveAnywayAction.tsx b/src/renderer/src/components/settings/SshHostMoveAnywayAction.tsx index b23fa062db3..2987b2987ed 100644 --- a/src/renderer/src/components/settings/SshHostMoveAnywayAction.tsx +++ b/src/renderer/src/components/settings/SshHostMoveAnywayAction.tsx @@ -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(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 ( -
- - !next && !busy && setOpen(false)}> - - - - {translate('auto.components.settings.moveAnyway.title', 'Move this host anyway')} - - -
-

- {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 } - )} -

-

{sshHostServerReassurance(target.label)}

-
-
-
- {status.detail ? ( -
-              {status.detail}
-            
- ) : null} - {error ?

{error}

: null} - - - - -
-
-
+ !next && !busy && setOpen(false)}> + + + + {translate('auto.components.settings.moveAnyway.title', 'Move this host anyway')} + + +
+

+ {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 } + )} +

+

{sshHostServerReassurance(target.label)}

+
+
+
+ {status.detail ? ( +
+            {status.detail}
+          
+ ) : null} + {error ?

{error}

: null} + + + + +
+
) } diff --git a/src/renderer/src/components/settings/SshHostRestartServerAction.test.tsx b/src/renderer/src/components/settings/SshHostRestartServerAction.test.tsx index 3caed71ea6d..ceec8feabb5 100644 --- a/src/renderer/src/components/settings/SshHostRestartServerAction.test.tsx +++ b/src/renderer/src/components/settings/SshHostRestartServerAction.test.tsx @@ -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 ( + <> + + + + ) +} + 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() + root.render() }) 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 () => { diff --git a/src/renderer/src/components/settings/SshHostRestartServerAction.tsx b/src/renderer/src/components/settings/SshHostRestartServerAction.tsx index df4570d0a4a..790bc126d92 100644 --- a/src/renderer/src/components/settings/SshHostRestartServerAction.tsx +++ b/src/renderer/src/components/settings/SshHostRestartServerAction.tsx @@ -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(null) if (!api || status?.kind !== 'managed' || !status.launchSessionEnded) { return null } + const setOpen = (next: boolean): void => { + setError(null) + onOpenChange(next) + } const restart = async (): Promise => { setBusy(true) setError(null) @@ -57,43 +70,30 @@ export function SshHostRestartServerAction({ } } return ( -
- - !next && !busy && setOpen(false)}> - - - - {translate('auto.components.settings.restartServer.title', 'Restart Orca’s server')} - - - {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.' - )} - - - {error ?

{error}

: null} - - - - -
-
-
+ !next && !busy && setOpen(false)}> + + + + {translate('auto.components.settings.restartServer.title', 'Restart Orca’s server')} + + + {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.' + )} + + + {error ?

{error}

: null} + + + + +
+
) } diff --git a/src/renderer/src/components/settings/SshManagedHostRemoveDialog.tsx b/src/renderer/src/components/settings/SshManagedHostRemoveDialog.tsx index 6de1dd00b5c..77a7f206523 100644 --- a/src/renderer/src/components/settings/SshManagedHostRemoveDialog.tsx +++ b/src/renderer/src/components/settings/SshManagedHostRemoveDialog.tsx @@ -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({ {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 ?? '' } )} @@ -56,8 +56,8 @@ export function SshManagedHostRemoveDialog({ diff --git a/src/renderer/src/components/settings/SshPane.test.tsx b/src/renderer/src/components/settings/SshPane.test.tsx index 9f25d5b2685..c81459377b8 100644 --- a/src/renderer/src/components/settings/SshPane.test.tsx +++ b/src/renderer/src/components/settings/SshPane.test.tsx @@ -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() })) 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 }) => ( + + ) + } +}) vi.mock('./SshTargetsEmptyState', () => ({ SshTargetsEmptyState: () => null })) let root: ReturnType | 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', () => { ) }) - const remove = container.querySelector('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 => ({ + id, + label: id, + host: `${id}.example.test`, + port: 22, + username: 'dev', + source: 'ssh-config', + ...extra + }) + + async function renderPane(targets: SshTarget[]): Promise { + 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( + + + + ) + }) + return container + } + + function checkbox(host: HTMLElement, label: string): HTMLElement { + return host.querySelector(`[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('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() + }) + const filter = () => + container?.querySelector('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') + }) +}) diff --git a/src/renderer/src/components/settings/SshPane.tsx b/src/renderer/src/components/settings/SshPane.tsx index 0f712bff434..6d12c5aaadf 100644 --- a/src/renderer/src/components/settings/SshPane.tsx +++ b/src/renderer/src/components/settings/SshPane.tsx @@ -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>(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(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 => { const savePayload = buildSshTargetSavePayload(form) @@ -168,12 +198,16 @@ export function SshPane({ addTargetIntentSignal }: SshPaneProps): React.JSX.Elem requestPlainRemove(target) } + const removeTargetQuietly = async (id: string): Promise => { + 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 => { 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 => { - 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 => { - 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 ( -
- {/* Header row */} -
-
-

- {translate('auto.components.settings.SshPane.94c5284560', 'SSH hosts')} -

-

- {translate( - 'auto.components.settings.SshPane.a7d28dff81', - 'Add an existing machine over SSH so projects and workspaces can run there.' - )} -

-
-
- - -
-
- - - {({ busyActionForTarget, requestRemove }) => ( +
+ + void sync()} + onAdd={openAddTargetForm} + onRestoreRemoved={() => setRestoreOpen(true)} + /> + + + {({ busyActionForTarget, requestRemove, requestBulkRemove }) => ( <> - {/* Target list */} + 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 ? ( + ) : visibleTargets.length === 0 ? ( +

+ {translate('auto.components.settings.SshPane.noFilterMatch', 'No hosts match.')} +

) : ( -
- {targets.map((target) => ( -
- - requestRemoveTarget({ id, label: target.label }, requestRemove) - } - /> - void loadTargets()} /> -
- ))} -
+ requestRemoveTarget(target, requestRemove)} + onServerChanged={() => void loadTargets()} + /> )} )}
+ { + await sync({ restoreRemoved: true }) + if (mountedRef.current) { + setRestoreOpen(false) + } + }} + /> + {/* Why: modal keeps the form in viewport over long host lists (STA-3067). */} 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(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 ( + <> + + + open && loadRemovedCount()}> + + + + + + + {restoreLabel} + + + + + ) +} + +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 ( +
+
+ {props.showFilter ? ( + + ) : ( + + {translate('auto.components.settings.SshPaneToolbar.hostCount', '{{count}} hosts', { + count: props.totalCount + })} + + )} +
+ {props.selectedCount > 0 ? ( +
+ + {translate('auto.components.settings.SshPaneToolbar.selected', '{{count}} selected', { + count: props.selectedCount + })} + + + + + +
+ ) : props.hiddenDeselectedCount > 0 ? ( + + {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 } + )} + + ) : null} +
+ ) +} + +function SshHostFilterField({ + value, + onChange +}: { + value: string + onChange: (value: string) => void +}): React.JSX.Element { + const inputRef = useRef(null) + const label = translate('auto.components.settings.SshPaneToolbar.filter', 'Filter hosts') + // Why not : 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 ( +
+ + 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 !== '' ? ( + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/SshRestoreRemovedHostsDialog.tsx b/src/renderer/src/components/settings/SshRestoreRemovedHostsDialog.tsx new file mode 100644 index 00000000000..f90ac6109f2 --- /dev/null +++ b/src/renderer/src/components/settings/SshRestoreRemovedHostsDialog.tsx @@ -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 +}): React.JSX.Element { + const [removed, setRemoved] = useState({ 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 ( + { + if (busy) { + return + } + if (!next) { + setRemoved({ status: 'loading' }) + } + onOpenChange(next) + }} + > + + + + {translate( + 'auto.components.settings.SshRestoreRemovedHosts.title', + 'Restore removed hosts?' + )} + + + {translate( + 'auto.components.settings.SshRestoreRemovedHosts.description', + 'Hosts from ~/.ssh/config that you removed in Orca come back in Settings and in every host picker.' + )} + + + {removed.status === 'unavailable' ? null : ( +
+ {removed.status === 'loading' ? ( + + ) : aliases.length > 0 ? ( + {aliases.join(', ')} + ) : ( + translate( + 'auto.components.settings.SshRestoreRemovedHosts.none', + 'No removed hosts from ~/.ssh/config.' + ) + )} +
+ )} + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/settings/SshTargetCard.test.tsx b/src/renderer/src/components/settings/SshTargetCard.test.tsx index a1da47381ae..9b1f610884f 100644 --- a/src/renderer/src/components/settings/SshTargetCard.test.tsx +++ b/src/renderer/src/components/settings/SshTargetCard.test.tsx @@ -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') + }) +}) diff --git a/src/renderer/src/components/settings/SshTargetCard.tsx b/src/renderer/src/components/settings/SshTargetCard.tsx index 13ce6562701..0a7349e37e6 100644 --- a/src/renderer/src/components/settings/SshTargetCard.tsx +++ b/src/renderer/src/components/settings/SshTargetCard.tsx @@ -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 = { 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 = { 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 onDisconnect: (targetId: string) => void | Promise onTest: (targetId: string) => void | Promise 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(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 => ( -
- - - - - - {translate('auto.components.settings.SshTargetCard.3d8af2949f', 'Edit target')} - - - - - - - - {translate('auto.components.settings.SshTargetCard.7f7b3d7ab4', 'Remove target')} - - -
- ) + const renderStatus = (): React.JSX.Element | null => { + if (removeInFlight || testing) { + return ( + + + {removeInFlight + ? translate('auto.components.settings.SshTargetCard.removing', 'Removing…') + : translate('auto.components.settings.SshTargetCard.testing', 'Testing…')} + + ) + } + // Disconnected is the resting state of every saved host, so it stays unlabeled. + if (status === 'disconnected') { + return null + } + return ( + + + {STATUS_LABELS[status]} + + ) + } + + const renderPrimaryAction = (): React.JSX.Element => { + if (status === 'connected') { + return ( + + ) + } + if (isSshTargetConnecting(status)) { + return ( + + ) + } + const 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 ( + + {button} + + ) + } return (
- + {onSelectClick ? ( + { + // Why: Radix toggles on click with no modifier info; selection owns the state instead. + event.preventDefault() + onSelectClick(target.id, event.shiftKey) + }} + /> + ) : null}
-
+
{target.label} - - {STATUS_LABELS[status]} + {servedByOrcaServer ? ( + + {translate('auto.components.settings.SshTargetCard.orcaServer', 'Orca server')} + + ) : null} + {/* Why the separator sits outside the mono spans: a monospace "·" reads visibly wider. */} + + {endpoint} + {keyName ? ( + <> + {' '} + · {keyName} + + ) : null} +
-

- {endpoint} - {target.identityFile ? ` \u2022 ${target.identityFile}` : ''} -

{/* Why not truncate: host key failures put the remedy (`ssh-keygen -R `) 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' ? ( -

{state.error}

+

{state.error}

) : null} +
-
- {status === 'connected' ? ( - <> - {renderSecondaryIconActions()} +
+ {renderStatus()} + {renderPrimaryAction()} + + - - ) : isSshTargetConnecting(status) ? ( - <> - {renderSecondaryIconActions()} - - - ) : ( - <> - {renderSecondaryIconActions()} - - - - )} + + + void onTest(target.id)} + > + + {translate( + 'auto.components.settings.SshTargetCard.testConnection', + 'Test connection' + )} + + onEdit(target)}> + + {translate('auto.components.settings.SshTargetCard.edit', 'Edit…')} + + + + onRemove(target.id)} + > + + {translate('auto.components.settings.SshTargetCard.remove', 'Remove…')} + + +
+ + onServerChanged?.()} + />
) } diff --git a/src/renderer/src/components/settings/SshTargetDestructiveActions.test.tsx b/src/renderer/src/components/settings/SshTargetDestructiveActions.test.tsx new file mode 100644 index 00000000000..c9f40da51d6 --- /dev/null +++ b/src/renderer/src/components/settings/SshTargetDestructiveActions.test.tsx @@ -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 ?
{children}
: null, + DialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: ReactNode }) =>

{children}

, + DialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: ReactNode }) =>
{children}
+})) + +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, + onFinished: (result: SshBulkRemoveResult) => void +): Promise { + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render( + + {({ requestBulkRemove }) => ( + + )} + + ) + }) + 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((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') + }) +}) diff --git a/src/renderer/src/components/settings/SshTargetDestructiveActions.tsx b/src/renderer/src/components/settings/SshTargetDestructiveActions.tsx index 06e6ad03529..82e8322549b 100644 --- a/src/renderer/src/components/settings/SshTargetDestructiveActions.tsx +++ b/src/renderer/src/components/settings/SshTargetDestructiveActions.tsx @@ -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 + /** One removal inside a bulk run; throws on failure and leaves reporting to the summary. */ + removeQuietly?: (targetId: string) => Promise + onBulkRemoveFinished?: (result: SshBulkRemoveResult) => void | Promise children: (actions: SshTargetDestructiveActionsRenderProps) => ReactNode } export function SshTargetDestructiveActions({ onRemove, + removeQuietly, + onBulkRemoveFinished, children }: SshTargetDestructiveActionsProps): React.JSX.Element { const [pendingRemove, setPendingRemove] = useState(null) + const [pendingBulk, setPendingBulk] = useState(null) + const [bulkProgress, setBulkProgress] = useState(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 => { + 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)) } /> + + 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} + /> ) } diff --git a/src/renderer/src/components/settings/SshTargetForm.test.tsx b/src/renderer/src/components/settings/SshTargetForm.test.tsx index 6c70f89feda..8e770c62c75 100644 --- a/src/renderer/src/components/settings/SshTargetForm.test.tsx +++ b/src/renderer/src/components/settings/SshTargetForm.test.tsx @@ -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 diff --git a/src/renderer/src/components/settings/SshTargetForm.tsx b/src/renderer/src/components/settings/SshTargetForm.tsx index b2eeb096b43..392cc1e603b 100644 --- a/src/renderer/src/components/settings/SshTargetForm.tsx +++ b/src/renderer/src/components/settings/SshTargetForm.tsx @@ -253,7 +253,7 @@ export function SshTargetForm({ diff --git a/src/renderer/src/components/settings/SshTargetList.test.tsx b/src/renderer/src/components/settings/SshTargetList.test.tsx new file mode 100644 index 00000000000..c66b4a490a8 --- /dev/null +++ b/src/renderer/src/components/settings/SshTargetList.test.tsx @@ -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('') + 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('') + const dialog = document.createElement('div') + dialog.setAttribute('role', 'dialog') + dialog.innerHTML = '' + 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( + '
Edit
log
' + ) + for (const id of ['field', 'item', 'detail']) { + expect(isListOwnSelectAllTarget(list, list.querySelector(`#${id}`))).toBe(false) + } + }) +}) diff --git a/src/renderer/src/components/settings/SshTargetList.tsx b/src/renderer/src/components/settings/SshTargetList.tsx new file mode 100644 index 00000000000..e2b9ec1ac3b --- /dev/null +++ b/src/renderer/src/components/settings/SshTargetList.tsx @@ -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 + connectionStates: ReadonlyMap + testingIds: ReadonlySet + busyActionForTarget: (targetId: string) => SshTargetBusyAction | undefined + selection: SshTargetSelection + onConnect: (targetId: string) => void | Promise + onDisconnect: (targetId: string) => void | Promise + onTest: (targetId: string) => void | Promise + 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 ( +
{ + 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 ( +
+
+ selection.setGroup(ids, checkState !== true)} + /> + + {groupTitle(group)} + + ·{' '} + {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} + + +
+
+ {group.targets.map((target) => ( + onRemove(target)} + onServerChanged={onServerChanged} + /> + ))} +
+
+ ) + })} +
+ ) +} diff --git a/src/renderer/src/components/settings/SshTargetServerStatus.tsx b/src/renderer/src/components/settings/SshTargetServerStatus.tsx index df8c7c8ce9e..af6710ed915 100644 --- a/src/renderer/src/components/settings/SshTargetServerStatus.tsx +++ b/src/renderer/src/components/settings/SshTargetServerStatus.tsx @@ -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 = ( - - ) return ( -
+

{line.hint ?

{line.hint}

: null} - {line.detail ? ( - - {/* Why one row: the recovery sits beside Details, so it reads as part of this failure. */} -
- - - - {mirrorAction} -
- -
-              {line.detail}
-            
-
-
- ) : ( -
{mirrorAction}
- )} - - - + {/* Why inline, not in the menu: it is the way out of this failure. */} +
) } + +/** 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 ( + <> + + {sourceChanged ? ( + <> + onOpenDialog('delta-move')}> + {translate('auto.components.settings.deltaMove.action', 'Move the new projects…')} + + onOpenDialog('keep-server')}> + {translate('auto.components.settings.keepServer.action', 'Keep the server’s version…')} + + + ) : null} + {canRestart ? ( + onOpenDialog('restart')}> + {translate('auto.components.settings.restartServer.action', 'Restart Orca’s server…')} + + ) : null} + {canMoveAnyway ? ( + onOpenDialog('move-anyway')}> + {translate('auto.components.settings.moveAnyway.action', 'Move anyway…')} + + ) : null} + {hasDetail ? ( + onOpenDialog('details')}> + {translate('auto.components.settings.sshHostServer.failureDetails', 'Details')} + + ) : 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 ? ( + onDialogChange(null)} + onFinished={onChanged} + /> + ) : null} + + + + + + + + {translate('auto.components.settings.sshHostServer.failureDetails', 'Details')} + + +
+            {detail}
+          
+
+
+ + ) +} diff --git a/src/renderer/src/components/settings/WslHostsSection.tsx b/src/renderer/src/components/settings/WslHostsSection.tsx index d19336c26c7..29a104c22c8 100644 --- a/src/renderer/src/components/settings/WslHostsSection.tsx +++ b/src/renderer/src/components/settings/WslHostsSection.tsx @@ -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 ( -
-
+
+
{translate('auto.components.settings.wslHosts.title', 'WSL hosts')} @@ -94,6 +132,7 @@ export function WslHostsSection({ disabled={serverFilesMissing} onClick={() => setDialogMode('wsl')} > + {translate('auto.components.settings.wslHosts.add', 'Add WSL distro')} ) : ( @@ -108,35 +147,40 @@ export function WslHostsSection({ )}
{hosts.length > 0 ? ( -
+
{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 ( -
-
-
{environment.name}
-

- {unavailableLabel ?? getWslHostRowDetail(status)} - {!unavailableLabel && status?.detail ? ` — ${status.detail}` : ''} -

- {deferred ? ( -

- {translate( - 'auto.components.settings.managedServers.row.deferredVersion', - 'Update to {{version}} deferred.', - { version: deferred.candidateVersion } - )}{' '} - {managedServerOutcomeLabel({ - outcome: 'deferred', - code: deferred.code - })} -

- ) : null} -
- {deferred ? ( - <> + } + chips={ + + {translate('auto.components.settings.wslHosts.chip', 'WSL')} + + } + meta={ + + {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}` : '' + }`} + + } + status={getWslHostRowStatus(status, unavailableLabel !== null)} + primaryAction={ + deferred ? ( - {deferred.forceable ? ( - + ) + } + menuItems={ + <> + {deferred?.forceable ? ( + void connect(environment.id, true)} + onSelect={() => void connect(environment.id, true)} > {translate('auto.components.settings.wslHosts.forceUpdate', 'Force update')} - + ) : null} + {deferred && !(ready && !unavailableLabel) ? ( + void connect(environment.id)} + > + {translate('auto.components.settings.wslHosts.connect', 'Connect')} + + ) : null} + setRemoving(environment)} + > + + {translate('auto.components.settings.serverList.remove', 'Remove…')} + - ) : null} - {ready && !unavailableLabel ? null : ( - - )} - -
+ } + /> ) })}
diff --git a/src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx b/src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx index 714525782a2..dad12f297b0 100644 --- a/src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx +++ b/src/renderer/src/components/settings/cli-source-control-integration-cards.test.tsx @@ -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') }) diff --git a/src/renderer/src/components/settings/managed-server-copy.test.ts b/src/renderer/src/components/settings/managed-server-copy.test.ts index 0bb3efc701f..7f8b9fabd9f 100644 --- a/src/renderer/src/components/settings/managed-server-copy.test.ts +++ b/src/renderer/src/components/settings/managed-server-copy.test.ts @@ -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.' ) }) diff --git a/src/renderer/src/components/settings/managed-server-copy.ts b/src/renderer/src/components/settings/managed-server-copy.ts index 21518668a8f..741cbaba6b5 100644 --- a/src/renderer/src/components/settings/managed-server-copy.ts +++ b/src/renderer/src/components/settings/managed-server-copy.ts @@ -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)) { diff --git a/src/renderer/src/components/settings/ManagedServerRow.test.tsx b/src/renderer/src/components/settings/managed-server-row-parts.test.tsx similarity index 79% rename from src/renderer/src/components/settings/ManagedServerRow.test.tsx rename to src/renderer/src/components/settings/managed-server-row-parts.test.tsx index 8b1d96b72da..c89c799465c 100644 --- a/src/renderer/src/components/settings/ManagedServerRow.test.tsx +++ b/src/renderer/src/components/settings/managed-server-row-parts.test.tsx @@ -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 + }) => +})) + +function ManagedRowHarness({ + api, + environment +}: { + api: ManagedOrcadPreloadApi + environment: PublicKnownRuntimeEnvironment +}): React.JSX.Element { + const lifecycle = useManagedServerLifecycle({ + api, + environment, + onChanged: () => {} + }) + return ( + <> + {managedServerTitle(environment.name)} + + + + + + ) +} 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( {}} />) + root.render() }) 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 () => { diff --git a/src/renderer/src/components/settings/managed-server-row-parts.tsx b/src/renderer/src/components/settings/managed-server-row-parts.tsx new file mode 100644 index 00000000000..3bdef4d917d --- /dev/null +++ b/src/renderer/src/components/settings/managed-server-row-parts.tsx @@ -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 ( +
+

+ {translate( + 'auto.components.settings.managedServers.row.orcaVersion', + 'Orca on this host: {{version}}', + { + version: managedServerVersionLabel(status, statusError) + } + )} +

+ {statusError ?

{statusError}

: null} + {status ? ( + <> +

{terminalCensusLabel(status.terminals)}

+ {recovery ?

{recoveryLabel(recovery)}

: null} + {status.migration ? ( +

+ {translate( + 'auto.components.settings.managedServers.row.migration', + 'Migration: {{phase}}', + { phase: migrationPhaseLabel(status.migration.phase) } + )} +

+ ) : null} + {status.deferredUpdate ? ( +

+ {translate( + 'auto.components.settings.managedServers.row.deferredVersion', + 'Update to {{version}} deferred.', + { version: status.deferredUpdate.candidateVersion } + )}{' '} + {managedServerOutcomeLabel(status.deferredUpdate)} +

+ ) : null} + + ) : null} +
+ ) +} + +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 ( + + ) +} + +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 ( +
+ {message ?

{message}

: null} +
+ + +
+
+ ) +} + +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 ( + { + 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 ( + { + lifecycle.setConfirmingForget(false) + void run('forget') + }} + onKeep={() => lifecycle.setConfirmingForget(false)} + /> + ) + } + if (recovery && lifecycle.confirmingRestore) { + return ( + { + 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 ( + <> + void lifecycle.loadStatus()}> + + {translate('auto.components.settings.managedServers.row.refresh', 'Refresh status')} + + void run('update')}> + + {translate( + 'auto.components.settings.managedServers.row.updateOrcaOnHost', + 'Update Orca on this host' + )} + + {status?.rollbackAvailable ? ( + void run('rollback')}> + + {translate('auto.components.settings.managedServers.row.rollback', 'Roll back')} + + ) : null} + {/* Why: while a stop is half done, Cancel stop holds the primary slot and Recover lives here. */} + {recovery?.operation === 'decommission' ? ( + void run('recover')}> + + {translate('auto.components.settings.managedServers.row.recover', 'Recover')} + + ) : null} + + { + lifecycle.setConfirmingForget(false) + lifecycle.setConfirmingStop(true) + }} + > + + {translate('auto.components.settings.managedServers.row.stop', 'Stop…')} + + {statusError ? ( + { + lifecycle.setConfirmingStop(false) + lifecycle.setConfirmingForget(true) + }} + > + + {translate('auto.components.settings.managedServers.row.forget', 'Forget…')} + + ) : null} + + ) +} diff --git a/src/renderer/src/components/settings/network-address-label.ts b/src/renderer/src/components/settings/network-address-label.ts new file mode 100644 index 00000000000..97860a56f1b --- /dev/null +++ b/src/renderer/src/components/settings/network-address-label.ts @@ -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}` + } +} diff --git a/src/renderer/src/components/settings/provider-account-scope.test.ts b/src/renderer/src/components/settings/provider-account-scope.test.ts index d587a9641bc..0700c0ea721 100644 --- a/src/renderer/src/components/settings/provider-account-scope.test.ts +++ b/src/renderer/src/components/settings/provider-account-scope.test.ts @@ -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.' }) }) diff --git a/src/renderer/src/components/settings/provider-account-scope.ts b/src/renderer/src/components/settings/provider-account-scope.ts index aa15e117e16..4dab412e3a4 100644 --- a/src/renderer/src/components/settings/provider-account-scope.ts +++ b/src/renderer/src/components/settings/provider-account-scope.ts @@ -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.' ) } } diff --git a/src/renderer/src/components/settings/runtime-active-server-section.tsx b/src/renderer/src/components/settings/runtime-active-server-section.tsx deleted file mode 100644 index f8e48abce1d..00000000000 --- a/src/renderer/src/components/settings/runtime-active-server-section.tsx +++ /dev/null @@ -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 - isBusy: boolean - isLoading: boolean - onToggleAdvanced: () => void - onValueChange: (value: string) => void - onRefresh: () => void -} - -export function RuntimeActiveServerSection({ - visible, - advancedOpen, - allowLocalRuntime, - localRuntimeValue, - noRuntimeValue, - activeValue, - environments, - detailsByEnvironmentId, - isBusy, - isLoading, - onToggleAdvanced, - onValueChange, - onRefresh -}: RuntimeActiveServerSectionProps): React.JSX.Element { - return ( -
- - -
-
-
-
- -

- {getActiveServerModeDescription(allowLocalRuntime)} -

-
-
- - -
- {environments.length > 0 ? ( -
-
- {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.serverDetails', - 'Server details' - )} -
-
- {environments.map((environment) => { - const details = detailsByEnvironmentId[environment.id] - return ( -
-
- {environment.name} -
-
-
- {environment.endpoints[0]?.endpoint ?? - translate( - 'auto.components.settings.RuntimeEnvironmentsPane.6ef71985da', - 'No endpoint' - )} -
- {details?.runtimeStatus ? ( -
- {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.0ef838094a', - 'Protocol {{value0}}', - { - value0: - details.runtimeStatus?.runtimeProtocolVersion ?? - details.runtimeStatus?.protocolVersion ?? - 0 - } - )} - {details.runtimeStatus.hostPlatform - ? ` · ${details.runtimeStatus.hostPlatform}` - : ''} - {' · '} - {getRuntimeCapabilitiesSummary(details.runtimeStatus)} -
- ) : null} - {getHostModelCapabilitySummary(details?.runtimeStatus) ? ( -
- {getHostModelCapabilitySummary(details?.runtimeStatus)} -
- ) : null} -
-
- ) - })} -
-
- ) : null} -
-
-
-
- ) -} diff --git a/src/renderer/src/components/settings/runtime-add-server-dialog.tsx b/src/renderer/src/components/settings/runtime-add-server-dialog.tsx new file mode 100644 index 00000000000..3c51cc2ed40 --- /dev/null +++ b/src/renderer/src/components/settings/runtime-add-server-dialog.tsx @@ -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 ( + { + if (!next) { + onClose() + } + }} + > + + + + {translate('auto.components.settings.serverList.addTitle', 'Add server')} + + + {translate( + 'auto.components.settings.serverList.addDescriptionShort', + 'Paste the access link from Share this computer on the other machine.' + )} + + + + + + ) +} diff --git a/src/renderer/src/components/settings/runtime-default-host-row.test.tsx b/src/renderer/src/components/settings/runtime-default-host-row.test.tsx new file mode 100644 index 00000000000..5ff83b9309a --- /dev/null +++ b/src/renderer/src/components/settings/runtime-default-host-row.test.tsx @@ -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( + + ) +} + +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') +}) diff --git a/src/renderer/src/components/settings/runtime-default-host-row.tsx b/src/renderer/src/components/settings/runtime-default-host-row.tsx new file mode 100644 index 00000000000..a1db3cac872 --- /dev/null +++ b/src/renderer/src/components/settings/runtime-default-host-row.tsx @@ -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 ( +

+ {translate( + 'auto.components.settings.serverList.defaultHostEmptyLine', + 'New projects open on this computer until you add a server.' + )} +

+ ) + } + return ( +
+
+
+ {translate( + 'auto.components.settings.serverList.defaultHostLabel', + 'New projects open on' + )} +
+

+ {getActiveServerModeDescription(allowLocalRuntime)} +

+
+
+ +
+
+ ) +} diff --git a/src/renderer/src/components/settings/runtime-environment-dialogs.tsx b/src/renderer/src/components/settings/runtime-environment-dialogs.tsx index 07b520573d5..5e81c0bd37f 100644 --- a/src/renderer/src/components/settings/runtime-environment-dialogs.tsx +++ b/src/renderer/src/components/settings/runtime-environment-dialogs.tsx @@ -103,8 +103,8 @@ export function RuntimeEnvironmentRemoveDialog({ {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', diff --git a/src/renderer/src/components/settings/runtime-environment-host-details.ts b/src/renderer/src/components/settings/runtime-environment-host-details.ts index cab0e5b4fcd..1e5fff60891 100644 --- a/src/renderer/src/components/settings/runtime-environment-host-details.ts +++ b/src/renderer/src/components/settings/runtime-environment-host-details.ts @@ -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' - } -} diff --git a/src/renderer/src/components/settings/runtime-environments-search.ts b/src/renderer/src/components/settings/runtime-environments-search.ts index 0921f4b86b0..c6f9cb242e5 100644 --- a/src/renderer/src/components/settings/runtime-environments-search.ts +++ b/src/renderer/src/components/settings/runtime-environments-search.ts @@ -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() ] }) diff --git a/src/renderer/src/components/settings/runtime-install-server-dialog.tsx b/src/renderer/src/components/settings/runtime-install-server-dialog.tsx new file mode 100644 index 00000000000..002922ae0a8 --- /dev/null +++ b/src/renderer/src/components/settings/runtime-install-server-dialog.tsx @@ -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 +} + +/** 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 => { + 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. + + +
{ + event.preventDefault() + if (canSubmit) { + void deploy() + } + }} + > + + + {translate( + 'auto.components.settings.serverList.setUpOnSshHost', + 'Set up Orca on an SSH host' + )} + + +
+

+ {translate( + 'auto.components.settings.managedServers.description', + 'Orca installs itself on an empty SSH host and keeps itself up to date.' + )} +

+ {/* Why: this is the moment something moves, so say what does and doesn't. */} +

+ {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.' + )} +

+
+
+
+
+ + +

+ {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.' + )}{' '} + +

+ {loadFailed ? ( +

+ {translate( + 'auto.components.settings.managedServers.loadFailed', + 'Orca couldn’t load SSH hosts and unfinished moves. Reopen Settings to try again.' + )} +

+ ) : null} +
+
+ + setName(event.target.value)} + /> +
+ + + + +
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/runtime-server-default-name.test.ts b/src/renderer/src/components/settings/runtime-server-default-name.test.ts new file mode 100644 index 00000000000..e3619e5599e --- /dev/null +++ b/src/renderer/src/components/settings/runtime-server-default-name.test.ts @@ -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('') + }) +}) diff --git a/src/renderer/src/components/settings/runtime-server-default-name.ts b/src/renderer/src/components/settings/runtime-server-default-name.ts new file mode 100644 index 00000000000..b2b40669030 --- /dev/null +++ b/src/renderer/src/components/settings/runtime-server-default-name.ts @@ -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 + } + } +} diff --git a/src/renderer/src/components/settings/runtime-server-list-row.tsx b/src/renderer/src/components/settings/runtime-server-list-row.tsx new file mode 100644 index 00000000000..4d58aa27bb6 --- /dev/null +++ b/src/renderer/src/components/settings/runtime-server-list-row.tsx @@ -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 = { + success: 'bg-status-success', + warning: 'bg-status-warning', + destructive: 'bg-destructive', + muted: 'bg-muted-foreground' +} + +const STATUS_LABEL_CLASS: Record = { + 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 ( + + {children} + + ) +} + +export function ServerRowStatusLabel({ status }: { status: ServerRowStatus }): React.JSX.Element { + return ( + + {status.busy ? ( + + ) : ( + + )} + {status.label} + + ) +} + +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 ( +
+
+ + {icon ?? } + +
+
+ {name} + {chips} + {descriptor ? ( + {descriptor} + ) : null} +
+ {meta ? ( +
+ {meta} +
+ ) : null} +
+
+ {status ? : null} +
+
{primaryAction}
+
+ {menuItems ? ( + + + + + + + + + {translate( + 'auto.components.settings.serverList.moreActionsTooltip', + 'More actions' + )} + + + + {menuItems} + + + ) : null} +
+
+ {footer ?
{footer}
: null} +
+ ) +} + +/** The inline "Details" toggle at the end of a meta line. */ +export function ServerRowDetailsToggle({ + expanded, + onToggle +}: { + expanded: boolean + onToggle: () => void +}): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/settings/runtime-server-list.tsx b/src/renderer/src/components/settings/runtime-server-list.tsx new file mode 100644 index 00000000000..706f082fcf3 --- /dev/null +++ b/src/renderer/src/components/settings/runtime-server-list.tsx @@ -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 + +function ManagedRuntimeServerRow({ + api, + onChanged, + ...props +}: Omit & { + api: ManagedOrcadPreloadApi + onChanged: () => void +}): React.JSX.Element { + const lifecycle = useManagedServerLifecycle({ api, environment: props.environment, onChanged }) + return +} + +type RuntimeServerListProps = { + environments: PublicKnownRuntimeEnvironment[] + detailsByEnvironmentId: Record + activeRuntimeEnvironmentId: string | null | undefined + remoteServerUpdates: Map + 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 ( +
+ + + +
+
+ {translate('auto.components.settings.serverList.noSavedServers', 'No saved servers')} +
+

+ {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.' + )} +

+
+
+ + {api ? ( + + ) : null} +
+
+ ) + } + + const firstManagedId = environments.find((environment) => getOrcadSshDeployment(environment))?.id + return ( +
+ {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) ? ( + + ) : ( + + ) + // Why the wrapper: deep links to "managed-servers" land on the first managed row. + return environment.id === firstManagedId ? ( +
+ {row} +
+ ) : ( +
{row}
+ ) + })} + {pending.map((row) => ( + target.id === row.sshTargetId)?.label ?? row.name + )} + meta={{migrationPhaseLabel(row.phase)}} + status={{ + label: translate('auto.components.settings.serverList.movePaused', 'Move paused'), + tone: 'muted' + }} + primaryAction={ + + } + /> + ))} +
+ ) +} diff --git a/src/renderer/src/components/settings/runtime-server-row-details.tsx b/src/renderer/src/components/settings/runtime-server-row-details.tsx new file mode 100644 index 00000000000..81488e24066 --- /dev/null +++ b/src/renderer/src/components/settings/runtime-server-row-details.tsx @@ -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 | 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 ( +
+ {lead ?

{lead}

: null} + {unreachable ? ( +

+ {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.' + )}` + : ''} +

+ ) : null} + {remoteUpdate?.phase === 'manual' ? ( +

{getRemoteServerManualUpdateHelp(remoteUpdate)}

+ ) : null} +
+
{translate('auto.components.settings.serverList.address', 'Address')}
+
+ {endpoint ?? + translate('auto.components.settings.RuntimeEnvironmentsPane.6ef71985da', 'No endpoint')} +
+ {sshRoute ? ( + <> +
{translate('auto.components.settings.serverList.route', 'Route')}
+
{sshRoute}
+ + ) : null} + {runtimeStatus ? ( + <> +
{translate('auto.components.settings.serverList.protocol', 'Protocol')}
+
+ {runtimeStatus.runtimeProtocolVersion ?? runtimeStatus.protocolVersion ?? 0} + {runtimeStatus.hostPlatform ? ` · ${runtimeStatus.hostPlatform}` : ''} +
+
{translate('auto.components.settings.serverList.capabilities', 'Capabilities')}
+
+ {getRuntimeCapabilitiesSummary(runtimeStatus)} +
+ + ) : null} +
+ {hostModelSummary ?

{hostModelSummary}

: null} + {children} + {rawError ? ( +
+ {errorOpen ? ( +
+              {rawError}
+            
+ ) : null} +
+ + +
+
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/runtime-server-row-menu.tsx b/src/renderer/src/components/settings/runtime-server-row-menu.tsx new file mode 100644 index 00000000000..80acc76cd24 --- /dev/null +++ b/src/renderer/src/components/settings/runtime-server-row-menu.tsx @@ -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 ? ( + + + {translate('auto.components.settings.serverList.updateMenu', 'Update…')} + + ) : null} + {showDisconnect ? ( + + + {translate('auto.components.settings.RuntimeEnvironmentsPane.disconnect', 'Disconnect')} + + ) : null} + {showConnect ? ( + + + {translate('auto.components.settings.RuntimeEnvironmentsPane.connect', 'Connect')} + + ) : null} + {showMakeDefault ? ( + + + {translate('auto.components.settings.serverList.makeDefault', 'Open new projects here')} + + ) : null} + + {sshAccessLinked !== null ? ( + + + {sshAccessLinked + ? translate('auto.components.settings.serverList.sshAccessLinked', 'SSH route…') + : translate('auto.components.settings.serverList.sshAccess', 'Connect through SSH…')} + + ) : null} + {onCheckForUpdates ? ( + onCheckForUpdates(getUpdateCheckClickOptions(event))} + > + + {translate('auto.components.settings.serverList.checkForUpdates', 'Check for updates')} + + ) : null} + + + {detailsExpanded + ? translate('auto.components.settings.serverList.hideDetails', 'Hide details') + : translate('auto.components.settings.serverList.details', 'Details')} + + + {managedItems} + + + {translate('auto.components.settings.serverList.remove', 'Remove…')} + + + ) +} diff --git a/src/renderer/src/components/settings/runtime-server-row-meta.test.ts b/src/renderer/src/components/settings/runtime-server-row-meta.test.ts new file mode 100644 index 00000000000..fca8dfb29fa --- /dev/null +++ b/src/renderer/src/components/settings/runtime-server-row-meta.test.ts @@ -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') + }) +}) diff --git a/src/renderer/src/components/settings/runtime-server-row-meta.ts b/src/renderer/src/components/settings/runtime-server-row-meta.ts new file mode 100644 index 00000000000..869b6bc5687 --- /dev/null +++ b/src/renderer/src/components/settings/runtime-server-row-meta.ts @@ -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 ". + 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 + 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 +} diff --git a/src/renderer/src/components/settings/runtime-server-row-presentation.test.ts b/src/renderer/src/components/settings/runtime-server-row-presentation.test.ts index 55afba4b267..ecdc9df386f 100644 --- a/src/renderer/src/components/settings/runtime-server-row-presentation.test.ts +++ b/src/renderer/src/components/settings/runtime-server-row-presentation.test.ts @@ -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 { @@ -22,55 +25,112 @@ function updateEntry(patch: Partial): RemoteServerUpdat } } +function statusInput(patch: Partial): 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() }) }) diff --git a/src/renderer/src/components/settings/runtime-server-row-presentation.ts b/src/renderer/src/components/settings/runtime-server-row-presentation.ts index 7bf080754b7..4cc6ee46d9a 100644 --- a/src/renderer/src/components/settings/runtime-server-row-presentation.ts +++ b/src/renderer/src/components/settings/runtime-server-row-presentation.ts @@ -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 = 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 } } diff --git a/src/renderer/src/components/settings/runtime-server-row-state.ts b/src/renderer/src/components/settings/runtime-server-row-state.ts new file mode 100644 index 00000000000..f02c1c65651 --- /dev/null +++ b/src/renderer/src/components/settings/runtime-server-row-state.ts @@ -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 } + ) +} diff --git a/src/renderer/src/components/settings/runtime-server-row-unverifiable-host.test.tsx b/src/renderer/src/components/settings/runtime-server-row-unverifiable-host.test.tsx index d5e40098be2..dcd0b87bfd4 100644 --- a/src/renderer/src/components/settings/runtime-server-row-unverifiable-host.test.tsx +++ b/src/renderer/src/components/settings/runtime-server-row-unverifiable-host.test.tsx @@ -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 }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuSeparator: () => null, + DropdownMenuItem: ({ + children, + onSelect + }: { + children: React.ReactNode + onSelect?: () => void + }) => +})) + const ENVIRONMENT_ID = 'env-a' const initialState = useAppStore.getInitialState() @@ -68,23 +84,25 @@ function renderRow( duplicateOfNames: readonly string[] = [] ): void { render( - + + + ) } @@ -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( + + + + ) + 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() }) diff --git a/src/renderer/src/components/settings/runtime-server-row.tsx b/src/renderer/src/components/settings/runtime-server-row.tsx index 9836b6b2173..78d8518bcd3 100644 --- a/src/renderer/src/components/settings/runtime-server-row.tsx +++ b/src/renderer/src/components/settings/runtime-server-row.tsx @@ -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 | 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 ? ( + + {problemLine.text} + + ) : ( + infoParts.map((part, index) => ( + + {index > 0 ? · : null} + {part} + + )) + )} + {!problemLine && availableText ? ( + + + · + + {availableText} + + ) : null} + {problemLine ? ( + <> + · + setExpanded((v) => !v)} /> + + ) : null} + + ) + + const primaryAction = + primary === 'managed' && managed ? ( + + ) : primary === 'repair' ? ( + + ) : primary === 'update' ? ( + + ) : primary === 'recheck' ? ( + + ) : primary === 'connect' || primary === 'retry' ? ( + + ) : null + + const showSshAccess = sshAccessApi !== null && !deployment && !pendingSshAccess + const footer = + pendingSshAccess || expanded || (managed && managedServerHasConfirmation(managed)) ? ( + <> + {pendingSshAccess && sshAccessApi ? ( + onChanged?.()} + /> + ) : null} + {managed ? : null} + {expanded ? ( + + {managed ? : null} + + ) : null} + + ) : null return ( -
- -
-
-
{hostDisplay.title}
- - - {statusIndicator.label} - - {/* Why: the red "Access revoked" label already carries the warning. */} - {!accessRevoked && effectiveDetails?.compatibility?.kind === 'blocked' ? ( - - ) : effectiveDetails?.status === 'loading' ? ( - - ) : null} -
- {hostDescriptorText ? ( -

{hostDescriptorText}

- ) : null} - {rowSummary ?

{rowSummary}

: null} - {duplicateOfNames.length > 0 ? ( -

- {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.pairedTwice', - 'Same server as {{value0}}, paired twice. Remove one so its projects can be edited.', - { value0: duplicateOfNames[0] } - )} -

- ) : null} - {detailsDescription ? ( -

- {detailsDescription} -

- ) : null} - {remoteUpdate && versionFooter ? ( -
- {versionFooter.text} - {versionFooter.showUpdateStatus ? ( - + <> + target.id === deployment.sshTargetId)?.label ?? + environment.name + ) + : row.title + } + descriptor={row.hostDescriptorText} + chips={ + <> + {isActive ? ( + + {translate('auto.components.settings.serverList.defaultChip', 'Default')} + ) : null} -
- ) : null} - {remoteUpdate?.phase === 'manual' ? ( -

- {getRemoteServerManualUpdateHelp(remoteUpdate)} -

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

{remoteUpdate.error}

- ) : null} -
-
- {remoteUpdate?.phase === 'available' || remoteUpdate?.phase === 'failed' ? ( - - ) : null} - {accessRevoked && repairable ? ( - - ) : null} - {isReachable ? ( - - ) : showConnect ? ( - - ) : null} - -
+ {!deployment && (sshAccess || environment.connectionDependency === 'ssh-tunnel') ? ( + + {translate('auto.components.settings.serverList.sshTunnelChip', 'SSH tunnel')} + + ) : null} + + } + meta={meta} + status={status} + primaryAction={primaryAction} + menuItems={ + : 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} + /> -
+ {showSshAccess && sshAccessApi ? ( + onChanged?.()} + /> + ) : null} + ) } diff --git a/src/renderer/src/components/settings/runtime-server-workflow-sections.test.tsx b/src/renderer/src/components/settings/runtime-server-workflow-sections.test.tsx deleted file mode 100644 index 0c44e932992..00000000000 --- a/src/renderer/src/components/settings/runtime-server-workflow-sections.test.tsx +++ /dev/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: () =>
-})) - -vi.mock('./MachineNameField', () => ({ - MachineNameField: ({ id }: { id?: string }) => ( -
- ) -})) - -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( - - ) - - 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() - }) -}) diff --git a/src/renderer/src/components/settings/runtime-server-workflow-sections.tsx b/src/renderer/src/components/settings/runtime-server-workflow-sections.tsx deleted file mode 100644 index 9ea637d4382..00000000000 --- a/src/renderer/src/components/settings/runtime-server-workflow-sections.tsx +++ /dev/null @@ -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 ( -
- {( - [ - [ - 'connect', - translate( - 'auto.components.settings.RuntimeEnvironmentsPane.connectWorkflow', - 'Connect to a host' - ), - translate( - 'auto.components.settings.RuntimeEnvironmentsPane.connectWorkflowHelp', - 'This app joins another machine' - ) - ], - [ - 'share', - translate( - 'auto.components.settings.RuntimeEnvironmentsPane.shareWorkflow', - 'Share this host' - ), - translate( - 'auto.components.settings.RuntimeEnvironmentsPane.shareWorkflowHelp', - 'Other devices join this machine' - ) - ], - [ - 'cloud-vm', - translate( - 'auto.components.settings.RuntimeEnvironmentsPane.cloudVmWorkflow', - 'Cloud VM' - ), - translate( - 'auto.components.settings.RuntimeEnvironmentsPane.cloudVmWorkflowHelp', - 'Manage recipe-created cloud machines' - ) - ] - ] as const - ) - .filter(([value]) => value !== 'share' || canGeneratePairingUrl) - .map(([value, label, description]) => ( - - ))} -
- ) -} - -export function RuntimeServerShareSection({ - shareServerFormOpen, - onToggleShareServerForm -}: { - shareServerFormOpen: boolean - onToggleShareServerForm: () => void -}): React.JSX.Element { - return ( -
-
-
- {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.advertiseThisApp', - 'Advertise this app as a server' - )} -
-

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

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

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

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

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

-
-
    -
  1. - {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.troubleshootStepShare', - 'On the other computer, open Share this host.' - )} -
  2. -
  3. - {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.troubleshootStepAddress', - 'Choose Another device and select its Tailscale or LAN address.' - )} -
  4. -
  5. - {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.troubleshootStepRegenerate', - 'Generate a new access link and use only the newest link here.' - )} -
  6. -
-
- {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.troubleshootTunnel', - 'Using an SSH local forward? Return to Connect to a host, paste the loopback link, then enable “I am using an SSH tunnel” under Advanced.' - )} -
-
-
- ) -} diff --git a/src/renderer/src/components/settings/runtime-servers-connect-section.tsx b/src/renderer/src/components/settings/runtime-servers-connect-section.tsx deleted file mode 100644 index f3e5eacac4d..00000000000 --- a/src/renderer/src/components/settings/runtime-servers-connect-section.tsx +++ /dev/null @@ -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 - activeRuntimeEnvironmentId: string | null | undefined - addServerFormOpen: boolean - name: string - pairingCode: string - addServerFailure: RuntimeHostAccessFailure | null - isBusy: boolean - remoteServerUpdates: Map - remoteServerUpdatesChecking: boolean - remoteServerUpdatesRunning: boolean - connectingId: string | null - switchingValue: string | null - disconnectingId: string | null - removingId: string | null - onOpenAddServerForm: () => void - onCloseAddServerForm: () => void - onNameChange: (value: string) => void - onPairingCodeChange: (value: string) => void - onAddEnvironment: (allowLoopback: boolean) => void - onOpenUpdateDialog: () => void - refreshRemoteServerUpdates: (options?: UpdateCheckOptions) => Promise - onConnect: (environment: PublicKnownRuntimeEnvironment) => void - onDisconnect: (environment: PublicKnownRuntimeEnvironment) => void - onRemove: (environment: PublicKnownRuntimeEnvironment) => void -} - -export function RuntimeServersConnectSection({ - visible, - environments, - detailsByEnvironmentId, - activeRuntimeEnvironmentId, - addServerFormOpen, - name, - pairingCode, - addServerFailure, - isBusy, - remoteServerUpdates, - remoteServerUpdatesChecking, - remoteServerUpdatesRunning, - connectingId, - switchingValue, - disconnectingId, - removingId, - onOpenAddServerForm, - onCloseAddServerForm, - onNameChange, - onPairingCodeChange, - onAddEnvironment, - onOpenUpdateDialog, - refreshRemoteServerUpdates, - onConnect, - onDisconnect, - onRemove -}: RuntimeServersConnectSectionProps): React.JSX.Element { - const updateCheckHint = getUpdateCheckHint() - return ( -
-
-
-
- {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.connectToRemoteServers', - 'Connect to remote servers' - )} -
-

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

-
-
- {environments.length > 0 ? ( - - ) : null} - {addServerFormOpen ? null : ( - - )} -
-
- - {addServerFormOpen ? ( - - ) : null} - -
- {environments.length === 0 ? ( -
- {translate( - 'auto.components.settings.RuntimeEnvironmentsPane.9a3758d983', - 'No saved servers.' - )} -
- ) : ( -
- {environments.map((environment) => ( - - ))} -
- )} -
-
- ) -} diff --git a/src/renderer/src/components/settings/runtime-servers-header-actions.tsx b/src/renderer/src/components/settings/runtime-servers-header-actions.tsx new file mode 100644 index 00000000000..45b08dd9e18 --- /dev/null +++ b/src/renderer/src/components/settings/runtime-servers-header-actions.tsx @@ -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 ( + + {icon} + + + {label} + {trailing} + + {description} + + + ) +} + +/** 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 ( +
+ {/* Why ≥2: a single update is one click on its own row. */} + {remoteServerUpdatesRunning || availableCount >= 2 ? ( + + ) : null} + {listEmpty ? null : ( + + + + + + + + } + 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 ? ( + } + 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} + } + label={translate('auto.components.settings.serverList.cloudVm', 'Cloud VM')} + trailing={} + description={translate( + 'auto.components.settings.serverList.cloudVmHelp', + 'Created per workspace from an environment recipe.' + )} + onSelect={() => + openSettingsTarget({ + pane: 'experimental', + repoId: null, + sectionId: 'ephemeral-vms' + }) + } + /> + + + + )} +
+ ) +} diff --git a/src/renderer/src/components/settings/runtime-servers-page-context.tsx b/src/renderer/src/components/settings/runtime-servers-page-context.tsx new file mode 100644 index 00000000000..fae7e2a9aba --- /dev/null +++ b/src/renderer/src/components/settings/runtime-servers-page-context.tsx @@ -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(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(null) + const [listEmpty, setListEmpty] = useState(false) + const value = useMemo(() => ({ dialog, setDialog, listEmpty, setListEmpty }), [dialog, listEmpty]) + return ( + + {children} + + ) +} + +export function useRuntimeServersPage(): RuntimeServersPageState { + const value = useContext(RuntimeServersPageContext) + if (!value) { + throw new Error('useRuntimeServersPage must be used inside RuntimeServersPageProvider') + } + return value +} diff --git a/src/renderer/src/components/settings/runtime-share-computer-card.test.tsx b/src/renderer/src/components/settings/runtime-share-computer-card.test.tsx new file mode 100644 index 00000000000..ec5cdb33425 --- /dev/null +++ b/src/renderer/src/components/settings/runtime-share-computer-card.test.tsx @@ -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: () =>
machine-name-field
})) +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() + 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() +}) diff --git a/src/renderer/src/components/settings/runtime-share-computer-card.tsx b/src/renderer/src/components/settings/runtime-share-computer-card.tsx new file mode 100644 index 00000000000..0d02b009b86 --- /dev/null +++ b/src/renderer/src/components/settings/runtime-share-computer-card.tsx @@ -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(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 ( +
+ +
+
+ {translate('auto.components.settings.serverList.shareTitle', 'Share this computer')} +
+

+ {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.' + )} +

+
+ {activeLinkCount ? ( + + {activeLinkCount === 1 + ? translate('auto.components.settings.serverList.oneActiveLink', '1 active link') + : translate( + 'auto.components.settings.serverList.activeLinks', + '{{value0}} active links', + { + value0: activeLinkCount + } + )} + + ) : null} + + + + + + {translate('auto.components.settings.serverList.shareTitle', 'Share this computer')} + + + {translate( + 'auto.components.settings.serverList.shareDialogDescriptionRuns', + 'Anyone with the link can run commands as you on this computer until you revoke it.' + )} + + + + {editingName ? ( + + ) : ( +
+ + {translate( + 'auto.components.settings.serverList.shownAs', + 'Shown to others as {{name}}', + { + name: + publishedMachineName ?? + translate('auto.components.settings.serverList.thisComputer', 'This computer') + } + )} + + · + +
+ )} + + + +
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/runtime-ssh-access-dialog.tsx b/src/renderer/src/components/settings/runtime-ssh-access-dialog.tsx new file mode 100644 index 00000000000..f6038d1ac7e --- /dev/null +++ b/src/renderer/src/components/settings/runtime-ssh-access-dialog.tsx @@ -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 + environment: PublicKnownRuntimeEnvironment + targets: SshTarget[] + onOpenChange: (open: boolean) => void + onChanged: () => void +}): React.JSX.Element { + return ( + + + + + {translate( + 'auto.components.settings.serverList.sshDialogTitle', + 'Connect {{name}} through SSH', + { name: environment.name } + )} + + + {translate( + 'auto.components.settings.serverList.sshDialogDescription', + 'Use this when the server isn’t reachable directly. Orca opens a tunnel through an SSH host.' + )} + + + { + onChanged() + onOpenChange(false) + }} + footerStart={ + + } + /> + + + ) +} diff --git a/src/renderer/src/components/settings/settings-remote-security-section-renderers.tsx b/src/renderer/src/components/settings/settings-remote-security-section-renderers.tsx index 5ba1d9fb57f..90f9cc820fc 100644 --- a/src/renderer/src/components/settings/settings-remote-security-section-renderers.tsx +++ b/src/renderer/src/components/settings/settings-remote-security-section-renderers.tsx @@ -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 ( - - {view.isSectionMounted('servers') ? ( - - ) : null} - + + : 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 ? ( + + ) : null} + + ) } export function renderSshSettingsSection(context: SettingsRenderContext): React.JSX.Element | null { const { model, navigation, view } = context + const mounted = view.isSectionMounted('ssh') return model.showDesktopOnlySettings ? ( - - {view.isSectionMounted('ssh') ? ( - - ) : null} - + + : 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 ? : null} + + ) : null } diff --git a/src/renderer/src/components/settings/ssh-bulk-remove-summary.ts b/src/renderer/src/components/settings/ssh-bulk-remove-summary.ts new file mode 100644 index 00000000000..32cb4f31bb2 --- /dev/null +++ b/src/renderer/src/components/settings/ssh-bulk-remove-summary.ts @@ -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(' ') } : {}) + } +} diff --git a/src/renderer/src/components/settings/ssh-hosts-header-slot.tsx b/src/renderer/src/components/settings/ssh-hosts-header-slot.tsx new file mode 100644 index 00000000000..8df96ec274f --- /dev/null +++ b/src/renderer/src/components/settings/ssh-hosts-header-slot.tsx @@ -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(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(null) + const value = useMemo(() => ({ slot, setSlot }), [slot]) + return ( + + {children} + + ) +} + +export function SshHostsHeaderSlot(): React.JSX.Element | null { + const context = useContext(SshHostsHeaderSlotContext) + return context ?
: 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
{children}
+ } + return context.slot ? createPortal(children, context.slot) : null +} diff --git a/src/renderer/src/components/settings/ssh-target-list-model.test.ts b/src/renderer/src/components/settings/ssh-target-list-model.test.ts new file mode 100644 index 00000000000..b0841d46f1c --- /dev/null +++ b/src/renderer/src/components/settings/ssh-target-list-model.test.ts @@ -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 => ({ + 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() + }) +}) diff --git a/src/renderer/src/components/settings/ssh-target-list-model.ts b/src/renderer/src/components/settings/ssh-target-list-model.ts new file mode 100644 index 00000000000..bac35f5dc26 --- /dev/null +++ b/src/renderer/src/components/settings/ssh-target-list-model.ts @@ -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 { + 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 + orderedIds: readonly string[] + anchorId: string | null + clickedId: string + shiftKey: boolean +}): Set { + 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, + 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 +} diff --git a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx index 819a2a0f224..d43c3f63184 100644 --- a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx +++ b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx @@ -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')) diff --git a/src/renderer/src/components/settings/use-managed-server-catalog.ts b/src/renderer/src/components/settings/use-managed-server-catalog.ts new file mode 100644 index 00000000000..225f8a7b4a6 --- /dev/null +++ b/src/renderer/src/components/settings/use-managed-server-catalog.ts @@ -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 + resume: (row: OrcadManagedPendingMigrationRow) => Promise +} + +/** 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([]) + const [pending, setPending] = useState([]) + const [resumingId, setResumingId] = useState(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 => { + 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 } +} diff --git a/src/renderer/src/components/settings/use-managed-server-lifecycle.ts b/src/renderer/src/components/settings/use-managed-server-lifecycle.ts new file mode 100644 index 00000000000..226487b1d24 --- /dev/null +++ b/src/renderer/src/components/settings/use-managed-server-lifecycle.ts @@ -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 + run: (action: ManagedServerAction) => Promise +} + +/** Turns a refusal or deferral into the one line the user acts on. */ +function outcomeMessage(result: Parameters[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 + onChanged: () => void +}): ManagedServerLifecycle { + const mountedRef = useMountedRef() + const [status, setStatus] = useState(null) + const [statusError, setStatusError] = useState(null) + const [busy, setBusy] = useState(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 => { + 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 + } +} diff --git a/src/renderer/src/components/settings/use-runtime-environment-catalog.ts b/src/renderer/src/components/settings/use-runtime-environment-catalog.ts index 8f8cf8c5dec..7642f0a5228 100644 --- a/src/renderer/src/components/settings/use-runtime-environment-catalog.ts +++ b/src/renderer/src/components/settings/use-runtime-environment-catalog.ts @@ -32,6 +32,19 @@ type RuntimeEnvironmentCatalog = { }) => Promise } +// 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() + +// 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 - >({}) + >(() => 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( diff --git a/src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts b/src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts index 1862664f71d..369d9c3c87f 100644 --- a/src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts +++ b/src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts @@ -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 - setAddServerFormOpen: Dispatch> + setAddServerFormOpen: (open: boolean) => void loadEnvironments: (verified?: { environmentId: string runtimeStatus: RuntimeStatus @@ -45,8 +48,10 @@ export function useRuntimeEnvironmentMutationActions({ } const addEnvironment = async (allowLoopback: boolean): Promise => { - 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( diff --git a/src/renderer/src/components/settings/use-ssh-bulk-remove.ts b/src/renderer/src/components/settings/use-ssh-bulk-remove.ts new file mode 100644 index 00000000000..9cff24d7aaf --- /dev/null +++ b/src/renderer/src/components/settings/use-ssh-bulk-remove.ts @@ -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 + clearSelection: () => void + reload: () => Promise + requestSingleRemove: ( + target: SshTarget, + requestPlainRemove: (target: PlainRemoveTarget) => void + ) => void +}): { + requestRemoveSelected: ( + requestBulkRemove: (request: SshBulkRemoveRequest) => void, + requestPlainRemove: (target: PlainRemoveTarget) => void + ) => void + finishBulkRemove: (result: SshBulkRemoveResult) => Promise +} { + 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([]) + + const finishBulkRemove = async (result: SshBulkRemoveResult): Promise => { + 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 } +} diff --git a/src/renderer/src/components/settings/use-ssh-config-sync.ts b/src/renderer/src/components/settings/use-ssh-config-sync.ts new file mode 100644 index 00000000000..df83113f479 --- /dev/null +++ b/src/renderer/src/components/settings/use-ssh-config-sync.ts @@ -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): { + syncing: boolean + sync: (options?: { restoreRemoved?: boolean }) => Promise +} { + const mountedRef = useMountedRef() + const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) + const [syncing, setSyncing] = useState(false) + + const sync = async (options?: { restoreRemoved?: boolean }): Promise => { + 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 } +} diff --git a/src/renderer/src/components/settings/use-ssh-target-selection.ts b/src/renderer/src/components/settings/use-ssh-target-selection.ts new file mode 100644 index 00000000000..9892af8e9cc --- /dev/null +++ b/src/renderer/src/components/settings/use-ssh-target-selection.ts @@ -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 + 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>(new Set()) + const anchorRef = useRef(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 } +} diff --git a/src/renderer/src/components/settings/use-ssh-target-tests.ts b/src/renderer/src/components/settings/use-ssh-target-tests.ts new file mode 100644 index 00000000000..13af8b8bd10 --- /dev/null +++ b/src/renderer/src/components/settings/use-ssh-target-tests.ts @@ -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 + testOne: (targetId: string) => Promise + testMany: (targets: readonly SshTarget[]) => Promise + bulkTesting: boolean +} { + const mountedRef = useMountedRef() + const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) + const [testingIds, setTestingIds] = useState>(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 => { + 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 => { + 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 => { + 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 => { + 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 } +} diff --git a/src/renderer/src/components/sidebar/AddRemoteHostFields.tsx b/src/renderer/src/components/sidebar/AddRemoteHostFields.tsx index cdab890d459..f351f661612 100644 --- a/src/renderer/src/components/sidebar/AddRemoteHostFields.tsx +++ b/src/renderer/src/components/sidebar/AddRemoteHostFields.tsx @@ -211,8 +211,8 @@ export function RemoteServerFields({ />

{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.' )}

{inputError ? ( diff --git a/src/renderer/src/hooks/settings-navigation-remote-sections.ts b/src/renderer/src/hooks/settings-navigation-remote-sections.ts index 2068252c5ba..84f32a691d6 100644 --- a/src/renderer/src/hooks/settings-navigation-remote-sections.ts +++ b/src/renderer/src/hooks/settings-navigation-remote-sections.ts @@ -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', diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 10dc621a9fa..3daecfb46c4 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -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 ", + "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 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": { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index bfdbc036ab6..031f53eb699 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index a2b94504ff4..41dfd659be1 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index dac7bb3f5bf..3508fae22e4 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -6542,7 +6542,8 @@ "sshConfigPickerAddAllEmpty": "Tout ajouter à Orca", "identityFileFromConfigHint": "Volontairement laissé vide : Orca utilise toutes les clés résolues par ~/.ssh/config pour {{value0}}. Saisissez un chemin pour n'utiliser que cette clé.", "sshConfigPickerRetry": "Réessayer", - "sshConfigPickerResolving": "Lecture…" + "sshConfigPickerResolving": "Lecture…", + "pairingHelpShareComputer": "Créez-le dans Réglages → Serveurs Orca distants → Partager cet ordinateur sur l’autre ordinateur." }, "ForgetSshWorkspaceDialog": { "reconnectFailed": "Échec de la reconnexion", @@ -8467,7 +8468,12 @@ "27cf8507ad": "Actualiser l'accès partagé", "f031182867": "Accès serveur partagé", "df142657a5": "Pas encore utilisé", - "b18d1764ef": "Dernière utilisation {{value0}}" + "b18d1764ef": "Dernière utilisation {{value0}}", + "linkName": "Lien · {{value0}}", + "activeLinks": "Liens actifs", + "noActiveLinks": "Aucun lien actif.", + "createdAt": "Créé le {{value0}}", + "revoke": "Révoquer" }, "RuntimeEnvironmentsPane": { "aeb26635d2": "Supprimer {{value0}}", @@ -8573,7 +8579,10 @@ "repair": "Re-pair…", "serverAccessRevoked": "Access revoked", "orcaVersionLastSeen": "Orca v{{value0}} (last seen)", - "pairedTwice": "Même serveur que {{value0}}, associé deux fois. Supprimez-en un pour pouvoir modifier ses projets." + "pairedTwice": "Même serveur que {{value0}}, associé deux fois. Supprimez-en un pour pouvoir modifier ses projets.", + "removeDefaultServerDescription": "Ce serveur est l’hôte par défaut des nouveaux projets. Choisissez-en un autre dans « Les nouveaux projets s’ouvrent sur » avant de le supprimer. Les sessions existantes ne sont pas touchées.", + "connectedNewProjectsHint": "Connecté à {{value0}}. Pour y créer les nouveaux projets par défaut, choisissez-le dans « Les nouveaux projets s’ouvrent sur ».", + "removeDefaultServerBlocked": "Ce serveur est l’hôte par défaut des nouveaux projets. Choisissez-en un autre dans « Les nouveaux projets s’ouvrent sur » avant de le supprimer." }, "RuntimePairingGeneratedUrlRows": { "0495f68959": "Copier {{value0}}" @@ -8626,7 +8635,15 @@ "localLinkHelp": "Ce lien ne fonctionne que dans un navigateur ou un client Orca exécuté sur cet ordinateur.", "noExternalAddress": "Aucune adresse pour un autre appareil n'a été trouvée. Connectez cet ordinateur à un LAN ou à Tailscale, actualisez, ou choisissez « Adresse personnalisée ».", "staleAddress": "L'adresse de connexion a changé. Générez un nouveau lien pour {{address}}.", - "customInvalid": "Saisissez un hôte, un hôte:port, une adresse IPv6 ou une URL ws(s):// valide." + "customInvalid": "Saisissez un hôte, un hôte:port, une adresse IPv6 ou une URL ws(s):// valide.", + "thisComputer": "Cet ordinateur", + "whoOpens": "Qui ouvrira le lien", + "addressLabel": "Adresse", + "createLink": "Créer un lien", + "browserLinkLabel": "Lien pour navigateur", + "accessLinkLabel": "Lien d’accès", + "accessLinkHelp": "Collez-le dans « Ajouter un serveur » sur l’autre Orca.", + "staleAddressCreate": "L’adresse de connexion a changé. Créez un nouveau lien pour {{address}}." }, "Settings": { "3bf149e873": "Paramètres du projet > {{value0}}", @@ -8704,7 +8721,8 @@ "devDescription": "Outils réservés au développement pour exercer les états de l'UI.", "linearTitle": "Linear", "linearDescription": "Fonctionnement de Linear dans Orca, checklist de configuration, skill d'agent et exemples de prompts.", - "tasksDescription": "Connectez des fournisseurs, installez le skill Linear et choisissez ce qui apparaît dans Tâches." + "tasksDescription": "Connectez des fournisseurs, installez le skill Linear et choisissez ce qui apparaît dans Tâches.", + "remoteServersDescription": "Exécutez Orca sur une autre machine et travaillez dessus depuis ici." }, "SettingsFormControls": { "42a4d15a30": "Aucune police correspondante.", @@ -8828,7 +8846,8 @@ "2ef2b2674b": "Supprimer {{value0}}" }, "SshDestructiveActionDialog": { - "895b216267": "Annuler" + "895b216267": "Annuler", + "moreTargets": "{{count}} de plus" }, "SshPane": { "c0f1c80166": "Aucune cible SSH configurée.", @@ -8851,7 +8870,10 @@ "f1fc50dad2": "Échec du chargement des cibles SSH", "0cda732f43": "Échec du test de connexion", "syncedServerSingular": "Synchronisation terminée : {{count}} serveur", - "syncedServerPlural": "Synchronisation terminée : {{count}} serveurs" + "syncedServerPlural": "Synchronisation terminée : {{count}} serveurs", + "noFilterMatch": "Aucun hôte ne correspond.", + "addHost": "Ajouter un hôte", + "alreadyInSync": "~/.ssh/config est déjà synchronisé" }, "SshPassphraseDialog": { "d5a234456f": "Annuler", @@ -8882,11 +8904,26 @@ "3d8af2949f": "Modifier la cible", "18968ede9e": "Erreur", "f0871e6bfb": "connect", - "47e94bd6ba": "connecté" + "47e94bd6ba": "connecté", + "removing": "Suppression…", + "testing": "Test en cours…", + "select": "Sélectionner {{label}}", + "orcaServer": "Serveur Orca", + "moreActions": "Autres actions pour {{label}}", + "testConnection": "Tester la connexion", + "edit": "Modifier…", + "remove": "Supprimer…" }, "SshTargetDestructiveActions": { "3bb0cf0ee4": "Ceci supprimera la cible et arrêtera tous les terminaux distants actifs.", - "4808966c41": "Supprimer la cible SSH" + "4808966c41": "Supprimer la cible SSH", + "bulkTitleSingular": "Supprimer {{count}} hôte SSH ?", + "bulkTitlePlural": "Supprimer {{count}} hôtes SSH ?", + "bulkDescription": "Les hôtes supprimés disparaissent des sélecteurs d’hôte des espaces de travail, des automatisations et de la barre latérale. Votre ~/.ssh/config n’est pas modifié, et les synchronisations suivantes ne les rajouteront pas.", + "bulkAction": "Supprimer {{count}}", + "bulkProgress": "Suppression {{done}} sur {{count}}…", + "bulkConnectedSingular": "{{count}} hôte est connecté. Ses terminaux distants seront fermés.", + "bulkConnectedPlural": "{{count}} hôtes sont connectés. Leurs terminaux distants seront fermés." }, "SshTargetForm": { "fea9cb402e": "Annuler", @@ -8922,7 +8959,8 @@ "addDescription": "Ajoutez une machine permanente sur laquelle vous pouvez vous connecter en SSH.", "editingPrefix": "Modification", "allowRemoteCliControl": "Autoriser l'orca CLI de cet hôte à contrôler Orca", - "allowRemoteCliControlHelp": "Activé par défaut pour les nouveaux hôtes. Permet aux commandes orca exécutées sur cet hôte d'ouvrir et de piloter des onglets du navigateur Orca sur ce bureau, uniquement dans les espaces de travail de cet hôte et uniquement tant qu'il est connecté, et aux terminaux lancés par une ancienne version d'Orca sur cet hôte d'exécuter des commandes d'orchestration (check, inbox, send, ask, reply) pour leur propre travail supervisé. Elles ne peuvent pas lire vos fichiers, votre presse-papiers ni vos cookies. Lorsque cette option est désactivée, elles n'atteignent que le serveur Orca de cet hôte." + "allowRemoteCliControlHelp": "Activé par défaut pour les nouveaux hôtes. Permet aux commandes orca exécutées sur cet hôte d'ouvrir et de piloter des onglets du navigateur Orca sur ce bureau, uniquement dans les espaces de travail de cet hôte et uniquement tant qu'il est connecté, et aux terminaux lancés par une ancienne version d'Orca sur cet hôte d'exécuter des commandes d'orchestration (check, inbox, send, ask, reply) pour leur propre travail supervisé. Elles ne peuvent pas lire vos fichiers, votre presse-papiers ni vos cookies. Lorsque cette option est désactivée, elles n'atteignent que le serveur Orca de cet hôte.", + "addHost": "Ajouter un hôte" }, "TasksPane": { "f71d8a9dd3": "Fournisseurs de tâches", @@ -10789,7 +10827,11 @@ "4575341c77": "Ajoutez un serveur Orca distant enregistré, générez une URL d'appairage ou choisissez l'hôte par défaut des nouveaux projets.", "defaultHostKeyword": "hôte par défaut", "newProjectsKeyword": "nouveaux projets", - "activeServerKeyword": "serveur actif" + "activeServerKeyword": "serveur actif", + "addShareDefault": "Ajoutez un serveur Orca distant, partagez cet ordinateur ou choisissez où s’ouvrent les nouveaux projets.", + "shareComputerKeyword": "partager cet ordinateur", + "accessLinkKeyword": "lien d’accès", + "installKeyword": "installer un serveur" } } }, @@ -11380,7 +11422,9 @@ "localMac": "Mac local", "localCredentials": "Les identifiants et vérifications de compte de ce fournisseur appartiennent à ce client de bureau. Pour utiliser un serveur, modifiez Paramètres > Serveurs Orca distants > Avancé > Hôte par défaut des nouveaux projets.", "remoteServerRateLimit": "Le budget d'API de {{value0}} provient de la CLI sur ce serveur distant. Choisissez un autre hôte dans le menu Hôte en haut des Paramètres pour voir son budget.", - "localRateLimit": "Le budget d'API de {{value0}} provient de la CLI sur ce client de bureau. Choisissez un serveur dans le menu Hôte en haut des Paramètres pour voir son budget." + "localRateLimit": "Le budget d'API de {{value0}} provient de la CLI sur ce client de bureau. Choisissez un serveur dans le menu Hôte en haut des Paramètres pour voir son budget.", + "remoteServerCredentialsNewProjects": "Les identifiants et vérifications de compte de ce fournisseur appartiennent à ce serveur distant. Pour utiliser un autre hôte, modifiez Réglages > Serveurs Orca distants > Les nouveaux projets s’ouvrent sur.", + "localCredentialsNewProjects": "Les identifiants et vérifications de compte de ce fournisseur appartiennent à ce client de bureau. Pour utiliser un serveur, modifiez Réglages > Serveurs Orca distants > Les nouveaux projets s’ouvrent sur." }, "settingOwnership": { "clientDefault": "Valeur par défaut du client", @@ -11600,7 +11644,8 @@ "stoppingCleanup": "Arrêt…", "stopCleanup": "Arrêter le nettoyage", "cleanupStopped": "Nettoyage arrêté", - "stopCleanupFailed": "Impossible d'arrêter le nettoyage de la VM Cloud." + "stopCleanupFailed": "Impossible d'arrêter le nettoyage de la VM Cloud.", + "cloudVmRefreshLabel": "Actualiser les Cloud VM" }, "ephemeralVms": { "search": { @@ -12218,14 +12263,24 @@ "saveFailed": "Impossible d'enregistrer l'hôte", "saveFailedHelp": "L'hôte a été vérifié, mais Orca n'a pas pu l'enregistrer. Vérifiez le nom et le stockage local des paramètres, puis réessayez.", "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": "Sur l’autre ordinateur, ouvrez « Partager cet ordinateur » et créez un lien pour « Un autre appareil » avec son adresse Tailscale ou LAN. Si vous y accédez via un tunnel SSH, activez l’option ci-dessous.", + "sshTunnelReach": "J’accède à cette adresse via un tunnel SSH", + "addServerWithTunnel": "Ajouter le serveur via le tunnel", + "addServer": "Ajouter un serveur", + "nameLabel": "Nom", + "optional": "(facultatif)", + "headlessHelpPrefix": "Vous exécutez Orca sans fenêtre ? Sur cet ordinateur, lancez" }, "CloudVmSetupGuide": { "title": "Créer une VM cloud", "description": "Les VM cloud sont créées à partir de recettes d'environnement lors de la création d'un espace de travail.", "setupRecipe": "Configurez une recette d'environnement pour votre fournisseur cloud.", "createWorkspace": "Créez un espace de travail et sélectionnez cette recette sous Run on.", - "openSetup": "Configurer les recettes d'environnement" + "openSetup": "Configurer les recettes d'environnement", + "cardTitle": "Cloud VM", + "cardDescription": "Créées pour chaque espace de travail à partir d’une recette d’environnement.", + "setUpRecipes": "Configurer les recettes" }, "ReleaseChannelSection": { "switchFailed": "Impossible de passer à cette version.", @@ -12596,7 +12651,9 @@ }, "sshManagedHostRemove": { "description": "Orca s’exécute sur {{label}}. Dans Orca sur vos hôtes SSH, utilisez Arrêter… pour retirer Orca de l’hôte, ou Oublier… si l’hôte n’existe plus. Vous pourrez ensuite supprimer cet hôte SSH.", - "open": "Ouvrir Orca sur vos hôtes SSH" + "open": "Ouvrir Orca sur vos hôtes SSH", + "descriptionServers": "Orca tourne sur {{label}}. Dans Serveurs Orca distants, ouvrez le menu ⋯ d’Orca sur {{label}} et choisissez « Arrêter… » pour retirer Orca de l’hôte, ou « Oublier… » si l’hôte n’existe plus. Vous pourrez ensuite supprimer cet hôte SSH.", + "openServers": "Ouvrir Serveurs Orca distants" }, "managedServers": { "row": { @@ -12619,7 +12676,10 @@ "confirmForget": "Oublier sur ce poste", "keepLinked": "Garder lié", "forgetDescription": "Dissocie ce serveur ici sans contacter son hôte. Si l'hôte revient, le serveur y tourne peut-être encore.", - "title": "Orca sur {{host}}" + "title": "Orca sur {{host}}", + "orcaVersion": "Orca sur cet hôte : {{version}}", + "stopOrcaDescription": "Arrête Orca sur l’hôte SSH et le retire de cette liste.", + "updateOrcaOnHost": "Mettre à jour Orca sur cet hôte" }, "title": "Orca sur vos hôtes SSH", "description": "Orca s’installe sur un hôte SSH vide et se maintient à jour.", @@ -12644,7 +12704,10 @@ "pendingLink": "La connexion via SSH n’a pas abouti.", "pendingUnlink": "L’arrêt de l’accès SSH n’a pas abouti.", "retry": "Réessayer", - "cancelLink": "Annuler" + "cancelLink": "Annuler", + "linkedRoute": "Accessible via {{host}}, port du serveur {{port}}.", + "connect": "Connecter", + "chooseHost": "Choisissez un hôte" }, "search": { "title": "Orca sur vos hôtes SSH", @@ -12697,7 +12760,8 @@ "unverifiable": "Non effectué : Orca n'a pas pu confirmer ce qui tourne sur ce serveur. Réessayez lorsque l'hôte est joignable.", "migration": "Non effectué : un déplacement vers ce serveur est inachevé. Reprenez-le d'abord.", "deferred": "Pas encore effectué : le serveur n'est pas prêt pour cela. Réessayez plus tard.", - "failed": "Orca n'a pas pu terminer cette action sur le serveur. Réessayez." + "failed": "Orca n'a pas pu terminer cette action sur le serveur. Réessayez.", + "defaultServer": "Non effectué : ce serveur est l’hôte par défaut des nouveaux projets. Choisissez-en un autre dans « Les nouveaux projets s’ouvrent sur » avant de l’arrêter." }, "reassurance": "S’exécute sur chaque hôte, accessible via votre propre connexion SSH. Votre code et vos fichiers restent où ils sont. Seule la liste des projets, onglets et terminaux d’Orca passe sur l’hôte." }, @@ -12812,14 +12876,16 @@ "connect": "Connecter", "remove": "Supprimer", "turnOn": "Activer dans Expérimental", - "forceUpdate": "Forcer la mise à jour" + "forceUpdate": "Forcer la mise à jour", + "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}} n’accepte plus cet ordinateur. Sur {{name}}, ouvrez Réglages → Serveurs Orca distants → Partager cet ordinateur, créez un lien pour un autre Orca, puis collez-le ici." }, "usageCredentialHost": { "outdated": "Mettez à jour Orca sur {{host}} pour y enregistrer cette clé.", @@ -12862,6 +12928,137 @@ "terminalsRunning": "{{count}} terminaux sont toujours en cours d’exécution sur cet hôte. Fermez-les, puis redémarrez le serveur Orca.", "cancel": "Annuler", "confirm": "Redémarrer" + }, + "SshPaneToolbar": { + "filter": "Filtrer les hôtes", + "sync": "Synchroniser ~/.ssh/config", + "more": "Autres actions sur les hôtes SSH", + "restoreRemoved": "Restaurer les hôtes supprimés…", + "selected": "{{count}} sélectionnés", + "selectAll": "Tout sélectionner ({{count}})", + "clear": "Effacer", + "removeSelected": "Supprimer {{count}}", + "restoreRemovedCount": "Restaurer les hôtes supprimés ({{count}})…", + "hostCount": "{{count}} hôtes", + "selectMatching": "Sélectionner les {{count}} correspondants", + "testSelected": "Tester", + "clearFilter": "Effacer le filtre", + "restoreRemovedLoading": "Restaurer les hôtes supprimés", + "restoreRemovedNone": "Aucun hôte supprimé à restaurer", + "hiddenDeselectedSingular": "{{count}} hôte masqué désélectionné", + "hiddenDeselectedPlural": "{{count}} hôtes masqués désélectionnés" + }, + "SshRestoreRemovedHosts": { + "title": "Restaurer les hôtes supprimés ?", + "description": "Les hôtes de ~/.ssh/config supprimés dans Orca réapparaissent dans les réglages et dans tous les sélecteurs d’hôte.", + "none": "Aucun hôte de ~/.ssh/config supprimé.", + "confirm": "Restaurer" + }, + "SshTargetList": { + "addedInOrca": "Ajoutés dans Orca", + "fromConfig": "Depuis ~/.ssh/config", + "selectGroup": "Tout sélectionner dans ce groupe", + "filteredCount": "{{count}} sur {{total}}", + "selectManualGroup": "Sélectionner tous les hôtes ajoutés dans Orca", + "selectConfigGroup": "Sélectionner tous les hôtes de ~/.ssh/config" + }, + "sshBulkRemove": { + "removedCount": "{{count}} supprimés", + "skippedCount": "{{count}} ignorés", + "failedCount": "{{count}} en échec", + "skippedManaged": "{{label}} a un serveur Orca.", + "skippedWorkspaces": "{{label}} a des espaces de travail.", + "skippedHint": "Supprimez les hôtes ignorés un par un pour choisir ce qu’il advient d’eux." + }, + "sshBulkTest": { + "passed": "{{count}} joignables", + "failed": "{{count}} en échec", + "alreadyConnected": "{{count}} déjà connectés" + }, + "serverList": { + "addTitle": "Ajouter un serveur", + "installNoHosts": "Aucun hôte SSH sans serveur Orca.", + "installHostHelp": "Uniquement les hôtes sans serveur Orca.", + "addSshHost": "Ajouter un hôte SSH", + "cancel": "Annuler", + "moreActions": "Plus d’actions pour {{name}}", + "moreActionsTooltip": "Plus d’actions", + "hideDetails": "Masquer les détails", + "details": "Détails", + "address": "Adresse", + "route": "Itinéraire", + "protocol": "Protocole", + "capabilities": "Fonctionnalités", + "copied": "Erreur copiée", + "copyFailed": "Impossible de copier l’erreur.", + "copyError": "Copier l’erreur", + "updateMenu": "Mettre à jour…", + "makeDefault": "Ouvrir les nouveaux projets ici", + "sshAccessLinked": "Itinéraire SSH…", + "sshAccess": "Se connecter via SSH…", + "remove": "Supprimer…", + "connecting": "Connexion…", + "disconnecting": "Déconnexion…", + "needsUpdate": "Mise à jour requise", + "unreachable": "Injoignable", + "version": "v{{version}}", + "updateAvailable": "v{{version}} disponible", + "throughSsh": "Via {{host}} → port {{port}}", + "manualUpdate": "Mettez à jour Orca sur l’hôte du serveur", + "defaultChip": "Par défaut", + "sshTunnelChip": "Tunnel SSH", + "sshDialogTitle": "Connecter {{name}} via SSH", + "sshDialogDescription": "Utilisez cette option quand le serveur n’est pas joignable directement. Orca ouvre un tunnel via un hôte SSH.", + "defaultHostLabel": "Les nouveaux projets s’ouvrent sur", + "thisComputer": "Cet ordinateur", + "defaultHostHelp": "Les projets existants restent où ils sont.", + "defaultHostWebHelp": "Ce navigateur ouvre les nouveaux projets sur le serveur choisi.", + "updateAll": "Tout mettre à jour ({{count}})", + "addServer": "Ajouter un serveur", + "moreWaysToAdd": "Autres façons d’ajouter un serveur", + "pasteAccessLink": "Coller un lien d’accès", + "checkForUpdates": "Rechercher des mises à jour", + "emptyDescription": "Associez une machine de développement, une machine cloud ou un ordinateur chez vous. Ses sessions continuent de tourner quand cet ordinateur est en veille.", + "movePaused": "Déplacement en pause", + "shareTitle": "Partager cet ordinateur", + "shareDescriptionNamed": "Permettez à un navigateur, un téléphone ou un autre Orca de travailler sur {{name}}.", + "shareDescription": "Permettez à un navigateur, un téléphone ou un autre Orca de travailler sur cet ordinateur.", + "shareButton": "Partager…", + "done": "Terminé", + "unreachableLead": "Orca n’a pas pu joindre ce serveur.", + "updateFailedLead": "La dernière mise à jour n’a pas abouti.", + "unreachableHint": "Vérifiez qu’Orca tourne sur le serveur et que cet ordinateur peut joindre son adresse.", + "tailscaleTip": "C’est une adresse Tailscale : les deux ordinateurs doivent être connectés au même tailnet.", + "loopbackTip": "Une adresse 127.0.0.1 ne fonctionne que via un tunnel SSH.", + "lastSeen": "Vu pour la dernière fois {{when}}", + "retry": "Réessayer", + "pasteAccessLinkHelp": "Associez-vous à un Orca qui tourne déjà sur une autre machine.", + "cloudVm": "Cloud VM", + "cloudVmHelp": "Créées pour chaque espace de travail à partir d’une recette d’environnement.", + "addDescriptionShort": "Collez le lien d’accès de « Partager cet ordinateur » sur l’autre machine.", + "installHostPlaceholder": "Choisissez un hôte SSH sans Orca", + "oneActiveLink": "1 lien actif", + "activeLinks": "{{value0}} liens actifs", + "shareDialogDescriptionRuns": "Toute personne disposant du lien peut exécuter des commandes en votre nom sur cet ordinateur jusqu’à ce que vous le révoquiez.", + "shownAs": "Affiché aux autres sous le nom {{name}}", + "editName": "Modifier", + "close": "Fermer", + "neverConnected": "Jamais connecté", + "noSavedServers": "Aucun serveur enregistré", + "hideError": "Masquer l’erreur", + "showError": "Afficher l’erreur", + "installNamePlaceholder": "Identique à l’hôte SSH", + "defaultHostEmptyLine": "Les nouveaux projets s’ouvrent sur cet ordinateur tant que vous n’ajoutez pas de serveur.", + "setUpDone": "Orca est configuré sur {{name}}.", + "settingUpInBackground": "Configuration d’Orca sur {{name}} en arrière-plan. Il apparaîtra dans la liste une fois prêt.", + "setUpOnSshHost": "Configurer Orca sur un hôte SSH", + "setUp": "Configurer", + "overYourSsh": "Via votre connexion SSH" + }, + "networkAddress": { + "tailscale": "Tailscale · {{address}}", + "lan": "Réseau local · {{address}}", + "loopback": "Cet ordinateur · {{address}}" } }, "right": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index d924975f805..d299976344b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -6460,7 +6460,8 @@ "sshConfigPickerAddAllEmpty": "すべてを Orca に追加", "identityFileFromConfigHint": "意図的に空のままにします: Orca は ~/.ssh/config が {{value0}} に対して解決するすべてのキーを使用します。そのキーのみを使用するパスを入力します。", "sshConfigPickerRetry": "再試行", - "sshConfigPickerResolving": "読む…" + "sshConfigPickerResolving": "読む…", + "pairingHelpShareComputer": "もう一方のコンピューターで「設定 → リモート Orca サーバー → このコンピューターを共有」から作成します。" }, "ForgetSshWorkspaceDialog": { "reconnectFailed": "再接続に失敗しました", @@ -8343,7 +8344,12 @@ "27cf8507ad": "共有アクセスを更新する", "f031182867": "共有サーバーアクセス", "df142657a5": "まだ使用されていません", - "b18d1764ef": "最後に使用したのは {{value0}}" + "b18d1764ef": "最後に使用したのは {{value0}}", + "linkName": "リンク · {{value0}}", + "activeLinks": "有効なリンク", + "noActiveLinks": "有効なリンクはありません。", + "createdAt": "作成 {{value0}}", + "revoke": "取り消す" }, "RuntimeEnvironmentsPane": { "aeb26635d2": "{{value0}} を削除", @@ -8449,7 +8455,10 @@ "repair": "Re-pair…", "serverAccessRevoked": "Access revoked", "orcaVersionLastSeen": "Orca v{{value0}} (last seen)", - "pairedTwice": "{{value0}} と同じサーバーが二重にペアリングされています。プロジェクトを編集できるよう、どちらかを削除してください。" + "pairedTwice": "{{value0}} と同じサーバーが二重にペアリングされています。プロジェクトを編集できるよう、どちらかを削除してください。", + "removeDefaultServerDescription": "このサーバーは新しいプロジェクトの既定のホストです。削除する前に「新しいプロジェクトを開く場所」で別のものを選んでください。既存のセッションはそのままです。", + "connectedNewProjectsHint": "{{value0}} に接続しました。新しいプロジェクトを既定でそこに作成するには、「新しいプロジェクトを開く場所」で選択してください。", + "removeDefaultServerBlocked": "このサーバーは新しいプロジェクトの既定のホストです。削除する前に「新しいプロジェクトを開く場所」で別のものを選んでください。" }, "RuntimePairingGeneratedUrlRows": { "0495f68959": "{{value0}}をコピー" @@ -8502,7 +8511,15 @@ "localLinkHelp": "このリンクは、このコンピュータで実行中のブラウザまたは Orca クライアントでのみ使えます。", "noExternalAddress": "別のデバイス用のアドレスが見つかりません。このコンピュータを LAN または Tailscale に接続して更新するか、カスタムアドレスを選んでください。", "staleAddress": "接続アドレスが変更されました。{{address}} 用の新規リンクを生成してください。", - "customInvalid": "有効なホスト、ホスト:ポート、IPv6 アドレス、または ws(s):// URL を入力してください。" + "customInvalid": "有効なホスト、ホスト:ポート、IPv6 アドレス、または ws(s):// URL を入力してください。", + "thisComputer": "このコンピューター", + "whoOpens": "リンクを開く人", + "addressLabel": "アドレス", + "createLink": "リンクを作成", + "browserLinkLabel": "ブラウザー用リンク", + "accessLinkLabel": "アクセスリンク", + "accessLinkHelp": "もう一方の Orca の「サーバーを追加」に貼り付けます。", + "staleAddressCreate": "接続アドレスが変わりました。{{address}} 用の新しいリンクを作成してください。" }, "Settings": { "3bf149e873": "プロジェクト設定 > {{value0}}", @@ -8580,7 +8597,8 @@ "devDescription": "UI の状態を確認するための開発者専用ツール。", "linearTitle": "Linear", "linearDescription": "Orca での Linear の仕組み、セットアップチェックリスト、Agent スキル、プロンプト例。", - "tasksDescription": "プロバイダーを接続し、Linear スキルをインストールして、タスクに表示する項目を選択します。" + "tasksDescription": "プロバイダーを接続し、Linear スキルをインストールして、タスクに表示する項目を選択します。", + "remoteServersDescription": "別のマシンで Orca を実行し、ここから作業します。" }, "SettingsFormControls": { "42a4d15a30": "一致するフォントがありません。", @@ -8704,7 +8722,8 @@ "2ef2b2674b": "{{value0}} を削除" }, "SshDestructiveActionDialog": { - "895b216267": "キャンセル" + "895b216267": "キャンセル", + "moreTargets": "ほか {{count}} 件" }, "SshPane": { "c0f1c80166": "SSH ターゲットが設定されていません。", @@ -8727,7 +8746,10 @@ "f1fc50dad2": "SSH ターゲットのロードに失敗しました", "0cda732f43": "接続テストに失敗しました", "syncedServerSingular": "{{count}} 台のサーバーを同期しました", - "syncedServerPlural": "{{count}} 台のサーバーを同期しました" + "syncedServerPlural": "{{count}} 台のサーバーを同期しました", + "noFilterMatch": "一致するホストはありません。", + "addHost": "ホストを追加", + "alreadyInSync": "~/.ssh/config はすでに同期済みです" }, "SshPassphraseDialog": { "d5a234456f": "キャンセル", @@ -8758,11 +8780,26 @@ "3d8af2949f": "ターゲットの編集", "18968ede9e": "エラー", "f0871e6bfb": "接続", - "47e94bd6ba": "接続されています" + "47e94bd6ba": "接続されています", + "removing": "削除中…", + "testing": "テスト中…", + "select": "{{label}} を選択", + "orcaServer": "Orca サーバー", + "moreActions": "{{label}} のその他の操作", + "testConnection": "接続をテスト", + "edit": "編集…", + "remove": "削除…" }, "SshTargetDestructiveActions": { "3bb0cf0ee4": "これによりターゲットが削除され、アクティブなリモートターミナルがすべて終了します。", - "4808966c41": "SSH ターゲットの削除" + "4808966c41": "SSH ターゲットの削除", + "bulkTitleSingular": "SSH ホスト {{count}} 件を削除しますか?", + "bulkTitlePlural": "SSH ホスト {{count}} 件を削除しますか?", + "bulkDescription": "削除したホストは、ワークスペース、オートメーション、サイドバーのホスト選択から消えます。~/.ssh/config は変更されず、今後の同期でも戻りません。", + "bulkAction": "{{count}} 件を削除", + "bulkProgress": "{{count}} 件中 {{done}} 件目を削除中…", + "bulkConnectedSingular": "{{count}} 件のホストが接続中です。リモートターミナルは閉じられます。", + "bulkConnectedPlural": "{{count}} 件のホストが接続中です。リモートターミナルは閉じられます。" }, "SshTargetForm": { "fea9cb402e": "キャンセル", @@ -8798,7 +8835,8 @@ "addDescription": "SSH でログインできる永続的なマシンを追加します。", "editingPrefix": "編集中", "allowRemoteCliControl": "このホストの orca CLI による Orca の操作を許可する", - "allowRemoteCliControlHelp": "新しいホストではデフォルトでオンです。このホストで実行される orca コマンドが、このホストのワークスペース内に限り、接続中のみ、このデスクトップの Orca ブラウザのタブを開いて操作できるようにし、このホストで古い Orca が起動したターミナルが自身の監督下の作業についてオーケストレーションコマンド(check、inbox、send、ask、reply)を実行できるようにします。ファイル、クリップボード、Cookie は読み取れません。オフの場合、このホスト自身の Orca サーバーにのみアクセスします。" + "allowRemoteCliControlHelp": "新しいホストではデフォルトでオンです。このホストで実行される orca コマンドが、このホストのワークスペース内に限り、接続中のみ、このデスクトップの Orca ブラウザのタブを開いて操作できるようにし、このホストで古い Orca が起動したターミナルが自身の監督下の作業についてオーケストレーションコマンド(check、inbox、send、ask、reply)を実行できるようにします。ファイル、クリップボード、Cookie は読み取れません。オフの場合、このホスト自身の Orca サーバーにのみアクセスします。", + "addHost": "ホストを追加" }, "TasksPane": { "f71d8a9dd3": "タスクプロバイダー", @@ -10661,7 +10699,11 @@ "4575341c77": "保存済みのリモート Orca サーバーを追加したり、ペアリング URL を生成したり、新規プロジェクトの既定ホストを選択したりします。", "defaultHostKeyword": "既定ホスト", "newProjectsKeyword": "新規プロジェクト", - "activeServerKeyword": "アクティブサーバー" + "activeServerKeyword": "アクティブサーバー", + "addShareDefault": "リモート Orca サーバーの追加、このコンピューターの共有、新しいプロジェクトを開く場所の選択を行います。", + "shareComputerKeyword": "このコンピューターを共有", + "accessLinkKeyword": "アクセスリンク", + "installKeyword": "サーバーをインストール" } } }, @@ -11294,7 +11336,9 @@ "localMac": "ローカルの Mac", "localCredentials": "このプロバイダーの認証情報とアカウントチェックは、このデスクトップクライアントが管理しています。サーバーを使うには、設定 > リモート Orca サーバー > 詳細 > 新規プロジェクトの既定ホスト を変更してください。", "remoteServerRateLimit": "{{value0}} の API 予算はこのリモートサーバー上の CLI から取得しています。別のホストの予算を見るには、設定上部のホストメニューで選択してください。", - "localRateLimit": "{{value0}} の API 予算はこのデスクトップクライアント上の CLI から取得しています。サーバーの予算を見るには、設定上部のホストメニューで選択してください。" + "localRateLimit": "{{value0}} の API 予算はこのデスクトップクライアント上の CLI から取得しています。サーバーの予算を見るには、設定上部のホストメニューで選択してください。", + "remoteServerCredentialsNewProjects": "このプロバイダーの認証情報とアカウント確認はこのリモートサーバーが管理します。別のホストを使うには、設定 > リモート Orca サーバー > 新しいプロジェクトを開く場所 を変更してください。", + "localCredentialsNewProjects": "このプロバイダーの認証情報とアカウント確認はこのデスクトップクライアントが管理します。サーバーを使うには、設定 > リモート Orca サーバー > 新しいプロジェクトを開く場所 を変更してください。" }, "settingOwnership": { "clientDefault": "クライアントのデフォルト", @@ -11514,7 +11558,8 @@ "stoppingCleanup": "停止中…", "stopCleanup": "クリーンアップを停止する", "cleanupStopped": "クリーンアップが停止されました", - "stopCleanupFailed": "Cloud VM のクリーンアップを停止できませんでした。" + "stopCleanupFailed": "Cloud VM のクリーンアップを停止できませんでした。", + "cloudVmRefreshLabel": "Cloud VM を更新" }, "ephemeralVms": { "search": { @@ -12119,14 +12164,24 @@ "saveFailed": "ホストを保存できませんでした", "saveFailedHelp": "ホストは確認されましたが、Orca はホストを保存できませんでした。名前とローカル設定ストレージを確認して、再試行してください。", "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": "もう一方のコンピューターで「このコンピューターを共有」を開き、Tailscale または LAN アドレスで「別のデバイス」用のリンクを作成してください。SSH トンネル経由で接続する場合は、下のオプションをオンにしてください。", + "sshTunnelReach": "このアドレスには SSH トンネル経由で接続する", + "addServerWithTunnel": "トンネル経由でサーバーを追加", + "addServer": "サーバーを追加", + "nameLabel": "名前", + "optional": "(任意)", + "headlessHelpPrefix": "ウィンドウなしで Orca を実行していますか?そのコンピューターで次を実行してください:" }, "CloudVmSetupGuide": { "title": "クラウド VM を作成", "description": "クラウド VM は、ワークスペースの作成時に環境レシピから作成されます。", "setupRecipe": "クラウドプロバイダー用の環境レシピをセットアップします。", "createWorkspace": "ワークスペースを作成し、「実行先」でそのレシピを選択します。", - "openSetup": "環境レシピをセットアップ" + "openSetup": "環境レシピをセットアップ", + "cardTitle": "Cloud VM", + "cardDescription": "環境レシピからワークスペースごとに作成されます。", + "setUpRecipes": "レシピを設定" }, "GlobalWorktreeVisibilitySourcesSetting": { "saveFailed": "表示設定のデフォルトを保存できませんでした。", @@ -12488,7 +12543,9 @@ }, "sshManagedHostRemove": { "description": "{{label}} では Orca が動作しています。[SSH ホスト上の Orca] で「停止…」を使ってホストから Orca を削除するか、ホストがもう存在しない場合は「登録を解除…」を使ってください。その後、この SSH ホストを削除できます。", - "open": "SSH ホスト上の Orca を開く" + "open": "SSH ホスト上の Orca を開く", + "descriptionServers": "Orca は {{label}} で動作しています。「リモート Orca サーバー」で {{label}} 上の Orca の ⋯ メニューを開き、「停止…」でホストから Orca を削除するか、ホストがもう存在しない場合は「削除…」を選んでください。その後、この SSH ホストを削除できます。", + "openServers": "リモート Orca サーバーを開く" }, "managedServers": { "row": { @@ -12511,7 +12568,10 @@ "confirmForget": "このデスクトップから登録を解除", "keepLinked": "リンクを維持", "forgetDescription": "ホストに接続せずに、このサーバーのリンクをここで解除します。ホストが復帰した場合、サーバーはまだそこで動作している可能性があります。", - "title": "{{host}} 上の Orca" + "title": "{{host}} 上の Orca", + "orcaVersion": "このホストの Orca: {{version}}", + "stopOrcaDescription": "SSH ホスト上の Orca を停止し、この一覧から削除します。", + "updateOrcaOnHost": "このホストの Orca を更新" }, "title": "SSH ホスト上の Orca", "description": "Orca は空の SSH ホストに自身をインストールし、常に最新の状態を保ちます。", @@ -12536,7 +12596,10 @@ "pendingLink": "SSH 経由の接続が完了しませんでした。", "pendingUnlink": "SSH アクセスの停止が完了しませんでした。", "retry": "再試行", - "cancelLink": "キャンセル" + "cancelLink": "キャンセル", + "linkedRoute": "{{host}} 経由で接続(サーバーポート {{port}})。", + "connect": "接続", + "chooseHost": "ホストを選択" }, "search": { "title": "SSH ホスト上の Orca", @@ -12589,7 +12652,8 @@ "unverifiable": "未完了: Orca はこのサーバーで何が実行中かを確認できませんでした。ホストに到達できるときに、もう一度お試しください。", "migration": "未完了: このサーバーへの移動が終わっていません。先に再開してください。", "deferred": "まだ未完了です: サーバーはこの操作の準備ができていません。しばらくしてから、もう一度お試しください。", - "failed": "Orca はサーバー上でこれを完了できませんでした。もう一度お試しください。" + "failed": "Orca はサーバー上でこれを完了できませんでした。もう一度お試しください。", + "defaultServer": "実行されませんでした: このサーバーは新しいプロジェクトの既定のホストです。停止する前に「新しいプロジェクトを開く場所」で別のものを選んでください。" }, "reassurance": "各ホスト上で動作し、あなた自身の SSH 接続を通じて利用します。コードやファイルは今の場所から動きません。ホストに移るのは、Orca のプロジェクト、タブ、ターミナルの一覧だけです。" }, @@ -12704,14 +12768,16 @@ "connect": "接続", "remove": "削除", "turnOn": "実験的機能でオンにする", - "forceUpdate": "強制更新" + "forceUpdate": "強制更新", + "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}} はこのコンピューターを受け付けなくなりました。{{name}} で「設定 → リモート Orca サーバー → このコンピューターを共有」を開き、別の Orca 用のリンクを作成してここに貼り付けてください。" }, "usageCredentialHost": { "outdated": "このキーを {{host}} に保存するには、そのホストの Orca を更新してください。", @@ -12754,6 +12820,137 @@ "terminalsRunning": "このホストでは {{count}} 個のターミナルがまだ実行中です。閉じてから Orca サーバーを再起動してください。", "cancel": "キャンセル", "confirm": "再起動" + }, + "SshPaneToolbar": { + "filter": "ホストを絞り込む", + "sync": "~/.ssh/config を同期", + "more": "SSH ホストのその他の操作", + "restoreRemoved": "削除したホストを復元…", + "selected": "{{count}} 件を選択中", + "selectAll": "{{count}} 件すべてを選択", + "clear": "選択解除", + "removeSelected": "{{count}} 件を削除", + "restoreRemovedCount": "削除したホストを復元({{count}})…", + "hostCount": "{{count}} 件のホスト", + "selectMatching": "一致する {{count}} 件をすべて選択", + "testSelected": "テスト", + "clearFilter": "フィルターをクリア", + "restoreRemovedLoading": "削除したホストを復元", + "restoreRemovedNone": "復元できる削除済みホストはありません", + "hiddenDeselectedSingular": "非表示のホスト {{count}} 件の選択を解除しました", + "hiddenDeselectedPlural": "非表示のホスト {{count}} 件の選択を解除しました" + }, + "SshRestoreRemovedHosts": { + "title": "削除したホストを復元しますか?", + "description": "Orca で削除した ~/.ssh/config のホストが、設定とすべてのホスト選択に戻ります。", + "none": "~/.ssh/config から削除したホストはありません。", + "confirm": "復元" + }, + "SshTargetList": { + "addedInOrca": "Orca で追加", + "fromConfig": "~/.ssh/config から", + "selectGroup": "このグループをすべて選択", + "filteredCount": "{{total}} 件中 {{count}} 件", + "selectManualGroup": "Orca で追加したホストをすべて選択", + "selectConfigGroup": "~/.ssh/config のホストをすべて選択" + }, + "sshBulkRemove": { + "removedCount": "{{count}} 件を削除", + "skippedCount": "{{count}} 件をスキップ", + "failedCount": "{{count}} 件が失敗", + "skippedManaged": "{{label}} には Orca サーバーがあります。", + "skippedWorkspaces": "{{label}} にはワークスペースがあります。", + "skippedHint": "スキップしたホストは 1 件ずつ削除して、扱い方を選んでください。" + }, + "sshBulkTest": { + "passed": "{{count}} 件に接続可能", + "failed": "{{count}} 件が失敗", + "alreadyConnected": "{{count}} 件は接続済み" + }, + "serverList": { + "addTitle": "サーバーを追加", + "installNoHosts": "Orca サーバーのない SSH ホストはありません。", + "installHostHelp": "Orca サーバーのないホストのみ。", + "addSshHost": "SSH ホストを追加", + "cancel": "キャンセル", + "moreActions": "{{name}} のその他の操作", + "moreActionsTooltip": "その他の操作", + "hideDetails": "詳細を隠す", + "details": "詳細", + "address": "アドレス", + "route": "経路", + "protocol": "プロトコル", + "capabilities": "機能", + "copied": "エラーをコピーしました", + "copyFailed": "エラーをコピーできませんでした。", + "copyError": "エラーをコピー", + "updateMenu": "更新…", + "makeDefault": "新しいプロジェクトをここで開く", + "sshAccessLinked": "SSH 経路…", + "sshAccess": "SSH 経由で接続…", + "remove": "削除…", + "connecting": "接続中…", + "disconnecting": "切断中…", + "needsUpdate": "更新が必要", + "unreachable": "到達できません", + "version": "v{{version}}", + "updateAvailable": "v{{version}} が利用可能", + "throughSsh": "{{host}} 経由 → ポート {{port}}", + "manualUpdate": "サーバーホストで Orca を更新してください", + "defaultChip": "既定", + "sshTunnelChip": "SSH トンネル", + "sshDialogTitle": "SSH 経由で {{name}} に接続", + "sshDialogDescription": "サーバーに直接接続できない場合に使います。Orca が SSH ホスト経由でトンネルを開きます。", + "defaultHostLabel": "新しいプロジェクトを開く場所", + "thisComputer": "このコンピューター", + "defaultHostHelp": "既存のプロジェクトはそのままです。", + "defaultHostWebHelp": "このブラウザーは新しいプロジェクトを選択したサーバーで開きます。", + "updateAll": "すべて更新 ({{count}})", + "addServer": "サーバーを追加", + "moreWaysToAdd": "サーバーを追加するその他の方法", + "pasteAccessLink": "アクセスリンクを貼り付け", + "checkForUpdates": "アップデートを確認", + "emptyDescription": "開発マシン、クラウドマシン、自宅のデスクトップをペアリングします。このコンピューターがスリープしても、セッションはそこで動き続けます。", + "movePaused": "移行を一時停止中", + "shareTitle": "このコンピューターを共有", + "shareDescriptionNamed": "ブラウザー、スマートフォン、別の Orca から {{name}} で作業できるようにします。", + "shareDescription": "ブラウザー、スマートフォン、別の Orca からこのコンピューターで作業できるようにします。", + "shareButton": "共有…", + "done": "完了", + "unreachableLead": "Orca はこのサーバーに接続できませんでした。", + "updateFailedLead": "前回の更新は完了しませんでした。", + "unreachableHint": "サーバーで Orca が動作していて、このコンピューターからそのアドレスに到達できるか確認してください。", + "tailscaleTip": "これは Tailscale アドレスなので、両方のコンピューターが同じ tailnet にサインインしている必要があります。", + "loopbackTip": "127.0.0.1 のアドレスは SSH トンネル経由でのみ機能します。", + "lastSeen": "最終確認 {{when}}", + "retry": "再試行", + "pasteAccessLinkHelp": "別のマシンですでに動作している Orca とペアリングします。", + "cloudVm": "Cloud VM", + "cloudVmHelp": "環境レシピからワークスペースごとに作成されます。", + "addDescriptionShort": "もう一方のマシンの「このコンピューターを共有」で作成したアクセスリンクを貼り付けます。", + "installHostPlaceholder": "Orca のない SSH ホストを選択", + "oneActiveLink": "有効なリンク 1 件", + "activeLinks": "有効なリンク {{value0}} 件", + "shareDialogDescriptionRuns": "リンクを持つ人は、取り消すまでこのコンピューターであなたとしてコマンドを実行できます。", + "shownAs": "他のユーザーには {{name}} と表示", + "editName": "編集", + "close": "閉じる", + "neverConnected": "未接続", + "noSavedServers": "保存済みのサーバーはありません", + "hideError": "エラーを隠す", + "showError": "エラーを表示", + "installNamePlaceholder": "SSH ホストと同じ", + "defaultHostEmptyLine": "サーバーを追加するまで、新しいプロジェクトはこのコンピューターで開きます。", + "setUpDone": "{{name}} に Orca をセットアップしました。", + "settingUpInBackground": "{{name}} に Orca をバックグラウンドでセットアップしています。準備ができるとリストに表示されます。", + "setUpOnSshHost": "SSH ホストに Orca をセットアップ", + "setUp": "セットアップ", + "overYourSsh": "あなたの SSH 接続経由" + }, + "networkAddress": { + "tailscale": "Tailscale · {{address}}", + "lan": "ローカルネットワーク · {{address}}", + "loopback": "このコンピューター · {{address}}" } }, "right": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 09b1c526c51..34c89ca3c5a 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -6460,7 +6460,8 @@ "sshConfigPickerAddAllEmpty": "Orca에 모두 추가", "identityFileFromConfigHint": "의도적으로 비워 두기: Orca는 ~/.ssh/config가 {{value0}}을 확인하는 모든 키를 사용합니다. 해당 키만 사용하려면 경로를 입력하세요.", "sshConfigPickerRetry": "다시 시도하세요", - "sshConfigPickerResolving": "독서…" + "sshConfigPickerResolving": "독서…", + "pairingHelpShareComputer": "다른 컴퓨터의 설정 → 원격 Orca 서버 → 이 컴퓨터 공유에서 만드세요." }, "ForgetSshWorkspaceDialog": { "reconnectFailed": "재연결 실패", @@ -8306,7 +8307,12 @@ "27cf8507ad": "공유 액세스 새로 고침", "f031182867": "공유 서버 액세스", "df142657a5": "아직 사용되지 않음", - "b18d1764ef": "마지막으로 사용된 날짜: {{value0}}" + "b18d1764ef": "마지막으로 사용된 날짜: {{value0}}", + "linkName": "링크 · {{value0}}", + "activeLinks": "활성 링크", + "noActiveLinks": "활성 링크가 없습니다.", + "createdAt": "생성 {{value0}}", + "revoke": "취소" }, "RuntimeEnvironmentsPane": { "aeb26635d2": "{{value0}} 제거", @@ -8412,7 +8418,10 @@ "repair": "Re-pair…", "serverAccessRevoked": "Access revoked", "orcaVersionLastSeen": "Orca v{{value0}} (last seen)", - "pairedTwice": "{{value0}}와(과) 같은 서버가 두 번 페어링되었습니다. 프로젝트를 편집하려면 하나를 제거하세요." + "pairedTwice": "{{value0}}와(과) 같은 서버가 두 번 페어링되었습니다. 프로젝트를 편집하려면 하나를 제거하세요.", + "removeDefaultServerDescription": "이 서버는 새 프로젝트의 기본 호스트입니다. 제거하기 전에 '새 프로젝트를 여는 위치'에서 다른 서버를 선택하세요. 기존 세션은 그대로 유지됩니다.", + "connectedNewProjectsHint": "{{value0}}에 연결되었습니다. 새 프로젝트를 기본적으로 이곳에 만들려면 '새 프로젝트를 여는 위치'에서 선택하세요.", + "removeDefaultServerBlocked": "이 서버는 새 프로젝트의 기본 호스트입니다. 제거하기 전에 '새 프로젝트를 여는 위치'에서 다른 서버를 선택하세요." }, "RuntimePairingGeneratedUrlRows": { "0495f68959": "{{value0}} 복사" @@ -8465,7 +8474,15 @@ "localLinkHelp": "이 링크는 이 컴퓨터에서 실행 중인 브라우저 또는 Orca 클라이언트에서만 작동합니다.", "noExternalAddress": "다른 기기용 주소를 찾지 못했습니다. 이 컴퓨터를 LAN 또는 Tailscale에 연결한 후 새로 고치거나 사용자 지정 주소를 선택하세요.", "staleAddress": "연결 주소가 변경되었습니다. {{address}}용 새 링크를 생성하세요.", - "customInvalid": "올바른 호스트, 호스트:포트, IPv6 주소 또는 ws(s):// URL을 입력하세요." + "customInvalid": "올바른 호스트, 호스트:포트, IPv6 주소 또는 ws(s):// URL을 입력하세요.", + "thisComputer": "이 컴퓨터", + "whoOpens": "링크를 열 사람", + "addressLabel": "주소", + "createLink": "링크 만들기", + "browserLinkLabel": "브라우저 링크", + "accessLinkLabel": "액세스 링크", + "accessLinkHelp": "다른 Orca의 '서버 추가'에 붙여 넣으세요.", + "staleAddressCreate": "연결 주소가 바뀌었습니다. {{address}}용 새 링크를 만드세요." }, "Settings": { "3bf149e873": "프로젝트 설정 > {{value0}}", @@ -8543,7 +8560,8 @@ "devDescription": "UI 상태를 시험해 보기 위한 개발 전용 도구입니다.", "linearTitle": "Linear", "linearDescription": "Orca에서 Linear가 작동하는 방식, 설정 체크리스트, 에이전트 스킬, 프롬프트 예시입니다.", - "tasksDescription": "제공업체를 연결하고 Linear 스킬을 설치한 다음 작업에 표시할 항목을 선택하세요." + "tasksDescription": "제공업체를 연결하고 Linear 스킬을 설치한 다음 작업에 표시할 항목을 선택하세요.", + "remoteServersDescription": "다른 컴퓨터에서 Orca를 실행하고 여기에서 작업하세요." }, "SettingsFormControls": { "42a4d15a30": "일치하는 글꼴이 없습니다.", @@ -8667,7 +8685,8 @@ "2ef2b2674b": "{{value0}} 삭제" }, "SshDestructiveActionDialog": { - "895b216267": "취소" + "895b216267": "취소", + "moreTargets": "외 {{count}}개" }, "SshPane": { "c0f1c80166": "구성된 SSH 대상이 없습니다.", @@ -8690,7 +8709,10 @@ "f1fc50dad2": "SSH 대상을 로드하지 못했습니다.", "0cda732f43": "연결 테스트 실패", "syncedServerSingular": "서버 {{count}}개를 동기화했습니다.", - "syncedServerPlural": "서버 {{count}}개를 동기화했습니다." + "syncedServerPlural": "서버 {{count}}개를 동기화했습니다.", + "noFilterMatch": "일치하는 호스트가 없습니다.", + "addHost": "호스트 추가", + "alreadyInSync": "~/.ssh/config가 이미 동기화되어 있습니다." }, "SshPassphraseDialog": { "d5a234456f": "취소", @@ -8721,11 +8743,26 @@ "3d8af2949f": "대상 편집", "18968ede9e": "오류", "f0871e6bfb": "연결", - "47e94bd6ba": "연결됨" + "47e94bd6ba": "연결됨", + "removing": "제거 중…", + "testing": "테스트 중…", + "select": "{{label}} 선택", + "orcaServer": "Orca 서버", + "moreActions": "{{label}} 작업 더 보기", + "testConnection": "연결 테스트", + "edit": "편집…", + "remove": "제거…" }, "SshTargetDestructiveActions": { "3bb0cf0ee4": "그러면 대상이 제거되고 활성 원격 terminals이 종료됩니다.", - "4808966c41": "SSH 대상 제거" + "4808966c41": "SSH 대상 제거", + "bulkTitleSingular": "SSH 호스트 {{count}}개를 제거할까요?", + "bulkTitlePlural": "SSH 호스트 {{count}}개를 제거할까요?", + "bulkDescription": "제거한 호스트는 워크스페이스, 자동화, 사이드바의 호스트 선택 목록에서 사라집니다. ~/.ssh/config는 변경되지 않으며, 이후 동기화해도 다시 추가되지 않습니다.", + "bulkAction": "{{count}}개 제거", + "bulkProgress": "{{count}}개 중 {{done}}번째 제거 중…", + "bulkConnectedSingular": "호스트 {{count}}개가 연결되어 있습니다. 원격 터미널이 닫힙니다.", + "bulkConnectedPlural": "호스트 {{count}}개가 연결되어 있습니다. 원격 터미널이 닫힙니다." }, "SshTargetForm": { "fea9cb402e": "취소", @@ -8761,7 +8798,8 @@ "addDescription": "SSH로 로그인할 수 있는 영구 머신을 추가합니다.", "editingPrefix": "편집 중", "allowRemoteCliControl": "이 호스트의 orca CLI가 Orca를 제어하도록 허용", - "allowRemoteCliControlHelp": "새 호스트에서는 기본적으로 켜져 있습니다. 이 호스트에서 실행한 orca 명령이 이 호스트의 작업 공간에서만, 연결되어 있는 동안에만 이 데스크톱의 Orca 브라우저 탭을 열고 조작할 수 있게 하고, 이 호스트에서 이전 버전의 Orca가 시작한 터미널이 자신이 감독받는 작업에 대해 오케스트레이션 명령(check, inbox, send, ask, reply)을 실행할 수 있게 합니다. 파일, 클립보드, 쿠키는 읽을 수 없습니다. 끄면 이 호스트 자체의 Orca 서버에만 접근합니다." + "allowRemoteCliControlHelp": "새 호스트에서는 기본적으로 켜져 있습니다. 이 호스트에서 실행한 orca 명령이 이 호스트의 작업 공간에서만, 연결되어 있는 동안에만 이 데스크톱의 Orca 브라우저 탭을 열고 조작할 수 있게 하고, 이 호스트에서 이전 버전의 Orca가 시작한 터미널이 자신이 감독받는 작업에 대해 오케스트레이션 명령(check, inbox, send, ask, reply)을 실행할 수 있게 합니다. 파일, 클립보드, 쿠키는 읽을 수 없습니다. 끄면 이 호스트 자체의 Orca 서버에만 접근합니다.", + "addHost": "호스트 추가" }, "TasksPane": { "f71d8a9dd3": "작업 제공자", @@ -10624,7 +10662,11 @@ "4575341c77": "저장된 원격 Orca 서버를 추가하거나, 페어링 URL을 생성하거나, 새 프로젝트의 기본 호스트를 선택합니다.", "defaultHostKeyword": "기본 호스트", "newProjectsKeyword": "새 프로젝트", - "activeServerKeyword": "활성 서버" + "activeServerKeyword": "활성 서버", + "addShareDefault": "원격 Orca 서버를 추가하거나, 이 컴퓨터를 공유하거나, 새 프로젝트를 열 위치를 선택하세요.", + "shareComputerKeyword": "이 컴퓨터 공유", + "accessLinkKeyword": "액세스 링크", + "installKeyword": "서버 설치" } } }, @@ -11294,7 +11336,9 @@ "localMac": "로컬 Mac", "localCredentials": "이 공급자의 자격 증명과 계정 확인은 이 데스크톱 클라이언트가 관리합니다. 서버를 사용하려면 설정 > 원격 Orca 서버 > 고급 > 새 프로젝트의 기본 호스트를 변경하세요.", "remoteServerRateLimit": "{{value0}} API 예산은 이 원격 서버의 CLI에서 가져옵니다. 다른 호스트의 예산을 보려면 설정 상단의 호스트 메뉴에서 선택하세요.", - "localRateLimit": "{{value0}} API 예산은 이 데스크톱 클라이언트의 CLI에서 가져옵니다. 서버의 예산을 보려면 설정 상단의 호스트 메뉴에서 선택하세요." + "localRateLimit": "{{value0}} API 예산은 이 데스크톱 클라이언트의 CLI에서 가져옵니다. 서버의 예산을 보려면 설정 상단의 호스트 메뉴에서 선택하세요.", + "remoteServerCredentialsNewProjects": "이 공급자의 자격 증명과 계정 확인은 이 원격 서버가 관리합니다. 다른 호스트를 사용하려면 설정 > 원격 Orca 서버 > 새 프로젝트를 여는 위치를 변경하세요.", + "localCredentialsNewProjects": "이 공급자의 자격 증명과 계정 확인은 이 데스크톱 클라이언트가 관리합니다. 서버를 사용하려면 설정 > 원격 Orca 서버 > 새 프로젝트를 여는 위치를 변경하세요." }, "settingOwnership": { "clientDefault": "클라이언트 기본값", @@ -11514,7 +11558,8 @@ "stoppingCleanup": "중지 중…", "stopCleanup": "정리 중지", "cleanupStopped": "정리가 중지되었습니다.", - "stopCleanupFailed": "클라우드 VM 정리를 중지할 수 없습니다." + "stopCleanupFailed": "클라우드 VM 정리를 중지할 수 없습니다.", + "cloudVmRefreshLabel": "Cloud VM 새로 고침" }, "ephemeralVms": { "search": { @@ -12119,14 +12164,24 @@ "saveFailed": "호스트를 저장할 수 없습니다.", "saveFailedHelp": "호스트가 확인되었지만 Orca가 호스트를 저장할 수 없습니다. 이름과 로컬 설정 저장소를 확인한 후 다시 시도하세요.", "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": "다른 컴퓨터에서 '이 컴퓨터 공유'를 열고 Tailscale 또는 LAN 주소로 '다른 기기'용 링크를 만드세요. SSH 터널로 연결한다면 아래 옵션을 켜세요.", + "sshTunnelReach": "SSH 터널을 통해 이 주소에 연결합니다", + "addServerWithTunnel": "터널로 서버 추가", + "addServer": "서버 추가", + "nameLabel": "이름", + "optional": "(선택 사항)", + "headlessHelpPrefix": "창 없이 Orca를 실행하나요? 그 컴퓨터에서 다음을 실행하세요:" }, "CloudVmSetupGuide": { "title": "클라우드 VM 만들기", "description": "클라우드 VM은 워크스페이스를 만들 때 환경 레시피에서 생성됩니다.", "setupRecipe": "클라우드 공급자를 위한 환경 레시피를 설정하세요.", "createWorkspace": "워크스페이스를 만들고 실행 위치에서 해당 레시피를 선택하세요.", - "openSetup": "환경 레시피 설정" + "openSetup": "환경 레시피 설정", + "cardTitle": "Cloud VM", + "cardDescription": "환경 레시피로 작업 공간마다 생성됩니다.", + "setUpRecipes": "레시피 설정" }, "GlobalWorktreeVisibilitySourcesSetting": { "saveFailed": "가시성 기본값을 저장할 수 없습니다.", @@ -12488,7 +12543,9 @@ }, "sshManagedHostRemove": { "description": "{{label}}에서 Orca가 실행 중입니다. SSH 호스트의 Orca에서 중지…를 사용해 호스트에서 Orca를 제거하거나, 호스트가 없어졌다면 잊기…를 사용하세요. 그런 다음 이 SSH 호스트를 제거할 수 있습니다.", - "open": "SSH 호스트의 Orca 열기" + "open": "SSH 호스트의 Orca 열기", + "descriptionServers": "Orca가 {{label}}에서 실행 중입니다. 원격 Orca 서버에서 {{label}}의 Orca ⋯ 메뉴를 열고 '중지…'를 선택해 호스트에서 Orca를 제거하거나, 호스트가 없어졌다면 '삭제…'를 선택하세요. 그런 다음 이 SSH 호스트를 제거할 수 있습니다.", + "openServers": "원격 Orca 서버 열기" }, "managedServers": { "row": { @@ -12511,7 +12568,10 @@ "confirmForget": "이 데스크톱에서 잊기", "keepLinked": "연결 유지", "forgetDescription": "호스트에 연결하지 않고 여기서 이 서버의 연결을 해제합니다. 호스트가 다시 살아나면 서버가 그곳에서 계속 실행 중일 수 있습니다.", - "title": "{{host}}의 Orca" + "title": "{{host}}의 Orca", + "orcaVersion": "이 호스트의 Orca: {{version}}", + "stopOrcaDescription": "SSH 호스트에서 Orca를 중지하고 이 목록에서 제거합니다.", + "updateOrcaOnHost": "이 호스트의 Orca 업데이트" }, "title": "SSH 호스트의 Orca", "description": "Orca는 빈 SSH 호스트에 자신을 설치하고 스스로 최신 상태를 유지합니다.", @@ -12536,7 +12596,10 @@ "pendingLink": "SSH를 통한 연결이 완료되지 않았습니다.", "pendingUnlink": "SSH 액세스 중지가 완료되지 않았습니다.", "retry": "다시 시도", - "cancelLink": "취소" + "cancelLink": "취소", + "linkedRoute": "{{host}}를 통해 연결됨, 서버 포트 {{port}}.", + "connect": "연결", + "chooseHost": "호스트 선택" }, "search": { "title": "SSH 호스트의 Orca", @@ -12589,7 +12652,8 @@ "unverifiable": "완료되지 않음: Orca가 이 서버에서 무엇이 실행 중인지 확인하지 못했습니다. 호스트에 연결할 수 있을 때 다시 시도하세요.", "migration": "완료되지 않음: 이 서버로의 이동이 끝나지 않았습니다. 먼저 재개하세요.", "deferred": "아직 완료되지 않음: 서버가 아직 준비되지 않았습니다. 나중에 다시 시도하세요.", - "failed": "Orca가 서버에서 이 작업을 완료하지 못했습니다. 다시 시도하세요." + "failed": "Orca가 서버에서 이 작업을 완료하지 못했습니다. 다시 시도하세요.", + "defaultServer": "완료되지 않음: 이 서버는 새 프로젝트의 기본 호스트입니다. 중지하기 전에 '새 프로젝트를 여는 위치'에서 다른 서버를 선택하세요." }, "reassurance": "각 호스트에서 실행되며 사용자 본인의 SSH 연결로 접속합니다. 코드와 파일은 지금 있는 곳에 그대로 있습니다. 호스트로 옮겨지는 것은 Orca의 프로젝트, 탭, 터미널 목록뿐입니다." }, @@ -12704,14 +12768,16 @@ "connect": "연결", "remove": "제거", "turnOn": "실험적에서 켜기", - "forceUpdate": "강제 업데이트" + "forceUpdate": "강제 업데이트", + "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}}이(가) 더 이상 이 컴퓨터를 허용하지 않습니다. {{name}}에서 설정 → 원격 Orca 서버 → 이 컴퓨터 공유를 열고 다른 Orca용 링크를 만든 다음 여기에 붙여 넣으세요." }, "usageCredentialHost": { "outdated": "이 키를 {{host}}에 저장하려면 해당 호스트의 Orca를 업데이트하세요.", @@ -12754,6 +12820,137 @@ "terminalsRunning": "이 호스트에서 터미널 {{count}}개가 아직 실행 중입니다. 닫은 후 Orca 서버를 다시 시작하세요.", "cancel": "취소", "confirm": "다시 시작" + }, + "SshPaneToolbar": { + "filter": "호스트 필터", + "sync": "~/.ssh/config 동기화", + "more": "SSH 호스트 작업 더 보기", + "restoreRemoved": "제거한 호스트 복원…", + "selected": "{{count}}개 선택됨", + "selectAll": "{{count}}개 모두 선택", + "clear": "선택 해제", + "removeSelected": "{{count}}개 제거", + "restoreRemovedCount": "제거한 호스트 복원({{count}})…", + "hostCount": "호스트 {{count}}개", + "selectMatching": "일치하는 {{count}}개 모두 선택", + "testSelected": "테스트", + "clearFilter": "필터 지우기", + "restoreRemovedLoading": "제거한 호스트 복원", + "restoreRemovedNone": "복원할 제거된 호스트가 없습니다", + "hiddenDeselectedSingular": "숨겨진 호스트 {{count}}개 선택 해제됨", + "hiddenDeselectedPlural": "숨겨진 호스트 {{count}}개 선택 해제됨" + }, + "SshRestoreRemovedHosts": { + "title": "제거한 호스트를 복원할까요?", + "description": "Orca에서 제거한 ~/.ssh/config 호스트가 설정과 모든 호스트 선택 목록에 다시 표시됩니다.", + "none": "~/.ssh/config에서 제거한 호스트가 없습니다.", + "confirm": "복원" + }, + "SshTargetList": { + "addedInOrca": "Orca에서 추가함", + "fromConfig": "~/.ssh/config에서 가져옴", + "selectGroup": "이 그룹 모두 선택", + "filteredCount": "{{total}}개 중 {{count}}개", + "selectManualGroup": "Orca에서 추가한 호스트 모두 선택", + "selectConfigGroup": "~/.ssh/config의 호스트 모두 선택" + }, + "sshBulkRemove": { + "removedCount": "{{count}}개 제거됨", + "skippedCount": "{{count}}개 건너뜀", + "failedCount": "{{count}}개 실패", + "skippedManaged": "{{label}}에 Orca 서버가 있습니다.", + "skippedWorkspaces": "{{label}}에 워크스페이스가 있습니다.", + "skippedHint": "건너뛴 호스트는 하나씩 제거하면서 처리 방법을 선택하세요." + }, + "sshBulkTest": { + "passed": "{{count}}개 연결 가능", + "failed": "{{count}}개 실패", + "alreadyConnected": "{{count}}개 이미 연결됨" + }, + "serverList": { + "addTitle": "서버 추가", + "installNoHosts": "Orca 서버가 없는 SSH 호스트가 없습니다.", + "installHostHelp": "Orca 서버가 없는 호스트만 표시됩니다.", + "addSshHost": "SSH 호스트 추가", + "cancel": "취소", + "moreActions": "{{name}}에 대한 추가 작업", + "moreActionsTooltip": "추가 작업", + "hideDetails": "세부 정보 숨기기", + "details": "세부 정보", + "address": "주소", + "route": "경로", + "protocol": "프로토콜", + "capabilities": "기능", + "copied": "오류를 복사했습니다", + "copyFailed": "오류를 복사하지 못했습니다.", + "copyError": "오류 복사", + "updateMenu": "업데이트…", + "makeDefault": "새 프로젝트를 여기에서 열기", + "sshAccessLinked": "SSH 경로…", + "sshAccess": "SSH로 연결…", + "remove": "제거…", + "connecting": "연결 중…", + "disconnecting": "연결 해제 중…", + "needsUpdate": "업데이트 필요", + "unreachable": "연결할 수 없음", + "version": "v{{version}}", + "updateAvailable": "v{{version}} 사용 가능", + "throughSsh": "{{host}} 경유 → 포트 {{port}}", + "manualUpdate": "서버 호스트에서 Orca를 업데이트하세요", + "defaultChip": "기본", + "sshTunnelChip": "SSH 터널", + "sshDialogTitle": "SSH로 {{name}} 연결", + "sshDialogDescription": "서버에 직접 연결할 수 없을 때 사용하세요. Orca가 SSH 호스트를 통해 터널을 엽니다.", + "defaultHostLabel": "새 프로젝트를 여는 위치", + "thisComputer": "이 컴퓨터", + "defaultHostHelp": "기존 프로젝트는 그대로 유지됩니다.", + "defaultHostWebHelp": "이 브라우저는 선택한 서버에서 새 프로젝트를 엽니다.", + "updateAll": "모두 업데이트({{count}})", + "addServer": "서버 추가", + "moreWaysToAdd": "서버를 추가하는 다른 방법", + "pasteAccessLink": "액세스 링크 붙여 넣기", + "checkForUpdates": "업데이트 확인", + "emptyDescription": "개발 머신, 클라우드 머신 또는 집에 있는 데스크톱을 페어링하세요. 이 컴퓨터가 절전 모드여도 세션은 그곳에서 계속 실행됩니다.", + "movePaused": "이전 일시 중지됨", + "shareTitle": "이 컴퓨터 공유", + "shareDescriptionNamed": "브라우저, 휴대폰 또는 다른 Orca에서 {{name}}로 작업할 수 있게 합니다.", + "shareDescription": "브라우저, 휴대폰 또는 다른 Orca에서 이 컴퓨터로 작업할 수 있게 합니다.", + "shareButton": "공유…", + "done": "완료", + "unreachableLead": "Orca가 이 서버에 연결하지 못했습니다.", + "updateFailedLead": "마지막 업데이트가 완료되지 않았습니다.", + "unreachableHint": "서버에서 Orca가 실행 중이고 이 컴퓨터가 해당 주소에 연결할 수 있는지 확인하세요.", + "tailscaleTip": "Tailscale 주소이므로 두 컴퓨터 모두 같은 tailnet에 로그인되어 있어야 합니다.", + "loopbackTip": "127.0.0.1 주소는 SSH 터널을 통해서만 작동합니다.", + "lastSeen": "마지막 확인 {{when}}", + "retry": "다시 시도", + "pasteAccessLinkHelp": "다른 컴퓨터에서 이미 실행 중인 Orca와 페어링합니다.", + "cloudVm": "Cloud VM", + "cloudVmHelp": "환경 레시피로 작업 공간마다 생성됩니다.", + "addDescriptionShort": "다른 컴퓨터의 '이 컴퓨터 공유'에서 만든 액세스 링크를 붙여 넣으세요.", + "installHostPlaceholder": "Orca가 없는 SSH 호스트 선택", + "oneActiveLink": "활성 링크 1개", + "activeLinks": "활성 링크 {{value0}}개", + "shareDialogDescriptionRuns": "링크가 있는 사람은 취소할 때까지 이 컴퓨터에서 사용자 권한으로 명령을 실행할 수 있습니다.", + "shownAs": "다른 사람에게 {{name}}(으)로 표시됨", + "editName": "편집", + "close": "닫기", + "neverConnected": "연결된 적 없음", + "noSavedServers": "저장된 서버 없음", + "hideError": "오류 숨기기", + "showError": "오류 표시", + "installNamePlaceholder": "SSH 호스트와 동일", + "defaultHostEmptyLine": "서버를 추가하기 전까지 새 프로젝트는 이 컴퓨터에서 열립니다.", + "setUpDone": "{{name}}에 Orca를 설정했습니다.", + "settingUpInBackground": "{{name}}에 Orca를 백그라운드에서 설정하는 중입니다. 준비되면 목록에 표시됩니다.", + "setUpOnSshHost": "SSH 호스트에 Orca 설정", + "setUp": "설정", + "overYourSsh": "내 SSH 연결을 통해" + }, + "networkAddress": { + "tailscale": "Tailscale · {{address}}", + "lan": "로컬 네트워크 · {{address}}", + "loopback": "이 컴퓨터 · {{address}}" } }, "right": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 7757c52bcba..ad7f5f170fd 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -6460,7 +6460,8 @@ "sshConfigPickerAddAllEmpty": "全部添加到 Orca", "identityFileFromConfigHint": "故意留空:Orca 使用 ~/.ssh/config 解析为 {{value0}} 的每个密钥。键入一个路径以仅使用该键。", "sshConfigPickerRetry": "重试", - "sshConfigPickerResolving": "阅读…" + "sshConfigPickerResolving": "阅读…", + "pairingHelpShareComputer": "在另一台电脑上通过“设置 → 远程 Orca 服务器 → 共享此电脑”创建。" }, "ForgetSshWorkspaceDialog": { "reconnectFailed": "重新连接失败", @@ -8306,7 +8307,12 @@ "27cf8507ad": "刷新共享访问", "f031182867": "共享服务器访问", "df142657a5": "尚未使用", - "b18d1764ef": "最后使用 {{value0}}" + "b18d1764ef": "最后使用 {{value0}}", + "linkName": "链接 · {{value0}}", + "activeLinks": "有效链接", + "noActiveLinks": "没有有效链接。", + "createdAt": "创建于 {{value0}}", + "revoke": "撤销" }, "RuntimeEnvironmentsPane": { "aeb26635d2": "删除 {{value0}}", @@ -8412,7 +8418,10 @@ "repair": "Re-pair…", "serverAccessRevoked": "Access revoked", "orcaVersionLastSeen": "Orca v{{value0}} (last seen)", - "pairedTwice": "与 {{value0}} 是同一台服务器,被配对了两次。请移除其中一个,以便编辑其项目。" + "pairedTwice": "与 {{value0}} 是同一台服务器,被配对了两次。请移除其中一个,以便编辑其项目。", + "removeDefaultServerDescription": "此服务器是新项目的默认主机。移除前请在“新项目打开位置”中选择其他主机。现有会话不受影响。", + "connectedNewProjectsHint": "已连接到 {{value0}}。若要默认在此创建新项目,请在“新项目打开位置”中选择它。", + "removeDefaultServerBlocked": "此服务器是新项目的默认主机。移除前请在“新项目打开位置”中选择其他主机。" }, "RuntimePairingGeneratedUrlRows": { "0495f68959": "复制 {{value0}}" @@ -8465,7 +8474,15 @@ "localLinkHelp": "此链接仅在运行于此计算机上的浏览器或 Orca 客户端中有效。", "noExternalAddress": "未找到其他设备的地址。将此计算机连接到 LAN 或 Tailscale,刷新,或选择“自定义地址”。", "staleAddress": "连接地址已更改。为 {{address}} 生成新链接。", - "customInvalid": "请输入有效的主机、主机:端口、IPv6 地址或 ws(s):// URL。" + "customInvalid": "请输入有效的主机、主机:端口、IPv6 地址或 ws(s):// URL。", + "thisComputer": "此电脑", + "whoOpens": "谁会打开此链接", + "addressLabel": "地址", + "createLink": "创建链接", + "browserLinkLabel": "浏览器链接", + "accessLinkLabel": "访问链接", + "accessLinkHelp": "将其粘贴到另一个 Orca 的“添加服务器”中。", + "staleAddressCreate": "连接地址已更改。请为 {{address}} 创建新链接。" }, "Settings": { "3bf149e873": "项目设置 > {{value0}}", @@ -8543,7 +8560,8 @@ "devDescription": "仅用于调试 UI 状态的开发工具。", "linearTitle": "Linear", "linearDescription": "Linear 在 Orca 中的工作方式、设置清单、智能体技能和提示词示例。", - "tasksDescription": "连接提供商、安装 Linear 技能并选择要在任务中显示的内容。" + "tasksDescription": "连接提供商、安装 Linear 技能并选择要在任务中显示的内容。", + "remoteServersDescription": "在另一台机器上运行 Orca,并在这里使用它。" }, "SettingsFormControls": { "42a4d15a30": "没有匹配的字体。", @@ -8667,7 +8685,8 @@ "2ef2b2674b": "删除 {{value0}}" }, "SshDestructiveActionDialog": { - "895b216267": "取消" + "895b216267": "取消", + "moreTargets": "另外 {{count}} 个" }, "SshPane": { "c0f1c80166": "未配置 SSH 目标。", @@ -8690,7 +8709,10 @@ "f1fc50dad2": "无法加载 SSH 目标", "0cda732f43": "连接测试失败", "syncedServerSingular": "已同步 {{count}} 台服务器", - "syncedServerPlural": "已同步 {{count}} 台服务器" + "syncedServerPlural": "已同步 {{count}} 台服务器", + "noFilterMatch": "没有匹配的主机。", + "addHost": "添加主机", + "alreadyInSync": "~/.ssh/config 已是最新同步状态" }, "SshPassphraseDialog": { "d5a234456f": "取消", @@ -8721,11 +8743,26 @@ "3d8af2949f": "编辑目标", "18968ede9e": "错误", "f0871e6bfb": "连接", - "47e94bd6ba": "已连接" + "47e94bd6ba": "已连接", + "removing": "正在移除…", + "testing": "正在测试…", + "select": "选择 {{label}}", + "orcaServer": "Orca 服务器", + "moreActions": "{{label}} 的更多操作", + "testConnection": "测试连接", + "edit": "编辑…", + "remove": "移除…" }, "SshTargetDestructiveActions": { "3bb0cf0ee4": "这将删除目标并结束任何活动的远程终端。", - "4808966c41": "删除 SSH 目标" + "4808966c41": "删除 SSH 目标", + "bulkTitleSingular": "移除 {{count}} 个 SSH 主机?", + "bulkTitlePlural": "移除 {{count}} 个 SSH 主机?", + "bulkDescription": "移除的主机将从工作区、自动化和侧边栏的主机选择器中消失。你的 ~/.ssh/config 不会被修改,之后同步也不会把它们加回来。", + "bulkAction": "移除 {{count}} 个", + "bulkProgress": "正在移除第 {{done}} 个,共 {{count}} 个…", + "bulkConnectedSingular": "{{count}} 个主机已连接。其远程终端将被关闭。", + "bulkConnectedPlural": "{{count}} 个主机已连接。其远程终端将被关闭。" }, "SshTargetForm": { "fea9cb402e": "取消", @@ -8761,7 +8798,8 @@ "addDescription": "添加一台可通过 SSH 登录的持久机器。", "editingPrefix": "正在编辑", "allowRemoteCliControl": "允许此主机的 orca CLI 控制 Orca", - "allowRemoteCliControlHelp": "新主机默认开启。允许在此主机上运行的 orca 命令打开并操作此桌面上的 Orca 浏览器标签页,但仅限此主机的工作区,且仅在主机连接期间;并允许此主机上由旧版 Orca 启动的终端为其自身受监督的工作运行编排命令(check、inbox、send、ask、reply)。它们无法读取你的文件、剪贴板或 Cookie。关闭时,它们只能访问此主机自身的 Orca 服务器。" + "allowRemoteCliControlHelp": "新主机默认开启。允许在此主机上运行的 orca 命令打开并操作此桌面上的 Orca 浏览器标签页,但仅限此主机的工作区,且仅在主机连接期间;并允许此主机上由旧版 Orca 启动的终端为其自身受监督的工作运行编排命令(check、inbox、send、ask、reply)。它们无法读取你的文件、剪贴板或 Cookie。关闭时,它们只能访问此主机自身的 Orca 服务器。", + "addHost": "添加主机" }, "TasksPane": { "f71d8a9dd3": "任务提供者", @@ -10624,7 +10662,11 @@ "4575341c77": "添加已保存的远程 Orca 服务器、生成配对 URL,或选择新项目的默认主机。", "defaultHostKeyword": "默认主机", "newProjectsKeyword": "新项目", - "activeServerKeyword": "活动服务器" + "activeServerKeyword": "活动服务器", + "addShareDefault": "添加远程 Orca 服务器、共享此电脑,或选择新项目的打开位置。", + "shareComputerKeyword": "共享此电脑", + "accessLinkKeyword": "访问链接", + "installKeyword": "安装服务器" } } }, @@ -11294,7 +11336,9 @@ "localMac": "本地 Mac", "localCredentials": "此提供商的凭据和账户检查由这台桌面客户端管理。要使用服务器,请更改“设置 > 远程 Orca 服务器 > 高级 > 新项目的默认主机”。", "remoteServerRateLimit": "{{value0}} API 额度从这台远程服务器上的 CLI 获取。要查看其他主机的额度,请在“设置”顶部的主机菜单中选择。", - "localRateLimit": "{{value0}} API 额度从这台桌面客户端上的 CLI 获取。要查看服务器的额度,请在“设置”顶部的主机菜单中选择。" + "localRateLimit": "{{value0}} API 额度从这台桌面客户端上的 CLI 获取。要查看服务器的额度,请在“设置”顶部的主机菜单中选择。", + "remoteServerCredentialsNewProjects": "此提供商的凭据和账户检查由此远程服务器负责。若要使用其他主机,请更改 设置 > 远程 Orca 服务器 > 新项目打开位置。", + "localCredentialsNewProjects": "此提供商的凭据和账户检查由此桌面客户端负责。若要使用服务器,请更改 设置 > 远程 Orca 服务器 > 新项目打开位置。" }, "settingOwnership": { "clientDefault": "客户端默认", @@ -11514,7 +11558,8 @@ "stoppingCleanup": "停止…", "stopCleanup": "停止清理", "cleanupStopped": "清理已停止", - "stopCleanupFailed": "无法停止云虚拟机清理。" + "stopCleanupFailed": "无法停止云虚拟机清理。", + "cloudVmRefreshLabel": "刷新 Cloud VM" }, "ephemeralVms": { "search": { @@ -12119,14 +12164,24 @@ "saveFailed": "无法保存主机", "saveFailedHelp": "主机已验证,但 Orca 无法保存。检查名称和本地设置存储,然后重试。", "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": "在另一台电脑上打开“共享此电脑”,使用其 Tailscale 或局域网地址为“其他设备”创建链接。如果你通过 SSH 隧道访问,请打开下面的选项。", + "sshTunnelReach": "我通过 SSH 隧道访问此地址", + "addServerWithTunnel": "通过隧道添加服务器", + "addServer": "添加服务器", + "nameLabel": "名称", + "optional": "(可选)", + "headlessHelpPrefix": "在无窗口模式下运行 Orca?请在那台电脑上运行" }, "CloudVmSetupGuide": { "title": "创建云虚拟机", "description": "云虚拟机会在创建工作区时通过环境模板创建。", "setupRecipe": "为你的云提供商设置环境模板。", "createWorkspace": "创建工作区,并在“运行于”中选择该模板。", - "openSetup": "设置环境模板" + "openSetup": "设置环境模板", + "cardTitle": "Cloud VM", + "cardDescription": "根据环境配方为每个工作区创建。", + "setUpRecipes": "设置配方" }, "VoiceMicrophoneSetting": { "systemDefault": "系统默认", @@ -12502,7 +12557,9 @@ }, "sshManagedHostRemove": { "description": "Orca 正在 {{label}} 上运行。请在“SSH 主机上的 Orca”中使用“停止…”从主机移除 Orca;如果主机已不存在,请使用“忘记…”。之后即可移除此 SSH 主机。", - "open": "打开“SSH 主机上的 Orca”" + "open": "打开“SSH 主机上的 Orca”", + "descriptionServers": "Orca 正在 {{label}} 上运行。请在“远程 Orca 服务器”中打开 {{label}} 上的 Orca 的 ⋯ 菜单,选择“停止…”从主机上移除 Orca;如果主机已不存在,请选择“忘记…”。之后即可移除此 SSH 主机。", + "openServers": "打开远程 Orca 服务器" }, "managedServers": { "row": { @@ -12525,7 +12582,10 @@ "confirmForget": "在此桌面上忘记", "keepLinked": "保持关联", "forgetDescription": "在此处取消关联此服务器,而不联系其主机。如果主机恢复,服务器可能仍在那里运行。", - "title": "{{host}} 上的 Orca" + "title": "{{host}} 上的 Orca", + "orcaVersion": "此主机上的 Orca:{{version}}", + "stopOrcaDescription": "停止 SSH 主机上的 Orca,并将其从此列表中移除。", + "updateOrcaOnHost": "更新此主机上的 Orca" }, "title": "SSH 主机上的 Orca", "description": "Orca 会将自身安装到空的 SSH 主机上,并自动保持最新。", @@ -12550,7 +12610,10 @@ "pendingLink": "通过 SSH 连接未完成。", "pendingUnlink": "停止 SSH 访问未完成。", "retry": "重试", - "cancelLink": "取消" + "cancelLink": "取消", + "linkedRoute": "通过 {{host}} 访问,服务器端口 {{port}}。", + "connect": "连接", + "chooseHost": "选择主机" }, "search": { "title": "SSH 主机上的 Orca", @@ -12603,7 +12666,8 @@ "unverifiable": "未完成:Orca 无法确认此服务器上正在运行什么。请在主机可访问时重试。", "migration": "未完成:迁移到此服务器的操作尚未完成。请先继续该操作。", "deferred": "尚未完成:服务器尚未准备好执行此操作。请稍后重试。", - "failed": "Orca 无法在服务器上完成此操作。请重试。" + "failed": "Orca 无法在服务器上完成此操作。请重试。", + "defaultServer": "未执行:此服务器是新项目的默认主机。停止前请在“新项目打开位置”中选择其他主机。" }, "reassurance": "在各个主机上运行,通过你自己的 SSH 连接访问。你的代码和文件留在原处。只有 Orca 的项目、标签页和终端列表会移到该主机。" }, @@ -12704,14 +12768,16 @@ "connect": "连接", "remove": "移除", "turnOn": "在实验性中开启", - "forceUpdate": "强制更新" + "forceUpdate": "强制更新", + "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}} 不再接受此电脑。请在 {{name}} 上打开 设置 → 远程 Orca 服务器 → 共享此电脑,为另一个 Orca 创建链接,然后粘贴到这里。" }, "usageCredentialHost": { "outdated": "请更新 {{host}} 上的 Orca,才能将此密钥保存在那里。", @@ -12754,6 +12820,137 @@ "terminalsRunning": "此主机上仍有 {{count}} 个终端在运行。请关闭它们,然后重启 Orca 服务器。", "cancel": "取消", "confirm": "重启" + }, + "SshPaneToolbar": { + "filter": "筛选主机", + "sync": "同步 ~/.ssh/config", + "more": "更多 SSH 主机操作", + "restoreRemoved": "恢复已移除的主机…", + "selected": "已选择 {{count}} 个", + "selectAll": "全选 {{count}} 个", + "clear": "清除", + "removeSelected": "移除 {{count}} 个", + "restoreRemovedCount": "恢复已移除的主机({{count}})…", + "hostCount": "{{count}} 个主机", + "selectMatching": "全选 {{count}} 个匹配项", + "testSelected": "测试", + "clearFilter": "清除筛选", + "restoreRemovedLoading": "恢复已移除的主机", + "restoreRemovedNone": "没有可恢复的已移除主机", + "hiddenDeselectedSingular": "已取消选择 {{count}} 个隐藏的主机", + "hiddenDeselectedPlural": "已取消选择 {{count}} 个隐藏的主机" + }, + "SshRestoreRemovedHosts": { + "title": "恢复已移除的主机?", + "description": "你在 Orca 中移除的 ~/.ssh/config 主机将重新出现在设置和所有主机选择器中。", + "none": "没有从 ~/.ssh/config 移除的主机。", + "confirm": "恢复" + }, + "SshTargetList": { + "addedInOrca": "在 Orca 中添加", + "fromConfig": "来自 ~/.ssh/config", + "selectGroup": "选择此组中的全部", + "filteredCount": "{{count}} / {{total}}", + "selectManualGroup": "选择所有在 Orca 中添加的主机", + "selectConfigGroup": "选择所有来自 ~/.ssh/config 的主机" + }, + "sshBulkRemove": { + "removedCount": "已移除 {{count}} 个", + "skippedCount": "已跳过 {{count}} 个", + "failedCount": "{{count}} 个失败", + "skippedManaged": "{{label}} 上有 Orca 服务器。", + "skippedWorkspaces": "{{label}} 上有工作区。", + "skippedHint": "请逐个移除已跳过的主机,以选择如何处理它们。" + }, + "sshBulkTest": { + "passed": "{{count}} 个可连接", + "failed": "{{count}} 个失败", + "alreadyConnected": "{{count}} 个已连接" + }, + "serverList": { + "addTitle": "添加服务器", + "installNoHosts": "没有未安装 Orca 服务器的 SSH 主机。", + "installHostHelp": "仅显示未安装 Orca 服务器的主机。", + "addSshHost": "添加 SSH 主机", + "cancel": "取消", + "moreActions": "{{name}} 的更多操作", + "moreActionsTooltip": "更多操作", + "hideDetails": "隐藏详细信息", + "details": "详细信息", + "address": "地址", + "route": "路由", + "protocol": "协议", + "capabilities": "功能", + "copied": "已复制错误", + "copyFailed": "无法复制错误。", + "copyError": "复制错误", + "updateMenu": "更新…", + "makeDefault": "在此打开新项目", + "sshAccessLinked": "SSH 路由…", + "sshAccess": "通过 SSH 连接…", + "remove": "移除…", + "connecting": "正在连接…", + "disconnecting": "正在断开…", + "needsUpdate": "需要更新", + "unreachable": "无法访问", + "version": "v{{version}}", + "updateAvailable": "v{{version}} 可用", + "throughSsh": "通过 {{host}} → 端口 {{port}}", + "manualUpdate": "请在服务器主机上更新 Orca", + "defaultChip": "默认", + "sshTunnelChip": "SSH 隧道", + "sshDialogTitle": "通过 SSH 连接 {{name}}", + "sshDialogDescription": "当无法直接访问服务器时使用。Orca 会通过 SSH 主机打开隧道。", + "defaultHostLabel": "新项目打开位置", + "thisComputer": "此电脑", + "defaultHostHelp": "现有项目保持不变。", + "defaultHostWebHelp": "此浏览器会在所选服务器上打开新项目。", + "updateAll": "全部更新({{count}})", + "addServer": "添加服务器", + "moreWaysToAdd": "添加服务器的更多方式", + "pasteAccessLink": "粘贴访问链接", + "checkForUpdates": "检查更新", + "emptyDescription": "配对开发机、云主机或家里的台式机。此电脑休眠时,会话会继续在那里运行。", + "movePaused": "迁移已暂停", + "shareTitle": "共享此电脑", + "shareDescriptionNamed": "让浏览器、手机或另一个 Orca 在 {{name}} 上工作。", + "shareDescription": "让浏览器、手机或另一个 Orca 在此电脑上工作。", + "shareButton": "共享…", + "done": "完成", + "unreachableLead": "Orca 无法访问此服务器。", + "updateFailedLead": "上次更新未完成。", + "unreachableHint": "请确认服务器上正在运行 Orca,且此电脑能访问其地址。", + "tailscaleTip": "这是 Tailscale 地址,因此两台电脑都需要登录同一个 tailnet。", + "loopbackTip": "127.0.0.1 地址只能通过 SSH 隧道使用。", + "lastSeen": "上次在线 {{when}}", + "retry": "重试", + "pasteAccessLinkHelp": "与已在另一台机器上运行的 Orca 配对。", + "cloudVm": "Cloud VM", + "cloudVmHelp": "根据环境配方为每个工作区创建。", + "addDescriptionShort": "粘贴另一台机器上“共享此电脑”生成的访问链接。", + "installHostPlaceholder": "选择未安装 Orca 的 SSH 主机", + "oneActiveLink": "1 个有效链接", + "activeLinks": "{{value0}} 个有效链接", + "shareDialogDescriptionRuns": "持有链接的人在你撤销之前都可以以你的身份在此电脑上运行命令。", + "shownAs": "向他人显示为 {{name}}", + "editName": "编辑", + "close": "关闭", + "neverConnected": "从未连接", + "noSavedServers": "没有已保存的服务器", + "hideError": "隐藏错误", + "showError": "显示错误", + "installNamePlaceholder": "与 SSH 主机相同", + "defaultHostEmptyLine": "在添加服务器之前,新项目会在此电脑上打开。", + "setUpDone": "已在 {{name}} 上设置好 Orca。", + "settingUpInBackground": "正在后台于 {{name}} 上设置 Orca。准备好后会显示在列表中。", + "setUpOnSshHost": "在 SSH 主机上设置 Orca", + "setUp": "设置", + "overYourSsh": "通过你的 SSH 连接" + }, + "networkAddress": { + "tailscale": "Tailscale · {{address}}", + "lan": "局域网 · {{address}}", + "loopback": "此电脑 · {{address}}" } }, "right": { diff --git a/src/shared/ssh-target-source.test.ts b/src/shared/ssh-target-source.test.ts new file mode 100644 index 00000000000..f81929c6c47 --- /dev/null +++ b/src/shared/ssh-target-source.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { isLegacyConfigImportTarget, isSshConfigSourcedTarget } from './ssh-target-source' + +describe('isSshConfigSourcedTarget', () => { + it('trusts an explicit source', () => { + expect(isSshConfigSourcedTarget({ source: 'ssh-config', label: 'a', host: 'a' })).toBe(true) + expect( + isSshConfigSourcedTarget({ + source: 'manual', + label: 'box', + configHost: 'box', + host: '10.0.0.5' + }) + ).toBe(false) + }) + + it('counts a legacy target as imported only in the old import shape', () => { + expect(isLegacyConfigImportTarget({ label: 'box', configHost: 'box', host: '10.0.0.5' })).toBe( + true + ) + // Alias equals the host: nothing was resolved, so it may be a hand-made target. + expect(isLegacyConfigImportTarget({ label: 'box', configHost: 'box', host: 'box' })).toBe(false) + expect(isSshConfigSourcedTarget({ label: 'My box', host: 'box.example.test' })).toBe(false) + }) +}) diff --git a/src/shared/ssh-target-source.ts b/src/shared/ssh-target-source.ts new file mode 100644 index 00000000000..767b8fbd2a1 --- /dev/null +++ b/src/shared/ssh-target-source.ts @@ -0,0 +1,24 @@ +import type { SshTarget } from './ssh-types' + +/** + * Legacy manual and imported targets both lack `source`. Only the old import shape counts as + * imported: the SSH alias kept as label/configHost while host stored the resolved HostName. + */ +export function isLegacyConfigImportTarget( + target: Pick +): boolean { + const alias = target.configHost ?? target.label + return Boolean( + alias && target.label === alias && target.configHost === alias && target.host !== alias + ) +} + +/** Whether a target came from ~/.ssh/config, by the same rule config sync uses. */ +export function isSshConfigSourcedTarget( + target: Pick +): boolean { + return ( + target.source === 'ssh-config' || + (target.source === undefined && isLegacyConfigImportTarget(target)) + ) +} diff --git a/tests/e2e/ephemeral-vm-cleanup-retry.spec.ts b/tests/e2e/ephemeral-vm-cleanup-retry.spec.ts index aee6a630c2f..6954a8e2f1e 100644 --- a/tests/e2e/ephemeral-vm-cleanup-retry.spec.ts +++ b/tests/e2e/ephemeral-vm-cleanup-retry.spec.ts @@ -49,10 +49,6 @@ test('shows interrupted hidden SSH cleanup as retryable', async ({ electronApp, state.openSettingsPage() }) await expect(orcaPage.getByPlaceholder('Search settings')).toBeVisible() - await orcaPage - .getByRole('group', { name: 'Remote server workflow' }) - .getByRole('button', { name: /^Cloud VM/ }) - .click() const runtimes = orcaPage.locator('[data-settings-section="temporary-vm-runtimes"]') await expect(runtimes.getByText('Interrupted cleanup')).toBeVisible() @@ -164,8 +160,6 @@ async function openCloudVmRuntimes(page: Page): Promise { state.openSettingsPage() }) await expect(page.getByPlaceholder('Search settings')).toBeVisible() - await page - .getByRole('group', { name: 'Remote server workflow' }) - .getByRole('button', { name: /^Cloud VM/ }) - .click() + // Why no click: the Cloud VMs card lists its runtimes on the servers page itself. + await expect(page.locator('[data-settings-section="temporary-vm-runtimes"]')).toBeVisible() } diff --git a/tests/e2e/helpers/ssh-config-host-picker.ts b/tests/e2e/helpers/ssh-config-host-picker.ts index bad31c818d9..48de52a6348 100644 --- a/tests/e2e/helpers/ssh-config-host-picker.ts +++ b/tests/e2e/helpers/ssh-config-host-picker.ts @@ -172,8 +172,8 @@ export async function openSshHostSettings(page: Page): Promise { .locator('section') .filter({ has: page.getByRole('heading', { name: 'SSH Hosts' }) }) await expect(sshSection).toBeVisible({ timeout: 10_000 }) - await expect(sshSection.getByRole('button', { name: 'Import' })).toBeVisible() - await expect(sshSection.getByRole('button', { name: 'Add Target' })).toBeVisible() + await expect(sshSection.getByRole('button', { name: 'Sync ~/.ssh/config' })).toBeVisible() + await expect(sshSection.getByRole('button', { name: 'Add host' })).toBeVisible() return sshSection } diff --git a/tests/e2e/ssh-config-host-import.spec.ts b/tests/e2e/ssh-config-host-import.spec.ts index 92bef7abf72..04285d34f66 100644 --- a/tests/e2e/ssh-config-host-import.spec.ts +++ b/tests/e2e/ssh-config-host-import.spec.ts @@ -187,7 +187,10 @@ test.describe('SSH config host import (bulk + settings re-adopt)', () => { }) // ── P9 ───────────────────────────────────────────────────────────── - test('P9: Settings Import re-adopts deleted config hosts', async ({ electronApp, orcaPage }) => { + test('P9: Sync keeps deleted config hosts; Restore removed hosts re-adopts them', async ({ + electronApp, + orcaPage + }) => { const hosts = await importPairThenDeleteAlias( orcaPage, electronApp, @@ -199,7 +202,19 @@ test.describe('SSH config host import (bulk + settings re-adopt)', () => { await expectSshHostListedInSettings(sshSection, hosts.bravo) await expectSshHostAbsentFromSettings(sshSection, hosts.alpha) - await sshSection.getByRole('button', { name: 'Import' }).click() + // Sync never lifts a removal, so bulk removal sticks. + await sshSection.getByRole('button', { name: 'Sync ~/.ssh/config' }).click() + await expect(orcaPage.getByText(/already in sync|Synced \d+ servers?/i).first()).toBeVisible({ + timeout: 15_000 + }) + await expectSshHostAbsentFromSettings(sshSection, hosts.alpha) + + await sshSection.getByRole('button', { name: 'More SSH host actions' }).click() + // The label carries the removed count once ~/.ssh/config is read; click waits for it to enable. + await orcaPage.getByRole('menuitem', { name: /^Restore removed hosts \(\d+\)…$/ }).click() + const restoreDialog = orcaPage.getByRole('dialog', { name: 'Restore removed hosts?' }) + await expect(restoreDialog.getByText(hosts.alpha.alias)).toBeVisible({ timeout: 15_000 }) + await restoreDialog.getByRole('button', { name: 'Restore' }).click() await expect(orcaPage.getByText(/Synced \d+ servers?/i)).toBeVisible({ timeout: 15_000 }) await expectSshHostListedInSettings(sshSection, hosts.alpha) diff --git a/tests/e2e/ssh-host-form-modal.spec.ts b/tests/e2e/ssh-host-form-modal.spec.ts index 8ffd09acb04..ba3eb2ab71a 100644 --- a/tests/e2e/ssh-host-form-modal.spec.ts +++ b/tests/e2e/ssh-host-form-modal.spec.ts @@ -84,7 +84,7 @@ async function openSshHostSettings(page: Page): Promise { await expect(sshSection).toBeVisible({ timeout: 10_000 }) // Why: section chrome uses "SSH Hosts"; the pane body catalog string is "Targets". await expect(sshSection.getByRole('heading', { name: 'SSH Hosts' })).toBeVisible() - await expect(sshSection.getByRole('button', { name: 'Add Target' })).toBeVisible({ + await expect(sshSection.getByRole('button', { name: 'Add host' })).toBeVisible({ timeout: 10_000 }) } @@ -123,7 +123,7 @@ test.describe('SSH host add/edit modal', () => { } // ── Add flow ──────────────────────────────────────────────────── - await sshSection.getByRole('button', { name: 'Add Target' }).click() + await sshSection.getByRole('button', { name: 'Add host' }).click() const addDialog = orcaPage.getByRole('dialog', { name: 'Add SSH host' }) await expect(addDialog).toBeVisible() @@ -131,14 +131,14 @@ test.describe('SSH host add/edit modal', () => { await expect( addDialog.getByText('Add a persistent machine you can log into over SSH.') ).toBeInViewport() - await expect(addDialog.getByRole('button', { name: 'Add Target' })).toBeInViewport() + await expect(addDialog.getByRole('button', { name: 'Add host' })).toBeInViewport() await expect(addDialog.getByRole('button', { name: 'Cancel' })).toBeInViewport() // Why: Advanced expands the form; sticky header/footer must stay on screen. await addDialog.getByRole('button', { name: 'Advanced' }).click() await expect(addDialog.getByText('Proxy Command')).toBeVisible() await expect(addDialog.getByRole('heading', { name: 'Add SSH host' })).toBeInViewport() - await expect(addDialog.getByRole('button', { name: 'Add Target' })).toBeInViewport() + await expect(addDialog.getByRole('button', { name: 'Add host' })).toBeInViewport() // Collapse before save so the dialog body is quieter; Advanced state is // re-checked on the next open session. @@ -149,7 +149,7 @@ test.describe('SSH host add/edit modal', () => { await addDialog.locator('#ssh-target-host').fill('created.example.test') await addDialog.locator('#ssh-target-username').fill('alice') await addDialog.locator('#ssh-target-port').fill('2222') - await addDialog.getByRole('button', { name: 'Add Target' }).click() + await addDialog.getByRole('button', { name: 'Add host' }).click() await expect(addDialog).toBeHidden({ timeout: 10_000 }) await expect(sshSection.getByText(createdLabel, { exact: true })).toBeVisible({ @@ -161,7 +161,8 @@ test.describe('SSH host add/edit modal', () => { const createdCard = sshSection.locator( `[data-ssh-target-card][data-ssh-target-label="${createdLabel}"]` ) - await createdCard.getByRole('button', { name: 'Edit target' }).click() + await createdCard.getByRole('button', { name: `More actions for ${createdLabel}` }).click() + await orcaPage.getByRole('menuitem', { name: 'Edit…' }).click() const editDialog = orcaPage.getByRole('dialog', { name: 'Edit SSH host' }) await expect(editDialog).toBeVisible() @@ -199,7 +200,8 @@ test.describe('SSH host add/edit modal', () => { await editDialog.getByRole('button', { name: 'Cancel' }).click() await expect(editDialog).toBeHidden() - await createdCard.getByRole('button', { name: 'Edit target' }).click() + await createdCard.getByRole('button', { name: `More actions for ${createdLabel}` }).click() + await orcaPage.getByRole('menuitem', { name: 'Edit…' }).click() const reopened = orcaPage.getByRole('dialog', { name: 'Edit SSH host' }) await expect(reopened).toBeVisible() await expect(reopened.locator('#ssh-target-label')).toHaveValue(createdLabel) diff --git a/tests/e2e/wsl-host-fake-transport.spec.ts b/tests/e2e/wsl-host-fake-transport.spec.ts index c6f6bcdbda6..881351cc02a 100644 --- a/tests/e2e/wsl-host-fake-transport.spec.ts +++ b/tests/e2e/wsl-host-fake-transport.spec.ts @@ -358,16 +358,13 @@ test('adds a WSL distro as a host, adds a project on it, and runs a terminal the await expect.poll(() => hubTerminalsIn(projectPath), { timeout: 30_000 }).toBe(0) await expect.poll(() => hubHasRepo(projectPath), { timeout: 30_000 }).toBe(false) - // An orphan: a terminal whose project the host already forgot (left by older builds). - // realpath: the host reports workspace paths resolved (/private/var on macOS). - const orphanPath = realpathSync(createGitProject()) - cleanupDirs.push(orphanPath) - const orphanRepo = await hubRuntime.call<{ repo: { id: string } }>('repo.add', { - path: orphanPath - }) - await hubRuntime.call('terminal.create', { worktree: `path:${orphanPath}` }) - await hubRuntime.call('repo.rm', { repo: orphanRepo.result.repo.id }) - expect(await hubTerminalsIn(orphanPath)).toBeGreaterThan(0) + // A shell only the host knows of: no desktop pane ever opened it. Why not a removed project's + // orphan: repo.rm now ends the project's shells on the host itself (#29964). + const hostOnlyPath = realpathSync(createGitProject()) + cleanupDirs.push(hostOnlyPath) + await hubRuntime.call('repo.add', { path: hostOnlyPath }) + await hubRuntime.call('terminal.create', { worktree: `path:${hostOnlyPath}` }) + expect(await hubTerminalsIn(hostOnlyPath)).toBeGreaterThan(0) // Experiment off: the host stays listed so its projects never seem to vanish, but turned off. await setWslHostsEnabled(page, false) @@ -394,7 +391,7 @@ test('adds a WSL distro as a host, adds a project on it, and runs a terminal the state?.openSettingsTarget({ pane: 'servers', repoId: null }) }) // A WSL host is listed only as a WSL host, never among the generic remote servers. - await expect(page.getByText('No saved servers.')).toBeVisible() + await expect(page.getByText('No saved servers', { exact: true })).toBeVisible() const section = page.locator('[data-settings-section="wsl-hosts"]') await section.scrollIntoViewIfNeeded() await expect(section).toContainText('WSL hosts are turned off in Settings → Experimental') @@ -404,7 +401,8 @@ test('adds a WSL distro as a host, adds a project on it, and runs a terminal the // Remove asks first, with WSL-specific copy, then drops the host. await setWslHostsEnabled(page, true) - await section.getByRole('button', { name: 'Remove' }).click() + await section.getByRole('button', { name: `More actions for ${WSL_HOST_LABEL}` }).click() + await page.getByRole('menuitem', { name: 'Remove…' }).click() const confirm = page.getByRole('dialog', { name: `Remove ${WSL_HOST_LABEL}?` }) await expect(confirm).toContainText( 'ends its open terminals and stops Orca’s server in that distro' @@ -414,8 +412,8 @@ test('adds a WSL distro as a host, adds a project on it, and runs a terminal the await page.screenshot({ path: testInfo.outputPath('wsl-host-remove-confirm.png') }) await confirm.getByRole('button', { name: 'Remove host' }).click() await expect(confirm).toBeHidden({ timeout: 30_000 }) - // Closed by handle, so the orphan ends too, and nothing is reported left running. - await expect.poll(() => hubTerminalsIn(orphanPath), { timeout: 30_000 }).toBe(0) + // Closed by handle, so the host-only shell ends too, and nothing is reported left running. + await expect.poll(() => hubTerminalsIn(hostOnlyPath), { timeout: 30_000 }).toBe(0) await expect(page.getByText(/something in the distro is still running/)).toHaveCount(0) await expect .poll(() => @@ -548,7 +546,7 @@ test('with the internal gate closed, no WSL host surface shows and a saved host await page.evaluate(() => window.__store?.getState().openSettingsTarget({ pane: 'servers', repoId: null }) ) - await expect(page.getByText('No saved servers.')).toBeVisible() + await expect(page.getByText('No saved servers', { exact: true })).toBeVisible() await expect(page.locator('[data-settings-section="wsl-hosts"]')).toHaveCount(0) await expect(page.getByText(WSL_HOST_LABEL)).toHaveCount(0)