1import {
2 createElement,
3 useEffect,
4 useImperativeHandle,
5 useRef,
6 type ComponentType,
7 type Ref,
8} from "react";
9import type { NativeSyntheticEvent, ViewProps } from "react-native";
10import { requireNativeView } from "expo";
11
12import { NativeViewResolutionError } from "../../native/nativeViewResolutionError";
13
14const NATIVE_REVIEW_DIFF_MODULE_NAME = "T3ReviewDiffSurface";
15const NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES = 60;
16
17interface ExpoGlobalWithViewConfig {
18 readonly expo?: {
19 getViewConfig?: (moduleName: string, viewName?: string) => unknown;
20 };
21}
22
23export 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
49export interface NativeReviewDiffWordDiffRange {
50 readonly start: number;
51 readonly end: number;
52}
53
54export interface NativeReviewDiffToken {
55 readonly content: string;
56 readonly color: string | null;
57 readonly fontStyle: number | null;
58}
59
60export 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
76export 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
109export 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
152export interface NativeReviewDiffViewHandle {
153 readonly scrollToFile: (fileId: string, animated?: boolean) => Promise<void>;
154 readonly scrollToTop: (animated?: boolean) => Promise<void>;
155}
156
157export function isNativeReviewDiffDrawEvent(payload: Readonly<Record<string, unknown>>): boolean {
158 return payload.message === "draw-metrics" || payload.message === "visible-range";
159}
160
161interface 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
169type NativeReviewDiffRawViewProps = Omit<
170 NativeReviewDiffViewProps,
171 "nativeViewRef" | "rowsJson" | "tokensJson" | "tokensPatchJson"
172> & {
173 readonly refreshEnabled: boolean;
174 readonly ref?: Ref<NativeReviewDiffViewRef>;
175};
176
177let cachedNativeReviewDiffRawView: ComponentType<NativeReviewDiffRawViewProps> | undefined;
178let nativeReviewDiffViewResolutionFailed = false;
179
180type NativeReviewDiffPayloadMethod = "setRowsJson" | "setTokensJson" | "setTokensPatchJson";
181
182export 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
191function 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
247function getExpoViewConfig(moduleName: string) {
248 return (globalThis as typeof globalThis & ExpoGlobalWithViewConfig).expo?.getViewConfig?.(
249 moduleName,
250 );
251}
252
253function 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
284export 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}