refactor: organize worktree-list into lifecycle dest folders

This commit is contained in:
Jinjing
2026-08-14 18:21:45 -07:00
parent b91ad74963
commit 1040cca429
111 changed files with 2270 additions and 2002 deletions
+1 -2
View File
@@ -235,8 +235,7 @@ inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx
inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
inline src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts
inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts
inline src/renderer/src/components/sidebar/worktree-list/rows/groups.test.ts
inline src/renderer/src/components/sidebar/worktree-list/rows/groups.ts
inline src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test.ts
inline src/renderer/src/components/stats/usage-overview-model.ts
inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
inline src/renderer/src/components/status-bar/StatusBar.tsx
@@ -132,7 +132,7 @@ import { getPaletteHostBadge, type PaletteHostBadge } from '@/components/cmd-j/p
import PaletteFilterMenu from '@/components/cmd-j/PaletteFilterMenu'
import PaletteFilterChips from '@/components/cmd-j/PaletteFilterChips'
import { buildPaletteFilterModel } from '@/components/cmd-j/palette-filter-options'
import { getProjectGroupExecutionHostIdForRows } from '@/components/sidebar/worktree-list/viewport/host-filtering'
import { getProjectGroupExecutionHostIdForRows } from '@/components/sidebar/worktree-list/listing/host-filtering'
import {
buildPaletteFilterPredicate,
EMPTY_PALETTE_FILTER,
@@ -6,10 +6,8 @@ import {
import type { Project, ProjectHostSetup } from '../../../../shared/project-types'
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import {
getProjectHeaderRevealTarget,
type ProjectGroupingModel
} from '../sidebar/worktree-list/rows/groups'
import { getProjectHeaderRevealTarget } from '../sidebar/worktree-list/grouping/project-grouping'
import type { ProjectGroupingModel } from '../sidebar/worktree-list/grouping/project-grouping'
import type { SidebarHostOption } from '../sidebar/sidebar-host-options'
import { buildPaletteFilterOptionSearchText } from './palette-filter-option-list'
@@ -8,11 +8,9 @@ import type { ProjectGroup } from '../../../../shared/project-group-types'
import type { Project, ProjectHostSetup } from '../../../../shared/project-types'
import type { Repo } from '../../../../shared/repo-types'
import { translate } from '@/i18n/i18n'
import {
getProjectGroupHeaderKey,
getProjectHeaderRevealTarget,
type ProjectGroupingModel
} from '../sidebar/worktree-list/rows/groups'
import { getProjectGroupHeaderKey } from '../sidebar/worktree-list/grouping/group-keys'
import { getProjectHeaderRevealTarget } from '../sidebar/worktree-list/grouping/project-grouping'
import type { ProjectGroupingModel } from '../sidebar/worktree-list/grouping/project-grouping'
export type CmdJProjectGroupResult = {
id: string
@@ -31,8 +31,8 @@ const HOVER_REVEAL_FILES = [
resolve(__dirname, 'sidebar/WorkspaceKanbanStatusLane.tsx'),
resolve(__dirname, 'sidebar/WorktreeCardPorts.tsx'),
resolve(__dirname, 'sidebar/WorktreeList.tsx'),
resolve(__dirname, 'sidebar/worktree-list/headers/HostSectionHeader.tsx'),
resolve(__dirname, 'sidebar/worktree-list/headers/SectionHeader.tsx'),
resolve(__dirname, 'sidebar/worktree-list/rows/HostSectionHeader.tsx'),
resolve(__dirname, 'sidebar/worktree-list/rows/SectionHeader.tsx'),
resolve(__dirname, 'sidebar/worktree-list/rows/item-row.tsx'),
resolve(__dirname, 'status-bar/ResourceUsageStatusSegment.tsx'),
resolve(__dirname, 'status-bar/ports-status-popover-rows.tsx'),
@@ -4,7 +4,7 @@ import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SidebarGroupByToggle } from './SidebarGroupByToggle'
import type { WorktreeGroupBy } from './worktree-list/rows/groups'
import type { WorktreeGroupBy } from './worktree-list/grouping/row-types'
const roots: Root[] = []
@@ -1,5 +1,5 @@
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import type { WorktreeGroupBy } from './worktree-list/rows/groups'
import type { WorktreeGroupBy } from './worktree-list/grouping/row-types'
import { GROUP_BY_OPTIONS } from './sidebar-workspace-option-items'
type SidebarGroupByToggleProps = {
@@ -8,7 +8,7 @@ import type { Repo } from '../../../../shared/repo-types'
import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import { getPinnedWorktreeRevealCollapsedGroupKeys } from './worktree-list/reveal/ancestors'
import { getPinnedWorktreeRevealCollapsedGroupKeys } from './worktree-list/navigation/reveal-ancestors'
import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses'
const mockStore = vi.hoisted(() => ({
@@ -14,29 +14,27 @@ import {
getSettingsFocusedExecutionHostId
} from '../../../../shared/execution-host'
import { getActiveSidebarWorkspaceId } from '../../../../shared/workspace-scope'
import { getPinnedWorktreeDisplayPolicy } from './worktree-list/rows/groups'
import { selectWorktreeListReviewCacheInputs } from './worktree-list/viewport/review-cache-inputs'
import { getPinnedWorktreeDisplayPolicy } from './worktree-list/grouping/row-types'
import { selectWorktreeListReviewCacheInputs } from './worktree-list/listing/review-cache-inputs'
import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor'
import { SidebarWorktreeListDialogs } from './worktree-list/SidebarWorktreeListDialogs'
import { SidebarWorktreeListEmptyState } from './worktree-list/SidebarWorktreeListEmptyState'
import { SidebarWorktreeListDialogs } from './worktree-list/rows/ProjectGroupDialogs'
import { SidebarWorktreeListEmptyState } from './worktree-list/listing/EmptyState'
import { VirtualizedWorktreeViewport } from './worktree-list/viewport/VirtualizedWorktreeViewport'
import { markSidebarWorktreeActiveImmediately } from './worktree-list/option-dom'
import {
EMPTY_PROJECT_GROUPS,
NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK
} from './worktree-list/viewport/viewport-props'
import { useAgentSendTargetWorktreeId } from './worktree-list/use-agent-send-target-worktree'
import { useEffectiveCollapsedGroups } from './worktree-list/use-effective-collapsed-groups'
import { useProjectGroupDialogs } from './worktree-list/use-project-group-dialogs'
import { useSidebarExternalWorktreeCards } from './worktree-list/use-sidebar-external-worktree-cards'
import { useSidebarHostVisibleScope } from './worktree-list/use-sidebar-host-visible-scope'
import { useSidebarRevealRequests } from './worktree-list/reveal/use-requests'
import { useSidebarSectionRows } from './worktree-list/use-sidebar-section-rows'
import { useSidebarWorktreeFilters } from './worktree-list/use-sidebar-worktree-filters'
import { useSidebarWorktreeSelection } from './worktree-list/use-sidebar-worktree-selection'
import { useSidebarWorktreeSortOrder } from './worktree-list/use-sidebar-worktree-sort-order'
import { useVisibleSidebarWorktrees } from './worktree-list/use-visible-sidebar-worktrees'
import { useWorktreeStatusMutations } from './worktree-list/use-status-mutations'
import { markSidebarWorktreeActiveImmediately } from './worktree-list/rows/option-dom'
import { EMPTY_PROJECT_GROUPS } from './worktree-list/viewport/viewport-props'
import { NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK } from './worktree-list/drag/drop-commit-context'
import { useAgentSendTargetWorktreeId } from './worktree-list/listing/use-agent-send-target'
import { useEffectiveCollapsedGroups } from './worktree-list/listing/use-collapsed-groups'
import { useProjectGroupDialogs } from './worktree-list/rows/use-project-group-dialogs'
import { useSidebarExternalWorktreeCards } from './worktree-list/listing/use-external-worktree-cards'
import { useSidebarHostVisibleScope } from './worktree-list/listing/use-host-visible-scope'
import { useSidebarRevealRequests } from './worktree-list/navigation/use-reveal-requests'
import { useSidebarSectionRows } from './worktree-list/listing/use-section-rows'
import { useSidebarWorktreeFilters } from './worktree-list/listing/use-filters'
import { useSidebarWorktreeSelection } from './worktree-list/navigation/use-selection'
import { useSidebarWorktreeSortOrder } from './worktree-list/listing/use-sort-order'
import { useVisibleSidebarWorktrees } from './worktree-list/listing/use-visible-worktrees'
import { useWorktreeStatusMutations } from './worktree-list/drag/use-status-mutations'
type WorktreeListProps = {
scrollOffsetRef: React.MutableRefObject<number>
@@ -1,6 +1,6 @@
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import type { WorktreeGroupBy } from './worktree-list/rows/groups'
import type { WorktreeGroupBy } from './worktree-list/grouping/row-types'
export function getEmptyProjectPlaceholderRepoIds(args: {
groupBy: WorktreeGroupBy
@@ -3,7 +3,8 @@ import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../../../shared/project-group-types'
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { PINNED_GROUP_KEY, type Row } from './worktree-list/rows/groups'
import { PINNED_GROUP_KEY } from './worktree-list/grouping/group-keys'
import type { Row } from './worktree-list/grouping/row-types'
import { addHostSectionRows, type HostSectionRow } from './host-section-rows'
function repo(id: string, connectionId?: string | null): Repo {
@@ -14,7 +14,7 @@ import type { SshConnectionStatus } from '../../../../shared/ssh-types'
import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../../../shared/project-group-types'
import type { Repo } from '../../../../shared/repo-types'
import type { Row } from './worktree-list/rows/groups'
import type { Row } from './worktree-list/grouping/row-types'
export type HostHeaderRow = {
type: 'host-header'
@@ -12,7 +12,7 @@ import {
effectiveExternalWorktreeVisibility,
isLegacyRepoForExternalWorktreeVisibility
} from '../../../../shared/worktree/ownership'
import type { ImportedWorktreesCardCandidate } from './worktree-list/rows/groups'
import type { ImportedWorktreesCardCandidate } from './worktree-list/grouping/row-types'
export function getHiddenImportedWorktrees(
detected: DetectedWorktreeListResult | undefined
@@ -8,7 +8,7 @@ import type { Repo } from '../../../../shared/repo-types'
import type { DetectedWorktreeListResult, Worktree } from '../../../../shared/worktree/types'
import { getNewExternalWorktreeInboxWorktrees } from '../../../../shared/external-worktree-inbox'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import type { NewExternalWorktreesInboxCandidate } from './worktree-list/rows/groups'
import type { NewExternalWorktreesInboxCandidate } from './worktree-list/grouping/row-types'
export function buildNewExternalWorktreesInboxCandidates(args: {
repos: readonly Repo[]
@@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'
function readWorktreeListSource(): string {
return readFileSync(
fileURLToPath(new URL('./worktree-list/headers/SectionHeader.tsx', import.meta.url)),
fileURLToPath(new URL('./worktree-list/rows/SectionHeader.tsx', import.meta.url)),
'utf8'
)
}
@@ -8,7 +8,7 @@ import {
getSidebarOrderedProjectGroupHeaderIdsByBucket,
mapSidebarProjectGroupDropIndexToSiblingInsertIndex
} from './project-group-header-drop'
import type { Row } from './worktree-list/rows/groups'
import type { Row } from './worktree-list/grouping/row-types'
import type { ProjectGroup } from '../../../../shared/project-group-types'
import type { Repo } from '../../../../shared/repo-types'
@@ -2,7 +2,7 @@ import {
computeWorktreeSidebarHeaderDropPreview,
type WorktreeSidebarHeaderDropPreview
} from './worktree-sidebar-header-drop-preview'
import type { Row } from './worktree-list/rows/groups'
import type { Row } from './worktree-list/grouping/row-types'
import type { ProjectGroup } from '../../../../shared/project-group-types'
export type ProjectGroupHeaderDragBucketKey = string
@@ -11,7 +11,7 @@ import {
mapSidebarProjectHeaderDropIndexToSiblingInsertIndex,
mapSidebarRepoDropIndexToAllRepoInsertAt
} from './project-header-drop'
import type { Row } from './worktree-list/rows/groups'
import type { Row } from './worktree-list/grouping/row-types'
import type { Repo } from '../../../../shared/repo-types'
describe('getProjectHeaderDragBucketKey', () => {
@@ -3,7 +3,7 @@ import {
computeWorktreeSidebarHeaderDropPreview,
type WorktreeSidebarHeaderDropPreview
} from './worktree-sidebar-header-drop-preview'
import type { Row } from './worktree-list/rows/groups'
import type { Row } from './worktree-list/grouping/row-types'
import type { Repo } from '../../../../shared/repo-types'
export type ProjectHeaderDragBucketKey = string
@@ -7,18 +7,19 @@ import {
} from '../../../../shared/execution-host'
import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors'
import { getProjectHostSetupProjectionFromState } from '@/store/project-host-setup-selector'
import { buildRows, getPinnedWorktreeDisplayPolicy } from './worktree-list/rows/groups'
import { buildRows } from './worktree-list/grouping/build-rows'
import { getPinnedWorktreeDisplayPolicy } from './worktree-list/grouping/row-types'
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/viewport/review-cache-inputs'
import { selectWorktreeListReviewCacheInputs } from './worktree-list/listing/review-cache-inputs'
import {
filterFolderWorkspacesForVisibleHosts,
filterProjectGroupsForVisibleHosts,
getVisibleSidebarHostIdSet
} from './worktree-list/viewport/host-filtering'
} from './worktree-list/listing/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
@@ -1,5 +1,5 @@
import type { WorktreeDragGroup } from './worktree-manual-order'
import { ALL_GROUP_KEY, PINNED_GROUP_KEY } from './worktree-list/rows/groups'
import { ALL_GROUP_KEY, PINNED_GROUP_KEY } from './worktree-list/grouping/group-keys'
export type WorktreeDragUnitGroup = WorktreeDragGroup & {
units: { worktreeId: string; worktreeIds: string[] }[]
@@ -1,4 +1,5 @@
import { estimateRenderRowSize, type RenderRow } from './worktree-list/viewport/virtual-rows'
import { estimateRenderRowSize } from './worktree-list/viewport/virtual-rows'
import type { RenderRow } from './worktree-list/listing/render-row'
function getEstimatedRenderRowStarts(
rows: readonly RenderRow[],
@@ -132,7 +132,7 @@ describe('getCyclableWorktreeIds', () => {
describe('WorktreeList keyboard cycling', () => {
it('cycles over the rendered rows instead of rebuilding a parallel layout', () => {
const source = readFileSync(
fileURLToPath(new URL('./worktree-list/viewport/use-keyboard.ts', import.meta.url)),
fileURLToPath(new URL('./worktree-list/navigation/use-keyboard.ts', import.meta.url)),
'utf8'
)
const navigateWorktree = source.slice(
@@ -1,5 +1,5 @@
import type { HostSectionRow } from './host-section-rows'
import type { PinnedWorktreeDisplayPolicy, WorktreeRow } from './worktree-list/rows/groups'
import type { PinnedWorktreeDisplayPolicy, WorktreeRow } from './worktree-list/grouping/row-types'
import { getPreferredWorktreeRows } from './worktree-sidebar-row-preference'
/** Worktree ids in sidebar order, taken from the rows the sidebar actually
@@ -6,10 +6,18 @@ import type {
import type { WorktreeDragGroup } from '../../worktree-manual-order'
import type { WorktreeDragUnitGroup } from '../../worktree-drag-units'
import type { WorktreeSidebarDropPreview } from '../../worktree-sidebar-drop-preview'
import type { WorktreeStatusDropAtIndexArgs } from '../viewport/viewport-props'
import type { WorktreeStatusDropRequest } from './use-session'
import type { WorktreeSidebarLineageDropTarget } from './row-state'
export const NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK = (): void => {}
export type WorktreeStatusDropAtIndexArgs = {
worktreeIds: readonly string[]
status: WorkspaceStatus
dropIndex: number
groups: readonly WorktreeDragGroup[]
}
// The shared surface every drop path (pointer, native drag, document capture) commits through.
export type WorktreeDropCommitContext = {
scrollRef: React.RefObject<HTMLDivElement | null>
@@ -1,4 +1,4 @@
import { ALL_GROUP_KEY, PINNED_GROUP_KEY } from '../rows/groups'
import { ALL_GROUP_KEY, PINNED_GROUP_KEY } from '../grouping/group-keys'
import type { HostSectionRow } from '../../host-section-rows'
import type { WorktreeDragGroup } from '../../worktree-manual-order'
@@ -8,8 +8,10 @@ import { resolveWorkspaceKanbanCardDropCommitTarget } from '../../workspace-kanb
import { getFullDropIndexForWorktreeDragUnit } from '../../worktree-drag-units'
import { resolveWorktreeSidebarStatusDropCommitTarget } from '../../worktree-sidebar-drop-preview'
import { getPointerDropStatusTarget, shouldPreferSidebarStatusDropTarget } from './status-target'
import type { WorktreeDropCommitContext } from './drop-commit-context'
import type { WorktreeStatusDropAtIndexArgs } from '../viewport/viewport-props'
import type {
WorktreeDropCommitContext,
WorktreeStatusDropAtIndexArgs
} from './drop-commit-context'
import type { WorktreeSidebarStatusDropTarget } from '../../worktree-sidebar-drop-preview'
import { NO_WORKTREE_SIDEBAR_DROP_TARGET, type WorktreePointerDrag } from './row-state'
@@ -0,0 +1,63 @@
import { useMemo } from 'react'
import type { WorkspaceStatusDefinition } from '../../../../../../shared/worktree/types'
import type { WorktreeDropCommitContext } from './drop-commit-context'
import type { useWorktreeDragRuntime } from './use-runtime'
import type { useWorktreeDragSession } from './use-session'
import type { useWorktreeLineageDropCommit } from './use-lineage-drop-commit'
/** Bundles the drag session, lineage commits, and viewport callbacks every drop path reads. */
export function useWorktreeDropCommitContext(args: {
scrollRef: React.RefObject<HTMLDivElement | null>
workspaceStatuses: readonly WorkspaceStatusDefinition[]
session: ReturnType<typeof useWorktreeDragSession>
lineageDrop: ReturnType<typeof useWorktreeLineageDropCommit>
runtime: ReturnType<typeof useWorktreeDragRuntime>
onMoveWorktreesToStatus: WorktreeDropCommitContext['onMoveWorktreesToStatus']
onMoveWorktreesToStatusAtIndex: WorktreeDropCommitContext['onMoveWorktreesToStatusAtIndex']
onReorderWorktrees: WorktreeDropCommitContext['onReorderWorktrees']
onPinWorktrees: WorktreeDropCommitContext['onPinWorktrees']
}): WorktreeDropCommitContext {
const { scrollRef, workspaceStatuses, session, lineageDrop, runtime } = args
const {
onMoveWorktreesToStatus,
onMoveWorktreesToStatusAtIndex,
onReorderWorktrees,
onPinWorktrees
} = args
return useMemo<WorktreeDropCommitContext>(
() => ({
scrollRef,
workspaceStatuses,
worktreeDragGroups: session.worktreeDragGroups,
worktreeDragUnitGroups: session.worktreeDragUnitGroups,
computeWorktreeDrop: session.computeWorktreeDrop,
computeWorktreeStatusDrop: session.computeWorktreeStatusDrop,
refreshWorktreeDragSession: session.refreshWorktreeDragSession,
getEligibleLineageDropTarget: lineageDrop.getEligibleLineageDropTarget,
commitWorktreeLineageParentDrop: lineageDrop.commitWorktreeLineageParentDrop,
clearReorderedWorktreeParents: lineageDrop.clearReorderedWorktreeParents,
clearWorktreeDrag: runtime.clearWorktreeDrag,
onMoveWorktreesToStatus,
onMoveWorktreesToStatusAtIndex,
onReorderWorktrees,
onPinWorktrees
}),
[
lineageDrop.clearReorderedWorktreeParents,
lineageDrop.commitWorktreeLineageParentDrop,
lineageDrop.getEligibleLineageDropTarget,
onMoveWorktreesToStatus,
onMoveWorktreesToStatusAtIndex,
onPinWorktrees,
onReorderWorktrees,
runtime.clearWorktreeDrag,
scrollRef,
session.computeWorktreeDrop,
session.computeWorktreeStatusDrop,
session.refreshWorktreeDragSession,
session.worktreeDragGroups,
session.worktreeDragUnitGroups,
workspaceStatuses
]
)
}
@@ -6,8 +6,8 @@ import type { ProjectOrderBy } from '../../../../../../shared/ui-chrome-types'
import type { Repo } from '../../../../../../shared/repo-types'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import type { HostHeaderRow, HostSectionRow } from '../../host-section-rows'
import type { Row, WorktreeGroupBy } from '../rows/groups'
import type { RenderRow } from '../viewport/virtual-rows'
import type { Row, WorktreeGroupBy } from '../grouping/row-types'
import type { RenderRow } from '../listing/render-row'
import {
getProjectGroupHeaderSectionEndByGroupId,
getRepoHeaderSectionEndByRepoId
@@ -17,7 +17,7 @@ import { useRepoHeaderDrag } from '../../project-header-drag'
import { getSidebarOrderedRepoHeaderIdsByBucket } from '../../project-header-drop'
import { useProjectGroupHeaderDrag } from '../../project-group-header-drag'
import { getSidebarOrderedProjectGroupHeaderIdsByBucket } from '../../project-group-header-drop'
import { USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS } from '../scroll/tuning'
import { USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS } from '../viewport/use-scroll-suppression'
export type WorktreeSidebarHeaderDrag = ReturnType<typeof useWorktreeSidebarHeaderDrag>
@@ -9,9 +9,11 @@ import {
} from '../../worktree-sidebar-pointer-drag-dom'
import { getWorktreeSidebarDragRectsForGroup } from '../../worktree-sidebar-drag-autoscroll'
import { getWorktreeSidebarDragGrab } from '../../worktree-sidebar-drag-geometry'
import { NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK } from '../viewport/viewport-props'
import type { WorktreeStatusDropAtIndexArgs } from '../viewport/viewport-props'
import type { WorktreeDropCommitContext } from './drop-commit-context'
import { NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK } from './drop-commit-context'
import type {
WorktreeDropCommitContext,
WorktreeStatusDropAtIndexArgs
} from './drop-commit-context'
import type { WorktreeDragRuntime } from './use-runtime'
import type { WorktreeDragSession } from './use-session'
import { useWorktreePointerDragAutoscroll } from './use-pointer-autoscroll'
@@ -1,11 +1,14 @@
import { useEffect } from 'react'
import type { WorktreeDragRuntime } from './use-runtime'
import type { WorktreeDropCommitContext } from './drop-commit-context'
import type { WorktreeStatusDropAtIndexArgs } from '../viewport/viewport-props'
import type {
WorktreeDropCommitContext,
WorktreeStatusDropAtIndexArgs
} from './drop-commit-context'
import { commitWorktreePointerDrop } from './pointer-commit'
import { SIDEBAR_POINTER_DRAG_THRESHOLD_PX } from '../scroll/tuning'
import type { WorktreePointerDrag } from './row-state'
const SIDEBAR_POINTER_DRAG_THRESHOLD_PX = 4
// Pointer drags escape the row that started them, so move/up/cancel are tracked on the
// window in capture phase.
export function useWorktreePointerDragWindowEvents(args: {
@@ -2,7 +2,7 @@ import { useCallback, useMemo, useRef } from 'react'
import type React from 'react'
import type { WorkspaceStatus } from '../../../../../../shared/worktree/types'
import type { HostSectionRow } from '../../host-section-rows'
import { PINNED_GROUP_KEY } from '../rows/groups'
import { PINNED_GROUP_KEY } from '../grouping/group-keys'
import { WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP } from '../viewport/virtual-rows'
import { getWorkspaceStatusGroupKey } from '../../workspace-status'
import { expandDraggedWorktreeIdsForVisibleLineage } from '../../worktree-manual-order'
@@ -23,7 +23,7 @@ import {
type WorktreeSidebarDropPreview
} from '../../worktree-sidebar-drop-preview'
import { getWorktreeDragGroups, getWorktreeDragIndexes } from './groups'
import type { WorktreeItemRow } from '../rows/renderable-rows'
import type { WorktreeItemRow } from '../listing/renderable-rows'
export type WorktreeStatusDropRequest = {
pointerY: number
@@ -4,18 +4,18 @@ import type {
WorkspaceStatus,
WorkspaceStatusDefinition,
Worktree
} from '../../../../../shared/worktree/types'
import type { WorktreeMeta } from '../../../../../shared/worktree/meta-types'
import { getWorkspaceStatus, getWorkspaceStatusGroupKey } from '../workspace-status'
} from '../../../../../../shared/worktree/types'
import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types'
import { getWorkspaceStatus, getWorkspaceStatusGroupKey } from '../../workspace-status'
import {
buildManualOrderUpdatesForGroupDrop,
buildManualOrderUpdatesForVisibleGroups,
shouldWriteManualOrderForGroupDrop,
type WorktreeDragGroup
} from '../worktree-manual-order'
import { buildWorkspaceKanbanSidebarDropUpdates } from '../workspace-kanban-sidebar-drop'
import type { SortBy } from '../smart-sort'
import type { WorktreeStatusDropAtIndexArgs } from './viewport/viewport-props'
} from '../../worktree-manual-order'
import { buildWorkspaceKanbanSidebarDropUpdates } from '../../workspace-kanban-sidebar-drop'
import type { SortBy } from '../../smart-sort'
import type { WorktreeStatusDropAtIndexArgs } from './drop-commit-context'
function buildRankByWorktreeId(
groups: readonly WorktreeDragGroup[],
@@ -2,7 +2,8 @@ import { useCallback, useMemo } from 'react'
import type React from 'react'
import type { WorkspaceStatus } from '../../../../../../shared/worktree/types'
import type { HostSectionRow } from '../../host-section-rows'
import { PINNED_GROUP_KEY, type WorktreeGroupBy } from '../rows/groups'
import { PINNED_GROUP_KEY } from '../grouping/group-keys'
import type { WorktreeGroupBy } from '../grouping/row-types'
import { hasWorkspaceDragData, readWorkspaceDragDataIds } from '../../workspace-status'
import { useWorkspaceStatusDocumentDrop } from '../../use-workspace-status-drop'
import type { WorktreeDropCommitContext } from './drop-commit-context'
@@ -4,22 +4,18 @@ import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { getExecutionHostLabel } from '../../../../../../shared/execution-host'
import { projectHostSetupProjectionFromRepos } from '../../../../../../shared/project-host-setup-projection'
import {
ALL_GROUP_META,
buildRows,
getGroupKeyForWorktree,
getGroupKeysForWorktree,
getLineageGroupKey,
getLineageRenderInfo,
getPRGroupKey,
PINNED_GROUP_KEY,
type PendingCreationRef
} from './groups'
import { buildRows } from './build-rows'
import { ALL_GROUP_META, getLineageGroupKey, getPRGroupKey, PINNED_GROUP_KEY } from './group-keys'
import { getGroupKeyForWorktree, getGroupKeysForWorktree } from './worktree-group-keys'
import type { PendingCreationRef } from './row-types'
import {
REPO_HEADER_ACTION_BUTTON_CLASS,
REPO_HEADER_ACTION_REVEAL_CLASS
} from '../../repo-header-action-button-class'
import { getWorktreeLineageAncestors } from '../../worktree-lineage-projection'
import {
getLineageRenderInfo,
getWorktreeLineageAncestors
} from '../../worktree-lineage-projection'
import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
import type { Project, ProjectHostSetup } from '../../../../../../shared/project-types'
@@ -64,10 +60,7 @@ function readWorktreeListSource(): string {
}
function readSectionHeaderRowSource(): string {
return readFileSync(
fileURLToPath(new URL('../headers/SectionHeader.tsx', import.meta.url)),
'utf8'
)
return readFileSync(fileURLToPath(new URL('../rows/SectionHeader.tsx', import.meta.url)), 'utf8')
}
const remoteRepo: Repo = {
@@ -0,0 +1,194 @@
import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
import type { Repo } from '../../../../../../shared/repo-types'
import type { ProjectOrderBy } from '../../../../../../shared/ui-chrome-types'
import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types'
import type { WorkspaceStatusDefinition, Worktree } from '../../../../../../shared/worktree/types'
import { cloneDefaultWorkspaceStatuses } from '../../../../../../shared/workspace-statuses'
import type { AppState } from '../../../../store/types'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../../../shared/execution-host'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import { getCyclicProjectedWorktreeLineageIds } from '../../worktree-lineage-projection'
import { ALL_GROUP_KEY, ALL_GROUP_META } from './group-keys'
import { appendOrderedGroups } from './group-sections'
import type { SectionAppendContext } from './group-sections'
import {
getHostWorktreeCounts,
getHostWorktreeIds,
getMixedWorktreeHostContextLabels
} from './host-labels'
import { buildProjectGroupingIndex } from './project-grouping'
import type { ProjectGroupingModel } from './project-grouping'
import { appendProjectGroupSections } from './project-group-sections'
import { appendWorktreeRows, buildPendingCreationRow, emitPinnedGroup } from './row-builders'
import { getPinnedWorktreeDisplayPolicy } from './row-types'
import type {
ImportedWorktreesCardCandidate,
NewExternalWorktreesInboxCandidate,
PendingCreationRef,
PinnedWorktreeDisplayPolicy,
Row,
WorktreeGroupBy
} from './row-types'
import { getRenderedNaturalAnchorRepoIds, withRepoSectionDisplayLabels } from './section-order'
import { buildOrderedGroups } from './worktree-grouping'
export function buildRows(
groupBy: WorktreeGroupBy,
worktrees: Worktree[],
repoMap: Map<string, Repo>,
prCache: Record<string, unknown> | null,
collapsedGroups: Set<string>,
repoOrder?: Map<string, number>,
workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(),
projectOrderBy: ProjectOrderBy = 'manual',
lineageById: Record<string, WorktreeLineage> = {},
worktreeMap = new Map<string, Worktree>(worktrees.map((worktree) => [worktree.id, worktree])),
nestLineage = false,
settings?: AppState['settings'],
projectGroups: readonly ProjectGroup[] = [],
placeholderRepoIds: ReadonlySet<string> = new Set(),
importedWorktreesByRepo: ReadonlyMap<string, ImportedWorktreesCardCandidate> = new Map(),
newExternalWorktreesInboxByRepo: ReadonlyMap<
string,
NewExternalWorktreesInboxCandidate
> = new Map(),
pendingCreations: readonly PendingCreationRef[] = [],
projectGrouping?: ProjectGroupingModel,
folderWorkspaces: readonly FolderWorkspace[] = [],
hostLabelById?: ReadonlyMap<string, string>,
defaultHostId: ExecutionHostId = LOCAL_EXECUTION_HOST_ID,
pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy = getPinnedWorktreeDisplayPolicy(settings)
): Row[] {
const result: Row[] = []
const projectIndex = buildProjectGroupingIndex(projectGrouping)
const cyclicLineageIds = nestLineage
? getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)
: new Set<string>()
const pendingByRepo = new Map<string, PendingCreationRef[]>()
for (const creation of pendingCreations) {
const list = pendingByRepo.get(creation.repoId) ?? []
list.push(creation)
pendingByRepo.set(creation.repoId, list)
}
// Why: non-repo groupings have no repo section to nest an in-progress create
// under, so surface them at the very top (where the old global strip sat)
// rather than dropping them. Repo grouping nests them under their repo below.
if (groupBy !== 'repo' && pendingCreations.length > 0) {
for (const creation of pendingCreations) {
result.push(buildPendingCreationRow(creation, repoMap))
}
}
const naturalWorktrees =
pinnedDisplayPolicy === 'duplicate-in-groups'
? worktrees
: worktrees.filter((worktree) => !worktree.isPinned)
const mixedWorktreeHostContextLabels = getMixedWorktreeHostContextLabels(
naturalWorktrees,
repoMap,
hostLabelById,
defaultHostId
)
const renderedNaturalAnchorRepoIds = getRenderedNaturalAnchorRepoIds({
groupBy,
worktrees: naturalWorktrees,
repoMap,
prCache,
collapsedGroups,
workspaceStatuses,
settings,
projectGrouping
})
emitPinnedGroup(
worktrees,
repoMap,
defaultHostId,
collapsedGroups,
renderedNaturalAnchorRepoIds,
importedWorktreesByRepo,
groupBy !== 'repo',
result
)
if (groupBy === 'none') {
if (naturalWorktrees.length > 0) {
result.push({
type: 'header',
key: ALL_GROUP_KEY,
label: ALL_GROUP_META.label,
count: naturalWorktrees.length,
tone: ALL_GROUP_META.tone,
icon: ALL_GROUP_META.icon,
hostWorktreeCounts: getHostWorktreeCounts(naturalWorktrees, repoMap, defaultHostId),
hostWorktreeIds: getHostWorktreeIds(naturalWorktrees, repoMap, defaultHostId),
worktreeIds: naturalWorktrees.map((worktree) => worktree.id)
})
if (!collapsedGroups.has(ALL_GROUP_KEY)) {
appendWorktreeRows(result, naturalWorktrees, repoMap, lineageById, worktreeMap, {
nestLineage,
collapsedGroups,
groupDepth: 0,
sectionKey: ALL_GROUP_KEY,
hostContextLabelByWorktreeId: mixedWorktreeHostContextLabels,
cyclicLineageIds
})
}
}
return result
}
const orderedGroups = buildOrderedGroups({
groupBy,
naturalWorktrees,
repoMap,
prCache,
settings,
workspaceStatuses,
projectIndex,
placeholderRepoIds,
importedWorktreesByRepo,
newExternalWorktreesInboxByRepo,
pendingByRepo,
repoOrder,
projectOrderBy
})
const sectionContext: SectionAppendContext = {
result,
groupBy,
collapsedGroups,
workspaceStatuses,
repoMap,
defaultHostId,
hostLabelById,
projectIndex,
importedWorktreesByRepo,
newExternalWorktreesInboxByRepo,
pendingByRepo,
mixedWorktreeHostContextLabels,
lineageById,
worktreeMap,
nestLineage,
cyclicLineageIds
}
if (groupBy !== 'repo' || projectGroups.length === 0) {
appendOrderedGroups(
sectionContext,
groupBy === 'repo' ? withRepoSectionDisplayLabels(orderedGroups) : orderedGroups
)
return result
}
appendProjectGroupSections(sectionContext, {
orderedGroups,
projectGroups,
folderWorkspaces,
projectOrderBy,
repoOrder
})
return result
}
@@ -0,0 +1,156 @@
import { CircleX, FolderTree, List, Pin } from 'lucide-react'
import type React from 'react'
import type { Repo } from '../../../../../../shared/repo-types'
import type { Worktree } from '../../../../../../shared/worktree/types'
import { branchName } from '../../../../lib/git-utils'
import {
ConductorDoneIcon,
ConductorProgressIcon,
ConductorReviewIcon
} from '../../workspace-status-icons'
import { UNGROUPED_PROJECT_GROUP_KEY } from '../../../../../../shared/project-groups'
import type { AppState } from '../../../../store/types'
import {
getGitHubPRCacheKey,
getLegacyGitHubPRCacheKey
} from '../../../../store/slices/github-cache-key'
import { translate } from '@/i18n/i18n'
export type PRGroupKey = 'done' | 'in-review' | 'in-progress' | 'closed'
export const PR_GROUP_ORDER: PRGroupKey[] = ['done', 'in-review', 'in-progress', 'closed']
export const PR_GROUP_META: Record<
PRGroupKey,
{
label: string
icon: React.ComponentType<{ className?: string }>
tone: string
}
> = {
done: {
get label() {
return translate('auto.components.sidebar.worktree.list.groups.5076efc3d2', 'Done')
},
icon: ConductorDoneIcon,
tone: 'text-[#c7a594]'
},
'in-review': {
get label() {
return translate('auto.components.sidebar.worktree.list.groups.6798dc7c94', 'In review')
},
icon: ConductorReviewIcon,
tone: 'text-[#16a34a]'
},
'in-progress': {
get label() {
return translate('auto.components.sidebar.worktree.list.groups.7c2f009786', 'In progress')
},
icon: ConductorProgressIcon,
tone: 'text-[#d4a300]'
},
closed: {
get label() {
return translate('auto.components.sidebar.worktree.list.groups.682ed5d551', 'Closed')
},
icon: CircleX,
tone: 'text-zinc-600 dark:text-zinc-300'
}
}
export const PROJECT_GROUP_META = {
tone: 'text-foreground',
icon: FolderTree
} as const
export function getProjectGroupHeaderKey(groupId: string | null): string {
return groupId ? `project-group:${groupId}` : UNGROUPED_PROJECT_GROUP_KEY
}
export const PINNED_GROUP_KEY = 'pinned'
export const PINNED_GROUP_META = {
get label() {
return translate('auto.components.sidebar.worktree.list.groups.4aeefc5996', 'Pinned')
},
tone: 'text-foreground',
icon: Pin
} as const
export const ALL_GROUP_KEY = 'all'
export const ALL_GROUP_META = {
get label() {
return translate('auto.components.sidebar.worktree.list.groups.0ed04075b8', 'All')
},
tone: 'text-foreground',
icon: List
} as const
export const LINEAGE_GROUP_PREFIX = 'lineage:'
export function getLineageGroupKey(worktreeId: string): string {
return `${LINEAGE_GROUP_PREFIX}${worktreeId}`
}
export function getPRGroupKey(
worktree: Worktree,
repoMap: Map<string, Repo>,
prCache: Record<string, unknown> | null,
settings?: AppState['settings']
): PRGroupKey {
const repo = repoMap.get(worktree.repoId)
const branch = branchName(worktree.branch)
const repoScopedCacheKey =
repo && branch
? getGitHubPRCacheKey(
repo.path,
repo.id,
branch,
settings,
repo.connectionId,
repo.executionHostId,
true
)
: ''
const canUseLegacyPRCache = repo !== undefined && !repo.connectionId && !repo.executionHostId
const legacyRepoScopedCacheKey =
canUseLegacyPRCache && branch ? getLegacyGitHubPRCacheKey(repo.path, repo.id, branch) : ''
const legacyPathScopedCacheKey =
canUseLegacyPRCache && branch ? getLegacyGitHubPRCacheKey(repo.path, undefined, branch) : ''
// Why: PR refreshes now write repo-id scoped entries; legacy path entries may
// still exist from persisted cache, but must not override fresher repo data.
const prEntry = prCache
? ((repoScopedCacheKey
? (prCache[repoScopedCacheKey] as { data?: { state?: string } } | undefined)
: undefined) ??
(legacyRepoScopedCacheKey
? (prCache[legacyRepoScopedCacheKey] as { data?: { state?: string } } | undefined)
: undefined) ??
(legacyPathScopedCacheKey
? (prCache[legacyPathScopedCacheKey] as { data?: { state?: string } } | undefined)
: undefined))
: undefined
const pr = prEntry?.data
if (!pr) {
return 'in-progress'
}
if (pr.state === 'merged') {
return 'done'
}
if (pr.state === 'closed') {
return 'closed'
}
if (pr.state === 'draft') {
return 'in-progress'
}
return 'in-review'
}
/**
* Emit a "Pinned" header + its items into `result`.
*
* Why: the dedicated Pinned section is always present for pinned worktrees;
* the display policy decides whether their natural group rows also render.
*/
@@ -0,0 +1,172 @@
import type { Repo } from '../../../../../../shared/repo-types'
import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types'
import type { WorkspaceStatusDefinition, Worktree } from '../../../../../../shared/worktree/types'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import {
getWorkspaceStatusFromGroupKey,
getWorkspaceStatusVisualMeta
} from '../../workspace-status'
import { PROJECT_GROUP_META, PR_GROUP_META } from './group-keys'
import type { PRGroupKey } from './group-keys'
import { getHostWorktreeCounts, getHostWorktreeIds, getMixedHostContextLabels } from './host-labels'
import type { OrderedGroupEntry, ProjectGroupingIndex } from './project-grouping'
import {
appendWorktreeRows,
buildImportedWorktreesCardRow,
buildNewExternalWorktreesInboxRow,
buildPendingCreationRow
} from './row-builders'
import type {
ImportedWorktreesCardCandidate,
NewExternalWorktreesInboxCandidate,
PendingCreationRef,
Row,
WorktreeGroupBy
} from './row-types'
import { orderMainWorktreeFirst } from './section-order'
/** Everything section emission reads that stays fixed for one buildRows call. */
export type SectionAppendContext = {
result: Row[]
groupBy: WorktreeGroupBy
collapsedGroups: Set<string>
workspaceStatuses: readonly WorkspaceStatusDefinition[]
repoMap: Map<string, Repo>
defaultHostId: ExecutionHostId
hostLabelById: ReadonlyMap<string, string> | undefined
projectIndex: ProjectGroupingIndex | null
importedWorktreesByRepo: ReadonlyMap<string, ImportedWorktreesCardCandidate>
newExternalWorktreesInboxByRepo: ReadonlyMap<string, NewExternalWorktreesInboxCandidate>
pendingByRepo: ReadonlyMap<string, PendingCreationRef[]>
mixedWorktreeHostContextLabels: Map<string, string> | undefined
lineageById: Record<string, WorktreeLineage>
worktreeMap: Map<string, Worktree>
nestLineage: boolean
cyclicLineageIds: ReadonlySet<string>
}
export function appendOrderedGroups(
ctx: SectionAppendContext,
groupsToAppend: OrderedGroupEntry[],
projectGroupDepth = 0
): void {
const {
result,
groupBy,
collapsedGroups,
workspaceStatuses,
repoMap,
defaultHostId,
hostLabelById,
projectIndex,
importedWorktreesByRepo,
newExternalWorktreesInboxByRepo,
pendingByRepo,
mixedWorktreeHostContextLabels,
lineageById,
worktreeMap,
nestLineage,
cyclicLineageIds
} = ctx
for (const [key, group] of groupsToAppend) {
const isCollapsed = collapsedGroups.has(key)
const repo = group.repo
const header =
groupBy === 'repo'
? {
type: 'header' as const,
key,
label: group.label,
count: group.items.length,
tone: PROJECT_GROUP_META.tone,
icon: PROJECT_GROUP_META.icon,
repo,
projectGroupDepth
}
: groupBy === 'workspace-status'
? (() => {
const workspaceStatus =
getWorkspaceStatusFromGroupKey(key, workspaceStatuses) ??
workspaceStatuses[0]?.id ??
'in-progress'
const definition = workspaceStatuses.find((status) => status.id === workspaceStatus)
const meta = getWorkspaceStatusVisualMeta(definition ?? workspaceStatus)
return {
type: 'header' as const,
key,
label: definition?.label ?? workspaceStatus,
count: group.items.length,
tone: meta.tone,
icon: meta.icon,
hostWorktreeCounts: getHostWorktreeCounts(group.items, repoMap, defaultHostId),
hostWorktreeIds: getHostWorktreeIds(group.items, repoMap, defaultHostId),
worktreeIds: group.items.map((worktree) => worktree.id)
}
})()
: (() => {
const prGroup = key.replace(/^pr:/, '') as PRGroupKey
const meta = PR_GROUP_META[prGroup]
return {
type: 'header' as const,
key,
label: meta.label,
count: group.items.length,
tone: meta.tone,
icon: meta.icon,
hostWorktreeCounts: getHostWorktreeCounts(group.items, repoMap, defaultHostId),
hostWorktreeIds: getHostWorktreeIds(group.items, repoMap, defaultHostId),
worktreeIds: group.items.map((worktree) => worktree.id)
}
})()
result.push(header)
if (!isCollapsed) {
if (groupBy === 'repo') {
const repoIds =
group.repoIds.size > 0
? [...group.repoIds]
: repo
? [repo.id]
: key.startsWith('repo:')
? [key.slice('repo:'.length)]
: []
for (const repoId of repoIds) {
const candidate = importedWorktreesByRepo.get(repoId)
if (candidate) {
result.push(buildImportedWorktreesCardRow(candidate, 'repo-group'))
}
}
for (const repoId of repoIds) {
const candidate = newExternalWorktreesInboxByRepo.get(repoId)
if (candidate) {
result.push(buildNewExternalWorktreesInboxRow(candidate))
}
}
// Why: surface in-progress creates at the top of their own repo so the
// new workspace appears where it will land, not flashed to the very top
// of the sidebar.
for (const repoId of repoIds) {
for (const creation of pendingByRepo.get(repoId) ?? []) {
result.push(buildPendingCreationRow(creation, repoMap))
}
}
}
const items = groupBy === 'repo' ? orderMainWorktreeFirst(group.items) : group.items
const hostContextLabelByRepoId =
groupBy === 'repo'
? getMixedHostContextLabels(group, repoMap, projectIndex, hostLabelById)
: undefined
const hostContextLabelByWorktreeId =
groupBy === 'repo' ? undefined : mixedWorktreeHostContextLabels
appendWorktreeRows(result, items, repoMap, lineageById, worktreeMap, {
nestLineage,
collapsedGroups,
groupDepth: projectGroupDepth,
sectionKey: key,
hostContextLabelByRepoId,
hostContextLabelByWorktreeId,
cyclicLineageIds
})
}
}
}
@@ -0,0 +1,106 @@
import type { Repo } from '../../../../../../shared/repo-types'
import type { Worktree } from '../../../../../../shared/worktree/types'
import {
getExecutionHostLabel,
getRepoExecutionHostId,
getWorktreeExecutionHostId
} from '../../../../../../shared/execution-host'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import type { ProjectGroupingIndex, WorktreeGroupEntry } from './project-grouping'
function getRepoHostLabel(
repoId: string,
repoMap: Map<string, Repo>,
projectIndex: ProjectGroupingIndex | null,
hostLabelById: ReadonlyMap<string, string> | undefined
): string | null {
const setup = projectIndex?.setupByRepoId.get(repoId)
if (setup) {
return hostLabelById?.get(setup.hostId) ?? getExecutionHostLabel(setup.hostId)
}
const repo = repoMap.get(repoId)
if (!repo) {
return null
}
const hostId = getRepoExecutionHostId(repo)
return hostLabelById?.get(hostId) ?? getExecutionHostLabel(hostId)
}
export function getMixedHostContextLabels(
group: WorktreeGroupEntry,
repoMap: Map<string, Repo>,
projectIndex: ProjectGroupingIndex | null,
hostLabelById: ReadonlyMap<string, string> | undefined
): Map<string, string> | undefined {
const labelsByRepoId = new Map<string, string>()
const uniqueLabels = new Set<string>()
for (const repoId of group.repoIds) {
const label = getRepoHostLabel(repoId, repoMap, projectIndex, hostLabelById)
if (!label) {
continue
}
labelsByRepoId.set(repoId, label)
uniqueLabels.add(label)
}
return uniqueLabels.size > 1 ? labelsByRepoId : undefined
}
export function getMixedWorktreeHostContextLabels(
worktrees: readonly Worktree[],
repoMap: Map<string, Repo>,
hostLabelById: ReadonlyMap<string, string> | undefined,
defaultHostId: ExecutionHostId
): Map<string, string> | undefined {
const labelsByWorktreeId = new Map<string, string>()
const uniqueHostIds = new Set<ExecutionHostId>()
for (const worktree of worktrees) {
const hostId = getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId), defaultHostId)
uniqueHostIds.add(hostId)
labelsByWorktreeId.set(worktree.id, hostLabelById?.get(hostId) ?? getExecutionHostLabel(hostId))
}
return uniqueHostIds.size > 1 ? labelsByWorktreeId : undefined
}
export function getHostWorktreeCounts(
worktrees: readonly Worktree[],
repoMap: Map<string, Repo>,
defaultHostId: ExecutionHostId
): Map<ExecutionHostId, number> | undefined {
if (worktrees.length === 0) {
return undefined
}
const counts = new Map<ExecutionHostId, number>()
const seenWorktreeIds = new Set<string>()
for (const worktree of worktrees) {
if (seenWorktreeIds.has(worktree.id)) {
continue
}
seenWorktreeIds.add(worktree.id)
const hostId = getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId), defaultHostId)
counts.set(hostId, (counts.get(hostId) ?? 0) + 1)
}
return counts
}
export function getHostWorktreeIds(
worktrees: readonly Worktree[],
repoMap: Map<string, Repo>,
defaultHostId: ExecutionHostId
): Map<ExecutionHostId, string[]> | undefined {
if (worktrees.length === 0) {
return undefined
}
const idsByHost = new Map<ExecutionHostId, string[]>()
const seenWorktreeIds = new Set<string>()
for (const worktree of worktrees) {
if (seenWorktreeIds.has(worktree.id)) {
continue
}
seenWorktreeIds.add(worktree.id)
const hostId = getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId), defaultHostId)
const ids = idsByHost.get(hostId) ?? []
ids.push(worktree.id)
idsByHost.set(hostId, ids)
}
return idsByHost
}
@@ -1,11 +1,11 @@
import { describe, expect, it } from 'vitest'
import { canKeepImportedWorktreesHidden } from './notice-rows'
import { getRenderRowKey } from '../viewport/virtual-rows'
import { canKeepImportedWorktreesHidden } from '../rows/notice-rows'
import { getRenderRowKey } from '../listing/render-row'
import { getWorktreeDragGroups, getWorktreeDragIndexes } from '../drag/groups'
import { renderRowContainsWorktree } from './render-row-lookup'
import { renderRowContainsWorktree } from '../navigation/render-row-lookup'
import type { Repo } from '../../../../../../shared/repo-types'
import type { Worktree } from '../../../../../../shared/worktree/types'
import type { Row } from './groups'
import type { Row } from './row-types'
const repo: Repo = {
id: 'repo-1',
@@ -0,0 +1,146 @@
import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
import type { ProjectOrderBy } from '../../../../../../shared/ui-chrome-types'
import { getEffectiveProjectGroupManualRank } from '../../../../../../shared/project-groups'
import { PROJECT_GROUP_META, getProjectGroupHeaderKey } from './group-keys'
import { appendOrderedGroups } from './group-sections'
import type { SectionAppendContext } from './group-sections'
import type { OrderedGroupEntry } from './project-grouping'
import {
compareRecentRank,
recentRankForEntry,
withRepoSectionDisplayLabels
} from './section-order'
export function appendProjectGroupSections(
ctx: SectionAppendContext,
args: {
orderedGroups: OrderedGroupEntry[]
projectGroups: readonly ProjectGroup[]
folderWorkspaces: readonly FolderWorkspace[]
projectOrderBy: ProjectOrderBy
repoOrder: Map<string, number> | undefined
}
): void {
const { orderedGroups, projectGroups, folderWorkspaces, projectOrderBy, repoOrder } = args
const { result, collapsedGroups } = ctx
const groupByProjectGroupId = new Map<string | null, OrderedGroupEntry[]>()
for (const entry of orderedGroups) {
const repo = entry[1].repo
const projectGroupId = repo?.projectGroupId ?? null
const list = groupByProjectGroupId.get(projectGroupId) ?? []
list.push(entry)
groupByProjectGroupId.set(projectGroupId, list)
}
const sortRepoEntriesWithinGroup = (entries: OrderedGroupEntry[]): OrderedGroupEntry[] => {
if (projectOrderBy === 'recent') {
return [...entries].sort((left, right) =>
compareRecentRank(recentRankForEntry(left), recentRankForEntry(right))
)
}
// Manual: within a Project Group, projects order by their per-group rank
// (projectGroupOrder), falling back to global repoOrder when unset so drag
// midpoint commits and the rendered order stay aligned.
return [...entries].sort((left, right) => {
const leftRank = getEffectiveProjectGroupManualRank(left[1].repo, repoOrder)
const rightRank = getEffectiveProjectGroupManualRank(right[1].repo, repoOrder)
return leftRank - rightRank
})
}
const projectGroupsById = new Map(projectGroups.map((group) => [group.id, group]))
const folderWorkspacesByProjectGroupId = new Map<string, FolderWorkspace[]>()
for (const workspace of folderWorkspaces) {
const group = projectGroupsById.get(workspace.projectGroupId)
if (!group?.parentPath) {
continue
}
const list = folderWorkspacesByProjectGroupId.get(workspace.projectGroupId) ?? []
list.push(workspace)
folderWorkspacesByProjectGroupId.set(workspace.projectGroupId, list)
}
for (const list of folderWorkspacesByProjectGroupId.values()) {
list.sort((left, right) => {
const leftOrder = left.manualOrder ?? left.sortOrder
const rightOrder = right.manualOrder ?? right.sortOrder
return rightOrder - leftOrder || left.name.localeCompare(right.name)
})
}
const childGroupsByParentId = new Map<string | null, ProjectGroup[]>()
for (const group of projectGroups) {
const parentId =
group.parentGroupId && projectGroupsById.has(group.parentGroupId) ? group.parentGroupId : null
const children = childGroupsByParentId.get(parentId) ?? []
children.push(group)
childGroupsByParentId.set(parentId, children)
}
for (const groups of childGroupsByParentId.values()) {
groups.sort(
(left, right) => left.tabOrder - right.tabOrder || left.name.localeCompare(right.name)
)
}
const getProjectGroupSubtreeCount = (groupId: string): number => {
const directCount = groupByProjectGroupId.get(groupId)?.length ?? 0
const folderWorkspaceCount = folderWorkspacesByProjectGroupId.get(groupId)?.length ?? 0
const children = childGroupsByParentId.get(groupId) ?? []
return children.reduce(
(count, child) => count + getProjectGroupSubtreeCount(child.id),
directCount + folderWorkspaceCount
)
}
const appendProjectGroup = (projectGroup: ProjectGroup, depth: number): void => {
const repoEntries = sortRepoEntriesWithinGroup(groupByProjectGroupId.get(projectGroup.id) ?? [])
const childGroups = childGroupsByParentId.get(projectGroup.id) ?? []
const key = getProjectGroupHeaderKey(projectGroup.id)
result.push({
type: 'header',
key,
label: projectGroup.name,
count: getProjectGroupSubtreeCount(projectGroup.id),
tone: PROJECT_GROUP_META.tone,
icon: PROJECT_GROUP_META.icon,
projectGroup,
projectGroupDepth: depth
})
if (!collapsedGroups.has(key)) {
for (const folderWorkspace of folderWorkspacesByProjectGroupId.get(projectGroup.id) ?? []) {
result.push({
type: 'folder-workspace',
key: `folder-workspace:${folderWorkspace.id}`,
folderWorkspace,
projectGroup,
depth: 0,
groupDepth: depth + 1
})
}
appendOrderedGroups(ctx, withRepoSectionDisplayLabels(repoEntries), depth + 1)
for (const childGroup of childGroups) {
appendProjectGroup(childGroup, depth + 1)
}
}
groupByProjectGroupId.delete(projectGroup.id)
}
for (const projectGroup of childGroupsByParentId.get(null) ?? []) {
appendProjectGroup(projectGroup, 0)
}
const remainingRepoEntries = [...(groupByProjectGroupId.get(null) ?? [])]
for (const [projectGroupId, entries] of groupByProjectGroupId) {
if (projectGroupId === null || projectGroupsById.has(projectGroupId)) {
continue
}
// Why: startup can have repos from hosts whose project-group metadata was
// not fetched yet; missing metadata must not make those repos disappear.
remainingRepoEntries.push(...entries)
}
appendOrderedGroups(
ctx,
withRepoSectionDisplayLabels(sortRepoEntriesWithinGroup(remainingRepoEntries)),
0
)
}
@@ -0,0 +1,165 @@
import type { Project, ProjectHostSetup } from '../../../../../../shared/project-types'
import type { Repo } from '../../../../../../shared/repo-types'
import type { Worktree } from '../../../../../../shared/worktree/types'
import { toSshExecutionHostId } from '../../../../../../shared/execution-host'
import { parseWslUncPath } from '../../../../../../shared/wsl-paths'
import {
isWindowsAbsolutePathLike,
normalizeRuntimePathForComparison
} from '../../../../../../shared/cross-platform-path'
export type OrderedGroupEntry = [string, WorktreeGroupEntry]
export type ProjectGroupingModel = {
projects: readonly Project[]
projectHostSetups: readonly ProjectHostSetup[]
}
export type WorktreeGroupEntry = {
label: string
items: Worktree[]
repo?: Repo
repoIds: Set<string>
}
export type ProjectGroupingIndex = {
projectById: Map<string, Project>
setupByRepoId: Map<string, ProjectHostSetup>
surfaceKeysRequiringSetupGroups: Set<string>
}
const projectGroupingIndexCache = new WeakMap<ProjectGroupingModel, ProjectGroupingIndex | null>()
// Why: provisioned and folder setups are not independent Git checkouts.
function isDistinctUserCheckout(setup: ProjectHostSetup): boolean {
return setup.setupMethod !== 'provisioned' && setup.kind !== 'folder'
}
// Why: execution target and filesystem namespace independently identify a surface.
function getProjectSetupSurfaceKey(setup: ProjectHostSetup): string {
return `${setup.projectId}::${setup.hostId}::${getExecutionSurface(setup)}::${getPathSurface(setup)}`
}
function getExecutionSurface(setup: ProjectHostSetup): string {
const connectionId = setup.connectionId?.trim()
if (connectionId) {
return toSshExecutionHostId(connectionId)
}
return setup.executionHostId?.trim() || setup.hostId
}
// Why: projection twins differ by row identity, not checkout directory.
function getCheckoutIdentity(setup: ProjectHostSetup): string {
return normalizeRuntimePathForComparison(setup.path.trim()) || setup.repoId || setup.id
}
function getPathSurface(setup: ProjectHostSetup): string {
const wslPath = parseWslUncPath(setup.path)
if (wslPath) {
return `wsl:${wslPath.distro.toLowerCase()}`
}
if (isWindowsAbsolutePathLike(setup.path)) {
return 'windows-host'
}
return 'default'
}
export function buildProjectGroupingIndex(
model?: ProjectGroupingModel
): ProjectGroupingIndex | null {
if (!model) {
return null
}
const cached = projectGroupingIndexCache.get(model)
if (cached !== undefined) {
return cached
}
const projects = model.projects ?? []
const projectHostSetups = model.projectHostSetups ?? []
if (projects.length === 0 || projectHostSetups.length === 0) {
projectGroupingIndexCache.set(model, null)
return null
}
const checkoutsByProjectSurface = new Map<string, Set<string>>()
for (const setup of projectHostSetups) {
if (!isDistinctUserCheckout(setup)) {
continue
}
const key = getProjectSetupSurfaceKey(setup)
const existing = checkoutsByProjectSurface.get(key)
if (existing) {
existing.add(getCheckoutIdentity(setup))
} else {
checkoutsByProjectSurface.set(key, new Set([getCheckoutIdentity(setup)]))
}
}
const surfaceKeysRequiringSetupGroups = new Set<string>()
for (const [surfaceKey, checkouts] of checkoutsByProjectSurface) {
if (checkouts.size > 1) {
surfaceKeysRequiringSetupGroups.add(surfaceKey)
}
}
const index = {
projectById: new Map(projects.map((project) => [project.id, project])),
setupByRepoId: new Map(projectHostSetups.map((setup) => [setup.repoId, setup])),
surfaceKeysRequiringSetupGroups
}
projectGroupingIndexCache.set(model, index)
return index
}
export type ProjectHeaderRevealTarget = {
key: string
label: string
repo?: Repo
projectId?: string
}
export function getProjectGroupingForRepo(
repoId: string,
repoMap: Map<string, Repo>,
projectIndex: ProjectGroupingIndex | null
): ProjectHeaderRevealTarget {
const repo = repoMap.get(repoId)
const setup = projectIndex?.setupByRepoId.get(repoId)
const project = setup ? projectIndex?.projectById.get(setup.projectId) : undefined
if (!setup || !project) {
return {
key: `repo:${repoId}`,
label: repo?.displayName ?? 'Unknown',
repo
}
}
if (
projectIndex?.surfaceKeysRequiringSetupGroups.has(getProjectSetupSurfaceKey(setup)) &&
isDistinctUserCheckout(setup)
) {
// Why: only the ambiguous surface needs checkout-specific headers.
return {
key: `project:${project.id}::setup:${repoId}`,
label: repo?.displayName ?? setup.displayName,
repo,
projectId: project.id
}
}
// Why: provisioned runtime copies and non-ambiguous checkouts follow project
// identity rather than path-scoped setup identity, so they stay in one project.
return {
key: `project:${project.id}`,
label: project.displayName,
repo,
projectId: project.id
}
}
export function getProjectHeaderRevealTarget(
repoId: string,
repoMap: Map<string, Repo>,
projectGrouping?: ProjectGroupingModel
): ProjectHeaderRevealTarget {
return getProjectGroupingForRepo(repoId, repoMap, buildProjectGroupingIndex(projectGrouping))
}
export function addRepoIdToGroup(group: WorktreeGroupEntry, repoId: string): void {
group.repoIds.add(repoId)
}
@@ -0,0 +1,279 @@
import type { Repo } from '../../../../../../shared/repo-types'
import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types'
import type { Worktree } from '../../../../../../shared/worktree/types'
import { getWorktreeExecutionHostId } from '../../../../../../shared/execution-host'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import { getLineageRenderInfo } from '../../worktree-lineage-projection'
import { PINNED_GROUP_KEY, PINNED_GROUP_META, getLineageGroupKey } from './group-keys'
import type {
ImportedWorktreesCardCandidate,
ImportedWorktreesCardRow,
NewExternalWorktreesInboxCandidate,
NewExternalWorktreesInboxRow,
PendingCreationRef,
PendingCreationRow,
Row,
WorktreeRow
} from './row-types'
export function buildPendingCreationRow(
creation: PendingCreationRef,
repoMap: Map<string, Repo>
): PendingCreationRow {
return {
type: 'pending-creation',
key: `pending:${creation.creationId}`,
creationId: creation.creationId,
repo: repoMap.get(creation.repoId)
}
}
export function emitPinnedGroup(
worktrees: Worktree[],
repoMap: Map<string, Repo>,
defaultHostId: ExecutionHostId,
collapsedGroups: Set<string>,
renderedNaturalAnchorRepoIds: ReadonlySet<string>,
importedWorktreesByRepo: ReadonlyMap<string, ImportedWorktreesCardCandidate>,
allowImportedFallback: boolean,
result: Row[]
): void {
const pinned = worktrees.filter((w) => w.isPinned)
if (pinned.length === 0) {
return
}
const hostWorktreeCounts = new Map<ExecutionHostId, number>()
const hostWorktreeIds = new Map<ExecutionHostId, string[]>()
const pinnedRepoOrder: string[] = []
const seenPinnedRepoIds = new Set<string>()
for (const worktree of pinned) {
const hostId = getWorktreeExecutionHostId(worktree, repoMap.get(worktree.repoId), defaultHostId)
hostWorktreeCounts.set(hostId, (hostWorktreeCounts.get(hostId) ?? 0) + 1)
const hostIds = hostWorktreeIds.get(hostId) ?? []
hostIds.push(worktree.id)
hostWorktreeIds.set(hostId, hostIds)
if (!seenPinnedRepoIds.has(worktree.repoId)) {
pinnedRepoOrder.push(worktree.repoId)
seenPinnedRepoIds.add(worktree.repoId)
}
}
result.push({
type: 'header',
key: PINNED_GROUP_KEY,
label: PINNED_GROUP_META.label,
count: pinned.length,
tone: PINNED_GROUP_META.tone,
icon: PINNED_GROUP_META.icon,
hostWorktreeCounts,
hostWorktreeIds,
worktreeIds: pinned.map((worktree) => worktree.id)
})
if (collapsedGroups.has(PINNED_GROUP_KEY)) {
for (const repoId of pinnedRepoOrder) {
const candidate = importedWorktreesByRepo.get(repoId)
if (allowImportedFallback && candidate && !renderedNaturalAnchorRepoIds.has(repoId)) {
result.push(buildImportedWorktreesCardRow(candidate, 'pinned-fallback'))
}
}
} else {
const lastPinnedIndexByRepoId = new Map<string, number>()
pinned.forEach((worktree, index) => lastPinnedIndexByRepoId.set(worktree.repoId, index))
for (const [index, worktree] of pinned.entries()) {
result.push(
buildWorktreeRow(worktree, repoMap, {
rowKey: `${PINNED_GROUP_KEY}:${worktree.id}`,
sectionKey: PINNED_GROUP_KEY,
depth: 0,
groupDepth: 0,
lineageTrail: [],
isLastLineageChild: false,
lineageChildCount: 0,
lineageCollapsed: false
})
)
const candidate = importedWorktreesByRepo.get(worktree.repoId)
if (
allowImportedFallback &&
candidate &&
!renderedNaturalAnchorRepoIds.has(worktree.repoId) &&
lastPinnedIndexByRepoId.get(worktree.repoId) === index
) {
result.push(buildImportedWorktreesCardRow(candidate, 'pinned-fallback'))
}
}
}
}
export function buildImportedWorktreesCardRow(
candidate: ImportedWorktreesCardCandidate,
placement: ImportedWorktreesCardRow['placement']
): ImportedWorktreesCardRow {
return {
type: 'imported-worktrees-card',
key: `imported-worktrees-card:${placement}:${candidate.repo.id}`,
repo: candidate.repo,
hiddenWorktrees: candidate.hiddenWorktrees,
placement
}
}
export function buildNewExternalWorktreesInboxRow(
candidate: NewExternalWorktreesInboxCandidate
): NewExternalWorktreesInboxRow {
return {
type: 'new-external-worktrees-inbox',
key: `new-external-worktrees-inbox:${candidate.repo.id}`,
repo: candidate.repo,
inboxWorktrees: candidate.inboxWorktrees
}
}
function buildWorktreeRow(
worktree: Worktree,
repoMap: Map<string, Repo>,
options: {
rowKey: string
sectionKey: string
depth: number
groupDepth: number
lineageTrail: boolean[]
isLastLineageChild: boolean
lineageChildCount: number
lineageCollapsed: boolean
hostContextLabel?: string
}
): WorktreeRow {
return {
type: 'item',
rowKey: options.rowKey,
sectionKey: options.sectionKey,
worktree,
repo: repoMap.get(worktree.repoId),
depth: options.depth,
groupDepth: options.groupDepth,
lineageTrail: options.lineageTrail,
isLastLineageChild: options.isLastLineageChild,
lineageChildCount: options.lineageChildCount,
...(options.hostContextLabel ? { hostContextLabel: options.hostContextLabel } : {}),
...(options.lineageChildCount > 0 ? { lineageGroupKey: getLineageGroupKey(worktree.id) } : {}),
...(options.lineageChildCount > 0 ? { lineageCollapsed: options.lineageCollapsed } : {})
}
}
export function appendWorktreeRows(
result: Row[],
worktrees: Worktree[],
repoMap: Map<string, Repo>,
lineageById: Record<string, WorktreeLineage>,
worktreeMap: Map<string, Worktree>,
options: {
nestLineage: boolean
collapsedGroups: Set<string>
groupDepth: number
sectionKey: string
hostContextLabelByRepoId?: ReadonlyMap<string, string>
hostContextLabelByWorktreeId?: ReadonlyMap<string, string>
cyclicLineageIds: ReadonlySet<string>
}
): void {
const {
nestLineage,
collapsedGroups,
groupDepth,
sectionKey,
hostContextLabelByRepoId,
hostContextLabelByWorktreeId,
cyclicLineageIds
} = options
if (!nestLineage) {
for (const worktree of worktrees) {
result.push(
buildWorktreeRow(worktree, repoMap, {
rowKey: `${sectionKey}:${worktree.id}`,
sectionKey,
depth: 0,
groupDepth,
lineageTrail: [],
isLastLineageChild: false,
lineageChildCount: 0,
lineageCollapsed: false,
hostContextLabel:
hostContextLabelByWorktreeId?.get(worktree.id) ??
hostContextLabelByRepoId?.get(worktree.repoId)
})
)
}
return
}
const visibleIds = new Set(worktrees.map((worktree) => worktree.id))
const childrenByParentId = new Map<string, Worktree[]>()
const childIds = new Set<string>()
for (const worktree of worktrees) {
const lineage = getLineageRenderInfo(worktree, lineageById, worktreeMap, cyclicLineageIds)
if (lineage.state !== 'valid' || !visibleIds.has(lineage.parent.id)) {
continue
}
childIds.add(worktree.id)
const children = childrenByParentId.get(lineage.parent.id) ?? []
children.push(worktree)
childrenByParentId.set(lineage.parent.id, children)
}
const emitted = new Set<string>()
const emit = (
worktree: Worktree,
depth: number,
lineageTrail: boolean[],
isLastChild: boolean
): void => {
if (emitted.has(worktree.id)) {
return
}
const children = childrenByParentId.get(worktree.id) ?? []
const lineageGroupKey = getLineageGroupKey(worktree.id)
const lineageCollapsed = collapsedGroups.has(lineageGroupKey)
emitted.add(worktree.id)
result.push(
buildWorktreeRow(worktree, repoMap, {
rowKey: `${sectionKey}:${worktree.id}`,
sectionKey,
depth,
groupDepth,
lineageTrail,
isLastLineageChild: isLastChild,
lineageChildCount: children.length,
lineageCollapsed,
hostContextLabel:
hostContextLabelByWorktreeId?.get(worktree.id) ??
hostContextLabelByRepoId?.get(worktree.repoId)
})
)
if (lineageCollapsed) {
return
}
children.forEach((child, index) => {
emit(
child,
depth + 1,
[...lineageTrail, index < children.length - 1],
index === children.length - 1
)
})
}
const roots = worktrees.filter((worktree) => !childIds.has(worktree.id))
for (const [index, worktree] of roots.entries()) {
emit(worktree, 0, [], index === roots.length - 1)
}
if (roots.length === 0) {
for (const worktree of worktrees) {
if (!emitted.has(worktree.id)) {
// Why: malformed cyclic lineage should not hide every participant.
// Render any leftovers as roots rather than recursing forever.
emit(worktree, 0, [], true)
}
}
}
}
@@ -0,0 +1,102 @@
import type React from 'react'
import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
import type { Repo } from '../../../../../../shared/repo-types'
import type { DetectedWorktree, Worktree } from '../../../../../../shared/worktree/types'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
export type WorktreeGroupBy = 'none' | 'workspace-status' | 'repo' | 'pr-status'
export type PinnedWorktreeDisplayPolicy = 'single-location' | 'duplicate-in-groups'
export function getPinnedWorktreeDisplayPolicy(
settings?: { showPinnedWorktreesInGroups?: boolean } | null
): PinnedWorktreeDisplayPolicy {
return settings?.showPinnedWorktreesInGroups === true ? 'duplicate-in-groups' : 'single-location'
}
export type GroupHeaderRow = {
type: 'header'
key: string
label: string
count: number
tone: string
icon?: React.ComponentType<{ className?: string }>
repo?: Repo
projectGroup?: ProjectGroup | { id: null; name: 'Ungrouped'; tabOrder: number }
projectGroupDepth?: number
hostId?: ExecutionHostId
hostWorktreeCounts?: ReadonlyMap<ExecutionHostId, number>
hostWorktreeIds?: ReadonlyMap<ExecutionHostId, readonly string[]>
worktreeIds?: readonly string[]
}
export type WorktreeRow = {
type: 'item'
rowKey: string
sectionKey: string
worktree: Worktree
repo: Repo | undefined
depth: number
groupDepth: number
lineageTrail: boolean[]
isLastLineageChild: boolean
lineageChildCount: number
lineageGroupKey?: string
lineageCollapsed?: boolean
hostContextLabel?: string
}
export type ImportedWorktreesCardCandidate = {
repo: Repo
hiddenWorktrees: DetectedWorktree[]
}
export type ImportedWorktreesCardRow = {
type: 'imported-worktrees-card'
key: string
repo: Repo
hiddenWorktrees: DetectedWorktree[]
placement: 'repo-group' | 'pinned-fallback'
}
export type NewExternalWorktreesInboxCandidate = {
repo: Repo
inboxWorktrees: DetectedWorktree[]
}
export type NewExternalWorktreesInboxRow = {
type: 'new-external-worktrees-inbox'
key: string
repo: Repo
inboxWorktrees: DetectedWorktree[]
}
export type PendingCreationRow = {
type: 'pending-creation'
key: string
creationId: string
repo: Repo | undefined
}
export type FolderWorkspaceRow = {
type: 'folder-workspace'
key: string
folderWorkspace: FolderWorkspace
projectGroup: ProjectGroup
depth: number
groupDepth: number
}
/** Minimal shape buildRows needs for an in-flight create. Deliberately not the
* full PendingWorktreeCreation: row identity depends only on which creates
* exist and their repo, so callers can subscribe on this stable shape and keep
* progress-field churn (phase/loaderVisible) from rebuilding the whole list. */
export type PendingCreationRef = { creationId: string; repoId: string }
export type Row =
| GroupHeaderRow
| WorktreeRow
| ImportedWorktreesCardRow
| NewExternalWorktreesInboxRow
| PendingCreationRow
| FolderWorkspaceRow
@@ -0,0 +1,209 @@
import type { Repo } from '../../../../../../shared/repo-types'
import type { ProjectOrderBy } from '../../../../../../shared/ui-chrome-types'
import type { WorkspaceStatusDefinition, Worktree } from '../../../../../../shared/worktree/types'
import type { AppState } from '../../../../store/types'
import { getRepoDisplayLabelKey, getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels'
import { ALL_GROUP_KEY } from './group-keys'
import type {
OrderedGroupEntry,
ProjectGroupingModel,
WorktreeGroupEntry
} from './project-grouping'
import type { WorktreeGroupBy } from './row-types'
import { getGroupKeyForWorktree } from './worktree-group-keys'
export function getRenderedNaturalAnchorRepoIds({
groupBy,
worktrees,
repoMap,
prCache,
collapsedGroups,
workspaceStatuses,
settings,
projectGrouping
}: {
groupBy: WorktreeGroupBy
worktrees: readonly Worktree[]
repoMap: Map<string, Repo>
prCache: Record<string, unknown> | null
collapsedGroups: ReadonlySet<string>
workspaceStatuses: readonly WorkspaceStatusDefinition[]
settings?: AppState['settings']
projectGrouping?: ProjectGroupingModel
}): Set<string> {
const renderedRepoIds = new Set<string>()
if (groupBy === 'none') {
if (!collapsedGroups.has(ALL_GROUP_KEY)) {
for (const worktree of worktrees) {
renderedRepoIds.add(worktree.repoId)
}
}
return renderedRepoIds
}
if (groupBy === 'repo') {
for (const worktree of worktrees) {
renderedRepoIds.add(worktree.repoId)
}
return renderedRepoIds
}
for (const worktree of worktrees) {
const groupKey = getGroupKeyForWorktree(
groupBy,
worktree,
repoMap,
prCache,
workspaceStatuses,
settings,
projectGrouping
)
if (groupKey && !collapsedGroups.has(groupKey)) {
renderedRepoIds.add(worktree.repoId)
}
}
return renderedRepoIds
}
export function orderMainWorktreeFirst(worktrees: Worktree[]): Worktree[] {
const mainWorktrees = worktrees.filter((worktree) => worktree.isMainWorktree)
if (mainWorktrees.length === 0) {
return worktrees
}
// Why: project groups are scanned by repo; keep the repo's canonical
// workspace anchored even when dynamic sorts rank a child workspace first.
return [...mainWorktrees, ...worktrees.filter((worktree) => !worktree.isMainWorktree)]
}
export function withRepoSectionDisplayLabels(
entries: readonly OrderedGroupEntry[]
): OrderedGroupEntry[] {
const repos = entries
.map((entry) => entry[1].repo)
.filter((repo): repo is Repo => repo !== undefined)
if (repos.length < 2) {
return [...entries]
}
const labelsByPath = getRepoDisplayLabelsByPath(repos)
return entries.map(([key, group]) => [
key,
group.repo
? { ...group, label: labelsByPath.get(getRepoDisplayLabelKey(group.repo)) ?? group.label }
: group
])
}
/**
* Recent rank for a project header. `hasActivity` projects (at least one
* visible worktree) always sort before fallback projects, regardless of the
* numeric values — a placeholder's `addedAt` must never outrank real activity.
* Within each tier, higher timestamps come first.
*/
type RecentRank = { hasActivity: boolean; ts: number }
export function recentRankForEntry(entry: OrderedGroupEntry): RecentRank {
let max = Number.NEGATIVE_INFINITY
for (const worktree of entry[1].items) {
if (worktree.lastActivityAt > max) {
max = worktree.lastActivityAt
}
}
if (max !== Number.NEGATIVE_INFINITY) {
// Why: Recent must be timestamp-based, not encounter order — the incoming
// array is no longer pre-sorted by recency once decoupled from sortBy.
return { hasActivity: true, ts: max }
}
const addedAt = entry[1].repo?.addedAt
return {
hasActivity: false,
ts: typeof addedAt === 'number' ? addedAt : Number.NEGATIVE_INFINITY
}
}
export function compareRecentRank(a: RecentRank, b: RecentRank): number {
if (a.hasActivity !== b.hasActivity) {
return a.hasActivity ? -1 : 1
}
return b.ts - a.ts
}
function manualRankForEntry(
entry: OrderedGroupEntry,
repoOrder: Map<string, number> | undefined
): number {
const key = entry[0]
const repoIds =
entry[1].repoIds.size > 0
? [...entry[1].repoIds]
: [key.startsWith('repo:') ? key.slice('repo:'.length) : key]
let rank = Number.POSITIVE_INFINITY
for (const repoId of repoIds) {
const repoRank = repoOrder?.get(repoId)
if (repoRank !== undefined && repoRank < rank) {
rank = repoRank
}
}
return rank
}
export function getManualOrderAnchorRepo(
group: WorktreeGroupEntry,
repoMap: Map<string, Repo>,
repoOrder: Map<string, number> | undefined
): Repo | undefined {
let anchor = group.repo
let anchorRank = anchor ? (repoOrder?.get(anchor.id) ?? Number.POSITIVE_INFINITY) : undefined
for (const repoId of group.repoIds) {
const repo = repoMap.get(repoId)
if (!repo) {
continue
}
const rank = repoOrder?.get(repoId) ?? Number.POSITIVE_INFINITY
if (!anchor || rank < (anchorRank ?? Number.POSITIVE_INFINITY)) {
anchor = repo
anchorRank = rank
}
}
return anchor
}
/**
* Order project header entries by the user's project-order preference. Manual
* follows the canonical repoOrder; Recent follows each project's most recent
* visible workspace activity (descending), with empty/imported-only projects
* sorting after active ones, then by manual rank, then label.
*/
export function sortProjectEntries(
entries: OrderedGroupEntry[],
projectOrderBy: ProjectOrderBy,
repoOrder: Map<string, number> | undefined
): OrderedGroupEntry[] {
if (projectOrderBy === 'recent') {
return [...entries].sort((a, b) => {
const byRecent = compareRecentRank(recentRankForEntry(a), recentRankForEntry(b))
if (byRecent !== 0) {
return byRecent
}
const ma = manualRankForEntry(a, repoOrder)
const mb = manualRankForEntry(b, repoOrder)
if (ma !== mb) {
return ma - mb
}
return a[1].label.localeCompare(b[1].label)
})
}
if (!repoOrder) {
return entries
}
return [...entries].sort((a, b) => {
const ra = manualRankForEntry(a, repoOrder)
const rb = manualRankForEntry(b, repoOrder)
if (ra !== rb) {
return ra - rb
}
return a[1].label.localeCompare(b[1].label)
})
}
/**
* Build the flat row list consumed by the virtualizer.
* Extracted here to keep WorktreeList.tsx under the line-count lint limit.
*/
@@ -0,0 +1,80 @@
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
import type { Repo } from '../../../../../../shared/repo-types'
import type { WorkspaceStatusDefinition, Worktree } from '../../../../../../shared/worktree/types'
import { getWorkspaceStatus, getWorkspaceStatusGroupKey } from '../../workspace-status'
import { cloneDefaultWorkspaceStatuses } from '../../../../../../shared/workspace-statuses'
import type { AppState } from '../../../../store/types'
import { ALL_GROUP_KEY, getPRGroupKey, getProjectGroupHeaderKey } from './group-keys'
import { buildProjectGroupingIndex, getProjectGroupingForRepo } from './project-grouping'
import type { ProjectGroupingModel } from './project-grouping'
import type { WorktreeGroupBy } from './row-types'
export function getGroupKeyForWorktree(
groupBy: WorktreeGroupBy,
worktree: Worktree,
repoMap: Map<string, Repo>,
prCache: Record<string, unknown> | null,
workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(),
settings?: AppState['settings'],
projectGrouping?: ProjectGroupingModel
): string | null {
if (groupBy === 'none') {
return ALL_GROUP_KEY
}
if (groupBy === 'workspace-status') {
return getWorkspaceStatusGroupKey(getWorkspaceStatus(worktree, workspaceStatuses))
}
if (groupBy === 'repo') {
return getProjectGroupingForRepo(
worktree.repoId,
repoMap,
buildProjectGroupingIndex(projectGrouping)
).key
}
return `pr:${getPRGroupKey(worktree, repoMap, prCache, settings)}`
}
export function getGroupKeysForWorktree(
groupBy: WorktreeGroupBy,
worktree: Worktree,
repoMap: Map<string, Repo>,
prCache: Record<string, unknown> | null,
workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(),
settings?: AppState['settings'],
projectGroups: readonly ProjectGroup[] = [],
projectGrouping?: ProjectGroupingModel
): string[] {
const groupKey = getGroupKeyForWorktree(
groupBy,
worktree,
repoMap,
prCache,
workspaceStatuses,
settings,
projectGrouping
)
if (!groupKey) {
return []
}
if (groupBy !== 'repo') {
return [groupKey]
}
const repo = repoMap.get(worktree.repoId)
const groupIds: string[] = []
const groupsById = new Map(projectGroups.map((group) => [group.id, group]))
const visited = new Set<string>()
let currentGroupId = repo?.projectGroupId ?? null
while (currentGroupId && !visited.has(currentGroupId)) {
const group = groupsById.get(currentGroupId)
if (!group) {
// Why: repos can arrive before their remote Project Group metadata; reveal
// keys must match the top-level fallback rows buildRows actually renders.
break
}
visited.add(currentGroupId)
groupIds.unshift(currentGroupId)
const parentId = group.parentGroupId ?? null
currentGroupId = parentId && groupsById.has(parentId) ? parentId : null
}
return [...groupIds.map((id) => getProjectGroupHeaderKey(id)), groupKey]
}
@@ -0,0 +1,192 @@
import type { Repo } from '../../../../../../shared/repo-types'
import type { ProjectOrderBy } from '../../../../../../shared/ui-chrome-types'
import type { WorkspaceStatusDefinition, Worktree } from '../../../../../../shared/worktree/types'
import type { AppState } from '../../../../store/types'
import { getWorkspaceStatus, getWorkspaceStatusGroupKey } from '../../workspace-status'
import { PR_GROUP_META, PR_GROUP_ORDER, getPRGroupKey } from './group-keys'
import { addRepoIdToGroup, getProjectGroupingForRepo } from './project-grouping'
import type {
OrderedGroupEntry,
ProjectGroupingIndex,
WorktreeGroupEntry
} from './project-grouping'
import type {
ImportedWorktreesCardCandidate,
NewExternalWorktreesInboxCandidate,
PendingCreationRef,
WorktreeGroupBy
} from './row-types'
import { getManualOrderAnchorRepo, sortProjectEntries } from './section-order'
/** Buckets worktrees into their sections and returns them in render order. */
export function buildOrderedGroups(args: {
groupBy: WorktreeGroupBy
naturalWorktrees: readonly Worktree[]
repoMap: Map<string, Repo>
prCache: Record<string, unknown> | null
settings: AppState['settings'] | undefined
workspaceStatuses: readonly WorkspaceStatusDefinition[]
projectIndex: ProjectGroupingIndex | null
placeholderRepoIds: ReadonlySet<string>
importedWorktreesByRepo: ReadonlyMap<string, ImportedWorktreesCardCandidate>
newExternalWorktreesInboxByRepo: ReadonlyMap<string, NewExternalWorktreesInboxCandidate>
pendingByRepo: ReadonlyMap<string, PendingCreationRef[]>
repoOrder: Map<string, number> | undefined
projectOrderBy: ProjectOrderBy
}): OrderedGroupEntry[] {
const {
groupBy,
naturalWorktrees,
repoMap,
prCache,
settings,
workspaceStatuses,
projectIndex,
placeholderRepoIds,
importedWorktreesByRepo,
newExternalWorktreesInboxByRepo,
pendingByRepo,
repoOrder,
projectOrderBy
} = args
const grouped = new Map<string, WorktreeGroupEntry>()
for (const w of naturalWorktrees) {
let key: string
let label: string
let repo: Repo | undefined
if (groupBy === 'repo') {
const grouping = getProjectGroupingForRepo(w.repoId, repoMap, projectIndex)
key = grouping.key
label = grouping.label
repo = grouping.repo
} else if (groupBy === 'workspace-status') {
const workspaceStatus = getWorkspaceStatus(w, workspaceStatuses)
key = getWorkspaceStatusGroupKey(workspaceStatus)
label =
workspaceStatuses.find((status) => status.id === workspaceStatus)?.label ?? workspaceStatus
} else {
const prGroup = getPRGroupKey(w, repoMap, prCache, settings)
key = `pr:${prGroup}`
label = PR_GROUP_META[prGroup].label
}
if (!grouped.has(key)) {
grouped.set(key, { label, items: [], repo, repoIds: new Set() })
}
const group = grouped.get(key)!
group.items.push(w)
addRepoIdToGroup(group, w.repoId)
}
if (groupBy === 'repo') {
for (const repoId of placeholderRepoIds) {
const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex)
if (!grouping.repo) {
continue
}
const key = grouping.key
if (!grouped.has(key)) {
// Why: repos can arrive before worktree scans, but stale IDs passed by
// older snapshots must not render an "Unknown" project header.
grouped.set(key, {
label: grouping.label,
items: [],
repo: grouping.repo,
repoIds: new Set([repoId])
})
} else {
addRepoIdToGroup(grouped.get(key)!, repoId)
}
}
}
if (groupBy === 'repo') {
for (const [repoId, candidate] of importedWorktreesByRepo) {
const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex)
const key = grouping.key
if (!grouped.has(key)) {
grouped.set(key, {
label: grouping.label,
items: [],
repo: grouping.repo ?? candidate.repo,
repoIds: new Set([repoId])
})
} else if (grouped.has(key)) {
addRepoIdToGroup(grouped.get(key)!, repoId)
}
}
}
if (groupBy === 'repo') {
for (const [repoId, candidate] of newExternalWorktreesInboxByRepo) {
const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex)
const key = grouping.key
if (!grouped.has(key)) {
// Why: the default policy removes pinned worktrees from natural groups,
// but actionable inbox rows still need a project section to render in.
grouped.set(key, {
label: grouping.label,
items: [],
repo: grouping.repo ?? candidate.repo,
repoIds: new Set([repoId])
})
} else if (grouped.has(key)) {
addRepoIdToGroup(grouped.get(key)!, repoId)
}
}
}
if (groupBy === 'repo') {
for (const repoId of pendingByRepo.keys()) {
const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex)
const key = grouping.key
if (!grouped.has(key)) {
// Why: creating the first worktree in a repo leaves it with no group yet;
// ensure one so the in-progress row nests under its repo instead of being
// dropped.
grouped.set(key, {
label: grouping.label,
items: [],
repo: grouping.repo,
repoIds: new Set([repoId])
})
} else {
addRepoIdToGroup(grouped.get(key)!, repoId)
}
}
}
const orderedGroups: OrderedGroupEntry[] = []
if (groupBy === 'pr-status') {
for (const prGroup of PR_GROUP_ORDER) {
const key = `pr:${prGroup}`
const group = grouped.get(key)
if (group) {
orderedGroups.push([key, group])
}
}
} else if (groupBy === 'workspace-status') {
// Why: status grouping is opt-in while the board drawer remains the wider
// all-lanes drag target; keep the sidebar compact by omitting empty lanes.
for (const status of workspaceStatuses) {
const key = getWorkspaceStatusGroupKey(status.id)
const group = grouped.get(key)
if (group) {
orderedGroups.push([key, group])
}
}
} else {
for (const group of grouped.values()) {
// Why: logical project headers can contain multiple host setup repos.
// Use the repo that anchors manual order so drag and actions target the
// same persisted order source the row sorter reads.
group.repo = getManualOrderAnchorRepo(group, repoMap, repoOrder)
}
// Why: project header order is its own user choice (projectOrderBy),
// decoupled from workspace sortBy. Manual uses the canonical repoOrder so
// header drag has a stable source of truth; Recent follows activity.
const entries = sortProjectEntries(Array.from(grouped.entries()), projectOrderBy, repoOrder)
// Why: large imported repo sets can have one group per repo; spreading
// those entries into push can exceed V8's argument limit.
for (const entry of entries) {
orderedGroups.push(entry)
}
}
return orderedGroups
}
@@ -0,0 +1,31 @@
import type { HostSectionRow } from '../../host-section-rows'
type WorktreeItemRow = Extract<HostSectionRow, { type: 'item' }>
export type RenderRow =
| HostSectionRow
| { type: 'lineage-group'; key: string; rows: WorktreeItemRow[] }
export function getRenderRowKey(row: RenderRow): string {
if (row.type === 'host-header') {
return `host:${row.hostId}`
}
if (row.type === 'header') {
return row.hostId ? `hdr:${row.hostId}:${row.key}` : `hdr:${row.key}`
}
if (row.type === 'lineage-group') {
return `lineage-group:${row.key}`
}
if (row.type === 'imported-worktrees-card') {
return `imported:${row.key}`
}
if (row.type === 'new-external-worktrees-inbox') {
return `inbox:${row.key}`
}
if (row.type === 'pending-creation') {
return `pending:${row.creationId}`
}
if (row.type === 'folder-workspace') {
return `folder-workspace:${row.folderWorkspace.id}`
}
return `wt:${row.rowKey}`
}
@@ -1,6 +1,6 @@
import { getLineageGroupKey, PINNED_GROUP_KEY } from './groups'
import { PINNED_GROUP_KEY, getLineageGroupKey } from '../grouping/group-keys'
import type { HostSectionRow } from '../../host-section-rows'
import type { RenderRow } from '../viewport/virtual-rows'
import type { RenderRow } from './render-row'
export type WorktreeItemRow = Extract<HostSectionRow, { type: 'item' }>
export type FolderWorkspaceItemRow = Extract<HostSectionRow, { type: 'folder-workspace' }>
@@ -1,6 +1,6 @@
import type { AppState } from '@/store/types'
import type { WorktreeCardProperty } from '../../../../../../shared/ui-chrome-types'
import type { WorktreeGroupBy } from '../rows/groups'
import type { WorktreeGroupBy } from '../grouping/row-types'
export type WorktreeListReviewCacheState = Pick<
AppState,
@@ -1,17 +1,14 @@
import { useMemo } from 'react'
import type { AppState } from '@/store/types'
import type { ProjectGroup } from '../../../../../shared/project-group-types'
import type { Repo } from '../../../../../shared/repo-types'
import type { WorkspaceStatusDefinition, Worktree } from '../../../../../shared/worktree/types'
import type { WorktreeLineage } from '../../../../../shared/worktree/lineage-types'
import {
getGroupKeysForWorktree,
getLineageGroupKey,
PINNED_GROUP_KEY,
type ProjectGroupingModel,
type WorktreeGroupBy
} from './rows/groups'
import { getWorktreeLineageAncestors } from '../worktree-lineage-projection'
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
import type { Repo } from '../../../../../../shared/repo-types'
import type { WorkspaceStatusDefinition, Worktree } from '../../../../../../shared/worktree/types'
import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types'
import { PINNED_GROUP_KEY, getLineageGroupKey } from '../grouping/group-keys'
import type { ProjectGroupingModel } from '../grouping/project-grouping'
import type { WorktreeGroupBy } from '../grouping/row-types'
import { getGroupKeysForWorktree } from '../grouping/worktree-group-keys'
import { getWorktreeLineageAncestors } from '../../worktree-lineage-projection'
// While the agent send picker targets a workspace, force open every section that hides it.
export function useEffectiveCollapsedGroups(args: {
@@ -1,22 +1,22 @@
import { useCallback, useMemo, useState } from 'react'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import type { Repo } from '../../../../../shared/repo-types'
import type { Repo } from '../../../../../../shared/repo-types'
import {
IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR,
keepImportedWorktreesHiddenCard,
showImportedWorktreesCard,
type ImportedWorktreeCardActionState
} from '../imported-worktrees-card-actions'
} from '../../imported-worktrees-card-actions'
import {
buildImportedWorktreesCardCandidates,
getHiddenImportedWorktrees
} from '../imported-worktrees-card-candidates'
} from '../../imported-worktrees-card-candidates'
import {
suppressNewExternalWorktreeInbox,
type NewExternalWorktreesInboxActionState
} from '../new-external-worktrees-inbox-actions'
import { buildNewExternalWorktreesInboxCandidates } from '../new-external-worktrees-inbox-candidates'
} from '../../new-external-worktrees-inbox-actions'
import { buildNewExternalWorktreesInboxCandidates } from '../../new-external-worktrees-inbox-candidates'
// The two sidebar notice rows for worktrees Orca detected but does not yet show, plus the
// pending/error state their inline actions surface.
@@ -1,7 +1,7 @@
import { useCallback, useMemo } from 'react'
import { useAppStore } from '@/store'
import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../../shared/constants'
import { computeClearFilterActions, sidebarHasActiveFilters } from '../visible-worktrees'
import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../../../shared/constants'
import { computeClearFilterActions, sidebarHasActiveFilters } from '../../visible-worktrees'
export type SidebarWorktreeFilters = ReturnType<typeof useSidebarWorktreeFilters>
@@ -1,15 +1,18 @@
import { useMemo } from 'react'
import type { FolderWorkspace } from '../../../../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../../../../shared/project-group-types'
import type { Repo } from '../../../../../shared/repo-types'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../../shared/execution-host'
import type { SidebarWorktreeFilters } from './use-sidebar-worktree-filters'
import { filterFolderWorkspacesFromOtherDevices } from '../workspace-creator-visibility'
import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
import type { Repo } from '../../../../../../shared/repo-types'
import {
getRepoExecutionHostId,
type ExecutionHostId
} from '../../../../../../shared/execution-host'
import type { SidebarWorktreeFilters } from './use-filters'
import { filterFolderWorkspacesFromOtherDevices } from '../../workspace-creator-visibility'
import {
filterFolderWorkspacesForVisibleHosts,
filterProjectGroupsForVisibleHosts,
getVisibleSidebarHostIdSet
} from './viewport/host-filtering'
} from './host-filtering'
// Narrows repos, project groups, and folder workspaces to the hosts (and devices) the
// current host filter admits.
@@ -1,5 +1,5 @@
import { useRef } from 'react'
import { reuseArrayIfEqual } from '../worktree-agent-row-selectors'
import { reuseArrayIfEqual } from '../../worktree-agent-row-selectors'
// Why: epoch-driven recomputes often produce arrays whose contents and order are unchanged; reusing the previous identity when element-wise equal keeps downstream memos and React.memo'd cards bailing out. Safe only because elements (Worktree objects / id strings) are immutably REPLACED on change — never wrap arrays of mutated-in-place objects.
export function useReusedArrayIdentity<T>(next: T[]): T[] {
@@ -2,27 +2,23 @@ import { useCallback, useMemo, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import type { FolderWorkspace } from '../../../../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../../../../shared/project-group-types'
import type { ProjectOrderBy } from '../../../../../shared/ui-chrome-types'
import type { Repo } from '../../../../../shared/repo-types'
import type { WorkspaceStatusDefinition, Worktree } from '../../../../../shared/worktree/types'
import type { WorktreeLineage } from '../../../../../shared/worktree/lineage-types'
import type { ExecutionHostId } from '../../../../../shared/execution-host'
import { folderWorkspaceKey } from '../../../../../shared/workspace-scope'
import { getHostDisplayLabelOverrides } from '../../../../../shared/host-setting-overrides'
import {
buildRows,
type PinnedWorktreeDisplayPolicy,
type ProjectGroupingModel,
type Row,
type WorktreeGroupBy
} from './rows/groups'
import { getLogicalRepoOrderRankById } from '../project-header-drop'
import { getEmptyProjectPlaceholderRepoIds } from '../empty-project-placeholder-repos'
import { addHostSectionRows } from '../host-section-rows'
import { orderHostSectionOptions } from '../host-section-order'
import { buildSidebarHostOptions } from '../sidebar-host-options'
import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
import type { ProjectOrderBy } from '../../../../../../shared/ui-chrome-types'
import type { Repo } from '../../../../../../shared/repo-types'
import type { WorkspaceStatusDefinition, Worktree } from '../../../../../../shared/worktree/types'
import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope'
import { getHostDisplayLabelOverrides } from '../../../../../../shared/host-setting-overrides'
import { buildRows } from '../grouping/build-rows'
import type { ProjectGroupingModel } from '../grouping/project-grouping'
import type { PinnedWorktreeDisplayPolicy, Row, WorktreeGroupBy } from '../grouping/row-types'
import { getLogicalRepoOrderRankById } from '../../project-header-drop'
import { getEmptyProjectPlaceholderRepoIds } from '../../empty-project-placeholder-repos'
import { addHostSectionRows } from '../../host-section-rows'
import { orderHostSectionOptions } from '../../host-section-order'
import { buildSidebarHostOptions } from '../../sidebar-host-options'
type SectionRowsArgs = {
groupBy: WorktreeGroupBy
@@ -4,17 +4,19 @@ import { getAllWorktreesFromState } from '@/store/selectors'
import { track } from '@/lib/telemetry'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import { persistWorktreeSortOrderByHost } from '@/lib/worktree-sort-order-persistence'
import type { Repo } from '../../../../../shared/repo-types'
import type { Worktree } from '../../../../../shared/worktree/types'
import { buildWorktreeComparator, compareWorktreeSortLabel, type SortBy } from '../smart-sort'
import type { Repo } from '../../../../../../shared/repo-types'
import type { Worktree } from '../../../../../../shared/worktree/types'
import { buildWorktreeComparator, compareWorktreeSortLabel, type SortBy } from '../../smart-sort'
import {
buildAttentionByWorktree,
hasFreshAttributedAgentStatus,
type SmartClass,
type WorktreeAttention
} from '../smart-attention'
} from '../../smart-attention'
import { useReusedArrayIdentity } from './use-reused-array-identity'
import { SORT_SETTLE_MS } from './scroll/tuning'
// Debounce re-sort after a sortEpoch bump so background score changes don't jar row positions.
const SORT_SETTLE_MS = 3_000
function trackSmartClassDistribution(attention: ReadonlyMap<string, WorktreeAttention>): void {
let class1 = 0
@@ -2,21 +2,21 @@ import { useMemo } from 'react'
import { useAppStore } from '@/store'
import { getWorktreeIdsWithLiveAgent } from '@/lib/worktree-activity-state'
import type { AppState } from '@/store/types'
import type { Repo } from '../../../../../shared/repo-types'
import type { Worktree } from '../../../../../shared/worktree/types'
import type { WorktreeLineage } from '../../../../../shared/worktree/lineage-types'
import { getSettingsFocusedExecutionHostId } from '../../../../../shared/execution-host'
import { computeVisibleWorktreeIds } from '../visible-worktrees'
import type { Repo } from '../../../../../../shared/repo-types'
import type { Worktree } from '../../../../../../shared/worktree/types'
import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types'
import { getSettingsFocusedExecutionHostId } from '../../../../../../shared/execution-host'
import { computeVisibleWorktreeIds } from '../../visible-worktrees'
import {
EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT,
getPairedDeviceIdsByEnvironment
} from '../workspace-creator-visibility'
} from '../../workspace-creator-visibility'
import {
getVisibleWorktreeBrowserActivityTabs,
getVisibleWorktreeTerminalActivityTabs
} from '../visible-worktree-activity-inputs'
import type { SortBy } from '../smart-sort'
import type { SidebarWorktreeFilters } from './use-sidebar-worktree-filters'
} from '../../visible-worktree-activity-inputs'
import type { SortBy } from '../../smart-sort'
import type { SidebarWorktreeFilters } from './use-filters'
import { useReusedArrayIdentity } from './use-reused-array-identity'
const EMPTY_WORKTREE_ID_SET: ReadonlySet<string> = new Set()
@@ -1,9 +1,9 @@
import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope'
import type { RenderRow } from './virtual-rows'
import type { PinnedWorktreeDisplayPolicy } from '../rows/groups'
import { isPinnedWorktreeRow } from '../rows/renderable-rows'
import { getRenderRowWorktreeItem, renderRowContainsWorktree } from '../rows/render-row-lookup'
import { getWorktreeOptionId } from '../option-dom'
import type { RenderRow } from '../listing/render-row'
import type { PinnedWorktreeDisplayPolicy } from '../grouping/row-types'
import { isPinnedWorktreeRow } from '../listing/renderable-rows'
import { getRenderRowWorktreeItem, renderRowContainsWorktree } from './render-row-lookup'
import { getWorktreeOptionId } from '../rows/option-dom'
export function getRenderRowOptionId(
row: RenderRow | undefined,
@@ -8,7 +8,7 @@ import {
getKnownSidebarWorktreeById,
sidebarWorkspaceStillExists
} from './folder-reveal'
import { getProjectGroupHeaderKey } from '../rows/groups'
import { getProjectGroupHeaderKey } from '../grouping/group-keys'
function makeFolderWorkspace(overrides: Partial<FolderWorkspace> = {}): FolderWorkspace {
return {
@@ -3,7 +3,7 @@ import type { ProjectGroup } from '../../../../../../shared/project-group-types'
import type { Worktree } from '../../../../../../shared/worktree/types'
import { folderWorkspaceToWorktree } from '../../../../../../shared/folder-workspace-worktree'
import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope'
import { getProjectGroupHeaderKey } from '../rows/groups'
import { getProjectGroupHeaderKey } from '../grouping/group-keys'
function findFolderWorkspaceByKey(
worktreeId: string,
@@ -1,5 +1,5 @@
import { revealElementInScrollContainer } from '../../worktree-sidebar-reveal'
import { getMountedWorktreeOptions, getWorktreeOptionId } from '../option-dom'
import { getMountedWorktreeOptions, getWorktreeOptionId } from '../rows/option-dom'
export function revealMountedWorktreeElement(
container: HTMLElement,
@@ -9,17 +9,14 @@ import type { WorkspaceStatusDefinition, Worktree } from '../../../../../../shar
import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import { getWorktreeExecutionHostId } from '../../../../../../shared/execution-host'
import type { RenderRow } from '../viewport/virtual-rows'
import {
getGroupKeysForWorktree,
getLineageGroupKey,
type PinnedWorktreeDisplayPolicy,
type ProjectGroupingModel,
type WorktreeGroupBy
} from '../rows/groups'
import type { RenderRow } from '../listing/render-row'
import { getLineageGroupKey } from '../grouping/group-keys'
import type { ProjectGroupingModel } from '../grouping/project-grouping'
import type { PinnedWorktreeDisplayPolicy, WorktreeGroupBy } from '../grouping/row-types'
import { getGroupKeysForWorktree } from '../grouping/worktree-group-keys'
import { getWorktreeLineageAncestors } from '../../worktree-lineage-projection'
import { getFolderWorkspaceRevealGroupKeys } from './folder-reveal'
import { getPinnedWorktreeRevealCollapsedGroupKeys } from './ancestors'
import { getPinnedWorktreeRevealCollapsedGroupKeys } from './reveal-ancestors'
export const MAX_REVEAL_RETRIES = 8
@@ -114,3 +111,13 @@ export function expandGroupsForWorktreeReveal(
}
}
}
export function resolvePendingSidebarReveal(args: {
targetIndex: number
targetWorktreeStillExists: boolean
}): 'scroll-and-clear' | 'clear' | 'keep-pending' {
if (args.targetIndex !== -1) {
return 'scroll-and-clear'
}
return args.targetWorktreeStillExists ? 'keep-pending' : 'clear'
}
@@ -1,7 +1,7 @@
import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope'
import type { RenderRow } from '../viewport/virtual-rows'
import type { PinnedWorktreeDisplayPolicy } from './groups'
import { isPinnedWorktreeRow, type WorktreeItemRow } from './renderable-rows'
import type { RenderRow } from '../listing/render-row'
import type { PinnedWorktreeDisplayPolicy } from '../grouping/row-types'
import { isPinnedWorktreeRow, type WorktreeItemRow } from '../listing/renderable-rows'
export function getRenderRowSidebarKey(row: RenderRow): string | null {
if (row.type === 'header') {
@@ -1,11 +1,8 @@
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
import type { Repo } from '../../../../../../shared/repo-types'
import type { Worktree } from '../../../../../../shared/worktree/types'
import {
getProjectGroupHeaderKey,
PINNED_GROUP_KEY,
type ProjectGroupingModel
} from '../rows/groups'
import { PINNED_GROUP_KEY, getProjectGroupHeaderKey } from '../grouping/group-keys'
import type { ProjectGroupingModel } from '../grouping/project-grouping'
function getProjectIdFromHeaderRowKey(rowKey: string): string | null {
if (!rowKey.startsWith('project:')) {
@@ -1,8 +1,8 @@
import { useCallback, useLayoutEffect, useState } from 'react'
import type { ActiveSurfaceVariant } from '../../WorktreeCard'
import type { HostSectionRow } from '../../host-section-rows'
import type { PinnedWorktreeDisplayPolicy } from '../rows/groups'
import { isPinnedWorktreeRow, type WorktreeItemRow } from '../rows/renderable-rows'
import type { PinnedWorktreeDisplayPolicy } from '../grouping/row-types'
import { isPinnedWorktreeRow, type WorktreeItemRow } from '../listing/renderable-rows'
// A worktree can render in more than one section; the row the user actually clicked owns
// the primary active surface so its duplicates stay visually secondary.
@@ -6,10 +6,10 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
import { keybindingMatchesAction } from '../../../../../../shared/keybindings'
import type { HostSectionRow } from '../../host-section-rows'
import type { PinnedWorktreeDisplayPolicy } from '../rows/groups'
import type { RenderRow } from './virtual-rows'
import type { PinnedWorktreeDisplayPolicy } from '../grouping/row-types'
import type { RenderRow } from '../listing/render-row'
import { getCyclableWorktreeIds, resolveCycledWorktreeId } from '../../worktree-keyboard-cycle'
import { findPreferredRenderRowIndexForWorktree } from '../rows/render-row-lookup'
import { findPreferredRenderRowIndexForWorktree } from './render-row-lookup'
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
@@ -2,21 +2,21 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { getRenderRowOptionId } from '../viewport/active-descendant-option'
import { getRenderRowOptionId } from './active-descendant-option'
import {
findPreferredRenderRowIndexForWorktree,
getRenderRowSidebarKey,
rowKeyMatchesRenderRow
} from '../rows/render-row-lookup'
import { revealMountedSidebarRowElement, revealMountedWorktreeElement } from './mounted-row'
import { getSidebarRowRevealAncestorKeys } from './ancestors'
} from './render-row-lookup'
import { revealMountedSidebarRowElement, revealMountedWorktreeElement } from './mounted-row-reveal'
import { getSidebarRowRevealAncestorKeys } from './reveal-ancestors'
import { sidebarWorkspaceStillExists } from './folder-reveal'
import { resolvePendingSidebarReveal } from '../scroll/tuning'
import {
expandGroupsForWorktreeReveal,
MAX_REVEAL_RETRIES,
resolvePendingSidebarReveal,
type PendingSidebarRevealArgs
} from './pending-inputs'
} from './pending-reveal-inputs'
// Drives the store's two pending reveal requests (a worktree card, or any sidebar row)
// from "expand the ancestors" through "scroll the mounted element into view".
@@ -6,7 +6,7 @@ import {
} from '@/lib/scroll-to-current-workspace-status'
import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types'
import type { Worktree } from '../../../../../../shared/worktree/types'
import type { WorktreeGroupBy } from '../rows/groups'
import type { WorktreeGroupBy } from '../grouping/row-types'
import { getKnownSidebarWorktreeById } from './folder-reveal'
// Turns a "show me the current workspace" request into whatever the sidebar must change
@@ -1,17 +1,17 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'
import type React from 'react'
import type { Worktree } from '../../../../../shared/worktree/types'
import type { HostSectionRow } from '../host-section-rows'
import type { PinnedWorktreeDisplayPolicy } from './rows/groups'
import { getRenderedWorktreesInSidebarOrder } from '../worktree-sidebar-row-preference'
import { setVisibleWorktreeIds } from '../visible-worktrees'
import type { Worktree } from '../../../../../../shared/worktree/types'
import type { HostSectionRow } from '../../host-section-rows'
import type { PinnedWorktreeDisplayPolicy } from '../grouping/row-types'
import { getRenderedWorktreesInSidebarOrder } from '../../worktree-sidebar-row-preference'
import { setVisibleWorktreeIds } from '../../visible-worktrees'
import {
areWorktreeSelectionsEqual,
getWorktreeSelectionIntent,
pruneWorktreeSelection,
updateWorktreeSelection
} from '../worktree-multi-selection'
import { useReusedArrayIdentity } from './use-reused-array-identity'
} from '../../worktree-multi-selection'
import { useReusedArrayIdentity } from '../listing/use-reused-array-identity'
function uniqueWorktreeIds(ids: readonly string[]): string[] {
return Array.from(new Set(ids))
@@ -2,11 +2,11 @@ import React from 'react'
import { translate } from '@/i18n/i18n'
import { findRepoForHost } from '@/store/slices/repo-host-identity'
import type { AppState } from '@/store/types'
import type { Repo } from '../../../../../shared/repo-types'
import { ProjectGroupNameDialog } from '../ProjectGroupNameDialog'
import { ProjectGroupDeleteDialog } from '../ProjectGroupDeleteDialog'
import SuppressExternalWorktreeInboxDialog from '../SuppressExternalWorktreeInboxDialog'
import type { NewExternalWorktreesInboxActionState } from '../new-external-worktrees-inbox-actions'
import type { Repo } from '../../../../../../shared/repo-types'
import { ProjectGroupNameDialog } from '../../ProjectGroupNameDialog'
import { ProjectGroupDeleteDialog } from '../../ProjectGroupDeleteDialog'
import SuppressExternalWorktreeInboxDialog from '../../SuppressExternalWorktreeInboxDialog'
import type { NewExternalWorktreesInboxActionState } from '../../new-external-worktrees-inbox-actions'
import type { ProjectGroupDialogs } from './use-project-group-dialogs'
export function SidebarWorktreeListDialogs({
@@ -12,8 +12,8 @@ import type {
WorkspaceStatus,
WorkspaceStatusDefinition
} from '../../../../../../shared/worktree/types'
import type { GroupHeaderRow, WorktreeGroupBy } from '../rows/groups'
import { PINNED_GROUP_KEY } from '../rows/groups'
import type { GroupHeaderRow, WorktreeGroupBy } from '../grouping/row-types'
import { PINNED_GROUP_KEY } from '../grouping/group-keys'
import { getWorkspaceStatusFromGroupKey } from '../../workspace-status'
import { getVirtualRowTransform } from '../viewport/virtual-rows'
import { resolveProjectGroupHeaderColor } from '../../project-header-color'
@@ -22,7 +22,7 @@ import { ProjectHeaderActions } from '../../ProjectHeaderActions'
import {
getProjectGroupHeaderPaddingLeft,
WORKTREE_SECTION_HEADER_PADDING_LEFT
} from '../rows/indentation'
} from './indentation'
import { FolderPathStatusIndicator } from './FolderPathStatusIndicator'
import {
ProjectGroupCreateWorkspaceButton,
@@ -36,9 +36,9 @@ import {
import {
handleRepoHeaderCollapseAffordancePointerDown,
shouldIgnoreRepoHeaderToggle
} from './event-guards'
} from './header-event-guards'
import type { WorktreeSidebarHeaderDrag } from '../drag/use-header-drag'
import { getWorktreeOptionId } from '../option-dom'
import { getWorktreeOptionId } from './option-dom'
export type SectionHeaderRowContext = {
groupBy: WorktreeGroupBy
@@ -11,13 +11,13 @@ import type { FolderWorkspacePathStatus } from '../../../../../../shared/folder-
import { isConfirmedStaleFolderPathStatus } from '../../../../../../shared/folder-workspace-path-status'
import { folderWorkspaceToWorktree } from '../../../../../../shared/folder-workspace-worktree'
import WorktreeCard from '../../WorktreeCard'
import type { WorktreeGroupBy } from './groups'
import type { WorktreeGroupBy } from '../grouping/row-types'
import { getVirtualRowTransform } from '../viewport/virtual-rows'
import { getFolderWorkspaceRowGeometry } from './indentation'
import { getFolderWorkspaceCardPrDisplay } from '../../folder-workspace-card-pr-display'
import { FolderPathStatusIndicator } from '../headers/FolderPathStatusIndicator'
import type { FolderWorkspaceItemRow } from './renderable-rows'
import { getWorktreeOptionId } from '../option-dom'
import { FolderPathStatusIndicator } from './FolderPathStatusIndicator'
import type { FolderWorkspaceItemRow } from '../listing/renderable-rows'
import { getWorktreeOptionId } from './option-dom'
export type FolderWorkspaceRowContext = {
groupBy: WorktreeGroupBy
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,8 @@ import { cn } from '@/lib/utils'
import type { AppState } from '@/store/types'
import type { Worktree } from '../../../../../../shared/worktree/types'
import WorktreeCard, { type ActiveSurfaceVariant } from '../../WorktreeCard'
import { PINNED_GROUP_KEY, type WorktreeGroupBy } from './groups'
import { PINNED_GROUP_KEY } from '../grouping/group-keys'
import type { WorktreeGroupBy } from '../grouping/row-types'
import {
getFolderBackedRepoWorktreeCardContentIndent,
getFolderBackedRepoWorktreeCardSurfaceInset,
@@ -14,9 +15,9 @@ import {
LINEAGE_CHILDREN_INLINE_OFFSET
} from './indentation'
import type { LineageToggleHandler } from '../../worktree-lineage-toggle-handler-cache'
import { stopNestedWorktreeCardBubble } from '../headers/event-guards'
import type { WorktreeItemRow } from './renderable-rows'
import { getWorktreeOptionId } from '../option-dom'
import { stopNestedWorktreeCardBubble } from './header-event-guards'
import type { WorktreeItemRow } from '../listing/renderable-rows'
import { getWorktreeOptionId } from './option-dom'
import type { WorktreePointerDrag, WorktreeRowDragState } from '../drag/row-state'
export type WorktreeItemRowContext = {
@@ -1,6 +1,6 @@
import React from 'react'
import type { VirtualItem } from '@tanstack/react-virtual'
import type { Row } from './groups'
import type { Row } from '../grouping/row-types'
import { getVirtualRowTransform } from '../viewport/virtual-rows'
import { PendingWorktreeRow } from '../../PendingWorktreeRow'
import ImportedWorktreesVisibilityLine from '../../ImportedWorktreesVisibilityLine'
@@ -18,7 +18,7 @@ import {
handleRepoHeaderActionPointerDown,
stopRepoHeaderKeyboardToggle,
stopRepoHeaderMenuEvent
} from './event-guards'
} from './header-event-guards'
export function ProjectGroupHeaderMenu({
groupId,
@@ -42,7 +42,7 @@ import {
handleRepoHeaderActionPointerDown,
stopRepoHeaderKeyboardToggle,
stopRepoHeaderMenuEvent
} from './event-guards'
} from './header-event-guards'
function getWorktreeVisibilityMenuLabel(
repo: Repo,
@@ -3,8 +3,8 @@ import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { selectProjectGroupRemovalTargets } from '@/store/slices/project-group-removal-targets'
import type { ProjectGroup } from '../../../../../shared/project-group-types'
import type { Repo } from '../../../../../shared/repo-types'
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
import type { Repo } from '../../../../../../shared/repo-types'
export type ProjectGroupNameDialogState =
| { type: 'create-from-repo'; repo: Repo }
@@ -6,17 +6,17 @@ import type {
WorkspaceStatusDefinition
} from '../../../../../../shared/worktree/types'
import { getWorkspaceStatus } from '../../workspace-status'
import type { WorktreeGroupBy } from './groups'
import type { WorktreeGroupBy } from '../grouping/row-types'
import {
getVirtualRowTransform,
getWorktreeVirtualRowTransform,
shouldUseHeaderTopSpacing,
type RenderRow
shouldUseHeaderTopSpacing
} from '../viewport/virtual-rows'
import type { RenderRow } from '../listing/render-row'
import type { ImportedWorktreeCardActionState } from '../../imported-worktrees-card-actions'
import type { NewExternalWorktreesInboxActionState } from '../../new-external-worktrees-inbox-actions'
import type { useHostHeaderDrag } from '../../host-header-drag'
import { HostSectionHeader } from '../headers/HostSectionHeader'
import { HostSectionHeader } from './HostSectionHeader'
import { renderFolderWorkspaceVirtualRow, type FolderWorkspaceRowContext } from './folder-row'
import {
renderImportedWorktreesVirtualRow,
@@ -28,10 +28,7 @@ import {
renderWorktreeLineageDescendants,
type WorktreeItemRowContext
} from './item-row'
import {
renderWorktreeSectionHeaderRow,
type SectionHeaderRowContext
} from '../headers/SectionHeader'
import { renderWorktreeSectionHeaderRow, type SectionHeaderRowContext } from './SectionHeader'
import type { WorktreeRowDragState } from '../drag/row-state'
export type WorktreeVirtualRowContext = {
@@ -1,47 +0,0 @@
import type React from 'react'
// Debounce re-sort after a sortEpoch bump so background score changes don't jar row positions.
export const SORT_SETTLE_MS = 3_000
export const USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS = 500
export const EXPANDING_CARD_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS = 300
export const SIDEBAR_POINTER_DRAG_THRESHOLD_PX = 4
export const WORKTREE_SIDEBAR_SCROLL_STYLE: React.CSSProperties = {
// Why: TanStack Virtual owns scroll correction; native overflow anchoring fights it and causes jumps.
overflowAnchor: 'none'
}
const recordKeyCountCache = new WeakMap<Record<string, unknown>, number>()
export function countRecordKeysByReference(record: Record<string, unknown>): number {
const cached = recordKeyCountCache.get(record)
if (cached !== undefined) {
return cached
}
const count = Object.keys(record).length
recordKeyCountCache.set(record, count)
return count
}
export function shouldAdjustWorktreeSidebarMeasuredRowScroll(args: {
isScrolling: boolean
now: number
suppressUntil: number
}): boolean {
return !args.isScrolling && args.now >= args.suppressUntil
}
export function resolvePendingSidebarReveal(args: {
targetIndex: number
targetWorktreeStillExists: boolean
}): 'scroll-and-clear' | 'clear' | 'keep-pending' {
if (args.targetIndex !== -1) {
return 'scroll-and-clear'
}
return args.targetWorktreeStillExists ? 'keep-pending' : 'clear'
}
export function installWorktreeVisibleRefreshVisibilityListener(onChange: () => void): () => void {
document.addEventListener('visibilitychange', onChange)
return () => document.removeEventListener('visibilitychange', onChange)
}
@@ -1,16 +1,16 @@
import React, { useCallback, useMemo, useRef, useState } from 'react'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { WorktreeSidebarDropIndicator } from '../../WorktreeSidebarDropIndicator'
import { WorktreeListScrollToTopButton } from '../../WorktreeListScrollToTopButton'
import { useWorktreeListScrollToTop } from '../scroll/use-scroll-to-top'
import { getActiveDescendantOptionId } from './active-descendant-option'
import { buildRenderableRows } from '../rows/renderable-rows'
import { useFolderWorkspacePathStatusRows } from './use-folder-path-statuses'
import { renderWorktreeSidebarDropIndicators } from './drop-indicators'
import { useWorktreeListScrollToTop } from './use-scroll-to-top'
import { getActiveDescendantOptionId } from '../navigation/active-descendant-option'
import { buildRenderableRows } from '../listing/renderable-rows'
import { useFolderWorkspacePathStatusRows } from '../listing/use-folder-path-statuses'
import { useGroupToggleWithScrollAnchor } from './use-group-toggle'
import { usePendingSidebarReveal } from '../reveal/use-pending'
import { usePrimaryActiveWorktreeRow } from './use-active-row'
import { useSidebarRevealHighlight } from '../reveal/use-highlight'
import { usePendingSidebarReveal } from '../navigation/use-pending-reveal'
import { usePrimaryActiveWorktreeRow } from '../navigation/use-active-row'
import { useSidebarRevealHighlight } from '../navigation/use-reveal-highlight'
import { useVirtualRowMeasurementSync } from './use-row-measurement'
import { useVisiblePrRefreshReporting } from './use-visible-review-refresh'
import { useWorkspaceStatusRowDrag } from '../drag/use-status-row-drag'
@@ -18,18 +18,22 @@ import { useWorktreeDragRuntime } from '../drag/use-runtime'
import { useWorktreeDragSession } from '../drag/use-session'
import { useWorktreeDocumentDrop } from '../drag/use-document-drop'
import { useWorktreeLineageDropCommit } from '../drag/use-lineage-drop-commit'
import { useWorktreeListKeyboardNavigation } from './use-keyboard'
import { useWorktreeListKeyboardNavigation } from '../navigation/use-keyboard'
import { useWorktreeListVirtualizer } from './use-virtualizer'
import { useWorktreeNativeDrag } from '../drag/use-native-drag'
import { useWorktreePointerDrag } from '../drag/use-pointer-drag'
import { useWorktreeSidebarHeaderDrag } from '../drag/use-header-drag'
import { useWorktreeSidebarScrollSuppression } from '../scroll/use-suppression'
import { useWorktreeSidebarScrollSuppression } from './use-scroll-suppression'
import { EMPTY_PROJECT_GROUPS, type VirtualizedWorktreeViewportProps } from './viewport-props'
import { WORKTREE_SIDEBAR_SCROLL_STYLE } from '../scroll/tuning'
import type { WorktreeDropCommitContext } from '../drag/drop-commit-context'
import { buildWorktreeVirtualRowContext } from '../rows/virtual-row-context'
import { useWorktreeDropCommitContext } from '../drag/use-drop-commit-context'
import { buildWorktreeVirtualRowContext } from './virtual-row-context'
import { renderWorktreeVirtualRow } from '../rows/virtual-row-dispatch'
const WORKTREE_SIDEBAR_SCROLL_STYLE: React.CSSProperties = {
// Why: TanStack Virtual owns scroll correction; native overflow anchoring fights it and causes jumps.
overflowAnchor: 'none'
}
export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewport(
props: VirtualizedWorktreeViewportProps
) {
@@ -188,41 +192,17 @@ export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktr
markDirectScrollInput
})
const dropCtx = useMemo<WorktreeDropCommitContext>(
() => ({
scrollRef,
workspaceStatuses,
worktreeDragGroups: session.worktreeDragGroups,
worktreeDragUnitGroups: session.worktreeDragUnitGroups,
computeWorktreeDrop: session.computeWorktreeDrop,
computeWorktreeStatusDrop: session.computeWorktreeStatusDrop,
refreshWorktreeDragSession: session.refreshWorktreeDragSession,
getEligibleLineageDropTarget: lineageDrop.getEligibleLineageDropTarget,
commitWorktreeLineageParentDrop: lineageDrop.commitWorktreeLineageParentDrop,
clearReorderedWorktreeParents: lineageDrop.clearReorderedWorktreeParents,
clearWorktreeDrag: runtime.clearWorktreeDrag,
onMoveWorktreesToStatus: props.onMoveWorktreesToStatus,
onMoveWorktreesToStatusAtIndex: props.onMoveWorktreesToStatusAtIndex,
onReorderWorktrees: props.onReorderWorktrees,
onPinWorktrees: props.onPinWorktrees
}),
[
lineageDrop.clearReorderedWorktreeParents,
lineageDrop.commitWorktreeLineageParentDrop,
lineageDrop.getEligibleLineageDropTarget,
props.onMoveWorktreesToStatus,
props.onMoveWorktreesToStatusAtIndex,
props.onPinWorktrees,
props.onReorderWorktrees,
runtime.clearWorktreeDrag,
session.computeWorktreeDrop,
session.computeWorktreeStatusDrop,
session.refreshWorktreeDragSession,
session.worktreeDragGroups,
session.worktreeDragUnitGroups,
workspaceStatuses
]
)
const dropCtx = useWorktreeDropCommitContext({
scrollRef,
workspaceStatuses,
session,
lineageDrop,
runtime,
onMoveWorktreesToStatus: props.onMoveWorktreesToStatus,
onMoveWorktreesToStatusAtIndex: props.onMoveWorktreesToStatusAtIndex,
onReorderWorktrees: props.onReorderWorktrees,
onPinWorktrees: props.onPinWorktrees
})
const { handleWorktreeRowPointerDown, handleWorktreeRowClickCapture } = useWorktreePointerDrag({
ctx: dropCtx,
@@ -377,27 +357,10 @@ export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktr
className="relative w-full"
style={{ height: `${virtualization.virtualizer.getTotalSize()}px` }}
>
{headerDrag.canReorderRepoHeaders &&
headerDrag.repoDrag.state.draggingRepoId !== null &&
headerDrag.repoDrag.state.dropIndicatorY !== null ? (
<WorktreeSidebarDropIndicator y={headerDrag.repoDrag.state.dropIndicatorY} />
) : null}
{headerDrag.canReorderProjectGroupHeaders &&
headerDrag.projectGroupDrag.state.draggingGroupId !== null &&
headerDrag.projectGroupDrag.state.dropIndicatorY !== null ? (
<WorktreeSidebarDropIndicator y={headerDrag.projectGroupDrag.state.dropIndicatorY} />
) : null}
{headerDrag.hostDrag.state.draggingHostId !== null &&
headerDrag.hostDrag.state.dropIndicatorY !== null ? (
<WorktreeSidebarDropIndicator
y={headerDrag.hostDrag.state.dropIndicatorY}
className="z-40"
/>
) : null}
{runtime.worktreeDragState.draggingWorktreeId !== null &&
runtime.worktreeDragState.dropIndicatorY !== null ? (
<WorktreeSidebarDropIndicator y={runtime.worktreeDragState.dropIndicatorY} />
) : null}
{renderWorktreeSidebarDropIndicators({
headerDrag,
worktreeDragState: runtime.worktreeDragState
})}
{virtualItems.map((vItem) => {
const row = renderRows[vItem.index]
return row ? renderWorktreeVirtualRow(rowContext, row, vItem) : null
@@ -0,0 +1,36 @@
import { WorktreeSidebarDropIndicator } from '../../WorktreeSidebarDropIndicator'
import type { useWorktreeDragRuntime } from '../drag/use-runtime'
import type { useWorktreeSidebarHeaderDrag } from '../drag/use-header-drag'
/** Header and worktree drop lines float above the virtual rows, so they render as siblings. */
export function renderWorktreeSidebarDropIndicators(args: {
headerDrag: ReturnType<typeof useWorktreeSidebarHeaderDrag>
worktreeDragState: ReturnType<typeof useWorktreeDragRuntime>['worktreeDragState']
}): React.ReactNode {
const { headerDrag, worktreeDragState } = args
return (
<>
{headerDrag.canReorderRepoHeaders &&
headerDrag.repoDrag.state.draggingRepoId !== null &&
headerDrag.repoDrag.state.dropIndicatorY !== null ? (
<WorktreeSidebarDropIndicator y={headerDrag.repoDrag.state.dropIndicatorY} />
) : null}
{headerDrag.canReorderProjectGroupHeaders &&
headerDrag.projectGroupDrag.state.draggingGroupId !== null &&
headerDrag.projectGroupDrag.state.dropIndicatorY !== null ? (
<WorktreeSidebarDropIndicator y={headerDrag.projectGroupDrag.state.dropIndicatorY} />
) : null}
{headerDrag.hostDrag.state.draggingHostId !== null &&
headerDrag.hostDrag.state.dropIndicatorY !== null ? (
<WorktreeSidebarDropIndicator
y={headerDrag.hostDrag.state.dropIndicatorY}
className="z-40"
/>
) : null}
{worktreeDragState.draggingWorktreeId !== null &&
worktreeDragState.dropIndicatorY !== null ? (
<WorktreeSidebarDropIndicator y={worktreeDragState.dropIndicatorY} />
) : null}
</>
)
}
@@ -1,9 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import {
countRecordKeysByReference,
resolvePendingSidebarReveal,
shouldAdjustWorktreeSidebarMeasuredRowScroll
} from './tuning'
import { countRecordKeysByReference } from './use-row-measurement'
import { shouldAdjustWorktreeSidebarMeasuredRowScroll } from './use-scroll-suppression'
import { resolvePendingSidebarReveal } from '../navigation/pending-reveal-inputs'
import {
getScrollTopToRevealBounds,
WORKTREE_SIDEBAR_REVEAL_TOP_INSET
@@ -13,9 +11,9 @@ import {
estimateRenderRowSize,
GROUP_HEADER_ROW_HEIGHT,
getActiveStickyHeaderIndexForScroll
} from '../viewport/virtual-rows'
} from './virtual-rows'
import type { Repo } from '../../../../../../shared/repo-types'
import type { Row } from '../rows/groups'
import type { Row } from '../grouping/row-types'
const repo: Repo = {
id: 'repo-1',
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest'
import { buildRows } from '../rows/groups'
import { buildRows } from '../grouping/build-rows'
import { getStickyHeaderIndexes } from './virtual-rows'
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
import type { Repo } from '../../../../../../shared/repo-types'
import type { Worktree } from '../../../../../../shared/worktree/types'
import type { Row } from '../rows/groups'
import type { Row } from '../grouping/row-types'
const repo: Repo = {
id: 'repo-1',
@@ -8,14 +8,25 @@ import {
import {
buildLineageRowRekeyMap,
getActiveStickyIndexesForScroll,
getRenderRowKey,
getVirtualRowKey,
pruneStaleVirtualRowElementCache,
type RenderRow
pruneStaleVirtualRowElementCache
} from './virtual-rows'
import { countRecordKeysByReference } from '../scroll/tuning'
import { getRenderRowKey } from '../listing/render-row'
import type { RenderRow } from '../listing/render-row'
import type { WorktreeListVirtualizer } from './use-virtualizer'
const recordKeyCountCache = new WeakMap<Record<string, unknown>, number>()
export function countRecordKeysByReference(record: Record<string, unknown>): number {
const cached = recordKeyCountCache.get(record)
if (cached !== undefined) {
return cached
}
const count = Object.keys(record).length
recordKeyCountCache.set(record, count)
return count
}
// Re-measures only rows whose DOM node still matches its virtual key, and publishes the
// sticky-header slots the render pass reads out of refs.
export function useVirtualRowMeasurementSync(args: {
@@ -6,10 +6,17 @@ import {
isRevealScrollSettling,
type PendingRevealScroll
} from '../../worktree-sidebar-reveal-scroll-settle'
import {
EXPANDING_CARD_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS,
USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS
} from './tuning'
export const USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS = 500
export const EXPANDING_CARD_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS = 300
export function shouldAdjustWorktreeSidebarMeasuredRowScroll(args: {
isScrolling: boolean
now: number
suppressUntil: number
}): boolean {
return !args.isScrolling && args.now >= args.suppressUntil
}
export type WorktreeSidebarScrollSuppression = ReturnType<
typeof useWorktreeSidebarScrollSuppression

Some files were not shown because too many files have changed in this diff Show More