authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-10 00:13:34-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-10 07:13:34+00:00
logc5f7cd40ba00c015373fdb95818916642917e000
treeff669a606eeb0e9108fa6b599e6179396b4c47e2
parente2db800f7c7d7b2cba4da9d48883bf332a7f187b
signature Signed by PGP key B5690EEEBB952194

Polish branch picker trigger, scroll fade, and list layout (#3024)

Co-authored-by: ss <ss@barekey.dev> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>

1 files changed, 105 insertions(+), 68 deletions(-)

apps/web/src/components/BranchToolbarBranchSelector.tsx+105-68
......@@ -1,11 +1,12 @@
11import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
22import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts";
33import { LegendList, type LegendListRef } from "@legendapp/list/react";
4import { ChevronDownIcon } from "lucide-react";
4import { 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 &quot;{trimmedBranchQuery}&quot;</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 );