Optimize worktree parent picker: conditional mount, virtualization, lazy status loading (#12225)
* Optimize worktree parent picker: conditional mount, virtualization, lazy - Mount WorktreeParentPickerPopover only when open to avoid hundreds of unmounted instances subscribing to lineage and worktree store updates. - Virtualize the candidate list and resolve activity statuses only for visible rows, eliminating redundant status subscriptions. - Extract filtering, placement calculations, and row rendering into separate modules for testability and clarity. * Memoize worktree parent picker search handler Wrap search state update in useCallback to stabilize the handler across re-renders. Reduces unnecessary effect runs and enables better memoization of child components. * Optimize worktree parent picker: defer unmount, memoize IDs - Defer unmount until exit animation completes (200ms) to prevent premature teardown - Memoize visibleWorktreeIds to prevent status hook from rebuilding its selector on every render
This commit is contained in:
parent
25213ec04d
commit
ea68d97c28
|
|
@ -84,6 +84,9 @@ const WORKTREE_NATIVE_CONTEXT_MENU_ATTR = 'data-worktree-native-context-menu'
|
|||
const CONTEXT_MENU_CLICK_SUPPRESSION_MS = 500
|
||||
const DELETE_POSITION_RESTORE_MAX_FRAMES = 180
|
||||
const DELETE_POSITION_RESTORE_STABLE_FRAMES = 6
|
||||
// Why: the picker is unmounted on close, which would cut PopoverContent's
|
||||
// data-[state=closed] exit animation short; hold the subtree for its duration.
|
||||
const PARENT_PICKER_EXIT_ANIMATION_MS = 200
|
||||
|
||||
// Why: stable empty sentinels let closed menu wrappers subscribe to a referentially
|
||||
// stable value instead of the high-churn maps that delete teardown replaces. The
|
||||
|
|
@ -346,11 +349,13 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
|||
childWorktreeId: string
|
||||
anchorElement: HTMLElement
|
||||
} | null>(null)
|
||||
const [parentPickerOpen, setParentPickerOpen] = useState(false)
|
||||
const pendingParentPickerRef = useRef<{
|
||||
childWorktreeId: string
|
||||
anchorElement: HTMLElement
|
||||
} | null>(null)
|
||||
const parentPickerFallbackTimerRef = useRef<number | null>(null)
|
||||
const parentPickerUnmountTimerRef = useRef<number | null>(null)
|
||||
const isDeleting = deleteState?.isDeleting ?? false
|
||||
const repoMap = useRepoMap()
|
||||
const worktreeMap = useWorktreeMap()
|
||||
|
|
@ -506,6 +511,9 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
|||
if (parentPickerFallbackTimerRef.current != null) {
|
||||
window.clearTimeout(parentPickerFallbackTimerRef.current)
|
||||
}
|
||||
if (parentPickerUnmountTimerRef.current != null) {
|
||||
window.clearTimeout(parentPickerUnmountTimerRef.current)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
|
@ -684,7 +692,23 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
|||
window.clearTimeout(parentPickerFallbackTimerRef.current)
|
||||
parentPickerFallbackTimerRef.current = null
|
||||
}
|
||||
if (parentPickerUnmountTimerRef.current != null) {
|
||||
window.clearTimeout(parentPickerUnmountTimerRef.current)
|
||||
parentPickerUnmountTimerRef.current = null
|
||||
}
|
||||
setParentPicker(pendingParentPicker)
|
||||
setParentPickerOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleParentPickerOpenChange = useCallback((open: boolean) => {
|
||||
if (open) {
|
||||
return
|
||||
}
|
||||
setParentPickerOpen(false)
|
||||
parentPickerUnmountTimerRef.current = window.setTimeout(() => {
|
||||
parentPickerUnmountTimerRef.current = null
|
||||
setParentPicker(null)
|
||||
}, PARENT_PICKER_EXIT_ANIMATION_MS)
|
||||
}, [])
|
||||
|
||||
const handleOpenParentPicker = useCallback(
|
||||
|
|
@ -1063,16 +1087,18 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
|||
onOpenChange={setCreateGroupDialogOpen}
|
||||
onSubmit={handleSubmitNewProjectGroup}
|
||||
/>
|
||||
<WorktreeParentPickerPopover
|
||||
open={parentPicker !== null}
|
||||
childWorktreeId={parentPicker?.childWorktreeId ?? null}
|
||||
anchorElement={parentPicker?.anchorElement ?? null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setParentPicker(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* Why: mounted only while open — one instance of this lives behind every
|
||||
worktree card, and each one subscribes to the worktree and lineage
|
||||
maps just to compute parent candidates it will never show. Closing
|
||||
flips `open` first so the exit animation runs, then unmounts. */}
|
||||
{parentPicker ? (
|
||||
<WorktreeParentPickerPopover
|
||||
open={parentPickerOpen}
|
||||
childWorktreeId={parentPicker.childWorktreeId}
|
||||
anchorElement={parentPicker.anchorElement}
|
||||
onOpenChange={handleParentPickerOpenChange}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
import {
|
||||
getWorktreeParentPickerItemValue,
|
||||
handleWorktreeParentPickerKeyDown,
|
||||
selectWorktreeParent
|
||||
} from './WorktreeParentPickerPopover'
|
||||
import {
|
||||
clampWorktreeParentPickerIndex,
|
||||
filterWorktreeParentCandidates,
|
||||
getWorktreeParentPickerItemValue
|
||||
} from './worktree-parent-picker-filtering'
|
||||
import {
|
||||
clampWorktreeParentPickerAnchorTop,
|
||||
estimateWorktreeParentPickerHeight
|
||||
} from './worktree-parent-picker-placement'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
|
|
@ -96,3 +105,100 @@ describe('getWorktreeParentPickerItemValue', () => {
|
|||
expect(getWorktreeParentPickerItemValue(makeWorktree())).toContain('/workspaces/parent')
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterWorktreeParentCandidates', () => {
|
||||
const alpha = makeWorktree({ id: 'alpha', displayName: 'alpha', path: '/workspaces/alpha' })
|
||||
const beta = makeWorktree({
|
||||
id: 'beta',
|
||||
displayName: 'beta',
|
||||
path: '/workspaces/beta',
|
||||
branch: 'refs/heads/feature/alpha-follow-up'
|
||||
})
|
||||
|
||||
it('returns every candidate when the search is blank', () => {
|
||||
expect(filterWorktreeParentCandidates([alpha, beta], ' ')).toEqual([alpha, beta])
|
||||
})
|
||||
|
||||
it('drops non-matching candidates and ranks the closest match first', () => {
|
||||
expect(filterWorktreeParentCandidates([beta, alpha], 'alpha')).toEqual([alpha, beta])
|
||||
expect(filterWorktreeParentCandidates([alpha, beta], 'nothing-matches')).toEqual([])
|
||||
})
|
||||
|
||||
it('matches on branch and path, not just display name', () => {
|
||||
expect(filterWorktreeParentCandidates([alpha, beta], 'follow-up')).toEqual([beta])
|
||||
expect(filterWorktreeParentCandidates([alpha, beta], '/workspaces/beta')).toEqual([beta])
|
||||
})
|
||||
})
|
||||
|
||||
describe('estimateWorktreeParentPickerHeight', () => {
|
||||
it('grows with the candidate count up to the list cap', () => {
|
||||
expect(estimateWorktreeParentPickerHeight(1)).toBe(79 + 56)
|
||||
expect(estimateWorktreeParentPickerHeight(3)).toBe(79 + 168)
|
||||
// Why: matches the height measured on a rendered picker in the dev app.
|
||||
expect(estimateWorktreeParentPickerHeight(300)).toBe(367)
|
||||
})
|
||||
|
||||
it('reserves a single row when nothing is eligible', () => {
|
||||
expect(estimateWorktreeParentPickerHeight(0)).toBe(79 + 56)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clampWorktreeParentPickerAnchorTop', () => {
|
||||
it('leaves an anchor that already fits where it is', () => {
|
||||
expect(clampWorktreeParentPickerAnchorTop(200, 333, 900)).toBe(200)
|
||||
})
|
||||
|
||||
it('lifts an anchor whose popover would run off the bottom', () => {
|
||||
expect(clampWorktreeParentPickerAnchorTop(800, 333, 900)).toBe(900 - 12 - 333)
|
||||
})
|
||||
|
||||
it('keeps an anchor above the window from riding off the top', () => {
|
||||
expect(clampWorktreeParentPickerAnchorTop(-40, 333, 900)).toBe(12)
|
||||
})
|
||||
|
||||
it('pins to the top padding when the window is shorter than the popover', () => {
|
||||
expect(clampWorktreeParentPickerAnchorTop(120, 333, 300)).toBe(12)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clampWorktreeParentPickerIndex', () => {
|
||||
it('keeps the highlight inside the filtered result window', () => {
|
||||
expect(clampWorktreeParentPickerIndex(5, 3)).toBe(2)
|
||||
expect(clampWorktreeParentPickerIndex(-1, 3)).toBe(0)
|
||||
expect(clampWorktreeParentPickerIndex(1, 3)).toBe(1)
|
||||
})
|
||||
|
||||
it('collapses to zero when nothing matches', () => {
|
||||
expect(clampWorktreeParentPickerIndex(4, 0)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parent picker keyboard input', () => {
|
||||
it.each([
|
||||
{ isComposing: true, keyCode: 13 },
|
||||
{ isComposing: false, keyCode: 229 }
|
||||
])('leaves IME composition keys to the input method', (nativeEvent) => {
|
||||
const moveHighlight = vi.fn()
|
||||
const selectParent = vi.fn()
|
||||
const preventDefault = vi.fn()
|
||||
const stopPropagation = vi.fn()
|
||||
|
||||
handleWorktreeParentPickerKeyDown({
|
||||
event: {
|
||||
key: 'Enter',
|
||||
nativeEvent,
|
||||
preventDefault,
|
||||
stopPropagation
|
||||
} as unknown as React.KeyboardEvent<HTMLInputElement>,
|
||||
candidates: [{ id: 'parent' }],
|
||||
activeIndex: 0,
|
||||
moveHighlight,
|
||||
selectParent
|
||||
})
|
||||
|
||||
expect(moveHighlight).not.toHaveBeenCalled()
|
||||
expect(selectParent).not.toHaveBeenCalled()
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(stopPropagation).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,22 +1,36 @@
|
|||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { GitBranch, Server } from 'lucide-react'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from '@/components/ui/command'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { Command, CommandInput, CommandList } from '@/components/ui/command'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useAllWorktrees, useRepoMap, useWorktreeMap } from '@/store/selectors'
|
||||
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
|
||||
import { branchDisplayName } from './WorktreeCardHelpers'
|
||||
import { WorktreeActivityStatusIndicator } from './WorktreeActivityStatusIndicator'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
|
||||
import { useWorktreeActivityStatuses } from './use-worktree-activity-statuses'
|
||||
import { WorktreeParentPickerRow } from './WorktreeParentPickerRow'
|
||||
import { getEligibleWorktreeParents } from './worktree-parent-candidates'
|
||||
import {
|
||||
clampWorktreeParentPickerIndex,
|
||||
filterWorktreeParentCandidates
|
||||
} from './worktree-parent-picker-filtering'
|
||||
import {
|
||||
clampWorktreeParentPickerAnchorTop,
|
||||
estimateWorktreeParentPickerHeight,
|
||||
PICKER_ROW_HEIGHT,
|
||||
PICKER_ROW_OVERSCAN,
|
||||
PICKER_LIST_MAX_HEIGHT,
|
||||
PICKER_VIEWPORT_PADDING
|
||||
} from './worktree-parent-picker-placement'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
|
||||
type WorktreeParentPickerPopoverProps = {
|
||||
open: boolean
|
||||
|
|
@ -35,12 +49,16 @@ type SelectParentArgs = {
|
|||
showError: (message: string) => void
|
||||
}
|
||||
|
||||
function getAnchorRect(anchorElement: HTMLElement | null): AnchorRect | null {
|
||||
return anchorElement?.getBoundingClientRect() ?? null
|
||||
type WorktreeParentPickerKeyboardArgs = {
|
||||
event: React.KeyboardEvent<HTMLInputElement>
|
||||
candidates: readonly { id: string }[]
|
||||
activeIndex: number
|
||||
moveHighlight: (index: number) => void
|
||||
selectParent: (worktreeId: string) => void
|
||||
}
|
||||
|
||||
export function getWorktreeParentPickerItemValue(candidate: Worktree): string {
|
||||
return `${candidate.displayName} ${branchDisplayName(candidate.branch)} ${candidate.path}`
|
||||
function getAnchorRect(anchorElement: HTMLElement | null): AnchorRect | null {
|
||||
return anchorElement?.getBoundingClientRect() ?? null
|
||||
}
|
||||
|
||||
export function selectWorktreeParent({
|
||||
|
|
@ -65,42 +83,37 @@ export function selectWorktreeParent({
|
|||
})
|
||||
}
|
||||
|
||||
function WorktreeParentPickerRow({
|
||||
candidate,
|
||||
isCurrent
|
||||
}: {
|
||||
candidate: Worktree
|
||||
isCurrent: boolean
|
||||
}): React.JSX.Element {
|
||||
const repo = useRepoMap().get(candidate.repoId)
|
||||
const branch = branchDisplayName(candidate.branch)
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 items-start gap-2">
|
||||
<WorktreeActivityStatusIndicator worktreeId={candidate.id} className="mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate text-[13px] font-medium">{candidate.displayName}</span>
|
||||
{isCurrent ? (
|
||||
<span className="shrink-0 rounded border border-border bg-muted px-1.5 py-px text-[9px] font-medium leading-none text-muted-foreground">
|
||||
{translate('auto.components.sidebar.WorktreeParentPickerPopover.current', 'Current')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[11px] leading-none text-muted-foreground">
|
||||
{repo ? (
|
||||
<span className="inline-flex min-w-0 max-w-[8rem] shrink-0 items-center gap-1 rounded border border-border bg-accent px-1.5 py-0.5 text-[10px] font-semibold text-foreground">
|
||||
<RepoBadgeMark color={repo.badgeColor} />
|
||||
<span className="truncate lowercase">{repo.displayName}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{repo?.connectionId ? <Server className="size-3 shrink-0" /> : null}
|
||||
<GitBranch className="size-3 shrink-0" />
|
||||
<span className="truncate">{branch}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
export function handleWorktreeParentPickerKeyDown({
|
||||
event,
|
||||
candidates,
|
||||
activeIndex,
|
||||
moveHighlight,
|
||||
selectParent
|
||||
}: WorktreeParentPickerKeyboardArgs): void {
|
||||
if (isImeCompositionKeyDown(event) || candidates.length === 0) {
|
||||
return
|
||||
}
|
||||
const navigate = (nextIndex: number): void => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
moveHighlight(clampWorktreeParentPickerIndex(nextIndex, candidates.length))
|
||||
}
|
||||
if (event.key === 'ArrowDown') {
|
||||
navigate(activeIndex + 1)
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
navigate(activeIndex - 1)
|
||||
} else if (event.key === 'Home') {
|
||||
navigate(0)
|
||||
} else if (event.key === 'End') {
|
||||
navigate(candidates.length - 1)
|
||||
} else if (event.key === 'Enter') {
|
||||
const candidate = candidates[activeIndex]
|
||||
if (candidate) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
selectParent(candidate.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function WorktreeParentPickerPopover({
|
||||
|
|
@ -116,9 +129,15 @@ export function WorktreeParentPickerPopover({
|
|||
const lineageById = useAppStore((s) => s.worktreeLineageById)
|
||||
const assignWorktreeParent = useAppStore((s) => s.assignWorktreeParent)
|
||||
const suppressInitialOutsideCloseRef = useRef(false)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const optionIdPrefix = `${useId()}option`
|
||||
const [search, setSearch] = useState('')
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0)
|
||||
const [anchorRect, setAnchorRect] = useState<AnchorRect | null>(() =>
|
||||
getAnchorRect(anchorElement)
|
||||
)
|
||||
const [viewportHeight, setViewportHeight] = useState(() => window.innerHeight)
|
||||
const child = childWorktreeId ? worktreeMap.get(childWorktreeId) : undefined
|
||||
const candidates = useMemo(
|
||||
() =>
|
||||
|
|
@ -138,7 +157,10 @@ export function WorktreeParentPickerPopover({
|
|||
if (!open) {
|
||||
return
|
||||
}
|
||||
const updateAnchorRect = (): void => setAnchorRect(getAnchorRect(anchorElement))
|
||||
const updateAnchorRect = (): void => {
|
||||
setAnchorRect(getAnchorRect(anchorElement))
|
||||
setViewportHeight(window.innerHeight)
|
||||
}
|
||||
updateAnchorRect()
|
||||
window.addEventListener('resize', updateAnchorRect)
|
||||
window.addEventListener('scroll', updateAnchorRect, true)
|
||||
|
|
@ -175,63 +197,189 @@ export function WorktreeParentPickerPopover({
|
|||
[assignWorktreeParent, childWorktreeId, onOpenChange]
|
||||
)
|
||||
|
||||
// Why: a `position: fixed` anchor element would be laid out against the
|
||||
// sidebar's transformed virtual-row container, not the viewport, so its
|
||||
// viewport coordinates landed a full row-offset too low. A virtual anchor
|
||||
// renders no node and hands Radix the measured rect directly.
|
||||
const virtualAnchorRef = useMemo(() => {
|
||||
if (!anchorRect) {
|
||||
return undefined
|
||||
}
|
||||
const top = clampWorktreeParentPickerAnchorTop(
|
||||
anchorRect.top,
|
||||
// Why: measured from the full candidate list, not the filtered one, so
|
||||
// the popover does not jump around while the user types.
|
||||
estimateWorktreeParentPickerHeight(candidates.length),
|
||||
viewportHeight
|
||||
)
|
||||
const rect = new DOMRect(anchorRect.left, top, anchorRect.width, anchorRect.height)
|
||||
return { current: { getBoundingClientRect: () => rect } }
|
||||
}, [anchorRect, candidates.length, viewportHeight])
|
||||
|
||||
const filtered = useMemo(
|
||||
() => filterWorktreeParentCandidates(candidates, search),
|
||||
[candidates, search]
|
||||
)
|
||||
const activeIndex = clampWorktreeParentPickerIndex(highlightedIndex, filtered.length)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: filtered.length,
|
||||
getScrollElement: () => listRef.current,
|
||||
estimateSize: () => PICKER_ROW_HEIGHT,
|
||||
overscan: PICKER_ROW_OVERSCAN,
|
||||
getItemKey: (index) => filtered[index]?.id ?? index,
|
||||
// Why: the list mounts inside a popover that measures on the next frame, so
|
||||
// seed the viewport with max-h-72 to avoid a blank first paint.
|
||||
initialRect: { width: 0, height: PICKER_LIST_MAX_HEIGHT }
|
||||
})
|
||||
const handleSearchChange = useCallback(
|
||||
(nextSearch: string) => {
|
||||
// Why: re-ranking on each keystroke makes any prior highlight meaningless.
|
||||
setSearch(nextSearch)
|
||||
setHighlightedIndex(0)
|
||||
virtualizer.scrollToOffset(0)
|
||||
},
|
||||
[virtualizer]
|
||||
)
|
||||
|
||||
const virtualRows = virtualizer.getVirtualItems()
|
||||
// Why: the hook memoizes its store selector on this array's identity, so a
|
||||
// fresh array each render would rebuild the status map on every render.
|
||||
const visibleWorktreeIds = useMemo(
|
||||
() =>
|
||||
virtualRows
|
||||
.map((row) => filtered[row.index]?.id)
|
||||
.filter((id): id is string => id !== undefined),
|
||||
[filtered, virtualRows]
|
||||
)
|
||||
const statuses = useWorktreeActivityStatuses(visibleWorktreeIds)
|
||||
|
||||
const moveHighlight = useCallback(
|
||||
(nextIndex: number) => {
|
||||
setHighlightedIndex(nextIndex)
|
||||
virtualizer.scrollToIndex(nextIndex, { align: 'auto' })
|
||||
},
|
||||
[virtualizer]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// Why: cmdk's root key handler navigates the items it has mounted, which
|
||||
// is only the virtual window here — own the navigation instead and keep
|
||||
// the event from reaching it.
|
||||
handleWorktreeParentPickerKeyDown({
|
||||
event,
|
||||
candidates: filtered,
|
||||
activeIndex,
|
||||
moveHighlight,
|
||||
selectParent: handleSelect
|
||||
})
|
||||
},
|
||||
[activeIndex, filtered, handleSelect, moveHighlight]
|
||||
)
|
||||
|
||||
// Why: cmdk's Input owns aria-activedescendant and points it at its own item
|
||||
// registry, which is empty while we drive selection. Re-point it after every
|
||||
// commit so assistive tech still tracks the highlighted row.
|
||||
useEffect(() => {
|
||||
const input = inputRef.current
|
||||
if (!input) {
|
||||
return
|
||||
}
|
||||
const activeOptionId = filtered.length > 0 ? `${optionIdPrefix}-${activeIndex}` : null
|
||||
if (activeOptionId) {
|
||||
input.setAttribute('aria-activedescendant', activeOptionId)
|
||||
} else {
|
||||
input.removeAttribute('aria-activedescendant')
|
||||
}
|
||||
})
|
||||
|
||||
if (!child || !anchorRect) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverAnchor asChild>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed"
|
||||
style={{
|
||||
left: anchorRect.left,
|
||||
top: anchorRect.top,
|
||||
width: anchorRect.width,
|
||||
height: anchorRect.height
|
||||
}}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
<PopoverAnchor virtualRef={virtualAnchorRef} />
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
className="w-80 p-0"
|
||||
collisionPadding={PICKER_VIEWPORT_PADDING}
|
||||
className="flex max-h-(--radix-popover-content-available-height) w-80 flex-col p-0"
|
||||
onInteractOutside={(event) => {
|
||||
if (suppressInitialOutsideCloseRef.current) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Command>
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5 border-b border-border bg-muted/30 px-3 py-2 text-[11px] leading-none text-muted-foreground">
|
||||
<span className="shrink-0">
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeParentPickerPopover.setParentFor',
|
||||
'Set parent for'
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate font-medium text-foreground">{child.displayName}</span>
|
||||
</div>
|
||||
<Command shouldFilter={false} className="min-h-0">
|
||||
<CommandInput
|
||||
ref={inputRef}
|
||||
value={search}
|
||||
onValueChange={handleSearchChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
wrapperClassName="shrink-0"
|
||||
placeholder={translate(
|
||||
'auto.components.sidebar.WorktreeParentPickerPopover.searchPlaceholder',
|
||||
'Search worktrees...'
|
||||
)}
|
||||
autoFocus
|
||||
/>
|
||||
<CommandList className="max-h-72">
|
||||
<CommandEmpty>
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeParentPickerPopover.empty',
|
||||
'No matching eligible worktrees.'
|
||||
)}
|
||||
</CommandEmpty>
|
||||
{candidates.map((candidate) => (
|
||||
<CommandItem
|
||||
key={candidate.id}
|
||||
value={getWorktreeParentPickerItemValue(candidate)}
|
||||
onSelect={() => handleSelect(candidate.id)}
|
||||
className="items-start px-2 py-2"
|
||||
>
|
||||
<WorktreeParentPickerRow
|
||||
candidate={candidate}
|
||||
isCurrent={activeWorktreeId === candidate.id}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
<CommandList ref={listRef} className="max-h-72 min-h-0 flex-1">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeParentPickerPopover.empty',
|
||||
'No matching eligible worktrees.'
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const candidate = filtered[virtualRow.index]
|
||||
if (!candidate) {
|
||||
return null
|
||||
}
|
||||
const isHighlighted = virtualRow.index === activeIndex
|
||||
return (
|
||||
<div
|
||||
key={candidate.id}
|
||||
id={`${optionIdPrefix}-${virtualRow.index}`}
|
||||
role="option"
|
||||
aria-selected={isHighlighted}
|
||||
data-selected={isHighlighted || undefined}
|
||||
className={cn(
|
||||
'absolute left-0 top-0 flex w-full cursor-default select-none items-start gap-2 overflow-hidden rounded-sm px-2 py-2 text-sm outline-none',
|
||||
isHighlighted && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
style={{
|
||||
height: virtualRow.size,
|
||||
transform: `translateY(${virtualRow.start}px)`
|
||||
}}
|
||||
onPointerMove={() => setHighlightedIndex(virtualRow.index)}
|
||||
onClick={() => handleSelect(candidate.id)}
|
||||
>
|
||||
<WorktreeParentPickerRow
|
||||
candidate={candidate}
|
||||
repo={repoMap.get(candidate.repoId)}
|
||||
status={statuses.get(candidate.id) ?? 'inactive'}
|
||||
isCurrent={activeWorktreeId === candidate.id}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import React from 'react'
|
||||
import { GitBranch, Server } from 'lucide-react'
|
||||
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
|
||||
import { getWorktreeStatusLabel, type WorktreeStatus } from '@/lib/worktree-status'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { branchDisplayName } from './WorktreeCardHelpers'
|
||||
import StatusIndicator from './StatusIndicator'
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
|
||||
// Why: presentational and memoized — the picker resolves every row's status
|
||||
// from one batched store read so the rows themselves hold no subscriptions.
|
||||
export const WorktreeParentPickerRow = React.memo(function WorktreeParentPickerRow({
|
||||
candidate,
|
||||
repo,
|
||||
status,
|
||||
isCurrent
|
||||
}: {
|
||||
candidate: Worktree
|
||||
repo: Pick<Repo, 'badgeColor' | 'connectionId' | 'displayName'> | undefined
|
||||
status: WorktreeStatus
|
||||
isCurrent: boolean
|
||||
}): React.JSX.Element {
|
||||
const branch = branchDisplayName(candidate.branch)
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 items-start gap-2">
|
||||
<StatusIndicator status={status} aria-hidden="true" className="mt-0.5" />
|
||||
<span className="sr-only">{getWorktreeStatusLabel(status)}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate text-[13px] font-medium">{candidate.displayName}</span>
|
||||
{isCurrent ? (
|
||||
<span className="shrink-0 rounded border border-border bg-muted px-1.5 py-px text-[9px] font-medium leading-none text-muted-foreground">
|
||||
{translate('auto.components.sidebar.WorktreeParentPickerPopover.current', 'Current')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[11px] leading-none text-muted-foreground">
|
||||
{repo ? (
|
||||
<span className="inline-flex min-w-0 max-w-[8rem] shrink-0 items-center gap-1 rounded border border-border bg-accent px-1.5 py-0.5 text-[10px] font-semibold text-foreground">
|
||||
<RepoBadgeMark color={repo.badgeColor} />
|
||||
<span className="truncate lowercase">{repo.displayName}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{repo?.connectionId ? <Server className="size-3 shrink-0" /> : null}
|
||||
<GitBranch className="size-3 shrink-0" />
|
||||
<span className="truncate">{branch}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { shallow } from 'zustand/shallow'
|
||||
import { selectWorktreeActivityStatuses } from './use-worktree-activity-statuses'
|
||||
|
||||
type StatusState = Parameters<typeof selectWorktreeActivityStatuses>[0]
|
||||
|
||||
function makeStatusState(): StatusState {
|
||||
return {
|
||||
tabsByWorktree: {},
|
||||
browserTabsByWorktree: {},
|
||||
runtimePaneTitlesByTabId: {},
|
||||
ptyIdsByTabId: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
agentStatusEpoch: 0,
|
||||
agentStatusByPaneKey: {},
|
||||
migrationUnsupportedByPtyId: {},
|
||||
retainedAgentsByPaneKey: {},
|
||||
runtimeAgentOrchestrationByPaneKey: {}
|
||||
}
|
||||
}
|
||||
|
||||
describe('selectWorktreeActivityStatuses', () => {
|
||||
it('stays shallow-equal when an unrelated worktree receives activity updates', () => {
|
||||
const state = makeStatusState()
|
||||
const unrelatedUpdate: StatusState = {
|
||||
...state,
|
||||
agentStatusEpoch: 1,
|
||||
browserTabsByWorktree: {
|
||||
other: []
|
||||
},
|
||||
runtimePaneTitlesByTabId: {
|
||||
'other-tab': { 0: 'codex [working]' }
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
'other-tab': ['other-pty']
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
shallow(
|
||||
selectWorktreeActivityStatuses(state, ['visible']),
|
||||
selectWorktreeActivityStatuses(unrelatedUpdate, ['visible'])
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { useCallback } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useAppStore, type AppState } from '@/store'
|
||||
import { resolveWorktreeStatus, type WorktreeStatus } from '@/lib/worktree-status'
|
||||
import { EMPTY_BROWSER_TABS, EMPTY_TABS } from './WorktreeCardHelpers'
|
||||
import {
|
||||
selectLivePtyIdsForWorktree,
|
||||
selectTerminalLayoutRootsForWorktree,
|
||||
selectRuntimePaneTitlesForWorktree
|
||||
} from './worktree-card-status-inputs'
|
||||
import { selectWorktreeAgentActivitySummary } from './worktree-agent-activity-summary'
|
||||
|
||||
type WorktreeActivityStatusState = Pick<
|
||||
AppState,
|
||||
| 'tabsByWorktree'
|
||||
| 'browserTabsByWorktree'
|
||||
| 'runtimePaneTitlesByTabId'
|
||||
| 'ptyIdsByTabId'
|
||||
| 'terminalLayoutsByTabId'
|
||||
| 'agentStatusEpoch'
|
||||
| 'agentStatusByPaneKey'
|
||||
| 'migrationUnsupportedByPtyId'
|
||||
| 'retainedAgentsByPaneKey'
|
||||
| 'runtimeAgentOrchestrationByPaneKey'
|
||||
>
|
||||
|
||||
export function selectWorktreeActivityStatuses(
|
||||
statusInputs: WorktreeActivityStatusState,
|
||||
worktreeIds: readonly string[]
|
||||
): Map<string, WorktreeStatus> {
|
||||
const statuses = new Map<string, WorktreeStatus>()
|
||||
for (const worktreeId of worktreeIds) {
|
||||
const {
|
||||
hasPermission,
|
||||
hasLiveWorking,
|
||||
hasLiveDone,
|
||||
hasRetainedDone,
|
||||
agentStatusPaneIdsByTabId
|
||||
} = selectWorktreeAgentActivitySummary(statusInputs, worktreeId)
|
||||
statuses.set(
|
||||
worktreeId,
|
||||
resolveWorktreeStatus({
|
||||
tabs: statusInputs.tabsByWorktree[worktreeId] ?? EMPTY_TABS,
|
||||
browserTabs: statusInputs.browserTabsByWorktree[worktreeId] ?? EMPTY_BROWSER_TABS,
|
||||
ptyIdsByTabId: selectLivePtyIdsForWorktree(statusInputs, worktreeId),
|
||||
runtimePaneTitlesByTabId: selectRuntimePaneTitlesForWorktree(statusInputs, worktreeId),
|
||||
agentStatusPaneIdsByTabId,
|
||||
terminalLayoutRootsByTabId: selectTerminalLayoutRootsForWorktree(statusInputs, worktreeId),
|
||||
hasPermission,
|
||||
hasLiveWorking,
|
||||
hasLiveDone,
|
||||
hasRetainedDone
|
||||
})
|
||||
)
|
||||
}
|
||||
return statuses
|
||||
}
|
||||
|
||||
// Why: return a shallow-stable status map so terminal updates outside the
|
||||
// visible candidates do not re-render the picker.
|
||||
export function useWorktreeActivityStatuses(
|
||||
worktreeIds: readonly string[]
|
||||
): Map<string, WorktreeStatus> {
|
||||
const selectStatuses = useCallback(
|
||||
(state: WorktreeActivityStatusState) => selectWorktreeActivityStatuses(state, worktreeIds),
|
||||
[worktreeIds]
|
||||
)
|
||||
return useAppStore(useShallow(selectStatuses))
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { defaultFilter } from 'cmdk'
|
||||
import { branchDisplayName } from './WorktreeCardHelpers'
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
|
||||
export function getWorktreeParentPickerItemValue(candidate: Worktree): string {
|
||||
return `${candidate.displayName} ${branchDisplayName(candidate.branch)} ${candidate.path}`
|
||||
}
|
||||
|
||||
// Why: a repo with hundreds of workspaces makes every eligible sibling a
|
||||
// candidate, so the list is scored here rather than handed to cmdk — cmdk
|
||||
// mounts, scores and DOM-reorders every registered item on each keystroke.
|
||||
export function filterWorktreeParentCandidates(
|
||||
candidates: readonly Worktree[],
|
||||
search: string
|
||||
): Worktree[] {
|
||||
const query = search.trim()
|
||||
if (!query) {
|
||||
return [...candidates]
|
||||
}
|
||||
return candidates
|
||||
.map((candidate) => ({
|
||||
candidate,
|
||||
score: defaultFilter(getWorktreeParentPickerItemValue(candidate), query, [])
|
||||
}))
|
||||
.filter((scored) => scored.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map((scored) => scored.candidate)
|
||||
}
|
||||
|
||||
export function clampWorktreeParentPickerIndex(index: number, count: number): number {
|
||||
if (count <= 0) {
|
||||
return 0
|
||||
}
|
||||
return Math.min(Math.max(index, 0), count - 1)
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
export const PICKER_ROW_HEIGHT = 56
|
||||
export const PICKER_ROW_OVERSCAN = 6
|
||||
export const PICKER_LIST_MAX_HEIGHT = 288
|
||||
export const PICKER_VIEWPORT_PADDING = 12
|
||||
// Why: input is h-10 inside a py-1 wrapper over a 1px border.
|
||||
const PICKER_INPUT_HEIGHT = 49
|
||||
// Why: 11px leading-none text in a py-2 row over a 1px border.
|
||||
const PICKER_HEADER_HEIGHT = 28
|
||||
// Why: the popover surface adds its own 1px border top and bottom.
|
||||
const PICKER_SURFACE_BORDER_HEIGHT = 2
|
||||
|
||||
export function estimateWorktreeParentPickerHeight(candidateCount: number): number {
|
||||
const listHeight = Math.min(
|
||||
Math.max(candidateCount, 1) * PICKER_ROW_HEIGHT,
|
||||
PICKER_LIST_MAX_HEIGHT
|
||||
)
|
||||
return PICKER_SURFACE_BORDER_HEIGHT + PICKER_HEADER_HEIGHT + PICKER_INPUT_HEIGHT + listHeight
|
||||
}
|
||||
|
||||
// Why: Radix shifts a side="right" popover along its main (horizontal) axis
|
||||
// only — `crossAxis: false` — and `flip` only swaps left/right, so nothing
|
||||
// corrects vertical overflow and a card low in the sidebar puts the list below
|
||||
// the window. The anchor is virtual here, so lift it until the list fits.
|
||||
export function clampWorktreeParentPickerAnchorTop(
|
||||
anchorTop: number,
|
||||
contentHeight: number,
|
||||
viewportHeight: number
|
||||
): number {
|
||||
const maxTop = viewportHeight - PICKER_VIEWPORT_PADDING - contentHeight
|
||||
if (maxTop <= PICKER_VIEWPORT_PADDING) {
|
||||
return PICKER_VIEWPORT_PADDING
|
||||
}
|
||||
return Math.min(Math.max(anchorTop, PICKER_VIEWPORT_PADDING), maxTop)
|
||||
}
|
||||
|
|
@ -5015,7 +5015,8 @@
|
|||
"failedSetParent": "Failed to set parent worktree",
|
||||
"current": "Current",
|
||||
"searchPlaceholder": "Search worktrees...",
|
||||
"empty": "No matching eligible worktrees."
|
||||
"empty": "No matching eligible worktrees.",
|
||||
"setParentFor": "Set parent for"
|
||||
},
|
||||
"WorktreeCardStatusSlot": {
|
||||
"branchIdentity": "Branch"
|
||||
|
|
|
|||
|
|
@ -4944,7 +4944,8 @@
|
|||
"failedSetParent": "No se pudo establecer el worktree padre",
|
||||
"current": "Actual",
|
||||
"searchPlaceholder": "Buscar worktrees...",
|
||||
"empty": "No hay worktrees elegibles que coincidan."
|
||||
"empty": "No hay worktrees elegibles que coincidan.",
|
||||
"setParentFor": "Establecer padre para"
|
||||
},
|
||||
"WorktreeCardStatusSlot": {
|
||||
"branchIdentity": "Rama"
|
||||
|
|
|
|||
|
|
@ -4944,7 +4944,8 @@
|
|||
"failedSetParent": "親ワークツリーを設定できませんでした",
|
||||
"current": "現在",
|
||||
"searchPlaceholder": "ワークツリーを検索...",
|
||||
"empty": "一致する対象ワークツリーがありません。"
|
||||
"empty": "一致する対象ワークツリーがありません。",
|
||||
"setParentFor": "次のワークツリーの親を設定"
|
||||
},
|
||||
"WorktreeCardStatusSlot": {
|
||||
"branchIdentity": "ブランチ"
|
||||
|
|
|
|||
|
|
@ -4944,7 +4944,8 @@
|
|||
"failedSetParent": "상위 워크트리를 설정하지 못했습니다",
|
||||
"current": "현재",
|
||||
"searchPlaceholder": "워크트리 검색...",
|
||||
"empty": "일치하는 적격 워크트리가 없습니다."
|
||||
"empty": "일치하는 적격 워크트리가 없습니다.",
|
||||
"setParentFor": "다음 워크트리의 상위 설정"
|
||||
},
|
||||
"WorktreeCardStatusSlot": {
|
||||
"branchIdentity": "브랜치"
|
||||
|
|
|
|||
|
|
@ -4956,7 +4956,8 @@
|
|||
"failedSetParent": "无法设置父工作树",
|
||||
"current": "当前",
|
||||
"searchPlaceholder": "搜索工作树...",
|
||||
"empty": "没有匹配的可用工作树。"
|
||||
"empty": "没有匹配的可用工作树。",
|
||||
"setParentFor": "为以下工作树设置父级"
|
||||
},
|
||||
"WorktreeCardStatusSlot": {
|
||||
"branchIdentity": "分支"
|
||||
|
|
|
|||
Loading…
Reference in New Issue