Skip to content

Commit 7533895

Browse files
ytkimirtiCahidArda
andauthored
feat: add search in tabs button (#29)
* feat: add search in tabs button * fix: use redis/cloudflare to fix access to `process` in vite * fix: use regular redis in tests --------- Co-authored-by: CahidArda <[email protected]>
1 parent a81a607 commit 7533895

File tree

8 files changed

+397
-33
lines changed

8 files changed

+397
-33
lines changed

bun.lockb

4.41 KB
Binary file not shown.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"@monaco-editor/react": "^4.6.0",
3737
"@radix-ui/react-alert-dialog": "^1.0.5",
3838
"@radix-ui/react-context-menu": "^2.2.2",
39-
"@radix-ui/react-dialog": "^1.0.5",
39+
"@radix-ui/react-dialog": "^1.1.14",
4040
"@radix-ui/react-dropdown-menu": "^2.1.15",
4141
"@radix-ui/react-icons": "1.3.0",
4242
"@radix-ui/react-label": "^2.1.7",
@@ -52,6 +52,7 @@
5252
"@types/bytes": "^3.1.4",
5353
"@upstash/redis": "^1.35.3",
5454
"bytes": "^3.1.2",
55+
"cmdk": "^1.1.1",
5556
"react-hook-form": "^7.53.0",
5657
"react-resizable-panels": "^2.1.4",
5758
"zustand": "5.0.0"

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

Lines changed: 231 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
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"
33
import { useDatabrowserStore } from "@/store"
44
import { TabIdProvider } from "@/tab-provider"
55
import {
@@ -14,11 +14,22 @@ 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 } from "@tabler/icons-react"
17+
import { IconPlus, IconSearch } from "@tabler/icons-react"
1818

1919
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"
2030

2131
import { Tab } from "./tab"
32+
import { TabTypeIcon } from "./tab-type-icon"
2233

2334
const SortableTab = ({ id }: { id: TabId }) => {
2435
const [originalWidth, setOriginalWidth] = useState<number | null>(null)
@@ -111,7 +122,63 @@ const SortableTab = ({ id }: { id: TabId }) => {
111122
}
112123

113124
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])
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+
}

src/components/databrowser/components/sidebar/search-input.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ export const SearchInput = () => {
3535
}
3636

3737
const filteredHistory = dedupeSearchHistory(
38-
searchHistory.filter((item) => item.includes(state) && item !== state)
38+
searchHistory
39+
.filter((item) => item.trim() !== "" && item.trim() !== "*")
40+
.filter((item) => item.includes(state) && item !== state)
3941
)
4042
.slice(0, 5)
4143
// If it has a * in the end, remove it

0 commit comments

Comments
 (0)