From 08027d0d237608acccce793ed460fe97e94c6202 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:35:41 -0700 Subject: [PATCH] refactor(sidebar): split WorktreeList into a worktree-list module (#14465) WorktreeList.tsx was 6.8k lines behind an `eslint-disable max-lines`. Break it into `sidebar/worktree-list/`: the container keeps store wiring and composition, the virtualized viewport keeps layout, and the drag, reveal, virtualization, row-model, and row-render concerns each get their own file. Every file now fits the oxlint budget, so the suppression and its baseline entry are gone. Behaviour-preserving. The only deliberate cleanups are duplicate branches folded into shared helpers (drop-preview state updates, status-hover fallback, the two identical scroll-to-index reveal branches) and a dead sticky-header-index ref. Tests that asserted on WorktreeList.tsx source text or imported its named helpers now point at the module that owns them. --- config/max-lines-baseline.txt | 1 - .../WorktreeList.lineage-child-card.test.ts | 4 +- .../src/components/sidebar/WorktreeList.tsx | 6848 +---------------- .../sidebar/project-group-header-dom.test.ts | 14 +- .../sidebar/worktree-keyboard-cycle.test.ts | 4 +- .../sidebar/worktree-list-groups.test.ts | 11 +- .../worktree-list-imported-rows.test.ts | 11 +- .../worktree-list-scroll-adjustment.test.ts | 8 +- .../worktree-list-visible-refresh.test.ts | 2 +- .../FolderPathStatusIndicator.tsx | 47 + .../worktree-list/HostSectionHeader.tsx | 161 + .../SidebarWorktreeListDialogs.tsx | 118 + .../SidebarWorktreeListEmptyState.tsx | 36 + .../VirtualizedWorktreeViewport.tsx | 411 + .../worktree-list/active-descendant-option.ts | 67 + .../folder-workspace-virtual-row.tsx | 129 + .../imported-worktrees-card-visibility.ts | 9 + .../worktree-list/mounted-row-reveal.ts | 35 + .../pending-sidebar-reveal-inputs.ts | 116 + .../pointer-drop-status-target.ts | 56 + .../project-group-header-actions.tsx | 132 + .../worktree-list/render-row-item-rows.ts | 13 + .../render-row-worktree-lookup.ts | 79 + .../sidebar/worktree-list/renderable-rows.ts | 40 + .../worktree-list/repo-header-event-guards.ts | 31 + .../repo-header-project-actions.tsx | 227 + .../worktree-list/sidebar-editable-target.ts | 19 + .../sidebar-row-reveal-ancestors.ts | 112 + .../stable-empty-store-slices.ts | 9 + .../use-agent-send-target-worktree.ts | 63 + .../use-effective-collapsed-groups.ts | 90 + .../use-folder-workspace-path-status-rows.ts | 128 + .../use-group-toggle-with-scroll-anchor.ts | 30 + .../use-pending-sidebar-reveal.ts | 288 + .../use-primary-active-worktree-row.ts | 70 + .../use-project-group-dialogs.ts | 172 + .../use-reused-array-identity.ts | 10 + .../use-sidebar-external-worktree-cards.ts | 187 + .../use-sidebar-host-visible-scope.ts | 69 + .../use-sidebar-reveal-highlight.ts | 63 + .../use-sidebar-reveal-requests.ts | 125 + .../worktree-list/use-sidebar-section-rows.ts | 260 + .../use-sidebar-worktree-filters.ts | 106 + .../use-sidebar-worktree-selection.ts | 140 + .../use-sidebar-worktree-sort-order.ts | 207 + .../use-virtual-row-measurement-sync.ts | 116 + .../use-visible-pr-refresh-reporting.ts | 130 + .../use-visible-sidebar-worktrees.ts | 134 + .../use-workspace-status-row-drag.ts | 147 + .../use-worktree-document-drop.ts | 115 + .../use-worktree-drag-runtime.ts | 123 + .../use-worktree-drag-session.ts | 244 + .../use-worktree-lineage-drop-commit.ts | 127 + .../use-worktree-list-keyboard-navigation.ts | 120 + .../use-worktree-list-virtualizer.ts | 159 + .../use-worktree-native-drag-autoscroll.ts | 108 + .../worktree-list/use-worktree-native-drag.ts | 231 + .../use-worktree-pointer-drag-autoscroll.ts | 79 + ...use-worktree-pointer-drag-window-events.ts | 95 + .../use-worktree-pointer-drag.ts | 259 + .../use-worktree-sidebar-header-drag.ts | 222 + ...use-worktree-sidebar-scroll-suppression.ts | 83 + .../use-worktree-status-mutations.ts | 201 + .../virtual-row-dom-attributes.ts | 15 + .../virtualized-worktree-viewport-props.ts | 116 + .../worktree-list/worktree-drag-groups.ts | 72 + .../worktree-drop-commit-context.ts | 43 + .../worktree-list/worktree-list-item-row.tsx | 251 + .../worktree-notice-virtual-rows.tsx | 116 + .../worktree-list/worktree-option-dom.ts | 58 + .../worktree-pointer-drag-commit.ts | 159 + .../worktree-pointer-drag-flush.ts | 172 + .../worktree-list/worktree-row-drag-state.ts | 132 + .../worktree-section-header-row.tsx | 393 + .../worktree-sidebar-scroll-tuning.ts | 42 + .../worktree-virtual-row-context.ts | 149 + .../worktree-virtual-row-dispatch.tsx | 262 + .../worktree-visibility-menu-label.ts | 13 + .../worktree-visible-refresh-listener.ts | 4 + .../worktree-scroll-to-current-button.test.ts | 2 +- .../xterm-bypass-policy-interrupt.test.ts | 33 +- 81 files changed, 8767 insertions(+), 6716 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-list/FolderPathStatusIndicator.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list/HostSectionHeader.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list/SidebarWorktreeListDialogs.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list/SidebarWorktreeListEmptyState.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list/VirtualizedWorktreeViewport.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list/active-descendant-option.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/folder-workspace-virtual-row.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list/imported-worktrees-card-visibility.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/mounted-row-reveal.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/pending-sidebar-reveal-inputs.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/pointer-drop-status-target.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/project-group-header-actions.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list/render-row-item-rows.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/render-row-worktree-lookup.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/renderable-rows.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/repo-header-event-guards.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/repo-header-project-actions.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list/sidebar-editable-target.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/sidebar-row-reveal-ancestors.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/stable-empty-store-slices.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-agent-send-target-worktree.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-effective-collapsed-groups.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-folder-workspace-path-status-rows.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-group-toggle-with-scroll-anchor.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-pending-sidebar-reveal.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-primary-active-worktree-row.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-project-group-dialogs.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-reused-array-identity.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-sidebar-external-worktree-cards.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-sidebar-host-visible-scope.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-sidebar-reveal-highlight.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-sidebar-reveal-requests.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-sidebar-section-rows.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-filters.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-selection.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-sort-order.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-virtual-row-measurement-sync.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-visible-pr-refresh-reporting.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-visible-sidebar-worktrees.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-workspace-status-row-drag.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-worktree-document-drop.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-worktree-drag-runtime.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-worktree-drag-session.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-worktree-lineage-drop-commit.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-worktree-list-keyboard-navigation.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-worktree-list-virtualizer.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-worktree-native-drag-autoscroll.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-worktree-native-drag.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag-autoscroll.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag-window-events.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-worktree-sidebar-header-drag.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-worktree-sidebar-scroll-suppression.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/use-worktree-status-mutations.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/virtual-row-dom-attributes.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/virtualized-worktree-viewport-props.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/worktree-drag-groups.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/worktree-drop-commit-context.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/worktree-list-item-row.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list/worktree-notice-virtual-rows.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list/worktree-option-dom.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/worktree-pointer-drag-commit.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/worktree-pointer-drag-flush.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/worktree-row-drag-state.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/worktree-section-header-row.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list/worktree-sidebar-scroll-tuning.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/worktree-virtual-row-context.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/worktree-virtual-row-dispatch.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list/worktree-visibility-menu-label.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/worktree-visible-refresh-listener.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 4375803952e..839595375eb 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -234,7 +234,6 @@ inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx 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/WorktreeList.tsx inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts inline src/renderer/src/components/sidebar/worktree-list-groups.test.ts inline src/renderer/src/components/sidebar/worktree-list-groups.ts 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 756c99d8522..a8c6045463d 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 type * as WorktreeListModule from './WorktreeList' +import { getPinnedWorktreeRevealCollapsedGroupKeys } from './worktree-list/sidebar-row-reveal-ancestors' import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses' const mockStore = vi.hoisted(() => ({ @@ -21,7 +21,6 @@ type WorktreeListComponent = React.ComponentType<{ }> let WorktreeList: WorktreeListComponent -let getPinnedWorktreeRevealCollapsedGroupKeys: typeof WorktreeListModule.getPinnedWorktreeRevealCollapsedGroupKeys function makeFolderWorkspacePathStatusMockState(): Record { return { @@ -828,7 +827,6 @@ describe('WorktreeList lineage child card renderer', () => { beforeAll(async () => { const module = await import('./WorktreeList') WorktreeList = module.default as WorktreeListComponent - getPinnedWorktreeRevealCollapsedGroupKeys = module.getPinnedWorktreeRevealCollapsedGroupKeys }, 60_000) it('renders project group headers when repos import before worktree rows load', async () => { diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 138f36a3a2f..dbbee941a4f 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -1,5208 +1,42 @@ -/* eslint-disable max-lines */ -import React, { useMemo, useCallback, useRef, useState, useEffect, useLayoutEffect } from 'react' -import { toast } from 'sonner' -import { - measureElement as measureVirtualElementSize, - useVirtualizer -} from '@tanstack/react-virtual' -import type { Range } from '@tanstack/react-virtual' -import { - AlertTriangle, - ChevronDown, - CircleX, - Ellipsis, - Eye, - FolderInput, - FolderPlus, - FolderX, - Loader2, - Plus, - Server, - ServerOff, - Shapes, - SlidersHorizontal, - Trash2 -} from 'lucide-react' +import React, { useCallback, useMemo } from 'react' import { useAppStore } from '@/store' -import { createLineageToggleHandlerCache } from './worktree-lineage-toggle-handler-cache' -import { reuseArrayIfEqual } from './worktree-agent-row-selectors' import { useShallow } from 'zustand/react/shallow' -import type { AppState } from '@/store/types' import { - getAllWorktreesFromState, useAllWorktrees, useProjectHostSetupProjection, useRepoMap, useWorktreeMap } from '@/store/selectors' -import WorktreeCard, { type ActiveSurfaceVariant } from './WorktreeCard' -import { WorktreeSidebarDropIndicator } from './WorktreeSidebarDropIndicator' -import { - getProjectGroupHeaderSectionEndByGroupId, - getRepoHeaderSectionEndByRepoId -} from './worktree-header-section-boundaries' -import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' -import { PendingWorktreeRow } from './PendingWorktreeRow' -import { SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT } from './WorktreeCardAgents' -import { Button } from '@/components/ui/button' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { cn } from '@/lib/utils' -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 { WorkspaceLineage, WorktreeLineage } from '../../../../shared/worktree/lineage-types' -import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' -import type { - WorkspaceStatus, - WorkspaceStatusDefinition, - Worktree -} from '../../../../shared/worktree/types' -import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' -import { buildWorktreeComparator, compareWorktreeSortLabel } from './smart-sort' -import { - buildAttentionByWorktree, - hasFreshAttributedAgentStatus, - type SmartClass, - type WorktreeAttention -} from './smart-attention' -import { track } from '@/lib/telemetry' -import { tabHasLivePty } from '@/lib/tab-has-live-pty' -import { deriveRunningAgentSendTargets } from '@/lib/running-agent-targets' -import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility' -import { - type Row, - type ProjectGroupingModel, - type WorktreeGroupBy, - ALL_GROUP_KEY, - PINNED_GROUP_KEY, - buildRows, - getProjectGroupHeaderKey, - getGroupKeysForWorktree, - getLineageGroupKey, - getPinnedWorktreeDisplayPolicy, - type PinnedWorktreeDisplayPolicy -} from './worktree-list-groups' -import { - buildLineageRowRekeyMap, - estimateRenderRowSize, - extractWorktreeVirtualRowIndexes, - getActiveStickyIndexesForScroll, - getRenderRowKey, - getStickyHeaderIndexes, - getVirtualRowTransform, - pruneStaleVirtualRowElementCache, - shouldUseHeaderTopSpacing, - WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP, - type RenderRow -} from './worktree-list-virtual-rows' -import { - revealElementInScrollContainer, - WORKTREE_SIDEBAR_REVEAL_TOP_INSET -} from './worktree-sidebar-reveal' -import { - createPendingRevealScroll, - isRevealScrollSettling, - type PendingRevealScroll -} from './worktree-sidebar-reveal-scroll-settle' -import { - getWorkspaceStatus, - getWorkspaceStatusFromGroupKey, - getWorkspaceStatusGroupKey, - hasWorkspaceDragData, - readWorkspaceDragDataIds -} from './workspace-status' -import { useWorkspaceStatusDocumentDrop } from './use-workspace-status-drop' -import { - computeClearFilterActions, - computeVisibleWorktreeIds, - setVisibleWorktreeIds, - sidebarHasActiveFilters -} from './visible-worktrees' -import { - EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, - filterFolderWorkspacesFromOtherDevices, - getPairedDeviceIdsByEnvironment -} from './workspace-creator-visibility' -import { - getCyclicProjectedWorktreeLineageIds, - getWorktreeLineageAncestors -} from './worktree-lineage-projection' -import { getWorktreeIdsWithLiveAgent } from '@/lib/worktree-activity-state' -import { getEmptyProjectPlaceholderRepoIds } from './empty-project-placeholder-repos' -import { - getVisibleWorktreeBrowserActivityTabs, - getVisibleWorktreeTerminalActivityTabs -} from './visible-worktree-activity-inputs' -import { selectWorktreeListReviewCacheInputs } from './worktree-list-review-cache-inputs' -import { - VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT, - useVirtualizedScrollAnchor, - type VirtualizedScrollAnchor -} from '@/hooks/useVirtualizedScrollAnchor' -import { activateAndRevealWorktree } from '@/lib/worktree-activation' -import { useFolderWorkspacePathStatusCacheExpiryTick } from '@/lib/folder-workspace-path-status-cache-expiry' -import { useWorktreeListScrollToTop } from './use-worktree-list-scroll-to-top' -import { WorktreeListScrollToTopButton } from './WorktreeListScrollToTopButton' -import { - getFolderWorkspacePathStatusDescription, - getFolderWorkspacePathStatusTitle -} from '@/lib/folder-workspace-path-status' -import { getShortcutPlatform } from '@/lib/shortcut-platform' -import { - SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT, - type ScrollToCurrentWorkspaceRevealRequestDetail -} from '@/lib/scroll-to-current-workspace-status' -import { isRepoHeaderActionTarget, useRepoHeaderDrag } from './project-header-drag' -import { - getLogicalRepoOrderRankById, - getSidebarOrderedRepoHeaderIdsByBucket -} from './project-header-drop' -import { useProjectGroupHeaderDrag } from './project-group-header-drag' -import { getSidebarOrderedProjectGroupHeaderIdsByBucket } from './project-group-header-drop' -import { - buildManualOrderUpdatesForGroupDrop, - buildManualOrderUpdatesForVisibleGroups, - expandDraggedWorktreeIdsForVisibleLineage, - shouldWriteManualOrderForGroupDrop, - type WorktreeDragGroup -} from './worktree-manual-order' -import { - buildWorkspaceKanbanSidebarDropUpdates, - clearWorkspaceKanbanSidebarDropTargetVisual, - getWorkspaceKanbanSidebarDropGroups, - getWorkspaceKanbanSidebarDropTarget, - hasWorkspaceKanbanSidebarDropBoard, - isWorkspaceKanbanSidebarDropPointInBoard, - resolveWorkspaceKanbanSidebarFullLaneDropIndex, - updateWorkspaceKanbanSidebarDropTargetVisual -} from './workspace-kanban-sidebar-drop' -import { - resolveWorkspaceKanbanCardDropCommitTarget, - type WorkspaceKanbanCardTrackedDropTarget -} from './workspace-kanban-card-pointer-drag-dom' -import { - getFullDropIndexForWorktreeDragUnit, - getWorktreeDragUnitGroups -} from './worktree-drag-units' -import { - createSidebarDragPreview, - isSidebarPointerDragBlocked, - setSidebarPointerDragDocumentStyles, - updateSidebarDragPreviewPosition -} from './worktree-sidebar-pointer-drag-dom' -import { - getWorktreeSidebarDragAutoscroll, - getWorktreeSidebarDragRectsForGroup, - refreshWorktreeSidebarDragSession, - type WorktreeSidebarDragRect, - type WorktreeSidebarDragSession, - type WorktreeSidebarDragPoint -} from './worktree-sidebar-drag-autoscroll' -import { - getWorktreeSidebarDragGrab, - shouldReevaluateWorktreeSidebarDropAnchor, - type WorktreeSidebarDragGrab, - type WorktreeSidebarDropAnchor -} from './worktree-sidebar-drag-geometry' -import { - computeWorktreeSidebarDropPreview, - resolveWorktreeSidebarStatusDropCommitTarget, - type WorktreeSidebarStatusDropTarget, - type WorktreeSidebarTrackedStatusDropTarget, - type WorktreeSidebarDropPreview -} from './worktree-sidebar-drop-preview' -import { - getReorderedWorktreeIdsToUnnest, - getWorktreeLineageDropTargetId -} from './worktree-lineage-drag-drop' -import { resolveProjectGroupHeaderColor } from './project-header-color' -import { - REPO_HEADER_ACTION_BUTTON_CLASS, - REPO_HEADER_ACTION_REVEAL_CLASS -} from './repo-header-action-button-class' -import { - areWorktreeSelectionsEqual, - getWorktreeSelectionIntent, - pruneWorktreeSelection, - updateWorktreeSelection -} from './worktree-multi-selection' -import { persistWorktreeSortOrderByHost } from '@/lib/worktree-sort-order-persistence' import { getRepoExecutionHostId, - getSettingsFocusedExecutionHostId, - getWorktreeExecutionHostId, - type ExecutionHostId + getSettingsFocusedExecutionHostId } from '../../../../shared/execution-host' -import { getRepoHeaderCreateState } from './repo-header-create-state' -import type { PendingSidebarRowReveal, PendingSidebarWorktreeReveal } from '@/store/slices/ui' -import { getRepositoryIconSectionId } from '@/components/settings/repository-settings-targets' -import { keybindingMatchesAction } from '../../../../shared/keybindings' -import { ProjectGroupNameDialog } from './ProjectGroupNameDialog' -import { ProjectGroupDeleteDialog } from './ProjectGroupDeleteDialog' -import { selectProjectGroupRemovalTargets } from '@/store/slices/project-group-removal-targets' -import { findRepoForHost } from '@/store/slices/repo-host-identity' -import { isGitRepoKind } from '../../../../shared/repo-kind' +import { getActiveSidebarWorkspaceId } from '../../../../shared/workspace-scope' +import { getPinnedWorktreeDisplayPolicy } from './worktree-list-groups' +import { selectWorktreeListReviewCacheInputs } from './worktree-list-review-cache-inputs' +import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor' +import { SidebarWorktreeListDialogs } from './worktree-list/SidebarWorktreeListDialogs' +import { SidebarWorktreeListEmptyState } from './worktree-list/SidebarWorktreeListEmptyState' +import { VirtualizedWorktreeViewport } from './worktree-list/VirtualizedWorktreeViewport' +import { markSidebarWorktreeActiveImmediately } from './worktree-list/worktree-option-dom' import { - effectiveExternalWorktreeVisibility, - isLegacyRepoForExternalWorktreeVisibility -} from '../../../../shared/worktree/ownership' -import { RepoIconGlyph } from '@/components/repo/repo-icon' -import { RepoForkIndicator } from '@/components/repo/repo-fork-indicator' -import ImportedWorktreesVisibilityLine from './ImportedWorktreesVisibilityLine' -import NewExternalWorktreesInboxLine from './NewExternalWorktreesInboxLine' -import SuppressExternalWorktreeInboxDialog from './SuppressExternalWorktreeInboxDialog' -import { - keepImportedWorktreesHiddenCard, - IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR, - showImportedWorktreesCard, - type ImportedWorktreeCardActionState -} from './imported-worktrees-card-actions' -import { - suppressNewExternalWorktreeInbox, - type NewExternalWorktreesInboxActionState -} from './new-external-worktrees-inbox-actions' -import { isEligibleWorktreeParent } from './worktree-parent-candidates' -import { - buildImportedWorktreesCardCandidates, - getHiddenImportedWorktrees -} from './imported-worktrees-card-candidates' -import { buildNewExternalWorktreesInboxCandidates } from './new-external-worktrees-inbox-candidates' -import { - WORKTREE_SECTION_HEADER_PADDING_LEFT, - LINEAGE_CHILDREN_INLINE_OFFSET, - getFolderBackedRepoWorktreeCardContentIndent, - getFolderBackedRepoWorktreeCardSurfaceInset, - getFolderWorkspaceRowGeometry, - getLineageChildrenInlineStyle, - getLineageNestedRowGeometry, - getProjectGroupHeaderPaddingLeft, - getWorktreeCardContentIndent, - getWorktreeCardSurfaceInset -} from './worktree-list-indentation' -import { addHostSectionRows, type HostHeaderRow, type HostSectionRow } from './host-section-rows' -import { orderHostSectionOptions } from './host-section-order' -import { useHostHeaderDrag } from './host-header-drag' -import { buildSidebarHostOptions } from './sidebar-host-options' -import { HostSectionHeaderMenu } from './HostSectionHeaderMenu' -import { ProjectHeaderActions } from './ProjectHeaderActions' -import { translate } from '@/i18n/i18n' -import { folderWorkspaceKey, getActiveSidebarWorkspaceId } from '../../../../shared/workspace-scope' -import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' -import { - isConfirmedStaleFolderPathStatus, - type FolderWorkspacePathStatus -} from '../../../../shared/folder-workspace-path-status' -import { - getFolderWorkspaceRevealGroupKeys, - getKnownSidebarWorktreeById, - sidebarWorkspaceStillExists -} from './worktree-list-folder-reveal' -import { - filterFolderWorkspacesForVisibleHosts, - filterProjectGroupsForVisibleHosts, - getFolderPathStatusRouteOptionsForRows, - getVisibleSidebarHostIdSet -} from './worktree-list-host-filtering' -import { getFolderWorkspaceCardPrDisplay } from './folder-workspace-card-pr-display' -import { getRenderedWorktreesInSidebarOrder } from './worktree-sidebar-row-preference' -import { getCyclableWorktreeIds, resolveCycledWorktreeId } from './worktree-keyboard-cycle' - -export { - getScrollTopToRevealBounds, - WORKTREE_SIDEBAR_REVEAL_TOP_INSET -} from './worktree-sidebar-reveal' - -type ProjectGroupNameDialogState = - | { type: 'create-from-repo'; repo: Repo } - | { type: 'rename'; groupId: string; currentName: string } - -type ProjectGroupDeleteDialogState = { - groupId: string - groupName: string - removeContainedProjects: boolean -} - -// 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. -function useReusedArrayIdentity(next: T[]): T[] { - const previousRef = useRef(next) - const result = reuseArrayIfEqual(previousRef.current, next) - previousRef.current = result - return result -} - -// Debounce re-sort after a sortEpoch bump so background score changes don't jar row positions. -const SORT_SETTLE_MS = 3_000 -const USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS = 500 -const EMPTY_PROJECT_GROUPS: readonly ProjectGroup[] = [] -const EMPTY_AGENT_STATUS_BY_PANE_KEY: AppState['agentStatusByPaneKey'] = {} -const EMPTY_WORKTREE_ID_SET: ReadonlySet = new Set() -const EMPTY_TABS_BY_WORKTREE: AppState['tabsByWorktree'] = {} -const EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID: AppState['terminalLayoutsByTabId'] = {} -const EMPTY_PTY_IDS_BY_TAB_ID: AppState['ptyIdsByTabId'] = {} -const EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID: AppState['runtimePaneTitlesByTabId'] = {} -const EXPANDING_CARD_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS = 300 -const NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK = (): void => {} -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' -} - -function isEditableTarget(target: EventTarget | null): boolean { - if (!(target instanceof HTMLElement)) { - return false - } - - // xterm's hidden input textarea isn't a real text field; treating it as one would block sidebar shortcuts. - if (target.classList.contains('xterm-helper-textarea')) { - return false - } - - if (target.isContentEditable) { - return true - } - - return ( - target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]') !== - null - ) -} - -function stopRepoHeaderKeyboardToggle(event: React.KeyboardEvent): void { - if (event.key === 'Enter' || event.key === ' ') { - event.stopPropagation() - } -} - -function stopNestedWorktreeCardBubble(event: React.SyntheticEvent): void { - event.stopPropagation() -} - -function handleRepoHeaderActionPointerDown(event: React.PointerEvent): void { - event.stopPropagation() -} - -function handleRepoHeaderCollapseAffordancePointerDown( - event: React.PointerEvent -): void { - // Why: keep collapse-chevron clicks from arming the repo-header row drag. - event.stopPropagation() -} - -function stopRepoHeaderMenuEvent(event: React.SyntheticEvent): void { - event.stopPropagation() -} - -function shouldIgnoreRepoHeaderToggle(event: React.SyntheticEvent): boolean { - return isRepoHeaderActionTarget(event.target, event.currentTarget) -} - -function getWorktreeOptionId(rowKey: string): string { - return `worktree-list-option-${encodeURIComponent(rowKey)}` -} - -function getMountedWorktreeOptions(worktreeId: string, root?: ParentNode | null): HTMLElement[] { - const scope = root ?? document - const result: HTMLElement[] = [] - scope.querySelectorAll('[data-worktree-id]').forEach((element) => { - if (element.dataset.worktreeId === worktreeId) { - result.push(element) - } - }) - return result -} - -function markSidebarWorktreeActiveImmediately(worktreeId: string, primaryRowKey?: string): void { - const sidebar = document.querySelector('[data-worktree-sidebar]') - const nextOptions = getMountedWorktreeOptions(worktreeId, sidebar) - const nextOption = nextOptions[0] - if (!nextOption) { - return - } - - sidebar - ?.querySelectorAll('[role="option"][aria-current="page"]') - .forEach((option) => option.removeAttribute('aria-current')) - - for (const option of nextOptions) { - option.setAttribute('aria-current', 'page') - } - sidebar - ?.querySelectorAll('[data-worktree-card-surface][data-worktree-card-active]') - .forEach((surface) => { - if (!nextOptions.some((option) => option.contains(surface))) { - surface.removeAttribute('data-worktree-card-active') - } - }) - for (const option of nextOptions) { - const activeSurfaceVariant = - primaryRowKey !== undefined - ? option.dataset.worktreeRowKey === primaryRowKey - ? 'primary' - : 'secondary' - : option === nextOption - ? 'primary' - : 'secondary' - const surface = option.matches('[data-worktree-card-surface]') - ? option - : option.querySelector('[data-worktree-card-surface]') - surface?.setAttribute('data-worktree-card-active', activeSurfaceVariant) - } -} - -function revealMountedWorktreeElement( - container: HTMLElement, - worktreeId: string, - behavior: ScrollBehavior, - optionId?: string, - onScrollIssued?: (targetTop: number) => void -): HTMLElement | null { - const element = optionId - ? document.getElementById(optionId) - : getMountedWorktreeOptions(worktreeId, container)[0] - if (!element || !container.contains(element)) { - return null - } - return revealElementInScrollContainer(container, element, behavior, onScrollIssued) - ? element - : null -} - -function revealMountedSidebarRowElement( - container: HTMLElement, - rowKey: string, - behavior: ScrollBehavior, - onScrollIssued?: (targetTop: number) => void -): HTMLElement | null { - const element = document.getElementById(getWorktreeOptionId(rowKey)) - if (!element || !container.contains(element)) { - return null - } - return revealElementInScrollContainer(container, element, behavior, onScrollIssued) - ? element - : null -} - -function getRenderRowSidebarKey(row: RenderRow): string | null { - if (row.type === 'header') { - return row.key - } - if (row.type === 'item') { - return row.rowKey - } - if (row.type === 'folder-workspace') { - return folderWorkspaceKey(row.folderWorkspace.id) - } - if (row.type === 'pending-creation') { - return `pending:${row.creationId}` - } - if (row.type === 'imported-worktrees-card') { - return row.key - } - if (row.type === 'new-external-worktrees-inbox') { - return row.key - } - return null -} - -function rowKeyMatchesRenderRow(row: RenderRow, rowKey: string): boolean { - if (row.type === 'lineage-group') { - return row.rows.some((item) => item.rowKey === rowKey) - } - return getRenderRowSidebarKey(row) === rowKey -} - -function getProjectIdFromHeaderRowKey(rowKey: string): string | null { - if (!rowKey.startsWith('project:')) { - return null - } - const withoutPrefix = rowKey.slice('project:'.length) - const setupSeparator = withoutPrefix.indexOf('::setup:') - return setupSeparator === -1 ? withoutPrefix : withoutPrefix.slice(0, setupSeparator) -} - -function getRepoIdsFromHeaderRowKey( - rowKey: string, - repoMap: Map, - projectGrouping?: ProjectGroupingModel -): string[] { - if (rowKey.startsWith('repo:')) { - return [rowKey.slice('repo:'.length)] - } - const setupMarker = '::setup:' - const setupIndex = rowKey.indexOf(setupMarker) - if (rowKey.startsWith('project:') && setupIndex !== -1) { - return [rowKey.slice(setupIndex + setupMarker.length)] - } - const projectId = getProjectIdFromHeaderRowKey(rowKey) - if (!projectId) { - return [] - } - const repoIds = new Set() - for (const setup of projectGrouping?.projectHostSetups ?? []) { - if (setup.projectId === projectId && repoMap.has(setup.repoId)) { - repoIds.add(setup.repoId) - } - } - const project = projectGrouping?.projects.find((candidate) => candidate.id === projectId) - for (const repoId of project?.sourceRepoIds ?? []) { - if (repoMap.has(repoId)) { - repoIds.add(repoId) - } - } - return [...repoIds] -} - -function getProjectGroupAncestorKeys( - projectGroupId: string | null | undefined, - projectGroups: readonly ProjectGroup[] -): string[] { - const groupsById = new Map(projectGroups.map((group) => [group.id, group])) - const keys: string[] = [] - const seen = new Set() - let currentGroupId = projectGroupId ?? null - while (currentGroupId && !seen.has(currentGroupId)) { - const group = groupsById.get(currentGroupId) - if (!group) { - break - } - seen.add(currentGroupId) - keys.unshift(getProjectGroupHeaderKey(group.id)) - currentGroupId = group.parentGroupId - } - return keys -} - -function getSidebarRowRevealAncestorKeys(args: { - rowKey: string - repoMap: Map - projectGroups: readonly ProjectGroup[] - projectGrouping?: ProjectGroupingModel -}): string[] { - if (args.rowKey.startsWith('project-group:')) { - const groupId = args.rowKey.slice('project-group:'.length) - const group = args.projectGroups.find((candidate) => candidate.id === groupId) - return getProjectGroupAncestorKeys(group?.parentGroupId, args.projectGroups) - } - const keys = new Set() - for (const repoId of getRepoIdsFromHeaderRowKey( - args.rowKey, - args.repoMap, - args.projectGrouping - )) { - const repo = args.repoMap.get(repoId) - for (const key of getProjectGroupAncestorKeys(repo?.projectGroupId, args.projectGroups)) { - keys.add(key) - } - } - return [...keys] -} - -function getWorktreeVisibilityMenuLabel(repo: Repo): string { - const visibility = effectiveExternalWorktreeVisibility( - repo, - isLegacyRepoForExternalWorktreeVisibility(repo) - ) - return visibility === 'show' ? 'Hide non-Orca worktrees' : 'Show hidden worktrees' -} - -const SIDEBAR_POINTER_DRAG_THRESHOLD_PX = 4 -type VirtualizedWorktreeViewportProps = { - rows: HostSectionRow[] - activeWorktreeId: string | null - currentWorktreeId: string | null - groupBy: WorktreeGroupBy - pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy - projectOrderBy: ProjectOrderBy - toggleGroup: (key: string) => void - collapsedGroups: Set - handleCreateForRepo: (projectId: string) => void - handleOpenRepoSettings: (projectId: string, sectionId?: string) => void - handleOpenWorktreeVisibility: (repo: Repo) => void - handleShowImportedWorktrees: (projectId: string) => void - handleKeepImportedWorktreesHidden: (projectId: string) => void - importedWorktreeCardActionState: ReadonlyMap - handleOpenSuppressExternalWorktreeInbox: (projectId: string) => void - newExternalWorktreeInboxActionState: ReadonlyMap - handleRemoveProject: (repo: Repo) => void - handleCreateGroupFromRepo: (repo: Repo) => void - handleMoveProjectToGroup: (repo: Repo, groupId: string) => void - handleRemoveProjectFromGroup: (repo: Repo) => void - handleRenameProjectGroup: (groupId: string, currentName: string) => void - handleDeleteProjectGroup: (groupId: string, groupName: string) => void - handleCreateFolderWorkspace: (projectGroup: ProjectGroup) => void - activeModal: string - pendingRevealWorktree: PendingSidebarWorktreeReveal | null - pendingRevealSidebarRow: PendingSidebarRowReveal | null - clearPendingRevealWorktreeId: () => void - clearPendingRevealSidebarRow: () => void - agentSendTargetWorktreeId: string | null - worktrees: Worktree[] - folderWorkspaces: readonly FolderWorkspace[] - selectedWorktreeIds: ReadonlySet - selectedWorktrees: readonly Worktree[] - onSelectionGesture: (event: React.MouseEvent, worktreeId: string) => boolean - onImmediateWorktreeActivate: (worktreeId: string, rowKey: string | undefined) => void - onContextMenuSelect: ( - event: React.MouseEvent, - worktree: Worktree - ) => readonly Worktree[] - repoMap: Map - defaultHostId: ExecutionHostId - worktreeMap: Map - worktreeLineageById: Record - workspaceLineageByChildKey: Record - // Full canonical repo-id order; must include hidden repos or a reorder silently drops them. - allRepoIds: string[] - onReorderHostSections: (orderedHostIds: ExecutionHostId[]) => void - onHostDragActiveChange: (active: boolean) => void - prCache: AppState['prCache'] | null - hostedReviewCache: AppState['hostedReviewCache'] | null - workspaceStatuses: readonly WorkspaceStatusDefinition[] - projectGrouping?: ProjectGroupingModel - projectGroups?: readonly ProjectGroup[] - onMoveWorktreeToStatus: (worktreeId: string, status: WorkspaceStatus) => void - onMoveWorktreesToStatus: (worktreeIds: readonly string[], status: WorkspaceStatus) => void - onMoveWorktreesToStatusAtIndex: (args: { - worktreeIds: readonly string[] - status: WorkspaceStatus - dropIndex: number - groups: readonly WorktreeDragGroup[] - }) => void - onPinWorktree: (worktreeId: string) => void - onPinWorktrees: (worktreeIds: readonly string[]) => void - onDropWorktreesOnWorkspaceBoard: (args: { - worktreeIds: readonly string[] - status: WorkspaceStatus - dropIndex: number - groups: readonly WorktreeDragGroup[] - }) => void - workspaceBoardOpen: boolean - onWorkspaceBoardDragPreviewStart: () => void - onWorkspaceBoardDragPreviewCommit: () => void - onWorkspaceBoardDragPreviewCancel: () => void - shouldShowWorkspaceBoardDropIndicator: ( - worktreeIds: readonly string[], - status: WorkspaceStatus - ) => boolean - onReorderWorktrees: (args: { - groups: readonly WorktreeDragGroup[] - sourceGroupKey: string - draggedIds: readonly string[] - dropIndex: number - }) => void - // Why: grouping remounts the viewport, add/delete stays mounted; bridge both so the virtualizer never resets to scrollTop 0. - scrollOffsetRef: React.MutableRefObject - scrollAnchorRef: React.MutableRefObject -} - -type WorktreeItemRow = Extract -type FolderWorkspaceItemRow = Extract - -function formatSectionActivityLabel(count: number, label: string): string { - return `${count} ${label}${count === 1 ? '' : 's'}` -} - -function SectionMetricsBadge({ count }: { count: number }): React.JSX.Element { - const totalLabel = formatSectionActivityLabel(count, 'workspace') - - return ( - - - - - {count} - - - - {totalLabel} - - - - ) -} - -function HostHeaderHealthIcon({ - health -}: { - health: HostHeaderRow['health'] -}): React.JSX.Element | null { - // Why: only surface states needing attention; healthy is the silent default. - if (health === 'connecting') { - return - } - if (health === 'blocked' || health === 'error') { - return - } - return null -} - -function getHostHeaderDetail(row: HostHeaderRow): { text: string; isWarning: boolean } | null { - // Why: a blocked compatibility verdict earns a compact warning so the host stands out. - if (row.health === 'blocked') { - return { - text: translate('auto.components.sidebar.WorktreeList.7a8b9c0d1e', 'Update required'), - isWarning: true - } - } - // Why: auth-failed needs a worded status; the health icon alone doesn't tell the user to re-auth. - if (row.connectionStatus === 'auth-failed') { - return { - text: translate( - 'auto.components.sidebar.WorktreeList.hostAuthNeeded', - 'Authentication needed' - ), - isWarning: true - } - } - if (row.health === 'disconnected') { - return { - text: translate('auto.components.sidebar.WorktreeList.hostDisconnected', 'Disconnected'), - isWarning: false - } - } - // Why: show the transport detail only for remote hosts; it's noise under the local label. - if (row.kind !== 'local') { - return { text: row.detail, isWarning: false } - } - return null -} - -function HostSectionHeader({ - row, - onToggle, - onDragPointerDown, - dragging -}: { - row: HostHeaderRow - onToggle: () => void - onDragPointerDown?: (event: React.PointerEvent) => void - dragging?: boolean -}): React.JSX.Element { - const isBlocked = row.health === 'blocked' - const isDisconnected = row.health === 'disconnected' - const detail = getHostHeaderDetail(row) - return ( -
- {/* Why: outlined card + server glyph marks hosts as machines, not mere groups. */} -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onToggle() - } - }} - > - {isDisconnected ? ( - - ) : ( - - )} - - {/* Why: badge hugs the label (like repo headers) instead of floating by the hover controls. */} -
- - {row.label} - - {detail ? ( - - {detail.text} - - ) : null} - -
-
- -
- - - -
-
- ) -} - -function FolderPathStatusIndicator({ - status -}: { - status: FolderWorkspacePathStatus | null | undefined -}): React.JSX.Element | null { - const title = getFolderWorkspacePathStatusTitle(status) - if (!status || status.exists || !title) { - return null - } - const destructive = isConfirmedStaleFolderPathStatus(status) - return ( - - - - - - - -
-
{title}
-
- {getFolderWorkspacePathStatusDescription(status)} -
-
-
-
- ) -} - -type WorktreeRowDragState = { - draggingWorktreeId: string | null - sourceGroupKey: string | null - dropIndex: number | null - dropIndicatorY: number | null - previewOffsetsByWorktreeId: ReadonlyMap - pointerY: number | null -} - -const EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS: ReadonlyMap = new Map() - -const WORKTREE_ROW_DRAG_INITIAL_STATE: WorktreeRowDragState = { - draggingWorktreeId: null, - sourceGroupKey: null, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: null -} - -type WorktreePointerDrag = { - pointerId: number - sourceRow: HTMLElement - startX: number - startY: number - currentX: number - currentY: number - worktreeId: string - draggedIds: readonly string[] - reorderDraggedIds: readonly string[] - reorderUnitDraggedIds: readonly string[] - sourceGroupKey: string - rects: readonly WorktreeSidebarDragRect[] - active: boolean - preview: HTMLElement | null - previewOffsetX: number - previewOffsetY: number - workspaceBoardDragPreviewRequested: boolean - frameId: number | null - latestBoardDropTarget: WorkspaceKanbanCardTrackedDropTarget | null - latestStatusDropTarget: WorktreeSidebarTrackedStatusDropTarget | null -} - -function areWorktreeDragPreviewOffsetsEqual( - a: ReadonlyMap, - b: ReadonlyMap -): boolean { - if (a === b) { - return true - } - if (a.size !== b.size) { - return false - } - for (const [key, value] of a) { - if (b.get(key) !== value) { - return false - } - } - return true -} - -function updateLatestWorktreeStatusDropTarget( - drag: WorktreePointerDrag, - target: WorktreeSidebarStatusDropTarget & { lineageParentId: string | null }, - preview: WorktreeSidebarDropPreview | null -): void { - drag.latestStatusDropTarget = - target.status || target.isPinDrop || target.lineageParentId - ? { - target, - preview, - x: drag.currentX, - y: drag.currentY - } - : null -} - -function getWorktreeVirtualRowTransform(start: number, previewOffset: number): string { - const base = getVirtualRowTransform(start) - return previewOffset === 0 ? base : `${base} translateY(${previewOffset}px)` -} - -function getPointerDropStatusTarget(args: { - container: HTMLElement - x: number - y: number -}): WorktreeSidebarStatusDropTarget & { lineageParentId: string | null } { - const target = document.elementFromPoint(args.x, args.y) - if (!(target instanceof Element) || !args.container.contains(target)) { - return { status: null, isPinDrop: false, lineageParentId: null } - } - const pinTarget = target.closest('[data-workspace-pin-drop-target]') - if (pinTarget && args.container.contains(pinTarget)) { - return { status: null, isPinDrop: true, lineageParentId: null } - } - const lineageParentId = getWorktreeLineageDropTargetId({ - container: args.container, - target, - pointerY: args.y - }) - const statusTarget = target.closest('[data-workspace-status-drop-target]') - return { - status: - statusTarget && args.container.contains(statusTarget) - ? ((statusTarget.dataset.workspaceStatus as WorkspaceStatus | undefined) ?? null) - : null, - isPinDrop: false, - lineageParentId - } -} - -function shouldPreferSidebarStatusDropTarget(args: { - sourceGroupKey: string - target: WorktreeSidebarStatusDropTarget - workspaceStatuses: readonly WorkspaceStatusDefinition[] -}): boolean { - if (args.target.isPinDrop) { - return true - } - if (!args.target.status) { - return false - } - const sourceStatus = getWorkspaceStatusFromGroupKey(args.sourceGroupKey, args.workspaceStatuses) - // Why: overlapping edge zones — the section under the pointer must win so guide and drop agree. - return sourceStatus !== null && args.target.status !== sourceStatus -} - -function isWorktreeItemRow(row: HostSectionRow): row is WorktreeItemRow { - return row.type === 'item' -} - -export function renderRowContainsWorktree(row: RenderRow, worktreeId: string | null): boolean { - if (worktreeId === null) { - return false - } - if (row.type === 'folder-workspace') { - return folderWorkspaceKey(row.folderWorkspace.id) === worktreeId - } - if (row.type === 'lineage-group') { - return row.rows.some((item) => item.worktree.id === worktreeId) - } - return row.type === 'item' && row.worktree.id === worktreeId -} - -function isPinnedWorktreeRow(row: WorktreeItemRow): boolean { - return row.sectionKey === PINNED_GROUP_KEY -} - -function getRenderRowWorktreeItem(row: RenderRow, worktreeId: string): WorktreeItemRow | null { - if (row.type === 'lineage-group') { - return row.rows.find((item) => item.worktree.id === worktreeId) ?? null - } - return row.type === 'item' && row.worktree.id === worktreeId ? row : null -} - -function getRenderRowOptionId( - row: RenderRow | undefined, - worktreeId?: string | null -): string | undefined { - if (!row) { - return undefined - } - if (row.type === 'lineage-group') { - const targetRow = worktreeId ? row.rows.find((item) => item.worktree.id === worktreeId) : null - return getWorktreeOptionId((targetRow ?? row.rows[0])?.rowKey ?? row.key) - } - if (row.type === 'item') { - return getWorktreeOptionId(row.rowKey) - } - if (row.type === 'folder-workspace') { - return getWorktreeOptionId(folderWorkspaceKey(row.folderWorkspace.id)) - } - return undefined -} - -function getActiveDescendantOptionId(args: { - activeWorktreeId: string | null - primaryActiveRowKey?: string - pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy - renderRows: readonly RenderRow[] - virtualItems: readonly { index: number }[] -}): string | undefined { - if (args.activeWorktreeId === null) { - return undefined - } - if (args.primaryActiveRowKey) { - const primaryOptionId = getWorktreeOptionId(args.primaryActiveRowKey) - for (const item of args.virtualItems) { - const row = args.renderRows[item.index] - if (row && getRenderRowOptionId(row, args.activeWorktreeId) === primaryOptionId) { - return primaryOptionId - } - } - } - let fallbackOptionId: string | undefined - for (const item of args.virtualItems) { - const row = args.renderRows[item.index] - if (row && renderRowContainsWorktree(row, args.activeWorktreeId)) { - const optionId = getRenderRowOptionId(row, args.activeWorktreeId) - if (!optionId) { - continue - } - const itemRow = getRenderRowWorktreeItem(row, args.activeWorktreeId) - if ( - args.pinnedDisplayPolicy === 'duplicate-in-groups' && - itemRow && - !isPinnedWorktreeRow(itemRow) - ) { - return optionId - } - fallbackOptionId ??= optionId - } - } - return fallbackOptionId -} - -function findPreferredRenderRowIndexForWorktree( - renderRows: readonly RenderRow[], - worktreeId: string, - pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy -): number { - let fallbackIndex = -1 - for (let index = 0; index < renderRows.length; index++) { - const row = renderRows[index] - if (!renderRowContainsWorktree(row, worktreeId)) { - continue - } - if (fallbackIndex === -1) { - fallbackIndex = index - } - const itemRow = getRenderRowWorktreeItem(row, worktreeId) - if (pinnedDisplayPolicy === 'duplicate-in-groups' && itemRow && !isPinnedWorktreeRow(itemRow)) { - return index - } - } - return fallbackIndex -} - -export function getPinnedWorktreeRevealCollapsedGroupKeys({ - worktree, - collapsedGroups -}: { - worktree: Worktree - collapsedGroups: ReadonlySet -}): string[] { - if (!worktree.isPinned) { - return [] - } - const keys: string[] = [] - // Why: the reveal effect already opens this host; re-returning it would toggle it back closed. - if (collapsedGroups.has(PINNED_GROUP_KEY)) { - keys.push(PINNED_GROUP_KEY) - } - return keys -} - -function uniqueWorktreeIds(ids: readonly string[]): string[] { - return Array.from(new Set(ids)) -} - -function buildRenderableRows(rows: HostSectionRow[]): RenderRow[] { - const renderRows: RenderRow[] = [] - for (let index = 0; index < rows.length; index++) { - const row = rows[index] - if ( - !isWorktreeItemRow(row) || - row.lineageChildCount === 0 || - row.lineageCollapsed || - rows[index + 1]?.type !== 'item' || - (rows[index + 1] as WorktreeItemRow).depth <= row.depth - ) { - renderRows.push(row) - continue - } - - const groupRows: WorktreeItemRow[] = [row] - let cursor = index + 1 - while (cursor < rows.length) { - const child = rows[cursor] - if (!isWorktreeItemRow(child) || child.depth <= row.depth) { - break - } - groupRows.push(child) - cursor++ - } - renderRows.push({ - type: 'lineage-group', - key: `${row.sectionKey}:${getLineageGroupKey(row.worktree.id)}`, - rows: groupRows - }) - index = cursor - 1 - } - return renderRows -} - -// Why: getRenderRowKey lives with the other virtual-row helpers now; keep the -// long-standing import path working for callers that reach for it here. -export { getRenderRowKey } - -export function getWorktreeDragGroups(rows: HostSectionRow[]): WorktreeDragGroup[] { - const groups: WorktreeDragGroup[] = [] - let current: { key: string; ids: string[] } | null = null - const naturalWorktreeIds = new Set( - rows.flatMap((row) => - row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : [] - ) - ) - - for (const row of rows) { - if (row.type === 'header') { - current = { key: row.key, ids: [] } - groups.push({ key: current.key, worktreeIds: current.ids }) - continue - } - if ( - row.type === 'host-header' || - row.type === 'imported-worktrees-card' || - row.type === 'new-external-worktrees-inbox' || - row.type === 'pending-creation' || - row.type === 'folder-workspace' - ) { - continue - } - if (row.sectionKey === PINNED_GROUP_KEY && naturalWorktreeIds.has(row.worktree.id)) { - continue - } - if (!current) { - current = { key: ALL_GROUP_KEY, ids: [] } - groups.push({ key: current.key, worktreeIds: current.ids }) - } - current.ids.push(row.worktree.id) - } - - return groups.filter((group) => group.worktreeIds.length > 0) -} - -export function canKeepImportedWorktreesHidden( - row: Extract, - actionState: ImportedWorktreeCardActionState | undefined -): boolean { - return row.placement === 'repo-group' && actionState?.forceVisible !== true -} - -export function getWorktreeDragIndexes(rows: readonly HostSectionRow[]): { - groupKeyByRowKey: Map - groupIndexByRowKey: Map -} { - const groupKeyByRowKey = new Map() - const groupIndexByRowKey = new Map() - const groupIndexes = new Map() - const naturalWorktreeIds = new Set( - rows.flatMap((row) => - row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : [] - ) - ) - for (const row of rows) { - if (row.type === 'header') { - groupIndexes.set(row.key, 0) - continue - } - if (row.type !== 'item') { - continue - } - if (row.sectionKey === PINNED_GROUP_KEY && naturalWorktreeIds.has(row.worktree.id)) { - continue - } - const index = groupIndexes.get(row.sectionKey) ?? 0 - groupKeyByRowKey.set(row.rowKey, row.sectionKey) - groupIndexByRowKey.set(row.rowKey, index) - groupIndexes.set(row.sectionKey, index + 1) - } - return { groupKeyByRowKey, groupIndexByRowKey } -} - -function getVirtualRowIndex(element: Element): number | null { - const index = Number.parseInt(element.getAttribute('data-index') ?? '', 10) - return Number.isNaN(index) ? null : index -} - -function getVirtualRowKey(element: Element): string | null { - return element.getAttribute('data-worktree-virtual-row-key') -} - -const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewport({ - rows, - activeWorktreeId, - currentWorktreeId, - groupBy, - pinnedDisplayPolicy, - projectOrderBy, - toggleGroup, - collapsedGroups, - handleCreateForRepo, - handleOpenRepoSettings, - handleOpenWorktreeVisibility, - handleShowImportedWorktrees, - handleKeepImportedWorktreesHidden, - importedWorktreeCardActionState, - handleOpenSuppressExternalWorktreeInbox, - newExternalWorktreeInboxActionState, - handleRemoveProject, - handleCreateGroupFromRepo, - handleMoveProjectToGroup, - handleRemoveProjectFromGroup, - handleRenameProjectGroup, - handleDeleteProjectGroup, - handleCreateFolderWorkspace, - activeModal, - pendingRevealWorktree, - pendingRevealSidebarRow, - clearPendingRevealWorktreeId, - clearPendingRevealSidebarRow, - agentSendTargetWorktreeId, - worktrees, - folderWorkspaces, - selectedWorktreeIds, - selectedWorktrees, - onSelectionGesture, - onImmediateWorktreeActivate, - onContextMenuSelect, - repoMap, - defaultHostId, - worktreeMap, - worktreeLineageById, - workspaceLineageByChildKey, - allRepoIds, - onReorderHostSections, - onHostDragActiveChange, - prCache, - hostedReviewCache, - workspaceStatuses, - projectGrouping, - projectGroups = EMPTY_PROJECT_GROUPS, - onMoveWorktreeToStatus, - onMoveWorktreesToStatus, - onMoveWorktreesToStatusAtIndex, - onPinWorktree, - onPinWorktrees, - onDropWorktreesOnWorkspaceBoard, - workspaceBoardOpen, - onWorkspaceBoardDragPreviewStart, - onWorkspaceBoardDragPreviewCommit, - onWorkspaceBoardDragPreviewCancel, - shouldShowWorkspaceBoardDropIndicator, - onReorderWorktrees, - scrollOffsetRef, - scrollAnchorRef -}: VirtualizedWorktreeViewportProps) { - const scrollRef = useRef(null) - // Why: callback-ref only mutates scrollRef; state re-runs the scroll-to-top listener attach. - const [scrollElement, setScrollElement] = useState(null) - const suppressMeasurementAdjustmentUntilRef = useRef(0) - const directScrollInputUntilRef = useRef(0) - const [dragOverStatus, setDragOverStatus] = useState(null) - const [pinDragOver, setPinDragOver] = useState(false) - const [nativeLineageDropTargetId, setNativeLineageDropTargetId] = useState(null) - const [worktreeDragState, setWorktreeDragState] = useState( - WORKTREE_ROW_DRAG_INITIAL_STATE - ) - const [pendingRevealRetryTick, setPendingRevealRetryTick] = useState(0) - const [documentVisibilityRevision, setDocumentVisibilityRevision] = useState(0) - const [highlightedRevealRowKey, setHighlightedRevealRowKey] = useState(null) - const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId) - const assignWorktreeParent = useAppStore((s) => s.assignWorktreeParent) - const updateWorktreeLineage = useAppStore((s) => s.updateWorktreeLineage) - const cyclicLineageIds = useMemo( - () => getCyclicProjectedWorktreeLineageIds(worktreeLineageById, worktreeMap), - [worktreeLineageById, worktreeMap] - ) - const worktreeDragSessionRef = useRef(null) - // Why: cross-group hovers hit-test a group the session never captured, so hold - // that group's drop decision separately or a card expanding in the target group - // moves the insertion line under a still pointer. - const statusDropAnchorsRef = useRef>(new Map()) - const worktreePointerDragRef = useRef(null) - const worktreePointerAutoscrollFrameIdRef = useRef(null) - const worktreePointerAutoscrollLastFrameTimeRef = useRef(null) - const worktreeNativeAutoscrollFrameIdRef = useRef(null) - const worktreeNativeAutoscrollLastFrameTimeRef = useRef(null) - const worktreeNativeLatestPointRef = useRef(null) - const pendingRevealRetryRef = useRef<{ worktreeId: string; count: number } | null>(null) - const pendingRowRevealRetryRef = useRef<{ rowKey: string; count: number } | null>(null) - const pendingRevealFrameIdsRef = useRef>(new Set()) - const pendingRevealScrollRef = useRef(null) - const revealHighlightFrameIdRef = useRef(null) - const revealHighlightTimeoutRef = useRef(null) - const cancelPendingRevealFrames = useCallback(() => { - for (const frameId of pendingRevealFrameIdsRef.current) { - window.cancelAnimationFrame(frameId) - } - pendingRevealFrameIdsRef.current.clear() - }, []) - const schedulePendingRevealFrame = useCallback((callback: FrameRequestCallback) => { - const frameId = window.requestAnimationFrame((time) => { - pendingRevealFrameIdsRef.current.delete(frameId) - callback(time) - }) - pendingRevealFrameIdsRef.current.add(frameId) - }, []) - const clearRevealHighlightFrame = useCallback(() => { - if (revealHighlightFrameIdRef.current !== null) { - window.cancelAnimationFrame(revealHighlightFrameIdRef.current) - revealHighlightFrameIdRef.current = null - } - }, []) - const clearRevealHighlightTimeout = useCallback(() => { - if (revealHighlightTimeoutRef.current !== null) { - window.clearTimeout(revealHighlightTimeoutRef.current) - revealHighlightTimeoutRef.current = null - } - }, []) - const flashRevealedRow = useCallback( - (rowKey: string) => { - clearRevealHighlightTimeout() - clearRevealHighlightFrame() - // Why: clear before set restarts the CSS glow when revealing the same row repeatedly. - setHighlightedRevealRowKey(null) - revealHighlightFrameIdRef.current = window.requestAnimationFrame(() => { - revealHighlightFrameIdRef.current = null - setHighlightedRevealRowKey(rowKey) - revealHighlightTimeoutRef.current = window.setTimeout(() => { - revealHighlightTimeoutRef.current = null - setHighlightedRevealRowKey(null) - }, 1500) - }) - }, - [clearRevealHighlightFrame, clearRevealHighlightTimeout] - ) - const suppressWorktreeClickUntilRef = useRef(0) - const hasProjectGroups = projectGroups.length > 0 - const canReorderRepoHeaders = groupBy === 'repo' && projectOrderBy === 'manual' - const canReorderProjectGroupHeaders = groupBy === 'repo' && hasProjectGroups - const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup) - const updateProjectGroup = useAppStore((s) => s.updateProjectGroup) - const lastVisibleRefreshKeyRef = useRef('') - const reportVisibleGitHubPRRefreshCandidates = useAppStore( - (s) => s.reportVisibleGitHubPRRefreshCandidates - ) - const cardProps = useAppStore((s) => s.worktreeCardProperties) - const rightSidebarShowsPR = useAppStore((s) => rightSidebarShowsPullRequestData(s)) - const keybindings = useAppStore((s) => s.keybindings) - const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration) - const prVisibleRefreshGeneration = useAppStore((s) => s.prVisibleRefreshGeneration) - const settings = useAppStore((s) => s.settings) - const newCardStyle = settings?.experimentalNewWorktreeCardStyle === true - const reorderRepos = useAppStore((s) => s.reorderRepos) - const folderBackedProjectGroupIds = useMemo( - () => - new Set( - projectGroups - .filter((group) => group.createdFrom === 'folder-scan') - .map((group) => group.id) - ), - [projectGroups] - ) - const projectGroupByIdForHeaderDrag = useMemo( - () => new Map(projectGroups.map((group) => [group.id, group])), - [projectGroups] - ) - - useEffect( - () => - installWorktreeVisibleRefreshVisibilityListener(() => { - if (document.visibilityState !== 'visible') { - // Why: row identity may be unchanged after a hidden window; reset the key so PR/CI rows refresh. - lastVisibleRefreshKeyRef.current = '__document_hidden__' - return - } - setDocumentVisibilityRevision((revision) => revision + 1) - }), - [] - ) - - // Why: reorder keeps scrollTop stable; flag direct scroll input so anchor-restore won't chase the moved row (jumpy drop). - const commitRepoReorder = useCallback( - (orderedIds: string[]) => { - const suppressUntil = - window.performance.now() + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS - suppressMeasurementAdjustmentUntilRef.current = suppressUntil - directScrollInputUntilRef.current = suppressUntil - reorderRepos(orderedIds) - }, - [reorderRepos] - ) - const orderedHostIds = useMemo( - () => - rows - .filter((row): row is HostHeaderRow => row.type === 'host-header') - .map((row) => row.hostId), - [rows] - ) - const hostDrag = useHostHeaderDrag({ - orderedHostIds, - onCommit: onReorderHostSections, - getScrollContainer: () => scrollRef.current - }) - useEffect(() => { - onHostDragActiveChange(hostDrag.state.draggingHostId !== null) - }, [hostDrag.state.draggingHostId, onHostDragActiveChange]) - useEffect(() => () => onHostDragActiveChange(false), [onHostDragActiveChange]) - const worktreeDragGroups = useMemo(() => getWorktreeDragGroups(rows), [rows]) - const worktreeDragUnitGroups = useMemo(() => getWorktreeDragUnitGroups(rows), [rows]) - const naturalDragWorktreeIds = useMemo( - () => - new Set( - rows.flatMap((row) => - row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : [] - ) - ), - [rows] - ) - const worktreeLineageDragRows = useMemo( - () => - rows - .filter((row): row is WorktreeItemRow => row.type === 'item') - .filter( - (row) => - row.sectionKey !== PINNED_GROUP_KEY || !naturalDragWorktreeIds.has(row.worktree.id) - ) - .map((row) => ({ worktreeId: row.worktree.id, depth: row.depth })), - [naturalDragWorktreeIds, rows] - ) - const getReorderDraggedIds = useCallback( - (draggedIds: readonly string[]) => - expandDraggedWorktreeIdsForVisibleLineage(worktreeLineageDragRows, draggedIds), - [worktreeLineageDragRows] - ) - const getReorderUnitDraggedIds = useCallback( - (sourceGroupKey: string, reorderDraggedIds: readonly string[]) => { - const group = worktreeDragUnitGroups.find((candidate) => candidate.key === sourceGroupKey) - if (!group) { - return reorderDraggedIds - } - const unitIds = new Set(group.worktreeIds) - const filtered = reorderDraggedIds.filter((worktreeId) => unitIds.has(worktreeId)) - return filtered.length > 0 ? filtered : reorderDraggedIds - }, - [worktreeDragUnitGroups] - ) - const { groupKeyByRowKey, groupIndexByRowKey } = useMemo( - () => getWorktreeDragIndexes(rows), - [rows] - ) - const refreshWorktreeDragSession = useCallback((): boolean => { - const session = worktreeDragSessionRef.current - const container = scrollRef.current - if (!session || !container) { - return false - } - - const refreshedSession = refreshWorktreeSidebarDragSession({ - session, - groups: worktreeDragGroups, - unitGroups: worktreeDragUnitGroups, - rects: getWorktreeSidebarDragRectsForGroup(container, session.sourceGroupKey) - }) - worktreeDragSessionRef.current = refreshedSession - return refreshedSession !== null - }, [worktreeDragGroups, worktreeDragUnitGroups]) - const computeWorktreeDropForGroup = useCallback( - (args: { - pointerY: number - groupKey: string - rects: readonly WorktreeSidebarDragRect[] - draggedIds: readonly string[] - draggingWorktreeId?: string | null - grab?: WorktreeSidebarDragGrab | null - anchor?: WorktreeSidebarDropAnchor | null - }): WorktreeSidebarDropPreview | null => { - const container = scrollRef.current - if (!container) { - return null - } - const group = worktreeDragUnitGroups.find((candidate) => candidate.key === args.groupKey) - if (!group) { - return null - } - const containerRect = container.getBoundingClientRect() - return computeWorktreeSidebarDropPreview({ - pointerY: args.pointerY, - containerTop: containerRect.top, - scrollTop: container.scrollTop, - rects: args.rects, - groupIds: group.worktreeIds, - draggedIds: args.draggedIds, - draggingWorktreeId: args.draggingWorktreeId, - fallbackGap: WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP, - grab: args.grab, - anchor: args.anchor - }) - }, - [worktreeDragUnitGroups] - ) - const computeWorktreeDrop = useCallback( - (pointerY: number): WorktreeSidebarDropPreview | null => { - const session = worktreeDragSessionRef.current - const container = scrollRef.current - if (!session || !container) { - return null - } - const scrollTop = container.scrollTop - // Why: only real pointer or scroll movement should re-decide the slot; a - // card growing under a still pointer must not move it. - const anchor = shouldReevaluateWorktreeSidebarDropAnchor({ - anchor: session.anchor, - pointerY, - scrollTop - }) - ? null - : session.anchor - const preview = computeWorktreeDropForGroup({ - pointerY, - groupKey: session.sourceGroupKey, - rects: session.rects, - draggedIds: session.reorderUnitDraggedIds, - draggingWorktreeId: session.draggingWorktreeId, - grab: session.grab, - anchor - }) - worktreeDragSessionRef.current = { - ...session, - anchor: preview ? { beforeWorktreeId: preview.dropAnchorId, pointerY, scrollTop } : null - } - return preview - }, - [computeWorktreeDropForGroup] - ) - const computeWorktreeStatusDrop = useCallback( - (args: { - pointerY: number - status: WorkspaceStatus - draggedIds: readonly string[] - }): WorktreeSidebarDropPreview | null => { - const container = scrollRef.current - if (!container) { - return null - } - const groupKey = getWorkspaceStatusGroupKey(args.status) - const session = worktreeDragSessionRef.current - const scrollTop = container.scrollTop - const heldAnchor = statusDropAnchorsRef.current.get(groupKey) ?? null - const anchor = shouldReevaluateWorktreeSidebarDropAnchor({ - anchor: heldAnchor, - pointerY: args.pointerY, - scrollTop - }) - ? null - : heldAnchor - const preview = computeWorktreeDropForGroup({ - pointerY: args.pointerY, - groupKey, - rects: getWorktreeSidebarDragRectsForGroup(container, groupKey), - draggedIds: args.draggedIds, - draggingWorktreeId: session?.draggingWorktreeId ?? null, - grab: session?.grab ?? null, - anchor - }) - if (preview) { - statusDropAnchorsRef.current.set(groupKey, { - beforeWorktreeId: preview.dropAnchorId, - pointerY: args.pointerY, - scrollTop - }) - } else { - statusDropAnchorsRef.current.delete(groupKey) - } - return preview - }, - [computeWorktreeDropForGroup] - ) - const renderRows = useMemo(() => buildRenderableRows(rows), [rows]) - const sidebarRepoHeaderIdsByBucket = useMemo( - () => - getSidebarOrderedRepoHeaderIdsByBucket( - rows.filter((row): row is Row => row.type !== 'host-header') - ), - [rows] - ) - const sidebarProjectGroupHeaderIdsByBucket = useMemo( - () => - getSidebarOrderedProjectGroupHeaderIdsByBucket( - rows.filter((row): row is Row => row.type !== 'host-header'), - projectGroupByIdForHeaderDrag - ), - [projectGroupByIdForHeaderDrag, rows] - ) - const repoHeaderIndexByRepoId = useMemo(() => { - const map = new Map() - for (const repoIds of sidebarRepoHeaderIdsByBucket.values()) { - repoIds.forEach((repoId, index) => { - map.set(repoId, index) - }) - } - return map - }, [sidebarRepoHeaderIdsByBucket]) - const repoHeaderBucketByRepoId = useMemo(() => { - const map = new Map() - for (const [bucketKey, repoIds] of sidebarRepoHeaderIdsByBucket) { - for (const repoId of repoIds) { - map.set(repoId, bucketKey) - } - } - return map - }, [sidebarRepoHeaderIdsByBucket]) - const projectGroupHeaderIndexByGroupId = useMemo(() => { - const map = new Map() - for (const groupIds of sidebarProjectGroupHeaderIdsByBucket.values()) { - groupIds.forEach((groupId, index) => { - map.set(groupId, index) - }) - } - return map - }, [sidebarProjectGroupHeaderIdsByBucket]) - const projectGroupHeaderBucketByGroupId = useMemo(() => { - const map = new Map() - for (const [bucketKey, groupIds] of sidebarProjectGroupHeaderIdsByBucket) { - for (const groupId of groupIds) { - map.set(groupId, bucketKey) - } - } - return map - }, [sidebarProjectGroupHeaderIdsByBucket]) - const commitProjectGroupOrder = useCallback( - (repoId: string, projectGroupId: string | null, order: number) => { - void moveProjectToGroup(repoId, projectGroupId, order) - }, - [moveProjectToGroup] - ) - const commitProjectGroupHeaderOrder = useCallback( - (groupId: string, tabOrder: number) => { - if (!Number.isFinite(tabOrder)) { - return - } - const suppressUntil = - window.performance.now() + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS - suppressMeasurementAdjustmentUntilRef.current = suppressUntil - directScrollInputUntilRef.current = suppressUntil - void updateProjectGroup(groupId, { tabOrder }) - }, - [updateProjectGroup] - ) - // Drag applies only in manual order; still construct the controller inert for stable hook order. - const repoDrag = useRepoHeaderDrag({ - orderedRepoIds: allRepoIds, - sidebarRepoHeaderIdsByBucket, - repoById: repoMap, - usesProjectGroupOrdering: hasProjectGroups, - onCommitRepoOrder: commitRepoReorder, - onCommitProjectGroupOrder: commitProjectGroupOrder, - getScrollContainer: () => scrollRef.current - }) - const projectGroupDrag = useProjectGroupHeaderDrag({ - sidebarProjectGroupHeaderIdsByBucket, - projectGroupById: projectGroupByIdForHeaderDrag, - onCommitProjectGroupTabOrder: commitProjectGroupHeaderOrder, - getScrollContainer: () => scrollRef.current - }) - const [primaryActiveWorktreeRow, setPrimaryActiveWorktreeRow] = useState<{ - worktreeId: string - rowKey: string - } | null>(null) - useEffect(() => { - if (activeWorktreeId === null) { - setPrimaryActiveWorktreeRow(null) - return - } - setPrimaryActiveWorktreeRow((current) => { - if (current === null || current.worktreeId !== activeWorktreeId) { - return null - } - const rowStillVisible = rows.some( - (row) => - row.type === 'item' && - row.worktree.id === current.worktreeId && - row.rowKey === current.rowKey - ) - return rowStillVisible ? current : null - }) - }, [activeWorktreeId, rows]) - const getActiveSurfaceVariant = useCallback( - (row: WorktreeItemRow): ActiveSurfaceVariant => { - if (primaryActiveWorktreeRow?.worktreeId === row.worktree.id) { - return primaryActiveWorktreeRow.rowKey === row.rowKey ? 'primary' : 'secondary' - } - if ( - pinnedDisplayPolicy === 'duplicate-in-groups' && - activeWorktreeId === row.worktree.id && - isPinnedWorktreeRow(row) - ) { - return 'secondary' - } - return 'primary' - }, - [activeWorktreeId, pinnedDisplayPolicy, primaryActiveWorktreeRow] - ) - const handleImmediateWorktreeRowActivate = useCallback( - (worktreeId: string, rowKey: string | undefined): void => { - setPrimaryActiveWorktreeRow(rowKey ? { worktreeId, rowKey } : null) - onImmediateWorktreeActivate(worktreeId, rowKey) - }, - [onImmediateWorktreeActivate] - ) - const firstHeaderIndex = useMemo( - () => renderRows.findIndex((row) => row.type === 'header' || row.type === 'host-header'), - [renderRows] - ) - const repoHeaderSectionEndByRepoId = useMemo( - () => - getRepoHeaderSectionEndByRepoId({ - rows: renderRows, - firstHeaderIndex, - sidebarRepoHeaderIdsByBucket, - repoHeaderBucketByRepoId - }), - [firstHeaderIndex, renderRows, repoHeaderBucketByRepoId, sidebarRepoHeaderIdsByBucket] - ) - const projectGroupHeaderSectionEndByGroupId = useMemo( - () => - getProjectGroupHeaderSectionEndByGroupId({ - rows: renderRows, - firstHeaderIndex, - sidebarProjectGroupHeaderIdsByBucket, - projectGroupHeaderBucketByGroupId - }), - [ - firstHeaderIndex, - projectGroupHeaderBucketByGroupId, - renderRows, - sidebarProjectGroupHeaderIdsByBucket - ] - ) - const firstHeaderIndexRef = useRef(firstHeaderIndex) - firstHeaderIndexRef.current = firstHeaderIndex - const stickyHeaderIndexes = useMemo(() => getStickyHeaderIndexes(renderRows), [renderRows]) - const stickyHeaderIndexesRef = useRef(stickyHeaderIndexes) - stickyHeaderIndexesRef.current = stickyHeaderIndexes - const activeStickyHeaderIndexRef = useRef(null) - const activeStickyHostIndexRef = useRef(null) - const stickyRangeStartIndexRef = useRef(0) - const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) - const { - folderWorkspacePathStatuses, - fetchFolderWorkspacePathStatus, - getFolderWorkspacePathStatusCacheKey, - getFreshFolderWorkspacePathStatus, - activeRuntimeEnvironmentId - } = useAppStore( - useShallow((s) => ({ - folderWorkspacePathStatuses: s.folderWorkspacePathStatuses, - fetchFolderWorkspacePathStatus: s.fetchFolderWorkspacePathStatus, - getFolderWorkspacePathStatusCacheKey: s.getFolderWorkspacePathStatusCacheKey, - getFreshFolderWorkspacePathStatus: s.getFreshFolderWorkspacePathStatus, - activeRuntimeEnvironmentId: s.settings?.activeRuntimeEnvironmentId ?? null - })) - ) - const folderPathStatusRepoMembershipKey = useMemo( - () => - allRepoIds - .map((repoId) => { - const repo = repoMap.get(repoId) - return `${repoId}:${repo?.path ?? ''}:${repo?.projectGroupId ?? ''}:${repo?.connectionId ?? ''}` - }) - .join('\0'), - [allRepoIds, repoMap] - ) - const folderPathStatusSshConnectionKey = useMemo( - () => - [...sshConnectionStates.entries()] - .map(([connectionId, state]) => `${connectionId}:${state.status}`) - .sort() - .join('\0'), - [sshConnectionStates] - ) - const folderPathStatusCacheExpiryTick = useFolderWorkspacePathStatusCacheExpiryTick( - folderWorkspacePathStatuses - ) - const projectGroupByIdForFolderPathStatus = useMemo( - () => new Map(projectGroups.map((group) => [group.id, group])), - [projectGroups] - ) - const folderWorkspaceByIdForFolderPathStatus = useMemo( - () => new Map(folderWorkspaces.map((workspace) => [workspace.id, workspace])), - [folderWorkspaces] - ) - const getFolderPathStatusRouteOptions = useCallback( - (request: Parameters[0]) => - getFolderPathStatusRouteOptionsForRows({ - request, - projectGroupsById: projectGroupByIdForFolderPathStatus, - folderWorkspacesById: folderWorkspaceByIdForFolderPathStatus - }), - [folderWorkspaceByIdForFolderPathStatus, projectGroupByIdForFolderPathStatus] - ) - useEffect(() => { - const requests = new Map< - string, - { - request: Parameters[0] - options?: { runtimeEnvironmentId: string | null } - } - >() - for (const group of projectGroups) { - if (group.parentPath) { - const request = { scope: 'project-group' as const, projectGroupId: group.id } - const options = getFolderPathStatusRouteOptions(request) - requests.set(getFolderWorkspacePathStatusCacheKey(request, options), { request, options }) - } - } - for (const workspace of folderWorkspaces) { - const request = { scope: 'folder-workspace' as const, folderWorkspaceId: workspace.id } - const options = getFolderPathStatusRouteOptions(request) - requests.set(getFolderWorkspacePathStatusCacheKey(request, options), { request, options }) - } - for (const { request, options } of requests.values()) { - void fetchFolderWorkspacePathStatus(request, { force: true, ...options }) - } - }, [ - activeRuntimeEnvironmentId, - fetchFolderWorkspacePathStatus, - folderPathStatusRepoMembershipKey, - folderPathStatusSshConnectionKey, - folderWorkspaces, - getFolderPathStatusRouteOptions, - getFolderWorkspacePathStatusCacheKey, - projectGroups - ]) - const getCachedFolderWorkspacePathStatus = useCallback( - (request: Parameters[0]) => { - const options = getFolderPathStatusRouteOptions(request) - const cacheKey = getFolderWorkspacePathStatusCacheKey(request, options) - // Why: don't let an expired negative status keep folder workspaces disabled while a refresh is in flight. - void folderWorkspacePathStatuses[cacheKey] - void folderPathStatusCacheExpiryTick - return getFreshFolderWorkspacePathStatus(request, options) - }, - [ - folderWorkspacePathStatuses, - folderPathStatusCacheExpiryTick, - getFolderPathStatusRouteOptions, - getFolderWorkspacePathStatusCacheKey, - getFreshFolderWorkspacePathStatus - ] - ) - const renderRowsRef = useRef(renderRows) - renderRowsRef.current = renderRows - const getVirtualItemKey = useCallback( - (index: number) => { - const row = renderRows[index] - if (!row) { - return `__stale_${index}` - } - return getRenderRowKey(row) - }, - [renderRows] - ) - const getExpectedVirtualRowKey = useCallback((element: Element) => { - const index = getVirtualRowIndex(element) - const row = index === null ? undefined : renderRowsRef.current[index] - return row ? getRenderRowKey(row) : null - }, []) - const isCurrentVirtualRowElement = useCallback( - (element: Element) => { - const expectedKey = getExpectedVirtualRowKey(element) - return ( - element.isConnected && - expectedKey !== null && - element.getAttribute('data-worktree-virtual-row-key') === expectedKey - ) - }, - [getExpectedVirtualRowKey] - ) - const measureCurrentVirtualRowElement = useCallback( - ( - element: HTMLDivElement, - entry: ResizeObserverEntry | undefined, - instance: Parameters>[2] - ) => { - if (!isCurrentVirtualRowElement(element)) { - const index = getVirtualRowIndex(element) - const measured = instance.getVirtualItems().find((item) => item.index === index) - // Why: a stale ResizeObserver row after remount would write a wrong height; return current size to no-op it. - return ( - measured?.size ?? - estimateRenderRowSize( - renderRowsRef.current, - index ?? -1, - firstHeaderIndexRef.current, - activeStickyHeaderIndexRef.current - ) - ) - } - const index = getVirtualRowIndex(element) - if ( - index !== null && - (renderRowsRef.current[index]?.type === 'header' || - renderRowsRef.current[index]?.type === 'host-header') - ) { - return estimateRenderRowSize( - renderRowsRef.current, - index, - firstHeaderIndexRef.current, - activeStickyHeaderIndexRef.current - ) - } - return measureVirtualElementSize(element, entry, instance) - }, - [isCurrentVirtualRowElement] - ) - const markScrollMovement = useCallback(() => { - suppressMeasurementAdjustmentUntilRef.current = - window.performance.now() + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS - }, []) - const markDirectScrollInput = useCallback(() => { - const suppressUntil = window.performance.now() + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS - suppressMeasurementAdjustmentUntilRef.current = suppressUntil - directScrollInputUntilRef.current = suppressUntil - }, []) - const { showScrollToTop, scrollToTop } = useWorktreeListScrollToTop({ - scrollElement, - onUserScrollIntent: markDirectScrollInput - }) - const hasDirectScrollInput = useCallback( - () => window.performance.now() < directScrollInputUntilRef.current, - [] - ) - const markRevealScroll = useCallback((targetTop: number) => { - pendingRevealScrollRef.current = createPendingRevealScroll(targetTop, window.performance.now()) - }, []) - const isRevealScrollSettlingNow = useCallback(() => { - const settling = isRevealScrollSettling({ - now: window.performance.now(), - pending: pendingRevealScrollRef.current, - scrollTop: scrollRef.current?.scrollTop ?? 0 - }) - if (!settling) { - pendingRevealScrollRef.current = null - } - return settling - }, []) - // Why: programmatic scrolls keep measurement correction quiet, but only direct input blocks anchor-restore retries. - // A reveal's smooth scroll is the exception: restoring the anchor mid-animation cancels it a few pixels in. - const shouldSkipScrollAnchorRestore = useCallback( - () => - window.performance.now() < directScrollInputUntilRef.current || isRevealScrollSettlingNow(), - [isRevealScrollSettlingNow] - ) - - const virtualizer = useVirtualizer({ - count: renderRows.length, - getScrollElement: () => scrollRef.current, - estimateSize: (index) => - estimateRenderRowSize( - renderRows, - index, - firstHeaderIndex, - activeStickyHeaderIndexRef.current - ), - measureElement: measureCurrentVirtualRowElement, - // Why: TanStack memoizes rangeExtractor by identity; header indexes must be deps or sticky slots go stale. - rangeExtractor: useCallback( - (range: Range) => { - stickyRangeStartIndexRef.current = range.startIndex - return extractWorktreeVirtualRowIndexes({ - range, - stickyHeaderIndexes, - rows: renderRowsRef.current - }) - }, - [stickyHeaderIndexes] - ), - overscan: 10, - gap: WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP, - // Why: the sticky group header lives inside the virtual list, so scroll math needs the same top inset as the DOM reveal. - scrollPaddingStart: WORKTREE_SIDEBAR_REVEAL_TOP_INSET, - isScrollingResetDelay: USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS, - // Why: sync-flushing rich card renders in the scroll listener stalls wheel input; async + overscan keeps rows filled. - useFlushSync: false, - // Why: seed scrollOffset from the ref (not 0) so the first getVirtualItems() after remount picks the right rows. - initialOffset: () => scrollOffsetRef.current, - getItemKey: getVirtualItemKey - }) - // Why: TanStack's default correction writes scrollTop while cards remeasure mid-wheel, which feels like rubber-banding. - // TODO(scroll-origin-migration): wall-clock suppression misclassifies under jank; migrate to programmaticScrollMarks + restoreSignal (see CombinedDiffViewer). - virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => - shouldAdjustWorktreeSidebarMeasuredRowScroll({ - isScrolling: instance.isScrolling, - now: window.performance.now(), - suppressUntil: suppressMeasurementAdjustmentUntilRef.current - }) - - useEffect(() => { - const handleSuppress = () => { - // Why: let an expanding agent row grow in place instead of TanStack compensating scrollTop. - suppressMeasurementAdjustmentUntilRef.current = - window.performance.now() + EXPANDING_CARD_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS - } - window.addEventListener(SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT, handleSuppress) - return () => { - window.removeEventListener(SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT, handleSuppress) - } - }, []) - - React.useEffect(() => { - if (!pendingRevealWorktree) { - return - } - - if (agentSendTargetWorktreeId !== pendingRevealWorktree.worktreeId) { - const folderGroupKeys = getFolderWorkspaceRevealGroupKeys( - pendingRevealWorktree.worktreeId, - folderWorkspaces, - projectGroups - ) - if (folderGroupKeys.length > 0) { - for (const groupKey of folderGroupKeys) { - if (collapsedGroups.has(groupKey)) { - toggleGroup(groupKey) - } - } - } else { - const targetWorktree = worktrees.find((w) => w.id === pendingRevealWorktree.worktreeId) - const targetRepo = targetWorktree ? repoMap.get(targetWorktree.repoId) : undefined - if (targetWorktree) { - const hostId = getWorktreeExecutionHostId(targetWorktree, targetRepo, defaultHostId) - const hostGroupKey = `host:${hostId}` - if (collapsedGroups.has(hostGroupKey)) { - toggleGroup(hostGroupKey) - } - - for (const parent of getWorktreeLineageAncestors( - targetWorktree, - worktreeLineageById, - worktreeMap - )) { - const lineageGroupKey = getLineageGroupKey(parent.id) - if (collapsedGroups.has(lineageGroupKey)) { - toggleGroup(lineageGroupKey) - } - } - - const groupKeys = - targetWorktree.isPinned && pinnedDisplayPolicy === 'single-location' - ? getPinnedWorktreeRevealCollapsedGroupKeys({ - worktree: targetWorktree, - collapsedGroups - }) - : getGroupKeysForWorktree( - groupBy, - targetWorktree, - repoMap, - prCache, - workspaceStatuses, - settings, - projectGroups, - projectGrouping - ) - for (const groupKey of groupKeys) { - if (collapsedGroups.has(groupKey)) { - toggleGroup(groupKey) - } - } - } - } - } - - let cancelled = false - schedulePendingRevealFrame(() => { - if (cancelled) { - return - } - const targetWorktreeStillExists = sidebarWorkspaceStillExists( - pendingRevealWorktree.worktreeId, - worktrees, - folderWorkspaces - ) - const targetIndex = findPreferredRenderRowIndexForWorktree( - renderRows, - pendingRevealWorktree.worktreeId, - pinnedDisplayPolicy - ) - const outcome = resolvePendingSidebarReveal({ targetIndex, targetWorktreeStillExists }) - if (outcome === 'scroll-and-clear') { - const targetRow = renderRows[targetIndex] - const container = scrollRef.current - const retryExactRevealOnNextFrame = () => { - const previousRetry = pendingRevealRetryRef.current - const nextRetryCount = - previousRetry?.worktreeId === pendingRevealWorktree.worktreeId - ? previousRetry.count + 1 - : 1 - pendingRevealRetryRef.current = { - worktreeId: pendingRevealWorktree.worktreeId, - count: nextRetryCount - } - if (nextRetryCount <= 8) { - schedulePendingRevealFrame(() => { - if (!cancelled) { - setPendingRevealRetryTick((tick) => tick + 1) - } - }) - } else { - pendingRevealRetryRef.current = null - clearPendingRevealWorktreeId() - } - } - const revealedOption = container - ? revealMountedWorktreeElement( - container, - pendingRevealWorktree.worktreeId, - pendingRevealWorktree.behavior, - getRenderRowOptionId(targetRow, pendingRevealWorktree.worktreeId), - markRevealScroll - ) - : null - if (revealedOption) { - if (pendingRevealWorktree.highlight) { - const revealedRowKey = - revealedOption.dataset.worktreeRowKey ?? getRenderRowSidebarKey(targetRow) - if (revealedRowKey) { - flashRevealedRow(revealedRowKey) - } - } - if (pendingRevealWorktree.beginRename) { - setRenamingWorktreeId({ - worktreeId: pendingRevealWorktree.worktreeId, - rowKey: revealedOption.dataset.worktreeRowKey - }) - } - pendingRevealRetryRef.current = null - clearPendingRevealWorktreeId() - return - } - - if (targetRow?.type !== 'lineage-group') { - // Why: virtual indexing can leave the card edge clipped; stage it into the window, then retry the exact DOM reveal. - virtualizer.scrollToIndex(targetIndex, { - align: 'auto', - behavior: 'auto' - }) - retryExactRevealOnNextFrame() - return - } - - // Why: for lineage groups the virtual row is only a staging target; jump into the window, then retry the exact reveal. - virtualizer.scrollToIndex(targetIndex, { - align: 'auto', - behavior: 'auto' - }) - retryExactRevealOnNextFrame() - return - } - if (outcome === 'clear') { - pendingRevealRetryRef.current = null - clearPendingRevealWorktreeId() - } - }) - return () => { - cancelled = true - cancelPendingRevealFrames() - } - }, [ - pendingRevealWorktree, - agentSendTargetWorktreeId, - groupBy, - worktrees, - folderWorkspaces, - repoMap, - prCache, - worktreeLineageById, - worktreeMap, - renderRows, - virtualizer, - clearPendingRevealWorktreeId, - toggleGroup, - collapsedGroups, - defaultHostId, - workspaceStatuses, - settings, - pinnedDisplayPolicy, - projectGrouping, - projectGroups, - pendingRevealRetryTick, - flashRevealedRow, - markRevealScroll, - setRenamingWorktreeId, - schedulePendingRevealFrame, - cancelPendingRevealFrames - ]) - - React.useEffect(() => { - if (!pendingRevealSidebarRow) { - return - } - - const isProjectHeaderTarget = - pendingRevealSidebarRow.rowKey.startsWith('project-group:') || - pendingRevealSidebarRow.rowKey.startsWith('project:') || - pendingRevealSidebarRow.rowKey.startsWith('repo:') - if (isProjectHeaderTarget && groupBy !== 'repo') { - return - } - - let toggledAncestor = false - for (const groupKey of getSidebarRowRevealAncestorKeys({ - rowKey: pendingRevealSidebarRow.rowKey, - repoMap, - projectGroups, - projectGrouping - })) { - if (collapsedGroups.has(groupKey)) { - toggleGroup(groupKey) - toggledAncestor = true - } - } - if (toggledAncestor) { - return - } - - let cancelled = false - const retryPendingReveal = () => { - const previousRetry = pendingRowRevealRetryRef.current - const nextRetryCount = - previousRetry?.rowKey === pendingRevealSidebarRow.rowKey ? previousRetry.count + 1 : 1 - pendingRowRevealRetryRef.current = { - rowKey: pendingRevealSidebarRow.rowKey, - count: nextRetryCount - } - if (nextRetryCount <= 8) { - schedulePendingRevealFrame(() => { - if (!cancelled) { - setPendingRevealRetryTick((tick) => tick + 1) - } - }) - return true - } - return false - } - schedulePendingRevealFrame(() => { - if (cancelled) { - return - } - const targetIndex = renderRows.findIndex((row) => - rowKeyMatchesRenderRow(row, pendingRevealSidebarRow.rowKey) - ) - if (targetIndex === -1) { - if (retryPendingReveal()) { - return - } - pendingRowRevealRetryRef.current = null - clearPendingRevealSidebarRow() - toast.error( - translate( - 'auto.components.sidebar.WorktreeList.sidebarRowMissing', - 'Target no longer exists' - ) - ) - return - } - - const retryExactRevealOnNextFrame = () => { - if (retryPendingReveal()) { - return - } - pendingRowRevealRetryRef.current = null - clearPendingRevealSidebarRow() - } - - const container = scrollRef.current - const revealedElement = container - ? revealMountedSidebarRowElement( - container, - pendingRevealSidebarRow.rowKey, - pendingRevealSidebarRow.behavior, - markRevealScroll - ) - : null - if (revealedElement) { - if (pendingRevealSidebarRow.highlight) { - flashRevealedRow(pendingRevealSidebarRow.rowKey) - } - pendingRowRevealRetryRef.current = null - clearPendingRevealSidebarRow() - return - } - - virtualizer.scrollToIndex(targetIndex, { - align: 'auto', - behavior: 'auto' - }) - retryExactRevealOnNextFrame() - }) - - return () => { - cancelled = true - cancelPendingRevealFrames() - } - }, [ - pendingRevealSidebarRow, - repoMap, - projectGroups, - projectGrouping, - collapsedGroups, - groupBy, - toggleGroup, - renderRows, - virtualizer, - pendingRevealRetryTick, - flashRevealedRow, - markRevealScroll, - clearPendingRevealSidebarRow, - schedulePendingRevealFrame, - cancelPendingRevealFrames - ]) - - const prCacheLen = useAppStore((s) => countRecordKeysByReference(s.prCache)) - const issueCacheLen = useAppStore((s) => countRecordKeysByReference(s.issueCache)) - const renderRowKeySignature = useMemo( - () => renderRows.map(getRenderRowKey).join('\n'), - [renderRows] - ) - const activeRenderRowKeys = useMemo(() => new Set(renderRows.map(getRenderRowKey)), [renderRows]) - const lineageRowRekeys = useMemo(() => buildLineageRowRekeyMap(renderRows), [renderRows]) - const totalSize = virtualizer.getTotalSize() - const virtualItems = virtualizer.getVirtualItems() - const activeStickyIndexes = getActiveStickyIndexesForScroll({ - rows: renderRows, - rangeStartIndex: stickyRangeStartIndexRef.current, - scrollOffset: virtualizer.scrollOffset ?? scrollOffsetRef.current, - stickyHeaderIndexes, - virtualItems - }) - activeStickyHeaderIndexRef.current = activeStickyIndexes.groupIndex - activeStickyHostIndexRef.current = activeStickyIndexes.hostIndex - - const measureMountedRows = useCallback(() => { - virtualizer.elementsCache.forEach((element) => { - if (!isCurrentVirtualRowElement(element)) { - return - } - virtualizer.measureElement(element) - }) - }, [isCurrentVirtualRowElement, virtualizer]) - const measureVirtualRowElement = useCallback( - (element: HTMLDivElement | null) => { - if (!element) { - virtualizer.measureElement(null) - return - } - if (!isCurrentVirtualRowElement(element)) { - return - } - virtualizer.measureElement(element) - }, - [isCurrentVirtualRowElement, virtualizer] - ) - - useLayoutEffect(() => { - pruneStaleVirtualRowElementCache({ - activeRowKeys: activeRenderRowKeys, - virtualizer - }) - // Why: a stale retained element after delete/collapse measures 0px and corrupts the next slot; measure only key-matched rows. - measureMountedRows() - const frameId = window.requestAnimationFrame(measureMountedRows) - return () => window.cancelAnimationFrame(frameId) - }, [ - activeRenderRowKeys, - prCacheLen, - issueCacheLen, - measureMountedRows, - renderRowKeySignature, - virtualizer - ]) - - useVirtualizedScrollAnchor({ - anchorRef: scrollAnchorRef, - getItemElementKey: getVirtualRowKey, - getRowKey: getRenderRowKey, - itemElementSelector: '[data-worktree-virtual-row]', - rekeyedRowKeys: lineageRowRekeys, - rows: renderRows, - scrollElementRef: scrollRef, - scrollOffsetRef, - hasDirectScrollInput, - shouldSkipRestore: shouldSkipScrollAnchorRestore, - totalSize, - virtualizer - }) - - const recordCurrentScrollAnchor = useCallback(() => { - scrollRef.current?.dispatchEvent(new Event(VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT)) - }, []) - const toggleGroupWithScrollAnchor = useCallback( - (groupKey: string) => { - recordCurrentScrollAnchor() - toggleGroup(groupKey) - }, - [recordCurrentScrollAnchor, toggleGroup] - ) - // Why: memo'd WorktreeCard needs a per-group-key stable onLineageToggle - // identity to bail out of re-renders; see worktree-lineage-toggle-handler-cache. - const getLineageToggleHandler = useMemo( - () => createLineageToggleHandlerCache(toggleGroupWithScrollAnchor), - [toggleGroupWithScrollAnchor] - ) - - const navigateWorktree = useCallback( - (direction: 'up' | 'down') => { - // Why: cycle over the rows the sidebar actually rendered — collapsing a group - // means "not now", and a rebuilt near-copy would drift from what is on screen - // (host sections, pinned placement, folder workspaces). - const nextWorktreeId = resolveCycledWorktreeId({ - worktreeIds: getCyclableWorktreeIds(rows, pinnedDisplayPolicy), - activeWorktreeId, - direction - }) - if (nextWorktreeId === null) { - return - } - - // Why: keyboard cycling is real navigation; route through the activation helper that records history. - activateAndRevealWorktree(nextWorktreeId) - - const rowIndex = findPreferredRenderRowIndexForWorktree( - renderRows, - nextWorktreeId, - pinnedDisplayPolicy - ) - if (rowIndex !== -1) { - virtualizer.scrollToIndex(rowIndex, { align: 'auto' }) - } - }, - [rows, renderRows, activeWorktreeId, virtualizer, pinnedDisplayPolicy] - ) - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (activeModal !== 'none' || isEditableTarget(e.target)) { - return - } - - const platform = getShortcutPlatform() - if (keybindingMatchesAction('sidebar.focusWorktreeList', e, platform, keybindings)) { - scrollRef.current?.focus() - e.preventDefault() - return - } - - const direction = keybindingMatchesAction('worktree.navigateUp', e, platform, keybindings) - ? 'up' - : keybindingMatchesAction('worktree.navigateDown', e, platform, keybindings) - ? 'down' - : null - if (direction) { - markDirectScrollInput() - navigateWorktree(direction) - e.preventDefault() - } - } - - window.addEventListener('keydown', handleKeyDown, { capture: true }) - return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }) - }, [activeModal, keybindings, markDirectScrollInput, navigateWorktree]) - - const handleContainerKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { - if (e.target !== e.currentTarget) { - return - } - markDirectScrollInput() - navigateWorktree(e.key === 'ArrowUp' ? 'up' : 'down') - e.preventDefault() - } else if (e.key === 'Enter') { - const helper = document.querySelector( - '.xterm-helper-textarea' - ) as HTMLTextAreaElement | null - if (helper) { - helper.focus() - } - e.preventDefault() - } else if (['PageUp', 'PageDown', 'Home', 'End', ' '].includes(e.key)) { - markDirectScrollInput() - } - }, - [markDirectScrollInput, navigateWorktree] - ) - - const handleScrollPointerDown = useCallback( - (event: React.PointerEvent) => { - const scrollbarWidth = event.currentTarget.offsetWidth - event.currentTarget.clientWidth - if (scrollbarWidth <= 0) { - return - } - const rect = event.currentTarget.getBoundingClientRect() - if (event.clientX >= rect.right - scrollbarWidth) { - markDirectScrollInput() - } - }, - [markDirectScrollInput] - ) - const handleScroll = useCallback(() => { - markScrollMovement() - }, [markScrollMovement]) - - const cancelWorktreePointerAutoscroll = useCallback(() => { - if (worktreePointerAutoscrollFrameIdRef.current !== null) { - window.cancelAnimationFrame(worktreePointerAutoscrollFrameIdRef.current) - worktreePointerAutoscrollFrameIdRef.current = null - } - worktreePointerAutoscrollLastFrameTimeRef.current = null - }, []) - - const cancelWorktreeNativeAutoscroll = useCallback(() => { - if (worktreeNativeAutoscrollFrameIdRef.current !== null) { - window.cancelAnimationFrame(worktreeNativeAutoscrollFrameIdRef.current) - worktreeNativeAutoscrollFrameIdRef.current = null - } - worktreeNativeAutoscrollLastFrameTimeRef.current = null - worktreeNativeLatestPointRef.current = null - }, []) - - const cleanupWorktreePointerDrag = useCallback(() => { - const drag = worktreePointerDragRef.current - cancelWorktreePointerAutoscroll() - setNativeLineageDropTargetId(null) - if (!drag) { - return - } - if (drag.frameId !== null) { - window.cancelAnimationFrame(drag.frameId) - } - drag.preview?.remove() - worktreePointerDragRef.current = null - setSidebarPointerDragDocumentStyles(false) - setDragOverStatus(null) - setPinDragOver(false) - clearWorkspaceKanbanSidebarDropTargetVisual() - onWorkspaceBoardDragPreviewCancel() - }, [cancelWorktreePointerAutoscroll, onWorkspaceBoardDragPreviewCancel]) - - const clearWorktreeDrag = useCallback(() => { - cleanupWorktreePointerDrag() - cancelWorktreeNativeAutoscroll() - worktreeDragSessionRef.current = null - statusDropAnchorsRef.current.clear() - setWorktreeDragState(WORKTREE_ROW_DRAG_INITIAL_STATE) - }, [cancelWorktreeNativeAutoscroll, cleanupWorktreePointerDrag]) - - const setScrollRootRef = useCallback( - (node: HTMLDivElement | null) => { - if (node === null && scrollRef.current !== null) { - // Why: drag previews, autoscroll frames, and reveal snapshots are tied to the scroll root; clear them before it unmounts. - cancelPendingRevealFrames() - clearRevealHighlightFrame() - clearRevealHighlightTimeout() - clearWorktreeDrag() - } - scrollRef.current = node - setScrollElement(node) - }, - [ - cancelPendingRevealFrames, - clearRevealHighlightFrame, - clearRevealHighlightTimeout, - clearWorktreeDrag - ] - ) - - const getEligibleLineageDropTarget = useCallback( - ( - target: WorktreeSidebarStatusDropTarget & { lineageParentId: string | null }, - draggedIds: readonly string[] - ): WorktreeSidebarStatusDropTarget & { lineageParentId: string | null } => { - const parentId = target.lineageParentId - if (!parentId) { - return target - } - const canAssignAll = draggedIds.every((draggedId) => { - const child = worktreeMap.get(draggedId) - if (!child) { - return false - } - const candidateParent = worktreeMap.get(parentId) - return Boolean( - candidateParent && - isEligibleWorktreeParent({ - child, - candidateParent, - lineageById: worktreeLineageById, - worktreeMap, - repoMap, - cyclicLineageIds - }) - ) - }) - return canAssignAll ? target : { ...target, lineageParentId: null } - }, - [cyclicLineageIds, repoMap, worktreeLineageById, worktreeMap] - ) - - const commitWorktreeLineageParentDrop = useCallback( - (draggedIds: readonly string[], parentId: string): boolean => { - const target = getEligibleLineageDropTarget( - { status: null, isPinDrop: false, lineageParentId: parentId }, - draggedIds - ) - if (!target.lineageParentId) { - return false - } - void Promise.all( - draggedIds.map((id) => assignWorktreeParent(id, { parentWorktreeId: parentId })) - ).catch((err) => { - console.error('Failed to nest workspace:', err) - toast.error( - translate( - 'auto.components.sidebar.WorktreeList.failedNestWorkspace', - 'Failed to nest workspace' - ) - ) - }) - return true - }, - [assignWorktreeParent, getEligibleLineageDropTarget] - ) - - const clearReorderedWorktreeParents = useCallback( - (args: { draggedIds: readonly string[]; sourceGroupKey: string }) => { - const sourceGroup = worktreeDragGroups.find((group) => group.key === args.sourceGroupKey) - if (!sourceGroup) { - return - } - const ids = getReorderedWorktreeIdsToUnnest({ - draggedIds: args.draggedIds, - sourceGroupIds: sourceGroup.worktreeIds, - lineageById: worktreeLineageById, - worktreeMap, - cyclicLineageIds - }) - if (ids.length === 0) { - return - } - // Why: dropping a nested card on a reorder line is the un-nest escape hatch; clear only the dragged children. - void Promise.all(ids.map((id) => updateWorktreeLineage(id, { noParent: true }))).catch( - (err) => { - console.error('Failed to unnest workspace:', err) - toast.error( - translate( - 'auto.components.sidebar.WorktreeList.failedUnnestWorkspace', - 'Failed to unnest workspace' - ) - ) - } - ) - }, - [cyclicLineageIds, updateWorktreeLineage, worktreeDragGroups, worktreeLineageById, worktreeMap] - ) - - const flushWorktreePointerDrag = useCallback(() => { - const drag = worktreePointerDragRef.current - if (!drag) { - return - } - drag.frameId = null - if (!drag.active || !drag.preview) { - return - } - updateSidebarDragPreviewPosition({ - preview: drag.preview, - pointerX: drag.currentX, - pointerY: drag.currentY, - offsetX: drag.previewOffsetX, - offsetY: drag.previewOffsetY - }) - if (!refreshWorktreeDragSession()) { - clearWorktreeDrag() - return - } - // Why: show the board preview as soon as a card drag begins so the drop target is visible up front, not only at the sidebar edge. - if ( - !drag.workspaceBoardDragPreviewRequested && - !workspaceBoardOpen && - !hasWorkspaceKanbanSidebarDropBoard() - ) { - drag.workspaceBoardDragPreviewRequested = true - onWorkspaceBoardDragPreviewStart() - } - const boardTarget = updateWorkspaceKanbanSidebarDropTargetVisual({ - x: drag.currentX, - y: drag.currentY, - shouldShowDropIndicator: (target) => - Boolean( - target.status && - shouldShowWorkspaceBoardDropIndicator(drag.reorderDraggedIds, target.status) - ) - }) - drag.latestBoardDropTarget = { - target: boardTarget, - x: drag.currentX, - y: drag.currentY - } - if (isWorkspaceKanbanSidebarDropPointInBoard(drag.currentX, drag.currentY)) { - onWorkspaceBoardDragPreviewCommit() - } - if (boardTarget.status || boardTarget.isPinDrop) { - drag.latestStatusDropTarget = null - setDragOverStatus(null) - setPinDragOver(false) - setWorktreeDragState((prev) => - prev.dropIndex === null && - prev.dropIndicatorY === null && - prev.pointerY === drag.currentY && - prev.previewOffsetsByWorktreeId.size === 0 - ? prev - : { - ...prev, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: drag.currentY - } - ) - return - } - - const sidebarContainer = scrollRef.current - const preferredStatusTarget = getEligibleLineageDropTarget( - sidebarContainer - ? getPointerDropStatusTarget({ - container: sidebarContainer, - x: drag.currentX, - y: drag.currentY - }) - : { status: null, isPinDrop: false, lineageParentId: null }, - drag.draggedIds - ) - if (preferredStatusTarget.lineageParentId) { - updateLatestWorktreeStatusDropTarget(drag, preferredStatusTarget, null) - clearWorkspaceKanbanSidebarDropTargetVisual() - setDragOverStatus(null) - setPinDragOver(false) - setWorktreeDragState((prev) => - prev.dropIndex === null && - prev.dropIndicatorY === null && - prev.pointerY === drag.currentY && - prev.previewOffsetsByWorktreeId.size === 0 - ? prev - : { - ...prev, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: drag.currentY - } - ) - return - } - if ( - shouldPreferSidebarStatusDropTarget({ - sourceGroupKey: drag.sourceGroupKey, - target: preferredStatusTarget, - workspaceStatuses - }) - ) { - const statusDrop = preferredStatusTarget.status - ? computeWorktreeStatusDrop({ - pointerY: drag.currentY, - status: preferredStatusTarget.status, - draggedIds: drag.reorderDraggedIds - }) - : null - if (statusDrop) { - updateLatestWorktreeStatusDropTarget(drag, preferredStatusTarget, statusDrop) - clearWorkspaceKanbanSidebarDropTargetVisual() - setDragOverStatus(null) - setPinDragOver(false) - setWorktreeDragState((prev) => - prev.dropIndex === statusDrop.dropIndex && - prev.dropIndicatorY === statusDrop.dropIndicatorY && - prev.pointerY === drag.currentY && - areWorktreeDragPreviewOffsetsEqual( - prev.previewOffsetsByWorktreeId, - statusDrop.previewOffsetsByWorktreeId - ) - ? prev - : { ...prev, ...statusDrop, pointerY: drag.currentY } - ) - return - } - updateLatestWorktreeStatusDropTarget(drag, preferredStatusTarget, statusDrop) - setDragOverStatus(preferredStatusTarget.status) - setPinDragOver(preferredStatusTarget.isPinDrop) - setWorktreeDragState((prev) => - prev.dropIndex === null && - prev.dropIndicatorY === null && - prev.pointerY === drag.currentY && - prev.previewOffsetsByWorktreeId.size === 0 - ? prev - : { - ...prev, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: drag.currentY - } - ) - return - } - - const drop = computeWorktreeDrop(drag.currentY) - if (!drop) { - const target = preferredStatusTarget - const statusDrop = target.status - ? computeWorktreeStatusDrop({ - pointerY: drag.currentY, - status: target.status, - draggedIds: drag.reorderDraggedIds - }) - : null - if (statusDrop) { - updateLatestWorktreeStatusDropTarget(drag, target, statusDrop) - clearWorkspaceKanbanSidebarDropTargetVisual() - setDragOverStatus(null) - setPinDragOver(false) - setWorktreeDragState((prev) => - prev.dropIndex === statusDrop.dropIndex && - prev.dropIndicatorY === statusDrop.dropIndicatorY && - prev.pointerY === drag.currentY && - areWorktreeDragPreviewOffsetsEqual( - prev.previewOffsetsByWorktreeId, - statusDrop.previewOffsetsByWorktreeId - ) - ? prev - : { ...prev, ...statusDrop, pointerY: drag.currentY } - ) - return - } - updateLatestWorktreeStatusDropTarget(drag, target, statusDrop) - setDragOverStatus(target.status) - setPinDragOver(target.isPinDrop) - setWorktreeDragState((prev) => - prev.dropIndex === null && - prev.dropIndicatorY === null && - prev.pointerY === drag.currentY && - prev.previewOffsetsByWorktreeId.size === 0 - ? prev - : { - ...prev, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: drag.currentY - } - ) - return - } - drag.latestStatusDropTarget = null - clearWorkspaceKanbanSidebarDropTargetVisual() - setDragOverStatus(null) - setPinDragOver(false) - setWorktreeDragState((prev) => - prev.dropIndex === drop.dropIndex && - prev.dropIndicatorY === drop.dropIndicatorY && - prev.pointerY === drag.currentY && - areWorktreeDragPreviewOffsetsEqual( - prev.previewOffsetsByWorktreeId, - drop.previewOffsetsByWorktreeId - ) - ? prev - : { ...prev, ...drop, pointerY: drag.currentY } - ) - }, [ - clearWorktreeDrag, - computeWorktreeDrop, - computeWorktreeStatusDrop, - onWorkspaceBoardDragPreviewStart, - refreshWorktreeDragSession, - onWorkspaceBoardDragPreviewCommit, - shouldShowWorkspaceBoardDropIndicator, - getEligibleLineageDropTarget, - workspaceBoardOpen, - workspaceStatuses - ]) - - const scheduleWorktreePointerDragFrame = useCallback( - (drag: WorktreePointerDrag) => { - if (drag.frameId !== null) { - return - } - drag.frameId = window.requestAnimationFrame(flushWorktreePointerDrag) - }, - [flushWorktreePointerDrag] - ) - - const runWorktreePointerAutoscrollFrame = useCallback( - (frameTime: number) => { - worktreePointerAutoscrollFrameIdRef.current = null - const drag = worktreePointerDragRef.current - const container = scrollRef.current - const session = worktreeDragSessionRef.current - if (!drag?.active || !container || !session) { - cancelWorktreePointerAutoscroll() - return - } - - const previousFrameTime = worktreePointerAutoscrollLastFrameTimeRef.current ?? frameTime - worktreePointerAutoscrollLastFrameTimeRef.current = frameTime - const autoscroll = getWorktreeSidebarDragAutoscroll({ - point: { clientX: drag.currentX, clientY: drag.currentY }, - containerRect: container.getBoundingClientRect(), - scrollTop: container.scrollTop, - scrollHeight: container.scrollHeight, - clientHeight: container.clientHeight, - elapsedMs: frameTime - previousFrameTime - }) - if (autoscroll) { - markScrollMovement() - container.scrollTop = autoscroll.scrollTop - if (!refreshWorktreeDragSession()) { - clearWorktreeDrag() - return - } - scheduleWorktreePointerDragFrame(drag) - } - - worktreePointerAutoscrollFrameIdRef.current = window.requestAnimationFrame( - runWorktreePointerAutoscrollFrame - ) - }, - [ - cancelWorktreePointerAutoscroll, - clearWorktreeDrag, - markScrollMovement, - refreshWorktreeDragSession, - scheduleWorktreePointerDragFrame - ] - ) - - const startWorktreePointerAutoscroll = useCallback(() => { - if (worktreePointerAutoscrollFrameIdRef.current !== null) { - return - } - worktreePointerAutoscrollLastFrameTimeRef.current = null - worktreePointerAutoscrollFrameIdRef.current = window.requestAnimationFrame( - runWorktreePointerAutoscrollFrame - ) - }, [runWorktreePointerAutoscrollFrame]) - - const beginWorktreePointerDrag = useCallback( - (drag: WorktreePointerDrag) => { - const { preview, offsetX, offsetY, height } = createSidebarDragPreview({ - sourceRow: drag.sourceRow, - pointerX: drag.currentX, - pointerY: drag.currentY, - draggedCount: drag.draggedIds.length - }) - drag.active = true - drag.preview = preview - drag.previewOffsetX = offsetX - drag.previewOffsetY = offsetY - suppressWorktreeClickUntilRef.current = window.performance.now() + 500 - setSidebarPointerDragDocumentStyles(true) - worktreeDragSessionRef.current = { - draggingWorktreeId: drag.worktreeId, - sourceGroupKey: drag.sourceGroupKey, - draggedIds: drag.draggedIds, - reorderDraggedIds: drag.reorderDraggedIds, - reorderUnitDraggedIds: drag.reorderUnitDraggedIds, - rects: drag.rects, - // Why: reuse the floating preview's own offset so the hit test tracks the - // card the user sees, not the raw pointer. - grab: getWorktreeSidebarDragGrab({ offsetY, height }), - anchor: null - } - setWorktreeDragState({ - draggingWorktreeId: drag.worktreeId, - sourceGroupKey: drag.sourceGroupKey, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: drag.currentY - }) - startWorktreePointerAutoscroll() - scheduleWorktreePointerDragFrame(drag) - }, - [scheduleWorktreePointerDragFrame, startWorktreePointerAutoscroll] - ) - - const handleWorktreeRowPointerDown = useCallback( - (event: React.PointerEvent, worktreeId: string, rowKey: string) => { - if (event.button !== 0 || event.pointerType === 'touch') { - return - } - const sourceRow = event.currentTarget - if (isSidebarPointerDragBlocked(event.target, sourceRow)) { - return - } - const sourceGroupKey = groupKeyByRowKey.get(rowKey) - const container = scrollRef.current - if (!sourceGroupKey || !container) { - return - } - const rects = getWorktreeSidebarDragRectsForGroup(container, sourceGroupKey) - const canPreviewWorkspaceBoardOnDrag = - !workspaceBoardOpen && - onWorkspaceBoardDragPreviewStart !== NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK - if ( - rects.length <= 1 && - !hasWorkspaceKanbanSidebarDropBoard() && - !canPreviewWorkspaceBoardOnDrag - ) { - return - } - const draggedIds = - selectedWorktreeIds.has(worktreeId) && selectedWorktrees.length > 1 - ? selectedWorktrees.map((worktree) => worktree.id) - : [worktreeId] - const reorderDraggedIds = getReorderDraggedIds(draggedIds) - const reorderUnitDraggedIds = getReorderUnitDraggedIds(sourceGroupKey, reorderDraggedIds) - worktreePointerDragRef.current = { - pointerId: event.pointerId, - sourceRow, - startX: event.clientX, - startY: event.clientY, - currentX: event.clientX, - currentY: event.clientY, - worktreeId, - draggedIds, - reorderDraggedIds, - reorderUnitDraggedIds, - sourceGroupKey, - rects, - active: false, - preview: null, - previewOffsetX: 0, - previewOffsetY: 0, - workspaceBoardDragPreviewRequested: false, - frameId: null, - latestBoardDropTarget: null, - latestStatusDropTarget: null - } - }, - [ - getReorderDraggedIds, - getReorderUnitDraggedIds, - groupKeyByRowKey, - onWorkspaceBoardDragPreviewStart, - selectedWorktreeIds, - selectedWorktrees, - workspaceBoardOpen - ] - ) - - const handleWorktreeRowClickCapture = useCallback((event: React.MouseEvent) => { - if (window.performance.now() >= suppressWorktreeClickUntilRef.current) { - return - } - event.preventDefault() - event.stopPropagation() - }, []) - - useEffect(() => { - const handlePointerMove = (event: PointerEvent): void => { - const drag = worktreePointerDragRef.current - if (!drag || event.pointerId !== drag.pointerId) { - return - } - drag.currentX = event.clientX - drag.currentY = event.clientY - if (!drag.active) { - const distance = Math.hypot(drag.currentX - drag.startX, drag.currentY - drag.startY) - if (distance < SIDEBAR_POINTER_DRAG_THRESHOLD_PX) { - return - } - beginWorktreePointerDrag(drag) - } - event.preventDefault() - event.stopPropagation() - scheduleWorktreePointerDragFrame(drag) - } - - const handlePointerUp = (event: PointerEvent): void => { - const drag = worktreePointerDragRef.current - if (!drag || event.pointerId !== drag.pointerId) { - return - } - drag.currentX = event.clientX - drag.currentY = event.clientY - if (!drag.active) { - worktreePointerDragRef.current = null - return - } - event.preventDefault() - event.stopPropagation() - if (!refreshWorktreeDragSession()) { - clearWorktreeDrag() - return - } - const boardDropTarget = resolveWorkspaceKanbanCardDropCommitTarget({ - currentTarget: getWorkspaceKanbanSidebarDropTarget(event.clientX, event.clientY), - latestTrackedTarget: drag.latestBoardDropTarget, - x: event.clientX, - y: event.clientY - }) - if (isWorkspaceKanbanSidebarDropPointInBoard(event.clientX, event.clientY)) { - onWorkspaceBoardDragPreviewCommit() - } - if (boardDropTarget.isPinDrop) { - onPinWorktrees(drag.draggedIds) - } else if (boardDropTarget.status) { - onDropWorktreesOnWorkspaceBoard({ - worktreeIds: drag.reorderDraggedIds, - status: boardDropTarget.status, - // Why: the target counts rendered cards, but the groups are the full - // lane. Board search can make those two differ. - dropIndex: resolveWorkspaceKanbanSidebarFullLaneDropIndex( - boardDropTarget.status, - boardDropTarget.dropIndex - ), - groups: getWorkspaceKanbanSidebarDropGroups() - }) - } else { - const preferredStatusTarget = getEligibleLineageDropTarget( - scrollRef.current - ? getPointerDropStatusTarget({ - container: scrollRef.current, - x: event.clientX, - y: event.clientY - }) - : { status: null, isPinDrop: false, lineageParentId: null }, - drag.draggedIds - ) - if (preferredStatusTarget.lineageParentId) { - commitWorktreeLineageParentDrop(drag.draggedIds, preferredStatusTarget.lineageParentId) - clearWorktreeDrag() - return - } - if ( - shouldPreferSidebarStatusDropTarget({ - sourceGroupKey: drag.sourceGroupKey, - target: preferredStatusTarget, - workspaceStatuses - }) - ) { - const statusDrop = preferredStatusTarget.status - ? computeWorktreeStatusDrop({ - pointerY: event.clientY, - status: preferredStatusTarget.status, - draggedIds: drag.reorderDraggedIds - }) - : null - if (preferredStatusTarget.isPinDrop) { - onPinWorktrees(drag.draggedIds) - } else if (preferredStatusTarget.status) { - if (statusDrop) { - onMoveWorktreesToStatusAtIndex({ - worktreeIds: drag.reorderDraggedIds, - status: preferredStatusTarget.status, - dropIndex: statusDrop.dropIndex, - groups: worktreeDragGroups - }) - } else { - onMoveWorktreesToStatus(drag.reorderDraggedIds, preferredStatusTarget.status) - } - } - clearWorktreeDrag() - return - } - const drop = computeWorktreeDrop(event.clientY) - if (drop) { - onReorderWorktrees({ - groups: worktreeDragGroups, - sourceGroupKey: drag.sourceGroupKey, - draggedIds: drag.reorderDraggedIds, - dropIndex: getFullDropIndexForWorktreeDragUnit({ - groups: worktreeDragUnitGroups, - sourceGroupKey: drag.sourceGroupKey, - dropIndex: drop.dropIndex - }) - }) - clearReorderedWorktreeParents({ - draggedIds: drag.draggedIds, - sourceGroupKey: drag.sourceGroupKey - }) - } else if (scrollRef.current) { - const currentTarget = preferredStatusTarget - const currentPreview = currentTarget.status - ? computeWorktreeStatusDrop({ - pointerY: event.clientY, - status: currentTarget.status, - draggedIds: drag.reorderDraggedIds - }) - : null - const { target, preview: statusDrop } = resolveWorktreeSidebarStatusDropCommitTarget({ - currentTarget, - currentPreview, - latestTrackedTarget: drag.latestStatusDropTarget, - x: event.clientX, - y: event.clientY - }) - if (target.lineageParentId) { - commitWorktreeLineageParentDrop(drag.draggedIds, target.lineageParentId) - } else if (target.isPinDrop) { - onPinWorktrees(drag.draggedIds) - } else if (target.status) { - if (statusDrop) { - onMoveWorktreesToStatusAtIndex({ - worktreeIds: drag.reorderDraggedIds, - status: target.status, - dropIndex: statusDrop.dropIndex, - groups: worktreeDragGroups - }) - } else { - onMoveWorktreesToStatus(drag.reorderDraggedIds, target.status) - } - } - } - } - clearWorktreeDrag() - } - - const handlePointerCancel = (event: PointerEvent): void => { - const drag = worktreePointerDragRef.current - if (!drag || event.pointerId !== drag.pointerId) { - return - } - clearWorktreeDrag() - } - - window.addEventListener('pointermove', handlePointerMove, { capture: true }) - window.addEventListener('pointerup', handlePointerUp, { capture: true }) - window.addEventListener('pointercancel', handlePointerCancel, { capture: true }) - return () => { - window.removeEventListener('pointermove', handlePointerMove, { capture: true }) - window.removeEventListener('pointerup', handlePointerUp, { capture: true }) - window.removeEventListener('pointercancel', handlePointerCancel, { capture: true }) - } - }, [ - beginWorktreePointerDrag, - clearWorktreeDrag, - clearReorderedWorktreeParents, - commitWorktreeLineageParentDrop, - computeWorktreeDrop, - computeWorktreeStatusDrop, - getEligibleLineageDropTarget, - onMoveWorktreesToStatus, - onMoveWorktreesToStatusAtIndex, - onDropWorktreesOnWorkspaceBoard, - onPinWorktrees, - onReorderWorktrees, - onWorkspaceBoardDragPreviewCommit, - refreshWorktreeDragSession, - scheduleWorktreePointerDragFrame, - shouldShowWorkspaceBoardDropIndicator, - worktreeDragGroups, - worktreeDragUnitGroups, - workspaceStatuses - ]) - - useEffect(() => { - const handleClick = (event: MouseEvent): void => { - if (window.performance.now() >= suppressWorktreeClickUntilRef.current) { - return - } - event.preventDefault() - event.stopPropagation() - event.stopImmediatePropagation() - } - - document.addEventListener('click', handleClick, true) - return () => document.removeEventListener('click', handleClick, true) - }, []) - - const runWorktreeNativeAutoscrollFrame = useCallback( - (frameTime: number) => { - worktreeNativeAutoscrollFrameIdRef.current = null - const point = worktreeNativeLatestPointRef.current - const container = scrollRef.current - const session = worktreeDragSessionRef.current - if (!point || !container || !session) { - cancelWorktreeNativeAutoscroll() - return - } - - const previousFrameTime = worktreeNativeAutoscrollLastFrameTimeRef.current ?? frameTime - worktreeNativeAutoscrollLastFrameTimeRef.current = frameTime - const autoscroll = getWorktreeSidebarDragAutoscroll({ - point, - containerRect: container.getBoundingClientRect(), - scrollTop: container.scrollTop, - scrollHeight: container.scrollHeight, - clientHeight: container.clientHeight, - elapsedMs: frameTime - previousFrameTime - }) - if (autoscroll) { - markScrollMovement() - container.scrollTop = autoscroll.scrollTop - if (!refreshWorktreeDragSession()) { - clearWorktreeDrag() - return - } - const drop = computeWorktreeDrop(point.clientY) - if (!drop) { - const target = getPointerDropStatusTarget({ - container, - x: point.clientX, - y: point.clientY - }) - const statusDrop = target.status - ? computeWorktreeStatusDrop({ - pointerY: point.clientY, - status: target.status, - draggedIds: session.reorderDraggedIds - }) - : null - if (statusDrop) { - setWorktreeDragState((prev) => - prev.dropIndex === statusDrop.dropIndex && - prev.dropIndicatorY === statusDrop.dropIndicatorY && - areWorktreeDragPreviewOffsetsEqual( - prev.previewOffsetsByWorktreeId, - statusDrop.previewOffsetsByWorktreeId - ) - ? prev - : { ...prev, ...statusDrop, pointerY: point.clientY } - ) - return - } - setWorktreeDragState((prev) => - prev.dropIndex === null && - prev.dropIndicatorY === null && - prev.previewOffsetsByWorktreeId.size === 0 - ? prev - : { - ...prev, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: null - } - ) - } else { - setWorktreeDragState((prev) => - prev.dropIndex === drop.dropIndex && - prev.dropIndicatorY === drop.dropIndicatorY && - areWorktreeDragPreviewOffsetsEqual( - prev.previewOffsetsByWorktreeId, - drop.previewOffsetsByWorktreeId - ) - ? prev - : { ...prev, ...drop, pointerY: point.clientY } - ) - } - } - - worktreeNativeAutoscrollFrameIdRef.current = window.requestAnimationFrame( - runWorktreeNativeAutoscrollFrame - ) - }, - [ - cancelWorktreeNativeAutoscroll, - clearWorktreeDrag, - computeWorktreeDrop, - computeWorktreeStatusDrop, - markScrollMovement, - refreshWorktreeDragSession - ] - ) - - const startWorktreeNativeAutoscroll = useCallback(() => { - if (worktreeNativeAutoscrollFrameIdRef.current !== null) { - return - } - worktreeNativeAutoscrollLastFrameTimeRef.current = null - worktreeNativeAutoscrollFrameIdRef.current = window.requestAnimationFrame( - runWorktreeNativeAutoscrollFrame - ) - }, [runWorktreeNativeAutoscrollFrame]) - - const handleWorktreeCardDragStart = useCallback( - (event: React.DragEvent, worktreeId: string, draggedIds: readonly string[]) => { - const sourceGroupKey = - worktreeDragGroups.find((group) => group.worktreeIds.includes(worktreeId))?.key ?? null - if (!sourceGroupKey) { - return - } - const reorderDraggedIds = getReorderDraggedIds(draggedIds) - const reorderUnitDraggedIds = getReorderUnitDraggedIds(sourceGroupKey, reorderDraggedIds) - const rects = scrollRef.current - ? getWorktreeSidebarDragRectsForGroup(scrollRef.current, sourceGroupKey) - : [] - const sourceRect = event.currentTarget.getBoundingClientRect() - worktreeDragSessionRef.current = { - draggingWorktreeId: worktreeId, - sourceGroupKey, - draggedIds, - reorderDraggedIds, - reorderUnitDraggedIds, - rects, - grab: getWorktreeSidebarDragGrab({ - offsetY: event.clientY - sourceRect.top, - height: sourceRect.height - }), - anchor: null - } - setWorktreeDragState({ - draggingWorktreeId: worktreeId, - sourceGroupKey, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: null - }) - }, - [getReorderDraggedIds, getReorderUnitDraggedIds, worktreeDragGroups] - ) - - const handleWorktreeDragOver = useCallback( - (event: React.DragEvent) => { - const session = worktreeDragSessionRef.current - if (!session) { - return - } - worktreeNativeLatestPointRef.current = { clientX: event.clientX, clientY: event.clientY } - startWorktreeNativeAutoscroll() - if (!refreshWorktreeDragSession()) { - clearWorktreeDrag() - return - } - const target = getEligibleLineageDropTarget( - getPointerDropStatusTarget({ - container: event.currentTarget, - x: event.clientX, - y: event.clientY - }), - session.draggedIds - ) - if (target.lineageParentId) { - event.preventDefault() - event.dataTransfer.dropEffect = 'move' - setNativeLineageDropTargetId(target.lineageParentId) - setWorktreeDragState((prev) => - prev.dropIndex === null && - prev.dropIndicatorY === null && - prev.previewOffsetsByWorktreeId.size === 0 - ? prev - : { - ...prev, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: event.clientY - } - ) - return - } - setNativeLineageDropTargetId(null) - - const drop = computeWorktreeDrop(event.clientY) - if (!drop) { - const statusDrop = target.status - ? computeWorktreeStatusDrop({ - pointerY: event.clientY, - status: target.status, - draggedIds: session.reorderDraggedIds - }) - : null - if (statusDrop) { - event.preventDefault() - event.dataTransfer.dropEffect = 'move' - setWorktreeDragState((prev) => - prev.dropIndex === statusDrop.dropIndex && - prev.dropIndicatorY === statusDrop.dropIndicatorY && - areWorktreeDragPreviewOffsetsEqual( - prev.previewOffsetsByWorktreeId, - statusDrop.previewOffsetsByWorktreeId - ) - ? prev - : { ...prev, ...statusDrop, pointerY: event.clientY } - ) - return - } - setWorktreeDragState((prev) => - prev.dropIndex === null && - prev.dropIndicatorY === null && - prev.previewOffsetsByWorktreeId.size === 0 - ? prev - : { - ...prev, - dropIndex: null, - dropIndicatorY: null, - previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, - pointerY: null - } - ) - return - } - event.preventDefault() - event.dataTransfer.dropEffect = 'move' - setWorktreeDragState((prev) => - prev.dropIndex === drop.dropIndex && - prev.dropIndicatorY === drop.dropIndicatorY && - areWorktreeDragPreviewOffsetsEqual( - prev.previewOffsetsByWorktreeId, - drop.previewOffsetsByWorktreeId - ) - ? prev - : { ...prev, ...drop, pointerY: event.clientY } - ) - }, - [ - clearWorktreeDrag, - computeWorktreeDrop, - computeWorktreeStatusDrop, - getEligibleLineageDropTarget, - refreshWorktreeDragSession, - startWorktreeNativeAutoscroll - ] - ) - - const handleWorktreeDrop = useCallback( - (event: React.DragEvent) => { - const session = worktreeDragSessionRef.current - if (!session) { - return - } - if (!refreshWorktreeDragSession()) { - clearWorktreeDrag() - return - } - const boardDropTarget = getWorkspaceKanbanSidebarDropTarget(event.clientX, event.clientY) - if (boardDropTarget.status || boardDropTarget.isPinDrop) { - clearWorktreeDrag() - return - } - - const container = scrollRef.current - const target = getEligibleLineageDropTarget( - container - ? getPointerDropStatusTarget({ - container, - x: event.clientX, - y: event.clientY - }) - : { status: null, isPinDrop: false, lineageParentId: null }, - session.draggedIds - ) - - if (target.lineageParentId) { - event.preventDefault() - event.stopPropagation() - commitWorktreeLineageParentDrop(session.draggedIds, target.lineageParentId) - clearWorktreeDrag() - return - } - - const drop = computeWorktreeDrop(event.clientY) - if (!drop) { - const statusDrop = target.status - ? computeWorktreeStatusDrop({ - pointerY: event.clientY, - status: target.status, - draggedIds: session.reorderDraggedIds - }) - : null - if (target.status && statusDrop) { - event.preventDefault() - event.stopPropagation() - onMoveWorktreesToStatusAtIndex({ - worktreeIds: session.reorderDraggedIds, - status: target.status, - dropIndex: statusDrop.dropIndex, - groups: worktreeDragGroups - }) - clearWorktreeDrag() - return - } - clearWorktreeDrag() - return - } - event.preventDefault() - onReorderWorktrees({ - groups: worktreeDragGroups, - sourceGroupKey: session.sourceGroupKey, - draggedIds: session.reorderDraggedIds, - dropIndex: getFullDropIndexForWorktreeDragUnit({ - groups: worktreeDragUnitGroups, - sourceGroupKey: session.sourceGroupKey, - dropIndex: drop.dropIndex - }) - }) - clearReorderedWorktreeParents({ - draggedIds: session.draggedIds, - sourceGroupKey: session.sourceGroupKey - }) - clearWorktreeDrag() - }, - [ - clearWorktreeDrag, - clearReorderedWorktreeParents, - commitWorktreeLineageParentDrop, - computeWorktreeDrop, - computeWorktreeStatusDrop, - getEligibleLineageDropTarget, - onMoveWorktreesToStatusAtIndex, - onReorderWorktrees, - refreshWorktreeDragSession, - worktreeDragGroups, - worktreeDragUnitGroups - ] - ) - - useEffect(() => { - if (document.visibilityState !== 'visible') { - lastVisibleRefreshKeyRef.current = '__document_hidden__' - return - } - const currentWorktree = currentWorktreeId ? (worktreeMap.get(currentWorktreeId) ?? null) : null - // Why: this reporter feeds the GitHub coordinator; GitLab-only MR panels refresh via hosted-review paths. - const sidebarWorktreeHasGitHubReview = - currentWorktree !== null && - ((currentWorktree.linkedGitLabMR ?? null) === null || - (currentWorktree.linkedPR ?? null) !== null) - const shouldTrackSidebarWorktree = rightSidebarShowsPR && sidebarWorktreeHasGitHubReview - const shouldTrackVisibleRows = - groupBy === 'pr-status' || - (newCardStyle - ? cardProps.includes('status') - : cardProps.includes('pr') || cardProps.includes('ci')) - if (!shouldTrackVisibleRows && !shouldTrackSidebarWorktree) { - if (lastVisibleRefreshKeyRef.current !== '__hidden__') { - lastVisibleRefreshKeyRef.current = '__hidden__' - reportVisibleGitHubPRRefreshCandidates([], Date.now()) - } - return - } - const scrollEl = scrollRef.current - if (!scrollEl) { - return - } - const viewportTop = scrollEl.scrollTop - const viewportBottom = viewportTop + scrollEl.clientHeight - const visibleRows = virtualItems - .filter((item) => item.start < viewportBottom && item.end > viewportTop) - .map((item) => renderRows[item.index]) - .filter((row): row is WorktreeItemRow => row?.type === 'item') - .filter((row) => row.repo?.kind === 'git' && !row.worktree.isBare && row.worktree.branch) - const visibleWorktreeIds = new Set(visibleRows.map((row) => row.worktree.id)) - if ( - shouldTrackSidebarWorktree && - currentWorktree && - !currentWorktree.isBare && - currentWorktree.branch - ) { - visibleWorktreeIds.add(currentWorktree.id) - } - const visibleIdentity = visibleRows - .map((row) => `${row.worktree.id}:${row.worktree.branch}:${row.worktree.linkedPR ?? ''}`) - .join('|') - const sidebarIdentity = - shouldTrackSidebarWorktree && currentWorktree - ? `${currentWorktree.id}:${currentWorktree.branch}:${currentWorktree.linkedPR ?? ''}` - : '' - const key = `${visibleIdentity}:${sidebarIdentity}:${sshConnectedGeneration}:${prVisibleRefreshGeneration}:${cardProps.join(',')}` - if (!key || key === lastVisibleRefreshKeyRef.current) { - return - } - lastVisibleRefreshKeyRef.current = key - reportVisibleGitHubPRRefreshCandidates(Array.from(visibleWorktreeIds), Date.now()) - }, [ - cardProps, - currentWorktreeId, - documentVisibilityRevision, - groupBy, - renderRows, - reportVisibleGitHubPRRefreshCandidates, - prVisibleRefreshGeneration, - rightSidebarShowsPR, - sshConnectedGeneration, - newCardStyle, - virtualItems, - worktreeMap - ]) - - const activeDescendantId = getActiveDescendantOptionId({ - activeWorktreeId, - primaryActiveRowKey: - primaryActiveWorktreeRow?.worktreeId === activeWorktreeId - ? primaryActiveWorktreeRow.rowKey - : undefined, - pinnedDisplayPolicy, - renderRows, - virtualItems - }) - - const hasWorkspaceDropTargets = useMemo( - () => - groupBy === 'workspace-status' || - rows.some((row) => row.type === 'header' && row.key === PINNED_GROUP_KEY), - [groupBy, rows] - ) - - const handleWorkspaceStatusDragOver = useCallback( - (event: React.DragEvent, status: WorkspaceStatus) => { - if (!hasWorkspaceDragData(event.dataTransfer)) { - return - } - event.preventDefault() - event.dataTransfer.dropEffect = 'move' - setDragOverStatus(status) - }, - [] - ) - - const handleWorkspaceStatusDragLeave = useCallback((event: React.DragEvent) => { - const relatedTarget = event.relatedTarget - if (relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget)) { - return - } - setDragOverStatus(null) - }, []) - - const handleWorkspacePinDragOver = useCallback((event: React.DragEvent) => { - if (!hasWorkspaceDragData(event.dataTransfer)) { - return - } - event.preventDefault() - event.dataTransfer.dropEffect = 'move' - setPinDragOver(true) - }, []) - - const handleWorkspacePinDragLeave = useCallback((event: React.DragEvent) => { - const relatedTarget = event.relatedTarget - if (relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget)) { - return - } - setPinDragOver(false) - }, []) - - const handleWorkspaceStatusDragFinish = useCallback(() => { - setDragOverStatus(null) - setPinDragOver(false) - }, []) - - const handleWorkspaceStatusDrop = useCallback( - (event: React.DragEvent, status: WorkspaceStatus) => { - const worktreeIds = readWorkspaceDragDataIds(event.dataTransfer) - if (worktreeIds.length === 0) { - return - } - event.preventDefault() - const session = worktreeDragSessionRef.current - const statusDrop = session - ? computeWorktreeStatusDrop({ - pointerY: event.clientY, - status, - draggedIds: session.reorderDraggedIds - }) - : null - setDragOverStatus(null) - if (session && statusDrop) { - event.stopPropagation() - onMoveWorktreesToStatusAtIndex({ - worktreeIds: session.reorderDraggedIds, - status, - dropIndex: statusDrop.dropIndex, - groups: worktreeDragGroups - }) - clearWorktreeDrag() - return - } - // Match status-drop scope to drag-preview scope (#9083): session uses its expanded set, else expand dataTransfer ids live. - onMoveWorktreesToStatus( - session ? session.reorderDraggedIds : getReorderDraggedIds(worktreeIds), - status - ) - }, - [ - clearWorktreeDrag, - computeWorktreeStatusDrop, - getReorderDraggedIds, - onMoveWorktreesToStatus, - onMoveWorktreesToStatusAtIndex, - worktreeDragGroups - ] - ) - - useEffect(() => { - const handleDocumentDrop = (event: DragEvent): void => { - const session = worktreeDragSessionRef.current - if (!session) { - return - } - if (!refreshWorktreeDragSession()) { - clearWorktreeDrag() - return - } - const drop = computeWorktreeDrop(event.clientY) - if (!drop) { - const container = scrollRef.current - const target = getEligibleLineageDropTarget( - container - ? getPointerDropStatusTarget({ - container, - x: event.clientX, - y: event.clientY - }) - : { status: null, isPinDrop: false, lineageParentId: null }, - session.draggedIds - ) - if (target.lineageParentId) { - event.preventDefault() - event.stopPropagation() - commitWorktreeLineageParentDrop(session.draggedIds, target.lineageParentId) - clearWorktreeDrag() - return - } - const statusDrop = target.status - ? computeWorktreeStatusDrop({ - pointerY: event.clientY, - status: target.status, - draggedIds: session.reorderDraggedIds - }) - : null - if (target.status && statusDrop) { - event.preventDefault() - event.stopPropagation() - onMoveWorktreesToStatusAtIndex({ - worktreeIds: session.reorderDraggedIds, - status: target.status, - dropIndex: statusDrop.dropIndex, - groups: worktreeDragGroups - }) - clearWorktreeDrag() - return - } - clearWorktreeDrag() - return - } - // Why: pointer still inside the source group means reorder, not status move; commit here and stop the capture handler. - event.preventDefault() - event.stopPropagation() - onReorderWorktrees({ - groups: worktreeDragGroups, - sourceGroupKey: session.sourceGroupKey, - draggedIds: session.reorderDraggedIds, - dropIndex: getFullDropIndexForWorktreeDragUnit({ - groups: worktreeDragUnitGroups, - sourceGroupKey: session.sourceGroupKey, - dropIndex: drop.dropIndex - }) - }) - clearReorderedWorktreeParents({ - draggedIds: session.draggedIds, - sourceGroupKey: session.sourceGroupKey - }) - clearWorktreeDrag() - } - - document.addEventListener('drop', handleDocumentDrop, true) - return () => document.removeEventListener('drop', handleDocumentDrop, true) - }, [ - clearWorktreeDrag, - clearReorderedWorktreeParents, - commitWorktreeLineageParentDrop, - computeWorktreeDrop, - computeWorktreeStatusDrop, - getEligibleLineageDropTarget, - onMoveWorktreesToStatusAtIndex, - onReorderWorktrees, - refreshWorktreeDragSession, - worktreeDragGroups, - worktreeDragUnitGroups - ]) - - useEffect(() => { - const handleDocumentDragEnd = (): void => { - if (worktreeDragSessionRef.current) { - clearWorktreeDrag() - } - } - - document.addEventListener('dragend', handleDocumentDragEnd, true) - return () => document.removeEventListener('dragend', handleDocumentDragEnd, true) - }, [clearWorktreeDrag]) - - useEffect(() => { - const handleVisibilityChange = (): void => { - if (document.visibilityState !== 'visible' && worktreeDragSessionRef.current) { - clearWorktreeDrag() - } - } - - document.addEventListener('visibilitychange', handleVisibilityChange) - return () => document.removeEventListener('visibilitychange', handleVisibilityChange) - }, [clearWorktreeDrag]) - - // Why: expand here (not the shared hook, used by the flat board) so a dropped parent carries its lineage children (#9083). - const moveWorktreesToStatusForDocumentDrop = useCallback( - (ids: readonly string[], status: WorkspaceStatus) => - onMoveWorktreesToStatus(getReorderDraggedIds(ids), status), - [getReorderDraggedIds, onMoveWorktreesToStatus] - ) - - useWorkspaceStatusDocumentDrop( - scrollRef, - onMoveWorktreeToStatus, - onPinWorktree, - handleWorkspaceStatusDragFinish, - hasWorkspaceDropTargets, - { - onMoveWorktreesToStatus: moveWorktreesToStatusForDocumentDrop, - onPinWorktrees - } - ) - - return ( -
-
-
- {canReorderRepoHeaders && - repoDrag.state.draggingRepoId !== null && - repoDrag.state.dropIndicatorY !== null ? ( - - ) : null} - {canReorderProjectGroupHeaders && - projectGroupDrag.state.draggingGroupId !== null && - projectGroupDrag.state.dropIndicatorY !== null ? ( - - ) : null} - {hostDrag.state.draggingHostId !== null && hostDrag.state.dropIndicatorY !== null ? ( - - ) : null} - {worktreeDragState.draggingWorktreeId !== null && - worktreeDragState.dropIndicatorY !== null ? ( - - ) : null} - {virtualItems.map((vItem) => { - const row = renderRows[vItem.index] - if (!row) { - return null - } - - if (row.type === 'host-header') { - // Why: the host card is the outer tier; it pins above group headers (z-30 vs z-20) and stays put as they hand off. - const isActiveStickyHost = activeStickyHostIndexRef.current === vItem.index - const hasHeaderTopSpacing = shouldUseHeaderTopSpacing({ - rows: renderRows, - index: vItem.index, - firstHeaderIndex - }) - return ( -
- toggleGroupWithScrollAnchor(row.key)} - onDragPointerDown={ - orderedHostIds.length > 1 - ? (e) => hostDrag.onHandlePointerDown(e, row.hostId) - : undefined - } - dragging={hostDrag.state.draggingHostId === row.hostId} - /> -
- ) - } - - if (row.type === 'header') { - const isActiveStickyHeader = activeStickyHeaderIndexRef.current === vItem.index - // Why: when a host card is pinned, the group tier pins flush beneath it, not at the viewport top. - const stickyTopClass = - activeStickyHostIndexRef.current !== null ? 'top-[35px]' : '-top-px' - const hasHeaderTopSpacing = shouldUseHeaderTopSpacing({ - rows: renderRows, - index: vItem.index, - firstHeaderIndex - }) - const isRepoHeader = groupBy === 'repo' && row.repo !== undefined - const isProjectGroupHeader = groupBy === 'repo' && row.projectGroup !== undefined - const projectIdForHeader = isRepoHeader ? row.repo!.id : undefined - const projectGroupIdForHeader = - isProjectGroupHeader && !row.repo && typeof row.projectGroup?.id === 'string' - ? row.projectGroup.id - : undefined - const repoHeaderIndex = - projectIdForHeader !== undefined - ? repoHeaderIndexByRepoId.get(projectIdForHeader) - : undefined - const repoHeaderBucketKey = - projectIdForHeader !== undefined - ? repoHeaderBucketByRepoId.get(projectIdForHeader) - : undefined - const projectGroupHeaderIndex = - projectGroupIdForHeader !== undefined - ? projectGroupHeaderIndexByGroupId.get(projectGroupIdForHeader) - : undefined - const projectGroupHeaderBucketKey = - projectGroupIdForHeader !== undefined - ? projectGroupHeaderBucketByGroupId.get(projectGroupIdForHeader) - : undefined - const isDraggableRepoHeader = Boolean( - canReorderRepoHeaders && - isRepoHeader && - projectIdForHeader && - repoHeaderBucketKey && - (sidebarRepoHeaderIdsByBucket.get(repoHeaderBucketKey)?.length ?? 0) > 1 - ) - const isDraggableProjectGroupHeader = Boolean( - canReorderProjectGroupHeaders && - projectGroupIdForHeader && - projectGroupHeaderBucketKey && - (sidebarProjectGroupHeaderIdsByBucket.get(projectGroupHeaderBucketKey)?.length ?? - 0) > 1 - ) - const isDraggingThis = - canReorderRepoHeaders && - repoDrag.state.draggingRepoId !== null && - repoDrag.state.draggingRepoId === projectIdForHeader - const isDraggingThisProjectGroup = - canReorderProjectGroupHeaders && - projectGroupDrag.state.draggingGroupId !== null && - projectGroupDrag.state.draggingGroupId === projectGroupIdForHeader - const headerWorkspaceStatus = - groupBy === 'workspace-status' - ? getWorkspaceStatusFromGroupKey(row.key, workspaceStatuses) - : null - const isPinnedHeader = row.key === PINNED_GROUP_KEY - const repoHeaderColor = resolveProjectGroupHeaderColor({ - groupBy, - headerKey: row.key, - badgeColor: row.repo?.badgeColor - }) - const createState = row.repo - ? getRepoHeaderCreateState({ - repo: row.repo, - label: row.label, - sshStatus: row.repo.connectionId - ? (sshConnectionStates.get(row.repo.connectionId)?.status ?? null) - : null - }) - : null - const projectGroupPathStatus = - isProjectGroupHeader && - row.projectGroup && - 'parentPath' in row.projectGroup && - row.projectGroup.parentPath - ? getCachedFolderWorkspacePathStatus({ - scope: 'project-group', - projectGroupId: row.projectGroup.id - }) - : null - const folderWorkspaceCreateDisabled = - projectGroupPathStatus?.exists === false && - (isConfirmedStaleFolderPathStatus(projectGroupPathStatus) || - projectGroupPathStatus.reason === 'ambiguous-connection') - const projectGroupDepth = row.projectGroupDepth ?? 0 - const isHeaderCollapsed = collapsedGroups.has(row.key) - // Why: repo/project/status/pinned share compact section chrome; flat "All" stays a simple label. - const showHeaderCollapseAffordance = - row.count > 0 && - (isRepoHeader || - isProjectGroupHeader || - headerWorkspaceStatus !== null || - isPinnedHeader) - // Why: non-project headers like "All" are flat-list labels; don't reserve project hierarchy indent. - const headerPaddingLeft = - isRepoHeader || isProjectGroupHeader - ? getProjectGroupHeaderPaddingLeft(projectGroupDepth) - : WORKTREE_SECTION_HEADER_PADDING_LEFT - return ( -
-
handleWorkspaceStatusDragOver(event, headerWorkspaceStatus) - : undefined - } - onDragLeave={ - isPinnedHeader - ? handleWorkspacePinDragLeave - : headerWorkspaceStatus - ? handleWorkspaceStatusDragLeave - : undefined - } - onDrop={ - headerWorkspaceStatus - ? (event) => handleWorkspaceStatusDrop(event, headerWorkspaceStatus) - : undefined - } - onPointerDown={ - isDraggableRepoHeader && projectIdForHeader - ? (event) => repoDrag.onHandlePointerDown(event, projectIdForHeader) - : isDraggableProjectGroupHeader && projectGroupIdForHeader - ? (event) => - projectGroupDrag.onHandlePointerDown(event, projectGroupIdForHeader) - : undefined - } - onClick={(event) => { - if (shouldIgnoreRepoHeaderToggle(event)) { - return - } - toggleGroupWithScrollAnchor(row.key) - }} - onKeyDown={(e) => { - if (shouldIgnoreRepoHeaderToggle(e)) { - return - } - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - toggleGroupWithScrollAnchor(row.key) - } - }} - > - {/* Why: grab cursor on icon+title only. Row still has handle attrs so - indent/padding can arm drag; actions are excluded via data-repo-header-actions. - self-stretch fills h-7 so grab matches the full title column height. */} -
- {row.icon ? ( -
- {row.repo ? ( - - ) : ( - - )} -
- ) : null} - -
-
-
- {row.label} -
- - -
-
-
- - - {showHeaderCollapseAffordance ? ( -
{ - event.preventDefault() - event.stopPropagation() - toggleGroupWithScrollAnchor(row.key) - }} - > - -
- ) : null} - - {isProjectGroupHeader && !row.repo && row.projectGroup?.id ? ( - - - - - - { - if (row.projectGroup?.id) { - handleRenameProjectGroup(row.projectGroup.id, row.label) - } - }} - > - {translate( - 'auto.components.sidebar.WorktreeList.4d7b73658c', - 'Rename group' - )} - - { - if (row.projectGroup?.id) { - handleDeleteProjectGroup(row.projectGroup.id, row.label) - } - }} - > - {translate( - 'auto.components.sidebar.WorktreeList.902115cdbe', - 'Delete group' - )} - - - - ) : null} - - {isProjectGroupHeader && - !row.repo && - row.projectGroup && - 'parentPath' in row.projectGroup && - row.projectGroup.parentPath ? ( - - - - - - {projectGroupPathStatus?.exists === false - ? getFolderWorkspacePathStatusDescription(projectGroupPathStatus) - : translate( - 'auto.components.sidebar.WorktreeList.bd37a57ac8', - 'Create workspace for {{value0}}', - { value0: row.label } - )} - - - ) : null} - - {row.repo && groupBy === 'repo' ? ( - - - - - - - - - {translate( - 'auto.components.sidebar.WorktreeList.2ef41bf9a7', - 'Project actions' - )} - - - - { - if (row.repo) { - handleOpenRepoSettings(row.repo.id) - } - }} - > - - {translate( - 'auto.components.sidebar.WorktreeList.2cdffbc728', - 'Project Settings' - )} - - { - if (row.repo) { - handleOpenRepoSettings( - row.repo.id, - getRepositoryIconSectionId(row.repo.id) - ) - } - }} - > - - {translate( - 'auto.components.sidebar.WorktreeList.e82d3589a1', - 'Change Project Icon' - )} - - {row.repo && isGitRepoKind(row.repo) ? ( - { - if (row.repo) { - handleOpenWorktreeVisibility(row.repo) - } - }} - > - - {getWorktreeVisibilityMenuLabel(row.repo)} - - ) : null} - { - if (row.repo) { - handleCreateGroupFromRepo(row.repo) - } - }} - > - - {translate( - 'auto.components.sidebar.WorktreeList.cbfd565f83', - 'New group from project' - )} - - {projectGroups.length > 0 ? ( - - - - {translate( - 'auto.components.sidebar.WorktreeList.4a08fb55f2', - 'Move to group' - )} - - - {projectGroups.map((group) => ( - { - if (row.repo) { - handleMoveProjectToGroup(row.repo, group.id) - } - }} - > - {group.name} - - ))} - - - ) : null} - {row.repo.projectGroupId ? ( - { - if (row.repo) { - handleRemoveProjectFromGroup(row.repo) - } - }} - > - - {translate( - 'auto.components.sidebar.WorktreeList.64e55f7f01', - 'Remove from group' - )} - - ) : null} - - { - if (row.repo) { - handleRemoveProject(row.repo) - } - }} - > - - {translate( - 'auto.components.sidebar.WorktreeList.c83968f87f', - 'Remove Project' - )} - - - - ) : null} - - {row.repo && groupBy === 'repo' ? ( - - - {createState?.disabled ? ( - event.stopPropagation()} - onPointerDown={handleRepoHeaderActionPointerDown} - > - - - ) : ( - - )} - - - {createState?.tooltip ?? - translate( - 'auto.components.sidebar.WorktreeList.bb85cd86ba', - 'Create workspace for {{value0}}', - { value0: row.label } - )} - - - ) : null} -
-
-
- ) - } - - const renderWorktreeRow = ( - itemRow: WorktreeItemRow, - nested: boolean, - lineageChildren?: React.ReactNode, - forceActiveSurface = false - ) => { - const lineageToggleGroupKey = itemRow.lineageGroupKey - const experimentalNewWorktreeCardStyle = - settings?.experimentalNewWorktreeCardStyle === true - const projectGroupId = itemRow.repo?.projectGroupId - const isFolderBackedRepoChild = - groupBy === 'repo' && - Boolean(projectGroupId && folderBackedProjectGroupIds.has(projectGroupId)) - // Why: experimental in-card lineage inherits the parent surface; legacy cards keep depth-based nested geometry. - const paddingDepth = nested ? Math.max(0, itemRow.depth - 1) : itemRow.depth - const getCardContentIndent = (lineageDepth: number): number => - isFolderBackedRepoChild - ? getFolderBackedRepoWorktreeCardContentIndent({ - groupDepth: itemRow.groupDepth, - lineageDepth - }) - : getWorktreeCardContentIndent({ - isGrouped: groupBy !== 'none', - groupDepth: itemRow.groupDepth, - lineageDepth - }) - const inheritedCardContentIndent = getCardContentIndent(0) - const nestedLineageGeometry = nested - ? getLineageNestedRowGeometry({ - experimentalNewWorktreeCardStyle, - inheritedCardContentIndent, - lineageDepth: itemRow.depth - }) - : null - // Why: grouped rows inherit their header depth, but the card surface still spans the full row. - const paddingLeft = - nested && groupBy !== 'none' - ? getWorktreeCardContentIndent({ - isGrouped: false, - groupDepth: itemRow.groupDepth, - lineageDepth: paddingDepth - }) - : getCardContentIndent(paddingDepth) - const surfaceInset = nested - ? nestedLineageGeometry!.surfaceInset - : isFolderBackedRepoChild - ? getFolderBackedRepoWorktreeCardSurfaceInset({ - groupDepth: itemRow.groupDepth, - lineageDepth: paddingDepth - }) - : getWorktreeCardSurfaceInset({ - isGrouped: groupBy !== 'none', - groupDepth: itemRow.groupDepth - }) - const cardContentIndent = nested - ? nestedLineageGeometry!.cardContentIndent - : Math.max(0, paddingLeft - surfaceInset) - const lineageChildrenStyle = lineageChildren - ? getLineageChildrenInlineStyle( - nestedLineageGeometry?.lineageChildrenInlineOffset ?? - LINEAGE_CHILDREN_INLINE_OFFSET - ) - : undefined - const worktreeDragGroupKey = groupKeyByRowKey.get(itemRow.rowKey) - const worktreeDragGroupIndex = groupIndexByRowKey.get(itemRow.rowKey) - const revealHighlightTone = - agentSendTargetWorktreeId === itemRow.worktree.id ? 'ai' : 'default' - const isLineageDropTarget = - worktreeDragState.draggingWorktreeId && - (worktreePointerDragRef.current?.latestStatusDropTarget?.target.lineageParentId === - itemRow.worktree.id || - nativeLineageDropTargetId === itemRow.worktree.id) - const isPinnedOverlayRow = itemRow.sectionKey === PINNED_GROUP_KEY - const isActiveWorktree = activeWorktreeId === itemRow.worktree.id - const activeSurfaceVariant = getActiveSurfaceVariant(itemRow) - return ( -
{ - if (nested) { - event.stopPropagation() - } - handleWorktreeRowPointerDown(event, itemRow.worktree.id, itemRow.rowKey) - }} - style={{ - paddingLeft: surfaceInset > 0 ? `${surfaceInset}px` : undefined - }} - > - -
- ) - } - - const renderLineageDescendants = ( - parent: WorktreeItemRow, - descendants: readonly WorktreeItemRow[] - ): React.ReactNode | undefined => { - const childNodes: React.ReactNode[] = [] - let cursor = 0 - while (cursor < descendants.length) { - const child = descendants[cursor] - if (!child || child.depth !== parent.depth + 1) { - cursor++ - continue - } - - let nextSiblingIndex = cursor + 1 - while ( - nextSiblingIndex < descendants.length && - descendants[nextSiblingIndex]!.depth > child.depth - ) { - nextSiblingIndex++ - } - - const childLineageChildren = renderLineageDescendants( - child, - descendants.slice(cursor + 1, nextSiblingIndex) - ) - childNodes.push(renderWorktreeRow(child, true, childLineageChildren)) - cursor = nextSiblingIndex - } - return childNodes.length > 0 ? childNodes : undefined - } - - if (row.type === 'lineage-group') { - const [parent, ...children] = row.rows - const childIsActive = children.some((child) => child.worktree.id === activeWorktreeId) - const parentPreviewOffset = parent - ? (worktreeDragState.previewOffsetsByWorktreeId.get(parent.worktree.id) ?? 0) - : 0 - return ( -
-
- {parent - ? renderWorktreeRow( - parent, - false, - renderLineageDescendants(parent, children), - childIsActive - ) - : null} -
-
- ) - } - - if (row.type === 'imported-worktrees-card') { - const actionState = importedWorktreeCardActionState.get(row.repo.id) - return ( -
- handleShowImportedWorktrees(row.repo.id)} - onKeepHidden={ - canKeepImportedWorktreesHidden(row, actionState) - ? () => handleKeepImportedWorktreesHidden(row.repo.id) - : undefined - } - /> -
- ) - } - - if (row.type === 'new-external-worktrees-inbox') { - const actionState = newExternalWorktreeInboxActionState.get(row.repo.id) - return ( -
- handleOpenWorktreeVisibility(row.repo)} - onSuppress={() => handleOpenSuppressExternalWorktreeInbox(row.repo.id)} - /> -
- ) - } - - if (row.type === 'pending-creation') { - return ( -
- -
- ) - } - - if (row.type === 'folder-workspace') { - const folderWorkspaceRow = row as FolderWorkspaceItemRow - const folderWorktree = folderWorkspaceToWorktree(folderWorkspaceRow.folderWorkspace) - const folderWorkspacePathStatus = getCachedFolderWorkspacePathStatus({ - scope: 'folder-workspace', - folderWorkspaceId: folderWorkspaceRow.folderWorkspace.id - }) - const folderWorkspaceActivationDisabled = - folderWorkspacePathStatus?.exists === false && - (isConfirmedStaleFolderPathStatus(folderWorkspacePathStatus) || - folderWorkspacePathStatus.reason === 'ambiguous-connection') - const folderPrDisplay = getFolderWorkspaceCardPrDisplay({ - folderWorkspaceId: folderWorkspaceRow.folderWorkspace.id, - workspaceLineageByChildKey, - worktreeLineageById, - worktreeMap, - repoMap, - hostedReviewCache, - prCache, - settings - }) - const isFolderBackedWorkspaceChild = - groupBy === 'repo' && folderWorkspaceRow.projectGroup.createdFrom === 'folder-scan' - const { surfaceInset, cardContentIndent } = getFolderWorkspaceRowGeometry({ - experimentalNewWorktreeCardStyle: newCardStyle, - isFolderBackedWorkspaceChild, - isGrouped: groupBy !== 'none', - groupDepth: folderWorkspaceRow.groupDepth, - lineageDepth: folderWorkspaceRow.depth - }) - return ( -
- handleWorktreeRowPointerDown(event, folderWorktree.id, folderWorktree.id) - } - > -
0 ? { paddingLeft: surfaceInset } : undefined} - > - -
- -
-
-
- ) - } - - const itemWorkspaceStatus = - groupBy === 'workspace-status' - ? getWorkspaceStatus(row.worktree, workspaceStatuses) - : null - const itemPreviewOffset = - worktreeDragState.previewOffsetsByWorktreeId.get(row.worktree.id) ?? 0 - - return ( -
handleWorkspaceStatusDragOver(event, itemWorkspaceStatus) - : undefined - } - onDragLeave={itemWorkspaceStatus ? handleWorkspaceStatusDragLeave : undefined} - onDrop={ - itemWorkspaceStatus - ? (event) => handleWorkspaceStatusDrop(event, itemWorkspaceStatus) - : undefined - } - > - {renderWorktreeRow(row, false)} -
- ) - })} -
-
- {showScrollToTop ? : null} -
- ) -}) + EMPTY_PROJECT_GROUPS, + NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK +} from './worktree-list/virtualized-worktree-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/use-sidebar-reveal-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-worktree-status-mutations' type WorktreeListProps = { scrollOffsetRef: React.MutableRefObject @@ -5213,11 +47,6 @@ type WorktreeListProps = { onWorkspaceBoardDragPreviewCancel?: () => void } -export function installWorktreeVisibleRefreshVisibilityListener(onChange: () => void): () => void { - document.addEventListener('visibilitychange', onChange) - return () => document.removeEventListener('visibilitychange', onChange) -} - const WorktreeList = React.memo(function WorktreeList({ scrollOffsetRef, scrollAnchorRef, @@ -5230,9 +59,9 @@ const WorktreeList = React.memo(function WorktreeList({ const allWorktrees = useAllWorktrees() const repoMap = useRepoMap() const worktreeMap = useWorktreeMap() + const repos = useAppStore((s) => s.repos) const worktreeLineageById = useAppStore((s) => s.worktreeLineageById) const workspaceLineageByChildKey = useAppStore((s) => s.workspaceLineageByChildKey) - const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const activeWorkspaceKey = useAppStore((s) => s.activeWorkspaceKey) @@ -5241,365 +70,29 @@ const WorktreeList = React.memo(function WorktreeList({ [activeWorkspaceKey, activeWorktreeId] ) const groupBy = useAppStore((s) => s.groupBy) - const setGroupBy = useAppStore((s) => s.setGroupBy) - const workspaceHostScope = useAppStore((s) => s.workspaceHostScope) - const visibleWorkspaceHostIds = useAppStore((s) => s.visibleWorkspaceHostIds) - const workspaceHostOrder = useAppStore((s) => s.workspaceHostOrder) - const setWorkspaceHostOrder = useAppStore((s) => s.setWorkspaceHostOrder) const workspaceStatuses = useAppStore((s) => s.workspaceStatuses) const sortBy = useAppStore((s) => s.sortBy) - const setSortBy = useAppStore((s) => s.setSortBy) const projectOrderBy = useAppStore((s) => s.projectOrderBy) - const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) - const agentStatusEpoch = useAppStore((s) => (!showSleepingWorkspaces ? s.agentStatusEpoch : 0)) - const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) - const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) - const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) - const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) - const hideWorkspacesFromOtherDevices = useAppStore((s) => s.hideWorkspacesFromOtherDevices) - const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) - const filterRepoIds = useAppStore((s) => s.filterRepoIds) const openModal = useAppStore((s) => s.openModal) const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) - const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) - const updateWorktreesMeta = useAppStore((s) => s.updateWorktreesMeta) - const updateRepo = useAppStore((s) => s.updateRepo) - const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) const activeView = useAppStore((s) => s.activeView) const activeModal = useAppStore((s) => s.activeModal) const pendingRevealWorktree = useAppStore((s) => s.pendingRevealWorktree) const pendingRevealSidebarRow = useAppStore((s) => s.pendingRevealSidebarRow) - const revealWorktreeInSidebar = useAppStore((s) => s.revealWorktreeInSidebar) - const revealSidebarRow = useAppStore((s) => s.revealSidebarRow) - const setWorktreesPinnedAndReveal = useAppStore((s) => s.setWorktreesPinnedAndReveal) const clearPendingRevealWorktreeId = useAppStore((s) => s.clearPendingRevealWorktreeId) const clearPendingRevealSidebarRow = useAppStore((s) => s.clearPendingRevealSidebarRow) - const agentSendPopoverTargetMode = useAppStore((s) => s.agentSendPopoverTargetMode) - // Why: eligibility only matters while the picker is open; when closed, don't subscribe to wake-time layout churn. - const agentTargetStatusByPaneKey = useAppStore((s) => - agentSendPopoverTargetMode ? s.agentStatusByPaneKey : EMPTY_AGENT_STATUS_BY_PANE_KEY - ) - const agentTargetStatusEpoch = useAppStore((s) => - agentSendPopoverTargetMode ? s.agentStatusEpoch : 0 - ) - const agentTargetTabsByWorktree = useAppStore((s) => - agentSendPopoverTargetMode ? s.tabsByWorktree : EMPTY_TABS_BY_WORKTREE - ) - const agentTargetTerminalLayoutsByTabId = useAppStore((s) => - agentSendPopoverTargetMode ? s.terminalLayoutsByTabId : EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID - ) - const agentTargetPtyIdsByTabId = useAppStore((s) => - agentSendPopoverTargetMode ? s.ptyIdsByTabId : EMPTY_PTY_IDS_BY_TAB_ID - ) - const agentTargetRuntimePaneTitlesByTabId = useAppStore((s) => - agentSendPopoverTargetMode ? s.runtimePaneTitlesByTabId : EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID - ) - const agentSendTargetWorktreeId = useMemo(() => { - void agentTargetStatusEpoch - if (!agentSendPopoverTargetMode) { - return null - } - const targets = deriveRunningAgentSendTargets( - { - agentStatusByPaneKey: agentTargetStatusByPaneKey, - tabsByWorktree: agentTargetTabsByWorktree, - terminalLayoutsByTabId: agentTargetTerminalLayoutsByTabId, - ptyIdsByTabId: agentTargetPtyIdsByTabId, - runtimePaneTitlesByTabId: agentTargetRuntimePaneTitlesByTabId - }, - agentSendPopoverTargetMode.worktreeId - ) - return targets.some((target) => target.status === 'eligible') - ? agentSendPopoverTargetMode.worktreeId - : null - }, [ - // Why: eligibility can flip when the stale-boundary scheduler bumps this epoch without replacing the status map. - agentTargetStatusEpoch, - agentSendPopoverTargetMode, - agentTargetStatusByPaneKey, - agentTargetTabsByWorktree, - agentTargetTerminalLayoutsByTabId, - agentTargetPtyIdsByTabId, - agentTargetRuntimePaneTitlesByTabId - ]) - - // Read tabsByWorktree when needed for filtering or sorting - const needsActivityMaps = !showSleepingWorkspaces || sortBy === 'smart' - const tabsByWorktree = useAppStore((s) => - needsActivityMaps ? getVisibleWorktreeTerminalActivityTabs(s.tabsByWorktree) : null - ) - const ptyIdsByTabId = useAppStore((s) => (needsActivityMaps ? s.ptyIdsByTabId : null)) - const browserTabsByWorktree = useAppStore((s) => - !showSleepingWorkspaces ? getVisibleWorktreeBrowserActivityTabs(s.browserTabsByWorktree) : null - ) - + const collapsedGroups = useAppStore((s) => s.collapsedGroups) + const toggleGroup = useAppStore((s) => s.toggleCollapsedGroup) + const projectGroups = useAppStore((s) => s.projectGroups ?? EMPTY_PROJECT_GROUPS) + const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) + const settings = useAppStore((s) => s.settings) const cardProps = useAppStore((s) => s.worktreeCardProperties) - const { prCache, hostedReviewCache } = useAppStore( useShallow((s) => selectWorktreeListReviewCacheInputs(s, groupBy, cardProps)) ) - const settings = useAppStore((s) => s.settings) const pinnedDisplayPolicy = getPinnedWorktreeDisplayPolicy(settings) - const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) - const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) - const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) - const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) - const pairedDeviceIdsByEnvironment = useMemo( - () => - hideWorkspacesFromOtherDevices - ? getPairedDeviceIdsByEnvironment(runtimeEnvironments, runtimeStatusByEnvironmentId) - : EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, - [hideWorkspacesFromOtherDevices, runtimeEnvironments, runtimeStatusByEnvironmentId] - ) - - const sortEpoch = useAppStore((s) => s.sortEpoch) - - // Non-archived count — detects structural changes (add/remove) so the debounce below can apply immediately. - const worktreeCount = useMemo(() => { - let count = 0 - for (const worktree of allWorktrees) { - if (!worktree.isArchived) { - count++ - } - } - return count - }, [allWorktrees]) - - // Why debounce: scores are time-decaying, so recomputing on every sortEpoch bump makes worktrees jump; settle to coalesce. - // Structural changes (add/remove) bypass the debounce so a new worktree appears at its sorted position immediately. - const [debouncedSortEpoch, setDebouncedSortEpoch] = useState(sortEpoch) - const prevWorktreeCountRef = useRef(worktreeCount) - useEffect(() => { - if (debouncedSortEpoch === sortEpoch) { - return - } - - const structuralChange = worktreeCount !== prevWorktreeCountRef.current - prevWorktreeCountRef.current = worktreeCount - - // Why: manual drag/drop is direct manipulation; the settle-window delay would make a successful drop look broken. - if (structuralChange || sortBy === 'manual') { - setDebouncedSortEpoch(sortEpoch) - return - } - - const timer = setTimeout(() => setDebouncedSortEpoch(sortEpoch), SORT_SETTLE_MS) - return () => clearTimeout(timer) - }, [sortEpoch, debouncedSortEpoch, worktreeCount, sortBy]) - - // Why a latching ref: a live signal makes Smart authoritative for the session, even after that activity ends. - const sessionHasHadLiveSmartSignal = useRef(false) - - // ── Stable sort order ────────────────────────────────────────── - // Why sortEpoch (not selection): selection side-effects (clearing isUnread, PR-cache refresh) must not reorder the sidebar under the user. - // Why useMemo not useEffect: order must be computed synchronously before the worktrees memo reads it. - // Why a ref alongside the memo: telemetry effects need the last attention map without re-reading store state. - const lastAttentionByWorktreeRef = useRef | null>(null) - - const recomputedSortedIds = useMemo(() => { - const state = useAppStore.getState() - const nonArchivedWorktrees = getAllWorktreesFromState(state).filter( - (worktree) => !worktree.isArchived - ) - const now = Date.now() - - // Why cold-start detection: agent-status hydrates async, so the warm comparator would collapse all to Class 4; keep the persisted order until a live signal appears. - if (sortBy === 'smart' && !sessionHasHadLiveSmartSignal.current) { - // Why tabHasLivePty over tab.ptyId: slept terminals keep tab.ptyId as a wake hint, so it'd falsely keep cold-start ordering off. - const hasAnyLivePty = Object.values(state.tabsByWorktree) - .flat() - .some((tab) => tabHasLivePty(state.ptyIdsByTabId, tab.id)) - if ( - hasAnyLivePty || - hasFreshAttributedAgentStatus(state.agentStatusByPaneKey, now, state.tabsByWorktree) - ) { - sessionHasHadLiveSmartSignal.current = true - } else { - nonArchivedWorktrees.sort( - (a, b) => b.sortOrder - a.sortOrder || compareWorktreeSortLabel(a, b) - ) - lastAttentionByWorktreeRef.current = null - return nonArchivedWorktrees.map((w) => w.id) - } - } - - const currentTabs = state.tabsByWorktree - // Why precompute: hot sort — build the attention map once so the O(N log N) comparator does O(1) lookups. - const attentionByWorktree = - sortBy === 'smart' - ? buildAttentionByWorktree( - nonArchivedWorktrees, - currentTabs, - state.agentStatusByPaneKey, - state.runtimePaneTitlesByTabId, - state.ptyIdsByTabId, - now, - state.migrationUnsupportedByPtyId, - state.terminalLayoutsByTabId - ) - : new Map() - lastAttentionByWorktreeRef.current = sortBy === 'smart' ? attentionByWorktree : null - nonArchivedWorktrees.sort(buildWorktreeComparator(sortBy, repoMap, now, attentionByWorktree)) - return nonArchivedWorktrees.map((w) => w.id) - // debouncedSortEpoch is an intentional trigger not read in the memo; its change (debounced) signals a recompute. - // oxlint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedSortEpoch, repoMap, sortBy]) - // Why: stable ID order prevents rank-only refreshes from echoing an unchanged snapshot. - const sortedIds = useReusedArrayIdentity(recomputedSortedIds) - - // Why a ref of prior class: fire class_1_promotion only on transitions into Class 1, not every recompute that stays there. - const prevClassByWorktreeIdRef = useRef>(new Map()) - // Why gate the first observation: an empty prev-class map makes every existing Class-1 worktree look freshly promoted; treat the first pass as a silent baseline. - const hasObservedSmartOnceRef = useRef(false) - - useEffect(() => { - const attention = lastAttentionByWorktreeRef.current - if (sortBy !== 'smart' || !attention) { - // Why reset: leaving Smart drops the prior-class map (and first-observation gate) so re-entry doesn't fire stale promotions. - prevClassByWorktreeIdRef.current = new Map() - hasObservedSmartOnceRef.current = false - return - } - const next = new Map() - const isFirstObservation = !hasObservedSmartOnceRef.current - for (const [worktreeId, info] of attention) { - const prev = prevClassByWorktreeIdRef.current.get(worktreeId) - if (!isFirstObservation && info.cls === 1 && prev !== 1 && info.cause) { - track('smart_sort_class_1_promotion', { cause: info.cause }) - } - next.set(worktreeId, info.cls) - } - prevClassByWorktreeIdRef.current = next - hasObservedSmartOnceRef.current = true - }, [sortBy, recomputedSortedIds]) - - // Why retry on recomputation: Smart may activate before attention hydrates; fire once, then stay quiet until the user leaves Smart. - const hasTrackedSmartDistributionRef = useRef(false) - useEffect(() => { - if (sortBy !== 'smart') { - hasTrackedSmartDistributionRef.current = false - return - } - if (hasTrackedSmartDistributionRef.current) { - return - } - const attention = lastAttentionByWorktreeRef.current - if (!attention || attention.size === 0) { - return - } - let class1 = 0 - let class2 = 0 - let class3 = 0 - let class4 = 0 - for (const info of attention.values()) { - if (info.cls === 1) { - class1++ - } else if (info.cls === 2) { - class2++ - } else if (info.cls === 3) { - class3++ - } else { - class4++ - } - } - track('smart_sort_class_distribution', { - class_1: class1, - class_2: class2, - class_3: class3, - class_4: class4, - total_worktrees: attention.size - }) - hasTrackedSmartDistributionRef.current = true - }, [sortBy, recomputedSortedIds]) - - // Why fire on the transition: switching away from Smart is the signal; compare via ref so a round-trip doesn't double-fire. - const prevSortByRef = useRef(sortBy) - useEffect(() => { - const prev = prevSortByRef.current - prevSortByRef.current = sortBy - if (prev === 'smart' && sortBy === 'recent') { - track('smart_to_recent_switch', {}) - } - }, [sortBy]) - - // Why: only persist during live sessions so cold start reads the persisted order instead of overwriting it. - useEffect(() => { - if (sortBy !== 'smart' || sortedIds.length === 0 || !sessionHasHadLiveSmartSignal.current) { - return - } - // Why: sortOrder lives in each host's worktreeMeta, so persist each host's ids on that host. - const state = useAppStore.getState() - persistWorktreeSortOrderByHost(state, sortedIds) - }, [sortedIds, sortBy]) - - // Flatten/filter/sort via the shared utility so card order matches Cmd+1–9 numbering. - const recomputedVisibleWorktrees = useMemo(() => { - void agentStatusEpoch - const ids = computeVisibleWorktreeIds(worktreesByRepo, sortedIds, { - filterRepoIds, - showSleepingWorkspaces, - tabsByWorktree, - ptyIdsByTabId, - browserTabsByWorktree, - // Why snapshot on agentStatusEpoch: update membership immediately without repainting on every hook ping. - worktreeIdsWithLiveAgent: showSleepingWorkspaces - ? EMPTY_WORKTREE_ID_SET - : getWorktreeIdsWithLiveAgent( - useAppStore.getState().agentStatusByPaneKey, - tabsByWorktree, - Date.now() - ), - hideDefaultBranchWorkspace, - hideAutomationGeneratedWorkspaces, - hideCliCreatedWorkspaces, - hideDetachedHeadWorkspaces, - hideWorkspacesFromOtherDevices, - pairedDeviceIdsByEnvironment, - alwaysShowDefaultBranchWorkspace, - repoMap, - workspaceHostScope, - visibleWorkspaceHostIds, - defaultHostId: getSettingsFocusedExecutionHostId(settings), - worktreeLineageById, - forcedVisibleWorktreeIds: agentSendTargetWorktreeId ? [agentSendTargetWorktreeId] : undefined - }) - return ids.map((id) => worktreeMap.get(id)).filter((w): w is Worktree => w != null) - }, [ - agentSendTargetWorktreeId, - agentStatusEpoch, - filterRepoIds, - showSleepingWorkspaces, - hideDefaultBranchWorkspace, - hideAutomationGeneratedWorkspaces, - hideCliCreatedWorkspaces, - hideDetachedHeadWorkspaces, - hideWorkspacesFromOtherDevices, - alwaysShowDefaultBranchWorkspace, - workspaceHostScope, - visibleWorkspaceHostIds, - settings, - repoMap, - tabsByWorktree, - ptyIdsByTabId, - browserTabsByWorktree, - sortedIds, - worktreeMap, - worktreeLineageById, - worktreesByRepo, - pairedDeviceIdsByEnvironment - ]) - // Why: agentStatusEpoch bumps recompute this memo even when membership and - // order are unchanged; keeping the previous identity stops the whole - // rows/sectionRows/renderedWorktrees chain from churning per epoch. - const visibleWorktrees = useReusedArrayIdentity(recomputedVisibleWorktrees) - - const worktrees = visibleWorktrees - const collapsedGroups = useAppStore((s) => s.collapsedGroups) - const toggleGroup = useAppStore((s) => s.toggleCollapsedGroup) - - // Why: manual header order is bound to state.repos; Recent/Smart derive order from the sorted worktree stream. - const repos = useAppStore((s) => s.repos) + const defaultHostId = getSettingsFocusedExecutionHostId(settings) const projectHostSetupProjection = useProjectHostSetupProjection() const projectGrouping = useMemo( () => ({ @@ -5608,427 +101,89 @@ const WorktreeList = React.memo(function WorktreeList({ }), [projectHostSetupProjection] ) - const projectGroups = useAppStore((s) => s.projectGroups ?? EMPTY_PROJECT_GROUPS) - const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) - const effectiveCollapsedGroups = useMemo(() => { - if (!agentSendTargetWorktreeId) { - return collapsedGroups - } - const targetWorktree = worktreeMap.get(agentSendTargetWorktreeId) - if (!targetWorktree) { - return collapsedGroups - } - const next = new Set(collapsedGroups) - if (targetWorktree.isPinned) { - next.delete(PINNED_GROUP_KEY) - } else { - for (const groupKey of getGroupKeysForWorktree( - groupBy, - targetWorktree, - repoMap, - prCache, - workspaceStatuses, - settings, - projectGroups, - projectGrouping - )) { - next.delete(groupKey) - } - } - for (const parent of getWorktreeLineageAncestors( - targetWorktree, - worktreeLineageById, - worktreeMap - )) { - next.delete(getLineageGroupKey(parent.id)) - } - return next - }, [ - agentSendTargetWorktreeId, - collapsedGroups, - groupBy, - prCache, - projectGroups, - projectGrouping, + const agentSendTargetWorktreeId = useAgentSendTargetWorktreeId() + const { filterState, hasFilters, clearFilters } = useSidebarWorktreeFilters() + const sortedIds = useSidebarWorktreeSortOrder({ allWorktrees, repoMap, sortBy }) + const { visibleWorktrees, pairedDeviceIdsByEnvironment } = useVisibleSidebarWorktrees({ + filterState, + sortBy, + sortedIds, repoMap, + worktreeMap, + worktreeLineageById, + settings, + agentSendTargetWorktreeId + }) + const effectiveCollapsedGroups = useEffectiveCollapsedGroups({ + collapsedGroups, + agentSendTargetWorktreeId, + groupBy, + repoMap, + worktreeMap, + worktreeLineageById, + prCache, + workspaceStatuses, + settings, + projectGroups, + projectGrouping + }) + const visibleScope = useSidebarHostVisibleScope({ + filterState, + defaultHostId, + repos, + projectGroups, + folderWorkspaces, + pairedDeviceIdsByEnvironment + }) + const externalWorktreeCards = useSidebarExternalWorktreeCards({ + repos, + visibleReposForRows: visibleScope.visibleReposForRows, + detectedWorktreesByRepo, + filterRepoIds: filterState.filterRepoIds + }) + const rowModel = useSidebarSectionRows({ + groupBy, + projectOrderBy, + pinnedDisplayPolicy, + defaultHostId, + worktrees: visibleWorktrees, + repos, + repoMap, + worktreeMap, + worktreeLineageById, + prCache, settings, workspaceStatuses, - worktreeLineageById, - worktreeMap - ]) - const defaultHostId = getSettingsFocusedExecutionHostId(settings) - const visibleHostIdSet = useMemo( - () => getVisibleSidebarHostIdSet(visibleWorkspaceHostIds, workspaceHostScope), - [visibleWorkspaceHostIds, workspaceHostScope] - ) - const visibleReposForRows = useMemo(() => { - if (!visibleHostIdSet) { - return repos - } - return repos.filter((repo) => { - const hostId = - repo.connectionId || repo.executionHostId ? getRepoExecutionHostId(repo) : defaultHostId - return visibleHostIdSet.has(hostId) - }) - }, [defaultHostId, repos, visibleHostIdSet]) - const visibleProjectGroupsForRows = useMemo( - () => filterProjectGroupsForVisibleHosts(projectGroups, visibleHostIdSet, defaultHostId), - [defaultHostId, projectGroups, visibleHostIdSet] - ) - const visibleFolderWorkspacesForRows = useMemo(() => { - const hostVisibleWorkspaces = filterFolderWorkspacesForVisibleHosts( - folderWorkspaces, - projectGroups, - visibleHostIdSet, - defaultHostId - ) - if (!hideWorkspacesFromOtherDevices) { - return hostVisibleWorkspaces - } - return filterFolderWorkspacesFromOtherDevices( - hostVisibleWorkspaces, - pairedDeviceIdsByEnvironment - ) - }, [ - defaultHostId, - folderWorkspaces, - hideWorkspacesFromOtherDevices, - pairedDeviceIdsByEnvironment, - projectGroups, - visibleHostIdSet - ]) - const repoOrder = useMemo(() => { - return getLogicalRepoOrderRankById(repos.map((repo) => repo.id)) - }, [repos]) - const [importedWorktreeCardActionState, setImportedWorktreeCardActionState] = useState< - Map - >(new Map()) - const [newExternalWorktreeInboxActionState, setNewExternalWorktreeInboxActionState] = useState< - Map - >(new Map()) - const [suppressExternalWorktreeInboxRepoId, setSuppressExternalWorktreeInboxRepoId] = useState< - string | null - >(null) - const importedWorktreesByRepo = useMemo(() => { - const forceVisibleRepoIds = new Set( - [...importedWorktreeCardActionState.entries()] - .filter(([, state]) => state.forceVisible) - .map(([repoId]) => repoId) - ) - return buildImportedWorktreesCardCandidates({ - repos: visibleReposForRows, - detectedWorktreesByRepo, - filterRepoIds, - forceVisibleRepoIds - }) - }, [detectedWorktreesByRepo, filterRepoIds, importedWorktreeCardActionState, visibleReposForRows]) - const newExternalWorktreesInboxByRepo = useMemo( - () => - buildNewExternalWorktreesInboxCandidates({ - repos: visibleReposForRows, - detectedWorktreesByRepo, - filterRepoIds - }), - [detectedWorktreesByRepo, filterRepoIds, visibleReposForRows] - ) - const placeholderRepoIds = useMemo(() => { - return getEmptyProjectPlaceholderRepoIds({ - groupBy, - repos: visibleReposForRows, - worktreesByRepo, - visibleWorktrees, - filterRepoIds - }) - }, [filterRepoIds, groupBy, visibleReposForRows, visibleWorktrees, worktreesByRepo]) - const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos]) - - // Why: subscribe on a flat key array (useShallow) so progress ticks don't rebuild the whole row model. - // Split on first space — creationId is a UUID (no space) so a space-containing repoId stays intact. - const pendingCreationKeys = useAppStore( - useShallow((s) => - Object.values(s.pendingWorktreeCreations ?? {}).map( - (creation) => `${creation.creationId} ${creation.request.repoId}` - ) - ) - ) - const pendingCreations = useMemo( - () => - pendingCreationKeys.map((key) => { - const separator = key.indexOf(' ') - return { creationId: key.slice(0, separator), repoId: key.slice(separator + 1) } - }), - [pendingCreationKeys] - ) - const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings]) - const hostOptions = useMemo( - () => - buildSidebarHostOptions({ - repos, - sshTargetLabels, - sshConnectionStates, - settings, - runtimeEnvironments, - runtimeStatusByEnvironmentId, - hostLabelOverrides - }), - [ - repos, - sshTargetLabels, - sshConnectionStates, - settings, - runtimeEnvironments, - runtimeStatusByEnvironmentId, - hostLabelOverrides - ] - ) - const hostLabelById = useMemo( - () => new Map(hostOptions.map((host) => [host.id, host.label])), - [hostOptions] - ) - - const rows: Row[] = useMemo( - () => - buildRows( - groupBy, - worktrees, - repoMap, - prCache, - effectiveCollapsedGroups, - repoOrder, - workspaceStatuses, - projectOrderBy, - worktreeLineageById, - worktreeMap, - true, - settings, - visibleProjectGroupsForRows, - placeholderRepoIds, - importedWorktreesByRepo, - newExternalWorktreesInboxByRepo, - pendingCreations, - projectGrouping, - visibleFolderWorkspacesForRows, - hostLabelById, - defaultHostId, - pinnedDisplayPolicy - ), - [ - groupBy, - worktrees, - repoMap, - prCache, - effectiveCollapsedGroups, - defaultHostId, - repoOrder, - workspaceStatuses, - projectOrderBy, - worktreeLineageById, - worktreeMap, - settings, - projectGrouping, - visibleProjectGroupsForRows, - visibleFolderWorkspacesForRows, - placeholderRepoIds, - importedWorktreesByRepo, - newExternalWorktreesInboxByRepo, - pendingCreations, - hostLabelById, - pinnedDisplayPolicy - ] - ) - const orderedHostOptions = useMemo( - () => orderHostSectionOptions(hostOptions, workspaceHostOrder), - [hostOptions, workspaceHostOrder] - ) - const [hostDragActive, setHostDragActive] = useState(false) - const handleReorderHostSections = useCallback( - (orderedVisibleHostIds: ExecutionHostId[]) => { - const visibleHostIds = new Set(orderedVisibleHostIds) - const hostOptionIds = orderedHostOptions.map((host) => host.id) - const knownHostIds = new Set(hostOptionIds) - const nextOrder: ExecutionHostId[] = [...orderedVisibleHostIds] - const seen = new Set(nextOrder) - // Why: dragging only covers rendered hosts; keep non-rendered SSH/runtime hosts in the saved order so they return in place. - for (const hostId of [...workspaceHostOrder, ...hostOptionIds]) { - if (!knownHostIds.has(hostId) || visibleHostIds.has(hostId) || seen.has(hostId)) { - continue - } - nextOrder.push(hostId) - seen.add(hostId) - } - setWorkspaceHostOrder(nextOrder) - }, - [orderedHostOptions, setWorkspaceHostOrder, workspaceHostOrder] - ) - const sectionRows = useMemo( - () => - addHostSectionRows({ - rows, - hostOptions: orderedHostOptions, - workspaceHostScope, - visibleWorkspaceHostIds, - defaultHostId, - collapsedHostKeys: effectiveCollapsedGroups, - forceCollapseHosts: hostDragActive, - // Why: projects/workspaces are the primary sidebar object; host sections are only an explicit host-filter view. - preferProjectGrouping: true - }), - [ - defaultHostId, - effectiveCollapsedGroups, - hostDragActive, - orderedHostOptions, - rows, - visibleWorkspaceHostIds, - workspaceHostScope - ] - ) - const renderedSidebarRowKeys = useMemo(() => { - const keys = new Set() - for (const row of sectionRows) { - if (row.type === 'header') { - keys.add(row.key) - } else if (row.type === 'item') { - keys.add(row.rowKey) - } else if (row.type === 'folder-workspace') { - keys.add(folderWorkspaceKey(row.folderWorkspace.id)) - } else if (row.type === 'pending-creation') { - keys.add(`pending:${row.creationId}`) - } else if (row.type === 'imported-worktrees-card') { - keys.add(row.key) - } else if (row.type === 'new-external-worktrees-inbox') { - keys.add(row.key) - } - } - return keys - }, [sectionRows]) - // Why: status headers move during wake (inactive -> active); key only on grouping mode so row identity survives. - const visibleHostResetKey = visibleWorkspaceHostIds?.join(',') ?? 'all' - const viewportResetKey = `group:${groupBy}:host:${visibleHostResetKey}:lineage` - - // Why: derive order from the built rows, not the flat worktrees array, so Cmd+1–9 match visual positions when grouping reorders cards. - const renderedWorktrees = useMemo( - () => getRenderedWorktreesInSidebarOrder(sectionRows, pinnedDisplayPolicy), - [pinnedDisplayPolicy, sectionRows] - ) - // Why: order-preserving sectionRows rebuilds must not give this array a new - // identity — updateSelectionForGesture depends on it, and a fresh identity - // there defeats React.memo bail-out for every WorktreeCard on epoch bumps. - const renderedWorktreeIds = useReusedArrayIdentity( - useMemo( - () => uniqueWorktreeIds(renderedWorktrees.map((worktree) => worktree.id)), - [renderedWorktrees] - ) - ) - const [selectedWorktreeIds, setSelectedWorktreeIds] = useState>(new Set()) - const [selectionAnchorId, setSelectionAnchorId] = useState(null) - - const prunedSelection = pruneWorktreeSelection( - selectedWorktreeIds, - selectionAnchorId, - renderedWorktreeIds - ) - // Why: filters/grouping can hide selected cards; prune during render so nothing sees stale ids for unrendered worktrees. - if (!areWorktreeSelectionsEqual(selectedWorktreeIds, prunedSelection.selectedIds)) { - setSelectedWorktreeIds(prunedSelection.selectedIds) - } - if (selectionAnchorId !== prunedSelection.anchorId) { - setSelectionAnchorId(prunedSelection.anchorId) - } - - // Why identity reuse: the empty/unchanged-selection case must keep one array - // identity — selectForContextMenu and both drag-start handlers depend on - // this array, and card memo bail-out depends on those staying stable. - const selectedWorktrees = useReusedArrayIdentity( - useMemo(() => { - if (selectedWorktreeIds.size === 0) { - return [] - } - const selected = new Map() - for (const worktree of renderedWorktrees) { - if (selectedWorktreeIds.has(worktree.id) && !selected.has(worktree.id)) { - selected.set(worktree.id, worktree) - } - } - return Array.from(selected.values()) - }, [renderedWorktrees, selectedWorktreeIds]) - ) - - useEffect(() => { - if (selectedWorktreeIds.size === 0) { - return - } - - const clearSelectionOutsideSidebar = (event: PointerEvent): void => { - const target = event.target - const sidebarContainer = document.querySelector('[data-worktree-sidebar-container]') - if (target instanceof Node && sidebarContainer?.contains(target)) { - return - } - setSelectedWorktreeIds(new Set()) - setSelectionAnchorId(null) - } - - document.addEventListener('pointerdown', clearSelectionOutsideSidebar, { capture: true }) - return () => { - document.removeEventListener('pointerdown', clearSelectionOutsideSidebar, { capture: true }) - } - }, [selectedWorktreeIds.size]) - - const updateSelectionForGesture = useCallback( - (event: React.MouseEvent, worktreeId: string): boolean => { - const intent = getWorktreeSelectionIntent(event, navigator.userAgent.includes('Mac')) - const result = updateWorktreeSelection({ - visibleIds: renderedWorktreeIds, - previousSelectedIds: selectedWorktreeIds, - previousAnchorId: selectionAnchorId, - targetId: worktreeId, - intent - }) - setSelectedWorktreeIds(result.selectedIds) - setSelectionAnchorId(result.anchorId) - // Plain click navigates; modifier gestures are selection-only so a batch can build without switching away. - return intent !== 'replace' - }, - [renderedWorktreeIds, selectedWorktreeIds, selectionAnchorId] - ) - - const selectForContextMenu = useCallback( - (_event: React.MouseEvent, worktree: Worktree): readonly Worktree[] => { - if (selectedWorktreeIds.has(worktree.id) && selectedWorktreeIds.size > 1) { - return selectedWorktrees - } - setSelectedWorktreeIds(new Set([worktree.id])) - setSelectionAnchorId(worktree.id) - return [worktree] - }, - [selectedWorktreeIds, selectedWorktrees] - ) + effectiveCollapsedGroups, + projectGrouping, + visibleReposForRows: visibleScope.visibleReposForRows, + visibleProjectGroupsForRows: visibleScope.visibleProjectGroupsForRows, + visibleFolderWorkspacesForRows: visibleScope.visibleFolderWorkspacesForRows, + importedWorktreesByRepo: externalWorktreeCards.importedWorktreesByRepo, + newExternalWorktreesInboxByRepo: externalWorktreeCards.newExternalWorktreesInboxByRepo, + filterRepoIds: filterState.filterRepoIds, + visibleWorkspaceHostIds: filterState.visibleWorkspaceHostIds, + workspaceHostScope: filterState.workspaceHostScope + }) + const selection = useSidebarWorktreeSelection({ + sectionRows: rowModel.sectionRows, + pinnedDisplayPolicy + }) + const statusMutations = useWorktreeStatusMutations({ worktreeMap, workspaceStatuses, sortBy }) + const projectGroupDialogs = useProjectGroupDialogs({ repos, repoMap, projectGroups }) const handleImmediateWorktreeActivate = useCallback((worktreeId: string, rowKey?: string) => { // Why: re-rendering the virtualized sidebar on the pointer path adds visible latency; mutate the row directly and let store state reconcile after. markSidebarWorktreeActiveImmediately(worktreeId, rowKey) }, []) - // Why: full-page nav views aren't scoped to a worktree, so no sidebar card should look selected. - const selectedSidebarWorktreeId = - activeView === 'tasks' || activeView === 'activity' ? null : currentSidebarWorktreeId - - // Why layout effect: the Cmd/Ctrl+1–9 handler can fire right after commit; publishing after paint would leave the shortcut cache stale. - useLayoutEffect(() => { - setVisibleWorktreeIds(renderedWorktreeIds) - // Why null, not []: [] is a real rendered order (all collapsed/filtered); null tells shortcuts the list is unmounted. - return () => setVisibleWorktreeIds(null) - }, [renderedWorktreeIds]) - const handleCreateForRepo = useCallback( (projectId: string) => { openModal('new-workspace-composer', { initialRepoId: projectId, telemetrySource: 'sidebar' }) }, [openModal] ) - const handleOpenRepoSettings = useCallback( (projectId: string, sectionId?: string) => { openSettingsTarget({ pane: 'repo', repoId: projectId, ...(sectionId ? { sectionId } : {}) }) @@ -6036,141 +191,12 @@ const WorktreeList = React.memo(function WorktreeList({ }, [openSettingsPage, openSettingsTarget] ) - const handleOpenWorktreeVisibility = useCallback( (repo: Repo) => { - openModal('worktree-visibility', { - repoId: repo.id, - hostId: getRepoExecutionHostId(repo) - }) + openModal('worktree-visibility', { repoId: repo.id, hostId: getRepoExecutionHostId(repo) }) }, [openModal] ) - - const setImportedWorktreeCardState = useCallback( - (projectId: string, state: ImportedWorktreeCardActionState | null) => { - setImportedWorktreeCardActionState((previous) => { - const next = new Map(previous) - if (state) { - next.set(projectId, state) - } else { - next.delete(projectId) - } - return next - }) - }, - [] - ) - - const handleShowImportedWorktrees = useCallback( - async (projectId: string) => { - await showImportedWorktreesCard({ - projectId, - forceVisible: importedWorktreeCardActionState.get(projectId)?.forceVisible === true, - updateRepo, - fetchWorktrees, - setCardState: setImportedWorktreeCardState - }) - }, - [fetchWorktrees, importedWorktreeCardActionState, setImportedWorktreeCardState, updateRepo] - ) - - const handleKeepImportedWorktreesHidden = useCallback( - async (projectId: string) => { - const repo = repos.find((candidate) => candidate.id === projectId) - let detected = detectedWorktreesByRepo[projectId] - // Why: baseline seeding needs authoritative hidden paths, so don't dismiss on a stale snapshot. - if (detected?.authoritative !== true) { - const refreshed = await fetchWorktrees(projectId, { requireAuthoritative: true }) - if (!refreshed) { - setImportedWorktreeCardState(projectId, { - pending: false, - error: IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR - }) - return - } - detected = useAppStore.getState().detectedWorktreesByRepo[projectId] - } - if (detected?.authoritative !== true) { - setImportedWorktreeCardState(projectId, { - pending: false, - error: IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR - }) - return - } - const hiddenWorktrees = getHiddenImportedWorktrees(detected) - await keepImportedWorktreesHiddenCard({ - projectId, - updateRepo, - setCardState: setImportedWorktreeCardState, - hiddenWorktreePaths: hiddenWorktrees.map((worktree) => worktree.path), - existingBaselinePaths: repo?.externalWorktreeInboxBaselinePaths - }) - }, - [detectedWorktreesByRepo, fetchWorktrees, repos, setImportedWorktreeCardState, updateRepo] - ) - - const setNewExternalWorktreeInboxState = useCallback( - (projectId: string, state: NewExternalWorktreesInboxActionState | null) => { - setNewExternalWorktreeInboxActionState((previous) => { - const next = new Map(previous) - if (state) { - next.set(projectId, state) - } else { - next.delete(projectId) - } - return next - }) - }, - [] - ) - - const getNewExternalWorktreeInboxActionArgs = useCallback( - (projectId: string, worktreePaths: readonly string[]) => { - const repo = repos.find((candidate) => candidate.id === projectId) - if (!repo) { - return null - } - return { - projectId, - repo, - worktreePaths, - updateRepo, - fetchWorktrees, - setInboxState: setNewExternalWorktreeInboxState - } - }, - [fetchWorktrees, repos, setNewExternalWorktreeInboxState, updateRepo] - ) - - const handleOpenSuppressExternalWorktreeInbox = useCallback((projectId: string) => { - setSuppressExternalWorktreeInboxRepoId(projectId) - }, []) - - const handleConfirmSuppressExternalWorktreeInbox = useCallback(async () => { - if (!suppressExternalWorktreeInboxRepoId) { - return - } - const projectId = suppressExternalWorktreeInboxRepoId - const inboxWorktrees = newExternalWorktreesInboxByRepo.get(projectId)?.inboxWorktrees ?? [] - const args = getNewExternalWorktreeInboxActionArgs( - projectId, - inboxWorktrees.map((worktree) => worktree.path) - ) - if (!args) { - setSuppressExternalWorktreeInboxRepoId(null) - return - } - const suppressed = await suppressNewExternalWorktreeInbox(args) - if (suppressed) { - setSuppressExternalWorktreeInboxRepoId(null) - } - }, [ - getNewExternalWorktreeInboxActionArgs, - newExternalWorktreesInboxByRepo, - suppressExternalWorktreeInboxRepoId - ]) - const handleRemoveProject = useCallback( (repo: Repo) => { openModal('confirm-remove-folder', { @@ -6181,139 +207,6 @@ const WorktreeList = React.memo(function WorktreeList({ }, [openModal] ) - - const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup) - const createProjectGroup = useAppStore((s) => s.createProjectGroup) - const updateProjectGroup = useAppStore((s) => s.updateProjectGroup) - const deleteProjectGroupWithContainedProjects = useAppStore( - (s) => s.deleteProjectGroupWithContainedProjects - ) - const [projectGroupNameDialog, setProjectGroupNameDialog] = - useState(null) - const [projectGroupDeleteDialog, setProjectGroupDeleteDialog] = - useState(null) - - const handleCreateGroupFromRepo = useCallback((repo: Repo) => { - setProjectGroupNameDialog({ type: 'create-from-repo', repo }) - }, []) - - const handleMoveProjectToGroup = useCallback( - (repo: Repo, groupId: string) => { - if (repo.projectGroupId === groupId) { - return - } - void moveProjectToGroup(repo.id, groupId) - }, - [moveProjectToGroup] - ) - - const handleRemoveProjectFromGroup = useCallback( - (repo: Repo) => { - void moveProjectToGroup(repo.id, null) - }, - [moveProjectToGroup] - ) - - const handleRenameProjectGroup = useCallback((groupId: string, currentName: string) => { - setProjectGroupNameDialog({ type: 'rename', groupId, currentName }) - }, []) - - const handleSubmitProjectGroupName = useCallback( - async (name: string) => { - if (!projectGroupNameDialog) { - return - } - if (projectGroupNameDialog.type === 'create-from-repo') { - const group = await createProjectGroup(name) - if (group) { - await moveProjectToGroup(projectGroupNameDialog.repo.id, group.id) - } - return - } - await updateProjectGroup(projectGroupNameDialog.groupId, { name }) - }, - [createProjectGroup, moveProjectToGroup, projectGroupNameDialog, updateProjectGroup] - ) - - const projectGroupDeleteTargets = useMemo(() => { - if (!projectGroupDeleteDialog) { - return null - } - return selectProjectGroupRemovalTargets(projectGroups, repos, projectGroupDeleteDialog.groupId) - }, [projectGroupDeleteDialog, projectGroups, repos]) - const projectGroupDeleteProjectCount = projectGroupDeleteTargets?.projectIds.length ?? 0 - const projectGroupDeleteProjectNames = useMemo( - () => - (projectGroupDeleteTargets?.projectIds ?? []).map( - (projectId) => repoMap.get(projectId)?.displayName ?? projectId - ), - [projectGroupDeleteTargets, repoMap] - ) - const projectGroupRemoveContainedProjects = - projectGroupDeleteProjectCount > 0 && projectGroupDeleteDialog?.removeContainedProjects === true - - const handleDeleteProjectGroup = useCallback((groupId: string, groupName: string) => { - setProjectGroupDeleteDialog({ groupId, groupName, removeContainedProjects: false }) - }, []) - - const handleConfirmDeleteProjectGroup = useCallback(async () => { - if (!projectGroupDeleteDialog) { - return - } - try { - const result = await deleteProjectGroupWithContainedProjects( - projectGroupDeleteDialog.groupId, - { - removeContainedProjects: projectGroupRemoveContainedProjects - } - ) - // Why: a missing group is already the desired end state, so only a real delete failure warrants a toast. - if (result.status === 'group-delete-failed') { - toast.error( - translate( - 'auto.components.sidebar.WorktreeList.groupDeleteFailed', - 'Failed to delete group' - ), - { - description: translate( - 'auto.components.sidebar.WorktreeList.groupDeleteFailedDesc', - 'Something went wrong while deleting the group. No projects were removed.' - ) - } - ) - return - } - if (result.status === 'deleted-group' && result.failedProjectRemovals.length > 0) { - const failedCount = result.failedProjectRemovals.length - const requestedCount = result.requestedProjectIds.length - toast.error( - translate( - 'auto.components.sidebar.WorktreeList.b667b59632', - 'Some projects could not be removed from Orca' - ), - { - description: translate( - 'auto.components.sidebar.WorktreeList.f94466bc39', - '{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.', - { - value0: failedCount, - value1: requestedCount, - value2: requestedCount === 1 ? '' : 's' - } - ) - } - ) - } - } finally { - // Why: deleting contained projects can unmount this dialog before its close handler runs, so the parent owns cleanup. - setProjectGroupDeleteDialog(null) - } - }, [ - deleteProjectGroupWithContainedProjects, - projectGroupRemoveContainedProjects, - projectGroupDeleteDialog - ]) - const handleCreateFolderWorkspace = useCallback( (projectGroup: ProjectGroup) => { if (!projectGroup.parentPath) { @@ -6327,472 +220,55 @@ const WorktreeList = React.memo(function WorktreeList({ [openModal] ) - const moveWorktreeToStatus = useCallback( - (worktreeId: string, status: WorkspaceStatus) => { - const current = worktreeMap.get(worktreeId) - if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) { - return - } - void updateWorktreeMeta(worktreeId, { workspaceStatus: status }) - }, - [updateWorktreeMeta, worktreeMap, workspaceStatuses] - ) - - const moveWorktreesToStatus = useCallback( - (worktreeIds: readonly string[], status: WorkspaceStatus) => { - const updates = new Map() - for (const worktreeId of worktreeIds) { - const current = worktreeMap.get(worktreeId) - if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) { - continue - } - updates.set(worktreeId, { workspaceStatus: status }) - } - if (updates.size > 0) { - void updateWorktreesMeta(updates) - } - }, - [updateWorktreesMeta, worktreeMap, workspaceStatuses] - ) - - const moveWorktreesToStatusAtIndex = useCallback( - (args: { - worktreeIds: readonly string[] - status: WorkspaceStatus - dropIndex: number - groups: readonly WorktreeDragGroup[] - }) => { - const targetGroupKey = getWorkspaceStatusGroupKey(args.status) - const rankByWorktreeId = new Map() - for (const group of args.groups) { - for (const worktreeId of group.worktreeIds) { - const worktree = worktreeMap.get(worktreeId) - if (worktree) { - rankByWorktreeId.set(worktreeId, worktree.manualOrder ?? worktree.sortOrder) - } - } - } - const order = buildManualOrderUpdatesForGroupDrop({ - groups: args.groups, - targetGroupKey, - draggedIds: args.worktreeIds, - dropIndex: args.dropIndex, - now: Date.now(), - rankByWorktreeId - }) - const updates = new Map>() - for (const worktreeId of args.worktreeIds) { - const current = worktreeMap.get(worktreeId) - if (!current) { - continue - } - const next: Partial = {} - if (getWorkspaceStatus(current, workspaceStatuses) !== args.status) { - next.workspaceStatus = args.status - } - updates.set(worktreeId, next) - } - for (const [worktreeId, manualOrder] of order.updates) { - updates.set(worktreeId, { ...updates.get(worktreeId), ...manualOrder }) - } - for (const [worktreeId, update] of Array.from(updates)) { - if (Object.keys(update).length === 0) { - updates.delete(worktreeId) - } - } - if (updates.size === 0) { - return - } - // Why: the insertion line promises exact placement, so persist manual order on a cross-status drop. - if (order.changed) { - setSortBy('manual') - } - void updateWorktreesMeta(updates) - }, - [setSortBy, updateWorktreesMeta, worktreeMap, workspaceStatuses] - ) - - const pinWorktree = useCallback( - (worktreeId: string) => { - setWorktreesPinnedAndReveal([worktreeId], true) - }, - [setWorktreesPinnedAndReveal] - ) - - const pinWorktrees = useCallback( - (worktreeIds: readonly string[]) => { - setWorktreesPinnedAndReveal(worktreeIds, true) - }, - [setWorktreesPinnedAndReveal] - ) - - const reorderWorktrees = useCallback( - (args: { - groups: readonly WorktreeDragGroup[] - sourceGroupKey: string - draggedIds: readonly string[] - dropIndex: number - }) => { - const rankByWorktreeId = new Map() - for (const group of args.groups) { - for (const worktreeId of group.worktreeIds) { - const worktree = worktreeMap.get(worktreeId) - if (worktree) { - rankByWorktreeId.set(worktreeId, worktree.manualOrder ?? worktree.sortOrder) - } - } - } - const result = buildManualOrderUpdatesForVisibleGroups({ - ...args, - now: Date.now(), - rankByWorktreeId - }) - if (!result.changed) { - return - } - // Why: only switch to Manual after a real move so accidental click-drags don't change the sort. - setSortBy('manual') - void updateWorktreesMeta(result.updates) - }, - [setSortBy, updateWorktreesMeta, worktreeMap] - ) - - const shouldShowWorkspaceBoardDropIndicator = useCallback( - (worktreeIds: readonly string[], status: WorkspaceStatus) => { - const sourceGroupKeys = worktreeIds.flatMap((worktreeId) => { - const worktree = worktreeMap.get(worktreeId) - return worktree ? [getWorkspaceStatus(worktree, workspaceStatuses)] : [] - }) - return shouldWriteManualOrderForGroupDrop({ - sortBy, - sourceGroupKeys, - targetGroupKey: status - }) - }, - [sortBy, worktreeMap, workspaceStatuses] - ) - - const dropWorktreesOnWorkspaceBoard = useCallback( - (args: { - worktreeIds: readonly string[] - status: WorkspaceStatus - dropIndex: number - groups: readonly WorktreeDragGroup[] - }) => { - const result = buildWorkspaceKanbanSidebarDropUpdates({ - ...args, - worktreeById: worktreeMap, - workspaceStatuses, - sortBy, - now: Date.now() - }) - if (result.updates.size === 0) { - return - } - // Why: switch to Manual when the drop changes order so the placement stays visible. - if (result.shouldSwitchToManual) { - setSortBy('manual') - } - useAppStore.getState().recordFeatureInteraction('workspace-board-actions') - void updateWorktreesMeta(result.updates) - }, - [setSortBy, sortBy, updateWorktreesMeta, worktreeMap, workspaceStatuses] - ) - - // Why: count hideDefaultBranchWorkspace as a filter so the Clear Filters escape hatch stays reachable when it alone empties the list. - const filterState = useMemo( - () => ({ - showSleepingWorkspaces, - filterRepoIds, - hideDefaultBranchWorkspace, - hideAutomationGeneratedWorkspaces, - hideCliCreatedWorkspaces, - hideDetachedHeadWorkspaces, - hideWorkspacesFromOtherDevices, - alwaysShowDefaultBranchWorkspace, - visibleWorkspaceHostIds, - workspaceHostScope - }), - [ - showSleepingWorkspaces, - filterRepoIds, - hideDefaultBranchWorkspace, - hideAutomationGeneratedWorkspaces, - hideCliCreatedWorkspaces, - hideDetachedHeadWorkspaces, - hideWorkspacesFromOtherDevices, - alwaysShowDefaultBranchWorkspace, - visibleWorkspaceHostIds, - workspaceHostScope - ] - ) - const hasFilters = sidebarHasActiveFilters(filterState) - const setShowSleepingWorkspaces = useAppStore((s) => s.setShowSleepingWorkspaces) - const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) - const setHideAutomationGeneratedWorkspaces = useAppStore( - (s) => s.setHideAutomationGeneratedWorkspaces - ) - const setHideCliCreatedWorkspaces = useAppStore((s) => s.setHideCliCreatedWorkspaces) - const setHideDetachedHeadWorkspaces = useAppStore((s) => s.setHideDetachedHeadWorkspaces) - const setHideWorkspacesFromOtherDevices = useAppStore((s) => s.setHideWorkspacesFromOtherDevices) - const setAlwaysShowDefaultBranchWorkspace = useAppStore( - (s) => s.setAlwaysShowDefaultBranchWorkspace - ) - const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds) - const setVisibleWorkspaceHostIds = useAppStore((s) => s.setVisibleWorkspaceHostIds) - - const clearFilters = useCallback(() => { - const actions = computeClearFilterActions(filterState) - if (actions.resetShowSleepingWorkspaces) { - setShowSleepingWorkspaces(DEFAULT_SHOW_SLEEPING_WORKSPACES) - } - if (actions.resetFilterRepoIds) { - setFilterRepoIds([]) - } - if (actions.resetHideDefaultBranchWorkspace) { - setHideDefaultBranchWorkspace(false) - } - if (actions.resetHideAutomationGeneratedWorkspaces) { - setHideAutomationGeneratedWorkspaces(false) - } - if (actions.resetHideCliCreatedWorkspaces) { - setHideCliCreatedWorkspaces(false) - } - if (actions.resetHideDetachedHeadWorkspaces) { - setHideDetachedHeadWorkspaces(false) - } - if (actions.resetHideWorkspacesFromOtherDevices) { - setHideWorkspacesFromOtherDevices(false) - } - if (actions.resetAlwaysShowDefaultBranchWorkspace) { - setAlwaysShowDefaultBranchWorkspace(true) - } - if (actions.resetVisibleWorkspaceHostIds) { - setVisibleWorkspaceHostIds(null) - } - }, [ - setShowSleepingWorkspaces, - setFilterRepoIds, - setHideDefaultBranchWorkspace, - setHideAutomationGeneratedWorkspaces, - setHideCliCreatedWorkspaces, - setHideDetachedHeadWorkspaces, - setHideWorkspacesFromOtherDevices, - setAlwaysShowDefaultBranchWorkspace, - setVisibleWorkspaceHostIds, - filterState - ]) - - useEffect(() => { - if (!pendingRevealSidebarRow) { - return - } - const rowKey = pendingRevealSidebarRow.rowKey - const isProjectHeaderTarget = - rowKey.startsWith('project-group:') || - rowKey.startsWith('project:') || - rowKey.startsWith('repo:') - if (isProjectHeaderTarget && groupBy !== 'repo') { - setGroupBy('repo') - return - } - if (!renderedSidebarRowKeys.has(rowKey) && hasFilters) { - clearFilters() - } - }, [ - clearFilters, + useSidebarRevealRequests({ groupBy, + renderedSidebarRowKeys: rowModel.renderedSidebarRowKeys, + renderedWorktreeIds: selection.renderedWorktreeIds, + currentSidebarWorktreeId, + worktreeMap, + folderWorkspaces, hasFilters, - pendingRevealSidebarRow, - renderedSidebarRowKeys, - setGroupBy - ]) - - const handleRevealCurrentWorkspaceRequest = useCallback( - (event: Event) => { - const detail = - event instanceof CustomEvent - ? (event.detail as ScrollToCurrentWorkspaceRevealRequestDetail | undefined) - : undefined - if (detail?.target?.type === 'sidebar-row') { - const sidebarDetail = detail as Extract< - ScrollToCurrentWorkspaceRevealRequestDetail, - { target: { type: 'sidebar-row' } } - > - revealSidebarRow(detail.target.rowKey, { - behavior: 'smooth', - highlight: sidebarDetail.highlight !== false - }) - return - } - if (!currentSidebarWorktreeId) { - return - } - const activeWorktree = getKnownSidebarWorktreeById( - currentSidebarWorktreeId, - worktreeMap, - folderWorkspaces - ) - if (!activeWorktree || activeWorktree.isArchived) { - return - } - if (!renderedWorktreeIds.includes(currentSidebarWorktreeId)) { - // Why: the reveal action must show the current workspace, so relax filters that hide it first. - clearFilters() - } - revealWorktreeInSidebar(currentSidebarWorktreeId, { - behavior: 'smooth', - highlight: true, - beginRename: (detail as { beginRename?: boolean } | undefined)?.beginRename === true - }) - }, - [ - clearFilters, - currentSidebarWorktreeId, - folderWorkspaces, - revealSidebarRow, - renderedWorktreeIds, - revealWorktreeInSidebar, - worktreeMap - ] - ) - - useEffect(() => { - window.addEventListener( - SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT, - handleRevealCurrentWorkspaceRequest - ) - return () => { - window.removeEventListener( - SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT, - handleRevealCurrentWorkspaceRequest - ) - } - }, [handleRevealCurrentWorkspaceRequest]) + clearFilters + }) const filtersHideAllRows = hasFilters && - worktrees.length === 0 && - placeholderRepoIds.size === 0 && - importedWorktreesByRepo.size === 0 + visibleWorktrees.length === 0 && + rowModel.placeholderRepoIds.size === 0 && + externalWorktreeCards.importedWorktreesByRepo.size === 0 // Why: when active filters hide every row, the Clear Filters empty state must win over Project Group headers. - if (rows.length === 0 || filtersHideAllRows) { - return ( -
-
-
- - {translate('auto.components.sidebar.WorktreeList.b7acbf038b', 'No workspaces found')} - - {hasFilters && ( - - )} -
-
-
- ) + if (rowModel.rows.length === 0 || filtersHideAllRows) { + return } return ( <> - { - if (!open) { - setProjectGroupNameDialog(null) - } + onConfirmSuppressExternalWorktreeInbox={() => { + void externalWorktreeCards.handleConfirmSuppressExternalWorktreeInbox() }} - onSubmit={handleSubmitProjectGroupName} - /> - repo.id === suppressExternalWorktreeInboxRepoId)?.displayName ?? - '') - : '' - } - pending={ - suppressExternalWorktreeInboxRepoId - ? (newExternalWorktreeInboxActionState.get(suppressExternalWorktreeInboxRepoId) - ?.pending ?? false) - : false - } - onOpenChange={(open) => { - if (!open) { - setSuppressExternalWorktreeInboxRepoId(null) - } - }} - onConfirm={() => { - void handleConfirmSuppressExternalWorktreeInbox() - }} - onOpenRecovery={() => { - if (!suppressExternalWorktreeInboxRepoId) { - return - } - const repo = findRepoForHost(repos, suppressExternalWorktreeInboxRepoId, { settings }) - setSuppressExternalWorktreeInboxRepoId(null) - if (repo) { - handleOpenWorktreeVisibility(repo) - } - }} - /> - { - setProjectGroupDeleteDialog((current) => - current ? { ...current, removeContainedProjects } : current - ) - }} - onOpenChange={(open) => { - if (!open) { - setProjectGroupDeleteDialog(null) - } - }} - onConfirm={handleConfirmDeleteProjectGroup} + onOpenWorktreeVisibility={handleOpenWorktreeVisibility} /> active); key only on grouping mode so row identity survives. + key={`group:${groupBy}:host:${filterState.visibleWorkspaceHostIds?.join(',') ?? 'all'}:lineage`} + rows={rowModel.sectionRows} + // Why: full-page nav views aren't scoped to a worktree, so no sidebar card should look selected. + activeWorktreeId={ + activeView === 'tasks' || activeView === 'activity' ? null : currentSidebarWorktreeId + } currentWorktreeId={currentSidebarWorktreeId} groupBy={groupBy} pinnedDisplayPolicy={pinnedDisplayPolicy} @@ -6802,17 +278,21 @@ const WorktreeList = React.memo(function WorktreeList({ handleCreateForRepo={handleCreateForRepo} handleOpenRepoSettings={handleOpenRepoSettings} handleOpenWorktreeVisibility={handleOpenWorktreeVisibility} - handleShowImportedWorktrees={handleShowImportedWorktrees} - handleKeepImportedWorktreesHidden={handleKeepImportedWorktreesHidden} - importedWorktreeCardActionState={importedWorktreeCardActionState} - handleOpenSuppressExternalWorktreeInbox={handleOpenSuppressExternalWorktreeInbox} - newExternalWorktreeInboxActionState={newExternalWorktreeInboxActionState} + handleShowImportedWorktrees={externalWorktreeCards.handleShowImportedWorktrees} + handleKeepImportedWorktreesHidden={externalWorktreeCards.handleKeepImportedWorktreesHidden} + importedWorktreeCardActionState={externalWorktreeCards.importedWorktreeCardActionState} + handleOpenSuppressExternalWorktreeInbox={ + externalWorktreeCards.handleOpenSuppressExternalWorktreeInbox + } + newExternalWorktreeInboxActionState={ + externalWorktreeCards.newExternalWorktreeInboxActionState + } handleRemoveProject={handleRemoveProject} - handleCreateGroupFromRepo={handleCreateGroupFromRepo} - handleMoveProjectToGroup={handleMoveProjectToGroup} - handleRemoveProjectFromGroup={handleRemoveProjectFromGroup} - handleRenameProjectGroup={handleRenameProjectGroup} - handleDeleteProjectGroup={handleDeleteProjectGroup} + handleCreateGroupFromRepo={projectGroupDialogs.handleCreateGroupFromRepo} + handleMoveProjectToGroup={projectGroupDialogs.handleMoveProjectToGroup} + handleRemoveProjectFromGroup={projectGroupDialogs.handleRemoveProjectFromGroup} + handleRenameProjectGroup={projectGroupDialogs.handleRenameProjectGroup} + handleDeleteProjectGroup={projectGroupDialogs.handleDeleteProjectGroup} handleCreateFolderWorkspace={handleCreateFolderWorkspace} activeModal={activeModal} pendingRevealWorktree={pendingRevealWorktree} @@ -6820,38 +300,40 @@ const WorktreeList = React.memo(function WorktreeList({ clearPendingRevealWorktreeId={clearPendingRevealWorktreeId} clearPendingRevealSidebarRow={clearPendingRevealSidebarRow} agentSendTargetWorktreeId={agentSendTargetWorktreeId} - worktrees={worktrees} + worktrees={visibleWorktrees} folderWorkspaces={folderWorkspaces} - selectedWorktreeIds={selectedWorktreeIds} - selectedWorktrees={selectedWorktrees} - onSelectionGesture={updateSelectionForGesture} + selectedWorktreeIds={selection.selectedWorktreeIds} + selectedWorktrees={selection.selectedWorktrees} + onSelectionGesture={selection.updateSelectionForGesture} onImmediateWorktreeActivate={handleImmediateWorktreeActivate} - onContextMenuSelect={selectForContextMenu} + onContextMenuSelect={selection.selectForContextMenu} repoMap={repoMap} defaultHostId={defaultHostId} worktreeMap={worktreeMap} worktreeLineageById={worktreeLineageById} workspaceLineageByChildKey={workspaceLineageByChildKey} - allRepoIds={allRepoIds} - onReorderHostSections={handleReorderHostSections} - onHostDragActiveChange={setHostDragActive} + allRepoIds={rowModel.allRepoIds} + onReorderHostSections={rowModel.handleReorderHostSections} + onHostDragActiveChange={rowModel.setHostDragActive} prCache={prCache} hostedReviewCache={hostedReviewCache} workspaceStatuses={workspaceStatuses} projectGrouping={projectGrouping} projectGroups={projectGroups} - onMoveWorktreeToStatus={moveWorktreeToStatus} - onMoveWorktreesToStatus={moveWorktreesToStatus} - onMoveWorktreesToStatusAtIndex={moveWorktreesToStatusAtIndex} - onPinWorktree={pinWorktree} - onPinWorktrees={pinWorktrees} - onDropWorktreesOnWorkspaceBoard={dropWorktreesOnWorkspaceBoard} + onMoveWorktreeToStatus={statusMutations.moveWorktreeToStatus} + onMoveWorktreesToStatus={statusMutations.moveWorktreesToStatus} + onMoveWorktreesToStatusAtIndex={statusMutations.moveWorktreesToStatusAtIndex} + onPinWorktree={statusMutations.pinWorktree} + onPinWorktrees={statusMutations.pinWorktrees} + onDropWorktreesOnWorkspaceBoard={statusMutations.dropWorktreesOnWorkspaceBoard} workspaceBoardOpen={workspaceBoardOpen} onWorkspaceBoardDragPreviewStart={onWorkspaceBoardDragPreviewStart} onWorkspaceBoardDragPreviewCommit={onWorkspaceBoardDragPreviewCommit} onWorkspaceBoardDragPreviewCancel={onWorkspaceBoardDragPreviewCancel} - shouldShowWorkspaceBoardDropIndicator={shouldShowWorkspaceBoardDropIndicator} - onReorderWorktrees={reorderWorktrees} + shouldShowWorkspaceBoardDropIndicator={ + statusMutations.shouldShowWorkspaceBoardDropIndicator + } + onReorderWorktrees={statusMutations.reorderWorktrees} scrollOffsetRef={scrollOffsetRef} scrollAnchorRef={scrollAnchorRef} /> 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 5f7d294ec61..58da2de4511 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 @@ -3,7 +3,17 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' function readWorktreeListSource(): string { - return readFileSync(fileURLToPath(new URL('./WorktreeList.tsx', import.meta.url)), 'utf8') + return readFileSync( + fileURLToPath(new URL('./worktree-list/worktree-section-header-row.tsx', import.meta.url)), + 'utf8' + ) +} + +function readHeaderDragSource(): string { + return readFileSync( + fileURLToPath(new URL('./worktree-list/use-worktree-sidebar-header-drag.ts', import.meta.url)), + 'utf8' + ) } describe('Project Group header drag DOM source', () => { @@ -17,7 +27,7 @@ describe('Project Group header drag DOM source', () => { }) it('commits Project Group manual sorting through updateProjectGroup tabOrder', () => { - const source = readWorktreeListSource() + const source = readHeaderDragSource() expect(source).toContain('const updateProjectGroup = useAppStore((s) => s.updateProjectGroup)') expect(source).toContain('void updateProjectGroup(groupId, { tabOrder })') 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 2b8bc7d68cd..edc4f41f47e 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,9 @@ describe('getCyclableWorktreeIds', () => { describe('WorktreeList keyboard cycling', () => { it('cycles over the rendered rows instead of rebuilding a parallel layout', () => { const source = readFileSync( - fileURLToPath(new URL('./WorktreeList.tsx', import.meta.url)), + fileURLToPath( + new URL('./worktree-list/use-worktree-list-keyboard-navigation.ts', import.meta.url) + ), 'utf8' ) const navigateWorktree = source.slice( diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index dacfcb3a139..7e6fe4b3b58 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -63,6 +63,13 @@ function readWorktreeListSource(): string { return readFileSync(fileURLToPath(new URL('./WorktreeList.tsx', import.meta.url)), 'utf8') } +function readSectionHeaderRowSource(): string { + return readFileSync( + fileURLToPath(new URL('./worktree-list/worktree-section-header-row.tsx', import.meta.url)), + 'utf8' + ) +} + const remoteRepo: Repo = { id: 'repo-remote', path: '/home/alice/orca', @@ -3985,7 +3992,7 @@ describe('buildRows workspace lineage nesting', () => { describe('WorktreeList header styles', () => { it('does not title-case workspace group labels', () => { - const source = readWorktreeListSource() + const source = readSectionHeaderRowSource() expect(source).not.toContain('leading-none capitalize') }) @@ -4005,7 +4012,7 @@ describe('WorktreeList header styles', () => { }) it('resolves repo header color from project group headers only', () => { - const source = readWorktreeListSource() + const source = readSectionHeaderRowSource() expect(source).toContain('resolveProjectGroupHeaderColor({') expect(source).toContain('headerKey: row.key') diff --git a/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts index 9cda62a93b4..552515ff936 100644 --- a/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts @@ -1,11 +1,8 @@ import { describe, expect, it } from 'vitest' -import { - canKeepImportedWorktreesHidden, - getRenderRowKey, - getWorktreeDragGroups, - getWorktreeDragIndexes, - renderRowContainsWorktree -} from './WorktreeList' +import { canKeepImportedWorktreesHidden } from './worktree-list/imported-worktrees-card-visibility' +import { getRenderRowKey } from './worktree-list-virtual-rows' +import { getWorktreeDragGroups, getWorktreeDragIndexes } from './worktree-list/worktree-drag-groups' +import { renderRowContainsWorktree } from './worktree-list/render-row-worktree-lookup' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import type { Row } from './worktree-list-groups' diff --git a/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts b/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts index f5b9d586f63..dd6f7c0689a 100644 --- a/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-scroll-adjustment.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it, vi } from 'vitest' import { countRecordKeysByReference, - getScrollTopToRevealBounds, resolvePendingSidebarReveal, - WORKTREE_SIDEBAR_REVEAL_TOP_INSET, shouldAdjustWorktreeSidebarMeasuredRowScroll -} from './WorktreeList' +} from './worktree-list/worktree-sidebar-scroll-tuning' +import { + getScrollTopToRevealBounds, + WORKTREE_SIDEBAR_REVEAL_TOP_INSET +} from './worktree-sidebar-reveal' import { extractWorktreeVirtualRowIndexes, estimateRenderRowSize, diff --git a/src/renderer/src/components/sidebar/worktree-list-visible-refresh.test.ts b/src/renderer/src/components/sidebar/worktree-list-visible-refresh.test.ts index 1e519ac6d36..b99c0957d52 100644 --- a/src/renderer/src/components/sidebar/worktree-list-visible-refresh.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-visible-refresh.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { installWorktreeVisibleRefreshVisibilityListener } from './WorktreeList' +import { installWorktreeVisibleRefreshVisibilityListener } from './worktree-list/worktree-visible-refresh-listener' describe('installWorktreeVisibleRefreshVisibilityListener', () => { beforeEach(() => { diff --git a/src/renderer/src/components/sidebar/worktree-list/FolderPathStatusIndicator.tsx b/src/renderer/src/components/sidebar/worktree-list/FolderPathStatusIndicator.tsx new file mode 100644 index 00000000000..9b699c1086c --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/FolderPathStatusIndicator.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import { FolderX } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { + getFolderWorkspacePathStatusDescription, + getFolderWorkspacePathStatusTitle +} from '@/lib/folder-workspace-path-status' +import { + isConfirmedStaleFolderPathStatus, + type FolderWorkspacePathStatus +} from '../../../../../shared/folder-workspace-path-status' + +export function FolderPathStatusIndicator({ + status +}: { + status: FolderWorkspacePathStatus | null | undefined +}): React.JSX.Element | null { + const title = getFolderWorkspacePathStatusTitle(status) + if (!status || status.exists || !title) { + return null + } + const destructive = isConfirmedStaleFolderPathStatus(status) + return ( + + + + + + + +
+
{title}
+
+ {getFolderWorkspacePathStatusDescription(status)} +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/HostSectionHeader.tsx b/src/renderer/src/components/sidebar/worktree-list/HostSectionHeader.tsx new file mode 100644 index 00000000000..898719956f2 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/HostSectionHeader.tsx @@ -0,0 +1,161 @@ +import React from 'react' +import { AlertTriangle, ChevronDown, Loader2, Server, ServerOff } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import type { HostHeaderRow } from '../host-section-rows' +import { HostSectionHeaderMenu } from '../HostSectionHeaderMenu' + +function formatSectionActivityLabel(count: number, label: string): string { + return `${count} ${label}${count === 1 ? '' : 's'}` +} + +function SectionMetricsBadge({ count }: { count: number }): React.JSX.Element { + const totalLabel = formatSectionActivityLabel(count, 'workspace') + + return ( + + + + + {count} + + + + {totalLabel} + + + + ) +} + +function HostHeaderHealthIcon({ + health +}: { + health: HostHeaderRow['health'] +}): React.JSX.Element | null { + // Why: only surface states needing attention; healthy is the silent default. + if (health === 'connecting') { + return + } + if (health === 'blocked' || health === 'error') { + return + } + return null +} + +function getHostHeaderDetail(row: HostHeaderRow): { text: string; isWarning: boolean } | null { + // Why: a blocked compatibility verdict earns a compact warning so the host stands out. + if (row.health === 'blocked') { + return { + text: translate('auto.components.sidebar.WorktreeList.7a8b9c0d1e', 'Update required'), + isWarning: true + } + } + // Why: auth-failed needs a worded status; the health icon alone doesn't tell the user to re-auth. + if (row.connectionStatus === 'auth-failed') { + return { + text: translate( + 'auto.components.sidebar.WorktreeList.hostAuthNeeded', + 'Authentication needed' + ), + isWarning: true + } + } + if (row.health === 'disconnected') { + return { + text: translate('auto.components.sidebar.WorktreeList.hostDisconnected', 'Disconnected'), + isWarning: false + } + } + // Why: show the transport detail only for remote hosts; it's noise under the local label. + if (row.kind !== 'local') { + return { text: row.detail, isWarning: false } + } + return null +} + +export function HostSectionHeader({ + row, + onToggle, + onDragPointerDown, + dragging +}: { + row: HostHeaderRow + onToggle: () => void + onDragPointerDown?: (event: React.PointerEvent) => void + dragging?: boolean +}): React.JSX.Element { + const isBlocked = row.health === 'blocked' + const isDisconnected = row.health === 'disconnected' + const detail = getHostHeaderDetail(row) + return ( +
+ {/* Why: outlined card + server glyph marks hosts as machines, not mere groups. */} +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onToggle() + } + }} + > + {isDisconnected ? ( + + ) : ( + + )} + + {/* Why: badge hugs the label (like repo headers) instead of floating by the hover controls. */} +
+ + {row.label} + + {detail ? ( + + {detail.text} + + ) : null} + +
+
+ +
+ + + +
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/SidebarWorktreeListDialogs.tsx b/src/renderer/src/components/sidebar/worktree-list/SidebarWorktreeListDialogs.tsx new file mode 100644 index 00000000000..cd46a80c3c7 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/SidebarWorktreeListDialogs.tsx @@ -0,0 +1,118 @@ +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 { ProjectGroupDialogs } from './use-project-group-dialogs' + +export function SidebarWorktreeListDialogs({ + dialogs, + repos, + settings, + suppressExternalWorktreeInboxRepoId, + setSuppressExternalWorktreeInboxRepoId, + newExternalWorktreeInboxActionState, + onConfirmSuppressExternalWorktreeInbox, + onOpenWorktreeVisibility +}: { + dialogs: ProjectGroupDialogs + repos: readonly Repo[] + settings: AppState['settings'] + suppressExternalWorktreeInboxRepoId: string | null + setSuppressExternalWorktreeInboxRepoId: (repoId: string | null) => void + newExternalWorktreeInboxActionState: ReadonlyMap + onConfirmSuppressExternalWorktreeInbox: () => void + onOpenWorktreeVisibility: (repo: Repo) => void +}): React.JSX.Element { + const { nameDialog, setNameDialog, deleteDialog, setDeleteDialog } = dialogs + return ( + <> + { + if (!open) { + setNameDialog(null) + } + }} + onSubmit={dialogs.handleSubmitProjectGroupName} + /> + repo.id === suppressExternalWorktreeInboxRepoId)?.displayName ?? + '') + : '' + } + pending={ + suppressExternalWorktreeInboxRepoId + ? (newExternalWorktreeInboxActionState.get(suppressExternalWorktreeInboxRepoId) + ?.pending ?? false) + : false + } + onOpenChange={(open) => { + if (!open) { + setSuppressExternalWorktreeInboxRepoId(null) + } + }} + onConfirm={onConfirmSuppressExternalWorktreeInbox} + onOpenRecovery={() => { + if (!suppressExternalWorktreeInboxRepoId) { + return + } + const repo = findRepoForHost(repos, suppressExternalWorktreeInboxRepoId, { settings }) + setSuppressExternalWorktreeInboxRepoId(null) + if (repo) { + onOpenWorktreeVisibility(repo) + } + }} + /> + { + setDeleteDialog((current) => + current ? { ...current, removeContainedProjects } : current + ) + }} + onOpenChange={(open) => { + if (!open) { + setDeleteDialog(null) + } + }} + onConfirm={dialogs.handleConfirmDeleteProjectGroup} + /> + + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/SidebarWorktreeListEmptyState.tsx b/src/renderer/src/components/sidebar/worktree-list/SidebarWorktreeListEmptyState.tsx new file mode 100644 index 00000000000..05f98037c60 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/SidebarWorktreeListEmptyState.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import { CircleX } from 'lucide-react' +import { translate } from '@/i18n/i18n' + +export function SidebarWorktreeListEmptyState({ + hasFilters, + onClearFilters +}: { + hasFilters: boolean + onClearFilters: () => void +}): React.JSX.Element { + return ( +
+
+
+ + {translate('auto.components.sidebar.WorktreeList.b7acbf038b', 'No workspaces found')} + + {hasFilters && ( + + )} +
+
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/VirtualizedWorktreeViewport.tsx b/src/renderer/src/components/sidebar/worktree-list/VirtualizedWorktreeViewport.tsx new file mode 100644 index 00000000000..94455e16037 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/VirtualizedWorktreeViewport.tsx @@ -0,0 +1,411 @@ +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 '../use-worktree-list-scroll-to-top' +import { getActiveDescendantOptionId } from './active-descendant-option' +import { buildRenderableRows } from './renderable-rows' +import { useFolderWorkspacePathStatusRows } from './use-folder-workspace-path-status-rows' +import { useGroupToggleWithScrollAnchor } from './use-group-toggle-with-scroll-anchor' +import { usePendingSidebarReveal } from './use-pending-sidebar-reveal' +import { usePrimaryActiveWorktreeRow } from './use-primary-active-worktree-row' +import { useSidebarRevealHighlight } from './use-sidebar-reveal-highlight' +import { useVirtualRowMeasurementSync } from './use-virtual-row-measurement-sync' +import { useVisiblePrRefreshReporting } from './use-visible-pr-refresh-reporting' +import { useWorkspaceStatusRowDrag } from './use-workspace-status-row-drag' +import { useWorktreeDragRuntime } from './use-worktree-drag-runtime' +import { useWorktreeDragSession } from './use-worktree-drag-session' +import { useWorktreeDocumentDrop } from './use-worktree-document-drop' +import { useWorktreeLineageDropCommit } from './use-worktree-lineage-drop-commit' +import { useWorktreeListKeyboardNavigation } from './use-worktree-list-keyboard-navigation' +import { useWorktreeListVirtualizer } from './use-worktree-list-virtualizer' +import { useWorktreeNativeDrag } from './use-worktree-native-drag' +import { useWorktreePointerDrag } from './use-worktree-pointer-drag' +import { useWorktreeSidebarHeaderDrag } from './use-worktree-sidebar-header-drag' +import { useWorktreeSidebarScrollSuppression } from './use-worktree-sidebar-scroll-suppression' +import { + EMPTY_PROJECT_GROUPS, + type VirtualizedWorktreeViewportProps +} from './virtualized-worktree-viewport-props' +import { WORKTREE_SIDEBAR_SCROLL_STYLE } from './worktree-sidebar-scroll-tuning' +import type { WorktreeDropCommitContext } from './worktree-drop-commit-context' +import { buildWorktreeVirtualRowContext } from './worktree-virtual-row-context' +import { renderWorktreeVirtualRow } from './worktree-virtual-row-dispatch' + +export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewport( + props: VirtualizedWorktreeViewportProps +) { + const { + rows, + groupBy, + pinnedDisplayPolicy, + activeWorktreeId, + collapsedGroups, + toggleGroup, + repoMap, + worktreeMap, + worktreeLineageById, + workspaceStatuses, + projectGroups = EMPTY_PROJECT_GROUPS, + scrollOffsetRef, + scrollAnchorRef + } = props + const scrollRef = useRef(null) + // Why: callback-ref only mutates scrollRef; state re-runs the scroll-to-top listener attach. + const [scrollElement, setScrollElement] = useState(null) + const settings = useAppStore((s) => s.settings) + const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + const newCardStyle = settings?.experimentalNewWorktreeCardStyle === true + + const reveal = useSidebarRevealHighlight() + const scrollSuppression = useWorktreeSidebarScrollSuppression(scrollRef) + const { markDirectScrollInput, markScrollMovement } = scrollSuppression + + const renderRows = useMemo(() => buildRenderableRows(rows), [rows]) + const firstHeaderIndex = useMemo( + () => renderRows.findIndex((row) => row.type === 'header' || row.type === 'host-header'), + [renderRows] + ) + const folderBackedProjectGroupIds = useMemo( + () => + new Set( + projectGroups + .filter((group) => group.createdFrom === 'folder-scan') + .map((group) => group.id) + ), + [projectGroups] + ) + + const headerDrag = useWorktreeSidebarHeaderDrag({ + rows, + renderRows, + firstHeaderIndex, + allRepoIds: props.allRepoIds, + repoMap, + projectGroups, + groupBy, + projectOrderBy: props.projectOrderBy, + scrollRef, + onReorderHostSections: props.onReorderHostSections, + onHostDragActiveChange: props.onHostDragActiveChange, + suppressMeasurementAdjustmentUntilRef: scrollSuppression.suppressMeasurementAdjustmentUntilRef, + directScrollInputUntilRef: scrollSuppression.directScrollInputUntilRef + }) + + const session = useWorktreeDragSession({ rows, scrollRef }) + const lineageDrop = useWorktreeLineageDropCommit({ + repoMap, + worktreeMap, + worktreeLineageById, + worktreeDragGroups: session.worktreeDragGroups + }) + const runtime = useWorktreeDragRuntime({ + worktreeDragSessionRef: session.worktreeDragSessionRef, + statusDropAnchorsRef: session.statusDropAnchorsRef, + onWorkspaceBoardDragPreviewCancel: props.onWorkspaceBoardDragPreviewCancel + }) + + const primaryActive = usePrimaryActiveWorktreeRow({ + rows, + activeWorktreeId, + pinnedDisplayPolicy, + onImmediateWorktreeActivate: props.onImmediateWorktreeActivate + }) + + const getCachedFolderWorkspacePathStatus = useFolderWorkspacePathStatusRows({ + allRepoIds: props.allRepoIds, + repoMap, + projectGroups, + folderWorkspaces: props.folderWorkspaces, + sshConnectionStates + }) + + const { showScrollToTop, scrollToTop } = useWorktreeListScrollToTop({ + scrollElement, + onUserScrollIntent: markDirectScrollInput + }) + + const virtualization = useWorktreeListVirtualizer({ + renderRows, + firstHeaderIndex, + scrollRef, + scrollOffsetRef, + suppressMeasurementAdjustmentUntilRef: scrollSuppression.suppressMeasurementAdjustmentUntilRef + }) + + usePendingSidebarReveal({ + pendingRevealWorktree: props.pendingRevealWorktree, + pendingRevealSidebarRow: props.pendingRevealSidebarRow, + clearPendingRevealWorktreeId: props.clearPendingRevealWorktreeId, + clearPendingRevealSidebarRow: props.clearPendingRevealSidebarRow, + agentSendTargetWorktreeId: props.agentSendTargetWorktreeId, + renderRows, + virtualizer: virtualization.virtualizer, + scrollRef, + worktrees: props.worktrees, + folderWorkspaces: props.folderWorkspaces, + repoMap, + worktreeMap, + worktreeLineageById, + collapsedGroups, + toggleGroup, + groupBy, + pinnedDisplayPolicy, + defaultHostId: props.defaultHostId, + prCache: props.prCache, + workspaceStatuses, + settings, + projectGroups, + projectGrouping: props.projectGrouping, + flashRevealedRow: reveal.flashRevealedRow, + markRevealScroll: scrollSuppression.markRevealScroll, + schedulePendingRevealFrame: reveal.schedulePendingRevealFrame, + cancelPendingRevealFrames: reveal.cancelPendingRevealFrames + }) + + const { virtualItems, measureVirtualRowElement } = useVirtualRowMeasurementSync({ + renderRows, + virtualization, + scrollRef, + scrollOffsetRef, + scrollAnchorRef, + hasDirectScrollInput: scrollSuppression.hasDirectScrollInput, + shouldSkipScrollAnchorRestore: scrollSuppression.shouldSkipScrollAnchorRestore + }) + + const { toggleGroupWithScrollAnchor, getLineageToggleHandler } = useGroupToggleWithScrollAnchor({ + scrollRef, + toggleGroup + }) + + const { handleContainerKeyDown } = useWorktreeListKeyboardNavigation({ + rows, + renderRows, + activeWorktreeId, + pinnedDisplayPolicy, + virtualizer: virtualization.virtualizer, + scrollRef, + activeModal: props.activeModal, + 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 { handleWorktreeRowPointerDown, handleWorktreeRowClickCapture } = useWorktreePointerDrag({ + ctx: dropCtx, + session, + runtime, + scrollRef, + markScrollMovement, + selectedWorktreeIds: props.selectedWorktreeIds, + selectedWorktrees: props.selectedWorktrees, + workspaceBoardOpen: props.workspaceBoardOpen, + onWorkspaceBoardDragPreviewStart: props.onWorkspaceBoardDragPreviewStart, + onWorkspaceBoardDragPreviewCommit: props.onWorkspaceBoardDragPreviewCommit, + onDropWorktreesOnWorkspaceBoard: props.onDropWorktreesOnWorkspaceBoard, + shouldShowWorkspaceBoardDropIndicator: props.shouldShowWorkspaceBoardDropIndicator + }) + const nativeDrag = useWorktreeNativeDrag({ + ctx: dropCtx, + session, + runtime, + scrollRef, + markScrollMovement + }) + useWorktreeDocumentDrop({ + ctx: dropCtx, + worktreeDragSessionRef: session.worktreeDragSessionRef + }) + const statusDrag = useWorkspaceStatusRowDrag({ + ctx: dropCtx, + session, + runtime, + scrollRef, + rows, + groupBy, + onMoveWorktreeToStatus: props.onMoveWorktreeToStatus, + onPinWorktree: props.onPinWorktree + }) + + useVisiblePrRefreshReporting({ + currentWorktreeId: props.currentWorktreeId, + worktreeMap, + groupBy, + newCardStyle, + renderRows, + virtualItems, + scrollRef + }) + + // Why: a callback ref that changes identity is re-invoked with null on every render, which + // would run the teardown below mid-drag; depend only on the stable teardown callbacks. + const { cancelPendingRevealFrames, clearRevealHighlightFrame, clearRevealHighlightTimeout } = + reveal + const { clearWorktreeDrag } = runtime + const setScrollRootRef = useCallback( + (node: HTMLDivElement | null) => { + if (node === null && scrollRef.current !== null) { + // Why: drag previews, autoscroll frames, and reveal snapshots are tied to the scroll root; clear them before it unmounts. + cancelPendingRevealFrames() + clearRevealHighlightFrame() + clearRevealHighlightTimeout() + clearWorktreeDrag() + } + scrollRef.current = node + setScrollElement(node) + }, + [ + cancelPendingRevealFrames, + clearRevealHighlightFrame, + clearRevealHighlightTimeout, + clearWorktreeDrag + ] + ) + const handleScrollPointerDown = useCallback( + (event: React.PointerEvent) => { + const scrollbarWidth = event.currentTarget.offsetWidth - event.currentTarget.clientWidth + if (scrollbarWidth <= 0) { + return + } + const rect = event.currentTarget.getBoundingClientRect() + if (event.clientX >= rect.right - scrollbarWidth) { + markDirectScrollInput() + } + }, + [markDirectScrollInput] + ) + const handleScroll = useCallback(() => { + markScrollMovement() + }, [markScrollMovement]) + + const rowContext = buildWorktreeVirtualRowContext({ + props, + renderRows, + firstHeaderIndex, + virtualization, + measureVirtualRowElement, + settings, + sshConnectionStates, + newCardStyle, + folderBackedProjectGroupIds, + projectGroups, + session, + runtime, + primaryActive, + reveal, + statusDrag, + nativeDrag, + headerDrag, + getCachedFolderWorkspacePathStatus, + getLineageToggleHandler, + toggleGroupWithScrollAnchor, + onRowClickCapture: handleWorktreeRowClickCapture, + onRowPointerDown: handleWorktreeRowPointerDown + }) + + 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} + {runtime.worktreeDragState.draggingWorktreeId !== null && + runtime.worktreeDragState.dropIndicatorY !== null ? ( + + ) : null} + {virtualItems.map((vItem) => { + const row = renderRows[vItem.index] + return row ? renderWorktreeVirtualRow(rowContext, row, vItem) : null + })} +
+
+ {showScrollToTop ? : null} +
+ ) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/active-descendant-option.ts b/src/renderer/src/components/sidebar/worktree-list/active-descendant-option.ts new file mode 100644 index 00000000000..b52614cfe4c --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/active-descendant-option.ts @@ -0,0 +1,67 @@ +import { folderWorkspaceKey } from '../../../../../shared/workspace-scope' +import type { RenderRow } from '../worktree-list-virtual-rows' +import type { PinnedWorktreeDisplayPolicy } from '../worktree-list-groups' +import { isPinnedWorktreeRow } from './render-row-item-rows' +import { getRenderRowWorktreeItem, renderRowContainsWorktree } from './render-row-worktree-lookup' +import { getWorktreeOptionId } from './worktree-option-dom' + +export function getRenderRowOptionId( + row: RenderRow | undefined, + worktreeId?: string | null +): string | undefined { + if (!row) { + return undefined + } + if (row.type === 'lineage-group') { + const targetRow = worktreeId ? row.rows.find((item) => item.worktree.id === worktreeId) : null + return getWorktreeOptionId((targetRow ?? row.rows[0])?.rowKey ?? row.key) + } + if (row.type === 'item') { + return getWorktreeOptionId(row.rowKey) + } + if (row.type === 'folder-workspace') { + return getWorktreeOptionId(folderWorkspaceKey(row.folderWorkspace.id)) + } + return undefined +} + +export function getActiveDescendantOptionId(args: { + activeWorktreeId: string | null + primaryActiveRowKey?: string + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy + renderRows: readonly RenderRow[] + virtualItems: readonly { index: number }[] +}): string | undefined { + if (args.activeWorktreeId === null) { + return undefined + } + if (args.primaryActiveRowKey) { + const primaryOptionId = getWorktreeOptionId(args.primaryActiveRowKey) + for (const item of args.virtualItems) { + const row = args.renderRows[item.index] + if (row && getRenderRowOptionId(row, args.activeWorktreeId) === primaryOptionId) { + return primaryOptionId + } + } + } + let fallbackOptionId: string | undefined + for (const item of args.virtualItems) { + const row = args.renderRows[item.index] + if (row && renderRowContainsWorktree(row, args.activeWorktreeId)) { + const optionId = getRenderRowOptionId(row, args.activeWorktreeId) + if (!optionId) { + continue + } + const itemRow = getRenderRowWorktreeItem(row, args.activeWorktreeId) + if ( + args.pinnedDisplayPolicy === 'duplicate-in-groups' && + itemRow && + !isPinnedWorktreeRow(itemRow) + ) { + return optionId + } + fallbackOptionId ??= optionId + } + } + return fallbackOptionId +} diff --git a/src/renderer/src/components/sidebar/worktree-list/folder-workspace-virtual-row.tsx b/src/renderer/src/components/sidebar/worktree-list/folder-workspace-virtual-row.tsx new file mode 100644 index 00000000000..eb3dc2f3556 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/folder-workspace-virtual-row.tsx @@ -0,0 +1,129 @@ +import React from 'react' +import type { VirtualItem } from '@tanstack/react-virtual' +import type { AppState } from '@/store/types' +import type { Repo } from '../../../../../shared/repo-types' +import type { + WorkspaceLineage, + WorktreeLineage +} from '../../../../../shared/worktree/lineage-types' +import type { Worktree } from '../../../../../shared/worktree/types' +import type { FolderWorkspacePathStatus } from '../../../../../shared/folder-workspace-path-status' +import { isConfirmedStaleFolderPathStatus } from '../../../../../shared/folder-workspace-path-status' +import { folderWorkspaceToWorktree } from '../../../../../shared/folder-workspace-worktree' +import WorktreeCard from '../WorktreeCard' +import type { WorktreeGroupBy } from '../worktree-list-groups' +import { getVirtualRowTransform } from '../worktree-list-virtual-rows' +import { getFolderWorkspaceRowGeometry } from '../worktree-list-indentation' +import { getFolderWorkspaceCardPrDisplay } from '../folder-workspace-card-pr-display' +import { FolderPathStatusIndicator } from './FolderPathStatusIndicator' +import type { FolderWorkspaceItemRow } from './render-row-item-rows' +import { getWorktreeOptionId } from './worktree-option-dom' + +export type FolderWorkspaceRowContext = { + groupBy: WorktreeGroupBy + newCardStyle: boolean + settings: AppState['settings'] + activeWorktreeId: string | null + currentWorktreeId: string | null + selectedWorktreeIds: ReadonlySet + repoMap: Map + worktreeMap: Map + worktreeLineageById: Record + workspaceLineageByChildKey: Record + prCache: AppState['prCache'] | null + hostedReviewCache: AppState['hostedReviewCache'] | null + getCachedFolderWorkspacePathStatus: (request: { + scope: 'folder-workspace' + folderWorkspaceId: string + }) => FolderWorkspacePathStatus | null + onSelectionGesture: (event: React.MouseEvent, worktreeId: string) => boolean + onContextMenuSelect: ( + event: React.MouseEvent, + worktree: Worktree + ) => readonly Worktree[] + onImmediateActivate: (worktreeId: string, rowKey: string | undefined) => void + onRowClickCapture: (event: React.MouseEvent) => void + onRowPointerDown: ( + event: React.PointerEvent, + worktreeId: string, + rowKey: string + ) => void +} + +export function renderFolderWorkspaceVirtualRow(args: { + ctx: FolderWorkspaceRowContext + row: FolderWorkspaceItemRow + vItem: VirtualItem + measureVirtualRowElement: (element: HTMLDivElement | null) => void +}): React.JSX.Element { + const { ctx, row, vItem } = args + const folderWorktree = folderWorkspaceToWorktree(row.folderWorkspace) + const pathStatus = ctx.getCachedFolderWorkspacePathStatus({ + scope: 'folder-workspace', + folderWorkspaceId: row.folderWorkspace.id + }) + const activationDisabled = + pathStatus?.exists === false && + (isConfirmedStaleFolderPathStatus(pathStatus) || pathStatus.reason === 'ambiguous-connection') + const folderPrDisplay = getFolderWorkspaceCardPrDisplay({ + folderWorkspaceId: row.folderWorkspace.id, + workspaceLineageByChildKey: ctx.workspaceLineageByChildKey, + worktreeLineageById: ctx.worktreeLineageById, + worktreeMap: ctx.worktreeMap, + repoMap: ctx.repoMap, + hostedReviewCache: ctx.hostedReviewCache, + prCache: ctx.prCache, + settings: ctx.settings + }) + const { surfaceInset, cardContentIndent } = getFolderWorkspaceRowGeometry({ + experimentalNewWorktreeCardStyle: ctx.newCardStyle, + isFolderBackedWorkspaceChild: + ctx.groupBy === 'repo' && row.projectGroup.createdFrom === 'folder-scan', + isGrouped: ctx.groupBy !== 'none', + groupDepth: row.groupDepth, + lineageDepth: row.depth + }) + return ( +
ctx.onRowPointerDown(event, folderWorktree.id, folderWorktree.id)} + > +
0 ? { paddingLeft: surfaceInset } : undefined} + > + +
+ +
+
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/imported-worktrees-card-visibility.ts b/src/renderer/src/components/sidebar/worktree-list/imported-worktrees-card-visibility.ts new file mode 100644 index 00000000000..bfb7b38b039 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/imported-worktrees-card-visibility.ts @@ -0,0 +1,9 @@ +import type { Row } from '../worktree-list-groups' +import type { ImportedWorktreeCardActionState } from '../imported-worktrees-card-actions' + +export function canKeepImportedWorktreesHidden( + row: Extract, + actionState: ImportedWorktreeCardActionState | undefined +): boolean { + return row.placement === 'repo-group' && actionState?.forceVisible !== true +} diff --git a/src/renderer/src/components/sidebar/worktree-list/mounted-row-reveal.ts b/src/renderer/src/components/sidebar/worktree-list/mounted-row-reveal.ts new file mode 100644 index 00000000000..b3b1436211d --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/mounted-row-reveal.ts @@ -0,0 +1,35 @@ +import { revealElementInScrollContainer } from '../worktree-sidebar-reveal' +import { getMountedWorktreeOptions, getWorktreeOptionId } from './worktree-option-dom' + +export function revealMountedWorktreeElement( + container: HTMLElement, + worktreeId: string, + behavior: ScrollBehavior, + optionId?: string, + onScrollIssued?: (targetTop: number) => void +): HTMLElement | null { + const element = optionId + ? document.getElementById(optionId) + : getMountedWorktreeOptions(worktreeId, container)[0] + if (!element || !container.contains(element)) { + return null + } + return revealElementInScrollContainer(container, element, behavior, onScrollIssued) + ? element + : null +} + +export function revealMountedSidebarRowElement( + container: HTMLElement, + rowKey: string, + behavior: ScrollBehavior, + onScrollIssued?: (targetTop: number) => void +): HTMLElement | null { + const element = document.getElementById(getWorktreeOptionId(rowKey)) + if (!element || !container.contains(element)) { + return null + } + return revealElementInScrollContainer(container, element, behavior, onScrollIssued) + ? element + : null +} diff --git a/src/renderer/src/components/sidebar/worktree-list/pending-sidebar-reveal-inputs.ts b/src/renderer/src/components/sidebar/worktree-list/pending-sidebar-reveal-inputs.ts new file mode 100644 index 00000000000..c29b08bd17f --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/pending-sidebar-reveal-inputs.ts @@ -0,0 +1,116 @@ +import type React from 'react' +import type { Virtualizer } from '@tanstack/react-virtual' +import type { AppState } from '@/store/types' +import type { PendingSidebarRowReveal, PendingSidebarWorktreeReveal } from '@/store/slices/ui' +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 { WorkspaceStatusDefinition, Worktree } from '../../../../../shared/worktree/types' +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 '../worktree-list-virtual-rows' +import { + getGroupKeysForWorktree, + getLineageGroupKey, + type PinnedWorktreeDisplayPolicy, + type ProjectGroupingModel, + type WorktreeGroupBy +} from '../worktree-list-groups' +import { getWorktreeLineageAncestors } from '../worktree-lineage-projection' +import { getFolderWorkspaceRevealGroupKeys } from '../worktree-list-folder-reveal' +import { getPinnedWorktreeRevealCollapsedGroupKeys } from './sidebar-row-reveal-ancestors' + +export const MAX_REVEAL_RETRIES = 8 + +export type PendingSidebarRevealArgs = { + pendingRevealWorktree: PendingSidebarWorktreeReveal | null + pendingRevealSidebarRow: PendingSidebarRowReveal | null + clearPendingRevealWorktreeId: () => void + clearPendingRevealSidebarRow: () => void + agentSendTargetWorktreeId: string | null + renderRows: RenderRow[] + virtualizer: Virtualizer + scrollRef: React.RefObject + worktrees: Worktree[] + folderWorkspaces: readonly FolderWorkspace[] + repoMap: Map + worktreeMap: Map + worktreeLineageById: Record + collapsedGroups: Set + toggleGroup: (key: string) => void + groupBy: WorktreeGroupBy + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy + defaultHostId: ExecutionHostId + prCache: AppState['prCache'] | null + workspaceStatuses: readonly WorkspaceStatusDefinition[] + settings: AppState['settings'] + projectGroups: readonly ProjectGroup[] + projectGrouping?: ProjectGroupingModel + flashRevealedRow: (rowKey: string) => void + markRevealScroll: (targetTop: number) => void + schedulePendingRevealFrame: (callback: FrameRequestCallback) => void + cancelPendingRevealFrames: () => void +} + +// Expand whatever collapsed section hides the reveal target, then scroll to it. +export function expandGroupsForWorktreeReveal( + args: PendingSidebarRevealArgs, + worktreeId: string +): void { + const folderGroupKeys = getFolderWorkspaceRevealGroupKeys( + worktreeId, + args.folderWorkspaces, + args.projectGroups + ) + if (folderGroupKeys.length > 0) { + for (const groupKey of folderGroupKeys) { + if (args.collapsedGroups.has(groupKey)) { + args.toggleGroup(groupKey) + } + } + return + } + const targetWorktree = args.worktrees.find((w) => w.id === worktreeId) + if (!targetWorktree) { + return + } + const targetRepo = args.repoMap.get(targetWorktree.repoId) + const hostGroupKey = `host:${getWorktreeExecutionHostId(targetWorktree, targetRepo, args.defaultHostId)}` + if (args.collapsedGroups.has(hostGroupKey)) { + args.toggleGroup(hostGroupKey) + } + + for (const parent of getWorktreeLineageAncestors( + targetWorktree, + args.worktreeLineageById, + args.worktreeMap + )) { + const lineageGroupKey = getLineageGroupKey(parent.id) + if (args.collapsedGroups.has(lineageGroupKey)) { + args.toggleGroup(lineageGroupKey) + } + } + + const groupKeys = + targetWorktree.isPinned && args.pinnedDisplayPolicy === 'single-location' + ? getPinnedWorktreeRevealCollapsedGroupKeys({ + worktree: targetWorktree, + collapsedGroups: args.collapsedGroups + }) + : getGroupKeysForWorktree( + args.groupBy, + targetWorktree, + args.repoMap, + args.prCache, + args.workspaceStatuses, + args.settings, + args.projectGroups, + args.projectGrouping + ) + for (const groupKey of groupKeys) { + if (args.collapsedGroups.has(groupKey)) { + args.toggleGroup(groupKey) + } + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/pointer-drop-status-target.ts b/src/renderer/src/components/sidebar/worktree-list/pointer-drop-status-target.ts new file mode 100644 index 00000000000..46e20c56234 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/pointer-drop-status-target.ts @@ -0,0 +1,56 @@ +import type { + WorkspaceStatus, + WorkspaceStatusDefinition +} from '../../../../../shared/worktree/types' +import { getWorkspaceStatusFromGroupKey } from '../workspace-status' +import { getWorktreeLineageDropTargetId } from '../worktree-lineage-drag-drop' +import type { WorktreeSidebarStatusDropTarget } from '../worktree-sidebar-drop-preview' +import { + NO_WORKTREE_SIDEBAR_DROP_TARGET, + type WorktreeSidebarLineageDropTarget +} from './worktree-row-drag-state' + +export function getPointerDropStatusTarget(args: { + container: HTMLElement + x: number + y: number +}): WorktreeSidebarLineageDropTarget { + const target = document.elementFromPoint(args.x, args.y) + if (!(target instanceof Element) || !args.container.contains(target)) { + return NO_WORKTREE_SIDEBAR_DROP_TARGET + } + const pinTarget = target.closest('[data-workspace-pin-drop-target]') + if (pinTarget && args.container.contains(pinTarget)) { + return { status: null, isPinDrop: true, lineageParentId: null } + } + const lineageParentId = getWorktreeLineageDropTargetId({ + container: args.container, + target, + pointerY: args.y + }) + const statusTarget = target.closest('[data-workspace-status-drop-target]') + return { + status: + statusTarget && args.container.contains(statusTarget) + ? ((statusTarget.dataset.workspaceStatus as WorkspaceStatus | undefined) ?? null) + : null, + isPinDrop: false, + lineageParentId + } +} + +export function shouldPreferSidebarStatusDropTarget(args: { + sourceGroupKey: string + target: WorktreeSidebarStatusDropTarget + workspaceStatuses: readonly WorkspaceStatusDefinition[] +}): boolean { + if (args.target.isPinDrop) { + return true + } + if (!args.target.status) { + return false + } + const sourceStatus = getWorkspaceStatusFromGroupKey(args.sourceGroupKey, args.workspaceStatuses) + // Why: overlapping edge zones — the section under the pointer must win so guide and drop agree. + return sourceStatus !== null && args.target.status !== sourceStatus +} diff --git a/src/renderer/src/components/sidebar/worktree-list/project-group-header-actions.tsx b/src/renderer/src/components/sidebar/worktree-list/project-group-header-actions.tsx new file mode 100644 index 00000000000..fc9edf19114 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/project-group-header-actions.tsx @@ -0,0 +1,132 @@ +import React from 'react' +import { Ellipsis, Plus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { getFolderWorkspacePathStatusDescription } from '@/lib/folder-workspace-path-status' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { FolderWorkspacePathStatus } from '../../../../../shared/folder-workspace-path-status' +import { REPO_HEADER_ACTION_BUTTON_CLASS } from '../repo-header-action-button-class' +import { + handleRepoHeaderActionPointerDown, + stopRepoHeaderKeyboardToggle, + stopRepoHeaderMenuEvent +} from './repo-header-event-guards' + +export function ProjectGroupHeaderMenu({ + groupId, + label, + onRename, + onDelete +}: { + groupId: string + label: string + onRename: (groupId: string, currentName: string) => void + onDelete: (groupId: string, groupName: string) => void +}): React.JSX.Element { + return ( + + + + + + onRename(groupId, label)}> + {translate('auto.components.sidebar.WorktreeList.4d7b73658c', 'Rename group')} + + onDelete(groupId, label)}> + {translate('auto.components.sidebar.WorktreeList.902115cdbe', 'Delete group')} + + + + ) +} + +export function ProjectGroupCreateWorkspaceButton({ + projectGroup, + label, + pathStatus, + disabled, + onCreate +}: { + projectGroup: ProjectGroup + label: string + pathStatus: FolderWorkspacePathStatus | null + disabled: boolean + onCreate: (projectGroup: ProjectGroup) => void +}): React.JSX.Element { + const createLabel = translate( + 'auto.components.sidebar.WorktreeList.bd37a57ac8', + 'Create workspace for {{value0}}', + { value0: label } + ) + return ( + + + + + + {pathStatus?.exists === false + ? getFolderWorkspacePathStatusDescription(pathStatus) + : createLabel} + + + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/render-row-item-rows.ts b/src/renderer/src/components/sidebar/worktree-list/render-row-item-rows.ts new file mode 100644 index 00000000000..b0895ad6cd4 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/render-row-item-rows.ts @@ -0,0 +1,13 @@ +import { PINNED_GROUP_KEY } from '../worktree-list-groups' +import type { HostSectionRow } from '../host-section-rows' + +export type WorktreeItemRow = Extract +export type FolderWorkspaceItemRow = Extract + +export function isWorktreeItemRow(row: HostSectionRow): row is WorktreeItemRow { + return row.type === 'item' +} + +export function isPinnedWorktreeRow(row: WorktreeItemRow): boolean { + return row.sectionKey === PINNED_GROUP_KEY +} diff --git a/src/renderer/src/components/sidebar/worktree-list/render-row-worktree-lookup.ts b/src/renderer/src/components/sidebar/worktree-list/render-row-worktree-lookup.ts new file mode 100644 index 00000000000..4605058d231 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/render-row-worktree-lookup.ts @@ -0,0 +1,79 @@ +import { folderWorkspaceKey } from '../../../../../shared/workspace-scope' +import type { RenderRow } from '../worktree-list-virtual-rows' +import type { PinnedWorktreeDisplayPolicy } from '../worktree-list-groups' +import { isPinnedWorktreeRow, type WorktreeItemRow } from './render-row-item-rows' + +export function getRenderRowSidebarKey(row: RenderRow): string | null { + if (row.type === 'header') { + return row.key + } + if (row.type === 'item') { + return row.rowKey + } + if (row.type === 'folder-workspace') { + return folderWorkspaceKey(row.folderWorkspace.id) + } + if (row.type === 'pending-creation') { + return `pending:${row.creationId}` + } + if (row.type === 'imported-worktrees-card') { + return row.key + } + if (row.type === 'new-external-worktrees-inbox') { + return row.key + } + return null +} + +export function rowKeyMatchesRenderRow(row: RenderRow, rowKey: string): boolean { + if (row.type === 'lineage-group') { + return row.rows.some((item) => item.rowKey === rowKey) + } + return getRenderRowSidebarKey(row) === rowKey +} + +export function renderRowContainsWorktree(row: RenderRow, worktreeId: string | null): boolean { + if (worktreeId === null) { + return false + } + if (row.type === 'folder-workspace') { + return folderWorkspaceKey(row.folderWorkspace.id) === worktreeId + } + if (row.type === 'lineage-group') { + return row.rows.some((item) => item.worktree.id === worktreeId) + } + return row.type === 'item' && row.worktree.id === worktreeId +} + +export function getRenderRowWorktreeItem( + row: RenderRow, + worktreeId: string +): WorktreeItemRow | null { + if (row.type === 'lineage-group') { + return row.rows.find((item) => item.worktree.id === worktreeId) ?? null + } + return row.type === 'item' && row.worktree.id === worktreeId ? row : null +} + +// Prefer the worktree's natural group row over its pinned duplicate when both are rendered. +export function findPreferredRenderRowIndexForWorktree( + renderRows: readonly RenderRow[], + worktreeId: string, + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy +): number { + let fallbackIndex = -1 + for (let index = 0; index < renderRows.length; index++) { + const row = renderRows[index] + if (!renderRowContainsWorktree(row, worktreeId)) { + continue + } + if (fallbackIndex === -1) { + fallbackIndex = index + } + const itemRow = getRenderRowWorktreeItem(row, worktreeId) + if (pinnedDisplayPolicy === 'duplicate-in-groups' && itemRow && !isPinnedWorktreeRow(itemRow)) { + return index + } + } + return fallbackIndex +} diff --git a/src/renderer/src/components/sidebar/worktree-list/renderable-rows.ts b/src/renderer/src/components/sidebar/worktree-list/renderable-rows.ts new file mode 100644 index 00000000000..cc7c5e7870b --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/renderable-rows.ts @@ -0,0 +1,40 @@ +import { getLineageGroupKey } from '../worktree-list-groups' +import type { HostSectionRow } from '../host-section-rows' +import type { RenderRow } from '../worktree-list-virtual-rows' +import { isWorktreeItemRow, type WorktreeItemRow } from './render-row-item-rows' + +// Collapse a parent and its visible lineage descendants into one virtual row so the card renders them inline. +export function buildRenderableRows(rows: HostSectionRow[]): RenderRow[] { + const renderRows: RenderRow[] = [] + for (let index = 0; index < rows.length; index++) { + const row = rows[index] + if ( + !isWorktreeItemRow(row) || + row.lineageChildCount === 0 || + row.lineageCollapsed || + rows[index + 1]?.type !== 'item' || + (rows[index + 1] as WorktreeItemRow).depth <= row.depth + ) { + renderRows.push(row) + continue + } + + const groupRows: WorktreeItemRow[] = [row] + let cursor = index + 1 + while (cursor < rows.length) { + const child = rows[cursor] + if (!isWorktreeItemRow(child) || child.depth <= row.depth) { + break + } + groupRows.push(child) + cursor++ + } + renderRows.push({ + type: 'lineage-group', + key: `${row.sectionKey}:${getLineageGroupKey(row.worktree.id)}`, + rows: groupRows + }) + index = cursor - 1 + } + return renderRows +} diff --git a/src/renderer/src/components/sidebar/worktree-list/repo-header-event-guards.ts b/src/renderer/src/components/sidebar/worktree-list/repo-header-event-guards.ts new file mode 100644 index 00000000000..c4ba87c3f50 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/repo-header-event-guards.ts @@ -0,0 +1,31 @@ +import type React from 'react' +import { isRepoHeaderActionTarget } from '../project-header-drag' + +export function stopRepoHeaderKeyboardToggle(event: React.KeyboardEvent): void { + if (event.key === 'Enter' || event.key === ' ') { + event.stopPropagation() + } +} + +export function stopNestedWorktreeCardBubble(event: React.SyntheticEvent): void { + event.stopPropagation() +} + +export function handleRepoHeaderActionPointerDown(event: React.PointerEvent): void { + event.stopPropagation() +} + +export function handleRepoHeaderCollapseAffordancePointerDown( + event: React.PointerEvent +): void { + // Why: keep collapse-chevron clicks from arming the repo-header row drag. + event.stopPropagation() +} + +export function stopRepoHeaderMenuEvent(event: React.SyntheticEvent): void { + event.stopPropagation() +} + +export function shouldIgnoreRepoHeaderToggle(event: React.SyntheticEvent): boolean { + return isRepoHeaderActionTarget(event.target, event.currentTarget) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/repo-header-project-actions.tsx b/src/renderer/src/components/sidebar/worktree-list/repo-header-project-actions.tsx new file mode 100644 index 00000000000..cd0d09c0733 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/repo-header-project-actions.tsx @@ -0,0 +1,227 @@ +import React from 'react' +import { + CircleX, + Ellipsis, + Eye, + FolderInput, + FolderPlus, + Plus, + Shapes, + SlidersHorizontal, + Trash2 +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { getRepositoryIconSectionId } from '@/components/settings/repository-settings-targets' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { Repo } from '../../../../../shared/repo-types' +import { isGitRepoKind } from '../../../../../shared/repo-kind' +import { + REPO_HEADER_ACTION_BUTTON_CLASS, + REPO_HEADER_ACTION_REVEAL_CLASS +} from '../repo-header-action-button-class' +import type { getRepoHeaderCreateState } from '../repo-header-create-state' +import { + handleRepoHeaderActionPointerDown, + stopRepoHeaderKeyboardToggle, + stopRepoHeaderMenuEvent +} from './repo-header-event-guards' +import { getWorktreeVisibilityMenuLabel } from './worktree-visibility-menu-label' + +export type RepoHeaderProjectActions = { + onOpenRepoSettings: (projectId: string, sectionId?: string) => void + onOpenWorktreeVisibility: (repo: Repo) => void + onCreateGroupFromRepo: (repo: Repo) => void + onMoveProjectToGroup: (repo: Repo, groupId: string) => void + onRemoveProjectFromGroup: (repo: Repo) => void + onRemoveProject: (repo: Repo) => void + onCreateForRepo: (projectId: string) => void +} + +export function RepoHeaderProjectActionsMenu({ + repo, + label, + projectGroups, + actions +}: { + repo: Repo + label: string + projectGroups: readonly ProjectGroup[] + actions: RepoHeaderProjectActions +}): React.JSX.Element { + return ( + + + + + + + + + {translate('auto.components.sidebar.WorktreeList.2ef41bf9a7', 'Project actions')} + + + + actions.onOpenRepoSettings(repo.id)}> + + {translate('auto.components.sidebar.WorktreeList.2cdffbc728', 'Project Settings')} + + actions.onOpenRepoSettings(repo.id, getRepositoryIconSectionId(repo.id))} + > + + {translate('auto.components.sidebar.WorktreeList.e82d3589a1', 'Change Project Icon')} + + {isGitRepoKind(repo) ? ( + actions.onOpenWorktreeVisibility(repo)}> + + {getWorktreeVisibilityMenuLabel(repo)} + + ) : null} + actions.onCreateGroupFromRepo(repo)}> + + {translate('auto.components.sidebar.WorktreeList.cbfd565f83', 'New group from project')} + + {projectGroups.length > 0 ? ( + + + + {translate('auto.components.sidebar.WorktreeList.4a08fb55f2', 'Move to group')} + + + {projectGroups.map((group) => ( + actions.onMoveProjectToGroup(repo, group.id)} + > + {group.name} + + ))} + + + ) : null} + {repo.projectGroupId ? ( + actions.onRemoveProjectFromGroup(repo)}> + + {translate('auto.components.sidebar.WorktreeList.64e55f7f01', 'Remove from group')} + + ) : null} + + actions.onRemoveProject(repo)}> + + {translate('auto.components.sidebar.WorktreeList.c83968f87f', 'Remove Project')} + + + + ) +} + +export function RepoHeaderCreateWorkspaceButton({ + repo, + label, + createState, + onCreateForRepo +}: { + repo: Repo + label: string + createState: ReturnType | null + onCreateForRepo: (projectId: string) => void +}): React.JSX.Element { + const fallbackLabel = translate( + 'auto.components.sidebar.WorktreeList.bb85cd86ba', + 'Create workspace for {{value0}}', + { value0: label } + ) + return ( + + + {createState?.disabled ? ( + event.stopPropagation()} + onPointerDown={handleRepoHeaderActionPointerDown} + > + + + ) : ( + + )} + + + {createState?.tooltip ?? fallbackLabel} + + + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/sidebar-editable-target.ts b/src/renderer/src/components/sidebar/worktree-list/sidebar-editable-target.ts new file mode 100644 index 00000000000..390df9dae2f --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/sidebar-editable-target.ts @@ -0,0 +1,19 @@ +export function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false + } + + // xterm's hidden input textarea isn't a real text field; treating it as one would block sidebar shortcuts. + if (target.classList.contains('xterm-helper-textarea')) { + return false + } + + if (target.isContentEditable) { + return true + } + + return ( + target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]') !== + null + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/sidebar-row-reveal-ancestors.ts b/src/renderer/src/components/sidebar/worktree-list/sidebar-row-reveal-ancestors.ts new file mode 100644 index 00000000000..cc387daba32 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/sidebar-row-reveal-ancestors.ts @@ -0,0 +1,112 @@ +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 '../worktree-list-groups' + +function getProjectIdFromHeaderRowKey(rowKey: string): string | null { + if (!rowKey.startsWith('project:')) { + return null + } + const withoutPrefix = rowKey.slice('project:'.length) + const setupSeparator = withoutPrefix.indexOf('::setup:') + return setupSeparator === -1 ? withoutPrefix : withoutPrefix.slice(0, setupSeparator) +} + +function getRepoIdsFromHeaderRowKey( + rowKey: string, + repoMap: Map, + projectGrouping?: ProjectGroupingModel +): string[] { + if (rowKey.startsWith('repo:')) { + return [rowKey.slice('repo:'.length)] + } + const setupMarker = '::setup:' + const setupIndex = rowKey.indexOf(setupMarker) + if (rowKey.startsWith('project:') && setupIndex !== -1) { + return [rowKey.slice(setupIndex + setupMarker.length)] + } + const projectId = getProjectIdFromHeaderRowKey(rowKey) + if (!projectId) { + return [] + } + const repoIds = new Set() + for (const setup of projectGrouping?.projectHostSetups ?? []) { + if (setup.projectId === projectId && repoMap.has(setup.repoId)) { + repoIds.add(setup.repoId) + } + } + const project = projectGrouping?.projects.find((candidate) => candidate.id === projectId) + for (const repoId of project?.sourceRepoIds ?? []) { + if (repoMap.has(repoId)) { + repoIds.add(repoId) + } + } + return [...repoIds] +} + +function getProjectGroupAncestorKeys( + projectGroupId: string | null | undefined, + projectGroups: readonly ProjectGroup[] +): string[] { + const groupsById = new Map(projectGroups.map((group) => [group.id, group])) + const keys: string[] = [] + const seen = new Set() + let currentGroupId = projectGroupId ?? null + while (currentGroupId && !seen.has(currentGroupId)) { + const group = groupsById.get(currentGroupId) + if (!group) { + break + } + seen.add(currentGroupId) + keys.unshift(getProjectGroupHeaderKey(group.id)) + currentGroupId = group.parentGroupId + } + return keys +} + +export function getSidebarRowRevealAncestorKeys(args: { + rowKey: string + repoMap: Map + projectGroups: readonly ProjectGroup[] + projectGrouping?: ProjectGroupingModel +}): string[] { + if (args.rowKey.startsWith('project-group:')) { + const groupId = args.rowKey.slice('project-group:'.length) + const group = args.projectGroups.find((candidate) => candidate.id === groupId) + return getProjectGroupAncestorKeys(group?.parentGroupId, args.projectGroups) + } + const keys = new Set() + for (const repoId of getRepoIdsFromHeaderRowKey( + args.rowKey, + args.repoMap, + args.projectGrouping + )) { + const repo = args.repoMap.get(repoId) + for (const key of getProjectGroupAncestorKeys(repo?.projectGroupId, args.projectGroups)) { + keys.add(key) + } + } + return [...keys] +} + +export function getPinnedWorktreeRevealCollapsedGroupKeys({ + worktree, + collapsedGroups +}: { + worktree: Worktree + collapsedGroups: ReadonlySet +}): string[] { + if (!worktree.isPinned) { + return [] + } + const keys: string[] = [] + // Why: the reveal effect already opens this host; re-returning it would toggle it back closed. + if (collapsedGroups.has(PINNED_GROUP_KEY)) { + keys.push(PINNED_GROUP_KEY) + } + return keys +} diff --git a/src/renderer/src/components/sidebar/worktree-list/stable-empty-store-slices.ts b/src/renderer/src/components/sidebar/worktree-list/stable-empty-store-slices.ts new file mode 100644 index 00000000000..49cae96a88b --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/stable-empty-store-slices.ts @@ -0,0 +1,9 @@ +import type { AppState } from '@/store/types' + +// Why: selectors that opt out of a slice must return one stable reference, or every store tick looks like a change. +export const EMPTY_AGENT_STATUS_BY_PANE_KEY: AppState['agentStatusByPaneKey'] = {} +export const EMPTY_WORKTREE_ID_SET: ReadonlySet = new Set() +export const EMPTY_TABS_BY_WORKTREE: AppState['tabsByWorktree'] = {} +export const EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID: AppState['terminalLayoutsByTabId'] = {} +export const EMPTY_PTY_IDS_BY_TAB_ID: AppState['ptyIdsByTabId'] = {} +export const EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID: AppState['runtimePaneTitlesByTabId'] = {} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-agent-send-target-worktree.ts b/src/renderer/src/components/sidebar/worktree-list/use-agent-send-target-worktree.ts new file mode 100644 index 00000000000..61269d78225 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-agent-send-target-worktree.ts @@ -0,0 +1,63 @@ +import { useMemo } from 'react' +import { useAppStore } from '@/store' +import { deriveRunningAgentSendTargets } from '@/lib/running-agent-targets' +import { + EMPTY_AGENT_STATUS_BY_PANE_KEY, + EMPTY_PTY_IDS_BY_TAB_ID, + EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID, + EMPTY_TABS_BY_WORKTREE, + EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID +} from './stable-empty-store-slices' + +// The agent send picker forces its target workspace visible, but only while a running +// agent there can actually receive the message. +export function useAgentSendTargetWorktreeId(): string | null { + const agentSendPopoverTargetMode = useAppStore((s) => s.agentSendPopoverTargetMode) + // Why: eligibility only matters while the picker is open; when closed, don't subscribe to wake-time layout churn. + const agentTargetStatusByPaneKey = useAppStore((s) => + agentSendPopoverTargetMode ? s.agentStatusByPaneKey : EMPTY_AGENT_STATUS_BY_PANE_KEY + ) + const agentTargetStatusEpoch = useAppStore((s) => + agentSendPopoverTargetMode ? s.agentStatusEpoch : 0 + ) + const agentTargetTabsByWorktree = useAppStore((s) => + agentSendPopoverTargetMode ? s.tabsByWorktree : EMPTY_TABS_BY_WORKTREE + ) + const agentTargetTerminalLayoutsByTabId = useAppStore((s) => + agentSendPopoverTargetMode ? s.terminalLayoutsByTabId : EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID + ) + const agentTargetPtyIdsByTabId = useAppStore((s) => + agentSendPopoverTargetMode ? s.ptyIdsByTabId : EMPTY_PTY_IDS_BY_TAB_ID + ) + const agentTargetRuntimePaneTitlesByTabId = useAppStore((s) => + agentSendPopoverTargetMode ? s.runtimePaneTitlesByTabId : EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID + ) + return useMemo(() => { + void agentTargetStatusEpoch + if (!agentSendPopoverTargetMode) { + return null + } + const targets = deriveRunningAgentSendTargets( + { + agentStatusByPaneKey: agentTargetStatusByPaneKey, + tabsByWorktree: agentTargetTabsByWorktree, + terminalLayoutsByTabId: agentTargetTerminalLayoutsByTabId, + ptyIdsByTabId: agentTargetPtyIdsByTabId, + runtimePaneTitlesByTabId: agentTargetRuntimePaneTitlesByTabId + }, + agentSendPopoverTargetMode.worktreeId + ) + return targets.some((target) => target.status === 'eligible') + ? agentSendPopoverTargetMode.worktreeId + : null + }, [ + // Why: eligibility can flip when the stale-boundary scheduler bumps this epoch without replacing the status map. + agentTargetStatusEpoch, + agentSendPopoverTargetMode, + agentTargetStatusByPaneKey, + agentTargetTabsByWorktree, + agentTargetTerminalLayoutsByTabId, + agentTargetPtyIdsByTabId, + agentTargetRuntimePaneTitlesByTabId + ]) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-effective-collapsed-groups.ts b/src/renderer/src/components/sidebar/worktree-list/use-effective-collapsed-groups.ts new file mode 100644 index 00000000000..681056b5fe6 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-effective-collapsed-groups.ts @@ -0,0 +1,90 @@ +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 '../worktree-list-groups' +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: { + collapsedGroups: Set + agentSendTargetWorktreeId: string | null + groupBy: WorktreeGroupBy + repoMap: Map + worktreeMap: Map + worktreeLineageById: Record + prCache: AppState['prCache'] | null + workspaceStatuses: readonly WorkspaceStatusDefinition[] + settings: AppState['settings'] + projectGroups: readonly ProjectGroup[] + projectGrouping: ProjectGroupingModel +}): Set { + const { + collapsedGroups, + agentSendTargetWorktreeId, + groupBy, + repoMap, + worktreeMap, + worktreeLineageById, + prCache, + workspaceStatuses, + settings, + projectGroups, + projectGrouping + } = args + return useMemo(() => { + if (!agentSendTargetWorktreeId) { + return collapsedGroups + } + const targetWorktree = worktreeMap.get(agentSendTargetWorktreeId) + if (!targetWorktree) { + return collapsedGroups + } + const next = new Set(collapsedGroups) + if (targetWorktree.isPinned) { + next.delete(PINNED_GROUP_KEY) + } else { + for (const groupKey of getGroupKeysForWorktree( + groupBy, + targetWorktree, + repoMap, + prCache, + workspaceStatuses, + settings, + projectGroups, + projectGrouping + )) { + next.delete(groupKey) + } + } + + for (const parent of getWorktreeLineageAncestors( + targetWorktree, + worktreeLineageById, + worktreeMap + )) { + next.delete(getLineageGroupKey(parent.id)) + } + return next + }, [ + agentSendTargetWorktreeId, + collapsedGroups, + groupBy, + prCache, + projectGroups, + projectGrouping, + repoMap, + settings, + workspaceStatuses, + worktreeLineageById, + worktreeMap + ]) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-folder-workspace-path-status-rows.ts b/src/renderer/src/components/sidebar/worktree-list/use-folder-workspace-path-status-rows.ts new file mode 100644 index 00000000000..8e5bd17b81d --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-folder-workspace-path-status-rows.ts @@ -0,0 +1,128 @@ +import { useCallback, useEffect, useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { useAppStore } from '@/store' +import { useFolderWorkspacePathStatusCacheExpiryTick } from '@/lib/folder-workspace-path-status-cache-expiry' +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 { AppState } from '@/store/types' +import { getFolderPathStatusRouteOptionsForRows } from '../worktree-list-host-filtering' + +type FolderPathStatusRequest = Parameters[0] + +// Keeps the sidebar's folder-path probes fresh for every project group and folder workspace +// it renders, and hands rows a cache reader that ignores expired negative results. +export function useFolderWorkspacePathStatusRows(args: { + allRepoIds: string[] + repoMap: Map + projectGroups: readonly ProjectGroup[] + folderWorkspaces: readonly FolderWorkspace[] + sshConnectionStates: AppState['sshConnectionStates'] +}) { + const { allRepoIds, repoMap, projectGroups, folderWorkspaces, sshConnectionStates } = args + const { + folderWorkspacePathStatuses, + fetchFolderWorkspacePathStatus, + getFolderWorkspacePathStatusCacheKey, + getFreshFolderWorkspacePathStatus, + activeRuntimeEnvironmentId + } = useAppStore( + useShallow((s) => ({ + folderWorkspacePathStatuses: s.folderWorkspacePathStatuses, + fetchFolderWorkspacePathStatus: s.fetchFolderWorkspacePathStatus, + getFolderWorkspacePathStatusCacheKey: s.getFolderWorkspacePathStatusCacheKey, + getFreshFolderWorkspacePathStatus: s.getFreshFolderWorkspacePathStatus, + activeRuntimeEnvironmentId: s.settings?.activeRuntimeEnvironmentId ?? null + })) + ) + const folderPathStatusRepoMembershipKey = useMemo( + () => + allRepoIds + .map((repoId) => { + const repo = repoMap.get(repoId) + return `${repoId}:${repo?.path ?? ''}:${repo?.projectGroupId ?? ''}:${repo?.connectionId ?? ''}` + }) + .join('\0'), + [allRepoIds, repoMap] + ) + const folderPathStatusSshConnectionKey = useMemo( + () => + [...sshConnectionStates.entries()] + .map(([connectionId, state]) => `${connectionId}:${state.status}`) + .sort() + .join('\0'), + [sshConnectionStates] + ) + const folderPathStatusCacheExpiryTick = useFolderWorkspacePathStatusCacheExpiryTick( + folderWorkspacePathStatuses + ) + const projectGroupByIdForFolderPathStatus = useMemo( + () => new Map(projectGroups.map((group) => [group.id, group])), + [projectGroups] + ) + const folderWorkspaceByIdForFolderPathStatus = useMemo( + () => new Map(folderWorkspaces.map((workspace) => [workspace.id, workspace])), + [folderWorkspaces] + ) + const getFolderPathStatusRouteOptions = useCallback( + (request: FolderPathStatusRequest) => + getFolderPathStatusRouteOptionsForRows({ + request, + projectGroupsById: projectGroupByIdForFolderPathStatus, + folderWorkspacesById: folderWorkspaceByIdForFolderPathStatus + }), + [folderWorkspaceByIdForFolderPathStatus, projectGroupByIdForFolderPathStatus] + ) + useEffect(() => { + const requests = new Map< + string, + { + request: FolderPathStatusRequest + options?: { runtimeEnvironmentId: string | null } + } + >() + for (const group of projectGroups) { + if (group.parentPath) { + const request = { scope: 'project-group' as const, projectGroupId: group.id } + const options = getFolderPathStatusRouteOptions(request) + requests.set(getFolderWorkspacePathStatusCacheKey(request, options), { request, options }) + } + } + for (const workspace of folderWorkspaces) { + const request = { scope: 'folder-workspace' as const, folderWorkspaceId: workspace.id } + const options = getFolderPathStatusRouteOptions(request) + requests.set(getFolderWorkspacePathStatusCacheKey(request, options), { request, options }) + } + for (const { request, options } of requests.values()) { + void fetchFolderWorkspacePathStatus(request, { force: true, ...options }) + } + }, [ + activeRuntimeEnvironmentId, + fetchFolderWorkspacePathStatus, + folderPathStatusRepoMembershipKey, + folderPathStatusSshConnectionKey, + folderWorkspaces, + getFolderPathStatusRouteOptions, + getFolderWorkspacePathStatusCacheKey, + projectGroups + ]) + const getCachedFolderWorkspacePathStatus = useCallback( + (request: FolderPathStatusRequest) => { + const options = getFolderPathStatusRouteOptions(request) + const cacheKey = getFolderWorkspacePathStatusCacheKey(request, options) + // Why: don't let an expired negative status keep folder workspaces disabled while a refresh is in flight. + void folderWorkspacePathStatuses[cacheKey] + void folderPathStatusCacheExpiryTick + return getFreshFolderWorkspacePathStatus(request, options) + }, + [ + folderWorkspacePathStatuses, + folderPathStatusCacheExpiryTick, + getFolderPathStatusRouteOptions, + getFolderWorkspacePathStatusCacheKey, + getFreshFolderWorkspacePathStatus + ] + ) + + return getCachedFolderWorkspacePathStatus +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-group-toggle-with-scroll-anchor.ts b/src/renderer/src/components/sidebar/worktree-list/use-group-toggle-with-scroll-anchor.ts new file mode 100644 index 00000000000..044c2a2651f --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-group-toggle-with-scroll-anchor.ts @@ -0,0 +1,30 @@ +import { useCallback, useMemo } from 'react' +import type React from 'react' +import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor' +import { createLineageToggleHandlerCache } from '../worktree-lineage-toggle-handler-cache' + +// Collapsing a section changes total height, so snapshot the anchor first or the viewport jumps. +export function useGroupToggleWithScrollAnchor(args: { + scrollRef: React.RefObject + toggleGroup: (key: string) => void +}) { + const { scrollRef, toggleGroup } = args + const recordCurrentScrollAnchor = useCallback(() => { + scrollRef.current?.dispatchEvent(new Event(VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT)) + }, [scrollRef]) + const toggleGroupWithScrollAnchor = useCallback( + (groupKey: string) => { + recordCurrentScrollAnchor() + toggleGroup(groupKey) + }, + [recordCurrentScrollAnchor, toggleGroup] + ) + // Why: memo'd WorktreeCard needs a per-group-key stable onLineageToggle + // identity to bail out of re-renders; see worktree-lineage-toggle-handler-cache. + const getLineageToggleHandler = useMemo( + () => createLineageToggleHandlerCache(toggleGroupWithScrollAnchor), + [toggleGroupWithScrollAnchor] + ) + + return { toggleGroupWithScrollAnchor, getLineageToggleHandler } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-pending-sidebar-reveal.ts b/src/renderer/src/components/sidebar/worktree-list/use-pending-sidebar-reveal.ts new file mode 100644 index 00000000000..7ba04fb5149 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-pending-sidebar-reveal.ts @@ -0,0 +1,288 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { getRenderRowOptionId } from './active-descendant-option' +import { + findPreferredRenderRowIndexForWorktree, + getRenderRowSidebarKey, + rowKeyMatchesRenderRow +} from './render-row-worktree-lookup' +import { revealMountedSidebarRowElement, revealMountedWorktreeElement } from './mounted-row-reveal' +import { getSidebarRowRevealAncestorKeys } from './sidebar-row-reveal-ancestors' +import { sidebarWorkspaceStillExists } from '../worktree-list-folder-reveal' +import { resolvePendingSidebarReveal } from './worktree-sidebar-scroll-tuning' +import { + expandGroupsForWorktreeReveal, + MAX_REVEAL_RETRIES, + type PendingSidebarRevealArgs +} from './pending-sidebar-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". +export function usePendingSidebarReveal(args: PendingSidebarRevealArgs): void { + const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId) + const [pendingRevealRetryTick, setPendingRevealRetryTick] = useState(0) + const pendingRevealRetryRef = useRef<{ worktreeId: string; count: number } | null>(null) + const pendingRowRevealRetryRef = useRef<{ rowKey: string; count: number } | null>(null) + const argsRef = useRef(args) + argsRef.current = args + + const { + pendingRevealWorktree, + pendingRevealSidebarRow, + clearPendingRevealWorktreeId, + clearPendingRevealSidebarRow, + renderRows, + virtualizer, + schedulePendingRevealFrame, + cancelPendingRevealFrames, + flashRevealedRow, + markRevealScroll, + pinnedDisplayPolicy + } = args + + const scheduleRetryTick = useCallback( + (cancelled: () => boolean) => { + schedulePendingRevealFrame(() => { + if (!cancelled()) { + setPendingRevealRetryTick((tick) => tick + 1) + } + }) + }, + [schedulePendingRevealFrame] + ) + + useEffect(() => { + if (!pendingRevealWorktree) { + return + } + const current = argsRef.current + if (current.agentSendTargetWorktreeId !== pendingRevealWorktree.worktreeId) { + expandGroupsForWorktreeReveal(current, pendingRevealWorktree.worktreeId) + } + + let cancelled = false + schedulePendingRevealFrame(() => { + if (cancelled) { + return + } + const targetWorktreeStillExists = sidebarWorkspaceStillExists( + pendingRevealWorktree.worktreeId, + argsRef.current.worktrees, + argsRef.current.folderWorkspaces + ) + const targetIndex = findPreferredRenderRowIndexForWorktree( + renderRows, + pendingRevealWorktree.worktreeId, + pinnedDisplayPolicy + ) + const outcome = resolvePendingSidebarReveal({ targetIndex, targetWorktreeStillExists }) + if (outcome === 'clear') { + pendingRevealRetryRef.current = null + clearPendingRevealWorktreeId() + return + } + if (outcome !== 'scroll-and-clear') { + return + } + const targetRow = renderRows[targetIndex] + const container = argsRef.current.scrollRef.current + const revealedOption = container + ? revealMountedWorktreeElement( + container, + pendingRevealWorktree.worktreeId, + pendingRevealWorktree.behavior, + getRenderRowOptionId(targetRow, pendingRevealWorktree.worktreeId), + markRevealScroll + ) + : null + if (revealedOption) { + if (pendingRevealWorktree.highlight) { + const revealedRowKey = + revealedOption.dataset.worktreeRowKey ?? getRenderRowSidebarKey(targetRow) + if (revealedRowKey) { + flashRevealedRow(revealedRowKey) + } + } + if (pendingRevealWorktree.beginRename) { + setRenamingWorktreeId({ + worktreeId: pendingRevealWorktree.worktreeId, + rowKey: revealedOption.dataset.worktreeRowKey + }) + } + pendingRevealRetryRef.current = null + clearPendingRevealWorktreeId() + return + } + + // Why: virtual indexing can leave the card edge clipped; stage it into the window, then retry the exact DOM reveal. + virtualizer.scrollToIndex(targetIndex, { align: 'auto', behavior: 'auto' }) + const previousRetry = pendingRevealRetryRef.current + const nextRetryCount = + previousRetry?.worktreeId === pendingRevealWorktree.worktreeId ? previousRetry.count + 1 : 1 + pendingRevealRetryRef.current = { + worktreeId: pendingRevealWorktree.worktreeId, + count: nextRetryCount + } + if (nextRetryCount <= MAX_REVEAL_RETRIES) { + scheduleRetryTick(() => cancelled) + return + } + pendingRevealRetryRef.current = null + clearPendingRevealWorktreeId() + }) + return () => { + cancelled = true + cancelPendingRevealFrames() + } + // Why: the effect re-reads live inputs through argsRef; only reveal-relevant identities should retrigger it. + // oxlint-disable-next-line react-hooks/exhaustive-deps + }, [ + pendingRevealWorktree, + args.agentSendTargetWorktreeId, + args.groupBy, + args.worktrees, + args.folderWorkspaces, + args.repoMap, + args.prCache, + args.worktreeLineageById, + args.worktreeMap, + renderRows, + virtualizer, + clearPendingRevealWorktreeId, + args.toggleGroup, + args.collapsedGroups, + args.defaultHostId, + args.workspaceStatuses, + args.settings, + pinnedDisplayPolicy, + args.projectGrouping, + args.projectGroups, + pendingRevealRetryTick, + flashRevealedRow, + markRevealScroll, + setRenamingWorktreeId, + schedulePendingRevealFrame, + cancelPendingRevealFrames, + scheduleRetryTick + ]) + + useEffect(() => { + if (!pendingRevealSidebarRow) { + return + } + const current = argsRef.current + + const isProjectHeaderTarget = + pendingRevealSidebarRow.rowKey.startsWith('project-group:') || + pendingRevealSidebarRow.rowKey.startsWith('project:') || + pendingRevealSidebarRow.rowKey.startsWith('repo:') + if (isProjectHeaderTarget && current.groupBy !== 'repo') { + return + } + + let toggledAncestor = false + for (const groupKey of getSidebarRowRevealAncestorKeys({ + rowKey: pendingRevealSidebarRow.rowKey, + repoMap: current.repoMap, + projectGroups: current.projectGroups, + projectGrouping: current.projectGrouping + })) { + if (current.collapsedGroups.has(groupKey)) { + current.toggleGroup(groupKey) + toggledAncestor = true + } + } + if (toggledAncestor) { + return + } + + let cancelled = false + const retryPendingReveal = (): boolean => { + const previousRetry = pendingRowRevealRetryRef.current + const nextRetryCount = + previousRetry?.rowKey === pendingRevealSidebarRow.rowKey ? previousRetry.count + 1 : 1 + pendingRowRevealRetryRef.current = { + rowKey: pendingRevealSidebarRow.rowKey, + count: nextRetryCount + } + if (nextRetryCount <= MAX_REVEAL_RETRIES) { + scheduleRetryTick(() => cancelled) + return true + } + return false + } + schedulePendingRevealFrame(() => { + if (cancelled) { + return + } + const targetIndex = renderRows.findIndex((row) => + rowKeyMatchesRenderRow(row, pendingRevealSidebarRow.rowKey) + ) + if (targetIndex === -1) { + if (retryPendingReveal()) { + return + } + pendingRowRevealRetryRef.current = null + clearPendingRevealSidebarRow() + toast.error( + translate( + 'auto.components.sidebar.WorktreeList.sidebarRowMissing', + 'Target no longer exists' + ) + ) + return + } + + const container = argsRef.current.scrollRef.current + const revealedElement = container + ? revealMountedSidebarRowElement( + container, + pendingRevealSidebarRow.rowKey, + pendingRevealSidebarRow.behavior, + markRevealScroll + ) + : null + if (revealedElement) { + if (pendingRevealSidebarRow.highlight) { + flashRevealedRow(pendingRevealSidebarRow.rowKey) + } + pendingRowRevealRetryRef.current = null + clearPendingRevealSidebarRow() + return + } + + virtualizer.scrollToIndex(targetIndex, { align: 'auto', behavior: 'auto' }) + if (retryPendingReveal()) { + return + } + pendingRowRevealRetryRef.current = null + clearPendingRevealSidebarRow() + }) + + return () => { + cancelled = true + cancelPendingRevealFrames() + } + // Why: the effect re-reads live inputs through argsRef; only reveal-relevant identities should retrigger it. + // oxlint-disable-next-line react-hooks/exhaustive-deps + }, [ + pendingRevealSidebarRow, + args.repoMap, + args.projectGroups, + args.projectGrouping, + args.collapsedGroups, + args.groupBy, + args.toggleGroup, + renderRows, + virtualizer, + pendingRevealRetryTick, + flashRevealedRow, + markRevealScroll, + clearPendingRevealSidebarRow, + schedulePendingRevealFrame, + cancelPendingRevealFrames, + scheduleRetryTick + ]) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-primary-active-worktree-row.ts b/src/renderer/src/components/sidebar/worktree-list/use-primary-active-worktree-row.ts new file mode 100644 index 00000000000..698d0b6d7c8 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-primary-active-worktree-row.ts @@ -0,0 +1,70 @@ +import { useCallback, useEffect, useState } from 'react' +import type { ActiveSurfaceVariant } from '../WorktreeCard' +import type { HostSectionRow } from '../host-section-rows' +import type { PinnedWorktreeDisplayPolicy } from '../worktree-list-groups' +import { isPinnedWorktreeRow, type WorktreeItemRow } from './render-row-item-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. +export function usePrimaryActiveWorktreeRow(args: { + rows: HostSectionRow[] + activeWorktreeId: string | null + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy + onImmediateWorktreeActivate: (worktreeId: string, rowKey: string | undefined) => void +}) { + const { rows, activeWorktreeId, pinnedDisplayPolicy, onImmediateWorktreeActivate } = args + const [primaryActiveWorktreeRow, setPrimaryActiveWorktreeRow] = useState<{ + worktreeId: string + rowKey: string + } | null>(null) + + useEffect(() => { + if (activeWorktreeId === null) { + setPrimaryActiveWorktreeRow(null) + return + } + setPrimaryActiveWorktreeRow((current) => { + if (current === null || current.worktreeId !== activeWorktreeId) { + return null + } + const rowStillVisible = rows.some( + (row) => + row.type === 'item' && + row.worktree.id === current.worktreeId && + row.rowKey === current.rowKey + ) + return rowStillVisible ? current : null + }) + }, [activeWorktreeId, rows]) + + const getActiveSurfaceVariant = useCallback( + (row: WorktreeItemRow): ActiveSurfaceVariant => { + if (primaryActiveWorktreeRow?.worktreeId === row.worktree.id) { + return primaryActiveWorktreeRow.rowKey === row.rowKey ? 'primary' : 'secondary' + } + if ( + pinnedDisplayPolicy === 'duplicate-in-groups' && + activeWorktreeId === row.worktree.id && + isPinnedWorktreeRow(row) + ) { + return 'secondary' + } + return 'primary' + }, + [activeWorktreeId, pinnedDisplayPolicy, primaryActiveWorktreeRow] + ) + + const handleImmediateWorktreeRowActivate = useCallback( + (worktreeId: string, rowKey: string | undefined): void => { + setPrimaryActiveWorktreeRow(rowKey ? { worktreeId, rowKey } : null) + onImmediateWorktreeActivate(worktreeId, rowKey) + }, + [onImmediateWorktreeActivate] + ) + + return { + primaryActiveWorktreeRow, + getActiveSurfaceVariant, + handleImmediateWorktreeRowActivate + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-project-group-dialogs.ts b/src/renderer/src/components/sidebar/worktree-list/use-project-group-dialogs.ts new file mode 100644 index 00000000000..800852f9570 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-project-group-dialogs.ts @@ -0,0 +1,172 @@ +import { useCallback, useMemo, useState } from 'react' +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' + +export type ProjectGroupNameDialogState = + | { type: 'create-from-repo'; repo: Repo } + | { type: 'rename'; groupId: string; currentName: string } + +export type ProjectGroupDeleteDialogState = { + groupId: string + groupName: string + removeContainedProjects: boolean +} + +export type ProjectGroupDialogs = ReturnType + +function reportProjectGroupDeleteFailures(result: { + status: string + failedProjectRemovals: readonly unknown[] + requestedProjectIds: readonly unknown[] +}): void { + // Why: a missing group is already the desired end state, so only a real delete failure warrants a toast. + if (result.status === 'group-delete-failed') { + toast.error( + translate('auto.components.sidebar.WorktreeList.groupDeleteFailed', 'Failed to delete group'), + { + description: translate( + 'auto.components.sidebar.WorktreeList.groupDeleteFailedDesc', + 'Something went wrong while deleting the group. No projects were removed.' + ) + } + ) + return + } + if (result.status === 'deleted-group' && result.failedProjectRemovals.length > 0) { + const requestedCount = result.requestedProjectIds.length + toast.error( + translate( + 'auto.components.sidebar.WorktreeList.b667b59632', + 'Some projects could not be removed from Orca' + ), + { + description: translate( + 'auto.components.sidebar.WorktreeList.f94466bc39', + '{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.', + { + value0: result.failedProjectRemovals.length, + value1: requestedCount, + value2: requestedCount === 1 ? '' : 's' + } + ) + } + ) + } +} + +// Create/rename/delete flows for project groups, including the contained-project fan-out. +export function useProjectGroupDialogs(args: { + repos: readonly Repo[] + repoMap: Map + projectGroups: readonly ProjectGroup[] +}) { + const { repos, repoMap, projectGroups } = args + const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup) + const createProjectGroup = useAppStore((s) => s.createProjectGroup) + const updateProjectGroup = useAppStore((s) => s.updateProjectGroup) + const deleteProjectGroupWithContainedProjects = useAppStore( + (s) => s.deleteProjectGroupWithContainedProjects + ) + const [nameDialog, setNameDialog] = useState(null) + const [deleteDialog, setDeleteDialog] = useState(null) + + const handleCreateGroupFromRepo = useCallback((repo: Repo) => { + setNameDialog({ type: 'create-from-repo', repo }) + }, []) + + const handleMoveProjectToGroup = useCallback( + (repo: Repo, groupId: string) => { + if (repo.projectGroupId === groupId) { + return + } + void moveProjectToGroup(repo.id, groupId) + }, + [moveProjectToGroup] + ) + + const handleRemoveProjectFromGroup = useCallback( + (repo: Repo) => { + void moveProjectToGroup(repo.id, null) + }, + [moveProjectToGroup] + ) + + const handleRenameProjectGroup = useCallback((groupId: string, currentName: string) => { + setNameDialog({ type: 'rename', groupId, currentName }) + }, []) + + const handleSubmitProjectGroupName = useCallback( + async (name: string) => { + if (!nameDialog) { + return + } + if (nameDialog.type === 'create-from-repo') { + const group = await createProjectGroup(name) + if (group) { + await moveProjectToGroup(nameDialog.repo.id, group.id) + } + return + } + await updateProjectGroup(nameDialog.groupId, { name }) + }, + [createProjectGroup, moveProjectToGroup, nameDialog, updateProjectGroup] + ) + + const deleteTargets = useMemo(() => { + if (!deleteDialog) { + return null + } + return selectProjectGroupRemovalTargets(projectGroups, repos, deleteDialog.groupId) + }, [deleteDialog, projectGroups, repos]) + const deleteProjectCount = deleteTargets?.projectIds.length ?? 0 + const deleteProjectNames = useMemo( + () => + (deleteTargets?.projectIds ?? []).map( + (projectId) => repoMap.get(projectId)?.displayName ?? projectId + ), + [deleteTargets, repoMap] + ) + const removeContainedProjects = + deleteProjectCount > 0 && deleteDialog?.removeContainedProjects === true + + const handleDeleteProjectGroup = useCallback((groupId: string, groupName: string) => { + setDeleteDialog({ groupId, groupName, removeContainedProjects: false }) + }, []) + + const handleConfirmDeleteProjectGroup = useCallback(async () => { + if (!deleteDialog) { + return + } + try { + reportProjectGroupDeleteFailures( + await deleteProjectGroupWithContainedProjects(deleteDialog.groupId, { + removeContainedProjects + }) + ) + } finally { + // Why: deleting contained projects can unmount this dialog before its close handler runs, so the parent owns cleanup. + setDeleteDialog(null) + } + }, [deleteProjectGroupWithContainedProjects, removeContainedProjects, deleteDialog]) + + return { + nameDialog, + setNameDialog, + deleteDialog, + setDeleteDialog, + deleteProjectCount, + deleteProjectNames, + removeContainedProjects, + handleCreateGroupFromRepo, + handleMoveProjectToGroup, + handleRemoveProjectFromGroup, + handleRenameProjectGroup, + handleSubmitProjectGroupName, + handleDeleteProjectGroup, + handleConfirmDeleteProjectGroup + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-reused-array-identity.ts b/src/renderer/src/components/sidebar/worktree-list/use-reused-array-identity.ts new file mode 100644 index 00000000000..892f2d7694a --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-reused-array-identity.ts @@ -0,0 +1,10 @@ +import { useRef } from 'react' +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[] { + const previousRef = useRef(next) + const result = reuseArrayIfEqual(previousRef.current, next) + previousRef.current = result + return result +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-external-worktree-cards.ts b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-external-worktree-cards.ts new file mode 100644 index 00000000000..a4ab21143da --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-external-worktree-cards.ts @@ -0,0 +1,187 @@ +import { useCallback, useMemo, useState } from 'react' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import type { Repo } from '../../../../../shared/repo-types' +import { + IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR, + keepImportedWorktreesHiddenCard, + showImportedWorktreesCard, + type ImportedWorktreeCardActionState +} from '../imported-worktrees-card-actions' +import { + buildImportedWorktreesCardCandidates, + getHiddenImportedWorktrees +} from '../imported-worktrees-card-candidates' +import { + suppressNewExternalWorktreeInbox, + type NewExternalWorktreesInboxActionState +} 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. +export function useSidebarExternalWorktreeCards(args: { + repos: readonly Repo[] + visibleReposForRows: readonly Repo[] + detectedWorktreesByRepo: AppState['detectedWorktreesByRepo'] + filterRepoIds: readonly string[] +}) { + const { repos, visibleReposForRows, detectedWorktreesByRepo, filterRepoIds } = args + const updateRepo = useAppStore((s) => s.updateRepo) + const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) + const [importedWorktreeCardActionState, setImportedWorktreeCardActionState] = useState< + Map + >(new Map()) + const [newExternalWorktreeInboxActionState, setNewExternalWorktreeInboxActionState] = useState< + Map + >(new Map()) + const [suppressExternalWorktreeInboxRepoId, setSuppressExternalWorktreeInboxRepoId] = useState< + string | null + >(null) + + const importedWorktreesByRepo = useMemo(() => { + const forceVisibleRepoIds = new Set( + [...importedWorktreeCardActionState.entries()] + .filter(([, state]) => state.forceVisible) + .map(([repoId]) => repoId) + ) + return buildImportedWorktreesCardCandidates({ + repos: visibleReposForRows, + detectedWorktreesByRepo, + filterRepoIds, + forceVisibleRepoIds + }) + }, [detectedWorktreesByRepo, filterRepoIds, importedWorktreeCardActionState, visibleReposForRows]) + const newExternalWorktreesInboxByRepo = useMemo( + () => + buildNewExternalWorktreesInboxCandidates({ + repos: visibleReposForRows, + detectedWorktreesByRepo, + filterRepoIds + }), + [detectedWorktreesByRepo, filterRepoIds, visibleReposForRows] + ) + + const setImportedWorktreeCardState = useCallback( + (projectId: string, state: ImportedWorktreeCardActionState | null) => { + setImportedWorktreeCardActionState((previous) => { + const next = new Map(previous) + if (state) { + next.set(projectId, state) + } else { + next.delete(projectId) + } + return next + }) + }, + [] + ) + + const handleShowImportedWorktrees = useCallback( + async (projectId: string) => { + await showImportedWorktreesCard({ + projectId, + forceVisible: importedWorktreeCardActionState.get(projectId)?.forceVisible === true, + updateRepo, + fetchWorktrees, + setCardState: setImportedWorktreeCardState + }) + }, + [fetchWorktrees, importedWorktreeCardActionState, setImportedWorktreeCardState, updateRepo] + ) + + const handleKeepImportedWorktreesHidden = useCallback( + async (projectId: string) => { + const repo = repos.find((candidate) => candidate.id === projectId) + let detected = detectedWorktreesByRepo[projectId] + // Why: baseline seeding needs authoritative hidden paths, so don't dismiss on a stale snapshot. + if (detected?.authoritative !== true) { + const refreshed = await fetchWorktrees(projectId, { requireAuthoritative: true }) + if (!refreshed) { + setImportedWorktreeCardState(projectId, { + pending: false, + error: IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR + }) + return + } + detected = useAppStore.getState().detectedWorktreesByRepo[projectId] + } + if (detected?.authoritative !== true) { + setImportedWorktreeCardState(projectId, { + pending: false, + error: IMPORTED_WORKTREES_KEEP_HIDDEN_ERROR + }) + return + } + await keepImportedWorktreesHiddenCard({ + projectId, + updateRepo, + setCardState: setImportedWorktreeCardState, + hiddenWorktreePaths: getHiddenImportedWorktrees(detected).map((worktree) => worktree.path), + existingBaselinePaths: repo?.externalWorktreeInboxBaselinePaths + }) + }, + [detectedWorktreesByRepo, fetchWorktrees, repos, setImportedWorktreeCardState, updateRepo] + ) + + const setNewExternalWorktreeInboxState = useCallback( + (projectId: string, state: NewExternalWorktreesInboxActionState | null) => { + setNewExternalWorktreeInboxActionState((previous) => { + const next = new Map(previous) + if (state) { + next.set(projectId, state) + } else { + next.delete(projectId) + } + return next + }) + }, + [] + ) + + const handleOpenSuppressExternalWorktreeInbox = useCallback((projectId: string) => { + setSuppressExternalWorktreeInboxRepoId(projectId) + }, []) + + const handleConfirmSuppressExternalWorktreeInbox = useCallback(async () => { + if (!suppressExternalWorktreeInboxRepoId) { + return + } + const projectId = suppressExternalWorktreeInboxRepoId + const repo = repos.find((candidate) => candidate.id === projectId) + if (!repo) { + setSuppressExternalWorktreeInboxRepoId(null) + return + } + const inboxWorktrees = newExternalWorktreesInboxByRepo.get(projectId)?.inboxWorktrees ?? [] + const suppressed = await suppressNewExternalWorktreeInbox({ + projectId, + repo, + worktreePaths: inboxWorktrees.map((worktree) => worktree.path), + updateRepo, + setInboxState: setNewExternalWorktreeInboxState + }) + if (suppressed) { + setSuppressExternalWorktreeInboxRepoId(null) + } + }, [ + newExternalWorktreesInboxByRepo, + repos, + setNewExternalWorktreeInboxState, + suppressExternalWorktreeInboxRepoId, + updateRepo + ]) + + return { + importedWorktreeCardActionState, + newExternalWorktreeInboxActionState, + importedWorktreesByRepo, + newExternalWorktreesInboxByRepo, + suppressExternalWorktreeInboxRepoId, + setSuppressExternalWorktreeInboxRepoId, + handleShowImportedWorktrees, + handleKeepImportedWorktreesHidden, + handleOpenSuppressExternalWorktreeInbox, + handleConfirmSuppressExternalWorktreeInbox + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-host-visible-scope.ts b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-host-visible-scope.ts new file mode 100644 index 00000000000..0a0b4cea44a --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-host-visible-scope.ts @@ -0,0 +1,69 @@ +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 { + filterFolderWorkspacesForVisibleHosts, + filterProjectGroupsForVisibleHosts, + getVisibleSidebarHostIdSet +} from '../worktree-list-host-filtering' + +// Narrows repos, project groups, and folder workspaces to the hosts (and devices) the +// current host filter admits. +export function useSidebarHostVisibleScope(args: { + filterState: SidebarWorktreeFilters['filterState'] + defaultHostId: ExecutionHostId + repos: readonly Repo[] + projectGroups: readonly ProjectGroup[] + folderWorkspaces: readonly FolderWorkspace[] + pairedDeviceIdsByEnvironment: Parameters[1] +}) { + const { filterState, defaultHostId, repos, projectGroups, folderWorkspaces } = args + const { visibleWorkspaceHostIds, workspaceHostScope, hideWorkspacesFromOtherDevices } = + filterState + const visibleHostIdSet = useMemo( + () => getVisibleSidebarHostIdSet(visibleWorkspaceHostIds, workspaceHostScope), + [visibleWorkspaceHostIds, workspaceHostScope] + ) + const visibleReposForRows = useMemo(() => { + if (!visibleHostIdSet) { + return repos + } + return repos.filter((repo) => { + const hostId = + repo.connectionId || repo.executionHostId ? getRepoExecutionHostId(repo) : defaultHostId + return visibleHostIdSet.has(hostId) + }) + }, [defaultHostId, repos, visibleHostIdSet]) + const visibleProjectGroupsForRows = useMemo( + () => filterProjectGroupsForVisibleHosts(projectGroups, visibleHostIdSet, defaultHostId), + [defaultHostId, projectGroups, visibleHostIdSet] + ) + const visibleFolderWorkspacesForRows = useMemo(() => { + const hostVisibleWorkspaces = filterFolderWorkspacesForVisibleHosts( + folderWorkspaces, + projectGroups, + visibleHostIdSet, + defaultHostId + ) + if (!hideWorkspacesFromOtherDevices) { + return hostVisibleWorkspaces + } + return filterFolderWorkspacesFromOtherDevices( + hostVisibleWorkspaces, + args.pairedDeviceIdsByEnvironment + ) + }, [ + args.pairedDeviceIdsByEnvironment, + defaultHostId, + folderWorkspaces, + hideWorkspacesFromOtherDevices, + projectGroups, + visibleHostIdSet + ]) + + return { visibleReposForRows, visibleProjectGroupsForRows, visibleFolderWorkspacesForRows } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-reveal-highlight.ts b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-reveal-highlight.ts new file mode 100644 index 00000000000..82811c7cd4a --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-reveal-highlight.ts @@ -0,0 +1,63 @@ +import { useCallback, useRef, useState } from 'react' + +export type SidebarRevealHighlight = ReturnType + +// Owns the frames a reveal schedules and the short glow it leaves on the revealed row. +export function useSidebarRevealHighlight() { + const [highlightedRevealRowKey, setHighlightedRevealRowKey] = useState(null) + const pendingRevealFrameIdsRef = useRef>(new Set()) + const revealHighlightFrameIdRef = useRef(null) + const revealHighlightTimeoutRef = useRef(null) + + const cancelPendingRevealFrames = useCallback(() => { + for (const frameId of pendingRevealFrameIdsRef.current) { + window.cancelAnimationFrame(frameId) + } + pendingRevealFrameIdsRef.current.clear() + }, []) + const schedulePendingRevealFrame = useCallback((callback: FrameRequestCallback) => { + const frameId = window.requestAnimationFrame((time) => { + pendingRevealFrameIdsRef.current.delete(frameId) + callback(time) + }) + pendingRevealFrameIdsRef.current.add(frameId) + }, []) + const clearRevealHighlightFrame = useCallback(() => { + if (revealHighlightFrameIdRef.current !== null) { + window.cancelAnimationFrame(revealHighlightFrameIdRef.current) + revealHighlightFrameIdRef.current = null + } + }, []) + const clearRevealHighlightTimeout = useCallback(() => { + if (revealHighlightTimeoutRef.current !== null) { + window.clearTimeout(revealHighlightTimeoutRef.current) + revealHighlightTimeoutRef.current = null + } + }, []) + const flashRevealedRow = useCallback( + (rowKey: string) => { + clearRevealHighlightTimeout() + clearRevealHighlightFrame() + // Why: clear before set restarts the CSS glow when revealing the same row repeatedly. + setHighlightedRevealRowKey(null) + revealHighlightFrameIdRef.current = window.requestAnimationFrame(() => { + revealHighlightFrameIdRef.current = null + setHighlightedRevealRowKey(rowKey) + revealHighlightTimeoutRef.current = window.setTimeout(() => { + revealHighlightTimeoutRef.current = null + setHighlightedRevealRowKey(null) + }, 1500) + }) + }, + [clearRevealHighlightFrame, clearRevealHighlightTimeout] + ) + + return { + highlightedRevealRowKey, + cancelPendingRevealFrames, + schedulePendingRevealFrame, + clearRevealHighlightFrame, + clearRevealHighlightTimeout, + flashRevealedRow + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-reveal-requests.ts b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-reveal-requests.ts new file mode 100644 index 00000000000..4737a7fc60a --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-reveal-requests.ts @@ -0,0 +1,125 @@ +import { useCallback, useEffect } from 'react' +import { useAppStore } from '@/store' +import { + SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT, + type ScrollToCurrentWorkspaceRevealRequestDetail +} 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 '../worktree-list-groups' +import { getKnownSidebarWorktreeById } from '../worktree-list-folder-reveal' + +// Turns a "show me the current workspace" request into whatever the sidebar must change +// first — grouping mode, active filters — before the viewport can scroll to it. +export function useSidebarRevealRequests(args: { + groupBy: WorktreeGroupBy + renderedSidebarRowKeys: ReadonlySet + renderedWorktreeIds: readonly string[] + currentSidebarWorktreeId: string | null + worktreeMap: Map + folderWorkspaces: readonly FolderWorkspace[] + hasFilters: boolean + clearFilters: () => void +}): void { + const { + groupBy, + renderedSidebarRowKeys, + renderedWorktreeIds, + currentSidebarWorktreeId, + worktreeMap, + folderWorkspaces, + hasFilters, + clearFilters + } = args + const setGroupBy = useAppStore((s) => s.setGroupBy) + const pendingRevealSidebarRow = useAppStore((s) => s.pendingRevealSidebarRow) + const revealSidebarRow = useAppStore((s) => s.revealSidebarRow) + const revealWorktreeInSidebar = useAppStore((s) => s.revealWorktreeInSidebar) + + useEffect(() => { + if (!pendingRevealSidebarRow) { + return + } + const rowKey = pendingRevealSidebarRow.rowKey + const isProjectHeaderTarget = + rowKey.startsWith('project-group:') || + rowKey.startsWith('project:') || + rowKey.startsWith('repo:') + if (isProjectHeaderTarget && groupBy !== 'repo') { + setGroupBy('repo') + return + } + if (!renderedSidebarRowKeys.has(rowKey) && hasFilters) { + clearFilters() + } + }, [ + clearFilters, + groupBy, + hasFilters, + pendingRevealSidebarRow, + renderedSidebarRowKeys, + setGroupBy + ]) + + const handleRevealCurrentWorkspaceRequest = useCallback( + (event: Event) => { + const detail = + event instanceof CustomEvent + ? (event.detail as ScrollToCurrentWorkspaceRevealRequestDetail | undefined) + : undefined + if (detail?.target?.type === 'sidebar-row') { + const sidebarDetail = detail as Extract< + ScrollToCurrentWorkspaceRevealRequestDetail, + { target: { type: 'sidebar-row' } } + > + revealSidebarRow(detail.target.rowKey, { + behavior: 'smooth', + highlight: sidebarDetail.highlight !== false + }) + return + } + if (!currentSidebarWorktreeId) { + return + } + const activeWorktree = getKnownSidebarWorktreeById( + currentSidebarWorktreeId, + worktreeMap, + folderWorkspaces + ) + if (!activeWorktree || activeWorktree.isArchived) { + return + } + if (!renderedWorktreeIds.includes(currentSidebarWorktreeId)) { + // Why: the reveal action must show the current workspace, so relax filters that hide it first. + clearFilters() + } + revealWorktreeInSidebar(currentSidebarWorktreeId, { + behavior: 'smooth', + highlight: true, + beginRename: (detail as { beginRename?: boolean } | undefined)?.beginRename === true + }) + }, + [ + clearFilters, + currentSidebarWorktreeId, + folderWorkspaces, + revealSidebarRow, + renderedWorktreeIds, + revealWorktreeInSidebar, + worktreeMap + ] + ) + + useEffect(() => { + window.addEventListener( + SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT, + handleRevealCurrentWorkspaceRequest + ) + return () => { + window.removeEventListener( + SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT, + handleRevealCurrentWorkspaceRequest + ) + } + }, [handleRevealCurrentWorkspaceRequest]) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-section-rows.ts b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-section-rows.ts new file mode 100644 index 00000000000..9b139e88719 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-section-rows.ts @@ -0,0 +1,260 @@ +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 '../worktree-list-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' + +type SectionRowsArgs = { + groupBy: WorktreeGroupBy + projectOrderBy: ProjectOrderBy + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy + defaultHostId: ExecutionHostId + worktrees: Worktree[] + repos: readonly Repo[] + repoMap: Map + worktreeMap: Map + worktreeLineageById: Record + prCache: AppState['prCache'] | null + settings: AppState['settings'] + workspaceStatuses: readonly WorkspaceStatusDefinition[] + effectiveCollapsedGroups: Set + projectGrouping: ProjectGroupingModel + visibleReposForRows: readonly Repo[] + visibleProjectGroupsForRows: readonly ProjectGroup[] + visibleFolderWorkspacesForRows: readonly FolderWorkspace[] + importedWorktreesByRepo: Parameters[14] + newExternalWorktreesInboxByRepo: Parameters[15] + filterRepoIds: readonly string[] + visibleWorkspaceHostIds: readonly ExecutionHostId[] | null + workspaceHostScope: AppState['workspaceHostScope'] +} + +function collectRenderedSidebarRowKeys(sectionRows: ReturnType) { + const keys = new Set() + for (const row of sectionRows) { + if (row.type === 'header') { + keys.add(row.key) + } else if (row.type === 'item') { + keys.add(row.rowKey) + } else if (row.type === 'folder-workspace') { + keys.add(folderWorkspaceKey(row.folderWorkspace.id)) + } else if (row.type === 'pending-creation') { + keys.add(`pending:${row.creationId}`) + } else if (row.type === 'imported-worktrees-card') { + keys.add(row.key) + } else if (row.type === 'new-external-worktrees-inbox') { + keys.add(row.key) + } + } + return keys +} + +// Builds the full sidebar row model: grouped worktree rows first, then the host-section +// tier wrapped around them. +export function useSidebarSectionRows(args: SectionRowsArgs) { + const { repos, worktrees, repoMap, effectiveCollapsedGroups, defaultHostId } = args + const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) + const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) + const workspaceHostOrder = useAppStore((s) => s.workspaceHostOrder) + const setWorkspaceHostOrder = useAppStore((s) => s.setWorkspaceHostOrder) + + // Why: manual header order is bound to state.repos; Recent/Smart derive order from the sorted worktree stream. + const repoOrder = useMemo( + () => getLogicalRepoOrderRankById(repos.map((repo) => repo.id)), + [repos] + ) + const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos]) + const placeholderRepoIds = useMemo( + () => + getEmptyProjectPlaceholderRepoIds({ + groupBy: args.groupBy, + repos: args.visibleReposForRows, + worktreesByRepo, + visibleWorktrees: worktrees, + filterRepoIds: args.filterRepoIds + }), + [args.filterRepoIds, args.groupBy, args.visibleReposForRows, worktrees, worktreesByRepo] + ) + + // Why: subscribe on a flat key array (useShallow) so progress ticks don't rebuild the whole row model. + // Split on first space — creationId is a UUID (no space) so a space-containing repoId stays intact. + const pendingCreationKeys = useAppStore( + useShallow((s) => + Object.values(s.pendingWorktreeCreations ?? {}).map( + (creation) => `${creation.creationId} ${creation.request.repoId}` + ) + ) + ) + const pendingCreations = useMemo( + () => + pendingCreationKeys.map((key) => { + const separator = key.indexOf(' ') + return { creationId: key.slice(0, separator), repoId: key.slice(separator + 1) } + }), + [pendingCreationKeys] + ) + const hostLabelOverrides = useMemo( + () => getHostDisplayLabelOverrides(args.settings), + [args.settings] + ) + const hostOptions = useMemo( + () => + buildSidebarHostOptions({ + repos, + sshTargetLabels, + sshConnectionStates, + settings: args.settings, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides + }), + [ + repos, + sshTargetLabels, + sshConnectionStates, + args.settings, + runtimeEnvironments, + runtimeStatusByEnvironmentId, + hostLabelOverrides + ] + ) + const hostLabelById = useMemo( + () => new Map(hostOptions.map((host) => [host.id, host.label])), + [hostOptions] + ) + + const rows: Row[] = useMemo( + () => + buildRows( + args.groupBy, + worktrees, + repoMap, + args.prCache, + effectiveCollapsedGroups, + repoOrder, + args.workspaceStatuses, + args.projectOrderBy, + args.worktreeLineageById, + args.worktreeMap, + true, + args.settings, + args.visibleProjectGroupsForRows, + placeholderRepoIds, + args.importedWorktreesByRepo, + args.newExternalWorktreesInboxByRepo, + pendingCreations, + args.projectGrouping, + args.visibleFolderWorkspacesForRows, + hostLabelById, + defaultHostId, + args.pinnedDisplayPolicy + ), + [ + args.groupBy, + worktrees, + repoMap, + args.prCache, + effectiveCollapsedGroups, + defaultHostId, + repoOrder, + args.workspaceStatuses, + args.projectOrderBy, + args.worktreeLineageById, + args.worktreeMap, + args.settings, + args.projectGrouping, + args.visibleProjectGroupsForRows, + args.visibleFolderWorkspacesForRows, + placeholderRepoIds, + args.importedWorktreesByRepo, + args.newExternalWorktreesInboxByRepo, + pendingCreations, + hostLabelById, + args.pinnedDisplayPolicy + ] + ) + const orderedHostOptions = useMemo( + () => orderHostSectionOptions(hostOptions, workspaceHostOrder), + [hostOptions, workspaceHostOrder] + ) + const [hostDragActive, setHostDragActive] = useState(false) + const handleReorderHostSections = useCallback( + (orderedVisibleHostIds: ExecutionHostId[]) => { + const visibleHostIds = new Set(orderedVisibleHostIds) + const hostOptionIds = orderedHostOptions.map((host) => host.id) + const knownHostIds = new Set(hostOptionIds) + const nextOrder: ExecutionHostId[] = [...orderedVisibleHostIds] + const seen = new Set(nextOrder) + // Why: dragging only covers rendered hosts; keep non-rendered SSH/runtime hosts in the saved order so they return in place. + for (const hostId of [...workspaceHostOrder, ...hostOptionIds]) { + if (!knownHostIds.has(hostId) || visibleHostIds.has(hostId) || seen.has(hostId)) { + continue + } + nextOrder.push(hostId) + seen.add(hostId) + } + setWorkspaceHostOrder(nextOrder) + }, + [orderedHostOptions, setWorkspaceHostOrder, workspaceHostOrder] + ) + const sectionRows = useMemo( + () => + addHostSectionRows({ + rows, + hostOptions: orderedHostOptions, + workspaceHostScope: args.workspaceHostScope, + visibleWorkspaceHostIds: args.visibleWorkspaceHostIds, + defaultHostId, + collapsedHostKeys: effectiveCollapsedGroups, + forceCollapseHosts: hostDragActive, + // Why: projects/workspaces are the primary sidebar object; host sections are only an explicit host-filter view. + preferProjectGrouping: true + }), + [ + args.visibleWorkspaceHostIds, + args.workspaceHostScope, + defaultHostId, + effectiveCollapsedGroups, + hostDragActive, + orderedHostOptions, + rows + ] + ) + const renderedSidebarRowKeys = useMemo( + () => collectRenderedSidebarRowKeys(sectionRows), + [sectionRows] + ) + + return { + rows, + sectionRows, + renderedSidebarRowKeys, + allRepoIds, + placeholderRepoIds, + handleReorderHostSections, + setHostDragActive + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-filters.ts b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-filters.ts new file mode 100644 index 00000000000..7f59d344e6d --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-filters.ts @@ -0,0 +1,106 @@ +import { useCallback, useMemo } from 'react' +import { useAppStore } from '@/store' +import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../../shared/constants' +import { computeClearFilterActions, sidebarHasActiveFilters } from '../visible-worktrees' + +export type SidebarWorktreeFilters = ReturnType + +// Every sidebar filter, plus the single escape hatch that resets all of them. +export function useSidebarWorktreeFilters() { + const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) + const filterRepoIds = useAppStore((s) => s.filterRepoIds) + const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) + const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) + const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) + const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) + const hideWorkspacesFromOtherDevices = useAppStore((s) => s.hideWorkspacesFromOtherDevices) + const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) + const visibleWorkspaceHostIds = useAppStore((s) => s.visibleWorkspaceHostIds) + const workspaceHostScope = useAppStore((s) => s.workspaceHostScope) + + const setShowSleepingWorkspaces = useAppStore((s) => s.setShowSleepingWorkspaces) + const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) + const setHideAutomationGeneratedWorkspaces = useAppStore( + (s) => s.setHideAutomationGeneratedWorkspaces + ) + const setHideCliCreatedWorkspaces = useAppStore((s) => s.setHideCliCreatedWorkspaces) + const setHideDetachedHeadWorkspaces = useAppStore((s) => s.setHideDetachedHeadWorkspaces) + const setHideWorkspacesFromOtherDevices = useAppStore((s) => s.setHideWorkspacesFromOtherDevices) + const setAlwaysShowDefaultBranchWorkspace = useAppStore( + (s) => s.setAlwaysShowDefaultBranchWorkspace + ) + const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds) + const setVisibleWorkspaceHostIds = useAppStore((s) => s.setVisibleWorkspaceHostIds) + + // Why: count hideDefaultBranchWorkspace as a filter so the Clear Filters escape hatch stays reachable when it alone empties the list. + const filterState = useMemo( + () => ({ + showSleepingWorkspaces, + filterRepoIds, + hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces, + hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, + alwaysShowDefaultBranchWorkspace, + visibleWorkspaceHostIds, + workspaceHostScope + }), + [ + showSleepingWorkspaces, + filterRepoIds, + hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces, + hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, + alwaysShowDefaultBranchWorkspace, + visibleWorkspaceHostIds, + workspaceHostScope + ] + ) + + const clearFilters = useCallback(() => { + const actions = computeClearFilterActions(filterState) + if (actions.resetShowSleepingWorkspaces) { + setShowSleepingWorkspaces(DEFAULT_SHOW_SLEEPING_WORKSPACES) + } + if (actions.resetFilterRepoIds) { + setFilterRepoIds([]) + } + if (actions.resetHideDefaultBranchWorkspace) { + setHideDefaultBranchWorkspace(false) + } + if (actions.resetHideAutomationGeneratedWorkspaces) { + setHideAutomationGeneratedWorkspaces(false) + } + if (actions.resetHideCliCreatedWorkspaces) { + setHideCliCreatedWorkspaces(false) + } + if (actions.resetHideDetachedHeadWorkspaces) { + setHideDetachedHeadWorkspaces(false) + } + if (actions.resetHideWorkspacesFromOtherDevices) { + setHideWorkspacesFromOtherDevices(false) + } + if (actions.resetAlwaysShowDefaultBranchWorkspace) { + setAlwaysShowDefaultBranchWorkspace(true) + } + if (actions.resetVisibleWorkspaceHostIds) { + setVisibleWorkspaceHostIds(null) + } + }, [ + setShowSleepingWorkspaces, + setFilterRepoIds, + setHideDefaultBranchWorkspace, + setHideAutomationGeneratedWorkspaces, + setHideCliCreatedWorkspaces, + setHideDetachedHeadWorkspaces, + setHideWorkspacesFromOtherDevices, + setAlwaysShowDefaultBranchWorkspace, + setVisibleWorkspaceHostIds, + filterState + ]) + + return { filterState, hasFilters: sidebarHasActiveFilters(filterState), clearFilters } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-selection.ts b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-selection.ts new file mode 100644 index 00000000000..426385c6063 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-selection.ts @@ -0,0 +1,140 @@ +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 '../worktree-list-groups' +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' + +function uniqueWorktreeIds(ids: readonly string[]): string[] { + return Array.from(new Set(ids)) +} + +// Multi-select over the rows the sidebar actually rendered, so gestures, context menus, and +// the Cmd+1–9 shortcut cache all agree on one order. +export function useSidebarWorktreeSelection(args: { + sectionRows: HostSectionRow[] + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy +}) { + const { sectionRows, pinnedDisplayPolicy } = args + // Why: derive order from the built rows, not the flat worktrees array, so Cmd+1–9 match visual positions when grouping reorders cards. + const renderedWorktrees = useMemo( + () => getRenderedWorktreesInSidebarOrder(sectionRows, pinnedDisplayPolicy), + [pinnedDisplayPolicy, sectionRows] + ) + // Why: order-preserving sectionRows rebuilds must not give this array a new + // identity — updateSelectionForGesture depends on it, and a fresh identity + // there defeats React.memo bail-out for every WorktreeCard on epoch bumps. + const renderedWorktreeIds = useReusedArrayIdentity( + useMemo( + () => uniqueWorktreeIds(renderedWorktrees.map((worktree) => worktree.id)), + [renderedWorktrees] + ) + ) + const [selectedWorktreeIds, setSelectedWorktreeIds] = useState>(new Set()) + const [selectionAnchorId, setSelectionAnchorId] = useState(null) + + const prunedSelection = pruneWorktreeSelection( + selectedWorktreeIds, + selectionAnchorId, + renderedWorktreeIds + ) + // Why: filters/grouping can hide selected cards; prune during render so nothing sees stale ids for unrendered worktrees. + if (!areWorktreeSelectionsEqual(selectedWorktreeIds, prunedSelection.selectedIds)) { + setSelectedWorktreeIds(prunedSelection.selectedIds) + } + if (selectionAnchorId !== prunedSelection.anchorId) { + setSelectionAnchorId(prunedSelection.anchorId) + } + + // Why identity reuse: the empty/unchanged-selection case must keep one array + // identity — selectForContextMenu and both drag-start handlers depend on + // this array, and card memo bail-out depends on those staying stable. + const selectedWorktrees = useReusedArrayIdentity( + useMemo(() => { + if (selectedWorktreeIds.size === 0) { + return [] + } + const selected = new Map() + for (const worktree of renderedWorktrees) { + if (selectedWorktreeIds.has(worktree.id) && !selected.has(worktree.id)) { + selected.set(worktree.id, worktree) + } + } + return Array.from(selected.values()) + }, [renderedWorktrees, selectedWorktreeIds]) + ) + + useEffect(() => { + if (selectedWorktreeIds.size === 0) { + return + } + + const clearSelectionOutsideSidebar = (event: PointerEvent): void => { + const target = event.target + const sidebarContainer = document.querySelector('[data-worktree-sidebar-container]') + if (target instanceof Node && sidebarContainer?.contains(target)) { + return + } + setSelectedWorktreeIds(new Set()) + setSelectionAnchorId(null) + } + + document.addEventListener('pointerdown', clearSelectionOutsideSidebar, { capture: true }) + return () => { + document.removeEventListener('pointerdown', clearSelectionOutsideSidebar, { capture: true }) + } + }, [selectedWorktreeIds.size]) + + const updateSelectionForGesture = useCallback( + (event: React.MouseEvent, worktreeId: string): boolean => { + const intent = getWorktreeSelectionIntent(event, navigator.userAgent.includes('Mac')) + const result = updateWorktreeSelection({ + visibleIds: renderedWorktreeIds, + previousSelectedIds: selectedWorktreeIds, + previousAnchorId: selectionAnchorId, + targetId: worktreeId, + intent + }) + setSelectedWorktreeIds(result.selectedIds) + setSelectionAnchorId(result.anchorId) + // Plain click navigates; modifier gestures are selection-only so a batch can build without switching away. + return intent !== 'replace' + }, + [renderedWorktreeIds, selectedWorktreeIds, selectionAnchorId] + ) + + const selectForContextMenu = useCallback( + (_event: React.MouseEvent, worktree: Worktree): readonly Worktree[] => { + if (selectedWorktreeIds.has(worktree.id) && selectedWorktreeIds.size > 1) { + return selectedWorktrees + } + setSelectedWorktreeIds(new Set([worktree.id])) + setSelectionAnchorId(worktree.id) + return [worktree] + }, + [selectedWorktreeIds, selectedWorktrees] + ) + + // Why layout effect: the Cmd/Ctrl+1–9 handler can fire right after commit; publishing after paint would leave the shortcut cache stale. + useLayoutEffect(() => { + setVisibleWorktreeIds(renderedWorktreeIds) + // Why null, not []: [] is a real rendered order (all collapsed/filtered); null tells shortcuts the list is unmounted. + return () => setVisibleWorktreeIds(null) + }, [renderedWorktreeIds]) + + return { + renderedWorktreeIds, + selectedWorktreeIds, + selectedWorktrees, + updateSelectionForGesture, + selectForContextMenu + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-sort-order.ts b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-sort-order.ts new file mode 100644 index 00000000000..64b2e426abf --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-sidebar-worktree-sort-order.ts @@ -0,0 +1,207 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { useAppStore } from '@/store' +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 { + buildAttentionByWorktree, + hasFreshAttributedAgentStatus, + type SmartClass, + type WorktreeAttention +} from '../smart-attention' +import { useReusedArrayIdentity } from './use-reused-array-identity' +import { SORT_SETTLE_MS } from './worktree-sidebar-scroll-tuning' + +function trackSmartClassDistribution(attention: ReadonlyMap): void { + let class1 = 0 + let class2 = 0 + let class3 = 0 + let class4 = 0 + for (const info of attention.values()) { + if (info.cls === 1) { + class1++ + } else if (info.cls === 2) { + class2++ + } else if (info.cls === 3) { + class3++ + } else { + class4++ + } + } + track('smart_sort_class_distribution', { + class_1: class1, + class_2: class2, + class_3: class3, + class_4: class4, + total_worktrees: attention.size + }) +} + +// Why debounce: scores are time-decaying, so recomputing on every sortEpoch bump makes worktrees jump; settle to coalesce. +// Structural changes (add/remove) bypass the debounce so a new worktree appears at its sorted position immediately. +function useDebouncedSortEpoch(worktreeCount: number, sortBy: SortBy): number { + const sortEpoch = useAppStore((s) => s.sortEpoch) + const [debouncedSortEpoch, setDebouncedSortEpoch] = useState(sortEpoch) + const prevWorktreeCountRef = useRef(worktreeCount) + useEffect(() => { + if (debouncedSortEpoch === sortEpoch) { + return + } + + const structuralChange = worktreeCount !== prevWorktreeCountRef.current + prevWorktreeCountRef.current = worktreeCount + + // Why: manual drag/drop is direct manipulation; the settle-window delay would make a successful drop look broken. + if (structuralChange || sortBy === 'manual') { + setDebouncedSortEpoch(sortEpoch) + return + } + + const timer = setTimeout(() => setDebouncedSortEpoch(sortEpoch), SORT_SETTLE_MS) + return () => clearTimeout(timer) + }, [sortEpoch, debouncedSortEpoch, worktreeCount, sortBy]) + return debouncedSortEpoch +} + +// ── Stable sort order ────────────────────────────────────────── +// Why sortEpoch (not selection): selection side-effects (clearing isUnread, PR-cache refresh) must not reorder the sidebar under the user. +// Why useMemo not useEffect: order must be computed synchronously before the worktrees memo reads it. +export function useSidebarWorktreeSortOrder(args: { + allWorktrees: readonly Worktree[] + repoMap: Map + sortBy: SortBy +}): string[] { + const { allWorktrees, repoMap, sortBy } = args + // Non-archived count — detects structural changes (add/remove) so the debounce below can apply immediately. + const worktreeCount = useMemo(() => { + let count = 0 + for (const worktree of allWorktrees) { + if (!worktree.isArchived) { + count++ + } + } + return count + }, [allWorktrees]) + const debouncedSortEpoch = useDebouncedSortEpoch(worktreeCount, sortBy) + + // Why a latching ref: a live signal makes Smart authoritative for the session, even after that activity ends. + const sessionHasHadLiveSmartSignal = useRef(false) + // Why a ref alongside the memo: telemetry effects need the last attention map without re-reading store state. + const lastAttentionByWorktreeRef = useRef | null>(null) + + const recomputedSortedIds = useMemo(() => { + const state = useAppStore.getState() + const nonArchivedWorktrees = getAllWorktreesFromState(state).filter( + (worktree) => !worktree.isArchived + ) + const now = Date.now() + + // Why cold-start detection: agent-status hydrates async, so the warm comparator would collapse all to Class 4; keep the persisted order until a live signal appears. + if (sortBy === 'smart' && !sessionHasHadLiveSmartSignal.current) { + // Why tabHasLivePty over tab.ptyId: slept terminals keep tab.ptyId as a wake hint, so it'd falsely keep cold-start ordering off. + const hasAnyLivePty = Object.values(state.tabsByWorktree) + .flat() + .some((tab) => tabHasLivePty(state.ptyIdsByTabId, tab.id)) + if ( + hasAnyLivePty || + hasFreshAttributedAgentStatus(state.agentStatusByPaneKey, now, state.tabsByWorktree) + ) { + sessionHasHadLiveSmartSignal.current = true + } else { + nonArchivedWorktrees.sort( + (a, b) => b.sortOrder - a.sortOrder || compareWorktreeSortLabel(a, b) + ) + lastAttentionByWorktreeRef.current = null + return nonArchivedWorktrees.map((w) => w.id) + } + } + + // Why precompute: hot sort — build the attention map once so the O(N log N) comparator does O(1) lookups. + const attentionByWorktree = + sortBy === 'smart' + ? buildAttentionByWorktree( + nonArchivedWorktrees, + state.tabsByWorktree, + state.agentStatusByPaneKey, + state.runtimePaneTitlesByTabId, + state.ptyIdsByTabId, + now, + state.migrationUnsupportedByPtyId, + state.terminalLayoutsByTabId + ) + : new Map() + lastAttentionByWorktreeRef.current = sortBy === 'smart' ? attentionByWorktree : null + nonArchivedWorktrees.sort(buildWorktreeComparator(sortBy, repoMap, now, attentionByWorktree)) + return nonArchivedWorktrees.map((w) => w.id) + // debouncedSortEpoch is an intentional trigger not read in the memo; its change (debounced) signals a recompute. + // oxlint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSortEpoch, repoMap, sortBy]) + // Why: stable ID order prevents rank-only refreshes from echoing an unchanged snapshot. + const sortedIds = useReusedArrayIdentity(recomputedSortedIds) + + // Why a ref of prior class: fire class_1_promotion only on transitions into Class 1, not every recompute that stays there. + const prevClassByWorktreeIdRef = useRef>(new Map()) + // Why gate the first observation: an empty prev-class map makes every existing Class-1 worktree look freshly promoted; treat the first pass as a silent baseline. + const hasObservedSmartOnceRef = useRef(false) + + useEffect(() => { + const attention = lastAttentionByWorktreeRef.current + if (sortBy !== 'smart' || !attention) { + // Why reset: leaving Smart drops the prior-class map (and first-observation gate) so re-entry doesn't fire stale promotions. + prevClassByWorktreeIdRef.current = new Map() + hasObservedSmartOnceRef.current = false + return + } + const next = new Map() + const isFirstObservation = !hasObservedSmartOnceRef.current + for (const [worktreeId, info] of attention) { + const prev = prevClassByWorktreeIdRef.current.get(worktreeId) + if (!isFirstObservation && info.cls === 1 && prev !== 1 && info.cause) { + track('smart_sort_class_1_promotion', { cause: info.cause }) + } + next.set(worktreeId, info.cls) + } + prevClassByWorktreeIdRef.current = next + hasObservedSmartOnceRef.current = true + }, [sortBy, recomputedSortedIds]) + + // Why retry on recomputation: Smart may activate before attention hydrates; fire once, then stay quiet until the user leaves Smart. + const hasTrackedSmartDistributionRef = useRef(false) + useEffect(() => { + if (sortBy !== 'smart') { + hasTrackedSmartDistributionRef.current = false + return + } + const attention = lastAttentionByWorktreeRef.current + if (hasTrackedSmartDistributionRef.current || !attention || attention.size === 0) { + return + } + trackSmartClassDistribution(attention) + hasTrackedSmartDistributionRef.current = true + }, [sortBy, recomputedSortedIds]) + + // Why fire on the transition: switching away from Smart is the signal; compare via ref so a round-trip doesn't double-fire. + const prevSortByRef = useRef(sortBy) + useEffect(() => { + const prev = prevSortByRef.current + prevSortByRef.current = sortBy + if (prev === 'smart' && sortBy === 'recent') { + track('smart_to_recent_switch', {}) + } + }, [sortBy]) + + // Why: only persist during live sessions so cold start reads the persisted order instead of overwriting it. + useEffect(() => { + if (sortBy !== 'smart' || sortedIds.length === 0 || !sessionHasHadLiveSmartSignal.current) { + return + } + // Why: sortOrder lives in each host's worktreeMeta, so persist each host's ids on that host. + persistWorktreeSortOrderByHost(useAppStore.getState(), sortedIds) + }, [sortedIds, sortBy]) + + return sortedIds +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-virtual-row-measurement-sync.ts b/src/renderer/src/components/sidebar/worktree-list/use-virtual-row-measurement-sync.ts new file mode 100644 index 00000000000..79f0d53b429 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-virtual-row-measurement-sync.ts @@ -0,0 +1,116 @@ +import { useCallback, useLayoutEffect, useMemo } from 'react' +import type React from 'react' +import { useAppStore } from '@/store' +import { + useVirtualizedScrollAnchor, + type VirtualizedScrollAnchor +} from '@/hooks/useVirtualizedScrollAnchor' +import { + buildLineageRowRekeyMap, + getActiveStickyIndexesForScroll, + getRenderRowKey, + pruneStaleVirtualRowElementCache, + type RenderRow +} from '../worktree-list-virtual-rows' +import { getVirtualRowKey } from './virtual-row-dom-attributes' +import { countRecordKeysByReference } from './worktree-sidebar-scroll-tuning' +import type { WorktreeListVirtualizer } from './use-worktree-list-virtualizer' + +// 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: { + renderRows: RenderRow[] + virtualization: WorktreeListVirtualizer + scrollRef: React.RefObject + scrollOffsetRef: React.MutableRefObject + scrollAnchorRef: React.MutableRefObject + hasDirectScrollInput: () => boolean + shouldSkipScrollAnchorRestore: () => boolean +}) { + const { + renderRows, + virtualization, + scrollRef, + scrollOffsetRef, + scrollAnchorRef, + hasDirectScrollInput, + shouldSkipScrollAnchorRestore + } = args + const { virtualizer, isCurrentVirtualRowElement } = virtualization + const prCacheLen = useAppStore((s) => countRecordKeysByReference(s.prCache)) + const issueCacheLen = useAppStore((s) => countRecordKeysByReference(s.issueCache)) + const renderRowKeySignature = useMemo( + () => renderRows.map(getRenderRowKey).join('\n'), + [renderRows] + ) + const activeRenderRowKeys = useMemo(() => new Set(renderRows.map(getRenderRowKey)), [renderRows]) + const lineageRowRekeys = useMemo(() => buildLineageRowRekeyMap(renderRows), [renderRows]) + const totalSize = virtualizer.getTotalSize() + const virtualItems = virtualizer.getVirtualItems() + const activeStickyIndexes = getActiveStickyIndexesForScroll({ + rows: renderRows, + rangeStartIndex: virtualization.stickyRangeStartIndexRef.current, + scrollOffset: virtualizer.scrollOffset ?? scrollOffsetRef.current, + stickyHeaderIndexes: virtualization.stickyHeaderIndexes, + virtualItems + }) + virtualization.activeStickyHeaderIndexRef.current = activeStickyIndexes.groupIndex + virtualization.activeStickyHostIndexRef.current = activeStickyIndexes.hostIndex + + const measureMountedRows = useCallback(() => { + virtualizer.elementsCache.forEach((element) => { + if (!isCurrentVirtualRowElement(element)) { + return + } + virtualizer.measureElement(element) + }) + }, [isCurrentVirtualRowElement, virtualizer]) + const measureVirtualRowElement = useCallback( + (element: HTMLDivElement | null) => { + if (!element) { + virtualizer.measureElement(null) + return + } + if (!isCurrentVirtualRowElement(element)) { + return + } + virtualizer.measureElement(element) + }, + [isCurrentVirtualRowElement, virtualizer] + ) + + useLayoutEffect(() => { + pruneStaleVirtualRowElementCache({ + activeRowKeys: activeRenderRowKeys, + virtualizer + }) + // Why: a stale retained element after delete/collapse measures 0px and corrupts the next slot; measure only key-matched rows. + measureMountedRows() + const frameId = window.requestAnimationFrame(measureMountedRows) + return () => window.cancelAnimationFrame(frameId) + }, [ + activeRenderRowKeys, + prCacheLen, + issueCacheLen, + measureMountedRows, + renderRowKeySignature, + virtualizer + ]) + + useVirtualizedScrollAnchor({ + anchorRef: scrollAnchorRef, + getItemElementKey: getVirtualRowKey, + getRowKey: getRenderRowKey, + itemElementSelector: '[data-worktree-virtual-row]', + rekeyedRowKeys: lineageRowRekeys, + rows: renderRows, + scrollElementRef: scrollRef, + scrollOffsetRef, + hasDirectScrollInput, + shouldSkipRestore: shouldSkipScrollAnchorRestore, + totalSize, + virtualizer + }) + + return { virtualItems, measureVirtualRowElement } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-visible-pr-refresh-reporting.ts b/src/renderer/src/components/sidebar/worktree-list/use-visible-pr-refresh-reporting.ts new file mode 100644 index 00000000000..ba6bafc0b5a --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-visible-pr-refresh-reporting.ts @@ -0,0 +1,130 @@ +import { useEffect, useRef, useState } from 'react' +import type React from 'react' +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 '../worktree-list-groups' +import type { RenderRow } from '../worktree-list-virtual-rows' +import type { WorktreeItemRow } from './render-row-item-rows' +import { installWorktreeVisibleRefreshVisibilityListener } from './worktree-visible-refresh-listener' + +const DOCUMENT_HIDDEN_KEY = '__document_hidden__' +const NOTHING_TO_TRACK_KEY = '__hidden__' + +// Reports which sidebar rows are on screen so the GitHub PR/CI coordinator can refresh +// exactly those, and no more. +export function useVisiblePrRefreshReporting(args: { + currentWorktreeId: string | null + worktreeMap: Map + groupBy: WorktreeGroupBy + newCardStyle: boolean + renderRows: RenderRow[] + virtualItems: readonly VirtualItem[] + scrollRef: React.RefObject +}): void { + const { + currentWorktreeId, + worktreeMap, + groupBy, + newCardStyle, + renderRows, + virtualItems, + scrollRef + } = args + const [documentVisibilityRevision, setDocumentVisibilityRevision] = useState(0) + const lastVisibleRefreshKeyRef = useRef('') + const reportVisibleGitHubPRRefreshCandidates = useAppStore( + (s) => s.reportVisibleGitHubPRRefreshCandidates + ) + const cardProps = useAppStore((s) => s.worktreeCardProperties) + const rightSidebarShowsPR = useAppStore((s) => rightSidebarShowsPullRequestData(s)) + const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration) + const prVisibleRefreshGeneration = useAppStore((s) => s.prVisibleRefreshGeneration) + + useEffect( + () => + installWorktreeVisibleRefreshVisibilityListener(() => { + if (document.visibilityState !== 'visible') { + // Why: row identity may be unchanged after a hidden window; reset the key so PR/CI rows refresh. + lastVisibleRefreshKeyRef.current = DOCUMENT_HIDDEN_KEY + return + } + setDocumentVisibilityRevision((revision) => revision + 1) + }), + [] + ) + + useEffect(() => { + if (document.visibilityState !== 'visible') { + lastVisibleRefreshKeyRef.current = DOCUMENT_HIDDEN_KEY + return + } + const currentWorktree = currentWorktreeId ? (worktreeMap.get(currentWorktreeId) ?? null) : null + // Why: this reporter feeds the GitHub coordinator; GitLab-only MR panels refresh via hosted-review paths. + const sidebarWorktreeHasGitHubReview = + currentWorktree !== null && + ((currentWorktree.linkedGitLabMR ?? null) === null || + (currentWorktree.linkedPR ?? null) !== null) + const shouldTrackSidebarWorktree = rightSidebarShowsPR && sidebarWorktreeHasGitHubReview + const shouldTrackVisibleRows = + groupBy === 'pr-status' || + (newCardStyle + ? cardProps.includes('status') + : cardProps.includes('pr') || cardProps.includes('ci')) + if (!shouldTrackVisibleRows && !shouldTrackSidebarWorktree) { + if (lastVisibleRefreshKeyRef.current !== NOTHING_TO_TRACK_KEY) { + lastVisibleRefreshKeyRef.current = NOTHING_TO_TRACK_KEY + reportVisibleGitHubPRRefreshCandidates([], Date.now()) + } + return + } + const scrollEl = scrollRef.current + if (!scrollEl) { + return + } + const viewportTop = scrollEl.scrollTop + const viewportBottom = viewportTop + scrollEl.clientHeight + const visibleRows = virtualItems + .filter((item) => item.start < viewportBottom && item.end > viewportTop) + .map((item) => renderRows[item.index]) + .filter((row): row is WorktreeItemRow => row?.type === 'item') + .filter((row) => row.repo?.kind === 'git' && !row.worktree.isBare && row.worktree.branch) + const visibleWorktreeIds = new Set(visibleRows.map((row) => row.worktree.id)) + if ( + shouldTrackSidebarWorktree && + currentWorktree && + !currentWorktree.isBare && + currentWorktree.branch + ) { + visibleWorktreeIds.add(currentWorktree.id) + } + const visibleIdentity = visibleRows + .map((row) => `${row.worktree.id}:${row.worktree.branch}:${row.worktree.linkedPR ?? ''}`) + .join('|') + const sidebarIdentity = + shouldTrackSidebarWorktree && currentWorktree + ? `${currentWorktree.id}:${currentWorktree.branch}:${currentWorktree.linkedPR ?? ''}` + : '' + const key = `${visibleIdentity}:${sidebarIdentity}:${sshConnectedGeneration}:${prVisibleRefreshGeneration}:${cardProps.join(',')}` + if (!key || key === lastVisibleRefreshKeyRef.current) { + return + } + lastVisibleRefreshKeyRef.current = key + reportVisibleGitHubPRRefreshCandidates(Array.from(visibleWorktreeIds), Date.now()) + }, [ + cardProps, + currentWorktreeId, + documentVisibilityRevision, + groupBy, + renderRows, + reportVisibleGitHubPRRefreshCandidates, + prVisibleRefreshGeneration, + rightSidebarShowsPR, + scrollRef, + sshConnectedGeneration, + newCardStyle, + virtualItems, + worktreeMap + ]) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-visible-sidebar-worktrees.ts b/src/renderer/src/components/sidebar/worktree-list/use-visible-sidebar-worktrees.ts new file mode 100644 index 00000000000..bd1f37fc708 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-visible-sidebar-worktrees.ts @@ -0,0 +1,134 @@ +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 { + EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, + getPairedDeviceIdsByEnvironment +} 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' +import { EMPTY_WORKTREE_ID_SET } from './stable-empty-store-slices' +import { useReusedArrayIdentity } from './use-reused-array-identity' + +// Applies every sidebar filter to the sorted id stream. Flatten/filter/sort goes through the +// shared utility so card order matches Cmd+1–9 numbering. +export function useVisibleSidebarWorktrees(args: { + filterState: SidebarWorktreeFilters['filterState'] + sortBy: SortBy + sortedIds: string[] + repoMap: Map + worktreeMap: Map + worktreeLineageById: Record + settings: AppState['settings'] + agentSendTargetWorktreeId: string | null +}) { + const { filterState, sortBy, sortedIds, repoMap, worktreeMap, worktreeLineageById, settings } = + args + const { + showSleepingWorkspaces, + filterRepoIds, + hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces, + hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, + alwaysShowDefaultBranchWorkspace, + visibleWorkspaceHostIds, + workspaceHostScope + } = filterState + const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const agentStatusEpoch = useAppStore((s) => (!showSleepingWorkspaces ? s.agentStatusEpoch : 0)) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) + const pairedDeviceIdsByEnvironment = useMemo( + () => + hideWorkspacesFromOtherDevices + ? getPairedDeviceIdsByEnvironment(runtimeEnvironments, runtimeStatusByEnvironmentId) + : EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, + [hideWorkspacesFromOtherDevices, runtimeEnvironments, runtimeStatusByEnvironmentId] + ) + + // Read tabsByWorktree when needed for filtering or sorting + const needsActivityMaps = !showSleepingWorkspaces || sortBy === 'smart' + const tabsByWorktree = useAppStore((s) => + needsActivityMaps ? getVisibleWorktreeTerminalActivityTabs(s.tabsByWorktree) : null + ) + const ptyIdsByTabId = useAppStore((s) => (needsActivityMaps ? s.ptyIdsByTabId : null)) + const browserTabsByWorktree = useAppStore((s) => + !showSleepingWorkspaces ? getVisibleWorktreeBrowserActivityTabs(s.browserTabsByWorktree) : null + ) + + const recomputedVisibleWorktrees = useMemo(() => { + void agentStatusEpoch + const ids = computeVisibleWorktreeIds(worktreesByRepo, sortedIds, { + filterRepoIds, + showSleepingWorkspaces, + tabsByWorktree, + ptyIdsByTabId, + browserTabsByWorktree, + // Why snapshot on agentStatusEpoch: update membership immediately without repainting on every hook ping. + worktreeIdsWithLiveAgent: showSleepingWorkspaces + ? EMPTY_WORKTREE_ID_SET + : getWorktreeIdsWithLiveAgent( + useAppStore.getState().agentStatusByPaneKey, + tabsByWorktree, + Date.now() + ), + hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces, + hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, + pairedDeviceIdsByEnvironment, + alwaysShowDefaultBranchWorkspace, + repoMap, + workspaceHostScope, + visibleWorkspaceHostIds, + defaultHostId: getSettingsFocusedExecutionHostId(settings), + worktreeLineageById, + forcedVisibleWorktreeIds: args.agentSendTargetWorktreeId + ? [args.agentSendTargetWorktreeId] + : undefined + }) + return ids.map((id) => worktreeMap.get(id)).filter((w): w is Worktree => w != null) + }, [ + args.agentSendTargetWorktreeId, + agentStatusEpoch, + filterRepoIds, + showSleepingWorkspaces, + hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces, + hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices, + alwaysShowDefaultBranchWorkspace, + workspaceHostScope, + visibleWorkspaceHostIds, + settings, + repoMap, + tabsByWorktree, + ptyIdsByTabId, + browserTabsByWorktree, + sortedIds, + worktreeMap, + worktreeLineageById, + worktreesByRepo, + pairedDeviceIdsByEnvironment + ]) + // Why: agentStatusEpoch bumps recompute this memo even when membership and + // order are unchanged; keeping the previous identity stops the whole + // rows/sectionRows/renderedWorktrees chain from churning per epoch. + const visibleWorktrees = useReusedArrayIdentity(recomputedVisibleWorktrees) + + return { visibleWorktrees, pairedDeviceIdsByEnvironment } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-workspace-status-row-drag.ts b/src/renderer/src/components/sidebar/worktree-list/use-workspace-status-row-drag.ts new file mode 100644 index 00000000000..cb45d99a93f --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-workspace-status-row-drag.ts @@ -0,0 +1,147 @@ +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 '../worktree-list-groups' +import { hasWorkspaceDragData, readWorkspaceDragDataIds } from '../workspace-status' +import { useWorkspaceStatusDocumentDrop } from '../use-workspace-status-drop' +import type { WorktreeDropCommitContext } from './worktree-drop-commit-context' +import type { WorktreeDragSession } from './use-worktree-drag-session' +import type { WorktreeDragRuntime } from './use-worktree-drag-runtime' + +// Drag-and-drop onto a status section header or a status-grouped row, including the +// document-level fallback used when the pointer leaves the sidebar mid-drag. +export function useWorkspaceStatusRowDrag(args: { + ctx: WorktreeDropCommitContext + session: WorktreeDragSession + runtime: WorktreeDragRuntime + scrollRef: React.RefObject + rows: HostSectionRow[] + groupBy: WorktreeGroupBy + onMoveWorktreeToStatus: (worktreeId: string, status: WorkspaceStatus) => void + onPinWorktree: (worktreeId: string) => void +}) { + const { ctx, session, runtime, scrollRef, rows, groupBy } = args + const { setDragOverStatus, setPinDragOver, clearWorktreeDrag } = runtime + + const hasWorkspaceDropTargets = useMemo( + () => + groupBy === 'workspace-status' || + rows.some((row) => row.type === 'header' && row.key === PINNED_GROUP_KEY), + [groupBy, rows] + ) + + const handleWorkspaceStatusDragOver = useCallback( + (event: React.DragEvent, status: WorkspaceStatus) => { + if (!hasWorkspaceDragData(event.dataTransfer)) { + return + } + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + setDragOverStatus(status) + }, + [setDragOverStatus] + ) + + const handleWorkspaceStatusDragLeave = useCallback( + (event: React.DragEvent) => { + const relatedTarget = event.relatedTarget + if (relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget)) { + return + } + setDragOverStatus(null) + }, + [setDragOverStatus] + ) + + const handleWorkspacePinDragOver = useCallback( + (event: React.DragEvent) => { + if (!hasWorkspaceDragData(event.dataTransfer)) { + return + } + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + setPinDragOver(true) + }, + [setPinDragOver] + ) + + const handleWorkspacePinDragLeave = useCallback( + (event: React.DragEvent) => { + const relatedTarget = event.relatedTarget + if (relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget)) { + return + } + setPinDragOver(false) + }, + [setPinDragOver] + ) + + const handleWorkspaceStatusDragFinish = useCallback(() => { + setDragOverStatus(null) + setPinDragOver(false) + }, [setDragOverStatus, setPinDragOver]) + + const handleWorkspaceStatusDrop = useCallback( + (event: React.DragEvent, status: WorkspaceStatus) => { + const worktreeIds = readWorkspaceDragDataIds(event.dataTransfer) + if (worktreeIds.length === 0) { + return + } + event.preventDefault() + const dragSession = session.worktreeDragSessionRef.current + const statusDrop = dragSession + ? ctx.computeWorktreeStatusDrop({ + pointerY: event.clientY, + status, + draggedIds: dragSession.reorderDraggedIds + }) + : null + setDragOverStatus(null) + if (dragSession && statusDrop) { + event.stopPropagation() + ctx.onMoveWorktreesToStatusAtIndex({ + worktreeIds: dragSession.reorderDraggedIds, + status, + dropIndex: statusDrop.dropIndex, + groups: ctx.worktreeDragGroups + }) + clearWorktreeDrag() + return + } + // Match status-drop scope to drag-preview scope (#9083): session uses its expanded set, else expand dataTransfer ids live. + ctx.onMoveWorktreesToStatus( + dragSession ? dragSession.reorderDraggedIds : session.getReorderDraggedIds(worktreeIds), + status + ) + }, + [clearWorktreeDrag, ctx, session, setDragOverStatus] + ) + + // Why: expand here (not the shared hook, used by the flat board) so a dropped parent carries its lineage children (#9083). + const moveWorktreesToStatusForDocumentDrop = useCallback( + (ids: readonly string[], status: WorkspaceStatus) => + ctx.onMoveWorktreesToStatus(session.getReorderDraggedIds(ids), status), + [ctx, session] + ) + + useWorkspaceStatusDocumentDrop( + scrollRef, + args.onMoveWorktreeToStatus, + args.onPinWorktree, + handleWorkspaceStatusDragFinish, + hasWorkspaceDropTargets, + { + onMoveWorktreesToStatus: moveWorktreesToStatusForDocumentDrop, + onPinWorktrees: ctx.onPinWorktrees + } + ) + + return { + handleWorkspaceStatusDragOver, + handleWorkspaceStatusDragLeave, + handleWorkspacePinDragOver, + handleWorkspacePinDragLeave, + handleWorkspaceStatusDrop + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-document-drop.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-document-drop.ts new file mode 100644 index 00000000000..7902f0647cb --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-document-drop.ts @@ -0,0 +1,115 @@ +import { useEffect } from 'react' +import type React from 'react' +import { getFullDropIndexForWorktreeDragUnit } from '../worktree-drag-units' +import type { WorktreeSidebarDragSession } from '../worktree-sidebar-drag-autoscroll' +import { getPointerDropStatusTarget } from './pointer-drop-status-target' +import type { WorktreeDropCommitContext } from './worktree-drop-commit-context' +import { NO_WORKTREE_SIDEBAR_DROP_TARGET } from './worktree-row-drag-state' + +// Why: a drop that lands outside the sidebar still belongs to the in-flight session, so the +// capture-phase document listeners commit or abandon it before anything else sees the event. +export function useWorktreeDocumentDrop(args: { + ctx: WorktreeDropCommitContext + worktreeDragSessionRef: React.MutableRefObject +}): void { + const { ctx, worktreeDragSessionRef } = args + const { clearWorktreeDrag } = ctx + + useEffect(() => { + const handleDocumentDrop = (event: DragEvent): void => { + const session = worktreeDragSessionRef.current + if (!session) { + return + } + if (!ctx.refreshWorktreeDragSession()) { + clearWorktreeDrag() + return + } + const drop = ctx.computeWorktreeDrop(event.clientY) + if (!drop) { + const container = ctx.scrollRef.current + const target = ctx.getEligibleLineageDropTarget( + container + ? getPointerDropStatusTarget({ + container, + x: event.clientX, + y: event.clientY + }) + : NO_WORKTREE_SIDEBAR_DROP_TARGET, + session.draggedIds + ) + if (target.lineageParentId) { + event.preventDefault() + event.stopPropagation() + ctx.commitWorktreeLineageParentDrop(session.draggedIds, target.lineageParentId) + clearWorktreeDrag() + return + } + const statusDrop = target.status + ? ctx.computeWorktreeStatusDrop({ + pointerY: event.clientY, + status: target.status, + draggedIds: session.reorderDraggedIds + }) + : null + if (target.status && statusDrop) { + event.preventDefault() + event.stopPropagation() + ctx.onMoveWorktreesToStatusAtIndex({ + worktreeIds: session.reorderDraggedIds, + status: target.status, + dropIndex: statusDrop.dropIndex, + groups: ctx.worktreeDragGroups + }) + clearWorktreeDrag() + return + } + clearWorktreeDrag() + return + } + // Why: pointer still inside the source group means reorder, not status move; commit here and stop the capture handler. + event.preventDefault() + event.stopPropagation() + ctx.onReorderWorktrees({ + groups: ctx.worktreeDragGroups, + sourceGroupKey: session.sourceGroupKey, + draggedIds: session.reorderDraggedIds, + dropIndex: getFullDropIndexForWorktreeDragUnit({ + groups: ctx.worktreeDragUnitGroups, + sourceGroupKey: session.sourceGroupKey, + dropIndex: drop.dropIndex + }) + }) + ctx.clearReorderedWorktreeParents({ + draggedIds: session.draggedIds, + sourceGroupKey: session.sourceGroupKey + }) + clearWorktreeDrag() + } + + document.addEventListener('drop', handleDocumentDrop, true) + return () => document.removeEventListener('drop', handleDocumentDrop, true) + }, [clearWorktreeDrag, ctx, worktreeDragSessionRef]) + + useEffect(() => { + const handleDocumentDragEnd = (): void => { + if (worktreeDragSessionRef.current) { + clearWorktreeDrag() + } + } + + document.addEventListener('dragend', handleDocumentDragEnd, true) + return () => document.removeEventListener('dragend', handleDocumentDragEnd, true) + }, [clearWorktreeDrag, worktreeDragSessionRef]) + + useEffect(() => { + const handleVisibilityChange = (): void => { + if (document.visibilityState !== 'visible' && worktreeDragSessionRef.current) { + clearWorktreeDrag() + } + } + + document.addEventListener('visibilitychange', handleVisibilityChange) + return () => document.removeEventListener('visibilitychange', handleVisibilityChange) + }, [clearWorktreeDrag, worktreeDragSessionRef]) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-drag-runtime.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-drag-runtime.ts new file mode 100644 index 00000000000..978c31c5cc3 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-drag-runtime.ts @@ -0,0 +1,123 @@ +import { useCallback, useMemo, useRef, useState } from 'react' +import type React from 'react' +import type { WorkspaceStatus } from '../../../../../shared/worktree/types' +import { clearWorkspaceKanbanSidebarDropTargetVisual } from '../workspace-kanban-sidebar-drop' +import { setSidebarPointerDragDocumentStyles } from '../worktree-sidebar-pointer-drag-dom' +import type { + WorktreeSidebarDragPoint, + WorktreeSidebarDragSession +} from '../worktree-sidebar-drag-autoscroll' +import type { WorktreeSidebarDropAnchor } from '../worktree-sidebar-drag-geometry' +import { + WORKTREE_ROW_DRAG_INITIAL_STATE, + type WorktreePointerDrag, + type WorktreeRowDragState +} from './worktree-row-drag-state' + +export type WorktreeDragRuntime = ReturnType + +// Owns everything a drag leaves behind: the visual state the rows read, the RAF handles, +// and the single teardown both the pointer and native drag paths call. +export function useWorktreeDragRuntime(args: { + worktreeDragSessionRef: React.MutableRefObject + statusDropAnchorsRef: React.MutableRefObject> + onWorkspaceBoardDragPreviewCancel: () => void +}) { + const { worktreeDragSessionRef, statusDropAnchorsRef, onWorkspaceBoardDragPreviewCancel } = args + const [dragOverStatus, setDragOverStatus] = useState(null) + const [pinDragOver, setPinDragOver] = useState(false) + const [nativeLineageDropTargetId, setNativeLineageDropTargetId] = useState(null) + const [worktreeDragState, setWorktreeDragState] = useState( + WORKTREE_ROW_DRAG_INITIAL_STATE + ) + const worktreePointerDragRef = useRef(null) + const pointerAutoscrollFrameIdRef = useRef(null) + const pointerAutoscrollLastFrameTimeRef = useRef(null) + const nativeAutoscrollFrameIdRef = useRef(null) + const nativeAutoscrollLastFrameTimeRef = useRef(null) + const nativeLatestPointRef = useRef(null) + const suppressWorktreeClickUntilRef = useRef(0) + + const cancelWorktreePointerAutoscroll = useCallback(() => { + if (pointerAutoscrollFrameIdRef.current !== null) { + window.cancelAnimationFrame(pointerAutoscrollFrameIdRef.current) + pointerAutoscrollFrameIdRef.current = null + } + pointerAutoscrollLastFrameTimeRef.current = null + }, []) + + const cancelWorktreeNativeAutoscroll = useCallback(() => { + if (nativeAutoscrollFrameIdRef.current !== null) { + window.cancelAnimationFrame(nativeAutoscrollFrameIdRef.current) + nativeAutoscrollFrameIdRef.current = null + } + nativeAutoscrollLastFrameTimeRef.current = null + nativeLatestPointRef.current = null + }, []) + + const cleanupWorktreePointerDrag = useCallback(() => { + const drag = worktreePointerDragRef.current + cancelWorktreePointerAutoscroll() + setNativeLineageDropTargetId(null) + if (!drag) { + return + } + if (drag.frameId !== null) { + window.cancelAnimationFrame(drag.frameId) + } + drag.preview?.remove() + worktreePointerDragRef.current = null + setSidebarPointerDragDocumentStyles(false) + setDragOverStatus(null) + setPinDragOver(false) + clearWorkspaceKanbanSidebarDropTargetVisual() + onWorkspaceBoardDragPreviewCancel() + }, [cancelWorktreePointerAutoscroll, onWorkspaceBoardDragPreviewCancel]) + + const clearWorktreeDrag = useCallback(() => { + cleanupWorktreePointerDrag() + cancelWorktreeNativeAutoscroll() + worktreeDragSessionRef.current = null + statusDropAnchorsRef.current.clear() + setWorktreeDragState(WORKTREE_ROW_DRAG_INITIAL_STATE) + }, [ + cancelWorktreeNativeAutoscroll, + cleanupWorktreePointerDrag, + statusDropAnchorsRef, + worktreeDragSessionRef + ]) + + // Why: drag handlers built from this object end up on memoised cards; keep one identity + // per meaningful change instead of one per render. + return useMemo( + () => ({ + dragOverStatus, + setDragOverStatus, + pinDragOver, + setPinDragOver, + nativeLineageDropTargetId, + setNativeLineageDropTargetId, + worktreeDragState, + setWorktreeDragState, + worktreePointerDragRef, + pointerAutoscrollFrameIdRef, + pointerAutoscrollLastFrameTimeRef, + nativeAutoscrollFrameIdRef, + nativeAutoscrollLastFrameTimeRef, + nativeLatestPointRef, + suppressWorktreeClickUntilRef, + cancelWorktreePointerAutoscroll, + cancelWorktreeNativeAutoscroll, + clearWorktreeDrag + }), + [ + cancelWorktreeNativeAutoscroll, + cancelWorktreePointerAutoscroll, + clearWorktreeDrag, + dragOverStatus, + nativeLineageDropTargetId, + pinDragOver, + worktreeDragState + ] + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-drag-session.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-drag-session.ts new file mode 100644 index 00000000000..f1077c71ff0 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-drag-session.ts @@ -0,0 +1,244 @@ +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 '../worktree-list-groups' +import { WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP } from '../worktree-list-virtual-rows' +import { getWorkspaceStatusGroupKey } from '../workspace-status' +import { expandDraggedWorktreeIdsForVisibleLineage } from '../worktree-manual-order' +import { getWorktreeDragUnitGroups } from '../worktree-drag-units' +import { + getWorktreeSidebarDragRectsForGroup, + refreshWorktreeSidebarDragSession, + type WorktreeSidebarDragRect, + type WorktreeSidebarDragSession +} from '../worktree-sidebar-drag-autoscroll' +import { + shouldReevaluateWorktreeSidebarDropAnchor, + type WorktreeSidebarDragGrab, + type WorktreeSidebarDropAnchor +} from '../worktree-sidebar-drag-geometry' +import { + computeWorktreeSidebarDropPreview, + type WorktreeSidebarDropPreview +} from '../worktree-sidebar-drop-preview' +import { getWorktreeDragGroups, getWorktreeDragIndexes } from './worktree-drag-groups' +import type { WorktreeItemRow } from './render-row-item-rows' + +export type WorktreeStatusDropRequest = { + pointerY: number + status: WorkspaceStatus + draggedIds: readonly string[] +} + +export type WorktreeDragSession = ReturnType + +// Owns the geometry side of a sidebar row drag: which groups exist, which ids travel +// together, and where the insertion line lands for a given pointer position. +export function useWorktreeDragSession(args: { + rows: HostSectionRow[] + scrollRef: React.RefObject +}) { + const { rows, scrollRef } = args + const worktreeDragSessionRef = useRef(null) + // Why: cross-group hovers hit-test a group the session never captured, so hold + // that group's drop decision separately or a card expanding in the target group + // moves the insertion line under a still pointer. + const statusDropAnchorsRef = useRef>(new Map()) + + const worktreeDragGroups = useMemo(() => getWorktreeDragGroups(rows), [rows]) + const worktreeDragUnitGroups = useMemo(() => getWorktreeDragUnitGroups(rows), [rows]) + const naturalDragWorktreeIds = useMemo( + () => + new Set( + rows.flatMap((row) => + row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : [] + ) + ), + [rows] + ) + const worktreeLineageDragRows = useMemo( + () => + rows + .filter((row): row is WorktreeItemRow => row.type === 'item') + .filter( + (row) => + row.sectionKey !== PINNED_GROUP_KEY || !naturalDragWorktreeIds.has(row.worktree.id) + ) + .map((row) => ({ worktreeId: row.worktree.id, depth: row.depth })), + [naturalDragWorktreeIds, rows] + ) + const getReorderDraggedIds = useCallback( + (draggedIds: readonly string[]) => + expandDraggedWorktreeIdsForVisibleLineage(worktreeLineageDragRows, draggedIds), + [worktreeLineageDragRows] + ) + const getReorderUnitDraggedIds = useCallback( + (sourceGroupKey: string, reorderDraggedIds: readonly string[]) => { + const group = worktreeDragUnitGroups.find((candidate) => candidate.key === sourceGroupKey) + if (!group) { + return reorderDraggedIds + } + const unitIds = new Set(group.worktreeIds) + const filtered = reorderDraggedIds.filter((worktreeId) => unitIds.has(worktreeId)) + return filtered.length > 0 ? filtered : reorderDraggedIds + }, + [worktreeDragUnitGroups] + ) + const { groupKeyByRowKey, groupIndexByRowKey } = useMemo( + () => getWorktreeDragIndexes(rows), + [rows] + ) + const refreshWorktreeDragSession = useCallback((): boolean => { + const session = worktreeDragSessionRef.current + const container = scrollRef.current + if (!session || !container) { + return false + } + + const refreshedSession = refreshWorktreeSidebarDragSession({ + session, + groups: worktreeDragGroups, + unitGroups: worktreeDragUnitGroups, + rects: getWorktreeSidebarDragRectsForGroup(container, session.sourceGroupKey) + }) + worktreeDragSessionRef.current = refreshedSession + return refreshedSession !== null + }, [scrollRef, worktreeDragGroups, worktreeDragUnitGroups]) + const computeWorktreeDropForGroup = useCallback( + (dropArgs: { + pointerY: number + groupKey: string + rects: readonly WorktreeSidebarDragRect[] + draggedIds: readonly string[] + draggingWorktreeId?: string | null + grab?: WorktreeSidebarDragGrab | null + anchor?: WorktreeSidebarDropAnchor | null + }): WorktreeSidebarDropPreview | null => { + const container = scrollRef.current + if (!container) { + return null + } + const group = worktreeDragUnitGroups.find((candidate) => candidate.key === dropArgs.groupKey) + if (!group) { + return null + } + const containerRect = container.getBoundingClientRect() + return computeWorktreeSidebarDropPreview({ + pointerY: dropArgs.pointerY, + containerTop: containerRect.top, + scrollTop: container.scrollTop, + rects: dropArgs.rects, + groupIds: group.worktreeIds, + draggedIds: dropArgs.draggedIds, + draggingWorktreeId: dropArgs.draggingWorktreeId, + fallbackGap: WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP, + grab: dropArgs.grab, + anchor: dropArgs.anchor + }) + }, + [scrollRef, worktreeDragUnitGroups] + ) + const computeWorktreeDrop = useCallback( + (pointerY: number): WorktreeSidebarDropPreview | null => { + const session = worktreeDragSessionRef.current + const container = scrollRef.current + if (!session || !container) { + return null + } + const scrollTop = container.scrollTop + // Why: only real pointer or scroll movement should re-decide the slot; a + // card growing under a still pointer must not move it. + const anchor = shouldReevaluateWorktreeSidebarDropAnchor({ + anchor: session.anchor, + pointerY, + scrollTop + }) + ? null + : session.anchor + const preview = computeWorktreeDropForGroup({ + pointerY, + groupKey: session.sourceGroupKey, + rects: session.rects, + draggedIds: session.reorderUnitDraggedIds, + draggingWorktreeId: session.draggingWorktreeId, + grab: session.grab, + anchor + }) + worktreeDragSessionRef.current = { + ...session, + anchor: preview ? { beforeWorktreeId: preview.dropAnchorId, pointerY, scrollTop } : null + } + return preview + }, + [computeWorktreeDropForGroup, scrollRef] + ) + const computeWorktreeStatusDrop = useCallback( + (request: WorktreeStatusDropRequest): WorktreeSidebarDropPreview | null => { + const container = scrollRef.current + if (!container) { + return null + } + const groupKey = getWorkspaceStatusGroupKey(request.status) + const session = worktreeDragSessionRef.current + const scrollTop = container.scrollTop + const heldAnchor = statusDropAnchorsRef.current.get(groupKey) ?? null + const anchor = shouldReevaluateWorktreeSidebarDropAnchor({ + anchor: heldAnchor, + pointerY: request.pointerY, + scrollTop + }) + ? null + : heldAnchor + const preview = computeWorktreeDropForGroup({ + pointerY: request.pointerY, + groupKey, + rects: getWorktreeSidebarDragRectsForGroup(container, groupKey), + draggedIds: request.draggedIds, + draggingWorktreeId: session?.draggingWorktreeId ?? null, + grab: session?.grab ?? null, + anchor + }) + if (preview) { + statusDropAnchorsRef.current.set(groupKey, { + beforeWorktreeId: preview.dropAnchorId, + pointerY: request.pointerY, + scrollTop + }) + } else { + statusDropAnchorsRef.current.delete(groupKey) + } + return preview + }, + [computeWorktreeDropForGroup, scrollRef] + ) + + // Why: consumers pass session-derived callbacks into memoised cards; a fresh object every + // render would defeat those bail-outs. + return useMemo( + () => ({ + worktreeDragSessionRef, + statusDropAnchorsRef, + worktreeDragGroups, + worktreeDragUnitGroups, + groupKeyByRowKey, + groupIndexByRowKey, + getReorderDraggedIds, + getReorderUnitDraggedIds, + refreshWorktreeDragSession, + computeWorktreeDrop, + computeWorktreeStatusDrop + }), + [ + computeWorktreeDrop, + computeWorktreeStatusDrop, + getReorderDraggedIds, + getReorderUnitDraggedIds, + groupIndexByRowKey, + groupKeyByRowKey, + refreshWorktreeDragSession, + worktreeDragGroups, + worktreeDragUnitGroups + ] + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-lineage-drop-commit.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-lineage-drop-commit.ts new file mode 100644 index 00000000000..6629fe526e1 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-lineage-drop-commit.ts @@ -0,0 +1,127 @@ +import { useCallback, useMemo } from 'react' +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import type { Repo } from '../../../../../shared/repo-types' +import type { Worktree } from '../../../../../shared/worktree/types' +import type { WorktreeLineage } from '../../../../../shared/worktree/lineage-types' +import { isEligibleWorktreeParent } from '../worktree-parent-candidates' +import { getCyclicProjectedWorktreeLineageIds } from '../worktree-lineage-projection' +import { getReorderedWorktreeIdsToUnnest } from '../worktree-lineage-drag-drop' +import type { WorktreeDragGroup } from '../worktree-manual-order' +import type { WorktreeSidebarLineageDropTarget } from './worktree-row-drag-state' + +export type WorktreeLineageDropCommit = ReturnType + +// Nesting and un-nesting are the two lineage mutations a sidebar drag can commit. +export function useWorktreeLineageDropCommit(args: { + repoMap: Map + worktreeMap: Map + worktreeLineageById: Record + worktreeDragGroups: readonly WorktreeDragGroup[] +}) { + const { repoMap, worktreeMap, worktreeLineageById, worktreeDragGroups } = args + const assignWorktreeParent = useAppStore((s) => s.assignWorktreeParent) + const updateWorktreeLineage = useAppStore((s) => s.updateWorktreeLineage) + const cyclicLineageIds = useMemo( + () => getCyclicProjectedWorktreeLineageIds(worktreeLineageById, worktreeMap), + [worktreeLineageById, worktreeMap] + ) + + const getEligibleLineageDropTarget = useCallback( + ( + target: WorktreeSidebarLineageDropTarget, + draggedIds: readonly string[] + ): WorktreeSidebarLineageDropTarget => { + const parentId = target.lineageParentId + if (!parentId) { + return target + } + const canAssignAll = draggedIds.every((draggedId) => { + const child = worktreeMap.get(draggedId) + if (!child) { + return false + } + const candidateParent = worktreeMap.get(parentId) + return Boolean( + candidateParent && + isEligibleWorktreeParent({ + child, + candidateParent, + lineageById: worktreeLineageById, + worktreeMap, + repoMap, + cyclicLineageIds + }) + ) + }) + return canAssignAll ? target : { ...target, lineageParentId: null } + }, + [cyclicLineageIds, repoMap, worktreeLineageById, worktreeMap] + ) + + const commitWorktreeLineageParentDrop = useCallback( + (draggedIds: readonly string[], parentId: string): boolean => { + const target = getEligibleLineageDropTarget( + { status: null, isPinDrop: false, lineageParentId: parentId }, + draggedIds + ) + if (!target.lineageParentId) { + return false + } + void Promise.all( + draggedIds.map((id) => assignWorktreeParent(id, { parentWorktreeId: parentId })) + ).catch((err) => { + console.error('Failed to nest workspace:', err) + toast.error( + translate( + 'auto.components.sidebar.WorktreeList.failedNestWorkspace', + 'Failed to nest workspace' + ) + ) + }) + return true + }, + [assignWorktreeParent, getEligibleLineageDropTarget] + ) + + const clearReorderedWorktreeParents = useCallback( + (unnestArgs: { draggedIds: readonly string[]; sourceGroupKey: string }) => { + const sourceGroup = worktreeDragGroups.find( + (group) => group.key === unnestArgs.sourceGroupKey + ) + if (!sourceGroup) { + return + } + const ids = getReorderedWorktreeIdsToUnnest({ + draggedIds: unnestArgs.draggedIds, + sourceGroupIds: sourceGroup.worktreeIds, + lineageById: worktreeLineageById, + worktreeMap, + cyclicLineageIds + }) + if (ids.length === 0) { + return + } + // Why: dropping a nested card on a reorder line is the un-nest escape hatch; clear only the dragged children. + void Promise.all(ids.map((id) => updateWorktreeLineage(id, { noParent: true }))).catch( + (err) => { + console.error('Failed to unnest workspace:', err) + toast.error( + translate( + 'auto.components.sidebar.WorktreeList.failedUnnestWorkspace', + 'Failed to unnest workspace' + ) + ) + } + ) + }, + [cyclicLineageIds, updateWorktreeLineage, worktreeDragGroups, worktreeLineageById, worktreeMap] + ) + + return { + getEligibleLineageDropTarget, + commitWorktreeLineageParentDrop, + clearReorderedWorktreeParents + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-list-keyboard-navigation.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-list-keyboard-navigation.ts new file mode 100644 index 00000000000..4a9fc419703 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-list-keyboard-navigation.ts @@ -0,0 +1,120 @@ +import { useCallback, useEffect } from 'react' +import type React from 'react' +import type { Virtualizer } from '@tanstack/react-virtual' +import { useAppStore } from '@/store' +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 '../worktree-list-groups' +import type { RenderRow } from '../worktree-list-virtual-rows' +import { getCyclableWorktreeIds, resolveCycledWorktreeId } from '../worktree-keyboard-cycle' +import { findPreferredRenderRowIndexForWorktree } from './render-row-worktree-lookup' +import { isEditableTarget } from './sidebar-editable-target' + +export function useWorktreeListKeyboardNavigation(args: { + rows: HostSectionRow[] + renderRows: RenderRow[] + activeWorktreeId: string | null + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy + virtualizer: Virtualizer + scrollRef: React.RefObject + activeModal: string + markDirectScrollInput: () => void +}) { + const { + rows, + renderRows, + activeWorktreeId, + pinnedDisplayPolicy, + virtualizer, + scrollRef, + activeModal, + markDirectScrollInput + } = args + const keybindings = useAppStore((s) => s.keybindings) + + const navigateWorktree = useCallback( + (direction: 'up' | 'down') => { + // Why: cycle over the rows the sidebar actually rendered — collapsing a group + // means "not now", and a rebuilt near-copy would drift from what is on screen + // (host sections, pinned placement, folder workspaces). + const nextWorktreeId = resolveCycledWorktreeId({ + worktreeIds: getCyclableWorktreeIds(rows, pinnedDisplayPolicy), + activeWorktreeId, + direction + }) + if (nextWorktreeId === null) { + return + } + + // Why: keyboard cycling is real navigation; route through the activation helper that records history. + activateAndRevealWorktree(nextWorktreeId) + + const rowIndex = findPreferredRenderRowIndexForWorktree( + renderRows, + nextWorktreeId, + pinnedDisplayPolicy + ) + if (rowIndex !== -1) { + virtualizer.scrollToIndex(rowIndex, { align: 'auto' }) + } + }, + [rows, renderRows, activeWorktreeId, virtualizer, pinnedDisplayPolicy] + ) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (activeModal !== 'none' || isEditableTarget(e.target)) { + return + } + + const platform = getShortcutPlatform() + if (keybindingMatchesAction('sidebar.focusWorktreeList', e, platform, keybindings)) { + scrollRef.current?.focus() + e.preventDefault() + return + } + + const direction = keybindingMatchesAction('worktree.navigateUp', e, platform, keybindings) + ? 'up' + : keybindingMatchesAction('worktree.navigateDown', e, platform, keybindings) + ? 'down' + : null + if (direction) { + markDirectScrollInput() + navigateWorktree(direction) + e.preventDefault() + } + } + + window.addEventListener('keydown', handleKeyDown, { capture: true }) + return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }) + }, [activeModal, keybindings, markDirectScrollInput, navigateWorktree, scrollRef]) + + const handleContainerKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + if (e.target !== e.currentTarget) { + return + } + markDirectScrollInput() + navigateWorktree(e.key === 'ArrowUp' ? 'up' : 'down') + e.preventDefault() + } else if (e.key === 'Enter') { + const helper = document.querySelector( + '.xterm-helper-textarea' + ) as HTMLTextAreaElement | null + if (helper) { + helper.focus() + } + e.preventDefault() + } else if (['PageUp', 'PageDown', 'Home', 'End', ' '].includes(e.key)) { + markDirectScrollInput() + } + }, + [markDirectScrollInput, navigateWorktree] + ) + + return { handleContainerKeyDown } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-list-virtualizer.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-list-virtualizer.ts new file mode 100644 index 00000000000..52bd92358ce --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-list-virtualizer.ts @@ -0,0 +1,159 @@ +import { useCallback, useMemo, useRef } from 'react' +import type React from 'react' +import { + measureElement as measureVirtualElementSize, + useVirtualizer +} from '@tanstack/react-virtual' +import type { Range } from '@tanstack/react-virtual' +import { + estimateRenderRowSize, + extractWorktreeVirtualRowIndexes, + getRenderRowKey, + getStickyHeaderIndexes, + WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP, + type RenderRow +} from '../worktree-list-virtual-rows' +import { WORKTREE_SIDEBAR_REVEAL_TOP_INSET } from '../worktree-sidebar-reveal' +import { getVirtualRowIndex } from './virtual-row-dom-attributes' +import { + shouldAdjustWorktreeSidebarMeasuredRowScroll, + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS +} from './worktree-sidebar-scroll-tuning' + +export type WorktreeListVirtualizer = ReturnType + +// The TanStack virtualizer plus the row-identity guards that stop a recycled DOM node +// from writing a stale height into the wrong slot. +export function useWorktreeListVirtualizer(args: { + renderRows: RenderRow[] + firstHeaderIndex: number + scrollRef: React.RefObject + scrollOffsetRef: React.MutableRefObject + suppressMeasurementAdjustmentUntilRef: React.MutableRefObject +}) { + const { renderRows, firstHeaderIndex, scrollRef, scrollOffsetRef } = args + const renderRowsRef = useRef(renderRows) + renderRowsRef.current = renderRows + const firstHeaderIndexRef = useRef(firstHeaderIndex) + firstHeaderIndexRef.current = firstHeaderIndex + const stickyHeaderIndexes = useMemo(() => getStickyHeaderIndexes(renderRows), [renderRows]) + const activeStickyHeaderIndexRef = useRef(null) + const activeStickyHostIndexRef = useRef(null) + const stickyRangeStartIndexRef = useRef(0) + + const getVirtualItemKey = useCallback( + (index: number) => { + const row = renderRows[index] + if (!row) { + return `__stale_${index}` + } + return getRenderRowKey(row) + }, + [renderRows] + ) + const getExpectedVirtualRowKey = useCallback((element: Element) => { + const index = getVirtualRowIndex(element) + const row = index === null ? undefined : renderRowsRef.current[index] + return row ? getRenderRowKey(row) : null + }, []) + const isCurrentVirtualRowElement = useCallback( + (element: Element) => { + const expectedKey = getExpectedVirtualRowKey(element) + return ( + element.isConnected && + expectedKey !== null && + element.getAttribute('data-worktree-virtual-row-key') === expectedKey + ) + }, + [getExpectedVirtualRowKey] + ) + const measureCurrentVirtualRowElement = useCallback( + ( + element: HTMLDivElement, + entry: ResizeObserverEntry | undefined, + instance: Parameters>[2] + ) => { + if (!isCurrentVirtualRowElement(element)) { + const index = getVirtualRowIndex(element) + const measured = instance.getVirtualItems().find((item) => item.index === index) + // Why: a stale ResizeObserver row after remount would write a wrong height; return current size to no-op it. + return ( + measured?.size ?? + estimateRenderRowSize( + renderRowsRef.current, + index ?? -1, + firstHeaderIndexRef.current, + activeStickyHeaderIndexRef.current + ) + ) + } + const index = getVirtualRowIndex(element) + if ( + index !== null && + (renderRowsRef.current[index]?.type === 'header' || + renderRowsRef.current[index]?.type === 'host-header') + ) { + return estimateRenderRowSize( + renderRowsRef.current, + index, + firstHeaderIndexRef.current, + activeStickyHeaderIndexRef.current + ) + } + return measureVirtualElementSize(element, entry, instance) + }, + [isCurrentVirtualRowElement] + ) + + const virtualizer = useVirtualizer({ + count: renderRows.length, + getScrollElement: () => scrollRef.current, + estimateSize: (index) => + estimateRenderRowSize( + renderRows, + index, + firstHeaderIndex, + activeStickyHeaderIndexRef.current + ), + measureElement: measureCurrentVirtualRowElement, + // Why: TanStack memoizes rangeExtractor by identity; header indexes must be deps or sticky slots go stale. + rangeExtractor: useCallback( + (range: Range) => { + stickyRangeStartIndexRef.current = range.startIndex + return extractWorktreeVirtualRowIndexes({ + range, + stickyHeaderIndexes, + rows: renderRowsRef.current + }) + }, + [stickyHeaderIndexes] + ), + overscan: 10, + gap: WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP, + // Why: the sticky group header lives inside the virtual list, so scroll math needs the same top inset as the DOM reveal. + scrollPaddingStart: WORKTREE_SIDEBAR_REVEAL_TOP_INSET, + isScrollingResetDelay: USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS, + // Why: sync-flushing rich card renders in the scroll listener stalls wheel input; async + overscan keeps rows filled. + useFlushSync: false, + // Why: seed scrollOffset from the ref (not 0) so the first getVirtualItems() after remount picks the right rows. + initialOffset: () => scrollOffsetRef.current, + getItemKey: getVirtualItemKey + }) + // Why: TanStack's default correction writes scrollTop while cards remeasure mid-wheel, which feels like rubber-banding. + // TODO(scroll-origin-migration): wall-clock suppression misclassifies under jank; migrate to programmaticScrollMarks + restoreSignal (see CombinedDiffViewer). + virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => + shouldAdjustWorktreeSidebarMeasuredRowScroll({ + isScrolling: instance.isScrolling, + now: window.performance.now(), + suppressUntil: args.suppressMeasurementAdjustmentUntilRef.current + }) + + return { + virtualizer, + isCurrentVirtualRowElement, + stickyHeaderIndexes, + activeStickyHeaderIndexRef, + activeStickyHostIndexRef, + stickyRangeStartIndexRef + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-native-drag-autoscroll.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-native-drag-autoscroll.ts new file mode 100644 index 00000000000..339f1f8b768 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-native-drag-autoscroll.ts @@ -0,0 +1,108 @@ +import { useCallback } from 'react' +import type React from 'react' +import { getWorktreeSidebarDragAutoscroll } from '../worktree-sidebar-drag-autoscroll' +import { getPointerDropStatusTarget } from './pointer-drop-status-target' +import type { WorktreeDropCommitContext } from './worktree-drop-commit-context' +import type { WorktreeDragRuntime } from './use-worktree-drag-runtime' +import type { WorktreeDragSession } from './use-worktree-drag-session' +import { applyWorktreeDropPreview, clearWorktreeDropPreview } from './worktree-row-drag-state' + +// The HTML5 drag equivalent of pointer autoscroll: the browser gives no move events while +// hovering the edge, so re-derive the drop preview from the last known point each frame. +export function useWorktreeNativeDragAutoscroll(args: { + ctx: WorktreeDropCommitContext + session: WorktreeDragSession + runtime: WorktreeDragRuntime + scrollRef: React.RefObject + markScrollMovement: () => void +}): () => void { + const { ctx, session, runtime, scrollRef, markScrollMovement } = args + const { + nativeAutoscrollFrameIdRef, + nativeAutoscrollLastFrameTimeRef, + nativeLatestPointRef, + cancelWorktreeNativeAutoscroll, + clearWorktreeDrag, + setWorktreeDragState + } = runtime + + const runFrame = useCallback( + (frameTime: number) => { + nativeAutoscrollFrameIdRef.current = null + const point = nativeLatestPointRef.current + const container = scrollRef.current + const dragSession = session.worktreeDragSessionRef.current + if (!point || !container || !dragSession) { + cancelWorktreeNativeAutoscroll() + return + } + + const previousFrameTime = nativeAutoscrollLastFrameTimeRef.current ?? frameTime + nativeAutoscrollLastFrameTimeRef.current = frameTime + const autoscroll = getWorktreeSidebarDragAutoscroll({ + point, + containerRect: container.getBoundingClientRect(), + scrollTop: container.scrollTop, + scrollHeight: container.scrollHeight, + clientHeight: container.clientHeight, + elapsedMs: frameTime - previousFrameTime + }) + if (autoscroll) { + markScrollMovement() + container.scrollTop = autoscroll.scrollTop + if (!session.refreshWorktreeDragSession()) { + clearWorktreeDrag() + return + } + const drop = ctx.computeWorktreeDrop(point.clientY) + if (!drop) { + const target = getPointerDropStatusTarget({ + container, + x: point.clientX, + y: point.clientY + }) + const statusDrop = target.status + ? ctx.computeWorktreeStatusDrop({ + pointerY: point.clientY, + status: target.status, + draggedIds: dragSession.reorderDraggedIds + }) + : null + if (statusDrop) { + setWorktreeDragState((prev) => + applyWorktreeDropPreview(prev, statusDrop, { pointerY: point.clientY }) + ) + return + } + setWorktreeDragState((prev) => clearWorktreeDropPreview(prev, { pointerY: null })) + } else { + setWorktreeDragState((prev) => + applyWorktreeDropPreview(prev, drop, { pointerY: point.clientY }) + ) + } + } + + nativeAutoscrollFrameIdRef.current = window.requestAnimationFrame(runFrame) + }, + [ + cancelWorktreeNativeAutoscroll, + clearWorktreeDrag, + ctx, + markScrollMovement, + nativeAutoscrollFrameIdRef, + nativeAutoscrollLastFrameTimeRef, + nativeLatestPointRef, + scrollRef, + session, + setWorktreeDragState + ] + ) + + return useCallback(() => { + if (nativeAutoscrollFrameIdRef.current !== null) { + return + } + nativeAutoscrollLastFrameTimeRef.current = null + nativeAutoscrollFrameIdRef.current = window.requestAnimationFrame(runFrame) + }, [nativeAutoscrollFrameIdRef, nativeAutoscrollLastFrameTimeRef, runFrame]) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-native-drag.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-native-drag.ts new file mode 100644 index 00000000000..b7fc693326e --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-native-drag.ts @@ -0,0 +1,231 @@ +import { useCallback } from 'react' +import type React from 'react' +import { getWorkspaceKanbanSidebarDropTarget } from '../workspace-kanban-sidebar-drop' +import { getFullDropIndexForWorktreeDragUnit } from '../worktree-drag-units' +import { getWorktreeSidebarDragRectsForGroup } from '../worktree-sidebar-drag-autoscroll' +import { getWorktreeSidebarDragGrab } from '../worktree-sidebar-drag-geometry' +import { getPointerDropStatusTarget } from './pointer-drop-status-target' +import type { WorktreeDropCommitContext } from './worktree-drop-commit-context' +import type { WorktreeDragRuntime } from './use-worktree-drag-runtime' +import type { WorktreeDragSession } from './use-worktree-drag-session' +import { useWorktreeNativeDragAutoscroll } from './use-worktree-native-drag-autoscroll' +import { + applyWorktreeDropPreview, + clearWorktreeDropPreview, + EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, + NO_WORKTREE_SIDEBAR_DROP_TARGET +} from './worktree-row-drag-state' + +// The HTML5 drag path used by cards that start a native dragstart (board <-> sidebar transfers). +export function useWorktreeNativeDrag(args: { + ctx: WorktreeDropCommitContext + session: WorktreeDragSession + runtime: WorktreeDragRuntime + scrollRef: React.RefObject + markScrollMovement: () => void +}) { + const { ctx, session, runtime, scrollRef, markScrollMovement } = args + const { + nativeLatestPointRef, + clearWorktreeDrag, + setWorktreeDragState, + setNativeLineageDropTargetId + } = runtime + + const startWorktreeNativeAutoscroll = useWorktreeNativeDragAutoscroll({ + ctx, + session, + runtime, + scrollRef, + markScrollMovement + }) + + const handleWorktreeCardDragStart = useCallback( + (event: React.DragEvent, worktreeId: string, draggedIds: readonly string[]) => { + const sourceGroupKey = + ctx.worktreeDragGroups.find((group) => group.worktreeIds.includes(worktreeId))?.key ?? null + if (!sourceGroupKey) { + return + } + const reorderDraggedIds = session.getReorderDraggedIds(draggedIds) + const reorderUnitDraggedIds = session.getReorderUnitDraggedIds( + sourceGroupKey, + reorderDraggedIds + ) + const rects = scrollRef.current + ? getWorktreeSidebarDragRectsForGroup(scrollRef.current, sourceGroupKey) + : [] + const sourceRect = event.currentTarget.getBoundingClientRect() + session.worktreeDragSessionRef.current = { + draggingWorktreeId: worktreeId, + sourceGroupKey, + draggedIds, + reorderDraggedIds, + reorderUnitDraggedIds, + rects, + grab: getWorktreeSidebarDragGrab({ + offsetY: event.clientY - sourceRect.top, + height: sourceRect.height + }), + anchor: null + } + setWorktreeDragState({ + draggingWorktreeId: worktreeId, + sourceGroupKey, + dropIndex: null, + dropIndicatorY: null, + previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, + pointerY: null + }) + }, + [ctx.worktreeDragGroups, scrollRef, session, setWorktreeDragState] + ) + + const handleWorktreeDragOver = useCallback( + (event: React.DragEvent) => { + const dragSession = session.worktreeDragSessionRef.current + if (!dragSession) { + return + } + nativeLatestPointRef.current = { clientX: event.clientX, clientY: event.clientY } + startWorktreeNativeAutoscroll() + if (!session.refreshWorktreeDragSession()) { + clearWorktreeDrag() + return + } + const target = ctx.getEligibleLineageDropTarget( + getPointerDropStatusTarget({ + container: event.currentTarget, + x: event.clientX, + y: event.clientY + }), + dragSession.draggedIds + ) + if (target.lineageParentId) { + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + setNativeLineageDropTargetId(target.lineageParentId) + setWorktreeDragState((prev) => clearWorktreeDropPreview(prev, { pointerY: event.clientY })) + return + } + setNativeLineageDropTargetId(null) + + const drop = ctx.computeWorktreeDrop(event.clientY) + if (!drop) { + const statusDrop = target.status + ? ctx.computeWorktreeStatusDrop({ + pointerY: event.clientY, + status: target.status, + draggedIds: dragSession.reorderDraggedIds + }) + : null + if (statusDrop) { + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + setWorktreeDragState((prev) => + applyWorktreeDropPreview(prev, statusDrop, { pointerY: event.clientY }) + ) + return + } + setWorktreeDragState((prev) => clearWorktreeDropPreview(prev, { pointerY: null })) + return + } + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + setWorktreeDragState((prev) => + applyWorktreeDropPreview(prev, drop, { pointerY: event.clientY }) + ) + }, + [ + clearWorktreeDrag, + ctx, + nativeLatestPointRef, + session, + setNativeLineageDropTargetId, + setWorktreeDragState, + startWorktreeNativeAutoscroll + ] + ) + + const handleWorktreeDrop = useCallback( + (event: React.DragEvent) => { + const dragSession = session.worktreeDragSessionRef.current + if (!dragSession) { + return + } + if (!session.refreshWorktreeDragSession()) { + clearWorktreeDrag() + return + } + const boardDropTarget = getWorkspaceKanbanSidebarDropTarget(event.clientX, event.clientY) + if (boardDropTarget.status || boardDropTarget.isPinDrop) { + clearWorktreeDrag() + return + } + + const container = scrollRef.current + const target = ctx.getEligibleLineageDropTarget( + container + ? getPointerDropStatusTarget({ + container, + x: event.clientX, + y: event.clientY + }) + : NO_WORKTREE_SIDEBAR_DROP_TARGET, + dragSession.draggedIds + ) + + if (target.lineageParentId) { + event.preventDefault() + event.stopPropagation() + ctx.commitWorktreeLineageParentDrop(dragSession.draggedIds, target.lineageParentId) + clearWorktreeDrag() + return + } + + const drop = ctx.computeWorktreeDrop(event.clientY) + if (!drop) { + const statusDrop = target.status + ? ctx.computeWorktreeStatusDrop({ + pointerY: event.clientY, + status: target.status, + draggedIds: dragSession.reorderDraggedIds + }) + : null + if (target.status && statusDrop) { + event.preventDefault() + event.stopPropagation() + ctx.onMoveWorktreesToStatusAtIndex({ + worktreeIds: dragSession.reorderDraggedIds, + status: target.status, + dropIndex: statusDrop.dropIndex, + groups: ctx.worktreeDragGroups + }) + clearWorktreeDrag() + return + } + clearWorktreeDrag() + return + } + event.preventDefault() + ctx.onReorderWorktrees({ + groups: ctx.worktreeDragGroups, + sourceGroupKey: dragSession.sourceGroupKey, + draggedIds: dragSession.reorderDraggedIds, + dropIndex: getFullDropIndexForWorktreeDragUnit({ + groups: ctx.worktreeDragUnitGroups, + sourceGroupKey: dragSession.sourceGroupKey, + dropIndex: drop.dropIndex + }) + }) + ctx.clearReorderedWorktreeParents({ + draggedIds: dragSession.draggedIds, + sourceGroupKey: dragSession.sourceGroupKey + }) + clearWorktreeDrag() + }, + [clearWorktreeDrag, ctx, scrollRef, session] + ) + + return { handleWorktreeCardDragStart, handleWorktreeDragOver, handleWorktreeDrop } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag-autoscroll.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag-autoscroll.ts new file mode 100644 index 00000000000..a2af2bd422d --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag-autoscroll.ts @@ -0,0 +1,79 @@ +import { useCallback } from 'react' +import type React from 'react' +import { getWorktreeSidebarDragAutoscroll } from '../worktree-sidebar-drag-autoscroll' +import type { WorktreeDragRuntime } from './use-worktree-drag-runtime' +import type { WorktreeDragSession } from './use-worktree-drag-session' +import type { WorktreePointerDrag } from './worktree-row-drag-state' + +// Scrolls the sidebar while a pointer drag hovers near its top or bottom edge, refreshing +// the drag session on every scrolled frame so the insertion line stays on the right row. +export function useWorktreePointerDragAutoscroll(args: { + session: WorktreeDragSession + runtime: WorktreeDragRuntime + scrollRef: React.RefObject + markScrollMovement: () => void + scheduleWorktreePointerDragFrame: (drag: WorktreePointerDrag) => void +}): () => void { + const { session, runtime, scrollRef, markScrollMovement, scheduleWorktreePointerDragFrame } = args + const { + worktreePointerDragRef, + pointerAutoscrollFrameIdRef, + pointerAutoscrollLastFrameTimeRef, + cancelWorktreePointerAutoscroll, + clearWorktreeDrag + } = runtime + + const runFrame = useCallback( + (frameTime: number) => { + pointerAutoscrollFrameIdRef.current = null + const drag = worktreePointerDragRef.current + const container = scrollRef.current + const dragSession = session.worktreeDragSessionRef.current + if (!drag?.active || !container || !dragSession) { + cancelWorktreePointerAutoscroll() + return + } + + const previousFrameTime = pointerAutoscrollLastFrameTimeRef.current ?? frameTime + pointerAutoscrollLastFrameTimeRef.current = frameTime + const autoscroll = getWorktreeSidebarDragAutoscroll({ + point: { clientX: drag.currentX, clientY: drag.currentY }, + containerRect: container.getBoundingClientRect(), + scrollTop: container.scrollTop, + scrollHeight: container.scrollHeight, + clientHeight: container.clientHeight, + elapsedMs: frameTime - previousFrameTime + }) + if (autoscroll) { + markScrollMovement() + container.scrollTop = autoscroll.scrollTop + if (!session.refreshWorktreeDragSession()) { + clearWorktreeDrag() + return + } + scheduleWorktreePointerDragFrame(drag) + } + + pointerAutoscrollFrameIdRef.current = window.requestAnimationFrame(runFrame) + }, + [ + cancelWorktreePointerAutoscroll, + clearWorktreeDrag, + markScrollMovement, + pointerAutoscrollFrameIdRef, + pointerAutoscrollLastFrameTimeRef, + scheduleWorktreePointerDragFrame, + scrollRef, + session, + worktreePointerDragRef + ] + ) + + return useCallback(() => { + if (pointerAutoscrollFrameIdRef.current !== null) { + return + } + pointerAutoscrollLastFrameTimeRef.current = null + pointerAutoscrollFrameIdRef.current = window.requestAnimationFrame(runFrame) + }, [pointerAutoscrollFrameIdRef, pointerAutoscrollLastFrameTimeRef, runFrame]) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag-window-events.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag-window-events.ts new file mode 100644 index 00000000000..745670dde4d --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag-window-events.ts @@ -0,0 +1,95 @@ +import { useEffect } from 'react' +import type { WorktreeDragRuntime } from './use-worktree-drag-runtime' +import type { WorktreeDropCommitContext } from './worktree-drop-commit-context' +import type { WorktreeStatusDropAtIndexArgs } from './virtualized-worktree-viewport-props' +import { commitWorktreePointerDrop } from './worktree-pointer-drag-commit' +import { SIDEBAR_POINTER_DRAG_THRESHOLD_PX } from './worktree-sidebar-scroll-tuning' +import type { WorktreePointerDrag } from './worktree-row-drag-state' + +// Pointer drags escape the row that started them, so move/up/cancel are tracked on the +// window in capture phase. +export function useWorktreePointerDragWindowEvents(args: { + ctx: WorktreeDropCommitContext + runtime: WorktreeDragRuntime + beginWorktreePointerDrag: (drag: WorktreePointerDrag) => void + scheduleWorktreePointerDragFrame: (drag: WorktreePointerDrag) => void + onWorkspaceBoardDragPreviewCommit: () => void + onDropWorktreesOnWorkspaceBoard: (dropArgs: WorktreeStatusDropAtIndexArgs) => void +}): void { + const { + ctx, + beginWorktreePointerDrag, + scheduleWorktreePointerDragFrame, + onWorkspaceBoardDragPreviewCommit, + onDropWorktreesOnWorkspaceBoard + } = args + const { worktreePointerDragRef, clearWorktreeDrag } = args.runtime + + useEffect(() => { + const handlePointerMove = (event: PointerEvent): void => { + const drag = worktreePointerDragRef.current + if (!drag || event.pointerId !== drag.pointerId) { + return + } + drag.currentX = event.clientX + drag.currentY = event.clientY + if (!drag.active) { + const distance = Math.hypot(drag.currentX - drag.startX, drag.currentY - drag.startY) + if (distance < SIDEBAR_POINTER_DRAG_THRESHOLD_PX) { + return + } + beginWorktreePointerDrag(drag) + } + event.preventDefault() + event.stopPropagation() + scheduleWorktreePointerDragFrame(drag) + } + + const handlePointerUp = (event: PointerEvent): void => { + const drag = worktreePointerDragRef.current + if (!drag || event.pointerId !== drag.pointerId) { + return + } + drag.currentX = event.clientX + drag.currentY = event.clientY + if (!drag.active) { + worktreePointerDragRef.current = null + return + } + event.preventDefault() + event.stopPropagation() + commitWorktreePointerDrop({ + event, + drag, + ctx, + onWorkspaceBoardDragPreviewCommit, + onDropWorktreesOnWorkspaceBoard + }) + } + + const handlePointerCancel = (event: PointerEvent): void => { + const drag = worktreePointerDragRef.current + if (!drag || event.pointerId !== drag.pointerId) { + return + } + clearWorktreeDrag() + } + + window.addEventListener('pointermove', handlePointerMove, { capture: true }) + window.addEventListener('pointerup', handlePointerUp, { capture: true }) + window.addEventListener('pointercancel', handlePointerCancel, { capture: true }) + return () => { + window.removeEventListener('pointermove', handlePointerMove, { capture: true }) + window.removeEventListener('pointerup', handlePointerUp, { capture: true }) + window.removeEventListener('pointercancel', handlePointerCancel, { capture: true }) + } + }, [ + beginWorktreePointerDrag, + clearWorktreeDrag, + ctx, + onDropWorktreesOnWorkspaceBoard, + onWorkspaceBoardDragPreviewCommit, + scheduleWorktreePointerDragFrame, + worktreePointerDragRef + ]) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag.ts new file mode 100644 index 00000000000..b61694d9bd4 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-pointer-drag.ts @@ -0,0 +1,259 @@ +import { useCallback, useEffect } from 'react' +import type React from 'react' +import type { WorkspaceStatus, Worktree } from '../../../../../shared/worktree/types' +import { hasWorkspaceKanbanSidebarDropBoard } from '../workspace-kanban-sidebar-drop' +import { + createSidebarDragPreview, + isSidebarPointerDragBlocked, + setSidebarPointerDragDocumentStyles +} 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 './virtualized-worktree-viewport-props' +import type { WorktreeStatusDropAtIndexArgs } from './virtualized-worktree-viewport-props' +import type { WorktreeDropCommitContext } from './worktree-drop-commit-context' +import type { WorktreeDragRuntime } from './use-worktree-drag-runtime' +import type { WorktreeDragSession } from './use-worktree-drag-session' +import { useWorktreePointerDragAutoscroll } from './use-worktree-pointer-drag-autoscroll' +import { useWorktreePointerDragWindowEvents } from './use-worktree-pointer-drag-window-events' +import { flushWorktreePointerDragFrame } from './worktree-pointer-drag-flush' +import { + EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, + type WorktreePointerDrag +} from './worktree-row-drag-state' + +export function useWorktreePointerDrag(args: { + ctx: WorktreeDropCommitContext + session: WorktreeDragSession + runtime: WorktreeDragRuntime + scrollRef: React.RefObject + markScrollMovement: () => void + selectedWorktreeIds: ReadonlySet + selectedWorktrees: readonly Worktree[] + workspaceBoardOpen: boolean + onWorkspaceBoardDragPreviewStart: () => void + onWorkspaceBoardDragPreviewCommit: () => void + onDropWorktreesOnWorkspaceBoard: (dropArgs: WorktreeStatusDropAtIndexArgs) => void + shouldShowWorkspaceBoardDropIndicator: ( + worktreeIds: readonly string[], + status: WorkspaceStatus + ) => boolean +}) { + const { + ctx, + session, + runtime, + scrollRef, + markScrollMovement, + selectedWorktreeIds, + selectedWorktrees, + workspaceBoardOpen, + onWorkspaceBoardDragPreviewStart, + onWorkspaceBoardDragPreviewCommit, + onDropWorktreesOnWorkspaceBoard, + shouldShowWorkspaceBoardDropIndicator + } = args + const { + worktreePointerDragRef, + suppressWorktreeClickUntilRef, + setWorktreeDragState, + setDragOverStatus, + setPinDragOver + } = runtime + + const flushWorktreePointerDrag = useCallback(() => { + const drag = worktreePointerDragRef.current + if (!drag) { + return + } + flushWorktreePointerDragFrame({ + drag, + ctx, + workspaceBoardOpen, + onWorkspaceBoardDragPreviewStart, + onWorkspaceBoardDragPreviewCommit, + shouldShowWorkspaceBoardDropIndicator, + setWorktreeDragState, + setDragOverStatus, + setPinDragOver + }) + }, [ + ctx, + onWorkspaceBoardDragPreviewCommit, + onWorkspaceBoardDragPreviewStart, + setDragOverStatus, + setPinDragOver, + setWorktreeDragState, + shouldShowWorkspaceBoardDropIndicator, + workspaceBoardOpen, + worktreePointerDragRef + ]) + + const scheduleWorktreePointerDragFrame = useCallback( + (drag: WorktreePointerDrag) => { + if (drag.frameId !== null) { + return + } + drag.frameId = window.requestAnimationFrame(flushWorktreePointerDrag) + }, + [flushWorktreePointerDrag] + ) + + const startWorktreePointerAutoscroll = useWorktreePointerDragAutoscroll({ + session, + runtime, + scrollRef, + markScrollMovement, + scheduleWorktreePointerDragFrame + }) + + const beginWorktreePointerDrag = useCallback( + (drag: WorktreePointerDrag) => { + const { preview, offsetX, offsetY, height } = createSidebarDragPreview({ + sourceRow: drag.sourceRow, + pointerX: drag.currentX, + pointerY: drag.currentY, + draggedCount: drag.draggedIds.length + }) + drag.active = true + drag.preview = preview + drag.previewOffsetX = offsetX + drag.previewOffsetY = offsetY + suppressWorktreeClickUntilRef.current = window.performance.now() + 500 + setSidebarPointerDragDocumentStyles(true) + session.worktreeDragSessionRef.current = { + draggingWorktreeId: drag.worktreeId, + sourceGroupKey: drag.sourceGroupKey, + draggedIds: drag.draggedIds, + reorderDraggedIds: drag.reorderDraggedIds, + reorderUnitDraggedIds: drag.reorderUnitDraggedIds, + rects: drag.rects, + // Why: reuse the floating preview's own offset so the hit test tracks the + // card the user sees, not the raw pointer. + grab: getWorktreeSidebarDragGrab({ offsetY, height }), + anchor: null + } + setWorktreeDragState({ + draggingWorktreeId: drag.worktreeId, + sourceGroupKey: drag.sourceGroupKey, + dropIndex: null, + dropIndicatorY: null, + previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, + pointerY: drag.currentY + }) + startWorktreePointerAutoscroll() + scheduleWorktreePointerDragFrame(drag) + }, + [ + scheduleWorktreePointerDragFrame, + session, + setWorktreeDragState, + startWorktreePointerAutoscroll, + suppressWorktreeClickUntilRef + ] + ) + + const handleWorktreeRowPointerDown = useCallback( + (event: React.PointerEvent, worktreeId: string, rowKey: string) => { + if (event.button !== 0 || event.pointerType === 'touch') { + return + } + const sourceRow = event.currentTarget + if (isSidebarPointerDragBlocked(event.target, sourceRow)) { + return + } + const sourceGroupKey = session.groupKeyByRowKey.get(rowKey) + const container = scrollRef.current + if (!sourceGroupKey || !container) { + return + } + const rects = getWorktreeSidebarDragRectsForGroup(container, sourceGroupKey) + const canPreviewWorkspaceBoardOnDrag = + !workspaceBoardOpen && + onWorkspaceBoardDragPreviewStart !== NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK + if ( + rects.length <= 1 && + !hasWorkspaceKanbanSidebarDropBoard() && + !canPreviewWorkspaceBoardOnDrag + ) { + return + } + const draggedIds = + selectedWorktreeIds.has(worktreeId) && selectedWorktrees.length > 1 + ? selectedWorktrees.map((worktree) => worktree.id) + : [worktreeId] + const reorderDraggedIds = session.getReorderDraggedIds(draggedIds) + const reorderUnitDraggedIds = session.getReorderUnitDraggedIds( + sourceGroupKey, + reorderDraggedIds + ) + worktreePointerDragRef.current = { + pointerId: event.pointerId, + sourceRow, + startX: event.clientX, + startY: event.clientY, + currentX: event.clientX, + currentY: event.clientY, + worktreeId, + draggedIds, + reorderDraggedIds, + reorderUnitDraggedIds, + sourceGroupKey, + rects, + active: false, + preview: null, + previewOffsetX: 0, + previewOffsetY: 0, + workspaceBoardDragPreviewRequested: false, + frameId: null, + latestBoardDropTarget: null, + latestStatusDropTarget: null + } + }, + [ + onWorkspaceBoardDragPreviewStart, + scrollRef, + selectedWorktreeIds, + selectedWorktrees, + session, + workspaceBoardOpen, + worktreePointerDragRef + ] + ) + + const handleWorktreeRowClickCapture = useCallback( + (event: React.MouseEvent) => { + if (window.performance.now() >= suppressWorktreeClickUntilRef.current) { + return + } + event.preventDefault() + event.stopPropagation() + }, + [suppressWorktreeClickUntilRef] + ) + + useWorktreePointerDragWindowEvents({ + ctx, + runtime, + beginWorktreePointerDrag, + scheduleWorktreePointerDragFrame, + onWorkspaceBoardDragPreviewCommit, + onDropWorktreesOnWorkspaceBoard + }) + + useEffect(() => { + const handleClick = (event: MouseEvent): void => { + if (window.performance.now() >= suppressWorktreeClickUntilRef.current) { + return + } + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + } + + document.addEventListener('click', handleClick, true) + return () => document.removeEventListener('click', handleClick, true) + }, [suppressWorktreeClickUntilRef]) + + return { handleWorktreeRowPointerDown, handleWorktreeRowClickCapture } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-sidebar-header-drag.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-sidebar-header-drag.ts new file mode 100644 index 00000000000..93be311bc8e --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-sidebar-header-drag.ts @@ -0,0 +1,222 @@ +import { useCallback, useEffect, useMemo } from 'react' +import type React from 'react' +import { useAppStore } from '@/store' +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 { ExecutionHostId } from '../../../../../shared/execution-host' +import type { HostHeaderRow, HostSectionRow } from '../host-section-rows' +import type { Row, WorktreeGroupBy } from '../worktree-list-groups' +import type { RenderRow } from '../worktree-list-virtual-rows' +import { + getProjectGroupHeaderSectionEndByGroupId, + getRepoHeaderSectionEndByRepoId +} from '../worktree-header-section-boundaries' +import { useHostHeaderDrag } from '../host-header-drag' +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 './worktree-sidebar-scroll-tuning' + +export type WorktreeSidebarHeaderDrag = ReturnType + +function indexById(byBucket: ReadonlyMap): Map { + const map = new Map() + for (const ids of byBucket.values()) { + ids.forEach((id, index) => { + map.set(id, index) + }) + } + return map +} + +function bucketById(byBucket: ReadonlyMap): Map { + const map = new Map() + for (const [bucketKey, ids] of byBucket) { + for (const id of ids) { + map.set(id, bucketKey) + } + } + return map +} + +// Reordering the three header tiers (host card, project group, project) plus the bucket +// bookkeeping their drag controllers and DOM attributes need. +export function useWorktreeSidebarHeaderDrag(args: { + rows: HostSectionRow[] + renderRows: readonly RenderRow[] + firstHeaderIndex: number + allRepoIds: string[] + repoMap: Map + projectGroups: readonly ProjectGroup[] + groupBy: WorktreeGroupBy + projectOrderBy: ProjectOrderBy + scrollRef: React.RefObject + onReorderHostSections: (orderedHostIds: ExecutionHostId[]) => void + onHostDragActiveChange: (active: boolean) => void + suppressMeasurementAdjustmentUntilRef: React.MutableRefObject + directScrollInputUntilRef: React.MutableRefObject +}) { + const { + rows, + renderRows, + firstHeaderIndex, + allRepoIds, + repoMap, + projectGroups, + groupBy, + projectOrderBy, + scrollRef, + onReorderHostSections, + onHostDragActiveChange, + suppressMeasurementAdjustmentUntilRef, + directScrollInputUntilRef + } = args + const reorderRepos = useAppStore((s) => s.reorderRepos) + const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup) + const updateProjectGroup = useAppStore((s) => s.updateProjectGroup) + const hasProjectGroups = projectGroups.length > 0 + const canReorderRepoHeaders = groupBy === 'repo' && projectOrderBy === 'manual' + const canReorderProjectGroupHeaders = groupBy === 'repo' && hasProjectGroups + const projectGroupByIdForHeaderDrag = useMemo( + () => new Map(projectGroups.map((group) => [group.id, group])), + [projectGroups] + ) + + // Why: reorder keeps scrollTop stable; flag direct scroll input so anchor-restore won't chase the moved row (jumpy drop). + const suppressScrollCorrectionForHeaderCommit = useCallback(() => { + const suppressUntil = window.performance.now() + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS + suppressMeasurementAdjustmentUntilRef.current = suppressUntil + directScrollInputUntilRef.current = suppressUntil + }, [directScrollInputUntilRef, suppressMeasurementAdjustmentUntilRef]) + const commitRepoReorder = useCallback( + (orderedIds: string[]) => { + suppressScrollCorrectionForHeaderCommit() + reorderRepos(orderedIds) + }, + [reorderRepos, suppressScrollCorrectionForHeaderCommit] + ) + const orderedHostIds = useMemo( + () => + rows + .filter((row): row is HostHeaderRow => row.type === 'host-header') + .map((row) => row.hostId), + [rows] + ) + const hostDrag = useHostHeaderDrag({ + orderedHostIds, + onCommit: onReorderHostSections, + getScrollContainer: () => scrollRef.current + }) + useEffect(() => { + onHostDragActiveChange(hostDrag.state.draggingHostId !== null) + }, [hostDrag.state.draggingHostId, onHostDragActiveChange]) + useEffect(() => () => onHostDragActiveChange(false), [onHostDragActiveChange]) + + const sidebarRepoHeaderIdsByBucket = useMemo( + () => + getSidebarOrderedRepoHeaderIdsByBucket( + rows.filter((row): row is Row => row.type !== 'host-header') + ), + [rows] + ) + const sidebarProjectGroupHeaderIdsByBucket = useMemo( + () => + getSidebarOrderedProjectGroupHeaderIdsByBucket( + rows.filter((row): row is Row => row.type !== 'host-header'), + projectGroupByIdForHeaderDrag + ), + [projectGroupByIdForHeaderDrag, rows] + ) + const repoHeaderIndexByRepoId = useMemo( + () => indexById(sidebarRepoHeaderIdsByBucket), + [sidebarRepoHeaderIdsByBucket] + ) + const repoHeaderBucketByRepoId = useMemo( + () => bucketById(sidebarRepoHeaderIdsByBucket), + [sidebarRepoHeaderIdsByBucket] + ) + const projectGroupHeaderIndexByGroupId = useMemo( + () => indexById(sidebarProjectGroupHeaderIdsByBucket), + [sidebarProjectGroupHeaderIdsByBucket] + ) + const projectGroupHeaderBucketByGroupId = useMemo( + () => bucketById(sidebarProjectGroupHeaderIdsByBucket), + [sidebarProjectGroupHeaderIdsByBucket] + ) + const commitProjectGroupOrder = useCallback( + (repoId: string, projectGroupId: string | null, order: number) => { + void moveProjectToGroup(repoId, projectGroupId, order) + }, + [moveProjectToGroup] + ) + const commitProjectGroupHeaderOrder = useCallback( + (groupId: string, tabOrder: number) => { + if (!Number.isFinite(tabOrder)) { + return + } + suppressScrollCorrectionForHeaderCommit() + void updateProjectGroup(groupId, { tabOrder }) + }, + [suppressScrollCorrectionForHeaderCommit, updateProjectGroup] + ) + // Drag applies only in manual order; still construct the controller inert for stable hook order. + const repoDrag = useRepoHeaderDrag({ + orderedRepoIds: allRepoIds, + sidebarRepoHeaderIdsByBucket, + repoById: repoMap, + usesProjectGroupOrdering: hasProjectGroups, + onCommitRepoOrder: commitRepoReorder, + onCommitProjectGroupOrder: commitProjectGroupOrder, + getScrollContainer: () => scrollRef.current + }) + const projectGroupDrag = useProjectGroupHeaderDrag({ + sidebarProjectGroupHeaderIdsByBucket, + projectGroupById: projectGroupByIdForHeaderDrag, + onCommitProjectGroupTabOrder: commitProjectGroupHeaderOrder, + getScrollContainer: () => scrollRef.current + }) + const repoHeaderSectionEndByRepoId = useMemo( + () => + getRepoHeaderSectionEndByRepoId({ + rows: renderRows, + firstHeaderIndex, + sidebarRepoHeaderIdsByBucket, + repoHeaderBucketByRepoId + }), + [firstHeaderIndex, renderRows, repoHeaderBucketByRepoId, sidebarRepoHeaderIdsByBucket] + ) + const projectGroupHeaderSectionEndByGroupId = useMemo( + () => + getProjectGroupHeaderSectionEndByGroupId({ + rows: renderRows, + firstHeaderIndex, + sidebarProjectGroupHeaderIdsByBucket, + projectGroupHeaderBucketByGroupId + }), + [ + firstHeaderIndex, + projectGroupHeaderBucketByGroupId, + renderRows, + sidebarProjectGroupHeaderIdsByBucket + ] + ) + + return { + canReorderRepoHeaders, + canReorderProjectGroupHeaders, + orderedHostIds, + hostDrag, + repoDrag, + projectGroupDrag, + sidebarRepoHeaderIdsByBucket, + sidebarProjectGroupHeaderIdsByBucket, + repoHeaderIndexByRepoId, + repoHeaderBucketByRepoId, + projectGroupHeaderIndexByGroupId, + projectGroupHeaderBucketByGroupId, + repoHeaderSectionEndByRepoId, + projectGroupHeaderSectionEndByGroupId + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-sidebar-scroll-suppression.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-sidebar-scroll-suppression.ts new file mode 100644 index 00000000000..e606c270a70 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-sidebar-scroll-suppression.ts @@ -0,0 +1,83 @@ +import { useCallback, useEffect, useRef } from 'react' +import type React from 'react' +import { SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT } from '../WorktreeCardAgents' +import { + createPendingRevealScroll, + isRevealScrollSettling, + type PendingRevealScroll +} from '../worktree-sidebar-reveal-scroll-settle' +import { + EXPANDING_CARD_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS, + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS +} from './worktree-sidebar-scroll-tuning' + +export type WorktreeSidebarScrollSuppression = ReturnType< + typeof useWorktreeSidebarScrollSuppression +> + +// Tracks the two suppression windows the virtualizer honours: measurement correction +// (any scroll movement) and anchor restore (direct user input or a settling reveal). +export function useWorktreeSidebarScrollSuppression( + scrollRef: React.RefObject +) { + const suppressMeasurementAdjustmentUntilRef = useRef(0) + const directScrollInputUntilRef = useRef(0) + const pendingRevealScrollRef = useRef(null) + + const markScrollMovement = useCallback(() => { + suppressMeasurementAdjustmentUntilRef.current = + window.performance.now() + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS + }, []) + const markDirectScrollInput = useCallback(() => { + const suppressUntil = window.performance.now() + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS + suppressMeasurementAdjustmentUntilRef.current = suppressUntil + directScrollInputUntilRef.current = suppressUntil + }, []) + const hasDirectScrollInput = useCallback( + () => window.performance.now() < directScrollInputUntilRef.current, + [] + ) + const markRevealScroll = useCallback((targetTop: number) => { + pendingRevealScrollRef.current = createPendingRevealScroll(targetTop, window.performance.now()) + }, []) + const isRevealScrollSettlingNow = useCallback(() => { + const settling = isRevealScrollSettling({ + now: window.performance.now(), + pending: pendingRevealScrollRef.current, + scrollTop: scrollRef.current?.scrollTop ?? 0 + }) + if (!settling) { + pendingRevealScrollRef.current = null + } + return settling + }, [scrollRef]) + // Why: programmatic scrolls keep measurement correction quiet, but only direct input blocks anchor-restore retries. + // A reveal's smooth scroll is the exception: restoring the anchor mid-animation cancels it a few pixels in. + const shouldSkipScrollAnchorRestore = useCallback( + () => + window.performance.now() < directScrollInputUntilRef.current || isRevealScrollSettlingNow(), + [isRevealScrollSettlingNow] + ) + + useEffect(() => { + const handleSuppress = () => { + // Why: let an expanding agent row grow in place instead of TanStack compensating scrollTop. + suppressMeasurementAdjustmentUntilRef.current = + window.performance.now() + EXPANDING_CARD_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS + } + window.addEventListener(SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT, handleSuppress) + return () => { + window.removeEventListener(SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT, handleSuppress) + } + }, []) + + return { + suppressMeasurementAdjustmentUntilRef, + directScrollInputUntilRef, + markScrollMovement, + markDirectScrollInput, + hasDirectScrollInput, + markRevealScroll, + shouldSkipScrollAnchorRestore + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/use-worktree-status-mutations.ts b/src/renderer/src/components/sidebar/worktree-list/use-worktree-status-mutations.ts new file mode 100644 index 00000000000..77f9cc38b3f --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/use-worktree-status-mutations.ts @@ -0,0 +1,201 @@ +import { useCallback } from 'react' +import { useAppStore } from '@/store' +import type { + WorkspaceStatus, + WorkspaceStatusDefinition, + Worktree +} 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 './virtualized-worktree-viewport-props' + +function buildRankByWorktreeId( + groups: readonly WorktreeDragGroup[], + worktreeMap: Map +): Map { + const rankByWorktreeId = new Map() + for (const group of groups) { + for (const worktreeId of group.worktreeIds) { + const worktree = worktreeMap.get(worktreeId) + if (worktree) { + rankByWorktreeId.set(worktreeId, worktree.manualOrder ?? worktree.sortOrder) + } + } + } + return rankByWorktreeId +} + +// Every write a sidebar drop can make: status changes, pin, manual order, and the board lane drop. +export function useWorktreeStatusMutations(args: { + worktreeMap: Map + workspaceStatuses: readonly WorkspaceStatusDefinition[] + sortBy: SortBy +}) { + const { worktreeMap, workspaceStatuses, sortBy } = args + const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) + const updateWorktreesMeta = useAppStore((s) => s.updateWorktreesMeta) + const setSortBy = useAppStore((s) => s.setSortBy) + const setWorktreesPinnedAndReveal = useAppStore((s) => s.setWorktreesPinnedAndReveal) + + const moveWorktreeToStatus = useCallback( + (worktreeId: string, status: WorkspaceStatus) => { + const current = worktreeMap.get(worktreeId) + if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) { + return + } + void updateWorktreeMeta(worktreeId, { workspaceStatus: status }) + }, + [updateWorktreeMeta, worktreeMap, workspaceStatuses] + ) + + const moveWorktreesToStatus = useCallback( + (worktreeIds: readonly string[], status: WorkspaceStatus) => { + const updates = new Map() + for (const worktreeId of worktreeIds) { + const current = worktreeMap.get(worktreeId) + if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) { + continue + } + updates.set(worktreeId, { workspaceStatus: status }) + } + if (updates.size > 0) { + void updateWorktreesMeta(updates) + } + }, + [updateWorktreesMeta, worktreeMap, workspaceStatuses] + ) + + const moveWorktreesToStatusAtIndex = useCallback( + (dropArgs: WorktreeStatusDropAtIndexArgs) => { + const order = buildManualOrderUpdatesForGroupDrop({ + groups: dropArgs.groups, + targetGroupKey: getWorkspaceStatusGroupKey(dropArgs.status), + draggedIds: dropArgs.worktreeIds, + dropIndex: dropArgs.dropIndex, + now: Date.now(), + rankByWorktreeId: buildRankByWorktreeId(dropArgs.groups, worktreeMap) + }) + const updates = new Map>() + for (const worktreeId of dropArgs.worktreeIds) { + const current = worktreeMap.get(worktreeId) + if (!current) { + continue + } + const next: Partial = {} + if (getWorkspaceStatus(current, workspaceStatuses) !== dropArgs.status) { + next.workspaceStatus = dropArgs.status + } + updates.set(worktreeId, next) + } + for (const [worktreeId, manualOrder] of order.updates) { + updates.set(worktreeId, { ...updates.get(worktreeId), ...manualOrder }) + } + for (const [worktreeId, update] of Array.from(updates)) { + if (Object.keys(update).length === 0) { + updates.delete(worktreeId) + } + } + if (updates.size === 0) { + return + } + // Why: the insertion line promises exact placement, so persist manual order on a cross-status drop. + if (order.changed) { + setSortBy('manual') + } + void updateWorktreesMeta(updates) + }, + [setSortBy, updateWorktreesMeta, worktreeMap, workspaceStatuses] + ) + + const pinWorktree = useCallback( + (worktreeId: string) => { + setWorktreesPinnedAndReveal([worktreeId], true) + }, + [setWorktreesPinnedAndReveal] + ) + + const pinWorktrees = useCallback( + (worktreeIds: readonly string[]) => { + setWorktreesPinnedAndReveal(worktreeIds, true) + }, + [setWorktreesPinnedAndReveal] + ) + + const reorderWorktrees = useCallback( + (reorderArgs: { + groups: readonly WorktreeDragGroup[] + sourceGroupKey: string + draggedIds: readonly string[] + dropIndex: number + }) => { + const result = buildManualOrderUpdatesForVisibleGroups({ + ...reorderArgs, + now: Date.now(), + rankByWorktreeId: buildRankByWorktreeId(reorderArgs.groups, worktreeMap) + }) + if (!result.changed) { + return + } + // Why: only switch to Manual after a real move so accidental click-drags don't change the sort. + setSortBy('manual') + void updateWorktreesMeta(result.updates) + }, + [setSortBy, updateWorktreesMeta, worktreeMap] + ) + + const shouldShowWorkspaceBoardDropIndicator = useCallback( + (worktreeIds: readonly string[], status: WorkspaceStatus) => { + const sourceGroupKeys = worktreeIds.flatMap((worktreeId) => { + const worktree = worktreeMap.get(worktreeId) + return worktree ? [getWorkspaceStatus(worktree, workspaceStatuses)] : [] + }) + return shouldWriteManualOrderForGroupDrop({ + sortBy, + sourceGroupKeys, + targetGroupKey: status + }) + }, + [sortBy, worktreeMap, workspaceStatuses] + ) + + const dropWorktreesOnWorkspaceBoard = useCallback( + (dropArgs: WorktreeStatusDropAtIndexArgs) => { + const result = buildWorkspaceKanbanSidebarDropUpdates({ + ...dropArgs, + worktreeById: worktreeMap, + workspaceStatuses, + sortBy, + now: Date.now() + }) + if (result.updates.size === 0) { + return + } + // Why: switch to Manual when the drop changes order so the placement stays visible. + if (result.shouldSwitchToManual) { + setSortBy('manual') + } + useAppStore.getState().recordFeatureInteraction('workspace-board-actions') + void updateWorktreesMeta(result.updates) + }, + [setSortBy, sortBy, updateWorktreesMeta, worktreeMap, workspaceStatuses] + ) + + return { + moveWorktreeToStatus, + moveWorktreesToStatus, + moveWorktreesToStatusAtIndex, + pinWorktree, + pinWorktrees, + reorderWorktrees, + shouldShowWorkspaceBoardDropIndicator, + dropWorktreesOnWorkspaceBoard + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/virtual-row-dom-attributes.ts b/src/renderer/src/components/sidebar/worktree-list/virtual-row-dom-attributes.ts new file mode 100644 index 00000000000..7523418e7c1 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/virtual-row-dom-attributes.ts @@ -0,0 +1,15 @@ +import { getVirtualRowTransform } from '../worktree-list-virtual-rows' + +export function getVirtualRowIndex(element: Element): number | null { + const index = Number.parseInt(element.getAttribute('data-index') ?? '', 10) + return Number.isNaN(index) ? null : index +} + +export function getVirtualRowKey(element: Element): string | null { + return element.getAttribute('data-worktree-virtual-row-key') +} + +export function getWorktreeVirtualRowTransform(start: number, previewOffset: number): string { + const base = getVirtualRowTransform(start) + return previewOffset === 0 ? base : `${base} translateY(${previewOffset}px)` +} diff --git a/src/renderer/src/components/sidebar/worktree-list/virtualized-worktree-viewport-props.ts b/src/renderer/src/components/sidebar/worktree-list/virtualized-worktree-viewport-props.ts new file mode 100644 index 00000000000..2aaacf85795 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/virtualized-worktree-viewport-props.ts @@ -0,0 +1,116 @@ +import type React from 'react' +import type { AppState } from '@/store/types' +import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor' +import type { PendingSidebarRowReveal, PendingSidebarWorktreeReveal } from '@/store/slices/ui' +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 { + WorkspaceLineage, + WorktreeLineage +} from '../../../../../shared/worktree/lineage-types' +import type { + WorkspaceStatus, + WorkspaceStatusDefinition, + Worktree +} from '../../../../../shared/worktree/types' +import type { ExecutionHostId } from '../../../../../shared/execution-host' +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 '../worktree-list-groups' + +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 + currentWorktreeId: string | null + groupBy: WorktreeGroupBy + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy + projectOrderBy: ProjectOrderBy + toggleGroup: (key: string) => void + collapsedGroups: Set + handleCreateForRepo: (projectId: string) => void + handleOpenRepoSettings: (projectId: string, sectionId?: string) => void + handleOpenWorktreeVisibility: (repo: Repo) => void + handleShowImportedWorktrees: (projectId: string) => void + handleKeepImportedWorktreesHidden: (projectId: string) => void + importedWorktreeCardActionState: ReadonlyMap + handleOpenSuppressExternalWorktreeInbox: (projectId: string) => void + newExternalWorktreeInboxActionState: ReadonlyMap + handleRemoveProject: (repo: Repo) => void + handleCreateGroupFromRepo: (repo: Repo) => void + handleMoveProjectToGroup: (repo: Repo, groupId: string) => void + handleRemoveProjectFromGroup: (repo: Repo) => void + handleRenameProjectGroup: (groupId: string, currentName: string) => void + handleDeleteProjectGroup: (groupId: string, groupName: string) => void + handleCreateFolderWorkspace: (projectGroup: ProjectGroup) => void + activeModal: string + pendingRevealWorktree: PendingSidebarWorktreeReveal | null + pendingRevealSidebarRow: PendingSidebarRowReveal | null + clearPendingRevealWorktreeId: () => void + clearPendingRevealSidebarRow: () => void + agentSendTargetWorktreeId: string | null + worktrees: Worktree[] + folderWorkspaces: readonly FolderWorkspace[] + selectedWorktreeIds: ReadonlySet + selectedWorktrees: readonly Worktree[] + onSelectionGesture: (event: React.MouseEvent, worktreeId: string) => boolean + onImmediateWorktreeActivate: (worktreeId: string, rowKey: string | undefined) => void + onContextMenuSelect: ( + event: React.MouseEvent, + worktree: Worktree + ) => readonly Worktree[] + repoMap: Map + defaultHostId: ExecutionHostId + worktreeMap: Map + worktreeLineageById: Record + workspaceLineageByChildKey: Record + // Full canonical repo-id order; must include hidden repos or a reorder silently drops them. + allRepoIds: string[] + onReorderHostSections: (orderedHostIds: ExecutionHostId[]) => void + onHostDragActiveChange: (active: boolean) => void + prCache: AppState['prCache'] | null + hostedReviewCache: AppState['hostedReviewCache'] | null + workspaceStatuses: readonly WorkspaceStatusDefinition[] + projectGrouping?: ProjectGroupingModel + projectGroups?: readonly ProjectGroup[] + onMoveWorktreeToStatus: (worktreeId: string, status: WorkspaceStatus) => void + onMoveWorktreesToStatus: (worktreeIds: readonly string[], status: WorkspaceStatus) => void + onMoveWorktreesToStatusAtIndex: (args: WorktreeStatusDropAtIndexArgs) => void + onPinWorktree: (worktreeId: string) => void + onPinWorktrees: (worktreeIds: readonly string[]) => void + onDropWorktreesOnWorkspaceBoard: (args: WorktreeStatusDropAtIndexArgs) => void + workspaceBoardOpen: boolean + onWorkspaceBoardDragPreviewStart: () => void + onWorkspaceBoardDragPreviewCommit: () => void + onWorkspaceBoardDragPreviewCancel: () => void + shouldShowWorkspaceBoardDropIndicator: ( + worktreeIds: readonly string[], + status: WorkspaceStatus + ) => boolean + onReorderWorktrees: (args: { + groups: readonly WorktreeDragGroup[] + sourceGroupKey: string + draggedIds: readonly string[] + dropIndex: number + }) => void + // Why: grouping remounts the viewport, add/delete stays mounted; bridge both so the virtualizer never resets to scrollTop 0. + scrollOffsetRef: React.MutableRefObject + scrollAnchorRef: React.MutableRefObject +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-drag-groups.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-drag-groups.ts new file mode 100644 index 00000000000..eb93ecf617a --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-drag-groups.ts @@ -0,0 +1,72 @@ +import { ALL_GROUP_KEY, PINNED_GROUP_KEY } from '../worktree-list-groups' +import type { HostSectionRow } from '../host-section-rows' +import type { WorktreeDragGroup } from '../worktree-manual-order' + +// A pinned duplicate of a worktree that also renders in its natural group is not its own drag slot. +function getNaturalWorktreeIds(rows: readonly HostSectionRow[]): Set { + return new Set( + rows.flatMap((row) => + row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : [] + ) + ) +} + +export function getWorktreeDragGroups(rows: HostSectionRow[]): WorktreeDragGroup[] { + const groups: WorktreeDragGroup[] = [] + let current: { key: string; ids: string[] } | null = null + const naturalWorktreeIds = getNaturalWorktreeIds(rows) + + for (const row of rows) { + if (row.type === 'header') { + current = { key: row.key, ids: [] } + groups.push({ key: current.key, worktreeIds: current.ids }) + continue + } + if ( + row.type === 'host-header' || + row.type === 'imported-worktrees-card' || + row.type === 'new-external-worktrees-inbox' || + row.type === 'pending-creation' || + row.type === 'folder-workspace' + ) { + continue + } + if (row.sectionKey === PINNED_GROUP_KEY && naturalWorktreeIds.has(row.worktree.id)) { + continue + } + if (!current) { + current = { key: ALL_GROUP_KEY, ids: [] } + groups.push({ key: current.key, worktreeIds: current.ids }) + } + current.ids.push(row.worktree.id) + } + + return groups.filter((group) => group.worktreeIds.length > 0) +} + +export function getWorktreeDragIndexes(rows: readonly HostSectionRow[]): { + groupKeyByRowKey: Map + groupIndexByRowKey: Map +} { + const groupKeyByRowKey = new Map() + const groupIndexByRowKey = new Map() + const groupIndexes = new Map() + const naturalWorktreeIds = getNaturalWorktreeIds(rows) + for (const row of rows) { + if (row.type === 'header') { + groupIndexes.set(row.key, 0) + continue + } + if (row.type !== 'item') { + continue + } + if (row.sectionKey === PINNED_GROUP_KEY && naturalWorktreeIds.has(row.worktree.id)) { + continue + } + const index = groupIndexes.get(row.sectionKey) ?? 0 + groupKeyByRowKey.set(row.rowKey, row.sectionKey) + groupIndexByRowKey.set(row.rowKey, index) + groupIndexes.set(row.sectionKey, index + 1) + } + return { groupKeyByRowKey, groupIndexByRowKey } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-drop-commit-context.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-drop-commit-context.ts new file mode 100644 index 00000000000..2fee58ba4a4 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-drop-commit-context.ts @@ -0,0 +1,43 @@ +import type React from 'react' +import type { + WorkspaceStatus, + WorkspaceStatusDefinition +} from '../../../../../shared/worktree/types' +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 './virtualized-worktree-viewport-props' +import type { WorktreeStatusDropRequest } from './use-worktree-drag-session' +import type { WorktreeSidebarLineageDropTarget } from './worktree-row-drag-state' + +// The shared surface every drop path (pointer, native drag, document capture) commits through. +export type WorktreeDropCommitContext = { + scrollRef: React.RefObject + workspaceStatuses: readonly WorkspaceStatusDefinition[] + worktreeDragGroups: readonly WorktreeDragGroup[] + worktreeDragUnitGroups: readonly WorktreeDragUnitGroup[] + computeWorktreeDrop: (pointerY: number) => WorktreeSidebarDropPreview | null + computeWorktreeStatusDrop: ( + request: WorktreeStatusDropRequest + ) => WorktreeSidebarDropPreview | null + refreshWorktreeDragSession: () => boolean + getEligibleLineageDropTarget: ( + target: WorktreeSidebarLineageDropTarget, + draggedIds: readonly string[] + ) => WorktreeSidebarLineageDropTarget + commitWorktreeLineageParentDrop: (draggedIds: readonly string[], parentId: string) => boolean + clearReorderedWorktreeParents: (args: { + draggedIds: readonly string[] + sourceGroupKey: string + }) => void + clearWorktreeDrag: () => void + onMoveWorktreesToStatus: (worktreeIds: readonly string[], status: WorkspaceStatus) => void + onMoveWorktreesToStatusAtIndex: (args: WorktreeStatusDropAtIndexArgs) => void + onReorderWorktrees: (args: { + groups: readonly WorktreeDragGroup[] + sourceGroupKey: string + draggedIds: readonly string[] + dropIndex: number + }) => void + onPinWorktrees: (worktreeIds: readonly string[]) => void +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-list-item-row.tsx b/src/renderer/src/components/sidebar/worktree-list/worktree-list-item-row.tsx new file mode 100644 index 00000000000..8e0a5c070c5 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-list-item-row.tsx @@ -0,0 +1,251 @@ +import React from 'react' +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 '../worktree-list-groups' +import { + getFolderBackedRepoWorktreeCardContentIndent, + getFolderBackedRepoWorktreeCardSurfaceInset, + getLineageChildrenInlineStyle, + getLineageNestedRowGeometry, + getWorktreeCardContentIndent, + getWorktreeCardSurfaceInset, + LINEAGE_CHILDREN_INLINE_OFFSET +} from '../worktree-list-indentation' +import type { LineageToggleHandler } from '../worktree-lineage-toggle-handler-cache' +import { stopNestedWorktreeCardBubble } from './repo-header-event-guards' +import type { WorktreeItemRow } from './render-row-item-rows' +import { getWorktreeOptionId } from './worktree-option-dom' +import type { WorktreePointerDrag, WorktreeRowDragState } from './worktree-row-drag-state' + +export type WorktreeItemRowContext = { + settings: AppState['settings'] + groupBy: WorktreeGroupBy + folderBackedProjectGroupIds: ReadonlySet + groupKeyByRowKey: ReadonlyMap + groupIndexByRowKey: ReadonlyMap + agentSendTargetWorktreeId: string | null + worktreeDragState: WorktreeRowDragState + worktreePointerDragRef: React.MutableRefObject + nativeLineageDropTargetId: string | null + activeWorktreeId: string | null + currentWorktreeId: string | null + highlightedRevealRowKey: string | null + selectedWorktreeIds: ReadonlySet + selectedWorktrees: readonly Worktree[] + getActiveSurfaceVariant: (row: WorktreeItemRow) => ActiveSurfaceVariant + getLineageToggleHandler: (groupKey: string) => LineageToggleHandler + onSelectionGesture: (event: React.MouseEvent, worktreeId: string) => boolean + onContextMenuSelect: ( + event: React.MouseEvent, + worktree: Worktree + ) => readonly Worktree[] + onImmediateActivate: (worktreeId: string, rowKey: string | undefined) => void + onRowClickCapture: (event: React.MouseEvent) => void + onRowPointerDown: ( + event: React.PointerEvent, + worktreeId: string, + rowKey: string + ) => void + onCardDragStart: ( + event: React.DragEvent, + worktreeId: string, + draggedIds: readonly string[] + ) => void + onCardDragEnd: () => void +} + +// Geometry differs three ways: a plain grouped row, a lineage child inheriting its parent's +// surface, and a row inside a folder-backed project group. +function getWorktreeItemRowGeometry( + ctx: WorktreeItemRowContext, + itemRow: WorktreeItemRow, + nested: boolean +): { surfaceInset: number; cardContentIndent: number; lineageChildrenInlineOffset?: number } { + const projectGroupId = itemRow.repo?.projectGroupId + const isFolderBackedRepoChild = + ctx.groupBy === 'repo' && + Boolean(projectGroupId && ctx.folderBackedProjectGroupIds.has(projectGroupId)) + // Why: experimental in-card lineage inherits the parent surface; legacy cards keep depth-based nested geometry. + const paddingDepth = nested ? Math.max(0, itemRow.depth - 1) : itemRow.depth + const getCardContentIndent = (lineageDepth: number): number => + isFolderBackedRepoChild + ? getFolderBackedRepoWorktreeCardContentIndent({ + groupDepth: itemRow.groupDepth, + lineageDepth + }) + : getWorktreeCardContentIndent({ + isGrouped: ctx.groupBy !== 'none', + groupDepth: itemRow.groupDepth, + lineageDepth + }) + const nestedLineageGeometry = nested + ? getLineageNestedRowGeometry({ + experimentalNewWorktreeCardStyle: ctx.settings?.experimentalNewWorktreeCardStyle === true, + inheritedCardContentIndent: getCardContentIndent(0), + lineageDepth: itemRow.depth + }) + : null + // Why: grouped rows inherit their header depth, but the card surface still spans the full row. + const paddingLeft = + nested && ctx.groupBy !== 'none' + ? getWorktreeCardContentIndent({ + isGrouped: false, + groupDepth: itemRow.groupDepth, + lineageDepth: paddingDepth + }) + : getCardContentIndent(paddingDepth) + const surfaceInset = nestedLineageGeometry + ? nestedLineageGeometry.surfaceInset + : isFolderBackedRepoChild + ? getFolderBackedRepoWorktreeCardSurfaceInset({ + groupDepth: itemRow.groupDepth, + lineageDepth: paddingDepth + }) + : getWorktreeCardSurfaceInset({ + isGrouped: ctx.groupBy !== 'none', + groupDepth: itemRow.groupDepth + }) + return { + surfaceInset, + cardContentIndent: nestedLineageGeometry + ? nestedLineageGeometry.cardContentIndent + : Math.max(0, paddingLeft - surfaceInset), + lineageChildrenInlineOffset: nestedLineageGeometry?.lineageChildrenInlineOffset + } +} + +export function renderWorktreeItemRow( + ctx: WorktreeItemRowContext, + itemRow: WorktreeItemRow, + nested: boolean, + lineageChildren?: React.ReactNode, + forceActiveSurface = false +): React.JSX.Element { + const { surfaceInset, cardContentIndent, lineageChildrenInlineOffset } = + getWorktreeItemRowGeometry(ctx, itemRow, nested) + const lineageChildrenStyle = lineageChildren + ? getLineageChildrenInlineStyle(lineageChildrenInlineOffset ?? LINEAGE_CHILDREN_INLINE_OFFSET) + : undefined + const worktreeDragGroupKey = ctx.groupKeyByRowKey.get(itemRow.rowKey) + const isLineageDropTarget = + ctx.worktreeDragState.draggingWorktreeId && + (ctx.worktreePointerDragRef.current?.latestStatusDropTarget?.target.lineageParentId === + itemRow.worktree.id || + ctx.nativeLineageDropTargetId === itemRow.worktree.id) + const isActiveWorktree = ctx.activeWorktreeId === itemRow.worktree.id + return ( +
{ + if (nested) { + event.stopPropagation() + } + ctx.onRowPointerDown(event, itemRow.worktree.id, itemRow.rowKey) + }} + style={{ + paddingLeft: surfaceInset > 0 ? `${surfaceInset}px` : undefined + }} + > + +
+ ) +} + +// Rebuild the visible lineage subtree so each card renders its own children inline. +export function renderWorktreeLineageDescendants( + ctx: WorktreeItemRowContext, + parent: WorktreeItemRow, + descendants: readonly WorktreeItemRow[] +): React.ReactNode | undefined { + const childNodes: React.ReactNode[] = [] + let cursor = 0 + while (cursor < descendants.length) { + const child = descendants[cursor] + if (!child || child.depth !== parent.depth + 1) { + cursor++ + continue + } + + let nextSiblingIndex = cursor + 1 + while ( + nextSiblingIndex < descendants.length && + descendants[nextSiblingIndex]!.depth > child.depth + ) { + nextSiblingIndex++ + } + + const childLineageChildren = renderWorktreeLineageDescendants( + ctx, + child, + descendants.slice(cursor + 1, nextSiblingIndex) + ) + childNodes.push(renderWorktreeItemRow(ctx, child, true, childLineageChildren)) + cursor = nextSiblingIndex + } + return childNodes.length > 0 ? childNodes : undefined +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-notice-virtual-rows.tsx b/src/renderer/src/components/sidebar/worktree-list/worktree-notice-virtual-rows.tsx new file mode 100644 index 00000000000..d997548f4bd --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-notice-virtual-rows.tsx @@ -0,0 +1,116 @@ +import React from 'react' +import type { VirtualItem } from '@tanstack/react-virtual' +import type { Row } from '../worktree-list-groups' +import { getVirtualRowTransform } from '../worktree-list-virtual-rows' +import { PendingWorktreeRow } from '../PendingWorktreeRow' +import ImportedWorktreesVisibilityLine from '../ImportedWorktreesVisibilityLine' +import NewExternalWorktreesInboxLine from '../NewExternalWorktreesInboxLine' +import type { ImportedWorktreeCardActionState } from '../imported-worktrees-card-actions' +import type { NewExternalWorktreesInboxActionState } from '../new-external-worktrees-inbox-actions' +import { canKeepImportedWorktreesHidden } from './imported-worktrees-card-visibility' + +type NoticeRowFrameProps = { + vItem: VirtualItem + measureVirtualRowElement: (element: HTMLDivElement | null) => void + className?: string + children: React.ReactNode +} + +// Every non-card sidebar row shares the same absolutely-positioned virtual frame. +function NoticeRowFrame({ + vItem, + measureVirtualRowElement, + className = 'absolute left-0 right-0 top-0', + children +}: NoticeRowFrameProps): React.JSX.Element { + return ( +
+ {children} +
+ ) +} + +export function renderImportedWorktreesVirtualRow(args: { + row: Extract + vItem: VirtualItem + measureVirtualRowElement: (element: HTMLDivElement | null) => void + actionState: ImportedWorktreeCardActionState | undefined + onShow: (projectId: string) => void + onKeepHidden: (projectId: string) => void +}): React.JSX.Element { + const { row, actionState } = args + return ( + + args.onShow(row.repo.id)} + onKeepHidden={ + canKeepImportedWorktreesHidden(row, actionState) + ? () => args.onKeepHidden(row.repo.id) + : undefined + } + /> + + ) +} + +export function renderNewExternalWorktreesInboxVirtualRow(args: { + row: Extract + vItem: VirtualItem + measureVirtualRowElement: (element: HTMLDivElement | null) => void + actionState: NewExternalWorktreesInboxActionState | undefined + onReview: (repo: Extract['repo']) => void + onSuppress: (projectId: string) => void +}): React.JSX.Element { + const { row, actionState } = args + return ( + + args.onReview(row.repo)} + onSuppress={() => args.onSuppress(row.repo.id)} + /> + + ) +} + +export function renderPendingCreationVirtualRow(args: { + row: Extract + vItem: VirtualItem + measureVirtualRowElement: (element: HTMLDivElement | null) => void +}): React.JSX.Element { + return ( + + + + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-option-dom.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-option-dom.ts new file mode 100644 index 00000000000..ec52da40a12 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-option-dom.ts @@ -0,0 +1,58 @@ +export function getWorktreeOptionId(rowKey: string): string { + return `worktree-list-option-${encodeURIComponent(rowKey)}` +} + +export function getMountedWorktreeOptions( + worktreeId: string, + root?: ParentNode | null +): HTMLElement[] { + const scope = root ?? document + const result: HTMLElement[] = [] + scope.querySelectorAll('[data-worktree-id]').forEach((element) => { + if (element.dataset.worktreeId === worktreeId) { + result.push(element) + } + }) + return result +} + +export function markSidebarWorktreeActiveImmediately( + worktreeId: string, + primaryRowKey?: string +): void { + const sidebar = document.querySelector('[data-worktree-sidebar]') + const nextOptions = getMountedWorktreeOptions(worktreeId, sidebar) + const nextOption = nextOptions[0] + if (!nextOption) { + return + } + + sidebar + ?.querySelectorAll('[role="option"][aria-current="page"]') + .forEach((option) => option.removeAttribute('aria-current')) + + for (const option of nextOptions) { + option.setAttribute('aria-current', 'page') + } + sidebar + ?.querySelectorAll('[data-worktree-card-surface][data-worktree-card-active]') + .forEach((surface) => { + if (!nextOptions.some((option) => option.contains(surface))) { + surface.removeAttribute('data-worktree-card-active') + } + }) + for (const option of nextOptions) { + const activeSurfaceVariant = + primaryRowKey !== undefined + ? option.dataset.worktreeRowKey === primaryRowKey + ? 'primary' + : 'secondary' + : option === nextOption + ? 'primary' + : 'secondary' + const surface = option.matches('[data-worktree-card-surface]') + ? option + : option.querySelector('[data-worktree-card-surface]') + surface?.setAttribute('data-worktree-card-active', activeSurfaceVariant) + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-pointer-drag-commit.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-pointer-drag-commit.ts new file mode 100644 index 00000000000..1e09a403885 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-pointer-drag-commit.ts @@ -0,0 +1,159 @@ +import { + getWorkspaceKanbanSidebarDropGroups, + getWorkspaceKanbanSidebarDropTarget, + isWorkspaceKanbanSidebarDropPointInBoard, + resolveWorkspaceKanbanSidebarFullLaneDropIndex +} from '../workspace-kanban-sidebar-drop' +import { resolveWorkspaceKanbanCardDropCommitTarget } from '../workspace-kanban-card-pointer-drag-dom' +import { getFullDropIndexForWorktreeDragUnit } from '../worktree-drag-units' +import { resolveWorktreeSidebarStatusDropCommitTarget } from '../worktree-sidebar-drop-preview' +import { + getPointerDropStatusTarget, + shouldPreferSidebarStatusDropTarget +} from './pointer-drop-status-target' +import type { WorktreeDropCommitContext } from './worktree-drop-commit-context' +import type { WorktreeStatusDropAtIndexArgs } from './virtualized-worktree-viewport-props' +import type { WorktreeSidebarStatusDropTarget } from '../worktree-sidebar-drop-preview' +import { + NO_WORKTREE_SIDEBAR_DROP_TARGET, + type WorktreePointerDrag +} from './worktree-row-drag-state' + +type PointerDropCommitArgs = { + event: PointerEvent + drag: WorktreePointerDrag + ctx: WorktreeDropCommitContext + onWorkspaceBoardDragPreviewCommit: () => void + onDropWorktreesOnWorkspaceBoard: (args: WorktreeStatusDropAtIndexArgs) => void +} + +function commitStatusOrPinDrop( + args: PointerDropCommitArgs, + target: WorktreeSidebarStatusDropTarget & { lineageParentId?: string | null }, + dropIndex: number | null +): void { + const { drag, ctx } = args + if (target.isPinDrop) { + ctx.onPinWorktrees(drag.draggedIds) + return + } + if (!target.status) { + return + } + if (dropIndex === null) { + ctx.onMoveWorktreesToStatus(drag.reorderDraggedIds, target.status) + return + } + ctx.onMoveWorktreesToStatusAtIndex({ + worktreeIds: drag.reorderDraggedIds, + status: target.status, + dropIndex, + groups: ctx.worktreeDragGroups + }) +} + +// Resolve where a released pointer drag lands: workspace board lane, lineage parent, +// status/pin section, or a reorder slot inside the source group. +export function commitWorktreePointerDrop(args: PointerDropCommitArgs): void { + const { event, drag, ctx } = args + if (!ctx.refreshWorktreeDragSession()) { + ctx.clearWorktreeDrag() + return + } + const boardDropTarget = resolveWorkspaceKanbanCardDropCommitTarget({ + currentTarget: getWorkspaceKanbanSidebarDropTarget(event.clientX, event.clientY), + latestTrackedTarget: drag.latestBoardDropTarget, + x: event.clientX, + y: event.clientY + }) + if (isWorkspaceKanbanSidebarDropPointInBoard(event.clientX, event.clientY)) { + args.onWorkspaceBoardDragPreviewCommit() + } + if (boardDropTarget.isPinDrop) { + ctx.onPinWorktrees(drag.draggedIds) + } else if (boardDropTarget.status) { + args.onDropWorktreesOnWorkspaceBoard({ + worktreeIds: drag.reorderDraggedIds, + status: boardDropTarget.status, + // Why: the target counts rendered cards, but the groups are the full + // lane. Board search can make those two differ. + dropIndex: resolveWorkspaceKanbanSidebarFullLaneDropIndex( + boardDropTarget.status, + boardDropTarget.dropIndex + ), + groups: getWorkspaceKanbanSidebarDropGroups() + }) + } else { + const preferredStatusTarget = ctx.getEligibleLineageDropTarget( + ctx.scrollRef.current + ? getPointerDropStatusTarget({ + container: ctx.scrollRef.current, + x: event.clientX, + y: event.clientY + }) + : NO_WORKTREE_SIDEBAR_DROP_TARGET, + drag.draggedIds + ) + if (preferredStatusTarget.lineageParentId) { + ctx.commitWorktreeLineageParentDrop(drag.draggedIds, preferredStatusTarget.lineageParentId) + ctx.clearWorktreeDrag() + return + } + if ( + shouldPreferSidebarStatusDropTarget({ + sourceGroupKey: drag.sourceGroupKey, + target: preferredStatusTarget, + workspaceStatuses: ctx.workspaceStatuses + }) + ) { + const statusDrop = preferredStatusTarget.status + ? ctx.computeWorktreeStatusDrop({ + pointerY: event.clientY, + status: preferredStatusTarget.status, + draggedIds: drag.reorderDraggedIds + }) + : null + commitStatusOrPinDrop(args, preferredStatusTarget, statusDrop?.dropIndex ?? null) + ctx.clearWorktreeDrag() + return + } + const drop = ctx.computeWorktreeDrop(event.clientY) + if (drop) { + ctx.onReorderWorktrees({ + groups: ctx.worktreeDragGroups, + sourceGroupKey: drag.sourceGroupKey, + draggedIds: drag.reorderDraggedIds, + dropIndex: getFullDropIndexForWorktreeDragUnit({ + groups: ctx.worktreeDragUnitGroups, + sourceGroupKey: drag.sourceGroupKey, + dropIndex: drop.dropIndex + }) + }) + ctx.clearReorderedWorktreeParents({ + draggedIds: drag.draggedIds, + sourceGroupKey: drag.sourceGroupKey + }) + } else if (ctx.scrollRef.current) { + const currentPreview = preferredStatusTarget.status + ? ctx.computeWorktreeStatusDrop({ + pointerY: event.clientY, + status: preferredStatusTarget.status, + draggedIds: drag.reorderDraggedIds + }) + : null + const { target, preview: statusDrop } = resolveWorktreeSidebarStatusDropCommitTarget({ + currentTarget: preferredStatusTarget, + currentPreview, + latestTrackedTarget: drag.latestStatusDropTarget, + x: event.clientX, + y: event.clientY + }) + if (target.lineageParentId) { + ctx.commitWorktreeLineageParentDrop(drag.draggedIds, target.lineageParentId) + } else { + commitStatusOrPinDrop(args, target, statusDrop?.dropIndex ?? null) + } + } + } + ctx.clearWorktreeDrag() +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-pointer-drag-flush.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-pointer-drag-flush.ts new file mode 100644 index 00000000000..90d70d4534b --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-pointer-drag-flush.ts @@ -0,0 +1,172 @@ +import type React from 'react' +import type { WorkspaceStatus } from '../../../../../shared/worktree/types' +import { + clearWorkspaceKanbanSidebarDropTargetVisual, + hasWorkspaceKanbanSidebarDropBoard, + isWorkspaceKanbanSidebarDropPointInBoard, + updateWorkspaceKanbanSidebarDropTargetVisual +} from '../workspace-kanban-sidebar-drop' +import { updateSidebarDragPreviewPosition } from '../worktree-sidebar-pointer-drag-dom' +import { + getPointerDropStatusTarget, + shouldPreferSidebarStatusDropTarget +} from './pointer-drop-status-target' +import type { WorktreeDropCommitContext } from './worktree-drop-commit-context' +import { + applyWorktreeDropPreview, + clearWorktreeDropPreview, + NO_WORKTREE_SIDEBAR_DROP_TARGET, + updateLatestWorktreeStatusDropTarget, + type WorktreePointerDrag, + type WorktreeRowDragState, + type WorktreeSidebarLineageDropTarget +} from './worktree-row-drag-state' + +export type WorktreePointerDragFrameArgs = { + drag: WorktreePointerDrag + ctx: WorktreeDropCommitContext + workspaceBoardOpen: boolean + onWorkspaceBoardDragPreviewStart: () => void + onWorkspaceBoardDragPreviewCommit: () => void + shouldShowWorkspaceBoardDropIndicator: ( + worktreeIds: readonly string[], + status: WorkspaceStatus + ) => boolean + setWorktreeDragState: React.Dispatch> + setDragOverStatus: (status: WorkspaceStatus | null) => void + setPinDragOver: (pinDragOver: boolean) => void +} + +// Reflect a status/pin hover that has no insertion line of its own. +function showStatusHoverWithoutInsertionLine( + args: WorktreePointerDragFrameArgs, + target: WorktreeSidebarLineageDropTarget +): void { + const { drag, ctx } = args + const statusDrop = target.status + ? ctx.computeWorktreeStatusDrop({ + pointerY: drag.currentY, + status: target.status, + draggedIds: drag.reorderDraggedIds + }) + : null + updateLatestWorktreeStatusDropTarget(drag, target, statusDrop) + if (statusDrop) { + clearWorkspaceKanbanSidebarDropTargetVisual() + args.setDragOverStatus(null) + args.setPinDragOver(false) + args.setWorktreeDragState((prev) => + applyWorktreeDropPreview(prev, statusDrop, { + pointerY: drag.currentY, + matchPointerY: true + }) + ) + return + } + args.setDragOverStatus(target.status) + args.setPinDragOver(target.isPinDrop) + args.setWorktreeDragState((prev) => + clearWorktreeDropPreview(prev, { pointerY: drag.currentY, matchPointerY: true }) + ) +} + +function clearInsertionLine(args: WorktreePointerDragFrameArgs): void { + args.setDragOverStatus(null) + args.setPinDragOver(false) + args.setWorktreeDragState((prev) => + clearWorktreeDropPreview(prev, { pointerY: args.drag.currentY, matchPointerY: true }) + ) +} + +// One animation frame of an in-flight pointer drag: move the floating preview, then decide +// whether the pointer is over the workspace board, a status/pin section, or a reorder slot. +export function flushWorktreePointerDragFrame(args: WorktreePointerDragFrameArgs): void { + const { drag, ctx } = args + drag.frameId = null + if (!drag.active || !drag.preview) { + return + } + updateSidebarDragPreviewPosition({ + preview: drag.preview, + pointerX: drag.currentX, + pointerY: drag.currentY, + offsetX: drag.previewOffsetX, + offsetY: drag.previewOffsetY + }) + if (!ctx.refreshWorktreeDragSession()) { + ctx.clearWorktreeDrag() + return + } + // Why: show the board preview as soon as a card drag begins so the drop target is visible up front, not only at the sidebar edge. + if ( + !drag.workspaceBoardDragPreviewRequested && + !args.workspaceBoardOpen && + !hasWorkspaceKanbanSidebarDropBoard() + ) { + drag.workspaceBoardDragPreviewRequested = true + args.onWorkspaceBoardDragPreviewStart() + } + const boardTarget = updateWorkspaceKanbanSidebarDropTargetVisual({ + x: drag.currentX, + y: drag.currentY, + shouldShowDropIndicator: (target) => + Boolean( + target.status && + args.shouldShowWorkspaceBoardDropIndicator(drag.reorderDraggedIds, target.status) + ) + }) + drag.latestBoardDropTarget = { + target: boardTarget, + x: drag.currentX, + y: drag.currentY + } + if (isWorkspaceKanbanSidebarDropPointInBoard(drag.currentX, drag.currentY)) { + args.onWorkspaceBoardDragPreviewCommit() + } + if (boardTarget.status || boardTarget.isPinDrop) { + drag.latestStatusDropTarget = null + clearInsertionLine(args) + return + } + + const sidebarContainer = ctx.scrollRef.current + const preferredStatusTarget = ctx.getEligibleLineageDropTarget( + sidebarContainer + ? getPointerDropStatusTarget({ + container: sidebarContainer, + x: drag.currentX, + y: drag.currentY + }) + : NO_WORKTREE_SIDEBAR_DROP_TARGET, + drag.draggedIds + ) + if (preferredStatusTarget.lineageParentId) { + updateLatestWorktreeStatusDropTarget(drag, preferredStatusTarget, null) + clearWorkspaceKanbanSidebarDropTargetVisual() + clearInsertionLine(args) + return + } + if ( + shouldPreferSidebarStatusDropTarget({ + sourceGroupKey: drag.sourceGroupKey, + target: preferredStatusTarget, + workspaceStatuses: ctx.workspaceStatuses + }) + ) { + showStatusHoverWithoutInsertionLine(args, preferredStatusTarget) + return + } + + const drop = ctx.computeWorktreeDrop(drag.currentY) + if (!drop) { + showStatusHoverWithoutInsertionLine(args, preferredStatusTarget) + return + } + drag.latestStatusDropTarget = null + clearWorkspaceKanbanSidebarDropTargetVisual() + args.setDragOverStatus(null) + args.setPinDragOver(false) + args.setWorktreeDragState((prev) => + applyWorktreeDropPreview(prev, drop, { pointerY: drag.currentY, matchPointerY: true }) + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-row-drag-state.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-row-drag-state.ts new file mode 100644 index 00000000000..2bb47089e22 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-row-drag-state.ts @@ -0,0 +1,132 @@ +import type { WorkspaceKanbanCardTrackedDropTarget } from '../workspace-kanban-card-pointer-drag-dom' +import type { WorktreeSidebarDragRect } from '../worktree-sidebar-drag-autoscroll' +import type { + WorktreeSidebarDropPreview, + WorktreeSidebarStatusDropTarget, + WorktreeSidebarTrackedStatusDropTarget +} from '../worktree-sidebar-drop-preview' + +export type WorktreeRowDragState = { + draggingWorktreeId: string | null + sourceGroupKey: string | null + dropIndex: number | null + dropIndicatorY: number | null + previewOffsetsByWorktreeId: ReadonlyMap + pointerY: number | null +} + +export const EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS: ReadonlyMap = new Map() + +export const WORKTREE_ROW_DRAG_INITIAL_STATE: WorktreeRowDragState = { + draggingWorktreeId: null, + sourceGroupKey: null, + dropIndex: null, + dropIndicatorY: null, + previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, + pointerY: null +} + +export type WorktreePointerDrag = { + pointerId: number + sourceRow: HTMLElement + startX: number + startY: number + currentX: number + currentY: number + worktreeId: string + draggedIds: readonly string[] + reorderDraggedIds: readonly string[] + reorderUnitDraggedIds: readonly string[] + sourceGroupKey: string + rects: readonly WorktreeSidebarDragRect[] + active: boolean + preview: HTMLElement | null + previewOffsetX: number + previewOffsetY: number + workspaceBoardDragPreviewRequested: boolean + frameId: number | null + latestBoardDropTarget: WorkspaceKanbanCardTrackedDropTarget | null + latestStatusDropTarget: WorktreeSidebarTrackedStatusDropTarget | null +} + +export type WorktreeSidebarLineageDropTarget = WorktreeSidebarStatusDropTarget & { + lineageParentId: string | null +} + +export const NO_WORKTREE_SIDEBAR_DROP_TARGET: WorktreeSidebarLineageDropTarget = { + status: null, + isPinDrop: false, + lineageParentId: null +} + +export function areWorktreeDragPreviewOffsetsEqual( + a: ReadonlyMap, + b: ReadonlyMap +): boolean { + if (a === b) { + return true + } + if (a.size !== b.size) { + return false + } + for (const [key, value] of a) { + if (b.get(key) !== value) { + return false + } + } + return true +} + +export function updateLatestWorktreeStatusDropTarget( + drag: WorktreePointerDrag, + target: WorktreeSidebarLineageDropTarget, + preview: WorktreeSidebarDropPreview | null +): void { + drag.latestStatusDropTarget = + target.status || target.isPinDrop || target.lineageParentId + ? { + target, + preview, + x: drag.currentX, + y: drag.currentY + } + : null +} + +// Why matchPointerY: pointer-driven updates must repaint when only the pointer moved; native-drag +// updates deliberately keep the previous state identity in that case. +export function clearWorktreeDropPreview( + previous: WorktreeRowDragState, + args: { pointerY: number | null; matchPointerY?: boolean } +): WorktreeRowDragState { + const unchanged = + previous.dropIndex === null && + previous.dropIndicatorY === null && + previous.previewOffsetsByWorktreeId.size === 0 && + (args.matchPointerY !== true || previous.pointerY === args.pointerY) + return unchanged + ? previous + : { + ...previous, + dropIndex: null, + dropIndicatorY: null, + previewOffsetsByWorktreeId: EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS, + pointerY: args.pointerY + } +} + +export function applyWorktreeDropPreview( + previous: WorktreeRowDragState, + drop: WorktreeSidebarDropPreview, + args: { pointerY: number; matchPointerY?: boolean } +): WorktreeRowDragState { + const unchanged = + previous.dropIndex === drop.dropIndex && + previous.dropIndicatorY === drop.dropIndicatorY && + (args.matchPointerY !== true || previous.pointerY === args.pointerY) && + areWorktreeDragPreviewOffsetsEqual( + previous.previewOffsetsByWorktreeId, + drop.previewOffsetsByWorktreeId + ) + return unchanged ? previous : { ...previous, ...drop, pointerY: args.pointerY } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-section-header-row.tsx b/src/renderer/src/components/sidebar/worktree-list/worktree-section-header-row.tsx new file mode 100644 index 00000000000..dd77e3dd0da --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-section-header-row.tsx @@ -0,0 +1,393 @@ +import React from 'react' +import { ChevronDown } from 'lucide-react' +import type { VirtualItem } from '@tanstack/react-virtual' +import { cn } from '@/lib/utils' +import type { AppState } from '@/store/types' +import { RepoIconGlyph } from '@/components/repo/repo-icon' +import { RepoForkIndicator } from '@/components/repo/repo-fork-indicator' +import type { FolderWorkspacePathStatus } from '../../../../../shared/folder-workspace-path-status' +import { isConfirmedStaleFolderPathStatus } from '../../../../../shared/folder-workspace-path-status' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { + WorkspaceStatus, + WorkspaceStatusDefinition +} from '../../../../../shared/worktree/types' +import type { GroupHeaderRow, WorktreeGroupBy } from '../worktree-list-groups' +import { PINNED_GROUP_KEY } from '../worktree-list-groups' +import { getWorkspaceStatusFromGroupKey } from '../workspace-status' +import { getVirtualRowTransform } from '../worktree-list-virtual-rows' +import { resolveProjectGroupHeaderColor } from '../project-header-color' +import { getRepoHeaderCreateState } from '../repo-header-create-state' +import { ProjectHeaderActions } from '../ProjectHeaderActions' +import { + getProjectGroupHeaderPaddingLeft, + WORKTREE_SECTION_HEADER_PADDING_LEFT +} from '../worktree-list-indentation' +import { FolderPathStatusIndicator } from './FolderPathStatusIndicator' +import { + ProjectGroupCreateWorkspaceButton, + ProjectGroupHeaderMenu +} from './project-group-header-actions' +import { + RepoHeaderCreateWorkspaceButton, + RepoHeaderProjectActionsMenu, + type RepoHeaderProjectActions +} from './repo-header-project-actions' +import { + handleRepoHeaderCollapseAffordancePointerDown, + shouldIgnoreRepoHeaderToggle +} from './repo-header-event-guards' +import type { WorktreeSidebarHeaderDrag } from './use-worktree-sidebar-header-drag' +import { getWorktreeOptionId } from './worktree-option-dom' + +export type SectionHeaderRowContext = { + groupBy: WorktreeGroupBy + collapsedGroups: Set + workspaceStatuses: readonly WorkspaceStatusDefinition[] + projectGroups: readonly ProjectGroup[] + sshConnectionStates: AppState['sshConnectionStates'] + highlightedRevealRowKey: string | null + dragOverStatus: WorkspaceStatus | null + pinDragOver: boolean + headerDrag: WorktreeSidebarHeaderDrag + getCachedFolderWorkspacePathStatus: (request: { + scope: 'project-group' + projectGroupId: string + }) => FolderWorkspacePathStatus | null + toggleGroupWithScrollAnchor: (groupKey: string) => void + projectActions: RepoHeaderProjectActions + onRenameProjectGroup: (groupId: string, currentName: string) => void + onDeleteProjectGroup: (groupId: string, groupName: string) => void + onCreateFolderWorkspace: (projectGroup: ProjectGroup) => void + onWorkspaceStatusDragOver: (event: React.DragEvent, status: WorkspaceStatus) => void + onWorkspaceStatusDragLeave: (event: React.DragEvent) => void + onWorkspacePinDragOver: (event: React.DragEvent) => void + onWorkspacePinDragLeave: (event: React.DragEvent) => void + onWorkspaceStatusDrop: (event: React.DragEvent, status: WorkspaceStatus) => void +} + +// The folder-scan project group whose parent path is gone can't create new workspaces. +function isFolderWorkspaceCreateDisabled(status: FolderWorkspacePathStatus | null): boolean { + return ( + status?.exists === false && + (isConfirmedStaleFolderPathStatus(status) || status.reason === 'ambiguous-connection') + ) +} + +export function renderWorktreeSectionHeaderRow(args: { + ctx: SectionHeaderRowContext + row: GroupHeaderRow + vItem: VirtualItem + isActiveStickyHeader: boolean + hasStickyHost: boolean + hasHeaderTopSpacing: boolean + measureVirtualRowElement: (element: HTMLDivElement | null) => void +}): React.JSX.Element { + const { ctx, row, vItem, isActiveStickyHeader } = args + const { headerDrag } = ctx + const isRepoHeader = ctx.groupBy === 'repo' && row.repo !== undefined + const isProjectGroupHeader = ctx.groupBy === 'repo' && row.projectGroup !== undefined + const projectIdForHeader = isRepoHeader ? row.repo!.id : undefined + const projectGroupIdForHeader = + isProjectGroupHeader && !row.repo && typeof row.projectGroup?.id === 'string' + ? row.projectGroup.id + : undefined + const repoHeaderIndex = + projectIdForHeader !== undefined + ? headerDrag.repoHeaderIndexByRepoId.get(projectIdForHeader) + : undefined + const repoHeaderBucketKey = + projectIdForHeader !== undefined + ? headerDrag.repoHeaderBucketByRepoId.get(projectIdForHeader) + : undefined + const projectGroupHeaderIndex = + projectGroupIdForHeader !== undefined + ? headerDrag.projectGroupHeaderIndexByGroupId.get(projectGroupIdForHeader) + : undefined + const projectGroupHeaderBucketKey = + projectGroupIdForHeader !== undefined + ? headerDrag.projectGroupHeaderBucketByGroupId.get(projectGroupIdForHeader) + : undefined + const isDraggableRepoHeader = Boolean( + headerDrag.canReorderRepoHeaders && + isRepoHeader && + projectIdForHeader && + repoHeaderBucketKey && + (headerDrag.sidebarRepoHeaderIdsByBucket.get(repoHeaderBucketKey)?.length ?? 0) > 1 + ) + const isDraggableProjectGroupHeader = Boolean( + headerDrag.canReorderProjectGroupHeaders && + projectGroupIdForHeader && + projectGroupHeaderBucketKey && + (headerDrag.sidebarProjectGroupHeaderIdsByBucket.get(projectGroupHeaderBucketKey)?.length ?? + 0) > 1 + ) + const isDraggingThis = + headerDrag.canReorderRepoHeaders && + headerDrag.repoDrag.state.draggingRepoId !== null && + headerDrag.repoDrag.state.draggingRepoId === projectIdForHeader + const isDraggingThisProjectGroup = + headerDrag.canReorderProjectGroupHeaders && + headerDrag.projectGroupDrag.state.draggingGroupId !== null && + headerDrag.projectGroupDrag.state.draggingGroupId === projectGroupIdForHeader + const headerWorkspaceStatus = + ctx.groupBy === 'workspace-status' + ? getWorkspaceStatusFromGroupKey(row.key, ctx.workspaceStatuses) + : null + const isPinnedHeader = row.key === PINNED_GROUP_KEY + const repoHeaderColor = resolveProjectGroupHeaderColor({ + groupBy: ctx.groupBy, + headerKey: row.key, + badgeColor: row.repo?.badgeColor + }) + const createState = row.repo + ? getRepoHeaderCreateState({ + repo: row.repo, + label: row.label, + sshStatus: row.repo.connectionId + ? (ctx.sshConnectionStates.get(row.repo.connectionId)?.status ?? null) + : null + }) + : null + const folderBackedProjectGroup = + isProjectGroupHeader && + !row.repo && + row.projectGroup && + 'parentPath' in row.projectGroup && + row.projectGroup.parentPath + ? row.projectGroup + : null + const projectGroupPathStatus = folderBackedProjectGroup + ? ctx.getCachedFolderWorkspacePathStatus({ + scope: 'project-group', + projectGroupId: folderBackedProjectGroup.id + }) + : null + const isHeaderCollapsed = ctx.collapsedGroups.has(row.key) + // Why: repo/project/status/pinned share compact section chrome; flat "All" stays a simple label. + const showHeaderCollapseAffordance = + row.count > 0 && + (isRepoHeader || isProjectGroupHeader || headerWorkspaceStatus !== null || isPinnedHeader) + return ( +
+
ctx.onWorkspaceStatusDragOver(event, headerWorkspaceStatus) + : undefined + } + onDragLeave={ + isPinnedHeader + ? ctx.onWorkspacePinDragLeave + : headerWorkspaceStatus + ? ctx.onWorkspaceStatusDragLeave + : undefined + } + onDrop={ + headerWorkspaceStatus + ? (event) => ctx.onWorkspaceStatusDrop(event, headerWorkspaceStatus) + : undefined + } + onPointerDown={ + isDraggableRepoHeader && projectIdForHeader + ? (event) => headerDrag.repoDrag.onHandlePointerDown(event, projectIdForHeader) + : isDraggableProjectGroupHeader && projectGroupIdForHeader + ? (event) => + headerDrag.projectGroupDrag.onHandlePointerDown(event, projectGroupIdForHeader) + : undefined + } + onClick={(event) => { + if (shouldIgnoreRepoHeaderToggle(event)) { + return + } + ctx.toggleGroupWithScrollAnchor(row.key) + }} + onKeyDown={(e) => { + if (shouldIgnoreRepoHeaderToggle(e)) { + return + } + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + ctx.toggleGroupWithScrollAnchor(row.key) + } + }} + > + {/* Why: grab cursor on icon+title only. Row still has handle attrs so + indent/padding can arm drag; actions are excluded via data-repo-header-actions. + self-stretch fills h-7 so grab matches the full title column height. */} +
+ {row.icon ? ( +
+ {row.repo ? ( + + ) : ( + + )} +
+ ) : null} + +
+
+
+ {row.label} +
+ + +
+
+
+ + + {showHeaderCollapseAffordance ? ( +
{ + event.preventDefault() + event.stopPropagation() + ctx.toggleGroupWithScrollAnchor(row.key) + }} + > + +
+ ) : null} + + {isProjectGroupHeader && !row.repo && projectGroupIdForHeader ? ( + + ) : null} + + {folderBackedProjectGroup ? ( + + ) : null} + + {row.repo && ctx.groupBy === 'repo' ? ( + + ) : null} + + {row.repo && ctx.groupBy === 'repo' ? ( + + ) : null} +
+
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-sidebar-scroll-tuning.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-sidebar-scroll-tuning.ts new file mode 100644 index 00000000000..bb404507e26 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-sidebar-scroll-tuning.ts @@ -0,0 +1,42 @@ +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' +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-virtual-row-context.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-virtual-row-context.ts new file mode 100644 index 00000000000..122bf81fc35 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-virtual-row-context.ts @@ -0,0 +1,149 @@ +import type React from 'react' +import type { AppState } from '@/store/types' +import type { ProjectGroup } from '../../../../../shared/project-group-types' +import type { RenderRow } from '../worktree-list-virtual-rows' +import type { LineageToggleHandler } from '../worktree-lineage-toggle-handler-cache' +import type { SidebarRevealHighlight } from './use-sidebar-reveal-highlight' +import type { WorktreeDragRuntime } from './use-worktree-drag-runtime' +import type { WorktreeDragSession } from './use-worktree-drag-session' +import type { useFolderWorkspacePathStatusRows } from './use-folder-workspace-path-status-rows' +import type { usePrimaryActiveWorktreeRow } from './use-primary-active-worktree-row' +import type { useWorkspaceStatusRowDrag } from './use-workspace-status-row-drag' +import type { useWorktreeNativeDrag } from './use-worktree-native-drag' +import type { WorktreeSidebarHeaderDrag } from './use-worktree-sidebar-header-drag' +import type { WorktreeListVirtualizer } from './use-worktree-list-virtualizer' +import type { VirtualizedWorktreeViewportProps } from './virtualized-worktree-viewport-props' +import type { WorktreeVirtualRowContext } from './worktree-virtual-row-dispatch' + +type BuildArgs = { + props: VirtualizedWorktreeViewportProps + projectGroups: readonly ProjectGroup[] + renderRows: RenderRow[] + firstHeaderIndex: number + virtualization: WorktreeListVirtualizer + measureVirtualRowElement: (element: HTMLDivElement | null) => void + settings: AppState['settings'] + sshConnectionStates: AppState['sshConnectionStates'] + newCardStyle: boolean + folderBackedProjectGroupIds: ReadonlySet + session: WorktreeDragSession + runtime: WorktreeDragRuntime + primaryActive: ReturnType + reveal: SidebarRevealHighlight + statusDrag: ReturnType + nativeDrag: ReturnType + headerDrag: WorktreeSidebarHeaderDrag + getCachedFolderWorkspacePathStatus: ReturnType + getLineageToggleHandler: (groupKey: string) => LineageToggleHandler + toggleGroupWithScrollAnchor: (groupKey: string) => void + onRowClickCapture: (event: React.MouseEvent) => void + onRowPointerDown: ( + event: React.PointerEvent, + worktreeId: string, + rowKey: string + ) => void +} + +// Assembles the per-render context the row dispatcher reads. Plain data — everything in it +// is either a prop, a memoised hook result, or a stable callback. +export function buildWorktreeVirtualRowContext(args: BuildArgs): WorktreeVirtualRowContext { + const { props, runtime, session, statusDrag, headerDrag, primaryActive, reveal } = args + return { + renderRows: args.renderRows, + firstHeaderIndex: args.firstHeaderIndex, + activeStickyHeaderIndexRef: args.virtualization.activeStickyHeaderIndexRef, + activeStickyHostIndexRef: args.virtualization.activeStickyHostIndexRef, + measureVirtualRowElement: args.measureVirtualRowElement, + groupBy: props.groupBy, + workspaceStatuses: props.workspaceStatuses, + activeWorktreeId: props.activeWorktreeId, + worktreeDragState: runtime.worktreeDragState, + orderedHostIds: headerDrag.orderedHostIds, + hostDrag: headerDrag.hostDrag, + toggleGroupWithScrollAnchor: args.toggleGroupWithScrollAnchor, + importedWorktreeCardActionState: props.importedWorktreeCardActionState, + newExternalWorktreeInboxActionState: props.newExternalWorktreeInboxActionState, + onShowImportedWorktrees: props.handleShowImportedWorktrees, + onKeepImportedWorktreesHidden: props.handleKeepImportedWorktreesHidden, + onOpenWorktreeVisibility: props.handleOpenWorktreeVisibility, + onOpenSuppressExternalWorktreeInbox: props.handleOpenSuppressExternalWorktreeInbox, + onWorkspaceStatusDragOver: statusDrag.handleWorkspaceStatusDragOver, + onWorkspaceStatusDragLeave: statusDrag.handleWorkspaceStatusDragLeave, + onWorkspaceStatusDrop: statusDrag.handleWorkspaceStatusDrop, + header: { + groupBy: props.groupBy, + collapsedGroups: props.collapsedGroups, + workspaceStatuses: props.workspaceStatuses, + projectGroups: args.projectGroups, + sshConnectionStates: args.sshConnectionStates, + highlightedRevealRowKey: reveal.highlightedRevealRowKey, + dragOverStatus: runtime.dragOverStatus, + pinDragOver: runtime.pinDragOver, + headerDrag, + getCachedFolderWorkspacePathStatus: args.getCachedFolderWorkspacePathStatus, + toggleGroupWithScrollAnchor: args.toggleGroupWithScrollAnchor, + projectActions: { + onOpenRepoSettings: props.handleOpenRepoSettings, + onOpenWorktreeVisibility: props.handleOpenWorktreeVisibility, + onCreateGroupFromRepo: props.handleCreateGroupFromRepo, + onMoveProjectToGroup: props.handleMoveProjectToGroup, + onRemoveProjectFromGroup: props.handleRemoveProjectFromGroup, + onRemoveProject: props.handleRemoveProject, + onCreateForRepo: props.handleCreateForRepo + }, + onRenameProjectGroup: props.handleRenameProjectGroup, + onDeleteProjectGroup: props.handleDeleteProjectGroup, + onCreateFolderWorkspace: props.handleCreateFolderWorkspace, + onWorkspaceStatusDragOver: statusDrag.handleWorkspaceStatusDragOver, + onWorkspaceStatusDragLeave: statusDrag.handleWorkspaceStatusDragLeave, + onWorkspacePinDragOver: statusDrag.handleWorkspacePinDragOver, + onWorkspacePinDragLeave: statusDrag.handleWorkspacePinDragLeave, + onWorkspaceStatusDrop: statusDrag.handleWorkspaceStatusDrop + }, + item: { + settings: args.settings, + groupBy: props.groupBy, + folderBackedProjectGroupIds: args.folderBackedProjectGroupIds, + groupKeyByRowKey: session.groupKeyByRowKey, + groupIndexByRowKey: session.groupIndexByRowKey, + agentSendTargetWorktreeId: props.agentSendTargetWorktreeId, + worktreeDragState: runtime.worktreeDragState, + worktreePointerDragRef: runtime.worktreePointerDragRef, + nativeLineageDropTargetId: runtime.nativeLineageDropTargetId, + activeWorktreeId: props.activeWorktreeId, + currentWorktreeId: props.currentWorktreeId, + highlightedRevealRowKey: reveal.highlightedRevealRowKey, + selectedWorktreeIds: props.selectedWorktreeIds, + selectedWorktrees: props.selectedWorktrees, + getActiveSurfaceVariant: primaryActive.getActiveSurfaceVariant, + getLineageToggleHandler: args.getLineageToggleHandler, + onSelectionGesture: props.onSelectionGesture, + onContextMenuSelect: props.onContextMenuSelect, + onImmediateActivate: primaryActive.handleImmediateWorktreeRowActivate, + onRowClickCapture: args.onRowClickCapture, + onRowPointerDown: args.onRowPointerDown, + onCardDragStart: args.nativeDrag.handleWorktreeCardDragStart, + onCardDragEnd: runtime.clearWorktreeDrag + }, + folderWorkspace: { + groupBy: props.groupBy, + newCardStyle: args.newCardStyle, + settings: args.settings, + activeWorktreeId: props.activeWorktreeId, + currentWorktreeId: props.currentWorktreeId, + selectedWorktreeIds: props.selectedWorktreeIds, + repoMap: props.repoMap, + worktreeMap: props.worktreeMap, + worktreeLineageById: props.worktreeLineageById, + workspaceLineageByChildKey: props.workspaceLineageByChildKey, + prCache: props.prCache, + hostedReviewCache: props.hostedReviewCache, + getCachedFolderWorkspacePathStatus: args.getCachedFolderWorkspacePathStatus, + onSelectionGesture: props.onSelectionGesture, + onContextMenuSelect: props.onContextMenuSelect, + onImmediateActivate: primaryActive.handleImmediateWorktreeRowActivate, + onRowClickCapture: args.onRowClickCapture, + onRowPointerDown: args.onRowPointerDown + } + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-virtual-row-dispatch.tsx b/src/renderer/src/components/sidebar/worktree-list/worktree-virtual-row-dispatch.tsx new file mode 100644 index 00000000000..b17c81439d9 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-virtual-row-dispatch.tsx @@ -0,0 +1,262 @@ +import React from 'react' +import type { VirtualItem } from '@tanstack/react-virtual' +import { cn } from '@/lib/utils' +import type { + WorkspaceStatus, + WorkspaceStatusDefinition +} from '../../../../../shared/worktree/types' +import { getWorkspaceStatus } from '../workspace-status' +import type { WorktreeGroupBy } from '../worktree-list-groups' +import { + getVirtualRowTransform, + shouldUseHeaderTopSpacing, + type RenderRow +} from '../worktree-list-virtual-rows' +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 './HostSectionHeader' +import { + renderFolderWorkspaceVirtualRow, + type FolderWorkspaceRowContext +} from './folder-workspace-virtual-row' +import { + renderImportedWorktreesVirtualRow, + renderNewExternalWorktreesInboxVirtualRow, + renderPendingCreationVirtualRow +} from './worktree-notice-virtual-rows' +import { + renderWorktreeItemRow, + renderWorktreeLineageDescendants, + type WorktreeItemRowContext +} from './worktree-list-item-row' +import { + renderWorktreeSectionHeaderRow, + type SectionHeaderRowContext +} from './worktree-section-header-row' +import { getWorktreeVirtualRowTransform } from './virtual-row-dom-attributes' +import type { WorktreeRowDragState } from './worktree-row-drag-state' + +export type WorktreeVirtualRowContext = { + renderRows: RenderRow[] + firstHeaderIndex: number + activeStickyHeaderIndexRef: React.MutableRefObject + activeStickyHostIndexRef: React.MutableRefObject + measureVirtualRowElement: (element: HTMLDivElement | null) => void + groupBy: WorktreeGroupBy + workspaceStatuses: readonly WorkspaceStatusDefinition[] + activeWorktreeId: string | null + worktreeDragState: WorktreeRowDragState + orderedHostIds: readonly string[] + hostDrag: ReturnType + toggleGroupWithScrollAnchor: (groupKey: string) => void + header: SectionHeaderRowContext + item: WorktreeItemRowContext + folderWorkspace: FolderWorkspaceRowContext + importedWorktreeCardActionState: ReadonlyMap + newExternalWorktreeInboxActionState: ReadonlyMap + onShowImportedWorktrees: (projectId: string) => void + onKeepImportedWorktreesHidden: (projectId: string) => void + onOpenWorktreeVisibility: ( + repo: Parameters[0] + ) => void + onOpenSuppressExternalWorktreeInbox: (projectId: string) => void + onWorkspaceStatusDragOver: (event: React.DragEvent, status: WorkspaceStatus) => void + onWorkspaceStatusDragLeave: (event: React.DragEvent) => void + onWorkspaceStatusDrop: (event: React.DragEvent, status: WorkspaceStatus) => void +} + +function renderHostHeaderVirtualRow( + ctx: WorktreeVirtualRowContext, + row: Extract, + vItem: VirtualItem +): React.JSX.Element { + // Why: the host card is the outer tier; it pins above group headers (z-30 vs z-20) and stays put as they hand off. + const isActiveStickyHost = ctx.activeStickyHostIndexRef.current === vItem.index + const hasHeaderTopSpacing = shouldUseHeaderTopSpacing({ + rows: ctx.renderRows, + index: vItem.index, + firstHeaderIndex: ctx.firstHeaderIndex + }) + return ( +
+ ctx.toggleGroupWithScrollAnchor(row.key)} + onDragPointerDown={ + ctx.orderedHostIds.length > 1 + ? (e) => ctx.hostDrag.onHandlePointerDown(e, row.hostId) + : undefined + } + dragging={ctx.hostDrag.state.draggingHostId === row.hostId} + /> +
+ ) +} + +function renderLineageGroupVirtualRow( + ctx: WorktreeVirtualRowContext, + row: Extract, + vItem: VirtualItem +): React.JSX.Element { + const [parent, ...children] = row.rows + const childIsActive = children.some((child) => child.worktree.id === ctx.activeWorktreeId) + const parentPreviewOffset = parent + ? (ctx.worktreeDragState.previewOffsetsByWorktreeId.get(parent.worktree.id) ?? 0) + : 0 + return ( +
+
+ {parent + ? renderWorktreeItemRow( + ctx.item, + parent, + false, + renderWorktreeLineageDescendants(ctx.item, parent, children), + childIsActive + ) + : null} +
+
+ ) +} + +// One virtual slot -> one row renderer. Every branch owns its own absolute/sticky frame +// because sticky headers and lineage groups position differently from plain cards. +export function renderWorktreeVirtualRow( + ctx: WorktreeVirtualRowContext, + row: RenderRow, + vItem: VirtualItem +): React.JSX.Element | null { + if (row.type === 'host-header') { + return renderHostHeaderVirtualRow(ctx, row, vItem) + } + + if (row.type === 'header') { + return renderWorktreeSectionHeaderRow({ + ctx: ctx.header, + row, + vItem, + isActiveStickyHeader: ctx.activeStickyHeaderIndexRef.current === vItem.index, + hasStickyHost: ctx.activeStickyHostIndexRef.current !== null, + hasHeaderTopSpacing: shouldUseHeaderTopSpacing({ + rows: ctx.renderRows, + index: vItem.index, + firstHeaderIndex: ctx.firstHeaderIndex + }), + measureVirtualRowElement: ctx.measureVirtualRowElement + }) + } + + if (row.type === 'lineage-group') { + return renderLineageGroupVirtualRow(ctx, row, vItem) + } + + if (row.type === 'imported-worktrees-card') { + return renderImportedWorktreesVirtualRow({ + row, + vItem, + measureVirtualRowElement: ctx.measureVirtualRowElement, + actionState: ctx.importedWorktreeCardActionState.get(row.repo.id), + onShow: ctx.onShowImportedWorktrees, + onKeepHidden: ctx.onKeepImportedWorktreesHidden + }) + } + + if (row.type === 'new-external-worktrees-inbox') { + return renderNewExternalWorktreesInboxVirtualRow({ + row, + vItem, + measureVirtualRowElement: ctx.measureVirtualRowElement, + actionState: ctx.newExternalWorktreeInboxActionState.get(row.repo.id), + onReview: ctx.onOpenWorktreeVisibility, + onSuppress: ctx.onOpenSuppressExternalWorktreeInbox + }) + } + + if (row.type === 'pending-creation') { + return renderPendingCreationVirtualRow({ + row, + vItem, + measureVirtualRowElement: ctx.measureVirtualRowElement + }) + } + + if (row.type === 'folder-workspace') { + return renderFolderWorkspaceVirtualRow({ + ctx: ctx.folderWorkspace, + row, + vItem, + measureVirtualRowElement: ctx.measureVirtualRowElement + }) + } + + const itemWorkspaceStatus = + ctx.groupBy === 'workspace-status' + ? getWorkspaceStatus(row.worktree, ctx.workspaceStatuses) + : null + const itemPreviewOffset = + ctx.worktreeDragState.previewOffsetsByWorktreeId.get(row.worktree.id) ?? 0 + + return ( +
ctx.onWorkspaceStatusDragOver(event, itemWorkspaceStatus) + : undefined + } + onDragLeave={itemWorkspaceStatus ? ctx.onWorkspaceStatusDragLeave : undefined} + onDrop={ + itemWorkspaceStatus + ? (event) => ctx.onWorkspaceStatusDrop(event, itemWorkspaceStatus) + : undefined + } + > + {renderWorktreeItemRow(ctx.item, row, false)} +
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-visibility-menu-label.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-visibility-menu-label.ts new file mode 100644 index 00000000000..666cd5121d8 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-visibility-menu-label.ts @@ -0,0 +1,13 @@ +import type { Repo } from '../../../../../shared/repo-types' +import { + effectiveExternalWorktreeVisibility, + isLegacyRepoForExternalWorktreeVisibility +} from '../../../../../shared/worktree/ownership' + +export function getWorktreeVisibilityMenuLabel(repo: Repo): string { + const visibility = effectiveExternalWorktreeVisibility( + repo, + isLegacyRepoForExternalWorktreeVisibility(repo) + ) + return visibility === 'show' ? 'Hide non-Orca worktrees' : 'Show hidden worktrees' +} diff --git a/src/renderer/src/components/sidebar/worktree-list/worktree-visible-refresh-listener.ts b/src/renderer/src/components/sidebar/worktree-list/worktree-visible-refresh-listener.ts new file mode 100644 index 00000000000..b9e77fefb83 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/worktree-visible-refresh-listener.ts @@ -0,0 +1,4 @@ +export function installWorktreeVisibleRefreshVisibilityListener(onChange: () => void): () => void { + document.addEventListener('visibilitychange', onChange) + return () => document.removeEventListener('visibilitychange', onChange) +} diff --git a/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts b/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts index 933261f20a4..fb984cb2d54 100644 --- a/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts +++ b/src/renderer/src/components/sidebar/worktree-scroll-to-current-button.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { getScrollTopToRevealBounds } from './WorktreeList' +import { getScrollTopToRevealBounds } from './worktree-sidebar-reveal' describe('getScrollTopToRevealBounds', () => { const makeContainer = (scrollTop: number, clientHeight: number) => diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts index 328ffc95739..bf7e6c75bee 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts @@ -148,10 +148,13 @@ describe('shouldHandleTerminalInterruptKeyboardEvent', () => { // And a non-Latin key on some other physical key must not become an interrupt. it('does not handle a non-Latin logical key on a physical key other than KeyC', () => { expect( - shouldHandleTerminalInterruptKeyboardEvent(event({ key: 'ㅁ', code: 'KeyA', ctrlKey: true }), { - isMac: true, - hasSelection: false - }) + shouldHandleTerminalInterruptKeyboardEvent( + event({ key: 'ㅁ', code: 'KeyA', ctrlKey: true }), + { + isMac: true, + hasSelection: false + } + ) ).toBe(false) }) @@ -160,10 +163,13 @@ describe('shouldHandleTerminalInterruptKeyboardEvent', () => { it('uses the layout map when an IME is layered over a Latin layout', () => { _setLayoutMapForTests(new Map([['KeyC', 'c']])) expect( - shouldHandleTerminalInterruptKeyboardEvent(event({ key: 'ㅊ', code: 'KeyC', ctrlKey: true }), { - isMac: true, - hasSelection: false - }) + shouldHandleTerminalInterruptKeyboardEvent( + event({ key: 'ㅊ', code: 'KeyC', ctrlKey: true }), + { + isMac: true, + hasSelection: false + } + ) ).toBe(true) }) @@ -172,10 +178,13 @@ describe('shouldHandleTerminalInterruptKeyboardEvent', () => { it('declines when the layout map shows the physical key is not C', () => { _setLayoutMapForTests(new Map([['KeyC', 'j']])) expect( - shouldHandleTerminalInterruptKeyboardEvent(event({ key: 'ㅊ', code: 'KeyC', ctrlKey: true }), { - isMac: true, - hasSelection: false - }) + shouldHandleTerminalInterruptKeyboardEvent( + event({ key: 'ㅊ', code: 'KeyC', ctrlKey: true }), + { + isMac: true, + hasSelection: false + } + ) ).toBe(false) })