fix(sidebar): keep project header labels from switching to the repo name (#16877)

This commit is contained in:
Neil
2026-08-29 21:18:30 -07:00
committed by GitHub
parent 8cf692cf71
commit 19fcaf7204
3 changed files with 158 additions and 6 deletions
@@ -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
})
})
@@ -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' }
])
})
})
@@ -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