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.
This commit is contained in:
Neil
2026-08-13 23:35:41 -07:00
committed by GitHub
parent e2e7768cc8
commit 08027d0d23
81 changed files with 8767 additions and 6716 deletions
-1
View File
@@ -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
@@ -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<string, unknown> {
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 () => {
File diff suppressed because it is too large Load Diff
@@ -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 })')
@@ -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(
@@ -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')
@@ -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'
@@ -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,
@@ -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(() => {
@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
'inline-flex size-4 shrink-0 items-center justify-center rounded-[4px]',
destructive ? 'text-destructive' : 'text-muted-foreground'
)}
aria-label={title}
>
<FolderX className="size-3.5" />
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6} className="max-w-72">
<div className="space-y-1">
<div className="font-medium">{title}</div>
<div className="text-muted-foreground">
{getFolderWorkspacePathStatusDescription(status)}
</div>
</div>
</TooltipContent>
</Tooltip>
)
}
@@ -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 (
<span
className="inline-flex h-4 shrink-0 overflow-hidden rounded-full border border-worktree-sidebar-border bg-worktree-sidebar-accent text-[9px] font-medium leading-none text-muted-foreground/90"
aria-label={totalLabel}
>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex h-full min-w-4 items-center justify-center px-1.5">
{count}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{totalLabel}
</TooltipContent>
</Tooltip>
</span>
)
}
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 <Loader2 className="size-3 shrink-0 animate-spin text-muted-foreground" />
}
if (health === 'blocked' || health === 'error') {
return <AlertTriangle className="size-3 shrink-0 text-destructive" />
}
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<HTMLElement>) => void
dragging?: boolean
}): React.JSX.Element {
const isBlocked = row.health === 'blocked'
const isDisconnected = row.health === 'disconnected'
const detail = getHostHeaderDetail(row)
return (
<div className="px-2 pt-1">
{/* Why: outlined card + server glyph marks hosts as machines, not mere groups. */}
<div
role="button"
tabIndex={0}
data-host-header-drag-id={row.hostId}
aria-expanded={!row.collapsed}
className={cn(
'group/host-header flex h-8 w-full cursor-pointer items-center gap-2 rounded-md border px-2 text-left transition-all',
onDragPointerDown && 'cursor-grab active:cursor-grabbing',
isBlocked
? 'border-destructive/40 bg-destructive/10'
: isDisconnected
? 'border-worktree-sidebar-border/70 bg-worktree-sidebar-accent/35 text-muted-foreground'
: 'border-worktree-sidebar-border bg-worktree-sidebar-accent/70',
dragging && 'pointer-events-none opacity-0'
)}
onPointerDown={onDragPointerDown}
onClick={onToggle}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}}
>
{isDisconnected ? (
<ServerOff className="size-3.5 shrink-0 text-muted-foreground/80" />
) : (
<Server className="size-3.5 shrink-0 text-muted-foreground" />
)}
<HostHeaderHealthIcon health={row.health} />
{/* Why: badge hugs the label (like repo headers) instead of floating by the hover controls. */}
<div className="flex min-w-0 flex-1 items-baseline gap-1.5">
<span
className={cn(
'min-w-0 truncate text-[12px] font-semibold leading-none',
isDisconnected ? 'text-muted-foreground' : 'text-foreground'
)}
>
{row.label}
</span>
{detail ? (
<span
className={cn(
'shrink-0 truncate text-[10px] leading-none',
detail.isWarning ? 'text-destructive' : 'text-muted-foreground/70'
)}
>
{detail.text}
</span>
) : null}
<SectionMetricsBadge count={row.count} />
</div>
<div className="flex size-4 shrink-0 items-center justify-center text-muted-foreground/60 can-hover:opacity-0 transition-opacity group-hover/host-header:opacity-100">
<ChevronDown
className={cn('size-3.5 transition-transform', row.collapsed && '-rotate-90')}
/>
</div>
<span data-host-header-action="">
<HostSectionHeaderMenu row={row} />
</span>
</div>
</div>
)
}
@@ -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<string, NewExternalWorktreesInboxActionState>
onConfirmSuppressExternalWorktreeInbox: () => void
onOpenWorktreeVisibility: (repo: Repo) => void
}): React.JSX.Element {
const { nameDialog, setNameDialog, deleteDialog, setDeleteDialog } = dialogs
return (
<>
<ProjectGroupNameDialog
open={nameDialog !== null}
title={
nameDialog?.type === 'rename'
? translate('auto.components.sidebar.WorktreeList.f9dc6cc5d3', 'Rename Project Group')
: translate('auto.components.sidebar.WorktreeList.13757c053c', 'New Project Group')
}
description={
nameDialog?.type === 'rename'
? translate(
'auto.components.sidebar.WorktreeList.bc1460beb3',
'Update the group name shown in the sidebar.'
)
: translate(
'auto.components.sidebar.WorktreeList.d880ea0744',
'Create a group and move this project into it.'
)
}
initialName={
nameDialog?.type === 'rename'
? nameDialog.currentName
: nameDialog
? `${nameDialog.repo.displayName} group`
: ''
}
confirmLabel={nameDialog?.type === 'rename' ? 'Rename' : 'Create'}
onOpenChange={(open) => {
if (!open) {
setNameDialog(null)
}
}}
onSubmit={dialogs.handleSubmitProjectGroupName}
/>
<SuppressExternalWorktreeInboxDialog
open={suppressExternalWorktreeInboxRepoId !== null}
repoDisplayName={
suppressExternalWorktreeInboxRepoId
? (repos.find((repo) => 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)
}
}}
/>
<ProjectGroupDeleteDialog
open={deleteDialog !== null}
groupName={deleteDialog?.groupName ?? ''}
projectCount={dialogs.deleteProjectCount}
projectNames={dialogs.deleteProjectNames}
removeContainedProjects={dialogs.removeContainedProjects}
onRemoveContainedProjectsChange={(removeContainedProjects) => {
setDeleteDialog((current) =>
current ? { ...current, removeContainedProjects } : current
)
}}
onOpenChange={(open) => {
if (!open) {
setDeleteDialog(null)
}
}}
onConfirm={dialogs.handleConfirmDeleteProjectGroup}
/>
</>
)
}
@@ -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 (
<div
data-worktree-sidebar-container
data-contextual-tour-target="workspace-list"
className="relative min-h-0 flex-1"
>
<div className="worktree-sidebar-scrollbar flex h-full flex-col overflow-y-auto overflow-x-hidden pl-1 scrollbar-sleek pt-px">
<div className="flex flex-col items-center gap-2 px-4 py-6 text-center text-[11px] text-muted-foreground">
<span>
{translate('auto.components.sidebar.WorktreeList.b7acbf038b', 'No workspaces found')}
</span>
{hasFilters && (
<button
onClick={onClearFilters}
className="inline-flex items-center gap-1.5 bg-secondary/70 border border-border/80 text-foreground font-medium text-[11px] px-2.5 py-1 rounded-md cursor-pointer hover:bg-accent transition-colors"
>
<CircleX className="size-3.5" />
{translate('auto.components.sidebar.WorktreeList.370c6a55dd', 'Clear Filters')}
</button>
)}
</div>
</div>
</div>
)
}
@@ -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<HTMLDivElement>(null)
// Why: callback-ref only mutates scrollRef; state re-runs the scroll-to-top listener attach.
const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(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<WorktreeDropCommitContext>(
() => ({
scrollRef,
workspaceStatuses,
worktreeDragGroups: session.worktreeDragGroups,
worktreeDragUnitGroups: session.worktreeDragUnitGroups,
computeWorktreeDrop: session.computeWorktreeDrop,
computeWorktreeStatusDrop: session.computeWorktreeStatusDrop,
refreshWorktreeDragSession: session.refreshWorktreeDragSession,
getEligibleLineageDropTarget: lineageDrop.getEligibleLineageDropTarget,
commitWorktreeLineageParentDrop: lineageDrop.commitWorktreeLineageParentDrop,
clearReorderedWorktreeParents: lineageDrop.clearReorderedWorktreeParents,
clearWorktreeDrag: runtime.clearWorktreeDrag,
onMoveWorktreesToStatus: props.onMoveWorktreesToStatus,
onMoveWorktreesToStatusAtIndex: props.onMoveWorktreesToStatusAtIndex,
onReorderWorktrees: props.onReorderWorktrees,
onPinWorktrees: props.onPinWorktrees
}),
[
lineageDrop.clearReorderedWorktreeParents,
lineageDrop.commitWorktreeLineageParentDrop,
lineageDrop.getEligibleLineageDropTarget,
props.onMoveWorktreesToStatus,
props.onMoveWorktreesToStatusAtIndex,
props.onPinWorktrees,
props.onReorderWorktrees,
runtime.clearWorktreeDrag,
session.computeWorktreeDrop,
session.computeWorktreeStatusDrop,
session.refreshWorktreeDragSession,
session.worktreeDragGroups,
session.worktreeDragUnitGroups,
workspaceStatuses
]
)
const { 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<HTMLDivElement>) => {
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 (
<div
data-worktree-sidebar-container
data-contextual-tour-target="workspace-list"
className="relative min-h-0 flex-1"
>
<div
ref={setScrollRootRef}
data-worktree-sidebar
tabIndex={0}
role="listbox"
aria-label={translate('auto.components.sidebar.WorktreeList.bfbedc547b', 'Worktrees')}
aria-orientation="vertical"
aria-multiselectable="true"
aria-activedescendant={getActiveDescendantOptionId({
activeWorktreeId,
primaryActiveRowKey:
primaryActive.primaryActiveWorktreeRow?.worktreeId === activeWorktreeId
? primaryActive.primaryActiveWorktreeRow.rowKey
: undefined,
pinnedDisplayPolicy,
renderRows,
virtualItems
})}
onKeyDown={handleContainerKeyDown}
// Why: trackpad momentum fires sparse scroll events after the input stream quiets; suppress correction until the viewport stops.
onScroll={handleScroll}
onPointerDown={handleScrollPointerDown}
onTouchMove={markDirectScrollInput}
onWheel={markDirectScrollInput}
onDragOver={nativeDrag.handleWorktreeDragOver}
onDrop={nativeDrag.handleWorktreeDrop}
className="worktree-sidebar-scrollbar h-full overflow-y-auto overflow-x-hidden pl-1 scrollbar-sleek outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset pt-px"
style={WORKTREE_SIDEBAR_SCROLL_STYLE}
>
<div
role="presentation"
className="relative w-full"
style={{ height: `${virtualization.virtualizer.getTotalSize()}px` }}
>
{headerDrag.canReorderRepoHeaders &&
headerDrag.repoDrag.state.draggingRepoId !== null &&
headerDrag.repoDrag.state.dropIndicatorY !== null ? (
<WorktreeSidebarDropIndicator y={headerDrag.repoDrag.state.dropIndicatorY} />
) : null}
{headerDrag.canReorderProjectGroupHeaders &&
headerDrag.projectGroupDrag.state.draggingGroupId !== null &&
headerDrag.projectGroupDrag.state.dropIndicatorY !== null ? (
<WorktreeSidebarDropIndicator y={headerDrag.projectGroupDrag.state.dropIndicatorY} />
) : null}
{headerDrag.hostDrag.state.draggingHostId !== null &&
headerDrag.hostDrag.state.dropIndicatorY !== null ? (
<WorktreeSidebarDropIndicator
y={headerDrag.hostDrag.state.dropIndicatorY}
className="z-40"
/>
) : null}
{runtime.worktreeDragState.draggingWorktreeId !== null &&
runtime.worktreeDragState.dropIndicatorY !== null ? (
<WorktreeSidebarDropIndicator y={runtime.worktreeDragState.dropIndicatorY} />
) : null}
{virtualItems.map((vItem) => {
const row = renderRows[vItem.index]
return row ? renderWorktreeVirtualRow(rowContext, row, vItem) : null
})}
</div>
</div>
{showScrollToTop ? <WorktreeListScrollToTopButton onClick={scrollToTop} /> : null}
</div>
)
})
@@ -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
}
@@ -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<string>
repoMap: Map<string, Repo>
worktreeMap: Map<string, Worktree>
worktreeLineageById: Record<string, WorktreeLineage>
workspaceLineageByChildKey: Record<string, WorkspaceLineage>
prCache: AppState['prCache'] | null
hostedReviewCache: AppState['hostedReviewCache'] | null
getCachedFolderWorkspacePathStatus: (request: {
scope: 'folder-workspace'
folderWorkspaceId: string
}) => FolderWorkspacePathStatus | null
onSelectionGesture: (event: React.MouseEvent<HTMLElement>, worktreeId: string) => boolean
onContextMenuSelect: (
event: React.MouseEvent<HTMLElement>,
worktree: Worktree
) => readonly Worktree[]
onImmediateActivate: (worktreeId: string, rowKey: string | undefined) => void
onRowClickCapture: (event: React.MouseEvent<HTMLDivElement>) => void
onRowPointerDown: (
event: React.PointerEvent<HTMLDivElement>,
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 (
<div
key={vItem.key}
id={getWorktreeOptionId(folderWorktree.id)}
role="option"
aria-selected={ctx.selectedWorktreeIds.has(folderWorktree.id)}
aria-current={ctx.activeWorktreeId === folderWorktree.id ? 'page' : undefined}
data-worktree-id={folderWorktree.id}
data-worktree-row-key={folderWorktree.id}
data-worktree-virtual-row
data-worktree-virtual-row-key={String(vItem.key)}
data-worktree-virtual-row-start={vItem.start}
data-index={vItem.index}
ref={args.measureVirtualRowElement}
className="absolute left-0 right-0 top-0"
style={{ transform: getVirtualRowTransform(vItem.start) }}
onClickCapture={ctx.onRowClickCapture}
onPointerDown={(event) => ctx.onRowPointerDown(event, folderWorktree.id, folderWorktree.id)}
>
<div
className="relative"
style={surfaceInset > 0 ? { paddingLeft: surfaceInset } : undefined}
>
<WorktreeCard
worktree={folderWorktree}
repo={undefined}
isActive={ctx.activeWorktreeId === folderWorktree.id}
isCurrentWorktree={ctx.currentWorktreeId === folderWorktree.id}
contentIndent={cardContentIndent}
flushSurface
nativeDragEnabled={false}
onImmediateActivate={activationDisabled ? undefined : ctx.onImmediateActivate}
activationRowKey={folderWorktree.id}
onSelectionGesture={ctx.onSelectionGesture}
onContextMenuSelect={ctx.onContextMenuSelect}
statusPrDisplay={folderPrDisplay}
/>
<div className="pointer-events-auto absolute right-3 top-1.5">
<FolderPathStatusIndicator status={pathStatus} />
</div>
</div>
</div>
)
}
@@ -0,0 +1,9 @@
import type { Row } from '../worktree-list-groups'
import type { ImportedWorktreeCardActionState } from '../imported-worktrees-card-actions'
export function canKeepImportedWorktreesHidden(
row: Extract<Row, { type: 'imported-worktrees-card' }>,
actionState: ImportedWorktreeCardActionState | undefined
): boolean {
return row.placement === 'repo-group' && actionState?.forceVisible !== true
}
@@ -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
}
@@ -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<HTMLDivElement, HTMLDivElement>
scrollRef: React.RefObject<HTMLDivElement | null>
worktrees: Worktree[]
folderWorkspaces: readonly FolderWorkspace[]
repoMap: Map<string, Repo>
worktreeMap: Map<string, Worktree>
worktreeLineageById: Record<string, WorktreeLineage>
collapsedGroups: Set<string>
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)
}
}
}
@@ -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<HTMLElement>('[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<HTMLElement>('[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
}
@@ -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 (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className={REPO_HEADER_ACTION_BUTTON_CLASS}
data-repo-header-action=""
aria-label={translate(
'auto.components.sidebar.WorktreeList.79465e9034',
'Group actions for {{value0}}',
{ value0: label }
)}
onClick={(event) => event.stopPropagation()}
onKeyDown={stopRepoHeaderKeyboardToggle}
onPointerDown={handleRepoHeaderActionPointerDown}
>
<Ellipsis className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
side="bottom"
sideOffset={6}
// Why: Radix portals keep React bubbling through the project header; block menu events from arming row drag/collapse.
onPointerDown={stopRepoHeaderMenuEvent}
onMouseDown={stopRepoHeaderMenuEvent}
onPointerUp={stopRepoHeaderMenuEvent}
onMouseUp={stopRepoHeaderMenuEvent}
onClick={stopRepoHeaderMenuEvent}
onKeyDown={stopRepoHeaderMenuEvent}
>
<DropdownMenuItem onSelect={() => onRename(groupId, label)}>
{translate('auto.components.sidebar.WorktreeList.4d7b73658c', 'Rename group')}
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onSelect={() => onDelete(groupId, label)}>
{translate('auto.components.sidebar.WorktreeList.902115cdbe', 'Delete group')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
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 (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
data-repo-header-action=""
className={cn(
REPO_HEADER_ACTION_BUTTON_CLASS,
disabled &&
'cursor-not-allowed text-muted-foreground/60 hover:bg-transparent hover:text-muted-foreground/60'
)}
aria-label={createLabel}
aria-disabled={disabled}
onKeyDown={stopRepoHeaderKeyboardToggle}
onPointerDown={handleRepoHeaderActionPointerDown}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
if (disabled) {
return
}
onCreate(projectGroup)
}}
>
<Plus className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{pathStatus?.exists === false
? getFolderWorkspacePathStatusDescription(pathStatus)
: createLabel}
</TooltipContent>
</Tooltip>
)
}
@@ -0,0 +1,13 @@
import { PINNED_GROUP_KEY } from '../worktree-list-groups'
import type { HostSectionRow } from '../host-section-rows'
export type WorktreeItemRow = Extract<HostSectionRow, { type: 'item' }>
export type FolderWorkspaceItemRow = Extract<HostSectionRow, { type: 'folder-workspace' }>
export function isWorktreeItemRow(row: HostSectionRow): row is WorktreeItemRow {
return row.type === 'item'
}
export function isPinnedWorktreeRow(row: WorktreeItemRow): boolean {
return row.sectionKey === PINNED_GROUP_KEY
}
@@ -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
}
@@ -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
}
@@ -0,0 +1,31 @@
import type React from 'react'
import { isRepoHeaderActionTarget } from '../project-header-drag'
export function stopRepoHeaderKeyboardToggle(event: React.KeyboardEvent<HTMLElement>): void {
if (event.key === 'Enter' || event.key === ' ') {
event.stopPropagation()
}
}
export function stopNestedWorktreeCardBubble(event: React.SyntheticEvent<HTMLElement>): void {
event.stopPropagation()
}
export function handleRepoHeaderActionPointerDown(event: React.PointerEvent<HTMLElement>): void {
event.stopPropagation()
}
export function handleRepoHeaderCollapseAffordancePointerDown(
event: React.PointerEvent<HTMLElement>
): void {
// Why: keep collapse-chevron clicks from arming the repo-header row drag.
event.stopPropagation()
}
export function stopRepoHeaderMenuEvent(event: React.SyntheticEvent<HTMLElement>): void {
event.stopPropagation()
}
export function shouldIgnoreRepoHeaderToggle(event: React.SyntheticEvent<HTMLElement>): boolean {
return isRepoHeaderActionTarget(event.target, event.currentTarget)
}
@@ -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 (
<DropdownMenu modal={false}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className={REPO_HEADER_ACTION_BUTTON_CLASS}
data-repo-header-action=""
aria-label={translate(
'auto.components.sidebar.WorktreeList.609633a9e6',
'Project actions for {{value0}}',
{ value0: label }
)}
onClick={(event) => event.stopPropagation()}
onKeyDown={stopRepoHeaderKeyboardToggle}
onPointerDown={handleRepoHeaderActionPointerDown}
>
<Ellipsis className="size-3.5" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.sidebar.WorktreeList.2ef41bf9a7', 'Project actions')}
</TooltipContent>
</Tooltip>
<DropdownMenuContent
align="end"
side="bottom"
sideOffset={6}
// Why: Radix portals keep React bubbling through the project header; block menu events from arming row drag/collapse.
onPointerDown={stopRepoHeaderMenuEvent}
onMouseDown={stopRepoHeaderMenuEvent}
onPointerUp={stopRepoHeaderMenuEvent}
onMouseUp={stopRepoHeaderMenuEvent}
onClick={stopRepoHeaderMenuEvent}
onKeyDown={stopRepoHeaderMenuEvent}
>
<DropdownMenuItem onSelect={() => actions.onOpenRepoSettings(repo.id)}>
<SlidersHorizontal className="size-3.5" />
{translate('auto.components.sidebar.WorktreeList.2cdffbc728', 'Project Settings')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => actions.onOpenRepoSettings(repo.id, getRepositoryIconSectionId(repo.id))}
>
<Shapes className="size-3.5" />
{translate('auto.components.sidebar.WorktreeList.e82d3589a1', 'Change Project Icon')}
</DropdownMenuItem>
{isGitRepoKind(repo) ? (
<DropdownMenuItem onSelect={() => actions.onOpenWorktreeVisibility(repo)}>
<Eye className="size-3.5" />
{getWorktreeVisibilityMenuLabel(repo)}
</DropdownMenuItem>
) : null}
<DropdownMenuItem onSelect={() => actions.onCreateGroupFromRepo(repo)}>
<FolderPlus className="size-3.5" />
{translate('auto.components.sidebar.WorktreeList.cbfd565f83', 'New group from project')}
</DropdownMenuItem>
{projectGroups.length > 0 ? (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FolderInput className="size-3.5" />
{translate('auto.components.sidebar.WorktreeList.4a08fb55f2', 'Move to group')}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{projectGroups.map((group) => (
<DropdownMenuItem
key={group.id}
disabled={repo.projectGroupId === group.id}
onSelect={() => actions.onMoveProjectToGroup(repo, group.id)}
>
<span className="max-w-48 truncate">{group.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
) : null}
{repo.projectGroupId ? (
<DropdownMenuItem onSelect={() => actions.onRemoveProjectFromGroup(repo)}>
<CircleX className="size-3.5" />
{translate('auto.components.sidebar.WorktreeList.64e55f7f01', 'Remove from group')}
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onSelect={() => actions.onRemoveProject(repo)}>
<Trash2 className="size-3.5" />
{translate('auto.components.sidebar.WorktreeList.c83968f87f', 'Remove Project')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
export function RepoHeaderCreateWorkspaceButton({
repo,
label,
createState,
onCreateForRepo
}: {
repo: Repo
label: string
createState: ReturnType<typeof getRepoHeaderCreateState> | null
onCreateForRepo: (projectId: string) => void
}): React.JSX.Element {
const fallbackLabel = translate(
'auto.components.sidebar.WorktreeList.bb85cd86ba',
'Create workspace for {{value0}}',
{ value0: label }
)
return (
<Tooltip>
<TooltipTrigger asChild>
{createState?.disabled ? (
<span
className={cn(
'inline-flex cursor-not-allowed transition-[margin,max-width,opacity]',
REPO_HEADER_ACTION_REVEAL_CLASS
)}
data-repo-header-action=""
tabIndex={0}
aria-label={createState.ariaLabel}
onKeyDown={stopRepoHeaderKeyboardToggle}
onClick={(event) => event.stopPropagation()}
onPointerDown={handleRepoHeaderActionPointerDown}
>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="pointer-events-none size-5 shrink-0 rounded-md text-muted-foreground transition-opacity opacity-60"
aria-label={createState.ariaLabel}
disabled
>
<Plus className="size-3" />
</Button>
</span>
) : (
<Button
type="button"
variant="ghost"
size="icon-xs"
className={REPO_HEADER_ACTION_BUTTON_CLASS}
data-repo-header-action=""
aria-label={createState?.ariaLabel ?? fallbackLabel}
onKeyDown={stopRepoHeaderKeyboardToggle}
onPointerDown={handleRepoHeaderActionPointerDown}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onCreateForRepo(repo.id)
}}
>
<Plus className="size-3" />
</Button>
)}
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{createState?.tooltip ?? fallbackLabel}
</TooltipContent>
</Tooltip>
)
}
@@ -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
)
}
@@ -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<string, Repo>,
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<string>()
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<string>()
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<string, Repo>
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<string>()
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>
}): 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
}
@@ -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<string> = 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'] = {}
@@ -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
])
}
@@ -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<string>
agentSendTargetWorktreeId: string | null
groupBy: WorktreeGroupBy
repoMap: Map<string, Repo>
worktreeMap: Map<string, Worktree>
worktreeLineageById: Record<string, WorktreeLineage>
prCache: AppState['prCache'] | null
workspaceStatuses: readonly WorkspaceStatusDefinition[]
settings: AppState['settings']
projectGroups: readonly ProjectGroup[]
projectGrouping: ProjectGroupingModel
}): Set<string> {
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
])
}
@@ -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<AppState['fetchFolderWorkspacePathStatus']>[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<string, Repo>
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
}
@@ -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<HTMLDivElement | null>
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 }
}
@@ -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
])
}
@@ -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
}
}
@@ -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<typeof useProjectGroupDialogs>
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<string, Repo>
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<ProjectGroupNameDialogState | null>(null)
const [deleteDialog, setDeleteDialog] = useState<ProjectGroupDeleteDialogState | null>(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
}
}
@@ -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<T>(next: T[]): T[] {
const previousRef = useRef<T[]>(next)
const result = reuseArrayIfEqual(previousRef.current, next)
previousRef.current = result
return result
}
@@ -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<string, ImportedWorktreeCardActionState>
>(new Map())
const [newExternalWorktreeInboxActionState, setNewExternalWorktreeInboxActionState] = useState<
Map<string, NewExternalWorktreesInboxActionState>
>(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
}
}
@@ -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<typeof filterFolderWorkspacesFromOtherDevices>[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 }
}
@@ -0,0 +1,63 @@
import { useCallback, useRef, useState } from 'react'
export type SidebarRevealHighlight = ReturnType<typeof useSidebarRevealHighlight>
// Owns the frames a reveal schedules and the short glow it leaves on the revealed row.
export function useSidebarRevealHighlight() {
const [highlightedRevealRowKey, setHighlightedRevealRowKey] = useState<string | null>(null)
const pendingRevealFrameIdsRef = useRef<Set<number>>(new Set())
const revealHighlightFrameIdRef = useRef<number | null>(null)
const revealHighlightTimeoutRef = useRef<number | null>(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
}
}
@@ -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<string>
renderedWorktreeIds: readonly string[]
currentSidebarWorktreeId: string | null
worktreeMap: Map<string, Worktree>
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])
}
@@ -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<string, Repo>
worktreeMap: Map<string, Worktree>
worktreeLineageById: Record<string, WorktreeLineage>
prCache: AppState['prCache'] | null
settings: AppState['settings']
workspaceStatuses: readonly WorkspaceStatusDefinition[]
effectiveCollapsedGroups: Set<string>
projectGrouping: ProjectGroupingModel
visibleReposForRows: readonly Repo[]
visibleProjectGroupsForRows: readonly ProjectGroup[]
visibleFolderWorkspacesForRows: readonly FolderWorkspace[]
importedWorktreesByRepo: Parameters<typeof buildRows>[14]
newExternalWorktreesInboxByRepo: Parameters<typeof buildRows>[15]
filterRepoIds: readonly string[]
visibleWorkspaceHostIds: readonly ExecutionHostId[] | null
workspaceHostScope: AppState['workspaceHostScope']
}
function collectRenderedSidebarRowKeys(sectionRows: ReturnType<typeof addHostSectionRows>) {
const keys = new Set<string>()
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
}
}
@@ -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<typeof useSidebarWorktreeFilters>
// 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 }
}
@@ -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<Set<string>>(new Set())
const [selectionAnchorId, setSelectionAnchorId] = useState<string | null>(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<string, Worktree>()
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<HTMLElement>, 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<HTMLElement>, 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
}
}
@@ -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<string, WorktreeAttention>): 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<string, Repo>
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<Map<string, WorktreeAttention> | 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<string, WorktreeAttention>()
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<Map<string, SmartClass>>(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<boolean>(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<string, SmartClass>()
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
}
@@ -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<HTMLDivElement | null>
scrollOffsetRef: React.MutableRefObject<number>
scrollAnchorRef: React.MutableRefObject<VirtualizedScrollAnchor>
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 }
}
@@ -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<string, Worktree>
groupBy: WorktreeGroupBy
newCardStyle: boolean
renderRows: RenderRow[]
virtualItems: readonly VirtualItem[]
scrollRef: React.RefObject<HTMLDivElement | null>
}): 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
])
}
@@ -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<string, Repo>
worktreeMap: Map<string, Worktree>
worktreeLineageById: Record<string, WorktreeLineage>
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 }
}
@@ -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<HTMLDivElement | null>
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
}
}
@@ -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<WorktreeSidebarDragSession | null>
}): 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])
}
@@ -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<typeof useWorktreeDragRuntime>
// 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<WorktreeSidebarDragSession | null>
statusDropAnchorsRef: React.MutableRefObject<Map<string, WorktreeSidebarDropAnchor>>
onWorkspaceBoardDragPreviewCancel: () => void
}) {
const { worktreeDragSessionRef, statusDropAnchorsRef, onWorkspaceBoardDragPreviewCancel } = args
const [dragOverStatus, setDragOverStatus] = useState<WorkspaceStatus | null>(null)
const [pinDragOver, setPinDragOver] = useState(false)
const [nativeLineageDropTargetId, setNativeLineageDropTargetId] = useState<string | null>(null)
const [worktreeDragState, setWorktreeDragState] = useState<WorktreeRowDragState>(
WORKTREE_ROW_DRAG_INITIAL_STATE
)
const worktreePointerDragRef = useRef<WorktreePointerDrag | null>(null)
const pointerAutoscrollFrameIdRef = useRef<number | null>(null)
const pointerAutoscrollLastFrameTimeRef = useRef<number | null>(null)
const nativeAutoscrollFrameIdRef = useRef<number | null>(null)
const nativeAutoscrollLastFrameTimeRef = useRef<number | null>(null)
const nativeLatestPointRef = useRef<WorktreeSidebarDragPoint | null>(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
]
)
}
@@ -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<typeof useWorktreeDragSession>
// 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<HTMLDivElement | null>
}) {
const { rows, scrollRef } = args
const worktreeDragSessionRef = useRef<WorktreeSidebarDragSession | null>(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<Map<string, WorktreeSidebarDropAnchor>>(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
]
)
}
@@ -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<typeof useWorktreeLineageDropCommit>
// Nesting and un-nesting are the two lineage mutations a sidebar drag can commit.
export function useWorktreeLineageDropCommit(args: {
repoMap: Map<string, Repo>
worktreeMap: Map<string, Worktree>
worktreeLineageById: Record<string, WorktreeLineage>
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
}
}
@@ -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<HTMLDivElement, HTMLDivElement>
scrollRef: React.RefObject<HTMLDivElement | null>
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 }
}
@@ -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<typeof useWorktreeListVirtualizer>
// 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<HTMLDivElement | null>
scrollOffsetRef: React.MutableRefObject<number>
suppressMeasurementAdjustmentUntilRef: React.MutableRefObject<number>
}) {
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<number | null>(null)
const activeStickyHostIndexRef = useRef<number | null>(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<typeof measureVirtualElementSize<HTMLDivElement>>[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
}
}
@@ -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<HTMLDivElement | null>
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])
}
@@ -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<HTMLDivElement | null>
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<HTMLDivElement>, 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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 }
}
@@ -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<HTMLDivElement | null>
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])
}
@@ -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
])
}
@@ -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<HTMLDivElement | null>
markScrollMovement: () => void
selectedWorktreeIds: ReadonlySet<string>
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<HTMLDivElement>, 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<HTMLDivElement>) => {
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 }
}
@@ -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<typeof useWorktreeSidebarHeaderDrag>
function indexById(byBucket: ReadonlyMap<string, readonly string[]>): Map<string, number> {
const map = new Map<string, number>()
for (const ids of byBucket.values()) {
ids.forEach((id, index) => {
map.set(id, index)
})
}
return map
}
function bucketById(byBucket: ReadonlyMap<string, readonly string[]>): Map<string, string> {
const map = new Map<string, string>()
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<string, Repo>
projectGroups: readonly ProjectGroup[]
groupBy: WorktreeGroupBy
projectOrderBy: ProjectOrderBy
scrollRef: React.RefObject<HTMLDivElement | null>
onReorderHostSections: (orderedHostIds: ExecutionHostId[]) => void
onHostDragActiveChange: (active: boolean) => void
suppressMeasurementAdjustmentUntilRef: React.MutableRefObject<number>
directScrollInputUntilRef: React.MutableRefObject<number>
}) {
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
}
}
@@ -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<HTMLDivElement | null>
) {
const suppressMeasurementAdjustmentUntilRef = useRef(0)
const directScrollInputUntilRef = useRef(0)
const pendingRevealScrollRef = useRef<PendingRevealScroll | null>(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
}
}
@@ -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<string, Worktree>
): Map<string, number> {
const rankByWorktreeId = new Map<string, number>()
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<string, Worktree>
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<string, { workspaceStatus: WorkspaceStatus }>()
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<string, Partial<WorktreeMeta>>()
for (const worktreeId of dropArgs.worktreeIds) {
const current = worktreeMap.get(worktreeId)
if (!current) {
continue
}
const next: Partial<WorktreeMeta> = {}
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
}
}
@@ -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)`
}
@@ -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<string>
handleCreateForRepo: (projectId: string) => void
handleOpenRepoSettings: (projectId: string, sectionId?: string) => void
handleOpenWorktreeVisibility: (repo: Repo) => void
handleShowImportedWorktrees: (projectId: string) => void
handleKeepImportedWorktreesHidden: (projectId: string) => void
importedWorktreeCardActionState: ReadonlyMap<string, ImportedWorktreeCardActionState>
handleOpenSuppressExternalWorktreeInbox: (projectId: string) => void
newExternalWorktreeInboxActionState: ReadonlyMap<string, NewExternalWorktreesInboxActionState>
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<string>
selectedWorktrees: readonly Worktree[]
onSelectionGesture: (event: React.MouseEvent<HTMLElement>, worktreeId: string) => boolean
onImmediateWorktreeActivate: (worktreeId: string, rowKey: string | undefined) => void
onContextMenuSelect: (
event: React.MouseEvent<HTMLElement>,
worktree: Worktree
) => readonly Worktree[]
repoMap: Map<string, Repo>
defaultHostId: ExecutionHostId
worktreeMap: Map<string, Worktree>
worktreeLineageById: Record<string, WorktreeLineage>
workspaceLineageByChildKey: Record<string, WorkspaceLineage>
// 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<number>
scrollAnchorRef: React.MutableRefObject<VirtualizedScrollAnchor>
}
@@ -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<string> {
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<string, string>
groupIndexByRowKey: Map<string, number>
} {
const groupKeyByRowKey = new Map<string, string>()
const groupIndexByRowKey = new Map<string, number>()
const groupIndexes = new Map<string, number>()
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 }
}
@@ -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<HTMLDivElement | null>
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
}
@@ -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<string>
groupKeyByRowKey: ReadonlyMap<string, string>
groupIndexByRowKey: ReadonlyMap<string, number>
agentSendTargetWorktreeId: string | null
worktreeDragState: WorktreeRowDragState
worktreePointerDragRef: React.MutableRefObject<WorktreePointerDrag | null>
nativeLineageDropTargetId: string | null
activeWorktreeId: string | null
currentWorktreeId: string | null
highlightedRevealRowKey: string | null
selectedWorktreeIds: ReadonlySet<string>
selectedWorktrees: readonly Worktree[]
getActiveSurfaceVariant: (row: WorktreeItemRow) => ActiveSurfaceVariant
getLineageToggleHandler: (groupKey: string) => LineageToggleHandler
onSelectionGesture: (event: React.MouseEvent<HTMLElement>, worktreeId: string) => boolean
onContextMenuSelect: (
event: React.MouseEvent<HTMLElement>,
worktree: Worktree
) => readonly Worktree[]
onImmediateActivate: (worktreeId: string, rowKey: string | undefined) => void
onRowClickCapture: (event: React.MouseEvent<HTMLDivElement>) => void
onRowPointerDown: (
event: React.PointerEvent<HTMLDivElement>,
worktreeId: string,
rowKey: string
) => void
onCardDragStart: (
event: React.DragEvent<HTMLDivElement>,
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 (
<div
key={itemRow.rowKey}
id={getWorktreeOptionId(itemRow.rowKey)}
role="option"
aria-selected={ctx.selectedWorktreeIds.has(itemRow.worktree.id)}
aria-current={isActiveWorktree ? 'page' : undefined}
data-worktree-id={itemRow.worktree.id}
data-worktree-row-key={itemRow.rowKey}
data-worktree-section-key={itemRow.sectionKey}
data-worktree-drag-id={worktreeDragGroupKey ? itemRow.worktree.id : undefined}
data-worktree-drag-group-key={worktreeDragGroupKey}
data-worktree-drag-group-index={ctx.groupIndexByRowKey.get(itemRow.rowKey)}
className={cn(
// Why: don't transition 'transform' — it lags/flashes when TanStack Virtual repositions adjacent rows.
'relative transition-[opacity,filter] duration-150 ease-out',
ctx.worktreeDragState.draggingWorktreeId === itemRow.worktree.id &&
// Why: the fixed drag preview is the affordance; a translucent source row would bleed through sticky headers/footers.
'pointer-events-none opacity-0'
)}
data-scroll-reveal-highlight={
ctx.highlightedRevealRowKey === itemRow.rowKey ? 'true' : undefined
}
// Why: nested child cards live inside the parent's clickable body; bubbling would activate/edit the parent too.
onClick={nested ? stopNestedWorktreeCardBubble : undefined}
onClickCapture={ctx.onRowClickCapture}
onDoubleClick={nested ? stopNestedWorktreeCardBubble : undefined}
onDragStart={nested ? stopNestedWorktreeCardBubble : undefined}
onPointerDown={(event) => {
if (nested) {
event.stopPropagation()
}
ctx.onRowPointerDown(event, itemRow.worktree.id, itemRow.rowKey)
}}
style={{
paddingLeft: surfaceInset > 0 ? `${surfaceInset}px` : undefined
}}
>
<WorktreeCard
worktree={itemRow.worktree}
repo={itemRow.repo}
isActive={isActiveWorktree}
isCurrentWorktree={ctx.currentWorktreeId === itemRow.worktree.id}
// Why: a child-active parent should look active without the active-card side effects (e.g. SSH reconnect UI).
isActiveSurface={forceActiveSurface || isActiveWorktree}
activeSurfaceVariant={
isActiveWorktree && !forceActiveSurface ? ctx.getActiveSurfaceVariant(itemRow) : 'primary'
}
isMultiSelected={ctx.selectedWorktreeIds.has(itemRow.worktree.id)}
revealHighlight={ctx.highlightedRevealRowKey === itemRow.rowKey}
revealHighlightTone={
ctx.agentSendTargetWorktreeId === itemRow.worktree.id ? 'ai' : 'default'
}
selectedWorktrees={ctx.selectedWorktrees}
nativeDragEnabled={false}
isLineageDropTarget={Boolean(isLineageDropTarget)}
contentIndent={cardContentIndent}
flushSurface
activationRowKey={itemRow.rowKey}
onImmediateActivate={ctx.onImmediateActivate}
onSelectionGesture={ctx.onSelectionGesture}
onContextMenuSelect={ctx.onContextMenuSelect}
onCardDragStart={ctx.onCardDragStart}
onCardDragEnd={ctx.onCardDragEnd}
hideRepoBadge={ctx.groupBy === 'repo'}
// Why: pinned worktrees mix repos in one section, so only it needs the leading repo identity chip.
hostContextLabel={itemRow.hostContextLabel}
inPinnedSection={itemRow.sectionKey === PINNED_GROUP_KEY}
renameRowKey={itemRow.rowKey}
lineageChildCount={itemRow.lineageChildCount}
lineageCollapsed={itemRow.lineageCollapsed}
lineageChildren={lineageChildren}
lineageChildrenStyle={lineageChildrenStyle}
onLineageToggle={
itemRow.lineageGroupKey ? ctx.getLineageToggleHandler(itemRow.lineageGroupKey) : undefined
}
/>
</div>
)
}
// 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
}
@@ -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 (
<div
role="presentation"
data-worktree-virtual-row
data-worktree-virtual-row-key={String(vItem.key)}
data-worktree-virtual-row-start={vItem.start}
data-index={vItem.index}
ref={measureVirtualRowElement}
className={className}
style={{ transform: getVirtualRowTransform(vItem.start) }}
>
{children}
</div>
)
}
export function renderImportedWorktreesVirtualRow(args: {
row: Extract<Row, { type: 'imported-worktrees-card' }>
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 (
<NoticeRowFrame
key={args.vItem.key}
vItem={args.vItem}
measureVirtualRowElement={args.measureVirtualRowElement}
>
<ImportedWorktreesVisibilityLine
repoDisplayName={row.repo.displayName}
hiddenWorktrees={row.hiddenWorktrees}
placement={row.placement}
pending={actionState?.pending ?? false}
error={actionState?.error ?? null}
onShow={() => args.onShow(row.repo.id)}
onKeepHidden={
canKeepImportedWorktreesHidden(row, actionState)
? () => args.onKeepHidden(row.repo.id)
: undefined
}
/>
</NoticeRowFrame>
)
}
export function renderNewExternalWorktreesInboxVirtualRow(args: {
row: Extract<Row, { type: 'new-external-worktrees-inbox' }>
vItem: VirtualItem
measureVirtualRowElement: (element: HTMLDivElement | null) => void
actionState: NewExternalWorktreesInboxActionState | undefined
onReview: (repo: Extract<Row, { type: 'new-external-worktrees-inbox' }>['repo']) => void
onSuppress: (projectId: string) => void
}): React.JSX.Element {
const { row, actionState } = args
return (
<NoticeRowFrame
key={args.vItem.key}
vItem={args.vItem}
measureVirtualRowElement={args.measureVirtualRowElement}
>
<NewExternalWorktreesInboxLine
repoDisplayName={row.repo.displayName}
inboxCount={row.inboxWorktrees.length}
pending={actionState?.pending ?? false}
error={actionState?.error ?? null}
onReview={() => args.onReview(row.repo)}
onSuppress={() => args.onSuppress(row.repo.id)}
/>
</NoticeRowFrame>
)
}
export function renderPendingCreationVirtualRow(args: {
row: Extract<Row, { type: 'pending-creation' }>
vItem: VirtualItem
measureVirtualRowElement: (element: HTMLDivElement | null) => void
}): React.JSX.Element {
return (
<NoticeRowFrame
key={args.vItem.key}
vItem={args.vItem}
measureVirtualRowElement={args.measureVirtualRowElement}
className="absolute left-0 right-0 top-0 px-2 pb-1.5"
>
<PendingWorktreeRow creationId={args.row.creationId} />
</NoticeRowFrame>
)
}
@@ -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<HTMLElement>('[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<HTMLElement>('[data-worktree-sidebar]')
const nextOptions = getMountedWorktreeOptions(worktreeId, sidebar)
const nextOption = nextOptions[0]
if (!nextOption) {
return
}
sidebar
?.querySelectorAll<HTMLElement>('[role="option"][aria-current="page"]')
.forEach((option) => option.removeAttribute('aria-current'))
for (const option of nextOptions) {
option.setAttribute('aria-current', 'page')
}
sidebar
?.querySelectorAll<HTMLElement>('[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<HTMLElement>('[data-worktree-card-surface]')
surface?.setAttribute('data-worktree-card-active', activeSurfaceVariant)
}
}
@@ -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()
}
@@ -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<React.SetStateAction<WorktreeRowDragState>>
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 })
)
}
@@ -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<string, number>
pointerY: number | null
}
export const EMPTY_WORKTREE_DRAG_PREVIEW_OFFSETS: ReadonlyMap<string, number> = 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<string, number>,
b: ReadonlyMap<string, number>
): 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 }
}
@@ -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<string>
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 (
<div
key={vItem.key}
role="presentation"
data-worktree-virtual-row
data-worktree-virtual-row-key={String(vItem.key)}
data-worktree-virtual-row-start={vItem.start}
data-worktree-sticky-header=""
data-worktree-sticky-header-active={isActiveStickyHeader ? '' : undefined}
data-index={vItem.index}
ref={args.measureVirtualRowElement}
className={cn(
'left-0 right-0',
// Why: drop the inter-group spacer once the header pins so it sits flush at top (see getActiveStickyHeaderIndexForScroll).
args.hasHeaderTopSpacing && !isActiveStickyHeader && 'pt-1',
isActiveStickyHeader
? cn(
'sticky z-20 bg-worktree-sidebar',
// Why: when a host card is pinned, the group tier pins flush beneath it, not at the viewport top.
args.hasStickyHost ? 'top-[35px]' : '-top-px'
)
: 'absolute top-0'
)}
style={isActiveStickyHeader ? undefined : { transform: getVirtualRowTransform(vItem.start) }}
>
<div
id={getWorktreeOptionId(row.key)}
role="button"
tabIndex={0}
aria-expanded={showHeaderCollapseAffordance ? !isHeaderCollapsed : undefined}
data-repo-header-id={projectIdForHeader}
data-repo-header-index={repoHeaderIndex}
data-repo-header-bucket={repoHeaderBucketKey}
data-repo-header-section-end={
projectIdForHeader
? headerDrag.repoHeaderSectionEndByRepoId.get(projectIdForHeader)
: undefined
}
// Why: row keeps handle attrs so indent/padding still arms drag; grab
// cursor lives only on the title surface so … / + never inherit it.
data-repo-header-drag-handle={isDraggableRepoHeader ? '' : undefined}
data-project-group-header-id={projectGroupIdForHeader}
data-project-group-header-index={projectGroupHeaderIndex}
data-project-group-header-bucket={projectGroupHeaderBucketKey}
data-project-group-header-section-end={
projectGroupIdForHeader
? headerDrag.projectGroupHeaderSectionEndByGroupId.get(projectGroupIdForHeader)
: undefined
}
data-project-group-header-drag-handle={isDraggableProjectGroupHeader ? '' : undefined}
data-workspace-status-drop-target={headerWorkspaceStatus ? '' : undefined}
data-workspace-status={headerWorkspaceStatus ?? undefined}
data-workspace-pin-drop-target={isPinnedHeader ? '' : undefined}
className={cn(
// Why: no row-level grab — only the title surface below shows the hand;
// actions use cursor-pointer so … / + never look reorderable.
'group relative flex h-7 w-full items-center gap-1.5 pr-2 text-left transition-all',
!(isDraggableRepoHeader || isDraggableProjectGroupHeader) && 'cursor-pointer',
ctx.highlightedRevealRowKey === row.key &&
'rounded-md bg-worktree-sidebar-accent ring-1 ring-worktree-sidebar-ring/50',
(isDraggingThis || isDraggingThisProjectGroup) &&
'bg-accent/80 ring-1 ring-ring/40 shadow-md rounded-md scale-[1.01]',
headerWorkspaceStatus &&
ctx.dragOverStatus === headerWorkspaceStatus &&
'rounded-md bg-worktree-sidebar-accent ring-1 ring-worktree-sidebar-ring/40',
isPinnedHeader &&
ctx.pinDragOver &&
'rounded-md bg-worktree-sidebar-accent ring-1 ring-worktree-sidebar-ring/40',
row.repo && 'overflow-hidden'
)}
style={{
// Why: non-project headers like "All" are flat-list labels; don't reserve project hierarchy indent.
paddingLeft:
isRepoHeader || isProjectGroupHeader
? getProjectGroupHeaderPaddingLeft(row.projectGroupDepth ?? 0)
: WORKTREE_SECTION_HEADER_PADDING_LEFT
}}
onDragOver={
isPinnedHeader
? ctx.onWorkspacePinDragOver
: headerWorkspaceStatus
? (event) => 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. */}
<div
data-repo-header-drag-handle={isDraggableRepoHeader ? '' : undefined}
data-project-group-header-drag-handle={isDraggableProjectGroupHeader ? '' : undefined}
className={cn(
'flex min-w-0 flex-1 items-center gap-1.5 self-stretch',
(isDraggableRepoHeader || isDraggableProjectGroupHeader) &&
'cursor-grab active:cursor-grabbing'
)}
>
{row.icon ? (
<div
className={cn(
'flex size-4 shrink-0 items-center justify-center rounded-[4px]',
repoHeaderColor ? 'text-muted-foreground' : row.tone
)}
>
{row.repo ? (
<RepoIconGlyph
repoIcon={row.repo.repoIcon}
color={repoHeaderColor}
className="size-4"
iconClassName="size-3.5"
/>
) : (
<row.icon className="size-3" />
)}
</div>
) : null}
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<div className="min-w-0 truncate text-[13px] font-semibold leading-none">
{row.label}
</div>
<RepoForkIndicator upstream={row.repo?.upstream} />
<FolderPathStatusIndicator status={projectGroupPathStatus} />
</div>
</div>
</div>
<ProjectHeaderActions>
{showHeaderCollapseAffordance ? (
<div
className="flex size-5 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent/70 hover:text-foreground"
data-repo-header-collapse-affordance=""
aria-hidden
onPointerDown={handleRepoHeaderCollapseAffordancePointerDown}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
ctx.toggleGroupWithScrollAnchor(row.key)
}}
>
<ChevronDown
className={cn('size-3.5 transition-transform', isHeaderCollapsed && '-rotate-90')}
/>
</div>
) : null}
{isProjectGroupHeader && !row.repo && projectGroupIdForHeader ? (
<ProjectGroupHeaderMenu
groupId={projectGroupIdForHeader}
label={row.label}
onRename={ctx.onRenameProjectGroup}
onDelete={ctx.onDeleteProjectGroup}
/>
) : null}
{folderBackedProjectGroup ? (
<ProjectGroupCreateWorkspaceButton
projectGroup={folderBackedProjectGroup}
label={row.label}
pathStatus={projectGroupPathStatus}
disabled={isFolderWorkspaceCreateDisabled(projectGroupPathStatus)}
onCreate={ctx.onCreateFolderWorkspace}
/>
) : null}
{row.repo && ctx.groupBy === 'repo' ? (
<RepoHeaderProjectActionsMenu
repo={row.repo}
label={row.label}
projectGroups={ctx.projectGroups}
actions={ctx.projectActions}
/>
) : null}
{row.repo && ctx.groupBy === 'repo' ? (
<RepoHeaderCreateWorkspaceButton
repo={row.repo}
label={row.label}
createState={createState}
onCreateForRepo={ctx.projectActions.onCreateForRepo}
/>
) : null}
</ProjectHeaderActions>
</div>
</div>
)
}
@@ -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<Record<string, unknown>, number>()
export function countRecordKeysByReference(record: Record<string, unknown>): number {
const cached = recordKeyCountCache.get(record)
if (cached !== undefined) {
return cached
}
const count = Object.keys(record).length
recordKeyCountCache.set(record, count)
return count
}
export function shouldAdjustWorktreeSidebarMeasuredRowScroll(args: {
isScrolling: boolean
now: number
suppressUntil: number
}): boolean {
return !args.isScrolling && args.now >= args.suppressUntil
}
export function resolvePendingSidebarReveal(args: {
targetIndex: number
targetWorktreeStillExists: boolean
}): 'scroll-and-clear' | 'clear' | 'keep-pending' {
if (args.targetIndex !== -1) {
return 'scroll-and-clear'
}
return args.targetWorktreeStillExists ? 'keep-pending' : 'clear'
}
@@ -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<string>
session: WorktreeDragSession
runtime: WorktreeDragRuntime
primaryActive: ReturnType<typeof usePrimaryActiveWorktreeRow>
reveal: SidebarRevealHighlight
statusDrag: ReturnType<typeof useWorkspaceStatusRowDrag>
nativeDrag: ReturnType<typeof useWorktreeNativeDrag>
headerDrag: WorktreeSidebarHeaderDrag
getCachedFolderWorkspacePathStatus: ReturnType<typeof useFolderWorkspacePathStatusRows>
getLineageToggleHandler: (groupKey: string) => LineageToggleHandler
toggleGroupWithScrollAnchor: (groupKey: string) => void
onRowClickCapture: (event: React.MouseEvent<HTMLDivElement>) => void
onRowPointerDown: (
event: React.PointerEvent<HTMLDivElement>,
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
}
}
}
@@ -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<number | null>
activeStickyHostIndexRef: React.MutableRefObject<number | null>
measureVirtualRowElement: (element: HTMLDivElement | null) => void
groupBy: WorktreeGroupBy
workspaceStatuses: readonly WorkspaceStatusDefinition[]
activeWorktreeId: string | null
worktreeDragState: WorktreeRowDragState
orderedHostIds: readonly string[]
hostDrag: ReturnType<typeof useHostHeaderDrag>
toggleGroupWithScrollAnchor: (groupKey: string) => void
header: SectionHeaderRowContext
item: WorktreeItemRowContext
folderWorkspace: FolderWorkspaceRowContext
importedWorktreeCardActionState: ReadonlyMap<string, ImportedWorktreeCardActionState>
newExternalWorktreeInboxActionState: ReadonlyMap<string, NewExternalWorktreesInboxActionState>
onShowImportedWorktrees: (projectId: string) => void
onKeepImportedWorktreesHidden: (projectId: string) => void
onOpenWorktreeVisibility: (
repo: Parameters<SectionHeaderRowContext['projectActions']['onOpenWorktreeVisibility']>[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<RenderRow, { type: 'host-header' }>,
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 (
<div
key={vItem.key}
role="presentation"
data-worktree-virtual-row
data-worktree-virtual-row-key={String(vItem.key)}
data-worktree-sticky-header=""
data-worktree-sticky-header-active={isActiveStickyHost ? '' : undefined}
data-index={vItem.index}
ref={ctx.measureVirtualRowElement}
className={cn(
'left-0 right-0',
hasHeaderTopSpacing && !isActiveStickyHost && 'pt-1',
isActiveStickyHost ? 'sticky -top-px z-30 bg-worktree-sidebar' : 'absolute top-0'
)}
style={isActiveStickyHost ? undefined : { transform: getVirtualRowTransform(vItem.start) }}
>
<HostSectionHeader
row={row}
onToggle={() => ctx.toggleGroupWithScrollAnchor(row.key)}
onDragPointerDown={
ctx.orderedHostIds.length > 1
? (e) => ctx.hostDrag.onHandlePointerDown(e, row.hostId)
: undefined
}
dragging={ctx.hostDrag.state.draggingHostId === row.hostId}
/>
</div>
)
}
function renderLineageGroupVirtualRow(
ctx: WorktreeVirtualRowContext,
row: Extract<RenderRow, { type: 'lineage-group' }>,
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 (
<div
key={vItem.key}
role="presentation"
data-worktree-virtual-row
data-worktree-virtual-row-key={String(vItem.key)}
data-worktree-virtual-row-start={vItem.start}
data-index={vItem.index}
ref={ctx.measureVirtualRowElement}
className={cn(
'absolute left-0 right-0 top-0',
ctx.worktreeDragState.draggingWorktreeId !== null &&
'transition-transform duration-150 ease-out will-change-transform'
)}
style={{ transform: getWorktreeVirtualRowTransform(vItem.start, parentPreviewOffset) }}
>
<div className="overflow-visible">
{parent
? renderWorktreeItemRow(
ctx.item,
parent,
false,
renderWorktreeLineageDescendants(ctx.item, parent, children),
childIsActive
)
: null}
</div>
</div>
)
}
// 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 (
<div
key={vItem.key}
role="presentation"
data-worktree-virtual-row
data-worktree-virtual-row-key={String(vItem.key)}
data-worktree-virtual-row-start={vItem.start}
data-index={vItem.index}
ref={ctx.measureVirtualRowElement}
data-workspace-status-drop-target={itemWorkspaceStatus ? '' : undefined}
data-workspace-status={itemWorkspaceStatus ?? undefined}
className={cn(
'absolute left-0 right-0 top-0',
ctx.worktreeDragState.draggingWorktreeId !== null &&
'transition-transform duration-150 ease-out will-change-transform'
)}
style={{ transform: getWorktreeVirtualRowTransform(vItem.start, itemPreviewOffset) }}
onDragOver={
itemWorkspaceStatus
? (event) => ctx.onWorkspaceStatusDragOver(event, itemWorkspaceStatus)
: undefined
}
onDragLeave={itemWorkspaceStatus ? ctx.onWorkspaceStatusDragLeave : undefined}
onDrop={
itemWorkspaceStatus
? (event) => ctx.onWorkspaceStatusDrop(event, itemWorkspaceStatus)
: undefined
}
>
{renderWorktreeItemRow(ctx.item, row, false)}
</div>
)
}
@@ -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'
}
@@ -0,0 +1,4 @@
export function installWorktreeVisibleRefreshVisibilityListener(onChange: () => void): () => void {
document.addEventListener('visibilitychange', onChange)
return () => document.removeEventListener('visibilitychange', onChange)
}
@@ -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) =>
@@ -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)
})