| ... | ... | @@ -3,7 +3,9 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | 3 | import { useVirtualizer } from "@tanstack/react-virtual"; |
| 4 | 4 | import { ChevronDownIcon } from "lucide-react"; |
| 5 | 5 | import { |
| 6 | type CSSProperties, |
| 6 | 7 | useCallback, |
| 8 | useDeferredValue, |
| 7 | 9 | useEffect, |
| 8 | 10 | useMemo, |
| 9 | 11 | useOptimistic, |
| ... | ... | @@ -12,13 +14,19 @@ import { |
| 12 | 14 | useTransition, |
| 13 | 15 | } from "react"; |
| 14 | 16 | |
| 15 | | import { gitBranchesQueryOptions, gitQueryKeys, invalidateGitQueries } from "../lib/gitReactQuery"; |
| 17 | import { |
| 18 | gitBranchesQueryOptions, |
| 19 | gitQueryKeys, |
| 20 | gitStatusQueryOptions, |
| 21 | invalidateGitQueries, |
| 22 | } from "../lib/gitReactQuery"; |
| 16 | 23 | import { readNativeApi } from "../nativeApi"; |
| 17 | 24 | import { parsePullRequestReference } from "../pullRequestReference"; |
| 18 | 25 | import { |
| 19 | 26 | dedupeRemoteBranchesWithLocalMatches, |
| 20 | 27 | deriveLocalBranchNameFromRemoteRef, |
| 21 | 28 | EnvMode, |
| 29 | filterBranchPickerItems, |
| 22 | 30 | resolveBranchToolbarValue, |
| 23 | 31 | } from "./BranchToolbar.logic"; |
| 24 | 32 | import { Button } from "./ui/button"; |
| ... | ... | @@ -78,13 +86,16 @@ export function BranchToolbarBranchSelector({ |
| 78 | 86 | const queryClient = useQueryClient(); |
| 79 | 87 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); |
| 80 | 88 | const [branchQuery, setBranchQuery] = useState(""); |
| 89 | const deferredBranchQuery = useDeferredValue(branchQuery); |
| 81 | 90 | |
| 82 | 91 | const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd)); |
| 92 | const branchStatusQuery = useQuery(gitStatusQueryOptions(branchCwd)); |
| 83 | 93 | const branches = useMemo( |
| 84 | 94 | () => dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []), |
| 85 | 95 | [branchesQuery.data?.branches], |
| 86 | 96 | ); |
| 87 | | const currentGitBranch = branches.find((branch) => branch.current)?.name ?? null; |
| 97 | const currentGitBranch = |
| 98 | branchStatusQuery.data?.branch ?? branches.find((branch) => branch.current)?.name ?? null; |
| 88 | 99 | const canonicalActiveBranch = resolveBranchToolbarValue({ |
| 89 | 100 | envMode: effectiveEnvMode, |
| 90 | 101 | activeWorktreePath, |
| ... | ... | @@ -97,12 +108,15 @@ export function BranchToolbarBranchSelector({ |
| 97 | 108 | [branches], |
| 98 | 109 | ); |
| 99 | 110 | const trimmedBranchQuery = branchQuery.trim(); |
| 100 | | const normalizedBranchQuery = trimmedBranchQuery.toLowerCase(); |
| 111 | const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); |
| 112 | const normalizedDeferredBranchQuery = deferredTrimmedBranchQuery.toLowerCase(); |
| 101 | 113 | const prReference = parsePullRequestReference(trimmedBranchQuery); |
| 114 | const isSelectingWorktreeBase = |
| 115 | effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath; |
| 102 | 116 | const checkoutPullRequestItemValue = prReference && onCheckoutPullRequestRequest |
| 103 | 117 | ? `__checkout_pull_request__:${prReference}` |
| 104 | 118 | : null; |
| 105 | | const canCreateBranch = effectiveEnvMode === "local" && trimmedBranchQuery.length > 0; |
| 119 | const canCreateBranch = !isSelectingWorktreeBase && trimmedBranchQuery.length > 0; |
| 106 | 120 | const hasExactBranchMatch = branchByName.has(trimmedBranchQuery); |
| 107 | 121 | const createBranchItemValue = canCreateBranch |
| 108 | 122 | ? `__create_new_branch__:${trimmedBranchQuery}` |
| ... | ... | @@ -122,19 +136,25 @@ export function BranchToolbarBranchSelector({ |
| 122 | 136 | ); |
| 123 | 137 | const filteredBranchPickerItems = useMemo( |
| 124 | 138 | () => |
| 125 | | normalizedBranchQuery.length === 0 |
| 126 | | ? branchPickerItems |
| 127 | | : branchPickerItems.filter((itemValue) => { |
| 128 | | if (createBranchItemValue && itemValue === createBranchItemValue) return true; |
| 129 | | return itemValue.toLowerCase().includes(normalizedBranchQuery); |
| 130 | | }), |
| 131 | | [branchPickerItems, createBranchItemValue, normalizedBranchQuery], |
| 139 | filterBranchPickerItems({ |
| 140 | itemValues: branchPickerItems, |
| 141 | normalizedQuery: normalizedDeferredBranchQuery, |
| 142 | createBranchItemValue, |
| 143 | checkoutPullRequestItemValue, |
| 144 | }), |
| 145 | [ |
| 146 | branchPickerItems, |
| 147 | checkoutPullRequestItemValue, |
| 148 | createBranchItemValue, |
| 149 | normalizedDeferredBranchQuery, |
| 150 | ], |
| 132 | 151 | ); |
| 133 | 152 | const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic( |
| 134 | 153 | canonicalActiveBranch, |
| 135 | 154 | (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch, |
| 136 | 155 | ); |
| 137 | 156 | const [isBranchActionPending, startBranchActionTransition] = useTransition(); |
| 157 | const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; |
| 138 | 158 | |
| 139 | 159 | const runBranchAction = (action: () => Promise<void>) => { |
| 140 | 160 | startBranchActionTransition(async () => { |
| ... | ... | @@ -148,7 +168,7 @@ export function BranchToolbarBranchSelector({ |
| 148 | 168 | if (!api || !branchCwd || isBranchActionPending) return; |
| 149 | 169 | |
| 150 | 170 | // In new-worktree mode, selecting a branch sets the base branch. |
| 151 | | if (effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath) { |
| 171 | if (isSelectingWorktreeBase) { |
| 152 | 172 | onSetThreadBranch(branch.name, null); |
| 153 | 173 | setIsBranchMenuOpen(false); |
| 154 | 174 | onComposerFocusRequest?.(); |
| ... | ... | @@ -274,7 +294,7 @@ export function BranchToolbarBranchSelector({ |
| 274 | 294 | estimateSize: () => 28, |
| 275 | 295 | getScrollElement: () => branchListScrollElementRef.current, |
| 276 | 296 | overscan: 12, |
| 277 | | enabled: isBranchMenuOpen, |
| 297 | enabled: isBranchMenuOpen && shouldVirtualizeBranchList, |
| 278 | 298 | initialRect: { |
| 279 | 299 | height: 224, |
| 280 | 300 | width: 0, |
| ... | ... | @@ -293,11 +313,16 @@ export function BranchToolbarBranchSelector({ |
| 293 | 313 | ); |
| 294 | 314 | |
| 295 | 315 | useEffect(() => { |
| 296 | | if (!isBranchMenuOpen) return; |
| 316 | if (!isBranchMenuOpen || !shouldVirtualizeBranchList) return; |
| 297 | 317 | queueMicrotask(() => { |
| 298 | 318 | branchListVirtualizer.measure(); |
| 299 | 319 | }); |
| 300 | | }, [branchListVirtualizer, filteredBranchPickerItems.length, isBranchMenuOpen]); |
| 320 | }, [ |
| 321 | branchListVirtualizer, |
| 322 | filteredBranchPickerItems.length, |
| 323 | isBranchMenuOpen, |
| 324 | shouldVirtualizeBranchList, |
| 325 | ]); |
| 301 | 326 | |
| 302 | 327 | const triggerLabel = getBranchTriggerLabel({ |
| 303 | 328 | activeWorktreePath, |
| ... | ... | @@ -305,6 +330,80 @@ export function BranchToolbarBranchSelector({ |
| 305 | 330 | resolvedActiveBranch, |
| 306 | 331 | }); |
| 307 | 332 | |
| 333 | function renderPickerItem(itemValue: string, index: number, style?: CSSProperties) { |
| 334 | if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) { |
| 335 | return ( |
| 336 | <ComboboxItem |
| 337 | hideIndicator |
| 338 | key={itemValue} |
| 339 | index={index} |
| 340 | value={itemValue} |
| 341 | style={style} |
| 342 | onClick={() => { |
| 343 | if (!prReference || !onCheckoutPullRequestRequest) { |
| 344 | return; |
| 345 | } |
| 346 | setIsBranchMenuOpen(false); |
| 347 | setBranchQuery(""); |
| 348 | onComposerFocusRequest?.(); |
| 349 | onCheckoutPullRequestRequest(prReference); |
| 350 | }} |
| 351 | > |
| 352 | <div className="flex min-w-0 flex-col items-start py-1"> |
| 353 | <span className="truncate font-medium">Checkout Pull Request</span> |
| 354 | <span className="truncate text-muted-foreground text-xs">{prReference}</span> |
| 355 | </div> |
| 356 | </ComboboxItem> |
| 357 | ); |
| 358 | } |
| 359 | if (createBranchItemValue && itemValue === createBranchItemValue) { |
| 360 | return ( |
| 361 | <ComboboxItem |
| 362 | hideIndicator |
| 363 | key={itemValue} |
| 364 | index={index} |
| 365 | value={itemValue} |
| 366 | style={style} |
| 367 | onClick={() => createBranch(trimmedBranchQuery)} |
| 368 | > |
| 369 | <span className="truncate">Create new branch "{trimmedBranchQuery}"</span> |
| 370 | </ComboboxItem> |
| 371 | ); |
| 372 | } |
| 373 | |
| 374 | const branch = branchByName.get(itemValue); |
| 375 | if (!branch) return null; |
| 376 | |
| 377 | const hasSecondaryWorktree = branch.worktreePath && branch.worktreePath !== activeProjectCwd; |
| 378 | const badge = branch.current |
| 379 | ? "current" |
| 380 | : hasSecondaryWorktree |
| 381 | ? "worktree" |
| 382 | : branch.isRemote |
| 383 | ? "remote" |
| 384 | : branch.isDefault |
| 385 | ? "default" |
| 386 | : null; |
| 387 | return ( |
| 388 | <ComboboxItem |
| 389 | hideIndicator |
| 390 | key={itemValue} |
| 391 | index={index} |
| 392 | value={itemValue} |
| 393 | className={itemValue === resolvedActiveBranch ? "bg-accent text-foreground" : undefined} |
| 394 | style={style} |
| 395 | onClick={() => selectBranch(branch)} |
| 396 | > |
| 397 | <div className="flex w-full items-center justify-between gap-2"> |
| 398 | <span className="truncate">{itemValue}</span> |
| 399 | {badge && ( |
| 400 | <span className="shrink-0 text-[10px] text-muted-foreground/45">{badge}</span> |
| 401 | )} |
| 402 | </div> |
| 403 | </ComboboxItem> |
| 404 | ); |
| 405 | } |
| 406 | |
| 308 | 407 | return ( |
| 309 | 408 | <Combobox |
| 310 | 409 | items={branchPickerItems} |
| ... | ... | @@ -322,12 +421,12 @@ export function BranchToolbarBranchSelector({ |
| 322 | 421 | <ComboboxTrigger |
| 323 | 422 | render={<Button variant="ghost" size="xs" />} |
| 324 | 423 | className="text-muted-foreground/70 hover:text-foreground/80" |
| 325 | | disabled={branchesQuery.isLoading || isBranchActionPending} |
| 424 | disabled={(branchesQuery.isLoading && branches.length === 0) || isBranchActionPending} |
| 326 | 425 | > |
| 327 | 426 | <span className="max-w-[240px] truncate">{triggerLabel}</span> |
| 328 | 427 | <ChevronDownIcon /> |
| 329 | 428 | </ComboboxTrigger> |
| 330 | | <ComboboxPopup align="end" side="top" className="w-64"> |
| 429 | <ComboboxPopup align="end" side="top" className="w-80"> |
| 331 | 430 | <div className="border-b p-1"> |
| 332 | 431 | <ComboboxInput |
| 333 | 432 | className="[&_input]:font-sans rounded-md" |
| ... | ... | @@ -342,109 +441,28 @@ export function BranchToolbarBranchSelector({ |
| 342 | 441 | <ComboboxEmpty>No branches found.</ComboboxEmpty> |
| 343 | 442 | |
| 344 | 443 | <ComboboxList ref={setBranchListRef} className="max-h-56"> |
| 345 | | <div |
| 346 | | className="relative" |
| 347 | | style={{ |
| 348 | | height: `${branchListVirtualizer.getTotalSize()}px`, |
| 349 | | }} |
| 350 | | > |
| 351 | | {virtualBranchRows.map((virtualRow) => { |
| 352 | | const itemValue = filteredBranchPickerItems[virtualRow.index]; |
| 353 | | if (!itemValue) return null; |
| 354 | | if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) { |
| 355 | | return ( |
| 356 | | <ComboboxItem |
| 357 | | hideIndicator |
| 358 | | key={itemValue} |
| 359 | | index={virtualRow.index} |
| 360 | | value={itemValue} |
| 361 | | style={{ |
| 362 | | position: "absolute", |
| 363 | | top: 0, |
| 364 | | left: 0, |
| 365 | | width: "100%", |
| 366 | | transform: `translateY(${virtualRow.start}px)`, |
| 367 | | }} |
| 368 | | onClick={() => { |
| 369 | | if (!prReference || !onCheckoutPullRequestRequest) { |
| 370 | | return; |
| 371 | | } |
| 372 | | setIsBranchMenuOpen(false); |
| 373 | | setBranchQuery(""); |
| 374 | | onComposerFocusRequest?.(); |
| 375 | | onCheckoutPullRequestRequest(prReference); |
| 376 | | }} |
| 377 | | > |
| 378 | | <div className="flex min-w-0 flex-col items-start"> |
| 379 | | <span className="truncate font-medium">Checkout Pull Request</span> |
| 380 | | <span className="truncate text-muted-foreground text-xs">{prReference}</span> |
| 381 | | </div> |
| 382 | | </ComboboxItem> |
| 383 | | ); |
| 384 | | } |
| 385 | | if (createBranchItemValue && itemValue === createBranchItemValue) { |
| 386 | | return ( |
| 387 | | <ComboboxItem |
| 388 | | hideIndicator |
| 389 | | key={itemValue} |
| 390 | | index={virtualRow.index} |
| 391 | | value={itemValue} |
| 392 | | style={{ |
| 393 | | position: "absolute", |
| 394 | | top: 0, |
| 395 | | left: 0, |
| 396 | | width: "100%", |
| 397 | | transform: `translateY(${virtualRow.start}px)`, |
| 398 | | }} |
| 399 | | onClick={() => createBranch(trimmedBranchQuery)} |
| 400 | | > |
| 401 | | <span className="truncate">Create new branch "{trimmedBranchQuery}"</span> |
| 402 | | </ComboboxItem> |
| 403 | | ); |
| 404 | | } |
| 405 | | |
| 406 | | const branch = branchByName.get(itemValue); |
| 407 | | if (!branch) return null; |
| 408 | | |
| 409 | | const hasSecondaryWorktree = |
| 410 | | branch.worktreePath && branch.worktreePath !== activeProjectCwd; |
| 411 | | const badge = branch.current |
| 412 | | ? "current" |
| 413 | | : hasSecondaryWorktree |
| 414 | | ? "worktree" |
| 415 | | : branch.isRemote |
| 416 | | ? "remote" |
| 417 | | : branch.isDefault |
| 418 | | ? "default" |
| 419 | | : null; |
| 420 | | return ( |
| 421 | | <ComboboxItem |
| 422 | | hideIndicator |
| 423 | | key={itemValue} |
| 424 | | index={virtualRow.index} |
| 425 | | value={itemValue} |
| 426 | | className={ |
| 427 | | itemValue === resolvedActiveBranch ? "bg-accent text-foreground" : undefined |
| 428 | | } |
| 429 | | style={{ |
| 430 | | position: "absolute", |
| 431 | | top: 0, |
| 432 | | left: 0, |
| 433 | | width: "100%", |
| 434 | | transform: `translateY(${virtualRow.start}px)`, |
| 435 | | }} |
| 436 | | onClick={() => selectBranch(branch)} |
| 437 | | > |
| 438 | | <div className="flex w-full items-center justify-between gap-2"> |
| 439 | | <span className="truncate">{itemValue}</span> |
| 440 | | {badge && ( |
| 441 | | <span className="shrink-0 text-[10px] text-muted-foreground/45">{badge}</span> |
| 442 | | )} |
| 443 | | </div> |
| 444 | | </ComboboxItem> |
| 445 | | ); |
| 446 | | })} |
| 447 | | </div> |
| 444 | {shouldVirtualizeBranchList ? ( |
| 445 | <div |
| 446 | className="relative" |
| 447 | style={{ |
| 448 | height: `${branchListVirtualizer.getTotalSize()}px`, |
| 449 | }} |
| 450 | > |
| 451 | {virtualBranchRows.map((virtualRow) => { |
| 452 | const itemValue = filteredBranchPickerItems[virtualRow.index]; |
| 453 | if (!itemValue) return null; |
| 454 | return renderPickerItem(itemValue, virtualRow.index, { |
| 455 | position: "absolute", |
| 456 | top: 0, |
| 457 | left: 0, |
| 458 | width: "100%", |
| 459 | transform: `translateY(${virtualRow.start}px)`, |
| 460 | }); |
| 461 | })} |
| 462 | </div> |
| 463 | ) : ( |
| 464 | filteredBranchPickerItems.map((itemValue, index) => renderPickerItem(itemValue, index)) |
| 465 | )} |
| 448 | 466 | </ComboboxList> |
| 449 | 467 | </ComboboxPopup> |
| 450 | 468 | </Combobox> |