Defer project header pointer capture until drag threshold is crossed (#5278)

This allows simple clicks on the repository header to still reach the
inner collapse handler on pointerup. Additionally, body cursor and
user-select styles are only applied when actual dragging begins,
rather than when the session is armed.
This commit is contained in:
Jinjing 2026-06-12 12:17:50 -07:00 committed by GitHub
parent dc8fdf65a3
commit b81f06203d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 241 additions and 18 deletions

View File

@ -3034,11 +3034,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
? undefined
: { transform: getVirtualRowTransform(vItem.start) }
}
onPointerDown={
isDraggableRepoHeader && projectIdForHeader
? (event) => repoDrag.onHandlePointerDown(event, projectIdForHeader)
: undefined
}
>
<div
role="button"
@ -3051,7 +3046,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
data-workspace-pin-drop-target={isPinnedHeader ? '' : undefined}
className={cn(
'group flex h-7 w-full items-center gap-1.5 pr-1 text-left transition-all',
isDraggableRepoHeader ? 'cursor-grab' : 'cursor-pointer',
'cursor-pointer',
isDraggingThis &&
'bg-accent/80 ring-1 ring-ring/40 shadow-md rounded-md scale-[1.01]',
headerWorkspaceStatus &&
@ -3120,7 +3115,18 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<div className="min-w-0 truncate text-[13px] font-semibold leading-none">
<div
data-repo-header-drag-handle=""
className={cn(
'min-w-0 truncate text-[13px] font-semibold leading-none',
isDraggableRepoHeader && 'cursor-grab'
)}
onPointerDown={
isDraggableRepoHeader && projectIdForHeader
? (event) => repoDrag.onHandlePointerDown(event, projectIdForHeader)
: undefined
}
>
{row.label}
</div>
<RepoForkIndicator upstream={row.repo?.upstream} />

View File

@ -45,9 +45,22 @@ export type ProjectHeaderDragSession = {
export const PROJECT_HEADER_DRAG_THRESHOLD_PX = 4
const REPO_HEADER_DRAG_HANDLE_SELECTOR = '[data-repo-header-drag-handle]'
const REPO_HEADER_ACTION_SELECTOR =
'[data-repo-header-action], button, a, input, textarea, select, [contenteditable=""], [contenteditable="true"]'
export function isProjectHeaderDragHandleTarget(
target: EventTarget | null,
currentTarget: HTMLElement
): boolean {
if (!(target instanceof HTMLElement)) {
return false
}
const dragHandle = target.closest(REPO_HEADER_DRAG_HANDLE_SELECTOR)
return dragHandle !== null && currentTarget.contains(dragHandle)
}
export function isRepoHeaderActionTarget(
target: EventTarget | null,
currentTarget: HTMLElement

View File

@ -0,0 +1,78 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from 'vitest'
import { createProjectHeaderDragSession } from './project-header-drag-start'
import type { Repo } from '../../../../shared/types'
function createRepo(id: string, projectGroupId: string | null = null): Repo {
return {
id,
path: `/tmp/${id}`,
displayName: id,
badgeColor: '#000000',
addedAt: 0,
projectGroupId,
projectGroupOrder: 0
}
}
describe('createProjectHeaderDragSession', () => {
it('does not capture the pointer when arming a drag session', () => {
const handleEl = document.createElement('div')
handleEl.setAttribute('data-repo-header-drag-handle', '')
handleEl.setPointerCapture = vi.fn()
const scrollContainer = document.createElement('div')
document.body.append(scrollContainer, handleEl)
const repoById = new Map<string, Repo>([['repo-a', createRepo('repo-a')]])
const sidebarRepoHeaderIdsByBucket = new Map([['ungrouped', ['repo-a', 'repo-b']]])
const session = createProjectHeaderDragSession({
event: {
button: 0,
pointerId: 1,
clientX: 10,
clientY: 20,
target: handleEl,
currentTarget: handleEl
} as unknown as React.PointerEvent<HTMLElement>,
repoId: 'repo-a',
repoById,
sidebarRepoHeaderIdsByBucket,
getScrollContainer: () => scrollContainer
})
expect(session).not.toBeNull()
expect(handleEl.setPointerCapture).not.toHaveBeenCalled()
})
it('does not arm drag when the pointer starts outside the project name handle', () => {
const header = document.createElement('div')
const handleEl = document.createElement('div')
handleEl.setAttribute('data-repo-header-drag-handle', '')
const chevron = document.createElement('span')
header.append(handleEl, chevron)
const scrollContainer = document.createElement('div')
document.body.append(scrollContainer, header)
const repoById = new Map<string, Repo>([['repo-a', createRepo('repo-a')]])
const sidebarRepoHeaderIdsByBucket = new Map([['ungrouped', ['repo-a', 'repo-b']]])
const session = createProjectHeaderDragSession({
event: {
button: 0,
pointerId: 1,
clientX: 10,
clientY: 20,
target: chevron,
currentTarget: header
} as unknown as React.PointerEvent<HTMLElement>,
repoId: 'repo-a',
repoById,
sidebarRepoHeaderIdsByBucket,
getScrollContainer: () => scrollContainer
})
expect(session).toBeNull()
})
})

View File

@ -6,6 +6,7 @@ import {
type ProjectHeaderDragBucketKey
} from './project-header-drop'
import {
isProjectHeaderDragHandleTarget,
isRepoHeaderActionTarget,
type ProjectHeaderDragSession
} from './project-header-drag-contract'
@ -21,6 +22,9 @@ export function createProjectHeaderDragSession(args: {
if (args.event.button !== 0) {
return null
}
if (!isProjectHeaderDragHandleTarget(args.event.target, args.event.currentTarget)) {
return null
}
if (isRepoHeaderActionTarget(args.event.target, args.event.currentTarget)) {
return null
}
@ -40,12 +44,8 @@ export function createProjectHeaderDragSession(args: {
return null
}
const handleEl = args.event.currentTarget
try {
handleEl.setPointerCapture(args.event.pointerId)
} catch {
// setPointerCapture can throw if the element is detached; the global
// pointer listeners still fire, so dragging keeps working.
}
// Why: defer setPointerCapture until the drag threshold is crossed so a
// header click still reaches the inner collapse handler on pointerup.
return {
repoId: args.repoId,
bucketKey,

View File

@ -1,7 +1,32 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import { act, createElement } from 'react'
import { createRoot } from 'react-dom/client'
import { describe, expect, it, vi } from 'vitest'
import { isRepoHeaderActionTarget } from './project-header-drag'
import {
isProjectHeaderDragHandleTarget,
isRepoHeaderActionTarget,
useRepoHeaderDrag
} from './project-header-drag'
import type { Repo } from '../../../../shared/types'
function createRepo(id: string, projectGroupId: string | null = null): Repo {
return {
id,
path: `/tmp/${id}`,
displayName: id,
badgeColor: '#000000',
addedAt: 0,
projectGroupId,
projectGroupOrder: 0
}
}
function createPointerEvent(type: string, init: MouseEventInit & { pointerId: number }): Event {
const event = new MouseEvent(type, { bubbles: true, ...init })
Object.defineProperty(event, 'pointerId', { value: init.pointerId })
return event
}
function createHeader(markup: string): HTMLElement {
const header = document.createElement('div')
@ -35,3 +60,95 @@ describe('repo header action targets', () => {
expect(isRepoHeaderActionTarget(header, header)).toBe(false)
})
})
describe('project header drag handle targets', () => {
it('accepts pointer events on the project name handle', () => {
const header = createHeader(`
<span data-repo-header-drag-handle="" id="handle">Orca</span>
<span id="chevron"></span>
`)
const handle = header.querySelector('#handle') as HTMLElement
expect(isProjectHeaderDragHandleTarget(handle, handle)).toBe(true)
})
it('rejects pointer events outside the project name handle', () => {
const header = createHeader(`
<span data-repo-header-drag-handle="" id="handle">Orca</span>
<span id="chevron"></span>
`)
expect(isProjectHeaderDragHandleTarget(header.querySelector('#chevron'), header)).toBe(false)
})
})
describe('repo header drag pointer capture', () => {
it('captures the pointer only after crossing the drag threshold', async () => {
const scrollContainer = document.createElement('div')
document.body.appendChild(scrollContainer)
const repoById = new Map<string, Repo>([
['repo-a', createRepo('repo-a')],
['repo-b', createRepo('repo-b')]
])
const sidebarRepoHeaderIdsByBucket = new Map([['ungrouped', ['repo-a', 'repo-b']]])
const setPointerCapture = vi.fn()
function DragHarness(): React.ReactElement {
const repoDrag = useRepoHeaderDrag({
orderedRepoIds: ['repo-a', 'repo-b'],
sidebarRepoHeaderIdsByBucket,
repoById,
usesProjectGroupOrdering: false,
onCommitRepoOrder: vi.fn(),
onCommitProjectGroupOrder: vi.fn(),
getScrollContainer: () => scrollContainer
})
return createElement('div', {
'data-repo-header-drag-handle': '',
'data-repo-header-id': 'repo-a',
'data-repo-header-index': 0,
'data-repo-header-bucket': 'ungrouped',
onPointerDown: (event: React.PointerEvent<HTMLElement>) =>
repoDrag.onHandlePointerDown(event, 'repo-a'),
ref: (element: HTMLDivElement | null) => {
if (element) {
element.setPointerCapture = setPointerCapture
}
}
})
}
const root = createRoot(scrollContainer)
await act(async () => {
root.render(createElement(DragHarness))
})
const handle = scrollContainer.querySelector<HTMLElement>('[data-repo-header-drag-handle]')
expect(handle).not.toBeNull()
await act(async () => {
handle!.dispatchEvent(
createPointerEvent('pointerdown', { button: 0, clientX: 10, clientY: 10, pointerId: 7 })
)
})
expect(setPointerCapture).not.toHaveBeenCalled()
await act(async () => {
window.dispatchEvent(
createPointerEvent('pointermove', { clientX: 12, clientY: 12, pointerId: 7 })
)
})
expect(setPointerCapture).not.toHaveBeenCalled()
await act(async () => {
window.dispatchEvent(
createPointerEvent('pointermove', { clientX: 20, clientY: 20, pointerId: 7 })
)
})
expect(setPointerCapture).toHaveBeenCalledWith(7)
await act(async () => {
root.unmount()
})
})
})

View File

@ -209,6 +209,12 @@ export function useRepoHeaderDrag({
return
}
session.promoted = true
try {
session.handleEl.setPointerCapture(session.pointerId)
} catch {
// setPointerCapture can throw if the element is detached; the global
// pointer listeners still fire, so dragging keeps working.
}
refreshHeaderRects()
setState({ draggingRepoId: session.repoId, dropIndex: null, dropIndicatorY: null })
}
@ -260,7 +266,7 @@ export function useRepoHeaderDrag({
}, [cancelAutoscroll, computeDrop, endDrag, ensureAutoscroll, refreshHeaderRects, sessionArmed])
useEffect(() => {
if (!sessionArmed) {
if (state.draggingRepoId === null) {
return
}
const body = document.body
@ -272,7 +278,7 @@ export function useRepoHeaderDrag({
body.style.cursor = prevCursor
body.style.userSelect = prevUserSelect
}
}, [sessionArmed])
}, [state.draggingRepoId])
const onHandlePointerDown = useCallback(
(event: React.PointerEvent<HTMLElement>, repoId: string) => {
@ -295,4 +301,7 @@ export function useRepoHeaderDrag({
return { state, onHandlePointerDown }
}
export { isRepoHeaderActionTarget } from './project-header-drag-contract'
export {
isRepoHeaderActionTarget,
isProjectHeaderDragHandleTarget
} from './project-header-drag-contract'