11// ========================================
22// TabBar Component
33// ========================================
4- // Tab management for CLI viewer panes
4+ // Tab management for CLI viewer panes with drag-and-drop support
55
6- import { useCallback , useMemo } from 'react' ;
6+ import { useCallback , useMemo , useState } from 'react' ;
77import { useIntl } from 'react-intl' ;
88import { X , Pin , PinOff , MoreHorizontal , SplitSquareHorizontal , SplitSquareVertical } from 'lucide-react' ;
99import { cn } from '@/lib/utils' ;
@@ -14,7 +14,7 @@ import {
1414 DropdownMenuItem ,
1515 DropdownMenuSeparator ,
1616 DropdownMenuTrigger ,
17- } from '@/components/ui/DropdownMenu ' ;
17+ } from '@/components/ui/Dropdown ' ;
1818import {
1919 useViewerStore ,
2020 useViewerPanes ,
@@ -32,6 +32,7 @@ export interface TabBarProps {
3232
3333interface TabItemProps {
3434 tab : TabState ;
35+ paneId : PaneId ;
3536 isActive : boolean ;
3637 onSelect : ( ) => void ;
3738 onClose : ( e : React . MouseEvent ) => void ;
@@ -49,28 +50,117 @@ const STATUS_COLORS = {
4950
5051// ========== Helper Components ==========
5152
53+ // Data transfer key for tab drag-and-drop
54+ const TAB_DRAG_DATA_TYPE = 'application/x-cli-viewer-tab' ;
55+
56+ interface TabDragData {
57+ tabId : string ;
58+ sourcePaneId : string ;
59+ }
60+
5261/**
53- * Individual tab item
62+ * Individual tab item with drag-and-drop support
5463 */
55- function TabItem ( { tab, isActive, onSelect, onClose, onTogglePin } : TabItemProps ) {
64+ function TabItem ( { tab, paneId, isActive, onSelect, onClose, onTogglePin } : TabItemProps ) {
65+ const [ isDragging , setIsDragging ] = useState ( false ) ;
66+ const [ isDragOver , setIsDragOver ] = useState ( false ) ;
67+ const moveTab = useViewerStore ( ( state ) => state . moveTab ) ;
68+ const panes = useViewerPanes ( ) ;
69+
5670 // Simplify title for display
5771 const displayTitle = useMemo ( ( ) => {
5872 // If title contains tool name pattern, extract it
5973 const parts = tab . title . split ( '-' ) ;
6074 return parts [ 0 ] || tab . title ;
6175 } , [ tab . title ] ) ;
6276
77+ // Drag start handler
78+ const handleDragStart = useCallback ( ( e : React . DragEvent ) => {
79+ const dragData : TabDragData = {
80+ tabId : tab . id ,
81+ sourcePaneId : paneId ,
82+ } ;
83+ e . dataTransfer . setData ( TAB_DRAG_DATA_TYPE , JSON . stringify ( dragData ) ) ;
84+ e . dataTransfer . effectAllowed = 'move' ;
85+ setIsDragging ( true ) ;
86+ } , [ tab . id , paneId ] ) ;
87+
88+ // Drag end handler
89+ const handleDragEnd = useCallback ( ( ) => {
90+ setIsDragging ( false ) ;
91+ } , [ ] ) ;
92+
93+ // Drag over handler
94+ const handleDragOver = useCallback ( ( e : React . DragEvent ) => {
95+ if ( e . dataTransfer . types . includes ( TAB_DRAG_DATA_TYPE ) ) {
96+ e . preventDefault ( ) ;
97+ e . dataTransfer . dropEffect = 'move' ;
98+ setIsDragOver ( true ) ;
99+ }
100+ } , [ ] ) ;
101+
102+ // Drag leave handler
103+ const handleDragLeave = useCallback ( ( ) => {
104+ setIsDragOver ( false ) ;
105+ } , [ ] ) ;
106+
107+ // Drop handler
108+ const handleDrop = useCallback ( ( e : React . DragEvent ) => {
109+ e . preventDefault ( ) ;
110+ setIsDragOver ( false ) ;
111+
112+ const rawData = e . dataTransfer . getData ( TAB_DRAG_DATA_TYPE ) ;
113+ if ( ! rawData ) return ;
114+
115+ try {
116+ const dragData : TabDragData = JSON . parse ( rawData ) ;
117+ const { tabId : sourceTabId , sourcePaneId } = dragData ;
118+
119+ // Don't do anything if dropping on the same tab
120+ if ( sourceTabId === tab . id ) return ;
121+
122+ // Find the target index
123+ const targetPane = panes [ paneId ] ;
124+ if ( ! targetPane ) return ;
125+
126+ const targetIndex = targetPane . tabs . findIndex ( ( t ) => t . id === tab . id ) ;
127+ if ( targetIndex === - 1 ) return ;
128+
129+ // Move the tab
130+ moveTab ( sourcePaneId , sourceTabId , paneId , targetIndex ) ;
131+ } catch ( err ) {
132+ console . error ( '[TabBar] Failed to parse drag data:' , err ) ;
133+ }
134+ } , [ tab . id , paneId , panes , moveTab ] ) ;
135+
63136 return (
64- < button
137+ < div
138+ role = "tab"
139+ tabIndex = { 0 }
140+ draggable = { ! tab . isPinned }
141+ onDragStart = { handleDragStart }
142+ onDragEnd = { handleDragEnd }
143+ onDragOver = { handleDragOver }
144+ onDragLeave = { handleDragLeave }
145+ onDrop = { handleDrop }
65146 onClick = { onSelect }
147+ onKeyDown = { ( e ) => {
148+ if ( e . key === 'Enter' || e . key === ' ' ) {
149+ e . preventDefault ( ) ;
150+ onSelect ( ) ;
151+ }
152+ } }
66153 className = { cn (
67154 'group relative flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs' ,
68155 'border border-border/50 shrink-0 min-w-0 max-w-[160px]' ,
69- 'transition-all duration-150' ,
156+ 'transition-all duration-150 select-none ' ,
70157 isActive
71158 ? 'bg-slate-100 dark:bg-slate-800 border-slate-300 dark:border-slate-600 shadow-sm'
72159 : 'bg-muted/30 hover:bg-muted/50 border-border/30' ,
73- tab . isPinned && 'border-amber-500/50'
160+ tab . isPinned && 'border-amber-500/50' ,
161+ isDragging && 'opacity-50 cursor-grabbing' ,
162+ isDragOver && 'border-primary border-dashed bg-primary/10' ,
163+ ! tab . isPinned && 'cursor-grab'
74164 ) }
75165 title = { tab . title }
76166 >
@@ -111,7 +201,7 @@ function TabItem({ tab, isActive, onSelect, onClose, onTogglePin }: TabItemProps
111201 </ button >
112202 ) }
113203 </ div >
114- </ button >
204+ </ div >
115205 ) ;
116206}
117207
@@ -125,17 +215,20 @@ function TabItem({ tab, isActive, onSelect, onClose, onTogglePin }: TabItemProps
125215 * - Active tab highlighting
126216 * - Close button on hover
127217 * - Pin/unpin functionality
218+ * - Drag-and-drop tab reordering and moving between panes
128219 * - Pane actions dropdown
129220 */
130221export function TabBar ( { paneId, className } : TabBarProps ) {
131222 const { formatMessage } = useIntl ( ) ;
223+ const [ isDragOver , setIsDragOver ] = useState ( false ) ;
132224 const panes = useViewerPanes ( ) ;
133225 const pane = panes [ paneId ] ;
134226 const setActiveTab = useViewerStore ( ( state ) => state . setActiveTab ) ;
135227 const removeTab = useViewerStore ( ( state ) => state . removeTab ) ;
136228 const togglePinTab = useViewerStore ( ( state ) => state . togglePinTab ) ;
137229 const addPane = useViewerStore ( ( state ) => state . addPane ) ;
138230 const removePane = useViewerStore ( ( state ) => state . removePane ) ;
231+ const moveTab = useViewerStore ( ( state ) => state . moveTab ) ;
139232
140233 const handleTabSelect = useCallback (
141234 ( tabId : string ) => {
@@ -172,6 +265,43 @@ export function TabBar({ paneId, className }: TabBarProps) {
172265 removePane ( paneId ) ;
173266 } , [ paneId , removePane ] ) ;
174267
268+ // Drag over handler for tab bar container (allows dropping to end of list)
269+ const handleContainerDragOver = useCallback ( ( e : React . DragEvent ) => {
270+ if ( e . dataTransfer . types . includes ( TAB_DRAG_DATA_TYPE ) ) {
271+ e . preventDefault ( ) ;
272+ e . dataTransfer . dropEffect = 'move' ;
273+ setIsDragOver ( true ) ;
274+ }
275+ } , [ ] ) ;
276+
277+ // Drag leave handler for container
278+ const handleContainerDragLeave = useCallback ( ( e : React . DragEvent ) => {
279+ // Only set false if leaving the container entirely, not just moving to a child
280+ if ( ! e . currentTarget . contains ( e . relatedTarget as Node ) ) {
281+ setIsDragOver ( false ) ;
282+ }
283+ } , [ ] ) ;
284+
285+ // Drop handler for tab bar container (drops to end of list)
286+ const handleContainerDrop = useCallback ( ( e : React . DragEvent ) => {
287+ e . preventDefault ( ) ;
288+ setIsDragOver ( false ) ;
289+
290+ const rawData = e . dataTransfer . getData ( TAB_DRAG_DATA_TYPE ) ;
291+ if ( ! rawData ) return ;
292+
293+ try {
294+ const dragData : TabDragData = JSON . parse ( rawData ) ;
295+ const { tabId : sourceTabId , sourcePaneId } = dragData ;
296+
297+ // Move the tab to the end of this pane
298+ const targetIndex = pane ?. tabs . length || 0 ;
299+ moveTab ( sourcePaneId , sourceTabId , paneId , targetIndex ) ;
300+ } catch ( err ) {
301+ console . error ( '[TabBar] Failed to parse drag data:' , err ) ;
302+ }
303+ } , [ paneId , pane , moveTab ] ) ;
304+
175305 // Sort tabs: pinned first, then by order
176306 const sortedTabs = useMemo ( ( ) => {
177307 if ( ! pane ) return [ ] ;
@@ -197,7 +327,15 @@ export function TabBar({ paneId, className }: TabBarProps) {
197327 ) }
198328 >
199329 { /* Tabs */ }
200- < div className = "flex items-center gap-1 flex-1 min-w-0 overflow-x-auto" >
330+ < div
331+ onDragOver = { handleContainerDragOver }
332+ onDragLeave = { handleContainerDragLeave }
333+ onDrop = { handleContainerDrop }
334+ className = { cn (
335+ 'flex items-center gap-1 flex-1 min-w-0 overflow-x-auto' ,
336+ isDragOver && 'bg-primary/5 border border-primary border-dashed rounded'
337+ ) }
338+ >
201339 { sortedTabs . length === 0 ? (
202340 < span className = "text-xs text-muted-foreground px-2" >
203341 { formatMessage ( { id : 'cliViewer.tabs.noTabs' , defaultMessage : 'No tabs open' } ) }
@@ -207,6 +345,7 @@ export function TabBar({ paneId, className }: TabBarProps) {
207345 < TabItem
208346 key = { tab . id }
209347 tab = { tab }
348+ paneId = { paneId }
210349 isActive = { pane . activeTabId === tab . id }
211350 onSelect = { ( ) => handleTabSelect ( tab . id ) }
212351 onClose = { ( e ) => handleTabClose ( e , tab . id ) }
0 commit comments