diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx index 2eabce998e8e..76c079c09a5c 100644 --- a/apps/mobile/src/features/files/SourceFileSurface.tsx +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -36,6 +36,26 @@ interface SourceFileSurfaceProps { type SourceHighlightStatus = "highlighting" | "ready" | "error"; +const SOURCE_SCROLL_RETRY_DELAY_MS = 100; +const SOURCE_SCROLL_MAX_RETRIES = 3; + +function useSourcePullToRefresh(onRefresh: SourceFileSurfaceProps["onRefresh"]) { + const [isRefreshing, setIsRefreshing] = useState(false); + const handleRefresh = useCallback(async () => { + if (!onRefresh) { + return; + } + setIsRefreshing(true); + try { + await onRefresh(); + } finally { + setIsRefreshing(false); + } + }, [onRefresh]); + + return { handleRefresh, isRefreshing }; +} + const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { readonly codeSurface: ResolvedMobileCodeSurface; readonly index: number; @@ -157,18 +177,7 @@ function NativeSourceFileSurface( const appTheme = useUniwindTheme(); const { width: viewportWidth } = useWindowDimensions(); const { rowsJson, status, targetIndex, tokens } = useSourceFileModel(props); - const [isPullRefreshing, setIsPullRefreshing] = useState(false); - const handlePullToRefresh = useCallback(async () => { - if (!onRefresh) { - return; - } - setIsPullRefreshing(true); - try { - await onRefresh(); - } finally { - setIsPullRefreshing(false); - } - }, [onRefresh]); + const { handleRefresh, isRefreshing } = useSourcePullToRefresh(onRefresh); const tokensJson = useMemo(() => JSON.stringify(buildNativeSourceTokens(tokens)), [tokens]); const selectedRowIdsJson = useMemo( () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]), @@ -202,8 +211,8 @@ function NativeSourceFileSurface( tokensJson={tokensJson} {...(onRefresh ? { - refreshing: isPullRefreshing, - onPullToRefresh: () => void handlePullToRefresh(), + refreshing: isRefreshing, + onPullToRefresh: () => void handleRefresh(), } : {})} /> @@ -214,17 +223,52 @@ function NativeSourceFileSurface( function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { const { codeSurface, codeWordBreak } = useAppearanceCodeSurface(); const { lines, status, targetIndex, tokens } = useSourceFileModel(props); + const { handleRefresh, isRefreshing } = useSourcePullToRefresh(props.onRefresh); const listRef = useRef>(null); + const scrollRetryCountRef = useRef(0); + const scrollRetryTimeoutRef = useRef | null>(null); + + const scrollToTargetIndex = useCallback((index: number) => { + listRef.current?.scrollToIndex({ index, animated: false, viewPosition: 0.3 }); + }, []); useEffect(() => { if (targetIndex === null) { return; } + scrollRetryCountRef.current = 0; const frame = requestAnimationFrame(() => { - listRef.current?.scrollToIndex({ index: targetIndex, animated: false, viewPosition: 0.3 }); + scrollToTargetIndex(targetIndex); }); - return () => cancelAnimationFrame(frame); - }, [props.path, targetIndex]); + return () => { + cancelAnimationFrame(frame); + if (scrollRetryTimeoutRef.current !== null) { + clearTimeout(scrollRetryTimeoutRef.current); + scrollRetryTimeoutRef.current = null; + } + }; + }, [props.path, scrollToTargetIndex, targetIndex]); + + const handleScrollToIndexFailed = useCallback( + (failure: { readonly averageItemLength: number; readonly index: number }) => { + listRef.current?.scrollToOffset({ + animated: false, + offset: failure.averageItemLength * failure.index, + }); + if (scrollRetryCountRef.current >= SOURCE_SCROLL_MAX_RETRIES) { + return; + } + scrollRetryCountRef.current += 1; + if (scrollRetryTimeoutRef.current !== null) { + clearTimeout(scrollRetryTimeoutRef.current); + } + scrollRetryTimeoutRef.current = setTimeout(() => { + scrollRetryTimeoutRef.current = null; + scrollToTargetIndex(failure.index); + }, SOURCE_SCROLL_RETRY_DELAY_MS); + }, + [scrollToTargetIndex], + ); const renderLine = useCallback( ({ item, index }: { item: string; index: number }) => ( @@ -248,6 +292,13 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { initialNumToRender={80} maxToRenderPerBatch={80} windowSize={12} + onScrollToIndexFailed={handleScrollToIndexFailed} + {...(props.onRefresh + ? { + refreshing: isRefreshing, + onRefresh: () => void handleRefresh(), + } + : {})} {...(codeWordBreak ? {} : { @@ -281,8 +332,9 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { } export function SourceFileSurface(props: SourceFileSurfaceProps) { + const { appearance } = useAppearancePreferences(); const NativeView = resolveNativeReviewDiffView(); - return NativeView ? ( + return NativeView && !appearance.codeWordBreak ? ( ) : (