1
- import { useCallback , useEffect , useRef , useState } from "react"
1
+ import { useCallback , useEffect , useMemo , useRef , useState } from "react"
2
2
import type { TabData , TabId } from "@/store"
3
- import { useDatabrowserStore } from "@/store"
3
+ import { useDatabrowserRootRef , useDatabrowserStore } from "@/store"
4
4
import { TabIdProvider } from "@/tab-provider"
5
5
import {
6
6
closestCenter ,
@@ -14,29 +14,30 @@ 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 , IconSearch } from "@tabler/icons-react"
17
+ import { IconChevronDown , IconPlus } from "@tabler/icons-react"
18
18
19
19
import { Button } from "@/components/ui/button"
20
20
import {
21
21
Command ,
22
22
CommandEmpty ,
23
23
CommandGroup ,
24
- CommandInput ,
25
24
CommandItem ,
26
25
CommandList ,
27
26
} from "@/components/ui/command"
28
27
import { Popover , PopoverContent , PopoverTrigger } from "@/components/ui/popover"
29
- import { Tooltip , TooltipContent , TooltipTrigger } from "@/components/ui/tooltip"
30
28
31
29
import { Tab } from "./tab"
32
- import { TabTypeIcon } from "./tab-type-icon"
33
30
34
31
const SortableTab = ( { id } : { id : TabId } ) => {
35
32
const [ originalWidth , setOriginalWidth ] = useState < number | null > ( null )
36
33
const textRef = useRef < HTMLElement | null > ( null )
34
+ const { tabs } = useDatabrowserStore ( )
35
+ const tabData = tabs . find ( ( [ tabId ] ) => tabId === id ) ?. [ 1 ]
36
+ const isPinned = tabData ?. pinned
37
37
38
38
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable ( {
39
39
id,
40
+ disabled : isPinned ,
40
41
resizeObserverConfig : {
41
42
disabled : true ,
42
43
} ,
@@ -110,9 +111,9 @@ const SortableTab = ({ id }: { id: TabId }) => {
110
111
< div
111
112
ref = { measureRef }
112
113
style = { style }
113
- className = { isDragging ? "cursor-grabbing" : "cursor-grab" }
114
+ className = { isDragging ? "cursor-grabbing" : isPinned ? "cursor-default" : "cursor-grab" }
114
115
{ ...attributes }
115
- { ...listeners }
116
+ { ...( isPinned ? { } : listeners ) }
116
117
>
117
118
< TabIdProvider value = { id as TabId } >
118
119
< Tab id = { id } />
@@ -122,7 +123,16 @@ const SortableTab = ({ id }: { id: TabId }) => {
122
123
}
123
124
124
125
export const DatabrowserTabs = ( ) => {
125
- const { tabs, addTab, reorderTabs, selectedTab, selectTab } = useDatabrowserStore ( )
126
+ const { tabs, reorderTabs, selectedTab, selectTab } = useDatabrowserStore ( )
127
+
128
+ // Sort tabs with pinned tabs first
129
+ const sortedTabs = useMemo ( ( ) => {
130
+ return [ ...tabs ] . sort ( ( [ , a ] , [ , b ] ) => {
131
+ if ( a . pinned && ! b . pinned ) return - 1
132
+ if ( ! a . pinned && b . pinned ) return 1
133
+ return 0
134
+ } )
135
+ } , [ tabs ] )
126
136
127
137
const scrollRef = useRef < HTMLDivElement | null > ( null )
128
138
const [ hasLeftShadow , setHasLeftShadow ] = useState ( false )
@@ -234,130 +244,122 @@ export const DatabrowserTabs = () => {
234
244
} }
235
245
>
236
246
< SortableContext
237
- items = { tabs . map ( ( [ id ] ) => id ) }
247
+ items = { sortedTabs . map ( ( [ id ] ) => id ) }
238
248
strategy = { horizontalListSortingStrategy }
239
249
>
240
- { selectedTab && tabs . map ( ( [ id ] ) => < SortableTab key = { id } id = { id } /> ) }
250
+ { selectedTab && sortedTabs . map ( ( [ id ] ) => < SortableTab key = { id } id = { id } /> ) }
241
251
</ SortableContext >
242
252
</ DndContext >
243
253
{ ! isOverflow && (
244
254
< 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
+ < AddTabButton />
255
256
</ div >
256
257
) }
257
258
</ div >
258
259
</ div >
259
260
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
- ) }
261
+ { /* Fixed right controls: search + add */ }
262
+ < div className = "flex items-center gap-1 pl-1" >
263
+ { isOverflow && < AddTabButton /> }
264
+ { tabs . length > 1 && < TabsListButton tabs = { tabs } onSelectTab = { selectTab } /> }
265
+ </ div >
275
266
</ div >
276
267
</ div >
277
268
)
278
269
}
279
270
280
- function TabSearch ( {
271
+ function AddTabButton ( ) {
272
+ const { addTab, selectTab } = useDatabrowserStore ( )
273
+ const rootRef = useDatabrowserRootRef ( )
274
+
275
+ const handleAddTab = ( ) => {
276
+ const tabsId = addTab ( )
277
+ selectTab ( tabsId )
278
+
279
+ setTimeout ( ( ) => {
280
+ const tab = rootRef ?. current ?. querySelector ( `#tab-${ tabsId } ` )
281
+ if ( ! tab ) return
282
+
283
+ tab . scrollIntoView ( { behavior : "smooth" } )
284
+ } , 20 )
285
+ }
286
+
287
+ return (
288
+ < Button
289
+ aria-label = "Add new tab"
290
+ variant = "secondary"
291
+ size = "icon-sm"
292
+ onClick = { handleAddTab }
293
+ className = "flex-shrink-0"
294
+ >
295
+ < IconPlus className = "text-zinc-500" size = { 16 } />
296
+ </ Button >
297
+ )
298
+ }
299
+
300
+ function TabsListButton ( {
281
301
tabs,
282
302
onSelectTab,
283
303
} : {
284
304
tabs : [ TabId , TabData ] [ ]
285
305
onSelectTab : ( id : TabId ) => void
286
306
} ) {
287
307
const [ open , setOpen ] = useState ( false )
288
- const [ query , setQuery ] = useState ( "" )
289
308
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
- }
309
+ const sorted = useMemo ( ( ) => {
310
+ return [ ...tabs ] . sort ( ( [ , a ] , [ , b ] ) => {
311
+ if ( a . pinned && ! b . pinned ) return - 1
312
+ if ( ! a . pinned && b . pinned ) return 1
313
+ return 0
314
+ } )
315
+ } , [ tabs ] )
308
316
309
- const deduped = [ ... dedupedMap . values ( ) ]
317
+ const rootRef = useDatabrowserRootRef ( )
310
318
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 ) ) )
319
+ const handleSelectTab = ( id : TabId ) => {
320
+ onSelectTab ( id )
321
+ setOpen ( false )
322
+
323
+ setTimeout ( ( ) => {
324
+ const tab = rootRef ?. current ?. querySelector ( `#tab-${ id } ` )
325
+ if ( ! tab ) return
326
+
327
+ tab . scrollIntoView ( { behavior : "smooth" } )
328
+ } , 20 )
329
+ }
316
330
317
331
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" >
332
+ < Popover open = { open } onOpenChange = { setOpen } >
333
+ < PopoverTrigger asChild >
334
+ < Button
335
+ variant = "secondary"
336
+ size = "sm"
337
+ className = "h-7 gap-1 px-2"
338
+ aria-label = "Search in tabs"
339
+ >
340
+ < span className = "text-xs text-zinc-600" > { tabs . length } </ span >
341
+ < IconChevronDown className = "text-zinc-500" size = { 16 } />
342
+ </ Button >
343
+ </ PopoverTrigger >
344
+ < PopoverContent className = "w-96 p-0" align = "end" >
336
345
< Command >
337
- < CommandInput
338
- placeholder = "Search tabs..."
339
- value = { query }
340
- onValueChange = { ( v ) => setQuery ( v ) }
341
- className = "h-9"
342
- />
343
346
< CommandList >
344
347
< CommandEmpty > No tabs</ CommandEmpty >
345
348
< CommandGroup >
346
- { filtered . map ( ( item ) => (
349
+ { sorted . map ( ( [ _id , item ] ) => (
347
350
< CommandItem
351
+ style = { {
352
+ padding : 0 ,
353
+ } }
348
354
key = { item . id }
349
- value = { buildDisplayLabel ( item ) }
355
+ value = { item . id }
350
356
onSelect = { ( ) => {
351
- onSelectTab ( item . id )
352
- setOpen ( false )
357
+ handleSelectTab ( item . id )
353
358
} }
354
359
>
355
- { item . searchKey ? (
356
- < IconSearch size = { 15 } />
357
- ) : (
358
- < TabTypeIcon selectedKey = { item . selectedKey } />
359
- ) }
360
- < span className = "truncate" > { buildDisplayLabel ( item ) } </ span >
360
+ < TabIdProvider value = { _id } >
361
+ < Tab id = { _id } isList />
362
+ </ TabIdProvider >
361
363
</ CommandItem >
362
364
) ) }
363
365
</ CommandGroup >
0 commit comments