| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import type { GitBranch, ThreadId } from "@t3tools/contracts"; |
| 2 | 2 | import { useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | | import { useOptimistic, useState, useTransition } from "react"; |
| 3 | import { useVirtualizer } from "@tanstack/react-virtual"; |
| 4 | import { useMemo, useOptimistic, useRef, useState, useTransition } from "react"; |
| 4 | 5 | |
| 5 | 6 | import { newCommandId } from "../lib/utils"; |
| 6 | 7 | import { gitBranchesQueryOptions, invalidateGitQueries } from "../lib/gitReactQuery"; |
| ... | ... | @@ -42,13 +43,13 @@ export default function BranchToolbar({ |
| 42 | 43 | const threads = useStore((store) => store.threads); |
| 43 | 44 | const projects = useStore((store) => store.projects); |
| 44 | 45 | const setThreadBranchAction = useStore((store) => store.setThreadBranch); |
| 45 | | const setThreadErrorAction = useStore((store) => store.setError); |
| 46 | 46 | const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId)); |
| 47 | 47 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); |
| 48 | 48 | const queryClient = useQueryClient(); |
| 49 | 49 | |
| 50 | 50 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); |
| 51 | 51 | const [branchQuery, setBranchQuery] = useState(""); |
| 52 | const branchListRef = useRef<HTMLDivElement | null>(null); |
| 52 | 53 | |
| 53 | 54 | const serverThread = threads.find((thread) => thread.id === threadId); |
| 54 | 55 | const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null; |
| ... | ... | @@ -67,24 +68,44 @@ export default function BranchToolbar({ |
| 67 | 68 | |
| 68 | 69 | const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd)); |
| 69 | 70 | |
| 70 | | const branches = dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []); |
| 71 | const branches = useMemo( |
| 72 | () => dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []), |
| 73 | [branchesQuery.data?.branches], |
| 74 | ); |
| 71 | 75 | const currentGitBranch = branches.find((branch) => branch.current)?.name ?? null; |
| 72 | 76 | const canonicalActiveBranch = |
| 73 | 77 | effectiveEnvMode === "worktree" && !activeWorktreePath |
| 74 | 78 | ? activeThreadBranch |
| 75 | 79 | : (currentGitBranch ?? activeThreadBranch); |
| 76 | | const branchNames = branches.map((branch) => branch.name); |
| 77 | | const branchByName = new Map(branches.map((branch) => [branch.name, branch])); |
| 80 | const branchNames = useMemo(() => branches.map((branch) => branch.name), [branches]); |
| 81 | const branchByName = useMemo( |
| 82 | () => new Map(branches.map((branch) => [branch.name, branch] as const)), |
| 83 | [branches], |
| 84 | ); |
| 78 | 85 | const trimmedBranchQuery = branchQuery.trim(); |
| 86 | const normalizedBranchQuery = trimmedBranchQuery.toLowerCase(); |
| 79 | 87 | const canCreateBranch = effectiveEnvMode === "local" && trimmedBranchQuery.length > 0; |
| 80 | 88 | const hasExactBranchMatch = branchByName.has(trimmedBranchQuery); |
| 81 | 89 | const createBranchItemValue = canCreateBranch |
| 82 | 90 | ? `__create_new_branch__:${trimmedBranchQuery}` |
| 83 | 91 | : null; |
| 84 | | const branchPickerItems = |
| 85 | | createBranchItemValue && !hasExactBranchMatch |
| 86 | | ? [...branchNames, createBranchItemValue] |
| 87 | | : branchNames; |
| 92 | const branchPickerItems = useMemo( |
| 93 | () => |
| 94 | createBranchItemValue && !hasExactBranchMatch |
| 95 | ? [...branchNames, createBranchItemValue] |
| 96 | : branchNames, |
| 97 | [branchNames, createBranchItemValue, hasExactBranchMatch], |
| 98 | ); |
| 99 | const filteredBranchPickerItems = useMemo( |
| 100 | () => |
| 101 | normalizedBranchQuery.length === 0 |
| 102 | ? branchPickerItems |
| 103 | : branchPickerItems.filter((itemValue) => { |
| 104 | if (createBranchItemValue && itemValue === createBranchItemValue) return true; |
| 105 | return itemValue.toLowerCase().includes(normalizedBranchQuery); |
| 106 | }), |
| 107 | [branchPickerItems, createBranchItemValue, normalizedBranchQuery], |
| 108 | ); |
| 88 | 109 | |
| 89 | 110 | // ── Helpers ─────────────────────────────────────────────────────────── |
| 90 | 111 | |
| ... | ... | @@ -100,10 +121,14 @@ export default function BranchToolbar({ |
| 100 | 121 | }); |
| 101 | 122 | }; |
| 102 | 123 | |
| 103 | | const setThreadError = (error: string | null) => { |
| 104 | | if (!activeThreadId) return; |
| 105 | | setThreadErrorAction(activeThreadId, error); |
| 106 | | }; |
| 124 | const branchListVirtualizer = useVirtualizer({ |
| 125 | count: filteredBranchPickerItems.length, |
| 126 | estimateSize: () => 28, |
| 127 | getScrollElement: () => branchListRef.current?.parentElement ?? null, |
| 128 | overscan: 12, |
| 129 | enabled: isBranchMenuOpen, |
| 130 | }); |
| 131 | const virtualBranchRows = branchListVirtualizer.getVirtualItems(); |
| 107 | 132 | |
| 108 | 133 | const setThreadBranch = (branch: string | null, worktreePath: string | null) => { |
| 109 | 134 | if (!activeThreadId) return; |
| ... | ... | @@ -261,7 +286,13 @@ export default function BranchToolbar({ |
| 261 | 286 | |
| 262 | 287 | <Combobox |
| 263 | 288 | items={branchPickerItems} |
| 289 | filteredItems={filteredBranchPickerItems} |
| 264 | 290 | autoHighlight |
| 291 | virtualized |
| 292 | onItemHighlighted={(_value, eventDetails) => { |
| 293 | if (!isBranchMenuOpen || eventDetails.index < 0) return; |
| 294 | branchListVirtualizer.scrollToIndex(eventDetails.index, { align: "auto" }); |
| 295 | }} |
| 265 | 296 | onOpenChange={(open) => { |
| 266 | 297 | setIsBranchMenuOpen(open); |
| 267 | 298 | if (!open) setBranchQuery(""); |
| ... | ... | @@ -298,54 +329,81 @@ export default function BranchToolbar({ |
| 298 | 329 | </div> |
| 299 | 330 | <ComboboxEmpty>No branches found.</ComboboxEmpty> |
| 300 | 331 | |
| 301 | | <ComboboxList className="max-h-56"> |
| 302 | | {(itemValue) => { |
| 303 | | if (createBranchItemValue && itemValue === createBranchItemValue) { |
| 332 | <ComboboxList ref={branchListRef} className="max-h-56"> |
| 333 | <div |
| 334 | className="relative" |
| 335 | style={{ |
| 336 | height: `${branchListVirtualizer.getTotalSize()}px`, |
| 337 | }} |
| 338 | > |
| 339 | {virtualBranchRows.map((virtualRow) => { |
| 340 | const itemValue = filteredBranchPickerItems[virtualRow.index]; |
| 341 | if (!itemValue) return null; |
| 342 | if (createBranchItemValue && itemValue === createBranchItemValue) { |
| 343 | return ( |
| 344 | <ComboboxItem |
| 345 | hideIndicator |
| 346 | key={itemValue} |
| 347 | index={virtualRow.index} |
| 348 | value={itemValue} |
| 349 | style={{ |
| 350 | position: "absolute", |
| 351 | top: 0, |
| 352 | left: 0, |
| 353 | width: "100%", |
| 354 | transform: `translateY(${virtualRow.start}px)`, |
| 355 | }} |
| 356 | onClick={() => createBranch(trimmedBranchQuery)} |
| 357 | > |
| 358 | <span className="truncate">Create new branch "{trimmedBranchQuery}"</span> |
| 359 | </ComboboxItem> |
| 360 | ); |
| 361 | } |
| 362 | |
| 363 | const branch = branchByName.get(itemValue); |
| 364 | if (!branch) return null; |
| 365 | |
| 366 | const hasSecondaryWorktree = |
| 367 | branch.worktreePath && branch.worktreePath !== activeProject.cwd; |
| 368 | const badge = branch.current |
| 369 | ? "current" |
| 370 | : hasSecondaryWorktree |
| 371 | ? "worktree" |
| 372 | : branch.isRemote |
| 373 | ? "remote" |
| 374 | : branch.isDefault |
| 375 | ? "default" |
| 376 | : null; |
| 304 | 377 | return ( |
| 305 | 378 | <ComboboxItem |
| 306 | 379 | hideIndicator |
| 307 | 380 | key={itemValue} |
| 381 | index={virtualRow.index} |
| 308 | 382 | value={itemValue} |
| 309 | | onClick={() => createBranch(trimmedBranchQuery)} |
| 383 | className={ |
| 384 | itemValue === resolvedActiveBranch ? "bg-accent text-foreground" : undefined |
| 385 | } |
| 386 | style={{ |
| 387 | position: "absolute", |
| 388 | top: 0, |
| 389 | left: 0, |
| 390 | width: "100%", |
| 391 | transform: `translateY(${virtualRow.start}px)`, |
| 392 | }} |
| 393 | onClick={() => selectBranch(branch)} |
| 310 | 394 | > |
| 311 | | <span className="truncate">Create new branch "{trimmedBranchQuery}"</span> |
| 395 | <div className="flex w-full items-center justify-between gap-2"> |
| 396 | <span className="truncate">{itemValue}</span> |
| 397 | {badge && ( |
| 398 | <span className="shrink-0 text-[10px] text-muted-foreground/45"> |
| 399 | {badge} |
| 400 | </span> |
| 401 | )} |
| 402 | </div> |
| 312 | 403 | </ComboboxItem> |
| 313 | 404 | ); |
| 314 | | } |
| 315 | | |
| 316 | | const branch = branchByName.get(itemValue); |
| 317 | | if (!branch) return null; |
| 318 | | |
| 319 | | const hasSecondaryWorktree = |
| 320 | | branch.worktreePath && branch.worktreePath !== activeProject.cwd; |
| 321 | | const badge = branch.current |
| 322 | | ? "current" |
| 323 | | : hasSecondaryWorktree |
| 324 | | ? "worktree" |
| 325 | | : branch.isRemote |
| 326 | | ? "remote" |
| 327 | | : branch.isDefault |
| 328 | | ? "default" |
| 329 | | : null; |
| 330 | | return ( |
| 331 | | <ComboboxItem |
| 332 | | hideIndicator |
| 333 | | key={itemValue} |
| 334 | | value={itemValue} |
| 335 | | className={ |
| 336 | | itemValue === resolvedActiveBranch ? "bg-accent text-foreground" : undefined |
| 337 | | } |
| 338 | | onClick={() => selectBranch(branch)} |
| 339 | | > |
| 340 | | <div className="flex w-full items-center justify-between gap-2"> |
| 341 | | <span className="truncate">{itemValue}</span> |
| 342 | | {badge && ( |
| 343 | | <span className="shrink-0 text-[10px] text-muted-foreground/45">{badge}</span> |
| 344 | | )} |
| 345 | | </div> |
| 346 | | </ComboboxItem> |
| 347 | | ); |
| 348 | | }} |
| 405 | })} |
| 406 | </div> |
| 349 | 407 | </ComboboxList> |
| 350 | 408 | </ComboboxPopup> |
| 351 | 409 | </Combobox> |