Skip to content

Commit cd0ae8a

Browse files
jonathanlabclaude
andauthored
feat: add horizontal scrolling to tab bar (#53)
Co-authored-by: Claude <[email protected]>
1 parent 0a95dad commit cd0ae8a

File tree

2 files changed

+165
-85
lines changed

2 files changed

+165
-85
lines changed

src/renderer/components/TabBar.tsx

Lines changed: 161 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
Text,
99
} from "@radix-ui/themes";
1010
import type React from "react";
11-
import { useCallback, useState } from "react";
11+
import { useCallback, useEffect, useRef, useState } from "react";
1212
import { useHotkeys } from "react-hotkeys-hook";
1313
import { useTabStore } from "../stores/tabStore";
1414

@@ -27,6 +27,8 @@ export function TabBar() {
2727
const [dropPosition, setDropPosition] = useState<"left" | "right" | null>(
2828
null,
2929
);
30+
const [showScrollGradient, setShowScrollGradient] = useState(false);
31+
const scrollContainerRef = useRef<HTMLDivElement>(null);
3032

3133
// Keyboard navigation handlers
3234
const handlePrevTab = useCallback(() => {
@@ -166,101 +168,175 @@ export function TabBar() {
166168
setDropPosition(null);
167169
}, []);
168170

171+
const checkScrollGradient = useCallback(() => {
172+
const container = scrollContainerRef.current;
173+
if (!container) return;
174+
175+
const canScrollRight =
176+
container.scrollWidth > container.clientWidth &&
177+
container.scrollLeft + container.clientWidth < container.scrollWidth - 1;
178+
179+
setShowScrollGradient(canScrollRight);
180+
}, []);
181+
182+
useEffect(() => {
183+
checkScrollGradient();
184+
185+
const container = scrollContainerRef.current;
186+
if (!container) return;
187+
188+
container.addEventListener("scroll", checkScrollGradient);
189+
window.addEventListener("resize", checkScrollGradient);
190+
191+
return () => {
192+
container.removeEventListener("scroll", checkScrollGradient);
193+
window.removeEventListener("resize", checkScrollGradient);
194+
};
195+
}, [checkScrollGradient]);
196+
197+
useEffect(() => {
198+
checkScrollGradient();
199+
}, [checkScrollGradient]);
200+
201+
useEffect(() => {
202+
const container = scrollContainerRef.current;
203+
if (!container) return;
204+
205+
const activeTabElement = container.querySelector(
206+
`[data-tab-id="${activeTabId}"]`,
207+
);
208+
if (activeTabElement) {
209+
activeTabElement.scrollIntoView({
210+
behavior: "smooth",
211+
block: "nearest",
212+
inline: "nearest",
213+
});
214+
}
215+
}, [activeTabId]);
216+
169217
return (
170218
<Flex
171219
className="drag border-gray-6 border-b"
172220
height="40px"
173221
minHeight="40px"
222+
position="relative"
174223
>
175224
{/* Spacer for macOS window controls */}
176225
<Box width="80px" flexShrink="0" />
177226

178-
{tabs.map((tab, index) => {
179-
const isDragging = draggedTab === tab.id;
180-
const isDragOver = dragOverTab === tab.id;
181-
const showLeftIndicator = isDragOver && dropPosition === "left";
182-
const showRightIndicator = isDragOver && dropPosition === "right";
183-
184-
return (
185-
<ContextMenu.Root key={tab.id}>
186-
<ContextMenu.Trigger>
187-
<Flex
188-
className={`no-drag group relative cursor-pointer border-gray-6 border-r border-b-2 transition-colors ${
189-
tab.id === activeTabId
190-
? "border-b-accent-8 bg-accent-3 text-accent-12"
191-
: "border-b-transparent text-gray-11 hover:bg-gray-3 hover:text-gray-12"
192-
} ${isDragging ? "opacity-50" : ""}`}
193-
align="center"
194-
px="4"
195-
draggable
196-
onClick={() => setActiveTab(tab.id)}
197-
onDragStart={(e) => handleDragStart(e, tab.id)}
198-
onDragOver={(e) => handleDragOver(e, tab.id)}
199-
onDragLeave={handleDragLeave}
200-
onDrop={(e) => handleDrop(e, tab.id)}
201-
onDragEnd={handleDragEnd}
202-
>
203-
{showLeftIndicator && (
204-
<Box
205-
className="absolute top-0 bottom-0 left-0 z-10 w-0.5 bg-accent-8"
206-
style={{ marginLeft: "-1px" }}
207-
/>
208-
)}
209-
210-
{showRightIndicator && (
211-
<Box
212-
className="absolute top-0 right-0 bottom-0 z-10 w-0.5 bg-accent-8"
213-
style={{ marginRight: "-1px" }}
214-
/>
215-
)}
216-
{index < 9 && (
217-
<Kbd size="1" className="mr-2 opacity-70">
218-
{navigator.platform.includes("Mac") ? "⌘" : "Ctrl+"}
219-
{index + 1}
220-
</Kbd>
221-
)}
222-
223-
<Text
224-
size="2"
225-
className="max-w-[200px] select-none overflow-hidden text-ellipsis whitespace-nowrap"
226-
mr="2"
227+
<Flex
228+
ref={scrollContainerRef}
229+
className="scrollbar-hide overflow-x-auto"
230+
flexGrow="1"
231+
style={{
232+
scrollbarWidth: "none",
233+
msOverflowStyle: "none",
234+
}}
235+
>
236+
{tabs.map((tab, index) => {
237+
const isDragging = draggedTab === tab.id;
238+
const isDragOver = dragOverTab === tab.id;
239+
const showLeftIndicator = isDragOver && dropPosition === "left";
240+
const showRightIndicator = isDragOver && dropPosition === "right";
241+
242+
return (
243+
<ContextMenu.Root key={tab.id}>
244+
<ContextMenu.Trigger>
245+
<Flex
246+
data-tab-id={tab.id}
247+
className={`no-drag group relative cursor-pointer border-gray-6 border-r border-b-2 transition-colors ${
248+
tab.id === activeTabId
249+
? "border-b-accent-8 bg-accent-3 text-accent-12"
250+
: "border-b-transparent text-gray-11 hover:bg-gray-3 hover:text-gray-12"
251+
} ${isDragging ? "opacity-50" : ""}`}
252+
align="center"
253+
px="4"
254+
draggable
255+
onClick={() => setActiveTab(tab.id)}
256+
onDragStart={(e) => handleDragStart(e, tab.id)}
257+
onDragOver={(e) => handleDragOver(e, tab.id)}
258+
onDragLeave={handleDragLeave}
259+
onDrop={(e) => handleDrop(e, tab.id)}
260+
onDragEnd={handleDragEnd}
227261
>
228-
{tab.title}
229-
</Text>
230-
231-
{tabs.length > 1 && (
232-
<IconButton
233-
size="1"
234-
variant="ghost"
235-
color={tab.id !== activeTabId ? "gray" : undefined}
236-
className="opacity-0 transition-opacity group-hover:opacity-100"
237-
onClick={(e) => {
238-
e.stopPropagation();
239-
closeTab(tab.id);
240-
}}
262+
{showLeftIndicator && (
263+
<Box
264+
className="absolute top-0 bottom-0 left-0 z-10 w-0.5 bg-accent-8"
265+
style={{ marginLeft: "-1px" }}
266+
/>
267+
)}
268+
269+
{showRightIndicator && (
270+
<Box
271+
className="absolute top-0 right-0 bottom-0 z-10 w-0.5 bg-accent-8"
272+
style={{ marginRight: "-1px" }}
273+
/>
274+
)}
275+
{index < 9 && (
276+
<Kbd size="1" className="mr-2 opacity-70">
277+
{navigator.platform.includes("Mac") ? "⌘" : "Ctrl+"}
278+
{index + 1}
279+
</Kbd>
280+
)}
281+
282+
<Text
283+
size="2"
284+
className="max-w-[200px] select-none overflow-hidden text-ellipsis whitespace-nowrap"
285+
mr="2"
241286
>
242-
<Cross2Icon />
243-
</IconButton>
244-
)}
245-
</Flex>
246-
</ContextMenu.Trigger>
247-
<ContextMenu.Content>
248-
<ContextMenu.Item
249-
disabled={tabs.length === 1}
250-
onSelect={() => closeOtherTabs(tab.id)}
251-
>
252-
Close other tabs
253-
</ContextMenu.Item>
254-
<ContextMenu.Item
255-
disabled={index === tabs.length - 1}
256-
onSelect={() => closeTabsToRight(tab.id)}
257-
>
258-
Close tabs to the right
259-
</ContextMenu.Item>
260-
</ContextMenu.Content>
261-
</ContextMenu.Root>
262-
);
263-
})}
287+
{tab.title}
288+
</Text>
289+
290+
{tabs.length > 1 && (
291+
<IconButton
292+
size="1"
293+
variant="ghost"
294+
color={tab.id !== activeTabId ? "gray" : undefined}
295+
className="opacity-0 transition-opacity group-hover:opacity-100"
296+
onClick={(e) => {
297+
e.stopPropagation();
298+
closeTab(tab.id);
299+
}}
300+
>
301+
<Cross2Icon />
302+
</IconButton>
303+
)}
304+
</Flex>
305+
</ContextMenu.Trigger>
306+
<ContextMenu.Content>
307+
<ContextMenu.Item
308+
disabled={tabs.length === 1}
309+
onSelect={() => closeOtherTabs(tab.id)}
310+
>
311+
Close other tabs
312+
</ContextMenu.Item>
313+
<ContextMenu.Item
314+
disabled={index === tabs.length - 1}
315+
onSelect={() => closeTabsToRight(tab.id)}
316+
>
317+
Close tabs to the right
318+
</ContextMenu.Item>
319+
</ContextMenu.Content>
320+
</ContextMenu.Root>
321+
);
322+
})}
323+
</Flex>
324+
325+
{showScrollGradient && (
326+
<Box
327+
position="absolute"
328+
top="0"
329+
right="0"
330+
height="40px"
331+
width="80px"
332+
className="pointer-events-none"
333+
style={{
334+
background:
335+
"linear-gradient(to left, var(--color-background) 0%, transparent 100%)",
336+
zIndex: 10,
337+
}}
338+
/>
339+
)}
264340
</Flex>
265341
);
266342
}

src/renderer/styles/globals.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ body {
3636
-webkit-app-region: no-drag;
3737
}
3838

39+
.scrollbar-hide::-webkit-scrollbar {
40+
display: none;
41+
}
42+
3943
/* File mention list styles */
4044
.file-mention-item {
4145
padding: var(--space-2);

0 commit comments

Comments
 (0)