import { Canvas, Picture, type SkPicture } from "@shopify/react-native-skia"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ScrollView, StyleSheet, View, type GestureResponderEvent, type LayoutChangeEvent, type ViewStyle, } from "react-native"; import { createAnimatedComponent, useAnimatedReaction, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, type SharedValue, } from "react-native-reanimated"; import { scheduleOnRN } from "react-native-worklets"; import { InlineReviewThread } from "@/review"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { DocumentFileHeader } from "./document-file-header"; import { hitTestDiffBodyPoint } from "./native-hit-testing"; import { HorizontalScroll } from "./horizontal-scroll.native"; import { horizontalOffsetForPath, retainHorizontalOffsetsForPaths, type DiffHorizontalOffsets, } from "./horizontal-offsets"; import { buildDiffDocumentModel, resolveRelayoutScrollTop, retainReusableModels } from "./model"; import { buildNativeCanvasSlabs, nativeCanvasSlabsForViewport, nativeCanvasWindowBucket, nativeCanvasWindowTop, nativeStickyHeaderIndices, type NativeCanvasSlab, } from "./native-slabs"; import { createNativePaints, recordNativeSlabPictures, type NativePaints } from "./paint.native"; import { createNativeTextLayoutStore, createNativeTextMeasurer, disposeNativeTextLayout, prepareNativeTextLayout, type NativeTextLayout, } from "./text.native"; import type { DiffDocumentModel, DiffFileSection, DiffSurfaceProps, DiffTypography, TextMeasurer, } from "./types"; const AnimatedScrollView = createAnimatedComponent(ScrollView); const AnimatedCodeLayer = createAnimatedComponent(View); const SYSTEM_MONO = "monospace"; const CODE_LEFT_PADDING = 8; export function DiffSurface(props: DiffSurfaceProps) { const { t } = useTranslation(); const [viewport, setViewport] = useState({ width: 0, height: 0 }); const scrollRef = useRef(null); const previousModelRef = useRef | null>(null); const reusableModelRef = useRef<{ dependencies: readonly unknown[]; models: ReturnType[]; } | null>(null); const consumedFocusRef = useRef(null); const scrollTop = useSharedValue(0); const horizontalOffsets = useSharedValue({}); const family = props.displayPreferences.monoFontFamily.trim() || SYSTEM_MONO; const typography = useMemo( () => ({ family, size: props.displayPreferences.codeFontSize, lineHeight: Math.round(props.displayPreferences.codeFontSize * 1.5), }), [family, props.displayPreferences.codeFontSize], ); const measurement = useMemo( () => createNativeTextMeasurer({ configuredFamily: family, fontSize: typography.size }), [family, typography.size], ); const reviewActions = props.mode.kind === "working" ? props.mode.reviewActions : undefined; const model = useMemo(() => { const dependencies = [ props.files, props.displayPreferences.layout, props.displayPreferences.wrapLines, viewport.width, typography, measurement, props.palette, t, ] as const; const previous = reusableModelRef.current; const canReuse = previous?.dependencies.every( (dependency, index) => dependency === dependencies[index], ); const reuseFrom = canReuse ? previous?.models : undefined; const next = buildDiffDocumentModel({ files: props.files, collapsedFilePaths: props.collapsedFilePaths, layout: props.displayPreferences.layout, wrapLines: props.displayPreferences.wrapLines, viewportWidth: viewport.width, typography, measureText: measurement, palette: props.palette, reviewActions, labels: { binary: t("workspace.git.diff.binaryFile"), tooLarge: t("workspace.git.diff.tooLarge"), }, reuseFrom, }); reusableModelRef.current = { dependencies, models: retainReusableModels(reuseFrom, next), }; return next; }, [ measurement, props.collapsedFilePaths, props.displayPreferences.layout, props.displayPreferences.wrapLines, props.files, props.palette, reviewActions, t, typography, viewport.width, ]); const paints = useMemo(() => createNativePaints(props.palette), [props.palette]); const textLayoutStore = useMemo( () => createNativeTextLayoutStore({ configuredFamily: family, fontSize: typography.size, lineHeight: typography.lineHeight, palette: props.palette, }), [family, props.palette, typography.lineHeight, typography.size], ); const textLayout = useMemo( () => prepareNativeTextLayout(textLayoutStore, model), [model, textLayoutStore], ); useEffect(() => () => disposeNativeTextLayout(textLayoutStore), [textLayoutStore]); const scrollHandler = useAnimatedScrollHandler((event) => { scrollTop.value = event.contentOffset.y; }); const layout = useCallback((event: LayoutChangeEvent) => { const { width, height } = event.nativeEvent.layout; setViewport((current) => current.width === width && current.height === height ? current : { width, height }, ); }, []); useLayoutEffect(() => { const previous = previousModelRef.current; if (previous && previous !== model) { const nextScrollTop = resolveRelayoutScrollTop(previous, model, scrollTop.value); scrollTop.value = nextScrollTop; scrollRef.current?.scrollTo({ y: nextScrollTop, animated: false }); } previousModelRef.current = model; }, [model, scrollTop]); useLayoutEffect(() => { horizontalOffsets.value = retainHorizontalOffsetsForPaths( horizontalOffsets.value, model.files.map((file) => file.path), ); }, [horizontalOffsets, model.files]); const mode = props.mode; const collapsedFilePaths = props.collapsedFilePaths; const onToggleFile = props.onToggleFile; useEffect(() => { if (mode.kind !== "working") return; const focusPath = mode.focusPath; if (!focusPath) return; const requestKey = `${mode.focusRequestId ?? "initial"}:${focusPath}`; if (consumedFocusRef.current === requestKey) return; if (collapsedFilePaths.has(focusPath)) onToggleFile(focusPath); const file = model.files.find((entry) => entry.path === focusPath); if (file) { scrollTop.value = file.top; scrollRef.current?.scrollTo({ y: file.top, animated: false }); consumedFocusRef.current = requestKey; } }, [collapsedFilePaths, mode, model.files, onToggleFile, scrollTop]); const contentStyle = useMemo( () => ({ minHeight: viewport.height, backgroundColor: "transparent" }), [viewport.height], ); const stickyHeaderIndices = useMemo( () => nativeStickyHeaderIndices(model.files.length), [model.files.length], ); return ( {model.files.flatMap((file, index) => [ 0 && !model.files[index - 1]?.isCollapsed} selectedPath={props.selectedPath} mode={props.mode} onToggleFile={props.onToggleFile} onSelectPath={props.onSelectPath} />, , ])} ); } function NativeCanvasSlabLayer({ model, viewportHeight, scrollTop, textLayout, paints, horizontalOffsets, }: { model: DiffDocumentModel; viewportHeight: number; scrollTop: SharedValue; textLayout: NativeTextLayout; paints: NativePaints; horizontalOffsets: SharedValue; }) { const [windowTop, setWindowTop] = useState(0); const updateWindow = useCallback((nextTop: number) => { setWindowTop((current) => (current === nextTop ? current : nextTop)); }, []); useAnimatedReaction( () => nativeCanvasWindowBucket(scrollTop.value, viewportHeight), (bucket, previous) => { if (bucket !== previous) { scheduleOnRN(updateWindow, nativeCanvasWindowTop(bucket, viewportHeight)); } }, [updateWindow, viewportHeight], ); const descriptors = useMemo( () => buildNativeCanvasSlabs(model, viewportHeight), [model, viewportHeight], ); const visible = useMemo( () => nativeCanvasSlabsForViewport(descriptors, windowTop, viewportHeight), [descriptors, viewportHeight, windowTop], ); return visible.map((slab) => ( )); } function NativeStickyFileHeader({ file, showTopBorder, selectedPath, mode, onToggleFile, onSelectPath, }: { file: DiffFileSection; showTopBorder: boolean; selectedPath: DiffSurfaceProps["selectedPath"]; mode: DiffSurfaceProps["mode"]; onToggleFile: DiffSurfaceProps["onToggleFile"]; onSelectPath: DiffSurfaceProps["onSelectPath"]; }) { return ( ); } function NativeFileBody({ file, model, mode, horizontalOffsets, }: { file: DiffFileSection; model: DiffDocumentModel; mode: DiffSurfaceProps["mode"]; horizontalOffsets: SharedValue; }) { const touchRef = useRef<{ x: number; y: number; startedAt: number; moved: boolean } | null>(null); const reviewActions = mode.kind === "working" ? mode.reviewActions : undefined; const touchStart = useCallback((event: GestureResponderEvent) => { touchRef.current = { x: event.nativeEvent.pageX, y: event.nativeEvent.pageY, startedAt: Date.now(), moved: false, }; }, []); const touchMove = useCallback((event: GestureResponderEvent) => { const touch = touchRef.current; if ( touch && Math.hypot(event.nativeEvent.pageX - touch.x, event.nativeEvent.pageY - touch.y) > 10 ) { touch.moved = true; } }, []); const touchEnd = useCallback( (event: GestureResponderEvent) => { const touch = touchRef.current; touchRef.current = null; if (!touch || touch.moved || Date.now() - touch.startedAt > 500 || !reviewActions) return; const hit = hitTestDiffBodyPoint({ model, file, locationX: event.nativeEvent.locationX, locationY: event.nativeEvent.locationY, horizontalOffset: horizontalOffsetForPath(horizontalOffsets.value, file.path), }); if (hit?.kind === "cell" && hit.target) reviewActions.onStartComment(hit.target); }, [file, horizontalOffsets, model, reviewActions], ); return ( ({ position: "relative", height: file.bodyHeight, zIndex: 2, })} onTouchStart={touchStart} onTouchMove={touchMove} onTouchEnd={touchEnd} > {!file.isCollapsed && !model.wrapLines ? ( ) : null} ); } function NativeReviewOverlays({ model, mode, }: { model: DiffDocumentModel; mode: DiffSurfaceProps["mode"]; }) { if (mode.kind !== "working" || !mode.reviewActions) return null; const reviewActions = mode.reviewActions; return model.rows.flatMap((row) => { if (row.kind !== "line" || row.reviewHeight === 0) return []; const columnWidth = model.viewportWidth / row.cells.length; return row.cells.flatMap((cell, index) => { if (!cell?.reviewTarget) return []; const comments = reviewActions.commentsByTarget.get(cell.reviewTarget.key) ?? []; const editor = reviewActions.editor; const hasEditor = editor?.target.filePath === cell.reviewTarget.filePath && editor.target.side === cell.reviewTarget.side && editor.target.lineNumber === cell.reviewTarget.lineNumber; if (comments.length === 0 && !hasEditor) return []; return [ ({ position: "absolute", top: row.top + row.height - row.reviewHeight, left: index * columnWidth, width: columnWidth, height: row.reviewHeight, zIndex: 6, })} > , ]; }); }); } function NativeCanvasSlabView({ slab, model, textLayout, paints, horizontalOffsets, }: { slab: NativeCanvasSlab; model: DiffDocumentModel; textLayout: NativeTextLayout; paints: NativePaints; horizontalOffsets: SharedValue; }) { const file = model.files[slab.fileIndex]; const pictures = useMemo( () => recordNativeSlabPictures({ model, fileIndex: slab.fileIndex, top: slab.top, height: slab.height, viewportWidth: model.viewportWidth, textLayout, paints, }), [model, paints, slab.fileIndex, slab.height, slab.top, textLayout], ); const style = useMemo( () => ({ position: "absolute", top: slab.top, left: 0, right: 0, height: slab.height, zIndex: 3, }), [slab.height, slab.top], ); if (!file) return null; const unifiedClip = { x: file.gutterWidth + CODE_LEFT_PADDING, width: model.viewportWidth - file.gutterWidth - CODE_LEFT_PADDING, }; const columnWidth = model.viewportWidth / 2; const splitClipWidth = columnWidth - file.gutterWidth - CODE_LEFT_PADDING; return ( {model.layout === "split" ? ( <> ) : null} ); } function NativeSlabCode({ picture, path, horizontalOffsets, wrapLines, clipX, clipWidth, contentWidth, height, }: { picture: SkPicture; path: string; horizontalOffsets: SharedValue; wrapLines: boolean; clipX: number; clipWidth: number; contentWidth: number; height: number; }) { const transform = useAnimatedStyle(() => ({ transform: [ { translateX: wrapLines ? 0 : -horizontalOffsetForPath(horizontalOffsets.value, path) }, ], })); const clipStyle = useMemo( () => ({ position: "absolute", top: 0, left: clipX, width: clipWidth, height, overflow: "hidden", }), [clipWidth, clipX, height], ); const contentStyle = useMemo( () => ({ position: "absolute", top: 0, left: -clipX, width: contentWidth, height }), [clipX, contentWidth, height], ); return ( ); } const styles = StyleSheet.create({ root: { flex: 1, minHeight: 0, position: "relative", overflow: "hidden" }, scroll: { backgroundColor: "transparent" }, header: { zIndex: 5 }, });