| ... | ... | @@ -1,11 +1,12 @@ |
| 1 | 1 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; |
| 2 | 2 | import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; |
| 3 | 3 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; |
| 4 | | import { ChevronDownIcon } from "lucide-react"; |
| 4 | import { ChevronDownIcon, GitBranchIcon, SearchIcon } from "lucide-react"; |
| 5 | 5 | import { |
| 6 | 6 | useCallback, |
| 7 | 7 | useDeferredValue, |
| 8 | 8 | useEffect, |
| 9 | useLayoutEffect, |
| 9 | 10 | useMemo, |
| 10 | 11 | useOptimistic, |
| 11 | 12 | useRef, |
| ... | ... | @@ -37,7 +38,6 @@ import { |
| 37 | 38 | ComboboxEmpty, |
| 38 | 39 | ComboboxInput, |
| 39 | 40 | ComboboxItem, |
| 40 | | ComboboxList, |
| 41 | 41 | ComboboxListVirtualized, |
| 42 | 42 | ComboboxPopup, |
| 43 | 43 | ComboboxStatus, |
| ... | ... | @@ -281,7 +281,6 @@ export function BranchToolbarBranchSelector({ |
| 281 | 281 | (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch, |
| 282 | 282 | ); |
| 283 | 283 | const [isBranchActionPending, startBranchActionTransition] = useTransition(); |
| 284 | | const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; |
| 285 | 284 | const totalBranchCount = branchRefState.data?.totalCount ?? 0; |
| 286 | 285 | const branchStatusText = isInitialBranchesLoadPending |
| 287 | 286 | ? "Loading refs..." |
| ... | ... | @@ -421,7 +420,9 @@ export function BranchToolbarBranchSelector({ |
| 421 | 420 | [branchRefTarget], |
| 422 | 421 | ); |
| 423 | 422 | |
| 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); |
| 425 | 426 | const fetchNextBranchPage = useCallback(() => { |
| 426 | 427 | if (!hasNextPage || isFetchingNextPage) { |
| 427 | 428 | return; |
| ... | ... | @@ -451,44 +452,61 @@ export function BranchToolbarBranchSelector({ |
| 451 | 452 | |
| 452 | 453 | fetchNextBranchPage(); |
| 453 | 454 | }, [fetchNextBranchPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]); |
| 455 | |
| 454 | 456 | 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); |
| 457 | 466 | }, []); |
| 458 | 467 | |
| 459 | 468 | useEffect(() => { |
| 460 | | if (!isBranchMenuOpen) { |
| 469 | if (isBranchMenuOpen) { |
| 461 | 470 | return; |
| 462 | 471 | } |
| 472 | setShowTopBranchScrollFade(false); |
| 473 | setShowBottomBranchScrollFade(false); |
| 474 | }, [isBranchMenuOpen]); |
| 463 | 475 | |
| 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; |
| 468 | 479 | } |
| 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 | ]); |
| 470 | 498 | |
| 471 | 499 | useEffect(() => { |
| 472 | | const scrollElement = branchListScrollElementRef.current; |
| 473 | | if (!scrollElement || !isBranchMenuOpen) { |
| 500 | if (!isBranchMenuOpen) { |
| 474 | 501 | return; |
| 475 | 502 | } |
| 476 | 503 | |
| 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]); |
| 487 | 506 | |
| 488 | 507 | useEffect(() => { |
| 489 | | if (shouldVirtualizeBranchList) return; |
| 490 | 508 | maybeFetchNextBranchPage(); |
| 491 | | }, [refs.length, maybeFetchNextBranchPage, shouldVirtualizeBranchList]); |
| 509 | }, [refs.length, maybeFetchNextBranchPage]); |
| 492 | 510 | |
| 493 | 511 | const triggerLabel = getBranchTriggerLabel({ |
| 494 | 512 | activeWorktreePath, |
| ... | ... | @@ -504,6 +522,7 @@ export function BranchToolbarBranchSelector({ |
| 504 | 522 | key={itemValue} |
| 505 | 523 | index={index} |
| 506 | 524 | value={itemValue} |
| 525 | className="pe-2" |
| 507 | 526 | onClick={() => { |
| 508 | 527 | if (!prReference || !onCheckoutPullRequestRequest) { |
| 509 | 528 | return; |
| ... | ... | @@ -533,6 +552,7 @@ export function BranchToolbarBranchSelector({ |
| 533 | 552 | key={itemValue} |
| 534 | 553 | index={index} |
| 535 | 554 | value={itemValue} |
| 555 | className="pe-1.5" |
| 536 | 556 | onClick={() => createRef(trimmedBranchQuery)} |
| 537 | 557 | > |
| 538 | 558 | <span className="truncate">Create new ref &quot;{trimmedBranchQuery}&quot;</span> |
| ... | ... | @@ -560,10 +580,11 @@ export function BranchToolbarBranchSelector({ |
| 560 | 580 | key={itemValue} |
| 561 | 581 | index={index} |
| 562 | 582 | value={itemValue} |
| 583 | className="pe-1.5" |
| 563 | 584 | onClick={() => selectBranch(refName)} |
| 564 | 585 | > |
| 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> |
| 567 | 588 | {badge && <span className="shrink-0 text-[10px] text-muted-foreground/45">{badge}</span>} |
| 568 | 589 | </div> |
| 569 | 590 | </ComboboxItem> |
| ... | ... | @@ -575,7 +596,7 @@ export function BranchToolbarBranchSelector({ |
| 575 | 596 | items={branchPickerItems} |
| 576 | 597 | filteredItems={filteredBranchPickerItems} |
| 577 | 598 | autoHighlight |
| 578 | | virtualized={shouldVirtualizeBranchList} |
| 599 | virtualized |
| 579 | 600 | onItemHighlighted={(_value, eventDetails) => { |
| 580 | 601 | if (!isBranchMenuOpen || eventDetails.index < 0 || eventDetails.reason !== "keyboard") { |
| 581 | 602 | return; |
| ... | ... | @@ -594,48 +615,64 @@ export function BranchToolbarBranchSelector({ |
| 594 | 615 | className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)} |
| 595 | 616 | disabled={isInitialBranchesLoadPending || isBranchActionPending} |
| 596 | 617 | > |
| 618 | <GitBranchIcon className="size-3 shrink-0 opacity-70" /> |
| 597 | 619 | <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" /> |
| 599 | 621 | </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" |
| 629 | 628 | /> |
| 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> |
| 639 | 676 | </ComboboxPopup> |
| 640 | 677 | </Combobox> |
| 641 | 678 | ); |