1import type { VcsRef } from "@t3tools/contracts";
2import { LegendList, type LegendListRef } from "@legendapp/list/react";
3import {
4 useCallback,
5 useEffect,
6 useId,
7 useLayoutEffect,
8 useRef,
9 useState,
10 type ComponentProps,
11 type ReactNode,
12} from "react";
13
14import { MiddleTruncate } from "./ui/middle-truncate";
15import { cn } from "../lib/utils";
16import { shouldLoadNextBranchPageAfterScroll } from "../state/paginatedBranches";
17import { RefreshIcon } from "./ui/refresh-icon";
18import { Switch } from "./ui/switch";
19import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area";
20import {
21 Combobox,
22 ComboboxEmpty,
23 ComboboxSearchInput,
24 ComboboxItem,
25 ComboboxListVirtualized,
26 ComboboxPopup,
27 ComboboxStatus,
28} from "./ui/combobox";
29import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
30
31/** Shared composer picker UI. The caller owns selection and any checkout mutations. */
32export function BranchPicker({
33 items,
34 filteredItems,
35 value,
36 query,
37 resultsQuery,
38 onQueryChange,
39 open,
40 onOpenChange,
41 onSelectItem,
42 hasNextPage,
43 isFetchingNextPage,
44 onLoadNext,
45 statusText,
46 originControl,
47 popupProps,
48 renderItem,
49 getItemType,
50 children,
51}: {
52 items: string[];
53 filteredItems: string[];
54 value: string | null;
55 query: string;
56 resultsQuery: string;
57 onQueryChange: (value: string) => void;
58 open: boolean;
59 onOpenChange: (open: boolean) => void;
60 onSelectItem: (value: string) => void;
61 hasNextPage: boolean;
62 isFetchingNextPage: boolean;
63 onLoadNext: () => void;
64 statusText: string | null;
65 originControl?: { checked: boolean; onCheckedChange: (checked: boolean) => void } | undefined;
66 popupProps: Omit<ComponentProps<typeof ComboboxPopup>, "children">;
67 renderItem: (value: string, index: number) => ReactNode;
68 getItemType?: ((value: string) => string) | undefined;
69 children: ReactNode;
70}) {
71 const highlightedValueRef = useRef<string | null>(null);
72 const startFromOriginSwitchId = useId();
73 const branchListScrollElementRef = useRef<HTMLElement | null>(null);
74 const previousBranchListScrollTopRef = useRef<number | null>(null);
75 const handleOpenChange = useCallback(
76 (nextOpen: boolean) => {
77 previousBranchListScrollTopRef.current = null;
78 if (!nextOpen) highlightedValueRef.current = null;
79 onOpenChange(nextOpen);
80 },
81 [onOpenChange],
82 );
83 const [showTopBranchScrollFade, setShowTopBranchScrollFade] = useState(false);
84 const [showBottomBranchScrollFade, setShowBottomBranchScrollFade] = useState(false);
85 const fetchNextBranchPage = useCallback(() => {
86 if (!hasNextPage || isFetchingNextPage) {
87 return;
88 }
89
90 onLoadNext();
91 }, [onLoadNext, hasNextPage, isFetchingNextPage]);
92 const maybeFetchNextBranchPage = useCallback(() => {
93 const scrollElement = branchListScrollElementRef.current;
94 if (!scrollElement) {
95 return;
96 }
97
98 const previousScrollTop = previousBranchListScrollTopRef.current;
99 previousBranchListScrollTopRef.current = scrollElement.scrollTop;
100 if (
101 !open ||
102 !hasNextPage ||
103 isFetchingNextPage ||
104 !shouldLoadNextBranchPageAfterScroll({
105 previousScrollTop,
106 scrollTop: scrollElement.scrollTop,
107 scrollHeight: scrollElement.scrollHeight,
108 clientHeight: scrollElement.clientHeight,
109 })
110 ) {
111 return;
112 }
113
114 fetchNextBranchPage();
115 }, [fetchNextBranchPage, hasNextPage, open, isFetchingNextPage]);
116
117 const branchListRef = useRef<LegendListRef | null>(null);
118 const updateBranchListScrollFades = useCallback(() => {
119 const scrollElement = branchListRef.current?.getScrollableNode?.();
120 if (!(scrollElement instanceof HTMLElement)) {
121 return;
122 }
123 branchListScrollElementRef.current = scrollElement;
124 const maxScrollOffset = Math.max(0, scrollElement.scrollHeight - scrollElement.clientHeight);
125 setShowTopBranchScrollFade(scrollElement.scrollTop > 1);
126 setShowBottomBranchScrollFade(maxScrollOffset - scrollElement.scrollTop > 1);
127 }, []);
128
129 useLayoutEffect(() => {
130 if (!open) {
131 return;
132 }
133
134 let nestedFrame = 0;
135 const frame = requestAnimationFrame(() => {
136 updateBranchListScrollFades();
137 nestedFrame = requestAnimationFrame(updateBranchListScrollFades);
138 });
139 return () => {
140 cancelAnimationFrame(frame);
141 cancelAnimationFrame(nestedFrame);
142 };
143 }, [open, updateBranchListScrollFades]);
144
145 const previousResultsQuery = useRef<string | null>(null);
146 useEffect(() => {
147 if (!open) {
148 previousResultsQuery.current = null;
149 return;
150 }
151 if (previousResultsQuery.current === resultsQuery) return;
152 previousResultsQuery.current = resultsQuery;
153 void branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false });
154 }, [resultsQuery, open]);
155
156 return (
157 <Combobox
158 items={items}
159 filteredItems={filteredItems}
160 autoHighlight
161 virtualized
162 onItemHighlighted={(value, eventDetails) => {
163 highlightedValueRef.current = typeof value === "string" ? value : null;
164 if (!open || eventDetails.index < 0 || eventDetails.reason !== "keyboard") {
165 return;
166 }
167 void branchListRef.current?.scrollIndexIntoView?.({
168 index: eventDetails.index,
169 animated: false,
170 });
171 }}
172 onOpenChange={handleOpenChange}
173 open={open}
174 value={value}
175 >
176 {children}
177 <ComboboxPopup {...popupProps}>
178 <ComboboxSearchInput
179 placeholder="Search refs..."
180 value={query}
181 onChange={(event) => onQueryChange(event.target.value)}
182 onKeyDown={(event) => {
183 if (event.key !== "Enter" || event.nativeEvent.isComposing || event.keyCode === 229)
184 return;
185 const highlightedValue = highlightedValueRef.current;
186 if (highlightedValue === null || !filteredItems.includes(highlightedValue)) return;
187 (
188 event as typeof event & { preventBaseUIHandler?: () => void }
189 ).preventBaseUIHandler?.();
190 event.preventDefault();
191 event.stopPropagation();
192 highlightedValueRef.current = null;
193 onSelectItem(highlightedValue);
194 }}
195 />
196 <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
197 <ComboboxEmpty>No refs found.</ComboboxEmpty>
198 <div className="relative min-h-0 w-full max-h-56 flex-1 overflow-hidden">
199 <ComboboxListVirtualized className="size-full min-w-0">
200 <LegendList<string>
201 ref={branchListRef}
202 data={filteredItems}
203 keyExtractor={(item) => item}
204 {...(getItemType ? { getItemType } : {})}
205 renderItem={({ item, index }) => renderItem(item, index)}
206 estimatedItemSize={28}
207 drawDistance={336}
208 onLayout={() => {
209 updateBranchListScrollFades();
210 previousBranchListScrollTopRef.current =
211 branchListScrollElementRef.current?.scrollTop ?? null;
212 }}
213 onScroll={() => {
214 updateBranchListScrollFades();
215 maybeFetchNextBranchPage();
216 }}
217 className={cn(
218 "max-h-56 scrollbar-gutter-stable overflow-x-hidden overscroll-y-contain ps-1 pe-0 pt-2 pb-1",
219 getVirtualizedScrollFadeClassName({
220 top: showTopBranchScrollFade,
221 bottom: showBottomBranchScrollFade,
222 }),
223 )}
224 />
225 </ComboboxListVirtualized>
226 </div>
227 {originControl ? (
228 <Tooltip>
229 <TooltipTrigger
230 render={
231 <label
232 htmlFor={startFromOriginSwitchId}
233 className="flex cursor-pointer items-center justify-between gap-3 border-t border-border/60 px-3 py-2 text-xs"
234 >
235 <span className="flex min-w-0 items-center gap-1.5 font-medium text-muted-foreground">
236 <RefreshIcon aria-hidden="true" className="size-3 shrink-0" />
237 <span className="truncate">Start from origin</span>
238 </span>
239 <Switch
240 id={startFromOriginSwitchId}
241 checked={originControl.checked}
242 size="sm"
243 aria-label="Start worktree from origin"
244 onCheckedChange={(checked) => originControl.onCheckedChange(Boolean(checked))}
245 />
246 </label>
247 }
248 />
249 <TooltipPopup side="top" className="max-w-72 whitespace-normal">
250 Creates the worktree from the latest matching branch on origin instead of your local
251 branch.
252 </TooltipPopup>
253 </Tooltip>
254 ) : null}
255 {statusText ? <ComboboxStatus>{statusText}</ComboboxStatus> : null}
256 </div>
257 </ComboboxPopup>
258 </Combobox>
259 );
260}
261
262export function BranchPickerRefItem({
263 branch: refName,
264 projectCwd: activeProjectCwd,
265 index,
266 value,
267 disabled,
268 onClick,
269 onContextMenu,
270}: {
271 branch: VcsRef;
272 projectCwd: string | null;
273 index: number;
274 value?: string;
275 disabled?: boolean;
276 onClick: ComponentProps<typeof ComboboxItem>["onClick"];
277 onContextMenu?: ComponentProps<typeof ComboboxItem>["onContextMenu"];
278}) {
279 const itemValue = refName.name;
280 const hasSecondaryWorktree =
281 refName.worktreePath && activeProjectCwd && refName.worktreePath !== activeProjectCwd;
282 const badge = refName.current
283 ? "current"
284 : hasSecondaryWorktree
285 ? "worktree"
286 : refName.isRemote
287 ? "remote"
288 : refName.isDefault
289 ? "default"
290 : null;
291 return (
292 <ComboboxItem
293 disabled={disabled}
294 hideIndicator
295 key={itemValue}
296 index={index}
297 value={value ?? itemValue}
298 onClick={onClick}
299 onContextMenu={onContextMenu}
300 >
301 <div className="flex w-full min-w-0 items-center justify-between gap-2">
302 <MiddleTruncate value={itemValue} className="flex-1" />
303 {badge && <span className="shrink-0 text-3xs text-muted-foreground/45">{badge}</span>}
304 </div>
305 </ComboboxItem>
306 );
307}