diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index cf55fa4575297246ca5b2c26c50cf182523abbaa..ea164b363048a02a16f0e2d76434b96814b4b8c6 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -3,9 +3,42 @@ import { describe, expect, it } from "vitest"; import { dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, + resolveDraftEnvModeAfterBranchChange, resolveBranchToolbarValue, } from "./BranchToolbar.logic"; +describe("resolveDraftEnvModeAfterBranchChange", () => { + it("switches to local mode when returning from an existing worktree to the main worktree", () => { + expect( + resolveDraftEnvModeAfterBranchChange({ + nextWorktreePath: null, + currentWorktreePath: "/repo/.t3/worktrees/feature-a", + effectiveEnvMode: "worktree", + }), + ).toBe("local"); + }); + + it("keeps new-worktree mode when selecting a base branch before worktree creation", () => { + expect( + resolveDraftEnvModeAfterBranchChange({ + nextWorktreePath: null, + currentWorktreePath: null, + effectiveEnvMode: "worktree", + }), + ).toBe("worktree"); + }); + + it("uses worktree mode when selecting a branch already attached to a worktree", () => { + expect( + resolveDraftEnvModeAfterBranchChange({ + nextWorktreePath: "/repo/.t3/worktrees/feature-a", + currentWorktreePath: null, + effectiveEnvMode: "local", + }), + ).toBe("worktree"); + }); +}); + describe("resolveBranchToolbarValue", () => { it("defaults new-worktree mode to current git branch when no explicit base branch is set", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index eabe23afc145f0c592da282e61d64f744ea2ed15..dd1912fb2493eb9377d2dd8c4f414dc22ba76746 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,5 +1,20 @@ import type { GitBranch } from "@t3tools/contracts"; +export function resolveDraftEnvModeAfterBranchChange(input: { + nextWorktreePath: string | null; + currentWorktreePath: string | null; + effectiveEnvMode: "local" | "worktree"; +}): "local" | "worktree" { + const { nextWorktreePath, currentWorktreePath, effectiveEnvMode } = input; + if (nextWorktreePath) { + return "worktree"; + } + if (effectiveEnvMode === "worktree" && !currentWorktreePath) { + return "worktree"; + } + return "local"; +} + export function resolveBranchToolbarValue(input: { envMode: "local" | "worktree"; activeWorktreePath: string | null; diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 288c7ae35e7e860d8160b8b39c52efa6ffcdb7a4..9d208a32c8494dfa5f0128a7b04da118d412a973 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,30 +1,13 @@ -import type { GitBranch, ThreadId } from "@t3tools/contracts"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useVirtualizer } from "@tanstack/react-virtual"; -import { useCallback, useEffect, useMemo, useOptimistic, useRef, useState, useTransition } from "react"; +import type { ThreadId } from "@t3tools/contracts"; +import { useCallback } from "react"; import { newCommandId } from "../lib/utils"; -import { gitBranchesQueryOptions, invalidateGitQueries } from "../lib/gitReactQuery"; import { readNativeApi } from "../nativeApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useStore } from "../store"; -import { - dedupeRemoteBranchesWithLocalMatches, - deriveLocalBranchNameFromRemoteRef, - resolveBranchToolbarValue, -} from "./BranchToolbar.logic"; +import { resolveDraftEnvModeAfterBranchChange } from "./BranchToolbar.logic"; +import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; import { Button } from "./ui/button"; -import { - Combobox, - ComboboxEmpty, - ComboboxInput, - ComboboxItem, - ComboboxList, - ComboboxPopup, - ComboboxTrigger, -} from "./ui/combobox"; -import { ChevronDownIcon } from "lucide-react"; -import { toastManager } from "./ui/toast"; interface BranchToolbarProps { threadId: ThreadId; @@ -46,11 +29,6 @@ export default function BranchToolbar({ const setThreadBranchAction = useStore((store) => store.setThreadBranch); 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 branchListScrollElementRef = useRef(null); const serverThread = threads.find((thread) => thread.id === threadId); const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null; @@ -65,65 +43,6 @@ export default function BranchToolbar({ ? "worktree" : envMode; - // ── Queries ─────────────────────────────────────────────────────────── - - const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd)); - - const branches = useMemo( - () => dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []), - [branchesQuery.data?.branches], - ); - const currentGitBranch = branches.find((branch) => branch.current)?.name ?? null; - const canonicalActiveBranch = resolveBranchToolbarValue({ - envMode: effectiveEnvMode, - activeWorktreePath, - activeThreadBranch, - currentGitBranch, - }); - 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 = 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 ─────────────────────────────────────────────────────────── - - const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic( - canonicalActiveBranch, - (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch, - ); - const [isBranchActionPending, startBranchActionTransition] = useTransition(); - const runBranchAction = (action: () => Promise) => { - startBranchActionTransition(async () => { - await action().catch(() => undefined); - await invalidateGitQueries(queryClient).catch(() => undefined); - }); - }; - const setThreadBranch = useCallback( (branch: string | null, worktreePath: string | null) => { if (!activeThreadId) return; @@ -154,10 +73,15 @@ export default function BranchToolbar({ setThreadBranchAction(activeThreadId, branch, worktreePath); return; } + const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({ + nextWorktreePath: worktreePath, + currentWorktreePath: activeWorktreePath, + effectiveEnvMode, + }); setDraftThreadContext(threadId, { branch, worktreePath, - envMode: effectiveEnvMode, + envMode: nextDraftEnvMode, }); }, [ @@ -172,138 +96,6 @@ export default function BranchToolbar({ ], ); - const branchListVirtualizer = useVirtualizer({ - count: filteredBranchPickerItems.length, - estimateSize: () => 28, - getScrollElement: () => branchListScrollElementRef.current, - overscan: 12, - enabled: isBranchMenuOpen, - initialRect: { - height: 224, - width: 0, - }, - }); - const virtualBranchRows = branchListVirtualizer.getVirtualItems(); - const setBranchListRef = useCallback( - (element: HTMLDivElement | null) => { - branchListScrollElementRef.current = (element?.parentElement as HTMLDivElement | null) ?? null; - if (element) { - branchListVirtualizer.measure(); - } - }, - [branchListVirtualizer], - ); - - useEffect(() => { - if (effectiveEnvMode !== "worktree" || activeWorktreePath || activeThreadBranch || !currentGitBranch) { - return; - } - setThreadBranch(currentGitBranch, null); - }, [activeThreadBranch, activeWorktreePath, currentGitBranch, effectiveEnvMode, setThreadBranch]); - - useEffect(() => { - if (!isBranchMenuOpen) return; - queueMicrotask(() => { - branchListVirtualizer.measure(); - }); - }, [branchListVirtualizer, filteredBranchPickerItems.length, isBranchMenuOpen]); - - const selectBranch = (branch: GitBranch) => { - const api = readNativeApi(); - if (!api || !activeThreadId || !branchCwd || isBranchActionPending) return; - - // For new worktree mode, selecting a branch picks the base branch. - if (effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath) { - setThreadBranch(branch.name, null); - setIsBranchMenuOpen(false); - onComposerFocusRequest?.(); - return; - } - - // If the branch already lives in a worktree, redirect there instead of - // trying to checkout (which git would reject with "already used by worktree"). - if (branch.worktreePath) { - const isMainWorktree = branch.worktreePath === activeProject?.cwd; - // Main worktree → switch back to local (project cwd, worktreePath=null). - // Secondary worktree → point the thread at that worktree path. - setThreadBranch(branch.name, isMainWorktree ? null : branch.worktreePath); - setIsBranchMenuOpen(false); - onComposerFocusRequest?.(); - return; - } - - const selectedBranchName = branch.isRemote - ? deriveLocalBranchNameFromRemoteRef(branch.name) - : branch.name; - - setIsBranchMenuOpen(false); - onComposerFocusRequest?.(); - - runBranchAction(async () => { - setOptimisticBranch(selectedBranchName); - try { - await api.git.checkout({ cwd: branchCwd, branch: branch.name }); - await invalidateGitQueries(queryClient); - } catch (error) { - toastManager.add({ - type: "error", - title: "Failed to checkout branch.", - description: error instanceof Error ? error.message : "An error occurred.", - }); - return; - } - - let nextBranchName = selectedBranchName; - if (branch.isRemote) { - const status = await api.git.status({ cwd: branchCwd }).catch(() => null); - if (status?.branch) { - nextBranchName = status.branch; - } - } - - setOptimisticBranch(nextBranchName); - setThreadBranch(nextBranchName, activeWorktreePath); - }); - }; - - const createBranch = (rawName: string) => { - const name = rawName.trim(); - const api = readNativeApi(); - if (!api || !activeThreadId || !branchCwd || !name || isBranchActionPending) return; - - setIsBranchMenuOpen(false); - onComposerFocusRequest?.(); - - runBranchAction(async () => { - setOptimisticBranch(name); - - try { - await api.git.createBranch({ cwd: branchCwd, branch: name }); - try { - await api.git.checkout({ cwd: branchCwd, branch: name }); - } catch (error) { - toastManager.add({ - type: "error", - title: "Failed to checkout branch.", - description: error instanceof Error ? error.message : "An error occurred.", - }); - return; - } - } catch (error) { - toastManager.add({ - type: "error", - title: "Failed to create branch.", - description: error instanceof Error ? error.message : "An error occurred.", - }); - return; - } - - setOptimisticBranch(name); - setThreadBranch(name, activeWorktreePath); - setBranchQuery(""); - }); - }; - if (!activeThreadId || !activeProject) return null; return ( @@ -326,128 +118,16 @@ export default function BranchToolbar({ )} - { - if (!isBranchMenuOpen || eventDetails.index < 0) return; - branchListVirtualizer.scrollToIndex(eventDetails.index, { align: "auto" }); - }} - onOpenChange={(open) => { - setIsBranchMenuOpen(open); - if (!open) setBranchQuery(""); - }} - open={isBranchMenuOpen} - value={resolvedActiveBranch} - > - } - className="text-muted-foreground/70 hover:text-foreground/80" - disabled={branchesQuery.isLoading || isBranchActionPending} - > - - {resolvedActiveBranch - ? effectiveEnvMode === "worktree" && !activeWorktreePath - ? `From ${resolvedActiveBranch}` - : resolvedActiveBranch - : "Select branch"} - - - - -
- setBranchQuery(event.target.value)} - /> -
- No branches found. - - -
- {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 ( - selectBranch(branch)} - > -
- {itemValue} - {badge && ( - - {badge} - - )} -
-
- ); - })} -
-
-
-
+ ); } diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bb3170981f22f6c9ab26a0ea0edbc6e7a438d454 --- /dev/null +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -0,0 +1,394 @@ +import type { GitBranch } from "@t3tools/contracts"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { ChevronDownIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useOptimistic, useRef, useState, useTransition } from "react"; + +import { gitBranchesQueryOptions, gitQueryKeys, invalidateGitQueries } from "../lib/gitReactQuery"; +import { readNativeApi } from "../nativeApi"; +import { + dedupeRemoteBranchesWithLocalMatches, + deriveLocalBranchNameFromRemoteRef, + resolveBranchToolbarValue, +} from "./BranchToolbar.logic"; +import { Button } from "./ui/button"; +import { + Combobox, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxPopup, + ComboboxTrigger, +} from "./ui/combobox"; +import { toastManager } from "./ui/toast"; + +interface BranchToolbarBranchSelectorProps { + activeProjectCwd: string; + activeThreadBranch: string | null; + activeWorktreePath: string | null; + branchCwd: string | null; + effectiveEnvMode: "local" | "worktree"; + envLocked: boolean; + onSetThreadBranch: (branch: string | null, worktreePath: string | null) => void; + onComposerFocusRequest?: () => void; +} + +function toBranchActionErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "An error occurred."; +} + +function getBranchTriggerLabel(input: { + activeWorktreePath: string | null; + effectiveEnvMode: "local" | "worktree"; + resolvedActiveBranch: string | null; +}): string { + const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch } = input; + if (!resolvedActiveBranch) { + return "Select branch"; + } + if (effectiveEnvMode === "worktree" && !activeWorktreePath) { + return `From ${resolvedActiveBranch}`; + } + return resolvedActiveBranch; +} + +export function BranchToolbarBranchSelector({ + activeProjectCwd, + activeThreadBranch, + activeWorktreePath, + branchCwd, + effectiveEnvMode, + envLocked, + onSetThreadBranch, + onComposerFocusRequest, +}: BranchToolbarBranchSelectorProps) { + const queryClient = useQueryClient(); + const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); + const [branchQuery, setBranchQuery] = useState(""); + + const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd)); + const branches = useMemo( + () => dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []), + [branchesQuery.data?.branches], + ); + const currentGitBranch = branches.find((branch) => branch.current)?.name ?? null; + const canonicalActiveBranch = resolveBranchToolbarValue({ + envMode: effectiveEnvMode, + activeWorktreePath, + activeThreadBranch, + currentGitBranch, + }); + 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 = 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], + ); + const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic( + canonicalActiveBranch, + (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch, + ); + const [isBranchActionPending, startBranchActionTransition] = useTransition(); + + const runBranchAction = (action: () => Promise) => { + startBranchActionTransition(async () => { + await action().catch(() => undefined); + await invalidateGitQueries(queryClient).catch(() => undefined); + }); + }; + + const selectBranch = (branch: GitBranch) => { + const api = readNativeApi(); + if (!api || !branchCwd || isBranchActionPending) return; + + // In new-worktree mode, selecting a branch sets the base branch. + if (effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath) { + onSetThreadBranch(branch.name, null); + setIsBranchMenuOpen(false); + onComposerFocusRequest?.(); + return; + } + + // If the branch already lives in a worktree, point the thread there. + if (branch.worktreePath) { + const isMainWorktree = branch.worktreePath === activeProjectCwd; + onSetThreadBranch(branch.name, isMainWorktree ? null : branch.worktreePath); + setIsBranchMenuOpen(false); + onComposerFocusRequest?.(); + return; + } + + const selectedBranchName = branch.isRemote + ? deriveLocalBranchNameFromRemoteRef(branch.name) + : branch.name; + + setIsBranchMenuOpen(false); + onComposerFocusRequest?.(); + + runBranchAction(async () => { + setOptimisticBranch(selectedBranchName); + try { + await api.git.checkout({ cwd: branchCwd, branch: branch.name }); + await invalidateGitQueries(queryClient); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to checkout branch.", + description: toBranchActionErrorMessage(error), + }); + return; + } + + let nextBranchName = selectedBranchName; + if (branch.isRemote) { + const status = await api.git.status({ cwd: branchCwd }).catch(() => null); + if (status?.branch) { + nextBranchName = status.branch; + } + } + + setOptimisticBranch(nextBranchName); + onSetThreadBranch(nextBranchName, activeWorktreePath); + }); + }; + + const createBranch = (rawName: string) => { + const name = rawName.trim(); + const api = readNativeApi(); + if (!api || !branchCwd || !name || isBranchActionPending) return; + + setIsBranchMenuOpen(false); + onComposerFocusRequest?.(); + + runBranchAction(async () => { + setOptimisticBranch(name); + + try { + await api.git.createBranch({ cwd: branchCwd, branch: name }); + try { + await api.git.checkout({ cwd: branchCwd, branch: name }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to checkout branch.", + description: toBranchActionErrorMessage(error), + }); + return; + } + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to create branch.", + description: toBranchActionErrorMessage(error), + }); + return; + } + + setOptimisticBranch(name); + onSetThreadBranch(name, activeWorktreePath); + setBranchQuery(""); + }); + }; + + useEffect(() => { + if (effectiveEnvMode !== "worktree" || activeWorktreePath || activeThreadBranch || !currentGitBranch) { + return; + } + onSetThreadBranch(currentGitBranch, null); + }, [ + activeThreadBranch, + activeWorktreePath, + currentGitBranch, + effectiveEnvMode, + onSetThreadBranch, + ]); + + const handleOpenChange = useCallback( + (open: boolean) => { + setIsBranchMenuOpen(open); + if (!open) { + setBranchQuery(""); + return; + } + void queryClient.invalidateQueries({ + queryKey: gitQueryKeys.branches(branchCwd), + }); + }, + [branchCwd, queryClient], + ); + + const branchListScrollElementRef = useRef(null); + const branchListVirtualizer = useVirtualizer({ + count: filteredBranchPickerItems.length, + estimateSize: () => 28, + getScrollElement: () => branchListScrollElementRef.current, + overscan: 12, + enabled: isBranchMenuOpen, + initialRect: { + height: 224, + width: 0, + }, + }); + const virtualBranchRows = branchListVirtualizer.getVirtualItems(); + const setBranchListRef = useCallback( + (element: HTMLDivElement | null) => { + branchListScrollElementRef.current = + (element?.parentElement as HTMLDivElement | null) ?? null; + if (element) { + branchListVirtualizer.measure(); + } + }, + [branchListVirtualizer], + ); + + useEffect(() => { + if (!isBranchMenuOpen) return; + queueMicrotask(() => { + branchListVirtualizer.measure(); + }); + }, [branchListVirtualizer, filteredBranchPickerItems.length, isBranchMenuOpen]); + + const triggerLabel = getBranchTriggerLabel({ + activeWorktreePath, + effectiveEnvMode, + resolvedActiveBranch, + }); + + return ( + { + if (!isBranchMenuOpen || eventDetails.index < 0) return; + branchListVirtualizer.scrollToIndex(eventDetails.index, { align: "auto" }); + }} + onOpenChange={handleOpenChange} + open={isBranchMenuOpen} + value={resolvedActiveBranch} + > + } + className="text-muted-foreground/70 hover:text-foreground/80" + disabled={branchesQuery.isLoading || isBranchActionPending} + > + {triggerLabel} + + + +
+ setBranchQuery(event.target.value)} + /> +
+ No branches found. + + +
+ {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 !== activeProjectCwd; + const badge = branch.current + ? "current" + : hasSecondaryWorktree + ? "worktree" + : branch.isRemote + ? "remote" + : branch.isDefault + ? "default" + : null; + return ( + selectBranch(branch)} + > +
+ {itemValue} + {badge && ( + {badge} + )} +
+
+ ); + })} +
+
+
+
+ ); +}