11import { scopeProjectRef , scopeThreadRef } from "@t3tools/client-runtime" ;
22import type { EnvironmentId , VcsRef , ThreadId } from "@t3tools/contracts" ;
33import { LegendList , type LegendListRef } from "@legendapp/list/react" ;
4- import { ChevronDownIcon } from "lucide-react" ;
4+ import { ChevronDownIcon , GitBranchIcon , SearchIcon } from "lucide-react" ;
55import {
66 useCallback ,
77 useDeferredValue ,
88 useEffect ,
9+ useLayoutEffect ,
910 useMemo ,
1011 useOptimistic ,
1112 useRef ,
@@ -37,7 +38,6 @@ import {
3738 ComboboxEmpty ,
3839 ComboboxInput ,
3940 ComboboxItem ,
40- ComboboxList ,
4141 ComboboxListVirtualized ,
4242 ComboboxPopup ,
4343 ComboboxStatus ,
@@ -281,7 +281,6 @@ export function BranchToolbarBranchSelector({
281281 ( _currentBranch : string | null , optimisticBranch : string | null ) => optimisticBranch ,
282282 ) ;
283283 const [ isBranchActionPending , startBranchActionTransition ] = useTransition ( ) ;
284- const shouldVirtualizeBranchList = filteredBranchPickerItems . length > 40 ;
285284 const totalBranchCount = branchRefState . data ?. totalCount ?? 0 ;
286285 const branchStatusText = isInitialBranchesLoadPending
287286 ? "Loading refs..."
@@ -421,7 +420,9 @@ export function BranchToolbarBranchSelector({
421420 [ branchRefTarget ] ,
422421 ) ;
423422
424- const branchListScrollElementRef = useRef < HTMLDivElement | null > ( null ) ;
423+ const branchListScrollElementRef = useRef < HTMLElement | null > ( null ) ;
424+ const [ showTopBranchScrollFade , setShowTopBranchScrollFade ] = useState ( false ) ;
425+ const [ showBottomBranchScrollFade , setShowBottomBranchScrollFade ] = useState ( false ) ;
425426 const fetchNextBranchPage = useCallback ( ( ) => {
426427 if ( ! hasNextPage || isFetchingNextPage ) {
427428 return ;
@@ -451,44 +452,61 @@ export function BranchToolbarBranchSelector({
451452
452453 fetchNextBranchPage ( ) ;
453454 } , [ fetchNextBranchPage , hasNextPage , isBranchMenuOpen , isFetchingNextPage ] ) ;
455+
454456 const branchListRef = useRef < LegendListRef | null > ( null ) ;
455- const setBranchListRef = useCallback ( ( element : HTMLDivElement | null ) => {
456- branchListScrollElementRef . current = ( element ?. parentElement as HTMLDivElement | null ) ?? null ;
457+ const updateBranchListScrollFades = useCallback ( ( ) => {
458+ const scrollElement = branchListRef . current ?. getScrollableNode ?.( ) ;
459+ if ( ! ( scrollElement instanceof HTMLElement ) ) {
460+ return ;
461+ }
462+ branchListScrollElementRef . current = scrollElement ;
463+ const maxScrollOffset = Math . max ( 0 , scrollElement . scrollHeight - scrollElement . clientHeight ) ;
464+ setShowTopBranchScrollFade ( scrollElement . scrollTop > 1 ) ;
465+ setShowBottomBranchScrollFade ( maxScrollOffset - scrollElement . scrollTop > 1 ) ;
457466 } , [ ] ) ;
458467
459468 useEffect ( ( ) => {
460- if ( ! isBranchMenuOpen ) {
469+ if ( isBranchMenuOpen ) {
461470 return ;
462471 }
472+ setShowTopBranchScrollFade ( false ) ;
473+ setShowBottomBranchScrollFade ( false ) ;
474+ } , [ isBranchMenuOpen ] ) ;
463475
464- if ( shouldVirtualizeBranchList ) {
465- branchListRef . current ?. scrollToOffset ?.( { offset : 0 , animated : false } ) ;
466- } else {
467- branchListScrollElementRef . current ?. scrollTo ( { top : 0 } ) ;
476+ useLayoutEffect ( ( ) => {
477+ if ( ! isBranchMenuOpen ) {
478+ return ;
468479 }
469- } , [ deferredTrimmedBranchQuery , isBranchMenuOpen , shouldVirtualizeBranchList ] ) ;
480+
481+ setShowTopBranchScrollFade ( false ) ;
482+ setShowBottomBranchScrollFade ( filteredBranchPickerItems . length > 8 ) ;
483+ let nestedFrame = 0 ;
484+ const frame = requestAnimationFrame ( ( ) => {
485+ updateBranchListScrollFades ( ) ;
486+ nestedFrame = requestAnimationFrame ( updateBranchListScrollFades ) ;
487+ } ) ;
488+ return ( ) => {
489+ cancelAnimationFrame ( frame ) ;
490+ cancelAnimationFrame ( nestedFrame ) ;
491+ } ;
492+ } , [
493+ deferredTrimmedBranchQuery ,
494+ filteredBranchPickerItems . length ,
495+ isBranchMenuOpen ,
496+ updateBranchListScrollFades ,
497+ ] ) ;
470498
471499 useEffect ( ( ) => {
472- const scrollElement = branchListScrollElementRef . current ;
473- if ( ! scrollElement || ! isBranchMenuOpen ) {
500+ if ( ! isBranchMenuOpen ) {
474501 return ;
475502 }
476503
477- const handleScroll = ( ) => {
478- maybeFetchNextBranchPage ( ) ;
479- } ;
480-
481- scrollElement . addEventListener ( "scroll" , handleScroll , { passive : true } ) ;
482- handleScroll ( ) ;
483- return ( ) => {
484- scrollElement . removeEventListener ( "scroll" , handleScroll ) ;
485- } ;
486- } , [ isBranchMenuOpen , maybeFetchNextBranchPage ] ) ;
504+ branchListRef . current ?. scrollToOffset ?.( { offset : 0 , animated : false } ) ;
505+ } , [ deferredTrimmedBranchQuery , isBranchMenuOpen ] ) ;
487506
488507 useEffect ( ( ) => {
489- if ( shouldVirtualizeBranchList ) return ;
490508 maybeFetchNextBranchPage ( ) ;
491- } , [ refs . length , maybeFetchNextBranchPage , shouldVirtualizeBranchList ] ) ;
509+ } , [ refs . length , maybeFetchNextBranchPage ] ) ;
492510
493511 const triggerLabel = getBranchTriggerLabel ( {
494512 activeWorktreePath,
@@ -504,6 +522,7 @@ export function BranchToolbarBranchSelector({
504522 key = { itemValue }
505523 index = { index }
506524 value = { itemValue }
525+ className = "pe-2"
507526 onClick = { ( ) => {
508527 if ( ! prReference || ! onCheckoutPullRequestRequest ) {
509528 return ;
@@ -533,6 +552,7 @@ export function BranchToolbarBranchSelector({
533552 key = { itemValue }
534553 index = { index }
535554 value = { itemValue }
555+ className = "pe-1.5"
536556 onClick = { ( ) => createRef ( trimmedBranchQuery ) }
537557 >
538558 < span className = "truncate" > Create new ref "{ trimmedBranchQuery } "</ span >
@@ -560,10 +580,11 @@ export function BranchToolbarBranchSelector({
560580 key = { itemValue }
561581 index = { index }
562582 value = { itemValue }
583+ className = "pe-1.5"
563584 onClick = { ( ) => selectBranch ( refName ) }
564585 >
565- < div className = "flex w-full items-center justify-between gap-2" >
566- < span className = "truncate" > { itemValue } </ span >
586+ < div className = "flex w-full min-w-0 items-center justify-between gap-2" >
587+ < span className = "min-w-0 flex-1 truncate" > { itemValue } </ span >
567588 { badge && < span className = "shrink-0 text-[10px] text-muted-foreground/45" > { badge } </ span > }
568589 </ div >
569590 </ ComboboxItem >
@@ -575,7 +596,7 @@ export function BranchToolbarBranchSelector({
575596 items = { branchPickerItems }
576597 filteredItems = { filteredBranchPickerItems }
577598 autoHighlight
578- virtualized = { shouldVirtualizeBranchList }
599+ virtualized
579600 onItemHighlighted = { ( _value , eventDetails ) => {
580601 if ( ! isBranchMenuOpen || eventDetails . index < 0 || eventDetails . reason !== "keyboard" ) {
581602 return ;
@@ -594,48 +615,64 @@ export function BranchToolbarBranchSelector({
594615 className = { cn ( "min-w-0 text-muted-foreground/70 hover:text-foreground/80" , className ) }
595616 disabled = { isInitialBranchesLoadPending || isBranchActionPending }
596617 >
618+ < GitBranchIcon className = "size-3 shrink-0 opacity-70" />
597619 < span className = "min-w-0 max-w-[240px] truncate" > { triggerLabel } </ span >
598- < ChevronDownIcon className = "shrink-0" />
620+ < ChevronDownIcon className = "size-3 shrink-0 opacity-50 " />
599621 </ ComboboxTrigger >
600- < ComboboxPopup align = "end" side = "top" className = "w-80" >
601- < div className = "border-b p-1" >
602- < ComboboxInput
603- className = "[&_input]:font-sans rounded-md"
604- inputClassName = "ring-0"
605- placeholder = "Search refs..."
606- showTrigger = { false }
607- size = "sm"
608- value = { branchQuery }
609- onChange = { ( event ) => setBranchQuery ( event . target . value ) }
610- />
611- </ div >
612- < ComboboxEmpty > No refs found.</ ComboboxEmpty >
613-
614- { shouldVirtualizeBranchList ? (
615- < ComboboxListVirtualized >
616- < LegendList < string >
617- ref = { branchListRef }
618- data = { filteredBranchPickerItems }
619- keyExtractor = { ( item ) => item }
620- renderItem = { ( { item, index } ) => renderPickerItem ( item , index ) }
621- estimatedItemSize = { 28 }
622- drawDistance = { 336 }
623- onEndReached = { ( ) => {
624- if ( hasNextPage && ! isFetchingNextPage ) {
625- fetchNextBranchPage ( ) ;
626- }
627- } }
628- style = { { maxHeight : "14rem" } }
622+ < ComboboxPopup align = "end" side = "top" className = "flex w-80 flex-col" >
623+ < div className = "shrink-0 px-3 pt-2.5" >
624+ < div className = "relative -translate-y-px border-b border-border/70 pb-1.5 transition-colors focus-within:border-ring" >
625+ < SearchIcon
626+ aria-hidden = "true"
627+ className = "pointer-events-none absolute top-1.5 left-0 size-4 shrink-0 text-muted-foreground/55"
629628 />
630- </ ComboboxListVirtualized >
631- ) : (
632- < ComboboxList ref = { setBranchListRef } className = "max-h-56" >
633- { filteredBranchPickerItems . map ( ( itemValue , index ) =>
634- renderPickerItem ( itemValue , index ) ,
635- ) }
636- </ ComboboxList >
637- ) }
638- { branchStatusText ? < ComboboxStatus > { branchStatusText } </ ComboboxStatus > : null }
629+ < ComboboxInput
630+ className = "[&_input]:h-6.5 [&_input]:ps-5 [&_input]:font-sans [&_input]:leading-6.5"
631+ inputClassName = "rounded-none bg-transparent text-sm"
632+ placeholder = "Search refs..."
633+ showTrigger = { false }
634+ size = "sm"
635+ unstyled
636+ value = { branchQuery }
637+ onChange = { ( event ) => setBranchQuery ( event . target . value ) }
638+ />
639+ </ div >
640+ </ div >
641+ < div className = "flex min-h-0 flex-1 flex-col overflow-hidden" >
642+ < ComboboxEmpty > No refs found.</ ComboboxEmpty >
643+ < div className = "relative min-h-0 w-full max-h-56 flex-1 overflow-hidden" >
644+ < ComboboxListVirtualized className = "size-full min-w-0 p-0" >
645+ < LegendList < string >
646+ ref = { branchListRef }
647+ data = { filteredBranchPickerItems }
648+ keyExtractor = { ( item ) => item }
649+ renderItem = { ( { item, index } ) => renderPickerItem ( item , index ) }
650+ estimatedItemSize = { 28 }
651+ drawDistance = { 336 }
652+ onEndReached = { ( ) => {
653+ if ( hasNextPage && ! isFetchingNextPage ) {
654+ fetchNextBranchPage ( ) ;
655+ }
656+ } }
657+ onLayout = { ( ) => {
658+ updateBranchListScrollFades ( ) ;
659+ maybeFetchNextBranchPage ( ) ;
660+ } }
661+ onScroll = { ( ) => {
662+ updateBranchListScrollFades ( ) ;
663+ maybeFetchNextBranchPage ( ) ;
664+ } }
665+ className = { cn (
666+ "scrollbar-gutter-stable overflow-x-hidden overscroll-y-contain ps-1 pe-0 pt-2 pb-1 [--fade-size:1.5rem]" ,
667+ showTopBranchScrollFade && "mask-t-from-[calc(100%-var(--fade-size))]" ,
668+ showBottomBranchScrollFade && "mask-b-from-[calc(100%-var(--fade-size))]" ,
669+ ) }
670+ style = { { maxHeight : "14rem" } }
671+ />
672+ </ ComboboxListVirtualized >
673+ </ div >
674+ { branchStatusText ? < ComboboxStatus > { branchStatusText } </ ComboboxStatus > : null }
675+ </ div >
639676 </ ComboboxPopup >
640677 </ Combobox >
641678 ) ;
0 commit comments