Files
orca/src/shared/sidebar-list/host-section-rows.ts
T
Neil ec546de75a fix(sidebar): remove redundant host badges in every grouping (#31744)
* fix(sidebar): omit host badges beneath host section headers

* fix(mobile): keep host health without repeating section labels

* test: align host section goldens and preserve chat translation exports

* fix(i18n): retire obsolete Portuguese Codex version key
2026-10-21 18:34:23 -07:00

334 lines
11 KiB
TypeScript

import {
ALL_EXECUTION_HOSTS_SCOPE,
LOCAL_EXECUTION_HOST_ID,
getRepoExecutionHostId,
getWorktreeExecutionHostId,
type ExecutionHostId,
type ExecutionHostKind,
type ExecutionHostScope
} from '../execution-host'
import type { ExecutionHostHealth } from '../execution-host-registry'
import type { RuntimeCompatVerdict } from '../protocol-compat'
import type { RuntimeHostBlockedReason } from '../runtime-host-status'
import type { SshConnectionStatus } from '../ssh-types'
import type { Repo } from '../repo-types'
import type { Row } from './row-types'
import { getFolderWorkspaceHostId } from './folder-workspace-host-id'
export type HostHeaderRow = {
type: 'host-header'
key: string
hostId: ExecutionHostId
kind: ExecutionHostKind
label: string
detail: string
health: ExecutionHostHealth
// Why: blocked-host guidance in the header menu needs the verdict reason so
// it can deep-link an "Update server/client required" row per skew direction.
compatibility?: RuntimeCompatVerdict
// Why: a revoked pairing needs "Re-pair", not the update guidance a version block gets.
blockedReason?: RuntimeHostBlockedReason
connectionStatus?: SshConnectionStatus
collapsed: boolean
count: number
}
export type HostSectionRow = Row | HostHeaderRow
export type HostSectionOption = {
id: ExecutionHostId
kind: ExecutionHostKind
label: string
detail: string
health: ExecutionHostHealth
compatibility?: RuntimeCompatVerdict
blockedReason?: RuntimeHostBlockedReason
connectionStatus?: SshConnectionStatus
}
function getRepoHostId(
repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined,
defaultHostId: ExecutionHostId
): ExecutionHostId {
// Why: explicit executionHostId must win over the focused/default host, or
// runtime-owned repos group under whichever host happens to be focused.
if (repo?.connectionId || repo?.executionHostId) {
return getRepoExecutionHostId(repo)
}
return defaultHostId
}
function getRowHostId(row: Row, defaultHostId: ExecutionHostId): ExecutionHostId | null {
switch (row.type) {
case 'item':
return getWorktreeExecutionHostId(row.worktree, row.repo, defaultHostId)
case 'pending-creation':
case 'imported-worktrees-card':
case 'new-external-worktrees-inbox':
return getRepoHostId(row.repo, defaultHostId)
case 'folder-workspace':
return getFolderWorkspaceHostId(row.folderWorkspace, row.projectGroup, defaultHostId)
case 'header':
return row.repo ? getRepoHostId(row.repo, defaultHostId) : null
}
}
function getFallbackHost(hostId: ExecutionHostId, localHostLabel: string): HostSectionOption {
const isLocal = hostId === LOCAL_EXECUTION_HOST_ID
return {
id: hostId,
kind: isLocal ? 'local' : hostId.startsWith('ssh:') ? 'ssh' : 'runtime',
label: isLocal ? localHostLabel : hostId,
detail: isLocal ? 'This computer' : 'Host',
health: isLocal ? 'local' : 'available'
}
}
function countWorkspaceRows(rows: readonly Row[]): number {
// Why: a collapsed repo group contributes a header row but no item rows;
// fall back to the header's own count so the host badge doesn't read 0
// while a visibly populated project sits right under it.
let count = 0
const seenWorktreeIds = new Set<string>()
let pendingHeader: Extract<Row, { type: 'header' }> | null = null
let pendingHeaderHadWorkspaces = false
const flushHeader = (): void => {
if (pendingHeader && !pendingHeaderHadWorkspaces) {
if (pendingHeader.worktreeIds) {
const headerWorktreeIds = new Set(pendingHeader.worktreeIds)
for (const worktreeId of pendingHeader.worktreeIds) {
if (!seenWorktreeIds.has(worktreeId)) {
count += 1
seenWorktreeIds.add(worktreeId)
}
}
// Folder workspaces contribute to the header count but have no worktree id.
count += Math.max(0, pendingHeader.count - headerWorktreeIds.size)
} else {
count += pendingHeader.count
}
}
pendingHeader = null
pendingHeaderHadWorkspaces = false
}
for (const row of rows) {
if (row.type === 'header') {
flushHeader()
pendingHeader = row
continue
}
if (row.type === 'item') {
if (!seenWorktreeIds.has(row.worktree.id)) {
count += 1
seenWorktreeIds.add(row.worktree.id)
}
pendingHeaderHadWorkspaces = pendingHeader !== null
continue
}
if (row.type === 'folder-workspace') {
count += 1
pendingHeaderHadWorkspaces = pendingHeader !== null
}
}
flushHeader()
return count
}
function localizePendingRowsForHost(
rows: readonly Extract<Row, { type: 'header' }>[],
hostId: ExecutionHostId
): Extract<Row, { type: 'header' }>[] {
const localized: Extract<Row, { type: 'header' }>[] = []
for (const row of rows) {
if (!row.hostWorktreeCounts) {
localized.push(row)
continue
}
const count = row.hostWorktreeCounts.get(hostId)
if (count !== undefined && count > 0) {
localized.push({
...row,
count,
hostId,
worktreeIds: row.hostWorktreeIds?.get(hostId) ?? row.worktreeIds
})
}
}
return localized
}
function getPendingRowKey(row: Extract<Row, { type: 'header' }>): string {
return `${row.key}:${row.count}:${row.worktreeIds?.join(',') ?? ''}`
}
/** A project group's header (it names no host itself): its nesting depth, else null. */
function getProjectGroupHeaderDepth(row: Row): number | null {
return row.type === 'header' && row.projectGroup && !row.repo
? (row.projectGroupDepth ?? 0)
: null
}
export function addHostSectionRows(args: {
rows: readonly Row[]
hostOptions: readonly HostSectionOption[]
workspaceHostScope: ExecutionHostScope
visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null
defaultHostId: ExecutionHostId
/** The listed host's own name for itself; a phone reader must not derive it from its own platform. */
localHostLabel: string
// Why: host sections reuse the sidebar's persisted collapsed-group keys
// (`host:<hostId>`) so collapse state survives restarts like other groups.
collapsedHostKeys?: ReadonlySet<string>
forceCollapseHosts?: boolean
// Why: in the default Projects view, project is the user's primary object
// and host is context inside it. Explicit host filters still keep host
// headers as an operational/troubleshooting view.
preferProjectGrouping?: boolean
}): HostSectionRow[] {
const visibleHostIds =
args.visibleWorkspaceHostIds ??
(args.workspaceHostScope === ALL_EXECUTION_HOSTS_SCOPE ? null : [args.workspaceHostScope])
if (
args.preferProjectGrouping &&
args.workspaceHostScope === ALL_EXECUTION_HOSTS_SCOPE &&
!args.visibleWorkspaceHostIds
) {
return [...args.rows]
}
if ((visibleHostIds && visibleHostIds.length <= 1) || args.hostOptions.length <= 1) {
return [...args.rows]
}
const hostOptionsById = new Map(args.hostOptions.map((host) => [host.id, host]))
const rowsByHostId = new Map<ExecutionHostId, Row[]>()
const globalRows: Row[] = []
// The headers above the next host-owned row: one lane header, or a project group's path.
let pendingRows: Extract<Row, { type: 'header' }>[] = []
const usedPendingRows = new Set<Row>()
// Each host's last emitted header path, so a sibling group repeats only what differs.
const emittedPathByHostId = new Map<ExecutionHostId, string[]>()
const flushUnusedPendingRows = (rows: readonly Extract<Row, { type: 'header' }>[]): void => {
const unused = rows.filter((row) => !usedPendingRows.has(row))
if (unused.length === 0) {
return
}
const hostScopedRow = unused.some((row) => row.hostWorktreeCounts)
if (!hostScopedRow) {
globalRows.push(...unused)
return
}
for (const row of unused) {
for (const [hostId, count] of row.hostWorktreeCounts ?? []) {
if (count <= 0) {
continue
}
const hostRows = rowsByHostId.get(hostId) ?? []
const hostIds = row.hostWorktreeIds?.get(hostId)
hostRows.push({
...row,
count,
hostId,
worktreeIds: hostIds ?? row.worktreeIds
})
rowsByHostId.set(hostId, hostRows)
}
}
}
for (const row of args.rows) {
const rowHostId = getRowHostId(row, args.defaultHostId)
if (rowHostId) {
const hostRows = rowsByHostId.get(rowHostId) ?? []
const localizedPendingRows = localizePendingRowsForHost(pendingRows, rowHostId)
const path = localizedPendingRows.map(getPendingRowKey)
const emitted = emittedPathByHostId.get(rowHostId) ?? []
let shared = 0
while (shared < path.length && path[shared] === emitted[shared]) {
shared += 1
}
if (shared < path.length || path.length !== emitted.length) {
hostRows.push(...localizedPendingRows.slice(shared))
emittedPathByHostId.set(rowHostId, path)
}
if (localizedPendingRows.length > 0) {
pendingRows.forEach((pendingRow) => usedPendingRows.add(pendingRow))
}
hostRows.push(row)
rowsByHostId.set(rowHostId, hostRows)
continue
}
// Why: status/"All" headers describe the rows that follow. Buffer them
// for every host-owned run so host remains above the existing grouping.
if (row.type === 'header') {
const depth = getProjectGroupHeaderDepth(row)
// A nested group keeps its ancestors pending, so each host section gets the whole path.
const kept =
depth !== null && depth > 0
? pendingRows.filter((pending) => (getProjectGroupHeaderDepth(pending) ?? depth) < depth)
: []
flushUnusedPendingRows(pendingRows.filter((pending) => !kept.includes(pending)))
pendingRows = [...kept, row]
} else {
globalRows.push(row)
}
}
flushUnusedPendingRows(pendingRows)
const hostOrder: ExecutionHostId[] = []
for (const host of args.hostOptions) {
if (rowsByHostId.has(host.id)) {
hostOrder.push(host.id)
}
}
for (const hostId of rowsByHostId.keys()) {
if (!hostOptionsById.has(hostId)) {
hostOrder.push(hostId)
}
}
// Why: a lone host section is pure noise — the grouping only earns its keep
// when there are at least two host sections to tell apart. Registered-but-
// empty hosts stay visible in the scope picker, not as headers.
if (rowsByHostId.size <= 1) {
return [...args.rows]
}
const result: HostSectionRow[] = [...globalRows]
for (const hostId of hostOrder) {
const hostRows = rowsByHostId.get(hostId)
if (!hostRows || hostRows.length === 0) {
continue
}
const host = hostOptionsById.get(hostId) ?? getFallbackHost(hostId, args.localHostLabel)
const collapsed =
args.forceCollapseHosts || (args.collapsedHostKeys?.has(`host:${host.id}`) ?? false)
result.push({
type: 'host-header',
key: `host:${host.id}`,
hostId: host.id,
kind: host.kind,
label: host.label,
detail: host.detail,
health: host.health,
compatibility: host.compatibility,
...(host.blockedReason ? { blockedReason: host.blockedReason } : {}),
connectionStatus: host.connectionStatus,
collapsed,
count: countWorkspaceRows(hostRows)
})
if (!collapsed) {
// The enclosing host header already names the owner of cards and notices.
result.push(
...hostRows.map((row) =>
'hostContextLabel' in row && row.hostContextLabel
? { ...row, hostContextLabel: undefined }
: row
)
)
}
}
return result
}