From 1040cca4292f1234bd63d920388bf038acda7fb4 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:21:45 -0700 Subject: [PATCH] refactor: organize worktree-list into lifecycle dest folders --- config/max-lines-baseline.txt | 3 +- .../src/components/WorktreeJumpPalette.tsx | 2 +- .../cmd-j/palette-filter-options.ts | 6 +- .../cmd-j/palette-project-results.ts | 8 +- ...ver-reveal-touch-action-visibility.test.ts | 4 +- .../sidebar/SidebarGroupByToggle.test.tsx | 2 +- .../sidebar/SidebarGroupByToggle.tsx | 2 +- .../WorktreeList.lineage-child-card.test.ts | 2 +- .../src/components/sidebar/WorktreeList.tsx | 40 +- .../empty-project-placeholder-repos.ts | 2 +- .../sidebar/host-section-rows.test.ts | 3 +- .../components/sidebar/host-section-rows.ts | 2 +- .../imported-worktrees-card-candidates.ts | 2 +- ...new-external-worktrees-inbox-candidates.ts | 2 +- .../sidebar/project-group-header-dom.test.ts | 2 +- .../sidebar/project-group-header-drop.test.ts | 2 +- .../sidebar/project-group-header-drop.ts | 2 +- .../sidebar/project-header-drop.test.ts | 2 +- .../components/sidebar/project-header-drop.ts | 2 +- .../rendered-sidebar-worktree-order.ts | 7 +- .../components/sidebar/worktree-drag-units.ts | 2 +- .../worktree-header-section-boundaries.ts | 3 +- .../sidebar/worktree-keyboard-cycle.test.ts | 2 +- .../sidebar/worktree-keyboard-cycle.ts | 2 +- .../worktree-list/drag/drop-commit-context.ts | 10 +- .../sidebar/worktree-list/drag/groups.ts | 2 +- .../worktree-list/drag/pointer-commit.ts | 6 +- .../drag/use-drop-commit-context.ts | 63 + .../worktree-list/drag/use-header-drag.ts | 6 +- .../worktree-list/drag/use-pointer-drag.ts | 8 +- .../drag/use-pointer-window-events.ts | 9 +- .../sidebar/worktree-list/drag/use-session.ts | 4 +- .../{ => drag}/use-status-mutations.ts | 14 +- .../worktree-list/drag/use-status-row-drag.ts | 3 +- .../build-rows.test.ts} | 25 +- .../worktree-list/grouping/build-rows.ts | 194 ++ .../worktree-list/grouping/group-keys.ts | 156 ++ .../worktree-list/grouping/group-sections.ts | 172 ++ .../worktree-list/grouping/host-labels.ts | 106 ++ .../{rows => grouping}/imported-rows.test.ts | 8 +- .../grouping/project-group-sections.ts | 146 ++ .../grouping/project-grouping.ts | 165 ++ .../worktree-list/grouping/row-builders.ts | 279 +++ .../worktree-list/grouping/row-types.ts | 102 ++ .../worktree-list/grouping/section-order.ts | 209 +++ .../grouping/worktree-group-keys.ts | 80 + .../grouping/worktree-grouping.ts | 192 ++ .../EmptyState.tsx} | 0 .../host-filtering.test.ts | 0 .../{viewport => listing}/host-filtering.ts | 0 .../worktree-list/listing/render-row.ts | 31 + .../{rows => listing}/renderable-rows.ts | 4 +- .../review-cache-inputs.test.ts | 0 .../review-cache-inputs.ts | 2 +- .../use-agent-send-target.ts} | 0 .../use-collapsed-groups.ts} | 21 +- .../use-external-worktree-cards.ts} | 10 +- .../use-filters.ts} | 4 +- .../use-folder-path-statuses.ts | 0 .../use-host-visible-scope.ts} | 17 +- .../use-reused-array-identity.ts | 2 +- .../use-section-rows.ts} | 38 +- .../use-sort-order.ts} | 12 +- .../use-visible-worktrees.ts} | 18 +- .../active-descendant-option.ts | 10 +- .../folder-reveal.test.ts | 2 +- .../{reveal => navigation}/folder-reveal.ts | 2 +- .../mounted-row-reveal.ts} | 2 +- .../pending-reveal-inputs.ts} | 25 +- .../{rows => navigation}/render-row-lookup.ts | 6 +- .../reveal-ancestors.ts} | 7 +- .../use-active-row.ts | 4 +- .../{viewport => navigation}/use-keyboard.ts | 6 +- .../use-pending-reveal.ts} | 12 +- .../use-reveal-highlight.ts} | 0 .../use-reveal-requests.ts} | 2 +- .../use-selection.ts} | 14 +- .../FolderPathStatusIndicator.tsx | 0 .../{headers => rows}/HostSectionHeader.tsx | 0 .../ProjectGroupDialogs.tsx} | 10 +- .../{headers => rows}/SectionHeader.tsx | 10 +- .../sidebar/worktree-list/rows/folder-row.tsx | 8 +- .../sidebar/worktree-list/rows/groups.ts | 1561 ----------------- .../header-event-guards.ts} | 0 .../sidebar/worktree-list/rows/item-row.tsx | 9 +- .../worktree-list/rows/notice-rows.tsx | 2 +- .../worktree-list/{ => rows}/option-dom.ts | 0 .../project-group-header-actions.tsx | 2 +- .../repo-header-project-actions.tsx | 2 +- .../{ => rows}/use-project-group-dialogs.ts | 4 +- .../rows/virtual-row-dispatch.tsx | 13 +- .../sidebar/worktree-list/scroll/tuning.ts | 47 - .../viewport/VirtualizedWorktreeViewport.tsx | 101 +- .../viewport/drop-indicators.tsx | 36 + .../hard-scroll-up.test.ts | 0 .../{scroll => viewport}/hard-scroll-up.ts | 0 .../scroll-adjustment.test.ts | 12 +- .../viewport/sticky-headers.test.ts | 4 +- .../viewport/use-row-measurement.ts | 19 +- .../use-scroll-suppression.ts} | 15 +- .../use-scroll-to-top.test.ts | 0 .../use-scroll-to-top.test.tsx | 0 .../{scroll => viewport}/use-scroll-to-top.ts | 0 .../worktree-list/viewport/use-virtualizer.ts | 8 +- .../viewport/use-visible-review-refresh.ts | 12 +- .../worktree-list/viewport/viewport-props.ts | 17 +- .../{rows => viewport}/virtual-row-context.ts | 14 +- .../viewport/virtual-rows.test.ts | 6 +- .../worktree-list/viewport/virtual-rows.ts | 35 +- .../visible-refresh.test.ts | 2 +- .../worktree-sidebar-row-preference.ts | 7 +- 111 files changed, 2270 insertions(+), 2002 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-list/drag/use-drop-commit-context.ts rename src/renderer/src/components/sidebar/worktree-list/{ => drag}/use-status-mutations.ts (94%) rename src/renderer/src/components/sidebar/worktree-list/{rows/groups.test.ts => grouping/build-rows.test.ts} (99%) create mode 100644 src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/grouping/group-keys.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/grouping/group-sections.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.ts rename src/renderer/src/components/sidebar/worktree-list/{rows => grouping}/imported-rows.test.ts (94%) create mode 100644 src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/grouping/project-grouping.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/grouping/row-builders.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/grouping/row-types.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/grouping/section-order.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/grouping/worktree-group-keys.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/grouping/worktree-grouping.ts rename src/renderer/src/components/sidebar/worktree-list/{SidebarWorktreeListEmptyState.tsx => listing/EmptyState.tsx} (100%) rename src/renderer/src/components/sidebar/worktree-list/{viewport => listing}/host-filtering.test.ts (100%) rename src/renderer/src/components/sidebar/worktree-list/{viewport => listing}/host-filtering.ts (100%) create mode 100644 src/renderer/src/components/sidebar/worktree-list/listing/render-row.ts rename src/renderer/src/components/sidebar/worktree-list/{rows => listing}/renderable-rows.ts (92%) rename src/renderer/src/components/sidebar/worktree-list/{viewport => listing}/review-cache-inputs.test.ts (100%) rename src/renderer/src/components/sidebar/worktree-list/{viewport => listing}/review-cache-inputs.ts (96%) rename src/renderer/src/components/sidebar/worktree-list/{use-agent-send-target-worktree.ts => listing/use-agent-send-target.ts} (100%) rename src/renderer/src/components/sidebar/worktree-list/{use-effective-collapsed-groups.ts => listing/use-collapsed-groups.ts} (76%) rename src/renderer/src/components/sidebar/worktree-list/{use-sidebar-external-worktree-cards.ts => listing/use-external-worktree-cards.ts} (95%) rename src/renderer/src/components/sidebar/worktree-list/{use-sidebar-worktree-filters.ts => listing/use-filters.ts} (98%) rename src/renderer/src/components/sidebar/worktree-list/{viewport => listing}/use-folder-path-statuses.ts (100%) rename src/renderer/src/components/sidebar/worktree-list/{use-sidebar-host-visible-scope.ts => listing/use-host-visible-scope.ts} (80%) rename src/renderer/src/components/sidebar/worktree-list/{ => listing}/use-reused-array-identity.ts (89%) rename src/renderer/src/components/sidebar/worktree-list/{use-sidebar-section-rows.ts => listing/use-section-rows.ts} (86%) rename src/renderer/src/components/sidebar/worktree-list/{use-sidebar-worktree-sort-order.ts => listing/use-sort-order.ts} (96%) rename src/renderer/src/components/sidebar/worktree-list/{use-visible-sidebar-worktrees.ts => listing/use-visible-worktrees.ts} (90%) rename src/renderer/src/components/sidebar/worktree-list/{viewport => navigation}/active-descendant-option.ts (88%) rename src/renderer/src/components/sidebar/worktree-list/{reveal => navigation}/folder-reveal.test.ts (98%) rename src/renderer/src/components/sidebar/worktree-list/{reveal => navigation}/folder-reveal.ts (97%) rename src/renderer/src/components/sidebar/worktree-list/{reveal/mounted-row.ts => navigation/mounted-row-reveal.ts} (98%) rename src/renderer/src/components/sidebar/worktree-list/{reveal/pending-inputs.ts => navigation/pending-reveal-inputs.ts} (84%) rename src/renderer/src/components/sidebar/worktree-list/{rows => navigation}/render-row-lookup.ts (91%) rename src/renderer/src/components/sidebar/worktree-list/{reveal/ancestors.ts => navigation/reveal-ancestors.ts} (95%) rename src/renderer/src/components/sidebar/worktree-list/{viewport => navigation}/use-active-row.ts (93%) rename src/renderer/src/components/sidebar/worktree-list/{viewport => navigation}/use-keyboard.ts (95%) rename src/renderer/src/components/sidebar/worktree-list/{reveal/use-pending.ts => navigation/use-pending-reveal.ts} (96%) rename src/renderer/src/components/sidebar/worktree-list/{reveal/use-highlight.ts => navigation/use-reveal-highlight.ts} (100%) rename src/renderer/src/components/sidebar/worktree-list/{reveal/use-requests.ts => navigation/use-reveal-requests.ts} (98%) rename src/renderer/src/components/sidebar/worktree-list/{use-sidebar-worktree-selection.ts => navigation/use-selection.ts} (91%) rename src/renderer/src/components/sidebar/worktree-list/{headers => rows}/FolderPathStatusIndicator.tsx (100%) rename src/renderer/src/components/sidebar/worktree-list/{headers => rows}/HostSectionHeader.tsx (100%) rename src/renderer/src/components/sidebar/worktree-list/{SidebarWorktreeListDialogs.tsx => rows/ProjectGroupDialogs.tsx} (90%) rename src/renderer/src/components/sidebar/worktree-list/{headers => rows}/SectionHeader.tsx (98%) delete mode 100644 src/renderer/src/components/sidebar/worktree-list/rows/groups.ts rename src/renderer/src/components/sidebar/worktree-list/{headers/event-guards.ts => rows/header-event-guards.ts} (100%) rename src/renderer/src/components/sidebar/worktree-list/{ => rows}/option-dom.ts (100%) rename src/renderer/src/components/sidebar/worktree-list/{headers => rows}/project-group-header-actions.tsx (99%) rename src/renderer/src/components/sidebar/worktree-list/{headers => rows}/repo-header-project-actions.tsx (99%) rename src/renderer/src/components/sidebar/worktree-list/{ => rows}/use-project-group-dialogs.ts (97%) delete mode 100644 src/renderer/src/components/sidebar/worktree-list/scroll/tuning.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/viewport/drop-indicators.tsx rename src/renderer/src/components/sidebar/worktree-list/{scroll => viewport}/hard-scroll-up.test.ts (100%) rename src/renderer/src/components/sidebar/worktree-list/{scroll => viewport}/hard-scroll-up.ts (100%) rename src/renderer/src/components/sidebar/worktree-list/{scroll => viewport}/scroll-adjustment.test.ts (95%) rename src/renderer/src/components/sidebar/worktree-list/{scroll/use-suppression.ts => viewport/use-scroll-suppression.ts} (90%) rename src/renderer/src/components/sidebar/worktree-list/{scroll => viewport}/use-scroll-to-top.test.ts (100%) rename src/renderer/src/components/sidebar/worktree-list/{scroll => viewport}/use-scroll-to-top.test.tsx (100%) rename src/renderer/src/components/sidebar/worktree-list/{scroll => viewport}/use-scroll-to-top.ts (100%) rename src/renderer/src/components/sidebar/worktree-list/{rows => viewport}/virtual-row-context.ts (93%) rename src/renderer/src/components/sidebar/worktree-list/{scroll => viewport}/visible-refresh.test.ts (97%) diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 67bc9216ca9..7d9c61bb228 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -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 diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index b7d7a9c98ea..994543bc53b 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.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, diff --git a/src/renderer/src/components/cmd-j/palette-filter-options.ts b/src/renderer/src/components/cmd-j/palette-filter-options.ts index d1143ffeed1..b455354886c 100644 --- a/src/renderer/src/components/cmd-j/palette-filter-options.ts +++ b/src/renderer/src/components/cmd-j/palette-filter-options.ts @@ -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' diff --git a/src/renderer/src/components/cmd-j/palette-project-results.ts b/src/renderer/src/components/cmd-j/palette-project-results.ts index dfc49166783..288a8f9fb76 100644 --- a/src/renderer/src/components/cmd-j/palette-project-results.ts +++ b/src/renderer/src/components/cmd-j/palette-project-results.ts @@ -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 diff --git a/src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts b/src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts index e9ef47ee496..cd585549100 100644 --- a/src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts +++ b/src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts @@ -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'), diff --git a/src/renderer/src/components/sidebar/SidebarGroupByToggle.test.tsx b/src/renderer/src/components/sidebar/SidebarGroupByToggle.test.tsx index 9b13a21b14c..7543486a2b9 100644 --- a/src/renderer/src/components/sidebar/SidebarGroupByToggle.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarGroupByToggle.test.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[] = [] diff --git a/src/renderer/src/components/sidebar/SidebarGroupByToggle.tsx b/src/renderer/src/components/sidebar/SidebarGroupByToggle.tsx index 0476af842a7..c07d01abc39 100644 --- a/src/renderer/src/components/sidebar/SidebarGroupByToggle.tsx +++ b/src/renderer/src/components/sidebar/SidebarGroupByToggle.tsx @@ -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 = { diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts index bbfdce4e993..1592d5f9082 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts @@ -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(() => ({ diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 6e380e8e355..4733168e1fb 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -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 diff --git a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts index b0ddcaf1646..ba81ad49152 100644 --- a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts +++ b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts @@ -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 diff --git a/src/renderer/src/components/sidebar/host-section-rows.test.ts b/src/renderer/src/components/sidebar/host-section-rows.test.ts index 7b658baa42d..38451403a6f 100644 --- a/src/renderer/src/components/sidebar/host-section-rows.test.ts +++ b/src/renderer/src/components/sidebar/host-section-rows.test.ts @@ -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 { diff --git a/src/renderer/src/components/sidebar/host-section-rows.ts b/src/renderer/src/components/sidebar/host-section-rows.ts index fd85d2a0e10..bbe9100bab1 100644 --- a/src/renderer/src/components/sidebar/host-section-rows.ts +++ b/src/renderer/src/components/sidebar/host-section-rows.ts @@ -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' diff --git a/src/renderer/src/components/sidebar/imported-worktrees-card-candidates.ts b/src/renderer/src/components/sidebar/imported-worktrees-card-candidates.ts index ed41f843c9d..21069496918 100644 --- a/src/renderer/src/components/sidebar/imported-worktrees-card-candidates.ts +++ b/src/renderer/src/components/sidebar/imported-worktrees-card-candidates.ts @@ -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 diff --git a/src/renderer/src/components/sidebar/new-external-worktrees-inbox-candidates.ts b/src/renderer/src/components/sidebar/new-external-worktrees-inbox-candidates.ts index 2903e561707..c84bcc6afed 100644 --- a/src/renderer/src/components/sidebar/new-external-worktrees-inbox-candidates.ts +++ b/src/renderer/src/components/sidebar/new-external-worktrees-inbox-candidates.ts @@ -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[] diff --git a/src/renderer/src/components/sidebar/project-group-header-dom.test.ts b/src/renderer/src/components/sidebar/project-group-header-dom.test.ts index a6d178f4d5f..bb3b6e06c85 100644 --- a/src/renderer/src/components/sidebar/project-group-header-dom.test.ts +++ b/src/renderer/src/components/sidebar/project-group-header-dom.test.ts @@ -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' ) } diff --git a/src/renderer/src/components/sidebar/project-group-header-drop.test.ts b/src/renderer/src/components/sidebar/project-group-header-drop.test.ts index aa632730891..24d58f95d54 100644 --- a/src/renderer/src/components/sidebar/project-group-header-drop.test.ts +++ b/src/renderer/src/components/sidebar/project-group-header-drop.test.ts @@ -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' diff --git a/src/renderer/src/components/sidebar/project-group-header-drop.ts b/src/renderer/src/components/sidebar/project-group-header-drop.ts index 83319ccab8e..8dc5e8609aa 100644 --- a/src/renderer/src/components/sidebar/project-group-header-drop.ts +++ b/src/renderer/src/components/sidebar/project-group-header-drop.ts @@ -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 diff --git a/src/renderer/src/components/sidebar/project-header-drop.test.ts b/src/renderer/src/components/sidebar/project-header-drop.test.ts index e0bb8725a82..f5ee78e81e5 100644 --- a/src/renderer/src/components/sidebar/project-header-drop.test.ts +++ b/src/renderer/src/components/sidebar/project-header-drop.test.ts @@ -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', () => { diff --git a/src/renderer/src/components/sidebar/project-header-drop.ts b/src/renderer/src/components/sidebar/project-header-drop.ts index eae08a5ebdf..4cc23ddd9c6 100644 --- a/src/renderer/src/components/sidebar/project-header-drop.ts +++ b/src/renderer/src/components/sidebar/project-header-drop.ts @@ -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 diff --git a/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts b/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts index 587f8b0e130..42f5c84f6f0 100644 --- a/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts +++ b/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts @@ -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 = Object.freeze(new Set()) const EMPTY_IMPORTED_BY_REPO = Object.freeze(new Map()) as never diff --git a/src/renderer/src/components/sidebar/worktree-drag-units.ts b/src/renderer/src/components/sidebar/worktree-drag-units.ts index 0f55bf25888..e53d663a9f0 100644 --- a/src/renderer/src/components/sidebar/worktree-drag-units.ts +++ b/src/renderer/src/components/sidebar/worktree-drag-units.ts @@ -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[] }[] diff --git a/src/renderer/src/components/sidebar/worktree-header-section-boundaries.ts b/src/renderer/src/components/sidebar/worktree-header-section-boundaries.ts index c531e25a304..d6b97601295 100644 --- a/src/renderer/src/components/sidebar/worktree-header-section-boundaries.ts +++ b/src/renderer/src/components/sidebar/worktree-header-section-boundaries.ts @@ -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[], diff --git a/src/renderer/src/components/sidebar/worktree-keyboard-cycle.test.ts b/src/renderer/src/components/sidebar/worktree-keyboard-cycle.test.ts index ccd97e44c76..5bf074f5295 100644 --- a/src/renderer/src/components/sidebar/worktree-keyboard-cycle.test.ts +++ b/src/renderer/src/components/sidebar/worktree-keyboard-cycle.test.ts @@ -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( diff --git a/src/renderer/src/components/sidebar/worktree-keyboard-cycle.ts b/src/renderer/src/components/sidebar/worktree-keyboard-cycle.ts index f2ad55b03b5..b5a54e853bb 100644 --- a/src/renderer/src/components/sidebar/worktree-keyboard-cycle.ts +++ b/src/renderer/src/components/sidebar/worktree-keyboard-cycle.ts @@ -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 diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/drop-commit-context.ts b/src/renderer/src/components/sidebar/worktree-list/drag/drop-commit-context.ts index 5323529ebb2..beb943d57ea 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/drop-commit-context.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/drop-commit-context.ts @@ -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 diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/groups.ts b/src/renderer/src/components/sidebar/worktree-list/drag/groups.ts index 7b693203cfd..f8ee60c495e 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/groups.ts @@ -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' diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/pointer-commit.ts b/src/renderer/src/components/sidebar/worktree-list/drag/pointer-commit.ts index 0f2f9efcb87..01e3a455d41 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/pointer-commit.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/pointer-commit.ts @@ -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' diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/use-drop-commit-context.ts b/src/renderer/src/components/sidebar/worktree-list/drag/use-drop-commit-context.ts new file mode 100644 index 00000000000..53552b50ab4 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/drag/use-drop-commit-context.ts @@ -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 + workspaceStatuses: readonly WorkspaceStatusDefinition[] + session: ReturnType + lineageDrop: ReturnType + runtime: ReturnType + 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( + () => ({ + 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 + ] + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/use-header-drag.ts b/src/renderer/src/components/sidebar/worktree-list/drag/use-header-drag.ts index 498e9c2d7aa..80f608f8665 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/use-header-drag.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/use-header-drag.ts @@ -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 diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-drag.ts b/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-drag.ts index 9398d9a7942..97efbd08a1d 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-drag.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-drag.ts @@ -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' diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-window-events.ts b/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-window-events.ts index 8e7a0fc1a91..b2989fb06ac 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-window-events.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/use-pointer-window-events.ts @@ -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: { diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/use-session.ts b/src/renderer/src/components/sidebar/worktree-list/drag/use-session.ts index a6fe3d438a3..78b25b25f38 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/use-session.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/use-session.ts @@ -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 diff --git a/src/renderer/src/components/sidebar/worktree-list/use-status-mutations.ts b/src/renderer/src/components/sidebar/worktree-list/drag/use-status-mutations.ts similarity index 94% rename from src/renderer/src/components/sidebar/worktree-list/use-status-mutations.ts rename to src/renderer/src/components/sidebar/worktree-list/drag/use-status-mutations.ts index aa58afa10a2..db3716a0d8c 100644 --- a/src/renderer/src/components/sidebar/worktree-list/use-status-mutations.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/use-status-mutations.ts @@ -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[], diff --git a/src/renderer/src/components/sidebar/worktree-list/drag/use-status-row-drag.ts b/src/renderer/src/components/sidebar/worktree-list/drag/use-status-row-drag.ts index ab73b823cc6..69726a6474f 100644 --- a/src/renderer/src/components/sidebar/worktree-list/drag/use-status-row-drag.ts +++ b/src/renderer/src/components/sidebar/worktree-list/drag/use-status-row-drag.ts @@ -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' diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/groups.test.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test.ts similarity index 99% rename from src/renderer/src/components/sidebar/worktree-list/rows/groups.test.ts rename to src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test.ts index 73813108206..6ce4073b739 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test.ts @@ -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 = { diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts new file mode 100644 index 00000000000..3abd7176929 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts @@ -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, + prCache: Record | null, + collapsedGroups: Set, + repoOrder?: Map, + workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(), + projectOrderBy: ProjectOrderBy = 'manual', + lineageById: Record = {}, + worktreeMap = new Map(worktrees.map((worktree) => [worktree.id, worktree])), + nestLineage = false, + settings?: AppState['settings'], + projectGroups: readonly ProjectGroup[] = [], + placeholderRepoIds: ReadonlySet = new Set(), + importedWorktreesByRepo: ReadonlyMap = new Map(), + newExternalWorktreesInboxByRepo: ReadonlyMap< + string, + NewExternalWorktreesInboxCandidate + > = new Map(), + pendingCreations: readonly PendingCreationRef[] = [], + projectGrouping?: ProjectGroupingModel, + folderWorkspaces: readonly FolderWorkspace[] = [], + hostLabelById?: ReadonlyMap, + 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() + + const pendingByRepo = new Map() + 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 +} diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/group-keys.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/group-keys.ts new file mode 100644 index 00000000000..972bd50503e --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/group-keys.ts @@ -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, + prCache: Record | 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. + */ diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/group-sections.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/group-sections.ts new file mode 100644 index 00000000000..31a0d86c0f6 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/group-sections.ts @@ -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 + workspaceStatuses: readonly WorkspaceStatusDefinition[] + repoMap: Map + defaultHostId: ExecutionHostId + hostLabelById: ReadonlyMap | undefined + projectIndex: ProjectGroupingIndex | null + importedWorktreesByRepo: ReadonlyMap + newExternalWorktreesInboxByRepo: ReadonlyMap + pendingByRepo: ReadonlyMap + mixedWorktreeHostContextLabels: Map | undefined + lineageById: Record + worktreeMap: Map + nestLineage: boolean + cyclicLineageIds: ReadonlySet +} + +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 + }) + } + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.ts new file mode 100644 index 00000000000..16f5280ad63 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.ts @@ -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, + projectIndex: ProjectGroupingIndex | null, + hostLabelById: ReadonlyMap | 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, + projectIndex: ProjectGroupingIndex | null, + hostLabelById: ReadonlyMap | undefined +): Map | undefined { + const labelsByRepoId = new Map() + const uniqueLabels = new Set() + 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, + hostLabelById: ReadonlyMap | undefined, + defaultHostId: ExecutionHostId +): Map | undefined { + const labelsByWorktreeId = new Map() + const uniqueHostIds = new Set() + 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, + defaultHostId: ExecutionHostId +): Map | undefined { + if (worktrees.length === 0) { + return undefined + } + const counts = new Map() + const seenWorktreeIds = new Set() + 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, + defaultHostId: ExecutionHostId +): Map | undefined { + if (worktrees.length === 0) { + return undefined + } + const idsByHost = new Map() + const seenWorktreeIds = new Set() + 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 +} diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/imported-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/imported-rows.test.ts similarity index 94% rename from src/renderer/src/components/sidebar/worktree-list/rows/imported-rows.test.ts rename to src/renderer/src/components/sidebar/worktree-list/grouping/imported-rows.test.ts index 0799488647e..7a578ba85a3 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/imported-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/imported-rows.test.ts @@ -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', diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts new file mode 100644 index 00000000000..da42befc634 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts @@ -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 | undefined + } +): void { + const { orderedGroups, projectGroups, folderWorkspaces, projectOrderBy, repoOrder } = args + const { result, collapsedGroups } = ctx + + const groupByProjectGroupId = new Map() + 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() + 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() + 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 + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/project-grouping.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/project-grouping.ts new file mode 100644 index 00000000000..961a0c61d88 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/project-grouping.ts @@ -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 +} + +export type ProjectGroupingIndex = { + projectById: Map + setupByRepoId: Map + surfaceKeysRequiringSetupGroups: Set +} + +const projectGroupingIndexCache = new WeakMap() + +// 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>() + 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() + 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, + 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, + projectGrouping?: ProjectGroupingModel +): ProjectHeaderRevealTarget { + return getProjectGroupingForRepo(repoId, repoMap, buildProjectGroupingIndex(projectGrouping)) +} + +export function addRepoIdToGroup(group: WorktreeGroupEntry, repoId: string): void { + group.repoIds.add(repoId) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/row-builders.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/row-builders.ts new file mode 100644 index 00000000000..3f9617174ce --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/row-builders.ts @@ -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 +): PendingCreationRow { + return { + type: 'pending-creation', + key: `pending:${creation.creationId}`, + creationId: creation.creationId, + repo: repoMap.get(creation.repoId) + } +} + +export function emitPinnedGroup( + worktrees: Worktree[], + repoMap: Map, + defaultHostId: ExecutionHostId, + collapsedGroups: Set, + renderedNaturalAnchorRepoIds: ReadonlySet, + importedWorktreesByRepo: ReadonlyMap, + allowImportedFallback: boolean, + result: Row[] +): void { + const pinned = worktrees.filter((w) => w.isPinned) + if (pinned.length === 0) { + return + } + const hostWorktreeCounts = new Map() + const hostWorktreeIds = new Map() + const pinnedRepoOrder: string[] = [] + const seenPinnedRepoIds = new Set() + 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() + 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, + 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, + lineageById: Record, + worktreeMap: Map, + options: { + nestLineage: boolean + collapsedGroups: Set + groupDepth: number + sectionKey: string + hostContextLabelByRepoId?: ReadonlyMap + hostContextLabelByWorktreeId?: ReadonlyMap + cyclicLineageIds: ReadonlySet + } +): 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() + const childIds = new Set() + 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() + 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) + } + } + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/row-types.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/row-types.ts new file mode 100644 index 00000000000..2e45d5856aa --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/row-types.ts @@ -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 + hostWorktreeIds?: ReadonlyMap + 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 diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/section-order.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/section-order.ts new file mode 100644 index 00000000000..00e7fe46f79 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/section-order.ts @@ -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 + prCache: Record | null + collapsedGroups: ReadonlySet + workspaceStatuses: readonly WorkspaceStatusDefinition[] + settings?: AppState['settings'] + projectGrouping?: ProjectGroupingModel +}): Set { + const renderedRepoIds = new Set() + 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 | 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, + repoOrder: Map | 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 | 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. + */ diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-group-keys.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-group-keys.ts new file mode 100644 index 00000000000..807e41e97f1 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-group-keys.ts @@ -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, + prCache: Record | 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, + prCache: Record | 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() + 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] +} diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-grouping.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-grouping.ts new file mode 100644 index 00000000000..350da045585 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/worktree-grouping.ts @@ -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 + prCache: Record | null + settings: AppState['settings'] | undefined + workspaceStatuses: readonly WorkspaceStatusDefinition[] + projectIndex: ProjectGroupingIndex | null + placeholderRepoIds: ReadonlySet + importedWorktreesByRepo: ReadonlyMap + newExternalWorktreesInboxByRepo: ReadonlyMap + pendingByRepo: ReadonlyMap + repoOrder: Map | undefined + projectOrderBy: ProjectOrderBy +}): OrderedGroupEntry[] { + const { + groupBy, + naturalWorktrees, + repoMap, + prCache, + settings, + workspaceStatuses, + projectIndex, + placeholderRepoIds, + importedWorktreesByRepo, + newExternalWorktreesInboxByRepo, + pendingByRepo, + repoOrder, + projectOrderBy + } = args + + const grouped = new Map() + 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 +} diff --git a/src/renderer/src/components/sidebar/worktree-list/SidebarWorktreeListEmptyState.tsx b/src/renderer/src/components/sidebar/worktree-list/listing/EmptyState.tsx similarity index 100% rename from src/renderer/src/components/sidebar/worktree-list/SidebarWorktreeListEmptyState.tsx rename to src/renderer/src/components/sidebar/worktree-list/listing/EmptyState.tsx diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/host-filtering.test.ts b/src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts similarity index 100% rename from src/renderer/src/components/sidebar/worktree-list/viewport/host-filtering.test.ts rename to src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/host-filtering.ts b/src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.ts similarity index 100% rename from src/renderer/src/components/sidebar/worktree-list/viewport/host-filtering.ts rename to src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.ts diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/render-row.ts b/src/renderer/src/components/sidebar/worktree-list/listing/render-row.ts new file mode 100644 index 00000000000..f3434a695cc --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/listing/render-row.ts @@ -0,0 +1,31 @@ +import type { HostSectionRow } from '../../host-section-rows' + +type WorktreeItemRow = Extract +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}` +} diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/renderable-rows.ts b/src/renderer/src/components/sidebar/worktree-list/listing/renderable-rows.ts similarity index 92% rename from src/renderer/src/components/sidebar/worktree-list/rows/renderable-rows.ts rename to src/renderer/src/components/sidebar/worktree-list/listing/renderable-rows.ts index 1e897f9d3ee..aeab21ce969 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/renderable-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/renderable-rows.ts @@ -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 export type FolderWorkspaceItemRow = Extract diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/review-cache-inputs.test.ts b/src/renderer/src/components/sidebar/worktree-list/listing/review-cache-inputs.test.ts similarity index 100% rename from src/renderer/src/components/sidebar/worktree-list/viewport/review-cache-inputs.test.ts rename to src/renderer/src/components/sidebar/worktree-list/listing/review-cache-inputs.test.ts diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/review-cache-inputs.ts b/src/renderer/src/components/sidebar/worktree-list/listing/review-cache-inputs.ts similarity index 96% rename from src/renderer/src/components/sidebar/worktree-list/viewport/review-cache-inputs.ts rename to src/renderer/src/components/sidebar/worktree-list/listing/review-cache-inputs.ts index d581b1c5194..6d0d78ad6f6 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/review-cache-inputs.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/review-cache-inputs.ts @@ -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, diff --git a/src/renderer/src/components/sidebar/worktree-list/use-agent-send-target-worktree.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-agent-send-target.ts similarity index 100% rename from src/renderer/src/components/sidebar/worktree-list/use-agent-send-target-worktree.ts rename to src/renderer/src/components/sidebar/worktree-list/listing/use-agent-send-target.ts diff --git a/src/renderer/src/components/sidebar/worktree-list/use-effective-collapsed-groups.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-collapsed-groups.ts similarity index 76% rename from src/renderer/src/components/sidebar/worktree-list/use-effective-collapsed-groups.ts rename to src/renderer/src/components/sidebar/worktree-list/listing/use-collapsed-groups.ts index d8c89febdee..f843c711e74 100644 --- a/src/renderer/src/components/sidebar/worktree-list/use-effective-collapsed-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-collapsed-groups.ts @@ -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: { diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-external-worktree-cards.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-external-worktree-cards.ts similarity index 95% rename from src/renderer/src/components/sidebar/worktree-list/use-sidebar-external-worktree-cards.ts rename to src/renderer/src/components/sidebar/worktree-list/listing/use-external-worktree-cards.ts index 1083b5e4084..04e92771c0e 100644 --- a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-external-worktree-cards.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-external-worktree-cards.ts @@ -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. diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-filters.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts similarity index 98% rename from src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-filters.ts rename to src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts index 7f59d344e6d..f04e57194af 100644 --- a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-filters.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts @@ -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 diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/use-folder-path-statuses.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-folder-path-statuses.ts similarity index 100% rename from src/renderer/src/components/sidebar/worktree-list/viewport/use-folder-path-statuses.ts rename to src/renderer/src/components/sidebar/worktree-list/listing/use-folder-path-statuses.ts diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-host-visible-scope.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-host-visible-scope.ts similarity index 80% rename from src/renderer/src/components/sidebar/worktree-list/use-sidebar-host-visible-scope.ts rename to src/renderer/src/components/sidebar/worktree-list/listing/use-host-visible-scope.ts index 7de2a2fa1a4..738a2999248 100644 --- a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-host-visible-scope.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-host-visible-scope.ts @@ -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. diff --git a/src/renderer/src/components/sidebar/worktree-list/use-reused-array-identity.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-reused-array-identity.ts similarity index 89% rename from src/renderer/src/components/sidebar/worktree-list/use-reused-array-identity.ts rename to src/renderer/src/components/sidebar/worktree-list/listing/use-reused-array-identity.ts index 892f2d7694a..0992c630dda 100644 --- a/src/renderer/src/components/sidebar/worktree-list/use-reused-array-identity.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-reused-array-identity.ts @@ -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(next: T[]): T[] { diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-section-rows.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts similarity index 86% rename from src/renderer/src/components/sidebar/worktree-list/use-sidebar-section-rows.ts rename to src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts index 8ca71a0245c..16899dbc146 100644 --- a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-section-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts @@ -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 diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-sort-order.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.ts similarity index 96% rename from src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-sort-order.ts rename to src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.ts index ed276cdbf49..dd739895972 100644 --- a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-sort-order.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-sort-order.ts @@ -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): void { let class1 = 0 diff --git a/src/renderer/src/components/sidebar/worktree-list/use-visible-sidebar-worktrees.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts similarity index 90% rename from src/renderer/src/components/sidebar/worktree-list/use-visible-sidebar-worktrees.ts rename to src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts index 7f560d20af0..a86e5b4063a 100644 --- a/src/renderer/src/components/sidebar/worktree-list/use-visible-sidebar-worktrees.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts @@ -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 = new Set() diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/active-descendant-option.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/active-descendant-option.ts similarity index 88% rename from src/renderer/src/components/sidebar/worktree-list/viewport/active-descendant-option.ts rename to src/renderer/src/components/sidebar/worktree-list/navigation/active-descendant-option.ts index 0d802ec2fd3..131efe54977 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/active-descendant-option.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/active-descendant-option.ts @@ -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, diff --git a/src/renderer/src/components/sidebar/worktree-list/reveal/folder-reveal.test.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.test.ts similarity index 98% rename from src/renderer/src/components/sidebar/worktree-list/reveal/folder-reveal.test.ts rename to src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.test.ts index 532c9fd6484..bfe5226f889 100644 --- a/src/renderer/src/components/sidebar/worktree-list/reveal/folder-reveal.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.test.ts @@ -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 { return { diff --git a/src/renderer/src/components/sidebar/worktree-list/reveal/folder-reveal.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.ts similarity index 97% rename from src/renderer/src/components/sidebar/worktree-list/reveal/folder-reveal.ts rename to src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.ts index 9694ce4552e..766ab310227 100644 --- a/src/renderer/src/components/sidebar/worktree-list/reveal/folder-reveal.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.ts @@ -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, diff --git a/src/renderer/src/components/sidebar/worktree-list/reveal/mounted-row.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/mounted-row-reveal.ts similarity index 98% rename from src/renderer/src/components/sidebar/worktree-list/reveal/mounted-row.ts rename to src/renderer/src/components/sidebar/worktree-list/navigation/mounted-row-reveal.ts index b22e55a5b0b..b147c30d044 100644 --- a/src/renderer/src/components/sidebar/worktree-list/reveal/mounted-row.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/mounted-row-reveal.ts @@ -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, diff --git a/src/renderer/src/components/sidebar/worktree-list/reveal/pending-inputs.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts similarity index 84% rename from src/renderer/src/components/sidebar/worktree-list/reveal/pending-inputs.ts rename to src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts index ef19e779d58..edcd6353b35 100644 --- a/src/renderer/src/components/sidebar/worktree-list/reveal/pending-inputs.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/pending-reveal-inputs.ts @@ -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' +} diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/render-row-lookup.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.ts similarity index 91% rename from src/renderer/src/components/sidebar/worktree-list/rows/render-row-lookup.ts rename to src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.ts index be74eaabf85..f4bd65cc4f0 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/render-row-lookup.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.ts @@ -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') { diff --git a/src/renderer/src/components/sidebar/worktree-list/reveal/ancestors.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/reveal-ancestors.ts similarity index 95% rename from src/renderer/src/components/sidebar/worktree-list/reveal/ancestors.ts rename to src/renderer/src/components/sidebar/worktree-list/navigation/reveal-ancestors.ts index 6f09e8bdb3f..95a988748eb 100644 --- a/src/renderer/src/components/sidebar/worktree-list/reveal/ancestors.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/reveal-ancestors.ts @@ -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:')) { diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/use-active-row.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/use-active-row.ts similarity index 93% rename from src/renderer/src/components/sidebar/worktree-list/viewport/use-active-row.ts rename to src/renderer/src/components/sidebar/worktree-list/navigation/use-active-row.ts index b9308714401..da5669125e3 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/use-active-row.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/use-active-row.ts @@ -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. diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/use-keyboard.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.ts similarity index 95% rename from src/renderer/src/components/sidebar/worktree-list/viewport/use-keyboard.ts rename to src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.ts index 6a737d70c1d..dc96f4d9849 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/use-keyboard.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.ts @@ -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)) { diff --git a/src/renderer/src/components/sidebar/worktree-list/reveal/use-pending.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/use-pending-reveal.ts similarity index 96% rename from src/renderer/src/components/sidebar/worktree-list/reveal/use-pending.ts rename to src/renderer/src/components/sidebar/worktree-list/navigation/use-pending-reveal.ts index 69cc396c28a..f3cd6a7ccae 100644 --- a/src/renderer/src/components/sidebar/worktree-list/reveal/use-pending.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/use-pending-reveal.ts @@ -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". diff --git a/src/renderer/src/components/sidebar/worktree-list/reveal/use-highlight.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/use-reveal-highlight.ts similarity index 100% rename from src/renderer/src/components/sidebar/worktree-list/reveal/use-highlight.ts rename to src/renderer/src/components/sidebar/worktree-list/navigation/use-reveal-highlight.ts diff --git a/src/renderer/src/components/sidebar/worktree-list/reveal/use-requests.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/use-reveal-requests.ts similarity index 98% rename from src/renderer/src/components/sidebar/worktree-list/reveal/use-requests.ts rename to src/renderer/src/components/sidebar/worktree-list/navigation/use-reveal-requests.ts index 9e57cc98885..62119d714a0 100644 --- a/src/renderer/src/components/sidebar/worktree-list/reveal/use-requests.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/use-reveal-requests.ts @@ -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 diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-selection.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/use-selection.ts similarity index 91% rename from src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-selection.ts rename to src/renderer/src/components/sidebar/worktree-list/navigation/use-selection.ts index 74e365b6865..23008561874 100644 --- a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-selection.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/use-selection.ts @@ -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)) diff --git a/src/renderer/src/components/sidebar/worktree-list/headers/FolderPathStatusIndicator.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/FolderPathStatusIndicator.tsx similarity index 100% rename from src/renderer/src/components/sidebar/worktree-list/headers/FolderPathStatusIndicator.tsx rename to src/renderer/src/components/sidebar/worktree-list/rows/FolderPathStatusIndicator.tsx diff --git a/src/renderer/src/components/sidebar/worktree-list/headers/HostSectionHeader.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/HostSectionHeader.tsx similarity index 100% rename from src/renderer/src/components/sidebar/worktree-list/headers/HostSectionHeader.tsx rename to src/renderer/src/components/sidebar/worktree-list/rows/HostSectionHeader.tsx diff --git a/src/renderer/src/components/sidebar/worktree-list/SidebarWorktreeListDialogs.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/ProjectGroupDialogs.tsx similarity index 90% rename from src/renderer/src/components/sidebar/worktree-list/SidebarWorktreeListDialogs.tsx rename to src/renderer/src/components/sidebar/worktree-list/rows/ProjectGroupDialogs.tsx index cd46a80c3c7..c25c73b10d8 100644 --- a/src/renderer/src/components/sidebar/worktree-list/SidebarWorktreeListDialogs.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/ProjectGroupDialogs.tsx @@ -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({ diff --git a/src/renderer/src/components/sidebar/worktree-list/headers/SectionHeader.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx similarity index 98% rename from src/renderer/src/components/sidebar/worktree-list/headers/SectionHeader.tsx rename to src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx index 83e3a823b3b..727438f997a 100644 --- a/src/renderer/src/components/sidebar/worktree-list/headers/SectionHeader.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx @@ -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 diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/folder-row.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/folder-row.tsx index 0a4f9f91e80..a8d235c7833 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/folder-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/folder-row.tsx @@ -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 diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/groups.ts b/src/renderer/src/components/sidebar/worktree-list/rows/groups.ts deleted file mode 100644 index 67b36fda423..00000000000 --- a/src/renderer/src/components/sidebar/worktree-list/rows/groups.ts +++ /dev/null @@ -1,1561 +0,0 @@ -/* eslint-disable max-lines -- Why: sidebar row construction keeps every grouping mode in one pure module so reveal, virtualized rendering, and tests share the same flat row contract. */ -import { CircleX, FolderTree, List, Pin } from 'lucide-react' -import type React from 'react' -import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../../../../../shared/project-group-types' -import type { Project, ProjectHostSetup } from '../../../../../../shared/project-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 { - DetectedWorktree, - WorkspaceStatusDefinition, - Worktree -} from '../../../../../../shared/worktree/types' -import { branchName } from '../../../../lib/git-utils' -import { - getWorkspaceStatus, - getWorkspaceStatusFromGroupKey, - getWorkspaceStatusGroupKey, - getWorkspaceStatusVisualMeta -} from '../../workspace-status' -import { - ConductorDoneIcon, - ConductorProgressIcon, - ConductorReviewIcon -} from '../../workspace-status-icons' -import { - getEffectiveProjectGroupManualRank, - UNGROUPED_PROJECT_GROUP_KEY -} from '../../../../../../shared/project-groups' -import { cloneDefaultWorkspaceStatuses } from '../../../../../../shared/workspace-statuses' -import type { AppState } from '../../../../store/types' -import { - getGitHubPRCacheKey, - getLegacyGitHubPRCacheKey -} from '../../../../store/slices/github-cache-key' -import { getRepoDisplayLabelKey, getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels' -import { translate } from '@/i18n/i18n' -import { - getExecutionHostLabel, - LOCAL_EXECUTION_HOST_ID, - getRepoExecutionHostId, - getWorktreeExecutionHostId, - toSshExecutionHostId, - type ExecutionHostId -} from '../../../../../../shared/execution-host' -import { parseWslUncPath } from '../../../../../../shared/wsl-paths' -import { - isWindowsAbsolutePathLike, - normalizeRuntimePathForComparison -} from '../../../../../../shared/cross-platform-path' -import { - getCyclicProjectedWorktreeLineageIds, - getLineageRenderInfo -} from '../../worktree-lineage-projection' - -export { getLineageRenderInfo } from '../../worktree-lineage-projection' - -export { branchName } - -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 - hostWorktreeIds?: ReadonlyMap - 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 - -function buildPendingCreationRow( - creation: PendingCreationRef, - repoMap: Map -): PendingCreationRow { - return { - type: 'pending-creation', - key: `pending:${creation.creationId}`, - creationId: creation.creationId, - repo: repoMap.get(creation.repoId) - } -} - -type OrderedGroupEntry = [string, WorktreeGroupEntry] - -export type ProjectGroupingModel = { - projects: readonly Project[] - projectHostSetups: readonly ProjectHostSetup[] -} - -type WorktreeGroupEntry = { - label: string - items: Worktree[] - repo?: Repo - repoIds: Set -} - -type ProjectGroupingIndex = { - projectById: Map - setupByRepoId: Map - surfaceKeysRequiringSetupGroups: Set -} - -const projectGroupingIndexCache = new WeakMap() - -// 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' -} - -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>() - 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() - 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 -} - -function getProjectGroupingForRepo( - repoId: string, - repoMap: Map, - 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, - projectGrouping?: ProjectGroupingModel -): ProjectHeaderRevealTarget { - return getProjectGroupingForRepo(repoId, repoMap, buildProjectGroupingIndex(projectGrouping)) -} - -function addRepoIdToGroup(group: WorktreeGroupEntry, repoId: string): void { - group.repoIds.add(repoId) -} - -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, - prCache: Record | 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. - */ -function emitPinnedGroup( - worktrees: Worktree[], - repoMap: Map, - defaultHostId: ExecutionHostId, - collapsedGroups: Set, - renderedNaturalAnchorRepoIds: ReadonlySet, - importedWorktreesByRepo: ReadonlyMap, - allowImportedFallback: boolean, - result: Row[] -): void { - const pinned = worktrees.filter((w) => w.isPinned) - if (pinned.length === 0) { - return - } - const hostWorktreeCounts = new Map() - const hostWorktreeIds = new Map() - const pinnedRepoOrder: string[] = [] - const seenPinnedRepoIds = new Set() - 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() - 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')) - } - } - } -} - -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 - } -} - -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, - 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 } : {}) - } -} - -function appendWorktreeRows( - result: Row[], - worktrees: Worktree[], - repoMap: Map, - lineageById: Record, - worktreeMap: Map, - options: { - nestLineage: boolean - collapsedGroups: Set - groupDepth: number - sectionKey: string - hostContextLabelByRepoId?: ReadonlyMap - hostContextLabelByWorktreeId?: ReadonlyMap - cyclicLineageIds: ReadonlySet - } -): 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() - const childIds = new Set() - 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() - 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) - } - } - } -} - -function getRepoHostLabel( - repoId: string, - repoMap: Map, - projectIndex: ProjectGroupingIndex | null, - hostLabelById: ReadonlyMap | 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) -} - -function getMixedHostContextLabels( - group: WorktreeGroupEntry, - repoMap: Map, - projectIndex: ProjectGroupingIndex | null, - hostLabelById: ReadonlyMap | undefined -): Map | undefined { - const labelsByRepoId = new Map() - const uniqueLabels = new Set() - 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 -} - -function getMixedWorktreeHostContextLabels( - worktrees: readonly Worktree[], - repoMap: Map, - hostLabelById: ReadonlyMap | undefined, - defaultHostId: ExecutionHostId -): Map | undefined { - const labelsByWorktreeId = new Map() - const uniqueHostIds = new Set() - 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 -} - -function getHostWorktreeCounts( - worktrees: readonly Worktree[], - repoMap: Map, - defaultHostId: ExecutionHostId -): Map | undefined { - if (worktrees.length === 0) { - return undefined - } - const counts = new Map() - const seenWorktreeIds = new Set() - 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 -} - -function getHostWorktreeIds( - worktrees: readonly Worktree[], - repoMap: Map, - defaultHostId: ExecutionHostId -): Map | undefined { - if (worktrees.length === 0) { - return undefined - } - const idsByHost = new Map() - const seenWorktreeIds = new Set() - 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 -} - -function getRenderedNaturalAnchorRepoIds({ - groupBy, - worktrees, - repoMap, - prCache, - collapsedGroups, - workspaceStatuses, - settings, - projectGrouping -}: { - groupBy: WorktreeGroupBy - worktrees: readonly Worktree[] - repoMap: Map - prCache: Record | null - collapsedGroups: ReadonlySet - workspaceStatuses: readonly WorkspaceStatusDefinition[] - settings?: AppState['settings'] - projectGrouping?: ProjectGroupingModel -}): Set { - const renderedRepoIds = new Set() - 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 -} - -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)] -} - -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 } - -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 - } -} - -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 | 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 -} - -function getManualOrderAnchorRepo( - group: WorktreeGroupEntry, - repoMap: Map, - repoOrder: Map | 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. - */ -function sortProjectEntries( - entries: OrderedGroupEntry[], - projectOrderBy: ProjectOrderBy, - repoOrder: Map | 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. - */ -export function buildRows( - groupBy: WorktreeGroupBy, - worktrees: Worktree[], - repoMap: Map, - prCache: Record | null, - collapsedGroups: Set, - repoOrder?: Map, - workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(), - projectOrderBy: ProjectOrderBy = 'manual', - lineageById: Record = {}, - worktreeMap = new Map(worktrees.map((worktree) => [worktree.id, worktree])), - nestLineage = false, - settings?: AppState['settings'], - projectGroups: readonly ProjectGroup[] = [], - placeholderRepoIds: ReadonlySet = new Set(), - importedWorktreesByRepo: ReadonlyMap = new Map(), - newExternalWorktreesInboxByRepo: ReadonlyMap< - string, - NewExternalWorktreesInboxCandidate - > = new Map(), - pendingCreations: readonly PendingCreationRef[] = [], - projectGrouping?: ProjectGroupingModel, - folderWorkspaces: readonly FolderWorkspace[] = [], - hostLabelById?: ReadonlyMap, - 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() - - const pendingByRepo = new Map() - 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 grouped = new Map() - 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) - } - } - - const appendOrderedGroups = ( - groupsToAppend: OrderedGroupEntry[], - projectGroupDepth = 0 - ): void => { - 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 - if (groupBy === 'repo') { - appendWorktreeRows(result, items, repoMap, lineageById, worktreeMap, { - nestLineage, - collapsedGroups, - groupDepth: projectGroupDepth, - sectionKey: key, - hostContextLabelByRepoId, - hostContextLabelByWorktreeId, - cyclicLineageIds - }) - } else { - appendWorktreeRows(result, items, repoMap, lineageById, worktreeMap, { - nestLineage, - collapsedGroups, - groupDepth: projectGroupDepth, - sectionKey: key, - hostContextLabelByRepoId, - hostContextLabelByWorktreeId, - cyclicLineageIds - }) - } - } - } - } - - if (groupBy !== 'repo' || projectGroups.length === 0) { - appendOrderedGroups( - groupBy === 'repo' ? withRepoSectionDisplayLabels(orderedGroups) : orderedGroups - ) - return result - } - - const groupByProjectGroupId = new Map() - 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() - 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() - 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(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( - withRepoSectionDisplayLabels(sortRepoEntriesWithinGroup(remainingRepoEntries)), - 0 - ) - - return result -} - -export function getGroupKeyForWorktree( - groupBy: WorktreeGroupBy, - worktree: Worktree, - repoMap: Map, - prCache: Record | 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, - prCache: Record | 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() - 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] -} diff --git a/src/renderer/src/components/sidebar/worktree-list/headers/event-guards.ts b/src/renderer/src/components/sidebar/worktree-list/rows/header-event-guards.ts similarity index 100% rename from src/renderer/src/components/sidebar/worktree-list/headers/event-guards.ts rename to src/renderer/src/components/sidebar/worktree-list/rows/header-event-guards.ts diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx index 3b24e11b2ad..974a2afdb86 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/item-row.tsx @@ -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 = { diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/notice-rows.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/notice-rows.tsx index 0882e4d86a7..b240d0d93a4 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/notice-rows.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/notice-rows.tsx @@ -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' diff --git a/src/renderer/src/components/sidebar/worktree-list/option-dom.ts b/src/renderer/src/components/sidebar/worktree-list/rows/option-dom.ts similarity index 100% rename from src/renderer/src/components/sidebar/worktree-list/option-dom.ts rename to src/renderer/src/components/sidebar/worktree-list/rows/option-dom.ts diff --git a/src/renderer/src/components/sidebar/worktree-list/headers/project-group-header-actions.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/project-group-header-actions.tsx similarity index 99% rename from src/renderer/src/components/sidebar/worktree-list/headers/project-group-header-actions.tsx rename to src/renderer/src/components/sidebar/worktree-list/rows/project-group-header-actions.tsx index 62270c43742..6cba3fbea82 100644 --- a/src/renderer/src/components/sidebar/worktree-list/headers/project-group-header-actions.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/project-group-header-actions.tsx @@ -18,7 +18,7 @@ import { handleRepoHeaderActionPointerDown, stopRepoHeaderKeyboardToggle, stopRepoHeaderMenuEvent -} from './event-guards' +} from './header-event-guards' export function ProjectGroupHeaderMenu({ groupId, diff --git a/src/renderer/src/components/sidebar/worktree-list/headers/repo-header-project-actions.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/repo-header-project-actions.tsx similarity index 99% rename from src/renderer/src/components/sidebar/worktree-list/headers/repo-header-project-actions.tsx rename to src/renderer/src/components/sidebar/worktree-list/rows/repo-header-project-actions.tsx index 60a98017b7a..379a8d8e614 100644 --- a/src/renderer/src/components/sidebar/worktree-list/headers/repo-header-project-actions.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/repo-header-project-actions.tsx @@ -42,7 +42,7 @@ import { handleRepoHeaderActionPointerDown, stopRepoHeaderKeyboardToggle, stopRepoHeaderMenuEvent -} from './event-guards' +} from './header-event-guards' function getWorktreeVisibilityMenuLabel( repo: Repo, diff --git a/src/renderer/src/components/sidebar/worktree-list/use-project-group-dialogs.ts b/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts similarity index 97% rename from src/renderer/src/components/sidebar/worktree-list/use-project-group-dialogs.ts rename to src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts index 800852f9570..b66eb737af5 100644 --- a/src/renderer/src/components/sidebar/worktree-list/use-project-group-dialogs.ts +++ b/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts @@ -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 } diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/virtual-row-dispatch.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/virtual-row-dispatch.tsx index e35899266af..2981021c76a 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/virtual-row-dispatch.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/virtual-row-dispatch.tsx @@ -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 = { diff --git a/src/renderer/src/components/sidebar/worktree-list/scroll/tuning.ts b/src/renderer/src/components/sidebar/worktree-list/scroll/tuning.ts deleted file mode 100644 index 6a163d36acc..00000000000 --- a/src/renderer/src/components/sidebar/worktree-list/scroll/tuning.ts +++ /dev/null @@ -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, number>() - -export function countRecordKeysByReference(record: Record): 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) -} diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx b/src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx index 4b87d668701..877c1e39bdd 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx @@ -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( - () => ({ - 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 ? ( - - ) : null} - {headerDrag.canReorderProjectGroupHeaders && - headerDrag.projectGroupDrag.state.draggingGroupId !== null && - headerDrag.projectGroupDrag.state.dropIndicatorY !== null ? ( - - ) : null} - {headerDrag.hostDrag.state.draggingHostId !== null && - headerDrag.hostDrag.state.dropIndicatorY !== null ? ( - - ) : null} - {runtime.worktreeDragState.draggingWorktreeId !== null && - runtime.worktreeDragState.dropIndicatorY !== null ? ( - - ) : null} + {renderWorktreeSidebarDropIndicators({ + headerDrag, + worktreeDragState: runtime.worktreeDragState + })} {virtualItems.map((vItem) => { const row = renderRows[vItem.index] return row ? renderWorktreeVirtualRow(rowContext, row, vItem) : null diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/drop-indicators.tsx b/src/renderer/src/components/sidebar/worktree-list/viewport/drop-indicators.tsx new file mode 100644 index 00000000000..031667fbe67 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/drop-indicators.tsx @@ -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 + worktreeDragState: ReturnType['worktreeDragState'] +}): React.ReactNode { + const { headerDrag, worktreeDragState } = args + return ( + <> + {headerDrag.canReorderRepoHeaders && + headerDrag.repoDrag.state.draggingRepoId !== null && + headerDrag.repoDrag.state.dropIndicatorY !== null ? ( + + ) : null} + {headerDrag.canReorderProjectGroupHeaders && + headerDrag.projectGroupDrag.state.draggingGroupId !== null && + headerDrag.projectGroupDrag.state.dropIndicatorY !== null ? ( + + ) : null} + {headerDrag.hostDrag.state.draggingHostId !== null && + headerDrag.hostDrag.state.dropIndicatorY !== null ? ( + + ) : null} + {worktreeDragState.draggingWorktreeId !== null && + worktreeDragState.dropIndicatorY !== null ? ( + + ) : null} + + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/scroll/hard-scroll-up.test.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/hard-scroll-up.test.ts similarity index 100% rename from src/renderer/src/components/sidebar/worktree-list/scroll/hard-scroll-up.test.ts rename to src/renderer/src/components/sidebar/worktree-list/viewport/hard-scroll-up.test.ts diff --git a/src/renderer/src/components/sidebar/worktree-list/scroll/hard-scroll-up.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/hard-scroll-up.ts similarity index 100% rename from src/renderer/src/components/sidebar/worktree-list/scroll/hard-scroll-up.ts rename to src/renderer/src/components/sidebar/worktree-list/viewport/hard-scroll-up.ts diff --git a/src/renderer/src/components/sidebar/worktree-list/scroll/scroll-adjustment.test.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/scroll-adjustment.test.ts similarity index 95% rename from src/renderer/src/components/sidebar/worktree-list/scroll/scroll-adjustment.test.ts rename to src/renderer/src/components/sidebar/worktree-list/viewport/scroll-adjustment.test.ts index 73ca1cf1c90..86f68d18acf 100644 --- a/src/renderer/src/components/sidebar/worktree-list/scroll/scroll-adjustment.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/scroll-adjustment.test.ts @@ -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', diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/sticky-headers.test.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/sticky-headers.test.ts index 37277f945ee..0e1ddf958a6 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/sticky-headers.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/sticky-headers.test.ts @@ -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', diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-measurement.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-measurement.ts index 392f88625ce..5526c2d39c9 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-measurement.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-measurement.ts @@ -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, number>() + +export function countRecordKeysByReference(record: Record): 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: { diff --git a/src/renderer/src/components/sidebar/worktree-list/scroll/use-suppression.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-suppression.ts similarity index 90% rename from src/renderer/src/components/sidebar/worktree-list/scroll/use-suppression.ts rename to src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-suppression.ts index 442dc36400f..6f902666ef2 100644 --- a/src/renderer/src/components/sidebar/worktree-list/scroll/use-suppression.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-suppression.ts @@ -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 diff --git a/src/renderer/src/components/sidebar/worktree-list/scroll/use-scroll-to-top.test.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-to-top.test.ts similarity index 100% rename from src/renderer/src/components/sidebar/worktree-list/scroll/use-scroll-to-top.test.ts rename to src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-to-top.test.ts diff --git a/src/renderer/src/components/sidebar/worktree-list/scroll/use-scroll-to-top.test.tsx b/src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-to-top.test.tsx similarity index 100% rename from src/renderer/src/components/sidebar/worktree-list/scroll/use-scroll-to-top.test.tsx rename to src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-to-top.test.tsx diff --git a/src/renderer/src/components/sidebar/worktree-list/scroll/use-scroll-to-top.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-to-top.ts similarity index 100% rename from src/renderer/src/components/sidebar/worktree-list/scroll/use-scroll-to-top.ts rename to src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-to-top.ts diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/use-virtualizer.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/use-virtualizer.ts index 52d6c17f54e..fd2ebf96541 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/use-virtualizer.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/use-virtualizer.ts @@ -8,17 +8,17 @@ import type { Range } from '@tanstack/react-virtual' import { estimateRenderRowSize, extractWorktreeVirtualRowIndexes, - getRenderRowKey, getStickyHeaderIndexes, getVirtualRowIndex, - WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP, - type RenderRow + WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP } from './virtual-rows' +import { getRenderRowKey } from '../listing/render-row' +import type { RenderRow } from '../listing/render-row' import { WORKTREE_SIDEBAR_REVEAL_TOP_INSET } from '../../worktree-sidebar-reveal' import { shouldAdjustWorktreeSidebarMeasuredRowScroll, USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS -} from '../scroll/tuning' +} from './use-scroll-suppression' export type WorktreeListVirtualizer = ReturnType diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/use-visible-review-refresh.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/use-visible-review-refresh.ts index ac3cb9eb071..727932a0738 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/use-visible-review-refresh.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/use-visible-review-refresh.ts @@ -4,10 +4,14 @@ import type { VirtualItem } from '@tanstack/react-virtual' import { useAppStore } from '@/store' import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility' import type { Worktree } from '../../../../../../shared/worktree/types' -import type { WorktreeGroupBy } from '../rows/groups' -import type { RenderRow } from './virtual-rows' -import type { WorktreeItemRow } from '../rows/renderable-rows' -import { installWorktreeVisibleRefreshVisibilityListener } from '../scroll/tuning' +import type { WorktreeGroupBy } from '../grouping/row-types' +import type { RenderRow } from '../listing/render-row' +import type { WorktreeItemRow } from '../listing/renderable-rows' + +export function installWorktreeVisibleRefreshVisibilityListener(onChange: () => void): () => void { + document.addEventListener('visibilitychange', onChange) + return () => document.removeEventListener('visibilitychange', onChange) +} const DOCUMENT_HIDDEN_KEY = '__document_hidden__' const NOTHING_TO_TRACK_KEY = '__hidden__' diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/viewport-props.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/viewport-props.ts index 8513e34c2cb..3e6a3e85a5d 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/viewport-props.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/viewport-props.ts @@ -20,22 +20,11 @@ import type { HostSectionRow } from '../../host-section-rows' import type { ImportedWorktreeCardActionState } from '../../imported-worktrees-card-actions' import type { NewExternalWorktreesInboxActionState } from '../../new-external-worktrees-inbox-actions' import type { WorktreeDragGroup } from '../../worktree-manual-order' -import type { - PinnedWorktreeDisplayPolicy, - ProjectGroupingModel, - WorktreeGroupBy -} from '../rows/groups' +import type { WorktreeStatusDropAtIndexArgs } from '../drag/drop-commit-context' +import type { ProjectGroupingModel } from '../grouping/project-grouping' +import type { PinnedWorktreeDisplayPolicy, WorktreeGroupBy } from '../grouping/row-types' export const EMPTY_PROJECT_GROUPS: readonly ProjectGroup[] = [] -export const NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK = (): void => {} - -export type WorktreeStatusDropAtIndexArgs = { - worktreeIds: readonly string[] - status: WorkspaceStatus - dropIndex: number - groups: readonly WorktreeDragGroup[] -} - export type VirtualizedWorktreeViewportProps = { rows: HostSectionRow[] activeWorktreeId: string | null diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/virtual-row-context.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts similarity index 93% rename from src/renderer/src/components/sidebar/worktree-list/rows/virtual-row-context.ts rename to src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts index 4390538289a..6123b3a625a 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/virtual-row-context.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts @@ -1,19 +1,19 @@ import type React from 'react' import type { AppState } from '@/store/types' import type { ProjectGroup } from '../../../../../../shared/project-group-types' -import type { RenderRow } from '../viewport/virtual-rows' +import type { RenderRow } from '../listing/render-row' import type { LineageToggleHandler } from '../../worktree-lineage-toggle-handler-cache' -import type { SidebarRevealHighlight } from '../reveal/use-highlight' +import type { SidebarRevealHighlight } from '../navigation/use-reveal-highlight' import type { WorktreeDragRuntime } from '../drag/use-runtime' import type { WorktreeDragSession } from '../drag/use-session' -import type { useFolderWorkspacePathStatusRows } from '../viewport/use-folder-path-statuses' -import type { usePrimaryActiveWorktreeRow } from '../viewport/use-active-row' +import type { useFolderWorkspacePathStatusRows } from '../listing/use-folder-path-statuses' +import type { usePrimaryActiveWorktreeRow } from '../navigation/use-active-row' import type { useWorkspaceStatusRowDrag } from '../drag/use-status-row-drag' import type { useWorktreeNativeDrag } from '../drag/use-native-drag' import type { WorktreeSidebarHeaderDrag } from '../drag/use-header-drag' -import type { WorktreeListVirtualizer } from '../viewport/use-virtualizer' -import type { VirtualizedWorktreeViewportProps } from '../viewport/viewport-props' -import type { WorktreeVirtualRowContext } from './virtual-row-dispatch' +import type { WorktreeListVirtualizer } from './use-virtualizer' +import type { VirtualizedWorktreeViewportProps } from './viewport-props' +import type { WorktreeVirtualRowContext } from '../rows/virtual-row-dispatch' import { getRepoOwnerWorktreeVisibilityDefaults } from '../../../../store/worktree-visibility-defaults-by-host' type BuildArgs = { diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.test.ts index 71d9066cd11..82ce28019c8 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.test.ts @@ -6,11 +6,11 @@ import { buildLineageRowRekeyMap, extractWorktreeVirtualRowIndexes, getActiveStickyIndexesForScroll, - getRenderRowKey, getStickyHeaderIndexes, - pruneStaleVirtualRowElementCache, - type RenderRow + pruneStaleVirtualRowElementCache } from './virtual-rows' +import { getRenderRowKey } from '../listing/render-row' +import type { RenderRow } from '../listing/render-row' function hostRow(hostId: ExecutionHostId): RenderRow { return { diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.ts index b3e868e9893..0b176f2e4af 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.ts @@ -1,7 +1,8 @@ import { defaultRangeExtractor } from '@tanstack/react-virtual' import type { Range, VirtualItem } from '@tanstack/react-virtual' -import type { HostSectionRow } from '../../host-section-rows' -import { getLineageGroupKey, PINNED_GROUP_KEY } from '../rows/groups' +import { PINNED_GROUP_KEY, getLineageGroupKey } from '../grouping/group-keys' +import { getRenderRowKey } from '../listing/render-row' +import type { RenderRow } from '../listing/render-row' export const GROUP_HEADER_ROW_HEIGHT = 28 export const HOST_HEADER_ROW_HEIGHT = 32 @@ -11,36 +12,6 @@ const IMPORTED_WORKTREES_LINE_ROW_HEIGHT = 36 const PENDING_CREATION_ROW_HEIGHT = 56 const FOLDER_WORKSPACE_ROW_HEIGHT = 64 -type WorktreeItemRow = Extract -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}` -} - /** * Old row key -> current row key for rows that were re-keyed without moving. * diff --git a/src/renderer/src/components/sidebar/worktree-list/scroll/visible-refresh.test.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/visible-refresh.test.ts similarity index 97% rename from src/renderer/src/components/sidebar/worktree-list/scroll/visible-refresh.test.ts rename to src/renderer/src/components/sidebar/worktree-list/viewport/visible-refresh.test.ts index aea1c032d04..fee590c6cd0 100644 --- a/src/renderer/src/components/sidebar/worktree-list/scroll/visible-refresh.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/visible-refresh.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { installWorktreeVisibleRefreshVisibilityListener } from './tuning' +import { installWorktreeVisibleRefreshVisibilityListener } from './use-visible-review-refresh' describe('installWorktreeVisibleRefreshVisibilityListener', () => { beforeEach(() => { diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-row-preference.ts b/src/renderer/src/components/sidebar/worktree-sidebar-row-preference.ts index 99f7b9ceece..a9a344f7564 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-row-preference.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-row-preference.ts @@ -1,11 +1,8 @@ import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' import type { Worktree } from '../../../../shared/worktree/types' import type { HostSectionRow } from './host-section-rows' -import { - PINNED_GROUP_KEY, - type PinnedWorktreeDisplayPolicy, - type WorktreeRow -} from './worktree-list/rows/groups' +import { PINNED_GROUP_KEY } from './worktree-list/grouping/group-keys' +import type { PinnedWorktreeDisplayPolicy, WorktreeRow } from './worktree-list/grouping/row-types' export function getPreferredWorktreeRows( rows: readonly WorktreeRow[],