import { useAtomValue } from "@effect/atom-react";import { AsyncResult } from "effect/unstable/reactivity";import type { ComponentType } from "react";import { memo, useCallback, useEffect, useMemo, useRef } from "react";import { FlatList, ScrollView, Text as NativeText, useColorScheme, View } from "react-native"; import { AppText as Text } from "../../components/AppText";import { LoadingStrip } from "../../components/LoadingStrip";import { type NativeReviewDiffViewProps, resolveNativeReviewDiffView,} from "../diffs/nativeReviewDiffSurface";import { createNativeReviewDiffTheme } from "../review/nativeReviewDiffAdapter";import { REVIEW_DIFF_LINE_HEIGHT, REVIEW_MONO_FONT_FAMILY, renderVisibleWhitespace,} from "../review/reviewDiffRendering";import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter";import { cn } from "../../lib/cn";import { MOBILE_CODE_SURFACE } from "../../lib/typography";import { buildNativeSourceRows, buildNativeSourceTokens, NATIVE_SOURCE_CONTENT_WIDTH, NATIVE_SOURCE_ROW_HEIGHT, NATIVE_SOURCE_STYLE, nativeSourceRowId,} from "./nativeSourceFileAdapter";import { sourceHighlightAtom } from "./sourceHighlightingState"; const SOURCE_LINE_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;const SOURCE_LINE_NUMBER_WIDTH = MOBILE_CODE_SURFACE.gutterWidth;const NATIVE_SOURCE_STYLE_JSON = JSON.stringify(NATIVE_SOURCE_STYLE); interface SourceFileSurfaceProps { readonly contents: string; readonly path: string; readonly initialLine?: number | null;} type SourceHighlightStatus = "highlighting" | "ready" | "error"; function splitSourceLines(contents: string): ReadonlyArray<string> { return contents.replace(/\r\n?/g, "\n").split("\n");} const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { readonly index: number; readonly line: string; readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null; readonly highlighted: boolean;}) { return ( <View className={cn("flex-row", props.highlighted && "bg-primary/10")} style={{ minHeight: SOURCE_LINE_HEIGHT }} > <NativeText className="select-none pr-3 text-right text-foreground-tertiary" style={{ width: SOURCE_LINE_NUMBER_WIDTH, fontFamily: REVIEW_MONO_FONT_FAMILY, fontSize: MOBILE_CODE_SURFACE.lineNumberFontSize, lineHeight: MOBILE_CODE_SURFACE.rowHeight, }} > {props.index + 1} </NativeText> <NativeText selectable numberOfLines={1} className="font-normal text-foreground" style={{ fontFamily: REVIEW_MONO_FONT_FAMILY, fontSize: MOBILE_CODE_SURFACE.fontSize, lineHeight: MOBILE_CODE_SURFACE.rowHeight, minWidth: 320, }} > {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 ( <NativeText key={`${start}:${token.content.length}:${token.color ?? ""}`} selectable style={{ color: token.color ?? undefined, fontFamily: REVIEW_MONO_FONT_FAMILY, fontWeight, fontStyle, }} > {token.content.length > 0 ? renderVisibleWhitespace(token.content) : " "} </NativeText> ); }); })() : renderVisibleWhitespace(props.line || " ")} </NativeText> </View> );}); function useSourceFileModel(props: SourceFileSurfaceProps) { const colorScheme = useColorScheme(); const theme: "dark" | "light" = colorScheme === "dark" ? "dark" : "light"; const normalizedContents = useMemo( () => props.contents.replace(/\r\n?/g, "\n"), [props.contents], ); const lines = useMemo(() => splitSourceLines(normalizedContents), [normalizedContents]); 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 = useMemo( () => sourceHighlightAtom({ path: props.path, contents: normalizedContents, theme }), [normalizedContents, props.path, 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 { lines, status, targetIndex, theme, tokens };} function SourceHighlightStatusView(props: { readonly status: SourceHighlightStatus }) { if (props.status === "highlighting") { return <LoadingStrip />; } if (props.status === "error") { return ( <View className="border-b border-border bg-card px-4 py-2"> <Text className="text-2xs font-t3-medium uppercase text-foreground-muted">Plain text</Text> </View> ); } return null;} function NativeSourceFileSurface( props: SourceFileSurfaceProps & { readonly NativeView: ComponentType<NativeReviewDiffViewProps>; },) { const { NativeView } = props; const { lines, status, targetIndex, theme, tokens } = useSourceFileModel(props); const rowsJson = useMemo(() => JSON.stringify(buildNativeSourceRows(lines)), [lines]); const tokensJson = useMemo(() => JSON.stringify(buildNativeSourceTokens(tokens)), [tokens]); const selectedRowIdsJson = useMemo( () => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]), [targetIndex], ); const themeJson = useMemo(() => JSON.stringify(createNativeReviewDiffTheme(theme)), [theme]); return ( <View className="relative flex-1 bg-card"> <SourceHighlightStatusView status={status} /> <NativeView key={props.path} collapsable={false} testID="source-native-code-view" style={{ flex: 1 }} appearanceScheme={theme} contentWidth={NATIVE_SOURCE_CONTENT_WIDTH} initialRowIndex={targetIndex ?? -1} rowHeight={NATIVE_SOURCE_ROW_HEIGHT} rowsJson={rowsJson} selectedRowIdsJson={selectedRowIdsJson} styleJson={NATIVE_SOURCE_STYLE_JSON} themeJson={themeJson} tokensJson={tokensJson} /> </View> );} function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { const { lines, status, targetIndex, tokens } = useSourceFileModel(props); const listRef = useRef<FlatList<string>>(null); useEffect(() => { if (targetIndex === null) { return; } const frame = requestAnimationFrame(() => { listRef.current?.scrollToIndex({ index: targetIndex, animated: false, viewPosition: 0.3 }); }); return () => cancelAnimationFrame(frame); }, [props.path, targetIndex]); const renderLine = useCallback( ({ item, index }: { item: string; index: number }) => ( <HighlightedSourceLine index={index} line={item} tokens={tokens?.[index] ?? null} highlighted={index === targetIndex} /> ), [targetIndex, tokens], ); return ( <View className="relative flex-1 bg-card"> <SourceHighlightStatusView status={status} /> <ScrollView horizontal bounces={false} className="flex-1"> <FlatList ref={listRef} data={lines} keyExtractor={(_line, index) => String(index)} initialNumToRender={80} maxToRenderPerBatch={80} windowSize={12} getItemLayout={(_data, index) => ({ length: SOURCE_LINE_HEIGHT, offset: SOURCE_LINE_HEIGHT * index, index, })} contentContainerStyle={{ minWidth: "100%", paddingBottom: REVIEW_DIFF_LINE_HEIGHT, paddingTop: 8, }} renderItem={renderLine} /> </ScrollView> </View> );} export function SourceFileSurface(props: SourceFileSurfaceProps) { const NativeView = resolveNativeReviewDiffView(); return NativeView ? ( <NativeSourceFileSurface {...props} NativeView={NativeView} /> ) : ( <JavaScriptSourceFileSurface {...props} /> );}
import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
import type { ComponentType } from "react";
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
import { FlatList, ScrollView, Text as NativeText, useColorScheme, View } from "react-native";
import { AppText as Text } from "../../components/AppText";
import { LoadingStrip } from "../../components/LoadingStrip";
import {
type NativeReviewDiffViewProps,
resolveNativeReviewDiffView,
} from "../diffs/nativeReviewDiffSurface";
import { createNativeReviewDiffTheme } from "../review/nativeReviewDiffAdapter";
import {
REVIEW_DIFF_LINE_HEIGHT,
REVIEW_MONO_FONT_FAMILY,
renderVisibleWhitespace,
} from "../review/reviewDiffRendering";
import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter";
import { cn } from "../../lib/cn";
import { MOBILE_CODE_SURFACE } from "../../lib/typography";
import {
buildNativeSourceRows,
buildNativeSourceTokens,
NATIVE_SOURCE_CONTENT_WIDTH,
NATIVE_SOURCE_ROW_HEIGHT,
NATIVE_SOURCE_STYLE,
nativeSourceRowId,
} from "./nativeSourceFileAdapter";
import { sourceHighlightAtom } from "./sourceHighlightingState";
const SOURCE_LINE_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;
const SOURCE_LINE_NUMBER_WIDTH = MOBILE_CODE_SURFACE.gutterWidth;
const NATIVE_SOURCE_STYLE_JSON = JSON.stringify(NATIVE_SOURCE_STYLE);
interface SourceFileSurfaceProps {
readonly contents: string;
readonly path: string;
readonly initialLine?: number | null;
}
type SourceHighlightStatus = "highlighting" | "ready" | "error";
function splitSourceLines(contents: string): ReadonlyArray<string> {
return contents.replace(/\r\n?/g, "\n").split("\n");
}
const HighlightedSourceLine = memo(function HighlightedSourceLine(props: {
readonly index: number;
readonly line: string;
readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null;
readonly highlighted: boolean;
}) {
return (
<View
className={cn("flex-row", props.highlighted && "bg-primary/10")}
style={{ minHeight: SOURCE_LINE_HEIGHT }}
>
<NativeText
className="select-none pr-3 text-right text-foreground-tertiary"
style={{
width: SOURCE_LINE_NUMBER_WIDTH,
fontFamily: REVIEW_MONO_FONT_FAMILY,
fontSize: MOBILE_CODE_SURFACE.lineNumberFontSize,
lineHeight: MOBILE_CODE_SURFACE.rowHeight,
}}
>
{props.index + 1}
</NativeText>
<NativeText
selectable
numberOfLines={1}
className="font-normal text-foreground"
style={{
fontFamily: REVIEW_MONO_FONT_FAMILY,
fontSize: MOBILE_CODE_SURFACE.fontSize,
lineHeight: MOBILE_CODE_SURFACE.rowHeight,
minWidth: 320,
}}
>
{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 (
<NativeText
key={`${start}:${token.content.length}:${token.color ?? ""}`}
selectable
style={{
color: token.color ?? undefined,
fontFamily: REVIEW_MONO_FONT_FAMILY,
fontWeight,
fontStyle,
}}
>
{token.content.length > 0 ? renderVisibleWhitespace(token.content) : " "}
</NativeText>
);
});
})()
: renderVisibleWhitespace(props.line || " ")}
</NativeText>
</View>
);
});
function useSourceFileModel(props: SourceFileSurfaceProps) {
const colorScheme = useColorScheme();
const theme: "dark" | "light" = colorScheme === "dark" ? "dark" : "light";
const normalizedContents = useMemo(
() => props.contents.replace(/\r\n?/g, "\n"),
[props.contents],
);
const lines = useMemo(() => splitSourceLines(normalizedContents), [normalizedContents]);
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 = useMemo(
() => sourceHighlightAtom({ path: props.path, contents: normalizedContents, theme }),
[normalizedContents, props.path, 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 { lines, status, targetIndex, theme, tokens };
}
function SourceHighlightStatusView(props: { readonly status: SourceHighlightStatus }) {
if (props.status === "highlighting") {
return <LoadingStrip />;
}
if (props.status === "error") {
return (
<View className="border-b border-border bg-card px-4 py-2">
<Text className="text-2xs font-t3-medium uppercase text-foreground-muted">Plain text</Text>
</View>
);
}
return null;
}
function NativeSourceFileSurface(
props: SourceFileSurfaceProps & {
readonly NativeView: ComponentType<NativeReviewDiffViewProps>;
},
) {
const { NativeView } = props;
const { lines, status, targetIndex, theme, tokens } = useSourceFileModel(props);
const rowsJson = useMemo(() => JSON.stringify(buildNativeSourceRows(lines)), [lines]);
const tokensJson = useMemo(() => JSON.stringify(buildNativeSourceTokens(tokens)), [tokens]);
const selectedRowIdsJson = useMemo(
() => JSON.stringify(targetIndex === null ? [] : [nativeSourceRowId(targetIndex)]),
[targetIndex],
);
const themeJson = useMemo(() => JSON.stringify(createNativeReviewDiffTheme(theme)), [theme]);
return (
<View className="relative flex-1 bg-card">
<SourceHighlightStatusView status={status} />
<NativeView
key={props.path}
collapsable={false}
testID="source-native-code-view"
style={{ flex: 1 }}
appearanceScheme={theme}
contentWidth={NATIVE_SOURCE_CONTENT_WIDTH}
initialRowIndex={targetIndex ?? -1}
rowHeight={NATIVE_SOURCE_ROW_HEIGHT}
rowsJson={rowsJson}
selectedRowIdsJson={selectedRowIdsJson}
styleJson={NATIVE_SOURCE_STYLE_JSON}
themeJson={themeJson}
tokensJson={tokensJson}
/>
</View>
);
}
function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
const { lines, status, targetIndex, tokens } = useSourceFileModel(props);
const listRef = useRef<FlatList<string>>(null);
useEffect(() => {
if (targetIndex === null) {
return;
}
const frame = requestAnimationFrame(() => {
listRef.current?.scrollToIndex({ index: targetIndex, animated: false, viewPosition: 0.3 });
});
return () => cancelAnimationFrame(frame);
}, [props.path, targetIndex]);
const renderLine = useCallback(
({ item, index }: { item: string; index: number }) => (
<HighlightedSourceLine
index={index}
line={item}
tokens={tokens?.[index] ?? null}
highlighted={index === targetIndex}
/>
),
[targetIndex, tokens],
);
return (
<View className="relative flex-1 bg-card">
<SourceHighlightStatusView status={status} />
<ScrollView horizontal bounces={false} className="flex-1">
<FlatList
ref={listRef}
data={lines}
keyExtractor={(_line, index) => String(index)}
initialNumToRender={80}
maxToRenderPerBatch={80}
windowSize={12}
getItemLayout={(_data, index) => ({
length: SOURCE_LINE_HEIGHT,
offset: SOURCE_LINE_HEIGHT * index,
index,
})}
contentContainerStyle={{
minWidth: "100%",
paddingBottom: REVIEW_DIFF_LINE_HEIGHT,
paddingTop: 8,
}}
renderItem={renderLine}
/>
</ScrollView>
</View>
);
}
export function SourceFileSurface(props: SourceFileSurfaceProps) {
const NativeView = resolveNativeReviewDiffView();
return NativeView ? (
<NativeSourceFileSurface {...props} NativeView={NativeView} />
) : (
<JavaScriptSourceFileSurface {...props} />
);
}