| 1 | import { |
| 2 | createElement, |
| 3 | useEffect, |
| 4 | useImperativeHandle, |
| 5 | useRef, |
| 6 | type ComponentType, |
| 7 | type Ref, |
| 8 | } from "react"; |
| 9 | import type { NativeSyntheticEvent, ViewProps } from "react-native"; |
| 10 | import { requireNativeView } from "expo"; |
| 11 | |
| 12 | import { NativeViewResolutionError } from "../../native/nativeViewResolutionError"; |
| 13 | |
| 14 | const NATIVE_REVIEW_DIFF_MODULE_NAME = "T3ReviewDiffSurface"; |
| 15 | const NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES = 60; |
| 16 | |
| 17 | interface ExpoGlobalWithViewConfig { |
| 18 | readonly expo?: { |
| 19 | getViewConfig?: (moduleName: string, viewName?: string) => unknown; |
| 20 | }; |
| 21 | } |
| 22 | |
| 23 | export interface NativeReviewDiffRow { |
| 24 | readonly kind: "file" | "hunk" | "line" | "notice" | "comment"; |
| 25 | readonly id: string; |
| 26 | readonly fileId?: string; |
| 27 | readonly filePath?: string; |
| 28 | readonly previousPath?: string | null; |
| 29 | readonly changeType?: |
| 30 | | "modified" |
| 31 | | "new" |
| 32 | | "deleted" |
| 33 | | "renamed" |
| 34 | | "rename-pure" |
| 35 | | "rename-changed"; |
| 36 | readonly additions?: number; |
| 37 | readonly deletions?: number; |
| 38 | readonly text?: string; |
| 39 | readonly content?: string; |
| 40 | readonly change?: "context" | "add" | "delete"; |
| 41 | readonly oldLineNumber?: number | null; |
| 42 | readonly newLineNumber?: number | null; |
| 43 | readonly wordDiffRanges?: ReadonlyArray<NativeReviewDiffWordDiffRange>; |
| 44 | readonly commentText?: string; |
| 45 | readonly commentRangeLabel?: string; |
| 46 | readonly commentSectionTitle?: string; |
| 47 | } |
| 48 | |
| 49 | export interface NativeReviewDiffWordDiffRange { |
| 50 | readonly start: number; |
| 51 | readonly end: number; |
| 52 | } |
| 53 | |
| 54 | export interface NativeReviewDiffToken { |
| 55 | readonly content: string; |
| 56 | readonly color: string | null; |
| 57 | readonly fontStyle: number | null; |
| 58 | } |
| 59 | |
| 60 | export interface NativeReviewDiffTheme { |
| 61 | readonly background: string; |
| 62 | readonly text: string; |
| 63 | readonly mutedText: string; |
| 64 | readonly headerBackground: string; |
| 65 | readonly border: string; |
| 66 | readonly hunkBackground: string; |
| 67 | readonly hunkText: string; |
| 68 | readonly addBackground: string; |
| 69 | readonly deleteBackground: string; |
| 70 | readonly addBar: string; |
| 71 | readonly deleteBar: string; |
| 72 | readonly addText: string; |
| 73 | readonly deleteText: string; |
| 74 | } |
| 75 | |
| 76 | export interface NativeReviewDiffStyle { |
| 77 | readonly rowHeight?: number; |
| 78 | readonly contentWidth?: number; |
| 79 | readonly changeBarWidth?: number; |
| 80 | readonly gutterWidth?: number; |
| 81 | readonly codePadding?: number; |
| 82 | readonly textVerticalInset?: number; |
| 83 | readonly fileHeaderHeight?: number; |
| 84 | readonly fileHeaderHorizontalMargin?: number; |
| 85 | readonly fileHeaderVerticalMargin?: number; |
| 86 | readonly fileHeaderCornerRadius?: number; |
| 87 | readonly fileHeaderHorizontalPadding?: number; |
| 88 | readonly fileHeaderPathRightPadding?: number; |
| 89 | readonly fileHeaderCountColumnWidth?: number; |
| 90 | readonly fileHeaderCountGap?: number; |
| 91 | readonly codeFontSize?: number; |
| 92 | readonly codeFontWeight?: string; |
| 93 | readonly lineNumberFontSize?: number; |
| 94 | readonly lineNumberFontWeight?: string; |
| 95 | readonly hunkFontSize?: number; |
| 96 | readonly hunkFontWeight?: string; |
| 97 | readonly fileHeaderFontSize?: number; |
| 98 | readonly fileHeaderFontWeight?: string; |
| 99 | readonly fileHeaderMetaFontSize?: number; |
| 100 | readonly fileHeaderMetaFontWeight?: string; |
| 101 | readonly fileHeaderSubtextFontSize?: number; |
| 102 | readonly fileHeaderSubtextFontWeight?: string; |
| 103 | readonly fileHeaderStatusFontSize?: number; |
| 104 | readonly fileHeaderStatusFontWeight?: string; |
| 105 | readonly emptyStateFontSize?: number; |
| 106 | readonly emptyStateFontWeight?: string; |
| 107 | } |
| 108 | |
| 109 | export interface NativeReviewDiffViewProps extends ViewProps { |
| 110 | readonly contentInsetTop?: number; |
| 111 | readonly contentInsetBottom?: number; |
| 112 | readonly rowsJson: string; |
| 113 | readonly tokensJson?: string; |
| 114 | readonly tokensPatchJson?: string; |
| 115 | readonly tokensResetKey?: string; |
| 116 | readonly contentResetKey?: string; |
| 117 | readonly collapsedFileIdsJson?: string; |
| 118 | readonly viewedFileIdsJson?: string; |
| 119 | readonly selectedRowIdsJson?: string; |
| 120 | readonly collapsedCommentIdsJson?: string; |
| 121 | readonly appearanceScheme: "light" | "dark"; |
| 122 | readonly themeJson: string; |
| 123 | readonly styleJson?: string; |
| 124 | readonly rowHeight: number; |
| 125 | readonly contentWidth: number; |
| 126 | readonly initialRowIndex?: number; |
| 127 | /** Disable the native vertical scroller when an excerpt grows inside a page. */ |
| 128 | readonly scrollEnabled?: boolean; |
| 129 | readonly bounces?: boolean; |
| 130 | readonly refreshing?: boolean; |
| 131 | readonly nativeViewRef?: Ref<NativeReviewDiffViewHandle>; |
| 132 | readonly onPullToRefresh?: (event: NativeSyntheticEvent<Record<string, never>>) => void; |
| 133 | readonly onDebug?: (event: NativeSyntheticEvent<Record<string, unknown>>) => void; |
| 134 | readonly onVisibleFileChange?: ( |
| 135 | event: NativeSyntheticEvent<{ readonly fileId?: string | null }>, |
| 136 | ) => void; |
| 137 | readonly onToggleFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void; |
| 138 | readonly onToggleViewedFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void; |
| 139 | readonly onPressLine?: ( |
| 140 | event: NativeSyntheticEvent<{ |
| 141 | readonly rowId?: string; |
| 142 | readonly fileId?: string; |
| 143 | readonly gesture?: "tap" | "longPress"; |
| 144 | readonly oldLineNumber?: number; |
| 145 | readonly newLineNumber?: number; |
| 146 | readonly change?: "context" | "add" | "delete"; |
| 147 | }>, |
| 148 | ) => void; |
| 149 | readonly onToggleComment?: (event: NativeSyntheticEvent<{ readonly commentId?: string }>) => void; |
| 150 | } |
| 151 | |
| 152 | export interface NativeReviewDiffViewHandle { |
| 153 | readonly scrollToFile: (fileId: string, animated?: boolean) => Promise<void>; |
| 154 | readonly scrollToTop: (animated?: boolean) => Promise<void>; |
| 155 | } |
| 156 | |
| 157 | export function isNativeReviewDiffDrawEvent(payload: Readonly<Record<string, unknown>>): boolean { |
| 158 | return payload.message === "draw-metrics" || payload.message === "visible-range"; |
| 159 | } |
| 160 | |
| 161 | interface NativeReviewDiffViewRef { |
| 162 | readonly setRowsJson: (rowsJson: string) => Promise<void>; |
| 163 | readonly setTokensJson: (tokensJson: string) => Promise<void>; |
| 164 | readonly setTokensPatchJson: (tokensPatchJson: string) => Promise<void>; |
| 165 | readonly scrollToFile: (fileId: string, animated: boolean) => Promise<void>; |
| 166 | readonly scrollToTop: (animated: boolean) => Promise<void>; |
| 167 | } |
| 168 | |
| 169 | type NativeReviewDiffRawViewProps = Omit< |
| 170 | NativeReviewDiffViewProps, |
| 171 | "nativeViewRef" | "rowsJson" | "tokensJson" | "tokensPatchJson" |
| 172 | > & { |
| 173 | readonly refreshEnabled: boolean; |
| 174 | readonly ref?: Ref<NativeReviewDiffViewRef>; |
| 175 | }; |
| 176 | |
| 177 | let cachedNativeReviewDiffRawView: ComponentType<NativeReviewDiffRawViewProps> | undefined; |
| 178 | let nativeReviewDiffViewResolutionFailed = false; |
| 179 | |
| 180 | type NativeReviewDiffPayloadMethod = "setRowsJson" | "setTokensJson" | "setTokensPatchJson"; |
| 181 | |
| 182 | export function isPendingNativeViewRegistration(error: unknown): boolean { |
| 183 | if (!(error instanceof Error)) return false; |
| 184 | return ( |
| 185 | error.message.includes(`Unable to find the '${NATIVE_REVIEW_DIFF_MODULE_NAME}' view`) || |
| 186 | (error.message.includes("Unable to find the class") && |
| 187 | error.message.includes("T3ReviewDiffView view with tag")) |
| 188 | ); |
| 189 | } |
| 190 | |
| 191 | function useNativeReviewDiffPayload( |
| 192 | nativeRef: React.RefObject<NativeReviewDiffViewRef | null>, |
| 193 | method: NativeReviewDiffPayloadMethod, |
| 194 | payload: string | undefined, |
| 195 | ) { |
| 196 | useEffect(() => { |
| 197 | if (payload === undefined) { |
| 198 | return; |
| 199 | } |
| 200 | |
| 201 | let cancelled = false; |
| 202 | let frame: number | null = null; |
| 203 | let attempts = 0; |
| 204 | |
| 205 | const dispatch = () => { |
| 206 | if (cancelled) { |
| 207 | return; |
| 208 | } |
| 209 | |
| 210 | const view = nativeRef.current; |
| 211 | const command = view?.[method]; |
| 212 | if (!view || !command) { |
| 213 | if (attempts < NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES) { |
| 214 | attempts += 1; |
| 215 | frame = requestAnimationFrame(dispatch); |
| 216 | } |
| 217 | return; |
| 218 | } |
| 219 | |
| 220 | void command.call(view, payload).catch((error: unknown) => { |
| 221 | if ( |
| 222 | !cancelled && |
| 223 | attempts < NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES && |
| 224 | isPendingNativeViewRegistration(error) |
| 225 | ) { |
| 226 | attempts += 1; |
| 227 | frame = requestAnimationFrame(dispatch); |
| 228 | return; |
| 229 | } |
| 230 | console.error(`[native-review-diff] ${method} failed`, error); |
| 231 | }); |
| 232 | }; |
| 233 | |
| 234 | // Fabric attaches the React ref before Expo registers the native tag used by |
| 235 | // view functions. Starting on the next frame avoids racing that registration. |
| 236 | frame = requestAnimationFrame(dispatch); |
| 237 | |
| 238 | return () => { |
| 239 | cancelled = true; |
| 240 | if (frame !== null) { |
| 241 | cancelAnimationFrame(frame); |
| 242 | } |
| 243 | }; |
| 244 | }, [method, nativeRef, payload]); |
| 245 | } |
| 246 | |
| 247 | function getExpoViewConfig(moduleName: string) { |
| 248 | return (globalThis as typeof globalThis & ExpoGlobalWithViewConfig).expo?.getViewConfig?.( |
| 249 | moduleName, |
| 250 | ); |
| 251 | } |
| 252 | |
| 253 | function NativeReviewDiffView(props: NativeReviewDiffViewProps) { |
| 254 | const { nativeViewRef, rowsJson, tokensJson, tokensPatchJson, ...nativeProps } = props; |
| 255 | const nativeRef = useRef<NativeReviewDiffViewRef>(null); |
| 256 | useNativeReviewDiffPayload(nativeRef, "setRowsJson", rowsJson); |
| 257 | useNativeReviewDiffPayload(nativeRef, "setTokensJson", tokensJson); |
| 258 | useNativeReviewDiffPayload(nativeRef, "setTokensPatchJson", tokensPatchJson); |
| 259 | useImperativeHandle( |
| 260 | nativeViewRef, |
| 261 | () => ({ |
| 262 | scrollToFile: async (fileId, animated = true) => { |
| 263 | await nativeRef.current?.scrollToFile(fileId, animated); |
| 264 | }, |
| 265 | scrollToTop: async (animated = true) => { |
| 266 | await nativeRef.current?.scrollToTop(animated); |
| 267 | }, |
| 268 | }), |
| 269 | [], |
| 270 | ); |
| 271 | |
| 272 | const RawNativeView = cachedNativeReviewDiffRawView; |
| 273 | if (!RawNativeView) { |
| 274 | return null; |
| 275 | } |
| 276 | |
| 277 | return createElement(RawNativeView, { |
| 278 | ...nativeProps, |
| 279 | refreshEnabled: props.onPullToRefresh !== undefined, |
| 280 | ref: nativeRef, |
| 281 | }); |
| 282 | } |
| 283 | |
| 284 | export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffViewProps> | null { |
| 285 | if (cachedNativeReviewDiffRawView) { |
| 286 | return NativeReviewDiffView; |
| 287 | } |
| 288 | |
| 289 | if (nativeReviewDiffViewResolutionFailed) { |
| 290 | return null; |
| 291 | } |
| 292 | |
| 293 | if (getExpoViewConfig(NATIVE_REVIEW_DIFF_MODULE_NAME) == null) { |
| 294 | return null; |
| 295 | } |
| 296 | |
| 297 | try { |
| 298 | cachedNativeReviewDiffRawView = requireNativeView<NativeReviewDiffRawViewProps>( |
| 299 | NATIVE_REVIEW_DIFF_MODULE_NAME, |
| 300 | ); |
| 301 | } catch (cause) { |
| 302 | nativeReviewDiffViewResolutionFailed = true; |
| 303 | console.error( |
| 304 | new NativeViewResolutionError({ |
| 305 | nativeModuleName: NATIVE_REVIEW_DIFF_MODULE_NAME, |
| 306 | cause, |
| 307 | }), |
| 308 | ); |
| 309 | return null; |
| 310 | } |
| 311 | |
| 312 | return cachedNativeReviewDiffRawView ? NativeReviewDiffView : null; |
| 313 | } |