From 59f3f4737a33d30aeb3c281606cf807bf152863c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 21 Mar 2026 23:32:38 +0700 Subject: [PATCH] Split diff horizontal scroll into platform-specific components Use Metro .web.tsx resolution to separate native gesture arbitration (RNGH ScrollView, waitFor, closeGestureRef) from web (plain RN ScrollView), fixing text selection drag on desktop web. --- packages/app/src/components/diff-scroll.tsx | 85 +++++++++++++++++++ .../app/src/components/diff-scroll.web.tsx | 29 +++++++ packages/app/src/components/git-diff-pane.tsx | 59 ++----------- 3 files changed, 119 insertions(+), 54 deletions(-) create mode 100644 packages/app/src/components/diff-scroll.tsx create mode 100644 packages/app/src/components/diff-scroll.web.tsx diff --git a/packages/app/src/components/diff-scroll.tsx b/packages/app/src/components/diff-scroll.tsx new file mode 100644 index 000000000..ab5feb77b --- /dev/null +++ b/packages/app/src/components/diff-scroll.tsx @@ -0,0 +1,85 @@ +import { useState, useCallback, useEffect, useId, useRef } from "react"; +import { + type LayoutChangeEvent, + type NativeSyntheticEvent, + type NativeScrollEvent, + type StyleProp, + type ViewStyle, +} from "react-native"; +import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler"; +import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context"; +import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; + +interface DiffScrollProps { + children: React.ReactNode; + scrollViewWidth: number; + onScrollViewWidthChange: (width: number) => void; + style?: StyleProp; + contentContainerStyle?: StyleProp; +} + +export function DiffScroll({ + children, + scrollViewWidth, + onScrollViewWidthChange, + style, + contentContainerStyle, +}: DiffScrollProps) { + const [isAtLeftEdge, setIsAtLeftEdge] = useState(true); + const horizontalScroll = useHorizontalScrollOptional(); + const scrollId = useId(); + const scrollViewRef = useRef(null); + + // Get the close gesture ref from animation context (may not be available outside sidebar) + let closeGestureRef: React.MutableRefObject | undefined; + try { + const animation = useExplorerSidebarAnimation(); + closeGestureRef = animation.closeGestureRef; + } catch { + // Not inside ExplorerSidebarAnimationProvider, which is fine + } + + // Register/unregister scroll offset tracking + useEffect(() => { + if (!horizontalScroll) return; + // Start at 0 (not scrolled) + horizontalScroll.registerScrollOffset(scrollId, 0); + return () => { + horizontalScroll.unregisterScrollOffset(scrollId); + }; + }, [horizontalScroll, scrollId]); + + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + const offsetX = event.nativeEvent.contentOffset.x; + // Track if we're at the left edge (with small threshold for float precision) + setIsAtLeftEdge(offsetX <= 1); + if (horizontalScroll) { + horizontalScroll.registerScrollOffset(scrollId, offsetX); + } + }, + [horizontalScroll, scrollId], + ); + + return ( + onScrollViewWidthChange(e.nativeEvent.layout.width)} + // When at left edge, wait for close gesture to fail before scrolling. + // The close gesture fails quickly on leftward swipes (failOffsetX=-10), + // so scrolling left works normally. On rightward swipes, close gesture + // activates and closes the sidebar. + waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined} + > + {children} + + ); +} diff --git a/packages/app/src/components/diff-scroll.web.tsx b/packages/app/src/components/diff-scroll.web.tsx new file mode 100644 index 000000000..65c8dce2d --- /dev/null +++ b/packages/app/src/components/diff-scroll.web.tsx @@ -0,0 +1,29 @@ +import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native"; + +interface DiffScrollProps { + children: React.ReactNode; + scrollViewWidth: number; + onScrollViewWidthChange: (width: number) => void; + style?: StyleProp; + contentContainerStyle?: StyleProp; +} + +export function DiffScroll({ + children, + onScrollViewWidthChange, + style, + contentContainerStyle, +}: DiffScrollProps) { + return ( + onScrollViewWidthChange(e.nativeEvent.layout.width)} + > + {children} + + ); +} diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 04efd162b..b50f84e7e 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -2,7 +2,6 @@ import { useState, useCallback, useEffect, - useId, useMemo, useRef, memo, @@ -20,7 +19,6 @@ import { type NativeSyntheticEvent, type NativeScrollEvent, } from "react-native"; -import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { @@ -44,8 +42,7 @@ import { } from "@/hooks/use-checkout-diff-query"; import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query"; import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query"; -import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context"; -import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; +import { DiffScroll } from "./diff-scroll"; import { darkHighlightColors, lightHighlightColors, @@ -262,41 +259,6 @@ function DiffFileBody({ testID?: string; }) { const [scrollViewWidth, setScrollViewWidth] = useState(0); - const [isAtLeftEdge, setIsAtLeftEdge] = useState(true); - const horizontalScroll = useHorizontalScrollOptional(); - const scrollId = useId(); - const scrollViewRef = useRef(null); - - // Get the close gesture ref from animation context (may not be available outside sidebar) - let closeGestureRef: React.MutableRefObject | undefined; - try { - const animation = useExplorerSidebarAnimation(); - closeGestureRef = animation.closeGestureRef; - } catch { - // Not inside ExplorerSidebarAnimationProvider, which is fine - } - - // Register/unregister scroll offset tracking - useEffect(() => { - if (!horizontalScroll) return; - // Start at 0 (not scrolled) - horizontalScroll.registerScrollOffset(scrollId, 0); - return () => { - horizontalScroll.unregisterScrollOffset(scrollId); - }; - }, [horizontalScroll, scrollId]); - - const handleScroll = useCallback( - (event: NativeSyntheticEvent) => { - const offsetX = event.nativeEvent.contentOffset.x; - // Track if we're at the left edge (with small threshold for float precision) - setIsAtLeftEdge(offsetX <= 1); - if (horizontalScroll) { - horizontalScroll.registerScrollOffset(scrollId, offsetX); - } - }, - [horizontalScroll, scrollId], - ); return ( setScrollViewWidth(e.nativeEvent.layout.width)} - // When at left edge, wait for close gesture to fail before scrolling. - // The close gesture fails quickly on leftward swipes (failOffsetX=-10), - // so scrolling left works normally. On rightward swipes, close gesture - // activates and closes the sidebar. - waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined} > 0 && { minWidth: scrollViewWidth }]} > {linesContent} - + ); })()}