diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 11112e39476a..80b147d129ee 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,6 +1,7 @@ import type { GitBranch, ThreadId } from "@t3tools/contracts"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useOptimistic, useState, useTransition } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useMemo, useOptimistic, useRef, useState, useTransition } from "react"; import { newCommandId } from "../lib/utils"; import { gitBranchesQueryOptions, invalidateGitQueries } from "../lib/gitReactQuery"; @@ -42,13 +43,13 @@ export default function BranchToolbar({ const threads = useStore((store) => store.threads); const projects = useStore((store) => store.projects); const setThreadBranchAction = useStore((store) => store.setThreadBranch); - const setThreadErrorAction = useStore((store) => store.setError); const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId)); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const queryClient = useQueryClient(); const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); const [branchQuery, setBranchQuery] = useState(""); + const branchListRef = useRef(null); const serverThread = threads.find((thread) => thread.id === threadId); const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null; @@ -67,24 +68,44 @@ export default function BranchToolbar({ const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd)); - const branches = dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []); + const branches = useMemo( + () => dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []), + [branchesQuery.data?.branches], + ); const currentGitBranch = branches.find((branch) => branch.current)?.name ?? null; const canonicalActiveBranch = effectiveEnvMode === "worktree" && !activeWorktreePath ? activeThreadBranch : (currentGitBranch ?? activeThreadBranch); - const branchNames = branches.map((branch) => branch.name); - const branchByName = new Map(branches.map((branch) => [branch.name, branch])); + const branchNames = useMemo(() => branches.map((branch) => branch.name), [branches]); + const branchByName = useMemo( + () => new Map(branches.map((branch) => [branch.name, branch] as const)), + [branches], + ); const trimmedBranchQuery = branchQuery.trim(); + const normalizedBranchQuery = trimmedBranchQuery.toLowerCase(); const canCreateBranch = effectiveEnvMode === "local" && trimmedBranchQuery.length > 0; const hasExactBranchMatch = branchByName.has(trimmedBranchQuery); const createBranchItemValue = canCreateBranch ? `__create_new_branch__:${trimmedBranchQuery}` : null; - const branchPickerItems = - createBranchItemValue && !hasExactBranchMatch - ? [...branchNames, createBranchItemValue] - : branchNames; + const branchPickerItems = useMemo( + () => + createBranchItemValue && !hasExactBranchMatch + ? [...branchNames, createBranchItemValue] + : branchNames, + [branchNames, createBranchItemValue, hasExactBranchMatch], + ); + const filteredBranchPickerItems = useMemo( + () => + normalizedBranchQuery.length === 0 + ? branchPickerItems + : branchPickerItems.filter((itemValue) => { + if (createBranchItemValue && itemValue === createBranchItemValue) return true; + return itemValue.toLowerCase().includes(normalizedBranchQuery); + }), + [branchPickerItems, createBranchItemValue, normalizedBranchQuery], + ); // ── Helpers ─────────────────────────────────────────────────────────── @@ -100,10 +121,14 @@ export default function BranchToolbar({ }); }; - const setThreadError = (error: string | null) => { - if (!activeThreadId) return; - setThreadErrorAction(activeThreadId, error); - }; + const branchListVirtualizer = useVirtualizer({ + count: filteredBranchPickerItems.length, + estimateSize: () => 28, + getScrollElement: () => branchListRef.current?.parentElement ?? null, + overscan: 12, + enabled: isBranchMenuOpen, + }); + const virtualBranchRows = branchListVirtualizer.getVirtualItems(); const setThreadBranch = (branch: string | null, worktreePath: string | null) => { if (!activeThreadId) return; @@ -261,7 +286,13 @@ export default function BranchToolbar({ { + if (!isBranchMenuOpen || eventDetails.index < 0) return; + branchListVirtualizer.scrollToIndex(eventDetails.index, { align: "auto" }); + }} onOpenChange={(open) => { setIsBranchMenuOpen(open); if (!open) setBranchQuery(""); @@ -298,54 +329,81 @@ export default function BranchToolbar({ No branches found. - - {(itemValue) => { - if (createBranchItemValue && itemValue === createBranchItemValue) { + +
+ {virtualBranchRows.map((virtualRow) => { + const itemValue = filteredBranchPickerItems[virtualRow.index]; + if (!itemValue) return null; + if (createBranchItemValue && itemValue === createBranchItemValue) { + return ( + createBranch(trimmedBranchQuery)} + > + Create new branch "{trimmedBranchQuery}" + + ); + } + + const branch = branchByName.get(itemValue); + if (!branch) return null; + + const hasSecondaryWorktree = + branch.worktreePath && branch.worktreePath !== activeProject.cwd; + const badge = branch.current + ? "current" + : hasSecondaryWorktree + ? "worktree" + : branch.isRemote + ? "remote" + : branch.isDefault + ? "default" + : null; return ( createBranch(trimmedBranchQuery)} + className={ + itemValue === resolvedActiveBranch ? "bg-accent text-foreground" : undefined + } + style={{ + position: "absolute", + top: 0, + left: 0, + width: "100%", + transform: `translateY(${virtualRow.start}px)`, + }} + onClick={() => selectBranch(branch)} > - Create new branch "{trimmedBranchQuery}" +
+ {itemValue} + {badge && ( + + {badge} + + )} +
); - } - - const branch = branchByName.get(itemValue); - if (!branch) return null; - - const hasSecondaryWorktree = - branch.worktreePath && branch.worktreePath !== activeProject.cwd; - const badge = branch.current - ? "current" - : hasSecondaryWorktree - ? "worktree" - : branch.isRemote - ? "remote" - : branch.isDefault - ? "default" - : null; - return ( - selectBranch(branch)} - > -
- {itemValue} - {badge && ( - {badge} - )} -
-
- ); - }} + })} +