Make desktop tab reordering independent of native HTML drag events

The tab strip was relying on browser drag-and-drop, which worked in the web UI
but did not reliably start inside the macOS overlay title bar in the packaged
Tauri desktop app. This switches tab reordering to a pointer-driven interaction
that computes insertion positions directly, keeps the drag indicator behavior,
and preserves the separate window-drag fallback for non-tab title-bar space.

Constraint: Must work inside the macOS overlay title bar where native window hit-testing interferes with browser drag events
Rejected: Keep tuning draggable/drop handling | web behavior stayed fine while desktop never consistently entered the drag sequence
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep tab reordering on explicit pointer handling unless the title-bar architecture changes away from overlay hit-testing
Tested: bun run test -- src/components/layout/TabBar.test.tsx; bun run lint; bun run build
Not-tested: Manual packaged desktop app verification after this commit
This commit is contained in:
程序员阿江(Relakkes) 2026-04-20 22:20:56 +08:00
parent febd842557
commit 870cfe8b74
3 changed files with 164 additions and 45 deletions

View File

@ -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(<TabBar />)
})
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<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<TabBar />)
})
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')
})
})

View File

@ -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<string | null>(null)
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
const [draggingSessionId, setDraggingSessionId] = useState<string | null>(null)
const [dragOffsetX, setDragOffsetX] = useState(0)
const dragIndexRef = useRef<number | null>(null)
const pendingDragRef = useRef<{ index: number; startX: number; startY: number } | null>(null)
const suppressClickRef = useRef(false)
const tabRefs = useRef(new Map<string, HTMLDivElement | null>())
const startDraggingRef = useRef<(() => Promise<void>) | 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<HTMLDivElement>) => {
@ -180,23 +244,23 @@ export function TabBar() {
<div
ref={scrollRef}
className="flex-1 flex items-stretch overflow-x-hidden"
className="tab-bar-hit-area flex-1 flex items-stretch overflow-x-hidden"
onDragOver={(e) => e.preventDefault()}
onMouseDown={handleScrollRegionMouseDown}
>
{tabs.map((tab, index) => (
<TabItem
key={tab.sessionId}
ref={(node) => { 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)}
/>
))}
</div>
@ -291,36 +355,40 @@ export function TabBar() {
)
}
function TabItem({ tab, isActive, isDragOver, onClick, onClose, onContextMenu, onDragStart, onDragOver, onDrop, onDragEnd }: {
const TabItem = forwardRef<HTMLDivElement, {
tab: Tab
isActive: boolean
isDragOver: boolean
isDragging: boolean
dragOffsetX: number
onClick: () => 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 (
<div
draggable
ref={ref}
data-dragging={isDragging ? 'true' : 'false'}
onClick={onClick}
onMouseDown={onMouseDown}
onContextMenu={onContextMenu}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onDragEnd={onDragEnd}
className={`
flex-shrink-0 flex items-center gap-1.5 px-3 min-h-[37px] cursor-pointer group transition-colors relative
tab-bar-hit-area flex-shrink-0 flex items-center gap-1.5 px-3 min-h-[37px] relative
${isDragging ? 'z-20 cursor-grabbing' : 'cursor-grab'}
transition-[background-color,box-shadow,opacity,transform] duration-150 ease-out
${isActive
? 'bg-[var(--color-surface)]'
: 'bg-transparent hover:bg-[var(--color-surface-hover)]'
}
${isDragOver ? 'before:absolute before:left-0 before:top-[6px] before:bottom-[6px] before:w-[2px] before:bg-[var(--color-brand)] before:rounded-full' : ''}
${isDragging ? 'opacity-95 shadow-[0_10px_24px_rgba(0,0,0,0.18)] ring-1 ring-[var(--color-border)]' : ''}
${isDragOver ? 'before:absolute before:left-0 before:top-[4px] before:bottom-[4px] before:w-[3px] before:bg-[var(--color-brand)] before:rounded-full before:shadow-[0_0_0_1px_rgba(255,255,255,0.25)]' : ''}
`}
style={{ width: TAB_WIDTH, maxWidth: TAB_WIDTH }}
style={{
width: TAB_WIDTH,
maxWidth: TAB_WIDTH,
transform: isDragging ? `translateX(${dragOffsetX}px) scale(1.02)` : undefined,
}}
>
{tab.type === 'session' && tab.status === 'running' && (
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-success)] animate-pulse flex-shrink-0" />
@ -340,6 +408,7 @@ function TabItem({ tab, isActive, isDragOver, onClick, onClose, onContextMenu, o
</span>
<button
onMouseDown={(e) => { e.stopPropagation() }}
onClick={(e) => { e.stopPropagation(); onClose() }}
className="flex-shrink-0 w-4 h-4 flex items-center justify-center rounded opacity-0 group-hover:opacity-100 hover:bg-[var(--color-surface-hover)] transition-opacity text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]"
>
@ -347,4 +416,5 @@ function TabItem({ tab, isActive, isDragOver, onClick, onClose, onContextMenu, o
</button>
</div>
)
}
})
TabItem.displayName = 'TabItem'

View File

@ -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 {