| ... | @@ -1,30 +1,13 @@ | ... | @@ -1,30 +1,13 @@ |
| 1 | import type { GitBranch, ThreadId } from "@t3tools/contracts"; | 1 | import type { ThreadId } from "@t3tools/contracts"; |
| 2 | import { useQuery, useQueryClient } from "@tanstack/react-query"; | 2 | import { useCallback } from "react"; |
| 3 | import { useVirtualizer } from "@tanstack/react-virtual"; | | |
| 4 | import { useCallback, useEffect, useMemo, useOptimistic, useRef, useState, useTransition } from "react"; | | |
| 5 | | 3 | |
| 6 | import { newCommandId } from "../lib/utils"; | 4 | import { newCommandId } from "../lib/utils"; |
| 7 | import { gitBranchesQueryOptions, invalidateGitQueries } from "../lib/gitReactQuery"; | | |
| 8 | import { readNativeApi } from "../nativeApi"; | 5 | import { readNativeApi } from "../nativeApi"; |
| 9 | import { useComposerDraftStore } from "../composerDraftStore"; | 6 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 10 | import { useStore } from "../store"; | 7 | import { useStore } from "../store"; |
| 11 | import { | 8 | import { resolveDraftEnvModeAfterBranchChange } from "./BranchToolbar.logic"; |
| 12 | dedupeRemoteBranchesWithLocalMatches, | 9 | import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; |
| 13 | deriveLocalBranchNameFromRemoteRef, | | |
| 14 | resolveBranchToolbarValue, | | |
| 15 | } from "./BranchToolbar.logic"; | | |
| 16 | import { Button } from "./ui/button"; | 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 | interface BranchToolbarProps { | 12 | interface BranchToolbarProps { |
| 30 | threadId: ThreadId; | 13 | threadId: ThreadId; |
| ... | @@ -46,11 +29,6 @@ export default function BranchToolbar({ | ... | @@ -46,11 +29,6 @@ export default function BranchToolbar({ |
| 46 | const setThreadBranchAction = useStore((store) => store.setThreadBranch); | 29 | const setThreadBranchAction = useStore((store) => store.setThreadBranch); |
| 47 | const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId)); | 30 | const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId)); |
| 48 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); | 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 | const serverThread = threads.find((thread) => thread.id === threadId); | 33 | const serverThread = threads.find((thread) => thread.id === threadId); |
| 56 | const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null; | 34 | const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null; |
| ... | @@ -65,65 +43,6 @@ export default function BranchToolbar({ | ... | @@ -65,65 +43,6 @@ export default function BranchToolbar({ |
| 65 | ? "worktree" | 43 | ? "worktree" |
| 66 | : envMode; | 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 | const setThreadBranch = useCallback( | 46 | const setThreadBranch = useCallback( |
| 128 | (branch: string | null, worktreePath: string | null) => { | 47 | (branch: string | null, worktreePath: string | null) => { |
| 129 | if (!activeThreadId) return; | 48 | if (!activeThreadId) return; |
| ... | @@ -154,10 +73,15 @@ export default function BranchToolbar({ | ... | @@ -154,10 +73,15 @@ export default function BranchToolbar({ |
| 154 | setThreadBranchAction(activeThreadId, branch, worktreePath); | 73 | setThreadBranchAction(activeThreadId, branch, worktreePath); |
| 155 | return; | 74 | return; |
| 156 | } | 75 | } |
| | 76 | const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({ |
| | 77 | nextWorktreePath: worktreePath, |
| | 78 | currentWorktreePath: activeWorktreePath, |
| | 79 | effectiveEnvMode, |
| | 80 | }); |
| 157 | setDraftThreadContext(threadId, { | 81 | setDraftThreadContext(threadId, { |
| 158 | branch, | 82 | branch, |
| 159 | worktreePath, | 83 | worktreePath, |
| 160 | envMode: effectiveEnvMode, | 84 | envMode: nextDraftEnvMode, |
| 161 | }); | 85 | }); |
| 162 | }, | 86 | }, |
| 163 | [ | 87 | [ |
| ... | @@ -172,138 +96,6 @@ export default function BranchToolbar({ | ... | @@ -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 | if (!activeThreadId || !activeProject) return null; | 99 | if (!activeThreadId || !activeProject) return null; |
| 308 | | 100 | |
| 309 | return ( | 101 | return ( |
| ... | @@ -326,128 +118,16 @@ export default function BranchToolbar({ | ... | @@ -326,128 +118,16 @@ export default function BranchToolbar({ |
| 326 | )} | 118 | )} |
| 327 | </div> | 119 | </div> |
| 328 | | 120 | |
| 329 | <Combobox | 121 | <BranchToolbarBranchSelector |
| 330 | items={branchPickerItems} | 122 | activeProjectCwd={activeProject.cwd} |
| 331 | filteredItems={filteredBranchPickerItems} | 123 | activeThreadBranch={activeThreadBranch} |
| 332 | autoHighlight | 124 | activeWorktreePath={activeWorktreePath} |
| 333 | virtualized | 125 | branchCwd={branchCwd} |
| 334 | onItemHighlighted={(_value, eventDetails) => { | 126 | effectiveEnvMode={effectiveEnvMode} |
| 335 | if (!isBranchMenuOpen || eventDetails.index < 0) return; | 127 | envLocked={envLocked} |
| 336 | branchListVirtualizer.scrollToIndex(eventDetails.index, { align: "auto" }); | 128 | onSetThreadBranch={setThreadBranch} |
| 337 | }} | 129 | {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})} |
| 338 | onOpenChange={(open) => { | 130 | /> |
| 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> | | |
| 451 | </div> | 131 | </div> |
| 452 | ); | 132 | ); |
| 453 | } | 133 | } |