mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
fix(sidebar): let the divider resize instead of scrolling the workspace list (#40087)
* fix(sidebar): let the divider resize instead of scrolling the workspace list The 4px scrollbar strip past the seam covered the visible divider pixel, so grabbing the divider over a scrolling list dragged the scrollbar. Drop the strip and start the resize handle on the divider; the native scrollbar inside the sidebar is unchanged. * fix(sidebar): trim the resize handle's outer reach by 1px
This commit is contained in:
@@ -1,139 +0,0 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
// Chromium keeps a native thumb at least about this tall on long lists.
|
||||
const NATIVE_THUMB_MIN_HEIGHT = 18
|
||||
|
||||
type SidebarSeamScrollbarStripProps = {
|
||||
sidebarRef: React.RefObject<HTMLElement | null>
|
||||
onResizeStart: (event: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
function findSeamScrollContainer(sidebar: HTMLElement, clientY: number): HTMLElement | null {
|
||||
const seamX = sidebar.getBoundingClientRect().right
|
||||
let node = document.elementFromPoint(seamX - 2, clientY)
|
||||
while (node instanceof HTMLElement && sidebar.contains(node)) {
|
||||
const overflowY = getComputedStyle(node).overflowY
|
||||
if (
|
||||
(overflowY === 'auto' || overflowY === 'scroll') &&
|
||||
node.scrollHeight > node.clientHeight + 1 &&
|
||||
Math.abs(node.getBoundingClientRect().right - seamX) <= 1
|
||||
) {
|
||||
return node
|
||||
}
|
||||
node = node.parentElement
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getThumbGeometry(container: HTMLElement): {
|
||||
trackTop: number
|
||||
travel: number
|
||||
thumbTop: number
|
||||
thumbHeight: number
|
||||
maxScrollTop: number
|
||||
} {
|
||||
const trackTop = container.getBoundingClientRect().top
|
||||
const trackHeight = container.clientHeight
|
||||
const maxScrollTop = Math.max(1, container.scrollHeight - container.clientHeight)
|
||||
const thumbHeight = Math.min(
|
||||
trackHeight,
|
||||
Math.max(
|
||||
NATIVE_THUMB_MIN_HEIGHT,
|
||||
(trackHeight * container.clientHeight) / container.scrollHeight
|
||||
)
|
||||
)
|
||||
const travel = Math.max(1, trackHeight - thumbHeight)
|
||||
return {
|
||||
trackTop,
|
||||
travel,
|
||||
thumbTop: trackTop + (container.scrollTop / maxScrollTop) * travel,
|
||||
thumbHeight,
|
||||
maxScrollTop
|
||||
}
|
||||
}
|
||||
|
||||
/** Extends the sidebar list's scrollbar hit area a few pixels past the seam into the main pane. */
|
||||
export function SidebarSeamScrollbarStrip({
|
||||
sidebarRef,
|
||||
onResizeStart
|
||||
}: SidebarSeamScrollbarStripProps): React.JSX.Element {
|
||||
const endDragRef = useRef<(() => void) | null>(null)
|
||||
|
||||
useEffect(() => () => endDragRef.current?.(), [])
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
const sidebar = sidebarRef.current
|
||||
const overScrollbar = sidebar ? findSeamScrollContainer(sidebar, event.clientY) : null
|
||||
event.currentTarget.style.cursor = overScrollbar ? 'default' : 'col-resize'
|
||||
},
|
||||
[sidebarRef]
|
||||
)
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
const sidebar = sidebarRef.current
|
||||
const container = sidebar ? findSeamScrollContainer(sidebar, event.clientY) : null
|
||||
if (!container) {
|
||||
onResizeStart(event)
|
||||
return
|
||||
}
|
||||
if (event.button !== 0) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
endDragRef.current?.()
|
||||
|
||||
const start = getThumbGeometry(container)
|
||||
const grabbedThumb =
|
||||
event.clientY >= start.thumbTop && event.clientY <= start.thumbTop + start.thumbHeight
|
||||
const grabOffset = grabbedThumb ? event.clientY - start.thumbTop : start.thumbHeight / 2
|
||||
|
||||
const scrollToPointer = (clientY: number): void => {
|
||||
const geometry = getThumbGeometry(container)
|
||||
const thumbTop = Math.max(
|
||||
0,
|
||||
Math.min(geometry.travel, clientY - geometry.trackTop - grabOffset)
|
||||
)
|
||||
container.scrollTop = (thumbTop / geometry.travel) * geometry.maxScrollTop
|
||||
}
|
||||
if (!grabbedThumb) {
|
||||
scrollToPointer(event.clientY)
|
||||
}
|
||||
|
||||
const onMove = (moveEvent: MouseEvent): void => {
|
||||
moveEvent.preventDefault()
|
||||
scrollToPointer(moveEvent.clientY)
|
||||
}
|
||||
// Why: a viewport overlay keeps mouseup over native plugins such as PDF embeds, preventing a stuck drag.
|
||||
const overlay = document.createElement('div')
|
||||
overlay.style.position = 'fixed'
|
||||
overlay.style.inset = '0'
|
||||
overlay.style.zIndex = '2147483647'
|
||||
const endDrag = (): void => {
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
window.removeEventListener('mouseup', endDrag)
|
||||
window.removeEventListener('blur', endDrag)
|
||||
overlay.remove()
|
||||
document.body.style.userSelect = ''
|
||||
endDragRef.current = null
|
||||
}
|
||||
document.body.appendChild(overlay)
|
||||
document.body.style.userSelect = 'none'
|
||||
window.addEventListener('mousemove', onMove)
|
||||
window.addEventListener('mouseup', endDrag)
|
||||
window.addEventListener('blur', endDrag)
|
||||
endDragRef.current = endDrag
|
||||
},
|
||||
[onResizeStart, sidebarRef]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-sidebar-seam-scrollbar-strip=""
|
||||
className="absolute -right-1 top-0 z-20 h-full w-1"
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { useSidebarResize } from '@/hooks/useSidebarResize'
|
||||
import SidebarHeader from './SidebarHeader'
|
||||
import SidebarNav from './SidebarNav'
|
||||
import { setSidebarLiveWidth } from './sidebar-live-width'
|
||||
import { SidebarSeamScrollbarStrip } from './SidebarSeamScrollbarStrip'
|
||||
import SetupScriptPromptCard from './SetupScriptPromptCard'
|
||||
import WorktreeList from './worktree-list/listing/WorktreeList'
|
||||
import WslMovedProjectsSection from './WslMovedProjectsSection'
|
||||
@@ -46,9 +45,9 @@ const AgentDashboardSidebarHost = lazyWithRetry(() => import('./AgentDashboardSi
|
||||
|
||||
const MIN_WIDTH = 220
|
||||
const MAX_WIDTH = 500
|
||||
// Why: stay above retained panes and past the seam strip so scrolling and resizing remain separate.
|
||||
// Why: start on the divider pixel so pointing at the visible seam resizes instead of scrolling the list.
|
||||
export const WORKTREE_SIDEBAR_RESIZE_HANDLE_CLASS_NAME =
|
||||
'peer absolute -right-3.5 top-0 z-20 h-full w-2.5 cursor-col-resize'
|
||||
'peer absolute -right-[9px] top-0 z-20 h-full w-[9px] cursor-col-resize'
|
||||
// Why: an open drawer covers the outer side of the seam, so keep the inner overlap wide enough to grab.
|
||||
const WORKTREE_SIDEBAR_RESIZE_HANDLE_UNDER_DRAWER_CLASS_NAME = '-right-1.5 w-3'
|
||||
export const WORKTREE_SIDEBAR_RESIZE_HANDLE_LINE_CLASS_NAME =
|
||||
@@ -260,9 +259,6 @@ function Sidebar({
|
||||
|
||||
{sidebarOpen && (
|
||||
<>
|
||||
{drawerCoversSeam ? null : (
|
||||
<SidebarSeamScrollbarStrip sidebarRef={containerRef} onResizeStart={onResizeStart} />
|
||||
)}
|
||||
<div
|
||||
data-sidebar-resize-handle=""
|
||||
className={cn(
|
||||
|
||||
@@ -12,29 +12,28 @@ test.describe('Sidebar boundaries beside terminal content', () => {
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
})
|
||||
|
||||
test('sidebar resize and scrollbar targets remain reachable over a terminal', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
test('sidebar resize handle owns the divider over a terminal', async ({ orcaPage }) => {
|
||||
const handle = orcaPage.locator('[data-sidebar-resize-handle]')
|
||||
const strip = orcaPage.locator('[data-sidebar-seam-scrollbar-strip]')
|
||||
const sidebar = orcaPage
|
||||
.locator('[data-sidebar-resize-handle]')
|
||||
.locator('xpath=preceding-sibling::div[last()]')
|
||||
await expect(handle).toBeVisible()
|
||||
await expect(orcaPage.locator('.xterm:visible').first()).toBeVisible()
|
||||
|
||||
const terminalBox = await orcaPage.locator('.xterm:visible').first().boundingBox()
|
||||
const handleBox = await handle.boundingBox()
|
||||
const stripBox = await strip.boundingBox()
|
||||
const sidebarBox = await sidebar.boundingBox()
|
||||
expect(terminalBox).not.toBeNull()
|
||||
expect(handleBox).not.toBeNull()
|
||||
expect(stripBox).not.toBeNull()
|
||||
expect(sidebarBox).not.toBeNull()
|
||||
const y = terminalBox!.y + terminalBox!.height / 2
|
||||
const seamX = sidebarBox!.x + sidebarBox!.width
|
||||
|
||||
for (const [target, box] of [
|
||||
[handle, handleBox!],
|
||||
[strip, stripBox!]
|
||||
] as const) {
|
||||
const reachable = await target.evaluate(
|
||||
// The visible divider pixel and the area just right of it must resize, not scroll.
|
||||
for (const x of [seamX + 0.5, handleBox!.x + handleBox!.width / 2]) {
|
||||
const reachable = await handle.evaluate(
|
||||
(element, point) => element.contains(document.elementFromPoint(point.x, point.y)),
|
||||
{ x: box.x + box.width / 2, y }
|
||||
{ x, y }
|
||||
)
|
||||
expect(reachable).toBe(true)
|
||||
}
|
||||
@@ -64,7 +63,10 @@ test.describe('Sidebar boundaries beside terminal content', () => {
|
||||
throw new Error('window.__store is not available — is the app in dev mode?')
|
||||
}
|
||||
const state = store.getState()
|
||||
return { activeView: state.activeView, sidebarOpen: state.sidebarOpen }
|
||||
return {
|
||||
activeView: state.activeView,
|
||||
sidebarOpen: state.sidebarOpen
|
||||
}
|
||||
}),
|
||||
{
|
||||
timeout: 5_000,
|
||||
|
||||
Reference in New Issue
Block a user