| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import type { GitBranch, ThreadId } from "@t3tools/contracts"; | 1 | import type { GitBranch, ThreadId } from "@t3tools/contracts"; |
| 2 | import { useQuery, useQueryClient } from "@tanstack/react-query"; | 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 | import { newCommandId } from "../lib/utils"; | 6 | import { newCommandId } from "../lib/utils"; |
| 6 | import { gitBranchesQueryOptions, invalidateGitQueries } from "../lib/gitReactQuery"; | 7 | import { gitBranchesQueryOptions, invalidateGitQueries } from "../lib/gitReactQuery"; |
| ... | @@ -42,13 +43,13 @@ export default function BranchToolbar({ | ... | @@ -42,13 +43,13 @@ export default function BranchToolbar({ |
| 42 | const threads = useStore((store) => store.threads); | 43 | const threads = useStore((store) => store.threads); |
| 43 | const projects = useStore((store) => store.projects); | 44 | const projects = useStore((store) => store.projects); |
| 44 | const setThreadBranchAction = useStore((store) => store.setThreadBranch); | 45 | const setThreadBranchAction = useStore((store) => store.setThreadBranch); |
| 45 | const setThreadErrorAction = useStore((store) => store.setError); | | |
| 46 | const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId)); | 46 | const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId)); |
| 47 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); | 47 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); |
| 48 | const queryClient = useQueryClient(); | 48 | const queryClient = useQueryClient(); |
| 49 | | 49 | |
| 50 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); | 50 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); |
| 51 | const [branchQuery, setBranchQuery] = useState(""); | 51 | const [branchQuery, setBranchQuery] = useState(""); |
| | 52 | const branchListRef = useRef<HTMLDivElement | null>(null); |
| 52 | | 53 | |
| 53 | const serverThread = threads.find((thread) => thread.id === threadId); | 54 | const serverThread = threads.find((thread) => thread.id === threadId); |
| 54 | const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null; | 55 | const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null; |
| ... | @@ -67,24 +68,44 @@ export default function BranchToolbar({ | ... | @@ -67,24 +68,44 @@ export default function BranchToolbar({ |
| 67 | | 68 | |
| 68 | const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd)); | 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 | const currentGitBranch = branches.find((branch) => branch.current)?.name ?? null; | 75 | const currentGitBranch = branches.find((branch) => branch.current)?.name ?? null; |
| 72 | const canonicalActiveBranch = | 76 | const canonicalActiveBranch = |
| 73 | effectiveEnvMode === "worktree" && !activeWorktreePath | 77 | effectiveEnvMode === "worktree" && !activeWorktreePath |
| 74 | ? activeThreadBranch | 78 | ? activeThreadBranch |
| 75 | : (currentGitBranch ?? activeThreadBranch); | 79 | : (currentGitBranch ?? activeThreadBranch); |
| 76 | const branchNames = branches.map((branch) => branch.name); | 80 | const branchNames = useMemo(() => branches.map((branch) => branch.name), [branches]); |
| 77 | const branchByName = new Map(branches.map((branch) => [branch.name, branch])); | 81 | const branchByName = useMemo( |
| | 82 | () => new Map(branches.map((branch) => [branch.name, branch] as const)), |
| | 83 | [branches], |
| | 84 | ); |
| 78 | const trimmedBranchQuery = branchQuery.trim(); | 85 | const trimmedBranchQuery = branchQuery.trim(); |
| | 86 | const normalizedBranchQuery = trimmedBranchQuery.toLowerCase(); |
| 79 | const canCreateBranch = effectiveEnvMode === "local" && trimmedBranchQuery.length > 0; | 87 | const canCreateBranch = effectiveEnvMode === "local" && trimmedBranchQuery.length > 0; |
| 80 | const hasExactBranchMatch = branchByName.has(trimmedBranchQuery); | 88 | const hasExactBranchMatch = branchByName.has(trimmedBranchQuery); |
| 81 | const createBranchItemValue = canCreateBranch | 89 | const createBranchItemValue = canCreateBranch |
| 82 | ? `__create_new_branch__:${trimmedBranchQuery}` | 90 | ? `__create_new_branch__:${trimmedBranchQuery}` |
| 83 | : null; | 91 | : null; |
| 84 | const branchPickerItems = | 92 | const branchPickerItems = useMemo( |
| 85 | createBranchItemValue && !hasExactBranchMatch | 93 | () => |
| 86 | ? [...branchNames, createBranchItemValue] | 94 | createBranchItemValue && !hasExactBranchMatch |
| 87 | : branchNames; | 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 | // ── Helpers ─────────────────────────────────────────────────────────── | 110 | // ── Helpers ─────────────────────────────────────────────────────────── |
| 90 | | 111 | |
| ... | @@ -100,10 +121,14 @@ export default function BranchToolbar({ | ... | @@ -100,10 +121,14 @@ export default function BranchToolbar({ |
| 100 | }); | 121 | }); |
| 101 | }; | 122 | }; |
| 102 | | 123 | |
| 103 | const setThreadError = (error: string | null) => { | 124 | const branchListVirtualizer = useVirtualizer({ |
| 104 | if (!activeThreadId) return; | 125 | count: filteredBranchPickerItems.length, |
| 105 | setThreadErrorAction(activeThreadId, error); | 126 | estimateSize: () => 28, |
| 106 | }; | 127 | getScrollElement: () => branchListRef.current?.parentElement ?? null, |
| | 128 | overscan: 12, |
| | 129 | enabled: isBranchMenuOpen, |
| | 130 | }); |
| | 131 | const virtualBranchRows = branchListVirtualizer.getVirtualItems(); |
| 107 | | 132 | |
| 108 | const setThreadBranch = (branch: string | null, worktreePath: string | null) => { | 133 | const setThreadBranch = (branch: string | null, worktreePath: string | null) => { |
| 109 | if (!activeThreadId) return; | 134 | if (!activeThreadId) return; |
| ... | @@ -261,7 +286,13 @@ export default function BranchToolbar({ | ... | @@ -261,7 +286,13 @@ export default function BranchToolbar({ |
| 261 | | 286 | |
| 262 | <Combobox | 287 | <Combobox |
| 263 | items={branchPickerItems} | 288 | items={branchPickerItems} |
| | 289 | filteredItems={filteredBranchPickerItems} |
| 264 | autoHighlight | 290 | autoHighlight |
| | 291 | virtualized |
| | 292 | onItemHighlighted={(_value, eventDetails) => { |
| | 293 | if (!isBranchMenuOpen || eventDetails.index < 0) return; |
| | 294 | branchListVirtualizer.scrollToIndex(eventDetails.index, { align: "auto" }); |
| | 295 | }} |
| 265 | onOpenChange={(open) => { | 296 | onOpenChange={(open) => { |
| 266 | setIsBranchMenuOpen(open); | 297 | setIsBranchMenuOpen(open); |
| 267 | if (!open) setBranchQuery(""); | 298 | if (!open) setBranchQuery(""); |
| ... | @@ -298,54 +329,81 @@ export default function BranchToolbar({ | ... | @@ -298,54 +329,81 @@ export default function BranchToolbar({ |
| 298 | </div> | 329 | </div> |
| 299 | <ComboboxEmpty>No branches found.</ComboboxEmpty> | 330 | <ComboboxEmpty>No branches found.</ComboboxEmpty> |
| 300 | | 331 | |
| 301 | <ComboboxList className="max-h-56"> | 332 | <ComboboxList ref={branchListRef} className="max-h-56"> |
| 302 | {(itemValue) => { | 333 | <div |
| 303 | if (createBranchItemValue && itemValue === createBranchItemValue) { | 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 | return ( | 377 | return ( |
| 305 | <ComboboxItem | 378 | <ComboboxItem |
| 306 | hideIndicator | 379 | hideIndicator |
| 307 | key={itemValue} | 380 | key={itemValue} |
| | 381 | index={virtualRow.index} |
| 308 | value={itemValue} | 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 | </ComboboxItem> | 403 | </ComboboxItem> |
| 313 | ); | 404 | ); |
| 314 | } | 405 | })} |
| 315 | | 406 | </div> |
| 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 | }} | | |
| 349 | </ComboboxList> | 407 | </ComboboxList> |
| 350 | </ComboboxPopup> | 408 | </ComboboxPopup> |
| 351 | </Combobox> | 409 | </Combobox> |