From f1304a2161b0590d91e1c10f81c56433d632225d Mon Sep 17 00:00:00 2001 From: sabraman Date: Tue, 8 Sep 2026 05:31:03 +0300 Subject: [PATCH] fix(mobile): word wrap now rewraps via the JS source surface --- .../src/components/AndroidScreenHeader.tsx | 12 ++- apps/mobile/src/components/AppSymbol.tsx | 2 + .../src/features/files/SourceFileSurface.tsx | 73 +++++++++++++++++-- .../features/files/ThreadFilesRouteScreen.tsx | 23 ++++++ 4 files changed, 101 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/components/AndroidScreenHeader.tsx b/apps/mobile/src/components/AndroidScreenHeader.tsx index 46bc2c7c0912..f672830d3645 100644 --- a/apps/mobile/src/components/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/AndroidScreenHeader.tsx @@ -18,6 +18,7 @@ export function AndroidHeaderIconButton(props: { readonly icon: AppSymbolName; readonly onPress?: () => void; readonly disabled?: boolean; + readonly filled?: boolean; }) { return ( diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index aa714bccb419..1f6bbeec8135 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -81,6 +81,7 @@ import IconSun from "@tabler/icons-react-native/IconSun"; import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2"; import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease"; import IconTextIncrease from "@tabler/icons-react-native/IconTextIncrease"; +import IconTextWrap from "@tabler/icons-react-native/IconTextWrap"; import IconTool from "@tabler/icons-react-native/IconTool"; import IconTrash from "@tabler/icons-react-native/IconTrash"; import IconTypography from "@tabler/icons-react-native/IconTypography"; @@ -96,6 +97,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "arrow.branch": IconGitBranch, "arrow.clockwise": IconRefresh, "arrow.down.circle": IconArrowDownCircle, + "arrow.left.and.line.vertical.and.arrow.right": IconTextWrap, "arrow.right.circle": IconArrowRightCircle, "arrow.triangle.branch": IconGitBranch, "arrow.triangle.pull": IconGitPullRequest, diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx index 2eabce998e8e..b308f550eeb4 100644 --- a/apps/mobile/src/features/files/SourceFileSurface.tsx +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; import type { ComponentType } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { FlatList, ScrollView, Text as NativeText, useWindowDimensions, View } from "react-native"; +import { FlatList, ScrollView, Text as NativeText, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; import { LoadingStrip } from "../../components/LoadingStrip"; @@ -50,6 +50,7 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { style={{ minHeight: props.codeSurface.rowHeight }} > { @@ -179,9 +180,7 @@ function NativeSourceFileSurface( [appTheme, themeAppearance, themeId], ); const styleJson = useMemo(() => JSON.stringify(nativeSourceStyle), [nativeSourceStyle]); - const contentWidth = codeWordBreak - ? Math.max(240, viewportWidth - codeSurface.gutterWidth - 24) - : NATIVE_SOURCE_CONTENT_WIDTH; + const contentWidth = NATIVE_SOURCE_CONTENT_WIDTH; return ( @@ -215,15 +214,39 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { const { codeSurface, codeWordBreak } = useAppearanceCodeSurface(); const { lines, status, targetIndex, tokens } = useSourceFileModel(props); const listRef = useRef>(null); + const [isPullRefreshing, setIsPullRefreshing] = useState(false); + + const handlePullToRefresh = useCallback(async () => { + if (!props.onRefresh) { + return; + } + setIsPullRefreshing(true); + try { + await props.onRefresh(); + } finally { + setIsPullRefreshing(false); + } + }, [props.onRefresh]); + const scrollRetryCountRef = useRef(0); + const retryFrameRef = useRef(null); + const targetIndexRef = useRef(null); + targetIndexRef.current = targetIndex; useEffect(() => { if (targetIndex === null) { return; } + scrollRetryCountRef.current = 0; const frame = requestAnimationFrame(() => { listRef.current?.scrollToIndex({ index: targetIndex, animated: false, viewPosition: 0.3 }); }); - return () => cancelAnimationFrame(frame); + return () => { + cancelAnimationFrame(frame); + if (retryFrameRef.current !== null) { + cancelAnimationFrame(retryFrameRef.current); + retryFrameRef.current = null; + } + }; }, [props.path, targetIndex]); const renderLine = useCallback( @@ -240,6 +263,31 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { [codeSurface, codeWordBreak, targetIndex, tokens], ); + const handleScrollToIndexFailed = useCallback( + (info: { index: number; averageItemLength: number }) => { + listRef.current?.scrollToOffset({ + offset: Math.max(0, info.averageItemLength * info.index), + animated: false, + }); + if (retryFrameRef.current !== null || scrollRetryCountRef.current >= 3) { + return; + } + scrollRetryCountRef.current += 1; + retryFrameRef.current = requestAnimationFrame(() => { + retryFrameRef.current = null; + if (targetIndexRef.current === null || targetIndexRef.current !== info.index) { + return; + } + listRef.current?.scrollToIndex({ + index: info.index, + animated: false, + viewPosition: 0.3, + }); + }); + }, + [], + ); + const list = ( void handlePullToRefresh(), + } + : {})} /> ); @@ -281,6 +336,10 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { } export function SourceFileSurface(props: SourceFileSurfaceProps) { + const { codeWordBreak } = useAppearanceCodeSurface(); + if (codeWordBreak) { + return ; + } const NativeView = resolveNativeReviewDiffView(); return NativeView ? ( diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 29bbdb49c890..ee04057afba5 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -547,6 +547,9 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { readonly mode: FileViewMode; } | null>(null); const [previewRevision, setPreviewRevision] = useState(0); + const { appearance, setCodeWordBreak } = useAppearancePreferences(); + const codeWordBreak = appearance.codeWordBreak; + const primaryColor = useUniwindTheme()["--color-primary"]; const previewKey = JSON.stringify([environmentId, cwd, relativePath, previewRevision]); const [fullScreenPreview, setFullScreenPreview] = useState(null); const isVideoFile = relativePath !== null && isVideoPreviewFile(relativePath); @@ -562,6 +565,9 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { ? modeOverride.mode : defaultViewMode(relativePath); const resolvedActiveMode = isVideoFile ? "preview" : canPreview ? activeMode : "source"; + const handleToggleWordBreak = useCallback(() => { + setCodeWordBreak(!codeWordBreak); + }, [codeWordBreak, setCodeWordBreak]); const assetPreviewPath = isBrowserFile || isImageFile || isVideoFile ? relativePath : null; const assetPreview = useWorkspaceFileAssetUrlState({ cwd, @@ -843,6 +849,14 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { onBack={handleBack} trailing={ <> + {resolvedActiveMode === "source" ? ( + + ) : null} {fileInspector.supported ? ( ) : null} + {resolvedActiveMode === "source" ? ( + + ) : null} {fileMenuActions.some(({ inline }) => inline) ? (