Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions apps/mobile/src/components/AndroidScreenHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function AndroidHeaderIconButton(props: {
readonly icon: AppSymbolName;
readonly onPress?: () => void;
readonly disabled?: boolean;
readonly filled?: boolean;
}) {
return (
<Pressable
Expand All @@ -27,14 +28,21 @@ export function AndroidHeaderIconButton(props: {
hitSlop={8}
onPress={props.onPress}
className={cn(
"size-11 items-center justify-center rounded-full bg-subtle",
"size-11 items-center justify-center rounded-full",
props.filled ? "bg-primary" : "bg-subtle",
props.disabled && "opacity-55",
)}
>
<SymbolView
name={props.icon}
size={20}
tintColorClassName={props.disabled ? "accent-icon-subtle" : "accent-foreground"}
tintColorClassName={
props.disabled
? "accent-icon-subtle"
: props.filled
? "accent-primary-foreground"
: "accent-foreground"
}
type="monochrome"
/>
</Pressable>
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/components/AppSymbol.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -96,6 +97,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
"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,
Expand Down
73 changes: 66 additions & 7 deletions apps/mobile/src/features/files/SourceFileSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -50,6 +50,7 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: {
style={{ minHeight: props.codeSurface.rowHeight }}
>
<NativeText
allowFontScaling={false}
className="select-none pr-3 text-right text-foreground-tertiary"
style={{
width: props.codeSurface.gutterWidth,
Expand All @@ -61,6 +62,7 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: {
{props.index + 1}
</NativeText>
<NativeText
allowFontScaling={false}
selectable
numberOfLines={props.wordBreak ? undefined : 1}
className="flex-1 font-normal text-foreground"
Expand Down Expand Up @@ -152,10 +154,9 @@ function NativeSourceFileSurface(
},
) {
const { NativeView, onRefresh } = props;
const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface();
const { codeSurface, nativeSourceStyle } = useAppearanceCodeSurface();
const { themeAppearance, themeId } = useAppearancePreferences();
const appTheme = useUniwindTheme();
const { width: viewportWidth } = useWindowDimensions();
const { rowsJson, status, targetIndex, tokens } = useSourceFileModel(props);
const [isPullRefreshing, setIsPullRefreshing] = useState(false);
const handlePullToRefresh = useCallback(async () => {
Expand All @@ -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 (
<View className="relative flex-1 bg-sheet">
Expand Down Expand Up @@ -215,15 +214,39 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
const { codeSurface, codeWordBreak } = useAppearanceCodeSurface();
const { lines, status, targetIndex, tokens } = useSourceFileModel(props);
const listRef = useRef<FlatList<string>>(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<number | null>(null);
const targetIndexRef = useRef<number | null>(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(
Expand All @@ -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 = (
<FlatList
ref={listRef}
Expand All @@ -248,6 +296,7 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
initialNumToRender={80}
maxToRenderPerBatch={80}
windowSize={12}
onScrollToIndexFailed={handleScrollToIndexFailed}
{...(codeWordBreak
? {}
: {
Expand All @@ -263,6 +312,12 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
paddingTop: 8,
}}
renderItem={renderLine}
{...(props.onRefresh
? {
refreshing: isPullRefreshing,
onRefresh: () => void handlePullToRefresh(),
}
: {})}
/>
);

Expand All @@ -281,6 +336,10 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
}

export function SourceFileSurface(props: SourceFileSurfaceProps) {
const { codeWordBreak } = useAppearanceCodeSurface();
if (codeWordBreak) {
return <JavaScriptSourceFileSurface {...props} />;
}
const NativeView = resolveNativeReviewDiffView();
return NativeView ? (
<NativeSourceFileSurface {...props} NativeView={NativeView} />
Expand Down
23 changes: 23 additions & 0 deletions apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<FilePreviewSource | null>(null);
const isVideoFile = relativePath !== null && isVideoPreviewFile(relativePath);
Expand All @@ -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,
Expand Down Expand Up @@ -843,6 +849,14 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
onBack={handleBack}
trailing={
<>
{resolvedActiveMode === "source" ? (
<AndroidHeaderIconButton
accessibilityLabel={codeWordBreak ? "Disable word wrap" : "Enable word wrap"}
icon="arrow.left.and.line.vertical.and.arrow.right"
onPress={handleToggleWordBreak}
filled={codeWordBreak}
/>
) : null}
{fileInspector.supported ? (
<AndroidHeaderIconButton
accessibilityLabel={
Expand Down Expand Up @@ -884,6 +898,15 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
separateBackground
/>
) : null}
{resolvedActiveMode === "source" ? (
<NativeHeaderToolbar.Button
accessibilityLabel={codeWordBreak ? "Disable word wrap" : "Enable word wrap"}
icon="arrow.left.and.line.vertical.and.arrow.right"
onPress={handleToggleWordBreak}
separateBackground
tintColor={codeWordBreak ? primaryColor : undefined}
/>
) : null}
<NativeHeaderToolbar.Menu accessibilityLabel="File actions" icon="ellipsis">
{fileMenuActions.some(({ inline }) => inline) ? (
<NativeHeaderToolbar.Menu inline>
Expand Down
Loading