| ... | @@ -1,11 +1,12 @@ | ... | @@ -1,11 +1,12 @@ |
| 1 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; | 1 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; |
| 2 | import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; | 2 | import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; |
| 3 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; | 3 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; |
| 4 | import { ChevronDownIcon } from "lucide-react"; | 4 | import { ChevronDownIcon, GitBranchIcon, SearchIcon } from "lucide-react"; |
| 5 | import { | 5 | import { |
| 6 | useCallback, | 6 | useCallback, |
| 7 | useDeferredValue, | 7 | useDeferredValue, |
| 8 | useEffect, | 8 | useEffect, |
| | 9 | useLayoutEffect, |
| 9 | useMemo, | 10 | useMemo, |
| 10 | useOptimistic, | 11 | useOptimistic, |
| 11 | useRef, | 12 | useRef, |
| ... | @@ -37,7 +38,6 @@ import { | ... | @@ -37,7 +38,6 @@ import { |
| 37 | ComboboxEmpty, | 38 | ComboboxEmpty, |
| 38 | ComboboxInput, | 39 | ComboboxInput, |
| 39 | ComboboxItem, | 40 | ComboboxItem, |
| 40 | ComboboxList, | | |
| 41 | ComboboxListVirtualized, | 41 | ComboboxListVirtualized, |
| 42 | ComboboxPopup, | 42 | ComboboxPopup, |
| 43 | ComboboxStatus, | 43 | ComboboxStatus, |
| ... | @@ -281,7 +281,6 @@ export function BranchToolbarBranchSelector({ | ... | @@ -281,7 +281,6 @@ export function BranchToolbarBranchSelector({ |
| 281 | (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch, | 281 | (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch, |
| 282 | ); | 282 | ); |
| 283 | const [isBranchActionPending, startBranchActionTransition] = useTransition(); | 283 | const [isBranchActionPending, startBranchActionTransition] = useTransition(); |
| 284 | const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; | | |
| 285 | const totalBranchCount = branchRefState.data?.totalCount ?? 0; | 284 | const totalBranchCount = branchRefState.data?.totalCount ?? 0; |
| 286 | const branchStatusText = isInitialBranchesLoadPending | 285 | const branchStatusText = isInitialBranchesLoadPending |
| 287 | ? "Loading refs..." | 286 | ? "Loading refs..." |
| ... | @@ -421,7 +420,9 @@ export function BranchToolbarBranchSelector({ | ... | @@ -421,7 +420,9 @@ export function BranchToolbarBranchSelector({ |
| 421 | [branchRefTarget], | 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 | const fetchNextBranchPage = useCallback(() => { | 426 | const fetchNextBranchPage = useCallback(() => { |
| 426 | if (!hasNextPage || isFetchingNextPage) { | 427 | if (!hasNextPage || isFetchingNextPage) { |
| 427 | return; | 428 | return; |
| ... | @@ -451,44 +452,61 @@ export function BranchToolbarBranchSelector({ | ... | @@ -451,44 +452,61 @@ export function BranchToolbarBranchSelector({ |
| 451 | | 452 | |
| 452 | fetchNextBranchPage(); | 453 | fetchNextBranchPage(); |
| 453 | }, [fetchNextBranchPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]); | 454 | }, [fetchNextBranchPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]); |
| | 455 | |
| 454 | const branchListRef = useRef<LegendListRef | null>(null); | 456 | const branchListRef = useRef<LegendListRef | null>(null); |
| 455 | const setBranchListRef = useCallback((element: HTMLDivElement | null) => { | 457 | const updateBranchListScrollFades = useCallback(() => { |
| 456 | branchListScrollElementRef.current = (element?.parentElement as HTMLDivElement | null) ?? null; | 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 | useEffect(() => { | 468 | useEffect(() => { |
| 460 | if (!isBranchMenuOpen) { | 469 | if (isBranchMenuOpen) { |
| 461 | return; | 470 | return; |
| 462 | } | 471 | } |
| | 472 | setShowTopBranchScrollFade(false); |
| | 473 | setShowBottomBranchScrollFade(false); |
| | 474 | }, [isBranchMenuOpen]); |
| 463 | | 475 | |
| 464 | if (shouldVirtualizeBranchList) { | 476 | useLayoutEffect(() => { |
| 465 | branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false }); | 477 | if (!isBranchMenuOpen) { |
| 466 | } else { | 478 | return; |
| 467 | branchListScrollElementRef.current?.scrollTo({ top: 0 }); | | |
| 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 | useEffect(() => { | 499 | useEffect(() => { |
| 472 | const scrollElement = branchListScrollElementRef.current; | 500 | if (!isBranchMenuOpen) { |
| 473 | if (!scrollElement || !isBranchMenuOpen) { | | |
| 474 | return; | 501 | return; |
| 475 | } | 502 | } |
| 476 | | 503 | |
| 477 | const handleScroll = () => { | 504 | branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false }); |
| 478 | maybeFetchNextBranchPage(); | 505 | }, [deferredTrimmedBranchQuery, isBranchMenuOpen]); |
| 479 | }; | | |
| 480 | | | |
| 481 | scrollElement.addEventListener("scroll", handleScroll, { passive: true }); | | |
| 482 | handleScroll(); | | |
| 483 | return () => { | | |
| 484 | scrollElement.removeEventListener("scroll", handleScroll); | | |
| 485 | }; | | |
| 486 | }, [isBranchMenuOpen, maybeFetchNextBranchPage]); | | |
| 487 | | 506 | |
| 488 | useEffect(() => { | 507 | useEffect(() => { |
| 489 | if (shouldVirtualizeBranchList) return; | | |
| 490 | maybeFetchNextBranchPage(); | 508 | maybeFetchNextBranchPage(); |
| 491 | }, [refs.length, maybeFetchNextBranchPage, shouldVirtualizeBranchList]); | 509 | }, [refs.length, maybeFetchNextBranchPage]); |
| 492 | | 510 | |
| 493 | const triggerLabel = getBranchTriggerLabel({ | 511 | const triggerLabel = getBranchTriggerLabel({ |
| 494 | activeWorktreePath, | 512 | activeWorktreePath, |
| ... | @@ -504,6 +522,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -504,6 +522,7 @@ export function BranchToolbarBranchSelector({ |
| 504 | key={itemValue} | 522 | key={itemValue} |
| 505 | index={index} | 523 | index={index} |
| 506 | value={itemValue} | 524 | value={itemValue} |
| | 525 | className="pe-2" |
| 507 | onClick={() => { | 526 | onClick={() => { |
| 508 | if (!prReference || !onCheckoutPullRequestRequest) { | 527 | if (!prReference || !onCheckoutPullRequestRequest) { |
| 509 | return; | 528 | return; |
| ... | @@ -533,6 +552,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -533,6 +552,7 @@ export function BranchToolbarBranchSelector({ |
| 533 | key={itemValue} | 552 | key={itemValue} |
| 534 | index={index} | 553 | index={index} |
| 535 | value={itemValue} | 554 | value={itemValue} |
| | 555 | className="pe-1.5" |
| 536 | onClick={() => createRef(trimmedBranchQuery)} | 556 | onClick={() => createRef(trimmedBranchQuery)} |
| 537 | > | 557 | > |
| 538 | <span className="truncate">Create new ref &quot;{trimmedBranchQuery}&quot;</span> | 558 | <span className="truncate">Create new ref &quot;{trimmedBranchQuery}&quot;</span> |
| ... | @@ -560,10 +580,11 @@ export function BranchToolbarBranchSelector({ | ... | @@ -560,10 +580,11 @@ export function BranchToolbarBranchSelector({ |
| 560 | key={itemValue} | 580 | key={itemValue} |
| 561 | index={index} | 581 | index={index} |
| 562 | value={itemValue} | 582 | value={itemValue} |
| | 583 | className="pe-1.5" |
| 563 | onClick={() => selectBranch(refName)} | 584 | onClick={() => selectBranch(refName)} |
| 564 | > | 585 | > |
| 565 | <div className="flex w-full items-center justify-between gap-2"> | 586 | <div className="flex w-full min-w-0 items-center justify-between gap-2"> |
| 566 | <span className="truncate">{itemValue}</span> | 587 | <span className="min-w-0 flex-1 truncate">{itemValue}</span> |
| 567 | {badge && <span className="shrink-0 text-[10px] text-muted-foreground/45">{badge}</span>} | 588 | {badge && <span className="shrink-0 text-[10px] text-muted-foreground/45">{badge}</span>} |
| 568 | </div> | 589 | </div> |
| 569 | </ComboboxItem> | 590 | </ComboboxItem> |
| ... | @@ -575,7 +596,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -575,7 +596,7 @@ export function BranchToolbarBranchSelector({ |
| 575 | items={branchPickerItems} | 596 | items={branchPickerItems} |
| 576 | filteredItems={filteredBranchPickerItems} | 597 | filteredItems={filteredBranchPickerItems} |
| 577 | autoHighlight | 598 | autoHighlight |
| 578 | virtualized={shouldVirtualizeBranchList} | 599 | virtualized |
| 579 | onItemHighlighted={(_value, eventDetails) => { | 600 | onItemHighlighted={(_value, eventDetails) => { |
| 580 | if (!isBranchMenuOpen || eventDetails.index < 0 || eventDetails.reason !== "keyboard") { | 601 | if (!isBranchMenuOpen || eventDetails.index < 0 || eventDetails.reason !== "keyboard") { |
| 581 | return; | 602 | return; |
| ... | @@ -594,48 +615,64 @@ export function BranchToolbarBranchSelector({ | ... | @@ -594,48 +615,64 @@ export function BranchToolbarBranchSelector({ |
| 594 | className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)} | 615 | className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)} |
| 595 | disabled={isInitialBranchesLoadPending || isBranchActionPending} | 616 | disabled={isInitialBranchesLoadPending || isBranchActionPending} |
| 596 | > | 617 | > |
| | 618 | <GitBranchIcon className="size-3 shrink-0 opacity-70" /> |
| 597 | <span className="min-w-0 max-w-[240px] truncate">{triggerLabel}</span> | 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 | </ComboboxTrigger> | 621 | </ComboboxTrigger> |
| 600 | <ComboboxPopup align="end" side="top" className="w-80"> | 622 | <ComboboxPopup align="end" side="top" className="flex w-80 flex-col"> |
| 601 | <div className="border-b p-1"> | 623 | <div className="shrink-0 px-3 pt-2.5"> |
| 602 | <ComboboxInput | 624 | <div className="relative -translate-y-px border-b border-border/70 pb-1.5 transition-colors focus-within:border-ring"> |
| 603 | className="[&_input]:font-sans rounded-md" | 625 | <SearchIcon |
| 604 | inputClassName="ring-0" | 626 | aria-hidden="true" |
| 605 | placeholder="Search refs..." | 627 | className="pointer-events-none absolute top-1.5 left-0 size-4 shrink-0 text-muted-foreground/55" |
| 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" }} | | |
| 629 | /> | 628 | /> |
| 630 | </ComboboxListVirtualized> | 629 | <ComboboxInput |
| 631 | ) : ( | 630 | className="[&_input]:h-6.5 [&_input]:ps-5 [&_input]:font-sans [&_input]:leading-6.5" |
| 632 | <ComboboxList ref={setBranchListRef} className="max-h-56"> | 631 | inputClassName="rounded-none bg-transparent text-sm" |
| 633 | {filteredBranchPickerItems.map((itemValue, index) => | 632 | placeholder="Search refs..." |
| 634 | renderPickerItem(itemValue, index), | 633 | showTrigger={false} |
| 635 | )} | 634 | size="sm" |
| 636 | </ComboboxList> | 635 | unstyled |
| 637 | )} | 636 | value={branchQuery} |
| 638 | {branchStatusText ? <ComboboxStatus>{branchStatusText}</ComboboxStatus> : null} | 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 | </ComboboxPopup> | 676 | </ComboboxPopup> |
| 640 | </Combobox> | 677 | </Combobox> |
| 641 | ); | 678 | ); |