Skip to content
Source
Sign inSign up
Open History to see the last commit

BranchPicker.tsx

239 lines8.3 KB

1import { useLegendListRef } from "~/hooks/useLegendListRef";
2import type { VcsRef } from "@t3tools/contracts";
3import { LegendList, type LegendListRef } from "@legendapp/list/react";
4import { useId, useRef, useState, type ComponentProps, type ReactNode } from "react";
5​
6import { MiddleTruncate } from "./ui/middle-truncate";
7import { cn } from "../lib/utils";
8import { RefreshIcon } from "./ui/refresh-icon";
9import { Switch } from "./ui/switch";
10import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area";
11import {
12 Combobox,
13 ComboboxEmpty,
14 ComboboxSearchInput,
15 ComboboxItem,
16 ComboboxListVirtualized,
17 ComboboxPopup,
18 ComboboxStatus,
19} from "./ui/combobox";
20import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
21​
22export function BranchPicker({
23 items,
24 filteredItems,
25 value,
26 query,
27 onQueryChange,
28 open,
29 onOpenChange,
30 onSelectItem,
31 hasNextPage,
32 isFetchingNextPage,
33 onLoadNext,
34 statusText,
35 notice,
36 originControl,
37 popupProps,
38 renderItem,
39 getItemType,
40 children,
41}: {
42 items: string[];
43 filteredItems: string[];
44 value: string | null;
45 query: string;
46 onQueryChange: (value: string) => void;
47 open: boolean;
48 onOpenChange: (open: boolean) => void;
49 onSelectItem: (value: string) => void;
50 hasNextPage: boolean;
51 isFetchingNextPage: boolean;
52 onLoadNext: () => void;
53 statusText: string | null;
54 notice?: string | null | undefined;
55 originControl?: { checked: boolean; onCheckedChange: (checked: boolean) => void } | undefined;
56 popupProps: Omit<ComponentProps<typeof ComboboxPopup>, "children">;
57 renderItem: (value: string, index: number) => ReactNode;
58 getItemType?: ((value: string) => string) | undefined;
59 children: ReactNode;
60}) {
61 const highlightedValueRef = useRef<string | null>(null);
62 const startFromOriginSwitchId = useId();
63 const handleOpenChange = (nextOpen: boolean) => {
64 if (!nextOpen) highlightedValueRef.current = null;
65 onOpenChange(nextOpen);
66 };
67 const [showTopBranchScrollFade, setShowTopBranchScrollFade] = useState(false);
68 const [showBottomBranchScrollFade, setShowBottomBranchScrollFade] = useState(false);
69 const fetchNextBranchPage = () => {
70 if (!hasNextPage || isFetchingNextPage) {
71 return;
72 }
73​
74 onLoadNext();
75 };
76 const branchListRef = useRef<LegendListRef | null>(null);
77 const updateBranchListScrollFades = () => {
78 const metrics = branchListRef.current?.getState();
79 if (!metrics) return;
80 setShowTopBranchScrollFade(!metrics.isAtStart);
81 setShowBottomBranchScrollFade(!metrics.isAtEnd);
82 };
83 const branchScrollRef = useLegendListRef(branchListRef, updateBranchListScrollFades);
84​
85 return (
86 <Combobox
87 items={items}
88 filteredItems={filteredItems}
89 autoHighlight
90 virtualized
91 onItemHighlighted={(value, eventDetails) => {
92 highlightedValueRef.current = typeof value === "string" ? value : null;
93 if (!open || eventDetails.index < 0 || eventDetails.reason !== "keyboard") {
94 return;
95 }
96 void branchListRef.current?.scrollIndexIntoView?.({
97 index: eventDetails.index,
98 animated: false,
99 });
100 }}
101 onOpenChange={handleOpenChange}
102 open={open}
103 value={value}
104 >
105 {children}
106 <ComboboxPopup {...popupProps}>
107 <ComboboxSearchInput
108 placeholder="Search refs..."
109 value={query}
110 onChange={(event) => {
111 onQueryChange(event.target.value);
112 void branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false });
113 }}
114 onKeyDown={(event) => {
115 if (event.key !== "Enter" || event.nativeEvent.isComposing || event.keyCode === 229)
116 return;
117 const highlightedValue = highlightedValueRef.current;
118 if (highlightedValue === null || !filteredItems.includes(highlightedValue)) return;
119 (
120 event as typeof event & { preventBaseUIHandler?: () => void }
121 ).preventBaseUIHandler?.();
122 event.preventDefault();
123 event.stopPropagation();
124 highlightedValueRef.current = null;
125 onSelectItem(highlightedValue);
126 }}
127 />
128 <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
129 <ComboboxEmpty>No refs found.</ComboboxEmpty>
130 <div className="relative flex min-h-0 w-full max-h-56 flex-1 flex-col overflow-hidden">
131 <ComboboxListVirtualized className="flex size-full min-h-0 min-w-0 flex-col">
132 <LegendList<string>
133 ref={branchScrollRef}
134 data={filteredItems}
135 initialScrollOffset={0}
136 keyExtractor={(item) => item}
137 {...(getItemType ? { getItemType } : {})}
138 renderItem={({ item, index }) => renderItem(item, index)}
139 extraData={renderItem}
140 estimatedItemSize={28}
141 drawDistance={336}
142 onScroll={updateBranchListScrollFades}
143 onLayout={updateBranchListScrollFades}
144 onReady={updateBranchListScrollFades}
145 onEndReached={fetchNextBranchPage}
146 onEndReachedThreshold={0.5}
147 className={cn(
148 "min-h-0 max-h-56 scrollbar-gutter-stable overflow-x-hidden overscroll-y-contain ps-1 pe-0 py-2",
149 getVirtualizedScrollFadeClassName({
150 top: showTopBranchScrollFade,
151 bottom: showBottomBranchScrollFade,
152 }),
153 )}
154 />
155 </ComboboxListVirtualized>
156 </div>
157 {originControl ? (
158 <Tooltip>
159 <TooltipTrigger
160 render={
161 <label
162 htmlFor={startFromOriginSwitchId}
163 className="flex cursor-pointer items-center justify-between gap-3 border-t border-border/60 px-3 py-2 text-xs"
164 >
165 <span className="flex min-w-0 items-center gap-1.5 font-medium text-muted-foreground">
166 <RefreshIcon aria-hidden="true" className="size-3 shrink-0" />
167 <span className="truncate">Start from remote</span>
168 </span>
169 <Switch
170 id={startFromOriginSwitchId}
171 checked={originControl.checked}
172 size="sm"
173 aria-label="Start worktree from remote"
174 onCheckedChange={(checked) => originControl.onCheckedChange(Boolean(checked))}
175 />
176 </label>
177 }
178 />
179 <TooltipPopup side="top" className="max-w-72 whitespace-normal">
180 Uses the latest commit of the base branch’s tracked remote branch, even if its name
181 differs. Otherwise uses the same-named branch on origin. Uses your local branch if
182 that remote branch is missing.
183 </TooltipPopup>
184 </Tooltip>
185 ) : null}
186 {notice ? <ComboboxStatus>{notice}</ComboboxStatus> : null}
187 {statusText ? <ComboboxStatus>{statusText}</ComboboxStatus> : null}
188 </div>
189 </ComboboxPopup>
190 </Combobox>
191 );
193​
194export function BranchPickerRefItem({
195 branch: refName,
196 projectCwd: activeProjectCwd,
197 index,
198 value,
199 disabled,
200 onClick,
201 onContextMenu,
202}: {
203 branch: VcsRef;
204 projectCwd: string | null;
205 index: number;
206 value?: string;
207 disabled?: boolean;
208 onClick: ComponentProps<typeof ComboboxItem>["onClick"];
209 onContextMenu?: ComponentProps<typeof ComboboxItem>["onContextMenu"];
210}) {
211 const itemValue = refName.name;
212 const hasSecondaryWorktree =
213 refName.worktreePath && activeProjectCwd && refName.worktreePath !== activeProjectCwd;
214 const badge = refName.current
215 ? "current"
216 : hasSecondaryWorktree
217 ? "worktree"
218 : refName.isRemote
219 ? "remote"
220 : refName.isDefault
221 ? "default"
222 : null;
223 return (
224 <ComboboxItem
225 disabled={disabled}
226 hideIndicator
227 key={itemValue}
228 index={index}
229 value={value ?? itemValue}
230 onClick={onClick}
231 onContextMenu={onContextMenu}
232 >
233 <div className="flex w-full min-w-0 items-center justify-between gap-2">
234 <MiddleTruncate value={itemValue} className="flex-1" />
235 {badge && <span className="shrink-0 text-3xs text-muted-foreground/45">{badge}</span>}
236 </div>
237 </ComboboxItem>
238 );