From 03b65d08e507f7ec7029d12d5ce75c66895562bb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:51:11 -0700 Subject: [PATCH] fix(sidebar): make Cmd/Ctrl+1-9 match the rendered card order when the sidebar is closed (#10693) --- .../src/components/sidebar/WorktreeList.tsx | 55 ++-- .../rendered-sidebar-worktree-order.test.ts | 262 ++++++++++++++++++ .../rendered-sidebar-worktree-order.ts | 122 ++++++++ .../components/sidebar/visible-worktrees.ts | 41 ++- .../sidebar/worktree-list-host-filtering.ts | 51 +++- 5 files changed, 484 insertions(+), 47 deletions(-) create mode 100644 src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.test.ts create mode 100644 src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index c85e035b2..7b96520e7 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -219,7 +219,6 @@ import { } from './worktree-multi-selection' import { persistWorktreeSortOrderByHost } from '@/lib/worktree-sort-order-persistence' import { - ALL_EXECUTION_HOSTS_SCOPE, getRepoExecutionHostId, getSettingsFocusedExecutionHostId, getWorktreeExecutionHostId, @@ -294,9 +293,10 @@ import { sidebarWorkspaceStillExists } from './worktree-list-folder-reveal' import { + filterFolderWorkspacesForVisibleHosts, + filterProjectGroupsForVisibleHosts, getFolderPathStatusRouteOptionsForRows, - getFolderWorkspaceExecutionHostIdForRows, - getProjectGroupExecutionHostIdForRows + getVisibleSidebarHostIdSet } from './worktree-list-host-filtering' import { getFolderWorkspaceCardPrDisplay } from './folder-workspace-card-pr-display' import { @@ -5615,12 +5615,10 @@ const WorktreeList = React.memo(function WorktreeList({ worktreeMap ]) const defaultHostId = getSettingsFocusedExecutionHostId(settings) - const visibleHostIdSet = useMemo(() => { - const visibleHostIds = - visibleWorkspaceHostIds ?? - (workspaceHostScope === ALL_EXECUTION_HOSTS_SCOPE ? null : [workspaceHostScope]) - return visibleHostIds ? new Set(visibleHostIds) : null - }, [visibleWorkspaceHostIds, workspaceHostScope]) + const visibleHostIdSet = useMemo( + () => getVisibleSidebarHostIdSet(visibleWorkspaceHostIds, workspaceHostScope), + [visibleWorkspaceHostIds, workspaceHostScope] + ) const visibleReposForRows = useMemo(() => { if (!visibleHostIdSet) { return repos @@ -5631,29 +5629,20 @@ const WorktreeList = React.memo(function WorktreeList({ return visibleHostIdSet.has(hostId) }) }, [defaultHostId, repos, visibleHostIdSet]) - const visibleProjectGroupsForRows = useMemo(() => { - if (!visibleHostIdSet) { - return projectGroups - } - return projectGroups.filter((group) => { - const hostId = getProjectGroupExecutionHostIdForRows(group, defaultHostId) - return visibleHostIdSet.has(hostId) - }) - }, [defaultHostId, projectGroups, visibleHostIdSet]) - const visibleFolderWorkspacesForRows = useMemo(() => { - if (!visibleHostIdSet) { - return folderWorkspaces - } - const projectGroupById = new Map(projectGroups.map((group) => [group.id, group])) - return folderWorkspaces.filter((folderWorkspace) => { - const hostId = getFolderWorkspaceExecutionHostIdForRows({ - folderWorkspace, - projectGroup: projectGroupById.get(folderWorkspace.projectGroupId), + const visibleProjectGroupsForRows = useMemo( + () => filterProjectGroupsForVisibleHosts(projectGroups, visibleHostIdSet, defaultHostId), + [defaultHostId, projectGroups, visibleHostIdSet] + ) + const visibleFolderWorkspacesForRows = useMemo( + () => + filterFolderWorkspacesForVisibleHosts( + folderWorkspaces, + projectGroups, + visibleHostIdSet, defaultHostId - }) - return visibleHostIdSet.has(hostId) - }) - }, [defaultHostId, folderWorkspaces, projectGroups, visibleHostIdSet]) + ), + [defaultHostId, folderWorkspaces, projectGroups, visibleHostIdSet] + ) const repoOrder = useMemo(() => { return getLogicalRepoOrderRankById(repos.map((repo) => repo.id)) }, [repos]) @@ -5974,8 +5963,8 @@ const WorktreeList = React.memo(function WorktreeList({ // Why layout effect: the Cmd/Ctrl+1–9 handler can fire right after commit; publishing after paint would leave the shortcut cache stale. useLayoutEffect(() => { setVisibleWorktreeIds(renderedWorktreeIds) - // Why: unmounting the list clears the rendered-order cache so shortcuts fall back to the live store snapshot. - return () => setVisibleWorktreeIds([]) + // Why null, not []: [] is a real rendered order (all collapsed/filtered); null tells shortcuts the list is unmounted. + return () => setVisibleWorktreeIds(null) }, [renderedWorktreeIds]) const handleCreateForRepo = useCallback( diff --git a/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.test.ts b/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.test.ts new file mode 100644 index 000000000..0f549db9f --- /dev/null +++ b/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.test.ts @@ -0,0 +1,262 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import { getVisibleWorktreeIds, setVisibleWorktreeIds } from './visible-worktrees' +import type { AppState } from '@/store/types' +import type { FolderWorkspace, ProjectGroup, Repo, Worktree } from '../../../../shared/types' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' + +const initialState = useAppStore.getInitialState() + +function makeWorktree(id: string, overrides: Partial = {}): Worktree { + return { + id, + repoId: 'repo1', + path: `/tmp/${id}`, + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false, + displayName: id, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } as Worktree +} + +function makeMainWorktree(id: string, overrides: Partial = {}): Worktree { + return makeWorktree(id, { isMainWorktree: true, branch: 'refs/heads/main', ...overrides }) +} + +function makeRepo(id = 'repo1', overrides: Partial = {}): Repo { + return { + id, + name: id, + path: `/tmp/${id}`, + defaultBranch: 'main', + connectionId: null, + ...overrides + } as unknown as Repo +} + +function makeProjectGroup(id: string, overrides: Partial = {}): ProjectGroup { + return { + id, + name: id, + parentPath: `/tmp/${id}`, + connectionId: null, + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1, + ...overrides + } as ProjectGroup +} + +function makeFolderWorkspace(id: string, projectGroupId: string): FolderWorkspace { + return { + id, + projectGroupId, + name: id, + folderPath: `/tmp/${id}`, + connectionId: null, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } +} + +/** + * Seeds the store with the fields the closed-sidebar order reads, then drops the + * published order so getVisibleWorktreeIds() must recompute it. + */ +function seedStore(worktrees: Worktree[], overrides: Partial = {}): void { + useAppStore.setState( + { + ...initialState, + repos: [makeRepo()], + worktreesByRepo: { repo1: worktrees }, + // Why showSleepingWorkspaces: fixtures have no PTYs, so the activity filter + // would otherwise drop every workspace before ordering runs. + showSleepingWorkspaces: true, + groupBy: 'repo', + sortBy: 'manual', + ...overrides + } as AppState, + true + ) + setVisibleWorktreeIds(null) +} + +describe('closed-sidebar Cmd+1-9 ordering (#9497)', () => { + afterEach(() => { + setVisibleWorktreeIds(null) + useAppStore.setState(initialState, true) + }) + + it('hoists the repo main worktree first, matching the rendered sidebar', () => { + // Manual sort ranks by sortOrder descending, so the base sort puts the feature + // workspace first; only the repo grouping layer hoists main ahead of it. + const feature = makeWorktree('wt-feature', { sortOrder: 1 }) + const main = makeMainWorktree('wt-main', { sortOrder: 0 }) + seedStore([feature, main]) + + const order = getVisibleWorktreeIds() + + expect(order).toEqual(['wt-main', 'wt-feature']) + // The pre-fix flat fallback returned membership order and numbered the wrong card. + expect(order).not.toEqual(['wt-feature', 'wt-main']) + }) + + it('ignores the agent-send collapse override, which only applies to a mounted list', () => { + // Safe to ignore WorktreeList's effectiveCollapsedGroups override only because + // it needs a mounted list, which publishes its order and skips this path. + const main = makeMainWorktree('wt-main') + const feature = makeWorktree('wt-feature') + seedStore([main, feature], { + collapsedGroups: new Set(['project:repo:repo1']), + agentSendPopoverTargetMode: { + worktreeId: 'wt-feature', + id: 'send-1', + instanceId: 'inst-1' + } as AppState['agentSendPopoverTargetMode'] + }) + + expect(getVisibleWorktreeIds()).toEqual([]) + + setVisibleWorktreeIds(['wt-feature', 'wt-main']) + expect(getVisibleWorktreeIds()).toEqual(['wt-feature', 'wt-main']) + }) + + it('places a pinned workspace per the pinned section under both display policies', () => { + const main = makeMainWorktree('wt-main') + const plain = makeWorktree('wt-plain') + const pinned = makeWorktree('wt-pinned', { isPinned: true }) + + for (const showPinnedWorktreesInGroups of [false, true]) { + seedStore([main, plain, pinned], { + settings: { showPinnedWorktreesInGroups } as AppState['settings'] + }) + + const order = getVisibleWorktreeIds() + + // Each workspace owns exactly one ordinal even when pinning duplicates its row. + expect(order).toEqual([...new Set(order)]) + expect([...order].sort()).toEqual(['wt-main', 'wt-pinned', 'wt-plain']) + expect(order[0]).toBe(showPinnedWorktreesInGroups ? 'wt-main' : 'wt-pinned') + } + }) + + it('elides members of a collapsed group, which render no card to number', () => { + const main = makeMainWorktree('wt-main') + const feature = makeWorktree('wt-feature') + seedStore([main, feature], { collapsedGroups: new Set(['project:repo:repo1']) }) + + expect(getVisibleWorktreeIds()).toEqual([]) + }) + + it('numbers folder workspaces, which the flat fallback omitted entirely', () => { + const group = makeProjectGroup('group-1') + const folderWorkspace = makeFolderWorkspace('fw-1', group.id) + seedStore([makeMainWorktree('wt-main')], { + projectGroups: [group], + folderWorkspaces: [folderWorkspace] + }) + + expect(getVisibleWorktreeIds()).toContain(folderWorkspaceKey(folderWorkspace.id)) + }) + + it('numbers a workspace created while nothing was published', () => { + // A retained-cache fix cannot surface this: it can only prune ids it already had. + seedStore([makeMainWorktree('wt-main')]) + expect(getVisibleWorktreeIds()).toEqual(['wt-main']) + + seedStore([makeMainWorktree('wt-main'), makeWorktree('wt-created-while-closed')]) + + expect(getVisibleWorktreeIds()).toEqual(['wt-main', 'wt-created-while-closed']) + }) + + it('treats a published order as authoritative, including an explicitly empty one', () => { + seedStore([makeMainWorktree('wt-main'), makeWorktree('wt-feature')]) + + setVisibleWorktreeIds(['x']) + expect(getVisibleWorktreeIds()).toEqual(['x']) + + // An empty rendered sidebar is a real order; the old length check fell through here. + setVisibleWorktreeIds([]) + expect(getVisibleWorktreeIds()).toEqual([]) + + setVisibleWorktreeIds(null) + expect(getVisibleWorktreeIds()).toEqual(['wt-main', 'wt-feature']) + }) + + it('honors an explicit host filter and keeps the all-hosts default unfiltered', () => { + const localRepo = makeRepo('repo1') + const sshRepo = makeRepo('repo-ssh', { connectionId: 'my-target' }) + // Base sort interleaves the hosts, so any host grouping has to reorder them. + const localMain = makeMainWorktree('wt-local-main', { sortOrder: 3 }) + const remoteMain = makeMainWorktree('wt-remote-main', { repoId: 'repo-ssh', sortOrder: 2 }) + const localFeature = makeWorktree('wt-local-feature', { sortOrder: 1 }) + const remoteFeature = makeWorktree('wt-remote-feature', { repoId: 'repo-ssh', sortOrder: 0 }) + const storeOverrides = { + repos: [localRepo, sshRepo], + worktreesByRepo: { + repo1: [localMain, localFeature], + 'repo-ssh': [remoteMain, remoteFeature] + }, + sshTargetLabels: new Map([['my-target', 'My Target']]) + } as Partial + + seedStore([], storeOverrides) + // Default all-hosts scope adds no host sections, so repo grouping alone orders it. + expect(getVisibleWorktreeIds()).toEqual([ + 'wt-local-main', + 'wt-local-feature', + 'wt-remote-main', + 'wt-remote-feature' + ]) + + seedStore([], { ...storeOverrides, visibleWorkspaceHostIds: ['local'] }) + expect(getVisibleWorktreeIds()).toEqual(['wt-local-main', 'wt-local-feature']) + + seedStore([], { + ...storeOverrides, + visibleWorkspaceHostIds: ['ssh:my-target', 'local'] + }) + // Host sections keep each host's workspaces contiguous, ordered by the host + // registry (local first) rather than by the filter argument's order. + expect(getVisibleWorktreeIds()).toEqual([ + 'wt-local-main', + 'wt-local-feature', + 'wt-remote-main', + 'wt-remote-feature' + ]) + }) + + it('keeps the sort layer intact for smart and comparator sort modes', () => { + const main = makeMainWorktree('wt-main') + const older = makeWorktree('wt-older', { displayName: 'b-older', lastActivityAt: 1 }) + const newer = makeWorktree('wt-newer', { displayName: 'a-newer', lastActivityAt: 999 }) + + seedStore([older, newer, main], { sortBy: 'smart' }) + expect(getVisibleWorktreeIds()).toEqual(['wt-main', 'wt-newer', 'wt-older']) + + seedStore([older, newer, main], { sortBy: 'name' }) + expect(getVisibleWorktreeIds()).toEqual(['wt-main', 'wt-newer', 'wt-older']) + }) +}) diff --git a/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts b/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts new file mode 100644 index 000000000..fccdc7995 --- /dev/null +++ b/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts @@ -0,0 +1,122 @@ +import type { Worktree } from '../../../../shared/types' +import type { AppState } from '@/store/types' +import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' +import { + ALL_EXECUTION_HOSTS_SCOPE, + getSettingsFocusedExecutionHostId +} from '../../../../shared/execution-host' +import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors' +import { getProjectHostSetupProjectionFromState } from '@/store/project-host-setup-selector' +import { buildRows, getPinnedWorktreeDisplayPolicy } from './worktree-list-groups' +import { addHostSectionRows } from './host-section-rows' +import { orderHostSectionOptions } from './host-section-order' +import { buildSidebarHostOptions } from './sidebar-host-options' +import { getLogicalRepoOrderRankById } from './project-header-drop' +import { getRenderedWorktreesInSidebarOrder } from './worktree-sidebar-row-preference' +import { selectWorktreeListReviewCacheInputs } from './worktree-list-review-cache-inputs' +import { + filterFolderWorkspacesForVisibleHosts, + filterProjectGroupsForVisibleHosts, + getVisibleSidebarHostIdSet +} from './worktree-list-host-filtering' + +const EMPTY_REPO_ID_SET: ReadonlySet = Object.freeze(new Set()) +const EMPTY_IMPORTED_BY_REPO = Object.freeze(new Map()) as never +const EMPTY_INBOX_BY_REPO = Object.freeze(new Map()) as never +const EMPTY_PENDING_CREATIONS = Object.freeze([]) as never + +/** + * Orders already-filtered worktrees the way the sidebar would render them, for + * Cmd+1–9 numbering while WorktreeList is unmounted (#9497). + * + * Why replay the pipeline: a flat list drops grouping, pinning, main-worktree + * hoisting and collapse elision, so the shortcut numbered the wrong card. + * Why worktrees are passed in: keeps the dependency on visible-worktrees.ts + * one-way, since test suites mock that module's path. + */ +export function computeRenderedSidebarWorktreeOrder( + state: AppState, + visibleWorktrees: readonly Worktree[] +): string[] { + const defaultHostId = getSettingsFocusedExecutionHostId(state.settings) + const pinnedDisplayPolicy = getPinnedWorktreeDisplayPolicy(state.settings) + const projection = getProjectHostSetupProjectionFromState(state) + const visibleHostIdSet = getVisibleSidebarHostIdSet( + state.visibleWorkspaceHostIds, + state.workspaceHostScope + ) + const projectGroups = state.projectGroups ?? [] + const { prCache } = selectWorktreeListReviewCacheInputs( + state, + state.groupBy, + state.worktreeCardProperties + ) + + const rows = buildRows( + state.groupBy, + [...visibleWorktrees], + getRepoMapFromState(state), + prCache, + state.collapsedGroups, + getLogicalRepoOrderRankById(state.repos.map((repo) => repo.id)), + state.workspaceStatuses, + state.projectOrderBy, + state.worktreeLineageById, + getWorktreeMapFromState(state), + true, + state.settings, + filterProjectGroupsForVisibleHosts(projectGroups, visibleHostIdSet, defaultHostId), + // Why empty: placeholder/imported/inbox/pending inputs never emit item or folder-workspace rows, the only two the order reads. + EMPTY_REPO_ID_SET, + EMPTY_IMPORTED_BY_REPO, + EMPTY_INBOX_BY_REPO, + EMPTY_PENDING_CREATIONS, + { projects: projection.projects, projectHostSetups: projection.setups }, + filterFolderWorkspacesForVisibleHosts( + state.folderWorkspaces, + projectGroups, + visibleHostIdSet, + defaultHostId + ), + // Why no hostLabelById: it only feeds display-only host context labels, never row order. + undefined, + defaultHostId, + pinnedDisplayPolicy + ) + + // Why lazy: with no host filter, addHostSectionRows is a pass-through, so skip building the whole host registry on a keystroke. + // Deliberately a superset of its internal guards — on <=1 host it still no-ops, wasting only the registry build. + const needsHostSections = + state.workspaceHostScope !== ALL_EXECUTION_HOSTS_SCOPE || state.visibleWorkspaceHostIds != null + const sectionRows = needsHostSections + ? addHostSectionRows({ + rows, + hostOptions: orderHostSectionOptions( + buildSidebarHostOptions({ + repos: state.repos, + sshTargetLabels: state.sshTargetLabels, + sshConnectionStates: state.sshConnectionStates, + settings: state.settings, + runtimeEnvironments: state.runtimeEnvironments, + runtimeStatusByEnvironmentId: state.runtimeStatusByEnvironmentId, + hostLabelOverrides: getHostDisplayLabelOverrides(state.settings) + }), + state.workspaceHostOrder + ), + workspaceHostScope: state.workspaceHostScope, + visibleWorkspaceHostIds: state.visibleWorkspaceHostIds, + defaultHostId, + collapsedHostKeys: state.collapsedGroups, + forceCollapseHosts: false, + preferProjectGrouping: true + }) + : rows + + return Array.from( + new Set( + getRenderedWorktreesInSidebarOrder(sectionRows, pinnedDisplayPolicy).map( + (worktree) => worktree.id + ) + ) + ) +} diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 24c3878e6..2ee9f41a0 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -2,7 +2,11 @@ import type { Worktree, Repo, TerminalTab, WorktreeLineage } from '../../../../s import { buildWorktreeComparator, sortWorktreesSmart } from './smart-sort' import { getWorktreeIdsWithLiveAgent, isInactiveWorkspace } from '@/lib/worktree-activity-state' import { useAppStore } from '@/store' -import { getAllWorktreesFromState, getRepoMapFromState } from '@/store/selectors' +import { + getAllWorktreesFromState, + getRepoMapFromState, + getWorktreeMapFromState +} from '@/store/selectors' import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' import { ALL_EXECUTION_HOSTS_SCOPE, @@ -15,6 +19,7 @@ import { getCyclicProjectedWorktreeLineageIds, getLineageRenderInfo } from './worktree-lineage-projection' +import { computeRenderedSidebarWorktreeOrder } from './rendered-sidebar-worktree-order' /** * Whether a worktree represents the repo's default-branch row that the @@ -270,15 +275,19 @@ function addVisibleLineageAncestors( * could target a different worktree than what's rendered at that sidebar * position. By caching the IDs that WorktreeList actually rendered, the * shortcut numbering always matches the sidebar card order. + * + * Why null vs []: [] is a real rendered order (everything collapsed/filtered); + * null means WorktreeList is unmounted. */ -let _cachedVisibleIds: string[] = [] +let _publishedVisibleIds: string[] | null = null /** * Called by WorktreeList after computing visible worktrees so the Cmd+1–9 - * handler can read the exact same ordering the user sees on screen. + * handler can read the exact same ordering the user sees on screen. Pass null + * on unmount. */ -export function setVisibleWorktreeIds(ids: string[]): void { - _cachedVisibleIds = ids +export function setVisibleWorktreeIds(ids: string[] | null): void { + _publishedVisibleIds = ids } /** @@ -286,17 +295,16 @@ export function setVisibleWorktreeIds(ids: string[]): void { * state. Called by the App-level Cmd+1–9 handler (not a React hook — reads * store snapshot at call time). * - * If WorktreeList has rendered at least once, returns the cached IDs so the - * shortcut numbering matches the sidebar. Falls back to a live recomputation - * only before WorktreeList's first render (e.g. app startup). + * If WorktreeList is mounted, returns the exact IDs it rendered. Otherwise + * recomputes the order the sidebar *would* render from the same row pipeline, + * so a closed sidebar numbers workspaces the same way an open one does (#9497). */ export function getVisibleWorktreeIds(): string[] { - // Prefer the cached IDs that mirror the rendered sidebar order. - if (_cachedVisibleIds.length > 0) { - return _cachedVisibleIds + // Prefer the published IDs that mirror the rendered sidebar order. + if (_publishedVisibleIds) { + return _publishedVisibleIds } - // Fallback: live recomputation for the window before WorktreeList renders. const state = useAppStore.getState() const allWorktrees = getAllWorktreesFromState(state).filter((w) => !w.isArchived) @@ -325,7 +333,7 @@ export function getVisibleWorktreeIds(): string[] { sortedIds = sorted.map((w) => w.id) } - return computeVisibleWorktreeIds(state.worktreesByRepo, sortedIds, { + const visibleIds = computeVisibleWorktreeIds(state.worktreesByRepo, sortedIds, { filterRepoIds: state.filterRepoIds, showSleepingWorkspaces: state.showSleepingWorkspaces, tabsByWorktree: state.tabsByWorktree, @@ -345,4 +353,11 @@ export function getVisibleWorktreeIds(): string[] { defaultHostId: getSettingsFocusedExecutionHostId(state.settings), worktreeLineageById: state.worktreeLineageById }) + + const worktreeMap = getWorktreeMapFromState(state) + // Why the row pipeline: grouping, pinning and main-worktree hoisting reorder cards, so a flat sort numbers the wrong workspace. + return computeRenderedSidebarWorktreeOrder( + state, + visibleIds.map((id) => worktreeMap.get(id)).filter((w): w is Worktree => w != null) + ) } diff --git a/src/renderer/src/components/sidebar/worktree-list-host-filtering.ts b/src/renderer/src/components/sidebar/worktree-list-host-filtering.ts index 6f7a62c02..302739579 100644 --- a/src/renderer/src/components/sidebar/worktree-list-host-filtering.ts +++ b/src/renderer/src/components/sidebar/worktree-list-host-filtering.ts @@ -1,12 +1,61 @@ import { + ALL_EXECUTION_HOSTS_SCOPE, normalizeExecutionHostId, parseExecutionHostId, toSshExecutionHostId, - type ExecutionHostId + type ExecutionHostId, + type ExecutionHostScope } from '../../../../shared/execution-host' import type { FolderWorkspacePathStatusRequest } from '../../../../shared/folder-workspace-path-status' import type { FolderWorkspace, ProjectGroup } from '../../../../shared/types' +/** null means "no host filter" — every host is visible. */ +export function getVisibleSidebarHostIdSet( + visibleWorkspaceHostIds: readonly ExecutionHostId[] | null | undefined, + workspaceHostScope: ExecutionHostScope +): Set | null { + const visibleHostIds = + visibleWorkspaceHostIds ?? + (workspaceHostScope === ALL_EXECUTION_HOSTS_SCOPE ? null : [workspaceHostScope]) + return visibleHostIds ? new Set(visibleHostIds) : null +} + +// Why shared: the sidebar render path and the Cmd+1–9 order must apply the same +// host filtering, or the numbering drifts from the cards whenever a filter is on. +export function filterProjectGroupsForVisibleHosts( + projectGroups: readonly ProjectGroup[], + visibleHostIdSet: ReadonlySet | null, + defaultHostId: ExecutionHostId +): readonly ProjectGroup[] { + if (!visibleHostIdSet) { + return projectGroups + } + return projectGroups.filter((group) => + visibleHostIdSet.has(getProjectGroupExecutionHostIdForRows(group, defaultHostId)) + ) +} + +export function filterFolderWorkspacesForVisibleHosts( + folderWorkspaces: readonly FolderWorkspace[], + projectGroups: readonly ProjectGroup[], + visibleHostIdSet: ReadonlySet | null, + defaultHostId: ExecutionHostId +): readonly FolderWorkspace[] { + if (!visibleHostIdSet) { + return folderWorkspaces + } + const projectGroupById = new Map(projectGroups.map((group) => [group.id, group])) + return folderWorkspaces.filter((folderWorkspace) => + visibleHostIdSet.has( + getFolderWorkspaceExecutionHostIdForRows({ + folderWorkspace, + projectGroup: projectGroupById.get(folderWorkspace.projectGroupId), + defaultHostId + }) + ) + ) +} + export function getProjectGroupExecutionHostIdForRows( group: Pick, defaultHostId: ExecutionHostId