From 648d17372b75eb0f0bfae4fb95d39449331bc89f Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:56:07 -0700 Subject: [PATCH] feat(sidebar): add jump-to-top button for hard upward scrolling (#13864) * feat(sidebar): add jump-to-top button for hard upward scrolling Detect intentional hard scroll-up gestures (wheel or scrollbar drag) and offer a one-click jump-to-top affordance. Auto-hide after idle to avoid persistent visual clutter. Addresses the common case of fast navigation through long worktree lists ranked by agent activity. * test(sidebar): improve scroll-to-top detection for active gestures Only detect velocity from active scrollbar drag or touch, not programmatic scrolls. Return focus to list after jump-to-top. Add comprehensive hook tests with gesture simulation. Improve cumulative down-delta tracking for dismissal. * test(sidebar): add scroll-to-top gesture detection tests Add comprehensive test coverage for the hard-upward-scroll detection hook, verifying idle timer behavior, gesture suppression, scrollability checks, and cleanup on unmount. Extract the post-jump suppression window into a named constant for maintainability. --- .../src/components/sidebar/WorktreeList.tsx | 10 + .../sidebar/WorktreeListScrollToTopButton.tsx | 50 +++ .../use-worktree-list-scroll-to-top.test.ts | 175 +++++++++ .../use-worktree-list-scroll-to-top.test.tsx | 102 ++++++ .../use-worktree-list-scroll-to-top.ts | 230 ++++++++++++ .../worktree-list-hard-scroll-up.test.ts | 262 ++++++++++++++ .../sidebar/worktree-list-hard-scroll-up.ts | 338 ++++++++++++++++++ src/renderer/src/i18n/locales/en.json | 3 + 8 files changed, 1170 insertions(+) create mode 100644 src/renderer/src/components/sidebar/WorktreeListScrollToTopButton.tsx create mode 100644 src/renderer/src/components/sidebar/use-worktree-list-scroll-to-top.test.ts create mode 100644 src/renderer/src/components/sidebar/use-worktree-list-scroll-to-top.test.tsx create mode 100644 src/renderer/src/components/sidebar/use-worktree-list-scroll-to-top.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list-hard-scroll-up.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list-hard-scroll-up.ts diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 4be9c47d1eb..2b6f3e9ee24 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -147,6 +147,8 @@ import { } from '@/hooks/useVirtualizedScrollAnchor' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { useFolderWorkspacePathStatusCacheExpiryTick } from '@/lib/folder-workspace-path-status-cache-expiry' +import { useWorktreeListScrollToTop } from './use-worktree-list-scroll-to-top' +import { WorktreeListScrollToTopButton } from './WorktreeListScrollToTopButton' import { getFolderWorkspacePathStatusDescription, getFolderWorkspacePathStatusTitle @@ -1374,6 +1376,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp scrollAnchorRef }: VirtualizedWorktreeViewportProps) { const scrollRef = useRef(null) + // Why: callback-ref only mutates scrollRef; state re-runs the scroll-to-top listener attach. + const [scrollElement, setScrollElement] = useState(null) const suppressMeasurementAdjustmentUntilRef = useRef(0) const directScrollInputUntilRef = useRef(0) const [dragOverStatus, setDragOverStatus] = useState(null) @@ -2038,6 +2042,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp suppressMeasurementAdjustmentUntilRef.current = suppressUntil directScrollInputUntilRef.current = suppressUntil }, []) + const { showScrollToTop, scrollToTop } = useWorktreeListScrollToTop({ + scrollElement, + onUserScrollIntent: markDirectScrollInput + }) const hasDirectScrollInput = useCallback( () => window.performance.now() < directScrollInputUntilRef.current, [] @@ -2656,6 +2664,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp clearWorktreeDrag() } scrollRef.current = node + setScrollElement(node) }, [ cancelPendingRevealFrames, @@ -5171,6 +5180,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp })} + {showScrollToTop ? : null} ) }) diff --git a/src/renderer/src/components/sidebar/WorktreeListScrollToTopButton.tsx b/src/renderer/src/components/sidebar/WorktreeListScrollToTopButton.tsx new file mode 100644 index 00000000000..696557189f7 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeListScrollToTopButton.tsx @@ -0,0 +1,50 @@ +import { ChevronsUp } from 'lucide-react' +import React from 'react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' + +export function WorktreeListScrollToTopButton({ + onClick, + className +}: { + onClick: () => void + className?: string +}): React.JSX.Element { + const label = translate( + 'auto.components.sidebar.WorktreeListScrollToTopButton.jumpToTop', + 'Jump to top' + ) + + return ( +
+ + + + + + {label} + + +
+ ) +} diff --git a/src/renderer/src/components/sidebar/use-worktree-list-scroll-to-top.test.ts b/src/renderer/src/components/sidebar/use-worktree-list-scroll-to-top.test.ts new file mode 100644 index 00000000000..286221602e1 --- /dev/null +++ b/src/renderer/src/components/sidebar/use-worktree-list-scroll-to-top.test.ts @@ -0,0 +1,175 @@ +// @vitest-environment happy-dom +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useWorktreeListScrollToTop } from './use-worktree-list-scroll-to-top' +import { HARD_SCROLL_UP } from './worktree-list-hard-scroll-up' + +let clockMs = 0 + +function advance(ms: number): void { + clockMs += ms + act(() => { + vi.advanceTimersByTime(ms) + }) +} + +function createViewportElement({ + scrollTop = 1200, + scrollHeight = 5000, + clientHeight = 1000 +}: { scrollTop?: number; scrollHeight?: number; clientHeight?: number } = {}): HTMLElement { + const element = document.createElement('div') + // happy-dom reports zero layout; stub the metrics the detector reads. + Object.defineProperty(element, 'scrollHeight', { value: scrollHeight, configurable: true }) + Object.defineProperty(element, 'clientHeight', { value: clientHeight, configurable: true }) + Object.defineProperty(element, 'scrollTop', { + value: scrollTop, + configurable: true, + writable: true + }) + element.scrollTo = vi.fn(() => { + element.scrollTop = 0 + }) as unknown as HTMLElement['scrollTo'] + element.focus = vi.fn() + document.body.append(element) + return element +} + +/** Enough upward wheel travel to clear `hardTotalDeltaPx` inside the intent window. */ +function dispatchHardWheelUp(element: HTMLElement, samples = 4): void { + for (let i = 0; i < samples; i += 1) { + clockMs += 16 + act(() => { + element.dispatchEvent(new WheelEvent('wheel', { deltaY: -240 })) + }) + } +} + +describe('useWorktreeListScrollToTop', () => { + beforeEach(() => { + clockMs = 0 + vi.useFakeTimers() + vi.spyOn(window.performance, 'now').mockImplementation(() => clockMs) + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + document.body.replaceChildren() + }) + + it('shows on a hard upward wheel gesture and hides on the idle deadline', () => { + const element = createViewportElement() + const { result } = renderHook(() => useWorktreeListScrollToTop({ scrollElement: element })) + + dispatchHardWheelUp(element) + expect(result.current.showScrollToTop).toBe(true) + + advance(HARD_SCROLL_UP.hideAfterIdleMs) + expect(result.current.showScrollToTop).toBe(false) + }) + + it('does not extend the idle deadline on non-intent scroll noise', () => { + const element = createViewportElement() + const { result } = renderHook(() => useWorktreeListScrollToTop({ scrollElement: element })) + + dispatchHardWheelUp(element) + const intentAt = clockMs + // Let the hard samples age out so later ticks are judged on their own. + advance(HARD_SCROLL_UP.windowMs + 16) + + // Gentle upward ticks keep arriving but never refresh intent. + for (let i = 0; i < 6; i += 1) { + advance(200) + act(() => { + element.dispatchEvent(new WheelEvent('wheel', { deltaY: -4 })) + }) + } + expect(clockMs - intentAt).toBeLessThan(HARD_SCROLL_UP.hideAfterIdleMs) + expect(result.current.showScrollToTop).toBe(true) + + advance(intentAt + HARD_SCROLL_UP.hideAfterIdleMs - clockMs) + expect(result.current.showScrollToTop).toBe(false) + }) + + it('re-arms the idle timer when intent is refreshed', () => { + const element = createViewportElement() + const { result } = renderHook(() => useWorktreeListScrollToTop({ scrollElement: element })) + + dispatchHardWheelUp(element) + advance(HARD_SCROLL_UP.hideAfterIdleMs - 200) + expect(result.current.showScrollToTop).toBe(true) + + dispatchHardWheelUp(element) + advance(HARD_SCROLL_UP.hideAfterIdleMs - 200) + expect(result.current.showScrollToTop).toBe(true) + + advance(200) + expect(result.current.showScrollToTop).toBe(false) + }) + + it('suppresses detection for the post-jump window after scrollToTop', () => { + const element = createViewportElement() + const onUserScrollIntent = vi.fn() + const { result } = renderHook(() => + useWorktreeListScrollToTop({ scrollElement: element, onUserScrollIntent }) + ) + + dispatchHardWheelUp(element) + act(() => { + result.current.scrollToTop() + }) + const jumpAt = clockMs + expect(onUserScrollIntent).toHaveBeenCalledTimes(1) + expect(element.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'auto' }) + expect(result.current.showScrollToTop).toBe(false) + + // Momentum events land back at depth while suppression is active. + element.scrollTop = 1200 + dispatchHardWheelUp(element) + expect(clockMs).toBeLessThan(jumpAt + HARD_SCROLL_UP.suppressAfterJumpMs) + expect(result.current.showScrollToTop).toBe(false) + + advance(HARD_SCROLL_UP.suppressAfterJumpMs) + dispatchHardWheelUp(element) + expect(result.current.showScrollToTop).toBe(true) + }) + + it('force-hides when the list stops being scrollable, even mid-gesture', () => { + const element = createViewportElement() + const { result } = renderHook(() => useWorktreeListScrollToTop({ scrollElement: element })) + + dispatchHardWheelUp(element) + expect(result.current.showScrollToTop).toBe(true) + + Object.defineProperty(element, 'scrollHeight', { value: 1100, configurable: true }) + act(() => { + element.dispatchEvent(new WheelEvent('wheel', { deltaY: -240 })) + }) + expect(result.current.showScrollToTop).toBe(false) + + // The pending idle timer was cleared, so nothing flips state later. + advance(HARD_SCROLL_UP.hideAfterIdleMs * 2) + expect(result.current.showScrollToTop).toBe(false) + }) + + it('clears the timer and detaches listeners when the scroll element goes away', () => { + const element = createViewportElement() + const { result, rerender } = renderHook( + ({ scrollElement }: { scrollElement: HTMLElement | null }) => + useWorktreeListScrollToTop({ scrollElement }), + { initialProps: { scrollElement: element as HTMLElement | null } } + ) + + dispatchHardWheelUp(element) + expect(result.current.showScrollToTop).toBe(true) + + rerender({ scrollElement: null }) + expect(result.current.showScrollToTop).toBe(false) + + dispatchHardWheelUp(element) + advance(HARD_SCROLL_UP.hideAfterIdleMs * 2) + expect(result.current.showScrollToTop).toBe(false) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/renderer/src/components/sidebar/use-worktree-list-scroll-to-top.test.tsx b/src/renderer/src/components/sidebar/use-worktree-list-scroll-to-top.test.tsx new file mode 100644 index 00000000000..f35bde00ffc --- /dev/null +++ b/src/renderer/src/components/sidebar/use-worktree-list-scroll-to-top.test.tsx @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useWorktreeListScrollToTop } from './use-worktree-list-scroll-to-top' + +function createScroller(): HTMLElement { + const element = document.createElement('div') + element.tabIndex = 0 + document.body.append(element) + Object.defineProperties(element, { + scrollHeight: { configurable: true, value: 5000 }, + clientHeight: { configurable: true, value: 500 }, + // 16px narrower than offsetWidth so the scrollbar hit-test has a realistic gutter. + clientWidth: { configurable: true, value: 284 }, + offsetWidth: { configurable: true, value: 300 } + }) + element.getBoundingClientRect = () => ({ + bottom: 500, + height: 500, + left: 0, + right: 300, + top: 0, + width: 300, + x: 0, + y: 0, + toJSON: () => ({}) + }) + return element +} + +afterEach(() => { + cleanup() + vi.restoreAllMocks() + document.body.replaceChildren() +}) + +describe('useWorktreeListScrollToTop', () => { + it('ignores fast programmatic scrolling', () => { + const element = createScroller() + let now = 0 + vi.spyOn(window.performance, 'now').mockImplementation(() => now) + const view = renderHook(() => useWorktreeListScrollToTop({ scrollElement: element })) + + for (const sample of [ + { t: 0, scrollTop: 2200 }, + { t: 80, scrollTop: 2000 }, + { t: 170, scrollTop: 1700 } + ]) { + now = sample.t + element.scrollTop = sample.scrollTop + act(() => element.dispatchEvent(new Event('scroll'))) + } + + expect(view.result.current.showScrollToTop).toBe(false) + }) + + it('detects velocity while the scrollbar is actively dragged', () => { + const element = createScroller() + let now = 0 + vi.spyOn(window.performance, 'now').mockImplementation(() => now) + const view = renderHook(() => useWorktreeListScrollToTop({ scrollElement: element })) + + act(() => + element.dispatchEvent(new PointerEvent('pointerdown', { clientX: 295, pointerType: 'mouse' })) + ) + for (const sample of [ + { t: 0, scrollTop: 2200 }, + { t: 80, scrollTop: 2000 }, + { t: 170, scrollTop: 1700 } + ]) { + now = sample.t + element.scrollTop = sample.scrollTop + act(() => element.dispatchEvent(new Event('scroll'))) + } + + expect(view.result.current.showScrollToTop).toBe(true) + }) + + it('returns focus to the list after jumping to the top', () => { + const element = createScroller() + element.scrollTop = 1200 + element.scrollTo = vi.fn(({ top }) => { + element.scrollTop = Number(top) + }) + const view = renderHook(() => useWorktreeListScrollToTop({ scrollElement: element })) + + act(() => { + for (let index = 0; index < 4; index += 1) { + element.dispatchEvent(new WheelEvent('wheel', { deltaY: -200 })) + } + }) + const button = document.createElement('button') + document.body.append(button) + button.focus() + + act(() => view.result.current.scrollToTop()) + + expect(element.scrollTop).toBe(0) + expect(document.activeElement).toBe(element) + }) +}) diff --git a/src/renderer/src/components/sidebar/use-worktree-list-scroll-to-top.ts b/src/renderer/src/components/sidebar/use-worktree-list-scroll-to-top.ts new file mode 100644 index 00000000000..3199c69543a --- /dev/null +++ b/src/renderer/src/components/sidebar/use-worktree-list-scroll-to-top.ts @@ -0,0 +1,230 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { + createHardScrollUpDetectorState, + HARD_SCROLL_UP, + reduceHardScrollUpOnDismiss, + reduceHardScrollUpOnIdle, + reduceHardScrollUpOnScroll, + reduceHardScrollUpOnWheel, + type HardScrollUpDetectorState +} from './worktree-list-hard-scroll-up' + +function readViewport(element: HTMLElement): { scrollTop: number; maxScroll: number } { + const maxScroll = Math.max(0, element.scrollHeight - element.clientHeight) + return { + scrollTop: element.scrollTop, + maxScroll + } +} + +function shouldForceHide(viewport: { scrollTop: number; maxScroll: number }): boolean { + return ( + viewport.scrollTop <= HARD_SCROLL_UP.nearTopPx || + viewport.maxScroll < HARD_SCROLL_UP.minScrollablePx + ) +} + +/** + * Offers a jump-to-top affordance when the user is hard-scrolling up a long + * worktree list (common with smart/agent-activity ranking). + * + * Visibility is deadline-based from `lastIntentAt` so scroll noise cannot keep + * the button stuck forever. + */ +export function useWorktreeListScrollToTop({ + scrollElement, + onUserScrollIntent +}: { + scrollElement: HTMLElement | null + /** Called when the user clicks jump-to-top so virtualizer scroll guards engage. */ + onUserScrollIntent?: () => void +}): { + showScrollToTop: boolean + scrollToTop: () => void +} { + const detectorRef = useRef(createHardScrollUpDetectorState()) + const [showScrollToTop, setShowScrollToTop] = useState(false) + const showScrollToTopRef = useRef(false) + const idleTimerRef = useRef(null) + const scrollbarDragRef = useRef(false) + const touchScrollRef = useRef(false) + // Why: jump-to-top can still emit a burst of scroll events; ignore them briefly so the button does not reappear. + const suppressDetectionUntilRef = useRef(0) + + const publishVisible = useCallback((next: HardScrollUpDetectorState) => { + detectorRef.current = next + if (showScrollToTopRef.current !== next.visible) { + showScrollToTopRef.current = next.visible + setShowScrollToTop(next.visible) + } + }, []) + + const clearIdleTimer = useCallback(() => { + if (idleTimerRef.current !== null) { + window.clearTimeout(idleTimerRef.current) + idleTimerRef.current = null + } + }, []) + + // Arm once per intent timestamp; do not reset on every scroll tick (that stuck the button). + const armIdleHide = useCallback( + (element: HTMLElement, lastIntentAt: number) => { + clearIdleTimer() + const fireAt = lastIntentAt + HARD_SCROLL_UP.hideAfterIdleMs + const delayMs = Math.max(0, fireAt - window.performance.now()) + + idleTimerRef.current = window.setTimeout(() => { + idleTimerRef.current = null + const now = window.performance.now() + + // Jump-to-top already dismissed; if a stale timer lands in the suppress window, force-hide. + if (now < suppressDetectionUntilRef.current) { + publishVisible(createHardScrollUpDetectorState()) + return + } + + const viewport = readViewport(element) + const next = reduceHardScrollUpOnIdle(detectorRef.current, { + ...viewport, + t: now + }) + // Belt-and-suspenders: if still visible past the deadline, force dismiss. + if (next.visible && now - next.lastIntentAt >= HARD_SCROLL_UP.hideAfterIdleMs) { + publishVisible(createHardScrollUpDetectorState()) + return + } + publishVisible(next) + }, delayMs) + }, + [clearIdleTimer, publishVisible] + ) + + const applyDetectorResult = useCallback( + ( + element: HTMLElement, + previous: HardScrollUpDetectorState, + next: HardScrollUpDetectorState + ) => { + publishVisible(next) + if (!next.visible) { + clearIdleTimer() + return + } + // Only re-arm when intent is refreshed; scroll spam must not extend lifetime. + if (next.lastIntentAt !== previous.lastIntentAt) { + armIdleHide(element, next.lastIntentAt) + } + }, + [armIdleHide, clearIdleTimer, publishVisible] + ) + + useEffect(() => { + if (!scrollElement) { + clearIdleTimer() + publishVisible(createHardScrollUpDetectorState()) + return + } + + const onWheel = (event: WheelEvent): void => { + const now = window.performance.now() + const viewport = readViewport(scrollElement) + + // Always allow force-hide even while jump-to-top is suppressing detection. + if (shouldForceHide(viewport)) { + publishVisible(createHardScrollUpDetectorState()) + clearIdleTimer() + return + } + + if (now < suppressDetectionUntilRef.current) { + return + } + + const previous = detectorRef.current + const next = reduceHardScrollUpOnWheel(previous, { + ...viewport, + t: now, + deltaY: event.deltaY, + deltaMode: event.deltaMode + }) + applyDetectorResult(scrollElement, previous, next) + } + + const onScroll = (): void => { + const now = window.performance.now() + const viewport = readViewport(scrollElement) + + if (shouldForceHide(viewport)) { + publishVisible(createHardScrollUpDetectorState()) + clearIdleTimer() + return + } + + if (now < suppressDetectionUntilRef.current) { + return + } + + // Scroll events do not expose their origin; only velocity-detect gestures we observed directly. + if (!scrollbarDragRef.current && !touchScrollRef.current) { + return + } + + const previous = detectorRef.current + const next = reduceHardScrollUpOnScroll(previous, { + ...viewport, + t: now + }) + applyDetectorResult(scrollElement, previous, next) + } + + const onPointerDown = (event: PointerEvent): void => { + if (event.pointerType === 'touch') { + touchScrollRef.current = true + return + } + const rect = scrollElement.getBoundingClientRect() + const nativeScrollbarWidth = scrollElement.offsetWidth - scrollElement.clientWidth + const scrollbarHitWidth = Math.max(12, nativeScrollbarWidth) + scrollbarDragRef.current = + event.target === scrollElement && event.clientX >= rect.right - scrollbarHitWidth + } + + const onPointerEnd = (): void => { + scrollbarDragRef.current = false + touchScrollRef.current = false + } + + scrollElement.addEventListener('wheel', onWheel, { passive: true }) + scrollElement.addEventListener('scroll', onScroll, { passive: true }) + scrollElement.addEventListener('pointerdown', onPointerDown, { passive: true }) + window.addEventListener('pointerup', onPointerEnd, { passive: true }) + window.addEventListener('pointercancel', onPointerEnd, { passive: true }) + return () => { + scrollElement.removeEventListener('wheel', onWheel) + scrollElement.removeEventListener('scroll', onScroll) + scrollElement.removeEventListener('pointerdown', onPointerDown) + window.removeEventListener('pointerup', onPointerEnd) + window.removeEventListener('pointercancel', onPointerEnd) + onPointerEnd() + clearIdleTimer() + } + }, [applyDetectorResult, clearIdleTimer, publishVisible, scrollElement]) + + const scrollToTop = useCallback(() => { + if (!scrollElement) { + return + } + onUserScrollIntent?.() + publishVisible(reduceHardScrollUpOnDismiss(detectorRef.current)) + clearIdleTimer() + suppressDetectionUntilRef.current = + window.performance.now() + HARD_SCROLL_UP.suppressAfterJumpMs + scrollElement.scrollTo({ top: 0, behavior: 'auto' }) + scrollElement.focus({ preventScroll: true }) + }, [clearIdleTimer, onUserScrollIntent, publishVisible, scrollElement]) + + return { + showScrollToTop, + scrollToTop + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list-hard-scroll-up.test.ts b/src/renderer/src/components/sidebar/worktree-list-hard-scroll-up.test.ts new file mode 100644 index 00000000000..c3a51731e52 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list-hard-scroll-up.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from 'vitest' +import { + createHardScrollUpDetectorState, + HARD_SCROLL_UP, + normalizeWheelDeltaY, + reduceHardScrollUpOnDismiss, + reduceHardScrollUpOnIdle, + reduceHardScrollUpOnScroll, + reduceHardScrollUpOnWheel +} from './worktree-list-hard-scroll-up' + +const DEEP = { + scrollTop: 1200, + maxScroll: 4000 +} as const + +const SHORT_LIST = { + scrollTop: 200, + maxScroll: 200 +} as const + +describe('normalizeWheelDeltaY', () => { + it('keeps pixel mode and expands line/page modes', () => { + expect(normalizeWheelDeltaY(-40, 0)).toBe(-40) + expect(normalizeWheelDeltaY(-3, 1)).toBe(-48) + expect(normalizeWheelDeltaY(-1, 2)).toBe(-600) + }) +}) + +describe('reduceHardScrollUpOnWheel', () => { + it('stays hidden for short lists and near-top viewports', () => { + let state = createHardScrollUpDetectorState() + for (let i = 0; i < 10; i += 1) { + state = reduceHardScrollUpOnWheel(state, { + ...SHORT_LIST, + t: i * 16, + deltaY: -120 + }) + } + expect(state.visible).toBe(false) + + state = createHardScrollUpDetectorState() + for (let i = 0; i < 10; i += 1) { + state = reduceHardScrollUpOnWheel(state, { + scrollTop: 20, + maxScroll: 4000, + t: i * 16, + deltaY: -120 + }) + } + expect(state.visible).toBe(false) + }) + + it('does not show on gentle upward scrolling', () => { + let state = createHardScrollUpDetectorState() + // Small trackpad ticks — effort exists but is not "hard". + for (let i = 0; i < 8; i += 1) { + state = reduceHardScrollUpOnWheel(state, { + ...DEEP, + t: i * 40, + deltaY: -18 + }) + } + expect(state.visible).toBe(false) + }) + + it('shows after a sustained hard upward wheel burst', () => { + let state = createHardScrollUpDetectorState() + // ~5 hard ticks in < window: 5 * 160 = 800 >= hardTotalDeltaPx + for (let i = 0; i < 5; i += 1) { + state = reduceHardScrollUpOnWheel(state, { + ...DEEP, + scrollTop: DEEP.scrollTop - i * 40, + t: 1000 + i * 40, + deltaY: -160 + }) + } + expect(state.visible).toBe(true) + expect(state.lastIntentAt).toBe(1000 + 4 * 40) + }) + + it('shows after a trackpad fling (high peak + enough total)', () => { + let state = createHardScrollUpDetectorState() + const deltas = [-40, -120, -200, -80] + deltas.forEach((deltaY, i) => { + state = reduceHardScrollUpOnWheel(state, { + ...DEEP, + t: 2000 + i * 30, + deltaY + }) + }) + expect(state.visible).toBe(true) + }) + + it('hides on significant downward scroll', () => { + let state = createHardScrollUpDetectorState() + for (let i = 0; i < 5; i += 1) { + state = reduceHardScrollUpOnWheel(state, { + ...DEEP, + t: i * 40, + deltaY: -160 + }) + } + expect(state.visible).toBe(true) + + state = reduceHardScrollUpOnWheel(state, { + ...DEEP, + t: 500, + deltaY: 80 + }) + expect(state.visible).toBe(false) + expect(state.wheelSamples).toEqual([]) + expect(state.scrollSamples).toEqual([]) + }) + + it('hides after cumulative small downward wheel events', () => { + let state = createHardScrollUpDetectorState() + for (let i = 0; i < 5; i += 1) { + state = reduceHardScrollUpOnWheel(state, { + ...DEEP, + t: i * 40, + deltaY: -160 + }) + } + + state = reduceHardScrollUpOnWheel(state, { ...DEEP, t: 220, deltaY: 20 }) + state = reduceHardScrollUpOnWheel(state, { ...DEEP, t: 260, deltaY: 20 }) + expect(state.visible).toBe(true) + expect(state.lastIntentAt).toBe(160) + + state = reduceHardScrollUpOnWheel(state, { ...DEEP, t: 300, deltaY: 20 }) + expect(state).toEqual(createHardScrollUpDetectorState()) + }) + + it('clears when the user reaches the top', () => { + let state = createHardScrollUpDetectorState() + for (let i = 0; i < 5; i += 1) { + state = reduceHardScrollUpOnWheel(state, { + ...DEEP, + t: i * 40, + deltaY: -160 + }) + } + expect(state.visible).toBe(true) + + state = reduceHardScrollUpOnWheel(state, { + scrollTop: 10, + maxScroll: 4000, + t: 400, + deltaY: -20 + }) + expect(state.visible).toBe(false) + }) +}) + +describe('reduceHardScrollUpOnScroll', () => { + it('shows when scrollbar drag velocity is hard upward', () => { + let state = createHardScrollUpDetectorState() + // Drop 500px in 250ms => 2000 px/s + state = reduceHardScrollUpOnScroll(state, { + scrollTop: 1500, + maxScroll: 4000, + t: 0 + }) + state = reduceHardScrollUpOnScroll(state, { + scrollTop: 1000, + maxScroll: 4000, + t: HARD_SCROLL_UP.velocitySustainMs + 90 + }) + expect(state.visible).toBe(true) + }) + + it('ignores slow scrollbar movement', () => { + let state = createHardScrollUpDetectorState() + state = reduceHardScrollUpOnScroll(state, { + scrollTop: 1500, + maxScroll: 4000, + t: 0 + }) + state = reduceHardScrollUpOnScroll(state, { + scrollTop: 1450, + maxScroll: 4000, + t: 300 + }) + expect(state.visible).toBe(false) + }) + + it('hides after cumulative small downward scrollbar movement', () => { + let state = createHardScrollUpDetectorState() + state = reduceHardScrollUpOnScroll(state, { ...DEEP, scrollTop: 1500, t: 0 }) + state = reduceHardScrollUpOnScroll(state, { ...DEEP, scrollTop: 1000, t: 250 }) + expect(state.visible).toBe(true) + + state = reduceHardScrollUpOnScroll(state, { ...DEEP, scrollTop: 1020, t: 280 }) + state = reduceHardScrollUpOnScroll(state, { ...DEEP, scrollTop: 1040, t: 310 }) + expect(state.visible).toBe(true) + expect(state.lastIntentAt).toBe(250) + + state = reduceHardScrollUpOnScroll(state, { ...DEEP, scrollTop: 1060, t: 340 }) + expect(state).toEqual(createHardScrollUpDetectorState()) + }) +}) + +describe('reduceHardScrollUpOnIdle / dismiss', () => { + it('auto-hides after idle while still deep', () => { + let state = createHardScrollUpDetectorState() + for (let i = 0; i < 5; i += 1) { + state = reduceHardScrollUpOnWheel(state, { + ...DEEP, + t: i * 40, + deltaY: -160 + }) + } + expect(state.visible).toBe(true) + + state = reduceHardScrollUpOnIdle(state, { + ...DEEP, + t: state.lastIntentAt + HARD_SCROLL_UP.hideAfterIdleMs - 1 + }) + expect(state.visible).toBe(true) + + state = reduceHardScrollUpOnIdle(state, { + ...DEEP, + t: state.lastIntentAt + HARD_SCROLL_UP.hideAfterIdleMs + }) + expect(state.visible).toBe(false) + }) + + it('hides on later non-intent scroll after the idle deadline (scroll spam must not extend)', () => { + let state = createHardScrollUpDetectorState() + for (let i = 0; i < 5; i += 1) { + state = reduceHardScrollUpOnWheel(state, { + ...DEEP, + t: 1000 + i * 40, + deltaY: -160 + }) + } + const intentAt = state.lastIntentAt + expect(state.visible).toBe(true) + + // Tiny non-intent scrolls after the deadline must still clear visibility. + state = reduceHardScrollUpOnScroll(state, { + scrollTop: DEEP.scrollTop - 2, + maxScroll: DEEP.maxScroll, + t: intentAt + HARD_SCROLL_UP.hideAfterIdleMs + 10 + }) + expect(state.visible).toBe(false) + }) + + it('dismiss resets state', () => { + let state = createHardScrollUpDetectorState() + for (let i = 0; i < 5; i += 1) { + state = reduceHardScrollUpOnWheel(state, { + ...DEEP, + t: i * 40, + deltaY: -160 + }) + } + state = reduceHardScrollUpOnDismiss(state) + expect(state).toEqual(createHardScrollUpDetectorState()) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list-hard-scroll-up.ts b/src/renderer/src/components/sidebar/worktree-list-hard-scroll-up.ts new file mode 100644 index 00000000000..7db5b88dc09 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list-hard-scroll-up.ts @@ -0,0 +1,338 @@ +/** + * Detects intentional "hard scroll up" on a long worktree list so we can offer + * a jump-to-top control. Tuned for trackpad wheel streams and scrollbar drags. + */ + +export const HARD_SCROLL_UP = { + /** Only samples inside this window contribute to intent. */ + windowMs: 480, + /** List must be scrollable by at least this much. */ + minScrollablePx: 480, + /** Must be at least this far from the top before the button can appear. */ + minDepthPx: 280, + /** Hide once the viewport is this close to the top. */ + nearTopPx: 56, + /** Minimum upward wheel samples in the window. */ + minUpEvents: 3, + /** Accumulated |deltaY| (px) for a sustained hard-up gesture. */ + hardTotalDeltaPx: 720, + /** Peak single-sample |deltaY| that marks a fling/burst. */ + burstPeakDeltaPx: 90, + /** Accumulated |deltaY| paired with a burst peak. */ + burstTotalDeltaPx: 320, + /** Scroll-position velocity (px/s upward) that counts as hard drag/fling. */ + hardVelocityPxPerSec: 1600, + /** How long hard velocity must be sustained. */ + velocitySustainMs: 160, + /** Clear intent after idle so the button does not linger forever. */ + hideAfterIdleMs: 2600, + /** Significant down-scroll cancels upward intent. */ + significantDownDeltaPx: 48, + /** Cap stored samples so long sessions stay cheap. */ + maxSamples: 32, + /** Ignore scroll input this long after a programmatic jump to top. */ + suppressAfterJumpMs: 120 +} as const + +export type HardScrollUpWheelSample = { + t: number + /** Positive = toward top (up). Normalized to CSS pixels. */ + upDeltaPx: number +} + +export type HardScrollUpScrollSample = { + t: number + scrollTop: number +} + +export type HardScrollUpDetectorState = { + // Why: wheel + scroll both fire for the same gesture; keep them separate so + // wheel magnitude is not double-counted with scrollTop deltas. + wheelSamples: HardScrollUpWheelSample[] + scrollSamples: HardScrollUpScrollSample[] + wheelDownDeltaPx: number + scrollDownDeltaPx: number + visible: boolean + lastIntentAt: number +} + +export type HardScrollUpViewport = { + scrollTop: number + maxScroll: number + t: number +} + +export function createHardScrollUpDetectorState(): HardScrollUpDetectorState { + return { + wheelSamples: [], + scrollSamples: [], + wheelDownDeltaPx: 0, + scrollDownDeltaPx: 0, + visible: false, + lastIntentAt: 0 + } +} + +export function normalizeWheelDeltaY(deltaY: number, deltaMode: number): number { + // WheelEvent.DOM_DELTA_LINE / DOM_DELTA_PAGE — convert to approximate CSS px. + if (deltaMode === 1) { + return deltaY * 16 + } + if (deltaMode === 2) { + return deltaY * 600 + } + return deltaY +} + +function pruneByTime(samples: readonly T[], t: number): T[] { + const cutoff = t - HARD_SCROLL_UP.windowMs + const pruned = samples.filter((sample) => sample.t >= cutoff) + if (pruned.length <= HARD_SCROLL_UP.maxSamples) { + return pruned + } + return pruned.slice(pruned.length - HARD_SCROLL_UP.maxSamples) +} + +function isListLongEnough(maxScroll: number): boolean { + return maxScroll >= HARD_SCROLL_UP.minScrollablePx +} + +function isDeepEnough(scrollTop: number): boolean { + return scrollTop >= HARD_SCROLL_UP.minDepthPx +} + +function isNearTop(scrollTop: number): boolean { + return scrollTop <= HARD_SCROLL_UP.nearTopPx +} + +function computeWheelIntent(samples: readonly HardScrollUpWheelSample[]): boolean { + const upSamples = samples.filter((sample) => sample.upDeltaPx > 0) + if (upSamples.length < HARD_SCROLL_UP.minUpEvents) { + return false + } + + let totalUp = 0 + let peakUp = 0 + for (const sample of upSamples) { + totalUp += sample.upDeltaPx + if (sample.upDeltaPx > peakUp) { + peakUp = sample.upDeltaPx + } + } + + if (totalUp >= HARD_SCROLL_UP.hardTotalDeltaPx) { + return true + } + + return peakUp >= HARD_SCROLL_UP.burstPeakDeltaPx && totalUp >= HARD_SCROLL_UP.burstTotalDeltaPx +} + +function computeVelocityIntent(samples: readonly HardScrollUpScrollSample[], t: number): boolean { + if (samples.length < 2) { + return false + } + + const newest = samples.at(-1) + if (!newest || t - newest.t > HARD_SCROLL_UP.velocitySustainMs) { + return false + } + + // Walk backward to the oldest sample still inside the window, then measure + // average upward velocity across that span. + let oldest = newest + for (let i = samples.length - 2; i >= 0; i -= 1) { + const sample = samples.at(i) + if (!sample) { + continue + } + if (newest.t - sample.t > HARD_SCROLL_UP.windowMs) { + break + } + oldest = sample + } + + const elapsedMs = newest.t - oldest.t + if (elapsedMs < HARD_SCROLL_UP.velocitySustainMs) { + return false + } + + const upwardPx = oldest.scrollTop - newest.scrollTop + if (upwardPx <= 0) { + return false + } + + const velocity = (upwardPx / elapsedMs) * 1000 + return velocity >= HARD_SCROLL_UP.hardVelocityPxPerSec +} + +function withVisibility( + state: HardScrollUpDetectorState, + { t, scrollTop, maxScroll, intent }: HardScrollUpViewport & { intent: boolean } +): HardScrollUpDetectorState { + if (!isListLongEnough(maxScroll) || isNearTop(scrollTop)) { + if ( + !state.visible && + state.wheelSamples.length === 0 && + state.scrollSamples.length === 0 && + state.wheelDownDeltaPx === 0 && + state.scrollDownDeltaPx === 0 && + state.lastIntentAt === 0 + ) { + return state + } + return createHardScrollUpDetectorState() + } + + if (intent && isDeepEnough(scrollTop)) { + return { + ...state, + visible: true, + lastIntentAt: t + } + } + + // Why: hide on the deadline itself even if only non-intent scroll noise arrives; + // the hook also arms a timer, but event-driven hide must not depend on timer re-arms. + if (state.visible && t - state.lastIntentAt >= HARD_SCROLL_UP.hideAfterIdleMs) { + return createHardScrollUpDetectorState() + } + + return state +} + +/** + * Ingest a wheel event. `deltaY` follows the browser convention (negative = up). + */ +export function reduceHardScrollUpOnWheel( + state: HardScrollUpDetectorState, + { + deltaY, + deltaMode = 0, + scrollTop, + maxScroll, + t + }: HardScrollUpViewport & { deltaY: number; deltaMode?: number } +): HardScrollUpDetectorState { + if (!isListLongEnough(maxScroll)) { + return createHardScrollUpDetectorState() + } + + if (isNearTop(scrollTop)) { + return withVisibility(state, { t, scrollTop, maxScroll, intent: false }) + } + + const pixelDeltaY = normalizeWheelDeltaY(deltaY, deltaMode) + // Browser: negative deltaY = scroll up (content moves down, viewport toward top). + const upDeltaPx = -pixelDeltaY + const wheelDownDeltaPx = + upDeltaPx < 0 + ? state.wheelDownDeltaPx + Math.abs(upDeltaPx) + : upDeltaPx > 0 + ? 0 + : state.wheelDownDeltaPx + + if (wheelDownDeltaPx >= HARD_SCROLL_UP.significantDownDeltaPx) { + return createHardScrollUpDetectorState() + } + + const nextWheelSamples = pruneByTime( + upDeltaPx > 0 ? [...state.wheelSamples, { t, upDeltaPx }] : [], + t + ) + const nextScrollSamples = pruneByTime(state.scrollSamples, t) + + const intent = upDeltaPx > 0 && computeWheelIntent(nextWheelSamples) + return withVisibility( + { + ...state, + wheelSamples: nextWheelSamples, + scrollSamples: nextScrollSamples, + wheelDownDeltaPx + }, + { t, scrollTop, maxScroll, intent } + ) +} + +/** + * Ingest a scroll position sample from an active scrollbar/touch gesture. + * Velocity-based; ignores tiny no-op scrolls. + */ +export function reduceHardScrollUpOnScroll( + state: HardScrollUpDetectorState, + { scrollTop, maxScroll, t }: HardScrollUpViewport +): HardScrollUpDetectorState { + if (!isListLongEnough(maxScroll)) { + return createHardScrollUpDetectorState() + } + + if (isNearTop(scrollTop)) { + return withVisibility(state, { t, scrollTop, maxScroll, intent: false }) + } + + const last = state.scrollSamples.at(-1) + const downDeltaPx = last ? Math.max(0, scrollTop - last.scrollTop) : 0 + const upDeltaPx = last ? Math.max(0, last.scrollTop - scrollTop) : 0 + const scrollDownDeltaPx = + downDeltaPx > 0 + ? state.scrollDownDeltaPx + downDeltaPx + : upDeltaPx > 0 + ? 0 + : state.scrollDownDeltaPx + + if (scrollDownDeltaPx >= HARD_SCROLL_UP.significantDownDeltaPx) { + return createHardScrollUpDetectorState() + } + + // Ignore pure no-ops so a stalled scrollbar does not pad the window. + if (last && last.scrollTop === scrollTop && t - last.t < 16) { + return withVisibility(state, { + t, + scrollTop, + maxScroll, + intent: false + }) + } + + const nextScrollSamples = pruneByTime( + [...(downDeltaPx > 0 ? [] : state.scrollSamples), { t, scrollTop }], + t + ) + const nextWheelSamples = pruneByTime(state.wheelSamples, t) + + const intent = upDeltaPx > 0 && computeVelocityIntent(nextScrollSamples, t) + return withVisibility( + { + ...state, + wheelSamples: nextWheelSamples, + scrollSamples: nextScrollSamples, + scrollDownDeltaPx + }, + { t, scrollTop, maxScroll, intent } + ) +} + +/** Idle tick so the button auto-hides without requiring more input. */ +export function reduceHardScrollUpOnIdle( + state: HardScrollUpDetectorState, + { scrollTop, maxScroll, t }: HardScrollUpViewport +): HardScrollUpDetectorState { + if (!state.visible) { + return state + } + return withVisibility(state, { t, scrollTop, maxScroll, intent: false }) +} + +export function reduceHardScrollUpOnDismiss( + state: HardScrollUpDetectorState +): HardScrollUpDetectorState { + if ( + !state.visible && + state.wheelSamples.length === 0 && + state.scrollSamples.length === 0 && + state.wheelDownDeltaPx === 0 && + state.scrollDownDeltaPx === 0 + ) { + return state + } + return createHardScrollUpDetectorState() +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2615c32bc45..d3ef9da104e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -5386,6 +5386,9 @@ "a0f9863597": "Force Delete {{count}} Branches", "a0f9863597_one": "Force Delete {{count}} Branch", "a0f9863597_other": "Force Delete {{count}} Branches" + }, + "WorktreeListScrollToTopButton": { + "jumpToTop": "Jump to top" } }, "shared": {