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"
3
3
import { useDatabrowserStore } from "@/store"
4
4
import { TabIdProvider } from "@/tab-provider"
5
5
import {
@@ -14,11 +14,22 @@ import {
14
14
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers"
15
15
import { horizontalListSortingStrategy , SortableContext , useSortable } from "@dnd-kit/sortable"
16
16
import { CSS } from "@dnd-kit/utilities"
17
- import { IconPlus } from "@tabler/icons-react"
17
+ import { IconPlus , IconSearch } from "@tabler/icons-react"
18
18
19
19
import { 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"
20
30
21
31
import { Tab } from "./tab"
32
+ import { TabTypeIcon } from "./tab-type-icon"
22
33
23
34
const SortableTab = ( { id } : { id : TabId } ) => {
24
35
const [ originalWidth , setOriginalWidth ] = useState < number | null > ( null )
@@ -111,7 +122,63 @@ const SortableTab = ({ id }: { id: TabId }) => {
111
122
}
112
123
113
124
export 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 ] )
115
182
116
183
const sensors = useSensors (
117
184
useSensor ( PointerSensor , {
@@ -136,32 +203,167 @@ export const DatabrowserTabs = () => {
136
203
< div className = "relative mb-2 shrink-0" >
137
204
< div className = "absolute bottom-0 left-0 right-0 -z-10 h-[1px] w-full bg-zinc-200" />
138
205
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
+ ) }
164
275
</ div >
165
276
</ div >
166
277
)
167
278
}
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