fix(app): stabilize canvas diff rendering

Keep diff headers and painted content on the same scroll clock while preserving native scrolling and independent per-file horizontal offsets.

Complete review, selection, typography, tree, and palette parity without returning line rendering to React.
This commit is contained in:
Mohamed Boudra
2026-08-17 21:14:38 +02:00
parent 4d23905587
commit c7b103a2cd
50 changed files with 2180 additions and 580 deletions
@@ -3,19 +3,23 @@ import { FileHeader } from "@/git/file-header";
import type { DiffFileSection } from "./types";
import type { DiffDocumentProps } from "./types";
interface DocumentFileHeaderProps {
file: DiffFileSection;
selectedPath: string | null;
mode: DiffDocumentProps["mode"];
onToggleFile: (path: string) => void;
onSelectPath: (path: string) => void;
showTopBorder: boolean;
}
export const DocumentFileHeader = memo(function DocumentFileHeader({
file,
selectedPath,
mode,
onToggleFile,
onSelectPath,
}: {
file: DiffFileSection;
selectedPath: string | null;
mode: DiffDocumentProps["mode"];
onToggleFile: (path: string) => void;
onSelectPath: (path: string) => void;
}) {
showTopBorder,
}: DocumentFileHeaderProps) {
const activate = useCallback(
(path: string) => {
if (mode.kind !== "working") return;
@@ -31,6 +35,7 @@ export const DocumentFileHeader = memo(function DocumentFileHeader({
bodyVisible={!file.isCollapsed}
isSelected={selectedPath === file.path}
interactive={mode.kind === "working"}
showTopBorder={showTopBorder}
workspaceFileDragScope={working?.workspaceFileDragScope}
onActivate={activate}
onSelect={onSelectPath}
@@ -46,4 +51,41 @@ export const DocumentFileHeader = memo(function DocumentFileHeader({
testID={`diff-file-${file.fileIndex}`}
/>
);
});
}, documentFileHeaderPropsEqual);
function documentFileHeaderPropsEqual(
previous: DocumentFileHeaderProps,
next: DocumentFileHeaderProps,
): boolean {
if (!documentFileHeaderIdentityMatches(previous, next)) return false;
if (previous.mode.kind === "commit" || next.mode.kind === "commit") return true;
return (
previous.mode.onFilePress === next.mode.onFilePress &&
previous.mode.workspaceFileDragScope === next.mode.workspaceFileDragScope &&
previous.mode.onOpenFile === next.mode.onOpenFile &&
previous.mode.onAddToChat === next.mode.onAddToChat &&
previous.mode.onCopyPath === next.mode.onCopyPath &&
previous.mode.onCopyRelativePath === next.mode.onCopyRelativePath &&
previous.mode.onReveal === next.mode.onReveal &&
previous.mode.revealTargetName === next.mode.revealTargetName &&
previous.mode.onDownload === next.mode.onDownload &&
previous.mode.onDuplicate === next.mode.onDuplicate &&
previous.mode.onRevert === next.mode.onRevert
);
}
function documentFileHeaderIdentityMatches(
previous: DocumentFileHeaderProps,
next: DocumentFileHeaderProps,
): boolean {
return !(
previous.file.file !== next.file.file ||
previous.file.fileIndex !== next.file.fileIndex ||
previous.file.isCollapsed !== next.file.isCollapsed ||
previous.showTopBorder !== next.showTopBorder ||
(previous.selectedPath === previous.file.path) !== (next.selectedPath === next.file.path) ||
previous.onToggleFile !== next.onToggleFile ||
previous.onSelectPath !== next.onSelectPath ||
previous.mode.kind !== next.mode.kind
);
}
@@ -1,12 +1,18 @@
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import { View } from "react-native";
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
import { useAnimatedScrollHandler, type SharedValue } from "react-native-reanimated";
import {
createAnimatedComponent,
useAnimatedScrollHandler,
type SharedValue,
} from "react-native-reanimated";
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures";
import { horizontalOffsetForPath, type DiffHorizontalOffsets } from "./horizontal-offsets";
import type { DiffFileSection } from "./types";
const AnimatedHorizontalScrollView = createAnimatedComponent(ScrollView);
export function HorizontalScroll({
file,
offsets,
@@ -48,16 +54,33 @@ export function HorizontalScroll({
},
[horizontalScroll, scrollId],
);
const beginHorizontalGesture = useCallback(() => {
horizontalScroll?.beginHorizontalGesture(horizontalOffsetForPath(offsets.value, file.path));
}, [file.path, horizontalScroll, offsets]);
const endHorizontalGesture = useCallback(() => {
horizontalScroll?.endHorizontalGesture();
}, [horizontalScroll]);
const rootStyle = useMemo(
() => ({ position: "absolute" as const, top: 0, left: 0, right: 0, height: file.bodyHeight }),
() => ({
position: "absolute" as const,
top: 0,
left: 0,
right: 0,
height: file.bodyHeight,
backgroundColor: "transparent",
}),
[file.bodyHeight],
);
const spacerStyle = useMemo(
() => ({ width: file.contentWidth, height: file.bodyHeight }),
() => ({
width: file.contentWidth,
height: file.bodyHeight,
backgroundColor: "transparent",
}),
[file.bodyHeight, file.contentWidth],
);
return (
<ScrollView
<AnimatedHorizontalScrollView
ref={scrollRef}
horizontal
nestedScrollEnabled
@@ -66,12 +89,15 @@ export function HorizontalScroll({
style={rootStyle}
contentContainerStyle={spacerStyle}
onScroll={handler}
onTouchStart={beginHorizontalGesture}
onTouchEnd={endHorizontalGesture}
onTouchCancel={endHorizontalGesture}
onScrollEndDrag={reportOffset}
scrollEventThrottle={16}
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
onMomentumScrollEnd={reportOffset}
>
<View style={spacerStyle} />
</ScrollView>
</AnimatedHorizontalScrollView>
);
}
+6 -2
View File
@@ -1,6 +1,6 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { withUnistyles } from "react-native-unistyles";
import { createDiffPalette } from "./palette";
import { createDiffPalette, retainDiffPalette } from "./palette";
import { DiffSurface } from "./surface";
import type { DiffDocumentProps, DiffPalette } from "./types";
@@ -14,6 +14,9 @@ const EMPTY_PATHS: string[] = [];
function ThemedDiffDocument(props: ThemedDiffDocumentProps) {
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const paletteRef = useRef(props.palette);
paletteRef.current = retainDiffPalette(paletteRef.current, props.palette);
const palette = paletteRef.current;
const collapseState = props.mode.kind === "working" ? props.collapseState : null;
const paths = collapseState?.paths ?? EMPTY_PATHS;
const collapsedFilePaths = useMemo(() => new Set(paths), [paths]);
@@ -30,6 +33,7 @@ function ThemedDiffDocument(props: ThemedDiffDocumentProps) {
return (
<DiffSurface
{...props}
palette={palette}
collapsedFilePaths={collapsedFilePaths}
onToggleFile={toggleFile}
selectedPath={selectedPath}
@@ -3,9 +3,11 @@ import { describe, expect, it } from "vitest";
import {
buildDiffDocumentModel,
captureScrollAnchor,
FILE_HEADER_HEIGHT,
graphemeBoundaries,
measureFragments,
resolveScrollAnchor,
resolveRelayoutScrollTop,
} from "./model";
import type { BuildDiffDocumentModelInput, TextMeasurer } from "./types";
@@ -63,6 +65,24 @@ function input(overrides: Partial<BuildDiffDocumentModelInput> = {}): BuildDiffD
}
describe("diff document model", () => {
it("segments graphemes when Intl.Segmenter is unavailable", () => {
const originalSegmenter = Intl.Segmenter;
Object.defineProperty(Intl, "Segmenter", { configurable: true, value: undefined });
try {
const source = "中é👨‍👩‍👧‍👦";
const fragments = measureFragments({
text: source,
availableWidth: 100,
wrapLines: true,
lineHeight: 18,
measureText: measurer,
});
expect(fragments[0]?.graphemes.map((grapheme) => grapheme.text)).toEqual(["中", "é", "👨‍👩‍👧‍👦"]);
} finally {
Object.defineProperty(Intl, "Segmenter", { configurable: true, value: originalSegmenter });
}
});
it("measures grapheme-safe wrapped fragments", () => {
const text = "ab👨‍👩‍👧‍👦cd";
const fragments = measureFragments({
@@ -161,6 +181,58 @@ describe("diff document model", () => {
expect(collapsed.height).toBeLessThan(expanded.height);
});
it("reuses unchanged file measurements when collapse state changes", () => {
const files = [file("a.ts"), file("b.ts")];
let measurementCount = 0;
const countingMeasurer: TextMeasurer = {
measure(text) {
measurementCount += 1;
return Array.from(text).length * 10;
},
};
const expanded = buildDiffDocumentModel(input({ files, measureText: countingMeasurer }));
const initialMeasurementCount = measurementCount;
const collapsed = buildDiffDocumentModel(
input({
files,
collapsedFilePaths: new Set(["a.ts"]),
measureText: countingMeasurer,
reuseFrom: [expanded],
}),
);
expect(measurementCount).toBe(initialMeasurementCount);
expect(collapsed.files[1]?.bodyHeight).toBe(expanded.files[1]?.bodyHeight);
expect(collapsed.rows.every((row, index) => row.index === index)).toBe(true);
expect(collapsed.rows[0]?.top).toBe(FILE_HEADER_HEIGHT * 2);
const expandedAgain = buildDiffDocumentModel(
input({
files,
measureText: countingMeasurer,
reuseFrom: [expanded, collapsed],
}),
);
expect(measurementCount).toBe(initialMeasurementCount);
expect(expandedAgain.height).toBe(expanded.height);
});
it("keeps the toggled sticky header anchored through collapse and expansion", () => {
const files = [file("a.ts"), file("b.ts")];
const expanded = buildDiffDocumentModel(input({ files }));
const firstFile = expanded.files[0]!;
const scrollTop = firstFile.top + 20;
const collapsed = buildDiffDocumentModel(
input({ files, collapsedFilePaths: new Set(["a.ts"]), reuseFrom: [expanded] }),
);
expect(resolveRelayoutScrollTop(expanded, collapsed, scrollTop)).toBe(collapsed.files[0]!.top);
expect(resolveRelayoutScrollTop(collapsed, expanded, collapsed.files[0]!.top)).toBe(
expanded.files[0]!.top,
);
});
it("reserves review geometry in the measured row", () => {
const model = buildDiffDocumentModel(
input({
+70 -12
View File
@@ -1,4 +1,5 @@
import { lineNumberGutterWidth } from "@/components/code-insets";
import { findClusterBreak } from "@marijn/find-cluster-break";
import {
buildSplitDiffRows,
buildUnifiedDiffLines,
@@ -22,7 +23,7 @@ import type {
TextMeasurer,
} from "./types";
export const FILE_HEADER_HEIGHT = 44;
export const FILE_HEADER_HEIGHT = 30;
const BODY_BORDER_HEIGHT = 1;
const CODE_HORIZONTAL_PADDING = 16;
@@ -49,7 +50,28 @@ export function buildDiffDocumentModel(input: BuildDiffDocumentModelInput): Diff
const gutterWidth = lineNumberGutterWidth(maximumLineNumber(file), input.typography.size);
let maximumHorizontalOverflow = 0;
if (!isCollapsed) {
const reusableModel = input.reuseFrom?.find((candidate) =>
candidate.files.some((entry) => entry.file === file && !entry.isCollapsed),
);
const reusableFile = reusableModel?.files.find(
(candidate) => candidate.file === file && !candidate.isCollapsed,
);
if (!isCollapsed && reusableFile) {
const reusedRows = reusableModel!.rows.slice(reusableFile.rowStart, reusableFile.rowEnd);
for (const reusedRow of reusedRows) {
const height = reusedRow.height;
rows.push({
...reusedRow,
index: rows.length,
fileIndex,
top: documentTop,
});
documentTop += height;
}
maximumHorizontalOverflow = Math.max(0, reusableFile.contentWidth - input.viewportWidth);
documentTop += BODY_BORDER_HEIGHT;
} else if (!isCollapsed) {
if (file.status === "binary" || file.status === "too_large") {
const height = input.typography.lineHeight + 24;
rows.push({
@@ -136,6 +158,16 @@ export function buildDiffDocumentModel(input: BuildDiffDocumentModelInput): Diff
};
}
export function retainReusableModels(
previous: readonly DiffDocumentModel[] | undefined,
next: DiffDocumentModel,
): DiffDocumentModel[] {
const fullest = [...(previous ?? []), next].reduce((best, candidate) =>
candidate.rows.length > best.rows.length ? candidate : best,
);
return fullest === next ? [next] : [fullest, next];
}
function lineSources(
file: BuildDiffDocumentModelInput["files"][number],
layout: "unified" | "split",
@@ -302,17 +334,19 @@ const TAB_SIZE = 4;
function segmentGraphemes(text: string): Array<Omit<DiffGrapheme, "width">> {
const segments: Array<Omit<DiffGrapheme, "width">> = [];
let column = 0;
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
for (const segment of segmenter.segment(text)) {
const isTab = segment.segment === "\t";
for (let index = 0; index < text.length; ) {
const end = findClusterBreak(text, index);
const segment = text.slice(index, end);
const isTab = segment === "\t";
const spaces = isTab ? TAB_SIZE - (column % TAB_SIZE) : 0;
const displayText = isTab ? " ".repeat(spaces) : segment.segment;
const displayText = isTab ? " ".repeat(spaces) : segment;
segments.push({
start: segment.index,
end: segment.index + segment.segment.length,
start: index,
end,
text: displayText,
});
column += isTab ? spaces : 1;
index = end;
}
return segments;
}
@@ -351,6 +385,7 @@ function measurePrefixAdvances(
}
export function fragmentTextForRange(fragment: DiffFragment, start: number, end: number): string {
"worklet";
return fragment.graphemes
.filter((grapheme) => grapheme.start >= start && grapheme.end <= end)
.map((grapheme) => grapheme.text)
@@ -365,10 +400,9 @@ export function fragmentWidthForRange(fragment: DiffFragment, start: number, end
export function graphemeBoundaries(text: string): number[] {
const boundaries = [0];
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
for (const segment of segmenter.segment(text)) {
const end = segment.index + segment.segment.length;
if (end > boundaries[boundaries.length - 1]) boundaries.push(end);
for (let index = 0; index < text.length; ) {
index = findClusterBreak(text, index);
boundaries.push(index);
}
return boundaries;
}
@@ -501,6 +535,30 @@ export function resolveScrollAnchor(model: DiffDocumentModel, anchor: DiffScroll
return Math.max(0, row.top + (fragment?.top ?? 0) - anchor.viewportDelta);
}
export function resolveRelayoutScrollTop(
previous: DiffDocumentModel,
next: DiffDocumentModel,
scrollTop: number,
): number {
const changedFiles = previous.files.filter((file) => {
const nextFile = next.files.find((candidate) => candidate.path === file.path);
return nextFile && nextFile.isCollapsed !== file.isCollapsed;
});
if (changedFiles.length === 1) {
const previousFile = changedFiles[0]!;
const nextFile = next.files.find((candidate) => candidate.path === previousFile.path);
if (nextFile) {
const headerDelta =
previousFile.top <= scrollTop && scrollTop < previousFile.bottom
? 0
: previousFile.top - scrollTop;
return Math.max(0, nextFile.top - headerDelta);
}
}
const anchor = captureScrollAnchor(previous, scrollTop);
return anchor ? resolveScrollAnchor(next, anchor) : scrollTop;
}
function sameSourceIdentity(left: DiffSourceIdentity, right: DiffSourceIdentity): boolean {
return (
left.hunkIndex === right.hunkIndex &&
@@ -1,24 +1,21 @@
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
import { describe, expect, it } from "vitest";
import { buildDiffDocumentModel } from "./model";
import { hitTestDiffPagePoint } from "./native-hit-testing";
import { hitTestDiffBodyPoint } from "./native-hit-testing";
import type { BuildDiffDocumentModelInput } from "./types";
const origin = { x: 80, y: 120 };
describe("native diff page-coordinate hit testing", () => {
it("resolves first and later files through the measured surface origin and vertical scroll", () => {
describe("native diff body-coordinate hit testing", () => {
it("resolves first and later files from their body-local coordinates", () => {
const model = build("unified");
const rows = model.rows.filter((row) => row.kind === "line" && row.cells[0]?.type === "add");
for (const row of rows) {
const scrollTop = Math.max(0, row.top - 30);
const hit = hitTestDiffPagePoint({
const file = model.files[row.fileIndex]!;
const hit = hitTestDiffBodyPoint({
model,
pageX: origin.x + model.files[row.fileIndex]!.gutterWidth + 18,
pageY: origin.y + row.top - scrollTop + 2,
surfaceOrigin: origin,
scrollTop,
horizontalOffsetForPath: () => 0,
file,
locationX: file.gutterWidth + 18,
locationY: row.top - file.bodyTop + 2,
horizontalOffset: 0,
});
expect(hit?.kind).toBe("cell");
if (hit?.kind === "cell") expect(hit.position.fileIndex).toBe(row.fileIndex);
@@ -33,18 +30,18 @@ describe("native diff page-coordinate hit testing", () => {
candidate.path === "src/second.ts" &&
candidate.cells[0]?.type === "add",
)!;
const hit = hitTestDiffPagePoint({
const file = model.files[1]!;
const hit = hitTestDiffBodyPoint({
model,
pageX: origin.x + model.files[1]!.gutterWidth + 8,
pageY: origin.y + row.top + 2,
surfaceOrigin: origin,
scrollTop: 0,
horizontalOffsetForPath: (path) => (path === "src/second.ts" ? 30 : 0),
file,
locationX: file.gutterWidth + 8,
locationY: row.top - file.bodyTop + 2,
horizontalOffset: 30,
});
expect(hit?.kind === "cell" ? hit.position.sourceOffset : -1).toBe(3);
});
it("resolves old and new split cells from page coordinates independent of the touch target", () => {
it("resolves old and new split cells from body-local coordinates", () => {
const model = build("split");
const row = model.rows.find(
(candidate) =>
@@ -58,13 +55,13 @@ describe("native diff page-coordinate hit testing", () => {
});
function hitAt(model: ReturnType<typeof buildDiffDocumentModel>, rowTop: number, x: number) {
return hitTestDiffPagePoint({
const file = model.files.find((entry) => entry.bodyTop <= rowTop && rowTop < entry.bottom)!;
return hitTestDiffBodyPoint({
model,
pageX: origin.x + x,
pageY: origin.y + rowTop + 2,
surfaceOrigin: origin,
scrollTop: 0,
horizontalOffsetForPath: () => 0,
file,
locationX: x,
locationY: rowTop - file.bodyTop + 2,
horizontalOffset: 0,
});
}
@@ -1,28 +1,17 @@
import { hitTestDiffDocument } from "./hit-testing";
import type { DiffDocumentModel, DiffHit } from "./types";
import type { DiffDocumentModel, DiffFileSection, DiffHit } from "./types";
export interface SurfaceWindowOrigin {
x: number;
y: number;
}
export function hitTestDiffPagePoint(input: {
export function hitTestDiffBodyPoint(input: {
model: DiffDocumentModel;
pageX: number;
pageY: number;
surfaceOrigin: SurfaceWindowOrigin;
scrollTop: number;
horizontalOffsetForPath: (path: string) => number;
file: DiffFileSection;
locationX: number;
locationY: number;
horizontalOffset: number;
}): DiffHit | null {
const x = input.pageX - input.surfaceOrigin.x;
const documentY = input.pageY - input.surfaceOrigin.y + input.scrollTop;
const file = input.model.files.find(
(entry) => entry.top <= documentY && documentY < entry.bottom,
);
return hitTestDiffDocument({
model: input.model,
x,
documentY,
horizontalOffset: file ? input.horizontalOffsetForPath(file.path) : 0,
x: input.locationX,
documentY: input.file.bodyTop + input.locationY,
horizontalOffset: input.horizontalOffset,
});
}
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { FILE_HEADER_HEIGHT } from "./model";
import {
buildNativeCanvasSlabs,
nativeCanvasSlabsForViewport,
nativeCanvasWindowBucket,
nativeCanvasWindowTop,
nativeStickyHeaderIndices,
} from "./native-slabs";
import type { DiffDocumentModel } from "./types";
function modelWithBodies(
bodies: Array<{ path: string; bodyTop: number; bottom: number }>,
): DiffDocumentModel {
return {
files: bodies.map((body, fileIndex) => ({
file: {} as never,
fileIndex,
path: body.path,
top: body.bodyTop - FILE_HEADER_HEIGHT,
headerHeight: FILE_HEADER_HEIGHT,
bodyTop: body.bodyTop,
bodyHeight: body.bottom - body.bodyTop,
bottom: body.bottom,
gutterWidth: 48,
contentWidth: 500,
rowStart: 0,
rowEnd: 0,
isCollapsed: body.bottom === body.bodyTop,
})),
rows: [],
height: bodies.at(-1)?.bottom ?? 0,
lineHeight: 20,
layout: "unified",
wrapLines: false,
viewportWidth: 400,
};
}
describe("native canvas slabs", () => {
it("identifies each direct file header child for native sticky scrolling", () => {
expect(nativeStickyHeaderIndices(0)).toEqual([]);
expect(nativeStickyHeaderIndices(3)).toEqual([1, 3, 5]);
});
it("cuts large file bodies into fixed document-positioned slabs", () => {
const slabs = buildNativeCanvasSlabs(
modelWithBodies([
{ path: "small.ts", bodyTop: FILE_HEADER_HEIGHT, bottom: FILE_HEADER_HEIGHT + 200 },
{
path: "large.ts",
bodyTop: FILE_HEADER_HEIGHT * 2 + 200,
bottom: FILE_HEADER_HEIGHT * 2 + 3400,
},
]),
600,
);
expect(slabs.map(({ path, top, height }) => ({ path, top, height }))).toEqual([
{ path: "small.ts", top: 30, height: 200 },
{ path: "large.ts", top: 260, height: 1200 },
{ path: "large.ts", top: 1460, height: 1200 },
{ path: "large.ts", top: 2660, height: 800 },
]);
});
it("retains only viewport-adjacent slabs and updates on half-viewport boundaries", () => {
const slabs = buildNativeCanvasSlabs(
modelWithBodies([
{ path: "large.ts", bodyTop: FILE_HEADER_HEIGHT, bottom: FILE_HEADER_HEIGHT + 4800 },
]),
600,
);
expect(nativeCanvasSlabsForViewport(slabs, 1700, 600).map((slab) => slab.top)).toEqual([
30, 1230, 2430,
]);
expect(nativeCanvasWindowBucket(899, 600)).toBe(2);
expect(nativeCanvasWindowTop(2, 600)).toBe(600);
});
});
@@ -0,0 +1,54 @@
import type { DiffDocumentModel } from "./types";
export interface NativeCanvasSlab {
key: string;
path: string;
fileIndex: number;
top: number;
height: number;
}
export function nativeStickyHeaderIndices(fileCount: number): number[] {
return Array.from({ length: fileCount }, (_, index) => index * 2 + 1);
}
export function buildNativeCanvasSlabs(
model: DiffDocumentModel,
viewportHeight: number,
): NativeCanvasSlab[] {
const slabHeight = Math.max(768, viewportHeight * 2);
return model.files.flatMap((file) => {
const slabs: NativeCanvasSlab[] = [];
for (let top = file.bodyTop; top < file.bottom; top += slabHeight) {
const height = Math.min(slabHeight, file.bottom - top);
slabs.push({
key: `${file.path}:${top}:${height}`,
path: file.path,
fileIndex: file.fileIndex,
top,
height,
});
}
return slabs;
});
}
export function nativeCanvasSlabsForViewport(
slabs: readonly NativeCanvasSlab[],
scrollTop: number,
viewportHeight: number,
): NativeCanvasSlab[] {
const top = Math.max(0, scrollTop - viewportHeight);
const bottom = scrollTop + viewportHeight * 2;
return slabs.filter((slab) => slab.top < bottom && slab.top + slab.height > top);
}
export function nativeCanvasWindowBucket(scrollTop: number, viewportHeight: number): number {
"worklet";
return Math.floor(Math.max(0, scrollTop) / Math.max(1, viewportHeight / 2));
}
export function nativeCanvasWindowTop(bucket: number, viewportHeight: number): number {
"worklet";
return bucket * Math.max(1, viewportHeight / 2);
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { nativeTextRuns } from "./native-text-runs";
import type { DiffCell, DiffFragment } from "./types";
describe("native diff text runs", () => {
it("positions adjacent token text without redrawing through clipped color bands", () => {
const fragment: DiffFragment = {
start: 0,
end: 6,
text: "import",
width: 42,
top: 0,
baseline: 14,
graphemes: [..."import"].map((text, index) => ({
start: index,
end: index + 1,
text,
width: 7,
})),
};
const cell: DiffCell = {
type: "context",
content: "import",
lineNumber: 1,
tokens: [
{ start: 0, end: 2, color: "purple" },
{ start: 2, end: 6, color: "blue" },
],
fragments: [fragment],
reviewTarget: null,
sourceIdentity: { hunkIndex: 0, lineIndex: 1, side: "new" },
};
expect(nativeTextRuns(cell, fragment)).toEqual([
{ color: "purple", left: 0, text: "im" },
{ color: "blue", left: 14, text: "port" },
]);
});
});
@@ -0,0 +1,23 @@
import { fragmentTextForRange } from "./model";
import type { DiffCell, DiffFragment } from "./types";
export interface NativeTextRun {
color: string;
left: number;
text: string;
}
/** Converts syntax ranges into disjoint positioned runs so native paints every glyph once. */
export function nativeTextRuns(cell: DiffCell, fragment: DiffFragment): NativeTextRun[] {
"worklet";
return cell.tokens.flatMap((token) => {
const start = Math.max(fragment.start, token.start);
const end = Math.min(fragment.end, token.end);
if (end <= start) return [];
let left = 0;
for (const grapheme of fragment.graphemes) {
if (grapheme.end <= start) left += grapheme.width;
}
return [{ color: token.color, left, text: fragmentTextForRange(fragment, start, end) }];
});
}
@@ -1,16 +1,30 @@
import { ClipOp, Skia, type SkCanvas, type SkPaint } from "@shopify/react-native-skia";
import {
ClipOp,
Skia,
type SkCanvas,
type SkPaint,
type SkPicture,
} from "@shopify/react-native-skia";
import { visibleRowRange } from "./model";
import { nativeTextRuns } from "./native-text-runs";
import { horizontalOffsetForPath, type DiffHorizontalOffsets } from "./horizontal-offsets";
import { codeLineNumberTone } from "./palette";
import { reviewBackgroundPaint, reviewDividerHeight, reviewGapTop } from "./review-paint";
import type { NativeTextLayout } from "./text.native";
import type { DiffCell, DiffDocumentModel, DiffLineRow, DiffPalette } from "./types";
import type {
DiffCell,
DiffDocumentModel,
DiffFileSection,
DiffLineRow,
DiffPalette,
} from "./types";
const CODE_LEFT_PADDING = 8;
interface NativePaints {
export interface NativePaints {
surface: SkPaint;
border: SkPaint;
foreground: SkPaint;
foregroundMuted: SkPaint;
addition: SkPaint;
deletion: SkPaint;
@@ -18,13 +32,14 @@ interface NativePaints {
deletionBackground: SkPaint;
headerSurface: SkPaint;
emptyBackground: SkPaint;
syntax: Record<string, SkPaint>;
text: Record<string, SkPaint>;
}
export function createNativePaints(palette: DiffPalette): NativePaints {
return {
surface: paint(palette.surface),
border: paint(palette.border),
foreground: paint(palette.foreground),
foregroundMuted: paint(palette.foregroundMuted),
addition: paint(palette.addition),
deletion: paint(palette.deletion),
@@ -32,7 +47,11 @@ export function createNativePaints(palette: DiffPalette): NativePaints {
deletionBackground: paint(palette.deletionBackground),
headerSurface: paint(palette.headerSurface),
emptyBackground: paint(palette.emptyBackground),
syntax: Object.fromEntries(Object.values(palette.syntax).map((color) => [color, paint(color)])),
text: Object.fromEntries(
[
...new Set([palette.foreground, palette.foregroundMuted, ...Object.values(palette.syntax)]),
].map((color) => [color, paint(color)]),
),
};
}
@@ -52,113 +71,308 @@ export function paintNativeViewport(input: {
textLayout: NativeTextLayout;
paints: NativePaints;
}): void {
"worklet";
input.canvas.drawRect(
Skia.XYWHRect(0, 0, input.viewportWidth, input.viewportHeight),
input.paints.surface,
paintNativeRange({ ...input, layer: "all" });
}
export function recordNativeSlabPictures(input: {
model: DiffDocumentModel;
fileIndex: number;
top: number;
height: number;
viewportWidth: number;
textLayout: NativeTextLayout;
paints: NativePaints;
}): {
fixed: SkPicture;
gutter: SkPicture;
content: { unified: SkPicture; left: SkPicture; right: SkPicture };
} {
const file = input.model.files[input.fileIndex];
const fixedRecorder = Skia.PictureRecorder();
const fixedCanvas = fixedRecorder.beginRecording(
Skia.XYWHRect(0, 0, input.viewportWidth, input.height),
);
paintNativeRange({
canvas: fixedCanvas,
model: input.model,
viewportWidth: input.viewportWidth,
viewportHeight: input.height,
scrollTop: input.top,
horizontalOffsets: {},
textLayout: input.textLayout,
paints: input.paints,
layer: "fixed",
});
const recordContent = (contentCell: "unified" | "left" | "right") => {
const recorder = Skia.PictureRecorder();
const canvas = recorder.beginRecording(
Skia.XYWHRect(0, 0, Math.max(input.viewportWidth, file?.contentWidth ?? 0), input.height),
);
paintNativeRange({
canvas,
model: input.model,
viewportWidth: input.viewportWidth,
viewportHeight: input.height,
scrollTop: input.top,
horizontalOffsets: {},
textLayout: input.textLayout,
paints: input.paints,
layer: "content",
contentCell,
});
return recorder.finishRecordingAsPicture();
};
const gutterRecorder = Skia.PictureRecorder();
const gutterCanvas = gutterRecorder.beginRecording(
Skia.XYWHRect(0, 0, input.viewportWidth, input.height),
);
paintNativeGutter({
canvas: gutterCanvas,
model: input.model,
top: input.top,
height: input.height,
textLayout: input.textLayout,
paints: input.paints,
});
return {
fixed: fixedRecorder.finishRecordingAsPicture(),
gutter: gutterRecorder.finishRecordingAsPicture(),
content: {
unified: recordContent("unified"),
left: recordContent("left"),
right: recordContent("right"),
},
};
}
function paintNativeGutter(input: {
canvas: SkCanvas;
model: DiffDocumentModel;
top: number;
height: number;
textLayout: NativeTextLayout;
paints: NativePaints;
}): void {
const range = visibleRowRange(input.model.rows, input.top, input.height);
for (let index = range.start; index < range.end; index += 1) {
const row = input.model.rows[index];
if (!row || row.kind !== "line") continue;
const file = input.model.files[row.fileIndex];
if (!file) continue;
const y = row.top - input.top;
const columnWidth = input.model.viewportWidth / row.cells.length;
row.cells.forEach((cell, cellIndex) => {
if (!cell) return;
const columnX = cellIndex * columnWidth;
input.canvas.drawRect(
Skia.XYWHRect(columnX + file.gutterWidth, y, 1, reviewDividerHeight(row.height)),
input.paints.border,
);
const label = String(cell.lineNumber ?? "");
const font = input.textLayout.font;
input.canvas.drawText(
label,
columnX + file.gutterWidth - 7 - font.getTextWidth(label),
y + input.model.lineHeight * 0.78,
input.paints[codeLineNumberTone(cell)],
font,
);
});
}
}
interface PaintNativeRangeInput {
canvas: SkCanvas;
model: DiffDocumentModel;
viewportWidth: number;
viewportHeight: number;
scrollTop: number;
horizontalOffsets: Readonly<DiffHorizontalOffsets>;
textLayout: NativeTextLayout;
paints: NativePaints;
layer: "all" | "fixed" | "content";
contentCell?: "unified" | "left" | "right";
}
function paintNativeRange(input: PaintNativeRangeInput): void {
"worklet";
const paintsFixedContent = input.layer !== "content";
const paintsCodeContent = input.layer !== "fixed";
if (paintsFixedContent) {
input.canvas.drawRect(
Skia.XYWHRect(0, 0, input.viewportWidth, input.viewportHeight),
input.paints.surface,
);
}
const backgroundPaints: Record<DiffCell["type"], SkPaint> = {
add: input.paints.additionBackground,
context: input.paints.surface,
empty: input.paints.emptyBackground,
header: input.paints.headerSurface,
remove: input.paints.deletionBackground,
};
const range = visibleRowRange(input.model.rows, input.scrollTop, input.viewportHeight);
for (let index = range.start; index < range.end; index += 1) {
const row = input.model.rows[index];
if (!row) continue;
const y = row.top - input.scrollTop;
if (row.kind === "status") {
input.canvas.drawText(
row.label,
12,
y + input.model.lineHeight,
input.paints.foregroundMuted,
input.textLayout.font,
);
if (paintsFixedContent) {
input.canvas.drawText(
row.label,
12,
y + input.model.lineHeight,
input.paints.foregroundMuted,
input.textLayout.font,
);
}
continue;
}
paintLine({
...input,
row,
y,
horizontalOffset: horizontalOffsetForPath(input.horizontalOffsets, row.path),
const file = input.model.files[row.fileIndex];
if (!file) continue;
const horizontalOffset = horizontalOffsetForPath(input.horizontalOffsets, row.path);
const columnWidth = input.viewportWidth / row.cells.length;
row.cells.forEach((cell, cellIndex) => {
const columnX = cellIndex * columnWidth;
if (paintsFixedContent) {
paintNativeFixedCell({ input, row, file, cell, columnX, columnWidth, y, backgroundPaints });
}
if (
paintsCodeContent &&
cell &&
paintsNativeContentCell(input, row.cells.length, cellIndex)
) {
paintNativeCodeCell({
input,
row,
file,
cell,
cellIndex,
columnX,
columnWidth,
horizontalOffset,
y,
});
}
});
}
}
function paintLine(input: {
canvas: SkCanvas;
model: DiffDocumentModel;
viewportWidth: number;
function paintsNativeContentCell(
input: PaintNativeRangeInput,
columnCount: number,
cellIndex: number,
): boolean {
if (input.layer !== "content") return true;
if (columnCount === 1) return input.contentCell === "unified";
return input.contentCell === (cellIndex === 0 ? "left" : "right");
}
function paintNativeFixedCell(input: {
input: PaintNativeRangeInput;
row: DiffLineRow;
file: DiffFileSection;
cell: DiffCell | null;
columnX: number;
columnWidth: number;
y: number;
horizontalOffset: number;
textLayout: NativeTextLayout;
paints: NativePaints;
backgroundPaints: Record<DiffCell["type"], SkPaint>;
}): void {
"worklet";
const file = input.model.files[input.row.fileIndex];
if (!file) return;
const columnWidth = input.viewportWidth / input.row.cells.length;
input.row.cells.forEach((cell, cellIndex) => {
const columnX = cellIndex * columnWidth;
input.canvas.drawRect(
Skia.XYWHRect(columnX, input.y, columnWidth, input.row.height),
background(cell, input.paints),
);
if (input.row.reviewHeight > 0) {
input.canvas.drawRect(
Skia.XYWHRect(
columnX,
reviewGapTop(input.y, input.row.height, input.row.reviewHeight),
columnWidth,
input.row.reviewHeight,
),
reviewBackgroundPaint(input.paints.surface),
);
}
if (!cell) return;
input.canvas.drawRect(
Skia.XYWHRect(columnX + file.gutterWidth, input.y, 1, reviewDividerHeight(input.row.height)),
input.paints.border,
);
if (cell.lineNumber !== null) {
const label = String(cell.lineNumber);
const font = input.textLayout.font;
input.canvas.drawText(
label,
columnX + file.gutterWidth - 7 - font.getTextWidth(label),
input.y + input.model.lineHeight * 0.78,
lineNumberPaint(cell, input.paints),
font,
);
}
const textX = columnX + file.gutterWidth + CODE_LEFT_PADDING;
input.canvas.save();
input.canvas.clipRect(
const backgroundPaint = input.cell
? input.backgroundPaints[input.cell.type]
: input.input.paints.emptyBackground;
input.input.canvas.drawRect(
Skia.XYWHRect(input.columnX, input.y, input.columnWidth, input.row.height),
backgroundPaint,
);
input.input.canvas.drawRect(
Skia.XYWHRect(
input.columnX,
reviewGapTop(input.y, input.row.height, input.row.reviewHeight),
input.columnWidth,
input.row.reviewHeight,
),
reviewBackgroundPaint(input.input.paints.surface),
);
if (!input.cell) return;
input.input.canvas.drawRect(
Skia.XYWHRect(
input.columnX + input.file.gutterWidth,
input.y,
1,
reviewDividerHeight(input.row.height),
),
input.input.paints.border,
);
const label = String(input.cell.lineNumber ?? "");
const font = input.input.textLayout.font;
input.input.canvas.drawText(
label,
input.columnX + input.file.gutterWidth - 7 - font.getTextWidth(label),
input.y + input.input.model.lineHeight * 0.78,
input.input.paints[codeLineNumberTone(input.cell)],
font,
);
}
function paintNativeCodeCell(input: {
input: PaintNativeRangeInput;
row: DiffLineRow;
file: DiffFileSection;
cell: DiffCell;
cellIndex: number;
columnX: number;
columnWidth: number;
horizontalOffset: number;
y: number;
}): void {
const textX = input.columnX + input.file.gutterWidth + CODE_LEFT_PADDING;
const clipsContent = input.input.layer === "all";
if (clipsContent) {
input.input.canvas.save();
input.input.canvas.clipRect(
Skia.XYWHRect(
textX,
input.y,
columnWidth - file.gutterWidth - CODE_LEFT_PADDING,
input.columnWidth - input.file.gutterWidth - CODE_LEFT_PADDING,
input.row.height,
),
ClipOp.Intersect,
false,
);
const offset = input.model.wrapLines ? 0 : input.horizontalOffset;
for (const [fragmentIndex, fragment] of cell.fragments.entries()) {
const paragraph = input.textLayout.paragraphs[input.row.index]?.[cellIndex]?.[fragmentIndex];
paragraph?.paint(input.canvas, textX - offset, input.y + fragment.top);
}
const offset = input.input.model.wrapLines ? 0 : input.horizontalOffset;
for (const [fragmentIndex, fragment] of input.cell.fragments.entries()) {
const paragraph =
input.input.textLayout.paragraphs[input.row.index]?.[input.cellIndex]?.[fragmentIndex];
if (paragraph) {
paragraph.paint(input.input.canvas, textX - offset, input.y + fragment.top);
continue;
}
input.canvas.restore();
});
}
function background(cell: DiffCell | null, paints: NativePaints): SkPaint {
"worklet";
if (!cell) return paints.emptyBackground;
if (cell.type === "add") return paints.additionBackground;
if (cell.type === "remove") return paints.deletionBackground;
if (cell.type === "header") return paints.headerSurface;
return paints.surface;
}
function lineNumberPaint(cell: DiffCell, paints: NativePaints): SkPaint {
"worklet";
return paints[codeLineNumberTone(cell)];
const fragmentX = textX - offset;
const baseline = input.y + fragment.baseline;
if (input.cell.tokens.length === 0 || input.cell.type === "header") {
input.input.canvas.drawText(
fragment.text,
fragmentX,
baseline,
input.cell.type === "header"
? input.input.paints.foregroundMuted
: input.input.paints.foreground,
input.input.textLayout.font,
);
continue;
}
for (const run of nativeTextRuns(input.cell, fragment)) {
input.input.canvas.drawText(
run.text,
fragmentX + run.left,
baseline,
input.input.paints.text[run.color] ?? input.input.paints.foreground,
input.input.textLayout.font,
);
}
}
if (clipsContent) input.input.canvas.restore();
}
@@ -26,19 +26,27 @@ export interface PaintWebViewportInput {
horizontalOffsets: ReadonlyMap<string, number>;
selection: DiffSelection | null;
devicePixelRatio: number;
paintTop?: number;
paintHeight?: number;
}
export function paintWebViewport(input: PaintWebViewportInput): void {
const { context } = input;
const scale = input.devicePixelRatio;
const paintTop = input.paintTop ?? 0;
const paintHeight = input.paintHeight ?? input.viewportHeight;
context.setTransform(scale, 0, 0, scale, 0, 0);
context.clearRect(0, 0, input.viewportWidth, input.viewportHeight);
context.save();
context.beginPath();
context.rect(0, paintTop, input.viewportWidth, paintHeight);
context.clip();
context.clearRect(0, paintTop, input.viewportWidth, paintHeight);
context.fillStyle = input.palette.surface;
context.fillRect(0, 0, input.viewportWidth, input.viewportHeight);
context.fillRect(0, paintTop, input.viewportWidth, paintHeight);
context.font = `${input.typography.size}px ${input.typography.family}`;
context.textBaseline = "alphabetic";
const range = visibleRowRange(input.model.rows, input.scrollTop, input.viewportHeight);
const range = visibleRowRange(input.model.rows, input.scrollTop + paintTop, paintHeight);
for (let index = range.start; index < range.end; index += 1) {
const row = input.model.rows[index];
if (!row) continue;
@@ -57,6 +65,7 @@ export function paintWebViewport(input: PaintWebViewportInput): void {
}
if (input.selection) paintSelection(input, input.selection);
context.restore();
}
function paintLine(
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import { codeLineNumberTone, codeTextColor } from "./palette";
import { darkTheme, lightTheme } from "@/styles/theme";
import { hexColorWithAlpha } from "@/utils/color";
import { codeLineNumberTone, codeTextColor, createDiffPalette, retainDiffPalette } from "./palette";
import type { DiffCell, DiffPalette } from "./types";
describe("diff text color", () => {
@@ -19,6 +21,32 @@ describe("diff text color", () => {
});
});
describe("diff palette retention", () => {
it("retains the previous value when a theme wrapper recreates equal colors", () => {
const recreated = { ...palette, syntax: { keyword: "purple" } };
const previous = { ...recreated, syntax: { keyword: "purple" } };
expect(retainDiffPalette(previous, recreated)).toBe(previous);
});
it("accepts a real color change", () => {
const changed = { ...palette, foreground: "new-foreground" };
expect(retainDiffPalette(palette, changed)).toBe(changed);
});
});
describe.each([lightTheme, darkTheme])("semantic diff colors", (theme) => {
it("uses the app status palette for gutter text and derived row tints", () => {
const created = createDiffPalette(theme);
expect(created.addition).toBe(theme.colors.statusSuccess);
expect(created.deletion).toBe(theme.colors.statusDanger);
expect(created.additionBackground).toBe(hexColorWithAlpha(theme.colors.statusSuccess, 0.15));
expect(created.deletionBackground).toBe(hexColorWithAlpha(theme.colors.statusDanger, 0.1));
});
});
const palette: DiffPalette = {
surface: "surface",
headerSurface: "header",
+35 -8
View File
@@ -1,23 +1,50 @@
import type { Theme } from "@/styles/theme";
import { hexColorWithAlpha } from "@/utils/color";
import type { DiffCell, DiffPalette } from "./types";
export function createDiffPalette(theme: Theme): DiffPalette {
return {
surface: theme.colors.surface1,
headerSurface: theme.colors.surface2,
surface: theme.colors.surface0,
headerSurface: theme.colors.surface0,
border: theme.colors.border,
foreground: theme.colors.foreground,
foregroundMuted: theme.colors.foregroundMuted,
addition: theme.colors.diffAddition,
deletion: theme.colors.diffDeletion,
additionBackground: "rgba(46, 160, 67, 0.15)",
deletionBackground: "rgba(248, 81, 73, 0.1)",
emptyBackground: theme.colors.surfaceDiffEmpty,
selection: theme.colors.surfaceSidebarHover,
addition: theme.colors.statusSuccess,
deletion: theme.colors.statusDanger,
additionBackground: hexColorWithAlpha(theme.colors.statusSuccess, 0.15),
deletionBackground: hexColorWithAlpha(theme.colors.statusDanger, 0.1),
emptyBackground: theme.colors.surface0,
selection: theme.colors.terminal.blue,
syntax: theme.colors.syntax,
};
}
export function retainDiffPalette(previous: DiffPalette, next: DiffPalette): DiffPalette {
if (
previous.surface !== next.surface ||
previous.headerSurface !== next.headerSurface ||
previous.border !== next.border ||
previous.foreground !== next.foreground ||
previous.foregroundMuted !== next.foregroundMuted ||
previous.addition !== next.addition ||
previous.deletion !== next.deletion ||
previous.additionBackground !== next.additionBackground ||
previous.deletionBackground !== next.deletionBackground ||
previous.emptyBackground !== next.emptyBackground ||
previous.selection !== next.selection ||
!sameColorMap(previous.syntax, next.syntax)
) {
return next;
}
return previous;
}
function sameColorMap(left: Record<string, string>, right: Record<string, string>): boolean {
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
return leftKeys.length === rightKeys.length && leftKeys.every((key) => left[key] === right[key]);
}
export function codeTextColor(cell: DiffCell, palette: DiffPalette): string {
return cell.type === "header" ? palette.foregroundMuted : palette.foreground;
}
@@ -1,39 +1,72 @@
import { Canvas, Picture, Skia } from "@shopify/react-native-skia";
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 LayoutChangeEvent, type ViewStyle } from "react-native";
import {
ScrollView,
StyleSheet,
View,
type GestureResponderEvent,
type LayoutChangeEvent,
type ViewStyle,
} from "react-native";
import {
createAnimatedComponent,
useAnimatedReaction,
useAnimatedScrollHandler,
useDerivedValue,
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 { hitTestDiffPagePoint, type SurfaceWindowOrigin } from "./native-hit-testing";
import { hitTestDiffBodyPoint } from "./native-hit-testing";
import { HorizontalScroll } from "./horizontal-scroll.native";
import {
horizontalOffsetForPath,
retainHorizontalOffsetsForPaths,
type DiffHorizontalOffsets,
} from "./horizontal-offsets";
import { buildDiffDocumentModel, captureScrollAnchor, resolveScrollAnchor } from "./model";
import { createNativePaints, paintNativeViewport } from "./paint.native";
import { createNativeTextLayout, createNativeTextMeasurer } from "./text.native";
import { InlineReviewThread } from "@/review";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import type { DiffSurfaceProps, DiffTypography, TextMeasurer } from "./types";
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 touchRef = useRef<{ x: number; y: number; startedAt: number; moved: boolean } | null>(null);
const scrollRef = useRef<ScrollView>(null);
const rootRef = useRef<View>(null);
const surfaceOriginRef = useRef<SurfaceWindowOrigin>({ x: 0, y: 0 });
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>({});
@@ -51,81 +84,89 @@ export function DiffSurface(props: DiffSurfaceProps) {
[family, typography.size],
);
const reviewActions = props.mode.kind === "working" ? props.mode.reviewActions : undefined;
const model = useMemo(
() =>
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"),
},
}),
[
measurement,
props.collapsedFilePaths,
const model = useMemo(() => {
const dependencies = [
props.files,
props.displayPreferences.layout,
props.displayPreferences.wrapLines,
props.files,
viewport.width,
typography,
measurement,
props.palette,
reviewActions,
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,
viewport.width,
],
);
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 textLayout = useMemo(
const textLayoutStore = useMemo(
() =>
createNativeTextLayout({
model,
createNativeTextLayoutStore({
configuredFamily: family,
fontSize: typography.size,
lineHeight: typography.lineHeight,
palette: props.palette,
}),
[family, model, props.palette, typography.size],
[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 picture = useDerivedValue(() => {
const recorder = Skia.PictureRecorder();
const canvas = recorder.beginRecording(Skia.XYWHRect(0, 0, viewport.width, viewport.height));
paintNativeViewport({
canvas,
model,
viewportWidth: viewport.width,
viewportHeight: viewport.height,
scrollTop: scrollTop.value,
horizontalOffsets: horizontalOffsets.value,
textLayout,
paints,
});
return recorder.finishRecordingAsPicture();
}, [model, paints, textLayout, viewport.height, viewport.width]);
const layout = useCallback((event: LayoutChangeEvent) => {
const { width, height } = event.nativeEvent.layout;
setViewport({ width, height });
rootRef.current?.measureInWindow((x, y) => {
surfaceOriginRef.current = { x, y };
});
setViewport((current) =>
current.width === width && current.height === height ? current : { width, height },
);
}, []);
useLayoutEffect(() => {
const previous = previousModelRef.current;
if (previous && previous !== model) {
const anchor = captureScrollAnchor(previous, scrollTop.value);
if (anchor) {
const nextScrollTop = resolveScrollAnchor(model, anchor);
scrollTop.value = nextScrollTop;
scrollRef.current?.scrollTo({ y: nextScrollTop, animated: false });
}
const nextScrollTop = resolveRelayoutScrollTop(previous, model, scrollTop.value);
scrollTop.value = nextScrollTop;
scrollRef.current?.scrollTo({ y: nextScrollTop, animated: false });
}
previousModelRef.current = model;
}, [model, scrollTop]);
@@ -135,6 +176,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
model.files.map((file) => file.path),
);
}, [horizontalOffsets, model.files]);
const mode = props.mode;
const collapsedFilePaths = props.collapsedFilePaths;
const onToggleFile = props.onToggleFile;
@@ -147,50 +189,22 @@ export function DiffSurface(props: DiffSurfaceProps) {
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]);
const touchStart = useCallback((event: { nativeEvent: { pageX: number; pageY: number } }) => {
touchRef.current = {
x: event.nativeEvent.pageX,
y: event.nativeEvent.pageY,
startedAt: Date.now(),
moved: false,
};
}, []);
const touchMove = useCallback((event: { nativeEvent: { pageX: number; pageY: number } }) => {
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: { nativeEvent: { pageX: number; pageY: number } }) => {
const touch = touchRef.current;
touchRef.current = null;
if (!touch || touch.moved || Date.now() - touch.startedAt > 500 || !reviewActions) return;
const hit = hitTestDiffPagePoint({
model,
pageX: event.nativeEvent.pageX,
pageY: event.nativeEvent.pageY,
surfaceOrigin: surfaceOriginRef.current,
scrollTop: scrollTop.value,
horizontalOffsetForPath: (path) => horizontalOffsetForPath(horizontalOffsets.value, path),
});
if (hit?.kind === "cell" && hit.target) reviewActions.onStartComment(hit.target);
},
[horizontalOffsets, model, reviewActions, scrollTop],
}, [collapsedFilePaths, mode, model.files, onToggleFile, scrollTop]);
const contentStyle = useMemo(
() => ({ minHeight: viewport.height, backgroundColor: "transparent" }),
[viewport.height],
);
const contentStyle = useMemo(() => ({ minHeight: viewport.height }), [viewport.height]);
const stickyHeaderIndices = useMemo(
() => nativeStickyHeaderIndices(model.files.length),
[model.files.length],
);
return (
<View
ref={rootRef}
style={[styles.root, { backgroundColor: props.palette.surface }]}
onLayout={layout}
>
<View style={[styles.root, { backgroundColor: props.palette.surface }]} onLayout={layout}>
<AnimatedScrollView
ref={scrollRef}
style={[StyleSheet.absoluteFill, styles.scroll]}
@@ -198,90 +212,372 @@ export function DiffSurface(props: DiffSurfaceProps) {
onScroll={scrollHandler}
scrollEventThrottle={16}
showsVerticalScrollIndicator
stickyHeaderIndices={model.files.map((_, index) => index * 2)}
stickyHeaderIndices={stickyHeaderIndices}
testID="git-diff-scroll"
onTouchStart={touchStart}
onTouchMove={touchMove}
onTouchEnd={touchEnd}
>
{model.files.flatMap((file) => [
<View key={`${file.path}:header`} style={styles.header}>
<DocumentFileHeader
file={file}
selectedPath={props.selectedPath}
mode={props.mode}
onToggleFile={props.onToggleFile}
onSelectPath={props.onSelectPath}
/>
</View>,
<View
<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`}
testID={`diff-file-${file.fileIndex}-body`}
style={inlineUnistylesStyle<ViewStyle>({
position: "relative",
height: file.bodyHeight,
})}
>
{!file.isCollapsed && !model.wrapLines ? (
<HorizontalScroll file={file} offsets={horizontalOffsets} />
) : null}
{props.mode.kind === "working" && reviewActions
? model.rows.slice(file.rowStart, file.rowEnd).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 - file.bodyTop + row.height - row.reviewHeight,
left: index * columnWidth,
width: columnWidth,
height: row.reviewHeight,
zIndex: 4,
})}
>
<InlineReviewThread
reviewTarget={cell.reviewTarget}
reviewActions={reviewActions}
height={row.reviewHeight}
viewportWidth={columnWidth}
pinToViewport={!model.wrapLines}
/>
</View>,
];
});
})
: null}
</View>,
file={file}
model={model}
mode={props.mode}
horizontalOffsets={horizontalOffsets}
/>,
])}
<NativeReviewOverlays model={model} mode={props.mode} />
</AnimatedScrollView>
<View
pointerEvents="none"
style={[StyleSheet.absoluteFill, styles.canvas]}
testID="git-diff-canvas"
>
</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>
</View>
</AnimatedCodeLayer>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, minHeight: 0, position: "relative", overflow: "hidden" },
canvas: { zIndex: 1 },
scroll: { zIndex: 2 },
header: { zIndex: 3 },
scroll: { backgroundColor: "transparent" },
header: { zIndex: 5 },
});
@@ -1,12 +1,19 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { InlineReviewThread } from "@/review";
import type { ViewStyle } from "react-native";
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
import { InlineReviewAddButton, InlineReviewThread } from "@/review";
import type { ReviewableDiffTarget } from "@/utils/diff-layout";
import { DocumentFileHeader } from "./document-file-header";
import { hitTestDiffDocument, selectedSourceText } from "./hit-testing";
import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets";
import { HorizontalScroll } from "./horizontal-scroll.web";
import { buildDiffDocumentModel, captureScrollAnchor, resolveScrollAnchor } from "./model";
import {
buildDiffDocumentModel,
FILE_HEADER_HEIGHT,
resolveRelayoutScrollTop,
retainReusableModels,
} from "./model";
import { paintWebViewport } from "./paint.web";
import { hasPointerDragStarted } from "./pointer-gesture";
import type {
@@ -23,9 +30,14 @@ export function DiffSurface(props: DiffSurfaceProps) {
const { t } = useTranslation();
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const canvasScratchRef = useRef<HTMLCanvasElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const modelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(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 scrollTopRef = useRef(0);
const horizontalOffsetsRef = useRef(new Map<string, number>());
@@ -37,6 +49,8 @@ export function DiffSurface(props: DiffSurfaceProps) {
moved: boolean;
} | null>(null);
const frameRef = useRef<number | null>(null);
const forcePaintRef = useRef(true);
const canvasWindowRef = useRef({ top: 0, height: 0 });
const [viewport, setViewport] = useState({ width: 0, height: 0 });
const [loadedTypography, setLoadedTypography] = useState<DiffTypography | null>(null);
const [hoveredAffordance, setHoveredAffordance] = useState<{
@@ -73,7 +87,23 @@ export function DiffSurface(props: DiffSurfaceProps) {
lineHeight: desiredTypography.lineHeight,
});
}
return buildDiffDocumentModel({
const dependencies = [
props.files,
props.displayPreferences.layout,
props.displayPreferences.wrapLines,
viewport.width,
loadedTypography,
measurement,
props.palette,
reviewActions,
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,
@@ -87,7 +117,13 @@ export function DiffSurface(props: DiffSurfaceProps) {
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,
@@ -108,31 +144,77 @@ export function DiffSurface(props: DiffSurfaceProps) {
const canvas = canvasRef.current;
const currentModel = modelRef.current;
if (!canvas || !currentModel || viewport.width <= 0 || viewport.height <= 0) return;
const desiredHeight = Math.min(
Math.max(currentModel.height, viewport.height),
viewport.height * 3,
);
const currentWindow = canvasWindowRef.current;
const viewportTop = scrollTopRef.current;
const viewportBottom = viewportTop + viewport.height;
const safeInset = viewport.height / 2;
const mustRecenter =
currentWindow.height !== desiredHeight ||
viewportTop < currentWindow.top + safeInset ||
viewportBottom > currentWindow.top + currentWindow.height - safeInset;
if (!forcePaintRef.current && !mustRecenter) return;
const forceFullPaint = forcePaintRef.current;
forcePaintRef.current = false;
const canvasHeight = desiredHeight;
const ratio = window.devicePixelRatio || 1;
const requestedCanvasTop = mustRecenter
? Math.min(
Math.max(0, viewportTop - viewport.height),
Math.max(0, currentModel.height - canvasHeight),
)
: currentWindow.top;
const canvasTop = Math.round(requestedCanvasTop * ratio) / ratio;
canvasWindowRef.current = { top: canvasTop, height: canvasHeight };
const pixelWidth = Math.ceil(viewport.width * ratio);
const pixelHeight = Math.ceil(viewport.height * ratio);
const pixelHeight = Math.ceil(canvasHeight * ratio);
const resized = canvas.width !== pixelWidth || canvas.height !== pixelHeight;
if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
canvas.style.top = `${canvasTop}px`;
canvas.style.height = `${canvasHeight}px`;
const context = canvas.getContext("2d");
if (!context) return;
if (!measurement || !loadedTypography) return;
const { paintTop, paintHeight } = shiftCanvasPixels({
canvas,
scratch:
canvasScratchRef.current ?? (canvasScratchRef.current = document.createElement("canvas")),
context,
currentTop: currentWindow.top,
nextTop: canvasTop,
canvasHeight,
pixelWidth,
pixelHeight,
ratio,
canReuse: !forceFullPaint && !resized && currentWindow.height === canvasHeight,
});
paintWebViewport({
context,
model: currentModel,
palette: props.palette,
typography: loadedTypography,
measureText: measurement,
scrollTop: scrollTopRef.current,
scrollTop: canvasTop,
viewportWidth: viewport.width,
viewportHeight: viewport.height,
viewportHeight: canvasHeight,
horizontalOffsets: horizontalOffsetsRef.current,
selection: selectionRef.current,
devicePixelRatio: ratio,
paintTop,
paintHeight,
});
}, [loadedTypography, measurement, props.palette, viewport.height, viewport.width]);
const schedulePaint = useCallback(() => {
if (frameRef.current === null) frameRef.current = requestAnimationFrame(paint);
}, [paint]);
const schedulePaint = useCallback(
(force = true) => {
if (force) forcePaintRef.current = true;
if (frameRef.current === null) frameRef.current = requestAnimationFrame(paint);
},
[paint],
);
useLayoutEffect(() => {
const root = rootRef.current;
@@ -162,12 +244,9 @@ export function DiffSurface(props: DiffSurfaceProps) {
const previous = previousModelRef.current;
const scroll = scrollRef.current;
if (previous && scroll && previous !== model) {
const anchor = captureScrollAnchor(previous, scroll.scrollTop);
if (anchor) {
const nextScrollTop = resolveScrollAnchor(model, anchor);
scrollTopRef.current = nextScrollTop;
scroll.scrollTop = nextScrollTop;
}
const nextScrollTop = resolveRelayoutScrollTop(previous, model, scroll.scrollTop);
scrollTopRef.current = nextScrollTop;
scroll.scrollTop = nextScrollTop;
}
previousModelRef.current = model;
}, [model]);
@@ -202,16 +281,42 @@ export function DiffSurface(props: DiffSurfaceProps) {
}, [collapsedFilePaths, mode, model.files, onToggleFile]);
const handleVerticalScroll = useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
scrollTopRef.current = event.currentTarget.scrollTop;
(scrollElement: HTMLDivElement) => {
const scrollTop = scrollElement.scrollTop;
scrollTopRef.current = scrollTop;
if (hasHoveredAffordanceRef.current) {
hasHoveredAffordanceRef.current = false;
setHoveredAffordance(null);
}
schedulePaint();
const currentModel = modelRef.current;
const currentWindow = canvasWindowRef.current;
if (!currentModel || currentWindow.height === 0) {
schedulePaint(false);
return;
}
const viewportBottom = scrollTop + viewport.height;
const safeInset = viewport.height / 2;
const crossedSafeInset =
scrollTop < currentWindow.top + safeInset ||
viewportBottom > currentWindow.top + currentWindow.height - safeInset;
if (!crossedSafeInset) return;
const requestedTop = Math.min(
Math.max(0, scrollTop - viewport.height),
Math.max(0, currentModel.height - currentWindow.height),
);
const ratio = window.devicePixelRatio || 1;
const nextTop = Math.round(requestedTop * ratio) / ratio;
if (nextTop !== currentWindow.top) schedulePaint(false);
},
[schedulePaint],
[schedulePaint, viewport.height],
);
useEffect(() => {
const scroll = scrollRef.current;
if (!scroll) return;
const onScroll = () => handleVerticalScroll(scroll);
scroll.addEventListener("scroll", onScroll, { passive: true });
return () => scroll.removeEventListener("scroll", onScroll);
}, [handleVerticalScroll]);
const handleHorizontalScroll = useCallback(
(path: string, offset: number) => {
horizontalOffsetsRef.current.set(path, offset);
@@ -247,6 +352,12 @@ export function DiffSurface(props: DiffSurfaceProps) {
)
)
return;
if (selectionRef.current) {
selectionRef.current = null;
dragRef.current = null;
schedulePaint();
return;
}
const hit = pointHit(event);
if (hit?.kind !== "cell") return;
dragRef.current = {
@@ -285,12 +396,12 @@ export function DiffSurface(props: DiffSurfaceProps) {
const gutterBorder = sideIndex * columnWidth + file.gutterWidth;
const rootBounds = rootRef.current?.getBoundingClientRect();
const pointerX = rootBounds ? event.clientX - rootBounds.left : Number.NaN;
if (Math.abs(pointerX - gutterBorder) <= 14) {
if (hit.target && Number.isFinite(pointerX)) {
hasHoveredAffordanceRef.current = true;
setHoveredAffordance({
hit,
left: gutterBorder - 14,
top: row.top - scrollTopRef.current + (modelRef.current!.lineHeight - 28) / 2,
left: gutterBorder - 12,
top: row.top - scrollTopRef.current + (modelRef.current!.lineHeight - 22) / 2,
});
} else {
hasHoveredAffordanceRef.current = false;
@@ -351,7 +462,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
() => ({ ...CONTENT_STYLE, height: Math.max(model.height, viewport.height) }),
[model.height, viewport.height],
);
const affordanceStyle = useMemo<React.CSSProperties>(
const affordanceStyle = useMemo<ViewStyle>(
() => ({
...AFFORDANCE_STYLE,
left: hoveredAffordance?.left ?? 0,
@@ -377,8 +488,8 @@ export function DiffSurface(props: DiffSurfaceProps) {
<div
ref={scrollRef}
data-testid="git-diff-scroll"
data-overlay-scrollbar="true"
tabIndex={0}
onScroll={handleVerticalScroll}
onPointerDown={pointerDown}
onPointerMove={pointerMove}
onPointerUp={pointerUp}
@@ -388,10 +499,12 @@ export function DiffSurface(props: DiffSurfaceProps) {
style={SCROLL_STYLE}
>
<div style={contentStyle} onMouseDown={preventDocumentMouseSelection}>
{model.files.map((file) => (
<canvas ref={canvasRef} data-testid="git-diff-canvas" style={canvasStyle} />
{model.files.map((file, index) => (
<WebFileHeaderSection key={file.path} file={file}>
<DocumentFileHeader
file={file}
showTopBorder={index > 0 && !model.files[index - 1]?.isCollapsed}
selectedPath={props.selectedPath}
mode={props.mode}
onToggleFile={props.onToggleFile}
@@ -438,21 +551,68 @@ export function DiffSurface(props: DiffSurfaceProps) {
: null}
</div>
</div>
<canvas ref={canvasRef} data-testid="git-diff-canvas" style={canvasStyle} />
<DomOverlayScrollbar scrollContainerRef={scrollRef} onUserScrollUp={noop} />
{hoveredAffordance?.hit.target && reviewActions ? (
<button
type="button"
aria-label={t("review.comment.add")}
onClick={addHoveredComment}
style={affordanceStyle}
>
+
</button>
<InlineReviewAddButton onPress={addHoveredComment} style={affordanceStyle} />
) : null}
</div>
);
}
function shiftCanvasPixels(input: {
canvas: HTMLCanvasElement;
scratch: HTMLCanvasElement;
context: CanvasRenderingContext2D;
currentTop: number;
nextTop: number;
canvasHeight: number;
pixelWidth: number;
pixelHeight: number;
ratio: number;
canReuse: boolean;
}): { paintTop: number; paintHeight: number } {
const delta = input.nextTop - input.currentTop;
if (!input.canReuse || delta === 0 || Math.abs(delta) >= input.canvasHeight) {
return { paintTop: 0, paintHeight: input.canvasHeight };
}
const deltaPixels = Math.round(Math.abs(delta) * input.ratio);
const overlapPixels = input.pixelHeight - deltaPixels;
if (input.scratch.width !== input.pixelWidth) input.scratch.width = input.pixelWidth;
if (input.scratch.height !== input.pixelHeight) input.scratch.height = input.pixelHeight;
const scratchContext = input.scratch.getContext("2d");
if (!scratchContext) return { paintTop: 0, paintHeight: input.canvasHeight };
scratchContext.setTransform(1, 0, 0, 1, 0, 0);
scratchContext.globalCompositeOperation = "copy";
scratchContext.drawImage(input.canvas, 0, 0);
input.context.setTransform(1, 0, 0, 1, 0, 0);
if (delta > 0) {
input.context.drawImage(
input.scratch,
0,
deltaPixels,
input.pixelWidth,
overlapPixels,
0,
0,
input.pixelWidth,
overlapPixels,
);
return { paintTop: input.canvasHeight - Math.abs(delta), paintHeight: Math.abs(delta) };
}
input.context.drawImage(
input.scratch,
0,
0,
input.pixelWidth,
overlapPixels,
0,
deltaPixels,
input.pixelWidth,
overlapPixels,
);
return { paintTop: 0, paintHeight: Math.abs(delta) };
}
function WebFileHeaderSection({
file,
children,
@@ -532,7 +692,11 @@ const SCROLL_STYLE: React.CSSProperties = {
zIndex: 2,
overflowY: "auto",
overflowX: "hidden",
scrollbarWidth: "none",
cursor: "text",
};
function noop(): void {}
const CONTENT_STYLE: React.CSSProperties = {
position: "relative",
width: "100%",
@@ -553,7 +717,7 @@ const STICKY_HEADER_STYLE: React.CSSProperties = {
};
const BODY_MARKER_STYLE: React.CSSProperties = {
position: "absolute",
top: 44,
top: FILE_HEADER_HEIGHT,
bottom: 0,
left: 0,
right: 0,
@@ -562,17 +726,16 @@ const BODY_MARKER_STYLE: React.CSSProperties = {
const REVIEW_STYLE: React.CSSProperties = { position: "absolute", zIndex: 4, userSelect: "text" };
const CANVAS_STYLE: React.CSSProperties = {
position: "absolute",
inset: 0,
top: 0,
left: 0,
right: 0,
zIndex: 1,
width: "100%",
height: "100%",
pointerEvents: "none",
};
const AFFORDANCE_STYLE: React.CSSProperties = {
const AFFORDANCE_STYLE: ViewStyle = {
position: "absolute",
zIndex: 5,
width: 28,
height: 28,
};
function emptyDiffDocumentModel(input: {
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { createFallbackAwareTextMeasurer, type PrimaryTextFace } from "./text-measurement";
import {
createFallbackAwareTextMeasurer,
requiresNativeParagraph,
type PrimaryTextFace,
} from "./text-measurement";
describe.each(["ios", "android"])("%s native text measurement", (platform) => {
it("routes CJK and emoji missing glyphs through the system fallback shaper", () => {
@@ -26,6 +30,16 @@ describe.each(["ios", "android"])("%s native text measurement", (platform) => {
});
});
describe("native paragraph retention", () => {
it("keeps ordinary code on the allocation-free font path", () => {
expect(requiresNativeParagraph("const answer = value + 1;")).toBe(false);
});
it.each(["value => next", "中", "e\u0301", "👩‍💻", "مرحبا"])("retains shaping for %s", (text) => {
expect(requiresNativeParagraph(text)).toBe(true);
});
});
function primaryFaceWithout(...missing: string[]): PrimaryTextFace {
return {
glyphIds(text) {
@@ -5,6 +5,12 @@ export interface PrimaryTextFace {
measure(text: string): number;
}
const CODE_LIGATURE_CANDIDATE = /(?:===?|!==?|=>|<=|>=|->|::|\+\+|--)/;
export function requiresNativeParagraph(text: string): boolean {
return /[^\x20-\x7e\t]/u.test(text) || CODE_LIGATURE_CANDIDATE.test(text);
}
export function createFallbackAwareTextMeasurer(input: {
primary: PrimaryTextFace;
measureWithSystemFallback: (text: string) => number;
@@ -7,7 +7,7 @@ import {
} from "@shopify/react-native-skia";
import { fragmentTextForRange } from "./model";
import { codeTextColor } from "./palette";
import { createFallbackAwareTextMeasurer } from "./text-measurement";
import { createFallbackAwareTextMeasurer, requiresNativeParagraph } from "./text-measurement";
import type { DiffCell, DiffDocumentModel, DiffFragment, DiffPalette, TextMeasurer } from "./types";
const PARAGRAPH_WIDTH = 100_000;
@@ -17,31 +17,69 @@ export interface NativeTextLayout {
paragraphs: Array<Array<Array<SkParagraph | null>>>;
}
export function createNativeTextLayout(input: {
model: DiffDocumentModel;
export interface NativeTextLayoutStore {
font: SkFont;
paragraphsByCell: WeakMap<DiffCell, Array<SkParagraph | null>>;
ownedParagraphs: Set<SkParagraph>;
families: string[];
fontSize: number;
lineHeight: number;
palette: DiffPalette;
}
export function disposeNativeTextLayout(layout: NativeTextLayoutStore): void {
for (const paragraph of layout.ownedParagraphs) paragraph.dispose();
layout.ownedParagraphs.clear();
layout.font.dispose();
}
export function createNativeTextLayoutStore(input: {
configuredFamily: string;
fontSize: number;
lineHeight: number;
palette: DiffPalette;
}): NativeTextLayout {
}): NativeTextLayoutStore {
const families = nativeFontFamilies(input.configuredFamily);
const font = primaryFont(families, input.fontSize);
const paragraphs = input.model.rows.map((row) => {
return {
font,
paragraphsByCell: new WeakMap(),
ownedParagraphs: new Set(),
families,
fontSize: input.fontSize,
lineHeight: input.lineHeight,
palette: input.palette,
};
}
export function prepareNativeTextLayout(
store: NativeTextLayoutStore,
model: DiffDocumentModel,
): NativeTextLayout {
const paragraphs = model.rows.map((row) => {
if (row.kind !== "line") return [];
return row.cells.map((cell) => {
if (!cell) return [];
return cell.fragments.map((fragment) =>
createFragmentParagraph({
const cached = store.paragraphsByCell.get(cell);
if (cached) return cached;
const cellParagraphs = cell.fragments.map((fragment) => {
if (!requiresRetainedParagraph(fragment.text, store.font)) return null;
const paragraph = createFragmentParagraph({
cell,
fragment,
families,
fontSize: input.fontSize,
lineHeight: input.model.lineHeight,
palette: input.palette,
}),
);
families: store.families,
fontSize: store.fontSize,
lineHeight: store.lineHeight,
palette: store.palette,
});
store.ownedParagraphs.add(paragraph);
return paragraph;
});
store.paragraphsByCell.set(cell, cellParagraphs);
return cellParagraphs;
});
});
return { font, paragraphs };
return { font: store.font, paragraphs };
}
export function createNativeTextMeasurer(input: {
@@ -63,13 +101,22 @@ export function createNativeTextMeasurer(input: {
lineHeight: Math.round(input.fontSize * 1.5),
color: Skia.Color("black"),
});
return paragraph.getLongestLine();
const width = paragraph.getLongestLine();
paragraph.dispose();
return width;
},
});
return {
...fallbackAware,
measureAdvances(graphemes) {
const text = graphemes.join("");
if (!requiresRetainedParagraph(text, font)) {
let prefix = "";
return graphemes.map((grapheme) => {
prefix += grapheme;
return font.getTextWidth(prefix);
});
}
const paragraph = createParagraph({
text,
families,
@@ -78,7 +125,7 @@ export function createNativeTextMeasurer(input: {
color: Skia.Color("black"),
});
let end = 0;
return graphemes.map((grapheme) => {
const advances = graphemes.map((grapheme) => {
end += grapheme.length;
const rectangles = paragraph.getRectsForRange(0, end);
return rectangles.reduce(
@@ -86,10 +133,16 @@ export function createNativeTextMeasurer(input: {
0,
);
});
paragraph.dispose();
return advances;
},
};
}
function requiresRetainedParagraph(text: string, font: SkFont): boolean {
return requiresNativeParagraph(text) || font.getGlyphIDs(text).some((glyph) => glyph === 0);
}
function createFragmentParagraph(input: {
cell: DiffCell;
fragment: DiffFragment;
@@ -167,6 +167,8 @@ export interface BuildDiffDocumentModelInput {
palette: DiffPalette;
reviewActions?: InlineReviewActions;
labels: { binary: string; tooLarge: string };
/** A geometry-compatible model whose unchanged file measurements may be reused. */
reuseFrom?: readonly DiffDocumentModel[];
}
export interface DiffCharacterPosition {