import { createElement, useEffect, useImperativeHandle, useRef, type ComponentType, type Ref,} from "react";import type { NativeSyntheticEvent, ViewProps } from "react-native";import { requireNativeView } from "expo"; import { NativeViewResolutionError } from "../../native/nativeViewResolutionError"; const NATIVE_REVIEW_DIFF_MODULE_NAME = "T3ReviewDiffSurface";const NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES = 60; interface ExpoGlobalWithViewConfig { readonly expo?: { getViewConfig?: (moduleName: string, viewName?: string) => unknown; };} export interface NativeReviewDiffRow { readonly kind: "file" | "hunk" | "line" | "notice" | "comment"; readonly id: string; readonly fileId?: string; readonly filePath?: string; readonly previousPath?: string | null; readonly changeType?: | "modified" | "new" | "deleted" | "renamed" | "rename-pure" | "rename-changed"; readonly additions?: number; readonly deletions?: number; readonly text?: string; readonly content?: string; readonly change?: "context" | "add" | "delete"; readonly oldLineNumber?: number | null; readonly newLineNumber?: number | null; readonly wordDiffRanges?: ReadonlyArray<NativeReviewDiffWordDiffRange>; readonly commentText?: string; readonly commentRangeLabel?: string; readonly commentSectionTitle?: string;} export interface NativeReviewDiffWordDiffRange { readonly start: number; readonly end: number;} export interface NativeReviewDiffToken { readonly content: string; readonly color: string | null; readonly fontStyle: number | null;} export interface NativeReviewDiffTheme { readonly background: string; readonly text: string; readonly mutedText: string; readonly headerBackground: string; readonly border: string; readonly hunkBackground: string; readonly hunkText: string; readonly addBackground: string; readonly deleteBackground: string; readonly addBar: string; readonly deleteBar: string; readonly addText: string; readonly deleteText: string;} export interface NativeReviewDiffStyle { readonly rowHeight?: number; readonly contentWidth?: number; readonly changeBarWidth?: number; readonly gutterWidth?: number; readonly codePadding?: number; readonly textVerticalInset?: number; readonly fileHeaderHeight?: number; readonly fileHeaderHorizontalMargin?: number; readonly fileHeaderVerticalMargin?: number; readonly fileHeaderCornerRadius?: number; readonly fileHeaderHorizontalPadding?: number; readonly fileHeaderPathRightPadding?: number; readonly fileHeaderCountColumnWidth?: number; readonly fileHeaderCountGap?: number; readonly codeFontSize?: number; readonly codeFontWeight?: string; readonly lineNumberFontSize?: number; readonly lineNumberFontWeight?: string; readonly hunkFontSize?: number; readonly hunkFontWeight?: string; readonly fileHeaderFontSize?: number; readonly fileHeaderFontWeight?: string; readonly fileHeaderMetaFontSize?: number; readonly fileHeaderMetaFontWeight?: string; readonly fileHeaderSubtextFontSize?: number; readonly fileHeaderSubtextFontWeight?: string; readonly fileHeaderStatusFontSize?: number; readonly fileHeaderStatusFontWeight?: string; readonly emptyStateFontSize?: number; readonly emptyStateFontWeight?: string;} export interface NativeReviewDiffViewProps extends ViewProps { readonly rowsJson: string; readonly tokensJson?: string; readonly tokensPatchJson?: string; readonly tokensResetKey?: string; readonly contentResetKey?: string; readonly collapsedFileIdsJson?: string; readonly viewedFileIdsJson?: string; readonly selectedRowIdsJson?: string; readonly collapsedCommentIdsJson?: string; readonly appearanceScheme: "light" | "dark"; readonly themeJson: string; readonly styleJson?: string; readonly rowHeight: number; readonly contentWidth: number; readonly initialRowIndex?: number; readonly refreshing?: boolean; readonly nativeViewRef?: Ref<NativeReviewDiffViewHandle>; readonly onPullToRefresh?: (event: NativeSyntheticEvent<Record<string, never>>) => void; readonly onDebug?: (event: NativeSyntheticEvent<Record<string, unknown>>) => void; readonly onVisibleFileChange?: ( event: NativeSyntheticEvent<{ readonly fileId?: string | null }>, ) => void; readonly onToggleFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void; readonly onToggleViewedFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void; readonly onPressLine?: ( event: NativeSyntheticEvent<{ readonly rowId?: string; readonly fileId?: string; readonly gesture?: "tap" | "longPress"; readonly oldLineNumber?: number; readonly newLineNumber?: number; readonly change?: "context" | "add" | "delete"; }>, ) => void; readonly onToggleComment?: (event: NativeSyntheticEvent<{ readonly commentId?: string }>) => void;} export interface NativeReviewDiffViewHandle { readonly scrollToFile: (fileId: string, animated?: boolean) => Promise<void>; readonly scrollToTop: (animated?: boolean) => Promise<void>;} export function isNativeReviewDiffDrawEvent(payload: Readonly<Record<string, unknown>>): boolean { return payload.message === "draw-metrics" || payload.message === "visible-range";} interface NativeReviewDiffViewRef { readonly setRowsJson: (rowsJson: string) => Promise<void>; readonly setTokensJson: (tokensJson: string) => Promise<void>; readonly setTokensPatchJson: (tokensPatchJson: string) => Promise<void>; readonly scrollToFile: (fileId: string, animated: boolean) => Promise<void>; readonly scrollToTop: (animated: boolean) => Promise<void>;} type NativeReviewDiffRawViewProps = Omit< NativeReviewDiffViewProps, "nativeViewRef" | "rowsJson" | "tokensJson" | "tokensPatchJson"> & { readonly ref?: Ref<NativeReviewDiffViewRef>;}; let cachedNativeReviewDiffRawView: ComponentType<NativeReviewDiffRawViewProps> | undefined;let nativeReviewDiffViewResolutionFailed = false; type NativeReviewDiffPayloadMethod = "setRowsJson" | "setTokensJson" | "setTokensPatchJson"; export function isPendingNativeViewRegistration(error: unknown): boolean { if (!(error instanceof Error)) return false; return ( error.message.includes(`Unable to find the '${NATIVE_REVIEW_DIFF_MODULE_NAME}' view`) || (error.message.includes("Unable to find the class") && error.message.includes("T3ReviewDiffView view with tag")) );} function useNativeReviewDiffPayload( nativeRef: React.RefObject<NativeReviewDiffViewRef | null>, method: NativeReviewDiffPayloadMethod, payload: string | undefined,) { useEffect(() => { if (payload === undefined) { return; } let cancelled = false; let frame: number | null = null; let attempts = 0; const dispatch = () => { if (cancelled) { return; } const view = nativeRef.current; const command = view?.[method]; if (!view || !command) { if (attempts < NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES) { attempts += 1; frame = requestAnimationFrame(dispatch); } return; } void command.call(view, payload).catch((error: unknown) => { if ( !cancelled && attempts < NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES && isPendingNativeViewRegistration(error) ) { attempts += 1; frame = requestAnimationFrame(dispatch); return; } console.error(`[native-review-diff] ${method} failed`, error); }); }; // Fabric attaches the React ref before Expo registers the native tag used by // view functions. Starting on the next frame avoids racing that registration. frame = requestAnimationFrame(dispatch); return () => { cancelled = true; if (frame !== null) { cancelAnimationFrame(frame); } }; }, [method, nativeRef, payload]);} function getExpoViewConfig(moduleName: string) { return (globalThis as typeof globalThis & ExpoGlobalWithViewConfig).expo?.getViewConfig?.( moduleName, );} function NativeReviewDiffView(props: NativeReviewDiffViewProps) { const { nativeViewRef, rowsJson, tokensJson, tokensPatchJson, ...nativeProps } = props; const nativeRef = useRef<NativeReviewDiffViewRef>(null); useNativeReviewDiffPayload(nativeRef, "setRowsJson", rowsJson); useNativeReviewDiffPayload(nativeRef, "setTokensJson", tokensJson); useNativeReviewDiffPayload(nativeRef, "setTokensPatchJson", tokensPatchJson); useImperativeHandle( nativeViewRef, () => ({ scrollToFile: async (fileId, animated = true) => { await nativeRef.current?.scrollToFile(fileId, animated); }, scrollToTop: async (animated = true) => { await nativeRef.current?.scrollToTop(animated); }, }), [], ); const RawNativeView = cachedNativeReviewDiffRawView; if (!RawNativeView) { return null; } return createElement(RawNativeView, { ...nativeProps, ref: nativeRef });} export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffViewProps> | null { if (cachedNativeReviewDiffRawView) { return NativeReviewDiffView; } if (nativeReviewDiffViewResolutionFailed) { return null; } if (getExpoViewConfig(NATIVE_REVIEW_DIFF_MODULE_NAME) == null) { return null; } try { cachedNativeReviewDiffRawView = requireNativeView<NativeReviewDiffRawViewProps>( NATIVE_REVIEW_DIFF_MODULE_NAME, ); } catch (cause) { nativeReviewDiffViewResolutionFailed = true; console.error( new NativeViewResolutionError({ nativeModuleName: NATIVE_REVIEW_DIFF_MODULE_NAME, cause, }), ); return null; } return cachedNativeReviewDiffRawView ? NativeReviewDiffView : null;}
import {
createElement,
useEffect,
useImperativeHandle,
useRef,
type ComponentType,
type Ref,
} from "react";
import type { NativeSyntheticEvent, ViewProps } from "react-native";
import { requireNativeView } from "expo";
import { NativeViewResolutionError } from "../../native/nativeViewResolutionError";
const NATIVE_REVIEW_DIFF_MODULE_NAME = "T3ReviewDiffSurface";
const NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES = 60;
interface ExpoGlobalWithViewConfig {
readonly expo?: {
getViewConfig?: (moduleName: string, viewName?: string) => unknown;
};
}
export interface NativeReviewDiffRow {
readonly kind: "file" | "hunk" | "line" | "notice" | "comment";
readonly id: string;
readonly fileId?: string;
readonly filePath?: string;
readonly previousPath?: string | null;
readonly changeType?:
| "modified"
| "new"
| "deleted"
| "renamed"
| "rename-pure"
| "rename-changed";
readonly additions?: number;
readonly deletions?: number;
readonly text?: string;
readonly content?: string;
readonly change?: "context" | "add" | "delete";
readonly oldLineNumber?: number | null;
readonly newLineNumber?: number | null;
readonly wordDiffRanges?: ReadonlyArray<NativeReviewDiffWordDiffRange>;
readonly commentText?: string;
readonly commentRangeLabel?: string;
readonly commentSectionTitle?: string;
}
export interface NativeReviewDiffWordDiffRange {
readonly start: number;
readonly end: number;
}
export interface NativeReviewDiffToken {
readonly content: string;
readonly color: string | null;
readonly fontStyle: number | null;
}
export interface NativeReviewDiffTheme {
readonly background: string;
readonly text: string;
readonly mutedText: string;
readonly headerBackground: string;
readonly border: string;
readonly hunkBackground: string;
readonly hunkText: string;
readonly addBackground: string;
readonly deleteBackground: string;
readonly addBar: string;
readonly deleteBar: string;
readonly addText: string;
readonly deleteText: string;
}
export interface NativeReviewDiffStyle {
readonly rowHeight?: number;
readonly contentWidth?: number;
readonly changeBarWidth?: number;
readonly gutterWidth?: number;
readonly codePadding?: number;
readonly textVerticalInset?: number;
readonly fileHeaderHeight?: number;
readonly fileHeaderHorizontalMargin?: number;
readonly fileHeaderVerticalMargin?: number;
readonly fileHeaderCornerRadius?: number;
readonly fileHeaderHorizontalPadding?: number;
readonly fileHeaderPathRightPadding?: number;
readonly fileHeaderCountColumnWidth?: number;
readonly fileHeaderCountGap?: number;
readonly codeFontSize?: number;
readonly codeFontWeight?: string;
readonly lineNumberFontSize?: number;
readonly lineNumberFontWeight?: string;
readonly hunkFontSize?: number;
readonly hunkFontWeight?: string;
readonly fileHeaderFontSize?: number;
readonly fileHeaderFontWeight?: string;
readonly fileHeaderMetaFontSize?: number;
readonly fileHeaderMetaFontWeight?: string;
readonly fileHeaderSubtextFontSize?: number;
readonly fileHeaderSubtextFontWeight?: string;
readonly fileHeaderStatusFontSize?: number;
readonly fileHeaderStatusFontWeight?: string;
readonly emptyStateFontSize?: number;
readonly emptyStateFontWeight?: string;
}
export interface NativeReviewDiffViewProps extends ViewProps {
readonly rowsJson: string;
readonly tokensJson?: string;
readonly tokensPatchJson?: string;
readonly tokensResetKey?: string;
readonly contentResetKey?: string;
readonly collapsedFileIdsJson?: string;
readonly viewedFileIdsJson?: string;
readonly selectedRowIdsJson?: string;
readonly collapsedCommentIdsJson?: string;
readonly appearanceScheme: "light" | "dark";
readonly themeJson: string;
readonly styleJson?: string;
readonly rowHeight: number;
readonly contentWidth: number;
readonly initialRowIndex?: number;
readonly refreshing?: boolean;
readonly nativeViewRef?: Ref<NativeReviewDiffViewHandle>;
readonly onPullToRefresh?: (event: NativeSyntheticEvent<Record<string, never>>) => void;
readonly onDebug?: (event: NativeSyntheticEvent<Record<string, unknown>>) => void;
readonly onVisibleFileChange?: (
event: NativeSyntheticEvent<{ readonly fileId?: string | null }>,
) => void;
readonly onToggleFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void;
readonly onToggleViewedFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void;
readonly onPressLine?: (
event: NativeSyntheticEvent<{
readonly rowId?: string;
readonly fileId?: string;
readonly gesture?: "tap" | "longPress";
readonly oldLineNumber?: number;
readonly newLineNumber?: number;
readonly change?: "context" | "add" | "delete";
}>,
) => void;
readonly onToggleComment?: (event: NativeSyntheticEvent<{ readonly commentId?: string }>) => void;
}
export interface NativeReviewDiffViewHandle {
readonly scrollToFile: (fileId: string, animated?: boolean) => Promise<void>;
readonly scrollToTop: (animated?: boolean) => Promise<void>;
}
export function isNativeReviewDiffDrawEvent(payload: Readonly<Record<string, unknown>>): boolean {
return payload.message === "draw-metrics" || payload.message === "visible-range";
}
interface NativeReviewDiffViewRef {
readonly setRowsJson: (rowsJson: string) => Promise<void>;
readonly setTokensJson: (tokensJson: string) => Promise<void>;
readonly setTokensPatchJson: (tokensPatchJson: string) => Promise<void>;
readonly scrollToFile: (fileId: string, animated: boolean) => Promise<void>;
readonly scrollToTop: (animated: boolean) => Promise<void>;
}
type NativeReviewDiffRawViewProps = Omit<
NativeReviewDiffViewProps,
"nativeViewRef" | "rowsJson" | "tokensJson" | "tokensPatchJson"
> & {
readonly ref?: Ref<NativeReviewDiffViewRef>;
};
let cachedNativeReviewDiffRawView: ComponentType<NativeReviewDiffRawViewProps> | undefined;
let nativeReviewDiffViewResolutionFailed = false;
type NativeReviewDiffPayloadMethod = "setRowsJson" | "setTokensJson" | "setTokensPatchJson";
export function isPendingNativeViewRegistration(error: unknown): boolean {
if (!(error instanceof Error)) return false;
return (
error.message.includes(`Unable to find the '${NATIVE_REVIEW_DIFF_MODULE_NAME}' view`) ||
(error.message.includes("Unable to find the class") &&
error.message.includes("T3ReviewDiffView view with tag"))
);
}
function useNativeReviewDiffPayload(
nativeRef: React.RefObject<NativeReviewDiffViewRef | null>,
method: NativeReviewDiffPayloadMethod,
payload: string | undefined,
) {
useEffect(() => {
if (payload === undefined) {
return;
}
let cancelled = false;
let frame: number | null = null;
let attempts = 0;
const dispatch = () => {
if (cancelled) {
return;
}
const view = nativeRef.current;
const command = view?.[method];
if (!view || !command) {
if (attempts < NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES) {
attempts += 1;
frame = requestAnimationFrame(dispatch);
}
return;
}
void command.call(view, payload).catch((error: unknown) => {
if (
!cancelled &&
attempts < NATIVE_REVIEW_DIFF_PAYLOAD_RETRY_FRAMES &&
isPendingNativeViewRegistration(error)
) {
attempts += 1;
frame = requestAnimationFrame(dispatch);
return;
}
console.error(`[native-review-diff] ${method} failed`, error);
});
};
// Fabric attaches the React ref before Expo registers the native tag used by
// view functions. Starting on the next frame avoids racing that registration.
frame = requestAnimationFrame(dispatch);
return () => {
cancelled = true;
if (frame !== null) {
cancelAnimationFrame(frame);
}
};
}, [method, nativeRef, payload]);
}
function getExpoViewConfig(moduleName: string) {
return (globalThis as typeof globalThis & ExpoGlobalWithViewConfig).expo?.getViewConfig?.(
moduleName,
);
}
function NativeReviewDiffView(props: NativeReviewDiffViewProps) {
const { nativeViewRef, rowsJson, tokensJson, tokensPatchJson, ...nativeProps } = props;
const nativeRef = useRef<NativeReviewDiffViewRef>(null);
useNativeReviewDiffPayload(nativeRef, "setRowsJson", rowsJson);
useNativeReviewDiffPayload(nativeRef, "setTokensJson", tokensJson);
useNativeReviewDiffPayload(nativeRef, "setTokensPatchJson", tokensPatchJson);
useImperativeHandle(
nativeViewRef,
() => ({
scrollToFile: async (fileId, animated = true) => {
await nativeRef.current?.scrollToFile(fileId, animated);
},
scrollToTop: async (animated = true) => {
await nativeRef.current?.scrollToTop(animated);
},
}),
[],
);
const RawNativeView = cachedNativeReviewDiffRawView;
if (!RawNativeView) {
return null;
}
return createElement(RawNativeView, { ...nativeProps, ref: nativeRef });
}
export function resolveNativeReviewDiffView(): ComponentType<NativeReviewDiffViewProps> | null {
if (cachedNativeReviewDiffRawView) {
return NativeReviewDiffView;
}
if (nativeReviewDiffViewResolutionFailed) {
return null;
}
if (getExpoViewConfig(NATIVE_REVIEW_DIFF_MODULE_NAME) == null) {
return null;
}
try {
cachedNativeReviewDiffRawView = requireNativeView<NativeReviewDiffRawViewProps>(
NATIVE_REVIEW_DIFF_MODULE_NAME,
);
} catch (cause) {
nativeReviewDiffViewResolutionFailed = true;
console.error(
new NativeViewResolutionError({
nativeModuleName: NATIVE_REVIEW_DIFF_MODULE_NAME,
cause,
}),
);
return null;
}
return cachedNativeReviewDiffRawView ? NativeReviewDiffView : null;
}