diff --git a/src/components/TabBar.test.tsx b/src/components/TabBar.test.tsx
index fa150e8a..5ddf4632 100644
--- a/src/components/TabBar.test.tsx
+++ b/src/components/TabBar.test.tsx
@@ -132,6 +132,71 @@ describe('TabBar', () => {
expect(screen.getAllByTestId('tab-modified-indicator')).toHaveLength(3)
})
+ it('does not reorder on drag cancel (dragEnd without drop)', () => {
+ const onReorderTabs = vi.fn()
+ const tabs = makeTabs(['Alpha', 'Beta', 'Gamma'])
+ render(
+
+ )
+
+ const alphaTab = screen.getByText('Alpha').closest('[draggable]')!
+ const betaTab = screen.getByText('Beta').closest('[draggable]')!
+
+ fireEvent.dragStart(alphaTab, {
+ dataTransfer: { effectAllowed: 'move', setData: vi.fn() },
+ })
+
+ const rect = betaTab.getBoundingClientRect()
+ fireEvent.dragOver(betaTab, {
+ clientX: rect.left + rect.width * 0.75,
+ dataTransfer: { dropEffect: 'move' },
+ })
+
+ // Cancel via dragEnd (Escape or release outside tab bar)
+ fireEvent.dragEnd(alphaTab)
+
+ expect(onReorderTabs).not.toHaveBeenCalled()
+ })
+
+ it('reorders from last toward first position', () => {
+ const onReorderTabs = vi.fn()
+ const tabs = makeTabs(['Alpha', 'Beta', 'Gamma'])
+ render(
+
+ )
+
+ const gammaTab = screen.getByText('Gamma').closest('[draggable]')!
+ const alphaTab = screen.getByText('Alpha').closest('[draggable]')!
+
+ fireEvent.dragStart(gammaTab, {
+ dataTransfer: { effectAllowed: 'move', setData: vi.fn() },
+ })
+
+ // jsdom returns zero-sized rects, so clientX always hits "right half"
+ // (insert after index 0 → insert index 1). This still validates
+ // that dragging the last tab toward the front produces a reorder.
+ const rect = alphaTab.getBoundingClientRect()
+ fireEvent.dragOver(alphaTab, {
+ clientX: rect.left + rect.width * 0.75,
+ dataTransfer: { dropEffect: 'move' },
+ })
+
+ fireEvent.drop(alphaTab, { dataTransfer: {} })
+
+ // Gamma (2) dragged onto Alpha (0) → reorder from 2 to 1
+ expect(onReorderTabs).toHaveBeenCalledWith(2, 1)
+ })
+
it('switches tab on click', () => {
const onSwitchTab = vi.fn()
const tabs = makeTabs(['Alpha', 'Beta'])
diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx
index 73ef729a..f0a30826 100644
--- a/src/components/TabBar.tsx
+++ b/src/components/TabBar.tsx
@@ -100,16 +100,25 @@ function computeInsertIndex(e: React.DragEvent, index: number):
function useTabDrag(onReorderTabs?: (from: number, to: number) => void) {
const [dragIndex, setDragIndex] = useState(null)
const [dropIndex, setDropIndex] = useState(null)
+ // Refs mirror state so event handlers always read the latest values,
+ // avoiding stale closures when dragover and drop fire in the same frame.
+ const dragIndexRef = useRef(null)
+ const dropIndexRef = useRef(null)
const dragNodeRef = useRef(null)
+ const onReorderRef = useRef(onReorderTabs)
+ useEffect(() => { onReorderRef.current = onReorderTabs })
const resetDrag = useCallback(() => {
if (dragNodeRef.current) dragNodeRef.current.style.opacity = ''
dragNodeRef.current = null
+ dragIndexRef.current = null
+ dropIndexRef.current = null
setDragIndex(null)
setDropIndex(null)
}, [])
const handleDragStart = useCallback((e: React.DragEvent, index: number) => {
+ dragIndexRef.current = index
setDragIndex(index)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', String(index))
@@ -122,20 +131,32 @@ function useTabDrag(onReorderTabs?: (from: number, to: number) => void) {
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
- if (dragIndex === null || dragIndex === index) { setDropIndex(null); return }
- setDropIndex(computeInsertIndex(e, index))
- }, [dragIndex])
+ const currentDrag = dragIndexRef.current
+ if (currentDrag === null || currentDrag === index) {
+ dropIndexRef.current = null
+ setDropIndex(null)
+ return
+ }
+ const idx = computeInsertIndex(e, index)
+ dropIndexRef.current = idx
+ setDropIndex(idx)
+ }, [])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
- const toIndex = computeDropTarget(dragIndex, dropIndex)
- if (toIndex !== null && onReorderTabs) onReorderTabs(dragIndex!, toIndex)
+ const toIndex = computeDropTarget(dragIndexRef.current, dropIndexRef.current)
+ if (toIndex !== null && onReorderRef.current) {
+ onReorderRef.current(dragIndexRef.current!, toIndex)
+ }
resetDrag()
- }, [dragIndex, dropIndex, onReorderTabs, resetDrag])
+ }, [resetDrag])
const handleBarDragLeave = useCallback((e: React.DragEvent) => {
const related = e.relatedTarget as HTMLElement | null
- if (!e.currentTarget.contains(related)) setDropIndex(null)
+ if (!e.currentTarget.contains(related)) {
+ dropIndexRef.current = null
+ setDropIndex(null)
+ }
}, [])
return { dragIndex, dropIndex, handleDragStart, handleDragEnd: resetDrag, handleDragOver, handleDrop, handleBarDragLeave }
diff --git a/src/hooks/useTabManagement.test.ts b/src/hooks/useTabManagement.test.ts
index 749a2c79..ca7186d9 100644
--- a/src/hooks/useTabManagement.test.ts
+++ b/src/hooks/useTabManagement.test.ts
@@ -205,6 +205,28 @@ describe('useTabManagement', () => {
expect(result.current.tabs.map(t => t.entry.title)).toEqual(['C', 'A', 'B'])
})
+ it('preserves active tab after reorder', async () => {
+ const { result } = renderHook(() => useTabManagement())
+
+ await act(async () => {
+ await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
+ })
+ await act(async () => {
+ await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
+ })
+ await act(async () => {
+ await result.current.handleSelectNote(makeEntry({ path: '/vault/c.md', title: 'C' }))
+ })
+
+ // C is active (last opened). Move it to the front.
+ act(() => {
+ result.current.handleReorderTabs(2, 0)
+ })
+
+ expect(result.current.tabs.map(t => t.entry.title)).toEqual(['C', 'A', 'B'])
+ expect(result.current.activeTabPath).toBe('/vault/c.md')
+ })
+
it('persists tab order to localStorage', async () => {
const { result } = renderHook(() => useTabManagement())