From 82c6ee130364fccaed8d18896464fc27a98aab3f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 23:17:05 -0700 Subject: [PATCH] Fix worktree host ownership consistency --- .../src/components/sidebar/WorktreeList.tsx | 6 ++---- .../components/sidebar/host-section-rows.ts | 5 ++--- .../sidebar/visible-worktrees.test.ts | 19 +++++++++++++++++ .../components/sidebar/visible-worktrees.ts | 7 ++----- .../sidebar/worktree-list-groups.ts | 21 ++++--------------- .../sidebar/worktree-parent-candidates.ts | 4 ++-- src/shared/execution-host.test.ts | 15 +++++++++++++ src/shared/execution-host.ts | 15 ++++++++++++- 8 files changed, 60 insertions(+), 32 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 158fde9c890..e73e9142f3e 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -215,6 +215,7 @@ import { ALL_EXECUTION_HOSTS_SCOPE, getRepoExecutionHostId, getSettingsFocusedExecutionHostId, + getWorktreeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import { getRepoHeaderCreateState } from './repo-header-create-state' @@ -2115,10 +2116,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const targetWorktree = worktrees.find((w) => w.id === pendingRevealWorktree.worktreeId) const targetRepo = targetWorktree ? repoMap.get(targetWorktree.repoId) : undefined if (targetWorktree) { - const hostId = - targetRepo && (targetRepo.connectionId || targetRepo.executionHostId) - ? getRepoExecutionHostId(targetRepo) - : defaultHostId + const hostId = getWorktreeExecutionHostId(targetWorktree, targetRepo, defaultHostId) const hostGroupKey = `host:${hostId}` if (collapsedGroups.has(hostGroupKey)) { toggleGroup(hostGroupKey) diff --git a/src/renderer/src/components/sidebar/host-section-rows.ts b/src/renderer/src/components/sidebar/host-section-rows.ts index 34022238a89..7ca084d7520 100644 --- a/src/renderer/src/components/sidebar/host-section-rows.ts +++ b/src/renderer/src/components/sidebar/host-section-rows.ts @@ -3,6 +3,7 @@ import { LOCAL_EXECUTION_HOST_ID, getLocalExecutionHostLabel, getRepoExecutionHostId, + getWorktreeExecutionHostId, type ExecutionHostId, type ExecutionHostKind, type ExecutionHostScope @@ -69,9 +70,7 @@ function getFolderWorkspaceHostId( function getRowHostId(row: Row, defaultHostId: ExecutionHostId): ExecutionHostId | null { switch (row.type) { case 'item': - // Why: runtime and SSH snapshots can carry a more precise owner than the - // repo fallback; header localization already uses this worktree identity. - return row.worktree.hostId ?? getRepoHostId(row.repo, defaultHostId) + return getWorktreeExecutionHostId(row.worktree, row.repo, defaultHostId) case 'pending-creation': case 'imported-worktrees-card': case 'new-external-worktrees-inbox': diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index c1da9409447..939ca46a1e9 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -329,6 +329,25 @@ describe('computeVisibleWorktreeIds', () => { expect(result).toEqual([local.id]) }) + it('uses explicit worktree ownership when it differs from the repo host', () => { + const runtime = makeWorktree('runtime', 'repo1') + runtime.hostId = 'runtime:env-1' + + const runtimeResult = computeVisibleWorktreeIds( + { repo1: [runtime] }, + [runtime.id], + visibleOptions({ workspaceHostScope: 'runtime:env-1' }) + ) + const localResult = computeVisibleWorktreeIds( + { repo1: [runtime] }, + [runtime.id], + visibleOptions({ workspaceHostScope: 'local' }) + ) + + expect(runtimeResult).toEqual([runtime.id]) + expect(localResult).toEqual([]) + }) + it('keeps every host visible when workspace host scope is all', () => { const local = makeWorktree('local', 'repo1') const remote = makeWorktree('remote', 'repo2') diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 7ac1b111832..8809e7f7835 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -6,8 +6,8 @@ import { getAllWorktreesFromState, getRepoMapFromState } from '@/store/selectors import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' import { ALL_EXECUTION_HOSTS_SCOPE, - getRepoExecutionHostId, getSettingsFocusedExecutionHostId, + getWorktreeExecutionHostId, type ExecutionHostId, type ExecutionHostScope } from '../../../../shared/execution-host' @@ -153,10 +153,7 @@ export function computeVisibleWorktreeIds( if (!repo) { return false } - const hostId = - repo.connectionId || repo.executionHostId - ? getRepoExecutionHostId(repo) - : opts.defaultHostId + const hostId = getWorktreeExecutionHostId(w, repo, opts.defaultHostId) return visibleHostIdSet.has(hostId) }) } diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index f384293fd7f..41fcdaf9c3b 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -38,6 +38,7 @@ import { getExecutionHostLabel, LOCAL_EXECUTION_HOST_ID, getRepoExecutionHostId, + getWorktreeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import { parseWslUncPath } from '../../../../shared/wsl-paths' @@ -476,7 +477,7 @@ function emitPinnedGroup( const pinnedRepoOrder: string[] = [] const seenPinnedRepoIds = new Set() for (const worktree of pinned) { - const hostId = getWorktreeHostId(worktree, repoMap, defaultHostId) + const hostId = getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId), defaultHostId) hostWorktreeCounts.set(hostId, (hostWorktreeCounts.get(hostId) ?? 0) + 1) const hostIds = hostWorktreeIds.get(hostId) ?? [] hostIds.push(worktree.id) @@ -730,20 +731,6 @@ function getMixedHostContextLabels( return uniqueLabels.size > 1 ? labelsByRepoId : undefined } -function getWorktreeHostId( - worktree: Worktree, - repoMap: Map, - defaultHostId: ExecutionHostId -): ExecutionHostId { - if (worktree.hostId) { - return worktree.hostId - } - const repo = repoMap.get(worktree.repoId) - return repo && (repo.connectionId || repo.executionHostId) - ? getRepoExecutionHostId(repo) - : defaultHostId -} - function getHostWorktreeCounts( worktrees: readonly Worktree[], repoMap: Map, @@ -759,7 +746,7 @@ function getHostWorktreeCounts( continue } seenWorktreeIds.add(worktree.id) - const hostId = getWorktreeHostId(worktree, repoMap, defaultHostId) + const hostId = getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId), defaultHostId) counts.set(hostId, (counts.get(hostId) ?? 0) + 1) } return counts @@ -780,7 +767,7 @@ function getHostWorktreeIds( continue } seenWorktreeIds.add(worktree.id) - const hostId = getWorktreeHostId(worktree, repoMap, defaultHostId) + const hostId = getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId), defaultHostId) const ids = idsByHost.get(hostId) ?? [] ids.push(worktree.id) idsByHost.set(hostId, ids) diff --git a/src/renderer/src/components/sidebar/worktree-parent-candidates.ts b/src/renderer/src/components/sidebar/worktree-parent-candidates.ts index 4b3daa74238..6509743ba09 100644 --- a/src/renderer/src/components/sidebar/worktree-parent-candidates.ts +++ b/src/renderer/src/components/sidebar/worktree-parent-candidates.ts @@ -1,4 +1,4 @@ -import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { getWorktreeExecutionHostId } from '../../../../shared/execution-host' import type { Repo, Worktree, WorktreeLineage } from '../../../../shared/types' import { canAssignWorktreeParent } from './worktree-parent-eligibility' @@ -15,7 +15,7 @@ function getWorktreeOwnerHostId( repoMap: Map> ): string | null { const repo = repoMap.get(worktree.repoId) - return worktree.hostId ?? (repo ? getRepoExecutionHostId(repo) : null) + return repo ? getWorktreeExecutionHostId(worktree, repo) : (worktree.hostId ?? null) } export function getEligibleWorktreeParents({ diff --git a/src/shared/execution-host.test.ts b/src/shared/execution-host.test.ts index 503962a1021..d5e18506cb5 100644 --- a/src/shared/execution-host.test.ts +++ b/src/shared/execution-host.test.ts @@ -5,6 +5,7 @@ import { getLocalExecutionHostLabel, getRepoExecutionHostId, getSettingsFocusedExecutionHostId, + getWorktreeExecutionHostId, normalizeExecutionHostOrder, normalizeExecutionHostScope, normalizeVisibleExecutionHostIds, @@ -87,6 +88,20 @@ describe('execution host identity', () => { expect(getRepoExecutionHostId({ connectionId: 'ssh-target-1' })).toBe('ssh:ssh-target-1') }) + it('prefers explicit worktree ownership before repo and focused-host fallbacks', () => { + expect( + getWorktreeExecutionHostId( + { hostId: 'runtime:workspace-owner' }, + { connectionId: 'repo-owner' }, + 'runtime:focused-host' + ) + ).toBe('runtime:workspace-owner') + expect( + getWorktreeExecutionHostId({}, { connectionId: 'repo-owner' }, 'runtime:focused-host') + ).toBe('ssh:repo-owner') + expect(getWorktreeExecutionHostId({}, {}, 'runtime:focused-host')).toBe('runtime:focused-host') + }) + it('derives focused host compatibility from active runtime settings', () => { expect(getSettingsFocusedExecutionHostId(null)).toBe(LOCAL_EXECUTION_HOST_ID) expect(getSettingsFocusedExecutionHostId({ activeRuntimeEnvironmentId: 'runtime-1' })).toBe( diff --git a/src/shared/execution-host.ts b/src/shared/execution-host.ts index a2b9ae69104..1eadaebe942 100644 --- a/src/shared/execution-host.ts +++ b/src/shared/execution-host.ts @@ -1,4 +1,4 @@ -import type { GlobalSettings, Repo } from './types' +import type { GlobalSettings, Repo, Worktree } from './types' export const LOCAL_EXECUTION_HOST_ID = 'local' export const ALL_EXECUTION_HOSTS_SCOPE = 'all' @@ -150,6 +150,19 @@ export function getRepoExecutionHostId( return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID } +export function getWorktreeExecutionHostId( + worktree: Pick, + repo: Pick | undefined, + defaultHostId: ExecutionHostId = LOCAL_EXECUTION_HOST_ID +): ExecutionHostId { + // Why: runtime and SSH snapshots can identify a more precise owner than + // the repo fallback; every sidebar host decision must use the same precedence. + return ( + worktree.hostId ?? + (repo?.connectionId || repo?.executionHostId ? getRepoExecutionHostId(repo) : defaultHostId) + ) +} + export function getSettingsFocusedExecutionHostId( settings: Pick | null | undefined ): ExecutionHostId {