mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(sidebar): stop a missed pointerup from hiding a remote host section (#19032)
Clicking a host header arms a drag session on pointerdown, but the window pointermove/pointerup listeners attach from an effect gated on that state -- a render and a paint later. On a heavy sidebar a quick click's pointerup can land inside that window and never be seen, so the session survives the click and the next bare mouse move clears the 4px threshold and promotes a drag the user is not doing. The host tier is the only header that hides itself while dragging (opacity-0, plus forceCollapseHosts on every section), so the host the user just collapsed vanishes outright. It only returns on a stray later pointerup or when the viewport remounts -- which is why toggling the host filter fixes it: the viewport's React key includes visibleWorkspaceHostIds. Treat a pointermove with no button held as a released pointer and end the session instead of promoting. The repo and project-group header drags share the race, where it commits an unintended reorder on the next click, so they get the same guard. Extracting their duplicated click-swallow block keeps project-header-drag.ts under the max-lines ceiling.
This commit is contained in:
@@ -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<typeof setTimeout> {
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<typeof useHostHeaderDrag> | 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 (
|
||||
<div
|
||||
data-host-header-drag-id="ssh:host-a"
|
||||
onPointerDown={(event) => drag.onHandlePointerDown(event, 'ssh:host-a')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const view = render(<Harness />)
|
||||
const header = view.container.querySelector<HTMLElement>('[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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user