Revert "Make large diffs open quickly and scroll as one surface (#3212)"
This reverts commit b599d38a77.
This commit is contained in:
@@ -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 Locator, type Page } from "@playwright/test";
|
||||
import { 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,10 +63,6 @@ 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 {
|
||||
@@ -469,12 +465,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(diffGroups(visiblePanel).first()).toBeVisible();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-file-pane")).toHaveCount(0);
|
||||
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(diffGroups(visiblePanel)).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0);
|
||||
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(diffGroups(visiblePanel).first()).toBeVisible();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
const workingDiffLayoutToggle = visiblePanel.getByTestId("working-diff-toggle-layout");
|
||||
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
||||
await workingDiffLayoutToggle.click();
|
||||
@@ -490,12 +486,14 @@ 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+-group-\d+$/)).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0);
|
||||
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
|
||||
await expect(diffGroups(visiblePanel).first()).toBeVisible();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
|
||||
await expect(diffGroups(page.getByTestId("explorer-content-area"))).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
|
||||
).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);
|
||||
@@ -518,9 +516,13 @@ 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(diffGroups(page.getByTestId("explorer-content-area")).first()).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
|
||||
).toBeVisible();
|
||||
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
|
||||
await expect(diffGroups(page.getByTestId("explorer-content-area"))).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("changes diff switches between flat and tree file lists", async ({ page }) => {
|
||||
@@ -583,7 +585,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(diffGroups(page).first()).toBeVisible();
|
||||
await expect(page.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
await page.getByTestId("diff-folder-src-toggle").click();
|
||||
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
||||
@@ -607,90 +609,6 @@ 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 }) => {
|
||||
@@ -716,12 +634,6 @@ 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 }) => {
|
||||
@@ -770,22 +682,21 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
|
||||
codeLineHeight: number;
|
||||
}[];
|
||||
}> {
|
||||
return diffGroups(page).evaluateAll((groups) => {
|
||||
if (groups.length === 0) {
|
||||
return page.locator("body").evaluate(({ ownerDocument }) => {
|
||||
const root = ownerDocument.querySelector('[data-testid="diff-file-0-body"]');
|
||||
if (!root) {
|
||||
throw new Error("Expanded diff body is not mounted");
|
||||
}
|
||||
|
||||
const readRows = (prefix: string, textPrefix: string) =>
|
||||
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 };
|
||||
}),
|
||||
);
|
||||
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 };
|
||||
});
|
||||
|
||||
const gutters = new Map(
|
||||
readRows("diff-gutter-row-", "diff-gutter-text-").map((row) => [row.index, row]),
|
||||
@@ -889,7 +800,7 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
|
||||
await expect(diffGroups(page).first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
@@ -928,9 +839,15 @@ async function returnToWorkspaceChanges(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function scrollToLowerUnwrappedDiffRows(page: Page): Promise<void> {
|
||||
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();
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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-group-\d+$/).first()).toBeVisible();
|
||||
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
await page.getByTestId(/^workspace-commit-diff-close-/).click();
|
||||
await expect(panel).toHaveCount(0);
|
||||
|
||||
@@ -1,75 +1,58 @@
|
||||
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 { useState, useCallback, useEffect, useId, useRef } from "react";
|
||||
import {
|
||||
createAnimatedComponent,
|
||||
useAnimatedScrollHandler,
|
||||
useSharedValue,
|
||||
type SharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
type LayoutChangeEvent,
|
||||
type NativeSyntheticEvent,
|
||||
type NativeScrollEvent,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
||||
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 [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
|
||||
const isAtLeftEdgeValue = useSharedValue(true);
|
||||
const scrollViewRef = useRef<ScrollViewType>(null);
|
||||
|
||||
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 recordScrollOffset = useCallback(
|
||||
(offsetX: number) => {
|
||||
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);
|
||||
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),
|
||||
@@ -77,7 +60,8 @@ export function DiffScroll({
|
||||
);
|
||||
|
||||
return (
|
||||
<AnimatedScrollView
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
@@ -87,7 +71,6 @@ 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
|
||||
@@ -95,6 +78,6 @@ export function DiffScroll({
|
||||
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
|
||||
>
|
||||
{children}
|
||||
</AnimatedScrollView>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
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";
|
||||
import { useCallback } from "react";
|
||||
import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
|
||||
|
||||
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({
|
||||
@@ -25,31 +14,9 @@ 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(
|
||||
(event: LayoutChangeEvent) => onScrollViewWidthChange(event.nativeEvent.layout.width),
|
||||
(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width),
|
||||
[onScrollViewWidthChange],
|
||||
);
|
||||
|
||||
@@ -58,14 +25,9 @@ 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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -260,7 +260,6 @@ export function InlineReviewGutterCell({
|
||||
onStartComment,
|
||||
style,
|
||||
actionTestID,
|
||||
testID,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
reviewTarget: ReviewableDiffTarget | null | undefined;
|
||||
@@ -271,7 +270,6 @@ export function InlineReviewGutterCell({
|
||||
onStartComment: (target: ReviewableDiffTarget) => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
actionTestID?: string;
|
||||
testID?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const canComment = Boolean(reviewTarget);
|
||||
@@ -311,6 +309,7 @@ export function InlineReviewGutterCell({
|
||||
}
|
||||
}, [isInteractionActive]);
|
||||
|
||||
const pressableStyle = useCallback((): StyleProp<ViewStyle> => style, [style]);
|
||||
const lineHeightStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() =>
|
||||
lineHeight !== undefined
|
||||
@@ -319,15 +318,13 @@ export function InlineReviewGutterCell({
|
||||
[lineHeight],
|
||||
);
|
||||
|
||||
const pressableStyle = useCallback(
|
||||
(): StyleProp<ViewStyle> => [
|
||||
style,
|
||||
styles.gutterInner,
|
||||
styles.gutterLabel,
|
||||
lineHeightStyle,
|
||||
hasComments && styles.gutterLabelActive,
|
||||
],
|
||||
[hasComments, lineHeightStyle, style],
|
||||
const labelStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.gutterLabel, lineHeightStyle, hasComments && styles.gutterLabelActive],
|
||||
[hasComments, lineHeightStyle],
|
||||
);
|
||||
const innerStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.gutterInner, lineHeightStyle],
|
||||
[lineHeightStyle],
|
||||
);
|
||||
const actionIconStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [
|
||||
@@ -349,14 +346,17 @@ export function InlineReviewGutterCell({
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
style={pressableStyle}
|
||||
testID={testID}
|
||||
>
|
||||
{children}
|
||||
{showAction ? (
|
||||
<View style={actionIconStyle} testID={actionTestID}>
|
||||
<ThemedPlus size={16} strokeWidth={2.4} uniProps={accentForegroundIconColorMapping} />
|
||||
<View style={innerStyle}>
|
||||
<View style={labelStyle}>
|
||||
{children}
|
||||
{showAction ? (
|
||||
<View style={actionIconStyle} testID={actionTestID}>
|
||||
<ThemedPlus size={16} strokeWidth={2.4} uniProps={accentForegroundIconColorMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
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,7 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
compactHighlightTokens,
|
||||
formatDiffContentText,
|
||||
formatDiffGutterText,
|
||||
hasVisibleDiffTokens,
|
||||
@@ -29,23 +28,4 @@ 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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,28 +2,6 @@ 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.
|
||||
|
||||
Reference in New Issue
Block a user