| 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { AsyncResult } from "effect/reactivity"; |
| 3 | import type { ComponentType } from "react"; |
| 4 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 5 | import { |
| 6 | FlatList, |
| 7 | Platform, |
| 8 | RefreshControl, |
| 9 | ScrollView, |
| 10 | Text as NativeText, |
| 11 | useWindowDimensions, |
| 12 | View, |
| 13 | } from "react-native"; |
| 14 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 15 | import { AppText as Text } from "../../components/AppText"; |
| 16 | import { LoadingStrip } from "../../components/LoadingStrip"; |
| 17 | import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; |
| 18 | import { |
| 19 | type NativeReviewDiffViewProps, |
| 20 | resolveNativeReviewDiffView, |
| 21 | } from "../diffs/nativeReviewDiffSurface"; |
| 22 | import { createNativeReviewDiffTheme } from "../review/nativeReviewDiffAdapter"; |
| 23 | import { REVIEW_MONO_FONT_FAMILY, renderVisibleWhitespace } from "../review/reviewDiffRendering"; |
| 24 | import type { ReviewHighlightedToken } from "../review/shikiReviewHighlighter"; |
| 25 | import { cn } from "../../lib/cn"; |
| 26 | import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences"; |
| 27 | import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; |
| 28 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 29 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 30 | import { |
| 31 | buildNativeSourceTokens, |
| 32 | NATIVE_SOURCE_CONTENT_WIDTH, |
| 33 | nativeSourceRowId, |
| 34 | } from "./nativeSourceFileAdapter"; |
| 35 | import { SourceText } from "@t3tools/mobile-source-text"; |
| 36 | |
| 37 | import { boundedSelectableSourceTokens, prepareSourceFileDocument } from "./source-file-document"; |
| 38 | import { sourceHighlightAtom } from "./sourceHighlightingState"; |
| 39 | |
| 40 | interface 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 | |
| 54 | type SourceHighlightStatus = "highlighting" | "ready" | "error"; |
| 55 | |
| 56 | const 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 | |
| 133 | function 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 | |
| 156 | function 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 | |
| 170 | function 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 | |
| 186 | function 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 | |
| 245 | function 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 | |
| 404 | export 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 | } |