fix(sidebar): keep host-filtered project groups visible

This commit is contained in:
Brennan Benson
2026-08-10 19:40:48 -07:00
parent 8c066084aa
commit 2237947314
2 changed files with 302 additions and 8 deletions
@@ -6944,13 +6944,17 @@ const WorktreeList = React.memo(function WorktreeList({
}
}, [handleRevealCurrentWorkspaceRequest])
const filtersHideAllRows =
hasFilters &&
const workspaceFiltersHideAllRows =
sidebarHasActiveFilters({
...filterState,
visibleWorkspaceHostIds: null,
workspaceHostScope: 'all'
}) &&
worktrees.length === 0 &&
placeholderRepoIds.size === 0 &&
importedWorktreesByRepo.size === 0
// Why: when active filters hide every row, the Clear Filters empty state must win over Project Group headers.
if (rows.length === 0 || filtersHideAllRows) {
// Why: host filters own Project Group rows; only workspace filters may replace those rows with Clear Filters.
if (rows.length === 0 || workspaceFiltersHideAllRows) {
return (
<div
data-worktree-sidebar-container
@@ -1,6 +1,10 @@
// @vitest-environment happy-dom
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { join } from 'node:path'
import { act, createElement, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import { getFolderWorkspaceRowKey } from '../../../../shared/folder-workspaces'
import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree'
@@ -21,12 +25,15 @@ import { addHostSectionRows } from './host-section-rows'
import { getRenderRowKey } from './worktree-list-virtual-rows'
function readWorktreeListSource(): string {
return readFileSync(fileURLToPath(new URL('./WorktreeList.tsx', import.meta.url)), 'utf8')
return readFileSync(
join(process.cwd(), 'src', 'renderer', 'src', 'components', 'sidebar', 'WorktreeList.tsx'),
'utf8'
)
}
function readComposerStateSource(): string {
return readFileSync(
fileURLToPath(new URL('../../hooks/useComposerState.ts', import.meta.url)),
join(process.cwd(), 'src', 'renderer', 'src', 'hooks', 'useComposerState.ts'),
'utf8'
)
}
@@ -489,3 +496,286 @@ describe('duplicate project-group header identity (#12532)', () => {
)
})
})
const worktreeListStore = vi.hoisted(() => ({ state: {} as Record<string, unknown> }))
type WorktreeListComponent = React.ComponentType<{
scrollOffsetRef: React.RefObject<number>
scrollAnchorRef: React.RefObject<unknown>
}>
let WorktreeList: WorktreeListComponent
vi.mock('@/store', () => {
const useAppStore = ((selector: (state: Record<string, unknown>) => unknown) =>
selector(worktreeListStore.state)) as ((
selector: (state: Record<string, unknown>) => unknown
) => unknown) & { getState: () => Record<string, unknown> }
useAppStore.getState = () => worktreeListStore.state
return { useAppStore }
})
vi.mock('@tanstack/react-virtual', () => ({
defaultRangeExtractor: ({ startIndex, endIndex }: { startIndex: number; endIndex: number }) =>
Array.from({ length: endIndex - startIndex + 1 }, (_, index) => startIndex + index),
measureElement: () => 32,
useVirtualizer: ({ count }: { count: number }) => ({
elementsCache: new Map(),
getTotalSize: () => count * 96,
getVirtualItems: () =>
Array.from({ length: count }, (_, index) => ({
index,
key: `row-${index}`,
start: index * 96
})),
measureElement: vi.fn(),
scrollToIndex: vi.fn()
})
}))
vi.mock('@/hooks/useVirtualizedScrollAnchor', () => ({
VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT: 'orca:test-record-scroll-anchor',
useVirtualizedScrollAnchor: vi.fn()
}))
vi.mock('./project-header-drag', () => ({
useRepoHeaderDrag: () => ({
state: { draggingRepoId: null, dropIndicatorY: null },
onHandlePointerDown: vi.fn()
}),
isRepoHeaderActionTarget: () => false
}))
function childrenOnly({ children }: { children: ReactNode }): ReactNode {
return children
}
vi.mock('@/components/ui/hover-card', () => ({
HoverCard: childrenOnly,
HoverCardContent: childrenOnly,
HoverCardTrigger: childrenOnly
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: childrenOnly,
TooltipContent: childrenOnly,
TooltipTrigger: childrenOnly
}))
vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenu: childrenOnly,
DropdownMenuContent: childrenOnly,
DropdownMenuItem: childrenOnly,
DropdownMenuSeparator: () => null,
DropdownMenuSub: childrenOnly,
DropdownMenuSubContent: childrenOnly,
DropdownMenuSubTrigger: childrenOnly,
DropdownMenuTrigger: childrenOnly
}))
vi.mock('@/lib/sidebar-worktree-activation', () => ({ activateWorktreeFromSidebar: vi.fn() }))
vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: vi.fn() }))
vi.mock('@/runtime/runtime-rpc-client', () => ({
getActiveRuntimeTarget: () => ({ kind: 'local' }),
callRuntimeRpc: vi.fn()
}))
vi.mock('./WorktreeCardAgents', () => ({
default: () => null,
SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT: 'orca:test-suppress-scroll-adjustment'
}))
vi.mock('./WorktreeCard', () => ({
default: ({ worktree }: { worktree: Worktree }) =>
createElement('div', { 'data-mock-worktree-card': worktree.id }, worktree.displayName)
}))
function hostFilteredProjectGroup(): ProjectGroup {
return {
id: 'runtime-group',
name: 'Runtime project group',
parentPath: '/srv/runtime-project',
executionHostId: 'runtime:env-1',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
}
function hostFilteredFolderWorkspace(): FolderWorkspace {
return {
id: 'runtime-folder',
projectGroupId: 'runtime-group',
name: 'Runtime folder workspace',
folderPath: '/srv/runtime-project/task',
executionHostId: 'runtime:env-1',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1
}
}
function setHostFilteredWorktreeListState(folderWorkspaces: FolderWorkspace[]): void {
worktreeListStore.state = {
activeModal: '',
activeView: 'terminal',
activeWorkspaceExecutionHostId: null,
activeWorkspaceKey: null,
activeWorktreeId: null,
agentSendPopoverTargetMode: null,
agentStatusByPaneKey: {},
agentStatusEpoch: 0,
alwaysShowDefaultBranchWorkspace: true,
browserTabsByWorktree: {},
clearPendingRevealSidebarRow: vi.fn(),
clearPendingRevealWorktreeId: vi.fn(),
collapsedGroups: new Set<string>(),
deleteStateByWorktreeId: {},
detectedWorktreesByRepo: {},
fetchFolderWorkspacePathStatus: vi.fn(),
fetchHostedReviewForBranch: vi.fn(),
fetchIssue: vi.fn(),
fetchLinearIssue: vi.fn(),
filterRepoIds: [],
folderWorkspacePathStatuses: {},
folderWorkspaces,
getFolderWorkspacePathStatusCacheKey: (request: unknown) => JSON.stringify(request),
getFreshFolderWorkspacePathStatus: vi.fn(() => null),
gitConflictOperationByWorktreeId: {},
groupBy: 'repo',
hideAutomationGeneratedWorkspaces: false,
hideCliCreatedWorkspaces: false,
hideDefaultBranchWorkspace: false,
hideDetachedHeadWorkspaces: false,
hostedReviewCache: {},
issueCache: {},
linearIssueCache: {},
linearStatus: null,
migrationUnsupportedByPtyId: {},
openModal: vi.fn(),
openSettingsPage: vi.fn(),
openSettingsTarget: null,
openTaskPage: vi.fn(),
pendingRevealSidebarRow: null,
pendingRevealWorktree: null,
prCache: {},
projectGroups: [hostFilteredProjectGroup()],
projectOrderBy: 'manual',
ptyIdsByTabId: {},
recordFeatureInteraction: vi.fn(),
remoteBranchConflictByWorktreeId: {},
reorderRepos: vi.fn(),
reportVisibleGitHubPRRefreshCandidates: vi.fn(),
repos: [],
retainedAgentsByPaneKey: {},
revealSidebarRow: vi.fn(),
revealWorktreeInSidebar: vi.fn(),
runtimeEnvironments: [{ id: 'env-1', name: 'Runtime host' }],
runtimePaneTitlesByTabId: {},
runtimeStatusByEnvironmentId: new Map(),
setAlwaysShowDefaultBranchWorkspace: vi.fn(),
setFilterRepoIds: vi.fn(),
setGroupBy: vi.fn(),
setHideAutomationGeneratedWorkspaces: vi.fn(),
setHideCliCreatedWorkspaces: vi.fn(),
setHideDefaultBranchWorkspace: vi.fn(),
setHideDetachedHeadWorkspaces: vi.fn(),
setRenamingWorktreeId: vi.fn(),
setShowSleepingWorkspaces: vi.fn(),
setSortBy: vi.fn(),
setVisibleWorkspaceHostIds: vi.fn(),
setWorkspaceHostOrder: vi.fn(),
setWorktreesPinnedAndReveal: vi.fn(),
settings: null,
showSleepingWorkspaces: true,
sortBy: 'manual',
sortEpoch: 0,
sshConnectedGeneration: 0,
sshConnectionStates: new Map(),
sshTargetLabels: new Map(),
tabsByWorktree: {},
terminalLayoutsByTabId: {},
toggleCollapsedGroup: vi.fn(),
updateProjectGroup: vi.fn(),
updateRepo: vi.fn(),
updateWorktreeMeta: vi.fn(),
updateWorktreesMeta: vi.fn(),
visibleWorkspaceHostIds: ['runtime:env-1'],
workspaceHostOrder: [],
workspaceHostScope: 'all',
workspaceLineageByChildKey: {},
workspacePortScan: null,
workspaceStatuses: [],
worktreeCardProperties: ['status', 'pr', 'comment'],
worktreeLineageById: {},
worktreesByRepo: {}
}
}
const mountedWorktreeListRoots: Root[] = []
async function renderHostFilteredWorktreeList(): Promise<HTMLElement> {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
mountedWorktreeListRoots.push(root)
await act(async () => {
root.render(
createElement(WorktreeList, {
scrollOffsetRef: { current: 0 },
scrollAnchorRef: { current: null }
})
)
})
return container
}
describe('WorktreeList project-group host filtering', () => {
beforeAll(async () => {
WorktreeList = (await import('./WorktreeList')).default as WorktreeListComponent
}, 60_000)
beforeEach(() => vi.clearAllMocks())
afterEach(async () => {
await act(async () => {
for (const root of mountedWorktreeListRoots.splice(0)) {
root.unmount()
}
})
document.body.innerHTML = ''
})
it.each([
['zero-count', []],
['folder-only', [hostFilteredFolderWorkspace()]]
] as const)('keeps the selected-host %s owner group visible', async (_case, workspaces) => {
setHostFilteredWorktreeListState([...workspaces])
const container = await renderHostFilteredWorktreeList()
expect(container.textContent).toContain('Runtime project group')
expect(container.textContent).not.toContain('No workspaces found')
})
it('still lets a workspace filter replace empty project-group headers', async () => {
setHostFilteredWorktreeListState([])
worktreeListStore.state = {
...worktreeListStore.state,
hideDefaultBranchWorkspace: true
}
const container = await renderHostFilteredWorktreeList()
expect(container.textContent).toContain('No workspaces found')
expect(container.textContent).not.toContain('Runtime project group')
})
})