Defer project header pointer capture until drag threshold is crossed

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:05:13 -07:00
parent 528a9292aa
commit b2774fc996
6 changed files with 241 additions and 18 deletions
@@ -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} />
@@ -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
@@ -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()
})
})
@@ -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,
@@ -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()
})
})
})
@@ -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'