Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fe19830
fix(mobile): Android file viewer can select text across lines
shivamhwp Nov 3, 2026
fcf1a93
test(mobile): cover line restoration after text selection
shivamhwp Nov 5, 2026
fa43ea8
fix(mobile): keep Android text selection responsive on large files
shivamhwp Nov 5, 2026
e8a663a
test(mobile): check selectable HTML at the helper layer
shivamhwp Nov 5, 2026
8bbdd2a
perf(mobile): skip unused native tree during Android selection
shivamhwp Nov 5, 2026
6badfc8
fix(mobile): wrapped file viewer reaches far requested lines
shivamhwp Nov 5, 2026
ac80d42
fix(mobile): clear stalled file line jumps after bounded retries
shivamhwp Nov 5, 2026
0c9723b
Merge remote-tracking branch 'origin/main' into issue-watch/30178
shivamhwp Nov 7, 2026
de78e46
refactor(mobile): leave wrapped far-line scroll fix out of text selec…
shivamhwp Nov 7, 2026
e11dc6d
Merge remote-tracking branch 'origin/main' into issue-watch/30178
shivamhwp Nov 7, 2026
ef4e39d
Merge remote-tracking branch 'origin/main' into pr-31286
shivamhwp Nov 8, 2026
11b2569
fix(mobile): long-press a file line to select text across lines on An…
shivamhwp Nov 8, 2026
e20df26
Merge remote-tracking branch 'origin/main' into pr-31286
shivamhwp Nov 8, 2026
7b8ad70
fix(mobile): TalkBack can open file text selection from a row
shivamhwp Nov 8, 2026
d3b1902
Merge remote-tracking branch 'origin/main' into pr-31286-review
shivamhwp Nov 8, 2026
432704b
fix(mobile): file text selection lets other navigation and modes through
shivamhwp Nov 8, 2026
acda47d
fix(mobile): clear file selection when changing preview modes
shivamhwp Nov 8, 2026
0321ec0
Merge remote-tracking branch 'origin/main' into pr-31286-review
shivamhwp Nov 8, 2026
20c0018
Merge remote-tracking branch 'origin/main' into prbatch/31286
shivamhwp Nov 9, 2026
373bb37
refactor(mobile): keep file text selection state with the source view
shivamhwp Nov 9, 2026
41b54fc
fix(mobile): reset file text selection when the requested line changes
shivamhwp Nov 9, 2026
3f40f93
Merge remote-tracking branch 'origin/main' into prbatch/31286
shivamhwp Nov 9, 2026
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
89 changes: 76 additions & 13 deletions apps/mobile/src/features/files/SourceFileSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import { useLayoutEffect, useRef, useState } from "react";
import {
FlatList,
Platform,
Pressable,
RefreshControl,
ScrollView,
Text as NativeText,
useWindowDimensions,
View,
} from "react-native";
import { WebView } from "react-native-webview";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppText as Text } from "../../components/ui/AppText";
import { LoadingStrip } from "../../components/ui/LoadingStrip";
Expand All @@ -35,7 +37,11 @@ import {
} from "./nativeSourceFileAdapter";
import { SourceText } from "@t3tools/mobile-source-text";

import { boundedSelectableSourceTokens, prepareSourceFileDocument } from "./source-file-document";
import {
boundedSelectableSourceTokens,
prepareSourceFileDocument,
selectableSourceHtml,
} from "./source-file-document";
import { sourceHighlightAtom } from "./sourceHighlightingState";
import { createSourceLineScroll } from "./source-file-scroll";

Expand All @@ -46,6 +52,7 @@ interface SourceFileSurfaceProps {
readonly initialLine?: number | null;
readonly headerInsetTop?: number;
readonly selectable?: boolean;
readonly onLongPressLine?: (line: number) => void;
readonly onRefresh?: () => Promise<void> | void;
}

Expand All @@ -58,8 +65,10 @@ const HighlightedSourceLine = function HighlightedSourceLine(props: {
readonly tokens: ReadonlyArray<HighlightedToken> | null;
readonly highlighted: boolean;
readonly wordBreak: boolean;
readonly onLongPress?: () => void;
}) {
return (
const selectable = props.onLongPress === undefined;
const row = (
<View
className={cn("flex-row", props.highlighted && "bg-primary/10")}
style={{ minHeight: props.codeSurface.rowHeight }}
Expand All @@ -76,7 +85,7 @@ const HighlightedSourceLine = function HighlightedSourceLine(props: {
{props.index + 1}
</NativeText>
<NativeText
selectable
selectable={selectable}
selectionColorClassName={Platform.OS === "android" ? "accent-focus/32" : undefined}
numberOfLines={props.wordBreak ? undefined : 1}
className="flex-1 font-normal text-foreground"
Expand Down Expand Up @@ -106,7 +115,7 @@ const HighlightedSourceLine = function HighlightedSourceLine(props: {
return (
<NativeText
key={`${start}:${token.content.length}:${token.color ?? ""}`}
selectable
selectable={selectable}
selectionColorClassName={
Platform.OS === "android" ? "accent-focus/32" : undefined
}
Expand All @@ -126,6 +135,20 @@ const HighlightedSourceLine = function HighlightedSourceLine(props: {
</NativeText>
</View>
);
const onLongPress = props.onLongPress;
return onLongPress ? (
<Pressable
onLongPress={onLongPress}
accessibilityActions={[{ name: "longpress", label: "Select text" }]}
onAccessibilityAction={(event) => {
if (event.nativeEvent.actionName === "longpress") onLongPress();
}}
>
{row}
</Pressable>
) : (
row
);
};

function useSourceFileModel(props: SourceFileSurfaceProps) {
Expand Down Expand Up @@ -185,7 +208,7 @@ function NativeSourceFileSurface(
readonly NativeView: ComponentType<NativeReviewDiffViewProps>;
},
) {
const { NativeView, onRefresh } = props;
const { NativeView, onLongPressLine, onRefresh } = props;
const insets = useSafeAreaInsets();
const columnMetrics = useNativeColumnLayoutMetrics();
const safeArea = columnMetrics?.safeArea ?? insets;
Expand Down Expand Up @@ -224,6 +247,11 @@ function NativeSourceFileSurface(
styleJson={styleJson}
themeJson={themeJson}
tokensJson={tokensJson}
onPressLine={({ nativeEvent }) => {
if (nativeEvent.gesture === "longPress" && nativeEvent.newLineNumber !== undefined) {
onLongPressLine?.(nativeEvent.newLineNumber);
}
}}
{...(onRefresh
? {
refreshing: isPullRefreshing,
Expand All @@ -237,7 +265,8 @@ function NativeSourceFileSurface(

function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
const usesAutomaticInsets = Platform.OS === "ios" && !props.embedded;
const foreground = useUniwindTheme()["--color-foreground"];
const appTheme = useUniwindTheme();
const foreground = appTheme["--color-foreground"];
const { codeSurface, codeWordBreak } = useAppearanceCodeSurface();
const { normalizedContents, lines, status, targetIndex, tokens } = useSourceFileModel(props);
const selectableTokens = props.selectable ? boundedSelectableSourceTokens(tokens) : null;
Expand All @@ -248,17 +277,23 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
<RefreshControl refreshing={isPullRefreshing} onRefresh={() => void handlePullToRefresh()} />
) : undefined;

const usesLineList = !props.selectable;
// Android lays out a full selectable TextView on the UI thread, which can stall large files.
const usesWebSelection = props.selectable === true && Platform.OS === "android";
const usesTextSelection = props.selectable === true && !usesWebSelection;

// Layout effect so a pending jump is cancelled in the commit that swaps the file.
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scrolls the FlatList to the target line and cancels a pending jump when the file changes
useLayoutEffect(() => {
if (targetIndex === null) {
if (targetIndex === null || !usesLineList) {
return;
}
lineScroll.scrollToLine(listRef.current, targetIndex);
return lineScroll.cancel;
// oxlint-disable-next-line react/exhaustive-effect-dependencies -- Opening another file at the same line index scrolls the new file to it.
}, [lineScroll, props.path, targetIndex]);
// oxlint-disable-next-line react/exhaustive-effect-dependencies -- Opening another file at the same line index, or leaving selection mode, scrolls the remounted list to it.
}, [lineScroll, props.path, targetIndex, usesLineList]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

const { onLongPressLine } = props;
const renderLine = ({ item, index }: { item: string; index: number }) => (
<HighlightedSourceLine
codeSurface={codeSurface}
Expand All @@ -267,14 +302,14 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
tokens={tokens?.[index] ?? null}
highlighted={index === targetIndex}
wordBreak={codeWordBreak}
onLongPress={onLongPressLine ? () => onLongPressLine(index + 1) : undefined}
/>
);

// One native text view permits document-wide selection; virtualized rows cannot.
const selectableBlock = props.selectable ? (
const selectableBlock = usesTextSelection ? (
<SourceText
selectable
selectionColorClassName={Platform.OS === "android" ? "accent-focus/32" : undefined}
style={{
color: foreground,
fontFamily: REVIEW_MONO_FONT_FAMILY,
Expand Down Expand Up @@ -322,6 +357,33 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
</SourceText>
) : null;

const webSelection = usesWebSelection ? (
<WebView
source={{
html: selectableSourceHtml(normalizedContents, {
foreground,
selection: appTheme["--color-focus"],
fontSize: codeSurface.fontSize,
rowHeight: codeSurface.rowHeight,
wordBreak: codeWordBreak,
}),
}}
originWhitelist={["*"]}
// Android pastes copied HTML as styled text with collapsed whitespace, so copy plain text.
// Wrapped text reflows once the WebView reaches its final width, so scroll again then.
injectedJavaScript={`addEventListener("copy",(event)=>{event.clipboardData.setData("text/plain",getSelection().toString());event.preventDefault();});${
targetIndex === null
? ""
: `const line=document.getElementById("L${targetIndex + 1}");const reveal=()=>line?.scrollIntoView({block:"center"});reveal();addEventListener("resize",reveal,{once:true});`
}true;`}
domStorageEnabled={false}
cacheEnabled={false}
setSupportMultipleWindows={false}
onShouldStartLoadWithRequest={(request) => request.url === "about:blank"}
style={{ flex: 1, backgroundColor: "transparent" }}
/>
) : null;

const list = (
<FlatList
ref={listRef}
Expand Down Expand Up @@ -353,7 +415,6 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
/>
);

const usesLineList = !props.selectable;
const padded = (
<ScrollView
refreshControl={refreshControl}
Expand All @@ -372,7 +433,7 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {

return (
<View className="relative flex-1 bg-sheet">
<SourceHighlightStatusView status={status} />
{usesWebSelection ? null : <SourceHighlightStatusView status={status} />}
{usesLineList ? (
codeWordBreak ? (
list
Expand All @@ -381,6 +442,8 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
{list}
</ScrollView>
)
) : usesWebSelection ? (
webSelection
Comment thread
shivamhwp marked this conversation as resolved.
Resolved
) : codeWordBreak ? (
padded
) : (
Expand Down
39 changes: 36 additions & 3 deletions apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-action
import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks";
import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
import { environmentSession } from "../../state/session";
import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
import { type ReactNode, useId, useState } from "react";
import {
StackActions,
useNavigation,
usePreventRemove,
type StaticScreenProps,
} from "@react-navigation/native";
import { type ComponentProps, type ReactNode, useId, useState } from "react";
import { Platform, View } from "react-native";
import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
import { EnvironmentId, type ProjectReadFileResult, ThreadId } from "@t3tools/contracts";
Expand Down Expand Up @@ -197,6 +202,33 @@ function defaultViewMode(path: string | null): FileViewMode {
: "source";
}

// Android numbered rows are separate text views, so a long-press opens the whole file as one
// selectable text at that line. Back returns to the numbered rows at the same line.
function FileSourceSurface(props: ComponentProps<typeof SourceFileSurface>) {
const navigation = useNavigation();
const [selection, setSelection] = useState<{
readonly line: number;
readonly active: boolean;
} | null>(null);
usePreventRemove(selection?.active === true, ({ data }) => {
setSelection((value) => value && { ...value, active: false });
// The re-dispatched action skips this guard, so "Return to chat" still leaves in one tap.
if (data.action.type !== "GO_BACK" && data.action.type !== "POP") {
navigation.dispatch(data.action);
}
});
return (
<SourceFileSurface
{...props}
initialLine={selection?.line ?? props.initialLine}
selectable={selection?.active === true}
onLongPressLine={
Platform.OS === "android" ? (line) => setSelection({ line, active: true }) : undefined
}
/>
);
}

function FileContent(props: {
readonly activeMode: FileViewMode;
readonly cwd: string;
Expand Down Expand Up @@ -308,7 +340,8 @@ function FileContent(props: {
onRefresh={props.onRefresh}
/>
) : (
<SourceFileSurface
<FileSourceSurface
key={props.initialLine}
contents={props.fileContents}
path={props.relativePath}
initialLine={props.initialLine}
Expand Down
20 changes: 20 additions & 0 deletions apps/mobile/src/features/files/source-file-document.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { expect, it } from "vite-plus/test";
import { prepareSourceFileDocument, selectableSourceHtml } from "./source-file-document";

it("preserves source whitespace and markup as literal selectable text", () => {
const source = prepareSourceFileDocument(
"\n\tlead\r\n<script>alert(1)</script> &amp; > 😀\u00a0end\n",
);
const html = selectableSourceHtml(source.contents, {
foreground: "#000000",
selection: "#3366ff",
fontSize: 14,
rowHeight: 24,
wordBreak: true,
});
const page = new DOMParser().parseFromString(html, "text/html");

expect(page.querySelector("script")).toBeNull();
expect(page.body.textContent).toBe("\n\tlead\n<script>alert(1)</script> &amp; > 😀\u00a0end\n");
expect(page.getElementById("L2")?.textContent).toBe("\tlead");
});
25 changes: 25 additions & 0 deletions apps/mobile/src/features/files/source-file-document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,28 @@ export function boundedSelectableSourceTokens(
}
return tokens;
}

export interface SelectableSourceHtmlStyle {
readonly foreground: string;
readonly selection: string;
readonly fontSize: number;
readonly rowHeight: number;
readonly wordBreak: boolean;
}

// A <pre> would drop a leading newline, so whitespace is preserved on a plain <div>.
export function selectableSourceHtml(contents: string, style: SelectableSourceHtmlStyle): string {
const text = contents
.split("\n")
.map(
(line, index) =>
`<span id="L${index + 1}">${line.replace(/[&<>]/g, (char) =>
char === "&" ? "&amp;" : char === "<" ? "&lt;" : "&gt;",
)}</span>`,
)
.join("\n");
const wrap = style.wordBreak
? "white-space:pre-wrap;overflow-wrap:anywhere"
: "white-space:pre;width:max-content";
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none';style-src 'unsafe-inline'"><style>html,body{margin:0;background:transparent}div{box-sizing:border-box;padding:8px 12px ${style.rowHeight}px;color:${style.foreground};font-family:monospace;font-size:${style.fontSize}px;line-height:${style.rowHeight / style.fontSize};${wrap}}::selection{background:color-mix(in srgb,${style.selection} 32%,transparent)}</style></head><body><div>${text}</div></body></html>`;
}
1 change: 1 addition & 0 deletions apps/mobile/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const reactTests = [
"src/state/use-thread-pr.demand.test.tsx",
"src/features/threads/git/GitBranchesSheet.occupancy.test.tsx",
"src/features/files/useFileTreeEntries.test.tsx",
"src/features/files/source-file-document.test.ts",
"src/features/projects/AddProjectScreen.test.ts",
"src/lib/useMediaVideoPlayback.test.tsx",
"src/features/pull-requests/usePullRequestEnvironmentFallback.test.tsx",
Expand Down
Loading