import { settlePromise } from "@t3tools/client-runtime/state/runtime"; import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/reactivity"; import type { ComponentType } from "react"; import { useLayoutEffect, useRef, useState } from "react"; import { FlatList, Platform, RefreshControl, ScrollView, Text as NativeText, useWindowDimensions, View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/ui/AppText"; import { LoadingStrip } from "../../components/ui/LoadingStrip"; import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { type NativeReviewDiffViewProps, resolveNativeReviewDiffView, } from "../diffs/nativeReviewDiffSurface"; import { createNativeReviewDiffTheme } from "../review/nativeReviewDiffAdapter"; import { REVIEW_MONO_FONT_FAMILY, renderVisibleWhitespace } from "../review/reviewDiffRendering"; import type { HighlightedToken } from "../../lib/highlights"; import { cn } from "../../lib/cn"; import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences"; import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { buildNativeSourceTokens, NATIVE_SOURCE_CONTENT_WIDTH, nativeSourceRowId, } from "./nativeSourceFileAdapter"; import { SourceText } from "@t3tools/mobile-source-text"; import { boundedSelectableSourceTokens, prepareSourceFileDocument } from "./source-file-document"; import { sourceHighlightAtom } from "./sourceHighlightingState"; import { createSourceLineScroll } from "./source-file-scroll"; interface SourceFileSurfaceProps { readonly contents: string; readonly path: string; readonly embedded?: boolean; readonly initialLine?: number | null; readonly headerInsetTop?: number; readonly selectable?: boolean; readonly onRefresh?: () => Promise | void; } type SourceHighlightStatus = "highlighting" | "ready" | "error"; const HighlightedSourceLine = function HighlightedSourceLine(props: { readonly codeSurface: ResolvedMobileCodeSurface; readonly index: number; readonly line: string; readonly tokens: ReadonlyArray | null; readonly highlighted: boolean; readonly wordBreak: boolean; }) { return ( {props.index + 1} {props.tokens && props.tokens.length > 0 ? (() => { let offset = 0; return props.tokens.map((token) => { const start = offset; offset += token.content.length; const fontWeight = token.fontStyle !== null && (token.fontStyle & 2) === 2 ? ("700" as const) : ("400" as const); const fontStyle = token.fontStyle !== null && (token.fontStyle & 1) === 1 ? ("italic" as const) : ("normal" as const); return ( {token.content.length > 0 ? renderVisibleWhitespace(token.content) : " "} ); }); })() : renderVisibleWhitespace(props.line || " ")} ); }; function useSourceFileModel(props: SourceFileSurfaceProps) { const { themeAppearance: theme } = useAppearancePreferences(); const document = prepareSourceFileDocument(props.contents); const { contents: normalizedContents, lines, rowsJson } = document; const targetIndex = props.initialLine !== null && props.initialLine !== undefined && props.initialLine > 0 ? Math.min(Math.floor(props.initialLine) - 1, Math.max(0, lines.length - 1)) : null; const highlightAtom = sourceHighlightAtom({ path: props.path, contents: normalizedContents, theme, }); const highlightResult = useAtomValue(highlightAtom); const tokens = AsyncResult.isSuccess(highlightResult) ? highlightResult.value : null; const status: SourceHighlightStatus = AsyncResult.isFailure(highlightResult) ? "error" : AsyncResult.isSuccess(highlightResult) ? "ready" : "highlighting"; return { normalizedContents, lines, rowsJson, status, targetIndex, theme, tokens }; } function SourceHighlightStatusView(props: { readonly status: SourceHighlightStatus }) { if (props.status === "highlighting") { return ; } if (props.status === "error") { return ( Plain text ); } return null; } function useSourceFileRefresh(onRefresh: SourceFileSurfaceProps["onRefresh"]) { const [isPullRefreshing, setIsPullRefreshing] = useState(false); const handlePullToRefresh = async () => { if (!onRefresh) { return; } setIsPullRefreshing(true); // RefreshControl only needs the spinner cleared; the caller surfaces its own errors. await settlePromise(async () => onRefresh()); setIsPullRefreshing(false); }; return { isPullRefreshing, handlePullToRefresh }; } function NativeSourceFileSurface( props: SourceFileSurfaceProps & { readonly NativeView: ComponentType; }, ) { const { NativeView, onRefresh } = props; const insets = useSafeAreaInsets(); const columnMetrics = useNativeColumnLayoutMetrics(); const safeArea = columnMetrics?.safeArea ?? insets; const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface(); const { themeAppearance, themeId } = useAppearancePreferences(); const appTheme = useUniwindTheme(); const { width: viewportWidth } = useWindowDimensions(); const { rowsJson, status, targetIndex, tokens } = useSourceFileModel(props); const { isPullRefreshing, handlePullToRefresh } = useSourceFileRefresh(onRefresh); const tokensJson = JSON.stringify(buildNativeSourceTokens(tokens)); const selectedRowIdsJson = JSON.stringify( targetIndex === null ? [] : [nativeSourceRowId(targetIndex)], ); const themeJson = JSON.stringify(createNativeReviewDiffTheme(themeAppearance, themeId, appTheme)); const styleJson = JSON.stringify(nativeSourceStyle); const contentWidth = codeWordBreak ? Math.max(240, viewportWidth - codeSurface.gutterWidth - 24) : NATIVE_SOURCE_CONTENT_WIDTH; return ( void handlePullToRefresh(), } : {})} /> ); } function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { const usesAutomaticInsets = Platform.OS === "ios" && !props.embedded; const foreground = useUniwindTheme()["--color-foreground"]; const { codeSurface, codeWordBreak } = useAppearanceCodeSurface(); const { normalizedContents, lines, status, targetIndex, tokens } = useSourceFileModel(props); const selectableTokens = props.selectable ? boundedSelectableSourceTokens(tokens) : null; const listRef = useRef>(null); const [lineScroll] = useState(createSourceLineScroll); const { isPullRefreshing, handlePullToRefresh } = useSourceFileRefresh(props.onRefresh); const refreshControl = props.onRefresh ? ( void handlePullToRefresh()} /> ) : undefined; // 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) { 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]); const renderLine = ({ item, index }: { item: string; index: number }) => ( ); // One native text view permits document-wide selection; virtualized rows cannot. const selectableBlock = props.selectable ? ( {selectableTokens ? lines.map( ( line, index, ) => /* oxlint-disable react/no-array-index-key -- Source lines and highlight tokens are stateless positions within the loaded file. */ { const lineTokens = selectableTokens[index] ?? null; const body = lineTokens && lineTokens.length > 0 ? lineTokens.map((token, tokenIndex) => ( {token.content} )) : line; return ( {body} {index < lines.length - 1 ? "\n" : ""} ); } /* oxlint-enable react/no-array-index-key */, ) : normalizedContents} ) : null; const list = ( String(index)} initialNumToRender={80} maxToRenderPerBatch={80} windowSize={12} {...(codeWordBreak ? {} : { getItemLayout: (_data, index) => ({ length: codeSurface.rowHeight, offset: codeSurface.rowHeight * index, index, }), })} contentContainerStyle={{ minWidth: codeWordBreak ? undefined : "100%", paddingBottom: codeSurface.rowHeight, paddingTop: 8, }} renderItem={renderLine} onScrollToIndexFailed={lineScroll.onScrollToIndexFailed} onScrollBeginDrag={lineScroll.cancel} /> ); const usesLineList = !props.selectable; const padded = ( {selectableBlock} ); return ( {usesLineList ? ( codeWordBreak ? ( list ) : ( {list} ) ) : codeWordBreak ? ( padded ) : ( {padded} )} ); } export function SourceFileSurface(props: SourceFileSurfaceProps) { const NativeView = resolveNativeReviewDiffView(); const { codeWordBreak } = useAppearanceCodeSurface(); const surface = NativeView && !codeWordBreak && !props.selectable ? ( ) : ( ); return surface; }