mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-04 17:04:18 +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()
|
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 { TabBar } = await import('./TabBar')
|
||||||
const { useTabStore } = await import('../../stores/tabStore')
|
const { useTabStore } = await import('../../stores/tabStore')
|
||||||
const { useChatStore } = await import('../../stores/chatStore')
|
const { useChatStore } = await import('../../stores/chatStore')
|
||||||
@ -237,17 +237,62 @@ describe('TabBar', () => {
|
|||||||
render(<TabBar />)
|
render(<TabBar />)
|
||||||
})
|
})
|
||||||
|
|
||||||
const firstTab = screen.getByText('First Session').closest('[draggable="true"]')
|
expect(screen.getByTestId('tab-bar').querySelector('.tab-bar-hit-area')).toBeInTheDocument()
|
||||||
const secondTab = screen.getByText('Second Session').closest('[draggable="true"]')
|
|
||||||
|
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(firstTab).toBeTruthy()
|
||||||
expect(secondTab).toBeTruthy()
|
expect(secondTab).toBeTruthy()
|
||||||
|
|
||||||
fireEvent.dragStart(firstTab!)
|
Object.defineProperty(firstTab!, 'getBoundingClientRect', {
|
||||||
fireEvent.dragOver(secondTab!)
|
configurable: true,
|
||||||
fireEvent.drop(secondTab!)
|
value: () => ({ left: 0, width: 180 }),
|
||||||
fireEvent.dragEnd(firstTab!)
|
})
|
||||||
|
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'])
|
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 { useTabStore, type Tab } from '../../stores/tabStore'
|
||||||
import { useChatStore } from '../../stores/chatStore'
|
import { useChatStore } from '../../stores/chatStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { WindowControls, showWindowControls } from './WindowControls'
|
import { WindowControls, showWindowControls } from './WindowControls'
|
||||||
|
|
||||||
const TAB_WIDTH = 180
|
const TAB_WIDTH = 180
|
||||||
|
const DRAG_START_THRESHOLD = 4
|
||||||
const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
|
const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
|
||||||
|
|
||||||
export function TabBar() {
|
export function TabBar() {
|
||||||
@ -21,7 +22,12 @@ export function TabBar() {
|
|||||||
const [contextMenu, setContextMenu] = useState<{ sessionId: string; x: number; y: number } | null>(null)
|
const [contextMenu, setContextMenu] = useState<{ sessionId: string; x: number; y: number } | null>(null)
|
||||||
const [closingTabId, setClosingTabId] = useState<string | null>(null)
|
const [closingTabId, setClosingTabId] = useState<string | null>(null)
|
||||||
const [dragOverIndex, setDragOverIndex] = useState<number | 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 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 startDraggingRef = useRef<(() => Promise<void>) | null>(null)
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
|
|
||||||
@ -131,30 +137,88 @@ export function TabBar() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDragStart = (index: number) => {
|
const getTargetIndexFromClientX = useCallback((clientX: number) => {
|
||||||
dragIndexRef.current = index
|
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) => {
|
return tabs.length > 0 ? tabs.length - 1 : null
|
||||||
e.preventDefault()
|
}, [tabs])
|
||||||
if (dragIndexRef.current === null || dragIndexRef.current === index) {
|
|
||||||
|
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)
|
setDragOverIndex(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setDragOverIndex(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)
|
||||||
|
}
|
||||||
|
}, [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 handleDrop = (index: number) => {
|
const handleTabClick = (sessionId: string) => {
|
||||||
if (dragIndexRef.current !== null && dragIndexRef.current !== index) {
|
if (suppressClickRef.current) {
|
||||||
moveTab(dragIndexRef.current, index)
|
suppressClickRef.current = false
|
||||||
|
return
|
||||||
}
|
}
|
||||||
dragIndexRef.current = null
|
setActiveTab(sessionId)
|
||||||
setDragOverIndex(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragEnd = () => {
|
|
||||||
dragIndexRef.current = null
|
|
||||||
setDragOverIndex(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScrollRegionMouseDown = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
const handleScrollRegionMouseDown = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
@ -180,23 +244,23 @@ export function TabBar() {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
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()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
onMouseDown={handleScrollRegionMouseDown}
|
onMouseDown={handleScrollRegionMouseDown}
|
||||||
>
|
>
|
||||||
{tabs.map((tab, index) => (
|
{tabs.map((tab, index) => (
|
||||||
<TabItem
|
<TabItem
|
||||||
key={tab.sessionId}
|
key={tab.sessionId}
|
||||||
|
ref={(node) => { tabRefs.current.set(tab.sessionId, node) }}
|
||||||
tab={tab}
|
tab={tab}
|
||||||
isActive={tab.sessionId === activeTabId}
|
isActive={tab.sessionId === activeTabId}
|
||||||
isDragOver={dragOverIndex === index}
|
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)}
|
onClose={() => handleClose(tab.sessionId)}
|
||||||
onContextMenu={(e) => handleContextMenu(e, tab.sessionId)}
|
onContextMenu={(e) => handleContextMenu(e, tab.sessionId)}
|
||||||
onDragStart={() => handleDragStart(index)}
|
onMouseDown={(event) => handleTabMouseDown(event, index)}
|
||||||
onDragOver={(e) => handleDragOver(e, index)}
|
|
||||||
onDrop={() => handleDrop(index)}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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
|
tab: Tab
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
isDragOver: boolean
|
isDragOver: boolean
|
||||||
|
isDragging: boolean
|
||||||
|
dragOffsetX: number
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onContextMenu: (e: React.MouseEvent) => void
|
onContextMenu: (e: React.MouseEvent) => void
|
||||||
onDragStart: () => void
|
onMouseDown: (event: React.MouseEvent) => void
|
||||||
onDragOver: (e: React.DragEvent) => void
|
}>(({ tab, isActive, isDragOver, isDragging, dragOffsetX, onClick, onClose, onContextMenu, onMouseDown }, ref) => {
|
||||||
onDrop: () => void
|
|
||||||
onDragEnd: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
draggable
|
ref={ref}
|
||||||
|
data-dragging={isDragging ? 'true' : 'false'}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
onDragStart={onDragStart}
|
|
||||||
onDragOver={onDragOver}
|
|
||||||
onDrop={onDrop}
|
|
||||||
onDragEnd={onDragEnd}
|
|
||||||
className={`
|
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
|
${isActive
|
||||||
? 'bg-[var(--color-surface)]'
|
? 'bg-[var(--color-surface)]'
|
||||||
: 'bg-transparent hover:bg-[var(--color-surface-hover)]'
|
: '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' && (
|
{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" />
|
<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>
|
</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
onMouseDown={(e) => { e.stopPropagation() }}
|
||||||
onClick={(e) => { e.stopPropagation(); onClose() }}
|
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)]"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
TabItem.displayName = 'TabItem'
|
||||||
|
|||||||
@ -412,6 +412,10 @@ button, input, textarea, select, a, [role="button"] {
|
|||||||
[draggable="true"] {
|
[draggable="true"] {
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
.tab-bar-hit-area,
|
||||||
|
.tab-bar-hit-area * {
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom shadow from prototype */
|
/* Custom shadow from prototype */
|
||||||
.custom-shadow {
|
.custom-shadow {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user