diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index 74b6aba63..ea77889e6 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -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(null) + const parentPickerUnmountTimerRef = useRef(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} /> - { - 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 ? ( + + ) : null} ) }) diff --git a/src/renderer/src/components/sidebar/WorktreeParentPickerPopover.test.ts b/src/renderer/src/components/sidebar/WorktreeParentPickerPopover.test.ts index fac92ba8f..ff761bbc2 100644 --- a/src/renderer/src/components/sidebar/WorktreeParentPickerPopover.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeParentPickerPopover.test.ts @@ -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, + candidates: [{ id: 'parent' }], + activeIndex: 0, + moveHighlight, + selectParent + }) + + expect(moveHighlight).not.toHaveBeenCalled() + expect(selectParent).not.toHaveBeenCalled() + expect(preventDefault).not.toHaveBeenCalled() + expect(stopPropagation).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeParentPickerPopover.tsx b/src/renderer/src/components/sidebar/WorktreeParentPickerPopover.tsx index a7cda5b6d..49af0e99e 100644 --- a/src/renderer/src/components/sidebar/WorktreeParentPickerPopover.tsx +++ b/src/renderer/src/components/sidebar/WorktreeParentPickerPopover.tsx @@ -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 + 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 ( -
- -
-
- {candidate.displayName} - {isCurrent ? ( - - {translate('auto.components.sidebar.WorktreeParentPickerPopover.current', 'Current')} - - ) : null} -
-
- {repo ? ( - - - {repo.displayName} - - ) : null} - {repo?.connectionId ? : null} - - {branch} -
-
-
- ) +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(null) + const inputRef = useRef(null) + const optionIdPrefix = `${useId()}option` + const [search, setSearch] = useState('') + const [highlightedIndex, setHighlightedIndex] = useState(0) const [anchorRect, setAnchorRect] = useState(() => 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) => { + // 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 ( - - - + { if (suppressInitialOutsideCloseRef.current) { event.preventDefault() } }} > - +
+ + {translate( + 'auto.components.sidebar.WorktreeParentPickerPopover.setParentFor', + 'Set parent for' + )} + + {child.displayName} +
+ - - - {translate( - 'auto.components.sidebar.WorktreeParentPickerPopover.empty', - 'No matching eligible worktrees.' - )} - - {candidates.map((candidate) => ( - handleSelect(candidate.id)} - className="items-start px-2 py-2" - > - - - ))} + + {filtered.length === 0 ? ( +
+ {translate( + 'auto.components.sidebar.WorktreeParentPickerPopover.empty', + 'No matching eligible worktrees.' + )} +
+ ) : ( +
+ {virtualRows.map((virtualRow) => { + const candidate = filtered[virtualRow.index] + if (!candidate) { + return null + } + const isHighlighted = virtualRow.index === activeIndex + return ( +
setHighlightedIndex(virtualRow.index)} + onClick={() => handleSelect(candidate.id)} + > + +
+ ) + })} +
+ )}
diff --git a/src/renderer/src/components/sidebar/WorktreeParentPickerRow.tsx b/src/renderer/src/components/sidebar/WorktreeParentPickerRow.tsx new file mode 100644 index 000000000..1d78102cf --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeParentPickerRow.tsx @@ -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 | undefined + status: WorktreeStatus + isCurrent: boolean +}): React.JSX.Element { + const branch = branchDisplayName(candidate.branch) + + return ( +
+
+ ) +}) diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-statuses.test.ts b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.test.ts new file mode 100644 index 000000000..c9b5092f7 --- /dev/null +++ b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.test.ts @@ -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[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) + }) +}) diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts new file mode 100644 index 000000000..4b1216d71 --- /dev/null +++ b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts @@ -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 { + const statuses = new Map() + 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 { + const selectStatuses = useCallback( + (state: WorktreeActivityStatusState) => selectWorktreeActivityStatuses(state, worktreeIds), + [worktreeIds] + ) + return useAppStore(useShallow(selectStatuses)) +} diff --git a/src/renderer/src/components/sidebar/worktree-parent-picker-filtering.ts b/src/renderer/src/components/sidebar/worktree-parent-picker-filtering.ts new file mode 100644 index 000000000..3c07c35b1 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-parent-picker-filtering.ts @@ -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) +} diff --git a/src/renderer/src/components/sidebar/worktree-parent-picker-placement.ts b/src/renderer/src/components/sidebar/worktree-parent-picker-placement.ts new file mode 100644 index 000000000..dd5c6741f --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-parent-picker-placement.ts @@ -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) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ad63afa7a..4276272e7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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" diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index cef83e8bd..01210fbd7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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" diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 5610ec1d7..46170810b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4944,7 +4944,8 @@ "failedSetParent": "親ワークツリーを設定できませんでした", "current": "現在", "searchPlaceholder": "ワークツリーを検索...", - "empty": "一致する対象ワークツリーがありません。" + "empty": "一致する対象ワークツリーがありません。", + "setParentFor": "次のワークツリーの親を設定" }, "WorktreeCardStatusSlot": { "branchIdentity": "ブランチ" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 31c001734..524efb4a6 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4944,7 +4944,8 @@ "failedSetParent": "상위 워크트리를 설정하지 못했습니다", "current": "현재", "searchPlaceholder": "워크트리 검색...", - "empty": "일치하는 적격 워크트리가 없습니다." + "empty": "일치하는 적격 워크트리가 없습니다.", + "setParentFor": "다음 워크트리의 상위 설정" }, "WorktreeCardStatusSlot": { "branchIdentity": "브랜치" diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 647fea043..b8a16a687 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4956,7 +4956,8 @@ "failedSetParent": "无法设置父工作树", "current": "当前", "searchPlaceholder": "搜索工作树...", - "empty": "没有匹配的可用工作树。" + "empty": "没有匹配的可用工作树。", + "setParentFor": "为以下工作树设置父级" }, "WorktreeCardStatusSlot": { "branchIdentity": "分支"