mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 02:02:42 +00:00
* 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
287 lines
9.1 KiB
TypeScript
287 lines
9.1 KiB
TypeScript
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));
|
|
});
|
|
});
|