Files
paseo/packages/app/src/git/diff-document/surface.native.tsx
T
Mohamed Boudra 8f7b3e8d75 fix(app): stabilize diff reviews and workspace tabs
Reuse measured diff geometry when review controls open, keeping the interaction responsive on large changes. Preserve complete tab-track snapshots while exact content widths settle so new tabs and retained workspaces do not flash or truncate.
2026-08-17 21:14:38 +02:00

583 lines
18 KiB
TypeScript

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<ScrollView>(null);
const previousModelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
const reusableModelRef = useRef<{
dependencies: readonly unknown[];
models: ReturnType<typeof buildDiffDocumentModel>[];
} | null>(null);
const consumedFocusRef = useRef<string | null>(null);
const scrollTop = useSharedValue(0);
const horizontalOffsets = useSharedValue<DiffHorizontalOffsets>({});
const family = props.displayPreferences.monoFontFamily.trim() || SYSTEM_MONO;
const typography = useMemo<DiffTypography>(
() => ({
family,
size: props.displayPreferences.codeFontSize,
lineHeight: Math.round(props.displayPreferences.codeFontSize * 1.5),
}),
[family, props.displayPreferences.codeFontSize],
);
const measurement = useMemo<TextMeasurer>(
() => 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 (
<View style={[styles.root, { backgroundColor: props.palette.surface }]} onLayout={layout}>
<AnimatedScrollView
ref={scrollRef}
style={[StyleSheet.absoluteFill, styles.scroll]}
contentContainerStyle={contentStyle}
onScroll={scrollHandler}
scrollEventThrottle={16}
showsVerticalScrollIndicator
stickyHeaderIndices={stickyHeaderIndices}
testID="git-diff-scroll"
>
<NativeCanvasSlabLayer
model={model}
viewportHeight={viewport.height}
scrollTop={scrollTop}
textLayout={textLayout}
paints={paints}
horizontalOffsets={horizontalOffsets}
/>
{model.files.flatMap((file, index) => [
<NativeStickyFileHeader
key={`${file.path}:header`}
file={file}
showTopBorder={index > 0 && !model.files[index - 1]?.isCollapsed}
selectedPath={props.selectedPath}
mode={props.mode}
onToggleFile={props.onToggleFile}
onSelectPath={props.onSelectPath}
/>,
<NativeFileBody
key={`${file.path}:body`}
file={file}
model={model}
mode={props.mode}
horizontalOffsets={horizontalOffsets}
/>,
])}
<NativeReviewOverlays model={model} mode={props.mode} />
</AnimatedScrollView>
</View>
);
}
function NativeCanvasSlabLayer({
model,
viewportHeight,
scrollTop,
textLayout,
paints,
horizontalOffsets,
}: {
model: DiffDocumentModel;
viewportHeight: number;
scrollTop: SharedValue<number>;
textLayout: NativeTextLayout;
paints: NativePaints;
horizontalOffsets: SharedValue<DiffHorizontalOffsets>;
}) {
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) => (
<NativeCanvasSlabView
key={slab.key}
slab={slab}
model={model}
textLayout={textLayout}
paints={paints}
horizontalOffsets={horizontalOffsets}
/>
));
}
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 (
<View style={styles.header}>
<DocumentFileHeader
file={file}
showTopBorder={showTopBorder}
selectedPath={selectedPath}
mode={mode}
onToggleFile={onToggleFile}
onSelectPath={onSelectPath}
/>
</View>
);
}
function NativeFileBody({
file,
model,
mode,
horizontalOffsets,
}: {
file: DiffFileSection;
model: DiffDocumentModel;
mode: DiffSurfaceProps["mode"];
horizontalOffsets: SharedValue<DiffHorizontalOffsets>;
}) {
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 (
<View
testID={`diff-file-${file.fileIndex}-body`}
style={inlineUnistylesStyle<ViewStyle>({
position: "relative",
height: file.bodyHeight,
zIndex: 2,
})}
onTouchStart={touchStart}
onTouchMove={touchMove}
onTouchEnd={touchEnd}
>
{!file.isCollapsed && !model.wrapLines ? (
<HorizontalScroll file={file} offsets={horizontalOffsets} />
) : null}
</View>
);
}
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 [
<View
key={cell.reviewTarget.key}
style={inlineUnistylesStyle<ViewStyle>({
position: "absolute",
top: row.top + row.height - row.reviewHeight,
left: index * columnWidth,
width: columnWidth,
height: row.reviewHeight,
zIndex: 6,
})}
>
<InlineReviewThread
reviewTarget={cell.reviewTarget}
reviewActions={reviewActions}
height={row.reviewHeight}
viewportWidth={columnWidth}
pinToViewport={!model.wrapLines}
/>
</View>,
];
});
});
}
function NativeCanvasSlabView({
slab,
model,
textLayout,
paints,
horizontalOffsets,
}: {
slab: NativeCanvasSlab;
model: DiffDocumentModel;
textLayout: NativeTextLayout;
paints: NativePaints;
horizontalOffsets: SharedValue<DiffHorizontalOffsets>;
}) {
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<ViewStyle>(
() => ({
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 (
<View pointerEvents="none" style={style} testID={`git-diff-canvas-${slab.key}`}>
<Canvas style={StyleSheet.absoluteFill}>
<Picture picture={pictures.fixed} />
</Canvas>
<NativeSlabCode
picture={pictures.content.unified}
path={slab.path}
horizontalOffsets={horizontalOffsets}
wrapLines={model.wrapLines}
clipX={unifiedClip.x}
clipWidth={unifiedClip.width}
contentWidth={file.contentWidth}
height={slab.height}
/>
{model.layout === "split" ? (
<>
<NativeSlabCode
picture={pictures.content.left}
path={slab.path}
horizontalOffsets={horizontalOffsets}
wrapLines={model.wrapLines}
clipX={file.gutterWidth + CODE_LEFT_PADDING}
clipWidth={splitClipWidth}
contentWidth={file.contentWidth}
height={slab.height}
/>
<NativeSlabCode
picture={pictures.content.right}
path={slab.path}
horizontalOffsets={horizontalOffsets}
wrapLines={model.wrapLines}
clipX={columnWidth + file.gutterWidth + CODE_LEFT_PADDING}
clipWidth={splitClipWidth}
contentWidth={file.contentWidth}
height={slab.height}
/>
</>
) : null}
<Canvas pointerEvents="none" style={StyleSheet.absoluteFill}>
<Picture picture={pictures.gutter} />
</Canvas>
</View>
);
}
function NativeSlabCode({
picture,
path,
horizontalOffsets,
wrapLines,
clipX,
clipWidth,
contentWidth,
height,
}: {
picture: SkPicture;
path: string;
horizontalOffsets: SharedValue<DiffHorizontalOffsets>;
wrapLines: boolean;
clipX: number;
clipWidth: number;
contentWidth: number;
height: number;
}) {
const transform = useAnimatedStyle(() => ({
transform: [
{ translateX: wrapLines ? 0 : -horizontalOffsetForPath(horizontalOffsets.value, path) },
],
}));
const clipStyle = useMemo<ViewStyle>(
() => ({
position: "absolute",
top: 0,
left: clipX,
width: clipWidth,
height,
overflow: "hidden",
}),
[clipWidth, clipX, height],
);
const contentStyle = useMemo<ViewStyle>(
() => ({ position: "absolute", top: 0, left: -clipX, width: contentWidth, height }),
[clipX, contentWidth, height],
);
return (
<View pointerEvents="none" style={clipStyle}>
<AnimatedCodeLayer style={[contentStyle, transform]}>
<Canvas style={StyleSheet.absoluteFill}>
<Picture picture={picture} />
</Canvas>
</AnimatedCodeLayer>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, minHeight: 0, position: "relative", overflow: "hidden" },
scroll: { backgroundColor: "transparent" },
header: { zIndex: 5 },
});