mirror of
https://github.com/stablyai/orca.git
synced 2026-11-06 12:07:58 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
+9
@@ -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
|
||||
])
|
||||
}
|
||||
+128
@@ -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
|
||||
}
|
||||
+30
@@ -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
|
||||
}
|
||||
+187
@@ -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
|
||||
}
|
||||
+116
@@ -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 }
|
||||
}
|
||||
+130
@@ -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
|
||||
]
|
||||
)
|
||||
}
|
||||
+127
@@ -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
|
||||
}
|
||||
}
|
||||
+120
@@ -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
|
||||
}
|
||||
}
|
||||
+108
@@ -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 }
|
||||
}
|
||||
+79
@@ -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])
|
||||
}
|
||||
+95
@@ -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 }
|
||||
}
|
||||
+222
@@ -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
|
||||
}
|
||||
}
|
||||
+83
@@ -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)`
|
||||
}
|
||||
+116
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user