Revert "Make large diffs open quickly and scroll as one surface (#3212)"
This reverts commit b599d38a77.
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
+378
-524
File diff suppressed because it is too large
Load Diff
@@ -1,286 +0,0 @@
|
||||
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));
|
||||
});
|
||||
});
|
||||
@@ -1,422 +0,0 @@
|
||||
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];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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