| author | |
| committer | |
| log | fabb3e07348eb864d6a2c33234db1afb59c7e310 |
| tree | 95fbb0fd75bfbc620fdc02924a5be298cd4ad28a |
| parent | 9ca9a862a5d31f4f7dcf0d0fe263ed54026347d5 |
- Move branch combobox logic into `BranchToolbarBranchSelector` to simplify `BranchToolbar`
- Re-fetch git branches when opening the selector so options are current
- Preserve draft env mode correctly when switching between main and worktree branches4 files changed, 462 insertions(+), 340 deletions(-)
apps/web/src/components/BranchToolbar.logic.test.ts+33| ... | ... | @@ -3,9 +3,42 @@ import { describe, expect, it } from "vitest"; |
| 3 | 3 | import { |
| 4 | 4 | dedupeRemoteBranchesWithLocalMatches, |
| 5 | 5 | deriveLocalBranchNameFromRemoteRef, |
| 6 | resolveDraftEnvModeAfterBranchChange, | |
| 6 | 7 | resolveBranchToolbarValue, |
| 7 | 8 | } from "./BranchToolbar.logic"; |
| 8 | 9 | |
| 10 | describe("resolveDraftEnvModeAfterBranchChange", () => { | |
| 11 | it("switches to local mode when returning from an existing worktree to the main worktree", () => { | |
| 12 | expect( | |
| 13 | resolveDraftEnvModeAfterBranchChange({ | |
| 14 | nextWorktreePath: null, | |
| 15 | currentWorktreePath: "/repo/.t3/worktrees/feature-a", | |
| 16 | effectiveEnvMode: "worktree", | |
| 17 | }), | |
| 18 | ).toBe("local"); | |
| 19 | }); | |
| 20 | ||
| 21 | it("keeps new-worktree mode when selecting a base branch before worktree creation", () => { | |
| 22 | expect( | |
| 23 | resolveDraftEnvModeAfterBranchChange({ | |
| 24 | nextWorktreePath: null, | |
| 25 | currentWorktreePath: null, | |
| 26 | effectiveEnvMode: "worktree", | |
| 27 | }), | |
| 28 | ).toBe("worktree"); | |
| 29 | }); | |
| 30 | ||
| 31 | it("uses worktree mode when selecting a branch already attached to a worktree", () => { | |
| 32 | expect( | |
| 33 | resolveDraftEnvModeAfterBranchChange({ | |
| 34 | nextWorktreePath: "/repo/.t3/worktrees/feature-a", | |
| 35 | currentWorktreePath: null, | |
| 36 | effectiveEnvMode: "local", | |
| 37 | }), | |
| 38 | ).toBe("worktree"); | |
| 39 | }); | |
| 40 | }); | |
| 41 | ||
| 9 | 42 | describe("resolveBranchToolbarValue", () => { |
| 10 | 43 | it("defaults new-worktree mode to current git branch when no explicit base branch is set", () => { |
| 11 | 44 | expect( |
apps/web/src/components/BranchToolbar.logic.ts+15| ... | ... | @@ -1,5 +1,20 @@ |
| 1 | 1 | import type { GitBranch } from "@t3tools/contracts"; |
| 2 | 2 | |
| 3 | export function resolveDraftEnvModeAfterBranchChange(input: { | |
| 4 | nextWorktreePath: string | null; | |
| 5 | currentWorktreePath: string | null; | |
| 6 | effectiveEnvMode: "local" | "worktree"; | |
| 7 | }): "local" | "worktree" { | |
| 8 | const { nextWorktreePath, currentWorktreePath, effectiveEnvMode } = input; | |
| 9 | if (nextWorktreePath) { | |
| 10 | return "worktree"; | |
| 11 | } | |
| 12 | if (effectiveEnvMode === "worktree" && !currentWorktreePath) { | |
| 13 | return "worktree"; | |
| 14 | } | |
| 15 | return "local"; | |
| 16 | } | |
| 17 | ||
| 3 | 18 | export function resolveBranchToolbarValue(input: { |
| 4 | 19 | envMode: "local" | "worktree"; |
| 5 | 20 | activeWorktreePath: string | null; |
apps/web/src/components/BranchToolbar.tsx+20-340| ... | ... | @@ -1,30 +1,13 @@ |
| 1 | import type { GitBranch, ThreadId } from "@t3tools/contracts"; | |
| 2 | import { useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 3 | import { useVirtualizer } from "@tanstack/react-virtual"; | |
| 4 | import { useCallback, useEffect, useMemo, useOptimistic, useRef, useState, useTransition } from "react"; | |
| 1 | import type { ThreadId } from "@t3tools/contracts"; | |
| 2 | import { useCallback } from "react"; | |
| 5 | 3 | |
| 6 | 4 | import { newCommandId } from "../lib/utils"; |
| 7 | import { gitBranchesQueryOptions, invalidateGitQueries } from "../lib/gitReactQuery"; | |
| 8 | 5 | import { readNativeApi } from "../nativeApi"; |
| 9 | 6 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 10 | 7 | import { useStore } from "../store"; |
| 11 | import { | |
| 12 | dedupeRemoteBranchesWithLocalMatches, | |
| 13 | deriveLocalBranchNameFromRemoteRef, | |
| 14 | resolveBranchToolbarValue, | |
| 15 | } from "./BranchToolbar.logic"; | |
| 8 | import { resolveDraftEnvModeAfterBranchChange } from "./BranchToolbar.logic"; | |
| 9 | import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; | |
| 16 | 10 | import { Button } from "./ui/button"; |
| 17 | import { | |
| 18 | Combobox, | |
| 19 | ComboboxEmpty, | |
| 20 | ComboboxInput, | |
| 21 | ComboboxItem, | |
| 22 | ComboboxList, | |
| 23 | ComboboxPopup, | |
| 24 | ComboboxTrigger, | |
| 25 | } from "./ui/combobox"; | |
| 26 | import { ChevronDownIcon } from "lucide-react"; | |
| 27 | import { toastManager } from "./ui/toast"; | |
| 28 | 11 | |
| 29 | 12 | interface BranchToolbarProps { |
| 30 | 13 | threadId: ThreadId; |
| ... | ... | @@ -46,11 +29,6 @@ export default function BranchToolbar({ |
| 46 | 29 | const setThreadBranchAction = useStore((store) => store.setThreadBranch); |
| 47 | 30 | const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId)); |
| 48 | 31 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); |
| 49 | const queryClient = useQueryClient(); | |
| 50 | ||
| 51 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); | |
| 52 | const [branchQuery, setBranchQuery] = useState(""); | |
| 53 | const branchListScrollElementRef = useRef<HTMLDivElement | null>(null); | |
| 54 | 32 | |
| 55 | 33 | const serverThread = threads.find((thread) => thread.id === threadId); |
| 56 | 34 | const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null; |
| ... | ... | @@ -65,65 +43,6 @@ export default function BranchToolbar({ |
| 65 | 43 | ? "worktree" |
| 66 | 44 | : envMode; |
| 67 | 45 | |
| 68 | // ── Queries ─────────────────────────────────────────────────────────── | |
| 69 | ||
| 70 | const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd)); | |
| 71 | ||
| 72 | const branches = useMemo( | |
| 73 | () => dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []), | |
| 74 | [branchesQuery.data?.branches], | |
| 75 | ); | |
| 76 | const currentGitBranch = branches.find((branch) => branch.current)?.name ?? null; | |
| 77 | const canonicalActiveBranch = resolveBranchToolbarValue({ | |
| 78 | envMode: effectiveEnvMode, | |
| 79 | activeWorktreePath, | |
| 80 | activeThreadBranch, | |
| 81 | currentGitBranch, | |
| 82 | }); | |
| 83 | const branchNames = useMemo(() => branches.map((branch) => branch.name), [branches]); | |
| 84 | const branchByName = useMemo( | |
| 85 | () => new Map(branches.map((branch) => [branch.name, branch] as const)), | |
| 86 | [branches], | |
| 87 | ); | |
| 88 | const trimmedBranchQuery = branchQuery.trim(); | |
| 89 | const normalizedBranchQuery = trimmedBranchQuery.toLowerCase(); | |
| 90 | const canCreateBranch = effectiveEnvMode === "local" && trimmedBranchQuery.length > 0; | |
| 91 | const hasExactBranchMatch = branchByName.has(trimmedBranchQuery); | |
| 92 | const createBranchItemValue = canCreateBranch | |
| 93 | ? `__create_new_branch__:${trimmedBranchQuery}` | |
| 94 | : null; | |
| 95 | const branchPickerItems = useMemo( | |
| 96 | () => | |
| 97 | createBranchItemValue && !hasExactBranchMatch | |
| 98 | ? [...branchNames, createBranchItemValue] | |
| 99 | : branchNames, | |
| 100 | [branchNames, createBranchItemValue, hasExactBranchMatch], | |
| 101 | ); | |
| 102 | const filteredBranchPickerItems = useMemo( | |
| 103 | () => | |
| 104 | normalizedBranchQuery.length === 0 | |
| 105 | ? branchPickerItems | |
| 106 | : branchPickerItems.filter((itemValue) => { | |
| 107 | if (createBranchItemValue && itemValue === createBranchItemValue) return true; | |
| 108 | return itemValue.toLowerCase().includes(normalizedBranchQuery); | |
| 109 | }), | |
| 110 | [branchPickerItems, createBranchItemValue, normalizedBranchQuery], | |
| 111 | ); | |
| 112 | ||
| 113 | // ── Helpers ─────────────────────────────────────────────────────────── | |
| 114 | ||
| 115 | const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic( | |
| 116 | canonicalActiveBranch, | |
| 117 | (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch, | |
| 118 | ); | |
| 119 | const [isBranchActionPending, startBranchActionTransition] = useTransition(); | |
| 120 | const runBranchAction = (action: () => Promise<void>) => { | |
| 121 | startBranchActionTransition(async () => { | |
| 122 | await action().catch(() => undefined); | |
| 123 | await invalidateGitQueries(queryClient).catch(() => undefined); | |
| 124 | }); | |
| 125 | }; | |
| 126 | ||
| 127 | 46 | const setThreadBranch = useCallback( |
| 128 | 47 | (branch: string | null, worktreePath: string | null) => { |
| 129 | 48 | if (!activeThreadId) return; |
| ... | ... | @@ -154,10 +73,15 @@ export default function BranchToolbar({ |
| 154 | 73 | setThreadBranchAction(activeThreadId, branch, worktreePath); |
| 155 | 74 | return; |
| 156 | 75 | } |
| 76 | const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({ | |
| 77 | nextWorktreePath: worktreePath, | |
| 78 | currentWorktreePath: activeWorktreePath, | |
| 79 | effectiveEnvMode, | |
| 80 | }); | |
| 157 | 81 | setDraftThreadContext(threadId, { |
| 158 | 82 | branch, |
| 159 | 83 | worktreePath, |
| 160 | envMode: effectiveEnvMode, | |
| 84 | envMode: nextDraftEnvMode, | |
| 161 | 85 | }); |
| 162 | 86 | }, |
| 163 | 87 | [ |
| ... | ... | @@ -172,138 +96,6 @@ export default function BranchToolbar({ |
| 172 | 96 | ], |
| 173 | 97 | ); |
| 174 | 98 | |
| 175 | const branchListVirtualizer = useVirtualizer({ | |
| 176 | count: filteredBranchPickerItems.length, | |
| 177 | estimateSize: () => 28, | |
| 178 | getScrollElement: () => branchListScrollElementRef.current, | |
| 179 | overscan: 12, | |
| 180 | enabled: isBranchMenuOpen, | |
| 181 | initialRect: { | |
| 182 | height: 224, | |
| 183 | width: 0, | |
| 184 | }, | |
| 185 | }); | |
| 186 | const virtualBranchRows = branchListVirtualizer.getVirtualItems(); | |
| 187 | const setBranchListRef = useCallback( | |
| 188 | (element: HTMLDivElement | null) => { | |
| 189 | branchListScrollElementRef.current = (element?.parentElement as HTMLDivElement | null) ?? null; | |
| 190 | if (element) { | |
| 191 | branchListVirtualizer.measure(); | |
| 192 | } | |
| 193 | }, | |
| 194 | [branchListVirtualizer], | |
| 195 | ); | |
| 196 | ||
| 197 | useEffect(() => { | |
| 198 | if (effectiveEnvMode !== "worktree" || activeWorktreePath || activeThreadBranch || !currentGitBranch) { | |
| 199 | return; | |
| 200 | } | |
| 201 | setThreadBranch(currentGitBranch, null); | |
| 202 | }, [activeThreadBranch, activeWorktreePath, currentGitBranch, effectiveEnvMode, setThreadBranch]); | |
| 203 | ||
| 204 | useEffect(() => { | |
| 205 | if (!isBranchMenuOpen) return; | |
| 206 | queueMicrotask(() => { | |
| 207 | branchListVirtualizer.measure(); | |
| 208 | }); | |
| 209 | }, [branchListVirtualizer, filteredBranchPickerItems.length, isBranchMenuOpen]); | |
| 210 | ||
| 211 | const selectBranch = (branch: GitBranch) => { | |
| 212 | const api = readNativeApi(); | |
| 213 | if (!api || !activeThreadId || !branchCwd || isBranchActionPending) return; | |
| 214 | ||
| 215 | // For new worktree mode, selecting a branch picks the base branch. | |
| 216 | if (effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath) { | |
| 217 | setThreadBranch(branch.name, null); | |
| 218 | setIsBranchMenuOpen(false); | |
| 219 | onComposerFocusRequest?.(); | |
| 220 | return; | |
| 221 | } | |
| 222 | ||
| 223 | // If the branch already lives in a worktree, redirect there instead of | |
| 224 | // trying to checkout (which git would reject with "already used by worktree"). | |
| 225 | if (branch.worktreePath) { | |
| 226 | const isMainWorktree = branch.worktreePath === activeProject?.cwd; | |
| 227 | // Main worktree → switch back to local (project cwd, worktreePath=null). | |
| 228 | // Secondary worktree → point the thread at that worktree path. | |
| 229 | setThreadBranch(branch.name, isMainWorktree ? null : branch.worktreePath); | |
| 230 | setIsBranchMenuOpen(false); | |
| 231 | onComposerFocusRequest?.(); | |
| 232 | return; | |
| 233 | } | |
| 234 | ||
| 235 | const selectedBranchName = branch.isRemote | |
| 236 | ? deriveLocalBranchNameFromRemoteRef(branch.name) | |
| 237 | : branch.name; | |
| 238 | ||
| 239 | setIsBranchMenuOpen(false); | |
| 240 | onComposerFocusRequest?.(); | |
| 241 | ||
| 242 | runBranchAction(async () => { | |
| 243 | setOptimisticBranch(selectedBranchName); | |
| 244 | try { | |
| 245 | await api.git.checkout({ cwd: branchCwd, branch: branch.name }); | |
| 246 | await invalidateGitQueries(queryClient); | |
| 247 | } catch (error) { | |
| 248 | toastManager.add({ | |
| 249 | type: "error", | |
| 250 | title: "Failed to checkout branch.", | |
| 251 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 252 | }); | |
| 253 | return; | |
| 254 | } | |
| 255 | ||
| 256 | let nextBranchName = selectedBranchName; | |
| 257 | if (branch.isRemote) { | |
| 258 | const status = await api.git.status({ cwd: branchCwd }).catch(() => null); | |
| 259 | if (status?.branch) { | |
| 260 | nextBranchName = status.branch; | |
| 261 | } | |
| 262 | } | |
| 263 | ||
| 264 | setOptimisticBranch(nextBranchName); | |
| 265 | setThreadBranch(nextBranchName, activeWorktreePath); | |
| 266 | }); | |
| 267 | }; | |
| 268 | ||
| 269 | const createBranch = (rawName: string) => { | |
| 270 | const name = rawName.trim(); | |
| 271 | const api = readNativeApi(); | |
| 272 | if (!api || !activeThreadId || !branchCwd || !name || isBranchActionPending) return; | |
| 273 | ||
| 274 | setIsBranchMenuOpen(false); | |
| 275 | onComposerFocusRequest?.(); | |
| 276 | ||
| 277 | runBranchAction(async () => { | |
| 278 | setOptimisticBranch(name); | |
| 279 | ||
| 280 | try { | |
| 281 | await api.git.createBranch({ cwd: branchCwd, branch: name }); | |
| 282 | try { | |
| 283 | await api.git.checkout({ cwd: branchCwd, branch: name }); | |
| 284 | } catch (error) { | |
| 285 | toastManager.add({ | |
| 286 | type: "error", | |
| 287 | title: "Failed to checkout branch.", | |
| 288 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 289 | }); | |
| 290 | return; | |
| 291 | } | |
| 292 | } catch (error) { | |
| 293 | toastManager.add({ | |
| 294 | type: "error", | |
| 295 | title: "Failed to create branch.", | |
| 296 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 297 | }); | |
| 298 | return; | |
| 299 | } | |
| 300 | ||
| 301 | setOptimisticBranch(name); | |
| 302 | setThreadBranch(name, activeWorktreePath); | |
| 303 | setBranchQuery(""); | |
| 304 | }); | |
| 305 | }; | |
| 306 | ||
| 307 | 99 | if (!activeThreadId || !activeProject) return null; |
| 308 | 100 | |
| 309 | 101 | return ( |
| ... | ... | @@ -326,128 +118,16 @@ export default function BranchToolbar({ |
| 326 | 118 | )} |
| 327 | 119 | </div> |
| 328 | 120 | |
| 329 | <Combobox | |
| 330 | items={branchPickerItems} | |
| 331 | filteredItems={filteredBranchPickerItems} | |
| 332 | autoHighlight | |
| 333 | virtualized | |
| 334 | onItemHighlighted={(_value, eventDetails) => { | |
| 335 | if (!isBranchMenuOpen || eventDetails.index < 0) return; | |
| 336 | branchListVirtualizer.scrollToIndex(eventDetails.index, { align: "auto" }); | |
| 337 | }} | |
| 338 | onOpenChange={(open) => { | |
| 339 | setIsBranchMenuOpen(open); | |
| 340 | if (!open) setBranchQuery(""); | |
| 341 | }} | |
| 342 | open={isBranchMenuOpen} | |
| 343 | value={resolvedActiveBranch} | |
| 344 | > | |
| 345 | <ComboboxTrigger | |
| 346 | render={<Button variant="ghost" size="xs" />} | |
| 347 | className="text-muted-foreground/70 hover:text-foreground/80" | |
| 348 | disabled={branchesQuery.isLoading || isBranchActionPending} | |
| 349 | > | |
| 350 | <span className="max-w-[240px] truncate"> | |
| 351 | {resolvedActiveBranch | |
| 352 | ? effectiveEnvMode === "worktree" && !activeWorktreePath | |
| 353 | ? `From ${resolvedActiveBranch}` | |
| 354 | : resolvedActiveBranch | |
| 355 | : "Select branch"} | |
| 356 | </span> | |
| 357 | <ChevronDownIcon /> | |
| 358 | </ComboboxTrigger> | |
| 359 | <ComboboxPopup align="end" side="top" className="w-64"> | |
| 360 | <div className="border-b p-1"> | |
| 361 | <ComboboxInput | |
| 362 | className="[&_input]:font-sans rounded-md" | |
| 363 | inputClassName="ring-0" | |
| 364 | placeholder="Search branches..." | |
| 365 | showTrigger={false} | |
| 366 | size="sm" | |
| 367 | value={branchQuery} | |
| 368 | onChange={(event) => setBranchQuery(event.target.value)} | |
| 369 | /> | |
| 370 | </div> | |
| 371 | <ComboboxEmpty>No branches found.</ComboboxEmpty> | |
| 372 | ||
| 373 | <ComboboxList ref={setBranchListRef} className="max-h-56"> | |
| 374 | <div | |
| 375 | className="relative" | |
| 376 | style={{ | |
| 377 | height: `${branchListVirtualizer.getTotalSize()}px`, | |
| 378 | }} | |
| 379 | > | |
| 380 | {virtualBranchRows.map((virtualRow) => { | |
| 381 | const itemValue = filteredBranchPickerItems[virtualRow.index]; | |
| 382 | if (!itemValue) return null; | |
| 383 | if (createBranchItemValue && itemValue === createBranchItemValue) { | |
| 384 | return ( | |
| 385 | <ComboboxItem | |
| 386 | hideIndicator | |
| 387 | key={itemValue} | |
| 388 | index={virtualRow.index} | |
| 389 | value={itemValue} | |
| 390 | style={{ | |
| 391 | position: "absolute", | |
| 392 | top: 0, | |
| 393 | left: 0, | |
| 394 | width: "100%", | |
| 395 | transform: `translateY(${virtualRow.start}px)`, | |
| 396 | }} | |
| 397 | onClick={() => createBranch(trimmedBranchQuery)} | |
| 398 | > | |
| 399 | <span className="truncate">Create new branch "{trimmedBranchQuery}"</span> | |
| 400 | </ComboboxItem> | |
| 401 | ); | |
| 402 | } | |
| 403 | ||
| 404 | const branch = branchByName.get(itemValue); | |
| 405 | if (!branch) return null; | |
| 406 | ||
| 407 | const hasSecondaryWorktree = | |
| 408 | branch.worktreePath && branch.worktreePath !== activeProject.cwd; | |
| 409 | const badge = branch.current | |
| 410 | ? "current" | |
| 411 | : hasSecondaryWorktree | |
| 412 | ? "worktree" | |
| 413 | : branch.isRemote | |
| 414 | ? "remote" | |
| 415 | : branch.isDefault | |
| 416 | ? "default" | |
| 417 | : null; | |
| 418 | return ( | |
| 419 | <ComboboxItem | |
| 420 | hideIndicator | |
| 421 | key={itemValue} | |
| 422 | index={virtualRow.index} | |
| 423 | value={itemValue} | |
| 424 | className={ | |
| 425 | itemValue === resolvedActiveBranch ? "bg-accent text-foreground" : undefined | |
| 426 | } | |
| 427 | style={{ | |
| 428 | position: "absolute", | |
| 429 | top: 0, | |
| 430 | left: 0, | |
| 431 | width: "100%", | |
| 432 | transform: `translateY(${virtualRow.start}px)`, | |
| 433 | }} | |
| 434 | onClick={() => selectBranch(branch)} | |
| 435 | > | |
| 436 | <div className="flex w-full items-center justify-between gap-2"> | |
| 437 | <span className="truncate">{itemValue}</span> | |
| 438 | {badge && ( | |
| 439 | <span className="shrink-0 text-[10px] text-muted-foreground/45"> | |
| 440 | {badge} | |
| 441 | </span> | |
| 442 | )} | |
| 443 | </div> | |
| 444 | </ComboboxItem> | |
| 445 | ); | |
| 446 | })} | |
| 447 | </div> | |
| 448 | </ComboboxList> | |
| 449 | </ComboboxPopup> | |
| 450 | </Combobox> | |
| 121 | <BranchToolbarBranchSelector | |
| 122 | activeProjectCwd={activeProject.cwd} | |
| 123 | activeThreadBranch={activeThreadBranch} | |
| 124 | activeWorktreePath={activeWorktreePath} | |
| 125 | branchCwd={branchCwd} | |
| 126 | effectiveEnvMode={effectiveEnvMode} | |
| 127 | envLocked={envLocked} | |
| 128 | onSetThreadBranch={setThreadBranch} | |
| 129 | {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})} | |
| 130 | /> | |
| 451 | 131 | </div> |
| 452 | 132 | ); |
| 453 | 133 | } |
apps/web/src/components/BranchToolbarBranchSelector.tsx created+394| ... | ... | @@ -0,0 +1,394 @@ |
| 1 | import type { GitBranch } from "@t3tools/contracts"; | |
| 2 | import { useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 3 | import { useVirtualizer } from "@tanstack/react-virtual"; | |
| 4 | import { ChevronDownIcon } from "lucide-react"; | |
| 5 | import { useCallback, useEffect, useMemo, useOptimistic, useRef, useState, useTransition } from "react"; | |
| 6 | ||
| 7 | import { gitBranchesQueryOptions, gitQueryKeys, invalidateGitQueries } from "../lib/gitReactQuery"; | |
| 8 | import { readNativeApi } from "../nativeApi"; | |
| 9 | import { | |
| 10 | dedupeRemoteBranchesWithLocalMatches, | |
| 11 | deriveLocalBranchNameFromRemoteRef, | |
| 12 | resolveBranchToolbarValue, | |
| 13 | } from "./BranchToolbar.logic"; | |
| 14 | import { Button } from "./ui/button"; | |
| 15 | import { | |
| 16 | Combobox, | |
| 17 | ComboboxEmpty, | |
| 18 | ComboboxInput, | |
| 19 | ComboboxItem, | |
| 20 | ComboboxList, | |
| 21 | ComboboxPopup, | |
| 22 | ComboboxTrigger, | |
| 23 | } from "./ui/combobox"; | |
| 24 | import { toastManager } from "./ui/toast"; | |
| 25 | ||
| 26 | interface BranchToolbarBranchSelectorProps { | |
| 27 | activeProjectCwd: string; | |
| 28 | activeThreadBranch: string | null; | |
| 29 | activeWorktreePath: string | null; | |
| 30 | branchCwd: string | null; | |
| 31 | effectiveEnvMode: "local" | "worktree"; | |
| 32 | envLocked: boolean; | |
| 33 | onSetThreadBranch: (branch: string | null, worktreePath: string | null) => void; | |
| 34 | onComposerFocusRequest?: () => void; | |
| 35 | } | |
| 36 | ||
| 37 | function toBranchActionErrorMessage(error: unknown): string { | |
| 38 | return error instanceof Error ? error.message : "An error occurred."; | |
| 39 | } | |
| 40 | ||
| 41 | function getBranchTriggerLabel(input: { | |
| 42 | activeWorktreePath: string | null; | |
| 43 | effectiveEnvMode: "local" | "worktree"; | |
| 44 | resolvedActiveBranch: string | null; | |
| 45 | }): string { | |
| 46 | const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch } = input; | |
| 47 | if (!resolvedActiveBranch) { | |
| 48 | return "Select branch"; | |
| 49 | } | |
| 50 | if (effectiveEnvMode === "worktree" && !activeWorktreePath) { | |
| 51 | return `From ${resolvedActiveBranch}`; | |
| 52 | } | |
| 53 | return resolvedActiveBranch; | |
| 54 | } | |
| 55 | ||
| 56 | export function BranchToolbarBranchSelector({ | |
| 57 | activeProjectCwd, | |
| 58 | activeThreadBranch, | |
| 59 | activeWorktreePath, | |
| 60 | branchCwd, | |
| 61 | effectiveEnvMode, | |
| 62 | envLocked, | |
| 63 | onSetThreadBranch, | |
| 64 | onComposerFocusRequest, | |
| 65 | }: BranchToolbarBranchSelectorProps) { | |
| 66 | const queryClient = useQueryClient(); | |
| 67 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); | |
| 68 | const [branchQuery, setBranchQuery] = useState(""); | |
| 69 | ||
| 70 | const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd)); | |
| 71 | const branches = useMemo( | |
| 72 | () => dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []), | |
| 73 | [branchesQuery.data?.branches], | |
| 74 | ); | |
| 75 | const currentGitBranch = branches.find((branch) => branch.current)?.name ?? null; | |
| 76 | const canonicalActiveBranch = resolveBranchToolbarValue({ | |
| 77 | envMode: effectiveEnvMode, | |
| 78 | activeWorktreePath, | |
| 79 | activeThreadBranch, | |
| 80 | currentGitBranch, | |
| 81 | }); | |
| 82 | const branchNames = useMemo(() => branches.map((branch) => branch.name), [branches]); | |
| 83 | const branchByName = useMemo( | |
| 84 | () => new Map(branches.map((branch) => [branch.name, branch] as const)), | |
| 85 | [branches], | |
| 86 | ); | |
| 87 | const trimmedBranchQuery = branchQuery.trim(); | |
| 88 | const normalizedBranchQuery = trimmedBranchQuery.toLowerCase(); | |
| 89 | const canCreateBranch = effectiveEnvMode === "local" && trimmedBranchQuery.length > 0; | |
| 90 | const hasExactBranchMatch = branchByName.has(trimmedBranchQuery); | |
| 91 | const createBranchItemValue = canCreateBranch | |
| 92 | ? `__create_new_branch__:${trimmedBranchQuery}` | |
| 93 | : null; | |
| 94 | const branchPickerItems = useMemo( | |
| 95 | () => | |
| 96 | createBranchItemValue && !hasExactBranchMatch | |
| 97 | ? [...branchNames, createBranchItemValue] | |
| 98 | : branchNames, | |
| 99 | [branchNames, createBranchItemValue, hasExactBranchMatch], | |
| 100 | ); | |
| 101 | const filteredBranchPickerItems = useMemo( | |
| 102 | () => | |
| 103 | normalizedBranchQuery.length === 0 | |
| 104 | ? branchPickerItems | |
| 105 | : branchPickerItems.filter((itemValue) => { | |
| 106 | if (createBranchItemValue && itemValue === createBranchItemValue) return true; | |
| 107 | return itemValue.toLowerCase().includes(normalizedBranchQuery); | |
| 108 | }), | |
| 109 | [branchPickerItems, createBranchItemValue, normalizedBranchQuery], | |
| 110 | ); | |
| 111 | const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic( | |
| 112 | canonicalActiveBranch, | |
| 113 | (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch, | |
| 114 | ); | |
| 115 | const [isBranchActionPending, startBranchActionTransition] = useTransition(); | |
| 116 | ||
| 117 | const runBranchAction = (action: () => Promise<void>) => { | |
| 118 | startBranchActionTransition(async () => { | |
| 119 | await action().catch(() => undefined); | |
| 120 | await invalidateGitQueries(queryClient).catch(() => undefined); | |
| 121 | }); | |
| 122 | }; | |
| 123 | ||
| 124 | const selectBranch = (branch: GitBranch) => { | |
| 125 | const api = readNativeApi(); | |
| 126 | if (!api || !branchCwd || isBranchActionPending) return; | |
| 127 | ||
| 128 | // In new-worktree mode, selecting a branch sets the base branch. | |
| 129 | if (effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath) { | |
| 130 | onSetThreadBranch(branch.name, null); | |
| 131 | setIsBranchMenuOpen(false); | |
| 132 | onComposerFocusRequest?.(); | |
| 133 | return; | |
| 134 | } | |
| 135 | ||
| 136 | // If the branch already lives in a worktree, point the thread there. | |
| 137 | if (branch.worktreePath) { | |
| 138 | const isMainWorktree = branch.worktreePath === activeProjectCwd; | |
| 139 | onSetThreadBranch(branch.name, isMainWorktree ? null : branch.worktreePath); | |
| 140 | setIsBranchMenuOpen(false); | |
| 141 | onComposerFocusRequest?.(); | |
| 142 | return; | |
| 143 | } | |
| 144 | ||
| 145 | const selectedBranchName = branch.isRemote | |
| 146 | ? deriveLocalBranchNameFromRemoteRef(branch.name) | |
| 147 | : branch.name; | |
| 148 | ||
| 149 | setIsBranchMenuOpen(false); | |
| 150 | onComposerFocusRequest?.(); | |
| 151 | ||
| 152 | runBranchAction(async () => { | |
| 153 | setOptimisticBranch(selectedBranchName); | |
| 154 | try { | |
| 155 | await api.git.checkout({ cwd: branchCwd, branch: branch.name }); | |
| 156 | await invalidateGitQueries(queryClient); | |
| 157 | } catch (error) { | |
| 158 | toastManager.add({ | |
| 159 | type: "error", | |
| 160 | title: "Failed to checkout branch.", | |
| 161 | description: toBranchActionErrorMessage(error), | |
| 162 | }); | |
| 163 | return; | |
| 164 | } | |
| 165 | ||
| 166 | let nextBranchName = selectedBranchName; | |
| 167 | if (branch.isRemote) { | |
| 168 | const status = await api.git.status({ cwd: branchCwd }).catch(() => null); | |
| 169 | if (status?.branch) { | |
| 170 | nextBranchName = status.branch; | |
| 171 | } | |
| 172 | } | |
| 173 | ||
| 174 | setOptimisticBranch(nextBranchName); | |
| 175 | onSetThreadBranch(nextBranchName, activeWorktreePath); | |
| 176 | }); | |
| 177 | }; | |
| 178 | ||
| 179 | const createBranch = (rawName: string) => { | |
| 180 | const name = rawName.trim(); | |
| 181 | const api = readNativeApi(); | |
| 182 | if (!api || !branchCwd || !name || isBranchActionPending) return; | |
| 183 | ||
| 184 | setIsBranchMenuOpen(false); | |
| 185 | onComposerFocusRequest?.(); | |
| 186 | ||
| 187 | runBranchAction(async () => { | |
| 188 | setOptimisticBranch(name); | |
| 189 | ||
| 190 | try { | |
| 191 | await api.git.createBranch({ cwd: branchCwd, branch: name }); | |
| 192 | try { | |
| 193 | await api.git.checkout({ cwd: branchCwd, branch: name }); | |
| 194 | } catch (error) { | |
| 195 | toastManager.add({ | |
| 196 | type: "error", | |
| 197 | title: "Failed to checkout branch.", | |
| 198 | description: toBranchActionErrorMessage(error), | |
| 199 | }); | |
| 200 | return; | |
| 201 | } | |
| 202 | } catch (error) { | |
| 203 | toastManager.add({ | |
| 204 | type: "error", | |
| 205 | title: "Failed to create branch.", | |
| 206 | description: toBranchActionErrorMessage(error), | |
| 207 | }); | |
| 208 | return; | |
| 209 | } | |
| 210 | ||
| 211 | setOptimisticBranch(name); | |
| 212 | onSetThreadBranch(name, activeWorktreePath); | |
| 213 | setBranchQuery(""); | |
| 214 | }); | |
| 215 | }; | |
| 216 | ||
| 217 | useEffect(() => { | |
| 218 | if (effectiveEnvMode !== "worktree" || activeWorktreePath || activeThreadBranch || !currentGitBranch) { | |
| 219 | return; | |
| 220 | } | |
| 221 | onSetThreadBranch(currentGitBranch, null); | |
| 222 | }, [ | |
| 223 | activeThreadBranch, | |
| 224 | activeWorktreePath, | |
| 225 | currentGitBranch, | |
| 226 | effectiveEnvMode, | |
| 227 | onSetThreadBranch, | |
| 228 | ]); | |
| 229 | ||
| 230 | const handleOpenChange = useCallback( | |
| 231 | (open: boolean) => { | |
| 232 | setIsBranchMenuOpen(open); | |
| 233 | if (!open) { | |
| 234 | setBranchQuery(""); | |
| 235 | return; | |
| 236 | } | |
| 237 | void queryClient.invalidateQueries({ | |
| 238 | queryKey: gitQueryKeys.branches(branchCwd), | |
| 239 | }); | |
| 240 | }, | |
| 241 | [branchCwd, queryClient], | |
| 242 | ); | |
| 243 | ||
| 244 | const branchListScrollElementRef = useRef<HTMLDivElement | null>(null); | |
| 245 | const branchListVirtualizer = useVirtualizer({ | |
| 246 | count: filteredBranchPickerItems.length, | |
| 247 | estimateSize: () => 28, | |
| 248 | getScrollElement: () => branchListScrollElementRef.current, | |
| 249 | overscan: 12, | |
| 250 | enabled: isBranchMenuOpen, | |
| 251 | initialRect: { | |
| 252 | height: 224, | |
| 253 | width: 0, | |
| 254 | }, | |
| 255 | }); | |
| 256 | const virtualBranchRows = branchListVirtualizer.getVirtualItems(); | |
| 257 | const setBranchListRef = useCallback( | |
| 258 | (element: HTMLDivElement | null) => { | |
| 259 | branchListScrollElementRef.current = | |
| 260 | (element?.parentElement as HTMLDivElement | null) ?? null; | |
| 261 | if (element) { | |
| 262 | branchListVirtualizer.measure(); | |
| 263 | } | |
| 264 | }, | |
| 265 | [branchListVirtualizer], | |
| 266 | ); | |
| 267 | ||
| 268 | useEffect(() => { | |
| 269 | if (!isBranchMenuOpen) return; | |
| 270 | queueMicrotask(() => { | |
| 271 | branchListVirtualizer.measure(); | |
| 272 | }); | |
| 273 | }, [branchListVirtualizer, filteredBranchPickerItems.length, isBranchMenuOpen]); | |
| 274 | ||
| 275 | const triggerLabel = getBranchTriggerLabel({ | |
| 276 | activeWorktreePath, | |
| 277 | effectiveEnvMode, | |
| 278 | resolvedActiveBranch, | |
| 279 | }); | |
| 280 | ||
| 281 | return ( | |
| 282 | <Combobox | |
| 283 | items={branchPickerItems} | |
| 284 | filteredItems={filteredBranchPickerItems} | |
| 285 | autoHighlight | |
| 286 | virtualized | |
| 287 | onItemHighlighted={(_value, eventDetails) => { | |
| 288 | if (!isBranchMenuOpen || eventDetails.index < 0) return; | |
| 289 | branchListVirtualizer.scrollToIndex(eventDetails.index, { align: "auto" }); | |
| 290 | }} | |
| 291 | onOpenChange={handleOpenChange} | |
| 292 | open={isBranchMenuOpen} | |
| 293 | value={resolvedActiveBranch} | |
| 294 | > | |
| 295 | <ComboboxTrigger | |
| 296 | render={<Button variant="ghost" size="xs" />} | |
| 297 | className="text-muted-foreground/70 hover:text-foreground/80" | |
| 298 | disabled={branchesQuery.isLoading || isBranchActionPending} | |
| 299 | > | |
| 300 | <span className="max-w-[240px] truncate">{triggerLabel}</span> | |
| 301 | <ChevronDownIcon /> | |
| 302 | </ComboboxTrigger> | |
| 303 | <ComboboxPopup align="end" side="top" className="w-64"> | |
| 304 | <div className="border-b p-1"> | |
| 305 | <ComboboxInput | |
| 306 | className="[&_input]:font-sans rounded-md" | |
| 307 | inputClassName="ring-0" | |
| 308 | placeholder="Search branches..." | |
| 309 | showTrigger={false} | |
| 310 | size="sm" | |
| 311 | value={branchQuery} | |
| 312 | onChange={(event) => setBranchQuery(event.target.value)} | |
| 313 | /> | |
| 314 | </div> | |
| 315 | <ComboboxEmpty>No branches found.</ComboboxEmpty> | |
| 316 | ||
| 317 | <ComboboxList ref={setBranchListRef} className="max-h-56"> | |
| 318 | <div | |
| 319 | className="relative" | |
| 320 | style={{ | |
| 321 | height: `${branchListVirtualizer.getTotalSize()}px`, | |
| 322 | }} | |
| 323 | > | |
| 324 | {virtualBranchRows.map((virtualRow) => { | |
| 325 | const itemValue = filteredBranchPickerItems[virtualRow.index]; | |
| 326 | if (!itemValue) return null; | |
| 327 | if (createBranchItemValue && itemValue === createBranchItemValue) { | |
| 328 | return ( | |
| 329 | <ComboboxItem | |
| 330 | hideIndicator | |
| 331 | key={itemValue} | |
| 332 | index={virtualRow.index} | |
| 333 | value={itemValue} | |
| 334 | style={{ | |
| 335 | position: "absolute", | |
| 336 | top: 0, | |
| 337 | left: 0, | |
| 338 | width: "100%", | |
| 339 | transform: `translateY(${virtualRow.start}px)`, | |
| 340 | }} | |
| 341 | onClick={() => createBranch(trimmedBranchQuery)} | |
| 342 | > | |
| 343 | <span className="truncate">Create new branch "{trimmedBranchQuery}"</span> | |
| 344 | </ComboboxItem> | |
| 345 | ); | |
| 346 | } | |
| 347 | ||
| 348 | const branch = branchByName.get(itemValue); | |
| 349 | if (!branch) return null; | |
| 350 | ||
| 351 | const hasSecondaryWorktree = | |
| 352 | branch.worktreePath && branch.worktreePath !== activeProjectCwd; | |
| 353 | const badge = branch.current | |
| 354 | ? "current" | |
| 355 | : hasSecondaryWorktree | |
| 356 | ? "worktree" | |
| 357 | : branch.isRemote | |
| 358 | ? "remote" | |
| 359 | : branch.isDefault | |
| 360 | ? "default" | |
| 361 | : null; | |
| 362 | return ( | |
| 363 | <ComboboxItem | |
| 364 | hideIndicator | |
| 365 | key={itemValue} | |
| 366 | index={virtualRow.index} | |
| 367 | value={itemValue} | |
| 368 | className={ | |
| 369 | itemValue === resolvedActiveBranch ? "bg-accent text-foreground" : undefined | |
| 370 | } | |
| 371 | style={{ | |
| 372 | position: "absolute", | |
| 373 | top: 0, | |
| 374 | left: 0, | |
| 375 | width: "100%", | |
| 376 | transform: `translateY(${virtualRow.start}px)`, | |
| 377 | }} | |
| 378 | onClick={() => selectBranch(branch)} | |
| 379 | > | |
| 380 | <div className="flex w-full items-center justify-between gap-2"> | |
| 381 | <span className="truncate">{itemValue}</span> | |
| 382 | {badge && ( | |
| 383 | <span className="shrink-0 text-[10px] text-muted-foreground/45">{badge}</span> | |
| 384 | )} | |
| 385 | </div> | |
| 386 | </ComboboxItem> | |
| 387 | ); | |
| 388 | })} | |
| 389 | </div> | |
| 390 | </ComboboxList> | |
| 391 | </ComboboxPopup> | |
| 392 | </Combobox> | |
| 393 | ); | |
| 394 | } |