From 4045ff0be7fffd9cc4bcde814e6fb3649426489a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:53:04 -0700 Subject: [PATCH] fix(sidebar): label pinned rows with their host on a multi-host sidebar emitPinnedGroup was the one section emitter that appended worktree rows without hostContextLabelByWorktreeIdentity, and the mixed-host map it would have received was computed over naturalWorktrees, which under the default pinned policy has the pinned worktrees filtered out. Under that policy a pinned worktree renders only in the Pinned section, so a pinned remote workspace had no host badge anywhere. Compute the mixed-host map over the full worktree set and thread it into the Pinned emitter. Single-host sidebars still draw no badge. Fixes #18472 --- ...ree-list-groups-pinned-host-labels.test.ts | 119 ++++++++++++++++++ .../worktree-list/grouping/build-rows.ts | 8 +- .../grouping/pinned-group-rows.ts | 4 +- 3 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-list-groups-pinned-host-labels.test.ts diff --git a/src/renderer/src/components/sidebar/worktree-list-groups-pinned-host-labels.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups-pinned-host-labels.test.ts new file mode 100644 index 00000000000..1abe08d2f83 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list-groups-pinned-host-labels.test.ts @@ -0,0 +1,119 @@ +/** + * #18472: a pinned worktree on a multi-host sidebar lost its host badge. Under + * the default pinned policy it renders only in the Pinned section, so that was + * its only chance at host attribution. + */ +import { describe, expect, it } from 'vitest' +import { buildRows } from './worktree-list/grouping/build-rows' +import type { Row } from './worktree-list/grouping/row-types' +import { + LOCAL_HOST_LABEL, + repo, + worktree, + remoteRepo, + remoteWorktree +} from './worktree-list-groups-test-fixtures' +import type { Worktree } from '../../../../shared/worktree/types' + +const hostLabelById = new Map([ + ['local', LOCAL_HOST_LABEL], + ['ssh:gpu-vm', 'gpu-vm'] +]) + +function buildPinnedRows( + worktrees: Worktree[], + groupBy: 'none' | 'repo' | 'workspace-status' = 'repo', + showPinnedWorktreesInGroups = false +): Row[] { + return buildRows( + groupBy, + worktrees, + new Map([ + [repo.id, repo], + [remoteRepo.id, remoteRepo] + ]), + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map(worktrees.map((candidate) => [candidate.id, candidate])), + false, + { showPinnedWorktreesInGroups } as never, + [], + new Set(), + new Map(), + new Map(), + [], + undefined, + [], + hostLabelById + ) +} + +function itemRows(rows: Row[]): { id: string; sectionKey: string; hostContextLabel?: string }[] { + return rows.flatMap((row) => + row.type === 'item' + ? [ + { + id: row.worktree.id, + sectionKey: row.sectionKey, + hostContextLabel: row.hostContextLabel + } + ] + : [] + ) +} + +describe('pinned rows on a multi-host sidebar', () => { + it.each(['repo', 'workspace-status', 'none'] as const)( + 'labels a pinned remote worktree in the Pinned section (%s grouping)', + (groupBy) => { + const pinnedRemote: Worktree = { ...remoteWorktree, isPinned: true } + const rows = itemRows(buildPinnedRows([worktree, pinnedRemote], groupBy)) + + // Default policy: the pinned row is the only row for that worktree. + expect(rows.filter((row) => row.id === pinnedRemote.id)).toEqual([ + { id: pinnedRemote.id, sectionKey: 'pinned', hostContextLabel: 'gpu-vm' } + ]) + expect(rows.find((row) => row.id === worktree.id)?.hostContextLabel).toBe(LOCAL_HOST_LABEL) + } + ) + + it('labels pinned rows when the only other host is itself pinned', () => { + // Why: the natural lane holds one host here, so a map scoped to it would say + // "not mixed" even though the sidebar shows two hosts. + const pinnedLocal: Worktree = { ...worktree, isPinned: true } + const pinnedRemote: Worktree = { ...remoteWorktree, isPinned: true } + const localOnly: Worktree = { ...worktree, id: 'wt-local-2', displayName: 'local-2' } + const rows = itemRows(buildPinnedRows([pinnedLocal, pinnedRemote, localOnly])) + + expect(rows).toEqual([ + { id: pinnedLocal.id, sectionKey: 'pinned', hostContextLabel: LOCAL_HOST_LABEL }, + { id: pinnedRemote.id, sectionKey: 'pinned', hostContextLabel: 'gpu-vm' }, + { id: localOnly.id, sectionKey: 'repo:repo-1', hostContextLabel: LOCAL_HOST_LABEL } + ]) + }) + + it('labels both copies when pinned worktrees also show in their groups', () => { + const pinnedRemote: Worktree = { ...remoteWorktree, isPinned: true } + const rows = itemRows(buildPinnedRows([worktree, pinnedRemote], 'repo', true)) + + expect(rows.filter((row) => row.id === pinnedRemote.id)).toEqual([ + { id: pinnedRemote.id, sectionKey: 'pinned', hostContextLabel: 'gpu-vm' }, + { id: pinnedRemote.id, sectionKey: 'repo:repo-remote', hostContextLabel: 'gpu-vm' } + ]) + }) + + it('draws no badge on a single-host sidebar even with a pinned row', () => { + const pinnedLocal: Worktree = { ...worktree, isPinned: true } + const localOnly: Worktree = { ...worktree, id: 'wt-local-2', displayName: 'local-2' } + const rows = itemRows(buildPinnedRows([pinnedLocal, localOnly])) + + expect(rows).toHaveLength(2) + for (const row of rows) { + expect(row.hostContextLabel).toBeUndefined() + } + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts index 77353708708..7a3041ce078 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts @@ -105,8 +105,11 @@ export function buildRows( pinnedDisplayPolicy === 'duplicate-in-groups' ? worktrees : worktrees.filter((worktree) => !pinnedSectionIds.has(getWorktreeHostIdentity(worktree))) + // Why the full set: under the default pinned policy a pinned worktree exists + // only in the Pinned section, and its host is part of whether the sidebar is + // mixed at all. Scoping to naturalWorktrees left pinned remotes unlabelled. const mixedWorktreeHostContextLabels = getMixedWorktreeHostContextLabels( - naturalWorktrees, + worktrees, repoMap, hostLabelById, defaultHostId @@ -145,7 +148,8 @@ export function buildRows( worktreeMap, nestLineage, cyclicLineageIds, - noticeHostContextLabelByRepoId + noticeHostContextLabelByRepoId, + mixedWorktreeHostContextLabels ) if (groupBy === 'none') { // Why folder workspaces gate this too: an account with only folder diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/pinned-group-rows.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/pinned-group-rows.ts index 2bba99116dd..16c594c4550 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/pinned-group-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/pinned-group-rows.ts @@ -27,7 +27,8 @@ export function emitPinnedGroup( worktreeMap: Map, nestLineage: boolean, cyclicLineageIds: ReadonlySet, - noticeHostContextLabelByRepoId?: ReadonlyMap + noticeHostContextLabelByRepoId?: ReadonlyMap, + hostContextLabelByWorktreeIdentity?: ReadonlyMap ): void { if (pinnedSectionWorktrees.length === 0) { return @@ -81,6 +82,7 @@ export function emitPinnedGroup( collapsedGroups, groupDepth: 0, sectionKey: PINNED_GROUP_KEY, + hostContextLabelByWorktreeIdentity, cyclicLineageIds }) if (!allowImportedFallback) {