Skip to content

Commit 49ff33a

Browse files
authored
feat: Add tabs context menu and tabs list (#30)
* feat: add search in tabs button * fix: use redis/cloudflare to fix access to `process` in vite * chore: remove red border from playground * feat: add a rootRef and pinned tab abilities to the store * fix: creating a new hash adding a "field: field" and "value: value" * feat: add icons to item and sidebar context menus * feat: add pinned tabs, fix add new tab button, scroll to new tab * feat: add tab id inside the tabs object in store and create a migration for it * feat: make context menu work inside the tabs and remove search * test: use upstash/redis for the test utils * test: fix add new tab test not passing becuase of missing label
1 parent 7533895 commit 49ff33a

File tree

9 files changed

+319
-116
lines changed

9 files changed

+319
-116
lines changed

src/components/databrowser/components/databrowser-tabs.tsx

Lines changed: 98 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { useCallback, useEffect, useRef, useState } from "react"
1+
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
22
import type { TabData, TabId } from "@/store"
3-
import { useDatabrowserStore } from "@/store"
3+
import { useDatabrowserRootRef, useDatabrowserStore } from "@/store"
44
import { TabIdProvider } from "@/tab-provider"
55
import {
66
closestCenter,
@@ -14,29 +14,30 @@ import {
1414
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers"
1515
import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable"
1616
import { CSS } from "@dnd-kit/utilities"
17-
import { IconPlus, IconSearch } from "@tabler/icons-react"
17+
import { IconChevronDown, IconPlus } from "@tabler/icons-react"
1818

1919
import { Button } from "@/components/ui/button"
2020
import {
2121
Command,
2222
CommandEmpty,
2323
CommandGroup,
24-
CommandInput,
2524
CommandItem,
2625
CommandList,
2726
} from "@/components/ui/command"
2827
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
29-
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
3028

3129
import { Tab } from "./tab"
32-
import { TabTypeIcon } from "./tab-type-icon"
3330

3431
const 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

124125
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])
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>

src/components/databrowser/components/item-context-menu.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useState, type PropsWithChildren } from "react"
2+
import { IconCopy, IconExternalLink, IconTrash } from "@tabler/icons-react"
23
import { useDatabrowserStore } from "@/store"
34
import { type ListDataType } from "@/types"
45
import { ContextMenuSeparator } from "@radix-ui/react-context-menu"
@@ -77,7 +78,9 @@ export const ItemContextMenu = ({
7778
description: "Key copied to clipboard",
7879
})
7980
}}
81+
className="gap-2"
8082
>
83+
<IconCopy size={16} />
8184
Copy key
8285
</ContextMenuItem>
8386
{data?.value && (
@@ -88,7 +91,9 @@ export const ItemContextMenu = ({
8891
description: "Value copied to clipboard",
8992
})
9093
}}
94+
className="gap-2"
9195
>
96+
<IconCopy size={16} />
9297
Copy value
9398
</ContextMenuItem>
9499
)}
@@ -102,11 +107,18 @@ export const ItemContextMenu = ({
102107
key: data.key,
103108
})
104109
}}
110+
className="gap-2"
105111
>
112+
<IconExternalLink size={16} />
106113
Open in new tab
107114
</ContextMenuItem>
108115
<ContextMenuSeparator />
109-
<ContextMenuItem disabled={type === "stream"} onClick={() => setAlertOpen(true)}>
116+
<ContextMenuItem
117+
disabled={type === "stream"}
118+
onClick={() => setAlertOpen(true)}
119+
className="gap-2"
120+
>
121+
<IconTrash size={16} />
110122
Delete item
111123
</ContextMenuItem>
112124
</ContextMenuContent>

src/components/databrowser/components/sidebar-context-menu.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useState, type PropsWithChildren } from "react"
2+
import { IconCopy, IconExternalLink, IconTrash } from "@tabler/icons-react"
23
import { useDatabrowserStore } from "@/store"
34
import { useTab } from "@/tab-provider"
45
import { ContextMenuSeparator } from "@radix-ui/react-context-menu"
@@ -57,7 +58,9 @@ export const SidebarContextMenu = ({ children }: PropsWithChildren) => {
5758
description: "Key copied to clipboard",
5859
})
5960
}}
61+
className="gap-2"
6062
>
63+
<IconCopy size={16} />
6164
Copy key
6265
</ContextMenuItem>
6366
<ContextMenuItem
@@ -67,11 +70,16 @@ export const SidebarContextMenu = ({ children }: PropsWithChildren) => {
6770
setSearch(newTabId, currentSearch)
6871
selectTab(newTabId)
6972
}}
73+
className="gap-2"
7074
>
75+
<IconExternalLink size={16} />
7176
Open in new tab
7277
</ContextMenuItem>
7378
<ContextMenuSeparator />
74-
<ContextMenuItem onClick={() => setAlertOpen(true)}>Delete key</ContextMenuItem>
79+
<ContextMenuItem onClick={() => setAlertOpen(true)} className="gap-2">
80+
<IconTrash size={16} />
81+
Delete key
82+
</ContextMenuItem>
7583
</ContextMenuContent>
7684
</ContextMenu>
7785
</>

0 commit comments

Comments
 (0)