Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 105 additions & 68 deletions apps/web/src/components/BranchToolbarBranchSelector.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -37,7 +38,6 @@ import {
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxListVirtualized,
ComboboxPopup,
ComboboxStatus,
Expand Down Expand Up @@ -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..."
Expand Down Expand Up @@ -421,7 +420,9 @@ export function BranchToolbarBranchSelector({
[branchRefTarget],
);

const branchListScrollElementRef = useRef<HTMLDivElement | null>(null);
const branchListScrollElementRef = useRef<HTMLElement | null>(null);
const [showTopBranchScrollFade, setShowTopBranchScrollFade] = useState(false);
const [showBottomBranchScrollFade, setShowBottomBranchScrollFade] = useState(false);
const fetchNextBranchPage = useCallback(() => {
if (!hasNextPage || isFetchingNextPage) {
return;
Expand Down Expand Up @@ -451,44 +452,61 @@ export function BranchToolbarBranchSelector({

fetchNextBranchPage();
}, [fetchNextBranchPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]);

const branchListRef = useRef<LegendListRef | null>(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,
]);

Comment thread
cursor[bot] marked this conversation as resolved.
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]);
Comment thread
cursor[bot] marked this conversation as resolved.

useEffect(() => {
if (shouldVirtualizeBranchList) return;
maybeFetchNextBranchPage();
}, [refs.length, maybeFetchNextBranchPage, shouldVirtualizeBranchList]);
}, [refs.length, maybeFetchNextBranchPage]);
Comment thread
cursor[bot] marked this conversation as resolved.

const triggerLabel = getBranchTriggerLabel({
activeWorktreePath,
Expand All @@ -504,6 +522,7 @@ export function BranchToolbarBranchSelector({
key={itemValue}
index={index}
value={itemValue}
className="pe-2"
onClick={() => {
if (!prReference || !onCheckoutPullRequestRequest) {
return;
Expand Down Expand Up @@ -533,6 +552,7 @@ export function BranchToolbarBranchSelector({
key={itemValue}
index={index}
value={itemValue}
className="pe-1.5"
onClick={() => createRef(trimmedBranchQuery)}
>
<span className="truncate">Create new ref &quot;{trimmedBranchQuery}&quot;</span>
Expand Down Expand Up @@ -560,10 +580,11 @@ export function BranchToolbarBranchSelector({
key={itemValue}
index={index}
value={itemValue}
className="pe-1.5"
onClick={() => selectBranch(refName)}
>
<div className="flex w-full items-center justify-between gap-2">
<span className="truncate">{itemValue}</span>
<div className="flex w-full min-w-0 items-center justify-between gap-2">
<span className="min-w-0 flex-1 truncate">{itemValue}</span>
{badge && <span className="shrink-0 text-[10px] text-muted-foreground/45">{badge}</span>}
</div>
</ComboboxItem>
Expand All @@ -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;
Expand All @@ -594,48 +615,64 @@ export function BranchToolbarBranchSelector({
className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)}
disabled={isInitialBranchesLoadPending || isBranchActionPending}
>
<GitBranchIcon className="size-3 shrink-0 opacity-70" />
<span className="min-w-0 max-w-[240px] truncate">{triggerLabel}</span>
<ChevronDownIcon className="shrink-0" />
<ChevronDownIcon className="size-3 shrink-0 opacity-50" />
</ComboboxTrigger>
<ComboboxPopup align="end" side="top" className="w-80">
<div className="border-b p-1">
<ComboboxInput
className="[&_input]:font-sans rounded-md"
inputClassName="ring-0"
placeholder="Search refs..."
showTrigger={false}
size="sm"
value={branchQuery}
onChange={(event) => setBranchQuery(event.target.value)}
/>
</div>
<ComboboxEmpty>No refs found.</ComboboxEmpty>

{shouldVirtualizeBranchList ? (
<ComboboxListVirtualized>
<LegendList<string>
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" }}
<ComboboxPopup align="end" side="top" className="flex w-80 flex-col">
<div className="shrink-0 px-3 pt-2.5">
<div className="relative -translate-y-px border-b border-border/70 pb-1.5 transition-colors focus-within:border-ring">
<SearchIcon
aria-hidden="true"
className="pointer-events-none absolute top-1.5 left-0 size-4 shrink-0 text-muted-foreground/55"
/>
</ComboboxListVirtualized>
) : (
<ComboboxList ref={setBranchListRef} className="max-h-56">
{filteredBranchPickerItems.map((itemValue, index) =>
renderPickerItem(itemValue, index),
)}
</ComboboxList>
)}
{branchStatusText ? <ComboboxStatus>{branchStatusText}</ComboboxStatus> : null}
<ComboboxInput
className="[&_input]:h-6.5 [&_input]:ps-5 [&_input]:font-sans [&_input]:leading-6.5"
inputClassName="rounded-none bg-transparent text-sm"
placeholder="Search refs..."
showTrigger={false}
size="sm"
unstyled
value={branchQuery}
onChange={(event) => setBranchQuery(event.target.value)}
/>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<ComboboxEmpty>No refs found.</ComboboxEmpty>
<div className="relative min-h-0 w-full max-h-56 flex-1 overflow-hidden">
<ComboboxListVirtualized className="size-full min-w-0 p-0">
<LegendList<string>
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" }}
/>
</ComboboxListVirtualized>
</div>
{branchStatusText ? <ComboboxStatus>{branchStatusText}</ComboboxStatus> : null}
</div>
</ComboboxPopup>
</Combobox>
);
Expand Down
Loading