From 19fcaf7204fc5c9342bf60b42f9a630776c2d3ab Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:18:30 -0700 Subject: [PATCH] fix(sidebar): keep project header labels from switching to the repo name (#16877) --- ...ee-list-groups-project-host-setups.test.ts | 58 +++++++++++- ...roups-section-label-disambiguation.test.ts | 91 +++++++++++++++++++ .../worktree-list/grouping/section-order.ts | 15 ++- 3 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-list-groups-section-label-disambiguation.test.ts diff --git a/src/renderer/src/components/sidebar/worktree-list-groups-project-host-setups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups-project-host-setups.test.ts index d51f4523a89..7cd9fa0cb65 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups-project-host-setups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups-project-host-setups.test.ts @@ -52,6 +52,61 @@ describe('buildRows with pinned worktrees', () => { ]) }) + it('keeps the cross-host project header label when another project section renders', () => { + // Why: the project header must read the project's own display name, not the + // anchor checkout's repo name, however many sections are visible — toggling + // "Hide sleeping workspaces" must not rename a project (#16127). + const otherRepo: Repo = { + ...repo, + id: 'repo-other', + path: '/tmp/design-assets', + displayName: 'design-assets' + } + const otherWorktree: Worktree = { + ...worktree, + id: 'wt-other', + repoId: otherRepo.id, + path: '/tmp/design-assets-feature', + displayName: 'palette' + } + const buildHeaders = (extraWorktrees: Worktree[], extraRepos: Repo[]) => { + const worktrees = [worktree, remoteWorktree, ...extraWorktrees] + const rows = buildRows( + 'repo', + worktrees, + new Map([ + [repo.id, repo], + [remoteRepo.id, remoteRepo], + ...extraRepos.map((entry): [string, Repo] => [entry.id, entry]) + ]), + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map(worktrees.map((entry) => [entry.id, entry])), + false, + undefined, + [], + new Set(), + new Map(), + new Map(), + [], + { projects: [project], projectHostSetups } + ) + return rows.filter((row) => row.type === 'header') + } + + expect(buildHeaders([], [])).toMatchObject([ + { key: 'project:github:stablyai/orca', label: 'Orca' } + ]) + expect(buildHeaders([otherWorktree], [otherRepo])).toMatchObject([ + { key: 'project:github:stablyai/orca', label: 'Orca' }, + { key: 'repo:repo-other', label: 'design-assets' } + ]) + }) + it('renders same-project records with git remote identity as one mixed-host project header', () => { const localRepo: Repo = { ...repo, @@ -724,11 +779,12 @@ describe('buildRows with pinned worktrees', () => { ]) // The provisioned copy nests under the plain project key with only its own // worktree; it never gets a path-scoped `::setup:` header like the real - // checkouts do. (buildRows disambiguates its visible label to the repo name.) + // checkouts do, and that header keeps the project's own display name. expect( headers.some((row) => row.key === 'project:github:stablyai/orca::setup:repo-runtime-b') ).toBe(false) expect(headers.find((row) => row.key === 'project:github:stablyai/orca')).toMatchObject({ + label: 'Orca', count: 1 }) }) diff --git a/src/renderer/src/components/sidebar/worktree-list-groups-section-label-disambiguation.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups-section-label-disambiguation.test.ts new file mode 100644 index 00000000000..43152aa9b9a --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list-groups-section-label-disambiguation.test.ts @@ -0,0 +1,91 @@ +/** + * Sidebar section headers disambiguate identical display names by appending + * parent path segments. Every tracked repo is projected into a project, so + * `project:` headers — not just `repo:` ones — are where collisions surface; + * stabilising which model layer supplies the label (#16127) must not turn the + * disambiguation off. + */ +import { describe, expect, it } from 'vitest' +import { buildRows } from './worktree-list/grouping/build-rows' +import { project, projectHostSetups, repo, worktree } from './worktree-list-groups-test-fixtures' +import type { Project, ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' + +const makeProject = (id: string, repoId: string): Project => ({ + ...project, + id, + displayName: 'api', + sourceRepoIds: [repoId] +}) + +const makeSetup = (checkout: Repo, projectId: string): ProjectHostSetup => ({ + ...projectHostSetups[0]!, + id: checkout.id, + projectId, + repoId: checkout.id, + path: checkout.path, + displayName: checkout.displayName +}) + +function buildHeaders(repos: Repo[], projects: Project[], setups: ProjectHostSetup[]) { + const worktrees = repos.map( + (entry): Worktree => ({ ...worktree, id: `wt-${entry.id}`, repoId: entry.id }) + ) + const rows = buildRows( + 'repo', + worktrees, + new Map(repos.map((entry) => [entry.id, entry])), + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map(worktrees.map((entry) => [entry.id, entry])), + false, + undefined, + [], + new Set(), + new Map(), + new Map(), + [], + { projects, projectHostSetups: setups } + ) + return rows.filter((row) => row.type === 'header') +} + +describe('sidebar section headers with colliding display names', () => { + const workRepo: Repo = { ...repo, id: 'repo-work', path: '/work/api', displayName: 'api' } + const ossRepo: Repo = { ...repo, id: 'repo-oss', path: '/oss/api', displayName: 'api' } + + it('path-disambiguates two project headers that share a display name', () => { + expect( + buildHeaders( + [workRepo, ossRepo], + [makeProject('github:acme/api', workRepo.id), makeProject('gitlab:me/api', ossRepo.id)], + [makeSetup(workRepo, 'github:acme/api'), makeSetup(ossRepo, 'gitlab:me/api')] + ) + ).toMatchObject([ + { key: 'project:github:acme/api', label: 'work/api' }, + { key: 'project:gitlab:me/api', label: 'oss/api' } + ]) + }) + + it('leaves a lone project header un-suffixed', () => { + expect( + buildHeaders( + [workRepo], + [makeProject('github:acme/api', workRepo.id)], + [makeSetup(workRepo, 'github:acme/api')] + ) + ).toMatchObject([{ key: 'project:github:acme/api', label: 'api' }]) + }) + + it('path-disambiguates untracked repo headers that share a display name', () => { + expect(buildHeaders([workRepo, ossRepo], [], [])).toMatchObject([ + { key: 'repo:repo-work', label: 'work/api' }, + { key: 'repo:repo-oss', label: 'oss/api' } + ]) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/section-order.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/section-order.ts index 00e7fe46f79..d266bdaa445 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/section-order.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/section-order.ts @@ -73,16 +73,21 @@ export function orderMainWorktreeFirst(worktrees: Worktree[]): Worktree[] { return [...mainWorktrees, ...worktrees.filter((worktree) => !worktree.isMainWorktree)] } +// Why: disambiguate a section's *own* label, not its anchor checkout's repo +// name. Substituting repo.displayName made a project header read the project +// name while it was the only repo-backed section and the checkout name as soon +// as a second one rendered, so filtering workspaces renamed the project +// (#16127). Path suffixes still resolve genuinely identical labels. export function withRepoSectionDisplayLabels( entries: readonly OrderedGroupEntry[] ): OrderedGroupEntry[] { - const repos = entries - .map((entry) => entry[1].repo) - .filter((repo): repo is Repo => repo !== undefined) - if (repos.length < 2) { + const labelItems = entries.flatMap(([, group]) => + group.repo ? [{ ...group.repo, displayName: group.label }] : [] + ) + if (labelItems.length === 0) { return [...entries] } - const labelsByPath = getRepoDisplayLabelsByPath(repos) + const labelsByPath = getRepoDisplayLabelsByPath(labelItems) return entries.map(([key, group]) => [ key, group.repo