From e01f3f474c39ce1a35c83d07c2adf36a380c48d3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:38:13 -0700 Subject: [PATCH] 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 --------- Co-authored-by: bbingz --- .../src/components/sidebar/WorktreeList.tsx | 3 +- .../empty-project-placeholder-repos.test.ts | 105 ++++++++++++++++++ .../empty-project-placeholder-repos.ts | 8 +- .../sidebar/worktree-list-groups.test.ts | 67 +++++++++++ 4 files changed, 181 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 81671c206..948228984 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -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 diff --git a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts index aaa3be238..8a33151f7 100644 --- a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts +++ b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts @@ -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]) + }) }) diff --git a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts index 26c248a2a..57aee59c1 100644 --- a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts +++ b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts @@ -5,6 +5,7 @@ export function getEmptyProjectPlaceholderRepoIds(args: { groupBy: WorktreeGroupBy repos: readonly Repo[] worktreesByRepo: Readonly> + visibleWorktrees: readonly Worktree[] filterRepoIds: readonly string[] }): Set { 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() 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) } } diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index b967c4cd3..4b4012f8c 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -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',