1- import { useCallback , useEffect , useRef , useState } from "react"
1+ import { useCallback , useEffect , useMemo , useRef , useState } from "react"
22import type { TabData , TabId } from "@/store"
3- import { useDatabrowserStore } from "@/store"
3+ import { useDatabrowserRootRef , useDatabrowserStore } from "@/store"
44import { TabIdProvider } from "@/tab-provider"
55import {
66 closestCenter ,
@@ -14,29 +14,30 @@ import {
1414import { restrictToHorizontalAxis } from "@dnd-kit/modifiers"
1515import { horizontalListSortingStrategy , SortableContext , useSortable } from "@dnd-kit/sortable"
1616import { CSS } from "@dnd-kit/utilities"
17- import { IconPlus , IconSearch } from "@tabler/icons-react"
17+ import { IconChevronDown , IconPlus } from "@tabler/icons-react"
1818
1919import { Button } from "@/components/ui/button"
2020import {
2121 Command ,
2222 CommandEmpty ,
2323 CommandGroup ,
24- CommandInput ,
2524 CommandItem ,
2625 CommandList ,
2726} from "@/components/ui/command"
2827import { Popover , PopoverContent , PopoverTrigger } from "@/components/ui/popover"
29- import { Tooltip , TooltipContent , TooltipTrigger } from "@/components/ui/tooltip"
3028
3129import { Tab } from "./tab"
32- import { TabTypeIcon } from "./tab-type-icon"
3330
3431const SortableTab = ( { id } : { id : TabId } ) => {
3532 const [ originalWidth , setOriginalWidth ] = useState < number | null > ( null )
3633 const textRef = useRef < HTMLElement | null > ( null )
34+ const { tabs } = useDatabrowserStore ( )
35+ const tabData = tabs . find ( ( [ tabId ] ) => tabId === id ) ?. [ 1 ]
36+ const isPinned = tabData ?. pinned
3737
3838 const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable ( {
3939 id,
40+ disabled : isPinned ,
4041 resizeObserverConfig : {
4142 disabled : true ,
4243 } ,
@@ -110,9 +111,9 @@ const SortableTab = ({ id }: { id: TabId }) => {
110111 < div
111112 ref = { measureRef }
112113 style = { style }
113- className = { isDragging ? "cursor-grabbing" : "cursor-grab" }
114+ className = { isDragging ? "cursor-grabbing" : isPinned ? "cursor-default" : "cursor-grab" }
114115 { ...attributes }
115- { ...listeners }
116+ { ...( isPinned ? { } : listeners ) }
116117 >
117118 < TabIdProvider value = { id as TabId } >
118119 < Tab id = { id } />
@@ -122,7 +123,16 @@ const SortableTab = ({ id }: { id: TabId }) => {
122123}
123124
124125export 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 ] )
126136
127137 const scrollRef = useRef < HTMLDivElement | null > ( null )
128138 const [ hasLeftShadow , setHasLeftShadow ] = useState ( false )
@@ -234,130 +244,122 @@ export const DatabrowserTabs = () => {
234244 } }
235245 >
236246 < SortableContext
237- items = { tabs . map ( ( [ id ] ) => id ) }
247+ items = { sortedTabs . map ( ( [ id ] ) => id ) }
238248 strategy = { horizontalListSortingStrategy }
239249 >
240- { selectedTab && tabs . map ( ( [ id ] ) => < SortableTab key = { id } id = { id } /> ) }
250+ { selectedTab && sortedTabs . map ( ( [ id ] ) => < SortableTab key = { id } id = { id } /> ) }
241251 </ SortableContext >
242252 </ DndContext >
243253 { ! isOverflow && (
244254 < 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 />
255256 </ div >
256257 ) }
257258 </ div >
258259 </ div >
259260
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 >
275266 </ div >
276267 </ div >
277268 )
278269}
279270
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 ( {
281301 tabs,
282302 onSelectTab,
283303} : {
284304 tabs : [ TabId , TabData ] [ ]
285305 onSelectTab : ( id : TabId ) => void
286306} ) {
287307 const [ open , setOpen ] = useState ( false )
288- const [ query , setQuery ] = useState ( "" )
289308
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 ] )
308316
309- const deduped = [ ... dedupedMap . values ( ) ]
317+ const rootRef = useDatabrowserRootRef ( )
310318
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+ }
316330
317331 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" >
336345 < Command >
337- < CommandInput
338- placeholder = "Search tabs..."
339- value = { query }
340- onValueChange = { ( v ) => setQuery ( v ) }
341- className = "h-9"
342- />
343346 < CommandList >
344347 < CommandEmpty > No tabs</ CommandEmpty >
345348 < CommandGroup >
346- { filtered . map ( ( item ) => (
349+ { sorted . map ( ( [ _id , item ] ) => (
347350 < CommandItem
351+ style = { {
352+ padding : 0 ,
353+ } }
348354 key = { item . id }
349- value = { buildDisplayLabel ( item ) }
355+ value = { item . id }
350356 onSelect = { ( ) => {
351- onSelectTab ( item . id )
352- setOpen ( false )
357+ handleSelectTab ( item . id )
353358 } }
354359 >
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 >
361363 </ CommandItem >
362364 ) ) }
363365 </ CommandGroup >
0 commit comments