Skip to content
Source
Sign inSign up

SourceFileSurface.tsx

466 lines17.5 KB

1import { LegendList, type LegendListRef } from "@legendapp/list/react-native";
2import { settlePromise } from "@t3tools/client-runtime/state/runtime";
3import { useAtomValue } from "@effect/atom-react";
4import { AsyncResult } from "effect/reactivity";
5import type { ComponentType } from "react";
6import { useLayoutEffect, useRef, useState } from "react";
7import {
8 Platform,
9 Pressable,
10 ScrollView,
11 Text as NativeText,
12 useWindowDimensions,
13 View,
14} from "react-native";
15import { NativeScrollView } from "../../native/NativeScrollViewMarker";
16import { RefreshControl } from "../../components/ui/RefreshControl";
17import { WebView } from "react-native-webview";
18import { useSafeAreaInsets } from "react-native-safe-area-context";
19import { AppText as Text } from "../../components/ui/AppText";
20import { LoadingStrip } from "../../components/ui/LoadingStrip";
21import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics";
22import {
23 type NativeReviewDiffViewProps,
24 resolveNativeReviewDiffView,
25} from "../diffs/nativeReviewDiffSurface";
26import { createNativeReviewDiffTheme } from "../review/nativeReviewDiffAdapter";
27import { REVIEW_MONO_FONT_FAMILY, renderVisibleWhitespace } from "../review/reviewDiffRendering";
28import type { HighlightedToken } from "../../lib/highlights";
29import { cn } from "../../lib/cn";
30import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences";
31import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
32import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
33import { useUniwindTheme } from "../../lib/useUniwindTheme";
34import {
35 buildNativeSourceTokens,
36 NATIVE_SOURCE_CONTENT_WIDTH,
37 nativeSourceRowId,
38} from "./nativeSourceFileAdapter";
39import { SourceText } from "@t3tools/mobile-source-text";
40​
41import {
42 boundedSelectableSourceTokens,
43 prepareSourceFileDocument,
44 selectableSourceHtml,
45} from "./source-file-document";
46import { sourceHighlightAtom } from "./sourceHighlightingState";
47​
48interface SourceFileSurfaceProps {
49 readonly contents: string;
50 readonly path: string;
51 readonly embedded?: boolean;
52 readonly initialLine?: number | null;
53 readonly headerInsetTop?: number;
54 readonly selectable?: boolean;
55 readonly onLongPressLine?: (line: number) => void;
56 readonly onRefresh?: () => Promise<void> | void;
58​
59type SourceHighlightStatus = "highlighting" | "ready" | "error";
60​
61const HighlightedSourceLine = function HighlightedSourceLine(props: {
62 readonly codeSurface: ResolvedMobileCodeSurface;
63 readonly index: number;
64 readonly line: string;
65 readonly tokens: ReadonlyArray<HighlightedToken> | null;
66 readonly highlighted: boolean;
67 readonly wordBreak: boolean;
68 readonly onLongPress?: () => void;
69}) {
70 const selectable = props.onLongPress === undefined;
71 const row = (
72 <View
73 className={cn("flex-row", props.highlighted && "bg-primary/10")}
74 style={{ minHeight: props.codeSurface.rowHeight }}
75 >
76 <NativeText
77 className="select-none pr-3 text-right text-foreground-tertiary"
78 style={{
79 width: props.codeSurface.gutterWidth,
80 fontFamily: REVIEW_MONO_FONT_FAMILY,
81 fontSize: props.codeSurface.lineNumberFontSize,
82 lineHeight: props.codeSurface.rowHeight,
83 }}
84 >
85 {props.index + 1}
86 </NativeText>
87 <NativeText
88 selectable={selectable}
89 selectionColorClassName={Platform.OS === "android" ? "accent-focus/32" : undefined}
90 numberOfLines={props.wordBreak ? undefined : 1}
91 className="flex-1 font-normal text-foreground"
92 style={{
93 fontFamily: REVIEW_MONO_FONT_FAMILY,
94 fontSize: props.codeSurface.fontSize,
95 lineHeight: props.codeSurface.rowHeight,
96 minWidth: props.wordBreak ? undefined : 320,
97 }}
98 >
99 {props.tokens && props.tokens.length > 0
100 ? (() => {
101 let offset = 0;
102 return props.tokens.map((token) => {
103 const start = offset;
104 offset += token.content.length;
105​
106 const fontWeight =
107 token.fontStyle !== null && (token.fontStyle & 2) === 2
108 ? ("700" as const)
109 : ("400" as const);
110 const fontStyle =
111 token.fontStyle !== null && (token.fontStyle & 1) === 1
112 ? ("italic" as const)
113 : ("normal" as const);
114​
115 return (
116 <NativeText
117 key={`${start}:${token.content.length}:${token.color ?? ""}`}
118 selectable={selectable}
119 selectionColorClassName={
120 Platform.OS === "android" ? "accent-focus/32" : undefined
121 }
122 style={{
123 color: token.color ?? undefined,
124 fontFamily: REVIEW_MONO_FONT_FAMILY,
125 fontWeight,
126 fontStyle,
127 }}
128 >
129 {token.content.length > 0 ? renderVisibleWhitespace(token.content) : " "}
130 </NativeText>
131 );
132 });
133 })()
134 : renderVisibleWhitespace(props.line || " ")}
135 </NativeText>
136 </View>
137 );
138 const onLongPress = props.onLongPress;
139 return onLongPress ? (
140 <Pressable
141 onLongPress={onLongPress}
142 accessibilityActions={[{ name: "longpress", label: "Select text" }]}
143 onAccessibilityAction={(event) => {
144 if (event.nativeEvent.actionName === "longpress") onLongPress();
145 }}
146 >
147 {row}
148 </Pressable>
149 ) : (
150 row
151 );
152};
153​
154function useSourceFileModel(props: SourceFileSurfaceProps) {
155 const { themeAppearance: theme } = useAppearancePreferences();
156 const document = prepareSourceFileDocument(props.contents);
157 const { contents: normalizedContents, lines, rowsJson } = document;
158 const targetIndex =
159 props.initialLine !== null && props.initialLine !== undefined && props.initialLine > 0
160 ? Math.min(Math.floor(props.initialLine) - 1, Math.max(0, lines.length - 1))
161 : null;
162 const highlightAtom = sourceHighlightAtom({
163 path: props.path,
164 contents: normalizedContents,
165 theme,
166 });
167 const highlightResult = useAtomValue(highlightAtom);
168 const tokens = AsyncResult.isSuccess(highlightResult) ? highlightResult.value : null;
169 const status: SourceHighlightStatus = AsyncResult.isFailure(highlightResult)
170 ? "error"
171 : AsyncResult.isSuccess(highlightResult)
172 ? "ready"
173 : "highlighting";
174​
175 return { normalizedContents, lines, rowsJson, status, targetIndex, theme, tokens };
177​
178function SourceHighlightStatusView(props: { readonly status: SourceHighlightStatus }) {
179 if (props.status === "highlighting") {
180 return <LoadingStrip />;
181 }
182 if (props.status === "error") {
183 return (
184 <View className="border-b border-border bg-card px-4 py-2">
185 <Text className="text-2xs font-t3-medium uppercase text-foreground-muted">Plain text</Text>
186 </View>
187 );
188 }
189 return null;
191​
192function useSourceFileRefresh(onRefresh: SourceFileSurfaceProps["onRefresh"]) {
193 const [isPullRefreshing, setIsPullRefreshing] = useState(false);
194 const handlePullToRefresh = async () => {
195 if (!onRefresh) {
196 return;
197 }
198 setIsPullRefreshing(true);
199 // RefreshControl only needs the spinner cleared; the caller surfaces its own errors.
200 await settlePromise(async () => onRefresh());
201 setIsPullRefreshing(false);
202 };
203 return { isPullRefreshing, handlePullToRefresh };
205​
206function NativeSourceFileSurface(
207 props: SourceFileSurfaceProps & {
208 readonly NativeView: ComponentType<NativeReviewDiffViewProps>;
209 },
210) {
211 const { NativeView, onLongPressLine, onRefresh } = props;
212 const insets = useSafeAreaInsets();
213 const columnMetrics = useNativeColumnLayoutMetrics();
214 const safeArea = columnMetrics?.safeArea ?? insets;
215 const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface();
216 const { themeAppearance, themeId } = useAppearancePreferences();
217 const appTheme = useUniwindTheme();
218 const { width: viewportWidth } = useWindowDimensions();
219 const { rowsJson, status, targetIndex, tokens } = useSourceFileModel(props);
220 const { isPullRefreshing, handlePullToRefresh } = useSourceFileRefresh(onRefresh);
221 const tokensJson = JSON.stringify(buildNativeSourceTokens(tokens));
222 const selectedRowIdsJson = JSON.stringify(
223 targetIndex === null ? [] : [nativeSourceRowId(targetIndex)],
224 );
225 const themeJson = JSON.stringify(createNativeReviewDiffTheme(themeAppearance, themeId, appTheme));
226 const styleJson = JSON.stringify(nativeSourceStyle);
227 const contentWidth = codeWordBreak
228 ? Math.max(240, viewportWidth - codeSurface.gutterWidth - 24)
229 : NATIVE_SOURCE_CONTENT_WIDTH;
230​
231 return (
232 <View className="relative flex-1 bg-sheet">
233 <SourceHighlightStatusView status={status} />
234 <NativeView
235 collapsable={false}
236 testID="source-native-code-view"
237 style={{ flex: 1 }}
238 appearanceScheme={themeAppearance}
239 contentResetKey={props.path}
240 contentWidth={contentWidth}
241 contentInsetTop={props.embedded ? 0 : (props.headerInsetTop ?? safeArea.top)}
242 contentInsetBottom={props.embedded ? 0 : safeArea.bottom + 8}
243 initialRowIndex={targetIndex ?? -1}
244 rowHeight={nativeSourceStyle.rowHeight ?? codeSurface.rowHeight}
245 rowsJson={rowsJson}
246 selectedRowIdsJson={selectedRowIdsJson}
247 styleJson={styleJson}
248 themeJson={themeJson}
249 tokensJson={tokensJson}
250 onPressLine={({ nativeEvent }) => {
251 if (nativeEvent.gesture === "longPress" && nativeEvent.newLineNumber !== undefined) {
252 onLongPressLine?.(nativeEvent.newLineNumber);
253 }
254 }}
255 {...(onRefresh
256 ? {
257 refreshing: isPullRefreshing,
258 onPullToRefresh: () => void handlePullToRefresh(),
259 }
260 : {})}
261 />
262 </View>
263 );
265​
266function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
267 const usesAutomaticInsets = Platform.OS === "ios" && !props.embedded;
268 const appTheme = useUniwindTheme();
269 const foreground = appTheme["--color-foreground"];
270 const { codeSurface, codeWordBreak } = useAppearanceCodeSurface();
271 const { normalizedContents, lines, status, targetIndex, tokens } = useSourceFileModel(props);
272 const selectableTokens = props.selectable ? boundedSelectableSourceTokens(tokens) : null;
273 const listRef = useRef<LegendListRef>(null);
274 const { isPullRefreshing, handlePullToRefresh } = useSourceFileRefresh(props.onRefresh);
275 const refreshControl = props.onRefresh ? (
276 <RefreshControl refreshing={isPullRefreshing} onRefresh={() => void handlePullToRefresh()} />
277 ) : undefined;
278​
279 const usesLineList = !props.selectable;
280 // Android lays out a full selectable TextView on the UI thread, which can stall large files.
281 const usesWebSelection = props.selectable === true && Platform.OS === "android";
282 const usesTextSelection = props.selectable === true && !usesWebSelection;
283​
284 // Layout effect so a pending jump is cancelled in the commit that swaps the file.
285 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scrolls the list to the target line and cancels a pending jump when the file changes
286 useLayoutEffect(() => {
287 if (targetIndex === null || !usesLineList) {
288 return;
289 }
290 const frame = requestAnimationFrame(() => {
291 void listRef.current?.scrollToIndex({
292 index: targetIndex,
293 animated: false,
294 viewPosition: 0.3,
295 });
296 });
297 return () => cancelAnimationFrame(frame);
298 // 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.
299 }, [props.path, targetIndex, usesLineList]);
300​
301 const { onLongPressLine } = props;
302 const renderLine = ({ item, index }: { item: string; index: number }) => (
303 <HighlightedSourceLine
304 codeSurface={codeSurface}
305 index={index}
306 line={item}
307 tokens={tokens?.[index] ?? null}
308 highlighted={index === targetIndex}
309 wordBreak={codeWordBreak}
310 onLongPress={onLongPressLine ? () => onLongPressLine(index + 1) : undefined}
311 />
312 );
313​
314 // One native text view permits document-wide selection; virtualized rows cannot.
315 const selectableBlock = usesTextSelection ? (
316 <SourceText
317 selectable
318 style={{
319 color: foreground,
320 fontFamily: REVIEW_MONO_FONT_FAMILY,
321 fontSize: codeSurface.fontSize,
322 lineHeight: codeSurface.rowHeight,
323 }}
324 >
325 {selectableTokens
326 ? lines.map(
327 (
328 line,
329 index,
330 ) => /* oxlint-disable react/no-array-index-key -- Source lines and highlight tokens are stateless positions within the loaded file. */ {
331 const lineTokens = selectableTokens[index] ?? null;
332 const body =
333 lineTokens && lineTokens.length > 0
334 ? lineTokens.map((token, tokenIndex) => (
335 <SourceText
336 key={`${index}:${tokenIndex}`}
337 style={{
338 color: token.color ?? foreground,
339 fontWeight:
340 token.fontStyle !== null && (token.fontStyle & 2) === 2
341 ? "700"
342 : "400",
343 fontStyle:
344 token.fontStyle !== null && (token.fontStyle & 1) === 1
345 ? "italic"
346 : "normal",
347 }}
348 >
349 {token.content}
350 </SourceText>
351 ))
352 : line;
353 return (
354 <SourceText key={index}>
355 {body}
356 {index < lines.length - 1 ? "\n" : ""}
357 </SourceText>
358 );
359 } /* oxlint-enable react/no-array-index-key */,
360 )
361 : normalizedContents}
362 </SourceText>
363 ) : null;
364​
365 const webSelection = usesWebSelection ? (
366 <WebView
367 source={{
368 html: selectableSourceHtml(normalizedContents, {
369 foreground,
370 selection: appTheme["--color-focus"],
371 fontSize: codeSurface.fontSize,
372 rowHeight: codeSurface.rowHeight,
373 wordBreak: codeWordBreak,
374 }),
375 }}
376 originWhitelist={["*"]}
377 // Android pastes copied HTML as styled text with collapsed whitespace, so copy plain text.
378 // Wrapped text reflows once the WebView reaches its final width, so scroll again then.
379 injectedJavaScript={`addEventListener("copy",(event)=>{event.clipboardData.setData("text/plain",getSelection().toString());event.preventDefault();});${
380 targetIndex === null
381 ? ""
382 : `const line=document.getElementById("L${targetIndex + 1}");const reveal=()=>line?.scrollIntoView({block:"center"});reveal();addEventListener("resize",reveal,{once:true});`
383 }true;`}
384 domStorageEnabled={false}
385 cacheEnabled={false}
386 setSupportMultipleWindows={false}
387 onShouldStartLoadWithRequest={(request) => request.url === "about:blank"}
388 style={{ flex: 1, backgroundColor: "transparent" }}
389 />
390 ) : null;
391​
392 const list = (
393 <LegendList
394 ref={listRef}
395 // An embedded viewer sits inside another screen's scroll surface.
396 renderScrollComponent={props.embedded ? undefined : NativeScrollView}
397 contentInsetAdjustmentBehavior={usesAutomaticInsets ? "automatic" : "never"}
398 automaticallyAdjustsScrollIndicatorInsets={usesAutomaticInsets}
399 refreshControl={refreshControl}
400 data={lines}
401 keyExtractor={(_line, index) => String(index)}
402 extraData={{ tokens, targetIndex, codeSurface, codeWordBreak, onLongPressLine }}
403 estimatedItemSize={codeSurface.rowHeight}
404 getFixedItemSize={codeWordBreak ? undefined : () => codeSurface.rowHeight}
405 drawDistance={codeSurface.rowHeight * 40}
406 recycleItems
407 contentContainerStyle={{
408 minWidth: codeWordBreak ? undefined : "100%",
409 paddingBottom: codeSurface.rowHeight,
410 paddingTop: 8,
411 }}
412 renderItem={renderLine}
413 />
414 );
415​
416 const padded = (
417 <ScrollView
418 refreshControl={refreshControl}
419 className="flex-1"
420 contentInsetAdjustmentBehavior={usesAutomaticInsets ? "automatic" : "never"}
421 automaticallyAdjustsScrollIndicatorInsets={usesAutomaticInsets}
422 contentContainerStyle={{
423 paddingBottom: codeSurface.rowHeight,
424 paddingHorizontal: 12,
425 paddingTop: 8,
426 }}
427 >
428 {selectableBlock}
429 </ScrollView>
430 );
431​
432 return (
433 <View className="relative flex-1 bg-sheet">
434 {usesWebSelection ? null : <SourceHighlightStatusView status={status} />}
435 {usesLineList ? (
436 codeWordBreak ? (
437 list
438 ) : (
439 <ScrollView horizontal bounces={false} className="flex-1">
440 {list}
441 </ScrollView>
442 )
443 ) : usesWebSelection ? (
444 webSelection
445 ) : codeWordBreak ? (
446 padded
447 ) : (
448 <ScrollView horizontal bounces={false} className="flex-1">
449 {padded}
450 </ScrollView>
451 )}
452 </View>
453 );
455​
456export function SourceFileSurface(props: SourceFileSurfaceProps) {
457 const NativeView = resolveNativeReviewDiffView();
458 const { codeWordBreak } = useAppearanceCodeSurface();
459 const surface =
460 NativeView && !codeWordBreak && !props.selectable ? (
461 <NativeSourceFileSurface {...props} NativeView={NativeView} />
462 ) : (
463 <JavaScriptSourceFileSurface {...props} />
464 );
465 return surface;