fix(sidebar): preserve Project Group members under Hide sleeping (#9101)

* fix(sidebar): preserve grouped projects under sleep filter

* test(sidebar): harden #8865 project-group sleep-filter coverage

Add adversarial cases for explicit project filters, multi-member groups,
ungrouped neighbors, and buildRows membership/count preservation.

Co-authored-by: bbingz <zzb@gxsmjx.com>

---------

Co-authored-by: bbingz <zzb@gxsmjx.com>
This commit is contained in:
Neil 2026-07-16 19:38:13 -07:00 committed by GitHub
parent 539e0601d5
commit e01f3f474c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 181 additions and 2 deletions

View File

@ -5711,9 +5711,10 @@ const WorktreeList = React.memo(function WorktreeList({
groupBy,
repos: visibleReposForRows,
worktreesByRepo,
visibleWorktrees,
filterRepoIds
})
}, [filterRepoIds, groupBy, visibleReposForRows, worktreesByRepo])
}, [filterRepoIds, groupBy, visibleReposForRows, visibleWorktrees, worktreesByRepo])
const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos])
// Why: buildRows only needs which creates exist and their repo. Subscribe on a

View File

@ -38,6 +38,7 @@ describe('getEmptyProjectPlaceholderRepoIds', () => {
groupBy: 'repo',
repos: [repo],
worktreesByRepo: { [repo.id]: [] },
visibleWorktrees: [],
filterRepoIds: []
})
)
@ -51,6 +52,7 @@ describe('getEmptyProjectPlaceholderRepoIds', () => {
groupBy: 'repo',
repos: [repo],
worktreesByRepo: {},
visibleWorktrees: [],
filterRepoIds: []
})
)
@ -67,6 +69,7 @@ describe('getEmptyProjectPlaceholderRepoIds', () => {
groupBy: 'repo',
repos: [selectedRepo, hiddenRepo],
worktreesByRepo: { [selectedRepo.id]: [], [hiddenRepo.id]: [] },
visibleWorktrees: [],
filterRepoIds: [selectedRepo.id]
})
)
@ -79,6 +82,7 @@ describe('getEmptyProjectPlaceholderRepoIds', () => {
groupBy: 'none',
repos: [repo],
worktreesByRepo: { [repo.id]: [] },
visibleWorktrees: [],
filterRepoIds: []
}).size
).toBe(0)
@ -90,8 +94,109 @@ describe('getEmptyProjectPlaceholderRepoIds', () => {
groupBy: 'repo',
repos: [repo],
worktreesByRepo: { [repo.id]: [worktree] },
visibleWorktrees: [],
filterRepoIds: []
}).size
).toBe(0)
})
it('keeps grouped repos visible when workspace filters hide all of their rows', () => {
const groupedRepo: Repo = { ...repo, projectGroupId: 'group-1' }
const groupedWorktree: Worktree = { ...worktree, repoId: groupedRepo.id }
expect(
Array.from(
getEmptyProjectPlaceholderRepoIds({
groupBy: 'repo',
repos: [groupedRepo],
worktreesByRepo: { [groupedRepo.id]: [groupedWorktree] },
visibleWorktrees: [],
filterRepoIds: []
})
)
).toEqual([groupedRepo.id])
})
it('does not create a grouped repo placeholder when one of its workspaces is visible', () => {
const groupedRepo: Repo = { ...repo, projectGroupId: 'group-1' }
const groupedWorktree: Worktree = { ...worktree, repoId: groupedRepo.id }
expect(
getEmptyProjectPlaceholderRepoIds({
groupBy: 'repo',
repos: [groupedRepo],
worktreesByRepo: { [groupedRepo.id]: [groupedWorktree] },
visibleWorktrees: [groupedWorktree],
filterRepoIds: []
}).size
).toBe(0)
})
it('still respects explicit project filters for sleep-filtered grouped members', () => {
const selected: Repo = { ...repo, id: 'repo-selected', projectGroupId: 'group-1' }
const filteredOut: Repo = { ...repo, id: 'repo-hidden', projectGroupId: 'group-1' }
const selectedWt: Worktree = { ...worktree, id: 'wt-selected', repoId: selected.id }
const hiddenWt: Worktree = { ...worktree, id: 'wt-hidden', repoId: filteredOut.id }
expect(
Array.from(
getEmptyProjectPlaceholderRepoIds({
groupBy: 'repo',
repos: [selected, filteredOut],
worktreesByRepo: {
[selected.id]: [selectedWt],
[filteredOut.id]: [hiddenWt]
},
// Why: simulate Hide sleeping removing every card while the project
// filter still intentionally excludes `filteredOut`.
visibleWorktrees: [],
filterRepoIds: [selected.id]
})
)
).toEqual([selected.id])
})
it('placeholders only the fully-filtered members of a multi-project group', () => {
const sleeping: Repo = { ...repo, id: 'repo-sleeping', projectGroupId: 'group-1' }
const awake: Repo = { ...repo, id: 'repo-awake', projectGroupId: 'group-1' }
const sleepingWt: Worktree = { ...worktree, id: 'wt-sleeping', repoId: sleeping.id }
const awakeWt: Worktree = { ...worktree, id: 'wt-awake', repoId: awake.id }
expect(
Array.from(
getEmptyProjectPlaceholderRepoIds({
groupBy: 'repo',
repos: [sleeping, awake],
worktreesByRepo: {
[sleeping.id]: [sleepingWt],
[awake.id]: [awakeWt]
},
visibleWorktrees: [awakeWt],
filterRepoIds: []
})
)
).toEqual([sleeping.id])
})
it('does not placeholder ungrouped neighbors of a filtered grouped member', () => {
const grouped: Repo = { ...repo, id: 'repo-grouped', projectGroupId: 'group-1' }
const ungrouped: Repo = { ...repo, id: 'repo-ungrouped' }
const groupedWt: Worktree = { ...worktree, id: 'wt-grouped', repoId: grouped.id }
const ungroupedWt: Worktree = { ...worktree, id: 'wt-ungrouped', repoId: ungrouped.id }
expect(
Array.from(
getEmptyProjectPlaceholderRepoIds({
groupBy: 'repo',
repos: [grouped, ungrouped],
worktreesByRepo: {
[grouped.id]: [groupedWt],
[ungrouped.id]: [ungroupedWt]
},
visibleWorktrees: [],
filterRepoIds: []
})
)
).toEqual([grouped.id])
})
})

View File

@ -5,6 +5,7 @@ export function getEmptyProjectPlaceholderRepoIds(args: {
groupBy: WorktreeGroupBy
repos: readonly Repo[]
worktreesByRepo: Readonly<Record<string, readonly Worktree[] | undefined>>
visibleWorktrees: readonly Worktree[]
filterRepoIds: readonly string[]
}): Set<string> {
if (args.groupBy !== 'repo') {
@ -12,12 +13,17 @@ export function getEmptyProjectPlaceholderRepoIds(args: {
}
const filterSet = args.filterRepoIds.length > 0 ? new Set(args.filterRepoIds) : null
const visibleRepoIds = new Set(args.visibleWorktrees.map((worktree) => worktree.repoId))
const placeholderRepoIds = new Set<string>()
for (const repo of args.repos) {
if (filterSet && !filterSet.has(repo.id)) {
continue
}
if ((args.worktreesByRepo[repo.id]?.length ?? 0) === 0) {
const hasNoWorktrees = (args.worktreesByRepo[repo.id]?.length ?? 0) === 0
// Why: workspace filters hide cards, but must not rewrite the visible
// membership of a persisted Project Group. #8865
const isFilteredProjectGroupMember = repo.projectGroupId != null && !visibleRepoIds.has(repo.id)
if (hasNoWorktrees || isFilteredProjectGroupMember) {
placeholderRepoIds.add(repo.id)
}
}

View File

@ -1933,6 +1933,73 @@ describe('project groups', () => {
expect(rows[0]).toMatchObject({ label: 'Platform' })
})
it('keeps sleep-filtered Project Group members as empty project headers', () => {
// Why: #8865 — Hide sleeping removes workspace cards; membership placeholders
// must still project the grouped project header so the group count stays honest.
const group: ProjectGroup = {
id: 'group-1',
name: 'Platform',
parentPath: '/platform',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
const sleepingRepo: Repo = {
...repo,
id: 'repo-sleeping',
displayName: 'sleeping-project',
projectGroupId: group.id
}
const awakeRepo: Repo = {
...repo,
id: 'repo-awake',
displayName: 'awake-project',
projectGroupId: group.id
}
const awakeWorktree: Worktree = {
...worktree,
id: 'wt-awake',
repoId: awakeRepo.id,
path: '/tmp/awake'
}
const rows = buildRows(
'repo',
[awakeWorktree],
new Map([
[sleepingRepo.id, sleepingRepo],
[awakeRepo.id, awakeRepo]
]),
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map([[awakeWorktree.id, awakeWorktree]]),
false,
undefined,
[group],
new Set([sleepingRepo.id])
)
expect(rows[0]).toMatchObject({
type: 'header',
key: 'project-group:group-1',
count: 2
})
// Why: empty/placeholder projects sort after projects with visible activity.
expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([
'project-group:group-1',
`repo:${awakeRepo.id}`,
`repo:${sleepingRepo.id}`
])
})
it('renders ungrouped repos as top-level repo rows when Project Groups exist', () => {
const group: ProjectGroup = {
id: 'group-1',