| ... | @@ -3,7 +3,9 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; | ... | @@ -3,7 +3,9 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | import { useVirtualizer } from "@tanstack/react-virtual"; | 3 | import { useVirtualizer } from "@tanstack/react-virtual"; |
| 4 | import { ChevronDownIcon } from "lucide-react"; | 4 | import { ChevronDownIcon } from "lucide-react"; |
| 5 | import { | 5 | import { |
| | 6 | type CSSProperties, |
| 6 | useCallback, | 7 | useCallback, |
| | 8 | useDeferredValue, |
| 7 | useEffect, | 9 | useEffect, |
| 8 | useMemo, | 10 | useMemo, |
| 9 | useOptimistic, | 11 | useOptimistic, |
| ... | @@ -12,13 +14,19 @@ import { | ... | @@ -12,13 +14,19 @@ import { |
| 12 | useTransition, | 14 | useTransition, |
| 13 | } from "react"; | 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 | import { readNativeApi } from "../nativeApi"; | 23 | import { readNativeApi } from "../nativeApi"; |
| 17 | import { parsePullRequestReference } from "../pullRequestReference"; | 24 | import { parsePullRequestReference } from "../pullRequestReference"; |
| 18 | import { | 25 | import { |
| 19 | dedupeRemoteBranchesWithLocalMatches, | 26 | dedupeRemoteBranchesWithLocalMatches, |
| 20 | deriveLocalBranchNameFromRemoteRef, | 27 | deriveLocalBranchNameFromRemoteRef, |
| 21 | EnvMode, | 28 | EnvMode, |
| | 29 | filterBranchPickerItems, |
| 22 | resolveBranchToolbarValue, | 30 | resolveBranchToolbarValue, |
| 23 | } from "./BranchToolbar.logic"; | 31 | } from "./BranchToolbar.logic"; |
| 24 | import { Button } from "./ui/button"; | 32 | import { Button } from "./ui/button"; |
| ... | @@ -78,13 +86,16 @@ export function BranchToolbarBranchSelector({ | ... | @@ -78,13 +86,16 @@ export function BranchToolbarBranchSelector({ |
| 78 | const queryClient = useQueryClient(); | 86 | const queryClient = useQueryClient(); |
| 79 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); | 87 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); |
| 80 | const [branchQuery, setBranchQuery] = useState(""); | 88 | const [branchQuery, setBranchQuery] = useState(""); |
| | 89 | const deferredBranchQuery = useDeferredValue(branchQuery); |
| 81 | | 90 | |
| 82 | const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd)); | 91 | const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd)); |
| | 92 | const branchStatusQuery = useQuery(gitStatusQueryOptions(branchCwd)); |
| 83 | const branches = useMemo( | 93 | const branches = useMemo( |
| 84 | () => dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []), | 94 | () => dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []), |
| 85 | [branchesQuery.data?.branches], | 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 | const canonicalActiveBranch = resolveBranchToolbarValue({ | 99 | const canonicalActiveBranch = resolveBranchToolbarValue({ |
| 89 | envMode: effectiveEnvMode, | 100 | envMode: effectiveEnvMode, |
| 90 | activeWorktreePath, | 101 | activeWorktreePath, |
| ... | @@ -97,12 +108,15 @@ export function BranchToolbarBranchSelector({ | ... | @@ -97,12 +108,15 @@ export function BranchToolbarBranchSelector({ |
| 97 | [branches], | 108 | [branches], |
| 98 | ); | 109 | ); |
| 99 | const trimmedBranchQuery = branchQuery.trim(); | 110 | const trimmedBranchQuery = branchQuery.trim(); |
| 100 | const normalizedBranchQuery = trimmedBranchQuery.toLowerCase(); | 111 | const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); |
| | 112 | const normalizedDeferredBranchQuery = deferredTrimmedBranchQuery.toLowerCase(); |
| 101 | const prReference = parsePullRequestReference(trimmedBranchQuery); | 113 | const prReference = parsePullRequestReference(trimmedBranchQuery); |
| | 114 | const isSelectingWorktreeBase = |
| | 115 | effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath; |
| 102 | const checkoutPullRequestItemValue = prReference && onCheckoutPullRequestRequest | 116 | const checkoutPullRequestItemValue = prReference && onCheckoutPullRequestRequest |
| 103 | ? `__checkout_pull_request__:${prReference}` | 117 | ? `__checkout_pull_request__:${prReference}` |
| 104 | : null; | 118 | : null; |
| 105 | const canCreateBranch = effectiveEnvMode === "local" && trimmedBranchQuery.length > 0; | 119 | const canCreateBranch = !isSelectingWorktreeBase && trimmedBranchQuery.length > 0; |
| 106 | const hasExactBranchMatch = branchByName.has(trimmedBranchQuery); | 120 | const hasExactBranchMatch = branchByName.has(trimmedBranchQuery); |
| 107 | const createBranchItemValue = canCreateBranch | 121 | const createBranchItemValue = canCreateBranch |
| 108 | ? `__create_new_branch__:${trimmedBranchQuery}` | 122 | ? `__create_new_branch__:${trimmedBranchQuery}` |
| ... | @@ -122,19 +136,25 @@ export function BranchToolbarBranchSelector({ | ... | @@ -122,19 +136,25 @@ export function BranchToolbarBranchSelector({ |
| 122 | ); | 136 | ); |
| 123 | const filteredBranchPickerItems = useMemo( | 137 | const filteredBranchPickerItems = useMemo( |
| 124 | () => | 138 | () => |
| 125 | normalizedBranchQuery.length === 0 | 139 | filterBranchPickerItems({ |
| 126 | ? branchPickerItems | 140 | itemValues: branchPickerItems, |
| 127 | : branchPickerItems.filter((itemValue) => { | 141 | normalizedQuery: normalizedDeferredBranchQuery, |
| 128 | if (createBranchItemValue && itemValue === createBranchItemValue) return true; | 142 | createBranchItemValue, |
| 129 | return itemValue.toLowerCase().includes(normalizedBranchQuery); | 143 | checkoutPullRequestItemValue, |
| 130 | }), | 144 | }), |
| 131 | [branchPickerItems, createBranchItemValue, normalizedBranchQuery], | 145 | [ |
| | 146 | branchPickerItems, |
| | 147 | checkoutPullRequestItemValue, |
| | 148 | createBranchItemValue, |
| | 149 | normalizedDeferredBranchQuery, |
| | 150 | ], |
| 132 | ); | 151 | ); |
| 133 | const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic( | 152 | const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic( |
| 134 | canonicalActiveBranch, | 153 | canonicalActiveBranch, |
| 135 | (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch, | 154 | (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch, |
| 136 | ); | 155 | ); |
| 137 | const [isBranchActionPending, startBranchActionTransition] = useTransition(); | 156 | const [isBranchActionPending, startBranchActionTransition] = useTransition(); |
| | 157 | const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; |
| 138 | | 158 | |
| 139 | const runBranchAction = (action: () => Promise<void>) => { | 159 | const runBranchAction = (action: () => Promise<void>) => { |
| 140 | startBranchActionTransition(async () => { | 160 | startBranchActionTransition(async () => { |
| ... | @@ -148,7 +168,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -148,7 +168,7 @@ export function BranchToolbarBranchSelector({ |
| 148 | if (!api || !branchCwd || isBranchActionPending) return; | 168 | if (!api || !branchCwd || isBranchActionPending) return; |
| 149 | | 169 | |
| 150 | // In new-worktree mode, selecting a branch sets the base branch. | 170 | // In new-worktree mode, selecting a branch sets the base branch. |
| 151 | if (effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath) { | 171 | if (isSelectingWorktreeBase) { |
| 152 | onSetThreadBranch(branch.name, null); | 172 | onSetThreadBranch(branch.name, null); |
| 153 | setIsBranchMenuOpen(false); | 173 | setIsBranchMenuOpen(false); |
| 154 | onComposerFocusRequest?.(); | 174 | onComposerFocusRequest?.(); |
| ... | @@ -274,7 +294,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -274,7 +294,7 @@ export function BranchToolbarBranchSelector({ |
| 274 | estimateSize: () => 28, | 294 | estimateSize: () => 28, |
| 275 | getScrollElement: () => branchListScrollElementRef.current, | 295 | getScrollElement: () => branchListScrollElementRef.current, |
| 276 | overscan: 12, | 296 | overscan: 12, |
| 277 | enabled: isBranchMenuOpen, | 297 | enabled: isBranchMenuOpen && shouldVirtualizeBranchList, |
| 278 | initialRect: { | 298 | initialRect: { |
| 279 | height: 224, | 299 | height: 224, |
| 280 | width: 0, | 300 | width: 0, |
| ... | @@ -293,11 +313,16 @@ export function BranchToolbarBranchSelector({ | ... | @@ -293,11 +313,16 @@ export function BranchToolbarBranchSelector({ |
| 293 | ); | 313 | ); |
| 294 | | 314 | |
| 295 | useEffect(() => { | 315 | useEffect(() => { |
| 296 | if (!isBranchMenuOpen) return; | 316 | if (!isBranchMenuOpen || !shouldVirtualizeBranchList) return; |
| 297 | queueMicrotask(() => { | 317 | queueMicrotask(() => { |
| 298 | branchListVirtualizer.measure(); | 318 | branchListVirtualizer.measure(); |
| 299 | }); | 319 | }); |
| 300 | }, [branchListVirtualizer, filteredBranchPickerItems.length, isBranchMenuOpen]); | 320 | }, [ |
| | 321 | branchListVirtualizer, |
| | 322 | filteredBranchPickerItems.length, |
| | 323 | isBranchMenuOpen, |
| | 324 | shouldVirtualizeBranchList, |
| | 325 | ]); |
| 301 | | 326 | |
| 302 | const triggerLabel = getBranchTriggerLabel({ | 327 | const triggerLabel = getBranchTriggerLabel({ |
| 303 | activeWorktreePath, | 328 | activeWorktreePath, |
| ... | @@ -305,6 +330,80 @@ export function BranchToolbarBranchSelector({ | ... | @@ -305,6 +330,80 @@ export function BranchToolbarBranchSelector({ |
| 305 | resolvedActiveBranch, | 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 | return ( | 407 | return ( |
| 309 | <Combobox | 408 | <Combobox |
| 310 | items={branchPickerItems} | 409 | items={branchPickerItems} |
| ... | @@ -322,12 +421,12 @@ export function BranchToolbarBranchSelector({ | ... | @@ -322,12 +421,12 @@ export function BranchToolbarBranchSelector({ |
| 322 | <ComboboxTrigger | 421 | <ComboboxTrigger |
| 323 | render={<Button variant="ghost" size="xs" />} | 422 | render={<Button variant="ghost" size="xs" />} |
| 324 | className="text-muted-foreground/70 hover:text-foreground/80" | 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 | <span className="max-w-[240px] truncate">{triggerLabel}</span> | 426 | <span className="max-w-[240px] truncate">{triggerLabel}</span> |
| 328 | <ChevronDownIcon /> | 427 | <ChevronDownIcon /> |
| 329 | </ComboboxTrigger> | 428 | </ComboboxTrigger> |
| 330 | <ComboboxPopup align="end" side="top" className="w-64"> | 429 | <ComboboxPopup align="end" side="top" className="w-80"> |
| 331 | <div className="border-b p-1"> | 430 | <div className="border-b p-1"> |
| 332 | <ComboboxInput | 431 | <ComboboxInput |
| 333 | className="[&_input]:font-sans rounded-md" | 432 | className="[&_input]:font-sans rounded-md" |
| ... | @@ -342,109 +441,28 @@ export function BranchToolbarBranchSelector({ | ... | @@ -342,109 +441,28 @@ export function BranchToolbarBranchSelector({ |
| 342 | <ComboboxEmpty>No branches found.</ComboboxEmpty> | 441 | <ComboboxEmpty>No branches found.</ComboboxEmpty> |
| 343 | | 442 | |
| 344 | <ComboboxList ref={setBranchListRef} className="max-h-56"> | 443 | <ComboboxList ref={setBranchListRef} className="max-h-56"> |
| 345 | <div | 444 | {shouldVirtualizeBranchList ? ( |
| 346 | className="relative" | 445 | <div |
| 347 | style={{ | 446 | className="relative" |
| 348 | height: `${branchListVirtualizer.getTotalSize()}px`, | 447 | style={{ |
| 349 | }} | 448 | height: `${branchListVirtualizer.getTotalSize()}px`, |
| 350 | > | 449 | }} |
| 351 | {virtualBranchRows.map((virtualRow) => { | 450 | > |
| 352 | const itemValue = filteredBranchPickerItems[virtualRow.index]; | 451 | {virtualBranchRows.map((virtualRow) => { |
| 353 | if (!itemValue) return null; | 452 | const itemValue = filteredBranchPickerItems[virtualRow.index]; |
| 354 | if (checkoutPullRequestItemValue && itemValue === checkoutPullRequestItemValue) { | 453 | if (!itemValue) return null; |
| 355 | return ( | 454 | return renderPickerItem(itemValue, virtualRow.index, { |
| 356 | <ComboboxItem | 455 | position: "absolute", |
| 357 | hideIndicator | 456 | top: 0, |
| 358 | key={itemValue} | 457 | left: 0, |
| 359 | index={virtualRow.index} | 458 | width: "100%", |
| 360 | value={itemValue} | 459 | transform: `translateY(${virtualRow.start}px)`, |
| 361 | style={{ | 460 | }); |
| 362 | position: "absolute", | 461 | })} |
| 363 | top: 0, | 462 | </div> |
| 364 | left: 0, | 463 | ) : ( |
| 365 | width: "100%", | 464 | filteredBranchPickerItems.map((itemValue, index) => renderPickerItem(itemValue, index)) |
| 366 | transform: `translateY(${virtualRow.start}px)`, | 465 | )} |
| 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> | | |
| 448 | </ComboboxList> | 466 | </ComboboxList> |
| 449 | </ComboboxPopup> | 467 | </ComboboxPopup> |
| 450 | </Combobox> | 468 | </Combobox> |