diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 152df1bf3e5..72391f714fc 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,11 +1,12 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; -import { ChevronDownIcon } from "lucide-react"; +import { ChevronDownIcon, GitBranchIcon, SearchIcon } from "lucide-react"; import { useCallback, useDeferredValue, useEffect, + useLayoutEffect, useMemo, useOptimistic, useRef, @@ -37,7 +38,6 @@ import { ComboboxEmpty, ComboboxInput, ComboboxItem, - ComboboxList, ComboboxListVirtualized, ComboboxPopup, ComboboxStatus, @@ -281,7 +281,6 @@ export function BranchToolbarBranchSelector({ (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch, ); const [isBranchActionPending, startBranchActionTransition] = useTransition(); - const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; const totalBranchCount = branchRefState.data?.totalCount ?? 0; const branchStatusText = isInitialBranchesLoadPending ? "Loading refs..." @@ -421,7 +420,9 @@ export function BranchToolbarBranchSelector({ [branchRefTarget], ); - const branchListScrollElementRef = useRef(null); + const branchListScrollElementRef = useRef(null); + const [showTopBranchScrollFade, setShowTopBranchScrollFade] = useState(false); + const [showBottomBranchScrollFade, setShowBottomBranchScrollFade] = useState(false); const fetchNextBranchPage = useCallback(() => { if (!hasNextPage || isFetchingNextPage) { return; @@ -451,44 +452,61 @@ export function BranchToolbarBranchSelector({ fetchNextBranchPage(); }, [fetchNextBranchPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]); + const branchListRef = useRef(null); - const setBranchListRef = useCallback((element: HTMLDivElement | null) => { - branchListScrollElementRef.current = (element?.parentElement as HTMLDivElement | null) ?? null; + const updateBranchListScrollFades = useCallback(() => { + const scrollElement = branchListRef.current?.getScrollableNode?.(); + if (!(scrollElement instanceof HTMLElement)) { + return; + } + branchListScrollElementRef.current = scrollElement; + const maxScrollOffset = Math.max(0, scrollElement.scrollHeight - scrollElement.clientHeight); + setShowTopBranchScrollFade(scrollElement.scrollTop > 1); + setShowBottomBranchScrollFade(maxScrollOffset - scrollElement.scrollTop > 1); }, []); useEffect(() => { - if (!isBranchMenuOpen) { + if (isBranchMenuOpen) { return; } + setShowTopBranchScrollFade(false); + setShowBottomBranchScrollFade(false); + }, [isBranchMenuOpen]); - if (shouldVirtualizeBranchList) { - branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false }); - } else { - branchListScrollElementRef.current?.scrollTo({ top: 0 }); + useLayoutEffect(() => { + if (!isBranchMenuOpen) { + return; } - }, [deferredTrimmedBranchQuery, isBranchMenuOpen, shouldVirtualizeBranchList]); + + setShowTopBranchScrollFade(false); + setShowBottomBranchScrollFade(filteredBranchPickerItems.length > 8); + let nestedFrame = 0; + const frame = requestAnimationFrame(() => { + updateBranchListScrollFades(); + nestedFrame = requestAnimationFrame(updateBranchListScrollFades); + }); + return () => { + cancelAnimationFrame(frame); + cancelAnimationFrame(nestedFrame); + }; + }, [ + deferredTrimmedBranchQuery, + filteredBranchPickerItems.length, + isBranchMenuOpen, + updateBranchListScrollFades, + ]); useEffect(() => { - const scrollElement = branchListScrollElementRef.current; - if (!scrollElement || !isBranchMenuOpen) { + if (!isBranchMenuOpen) { return; } - const handleScroll = () => { - maybeFetchNextBranchPage(); - }; - - scrollElement.addEventListener("scroll", handleScroll, { passive: true }); - handleScroll(); - return () => { - scrollElement.removeEventListener("scroll", handleScroll); - }; - }, [isBranchMenuOpen, maybeFetchNextBranchPage]); + branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false }); + }, [deferredTrimmedBranchQuery, isBranchMenuOpen]); useEffect(() => { - if (shouldVirtualizeBranchList) return; maybeFetchNextBranchPage(); - }, [refs.length, maybeFetchNextBranchPage, shouldVirtualizeBranchList]); + }, [refs.length, maybeFetchNextBranchPage]); const triggerLabel = getBranchTriggerLabel({ activeWorktreePath, @@ -504,6 +522,7 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} + className="pe-2" onClick={() => { if (!prReference || !onCheckoutPullRequestRequest) { return; @@ -533,6 +552,7 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} + className="pe-1.5" onClick={() => createRef(trimmedBranchQuery)} > Create new ref "{trimmedBranchQuery}" @@ -560,10 +580,11 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} + className="pe-1.5" onClick={() => selectBranch(refName)} > -
- {itemValue} +
+ {itemValue} {badge && {badge}}
@@ -575,7 +596,7 @@ export function BranchToolbarBranchSelector({ items={branchPickerItems} filteredItems={filteredBranchPickerItems} autoHighlight - virtualized={shouldVirtualizeBranchList} + virtualized onItemHighlighted={(_value, eventDetails) => { if (!isBranchMenuOpen || eventDetails.index < 0 || eventDetails.reason !== "keyboard") { return; @@ -594,48 +615,64 @@ export function BranchToolbarBranchSelector({ className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)} disabled={isInitialBranchesLoadPending || isBranchActionPending} > + {triggerLabel} - + - -
- setBranchQuery(event.target.value)} - /> -
- No refs found. - - {shouldVirtualizeBranchList ? ( - - - ref={branchListRef} - data={filteredBranchPickerItems} - keyExtractor={(item) => item} - renderItem={({ item, index }) => renderPickerItem(item, index)} - estimatedItemSize={28} - drawDistance={336} - onEndReached={() => { - if (hasNextPage && !isFetchingNextPage) { - fetchNextBranchPage(); - } - }} - style={{ maxHeight: "14rem" }} + +
+
+
+
+
+ No refs found. +
+ + + ref={branchListRef} + data={filteredBranchPickerItems} + keyExtractor={(item) => item} + renderItem={({ item, index }) => renderPickerItem(item, index)} + estimatedItemSize={28} + drawDistance={336} + onEndReached={() => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextBranchPage(); + } + }} + onLayout={() => { + updateBranchListScrollFades(); + maybeFetchNextBranchPage(); + }} + onScroll={() => { + updateBranchListScrollFades(); + maybeFetchNextBranchPage(); + }} + className={cn( + "scrollbar-gutter-stable overflow-x-hidden overscroll-y-contain ps-1 pe-0 pt-2 pb-1 [--fade-size:1.5rem]", + showTopBranchScrollFade && "mask-t-from-[calc(100%-var(--fade-size))]", + showBottomBranchScrollFade && "mask-b-from-[calc(100%-var(--fade-size))]", + )} + style={{ maxHeight: "14rem" }} + /> + +
+ {branchStatusText ? {branchStatusText} : null} +
);