diff --git a/desktop/src/components/layout/TabBar.test.tsx b/desktop/src/components/layout/TabBar.test.tsx index 1a288d19..01a6b6e1 100644 --- a/desktop/src/components/layout/TabBar.test.tsx +++ b/desktop/src/components/layout/TabBar.test.tsx @@ -216,7 +216,7 @@ describe('TabBar', () => { expect(startDraggingMock).not.toHaveBeenCalled() }) - it('reorders tabs via drag and drop', async () => { + it('reorders tabs via pointer drag', async () => { const { TabBar } = await import('./TabBar') const { useTabStore } = await import('../../stores/tabStore') const { useChatStore } = await import('../../stores/chatStore') @@ -237,17 +237,62 @@ describe('TabBar', () => { render() }) - const firstTab = screen.getByText('First Session').closest('[draggable="true"]') - const secondTab = screen.getByText('Second Session').closest('[draggable="true"]') + expect(screen.getByTestId('tab-bar').querySelector('.tab-bar-hit-area')).toBeInTheDocument() + + const firstTab = screen.getByText('First Session').closest('.tab-bar-hit-area') + const secondTab = screen.getByText('Second Session').closest('.tab-bar-hit-area') expect(firstTab).toBeTruthy() expect(secondTab).toBeTruthy() - fireEvent.dragStart(firstTab!) - fireEvent.dragOver(secondTab!) - fireEvent.drop(secondTab!) - fireEvent.dragEnd(firstTab!) + Object.defineProperty(firstTab!, 'getBoundingClientRect', { + configurable: true, + value: () => ({ left: 0, width: 180 }), + }) + Object.defineProperty(secondTab!, 'getBoundingClientRect', { + configurable: true, + value: () => ({ left: 180, width: 180 }), + }) + + fireEvent.mouseDown(firstTab!, { button: 0, clientX: 20, clientY: 10 }) + fireEvent.mouseMove(window, { clientX: 260, clientY: 10 }) + + expect(firstTab).toHaveAttribute('data-dragging', 'true') + + fireEvent.mouseUp(window) expect(useTabStore.getState().tabs.map((tab) => tab.sessionId)).toEqual(['tab-2', 'tab-1']) }) + + it('does not reorder on a simple click without dragging', async () => { + const { TabBar } = await import('./TabBar') + const { useTabStore } = await import('../../stores/tabStore') + const { useChatStore } = await import('../../stores/chatStore') + + useTabStore.setState({ + tabs: [ + { sessionId: 'tab-1', title: 'First Session', type: 'session', status: 'idle' }, + { sessionId: 'tab-2', title: 'Second Session', type: 'session', status: 'idle' }, + ], + activeTabId: 'tab-1', + }) + useChatStore.setState({ + sessions: {}, + disconnectSession: vi.fn(), + } as Partial>) + + await act(async () => { + render() + }) + + const firstTab = screen.getByText('First Session').closest('.tab-bar-hit-area') + expect(firstTab).toBeTruthy() + + fireEvent.mouseDown(firstTab!, { button: 0, clientX: 20, clientY: 10 }) + fireEvent.mouseUp(window) + fireEvent.click(firstTab!) + + expect(useTabStore.getState().tabs.map((tab) => tab.sessionId)).toEqual(['tab-1', 'tab-2']) + expect(useTabStore.getState().activeTabId).toBe('tab-1') + }) }) diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx index 5b6595f6..f9fc3a96 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -1,10 +1,11 @@ -import { useRef, useState, useEffect, useCallback } from 'react' +import { forwardRef, useRef, useState, useEffect, useCallback } from 'react' import { useTabStore, type Tab } from '../../stores/tabStore' import { useChatStore } from '../../stores/chatStore' import { useTranslation } from '../../i18n' import { WindowControls, showWindowControls } from './WindowControls' const TAB_WIDTH = 180 +const DRAG_START_THRESHOLD = 4 const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window) export function TabBar() { @@ -21,7 +22,12 @@ export function TabBar() { const [contextMenu, setContextMenu] = useState<{ sessionId: string; x: number; y: number } | null>(null) const [closingTabId, setClosingTabId] = useState(null) const [dragOverIndex, setDragOverIndex] = useState(null) + const [draggingSessionId, setDraggingSessionId] = useState(null) + const [dragOffsetX, setDragOffsetX] = useState(0) const dragIndexRef = useRef(null) + const pendingDragRef = useRef<{ index: number; startX: number; startY: number } | null>(null) + const suppressClickRef = useRef(false) + const tabRefs = useRef(new Map()) const startDraggingRef = useRef<(() => Promise) | null>(null) const t = useTranslation() @@ -131,30 +137,88 @@ export function TabBar() { } } - const handleDragStart = (index: number) => { - dragIndexRef.current = index - } + const getTargetIndexFromClientX = useCallback((clientX: number) => { + for (let index = 0; index < tabs.length; index++) { + const tab = tabs[index] + if (!tab) continue + const el = tabRefs.current.get(tab.sessionId) + if (!el) continue + const rect = el.getBoundingClientRect() + if (clientX < rect.left + rect.width / 2) return index + } - const handleDragOver = (e: React.DragEvent, index: number) => { - e.preventDefault() - if (dragIndexRef.current === null || dragIndexRef.current === index) { + return tabs.length > 0 ? tabs.length - 1 : null + }, [tabs]) + + const finalizeDrag = useCallback((targetIndex: number | null) => { + if (dragIndexRef.current !== null && targetIndex !== null && dragIndexRef.current !== targetIndex) { + moveTab(dragIndexRef.current, targetIndex) + } + dragIndexRef.current = null + pendingDragRef.current = null + setDraggingSessionId(null) + setDragOffsetX(0) + setDragOverIndex(null) + }, [moveTab]) + + const handlePointerMove = useCallback((event: MouseEvent) => { + const pending = pendingDragRef.current + if (!pending) return + + const deltaX = Math.abs(event.clientX - pending.startX) + const deltaY = Math.abs(event.clientY - pending.startY) + + if (dragIndexRef.current === null) { + if (Math.max(deltaX, deltaY) < DRAG_START_THRESHOLD) return + dragIndexRef.current = pending.index + suppressClickRef.current = true + setDraggingSessionId(tabs[pending.index]?.sessionId ?? null) + } + + setDragOffsetX(event.clientX - pending.startX) + + const targetIndex = getTargetIndexFromClientX(event.clientX) + if (targetIndex === null || targetIndex === dragIndexRef.current) { setDragOverIndex(null) return } - setDragOverIndex(index) - } - const handleDrop = (index: number) => { - if (dragIndexRef.current !== null && dragIndexRef.current !== index) { - moveTab(dragIndexRef.current, index) + setDragOverIndex(targetIndex) + }, [getTargetIndexFromClientX]) + + const handlePointerUp = useCallback(() => { + finalizeDrag(dragOverIndex) + }, [dragOverIndex, finalizeDrag]) + + useEffect(() => { + window.addEventListener('mousemove', handlePointerMove) + window.addEventListener('mouseup', handlePointerUp) + return () => { + window.removeEventListener('mousemove', handlePointerMove) + window.removeEventListener('mouseup', handlePointerUp) } - dragIndexRef.current = null - setDragOverIndex(null) + }, [handlePointerMove, handlePointerUp]) + + useEffect(() => { + if (!draggingSessionId) return + const previousCursor = document.body.style.cursor + document.body.style.cursor = 'grabbing' + return () => { + document.body.style.cursor = previousCursor + } + }, [draggingSessionId]) + + const handleTabMouseDown = (event: React.MouseEvent, index: number) => { + if (event.button !== 0) return + pendingDragRef.current = { index, startX: event.clientX, startY: event.clientY } } - const handleDragEnd = () => { - dragIndexRef.current = null - setDragOverIndex(null) + const handleTabClick = (sessionId: string) => { + if (suppressClickRef.current) { + suppressClickRef.current = false + return + } + setActiveTab(sessionId) } const handleScrollRegionMouseDown = useCallback((event: React.MouseEvent) => { @@ -180,23 +244,23 @@ export function TabBar() {
e.preventDefault()} onMouseDown={handleScrollRegionMouseDown} > {tabs.map((tab, index) => ( { tabRefs.current.set(tab.sessionId, node) }} tab={tab} isActive={tab.sessionId === activeTabId} isDragOver={dragOverIndex === index} - onClick={() => setActiveTab(tab.sessionId)} + isDragging={tab.sessionId === draggingSessionId} + dragOffsetX={tab.sessionId === draggingSessionId ? dragOffsetX : 0} + onClick={() => handleTabClick(tab.sessionId)} onClose={() => handleClose(tab.sessionId)} onContextMenu={(e) => handleContextMenu(e, tab.sessionId)} - onDragStart={() => handleDragStart(index)} - onDragOver={(e) => handleDragOver(e, index)} - onDrop={() => handleDrop(index)} - onDragEnd={handleDragEnd} + onMouseDown={(event) => handleTabMouseDown(event, index)} /> ))}
@@ -291,36 +355,40 @@ export function TabBar() { ) } -function TabItem({ tab, isActive, isDragOver, onClick, onClose, onContextMenu, onDragStart, onDragOver, onDrop, onDragEnd }: { +const TabItem = forwardRef void onClose: () => void onContextMenu: (e: React.MouseEvent) => void - onDragStart: () => void - onDragOver: (e: React.DragEvent) => void - onDrop: () => void - onDragEnd: () => void -}) { + onMouseDown: (event: React.MouseEvent) => void +}>(({ tab, isActive, isDragOver, isDragging, dragOffsetX, onClick, onClose, onContextMenu, onMouseDown }, ref) => { return (
{tab.type === 'session' && tab.status === 'running' && ( @@ -340,6 +408,7 @@ function TabItem({ tab, isActive, isDragOver, onClick, onClose, onContextMenu, o
) -} +}) +TabItem.displayName = 'TabItem' diff --git a/desktop/src/theme/globals.css b/desktop/src/theme/globals.css index eeb72334..57f11120 100644 --- a/desktop/src/theme/globals.css +++ b/desktop/src/theme/globals.css @@ -412,6 +412,10 @@ button, input, textarea, select, a, [role="button"] { [draggable="true"] { -webkit-app-region: no-drag; } +.tab-bar-hit-area, +.tab-bar-hit-area * { + -webkit-app-region: no-drag; +} /* Custom shadow from prototype */ .custom-shadow {