mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
parent
febd842557
commit
870cfe8b74
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user