mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 02:02:42 +00:00
fix(diff): keep file headers synchronized while scrolling (#4240)
The fixed header overlay repainted after compositor-driven scroll, so ordinary headers visibly lagged behind the diff body. Paint in-flow headers with the scrolling canvas and reserve a bounded sticky canvas pool for push-off behavior.
This commit is contained in:
@@ -397,23 +397,24 @@ test("every interactive file header has the same hover feedback", async ({ page
|
||||
|
||||
const first = page.getByTestId("diff-file-0-toggle");
|
||||
const second = page.getByTestId("diff-file-1-toggle");
|
||||
const canvas = page.getByTestId("git-diff-header-canvas");
|
||||
const normalBackground = await headerCanvasPixel(canvas, first, 10);
|
||||
const firstCanvas = page.getByTestId("git-diff-sticky-header-0");
|
||||
const secondCanvas = page.getByTestId("git-diff-sticky-header-1");
|
||||
const normalBackground = await headerCanvasPixel(firstCanvas, first, 10);
|
||||
|
||||
await first.hover();
|
||||
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).not.toBe(normalBackground);
|
||||
const hoverBackground = await headerCanvasPixel(canvas, first, 10);
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).not.toBe(normalBackground);
|
||||
const hoverBackground = await headerCanvasPixel(firstCanvas, first, 10);
|
||||
|
||||
await first.click();
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(first).toHaveAttribute("aria-expanded", "false");
|
||||
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(normalBackground);
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(normalBackground);
|
||||
|
||||
await first.hover();
|
||||
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(hoverBackground);
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(hoverBackground);
|
||||
await second.hover();
|
||||
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(normalBackground);
|
||||
await expect.poll(() => headerCanvasPixel(canvas, second, 10)).toBe(hoverBackground);
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(normalBackground);
|
||||
await expect.poll(() => headerCanvasPixel(secondCanvas, second, 10)).toBe(hoverBackground);
|
||||
});
|
||||
|
||||
test("horizontal body scrolling never moves or repaints the canvas header", async ({ page }) => {
|
||||
@@ -422,22 +423,28 @@ test("horizontal body scrolling never moves or repaints the canvas header", asyn
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
|
||||
const headerCanvas = page.getByTestId("git-diff-header-canvas");
|
||||
const headerCanvas = page.getByTestId("git-diff-canvas");
|
||||
const header = page.getByTestId("diff-file-0-toggle");
|
||||
const beforeImage = await headerCanvas.evaluate((element) =>
|
||||
(element as HTMLCanvasElement).toDataURL(),
|
||||
);
|
||||
const beforePixel = await headerCanvasPixel(headerCanvas, header, 10);
|
||||
const beforeBounds = await header.boundingBox();
|
||||
|
||||
await horizontallyScrollFirstFile(page, 320);
|
||||
await page.waitForTimeout(50);
|
||||
|
||||
expect(await headerCanvas.evaluate((element) => (element as HTMLCanvasElement).toDataURL())).toBe(
|
||||
beforeImage,
|
||||
);
|
||||
expect(await headerCanvasPixel(headerCanvas, header, 10)).toBe(beforePixel);
|
||||
expect(await header.boundingBox()).toEqual(beforeBounds);
|
||||
});
|
||||
|
||||
test("in-flow file headers move with the diff document", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithStickyTransitionDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
|
||||
const motion = await measureInFlowHeaderMotion(page);
|
||||
|
||||
expect(motion).toEqual({ headerSurface: -120, shell: -120 });
|
||||
});
|
||||
|
||||
test("the outgoing sticky header hands off without a gap or overlap", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
@@ -1096,7 +1103,7 @@ test("canvas headers keep a many-file diff bounded while scrolling end to end",
|
||||
|
||||
const root = page.getByTestId("git-diff-canvas-root");
|
||||
const scroller = page.getByTestId("git-diff-scroll");
|
||||
await expect(page.getByTestId("git-diff-header-canvas")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(root.locator('[data-testid^="git-diff-sticky-header-"]')).toHaveCount(2);
|
||||
await expect.poll(() => root.locator('[data-diff-header="true"]').count()).toBeLessThan(120);
|
||||
|
||||
await scroller.evaluate((element) => {
|
||||
@@ -1106,7 +1113,7 @@ test("canvas headers keep a many-file diff bounded while scrolling end to end",
|
||||
|
||||
await expect(page.getByTestId("diff-file-1999")).toBeVisible();
|
||||
await expect.poll(() => root.locator('[data-diff-header="true"]').count()).toBeLessThan(120);
|
||||
await expect(page.getByTestId("git-diff-header-canvas")).toBeVisible();
|
||||
await expect(root.locator('[data-testid^="git-diff-sticky-header-"]')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test("the whole reviewable row reveals the gutter affordance and uses a text cursor", async ({
|
||||
@@ -2030,6 +2037,39 @@ async function horizontallyScrollFirstFile(page: Page, requestedOffset: number):
|
||||
return retainedOffset;
|
||||
}
|
||||
|
||||
async function measureInFlowHeaderMotion(
|
||||
page: Page,
|
||||
): Promise<{ headerSurface: number; shell: number }> {
|
||||
return page.getByTestId("git-diff-scroll").evaluate(async (element) => {
|
||||
const scroll = element as HTMLElement;
|
||||
const headerSurface = document.querySelector<HTMLElement>('[data-testid="git-diff-canvas"]');
|
||||
const shell = document.querySelector<HTMLElement>('[data-diff-header-path="src/second.ts"]');
|
||||
if (!headerSurface || !shell) throw new Error("Diff header motion surfaces are unavailable");
|
||||
|
||||
const shellDocumentTop =
|
||||
scroll.scrollTop + shell.getBoundingClientRect().top - scroll.getBoundingClientRect().top;
|
||||
scroll.scrollTop = Math.max(0, shellDocumentTop - scroll.clientHeight + 80);
|
||||
scroll.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
|
||||
const before = {
|
||||
headerSurface: headerSurface.getBoundingClientRect().top,
|
||||
shell: shell.getBoundingClientRect().top,
|
||||
};
|
||||
scroll.scrollTop += 120;
|
||||
const after = {
|
||||
headerSurface: headerSurface.getBoundingClientRect().top,
|
||||
shell: shell.getBoundingClientRect().top,
|
||||
};
|
||||
return {
|
||||
headerSurface: after.headerSurface - before.headerSurface,
|
||||
shell: after.shell - before.shell,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function longPressFileHeader(page: Page, header: Locator): Promise<void> {
|
||||
const bounds = await header.boundingBox();
|
||||
if (!bounds) throw new Error("File header has no bounds");
|
||||
|
||||
@@ -105,7 +105,7 @@ async function createFeatureCommit(repoPath: string): Promise<void> {
|
||||
async function expectCommitDiffHeaderGeometry(panel: Locator): Promise<void> {
|
||||
const [header, canvas] = await Promise.all([
|
||||
panel.getByTestId("diff-file-0").boundingBox(),
|
||||
panel.getByTestId("git-diff-header-canvas").boundingBox(),
|
||||
panel.getByTestId("git-diff-sticky-header-0").boundingBox(),
|
||||
]);
|
||||
expect(header).not.toBeNull();
|
||||
expect(canvas).not.toBeNull();
|
||||
|
||||
@@ -93,7 +93,10 @@ diffPerfDescribe("Diff canvas performance", () => {
|
||||
[
|
||||
root.querySelectorAll('[data-diff-header="true"]').length,
|
||||
root.querySelectorAll("*").length,
|
||||
root.querySelector('[data-testid="git-diff-header-canvas"]') ? "canvas" : "dom",
|
||||
root.querySelector('[data-testid="git-diff-canvas"]') &&
|
||||
root.querySelectorAll('[data-testid^="git-diff-sticky-header-"]').length === 2
|
||||
? "canvas-pool"
|
||||
: "dom",
|
||||
] as const,
|
||||
);
|
||||
const headerDomMetrics = await page.getByTestId("diff-file-0-toggle").evaluate((header) => {
|
||||
@@ -125,7 +128,7 @@ diffPerfDescribe("Diff canvas performance", () => {
|
||||
};
|
||||
});
|
||||
const headerCanvasMetrics = await page
|
||||
.getByTestId("git-diff-header-canvas")
|
||||
.getByTestId("git-diff-sticky-header-0")
|
||||
.evaluate((canvas: HTMLCanvasElement) => {
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Header canvas has no 2D context");
|
||||
@@ -168,6 +171,7 @@ diffPerfDescribe("Diff canvas performance", () => {
|
||||
);
|
||||
if (!RUN_HEADER_COMPARISON) {
|
||||
expect(benchmark.maximumShells).toBeLessThan(120);
|
||||
expect(benchmark.maximumStickyCanvases).toBe(2);
|
||||
expect(benchmark.blankHeaderFrames).toBe(0);
|
||||
expect(benchmark.medianFrameMs).toBeLessThanOrEqual(MANY_FILE_MAX_MEDIAN_FRAME_MS);
|
||||
expect(benchmark.p95FrameMs).toBeLessThanOrEqual(MANY_FILE_MAX_P95_FRAME_MS);
|
||||
@@ -435,7 +439,8 @@ async function openFirstReviewEditor(page: Page): Promise<number> {
|
||||
}
|
||||
|
||||
async function expectBoundedCanvasSurfaces(page: Page): Promise<void> {
|
||||
await expect(page.locator("canvas")).toHaveCount(2);
|
||||
await expect(page.locator("canvas")).toHaveCount(3);
|
||||
await expect(page.locator('[data-testid^="git-diff-sticky-header-"]')).toHaveCount(2);
|
||||
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
|
||||
const elementCount = await page
|
||||
.getByTestId("git-diff-canvas-root")
|
||||
@@ -527,40 +532,57 @@ async function benchmarkManyFileHeaderScrolling(page: Page): Promise<{
|
||||
p95FrameMs: number;
|
||||
maximumFrameMs: number;
|
||||
maximumShells: number;
|
||||
maximumStickyCanvases: number;
|
||||
blankHeaderFrames: number;
|
||||
endScrollTop: number;
|
||||
maximumScrollTop: number;
|
||||
}> {
|
||||
return page.getByTestId("git-diff-scroll").evaluate(async (element) => {
|
||||
const scroll = element as HTMLElement;
|
||||
const canvas = document.querySelector<HTMLCanvasElement>(
|
||||
'[data-testid="git-diff-header-canvas"]',
|
||||
);
|
||||
const maximumScrollTop = scroll.scrollHeight - scroll.clientHeight;
|
||||
const frameDurations: number[] = [];
|
||||
let maximumShells = 0;
|
||||
let maximumStickyCanvases = 0;
|
||||
let blankHeaderFrames = 0;
|
||||
const sampleHeader = () => {
|
||||
maximumShells = Math.max(
|
||||
maximumShells,
|
||||
document.querySelectorAll('[data-diff-header="true"]').length,
|
||||
);
|
||||
const canvases = Array.from(
|
||||
document.querySelectorAll<HTMLCanvasElement>('[data-testid^="git-diff-sticky-header-"]'),
|
||||
);
|
||||
maximumStickyCanvases = Math.max(maximumStickyCanvases, canvases.length);
|
||||
const scrollBounds = scroll.getBoundingClientRect();
|
||||
const sampleX = scrollBounds.left + 4;
|
||||
const sampleY = scrollBounds.top + 10;
|
||||
const canvas = canvases.find((candidate) => {
|
||||
const bounds = candidate.getBoundingClientRect();
|
||||
return (
|
||||
bounds.width > 0 &&
|
||||
sampleX >= bounds.left &&
|
||||
sampleX < bounds.right &&
|
||||
sampleY >= bounds.top &&
|
||||
sampleY < bounds.bottom
|
||||
);
|
||||
});
|
||||
if (canvas) {
|
||||
const context = canvas.getContext("2d")!;
|
||||
const ratio = canvas.width / canvas.getBoundingClientRect().width;
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
const ratio = canvas.width / bounds.width;
|
||||
if (
|
||||
context.getImageData(Math.round(4 * ratio), Math.round(10 * ratio), 1, 1).data[3] === 0
|
||||
context.getImageData(
|
||||
Math.round((sampleX - bounds.left) * ratio),
|
||||
Math.round((sampleY - bounds.top) * ratio),
|
||||
1,
|
||||
1,
|
||||
).data[3] === 0
|
||||
) {
|
||||
blankHeaderFrames += 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const bounds = scroll.getBoundingClientRect();
|
||||
if (
|
||||
!document.elementFromPoint(bounds.left + 4, bounds.top + 10)?.closest("[data-diff-header]")
|
||||
) {
|
||||
blankHeaderFrames += 1;
|
||||
}
|
||||
blankHeaderFrames += 1;
|
||||
};
|
||||
|
||||
for (const progress of [0, 0.25, 0.5, 0.75, 1]) {
|
||||
@@ -601,6 +623,7 @@ async function benchmarkManyFileHeaderScrolling(page: Page): Promise<{
|
||||
p95FrameMs: percentile(0.95),
|
||||
maximumFrameMs: Math.max(...frameDurations),
|
||||
maximumShells,
|
||||
maximumStickyCanvases,
|
||||
blankHeaderFrames,
|
||||
endScrollTop: scroll.scrollTop,
|
||||
maximumScrollTop,
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
diffHeaderViewportKey,
|
||||
diffInteractionWindowTop,
|
||||
resolveVisibleFileSections,
|
||||
} from "./header-layout";
|
||||
import { diffInteractionWindowTop, resolveVisibleFileSections } from "./header-layout";
|
||||
import type { DiffFileSection } from "./types";
|
||||
|
||||
describe("diff header viewport", () => {
|
||||
@@ -48,15 +44,6 @@ describe("diff header viewport", () => {
|
||||
?.y,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("reuses a fixed sticky header until its visible layout changes", () => {
|
||||
const files = [section(0, 0, 4_000, false), section(1, 4_000, 4_030, true)];
|
||||
const keyAt = (scrollTop: number) =>
|
||||
diffHeaderViewportKey({ files, scrollTop, viewportHeight: 600, activePath: null });
|
||||
|
||||
expect(keyAt(100)).toBe(keyAt(2_000));
|
||||
expect(keyAt(3_985)).not.toBe(keyAt(2_000));
|
||||
});
|
||||
});
|
||||
|
||||
describe("diff interaction window", () => {
|
||||
|
||||
@@ -21,20 +21,6 @@ export function diffMaterializationWindow(
|
||||
};
|
||||
}
|
||||
|
||||
export function diffHeaderViewportKey(input: {
|
||||
files: readonly DiffFileSection[];
|
||||
scrollTop: number;
|
||||
viewportHeight: number;
|
||||
activePath: string | null;
|
||||
}): string {
|
||||
const window = resolveVisibleFileSections({ ...input, overscan: 0 });
|
||||
const headers = window.files
|
||||
.filter((file) => file !== window.sticky?.file)
|
||||
.map((file) => `${file.fileIndex}:${file.top - input.scrollTop}`);
|
||||
if (window.sticky) headers.push(`${window.sticky.file.fileIndex}:${window.sticky.y}`);
|
||||
return `${input.activePath ?? ""}|${headers.join(",")}`;
|
||||
}
|
||||
|
||||
export function resolveVisibleFileSections(input: {
|
||||
files: readonly DiffFileSection[];
|
||||
scrollTop: number;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { paintWebHeaders, paintWebViewport } from "./paint.web";
|
||||
import { paintWebViewport } from "./paint.web";
|
||||
import type { DiffCell, DiffDocumentModel, DiffLineRow, DiffPalette, DiffSelection } from "./types";
|
||||
|
||||
describe("web diff text shaping", () => {
|
||||
@@ -53,7 +53,7 @@ describe("web diff text shaping", () => {
|
||||
lineJoin: "miter",
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
|
||||
paintWebHeaders({
|
||||
paintWebViewport({
|
||||
context,
|
||||
model: {
|
||||
...model,
|
||||
@@ -69,19 +69,25 @@ describe("web diff text shaping", () => {
|
||||
height: 48,
|
||||
},
|
||||
palette,
|
||||
typography: { family: "system-ui", size: 14, statSize: 12 },
|
||||
typography: { family: "monospace", size: 12, lineHeight: 18 },
|
||||
headerTypography: { family: "system-ui", size: 14, statSize: 12 },
|
||||
measureText: { measure: () => 0 },
|
||||
scrollTop: 0,
|
||||
viewportWidth: 200,
|
||||
viewportHeight: 100,
|
||||
horizontalOffsets: new Map(),
|
||||
selection: null,
|
||||
devicePixelRatio: 1,
|
||||
activePath: null,
|
||||
activeHeaderPath: null,
|
||||
});
|
||||
|
||||
expect(fills.slice(0, 2)).toEqual([
|
||||
expect(
|
||||
fills.filter((fill) => fill.color === "header" || fill.color === "header-border"),
|
||||
).toEqual([
|
||||
{ color: "header", x: 0, y: 0, width: 200, height: 30 },
|
||||
{ color: "header-border", x: 0, y: 29, width: 200, height: 1 },
|
||||
]);
|
||||
expect(labels).toEqual([
|
||||
expect(labels.filter((label) => label.text !== "fi")).toEqual([
|
||||
{ text: "+1", x: 142, y: 18, color: "success", font: "12px system-ui" },
|
||||
{ text: "-0", x: 158, y: 18, color: "danger", font: "12px system-ui" },
|
||||
{ text: "a.ts", x: 12, y: 18, color: "foreground", font: "14px system-ui" },
|
||||
@@ -114,12 +120,14 @@ describe("web diff text shaping", () => {
|
||||
model,
|
||||
palette,
|
||||
typography: { family: "ligature-font", size: 12, lineHeight: 18 },
|
||||
headerTypography,
|
||||
measureText: { measure: () => 0 },
|
||||
scrollTop: 0,
|
||||
viewportWidth: 200,
|
||||
viewportHeight: 100,
|
||||
horizontalOffsets: new Map(),
|
||||
selection: null,
|
||||
activeHeaderPath: null,
|
||||
devicePixelRatio: 1,
|
||||
});
|
||||
|
||||
@@ -157,12 +165,14 @@ describe("web diff text shaping", () => {
|
||||
model: modelWithReview,
|
||||
palette,
|
||||
typography: { family: "monospace", size: 12, lineHeight: 18 },
|
||||
headerTypography,
|
||||
measureText: { measure: () => 0 },
|
||||
scrollTop: 0,
|
||||
viewportWidth: 200,
|
||||
viewportHeight: 100,
|
||||
horizontalOffsets: new Map(),
|
||||
selection: null,
|
||||
activeHeaderPath: null,
|
||||
devicePixelRatio: 1,
|
||||
});
|
||||
|
||||
@@ -220,12 +230,14 @@ describe("web diff text shaping", () => {
|
||||
model: borderedModel,
|
||||
palette,
|
||||
typography: { family: "monospace", size: 12, lineHeight: 18 },
|
||||
headerTypography,
|
||||
measureText: { measure: () => 0 },
|
||||
scrollTop: 0,
|
||||
viewportWidth: 200,
|
||||
viewportHeight: 100,
|
||||
horizontalOffsets: new Map(),
|
||||
selection: null,
|
||||
activeHeaderPath: null,
|
||||
devicePixelRatio: 1,
|
||||
});
|
||||
|
||||
@@ -331,12 +343,14 @@ function paintSelection(model: DiffDocumentModel, selection: DiffSelection): Sel
|
||||
model,
|
||||
palette,
|
||||
typography: { family: "monospace", size: 12, lineHeight: 18 },
|
||||
headerTypography,
|
||||
measureText: { measure: () => 0 },
|
||||
scrollTop: 0,
|
||||
viewportWidth: 200,
|
||||
viewportHeight: 100,
|
||||
horizontalOffsets: new Map([["src/selection.ts", 50]]),
|
||||
selection,
|
||||
activeHeaderPath: null,
|
||||
devicePixelRatio: 1,
|
||||
});
|
||||
return paints;
|
||||
@@ -451,6 +465,8 @@ const palette: DiffPalette = {
|
||||
syntax: { first: "red", second: "blue" },
|
||||
};
|
||||
|
||||
const headerTypography = { family: "system-ui", size: 14, statSize: 12 };
|
||||
|
||||
const model: DiffDocumentModel = {
|
||||
files: [
|
||||
{
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
fileNameForPath,
|
||||
formatDiffCount,
|
||||
} from "@/git/file-header-presentation";
|
||||
import { resolveVisibleFileSections } from "./header-layout";
|
||||
import { reviewBackgroundPaint, reviewDividerHeight, reviewGapTop } from "./review-paint";
|
||||
import type {
|
||||
DiffCell,
|
||||
@@ -40,12 +39,14 @@ export interface PaintWebViewportInput {
|
||||
model: DiffDocumentModel;
|
||||
palette: DiffPalette;
|
||||
typography: DiffTypography;
|
||||
headerTypography: DiffHeaderTypography;
|
||||
measureText: TextMeasurer;
|
||||
scrollTop: number;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
horizontalOffsets: ReadonlyMap<string, number>;
|
||||
selection: DiffSelection | null;
|
||||
activeHeaderPath: string | null;
|
||||
devicePixelRatio: number;
|
||||
paintTop?: number;
|
||||
paintHeight?: number;
|
||||
@@ -98,42 +99,38 @@ export function paintWebViewport(input: PaintWebViewportInput): void {
|
||||
context.fillRect(0, borderTop - input.scrollTop, input.viewportWidth, DIFF_BODY_BORDER_HEIGHT);
|
||||
}
|
||||
|
||||
for (const file of input.model.files) {
|
||||
if (
|
||||
file.headerHeight <= 0 ||
|
||||
file.top + file.headerHeight <= paintDocumentTop ||
|
||||
file.top >= paintDocumentBottom
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
paintWebFileHeader({
|
||||
context,
|
||||
file,
|
||||
palette: input.palette,
|
||||
typography: input.headerTypography,
|
||||
viewportWidth: input.viewportWidth,
|
||||
y: file.top - input.scrollTop,
|
||||
activePath: input.activeHeaderPath,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.selection) paintSelection(input, input.selection);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
export function paintWebHeaders(input: {
|
||||
export function paintWebFileHeader(input: {
|
||||
context: CanvasRenderingContext2D;
|
||||
model: DiffDocumentModel;
|
||||
file: DiffFileSection;
|
||||
palette: DiffPalette;
|
||||
typography: DiffHeaderTypography;
|
||||
scrollTop: number;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
devicePixelRatio: number;
|
||||
y: number;
|
||||
activePath: string | null;
|
||||
}): void {
|
||||
const scale = input.devicePixelRatio;
|
||||
input.context.setTransform(scale, 0, 0, scale, 0, 0);
|
||||
input.context.clearRect(0, 0, input.viewportWidth, input.viewportHeight);
|
||||
const window = resolveVisibleFileSections({
|
||||
files: input.model.files,
|
||||
scrollTop: input.scrollTop,
|
||||
viewportHeight: input.viewportHeight,
|
||||
overscan: 0,
|
||||
});
|
||||
for (const file of window.files) {
|
||||
if (file === window.sticky?.file) continue;
|
||||
const y = file.top - input.scrollTop;
|
||||
if (y + file.headerHeight <= 0 || y >= input.viewportHeight) continue;
|
||||
paintWebHeader({ ...input, file, y });
|
||||
}
|
||||
if (window.sticky) paintWebHeader({ ...input, file: window.sticky.file, y: window.sticky.y });
|
||||
}
|
||||
|
||||
function paintWebHeader(
|
||||
input: Parameters<typeof paintWebHeaders>[0] & { file: DiffFileSection; y: number },
|
||||
): void {
|
||||
const { context, file, palette, typography, viewportWidth, y } = input;
|
||||
context.fillStyle =
|
||||
input.activePath === file.path ? palette.headerActiveSurface : palette.headerSurface;
|
||||
|
||||
@@ -15,7 +15,6 @@ import { copyToClipboard } from "@/utils/copy-to-clipboard";
|
||||
import type { ReviewableDiffTarget } from "@/utils/diff-layout";
|
||||
import { DocumentFileHeader } from "./document-file-header";
|
||||
import {
|
||||
diffHeaderViewportKey,
|
||||
diffInteractionWindowTop,
|
||||
diffMaterializationWindow,
|
||||
resolveVisibleFileSections,
|
||||
@@ -29,12 +28,13 @@ import {
|
||||
import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets";
|
||||
import { HorizontalScroll } from "./horizontal-scroll.web";
|
||||
import { buildDiffDocumentModel, FILE_HEADER_HEIGHT, resolveRelayoutScrollTop } from "./model";
|
||||
import { paintWebHeaders, paintWebViewport } from "./paint.web";
|
||||
import { paintWebFileHeader, paintWebViewport } from "./paint.web";
|
||||
import { hasPointerDragStarted } from "./pointer-gesture";
|
||||
import { createMeasuredAdvances } from "./text-measurement";
|
||||
import { retainDiffViewport } from "./viewport";
|
||||
import type {
|
||||
DiffHit,
|
||||
DiffFileSection,
|
||||
DiffSelection,
|
||||
DiffSurfaceProps,
|
||||
DiffTypography,
|
||||
@@ -45,13 +45,40 @@ import { useDiffDocumentWorkspaceCache } from "./workspace-cache";
|
||||
const DEFAULT_MONO_STACK = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace";
|
||||
const RESIZE_SETTLE_DELAY_MS = 120;
|
||||
|
||||
interface StickyHeaderCanvasSlot {
|
||||
section: HTMLDivElement | null;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
file: DiffFileSection | null;
|
||||
active: boolean;
|
||||
width: number;
|
||||
ratio: number;
|
||||
palette: DiffSurfaceProps["palette"] | null;
|
||||
typography: DiffSurfaceProps["headerTypography"] | null;
|
||||
}
|
||||
|
||||
function emptyStickyHeaderCanvasSlot(): StickyHeaderCanvasSlot {
|
||||
return {
|
||||
section: null,
|
||||
canvas: null,
|
||||
file: null,
|
||||
active: false,
|
||||
width: 0,
|
||||
ratio: 0,
|
||||
palette: null,
|
||||
typography: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function DiffSurface(props: DiffSurfaceProps) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const workspaceCache = useDiffDocumentWorkspaceCache();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const headerCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const stickyHeaderSlotsRef = useRef<[StickyHeaderCanvasSlot, StickyHeaderCanvasSlot]>([
|
||||
emptyStickyHeaderCanvasSlot(),
|
||||
emptyStickyHeaderCanvasSlot(),
|
||||
]);
|
||||
const canvasScratchRef = useRef<HTMLCanvasElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const modelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
|
||||
@@ -68,16 +95,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
dismissSelectionOnClick: boolean;
|
||||
} | null>(null);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
const headerFrameRef = useRef<number | null>(null);
|
||||
const headerPaintRef = useRef<{
|
||||
key: string;
|
||||
model: ReturnType<typeof buildDiffDocumentModel>;
|
||||
width: number;
|
||||
height: number;
|
||||
ratio: number;
|
||||
palette: DiffSurfaceProps["palette"];
|
||||
typography: DiffSurfaceProps["headerTypography"];
|
||||
} | null>(null);
|
||||
const activeHeaderPathRef = useRef<string | null>(null);
|
||||
const resizeSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const resizeReleaseFrameRef = useRef<number | null>(null);
|
||||
@@ -185,6 +202,75 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
]);
|
||||
modelRef.current = model;
|
||||
|
||||
const paintStickyHeaderPool = useCallback(
|
||||
(currentModel: ReturnType<typeof buildDiffDocumentModel>, scrollTop: number) => {
|
||||
const stickyFile = resolveVisibleFileSections({
|
||||
files: currentModel.files,
|
||||
scrollTop,
|
||||
viewportHeight: Math.max(1, viewport.height),
|
||||
overscan: 0,
|
||||
}).sticky?.file;
|
||||
const pooledFiles = stickyFile
|
||||
? [stickyFile, currentModel.files[stickyFile.fileIndex + 1]].filter(
|
||||
(file): file is DiffFileSection => file !== undefined,
|
||||
)
|
||||
: [];
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
|
||||
for (const [slotIndex, slot] of stickyHeaderSlotsRef.current.entries()) {
|
||||
const file = pooledFiles.find((candidate) => candidate.fileIndex % 2 === slotIndex) ?? null;
|
||||
const { section, canvas } = slot;
|
||||
if (!section || !canvas) continue;
|
||||
if (!file) {
|
||||
section.style.display = "none";
|
||||
slot.file = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
section.style.display = "block";
|
||||
section.style.top = `${file.top}px`;
|
||||
section.style.height = `${file.bottom - file.top}px`;
|
||||
section.dataset.diffStickyHeaderPath = file.path;
|
||||
const active = activeHeaderPathRef.current === file.path;
|
||||
const canReusePaint =
|
||||
slot.file === file &&
|
||||
slot.active === active &&
|
||||
slot.width === viewport.width &&
|
||||
slot.ratio === ratio &&
|
||||
slot.palette === props.palette &&
|
||||
slot.typography === props.headerTypography;
|
||||
if (canReusePaint) continue;
|
||||
|
||||
const pixelWidth = Math.ceil(viewport.width * ratio);
|
||||
const pixelHeight = Math.ceil(FILE_HEADER_HEIGHT * ratio);
|
||||
if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
|
||||
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
|
||||
canvas.style.width = `${viewport.width}px`;
|
||||
canvas.style.height = `${FILE_HEADER_HEIGHT}px`;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) continue;
|
||||
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
context.clearRect(0, 0, viewport.width, FILE_HEADER_HEIGHT);
|
||||
paintWebFileHeader({
|
||||
context,
|
||||
file,
|
||||
palette: props.palette,
|
||||
typography: props.headerTypography,
|
||||
viewportWidth: viewport.width,
|
||||
y: 0,
|
||||
activePath: activeHeaderPathRef.current,
|
||||
});
|
||||
slot.file = file;
|
||||
slot.active = active;
|
||||
slot.width = viewport.width;
|
||||
slot.ratio = ratio;
|
||||
slot.palette = props.palette;
|
||||
slot.typography = props.headerTypography;
|
||||
}
|
||||
},
|
||||
[props.headerTypography, props.palette, viewport.height, viewport.width],
|
||||
);
|
||||
|
||||
const paint = useCallback(() => {
|
||||
frameRef.current = null;
|
||||
const canvas = canvasRef.current;
|
||||
@@ -244,17 +330,19 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
model: currentModel,
|
||||
palette: props.palette,
|
||||
typography: loadedTypography,
|
||||
headerTypography: props.headerTypography,
|
||||
measureText: measurement,
|
||||
scrollTop: canvasTop,
|
||||
viewportWidth: currentModel.viewportWidth,
|
||||
viewportHeight: canvasHeight,
|
||||
horizontalOffsets: horizontalOffsetsRef.current,
|
||||
selection: selectionRef.current,
|
||||
activeHeaderPath: activeHeaderPathRef.current,
|
||||
devicePixelRatio: ratio,
|
||||
paintTop,
|
||||
paintHeight,
|
||||
});
|
||||
}, [loadedTypography, measurement, props.palette, viewport.height]);
|
||||
}, [loadedTypography, measurement, props.headerTypography, props.palette, viewport.height]);
|
||||
const schedulePaint = useCallback(
|
||||
(force = true) => {
|
||||
if (force) forcePaintRef.current = true;
|
||||
@@ -262,65 +350,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
},
|
||||
[paint],
|
||||
);
|
||||
const paintHeaders = useCallback(() => {
|
||||
headerFrameRef.current = null;
|
||||
const canvas = headerCanvasRef.current;
|
||||
const currentModel = modelRef.current;
|
||||
if (!canvas || !currentModel || viewport.width <= 0 || viewport.height <= 0) return;
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
const key = diffHeaderViewportKey({
|
||||
files: currentModel.files,
|
||||
scrollTop: scrollTopRef.current,
|
||||
viewportHeight: viewport.height,
|
||||
activePath: activeHeaderPathRef.current,
|
||||
});
|
||||
const previous = headerPaintRef.current;
|
||||
if (
|
||||
previous?.key === key &&
|
||||
previous.model === currentModel &&
|
||||
previous.width === viewport.width &&
|
||||
previous.height === viewport.height &&
|
||||
previous.ratio === ratio &&
|
||||
previous.palette === props.palette &&
|
||||
previous.typography === props.headerTypography
|
||||
) {
|
||||
return;
|
||||
}
|
||||
headerPaintRef.current = {
|
||||
key,
|
||||
model: currentModel,
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
ratio,
|
||||
palette: props.palette,
|
||||
typography: props.headerTypography,
|
||||
};
|
||||
const pixelWidth = Math.ceil(viewport.width * ratio);
|
||||
const pixelHeight = Math.ceil(viewport.height * ratio);
|
||||
if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
|
||||
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
|
||||
canvas.style.width = `${viewport.width}px`;
|
||||
canvas.style.height = `${viewport.height}px`;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
paintWebHeaders({
|
||||
context,
|
||||
model: currentModel,
|
||||
palette: props.palette,
|
||||
typography: props.headerTypography,
|
||||
scrollTop: scrollTopRef.current,
|
||||
viewportWidth: viewport.width,
|
||||
viewportHeight: viewport.height,
|
||||
devicePixelRatio: ratio,
|
||||
activePath: activeHeaderPathRef.current,
|
||||
});
|
||||
}, [props.headerTypography, props.palette, viewport.height, viewport.width]);
|
||||
const scheduleHeaderPaint = useCallback(() => {
|
||||
if (headerFrameRef.current === null) {
|
||||
headerFrameRef.current = requestAnimationFrame(paintHeaders);
|
||||
}
|
||||
}, [paintHeaders]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
@@ -427,17 +456,15 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
schedulePaint();
|
||||
scheduleHeaderPaint();
|
||||
paintStickyHeaderPool(model, scrollTopRef.current);
|
||||
updateInteractionFiles(scrollTopRef.current);
|
||||
}, [model, scheduleHeaderPaint, schedulePaint, updateInteractionFiles]);
|
||||
}, [model, paintStickyHeaderPool, schedulePaint, updateInteractionFiles]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
|
||||
if (headerFrameRef.current !== null) cancelAnimationFrame(headerFrameRef.current);
|
||||
// Fast Refresh preserves refs while rerunning effects. Release ownership of the canceled
|
||||
// request so the refreshed renderer can schedule its first paint.
|
||||
frameRef.current = null;
|
||||
headerFrameRef.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -462,14 +489,18 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
(scrollElement: HTMLDivElement) => {
|
||||
const scrollTop = scrollElement.scrollTop;
|
||||
scrollTopRef.current = scrollTop;
|
||||
if (activeHeaderPathRef.current !== null) activeHeaderPathRef.current = null;
|
||||
scheduleHeaderPaint();
|
||||
const clearedActiveHeader = activeHeaderPathRef.current !== null;
|
||||
if (clearedActiveHeader) {
|
||||
activeHeaderPathRef.current = null;
|
||||
schedulePaint();
|
||||
}
|
||||
updateInteractionFiles(scrollTop);
|
||||
if (hasHoveredAffordanceRef.current) {
|
||||
hasHoveredAffordanceRef.current = false;
|
||||
setHoveredAffordance(null);
|
||||
}
|
||||
const currentModel = modelRef.current;
|
||||
if (currentModel) paintStickyHeaderPool(currentModel, scrollTop);
|
||||
const currentWindow = canvasWindowRef.current;
|
||||
if (!currentModel || currentWindow.height === 0) {
|
||||
schedulePaint(false);
|
||||
@@ -489,7 +520,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
const nextTop = Math.round(requestedTop * ratio) / ratio;
|
||||
if (nextTop !== currentWindow.top) schedulePaint(false);
|
||||
},
|
||||
[scheduleHeaderPaint, schedulePaint, updateInteractionFiles, viewport.height],
|
||||
[paintStickyHeaderPool, schedulePaint, updateInteractionFiles, viewport.height],
|
||||
);
|
||||
useEffect(() => {
|
||||
const scroll = scrollRef.current;
|
||||
@@ -580,9 +611,11 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
const activeHeaderPath = header?.dataset.diffHeaderPath ?? null;
|
||||
if (activeHeaderPathRef.current === activeHeaderPath) return;
|
||||
activeHeaderPathRef.current = activeHeaderPath;
|
||||
scheduleHeaderPaint();
|
||||
const currentModel = modelRef.current;
|
||||
if (currentModel) paintStickyHeaderPool(currentModel, scrollTopRef.current);
|
||||
schedulePaint();
|
||||
},
|
||||
[scheduleHeaderPaint],
|
||||
[paintStickyHeaderPool, schedulePaint],
|
||||
);
|
||||
const pointerMove = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
@@ -780,6 +813,8 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
>
|
||||
<div style={contentStyle} onMouseDown={preventDocumentMouseSelection}>
|
||||
<canvas ref={canvasRef} data-testid="git-diff-canvas" style={canvasStyle} />
|
||||
<StickyHeaderCanvasSlotView slotsRef={stickyHeaderSlotsRef} slotIndex={0} />
|
||||
<StickyHeaderCanvasSlotView slotsRef={stickyHeaderSlotsRef} slotIndex={1} />
|
||||
{interactionFiles.map((file) => (
|
||||
<WebFileHeaderSection key={file.path} file={file}>
|
||||
<DocumentFileHeader
|
||||
@@ -831,11 +866,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
<canvas
|
||||
ref={headerCanvasRef}
|
||||
data-testid="git-diff-header-canvas"
|
||||
style={HEADER_CANVAS_STYLE}
|
||||
/>
|
||||
<DomOverlayScrollbar scrollContainerRef={scrollRef} onUserScrollUp={noop} />
|
||||
{hoveredAffordance?.hit.target && reviewActions ? (
|
||||
<InlineReviewAddButton onPress={addHoveredComment} style={affordanceStyle} />
|
||||
@@ -872,6 +902,36 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function StickyHeaderCanvasSlotView({
|
||||
slotsRef,
|
||||
slotIndex,
|
||||
}: {
|
||||
slotsRef: React.RefObject<[StickyHeaderCanvasSlot, StickyHeaderCanvasSlot]>;
|
||||
slotIndex: 0 | 1;
|
||||
}) {
|
||||
const setSection = useCallback(
|
||||
(section: HTMLDivElement | null) => {
|
||||
slotsRef.current[slotIndex].section = section;
|
||||
},
|
||||
[slotIndex, slotsRef],
|
||||
);
|
||||
const setCanvas = useCallback(
|
||||
(canvas: HTMLCanvasElement | null) => {
|
||||
slotsRef.current[slotIndex].canvas = canvas;
|
||||
},
|
||||
[slotIndex, slotsRef],
|
||||
);
|
||||
return (
|
||||
<div ref={setSection} style={STICKY_CANVAS_SECTION_STYLE}>
|
||||
<canvas
|
||||
ref={setCanvas}
|
||||
data-testid={`git-diff-sticky-header-${slotIndex}`}
|
||||
style={STICKY_CANVAS_STYLE}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function shiftCanvasPixels(input: {
|
||||
canvas: HTMLCanvasElement;
|
||||
scratch: HTMLCanvasElement;
|
||||
@@ -1029,6 +1089,20 @@ const FILE_SECTION_STYLE: React.CSSProperties = {
|
||||
pointerEvents: "none",
|
||||
userSelect: "none",
|
||||
};
|
||||
const STICKY_CANVAS_SECTION_STYLE: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
display: "none",
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 2,
|
||||
pointerEvents: "none",
|
||||
};
|
||||
const STICKY_CANVAS_STYLE: React.CSSProperties = {
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
display: "block",
|
||||
pointerEvents: "none",
|
||||
};
|
||||
const STICKY_HEADER_STYLE: React.CSSProperties = {
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
@@ -1051,12 +1125,6 @@ const CANVAS_STYLE: React.CSSProperties = {
|
||||
zIndex: 1,
|
||||
pointerEvents: "none",
|
||||
};
|
||||
const HEADER_CANVAS_STYLE: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 3,
|
||||
pointerEvents: "none",
|
||||
};
|
||||
const AFFORDANCE_STYLE: ViewStyle = {
|
||||
position: "absolute",
|
||||
zIndex: 5,
|
||||
|
||||
Reference in New Issue
Block a user