fix(sidebar): make Cmd/Ctrl+1-9 match the rendered card order when the sidebar is closed (#10693)
This commit is contained in:
parent
6c5af95343
commit
03b65d08e5
|
|
@ -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<ExecutionHostId>(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(
|
||||
|
|
|
|||
|
|
@ -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> = {}): 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> = {}): Worktree {
|
||||
return makeWorktree(id, { isMainWorktree: true, branch: 'refs/heads/main', ...overrides })
|
||||
}
|
||||
|
||||
function makeRepo(id = 'repo1', overrides: Partial<Repo> = {}): Repo {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
path: `/tmp/${id}`,
|
||||
defaultBranch: 'main',
|
||||
connectionId: null,
|
||||
...overrides
|
||||
} as unknown as Repo
|
||||
}
|
||||
|
||||
function makeProjectGroup(id: string, overrides: Partial<ProjectGroup> = {}): 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<AppState> = {}): 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<AppState>
|
||||
|
||||
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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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<string> = Object.freeze(new Set<string>())
|
||||
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
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ExecutionHostId> | null {
|
||||
const visibleHostIds =
|
||||
visibleWorkspaceHostIds ??
|
||||
(workspaceHostScope === ALL_EXECUTION_HOSTS_SCOPE ? null : [workspaceHostScope])
|
||||
return visibleHostIds ? new Set<ExecutionHostId>(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<ExecutionHostId> | 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<ExecutionHostId> | 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<ProjectGroup, 'connectionId' | 'executionHostId'>,
|
||||
defaultHostId: ExecutionHostId
|
||||
|
|
|
|||
Loading…
Reference in New Issue