authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-02 19:25:19-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-02 20:31:39-08:00
log5c26df3a4fe5ff0141e771ed7a26a5abd23185b5
treef7a47e4b6d0583e34b2a9a8bf1dda65fe31b69dc
parent302323a64b0e4342af013784f4422020b7c1fa53

Virtualize branch picker list and forward ComboboxList ref

- Add virtualized rendering and filtered item handling in `BranchToolbar` - Memoize branch-derived data and sync highlight scrolling for large branch sets - Convert `ComboboxList` to `forwardRef` to support virtualizer scroll targeting

2 files changed, 128 insertions(+), 67 deletions(-)

apps/web/src/components/BranchToolbar.tsx+111-53
......@@ -1,6 +1,7 @@
11import type { GitBranch, ThreadId } from "@t3tools/contracts";
22import { useQuery, useQueryClient } from "@tanstack/react-query";
3import { useOptimistic, useState, useTransition } from "react";
3import { useVirtualizer } from "@tanstack/react-virtual";
4import { useMemo, useOptimistic, useRef, useState, useTransition } from "react";
45
56import { newCommandId } from "../lib/utils";
67import { gitBranchesQueryOptions, invalidateGitQueries } from "../lib/gitReactQuery";
......@@ -42,13 +43,13 @@ export default function BranchToolbar({
4243 const threads = useStore((store) => store.threads);
4344 const projects = useStore((store) => store.projects);
4445 const setThreadBranchAction = useStore((store) => store.setThreadBranch);
45 const setThreadErrorAction = useStore((store) => store.setError);
4646 const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId));
4747 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
4848 const queryClient = useQueryClient();
4949
5050 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
5151 const [branchQuery, setBranchQuery] = useState("");
52 const branchListRef = useRef<HTMLDivElement | null>(null);
5253
5354 const serverThread = threads.find((thread) => thread.id === threadId);
5455 const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null;
......@@ -67,24 +68,44 @@ export default function BranchToolbar({
6768
6869 const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd));
6970
70 const branches = dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []);
71 const branches = useMemo(
72 () => dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []),
73 [branchesQuery.data?.branches],
74 );
7175 const currentGitBranch = branches.find((branch) => branch.current)?.name ?? null;
7276 const canonicalActiveBranch =
7377 effectiveEnvMode === "worktree" && !activeWorktreePath
7478 ? activeThreadBranch
7579 : (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 );
7885 const trimmedBranchQuery = branchQuery.trim();
86 const normalizedBranchQuery = trimmedBranchQuery.toLowerCase();
7987 const canCreateBranch = effectiveEnvMode === "local" && trimmedBranchQuery.length > 0;
8088 const hasExactBranchMatch = branchByName.has(trimmedBranchQuery);
8189 const createBranchItemValue = canCreateBranch
8290 ? `__create_new_branch__:${trimmedBranchQuery}`
8391 : 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 );
88109
89110 // ── Helpers ───────────────────────────────────────────────────────────
90111
......@@ -100,10 +121,14 @@ export default function BranchToolbar({
100121 });
101122 };
102123
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();
107132
108133 const setThreadBranch = (branch: string | null, worktreePath: string | null) => {
109134 if (!activeThreadId) return;
......@@ -261,7 +286,13 @@ export default function BranchToolbar({
261286
262287 <Combobox
263288 items={branchPickerItems}
289 filteredItems={filteredBranchPickerItems}
264290 autoHighlight
291 virtualized
292 onItemHighlighted={(_value, eventDetails) => {
293 if (!isBranchMenuOpen || eventDetails.index < 0) return;
294 branchListVirtualizer.scrollToIndex(eventDetails.index, { align: "auto" });
295 }}
265296 onOpenChange={(open) => {
266297 setIsBranchMenuOpen(open);
267298 if (!open) setBranchQuery("");
......@@ -298,54 +329,81 @@ export default function BranchToolbar({
298329 </div>
299330 <ComboboxEmpty>No branches found.</ComboboxEmpty>
300331
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;
304377 return (
305378 <ComboboxItem
306379 hideIndicator
307380 key={itemValue}
381 index={virtualRow.index}
308382 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)}
310394 >
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>
312403 </ComboboxItem>
313404 );
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>
349407 </ComboboxList>
350408 </ComboboxPopup>
351409 </Combobox>
apps/web/src/components/ui/combobox.tsx+17-14
......@@ -260,20 +260,23 @@ function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
260260 return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;
261261}
262262
263function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
264 return (
265 <ScrollArea scrollbarGutter scrollFade>
266 <ComboboxPrimitive.List
267 className={cn(
268 "not-empty:scroll-py-1 not-empty:px-1 not-empty:py-1 in-data-has-overflow-y:pe-3",
269 className,
270 )}
271 data-slot="combobox-list"
272 {...props}
273 />
274 </ScrollArea>
275 );
276}
263const ComboboxList = React.forwardRef<HTMLDivElement, ComboboxPrimitive.List.Props>(
264 function ComboboxList({ className, ...props }, ref) {
265 return (
266 <ScrollArea scrollbarGutter scrollFade>
267 <ComboboxPrimitive.List
268 className={cn(
269 "not-empty:scroll-py-1 not-empty:px-1 not-empty:py-1 in-data-has-overflow-y:pe-3",
270 className,
271 )}
272 data-slot="combobox-list"
273 ref={ref}
274 {...props}
275 />
276 </ScrollArea>
277 );
278 },
279);
277280
278281function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
279282 return <ComboboxPrimitive.Clear className={className} data-slot="combobox-clear" {...props} />;