Skip to content

Commit ae1e939

Browse files
committed
feat: reorderable tabs
1 parent 0f4048b commit ae1e939

File tree

2 files changed

+138
-43
lines changed

2 files changed

+138
-43
lines changed

src/renderer/components/TabBar.tsx

Lines changed: 127 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
import React, { useCallback } from 'react';
1+
import React, { useCallback, useState } from 'react';
22
import { Flex, Box, Text, IconButton, Kbd } from '@radix-ui/themes';
33
import { Cross2Icon } from '@radix-ui/react-icons';
44
import { useTabStore } from '../stores/tabStore';
55
import { useHotkeys } from 'react-hotkeys-hook';
66

77
export function TabBar() {
8-
const { tabs, activeTabId, setActiveTab, closeTab } = useTabStore();
8+
const { tabs, activeTabId, setActiveTab, closeTab, reorderTabs } = useTabStore();
9+
const [draggedTab, setDraggedTab] = useState<string | null>(null);
10+
const [dragOverTab, setDragOverTab] = useState<string | null>(null);
11+
const [dropPosition, setDropPosition] = useState<'left' | 'right' | null>(null);
912

1013
// Keyboard navigation handlers
1114
const handlePrevTab = useCallback(() => {
@@ -75,53 +78,135 @@ export function TabBar() {
7578
}
7679
}, [handlePrevTab, handleNextTab, handleCloseTab, handleSwitchToTab]);
7780

81+
const handleDragStart = useCallback((e: React.DragEvent, tabId: string) => {
82+
setDraggedTab(tabId);
83+
e.dataTransfer.effectAllowed = 'move';
84+
e.dataTransfer.setData('text/plain', tabId);
85+
}, []);
86+
87+
const handleDragOver = useCallback((e: React.DragEvent, tabId: string) => {
88+
e.preventDefault();
89+
e.dataTransfer.dropEffect = 'move';
90+
91+
const rect = e.currentTarget.getBoundingClientRect();
92+
const midpoint = rect.left + rect.width / 2;
93+
const mouseX = e.clientX;
94+
95+
setDragOverTab(tabId);
96+
setDropPosition(mouseX < midpoint ? 'left' : 'right');
97+
}, []);
98+
99+
const handleDragLeave = useCallback(() => {
100+
setDragOverTab(null);
101+
setDropPosition(null);
102+
}, []);
103+
104+
const handleDrop = useCallback((e: React.DragEvent, targetTabId: string) => {
105+
e.preventDefault();
106+
const sourceTabId = e.dataTransfer.getData('text/plain');
107+
108+
if (sourceTabId && sourceTabId !== targetTabId) {
109+
const sourceIndex = tabs.findIndex(tab => tab.id === sourceTabId);
110+
let targetIndex = tabs.findIndex(tab => tab.id === targetTabId);
111+
112+
if (sourceIndex !== -1 && targetIndex !== -1) {
113+
// Adjust target index based on drop position
114+
if (dropPosition === 'right') {
115+
targetIndex = targetIndex + 1;
116+
}
117+
118+
// If moving to the right, adjust for the source being removed
119+
if (sourceIndex < targetIndex) {
120+
targetIndex = targetIndex - 1;
121+
}
122+
123+
reorderTabs(sourceIndex, targetIndex);
124+
}
125+
}
126+
127+
setDraggedTab(null);
128+
setDragOverTab(null);
129+
setDropPosition(null);
130+
}, [tabs, reorderTabs, dropPosition]);
131+
132+
const handleDragEnd = useCallback(() => {
133+
setDraggedTab(null);
134+
setDragOverTab(null);
135+
setDropPosition(null);
136+
}, []);
137+
78138
return (
79139
<Flex className="drag border-b border-gray-6" height="40px">
80140
{/* Spacer for macOS window controls */}
81141
<Box width="80px" flexShrink="0" />
82142

83-
{tabs.map((tab, index) => (
84-
<Flex
85-
key={tab.id}
86-
className={`no-drag cursor-pointer border-r border-gray-6 transition-colors group ${tab.id === activeTabId
87-
? 'bg-accent-3 text-accent-12 border-b-2 border-b-accent-8 font-medium'
88-
: 'text-gray-11 hover:bg-gray-3 hover:text-gray-12'
89-
}`}
90-
align="center"
91-
px="4"
92-
onClick={() => setActiveTab(tab.id)}
93-
>
94-
{index < 9 && (
95-
<Kbd size="1" className="mr-2 opacity-70">
96-
{navigator.platform.includes('Mac') ? '⌘' : 'Ctrl+'}{index + 1}
97-
</Kbd>
98-
)}
99-
100-
<Text
101-
size="2"
102-
className={`max-w-[200px] overflow-hidden select-none text-ellipsis whitespace-nowrap ${tab.id === activeTabId ? 'font-medium' : ''
103-
}`}
104-
mr="2"
143+
{tabs.map((tab, index) => {
144+
const isDragging = draggedTab === tab.id;
145+
const isDragOver = dragOverTab === tab.id;
146+
const showLeftIndicator = isDragOver && dropPosition === 'left';
147+
const showRightIndicator = isDragOver && dropPosition === 'right';
148+
149+
return (
150+
<Flex
151+
key={tab.id}
152+
className={`no-drag cursor-pointer border-r border-gray-6 border-b-2 transition-colors group relative ${tab.id === activeTabId
153+
? 'bg-accent-3 text-accent-12 border-b-accent-8'
154+
: 'text-gray-11 hover:bg-gray-3 hover:text-gray-12 border-b-transparent'
155+
} ${isDragging ? 'opacity-50' : ''}`}
156+
align="center"
157+
px="4"
158+
draggable
159+
onClick={() => setActiveTab(tab.id)}
160+
onDragStart={(e) => handleDragStart(e, tab.id)}
161+
onDragOver={(e) => handleDragOver(e, tab.id)}
162+
onDragLeave={handleDragLeave}
163+
onDrop={(e) => handleDrop(e, tab.id)}
164+
onDragEnd={handleDragEnd}
105165
>
106-
{tab.title}
107-
</Text>
108-
109-
{tabs.length > 1 && (
110-
<IconButton
111-
size="1"
112-
variant="ghost"
113-
color={tab.id === activeTabId ? "accent" : "gray"}
114-
className="opacity-0 group-hover:opacity-100 transition-opacity"
115-
onClick={(e) => {
116-
e.stopPropagation();
117-
closeTab(tab.id);
118-
}}
166+
{showLeftIndicator && (
167+
<Box
168+
className="absolute left-0 top-0 bottom-0 w-0.5 bg-accent-8 z-10"
169+
style={{ marginLeft: '-1px' }}
170+
/>
171+
)}
172+
173+
{showRightIndicator && (
174+
<Box
175+
className="absolute right-0 top-0 bottom-0 w-0.5 bg-accent-8 z-10"
176+
style={{ marginRight: '-1px' }}
177+
/>
178+
)}
179+
{index < 9 && (
180+
<Kbd size="1" className="mr-2 opacity-70">
181+
{navigator.platform.includes('Mac') ? '⌘' : 'Ctrl+'}{index + 1}
182+
</Kbd>
183+
)}
184+
185+
<Text
186+
size="2"
187+
className="max-w-[200px] overflow-hidden select-none text-ellipsis whitespace-nowrap"
188+
mr="2"
119189
>
120-
<Cross2Icon />
121-
</IconButton>
122-
)}
123-
</Flex>
124-
))}
190+
{tab.title}
191+
</Text>
192+
193+
{tabs.length > 1 && (
194+
<IconButton
195+
size="1"
196+
variant="ghost"
197+
color={tab.id === activeTabId ? "accent" : "gray"}
198+
className="opacity-0 group-hover:opacity-100 transition-opacity"
199+
onClick={(e) => {
200+
e.stopPropagation();
201+
closeTab(tab.id);
202+
}}
203+
>
204+
<Cross2Icon />
205+
</IconButton>
206+
)}
207+
</Flex>
208+
);
209+
})}
125210
</Flex>
126211
);
127212
}

src/renderer/stores/tabStore.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ import { v4 as uuidv4 } from 'uuid';
55
interface TabStore {
66
tabs: TabState[];
77
activeTabId: string;
8-
8+
99
createTab: (tab: Omit<TabState, 'id'>) => void;
1010
closeTab: (tabId: string) => void;
1111
setActiveTab: (tabId: string) => void;
12+
reorderTabs: (fromIndex: number, toIndex: number) => void;
1213
}
1314

1415
// Create initial tabs
@@ -64,4 +65,13 @@ export const useTabStore = create<TabStore>((set, get) => ({
6465
setActiveTab: (tabId) => {
6566
set({ activeTabId: tabId });
6667
},
68+
69+
reorderTabs: (fromIndex, toIndex) => {
70+
set(state => {
71+
const newTabs = [...state.tabs];
72+
const [movedTab] = newTabs.splice(fromIndex, 1);
73+
newTabs.splice(toIndex, 0, movedTab);
74+
return { tabs: newTabs };
75+
});
76+
},
6777
}));

0 commit comments

Comments
 (0)