1+ 'use client' ;
2+
3+ import * as React from "react" ;
4+ import { Check , ChevronDown , GitBranch , Search , Tag } from "lucide-react" ;
5+ import { useQuery } from "@tanstack/react-query" ;
6+ import { getRefs } from "@/app/api/(client)/client" ;
7+ import { isServiceError } from "@/lib/utils" ;
8+ import { cn } from "@/lib/utils" ;
9+ import {
10+ DropdownMenu ,
11+ DropdownMenuContent ,
12+ DropdownMenuItem ,
13+ DropdownMenuTrigger ,
14+ } from "@/components/ui/dropdown-menu" ;
15+ import { Input } from "@/components/ui/input" ;
16+ import { ScrollArea } from "@/components/ui/scroll-area" ;
17+
18+ interface BranchTagSelectorProps {
19+ repoName : string ;
20+ currentRef : string ;
21+ onRefChange : ( ref : string ) => void ;
22+ }
23+
24+ export function BranchTagSelector ( { repoName, currentRef, onRefChange } : BranchTagSelectorProps ) {
25+ const [ open , setOpen ] = React . useState ( false ) ;
26+ const [ activeTab , setActiveTab ] = React . useState < 'branches' | 'tags' > ( 'branches' ) ;
27+ const [ searchQuery , setSearchQuery ] = React . useState ( '' ) ;
28+ const inputRef = React . useRef < HTMLInputElement > ( null ) ;
29+
30+ const { data : refsData , isLoading } = useQuery ( {
31+ queryKey : [ 'refs' , repoName ] ,
32+ queryFn : async ( ) => {
33+ const result = await getRefs ( { repoName } ) ;
34+ if ( isServiceError ( result ) ) {
35+ throw new Error ( 'Failed to fetch refs' ) ;
36+ }
37+ return result ;
38+ } ,
39+ enabled : open || currentRef === 'HEAD' ,
40+ } ) ;
41+
42+ // Filter refs based on search query
43+ const filteredBranches = React . useMemo ( ( ) => {
44+ const branches = refsData ?. branches || [ ] ;
45+ if ( ! searchQuery ) return branches ;
46+ return branches . filter ( branch =>
47+ branch . toLowerCase ( ) . includes ( searchQuery . toLowerCase ( ) )
48+ ) ;
49+ } , [ refsData ?. branches , searchQuery ] ) ;
50+
51+ const filteredTags = React . useMemo ( ( ) => {
52+ const tags = refsData ?. tags || [ ] ;
53+ if ( ! searchQuery ) return tags ;
54+ return tags . filter ( tag =>
55+ tag . toLowerCase ( ) . includes ( searchQuery . toLowerCase ( ) )
56+ ) ;
57+ } , [ refsData ?. tags , searchQuery ] ) ;
58+
59+ const resolvedRef = currentRef === 'HEAD' ? ( refsData ?. defaultBranch || 'HEAD' ) : currentRef ;
60+ const displayRef = resolvedRef . replace ( / ^ r e f s \/ ( h e a d s | t a g s ) \/ / , '' ) ;
61+
62+ const handleRefSelect = ( ref : string ) => {
63+ onRefChange ( ref ) ;
64+ setOpen ( false ) ;
65+ setSearchQuery ( '' ) ;
66+ } ;
67+
68+ // Prevent dropdown items from stealing focus while user is typing
69+ const handleItemFocus = ( e : React . FocusEvent ) => {
70+ if ( searchQuery ) {
71+ e . preventDefault ( ) ;
72+ inputRef . current ?. focus ( ) ;
73+ }
74+ } ;
75+
76+ // Keep focus on the search input when typing
77+ React . useEffect ( ( ) => {
78+ if ( open && searchQuery && inputRef . current ) {
79+ const timeoutId = setTimeout ( ( ) => {
80+ inputRef . current ?. focus ( ) ;
81+ } , 0 ) ;
82+ return ( ) => clearTimeout ( timeoutId ) ;
83+ }
84+ } , [ open , searchQuery ] ) ;
85+
86+ return (
87+ < DropdownMenu open = { open } onOpenChange = { setOpen } >
88+ < DropdownMenuTrigger asChild >
89+ < button
90+ className = "flex items-center gap-1.5 px-2 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-100 rounded-md transition-colors"
91+ aria-label = "Switch branches or tags"
92+ >
93+ < GitBranch className = "h-3.5 w-3.5 flex-shrink-0" />
94+ < span className = "truncate max-w-[150px]" > { displayRef } </ span >
95+ < ChevronDown className = "h-3.5 w-3.5 text-gray-500 flex-shrink-0" />
96+ </ button >
97+ </ DropdownMenuTrigger >
98+ < DropdownMenuContent
99+ align = "start"
100+ className = "w-[320px] p-0"
101+ onCloseAutoFocus = { ( e ) => e . preventDefault ( ) }
102+ >
103+ < div className = "flex flex-col" onKeyDown = { ( e ) => {
104+ // Prevent dropdown keyboard navigation from interfering with search input
105+ if ( e . key === 'ArrowDown' || e . key === 'ArrowUp' ) {
106+ e . stopPropagation ( ) ;
107+ }
108+ } } >
109+ { /* Search input */ }
110+ < div className = "p-2 border-b" >
111+ < div className = "relative" >
112+ < Search className = "absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
113+ < Input
114+ ref = { inputRef }
115+ type = "text"
116+ placeholder = { activeTab === 'branches' ? "Find a branch..." : "Find a tag..." }
117+ value = { searchQuery }
118+ onChange = { ( e ) => setSearchQuery ( e . target . value ) }
119+ onKeyDown = { ( e ) => {
120+ // Prevent dropdown menu keyboard navigation
121+ e . stopPropagation ( ) ;
122+ } }
123+ className = "pl-8 h-8 text-sm"
124+ autoFocus
125+ />
126+ </ div >
127+ </ div >
128+
129+ < div className = "flex border-b" >
130+ < button
131+ onClick = { ( ) => setActiveTab ( 'branches' ) }
132+ className = { cn (
133+ "flex-1 px-4 py-2 text-sm font-medium transition-colors border-b-2" ,
134+ activeTab === 'branches'
135+ ? "border-blue-600 text-blue-600"
136+ : "border-transparent text-gray-600 hover:text-gray-900"
137+ ) }
138+ >
139+ < div className = "flex items-center justify-center gap-1.5" >
140+ < GitBranch className = "h-4 w-4" />
141+ Branches
142+ </ div >
143+ </ button >
144+ < button
145+ onClick = { ( ) => setActiveTab ( 'tags' ) }
146+ className = { cn (
147+ "flex-1 px-4 py-2 text-sm font-medium transition-colors border-b-2" ,
148+ activeTab === 'tags'
149+ ? "border-blue-600 text-blue-600"
150+ : "border-transparent text-gray-600 hover:text-gray-900"
151+ ) }
152+ >
153+ < div className = "flex items-center justify-center gap-1.5" >
154+ < Tag className = "h-4 w-4" />
155+ Tags
156+ </ div >
157+ </ button >
158+ </ div >
159+
160+ < ScrollArea className = "h-[300px]" >
161+ { isLoading ? (
162+ < div className = "p-4 text-sm text-gray-500 text-center" >
163+ Loading...
164+ </ div >
165+ ) : (
166+ < div className = "p-1" >
167+ { activeTab === 'branches' && (
168+ < >
169+ { filteredBranches . length === 0 ? (
170+ < div className = "p-4 text-sm text-gray-500 text-center" >
171+ { searchQuery ? 'No branches found' : 'No branches available' }
172+ </ div >
173+ ) : (
174+ filteredBranches . map ( ( branch ) => (
175+ < DropdownMenuItem
176+ key = { branch }
177+ onClick = { ( ) => handleRefSelect ( branch ) }
178+ onFocus = { handleItemFocus }
179+ className = "flex items-center justify-between px-3 py-2 cursor-pointer"
180+ >
181+ < div className = "flex items-center gap-2 flex-1 min-w-0" >
182+ < GitBranch className = "h-4 w-4 text-gray-500 flex-shrink-0" />
183+ < span className = "truncate text-sm" > { branch } </ span >
184+ { branch === refsData ?. defaultBranch && (
185+ < span className = "text-xs bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 px-1.5 py-0.5 rounded font-medium flex-shrink-0" >
186+ default
187+ </ span >
188+ ) }
189+ </ div >
190+ { branch === resolvedRef && (
191+ < Check className = "h-4 w-4 text-blue-600 flex-shrink-0" />
192+ ) }
193+ </ DropdownMenuItem >
194+ ) )
195+ ) }
196+ </ >
197+ ) }
198+ { activeTab === 'tags' && (
199+ < >
200+ { filteredTags . length === 0 ? (
201+ < div className = "p-4 text-sm text-gray-500 text-center" >
202+ { searchQuery ? 'No tags found' : 'No tags available' }
203+ </ div >
204+ ) : (
205+ filteredTags . map ( ( tag ) => (
206+ < DropdownMenuItem
207+ key = { tag }
208+ onClick = { ( ) => handleRefSelect ( tag ) }
209+ onFocus = { handleItemFocus }
210+ className = "flex items-center justify-between px-3 py-2 cursor-pointer"
211+ >
212+ < div className = "flex items-center gap-2 flex-1 min-w-0" >
213+ < Tag className = "h-4 w-4 text-gray-500 flex-shrink-0" />
214+ < span className = "truncate text-sm" > { tag } </ span >
215+ </ div >
216+ { tag === resolvedRef && (
217+ < Check className = "h-4 w-4 text-blue-600 flex-shrink-0" />
218+ ) }
219+ </ DropdownMenuItem >
220+ ) )
221+ ) }
222+ </ >
223+ ) }
224+ </ div >
225+ ) }
226+ </ ScrollArea >
227+ </ div >
228+ </ DropdownMenuContent >
229+ </ DropdownMenu >
230+ ) ;
231+ }
0 commit comments