Make large diffs open quickly and scroll as one surface (#3212)

* perf(app): virtualize large diff rendering

Render bounded diff row groups through one vertical list and one horizontal surface. Bound retained list ranges when expansion grows the data set so large diffs paint promptly without sacrificing scrolling behavior.

* refactor(app): normalize working tree diff options

* fix(app): keep wide diff text reachable
This commit is contained in:
Mohamed Boudra
2026-08-11 12:18:48 +02:00
committed by GitHub
parent 7253228dc5
commit b599d38a77
17 changed files with 1717 additions and 717 deletions
+122 -39
View File
@@ -1,7 +1,7 @@
import { execFileSync } from "node:child_process";
import { readFile, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { type Page } from "@playwright/test";
import { type Locator, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes";
import { test, expect } from "../support/fixtures";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
@@ -63,6 +63,10 @@ async function failNextDiscardRequest(page: Page): Promise<void> {
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
function diffGroups(scope: Page | Locator, fileIndex = 0): Locator {
return scope.getByTestId(new RegExp(`^diff-file-${fileIndex}-group-\\d+$`));
}
const BEFORE = `import { useLayoutEffect, useMemo, useRef, useState } from "react";
interface UseMountedTabSetInput {
@@ -465,12 +469,12 @@ test("Changes switches between inline and full-tab navigation", async ({ page })
await expect(visiblePanel).toBeVisible();
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
await expect(visiblePanel).toContainText("zz-deleted.ts");
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await expect(diffGroups(visiblePanel).first()).toBeVisible();
await expect(page.getByTestId("workspace-file-pane")).toHaveCount(0);
await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0);
await expect(diffGroups(visiblePanel)).toHaveCount(0);
await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await expect(diffGroups(visiblePanel).first()).toBeVisible();
const workingDiffLayoutToggle = visiblePanel.getByTestId("working-diff-toggle-layout");
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
await workingDiffLayoutToggle.click();
@@ -486,14 +490,12 @@ test("Changes switches between inline and full-tab navigation", async ({ page })
);
await page.keyboard.press("Escape");
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0);
await expect(visiblePanel.getByTestId(/^diff-file-\d+-group-\d+$/)).toHaveCount(0);
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await expect(diffGroups(visiblePanel).first()).toBeVisible();
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toHaveCount(0);
await expect(diffGroups(page.getByTestId("explorer-content-area"))).toHaveCount(0);
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
@@ -516,13 +518,9 @@ test("Changes switches between inline and full-tab navigation", async ({ page })
await changesTabToggle.click();
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0);
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toBeVisible();
await expect(diffGroups(page.getByTestId("explorer-content-area")).first()).toBeVisible();
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toHaveCount(0);
await expect(diffGroups(page.getByTestId("explorer-content-area"))).toHaveCount(0);
});
test("changes diff switches between flat and tree file lists", async ({ page }) => {
@@ -585,7 +583,7 @@ test("changes diff switches between flat and tree file lists", async ({ page })
await page.getByRole("button", { name: "Collapse all" }).click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
await page.getByRole("button", { name: "Expand all" }).click();
await expect(page.getByTestId("diff-file-0-body")).toBeVisible();
await expect(diffGroups(page).first()).toBeVisible();
await page.getByTestId("diff-folder-src-toggle").click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
@@ -609,6 +607,90 @@ test("changes diff applies code size changes to gutter and code typography", asy
await expectVisibleDiffRowsShareTypography(page);
});
const LARGE_DIFF_WIDE_LINE_INDEX = 900;
async function createWorkspaceWithLargeUnwrappedDiff(): Promise<DirtyWorkspace> {
const workspace = await createWorkspaceWithMountedTabDiff();
const addedLines = Array.from({ length: 1_200 }, (_, index) => {
if (index === 0) return `\t\t\t\tconst tabbed_${index} = "tabbed";`;
if (index === LARGE_DIFF_WIDE_LINE_INDEX) {
return `const wide_${index} = "${"界".repeat(80)}";`;
}
return `const generated_${index} = ${index};`;
}).join("\n");
await writeFile(
path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"),
`${AFTER}${addedLines}\n`,
);
return workspace;
}
async function scrollToWideDiffLine(page: Page): Promise<Locator> {
await page.getByTestId("git-diff-scroll").evaluate((element, lineIndex) => {
element.scrollTop = lineIndex * 18;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
}, LARGE_DIFF_WIDE_LINE_INDEX);
const wideLine = page.getByText(new RegExp(`const wide_${LARGE_DIFF_WIDE_LINE_INDEX}`));
await expect(wideLine).toHaveCount(1);
return wideLine;
}
test("unwrapped virtualized rows use one horizontal file surface", async ({ page }) => {
const workspace = await createWorkspaceWithLargeUnwrappedDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const horizontalSurface = page.getByTestId("diff-horizontal-scroll");
await expect(horizontalSurface).toHaveCount(1);
await expect(page.getByTestId(/^diff-file-0-group-\d+-scroll$/)).toHaveCount(0);
const codeBefore = await page.getByTestId("diff-code-row-0").boundingBox();
const gutterBefore = await page.getByTestId("diff-gutter-row-0").boundingBox();
expect(codeBefore).not.toBeNull();
expect(gutterBefore).not.toBeNull();
await horizontalSurface.evaluate((element, left) => {
element.scrollLeft = left;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
}, 120);
await expect.poll(() => horizontalSurface.evaluate((element) => element.scrollLeft)).toBe(120);
await expect
.poll(async () => (await page.getByTestId("diff-code-row-0").boundingBox())?.x ?? 0)
.toBeLessThanOrEqual(codeBefore!.x - 119);
await expect
.poll(async () => (await page.getByTestId("diff-gutter-row-0").boundingBox())?.x ?? 0)
.toBeCloseTo(gutterBefore!.x, 0);
const initiallyMountedGroups = await readMountedDiffGroupIds(page);
await page.getByTestId("git-diff-scroll").evaluate((element) => {
element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
element.dispatchEvent(new Event("scroll", { bubbles: false }));
});
await expect
.poll(async () => {
const mountedGroups = await readMountedDiffGroupIds(page);
return mountedGroups.some((group) => !initiallyMountedGroups.includes(group));
})
.toBe(true);
await expect.poll(() => horizontalSurface.evaluate((element) => element.scrollLeft)).toBe(120);
await expect(page.getByTestId(/^diff-file-0-group-\d+-scroll$/)).toHaveCount(0);
const wideLine = await scrollToWideDiffLine(page);
await horizontalSurface.evaluate((element) => {
element.scrollLeft = element.scrollWidth;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
});
await expect
.poll(async () => {
const surfaceBounds = await horizontalSurface.boundingBox();
const wideTextRight = await wideLine.evaluate((element) => {
const range = document.createRange();
range.selectNodeContents(element);
return range.getBoundingClientRect().right;
});
if (!surfaceBounds) return false;
return wideTextRight <= surfaceBounds.x + surfaceBounds.width + 1;
})
.toBe(true);
});
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
await page.addInitScript(
({ settingsKey, fontSize }) => {
@@ -634,6 +716,12 @@ async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
);
}
async function readMountedDiffGroupIds(page: Page): Promise<(string | null)[]> {
return diffGroups(page).evaluateAll((groups) =>
groups.map((group) => group.getAttribute("data-testid")),
);
}
async function useUnwrappedDiffLines(page: Page): Promise<void> {
await page.addInitScript(
({ preferencesKey }) => {
@@ -682,21 +770,22 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
codeLineHeight: number;
}[];
}> {
return page.locator("body").evaluate(({ ownerDocument }) => {
const root = ownerDocument.querySelector('[data-testid="diff-file-0-body"]');
if (!root) {
return diffGroups(page).evaluateAll((groups) => {
if (groups.length === 0) {
throw new Error("Expanded diff body is not mounted");
}
const readRows = (prefix: string, textPrefix: string) =>
Array.from(root.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map((row) => {
const testId = row.getAttribute("data-testid") ?? "";
const index = Number(testId.slice(prefix.length));
const rect = row.getBoundingClientRect();
const text = root.querySelector<HTMLElement>(`[data-testid="${textPrefix}${index}"]`);
const lineHeight = text ? Number.parseFloat(getComputedStyle(text).lineHeight) : 0;
return { index, top: rect.top, height: rect.height, lineHeight };
});
groups.flatMap((group) =>
Array.from(group.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map((row) => {
const testId = row.getAttribute("data-testid") ?? "";
const index = Number(testId.slice(prefix.length));
const rect = row.getBoundingClientRect();
const text = row.querySelector<HTMLElement>(`[data-testid="${textPrefix}${index}"]`);
const lineHeight = text ? Number.parseFloat(getComputedStyle(text).lineHeight) : 0;
return { index, top: rect.top, height: rect.height, lineHeight };
}),
);
const gutters = new Map(
readRows("diff-gutter-row-", "diff-gutter-text-").map((row) => [row.index, row]),
@@ -800,7 +889,7 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
}
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
await expect(diffGroups(page).first()).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
timeout: 30_000,
});
@@ -839,15 +928,9 @@ async function returnToWorkspaceChanges(page: Page): Promise<void> {
}
async function scrollToLowerUnwrappedDiffRows(page: Page): Promise<void> {
const lastRowIndex = await page.getByTestId("diff-file-0-body").evaluate((root) => {
const rows = Array.from(root.querySelectorAll<HTMLElement>('[data-testid^="diff-code-row-"]'));
if (rows.length === 0) {
throw new Error("No unwrapped code rows are mounted");
}
return Math.max(
...rows.map((row) => Number((row.getAttribute("data-testid") ?? "").slice(14))),
);
});
await page.getByTestId(`diff-code-row-${lastRowIndex}`).scrollIntoViewIfNeeded();
await expect(page.getByTestId(`diff-code-row-${lastRowIndex}`)).toBeVisible();
const groups = diffGroups(page);
await expect.poll(() => groups.count()).toBeGreaterThan(1);
const lastMountedGroup = groups.last();
await lastMountedGroup.scrollIntoViewIfNeeded();
await expect(lastMountedGroup.locator('[data-testid^="diff-code-row-"]').last()).toBeVisible();
}
@@ -55,7 +55,7 @@ test("commit history shows dates and shares diff layout preferences", async ({
await layoutToggle.click();
await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff");
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
await expect(panel.getByTestId(/^diff-file-0-group-\d+$/).first()).toBeVisible();
await page.getByTestId(/^workspace-commit-diff-close-/).click();
await expect(panel).toHaveCount(0);
+38 -21
View File
@@ -1,58 +1,75 @@
import { useState, useCallback, useEffect, useId, useRef } from "react";
import { useState, useCallback, useEffect, useId } from "react";
import { type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
import { ScrollView } from "react-native-gesture-handler";
import {
type LayoutChangeEvent,
type NativeSyntheticEvent,
type NativeScrollEvent,
type StyleProp,
type ViewStyle,
} from "react-native";
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
createAnimatedComponent,
useAnimatedScrollHandler,
useSharedValue,
type SharedValue,
} from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures";
const AnimatedScrollView = createAnimatedComponent(ScrollView);
interface DiffScrollProps {
children: React.ReactNode;
scrollViewWidth: number;
onScrollViewWidthChange: (width: number) => void;
style?: StyleProp<ViewStyle>;
contentContainerStyle?: StyleProp<ViewStyle>;
offset: SharedValue<number>;
testID?: string;
}
export function DiffScroll({
children,
scrollViewWidth: _scrollViewWidth,
onScrollViewWidthChange,
style,
contentContainerStyle,
offset,
testID,
}: DiffScrollProps) {
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
const horizontalScroll = useHorizontalScrollOptional();
const scrollId = useId();
const scrollViewRef = useRef<ScrollViewType>(null);
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
const isAtLeftEdgeValue = useSharedValue(true);
const closeGestureRef = useFileExplorerCloseGestureRef();
// Register/unregister scroll offset tracking
useEffect(() => {
if (!horizontalScroll) return;
// Start at 0 (not scrolled)
horizontalScroll.registerScrollOffset(scrollId, 0);
return () => {
horizontalScroll.unregisterScrollOffset(scrollId);
};
}, [horizontalScroll, scrollId]);
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const offsetX = event.nativeEvent.contentOffset.x;
// Track if we're at the left edge (with small threshold for float precision)
setIsAtLeftEdge(offsetX <= 1);
const recordScrollOffset = useCallback(
(offsetX: number) => {
if (horizontalScroll) {
horizontalScroll.registerScrollOffset(scrollId, offsetX);
}
},
[horizontalScroll, scrollId],
);
const recordLeftEdge = useCallback((atLeftEdge: boolean) => {
setIsAtLeftEdge(atLeftEdge);
}, []);
const handleScroll = useAnimatedScrollHandler({
onScroll: (event) => {
const offsetX = event.contentOffset.x;
offset.value = offsetX;
const atLeftEdge = offsetX <= 1;
if (atLeftEdge !== isAtLeftEdgeValue.value) {
isAtLeftEdgeValue.value = atLeftEdge;
scheduleOnRN(recordLeftEdge, atLeftEdge);
}
scheduleOnRN(recordScrollOffset, offsetX);
},
});
const handleLayout = useCallback(
(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width),
@@ -60,8 +77,7 @@ export function DiffScroll({
);
return (
<ScrollView
ref={scrollViewRef}
<AnimatedScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
@@ -71,6 +87,7 @@ export function DiffScroll({
onScroll={handleScroll}
scrollEventThrottle={16}
onLayout={handleLayout}
testID={testID}
// When at left edge, wait for close gesture to fail before scrolling.
// The close gesture fails quickly on leftward swipes (failOffsetX=-10),
// so scrolling left works normally. On rightward swipes, close gesture
@@ -78,6 +95,6 @@ export function DiffScroll({
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
>
{children}
</ScrollView>
</AnimatedScrollView>
);
}
@@ -1,12 +1,23 @@
import { useCallback } from "react";
import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
import { useCallback, useId, useLayoutEffect, useState } from "react";
import {
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
type StyleProp,
type ViewStyle,
} from "react-native";
import { ScrollView } from "react-native-gesture-handler";
import type { SharedValue } from "react-native-reanimated";
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures";
interface DiffScrollProps {
children: React.ReactNode;
scrollViewWidth: number;
onScrollViewWidthChange: (width: number) => void;
style?: StyleProp<ViewStyle>;
contentContainerStyle?: StyleProp<ViewStyle>;
offset: SharedValue<number>;
testID?: string;
}
export function DiffScroll({
@@ -14,9 +25,31 @@ export function DiffScroll({
onScrollViewWidthChange,
style,
contentContainerStyle,
offset,
testID,
}: DiffScrollProps) {
const horizontalScroll = useHorizontalScrollOptional();
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
const scrollId = useId();
const closeGestureRef = useFileExplorerCloseGestureRef();
useLayoutEffect(() => {
horizontalScroll?.registerScrollOffset(scrollId, 0);
return () => horizontalScroll?.unregisterScrollOffset(scrollId);
}, [horizontalScroll, scrollId]);
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const offsetX = event.nativeEvent.contentOffset.x;
offset.value = offsetX;
setIsAtLeftEdge(offsetX <= 1);
horizontalScroll?.registerScrollOffset(scrollId, offsetX);
},
[horizontalScroll, offset, scrollId],
);
const handleLayout = useCallback(
(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width),
(event: LayoutChangeEvent) => onScrollViewWidthChange(event.nativeEvent.layout.width),
[onScrollViewWidthChange],
);
@@ -25,9 +58,14 @@ export function DiffScroll({
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
bounces={false}
style={style}
contentContainerStyle={contentContainerStyle}
onScroll={handleScroll}
scrollEventThrottle={16}
onLayout={handleLayout}
testID={testID}
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
>
{children}
</ScrollView>
@@ -1,134 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "./diff-flat-items";
import type { ParsedDiffFile } from "@/git/use-diff-query";
function createFile(path: string, additions = 1, deletions = 0): ParsedDiffFile {
return { path, isNew: false, isDeleted: false, additions, deletions, hunks: [] };
}
function summarize(items: DiffFlatItem[]): string[] {
return items.map((item) => {
if (item.type === "folder") {
return `${" ".repeat(item.depth)}[${item.displayName}]${item.collapsed ? " (collapsed)" : ""}`;
}
const base = item.file.path.split("/").pop();
return `${" ".repeat(item.depth)}${item.type === "body" ? "body:" : ""}${base}`;
});
}
describe("buildDiffFlatItems", () => {
const files = [createFile("src/app/a.ts"), createFile("src/app/nested/b.ts")];
it("emits a body and a sticky index for each expanded file", () => {
const { items, stickyHeaderIndices } = buildDiffFlatItems({
files: [createFile("a.ts"), createFile("b.ts")],
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(["a.ts"]),
});
// Root-level files (no folder): header, body (for expanded a.ts), header
expect(summarize(items)).toEqual(["a.ts", "body:a.ts", "b.ts"]);
// sticky points at the header (index 0), not the body
expect(stickyHeaderIndices).toEqual([0]);
expect(items[0].type).toBe("header");
});
it("emits the old flat list without folder rows or indentation", () => {
const { items, stickyHeaderIndices } = buildDiffFlatItems({
files,
viewMode: "flat",
collapsedFolders: new Set(),
expandedPaths: new Set(["src/app/a.ts"]),
});
expect(summarize(items)).toEqual(["a.ts", "body:a.ts", "b.ts"]);
expect(stickyHeaderIndices).toEqual([0]);
expect(items.every((item) => item.type !== "folder")).toBe(true);
});
it("groups files under compressed folder rows, all expanded by default", () => {
const { items } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(),
});
// dirs sort before files within a level: [nested] precedes a.ts
expect(summarize(items)).toEqual(["[src/app]", " [nested]", " b.ts", " a.ts"]);
});
it("collapsing a folder hides its descendants but keeps the row", () => {
const { items } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(["src/app/nested"]),
expandedPaths: new Set(),
});
expect(summarize(items)).toEqual(["[src/app]", " [nested] (collapsed)", " a.ts"]);
});
it("collapsing an ancestor hides everything below it", () => {
const { items } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(["src/app"]),
expandedPaths: new Set(),
});
expect(summarize(items)).toEqual(["[src/app] (collapsed)"]);
});
it("derives sticky indices file-headers-only from the post-collapse list", () => {
// dirs-first: [src/app], [nested], b.ts, a.ts. Expanding a.ts puts its
// header at index 3, with the body right after.
const { items, stickyHeaderIndices } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(["src/app/a.ts"]),
});
expect(summarize(items)).toEqual([
"[src/app]",
" [nested]",
" b.ts",
" a.ts",
" body:a.ts",
]);
expect(stickyHeaderIndices).toEqual([3]);
for (const idx of stickyHeaderIndices) {
expect(items[idx].type).toBe("header");
}
});
it("maps tree file rows back to their original files array index", () => {
const { items } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(),
});
// Tree order is b.ts (fileIndex 1) then a.ts (fileIndex 0)
const headers = items.filter((i) => i.type === "header");
expect(headers.map((h) => (h.type === "header" ? h.fileIndex : -1))).toEqual([1, 0]);
});
});
describe("sumHeightsBefore", () => {
const items = buildDiffFlatItems({
files: [createFile("src/a.ts"), createFile("src/b.ts")],
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(),
}).items; // [folder, a.ts, b.ts]
const heightFor = (item: DiffFlatItem) => (item.type === "folder" ? 10 : 20);
it("sums the heights of items before the index, counting folder rows", () => {
expect(sumHeightsBefore(items, 0, heightFor)).toBe(0);
expect(sumHeightsBefore(items, 1, heightFor)).toBe(10); // folder above a.ts
expect(sumHeightsBefore(items, 2, heightFor)).toBe(30); // folder + a.ts above b.ts
});
it("clamps an out-of-range index to the list length", () => {
expect(sumHeightsBefore(items, 999, heightFor)).toBe(50);
});
});
-121
View File
@@ -1,121 +0,0 @@
import type { ParsedDiffFile } from "@/git/use-diff-query";
import {
buildDiffTree,
compressSingleChildChains,
flattenDiffTree,
type DiffTreeDirNode,
} from "@/git/diff-tree";
// The row model for the Changes FlatList. `fileIndex` is the file's index within
// the (path-sorted) `files` array — a stable identity for testIDs/keys that is
// independent of folder rows and collapse state. `depth` drives indentation.
export type DiffFlatItem =
| {
type: "folder";
dirPath: string;
displayName: string;
depth: number;
collapsed: boolean;
additions: number;
deletions: number;
}
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean; depth: number }
| { type: "body"; file: ParsedDiffFile; fileIndex: number; depth: number };
export interface DiffFlatItemsResult {
items: DiffFlatItem[];
/** Indices into `items` of expanded file headers — folder rows are never sticky. */
stickyHeaderIndices: number[];
}
export interface BuildDiffFlatItemsInput {
files: ParsedDiffFile[];
viewMode: "flat" | "tree";
/** Full uncompressed directory paths that are collapsed (empty = all expanded). */
collapsedFolders: ReadonlySet<string>;
/** File paths whose diff body is expanded. */
expandedPaths: ReadonlySet<string>;
/**
* Pre-built compressed tree (from the same `files`). Pass it to avoid rebuilding
* the tree on every collapse toggle; omitted callers (tests) build it internally.
*/
tree?: DiffTreeDirNode;
}
/**
* Build the FlatList item model plus its sticky-header indices: files grouped
* under directory rows, single-child chains compressed, descendants of collapsed
* folders omitted.
*
* Sticky indices are derived from the FINAL (post-collapse) item list and only
* ever point at expanded FILE headers, so folder rows can't stack or collide in
* the sticky header stack.
*/
export function buildDiffFlatItems({
files,
viewMode,
collapsedFolders,
expandedPaths,
tree,
}: BuildDiffFlatItemsInput): DiffFlatItemsResult {
const items: DiffFlatItem[] = [];
const stickyHeaderIndices: number[] = [];
const pushFile = (file: ParsedDiffFile, fileIndex: number, depth: number): void => {
const isExpanded = expandedPaths.has(file.path);
items.push({ type: "header", file, fileIndex, isExpanded, depth });
if (isExpanded) {
stickyHeaderIndices.push(items.length - 1);
items.push({ type: "body", file, fileIndex, depth });
}
};
if (viewMode === "flat") {
for (const [fileIndex, file] of files.entries()) {
pushFile(file, fileIndex, 0);
}
return { items, stickyHeaderIndices };
}
const indexByPath = new Map(files.map((file, index) => [file.path, index]));
const compressedTree = tree ?? compressSingleChildChains(buildDiffTree(files));
const rows = flattenDiffTree(compressedTree, collapsedFolders);
for (const row of rows) {
if (row.kind === "folder") {
items.push({
type: "folder",
dirPath: row.dirPath,
displayName: row.displayName,
depth: row.depth,
collapsed: collapsedFolders.has(row.dirPath),
additions: row.additions,
deletions: row.deletions,
});
continue;
}
const fileIndex = indexByPath.get(row.file.path);
if (fileIndex === undefined) {
// Should never happen: the tree is built from the same `files` array.
continue;
}
pushFile(row.file, fileIndex, row.depth);
}
return { items, stickyHeaderIndices };
}
/** Cumulative height of every item before `index`. Single source of truth for
* FlatList's getItemLayout AND the collapse scroll-anchor math. */
export function sumHeightsBefore(
items: DiffFlatItem[],
index: number,
heightFor: (item: DiffFlatItem) => number,
): number {
let offset = 0;
const end = Math.min(index, items.length);
for (let i = 0; i < end; i++) {
offset += heightFor(items[i]);
}
return Math.max(0, offset);
}
@@ -0,0 +1,80 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { type LayoutChangeEvent, type StyleProp, type ViewStyle, View } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
type SharedValue,
} from "react-native-reanimated";
import { DiffScroll } from "@/components/diff-scroll";
const FILL: ViewStyle = { flex: 1 };
const CLIP: ViewStyle = { flex: 1, overflow: "hidden" };
const DiffHorizontalOffsetContext = createContext<SharedValue<number> | null>(null);
export function DiffHorizontalSurface({
children,
contentWidth,
viewportWidth,
onViewportWidthChange,
}: {
children: ReactNode;
contentWidth: number;
viewportWidth: number;
onViewportWidthChange: (width: number) => void;
}) {
const offset = useSharedValue(0);
const pinnedViewportStyle = useAnimatedStyle(() => ({
transform: [{ translateX: offset.value }],
}));
const contentContainerStyle = useMemo<StyleProp<ViewStyle>>(
() => [{ flexGrow: 1 }, { minWidth: Math.max(viewportWidth, contentWidth) }],
[contentWidth, viewportWidth],
);
return (
<DiffHorizontalOffsetContext.Provider value={offset}>
<DiffScroll
offset={offset}
onScrollViewWidthChange={onViewportWidthChange}
style={FILL}
contentContainerStyle={contentContainerStyle}
testID="diff-horizontal-scroll"
>
<Animated.View style={[FILL, { width: viewportWidth }, pinnedViewportStyle]}>
{children}
</Animated.View>
</DiffScroll>
</DiffHorizontalOffsetContext.Provider>
);
}
export function DiffHorizontalContent({
children,
viewportStyle,
contentStyle,
onContentLayout,
testID,
}: {
children: ReactNode;
viewportStyle?: StyleProp<ViewStyle>;
contentStyle?: StyleProp<ViewStyle>;
onContentLayout?: (event: LayoutChangeEvent) => void;
testID?: string;
}) {
const offset = useContext(DiffHorizontalOffsetContext);
const translatedContentStyle = useAnimatedStyle(() => ({
transform: [{ translateX: -(offset?.value ?? 0) }],
}));
return (
<View style={[CLIP, viewportStyle]}>
<Animated.View
style={[contentStyle, translatedContentStyle]}
onLayout={onContentLayout}
testID={testID}
>
{children}
</Animated.View>
</View>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,286 @@
import { describe, expect, it } from "vitest";
import type { ParsedDiffFile } from "@/git/use-diff-query";
import {
buildDiffRowIndex,
createDiffRowCache,
DIFF_VIRTUALIZATION_CONFIG,
type DiffRowCache,
} from "./model";
function createFile(path: string, lineCount: number): ParsedDiffFile {
return {
path,
isNew: false,
isDeleted: false,
additions: lineCount,
deletions: 0,
hunks: [
{
oldStart: 1,
oldCount: 0,
newStart: 1,
newCount: lineCount,
lines: [
{ type: "header", content: `@@ -1,0 +1,${lineCount} @@` },
...Array.from({ length: lineCount }, (_, index) => ({
type: "add" as const,
content: `line ${index + 1}`,
})),
],
},
],
};
}
function buildIndex(files: ParsedDiffFile[], expandedPaths: string[]) {
return buildDiffRowIndex({
files,
viewMode: "flat",
collapsedFolders: new Set(),
expandedPaths: new Set(expandedPaths),
layout: "unified",
wrapLines: false,
lineHeight: 18,
viewportWidth: 400,
codeFontSize: 12,
headerHeightFor: () => 44,
folderHeight: 44,
statusHeight: 64,
measuredGroupHeights: new Map(),
geometryKey: "test",
reviewHeightFor: () => 0,
});
}
function containsReviewedLine(group: {
lines: Array<{ reviewTarget?: { lineNumber?: number } | null }>;
}): boolean {
return group.lines.some((line) => line.reviewTarget?.lineNumber === 10);
}
function summarizeTree(items: ReturnType<typeof buildIndex>["items"]): string[] {
return items.map((item) => {
if (item.type === "folder") {
return `${" ".repeat(item.depth)}[${item.displayName}]${item.collapsed ? " (collapsed)" : ""}`;
}
const base = item.file.path.split("/").pop();
const isGroup = item.type === "unified_group" || item.type === "split_group";
return `${" ".repeat(item.depth)}${isGroup ? "group:" : ""}${base}`;
});
}
describe("buildDiffRowIndex", () => {
it("turns a large expanded file into bounded native-list cells", () => {
const index = buildIndex([createFile("large.ts", 1_200)], ["large.ts"]);
const groups = index.items.filter((item) => item.type === "unified_group");
expect(groups.length).toBeGreaterThan(1);
expect(Math.max(...groups.map((group) => group.lines.length))).toBeLessThanOrEqual(
DIFF_VIRTUALIZATION_CONFIG.groupRowLimit,
);
expect(groups.reduce((count, group) => count + group.lines.length, 0)).toBe(1_201);
expect(index.stickyHeaderIndices).toEqual([0]);
});
it("counts wide glyphs and tabs conservatively for horizontal scroll extent", () => {
for (const [path, content] of [
["cjk.ts", "界".repeat(40)],
["emoji.ts", "😀".repeat(40)],
["tabs.ts", "\t".repeat(10)],
]) {
const file = createFile(path, 1);
file.hunks[0].lines[1].content = content;
const index = buildIndex([file], [file.path]);
const group = index.items.find((item) => item.type === "unified_group");
expect(group?.contentColumns).toBe(80);
}
});
it("precomputes exact offsets and preserves total file height", () => {
const index = buildIndex(
[createFile("first.ts", 65), createFile("second.ts", 1)],
["first.ts"],
);
const firstGroups = index.items.filter(
(item) => item.type === "unified_group" && item.file.path === "first.ts",
);
const secondHeaderIndex = index.items.findIndex(
(item) => item.type === "header" && item.file.path === "second.ts",
);
const expectedBodyHeight = 66 * 18 + 2;
expect(firstGroups).toHaveLength(5);
expect(index.offsetAt(secondHeaderIndex)).toBe(44 + expectedBodyHeight);
expect(index.offsetOfFile("second.ts")).toBe(44 + expectedBodyHeight);
expect(index.totalHeight).toBe(44 + expectedBodyHeight + 44);
});
it("keeps a review row whole and shifts every later offset by its height", () => {
const file = createFile("review.ts", 40);
const baseline = buildIndex([file], [file.path]);
const reviewed = buildDiffRowIndex({
files: [file],
viewMode: "flat",
collapsedFolders: new Set(),
expandedPaths: new Set([file.path]),
layout: "unified",
wrapLines: false,
lineHeight: 18,
viewportWidth: 400,
codeFontSize: 12,
headerHeightFor: () => 44,
folderHeight: 44,
statusHeight: 64,
measuredGroupHeights: new Map(),
geometryKey: "test",
reviewHeightFor: (target) => (target?.lineNumber === 10 ? 132 : 0),
});
expect(reviewed.totalHeight - baseline.totalHeight).toBe(132);
const reviewGroup = reviewed.items
.filter((item) => item.type === "unified_group")
.find(containsReviewedLine);
expect(reviewGroup).toBeDefined();
expect(reviewed.heightAt(reviewed.items.indexOf(reviewGroup!))).toBeGreaterThanOrEqual(150);
});
it("preserves compressed tree order, indentation, and collapse state", () => {
const files = [createFile("src/app/a.ts", 1), createFile("src/app/nested/b.ts", 1)];
const index = buildDiffRowIndex({
files,
viewMode: "tree",
collapsedFolders: new Set(["src/app/nested"]),
expandedPaths: new Set(["src/app/a.ts"]),
layout: "unified",
wrapLines: false,
lineHeight: 18,
viewportWidth: 400,
codeFontSize: 12,
headerHeightFor: () => 44,
folderHeight: 44,
statusHeight: 64,
measuredGroupHeights: new Map(),
geometryKey: "tree",
reviewHeightFor: () => 0,
});
expect(summarizeTree(index.items)).toEqual([
"[src/app]",
" [nested] (collapsed)",
" a.ts",
" group:a.ts",
]);
expect(index.stickyHeaderIndices).toEqual([2]);
});
it("keeps split groups bounded and sticky indices on headers", () => {
const file = createFile("split.ts", 100);
const index = buildDiffRowIndex({
files: [file],
viewMode: "flat",
collapsedFolders: new Set(),
expandedPaths: new Set([file.path]),
layout: "split",
wrapLines: false,
lineHeight: 18,
viewportWidth: 800,
codeFontSize: 12,
headerHeightFor: () => 44,
folderHeight: 44,
statusHeight: 64,
measuredGroupHeights: new Map(),
geometryKey: "split",
reviewHeightFor: () => 0,
});
const groups = index.items.filter((item) => item.type === "split_group");
expect(Math.max(...groups.map((group) => group.rows.length))).toBeLessThanOrEqual(
DIFF_VIRTUALIZATION_CONFIG.groupRowLimit,
);
expect(index.items[index.stickyHeaderIndices[0]].type).toBe("header");
});
it("updates measured group geometry without deriving any file rows again", () => {
const delegate = createDiffRowCache();
let derivations = 0;
const rowCache: DiffRowCache = {
unifiedFor(file) {
derivations += 1;
return delegate.unifiedFor(file);
},
splitFor(file) {
derivations += 1;
return delegate.splitFor(file);
},
};
const files = [createFile("first.ts", 100), createFile("second.ts", 100)];
const index = buildDiffRowIndex({
files,
viewMode: "flat",
collapsedFolders: new Set(),
expandedPaths: new Set(files.map((file) => file.path)),
layout: "unified",
wrapLines: true,
lineHeight: 18,
viewportWidth: 400,
codeFontSize: 12,
headerHeightFor: () => 44,
folderHeight: 44,
statusHeight: 64,
measuredGroupHeights: new Map(),
geometryKey: "wrap",
reviewHeightFor: () => 0,
rowCache,
});
const group = index.items.find((item) => item.type === "unified_group");
expect(group).toBeDefined();
expect(derivations).toBe(2);
const groupIndex = index.indexOfGroup(group!.key);
const laterOffset = index.offsetAt(groupIndex + 1);
const update = index.updateHeight(group!.key, index.heightAt(groupIndex) + 20);
expect(update?.delta).toBe(20);
expect(index.offsetAt(groupIndex + 1)).toBe(laterOffset + 20);
expect(derivations).toBe(2);
});
it("uses the key's rounded viewport width for wrapped group membership", () => {
const file = createFile("wrapped.ts", 40);
for (const line of file.hunks[0].lines) {
if (line.type !== "header") {
line.content = "x".repeat(88);
}
}
const buildWrapped = (viewportWidth: number) =>
buildDiffRowIndex({
files: [file],
viewMode: "flat",
collapsedFolders: new Set(),
expandedPaths: new Set([file.path]),
layout: "unified",
wrapLines: true,
lineHeight: 18,
viewportWidth,
codeFontSize: 12,
headerHeightFor: () => 44,
folderHeight: 44,
statusHeight: 64,
measuredGroupHeights: new Map(),
geometryKey: `wrap:${Math.round(viewportWidth)}`,
reviewHeightFor: () => 0,
});
const summarizeGroups = (viewportWidth: number) =>
buildWrapped(viewportWidth)
.items.filter((item) => item.type === "unified_group")
.map((group) => ({
key: group.key,
startRowIndex: group.startRowIndex,
lineCount: group.lines.length,
}));
expect(summarizeGroups(399.2)).toEqual(summarizeGroups(399.49));
});
});
+422
View File
@@ -0,0 +1,422 @@
import type { ParsedDiffFile } from "@/git/use-diff-query";
import {
buildDiffTree,
compressSingleChildChains,
flattenDiffTree,
type DiffTreeDirNode,
} from "@/git/diff-tree";
import {
buildSplitDiffRows,
buildUnifiedDiffLines,
type ReviewableDiffTarget,
type SplitDiffRow,
type UnifiedDiffDisplayLine,
} from "@/utils/diff-layout";
// Tune cell size and the outer list window together. On the 1,200-row fixture, the exact
// three-fling repro filled immediately with both 32/720 + window 21 and this lower-cost pair
// on an iPhone 17 Pro simulator. Simulator frame percentiles are unavailable in xctrace.
export const DIFF_VIRTUALIZATION_CONFIG = {
groupRowLimit: 16,
groupHeightLimit: 360,
initialNumToRender: 8,
maxToRenderPerBatch: 8,
updateCellsBatchingPeriod: 16,
windowSize: 10,
} as const;
interface FolderItem {
type: "folder";
dirPath: string;
displayName: string;
depth: number;
collapsed: boolean;
additions: number;
deletions: number;
}
interface HeaderItem {
type: "header";
file: ParsedDiffFile;
fileIndex: number;
isExpanded: boolean;
depth: number;
}
interface StatusItem {
type: "status";
file: ParsedDiffFile;
fileIndex: number;
depth: number;
}
interface RowGroupBase {
file: ParsedDiffFile;
fileIndex: number;
depth: number;
groupIndex: number;
startRowIndex: number;
isFirst: boolean;
isLast: boolean;
contentColumns: number;
key: string;
}
export interface UnifiedRowGroupItem extends RowGroupBase {
type: "unified_group";
lines: UnifiedDiffDisplayLine[];
}
export interface SplitRowGroupItem extends RowGroupBase {
type: "split_group";
rows: SplitDiffRow[];
}
export type DiffListItem =
| FolderItem
| HeaderItem
| StatusItem
| UnifiedRowGroupItem
| SplitRowGroupItem;
export interface DiffRowIndex {
items: DiffListItem[];
stickyHeaderIndices: number[];
heightAt: (index: number) => number;
offsetAt: (index: number) => number;
offsetOfFile: (path: string) => number;
indexOfFile: (path: string) => number;
indexOfGroup: (key: string) => number;
updateHeight: (
key: string,
height: number,
) => { delta: number; index: number; previousHeight: number } | null;
totalHeight: number;
}
export interface DiffRowCache {
unifiedFor: (file: ParsedDiffFile) => UnifiedDiffDisplayLine[];
splitFor: (file: ParsedDiffFile) => SplitDiffRow[];
}
export function createDiffRowCache(): DiffRowCache {
const unified = new WeakMap<ParsedDiffFile, UnifiedDiffDisplayLine[]>();
const split = new WeakMap<ParsedDiffFile, SplitDiffRow[]>();
return {
unifiedFor(file) {
const cached = unified.get(file);
if (cached) return cached;
const lines = buildUnifiedDiffLines(file);
unified.set(file, lines);
return lines;
},
splitFor(file) {
const cached = split.get(file);
if (cached) return cached;
const rows = buildSplitDiffRows(file);
split.set(file, rows);
return rows;
},
};
}
const defaultRowCache = createDiffRowCache();
export interface BuildDiffRowIndexInput {
files: ParsedDiffFile[];
viewMode: "flat" | "tree";
collapsedFolders: ReadonlySet<string>;
expandedPaths: ReadonlySet<string>;
layout: "unified" | "split";
wrapLines: boolean;
lineHeight: number;
viewportWidth: number;
codeFontSize: number;
headerHeightFor: (file: ParsedDiffFile) => number;
folderHeight: number;
statusHeight: number;
measuredGroupHeights: ReadonlyMap<string, number>;
geometryKey: string;
reviewHeightFor: (target: ReviewableDiffTarget | null | undefined) => number;
rowCache?: DiffRowCache;
tree?: DiffTreeDirNode;
}
interface PendingGroup<Row> {
rows: Row[];
startRowIndex: number;
height: number;
}
const FULL_WIDTH_CODE_POINT_RANGES = [
[0x1100, 0x115f],
[0x2329, 0x232a],
[0x2e80, 0x303e],
[0x3040, 0x3247],
[0x3250, 0x4dbf],
[0x4e00, 0xa4c6],
[0xa960, 0xa97c],
[0xac00, 0xd7a3],
[0xf900, 0xfaff],
[0xfe10, 0xfe19],
[0xfe30, 0xfe6b],
[0xff01, 0xff60],
[0xffe0, 0xffe6],
[0x1b000, 0x1b001],
[0x1f200, 0x1f251],
[0x1f300, 0x1faff],
[0x20000, 0x3fffd],
] as const;
function isFullWidthCodePoint(codePoint: number): boolean {
return FULL_WIDTH_CODE_POINT_RANGES.some(
([start, end]) => codePoint >= start && codePoint <= end,
);
}
function displayColumns(content: string): number {
let columns = 0;
for (const character of content) {
if (character === "\t") {
columns += 8 - (columns % 8);
continue;
}
columns += isFullWidthCodePoint(character.codePointAt(0) ?? 0) ? 2 : 1;
}
return columns;
}
function estimatedVisualLines(content: string, columns: number): number {
return Math.max(1, Math.ceil(displayColumns(content) / columns));
}
function groupRows<Row>(rows: Row[], heightFor: (row: Row) => number): PendingGroup<Row>[] {
const groups: PendingGroup<Row>[] = [];
let current: PendingGroup<Row> = { rows: [], startRowIndex: 0, height: 0 };
for (const [rowIndex, row] of rows.entries()) {
const rowHeight = heightFor(row);
const reachedRowLimit = current.rows.length >= DIFF_VIRTUALIZATION_CONFIG.groupRowLimit;
const reachedHeightLimit =
current.rows.length > 0 &&
current.height + rowHeight > DIFF_VIRTUALIZATION_CONFIG.groupHeightLimit;
if (reachedRowLimit || reachedHeightLimit) {
groups.push(current);
current = { rows: [], startRowIndex: rowIndex, height: 0 };
}
current.rows.push(row);
current.height += rowHeight;
}
if (current.rows.length > 0) {
groups.push(current);
}
return groups;
}
function contentColumnsForUnified(lines: UnifiedDiffDisplayLine[]): number {
return lines.reduce((longest, line) => Math.max(longest, displayColumns(line.line.content)), 0);
}
function contentColumnsForSplit(rows: SplitDiffRow[]): number {
return rows.reduce((longest, row) => {
if (row.kind === "header") {
return Math.max(longest, displayColumns(row.content));
}
return Math.max(
longest,
displayColumns(row.left?.content ?? ""),
displayColumns(row.right?.content ?? ""),
);
}, 0);
}
function availableTextColumns(input: BuildDiffRowIndexInput): number {
const viewportWidth = Math.round(input.viewportWidth);
const columnWidth = input.layout === "split" ? viewportWidth / 2 : viewportWidth;
const characterWidth = input.codeFontSize * 0.62;
return Math.max(8, Math.floor((columnWidth - 72) / characterWidth));
}
export function buildDiffRowIndex(input: BuildDiffRowIndexInput): DiffRowIndex {
const items: DiffListItem[] = [];
const heights: number[] = [];
const stickyHeaderIndices: number[] = [];
const fileIndexByPath = new Map<string, number>();
const groupIndexByKey = new Map<string, number>();
const availableColumns = availableTextColumns(input);
const rowCache = input.rowCache ?? defaultRowCache;
function push(item: DiffListItem, height: number): void {
if (item.type === "unified_group" || item.type === "split_group") {
groupIndexByKey.set(item.key, items.length);
}
items.push(item);
heights.push(height);
}
function pushUnifiedGroups(file: ParsedDiffFile, fileIndex: number, depth: number): void {
const lines = rowCache.unifiedFor(file);
const contentColumns = contentColumnsForUnified(lines);
const groups = groupRows(lines, (line) => {
const visualLines = input.wrapLines
? estimatedVisualLines(line.line.content, availableColumns)
: 1;
return visualLines * input.lineHeight + input.reviewHeightFor(line.reviewTarget);
});
for (const [groupIndex, group] of groups.entries()) {
const isFirst = groupIndex === 0;
const isLast = groupIndex === groups.length - 1;
const key = `${input.geometryKey}:${file.path}:unified:${group.startRowIndex}`;
const estimatedHeight = group.height + (isFirst ? 1 : 0) + (isLast ? 1 : 0);
push(
{
type: "unified_group",
file,
fileIndex,
depth,
groupIndex,
startRowIndex: group.startRowIndex,
isFirst,
isLast,
contentColumns,
key,
lines: group.rows,
},
input.measuredGroupHeights.get(key) ?? estimatedHeight,
);
}
}
function pushSplitGroups(file: ParsedDiffFile, fileIndex: number, depth: number): void {
const rows = rowCache.splitFor(file);
const contentColumns = contentColumnsForSplit(rows);
const groups = groupRows(rows, (row) => {
if (row.kind === "header") {
const visualLines = input.wrapLines
? estimatedVisualLines(row.content, availableColumns)
: 1;
return visualLines * input.lineHeight;
}
const visualLines = input.wrapLines
? Math.max(
estimatedVisualLines(row.left?.content ?? "", availableColumns),
estimatedVisualLines(row.right?.content ?? "", availableColumns),
)
: 1;
const reviewHeight = Math.max(
input.reviewHeightFor(row.left?.reviewTarget),
input.reviewHeightFor(row.right?.reviewTarget),
);
return visualLines * input.lineHeight + reviewHeight;
});
for (const [groupIndex, group] of groups.entries()) {
const isFirst = groupIndex === 0;
const isLast = groupIndex === groups.length - 1;
const key = `${input.geometryKey}:${file.path}:split:${group.startRowIndex}`;
const estimatedHeight = group.height + (isFirst ? 1 : 0) + (isLast ? 1 : 0);
push(
{
type: "split_group",
file,
fileIndex,
depth,
groupIndex,
startRowIndex: group.startRowIndex,
isFirst,
isLast,
contentColumns,
key,
rows: group.rows,
},
input.measuredGroupHeights.get(key) ?? estimatedHeight,
);
}
}
function pushFile(file: ParsedDiffFile, fileIndex: number, depth: number): void {
const isExpanded = input.expandedPaths.has(file.path);
const headerIndex = items.length;
fileIndexByPath.set(file.path, headerIndex);
push({ type: "header", file, fileIndex, isExpanded, depth }, input.headerHeightFor(file));
if (!isExpanded) {
return;
}
stickyHeaderIndices.push(headerIndex);
if (file.status === "binary" || file.status === "too_large") {
push({ type: "status", file, fileIndex, depth }, input.statusHeight);
return;
}
if (input.layout === "split") {
pushSplitGroups(file, fileIndex, depth);
return;
}
pushUnifiedGroups(file, fileIndex, depth);
}
if (input.viewMode === "flat") {
for (const [fileIndex, file] of input.files.entries()) {
pushFile(file, fileIndex, 0);
}
} else {
const indexByPath = new Map(input.files.map((file, index) => [file.path, index]));
const tree = input.tree ?? compressSingleChildChains(buildDiffTree(input.files));
for (const row of flattenDiffTree(tree, input.collapsedFolders)) {
if (row.kind === "folder") {
push(
{
type: "folder",
dirPath: row.dirPath,
displayName: row.displayName,
depth: row.depth,
collapsed: input.collapsedFolders.has(row.dirPath),
additions: row.additions,
deletions: row.deletions,
},
input.folderHeight,
);
continue;
}
const fileIndex = indexByPath.get(row.file.path);
if (fileIndex !== undefined) {
pushFile(row.file, fileIndex, row.depth);
}
}
}
const offsets = Array.from({ length: items.length + 1 }, () => 0);
for (let index = 0; index < items.length; index += 1) {
offsets[index + 1] = offsets[index] + heights[index];
}
return {
items,
stickyHeaderIndices,
heightAt: (index) => heights[index] ?? 0,
offsetAt: (index) => offsets[Math.max(0, Math.min(index, items.length))],
offsetOfFile: (path) => offsets[fileIndexByPath.get(path) ?? 0],
indexOfFile: (path) => fileIndexByPath.get(path) ?? -1,
indexOfGroup: (key) => groupIndexByKey.get(key) ?? -1,
updateHeight: (key, height) => {
const index = groupIndexByKey.get(key);
if (index === undefined || !Number.isFinite(height) || height < 0) {
return null;
}
const previousHeight = heights[index];
const delta = height - previousHeight;
if (delta === 0) {
return { delta, index, previousHeight };
}
heights[index] = height;
for (let offsetIndex = index + 1; offsetIndex < offsets.length; offsetIndex += 1) {
offsets[offsetIndex] += delta;
}
return { delta, index, previousHeight };
},
get totalHeight() {
return offsets[items.length];
},
};
}
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { boundRetainedRangeOnDataGrowth } from "@react-native/virtualized-lists/Lists/boundRetainedRangeOnDataGrowth";
describe("bounded VirtualizedList data growth", () => {
it("limits the carried range when middle insertion grows the list", () => {
expect(boundRetainedRangeOnDataGrowth({ first: 0, last: 39 }, 40, 115, 16)).toEqual({
first: 0,
last: 15,
});
});
it("bounds from the current scrolled range instead of jumping to the start", () => {
expect(boundRetainedRangeOnDataGrowth({ first: 84, last: 123 }, 160, 235, 16)).toEqual({
first: 84,
last: 99,
});
});
it("does not alter the range when data shrinks or the carried range is already bounded", () => {
const shortRange = { first: 12, last: 18 };
expect(boundRetainedRangeOnDataGrowth(shortRange, 40, 32, 16)).toBe(shortRange);
expect(boundRetainedRangeOnDataGrowth(shortRange, 40, 115, 16)).toEqual(shortRange);
});
});
+17 -17
View File
@@ -260,6 +260,7 @@ export function InlineReviewGutterCell({
onStartComment,
style,
actionTestID,
testID,
}: {
children: ReactNode;
reviewTarget: ReviewableDiffTarget | null | undefined;
@@ -270,6 +271,7 @@ export function InlineReviewGutterCell({
onStartComment: (target: ReviewableDiffTarget) => void;
style?: StyleProp<ViewStyle>;
actionTestID?: string;
testID?: string;
}) {
const { t } = useTranslation();
const canComment = Boolean(reviewTarget);
@@ -309,7 +311,6 @@ export function InlineReviewGutterCell({
}
}, [isInteractionActive]);
const pressableStyle = useCallback((): StyleProp<ViewStyle> => style, [style]);
const lineHeightStyle = useMemo<StyleProp<ViewStyle>>(
() =>
lineHeight !== undefined
@@ -318,13 +319,15 @@ export function InlineReviewGutterCell({
[lineHeight],
);
const labelStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.gutterLabel, lineHeightStyle, hasComments && styles.gutterLabelActive],
[hasComments, lineHeightStyle],
);
const innerStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.gutterInner, lineHeightStyle],
[lineHeightStyle],
const pressableStyle = useCallback(
(): StyleProp<ViewStyle> => [
style,
styles.gutterInner,
styles.gutterLabel,
lineHeightStyle,
hasComments && styles.gutterLabelActive,
],
[hasComments, lineHeightStyle, style],
);
const actionIconStyle = useMemo<StyleProp<ViewStyle>>(
() => [
@@ -346,17 +349,14 @@ export function InlineReviewGutterCell({
onPressIn={handlePressIn}
onPressOut={handlePressOut}
style={pressableStyle}
testID={testID}
>
<View style={innerStyle}>
<View style={labelStyle}>
{children}
{showAction ? (
<View style={actionIconStyle} testID={actionTestID}>
<ThemedPlus size={16} strokeWidth={2.4} uniProps={accentForegroundIconColorMapping} />
</View>
) : null}
{children}
{showAction ? (
<View style={actionIconStyle} testID={actionTestID}>
<ThemedPlus size={16} strokeWidth={2.4} uniProps={accentForegroundIconColorMapping} />
</View>
</View>
) : null}
</Pressable>
);
}
@@ -0,0 +1,8 @@
declare module "@react-native/virtualized-lists/Lists/boundRetainedRangeOnDataGrowth" {
export function boundRetainedRangeOnDataGrowth(
cellsAroundViewport: { first: number; last: number },
previousItemCount: number,
itemCount: number,
retainedCellLimit: number,
): { first: number; last: number };
}
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
compactHighlightTokens,
formatDiffContentText,
formatDiffGutterText,
hasVisibleDiffTokens,
@@ -28,4 +29,23 @@ describe("diff-rendering", () => {
expect(hasVisibleDiffTokens([{ text: "" }])).toBe(false);
expect(hasVisibleDiffTokens([{ text: "const value = 1;" }])).toBe(true);
});
it("compacts adjacent syntax tokens without changing their text or styles", () => {
expect(
compactHighlightTokens([
{ text: "const", style: "keyword" },
{ text: " ", style: null },
{ text: "value", style: null },
{ text: " ", style: null },
{ text: "=", style: "operator" },
{ text: "", style: "operator" },
{ text: " 1", style: "number" },
]),
).toEqual([
{ text: "const", style: "keyword" },
{ text: " value ", style: null },
{ text: "=", style: "operator" },
{ text: " 1", style: "number" },
]);
});
});
+22
View File
@@ -2,6 +2,28 @@ interface HighlightLikeToken {
text: string;
}
interface StyledHighlightToken extends HighlightLikeToken {
style: string | null;
}
export function compactHighlightTokens(
tokens: readonly StyledHighlightToken[],
): StyledHighlightToken[] {
const compacted: StyledHighlightToken[] = [];
for (const token of tokens) {
if (token.text.length === 0) {
continue;
}
const previous = compacted.at(-1);
if (previous?.style === token.style) {
previous.text += token.text;
continue;
}
compacted.push({ text: token.text, style: token.style });
}
return compacted;
}
// Preserve row height when a gutter or diff cell is intentionally blank.
// Non-breaking space because the gutter <Text> uses numberOfLines={1}, which
// collapses a plain ASCII space to zero height on web.
@@ -0,0 +1,105 @@
diff --git a/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js b/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js
index c92ac9d..6bdc84f 100644
--- a/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js
+++ b/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js
@@ -47,6 +47,7 @@ import {
windowSizeOrDefault,
} from './VirtualizedListProps';
+import {boundRetainedRangeOnDataGrowth} from './boundRetainedRangeOnDataGrowth';
import {
computeWindowedRenderLimits,
keyExtractor as defaultKeyExtractor,
} from './VirtualizeUtils';
@@ -706,7 +706,8 @@ class VirtualizedList extends StateSafePureComponent<
// first and last could be stale (e.g. if a new, shorter items props is passed in), so we make
// sure we're rendering a reasonable range here.
const itemCount = newProps.getItemCount(newProps.data);
- if (itemCount === prevState.renderMask.numCells()) {
+ const previousItemCount = prevState.renderMask.numCells();
+ if (itemCount === previousItemCount) {
return prevState;
}
@@ -741,7 +742,7 @@ class VirtualizedList extends StateSafePureComponent<
}
}
- const constrainedCells = VirtualizedList._constrainToItemCount(
+ let constrainedCells = VirtualizedList._constrainToItemCount(
maintainVisibleContentPositionAdjustment != null
? {
first:
@@ -756,6 +758,20 @@ class VirtualizedList extends StateSafePureComponent<
newProps,
);
+ if (
+ newProps.unstable_boundedRangeOnDataChange === true &&
+ itemCount > previousItemCount
+ ) {
+ const retainedCellLimit =
+ maxToRenderPerBatchOrDefault(newProps.maxToRenderPerBatch) * 2;
+ constrainedCells = boundRetainedRangeOnDataGrowth(
+ constrainedCells,
+ previousItemCount,
+ itemCount,
+ retainedCellLimit,
+ );
+ }
+
return {
cellsAroundViewport: constrainedCells,
renderMask: VirtualizedList._createRenderMask(newProps, constrainedCells),
diff --git a/node_modules/@react-native/virtualized-lists/Lists/boundRetainedRangeOnDataGrowth.js b/node_modules/@react-native/virtualized-lists/Lists/boundRetainedRangeOnDataGrowth.js
new file mode 100644
index 0000000..1e5b1f5
--- /dev/null
+++ b/node_modules/@react-native/virtualized-lists/Lists/boundRetainedRangeOnDataGrowth.js
@@ -0,0 +1,28 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @format
+ */
+
+'use strict';
+
+export function boundRetainedRangeOnDataGrowth(
+ cellsAroundViewport,
+ previousItemCount,
+ itemCount,
+ retainedCellLimit,
+) {
+ if (itemCount <= previousItemCount) {
+ return cellsAroundViewport;
+ }
+ return {
+ first: cellsAroundViewport.first,
+ last: Math.min(
+ cellsAroundViewport.last,
+ cellsAroundViewport.first + retainedCellLimit - 1,
+ ),
+ };
+}
diff --git a/node_modules/@react-native/virtualized-lists/Lists/VirtualizedListProps.js b/node_modules/@react-native/virtualized-lists/Lists/VirtualizedListProps.js
index 9d6d49e..54324a8 100644
--- a/node_modules/@react-native/virtualized-lists/Lists/VirtualizedListProps.js
+++ b/node_modules/@react-native/virtualized-lists/Lists/VirtualizedListProps.js
@@ -264,8 +264,14 @@ type OptionalVirtualizedListProps = {
* Amount of time between low-pri item render batches, e.g. for rendering items quite a ways off
* screen. Similar fill rate/responsiveness tradeoff as `maxToRenderPerBatch`.
*/
updateCellsBatchingPeriod?: ?number,
+
+ /**
+ * Limits the synchronous render range retained when data grows. This is useful for lists that
+ * insert many bounded cells in the middle of an already-rendered range.
+ */
+ unstable_boundedRangeOnDataChange?: ?boolean,
/**
* See `ViewabilityHelper` for flow type and further documentation.
*/
viewabilityConfig?: ViewabilityConfig,
+4
View File
@@ -8,6 +8,10 @@ import { join, relative } from "node:path";
// workspace root live in their workspace's own node_modules, and patch-package resolves
// the patch's node_modules/... paths relative to its working directory.
const patchedPackages = [
{
nodeModulesPath: "node_modules/@react-native/virtualized-lists",
patchPrefix: "@react-native+virtualized-lists+",
},
{
nodeModulesPath: "node_modules/react-native-markdown-display",
patchPrefix: "react-native-markdown-display+",