fix(sidebar): identify remote server workspaces (#10061)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
parent
bae29fd1e2
commit
a2e440b308
|
|
@ -14,6 +14,7 @@ let WorktreeCard: typeof WorktreeCardComponent
|
|||
let sshConnectionStates = new Map<string, { status: string }>()
|
||||
let sshTargetLabels = new Map<string, string>()
|
||||
let runtimeStatusByEnvironmentId = new Map<string, { status?: unknown }>()
|
||||
let runtimeEnvironments: { id: string; name: string }[] = []
|
||||
let sshStateByEnvironment = new Map()
|
||||
let worktreesByRepo: Record<string, Worktree[]> = {}
|
||||
let worktreeCardProperties: WorktreeCardProperty[] = ['status']
|
||||
|
|
@ -32,6 +33,7 @@ vi.mock('@/store', () => ({
|
|||
openModal,
|
||||
projectGroups: [],
|
||||
remoteBranchConflictByWorktreeId: {},
|
||||
runtimeEnvironments,
|
||||
runtimeStatusByEnvironmentId,
|
||||
removedSshTargetLabels: new Map(),
|
||||
settings: null,
|
||||
|
|
@ -136,6 +138,7 @@ describe('WorktreeCard SSH reconnect prompt', () => {
|
|||
sshConnectionStates = new Map()
|
||||
sshTargetLabels = new Map()
|
||||
runtimeStatusByEnvironmentId = new Map()
|
||||
runtimeEnvironments = []
|
||||
sshStateByEnvironment = new Map()
|
||||
worktreesByRepo = {}
|
||||
worktreeCardProperties = ['status']
|
||||
|
|
@ -157,6 +160,7 @@ describe('WorktreeCard SSH reconnect prompt', () => {
|
|||
})
|
||||
|
||||
it('marks a runtime-host worktree disconnected when its environment has no status', () => {
|
||||
runtimeEnvironments = [{ id: 'env-1', name: 'Remote Mac' }]
|
||||
const runtimeRepo: Repo = {
|
||||
...makeRepo(),
|
||||
connectionId: undefined,
|
||||
|
|
@ -166,21 +170,34 @@ describe('WorktreeCard SSH reconnect prompt', () => {
|
|||
const markup = renderToStaticMarkup(
|
||||
<WorktreeCard worktree={makeWorktree()} repo={runtimeRepo} isActive={false} />
|
||||
)
|
||||
expect(markup).toContain('Server disconnected')
|
||||
expect(markup).toContain('Remote Mac disconnected')
|
||||
})
|
||||
|
||||
it('shows a runtime-host worktree as connected when its environment has a status', () => {
|
||||
it('distinguishes connected worktrees on different Orca servers', () => {
|
||||
runtimeEnvironments = [
|
||||
{ id: 'env-1', name: 'Remote Mac' },
|
||||
{ id: 'env-2', name: 'Build Linux' }
|
||||
]
|
||||
runtimeStatusByEnvironmentId.set('env-1', { status: { runtimeId: 'r1' } })
|
||||
const runtimeRepo: Repo = {
|
||||
...makeRepo(),
|
||||
connectionId: undefined,
|
||||
executionHostId: 'runtime:env-1'
|
||||
}
|
||||
const markup = renderToStaticMarkup(
|
||||
<WorktreeCard worktree={makeWorktree()} repo={runtimeRepo} isActive={false} />
|
||||
runtimeStatusByEnvironmentId.set('env-2', { status: { runtimeId: 'r2' } })
|
||||
|
||||
const remoteMacMarkup = renderToStaticMarkup(
|
||||
<WorktreeCard
|
||||
worktree={{ ...makeWorktree(), runtimeOwnerEnvironmentId: 'env-1' }}
|
||||
repo={{ ...makeRepo(), connectionId: undefined, executionHostId: 'runtime:env-2' }}
|
||||
isActive={false}
|
||||
/>
|
||||
)
|
||||
expect(markup).not.toContain('Server disconnected')
|
||||
expect(markup).toContain('Project on Orca server')
|
||||
const buildLinuxMarkup = renderToStaticMarkup(
|
||||
<WorktreeCard
|
||||
worktree={makeWorktree()}
|
||||
repo={{ ...makeRepo(), connectionId: undefined, executionHostId: 'runtime:env-2' }}
|
||||
isActive={false}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(remoteMacMarkup).toContain('Project on Remote Mac')
|
||||
expect(buildLinuxMarkup).toContain('Project on Build Linux')
|
||||
})
|
||||
|
||||
it('reads nested SSH readiness from the owning HUB instead of client-local SSH state', () => {
|
||||
|
|
|
|||
|
|
@ -81,7 +81,12 @@ import {
|
|||
import { translate } from '@/i18n/i18n'
|
||||
import { recordRendererCrashBreadcrumb } from '@/lib/crash-diagnostics'
|
||||
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { isRuntimeOwnedSshTargetId, parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
import {
|
||||
isRuntimeOwnedSshTargetId,
|
||||
parseExecutionHostId,
|
||||
toRuntimeExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides'
|
||||
import { DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE } from '../../../../shared/constants'
|
||||
import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import {
|
||||
|
|
@ -358,13 +363,28 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
|||
(s) => (s.activeTabTypeByWorktree?.[worktree.id] ?? 'terminal') === 'terminal'
|
||||
)
|
||||
|
||||
const parsedRepoHost = parseExecutionHostId(repo?.executionHostId)
|
||||
const runtimeOwnerEnvironmentId =
|
||||
worktree.runtimeOwnerEnvironmentId ??
|
||||
(parsedRepoHost?.kind === 'runtime' ? parsedRepoHost.environmentId : null)
|
||||
const runtimeHostId = runtimeOwnerEnvironmentId
|
||||
? toRuntimeExecutionHostId(runtimeOwnerEnvironmentId)
|
||||
: null
|
||||
const runtimeEnvironmentName = useAppStore((s) =>
|
||||
runtimeOwnerEnvironmentId
|
||||
? (s.runtimeEnvironments.find((environment) => environment.id === runtimeOwnerEnvironmentId)
|
||||
?.name ?? null)
|
||||
: null
|
||||
)
|
||||
const runtimeHostLabel = runtimeHostId
|
||||
? (getHostDisplayLabelOverrides(settings).get(runtimeHostId) ?? runtimeEnvironmentName)
|
||||
: null
|
||||
// Why: runtime ("Orca server") hosts get the same disconnected dimming as SSH when their environment has no live status.
|
||||
const isRuntimeDisconnected = useAppStore((s) => {
|
||||
const parsed = parseExecutionHostId(repo?.executionHostId)
|
||||
if (parsed?.kind !== 'runtime') {
|
||||
if (!runtimeOwnerEnvironmentId) {
|
||||
return false
|
||||
}
|
||||
return !s.runtimeStatusByEnvironmentId.get(parsed.environmentId)?.status
|
||||
return !s.runtimeStatusByEnvironmentId.get(runtimeOwnerEnvironmentId)?.status
|
||||
})
|
||||
// Why: the reconnect dialog blocks, so it never auto-shows for the active card (would steal app-wide focus); opens only on deliberate focus (handleClick).
|
||||
const [showDisconnectedDialog, setShowDisconnectedDialog] = useState(false)
|
||||
|
|
@ -1405,31 +1425,42 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
|||
</Tooltip>
|
||||
)}
|
||||
|
||||
{!repo?.connectionId &&
|
||||
parseExecutionHostId(repo?.executionHostId)?.kind === 'runtime' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="shrink-0 inline-flex items-center">
|
||||
{isRuntimeDisconnected ? (
|
||||
<ServerOff className="size-3 text-red-400" />
|
||||
) : (
|
||||
<Server className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{isRuntimeDisconnected
|
||||
{!repo?.connectionId && parsedRepoHost?.kind === 'runtime' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="shrink-0 inline-flex items-center">
|
||||
{isRuntimeDisconnected ? (
|
||||
<ServerOff className="size-3 text-red-400" />
|
||||
) : (
|
||||
<Server className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{isRuntimeDisconnected
|
||||
? runtimeHostLabel
|
||||
? translate(
|
||||
'auto.components.sidebar.WorktreeCard.runtimeHostDisconnectedNamed',
|
||||
'{{hostName}} disconnected',
|
||||
{ hostName: runtimeHostLabel }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.sidebar.WorktreeCard.runtimeHostDisconnected',
|
||||
'Server disconnected'
|
||||
)
|
||||
: runtimeHostLabel
|
||||
? translate(
|
||||
'auto.components.sidebar.WorktreeCard.runtimeHostProjectNamed',
|
||||
'Project on {{hostName}}',
|
||||
{ hostName: runtimeHostLabel }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.sidebar.WorktreeCard.runtimeHostProject',
|
||||
'Project on Orca server'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{showInlineRepoBadge && (
|
||||
<RepoIdentityChip repo={repo}>
|
||||
|
|
|
|||
|
|
@ -1125,6 +1125,65 @@ describe('buildRows with pinned worktrees', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('shows distinct Orca server names when status grouping mixes runtime hosts', () => {
|
||||
const firstRepo: Repo = {
|
||||
...repo,
|
||||
id: 'repo-runtime-a',
|
||||
executionHostId: 'runtime:env-a'
|
||||
}
|
||||
const secondRepo: Repo = {
|
||||
...repo,
|
||||
id: 'repo-runtime-b',
|
||||
executionHostId: 'runtime:env-b'
|
||||
}
|
||||
const firstWorktree: Worktree = {
|
||||
...worktree,
|
||||
id: 'wt-runtime-a',
|
||||
repoId: firstRepo.id
|
||||
}
|
||||
const secondWorktree: Worktree = {
|
||||
...worktree,
|
||||
id: 'wt-runtime-b',
|
||||
repoId: secondRepo.id
|
||||
}
|
||||
const rows = buildRows(
|
||||
'workspace-status',
|
||||
[firstWorktree, secondWorktree],
|
||||
new Map([
|
||||
[firstRepo.id, firstRepo],
|
||||
[secondRepo.id, secondRepo]
|
||||
]),
|
||||
null,
|
||||
new Set(),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
new Map([
|
||||
[firstWorktree.id, firstWorktree],
|
||||
[secondWorktree.id, secondWorktree]
|
||||
]),
|
||||
false,
|
||||
undefined,
|
||||
[],
|
||||
new Set(),
|
||||
new Map(),
|
||||
new Map(),
|
||||
[],
|
||||
undefined,
|
||||
[],
|
||||
new Map([
|
||||
['runtime:env-a', 'Remote Mac'],
|
||||
['runtime:env-b', 'Build Linux']
|
||||
])
|
||||
)
|
||||
|
||||
expect(rows.filter((row) => row.type === 'item')).toMatchObject([
|
||||
{ worktree: { id: firstWorktree.id }, hostContextLabel: 'Remote Mac' },
|
||||
{ worktree: { id: secondWorktree.id }, hostContextLabel: 'Build Linux' }
|
||||
])
|
||||
})
|
||||
|
||||
it('omits host context labels when a project group only has one host', () => {
|
||||
const secondLocalWorktree: Worktree = {
|
||||
...worktree,
|
||||
|
|
|
|||
|
|
@ -585,6 +585,7 @@ function appendWorktreeRows(
|
|||
groupDepth: number
|
||||
sectionKey: string
|
||||
hostContextLabelByRepoId?: ReadonlyMap<string, string>
|
||||
hostContextLabelByWorktreeId?: ReadonlyMap<string, string>
|
||||
cyclicLineageIds: ReadonlySet<string>
|
||||
}
|
||||
): void {
|
||||
|
|
@ -594,6 +595,7 @@ function appendWorktreeRows(
|
|||
groupDepth,
|
||||
sectionKey,
|
||||
hostContextLabelByRepoId,
|
||||
hostContextLabelByWorktreeId,
|
||||
cyclicLineageIds
|
||||
} = options
|
||||
if (!nestLineage) {
|
||||
|
|
@ -608,7 +610,9 @@ function appendWorktreeRows(
|
|||
isLastLineageChild: false,
|
||||
lineageChildCount: 0,
|
||||
lineageCollapsed: false,
|
||||
hostContextLabel: hostContextLabelByRepoId?.get(worktree.repoId)
|
||||
hostContextLabel:
|
||||
hostContextLabelByWorktreeId?.get(worktree.id) ??
|
||||
hostContextLabelByRepoId?.get(worktree.repoId)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
|
@ -653,7 +657,9 @@ function appendWorktreeRows(
|
|||
isLastLineageChild: isLastChild,
|
||||
lineageChildCount: children.length,
|
||||
lineageCollapsed,
|
||||
hostContextLabel: hostContextLabelByRepoId?.get(worktree.repoId)
|
||||
hostContextLabel:
|
||||
hostContextLabelByWorktreeId?.get(worktree.id) ??
|
||||
hostContextLabelByRepoId?.get(worktree.repoId)
|
||||
})
|
||||
)
|
||||
if (lineageCollapsed) {
|
||||
|
|
@ -721,6 +727,22 @@ function getMixedHostContextLabels(
|
|||
return uniqueLabels.size > 1 ? labelsByRepoId : undefined
|
||||
}
|
||||
|
||||
function getMixedWorktreeHostContextLabels(
|
||||
worktrees: readonly Worktree[],
|
||||
repoMap: Map<string, Repo>,
|
||||
hostLabelById: ReadonlyMap<string, string> | undefined,
|
||||
defaultHostId: ExecutionHostId
|
||||
): Map<string, string> | undefined {
|
||||
const labelsByWorktreeId = new Map<string, string>()
|
||||
const uniqueHostIds = new Set<ExecutionHostId>()
|
||||
for (const worktree of worktrees) {
|
||||
const hostId = getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId), defaultHostId)
|
||||
uniqueHostIds.add(hostId)
|
||||
labelsByWorktreeId.set(worktree.id, hostLabelById?.get(hostId) ?? getExecutionHostLabel(hostId))
|
||||
}
|
||||
return uniqueHostIds.size > 1 ? labelsByWorktreeId : undefined
|
||||
}
|
||||
|
||||
function getHostWorktreeCounts(
|
||||
worktrees: readonly Worktree[],
|
||||
repoMap: Map<string, Repo>,
|
||||
|
|
@ -1013,6 +1035,12 @@ export function buildRows(
|
|||
pinnedDisplayPolicy === 'duplicate-in-groups'
|
||||
? worktrees
|
||||
: worktrees.filter((worktree) => !worktree.isPinned)
|
||||
const mixedWorktreeHostContextLabels = getMixedWorktreeHostContextLabels(
|
||||
naturalWorktrees,
|
||||
repoMap,
|
||||
hostLabelById,
|
||||
defaultHostId
|
||||
)
|
||||
const renderedNaturalAnchorRepoIds = getRenderedNaturalAnchorRepoIds({
|
||||
groupBy,
|
||||
worktrees: naturalWorktrees,
|
||||
|
|
@ -1052,6 +1080,7 @@ export function buildRows(
|
|||
collapsedGroups,
|
||||
groupDepth: 0,
|
||||
sectionKey: ALL_GROUP_KEY,
|
||||
hostContextLabelByWorktreeId: mixedWorktreeHostContextLabels,
|
||||
cyclicLineageIds
|
||||
})
|
||||
}
|
||||
|
|
@ -1290,6 +1319,8 @@ export function buildRows(
|
|||
groupBy === 'repo'
|
||||
? getMixedHostContextLabels(group, repoMap, projectIndex, hostLabelById)
|
||||
: undefined
|
||||
const hostContextLabelByWorktreeId =
|
||||
groupBy === 'repo' ? undefined : mixedWorktreeHostContextLabels
|
||||
if (groupBy === 'repo') {
|
||||
appendWorktreeRows(result, items, repoMap, lineageById, worktreeMap, {
|
||||
nestLineage,
|
||||
|
|
@ -1297,6 +1328,7 @@ export function buildRows(
|
|||
groupDepth: projectGroupDepth,
|
||||
sectionKey: key,
|
||||
hostContextLabelByRepoId,
|
||||
hostContextLabelByWorktreeId,
|
||||
cyclicLineageIds
|
||||
})
|
||||
} else {
|
||||
|
|
@ -1306,6 +1338,7 @@ export function buildRows(
|
|||
groupDepth: projectGroupDepth,
|
||||
sectionKey: key,
|
||||
hostContextLabelByRepoId,
|
||||
hostContextLabelByWorktreeId,
|
||||
cyclicLineageIds
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4298,7 +4298,9 @@
|
|||
"ca74db7550": "Project on SSH host",
|
||||
"021538e1d1": "SSH disconnected",
|
||||
"runtimeHostDisconnected": "Server disconnected",
|
||||
"runtimeHostDisconnectedNamed": "{{hostName}} disconnected",
|
||||
"runtimeHostProject": "Project on Orca server",
|
||||
"runtimeHostProjectNamed": "Project on {{hostName}}",
|
||||
"automationCreated": "Created by automation",
|
||||
"branchIdentity": "Branch",
|
||||
"branchFolderPathIdentity": "Branch or folder path",
|
||||
|
|
|
|||
|
|
@ -4252,7 +4252,9 @@
|
|||
"ca74db7550": "Proyecto en host SSH",
|
||||
"021538e1d1": "SSH desconectado",
|
||||
"runtimeHostDisconnected": "Servidor desconectado",
|
||||
"runtimeHostDisconnectedNamed": "{{hostName}} está desconectado",
|
||||
"runtimeHostProject": "Proyecto en servidor de Orca",
|
||||
"runtimeHostProjectNamed": "Proyecto en {{hostName}}",
|
||||
"automationCreated": "Creado por automatización",
|
||||
"branchIdentity": "Rama",
|
||||
"branchFolderPathIdentity": "Rama o ruta de carpeta",
|
||||
|
|
|
|||
|
|
@ -4233,7 +4233,9 @@
|
|||
"ca74db7550": "SSH 経由のリモートプロジェクト",
|
||||
"021538e1d1": "SSH が切断されました",
|
||||
"runtimeHostDisconnected": "サーバーが切断されました",
|
||||
"runtimeHostDisconnectedNamed": "{{hostName}} が切断されました",
|
||||
"runtimeHostProject": "Orca サーバー上のプロジェクト",
|
||||
"runtimeHostProjectNamed": "{{hostName}} 上のプロジェクト",
|
||||
"automationCreated": "自動化により作成",
|
||||
"branchIdentity": "ブランチ",
|
||||
"branchFolderPathIdentity": "ブランチまたはフォルダパス",
|
||||
|
|
|
|||
|
|
@ -4233,7 +4233,9 @@
|
|||
"ca74db7550": "SSH를 통한 원격 프로젝트",
|
||||
"021538e1d1": "SSH 연결이 끊어졌습니다.",
|
||||
"runtimeHostDisconnected": "서버 연결 끊김",
|
||||
"runtimeHostDisconnectedNamed": "{{hostName}} 연결 끊김",
|
||||
"runtimeHostProject": "Orca 서버의 프로젝트",
|
||||
"runtimeHostProjectNamed": "{{hostName}}의 프로젝트",
|
||||
"automationCreated": "자동화로 생성됨",
|
||||
"branchIdentity": "브랜치",
|
||||
"branchFolderPathIdentity": "브랜치 또는 폴더 경로",
|
||||
|
|
|
|||
|
|
@ -4233,7 +4233,9 @@
|
|||
"ca74db7550": "SSH 主机上的项目",
|
||||
"021538e1d1": "SSH 已断开连接",
|
||||
"runtimeHostDisconnected": "服务器已断开连接",
|
||||
"runtimeHostDisconnectedNamed": "{{hostName}} 已断开连接",
|
||||
"runtimeHostProject": "Orca 服务器上的项目",
|
||||
"runtimeHostProjectNamed": "{{hostName}} 上的项目",
|
||||
"automationCreated": "由自动化创建",
|
||||
"branchIdentity": "分支",
|
||||
"branchFolderPathIdentity": "分支或文件夹路径",
|
||||
|
|
|
|||
Loading…
Reference in New Issue