diff --git a/src/renderer/src/components/sidebar/header-drag-click-swallow.ts b/src/renderer/src/components/sidebar/header-drag-click-swallow.ts new file mode 100644 index 00000000000..1e875558b70 --- /dev/null +++ b/src/renderer/src/components/sidebar/header-drag-click-swallow.ts @@ -0,0 +1,20 @@ +/** + * Swallow the click that follows a completed header drag. + * + * Why: the pointerup that ends a promoted drag is followed by a click on the + * drag handle, which would also toggle the section the user just reordered. + * The listener removes itself on the first click; the returned timeout handle + * is the fallback for a drop that produces no click. + */ +export function swallowNextClickOnDragHandle(handleEl: HTMLElement): ReturnType { + const swallow = (event: MouseEvent): void => { + const target = event.target as Node | null + if (target && handleEl.contains(target)) { + event.stopPropagation() + event.preventDefault() + } + window.removeEventListener('click', swallow, true) + } + window.addEventListener('click', swallow, true) + return setTimeout(() => window.removeEventListener('click', swallow, true), 0) +} diff --git a/src/renderer/src/components/sidebar/header-drag-pointer-release.ts b/src/renderer/src/components/sidebar/header-drag-pointer-release.ts new file mode 100644 index 00000000000..4e8466ed3e7 --- /dev/null +++ b/src/renderer/src/components/sidebar/header-drag-pointer-release.ts @@ -0,0 +1,14 @@ +/** + * True when a pointermove arrives with no button held, meaning the pointerup + * that should have ended the armed drag never reached us. + * + * Why: the header drag hooks subscribe to window pointer events from an effect + * armed by pointerdown state, so a fast click's release can land before that + * effect runs (a heavy sidebar render sits between them). The session then + * survives the click and the next hover promotes a drag the user is not doing — + * for host sections that hides the header outright and force-collapses every + * host. A capture-phase listener swallowing pointerup has the same effect. + */ +export function hasPointerBeenReleased(event: PointerEvent): boolean { + return event.buttons === 0 +} diff --git a/src/renderer/src/components/sidebar/host-header-drag.test.tsx b/src/renderer/src/components/sidebar/host-header-drag.test.tsx new file mode 100644 index 00000000000..6c9e0f66e33 --- /dev/null +++ b/src/renderer/src/components/sidebar/host-header-drag.test.tsx @@ -0,0 +1,92 @@ +// @vitest-environment happy-dom +import React from 'react' +import { act, render } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { useHostHeaderDrag } from './host-header-drag' +import type { ExecutionHostId } from '../../../../shared/execution-host' + +function setup() { + const scrollContainer = document.createElement('div') + document.body.append(scrollContainer) + const controller: { current: ReturnType | null } = { current: null } + + function Harness(): React.JSX.Element { + const drag = useHostHeaderDrag({ + orderedHostIds: ['ssh:host-a', 'ssh:host-b'] as ExecutionHostId[], + onCommit: vi.fn(), + getScrollContainer: () => scrollContainer + }) + controller.current = drag + return ( +
drag.onHandlePointerDown(event, 'ssh:host-a')} + /> + ) + } + + const view = render() + const header = view.container.querySelector('[data-host-header-drag-id]')! + header.setPointerCapture = vi.fn() + header.releasePointerCapture = vi.fn() + return { controller, header } +} + +function pointer(type: string, init: PointerEventInit): PointerEvent { + return new PointerEvent(type, { bubbles: true, pointerId: 1, ...init }) +} + +describe('useHostHeaderDrag', () => { + it('does not start a drag when the pointer is released before the window listeners attach', () => { + const { controller, header } = setup() + + // A click: pointerdown arms the session, pointerup lands before React has + // flushed the passive effect that subscribes to window pointer events. + act(() => { + header.dispatchEvent(pointer('pointerdown', { button: 0, clientX: 10, clientY: 10 })) + window.dispatchEvent(pointer('pointerup', { clientX: 10, clientY: 10 })) + }) + + // Moving the mouse afterwards, with no button held, must not promote a drag. + act(() => { + window.dispatchEvent(pointer('pointermove', { clientX: 200, clientY: 400, buttons: 0 })) + }) + + expect(controller.current?.state.draggingHostId).toBeNull() + }) + + it('clears a session whose pointerup was missed so a later drag still works', () => { + const { controller, header } = setup() + + act(() => { + header.dispatchEvent(pointer('pointerdown', { button: 0, clientX: 10, clientY: 10 })) + window.dispatchEvent(pointer('pointerup', { clientX: 10, clientY: 10 })) + }) + act(() => { + window.dispatchEvent(pointer('pointermove', { clientX: 200, clientY: 400, buttons: 0 })) + }) + + act(() => { + header.dispatchEvent(pointer('pointerdown', { button: 0, clientX: 10, clientY: 10 })) + }) + act(() => { + window.dispatchEvent(pointer('pointermove', { clientX: 40, clientY: 60, buttons: 1 })) + }) + + expect(controller.current?.state.draggingHostId).toBe('ssh:host-a') + }) + + it('still promotes a drag while the pointer stays down', () => { + const { controller, header } = setup() + + act(() => { + header.dispatchEvent(pointer('pointerdown', { button: 0, clientX: 10, clientY: 10 })) + }) + act(() => { + window.dispatchEvent(pointer('pointermove', { clientX: 40, clientY: 60, buttons: 1 })) + }) + + expect(controller.current?.state.draggingHostId).toBe('ssh:host-a') + }) +}) diff --git a/src/renderer/src/components/sidebar/host-header-drag.ts b/src/renderer/src/components/sidebar/host-header-drag.ts index 8b6ea676a46..cb1955dda02 100644 --- a/src/renderer/src/components/sidebar/host-header-drag.ts +++ b/src/renderer/src/components/sidebar/host-header-drag.ts @@ -16,6 +16,8 @@ import { readHostHeaderRects, type HostHeaderRect } from './host-header-drag-dom' +import { hasPointerBeenReleased } from './header-drag-pointer-release' +import { swallowNextClickOnDragHandle } from './header-drag-click-swallow' export type HostDragState = { draggingHostId: ExecutionHostId | null @@ -157,17 +159,7 @@ export function useHostHeaderDrag({ session.preview?.remove() setSidebarPointerDragDocumentStyles(false) if (session.promoted) { - const handleEl = session.handleEl - const swallow = (e: MouseEvent): void => { - const target = e.target as Node | null - if (target && handleEl.contains(target)) { - e.stopPropagation() - e.preventDefault() - } - window.removeEventListener('click', swallow, true) - } - window.addEventListener('click', swallow, true) - setTimeout(() => window.removeEventListener('click', swallow, true), 0) + swallowNextClickOnDragHandle(session.handleEl) } const finalIndex = commit && session.promoted @@ -206,6 +198,10 @@ export function useHostHeaderDrag({ if (!session || e.pointerId !== session.pointerId) { return } + if (hasPointerBeenReleased(e)) { + endDrag(false) + return + } if (!session.promoted) { const dx = e.clientX - session.startX const dy = e.clientY - session.startY diff --git a/src/renderer/src/components/sidebar/project-group-header-drag.ts b/src/renderer/src/components/sidebar/project-group-header-drag.ts index e2a879aebdc..853adfc751f 100644 --- a/src/renderer/src/components/sidebar/project-group-header-drag.ts +++ b/src/renderer/src/components/sidebar/project-group-header-drag.ts @@ -15,6 +15,8 @@ import { } from './project-group-header-drag-contract' import { createProjectGroupHeaderDragSession } from './project-group-header-drag-start' import { getWorktreeSidebarDragAutoscroll } from './worktree-sidebar-drag-autoscroll' +import { hasPointerBeenReleased } from './header-drag-pointer-release' +import { swallowNextClickOnDragHandle } from './header-drag-click-swallow' // Why pointer events instead of HTML5 DnD: Project Group rows are virtualized // and may unmount while scrolling; cached row-model indices keep drops stable. @@ -114,20 +116,7 @@ export function useProjectGroupHeaderDrag({ // capture may already be released (pointercancel, element unmounted) } if (session.promoted) { - const handleEl = session.handleEl - const swallow = (event: MouseEvent): void => { - const target = event.target as Node | null - if (target && handleEl.contains(target)) { - event.stopPropagation() - event.preventDefault() - } - window.removeEventListener('click', swallow, true) - } - window.addEventListener('click', swallow, true) - clickSwallowTimeoutRef.current = setTimeout(() => { - window.removeEventListener('click', swallow, true) - clickSwallowTimeoutRef.current = null - }, 0) + clickSwallowTimeoutRef.current = swallowNextClickOnDragHandle(session.handleEl) } const sidebarDropIndex = commit && session.promoted && latestDropIndexRef.current !== null @@ -199,6 +188,10 @@ export function useProjectGroupHeaderDrag({ if (!session || event.pointerId !== session.pointerId) { return } + if (hasPointerBeenReleased(event)) { + endDrag(false) + return + } session.latestPointerY = event.clientY if (!session.promoted) { const dx = event.clientX - session.startX diff --git a/src/renderer/src/components/sidebar/project-header-drag.ts b/src/renderer/src/components/sidebar/project-header-drag.ts index 4d13ddfa0af..68ab299d616 100644 --- a/src/renderer/src/components/sidebar/project-header-drag.ts +++ b/src/renderer/src/components/sidebar/project-header-drag.ts @@ -15,6 +15,8 @@ import { } from './project-header-drag-contract' import { createProjectHeaderDragSession } from './project-header-drag-start' import { getWorktreeSidebarDragAutoscroll } from './worktree-sidebar-drag-autoscroll' +import { hasPointerBeenReleased } from './header-drag-pointer-release' +import { swallowNextClickOnDragHandle } from './header-drag-click-swallow' // Why pointer events instead of HTML5 DnD: rows are absolutely-positioned by // react-virtual and unmount/remount as scroll changes, so DnD enter/leave fire @@ -124,20 +126,7 @@ export function useRepoHeaderDrag({ // capture may already be released (pointercancel, element unmounted) } if (session.promoted) { - const handleEl = session.handleEl - const swallow = (e: MouseEvent): void => { - const target = e.target as Node | null - if (target && handleEl.contains(target)) { - e.stopPropagation() - e.preventDefault() - } - window.removeEventListener('click', swallow, true) - } - window.addEventListener('click', swallow, true) - clickSwallowTimeoutRef.current = setTimeout(() => { - window.removeEventListener('click', swallow, true) - clickSwallowTimeoutRef.current = null - }, 0) + clickSwallowTimeoutRef.current = swallowNextClickOnDragHandle(session.handleEl) } const sidebarDropIndex = commit && session.promoted && latestDropIndexRef.current !== null @@ -212,6 +201,10 @@ export function useRepoHeaderDrag({ if (!session || e.pointerId !== session.pointerId) { return } + if (hasPointerBeenReleased(e)) { + endDrag(false) + return + } session.latestPointerY = e.clientY if (!session.promoted) { const dx = e.clientX - session.startX