1- import { useEffect , useRef , useState } from "react"
2- import type { TabId } from "@/store"
1+ import { useCallback , useEffect , useRef , useState } from "react"
2+ import type { TabData , TabId } from "@/store"
33import { useDatabrowserStore } from "@/store"
44import { TabIdProvider } from "@/tab-provider"
55import {
@@ -14,11 +14,22 @@ import {
1414import { restrictToHorizontalAxis } from "@dnd-kit/modifiers"
1515import { horizontalListSortingStrategy , SortableContext , useSortable } from "@dnd-kit/sortable"
1616import { CSS } from "@dnd-kit/utilities"
17- import { IconPlus } from "@tabler/icons-react"
17+ import { IconPlus , IconSearch } from "@tabler/icons-react"
1818
1919import { Button } from "@/components/ui/button"
20+ import {
21+ Command ,
22+ CommandEmpty ,
23+ CommandGroup ,
24+ CommandInput ,
25+ CommandItem ,
26+ CommandList ,
27+ } from "@/components/ui/command"
28+ import { Popover , PopoverContent , PopoverTrigger } from "@/components/ui/popover"
29+ import { Tooltip , TooltipContent , TooltipTrigger } from "@/components/ui/tooltip"
2030
2131import { Tab } from "./tab"
32+ import { TabTypeIcon } from "./tab-type-icon"
2233
2334const SortableTab = ( { id } : { id : TabId } ) => {
2435 const [ originalWidth , setOriginalWidth ] = useState < number | null > ( null )
@@ -111,7 +122,63 @@ const SortableTab = ({ id }: { id: TabId }) => {
111122}
112123
113124export const DatabrowserTabs = ( ) => {
114- const { tabs, addTab, reorderTabs, selectedTab } = useDatabrowserStore ( )
125+ const { tabs, addTab, reorderTabs, selectedTab, selectTab } = useDatabrowserStore ( )
126+
127+ const scrollRef = useRef < HTMLDivElement | null > ( null )
128+ const [ hasLeftShadow , setHasLeftShadow ] = useState ( false )
129+ const [ hasRightShadow , setHasRightShadow ] = useState ( false )
130+ const [ isOverflow , setIsOverflow ] = useState ( false )
131+
132+ // Attach a non-passive wheel listener so we can preventDefault when translating vertical wheel to horizontal scroll
133+ useEffect ( ( ) => {
134+ const el = scrollRef . current
135+ if ( ! el ) return
136+
137+ const onWheel = ( event : WheelEvent ) => {
138+ if ( el . scrollWidth <= el . clientWidth ) return
139+ const primaryDelta =
140+ Math . abs ( event . deltaY ) > Math . abs ( event . deltaX ) ? event . deltaY : event . deltaX
141+ if ( primaryDelta !== 0 ) {
142+ el . scrollLeft += primaryDelta
143+ event . preventDefault ( )
144+ // Ensure shadow state updates after scrolling
145+ requestAnimationFrame ( ( ) => {
146+ const { scrollLeft, scrollWidth, clientWidth } = el
147+ setHasLeftShadow ( scrollLeft > 0 )
148+ setHasRightShadow ( scrollLeft + clientWidth < scrollWidth - 1 )
149+ setIsOverflow ( scrollWidth > clientWidth + 1 )
150+ } )
151+ }
152+ }
153+
154+ el . addEventListener ( "wheel" , onWheel , { passive : false } )
155+ return ( ) => {
156+ el . removeEventListener ( "wheel" , onWheel as EventListener )
157+ }
158+ } , [ ] )
159+
160+ const recomputeShadows = useCallback ( ( ) => {
161+ const el = scrollRef . current
162+ if ( ! el ) return
163+ const { scrollLeft, scrollWidth, clientWidth } = el
164+ setHasLeftShadow ( scrollLeft > 0 )
165+ setHasRightShadow ( scrollLeft + clientWidth < scrollWidth - 1 )
166+ setIsOverflow ( scrollWidth > clientWidth + 1 )
167+ } , [ ] )
168+
169+ useEffect ( ( ) => {
170+ recomputeShadows ( )
171+ const el = scrollRef . current
172+ if ( ! el ) return
173+ const onResize = ( ) => recomputeShadows ( )
174+ window . addEventListener ( "resize" , onResize )
175+ const obs = new ResizeObserver ( onResize )
176+ obs . observe ( el )
177+ return ( ) => {
178+ window . removeEventListener ( "resize" , onResize )
179+ obs . disconnect ( )
180+ }
181+ } , [ recomputeShadows ] )
115182
116183 const sensors = useSensors (
117184 useSensor ( PointerSensor , {
@@ -136,32 +203,167 @@ export const DatabrowserTabs = () => {
136203 < div className = "relative mb-2 shrink-0" >
137204 < div className = "absolute bottom-0 left-0 right-0 -z-10 h-[1px] w-full bg-zinc-200" />
138205
139- < div className = "scrollbar-hide flex translate-y-[1px] items-center gap-1 overflow-x-scroll pb-[1px] [&::-webkit-scrollbar]:hidden" >
140- < DndContext
141- sensors = { sensors }
142- collisionDetection = { closestCenter }
143- onDragEnd = { handleDragEnd }
144- modifiers = { [ restrictToHorizontalAxis ] }
145- measuring = { {
146- droppable : {
147- strategy : MeasuringStrategy . Always ,
148- } ,
149- } }
150- >
151- < SortableContext items = { tabs . map ( ( [ id ] ) => id ) } strategy = { horizontalListSortingStrategy } >
152- { selectedTab && tabs . map ( ( [ id ] ) => < SortableTab key = { id } id = { id } /> ) }
153- </ SortableContext >
154- </ DndContext >
155- < Button
156- variant = "secondary"
157- size = "icon-sm"
158- onClick = { addTab }
159- className = "mr-1 flex-shrink-0"
160- title = "Add new tab"
161- >
162- < IconPlus className = "text-zinc-500" size = { 16 } />
163- </ Button >
206+ < div className = "flex translate-y-[1px] items-center gap-1" >
207+ { /* Scrollable tabs area */ }
208+ < div className = "relative min-w-0 flex-1" >
209+ < div
210+ className = { `tabs-shadow-left pointer-events-none absolute left-0 top-0 z-10 h-full w-6 transition-opacity duration-200 ${
211+ hasLeftShadow ? "opacity-100" : "opacity-0"
212+ } `}
213+ />
214+ < div
215+ className = { `tabs-shadow-right pointer-events-none absolute right-0 top-0 z-10 h-full w-6 transition-opacity duration-200 ${
216+ hasRightShadow ? "opacity-100" : "opacity-0"
217+ } `}
218+ />
219+
220+ < div
221+ ref = { scrollRef }
222+ onScroll = { recomputeShadows }
223+ className = "scrollbar-hide flex min-w-0 flex-1 items-center gap-1 overflow-x-auto pb-[1px] [&::-webkit-scrollbar]:hidden"
224+ >
225+ < DndContext
226+ sensors = { sensors }
227+ collisionDetection = { closestCenter }
228+ onDragEnd = { handleDragEnd }
229+ modifiers = { [ restrictToHorizontalAxis ] }
230+ measuring = { {
231+ droppable : {
232+ strategy : MeasuringStrategy . Always ,
233+ } ,
234+ } }
235+ >
236+ < SortableContext
237+ items = { tabs . map ( ( [ id ] ) => id ) }
238+ strategy = { horizontalListSortingStrategy }
239+ >
240+ { selectedTab && tabs . map ( ( [ id ] ) => < SortableTab key = { id } id = { id } /> ) }
241+ </ SortableContext >
242+ </ DndContext >
243+ { ! isOverflow && (
244+ < div className = "flex items-center gap-1 pl-1 pr-1" >
245+ { tabs . length > 4 && < TabSearch tabs = { tabs } onSelectTab = { selectTab } /> }
246+ < Button
247+ variant = "secondary"
248+ size = "icon-sm"
249+ onClick = { addTab }
250+ className = "flex-shrink-0"
251+ title = "Add new tab"
252+ >
253+ < IconPlus className = "text-zinc-500" size = { 16 } />
254+ </ Button >
255+ </ div >
256+ ) }
257+ </ div >
258+ </ div >
259+
260+ { /* Always-visible controls */ }
261+ { isOverflow && (
262+ < div className = "flex items-center gap-1 pl-1" >
263+ { tabs . length > 4 && < TabSearch tabs = { tabs } onSelectTab = { selectTab } /> }
264+ < Button
265+ variant = "secondary"
266+ size = "icon-sm"
267+ onClick = { addTab }
268+ className = "mr-1 flex-shrink-0"
269+ title = "Add new tab"
270+ >
271+ < IconPlus className = "text-zinc-500" size = { 16 } />
272+ </ Button >
273+ </ div >
274+ ) }
164275 </ div >
165276 </ div >
166277 )
167278}
279+
280+ function TabSearch ( {
281+ tabs,
282+ onSelectTab,
283+ } : {
284+ tabs : [ TabId , TabData ] [ ]
285+ onSelectTab : ( id : TabId ) => void
286+ } ) {
287+ const [ open , setOpen ] = useState ( false )
288+ const [ query , setQuery ] = useState ( "" )
289+
290+ const items = tabs . map ( ( [ id , data ] ) => ( {
291+ id,
292+ label : data . search . key || data . selectedKey || "New Tab" ,
293+ searchKey : data . search . key ,
294+ selectedKey : data . selectedKey ,
295+ selectedItemKey : data . selectedListItem ?. key ,
296+ } ) )
297+
298+ // Build final label and de-duplicate by that label (case-insensitive)
299+ const buildDisplayLabel = ( it : ( typeof items ) [ number ] ) =>
300+ it . selectedItemKey ? `${ it . label } > ${ it . selectedItemKey } ` : it . label
301+
302+ const dedupedMap = new Map < string , ( typeof items ) [ number ] > ( )
303+ for ( const it of items ) {
304+ const display = buildDisplayLabel ( it )
305+ const key = display . toLowerCase ( )
306+ if ( ! dedupedMap . has ( key ) ) dedupedMap . set ( key , it )
307+ }
308+
309+ const deduped = [ ...dedupedMap . values ( ) ]
310+
311+ const filtered = (
312+ query
313+ ? deduped . filter ( ( i ) => buildDisplayLabel ( i ) . toLowerCase ( ) . includes ( query . toLowerCase ( ) ) )
314+ : deduped
315+ ) . sort ( ( a , b ) => buildDisplayLabel ( a ) . localeCompare ( buildDisplayLabel ( b ) ) )
316+
317+ return (
318+ < Popover
319+ open = { open }
320+ onOpenChange = { ( v ) => {
321+ setOpen ( v )
322+ if ( ! v ) setQuery ( "" )
323+ } }
324+ >
325+ < Tooltip delayDuration = { 400 } >
326+ < TooltipTrigger asChild >
327+ < PopoverTrigger asChild >
328+ < Button variant = "secondary" size = "icon-sm" aria-label = "Search in tabs" >
329+ < IconSearch className = "text-zinc-500" size = { 16 } />
330+ </ Button >
331+ </ PopoverTrigger >
332+ </ TooltipTrigger >
333+ < TooltipContent side = "top" > Search in tabs</ TooltipContent >
334+ </ Tooltip >
335+ < PopoverContent className = "w-72 p-0" align = "end" >
336+ < Command >
337+ < CommandInput
338+ placeholder = "Search tabs..."
339+ value = { query }
340+ onValueChange = { ( v ) => setQuery ( v ) }
341+ className = "h-9"
342+ />
343+ < CommandList >
344+ < CommandEmpty > No tabs</ CommandEmpty >
345+ < CommandGroup >
346+ { filtered . map ( ( item ) => (
347+ < CommandItem
348+ key = { item . id }
349+ value = { buildDisplayLabel ( item ) }
350+ onSelect = { ( ) => {
351+ onSelectTab ( item . id )
352+ setOpen ( false )
353+ } }
354+ >
355+ { item . searchKey ? (
356+ < IconSearch size = { 15 } />
357+ ) : (
358+ < TabTypeIcon selectedKey = { item . selectedKey } />
359+ ) }
360+ < span className = "truncate" > { buildDisplayLabel ( item ) } </ span >
361+ </ CommandItem >
362+ ) ) }
363+ </ CommandGroup >
364+ </ CommandList >
365+ </ Command >
366+ </ PopoverContent >
367+ </ Popover >
368+ )
369+ }
0 commit comments