1import { useAtomValue } from "@effect/atom-react";
2import { AsyncResult } from "effect/reactivity";
3import type { ComponentType } from "react";
4import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
5import {
6 FlatList,
7 Platform,
8 RefreshControl,
9 ScrollView,
10 Text as NativeText,
11 useWindowDimensions,
12 View,
13} from "react-native";
14import { useSafeAreaInsets } from "react-native-safe-area-context";
15import { AppText as Text } from "../../components/AppText";
16import { LoadingStrip } from "../../components/LoadingStrip";
17import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics";
18import {
19 type NativeReviewDiffViewProps,
20 resolveNativeReviewDiffView,
21} from "../diffs/nativeReviewDiffSurface";
22import { createNativeReviewDiffTheme } from "../review/nativeReviewDiffAdapter";
23import { REVIEW_MONO_FONT_FAMILY, renderVisibleWhitespace } from "../review/reviewDiffRendering";
24import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter";
25import { cn } from "../../lib/cn";
26import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences";
27import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
28import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
29import { useUniwindTheme } from "../../lib/useUniwindTheme";
30import {
31 buildNativeSourceTokens,
32 NATIVE_SOURCE_CONTENT_WIDTH,
33 nativeSourceRowId,
34} from "./nativeSourceFileAdapter";
35import { SourceText } from "@t3tools/mobile-source-text";
36
37import { boundedSelectableSourceTokens, prepareSourceFileDocument } from "./source-file-document";
38import { sourceHighlightAtom } from "./sourceHighlightingState";
39
40interface SourceFileSurfaceProps {
41 readonly contents: string;
42 readonly path: string;
43 /** The enclosing sheet or card already reserves its own chrome and safe area. */
44 readonly embedded?: boolean;
45 readonly initialLine?: number | null;
46 /** Override when a fixed notice already reserves the native header. */
47 readonly headerInsetTop?: number;
48 /** Keep the entire document in one native text-selection scope. */
49 readonly selectable?: boolean;
50 /** Enables native pull-to-refresh on the source surface. */
51 readonly onRefresh?: () => Promise<void> | void;
52}
53
54type SourceHighlightStatus = "highlighting" | "ready" | "error";
55
56const HighlightedSourceLine = memo(function HighlightedSourceLine(props: {
57 readonly codeSurface: ResolvedMobileCodeSurface;
58 readonly index: number;
59 readonly line: string;
60 readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null;
61 readonly highlighted: boolean;
62 readonly wordBreak: boolean;
63}) {
64 return (
65 <View
66 className={cn("flex-row", props.highlighted && "bg-primary/10")}
67 style={{ minHeight: props.codeSurface.rowHeight }}
68 >
69 <NativeText
70 className="select-none pr-3 text-right text-foreground-tertiary"
71 style={{
72 width: props.codeSurface.gutterWidth,
73 fontFamily: REVIEW_MONO_FONT_FAMILY,
74 fontSize: props.codeSurface.lineNumberFontSize,
75 lineHeight: props.codeSurface.rowHeight,
76 }}
77 >
78 {props.index + 1}
79 </NativeText>
80 <NativeText
81 selectable
82 selectionColorClassName={Platform.OS === "android" ? "accent-focus/32" : undefined}
83 numberOfLines={props.wordBreak ? undefined : 1}
84 className="flex-1 font-normal text-foreground"
85 style={{
86 fontFamily: REVIEW_MONO_FONT_FAMILY,
87 fontSize: props.codeSurface.fontSize,
88 lineHeight: props.codeSurface.rowHeight,
89 minWidth: props.wordBreak ? undefined : 320,
90 }}
91 >
92 {props.tokens && props.tokens.length > 0
93 ? (() => {
94 let offset = 0;
95 return props.tokens.map((token) => {
96 const start = offset;
97 offset += token.content.length;
98
99 const fontWeight =
100 token.fontStyle !== null && (token.fontStyle & 2) === 2
101 ? ("700" as const)
102 : ("400" as const);
103 const fontStyle =
104 token.fontStyle !== null && (token.fontStyle & 1) === 1
105 ? ("italic" as const)
106 : ("normal" as const);
107
108 return (
109 <NativeText
110 key={`${start}:${token.content.length}:${token.color ?? ""}`}
111 selectable
112 selectionColorClassName={
113 Platform.OS === "android" ? "accent-focus/32" : undefined
114 }
115 style={{
116 color: token.color ?? undefined,
117 fontFamily: REVIEW_MONO_FONT_FAMILY,
118 fontWeight,
119 fontStyle,
120 }}
121 >
122 {token.content.length > 0 ? renderVisibleWhitespace(token.content) : " "}
123 </NativeText>
124 );
125 });
126 })()
127 : renderVisibleWhitespace(props.line || " ")}
128 </NativeText>
129 </View>
130 );
131});
132
133function useSourceFileModel(props: SourceFileSurfaceProps) {
134 const { themeAppearance: theme } = useAppearancePreferences();
135 const document = useMemo(() => prepareSourceFileDocument(props.contents), [props.contents]);
136 const { contents: normalizedContents, lines, rowsJson } = document;
137 const targetIndex =
138 props.initialLine !== null && props.initialLine !== undefined && props.initialLine > 0
139 ? Math.min(Math.floor(props.initialLine) - 1, Math.max(0, lines.length - 1))
140 : null;
141 const highlightAtom = useMemo(
142 () => sourceHighlightAtom({ path: props.path, contents: normalizedContents, theme }),
143 [normalizedContents, props.path, theme],
144 );
145 const highlightResult = useAtomValue(highlightAtom);
146 const tokens = AsyncResult.isSuccess(highlightResult) ? highlightResult.value : null;
147 const status: SourceHighlightStatus = AsyncResult.isFailure(highlightResult)
148 ? "error"
149 : AsyncResult.isSuccess(highlightResult)
150 ? "ready"
151 : "highlighting";
152
153 return { normalizedContents, lines, rowsJson, status, targetIndex, theme, tokens };
154}
155
156function SourceHighlightStatusView(props: { readonly status: SourceHighlightStatus }) {
157 if (props.status === "highlighting") {
158 return <LoadingStrip />;
159 }
160 if (props.status === "error") {
161 return (
162 <View className="border-b border-border bg-card px-4 py-2">
163 <Text className="text-2xs font-t3-medium uppercase text-foreground-muted">Plain text</Text>
164 </View>
165 );
166 }
167 return null;
168}
169
170function useSourceFileRefresh(onRefresh: SourceFileSurfaceProps["onRefresh"]) {
171 const [isPullRefreshing, setIsPullRefreshing] = useState(false);
172 const handlePullToRefresh = useCallback(async () => {
173 if (!onRefresh) {
174 return;
175 }
176 setIsPullRefreshing(true);
177 try {
178 await onRefresh();
179 } finally {
180 setIsPullRefreshing(false);
181 }
182 }, [onRefresh]);
183 return { isPullRefreshing, handlePullToRefresh };
184}
185
186function NativeSourceFileSurface(
187 props: SourceFileSurfaceProps & {
188 readonly NativeView: ComponentType<NativeReviewDiffViewProps>;
189 },
190) {
191 const { NativeView, onRefresh } = props;
192 const insets = useSafeAreaInsets();
193 const columnMetrics = useNativeColumnLayoutMetrics();
194 const safeArea = columnMetrics?.safeArea ?? insets;
195 const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface();
196 const { themeAppearance, themeId } = useAppearancePreferences();
197 const appTheme = useUniwindTheme();
198 const { width: viewportWidth } = useWindowDimensions();
199 const { rowsJson, status, targetIndex, tokens } = useSourceFileModel(props);
200 const { isPullRefreshing, handlePullToRefresh } = useSourceFileRefresh(onRefresh);
201 const tokensJson = useMemo(() => JSON.stringify(buildNativeSourceTokens(tokens)), [tokens]);
202 const selectedRowIdsJson = useMemo(
203 () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]),
204 [targetIndex],
205 );
206 const themeJson = useMemo(
207 () => JSON.stringify(createNativeReviewDiffTheme(themeAppearance, themeId, appTheme)),
208 [appTheme, themeAppearance, themeId],
209 );
210 const styleJson = useMemo(() => JSON.stringify(nativeSourceStyle), [nativeSourceStyle]);
211 const contentWidth = codeWordBreak
212 ? Math.max(240, viewportWidth - codeSurface.gutterWidth - 24)
213 : NATIVE_SOURCE_CONTENT_WIDTH;
214
215 return (
216 <View className="relative flex-1 bg-sheet">
217 <SourceHighlightStatusView status={status} />
218 <NativeView
219 collapsable={false}
220 testID="source-native-code-view"
221 style={{ flex: 1 }}
222 appearanceScheme={themeAppearance}
223 contentResetKey={props.path}
224 contentWidth={contentWidth}
225 contentInsetTop={props.embedded ? 0 : (props.headerInsetTop ?? safeArea.top)}
226 contentInsetBottom={props.embedded ? 0 : safeArea.bottom + 8}
227 initialRowIndex={targetIndex ?? -1}
228 rowHeight={nativeSourceStyle.rowHeight ?? codeSurface.rowHeight}
229 rowsJson={rowsJson}
230 selectedRowIdsJson={selectedRowIdsJson}
231 styleJson={styleJson}
232 themeJson={themeJson}
233 tokensJson={tokensJson}
234 {...(onRefresh
235 ? {
236 refreshing: isPullRefreshing,
237 onPullToRefresh: () => void handlePullToRefresh(),
238 }
239 : {})}
240 />
241 </View>
242 );
243}
244
245function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
246 const usesAutomaticInsets = Platform.OS === "ios" && !props.embedded;
247 const foreground = useUniwindTheme()["--color-foreground"];
248 const { codeSurface, codeWordBreak } = useAppearanceCodeSurface();
249 const { normalizedContents, lines, status, targetIndex, tokens } = useSourceFileModel(props);
250 const selectableTokens = useMemo(
251 () => (props.selectable ? boundedSelectableSourceTokens(tokens) : null),
252 [props.selectable, tokens],
253 );
254 const listRef = useRef<FlatList<string>>(null);
255 const { isPullRefreshing, handlePullToRefresh } = useSourceFileRefresh(props.onRefresh);
256 const refreshControl = props.onRefresh ? (
257 <RefreshControl refreshing={isPullRefreshing} onRefresh={() => void handlePullToRefresh()} />
258 ) : undefined;
259
260 useEffect(() => {
261 if (targetIndex === null) {
262 return;
263 }
264 const frame = requestAnimationFrame(() => {
265 listRef.current?.scrollToIndex({ index: targetIndex, animated: false, viewPosition: 0.3 });
266 });
267 return () => cancelAnimationFrame(frame);
268 }, [props.path, targetIndex]);
269
270 const renderLine = useCallback(
271 ({ item, index }: { item: string; index: number }) => (
272 <HighlightedSourceLine
273 codeSurface={codeSurface}
274 index={index}
275 line={item}
276 tokens={tokens?.[index] ?? null}
277 highlighted={index === targetIndex}
278 wordBreak={codeWordBreak}
279 />
280 ),
281 [codeSurface, codeWordBreak, targetIndex, tokens],
282 );
283
284 // One selectable text for the whole file. On iOS `uiTextView` renders a real `UITextView`,
285 // which selects across every line, wraps, and lays out long documents through TextKit; on
286 // Android the primitive is an RN `Text`, which selects across its nested children. Either
287 // way "select all" takes the file rather than a line, which a `FlatList` row can never do
288 // because each row is its own selection scope.
289 const selectableBlock = props.selectable ? (
290 <SourceText
291 selectable
292 selectionColorClassName={Platform.OS === "android" ? "accent-focus/32" : undefined}
293 style={{
294 color: foreground,
295 fontFamily: REVIEW_MONO_FONT_FAMILY,
296 fontSize: codeSurface.fontSize,
297 lineHeight: codeSurface.rowHeight,
298 }}
299 >
300 {selectableTokens
301 ? lines.map((line, index) => {
302 const lineTokens = selectableTokens[index] ?? null;
303 const body =
304 lineTokens && lineTokens.length > 0
305 ? lineTokens.map((token, tokenIndex) => (
306 <SourceText
307 key={`${index}:${tokenIndex}`}
308 style={{
309 color: token.color ?? foreground,
310 fontWeight:
311 token.fontStyle !== null && (token.fontStyle & 2) === 2 ? "700" : "400",
312 fontStyle:
313 token.fontStyle !== null && (token.fontStyle & 1) === 1
314 ? "italic"
315 : "normal",
316 }}
317 >
318 {token.content}
319 </SourceText>
320 ))
321 : line;
322 return (
323 <SourceText key={index}>
324 {body}
325 {index < lines.length - 1 ? "\n" : ""}
326 </SourceText>
327 );
328 })
329 : normalizedContents}
330 </SourceText>
331 ) : null;
332
333 const list = (
334 <FlatList
335 ref={listRef}
336 contentInsetAdjustmentBehavior={usesAutomaticInsets ? "automatic" : "never"}
337 automaticallyAdjustsScrollIndicatorInsets={usesAutomaticInsets}
338 refreshControl={refreshControl}
339 data={lines}
340 keyExtractor={(_line, index) => String(index)}
341 initialNumToRender={80}
342 maxToRenderPerBatch={80}
343 windowSize={12}
344 {...(codeWordBreak
345 ? {}
346 : {
347 getItemLayout: (_data, index) => ({
348 length: codeSurface.rowHeight,
349 offset: codeSurface.rowHeight * index,
350 index,
351 }),
352 })}
353 contentContainerStyle={{
354 minWidth: codeWordBreak ? undefined : "100%",
355 paddingBottom: codeSurface.rowHeight,
356 paddingTop: 8,
357 }}
358 renderItem={renderLine}
359 />
360 );
361
362 // Workspace files retain their numbered, virtualized rows, with or without a line target.
363 // Attachments opt into one selection scope for the entire document.
364 const usesLineList = !props.selectable;
365 const padded = (
366 <ScrollView
367 refreshControl={refreshControl}
368 className="flex-1"
369 contentInsetAdjustmentBehavior={usesAutomaticInsets ? "automatic" : "never"}
370 automaticallyAdjustsScrollIndicatorInsets={usesAutomaticInsets}
371 contentContainerStyle={{
372 paddingBottom: codeSurface.rowHeight,
373 paddingHorizontal: 12,
374 paddingTop: 8,
375 }}
376 >
377 {selectableBlock}
378 </ScrollView>
379 );
380
381 return (
382 <View className="relative flex-1 bg-sheet">
383 <SourceHighlightStatusView status={status} />
384 {usesLineList ? (
385 codeWordBreak ? (
386 list
387 ) : (
388 <ScrollView horizontal bounces={false} className="flex-1">
389 {list}
390 </ScrollView>
391 )
392 ) : codeWordBreak ? (
393 padded
394 ) : (
395 // Without wrapping the text keeps its natural width and the reader pans to it.
396 <ScrollView horizontal bounces={false} className="flex-1">
397 {padded}
398 </ScrollView>
399 )}
400 </View>
401 );
402}
403
404export function SourceFileSurface(props: SourceFileSurfaceProps) {
405 const NativeView = resolveNativeReviewDiffView();
406 const { codeWordBreak } = useAppearanceCodeSurface();
407 // The native canvas draws source lines without text selection or wrapping. Attachments
408 // need one selectable text view in either wrap mode; workspace line navigation can still
409 // use the canvas when wrapping is disabled.
410 const surface =
411 NativeView && !codeWordBreak && !props.selectable ? (
412 <NativeSourceFileSurface {...props} NativeView={NativeView} />
413 ) : (
414 <JavaScriptSourceFileSurface {...props} />
415 );
416 return surface;
417}