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:
parent
539e0601d5
commit
e01f3f474c
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue