From 2237947314206bda43ab2c6a6a0dfd7963d71c83 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:40:48 -0700 Subject: [PATCH] fix(sidebar): keep host-filtered project groups visible --- .../src/components/sidebar/WorktreeList.tsx | 12 +- .../sidebar/project-group-header-dom.test.ts | 298 +++++++++++++++++- 2 files changed, 302 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index fa119cfacee..ed36be3e4f8 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -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 (
{ ) }) }) + +const worktreeListStore = vi.hoisted(() => ({ state: {} as Record })) + +type WorktreeListComponent = React.ComponentType<{ + scrollOffsetRef: React.RefObject + scrollAnchorRef: React.RefObject +}> + +let WorktreeList: WorktreeListComponent + +vi.mock('@/store', () => { + const useAppStore = ((selector: (state: Record) => unknown) => + selector(worktreeListStore.state)) as (( + selector: (state: Record) => unknown + ) => unknown) & { getState: () => Record } + 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(), + 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 { + 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') + }) +})