diff --git a/src/renderer/src/components/sidebar/workspace-card/host/WorktreeCardHostMark.tsx b/src/renderer/src/components/sidebar/workspace-card/host/WorktreeCardHostMark.tsx index 801b1880938..2726b59d242 100644 --- a/src/renderer/src/components/sidebar/workspace-card/host/WorktreeCardHostMark.tsx +++ b/src/renderer/src/components/sidebar/workspace-card/host/WorktreeCardHostMark.tsx @@ -1,5 +1,6 @@ import React from 'react' import { useAppStore } from '@/store' +import { findWorktreeById } from '@/store/github/refresh/worktree-refresh' import { useTranslate } from '@/i18n/use-translate' import { cn } from '@/lib/utils' import { HostGlyph, vmStateToDot } from '@/components/host-row-icon' @@ -87,6 +88,8 @@ export function WorktreeCardHostStateWord({ ? vmStateToDot(selectEphemeralVmWorkspaceState(state, worktreeId)) : null ) + // The separator only when line 2 goes on (a branch); "Asleep ·" alone reads as cut off. + const hasBranch = useAppStore((state) => Boolean(findWorktreeById(state, worktreeId)?.branch)) const t = useTranslate() const state = cloud.isCloud ? cloud.state : vmDot // Unknown is not a state to name (the panel shows a placeholder until the first read). @@ -101,7 +104,8 @@ export function WorktreeCardHostStateWord({ PROBLEM.has(state) ? 'text-destructive' : 'text-muted-foreground' )} > - {cloudStateLabel(state, t)} · + {cloudStateLabel(state, t)} + {hasBranch ? ' ·' : null} ) } diff --git a/src/renderer/src/components/sidebar/worktree-list/controls/SidebarHostScopeMenuSection.tsx b/src/renderer/src/components/sidebar/worktree-list/controls/SidebarHostScopeMenuSection.tsx index 65a9bc411e1..040eba4a865 100644 --- a/src/renderer/src/components/sidebar/worktree-list/controls/SidebarHostScopeMenuSection.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/controls/SidebarHostScopeMenuSection.tsx @@ -34,7 +34,8 @@ type SidebarHostScopeMenuSectionProps = { function getHostMetadata(host: SidebarHostOption, t: Translate = translate): string { const healthLabel = getSidebarHostHealthLabel(host.health, host.blockedReason, t, host.vmState) - if (host.kind === 'local') { + // Local and Orca Cloud name themselves: "This computer", "1 running · 1 asleep". + if (host.kind === 'local' || host.orcaCloud) { return host.detail } if (host.kind === 'ssh') { diff --git a/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-host-options.ts b/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-host-options.ts index b0f8b641040..b93e4a62239 100644 --- a/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-host-options.ts +++ b/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-host-options.ts @@ -1,4 +1,5 @@ import { shallow } from 'zustand/shallow' +import { collapseOrcaCloudHosts } from './sidebar-orca-cloud-host' import type { GlobalSettings } from '../../../../../../shared/global-settings-types' import type { Repo } from '../../../../../../shared/repo-types' import type { WorkspaceHostScope } from '../../../../../../shared/ui-chrome-types' @@ -62,6 +63,8 @@ export type SidebarHostOption = { wslDistro?: string /** Why a listed WSL host cannot run: the experiment is off, or this build lacks its server files. */ wslUnavailable?: 'turned-off' | 'server-files-missing' + /** On the one row standing for every Orca Cloud sandbox: how many run and sleep. */ + orcaCloud?: { running: number; asleep: number } } export type SidebarHostScopeOption = { @@ -117,7 +120,7 @@ export function buildSidebarHostOptions( const activeRuntimeHostId = args.settings?.activeRuntimeEnvironmentId?.trim() ? toRuntimeExecutionHostId(args.settings.activeRuntimeEnvironmentId.trim()) : null - return buildExecutionHostRegistry({ + const hosts = buildExecutionHostRegistry({ repos: args.repos, settings: args.settings, sshTargetLabels: args.sshTargetLabels, @@ -126,7 +129,7 @@ export function buildSidebarHostOptions( runtimeStatusByEnvironmentId: args.runtimeStatusByEnvironmentId, hostLabelOverrides: args.hostLabelOverrides, ephemeralVmHosts: args.ephemeralVmHosts - }).map((host) => { + }).map((host): SidebarHostOption => { if (host.kind === 'local') { return { ...host, presence: 'local' } } @@ -174,6 +177,7 @@ export function buildSidebarHostOptions( presence: host.id === activeRuntimeHostId ? 'active' : 'project' } }) + return collapseOrcaCloudHosts(hosts, args.runtimeEnvironments ?? [], t) } export function shouldShowHostScopeControls(hosts: readonly SidebarHostOption[]): boolean { diff --git a/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-orca-cloud-host.test.ts b/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-orca-cloud-host.test.ts new file mode 100644 index 00000000000..4d728a3d532 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-orca-cloud-host.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../../../../shared/repo-types' +import type { Worktree } from '../../../../../../shared/worktree/types' +import type { ExecutionHostId } from '../../../../../../shared/execution-host' +import { planSidebarRows } from '../../../../../../shared/sidebar-list/sidebar-row-plan' +import { addHostSectionRows } from '../../../../../../shared/sidebar-list/host-section-rows' +import { createHostScopedRepoOf } from '../../../../../../shared/sidebar-list/sidebar-feed-index' +import { cloneDefaultWorkspaceStatuses } from '../../../../../../shared/workspace-statuses' +import { expandEquivalentExecutionHostIds } from '../../../../../../shared/execution-host/managed-orcad-execution-host' +import { repo, worktree } from '../grouping/worktree-list-groups-test-fixtures' +import { buildSidebarHostOptions, buildSidebarHostScopeOptions } from './sidebar-host-options' + +// English defaults with {{slots}} filled, like i18next with no catalog. +const t = (_key: string, fallback: string, params?: Record): string => + fallback.replace(/\{\{(\w+)\}\}/g, (_slot, name: string) => String(params?.[name] ?? '')) + +// Two Orca Cloud sandboxes (one running, one asleep) and this computer, all for one project. +const cloudA: Repo = { ...repo, path: '/workspace/hello', executionHostId: 'runtime:env-a' } +const cloudB: Repo = { ...repo, path: '/workspace/hello', executionHostId: 'runtime:env-b' } +const environments = [ + { id: 'env-a', name: 'hello sandbox aaaaaaaa', source: 'ephemeral-vm' as const }, + { id: 'env-b', name: 'hello sandbox bbbbbbbb', source: 'ephemeral-vm' as const } +] + +function hostOptions() { + return buildSidebarHostOptions( + { + repos: [repo, cloudA, cloudB], + sshTargetLabels: new Map(), + settings: null, + runtimeEnvironments: environments, + ephemeralVmHosts: [ + { runtimeEnvironmentId: 'env-a', status: 'running', waking: false }, + { runtimeEnvironmentId: 'env-b', status: 'suspended', waking: false } + ] + }, + t + ) +} + +const worktrees: Worktree[] = [ + { ...worktree, id: 'wt-local', displayName: 'local work' }, + { ...worktree, id: 'wt-a', hostId: 'runtime:env-a', displayName: 'fix login' }, + { ...worktree, id: 'wt-b', hostId: 'runtime:env-b', displayName: 'docs refresh' } +] + +function groupedByHost() { + const hosts = hostOptions() + const repoMap = new Map([[repo.id, repo]]) + const rows = planSidebarRows({ + groupBy: 'repo', + sortBy: 'smart', + worktrees, + repoMap, + repoOfWorktree: createHostScopedRepoOf([repo, cloudA, cloudB], repoMap), + prGroupOf: () => 'in-progress', + workspaceStatuses: cloneDefaultWorkspaceStatuses(), + projectOrderBy: 'manual', + lineageById: {}, + worktreeMap: new Map(worktrees.map((row) => [row.id, row])), + nestLineage: false, + projectGroups: [], + placeholderRepoIds: new Set(), + importedWorktreesByRepo: new Map(), + newExternalWorktreesInboxByRepo: new Map(), + pendingCreations: [], + folderWorkspaces: [], + defaultHostId: 'local', + pinnedDisplayPolicy: 'single-location', + filterRepoIds: [] + }).render(new Set()) + const everyHost: ExecutionHostId[] = expandEquivalentExecutionHostIds( + hosts, + hosts.map((host) => host.id) + ) + return addHostSectionRows({ + rows, + hostOptions: hosts, + workspaceHostScope: 'all', + // The Hosts filter with every host checked: the view that draws host headers. + visibleWorkspaceHostIds: everyHost, + defaultHostId: 'local', + localHostLabel: 'Local Mac', + preferProjectGrouping: true + }) +} + +describe('Orca Cloud is one host (RA-2)', () => { + it('collapses two sandboxes into one "Orca Cloud" section with an aggregate state', () => { + const rows = groupedByHost() + const hostHeaders = rows.filter((row) => row.type === 'host-header') + expect(hostHeaders.map((row) => row.label)).toEqual(['Local Mac', 'Orca Cloud']) + const cloud = hostHeaders[1] + expect(cloud?.count).toBe(2) + // Never one sandbox's state. + expect(cloud?.vmState).toBeUndefined() + expect(cloud?.detail).toBe('1 running · 1 asleep') + // One project header under Orca Cloud, holding both sandboxes' workspaces. + const cloudIndex = rows.indexOf(cloud!) + const underCloud = rows.slice(cloudIndex + 1) + expect(underCloud.filter((row) => row.type === 'header')).toHaveLength(1) + expect( + underCloud.flatMap((row) => (row.type === 'item' ? [row.worktree.id] : [])).sort() + ).toEqual(['wt-a', 'wt-b']) + }) + + it('lists Orca Cloud once in the Hosts filter, and checking it selects every sandbox', () => { + const hosts = hostOptions() + const scope = buildSidebarHostScopeOptions(hosts, t) + expect(scope.map((option) => option.label)).toEqual(['All hosts', 'Local Mac', 'Orca Cloud']) + const cloudRow = scope.find((option) => option.label === 'Orca Cloud') + expect(cloudRow?.detail).toBe('1 running · 1 asleep') + expect( + expandEquivalentExecutionHostIds(hosts, [ + hosts.find((host) => host.id === cloudRow?.id)!.id + ]).sort() + ).toEqual(['runtime:env-a', 'runtime:env-b']) + }) + + it('says "Asleep" when every sandbox sleeps, never "Suspended"', () => { + const hosts = buildSidebarHostOptions( + { + repos: [repo, cloudA, cloudB], + sshTargetLabels: new Map(), + settings: null, + runtimeEnvironments: environments, + ephemeralVmHosts: [ + { runtimeEnvironmentId: 'env-a', status: 'suspended', waking: false }, + { runtimeEnvironmentId: 'env-b', status: 'suspended', waking: false } + ] + }, + t + ) + const cloud = hosts.find((host) => host.orcaCloud) + expect(cloud?.detail).toBe('Asleep') + expect(cloud?.vmState).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-orca-cloud-host.ts b/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-orca-cloud-host.ts new file mode 100644 index 00000000000..d372a490394 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/controls/sidebar-orca-cloud-host.ts @@ -0,0 +1,74 @@ +import type { ExecutionHostId } from '../../../../../../shared/execution-host' +import { toRuntimeExecutionHostId } from '../../../../../../shared/execution-host' +import { + ORCA_CLOUD_HOST_LABEL, + isOrcaCloudRuntimeEnvironment +} from '../../../../../../shared/remote-runtime/host-catalog/orca-cloud-environment-name' +import type { PublicKnownRuntimeEnvironment } from '../../../../../../shared/remote-runtime/host-catalog/runtime-environments' +import { translate } from '@/i18n/i18n' +import type { Translate } from '@/i18n/use-translate' +import type { SidebarHostOption } from './sidebar-host-options' + +/** + * Orca Cloud is one logical host. Each sandbox is its own runtime environment underneath, so + * without this every sandbox drew its own "Orca Cloud" header and Hosts entry (RA-2). The first + * sandbox's row stands for all of them (aliasHostIds); the rest merge into it, so filters and + * host sections treat every cloud workspace as the same host. The shown row carries an aggregate + * state, never one sandbox's. + */ +export function collapseOrcaCloudHosts( + hosts: SidebarHostOption[], + runtimeEnvironments: readonly Pick[], + t: Translate = translate +): SidebarHostOption[] { + const cloudIds = new Set( + runtimeEnvironments + .filter((environment) => isOrcaCloudRuntimeEnvironment(environment)) + .map((environment) => toRuntimeExecutionHostId(environment.id.trim())) + ) + const cloudHosts = hosts.filter((host) => cloudIds.has(host.id) && !host.mergedIntoHostId) + if (cloudHosts.length === 0) { + return hosts + } + const shown = cloudHosts[0] + const asleep = cloudHosts.filter((host) => host.vmState === 'suspended').length + const failed = cloudHosts.filter( + (host) => host.vmState === 'resume_failed' || host.vmState === 'provision_failed' + ).length + const running = cloudHosts.length - asleep - failed + const aliasIds = new Set(cloudHosts.slice(1).map((host) => host.id)) + return hosts.map((host): SidebarHostOption => { + if (host.id === shown.id) { + const { vmState: _vmState, ...rest } = host + return { + ...rest, + label: ORCA_CLOUD_HOST_LABEL, + detail: orcaCloudAggregateDetail(running, asleep, t), + // Dimmed like a sleeping host only when nothing is running. + health: running > 0 ? 'available' : 'suspended', + orcaCloud: { running, asleep }, + aliasHostIds: [...(host.aliasHostIds ?? []), ...aliasIds] + } + } + return aliasIds.has(host.id) + ? { ...host, label: ORCA_CLOUD_HOST_LABEL, mergedIntoHostId: shown.id } + : host + }) +} + +/** "Asleep", "2 running", or "1 running · 1 asleep": the cloud host as a whole. */ +export function orcaCloudAggregateDetail(running: number, asleep: number, t: Translate): string { + if (running === 0 && asleep > 0) { + return t('orcaCloudWorkspace.state.asleep', 'Asleep') + } + if (asleep === 0) { + return t('auto.components.sidebar.orcaCloudHost.running', '{{count}} running', { + count: running + }) + } + return t( + 'auto.components.sidebar.orcaCloudHost.runningAsleep', + '{{running}} running · {{asleep}} asleep', + { running, asleep } + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/HostSectionHeader.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/HostSectionHeader.tsx index 2adfd89771e..da7edf31660 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/HostSectionHeader.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/HostSectionHeader.tsx @@ -134,6 +134,10 @@ export function HostSectionHeader({ > {row.vmState ? ( + ) : isCloudHost ? ( + + ) : row.kind === 'local' ? ( + ) : isDisconnected ? ( ) : ( @@ -144,7 +148,8 @@ export function HostSectionHeader({
@@ -153,7 +158,7 @@ export function HostSectionHeader({ {detail ? ( diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/host-header-detail.ts b/src/renderer/src/components/sidebar/worktree-list/rows/host-header-detail.ts index 769c5b85798..ddc6d38f215 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/host-header-detail.ts +++ b/src/renderer/src/components/sidebar/worktree-list/rows/host-header-detail.ts @@ -13,7 +13,11 @@ export function getHostHeaderDetail( /** Orca Cloud hosts use the cloud glossary: Asleep, Waking, Couldn't wake, never "Suspended". */ isOrcaCloud = false ): { text: string; isWarning: boolean } | null { - if (isOrcaCloud && row.vmState) { + if (isOrcaCloud) { + // One header for every sandbox: its detail is the aggregate ("1 running · 1 asleep"). + if (!row.vmState) { + return row.detail ? { text: row.detail, isWarning: false } : null + } const dot = vmStateToDot(row.vmState) return { text: cloudStateLabel(dot, t), isWarning: dot === 'failed' || dot === 'wakeFailed' } } diff --git a/src/renderer/src/components/terminal-pane/provider-notices/claude-theme-sync/claude-theme-sync-offer.test.ts b/src/renderer/src/components/terminal-pane/provider-notices/claude-theme-sync/claude-theme-sync-offer.test.ts new file mode 100644 index 00000000000..c204802c7da --- /dev/null +++ b/src/renderer/src/components/terminal-pane/provider-notices/claude-theme-sync/claude-theme-sync-offer.test.ts @@ -0,0 +1,71 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const toast = vi.hoisted(() => Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() })) +vi.mock('sonner', () => ({ toast })) +vi.mock('@/lib/desktop-window-chrome', () => ({ isPairedWebClientWindow: () => false })) + +import { useAppStore } from '@/store/index' +import { seedCloudWorkspaceSnapshot } from '@/lib/orca-cloud/orca-cloud-workspace-store' +import { useClaudeThemeSyncOffer } from './claude-theme-sync-offer' +import { getDefaultSettings } from '../../../../../../shared/constants' +import { makeTerminalTab } from '../../../worktree-jump/worktree-jump-palette-test-fixtures' + +const initial = useAppStore.getState() + +type State = ReturnType + +function seed(active: { id: string; host: State['activeWorkspaceExecutionHostId'] }): void { + const settings = initial.settings ?? getDefaultSettings('/home/test') + useAppStore.setState({ + settings: { ...settings, claudeCodeThemeSyncOffer: 'pending' }, + updateSettings: vi.fn(async () => undefined), + activeWorktreeId: active.id, + activeWorkspaceExecutionHostId: active.host, + activePendingCreationId: null, + tabsByWorktree: { + [active.id]: [{ ...makeTerminalTab('tab-1', active.id, 'Claude'), launchAgent: 'claude' }] + } + }) +} + +beforeEach(() => toast.mockClear()) +afterEach(() => { + cleanup() + seedCloudWorkspaceSnapshot({}) + useAppStore.setState(initial, true) +}) + +describe('Claude Code theme-sync offer (RA-6)', () => { + it('holds the offer while Claude runs in an Orca Cloud workspace, then makes it on a local one', () => { + seedCloudWorkspaceSnapshot({ ids: new Set(['cloud-ws']), idsLoaded: true }) + seed({ id: 'cloud-ws', host: 'runtime:env-a' }) + renderHook(() => useClaudeThemeSyncOffer()) + expect(toast).not.toHaveBeenCalled() + + act(() => { + useAppStore.setState({ + activeWorktreeId: 'local-ws', + activeWorkspaceExecutionHostId: 'local' + }) + }) + expect(toast).toHaveBeenCalledTimes(1) + expect(toast.mock.calls[0]?.[0]).toBe('Match Claude Code to your Orca theme?') + }) + + it('holds it for a brand-new cloud workspace that is not in the cloud id list yet', () => { + seedCloudWorkspaceSnapshot({ ids: new Set(), idsLoaded: true }) + seed({ id: 'new-cloud-ws', host: 'runtime:env-b' }) + renderHook(() => useClaudeThemeSyncOffer()) + expect(toast).not.toHaveBeenCalled() + }) + + it('offers right away when Claude runs on this computer', () => { + seedCloudWorkspaceSnapshot({ ids: new Set(), idsLoaded: true }) + seed({ id: 'local-ws', host: 'local' }) + renderHook(() => useClaudeThemeSyncOffer()) + expect(toast).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/provider-notices/claude-theme-sync/claude-theme-sync-offer.ts b/src/renderer/src/components/terminal-pane/provider-notices/claude-theme-sync/claude-theme-sync-offer.ts index cb4cc6f87ff..a9a74512bfd 100644 --- a/src/renderer/src/components/terminal-pane/provider-notices/claude-theme-sync/claude-theme-sync-offer.ts +++ b/src/renderer/src/components/terminal-pane/provider-notices/claude-theme-sync/claude-theme-sync-offer.ts @@ -6,6 +6,8 @@ import type { AppState } from '@/store/types' import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' import { TRAILING_ACTIONS_CLASS_NAMES } from '@/components/ui/sonner' import { whenAgentTerminalAppears } from '../agent-presence/agent-terminal-presence' +import { getCloudWorkspaceSnapshot } from '@/lib/orca-cloud/orca-cloud-workspace-store' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../../../shared/execution-host' const OFFER_TOAST_ID = 'claude-theme-sync-offer' @@ -64,6 +66,48 @@ function showClaudeThemeSyncOffer(): void { }) } +/** + * The sync changes Claude Code on THIS computer. Hold the offer while the active workspace runs + * elsewhere (Orca Cloud, a VM, SSH, an Orca server) or is still being created: a brand-new cloud + * workspace is not in the cloud id list yet when its Claude terminal appears (RA-6). + */ +function isAwayFromThisComputer(state: AppState): boolean { + if (state.activePendingCreationId) { + return true + } + const id = state.activeWorktreeId + if (!id) { + return false + } + if (getCloudWorkspaceSnapshot().ids.has(id) || state.ephemeralVmRuntimesByWorkspaceId?.[id]) { + return true + } + const host = state.activeWorkspaceExecutionHostId + return Boolean(host && host !== LOCAL_EXECUTION_HOST_ID) +} + +/** + * The sync changes Claude Code on this computer, which an Orca Cloud workspace's Claude doesn't + * use: hold the offer while one is active and make it once a local workspace is (RA-6). + */ +function offerOnLocalWorkspace(): () => void { + if (!isAwayFromThisComputer(useAppStore.getState())) { + showClaudeThemeSyncOffer() + return () => {} + } + const unsubscribe = useAppStore.subscribe((state, previous) => { + const moved = + state.activeWorktreeId !== previous.activeWorktreeId || + state.activePendingCreationId !== previous.activePendingCreationId || + state.activeWorkspaceExecutionHostId !== previous.activeWorkspaceExecutionHostId + if (moved && !isAwayFromThisComputer(state)) { + unsubscribe() + showClaudeThemeSyncOffer() + } + }) + return unsubscribe +} + /** Offers theme sync once, when Claude Code first runs in an Orca terminal. */ export function useClaudeThemeSyncOffer(): void { const due = useAppStore(isOfferDue) @@ -73,6 +117,13 @@ export function useClaudeThemeSyncOffer(): void { if (!due || isPairedWebClientWindow()) { return } - return whenAgentTerminalAppears('claude', showClaudeThemeSyncOffer) + let stopWaitingForLocal: () => void = () => {} + const stopWatching = whenAgentTerminalAppears('claude', () => { + stopWaitingForLocal = offerOnLocalWorkspace() + }) + return () => { + stopWatching() + stopWaitingForLocal() + } }, [due]) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 10ba63ffd5f..9772895488e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -8087,6 +8087,10 @@ "delete": "Delete workspace", "uncommitted_one": "{{count}} uncommitted change", "uncommitted_other": "{{count}} uncommitted changes" + }, + "orcaCloudHost": { + "running": "{{count}} running", + "runningAsleep": "{{running}} running · {{asleep}} asleep" } }, "shared": { diff --git a/src/shared/sidebar-list/host-section-aliases.ts b/src/shared/sidebar-list/host-section-aliases.ts new file mode 100644 index 00000000000..4d9dece0b9c --- /dev/null +++ b/src/shared/sidebar-list/host-section-aliases.ts @@ -0,0 +1,86 @@ +import type { ExecutionHostId } from '../execution-host' +import type { Repo } from '../repo-types' +import type { Row } from './row-types' + +type MergedHost = { id: ExecutionHostId; mergedIntoHostId?: ExecutionHostId } + +/** + * Host ids that share one section: a merged-away host (a managed server's SSH route, or one Orca + * Cloud sandbox among several) resolves to the row that stands for it. Null when nothing merges. + */ +export function createHostSectionResolver( + hosts: readonly MergedHost[] +): ((hostId: ExecutionHostId) => ExecutionHostId) | null { + const shownById = new Map() + for (const host of hosts) { + if (host.mergedIntoHostId) { + shownById.set(host.id, host.mergedIntoHostId) + } + } + return shownById.size > 0 ? (hostId) => shownById.get(hostId) ?? hostId : null +} + +function rekeyCounts( + map: ReadonlyMap | undefined, + resolve: (hostId: ExecutionHostId) => ExecutionHostId +): ReadonlyMap | undefined { + if (!map) { + return map + } + const next = new Map() + for (const [hostId, count] of map) { + const shown = resolve(hostId) + next.set(shown, (next.get(shown) ?? 0) + count) + } + return next +} + +function rekeyIds( + map: ReadonlyMap | undefined, + resolve: (hostId: ExecutionHostId) => ExecutionHostId +): ReadonlyMap | undefined { + if (!map) { + return map + } + const next = new Map() + for (const [hostId, ids] of map) { + const shown = resolve(hostId) + next.set(shown, [...(next.get(shown) ?? []), ...ids]) + } + return next +} + +function rekeyRepos( + map: ReadonlyMap, + resolve: (hostId: ExecutionHostId) => ExecutionHostId +): ReadonlyMap { + const next = new Map() + for (const [hostId, repo] of map) { + const shown = resolve(hostId) + // The shown host's own registration names the header; else the first merged one's. + if (!next.has(shown) || hostId === shown) { + next.set(shown, repo) + } + } + return next +} + +/** A header's per-host maps keyed by section: one project header per section, counts summed. */ +export function mergeHeaderHostsIntoSections( + rows: readonly Row[], + resolve: (hostId: ExecutionHostId) => ExecutionHostId +): Row[] { + return rows.map((row) => { + if (row.type !== 'header' || (!row.hostRepos && !row.hostWorktreeCounts)) { + return row + } + return { + ...row, + ...(row.hostRepos ? { hostRepos: rekeyRepos(row.hostRepos, resolve) } : {}), + ...(row.hostWorktreeCounts + ? { hostWorktreeCounts: rekeyCounts(row.hostWorktreeCounts, resolve) } + : {}), + ...(row.hostWorktreeIds ? { hostWorktreeIds: rekeyIds(row.hostWorktreeIds, resolve) } : {}) + } + }) +} diff --git a/src/shared/sidebar-list/host-section-rows.ts b/src/shared/sidebar-list/host-section-rows.ts index fb02011167d..40e54b65003 100644 --- a/src/shared/sidebar-list/host-section-rows.ts +++ b/src/shared/sidebar-list/host-section-rows.ts @@ -17,6 +17,7 @@ import type { SshConnectionStatus } from '../ssh-types' import type { Row } from './row-types' import { getFolderWorkspaceHostId } from './folder-workspace-host-id' import { countWorkspaceRows } from './host-section-workspace-count' +import { createHostSectionResolver, mergeHeaderHostsIntoSections } from './host-section-aliases' import { localizeProjectHeader, localizeRowsToHost } from './project-header-localization' export type HostHeaderRow = { @@ -50,6 +51,8 @@ export type HostSectionOption = { blockedReason?: RuntimeHostBlockedReason vmState?: ExecutionHostVmState connectionStatus?: SshConnectionStatus + /** Shares the section of the host it merged into (see host-section-aliases). */ + mergedIntoHostId?: ExecutionHostId } function getRowHostId(row: Row, defaultHostId: ExecutionHostId): ExecutionHostId | null { @@ -146,6 +149,13 @@ export function addHostSectionRows(args: { return localizeRowsToHost(args.rows, args.hostOptions[0]?.id) } + // Merged-away hosts (each Orca Cloud sandbox after the first) share their shown host's section. + const resolveHost = createHostSectionResolver(args.hostOptions) + const inputRows = resolveHost ? mergeHeaderHostsIntoSections(args.rows, resolveHost) : args.rows + const sectionHostOf = (row: Row): ExecutionHostId | null => { + const hostId = getRowHostId(row, args.defaultHostId) + return hostId && resolveHost ? resolveHost(hostId) : hostId + } const hostOptionsById = new Map(args.hostOptions.map((host) => [host.id, host])) const rowsByHostId = new Map() const globalRows: Row[] = [] @@ -214,7 +224,7 @@ export function addHostSectionRows(args: { // A nested child stays in its lineage root's section, even when it runs on another machine. let lineageRootHostId: ExecutionHostId | null = null - for (const row of args.rows) { + for (const row of inputRows) { if (row.type === 'header' && row.repo) { retainPendingAncestors(row.projectGroupDepth ?? 0) } @@ -225,7 +235,7 @@ export function addHostSectionRows(args: { } continue } - const ownHostId = getRowHostId(row, args.defaultHostId) + const ownHostId = sectionHostOf(row) if ((row.type === 'item' || row.type === 'folder-workspace') && row.depth === 0) { lineageRootHostId = ownHostId } @@ -249,7 +259,7 @@ export function addHostSectionRows(args: { const hostOrder: ExecutionHostId[] = [] for (const host of args.hostOptions) { - if (rowsByHostId.has(host.id)) { + if (rowsByHostId.has(host.id) && !host.mergedIntoHostId) { hostOrder.push(host.id) } } @@ -261,7 +271,7 @@ export function addHostSectionRows(args: { // Show host headers only for mixed hosts; registered empty hosts remain in the scope picker. if (rowsByHostId.size <= 1) { - return localizeRowsToHost(args.rows, rowsByHostId.keys().next().value) + return localizeRowsToHost(inputRows, rowsByHostId.keys().next().value) } const result: HostSectionRow[] = [...globalRows] @@ -302,9 +312,7 @@ export function addHostSectionRows(args: { return { ...row, hostId: row.hostId ?? hostId, ...(repeated ? { repeated: true } : {}) } } // The enclosing host header already names the owner, unless a nested child runs elsewhere. - return 'hostContextLabel' in row && - row.hostContextLabel && - getRowHostId(row, args.defaultHostId) === hostId + return 'hostContextLabel' in row && row.hostContextLabel && sectionHostOf(row) === hostId ? { ...row, hostContextLabel: undefined } : row })