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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+526
-380
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));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user