Keep large diffs responsive across web and mobile (#4174)

* perf(app): keep large diffs responsive

Render file headers on the canvas and bound expensive text shaping to the active viewport while preserving sticky, hover, and horizontal-scroll behavior. Let native scroll cancellation arbitrate header presses so finger scrolling cannot expand files.

* test(app): assert canvas diff header accessibility

* test(app): assert canvas diff header semantics
This commit is contained in:
Mohamed Boudra
2026-09-01 23:52:11 +02:00
committed by GitHub
parent ba54575ab7
commit 3998ea11d9
33 changed files with 2250 additions and 530 deletions
@@ -9,6 +9,8 @@ interface DocumentFileHeaderProps {
mode: DiffDocumentProps["mode"];
onToggleFile: (path: string) => void;
onSelectPath: (path: string) => void;
canvasRendered?: boolean;
onActiveChange?: (active: boolean) => void;
}
export const DocumentFileHeader = memo(function DocumentFileHeader({
@@ -17,6 +19,8 @@ export const DocumentFileHeader = memo(function DocumentFileHeader({
mode,
onToggleFile,
onSelectPath,
canvasRendered = false,
onActiveChange,
}: DocumentFileHeaderProps) {
const activate = useCallback(
(path: string) => {
@@ -47,6 +51,8 @@ export const DocumentFileHeader = memo(function DocumentFileHeader({
onDuplicate={working?.onDuplicate}
onRevert={working?.onRevert}
testID={`diff-file-${file.fileIndex}`}
canvasRendered={canvasRendered}
onActiveChange={onActiveChange}
/>
);
}, documentFileHeaderPropsEqual);
@@ -84,6 +90,8 @@ function documentFileHeaderIdentityMatches(
(previous.selectedPath === previous.file.path) !== (next.selectedPath === next.file.path) ||
previous.onToggleFile !== next.onToggleFile ||
previous.onSelectPath !== next.onSelectPath ||
previous.canvasRendered !== next.canvasRendered ||
previous.onActiveChange !== next.onActiveChange ||
previous.mode.kind !== next.mode.kind
);
}
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import {
diffHeaderViewportKey,
diffInteractionWindowTop,
resolveVisibleFileSections,
} from "./header-layout";
import type { DiffFileSection } from "./types";
describe("diff header viewport", () => {
it("keeps a many-file document bounded by the viewport", () => {
const files = Array.from({ length: 2_000 }, (_, index) => collapsedFile(index));
const window = resolveVisibleFileSections({
files,
scrollTop: 30_000,
viewportHeight: 600,
overscan: 600,
});
expect(window.files.length).toBeLessThanOrEqual(61);
expect(window.sticky?.file.fileIndex).toBe(1_000);
expect(window.sticky?.y).toBe(0);
});
it("pins an expanded file whose original header is outside overscan", () => {
const files = [expandedFile(0, 4_000), collapsedFile(1, 4_030)];
const window = resolveVisibleFileSections({
files,
scrollTop: 2_000,
viewportHeight: 600,
overscan: 600,
});
expect(window.files.map((file) => file.path)).toEqual(["file-0.ts"]);
expect(window.sticky).toEqual({ file: files[0], y: 0 });
});
it("pushes the pinned header out one pixel at a time", () => {
const files = [expandedFile(0, 100), collapsedFile(1, 130)];
expect(
resolveVisibleFileSections({ files, scrollTop: 129, viewportHeight: 100, overscan: 0 }).sticky
?.y,
).toBe(-29);
expect(
resolveVisibleFileSections({ files, scrollTop: 130, viewportHeight: 100, overscan: 0 }).sticky
?.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", () => {
it("retains one shell window across two viewport heights", () => {
expect(diffInteractionWindowTop(0, 500)).toBe(0);
expect(diffInteractionWindowTop(999, 500)).toBe(0);
expect(diffInteractionWindowTop(1_000, 500)).toBe(1_000);
});
});
function collapsedFile(fileIndex: number, top = fileIndex * 30): DiffFileSection {
return section(fileIndex, top, top + 30, true);
}
function expandedFile(fileIndex: number, bodyHeight: number): DiffFileSection {
const top = fileIndex === 0 ? 0 : bodyHeight - 30;
return section(fileIndex, top, top + 30 + bodyHeight, false);
}
function section(
fileIndex: number,
top: number,
bottom: number,
isCollapsed: boolean,
): DiffFileSection {
const path = `file-${fileIndex}.ts`;
return {
file: {
path,
oldPath: undefined,
additions: 1,
deletions: 1,
isNew: false,
isDeleted: false,
hunks: [],
},
fileIndex,
path,
top,
headerHeight: 30,
bodyTop: top + 30,
bodyHeight: bottom - top - 30,
bottom,
gutterWidth: 30,
contentWidth: 300,
rowStart: 0,
rowEnd: 0,
isCollapsed,
};
}
@@ -0,0 +1,76 @@
import type { DiffFileSection } from "./types";
export interface VisibleDiffFileSections {
files: DiffFileSection[];
sticky: { file: DiffFileSection; y: number } | null;
}
export function diffInteractionWindowTop(scrollTop: number, viewportHeight: number): number {
"worklet";
const bucketHeight = Math.max(1, viewportHeight * 2);
return Math.floor(Math.max(0, scrollTop) / bucketHeight) * bucketHeight;
}
export function diffMaterializationWindow(
windowTop: number,
viewportHeight: number,
): { top: number; height: number } {
return {
top: Math.max(0, windowTop - viewportHeight * 2),
height: viewportHeight * 5,
};
}
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;
viewportHeight: number;
overscan: number;
}): VisibleDiffFileSections {
if (input.files.length === 0 || input.viewportHeight <= 0) {
return { files: [], sticky: null };
}
const visibleTop = Math.max(0, input.scrollTop - input.overscan);
const visibleBottom = input.scrollTop + input.viewportHeight + input.overscan;
const start = firstFileWhoseEndExceeds(input.files, visibleTop);
const files: DiffFileSection[] = [];
for (let index = start; index < input.files.length; index += 1) {
const file = input.files[index];
if (!file || file.top >= visibleBottom) break;
files.push(file);
}
const stickyFile = input.files[firstFileWhoseEndExceeds(input.files, input.scrollTop)] ?? null;
const sticky = stickyFile
? {
file: stickyFile,
y: Math.min(0, stickyFile.bottom - input.scrollTop - stickyFile.headerHeight),
}
: null;
return { files, sticky };
}
function firstFileWhoseEndExceeds(files: readonly DiffFileSection[], offset: number): number {
let low = 0;
let high = files.length;
while (low < high) {
const middle = (low + high) >>> 1;
const file = files[middle];
if (file && file.bottom <= offset) low = middle + 1;
else high = middle;
}
return low;
}
@@ -147,6 +147,11 @@ function buildModel(changedContent: string, overrides: Partial<BuildDiffDocument
deletionBackground: "#100",
emptyBackground: "#111",
selection: "blue",
headerActiveSurface: "#222",
headerBorder: "#333",
statusSuccess: "green",
statusDanger: "red",
statusWarning: "orange",
syntax: {},
},
labels: { binary: "Binary", tooLarge: "Too large" },
+7 -1
View File
@@ -3,12 +3,13 @@ import { withUnistyles } from "react-native-unistyles";
import { RenderProfile } from "@/utils/render-profiler";
import { createDiffPalette, retainDiffPalette } from "./palette";
import { DiffSurface } from "./surface";
import type { DiffDocumentProps, DiffPalette } from "./types";
import type { DiffDocumentProps, DiffHeaderTypography, DiffPalette } from "./types";
export type { DiffDocumentProps, WorkingDiffMode } from "./types";
type ThemedDiffDocumentProps = DiffDocumentProps & {
palette: DiffPalette;
headerTypography: DiffHeaderTypography;
};
const EMPTY_PATHS: string[] = [];
@@ -45,6 +46,11 @@ function ThemedDiffDocument(props: ThemedDiffDocumentProps) {
const StyledDiffDocument = withUnistyles(ThemedDiffDocument, (theme) => ({
palette: createDiffPalette(theme),
headerTypography: {
family: theme.fontFamily.ui,
size: theme.fontSize.base,
statSize: theme.fontSize.sm,
},
}));
export function DiffDocument(props: DiffDocumentProps) {
@@ -78,6 +78,11 @@ function shapedCharactersFor(options: { lineCount: number; lineLength: number })
deletionBackground: "#100",
emptyBackground: "#111",
selection: "blue",
headerActiveSurface: "#222",
headerBorder: "#333",
statusSuccess: "green",
statusDanger: "red",
statusWarning: "orange",
syntax: {},
},
labels: { binary: "Binary", tooLarge: "Too large" },
@@ -58,6 +58,11 @@ function input(overrides: Partial<BuildDiffDocumentModelInput> = {}): BuildDiffD
deletionBackground: "#100",
emptyBackground: "#111",
selection: "blue",
headerActiveSurface: "#222",
headerBorder: "#333",
statusSuccess: "green",
statusDanger: "red",
statusWarning: "orange",
syntax: {},
},
labels: { binary: "Binary", tooLarge: "Too large" },
@@ -446,8 +451,108 @@ describe("diff document model", () => {
expect(model.files[0]?.contentWidth).toBe(464);
expect(model.files[0]!.contentWidth - model.viewportWidth).toBe(224);
});
it("builds exact unwrapped scroll geometry without measuring offscreen files", () => {
const files = [file("a.ts"), file("b.ts"), file("c.ts")];
let measurementCount = 0;
const countingMeasurer: TextMeasurer = {
measure(text) {
measurementCount += 1;
return Array.from(text).length * 10;
},
};
const eager = buildDiffDocumentModel(input({ files, wrapLines: false }));
const firstFileWindow = buildDiffDocumentModel(
input({
files,
wrapLines: false,
measureText: countingMeasurer,
materializationWindow: { top: 0, height: eager.files[0]!.bottom },
}),
);
expect(firstFileWindow.height).toBe(eager.height);
expect(firstFileWindow.files.map((entry) => [entry.top, entry.bottom])).toEqual(
eager.files.map((entry) => [entry.top, entry.bottom]),
);
expect(firstFileWindow.rows[firstFileWindow.files[0]!.rowStart]).toMatchObject({
kind: "line",
});
expect(firstFileWindow.rows[firstFileWindow.files[1]!.rowStart]).toMatchObject({
kind: "line",
});
expect(measuredFragments(firstFileWindow, "a.ts")).toBeGreaterThan(0);
expect(measuredFragments(firstFileWindow, "b.ts")).toBe(0);
expect(measuredFragments(firstFileWindow, "c.ts")).toBe(0);
expect(measurementCount).toBeGreaterThan(0);
});
it("retains measured files while materializing a later unwrapped window", () => {
const files = [file("a.ts"), file("b.ts"), file("c.ts")];
const eager = buildDiffDocumentModel(input({ files, wrapLines: false }));
const first = buildDiffDocumentModel(
input({
files,
wrapLines: false,
materializationWindow: { top: 0, height: eager.files[0]!.bottom },
}),
);
const lastFile = eager.files[2]!;
const last = buildDiffDocumentModel(
input({
files,
wrapLines: false,
materializationWindow: { top: lastFile.top, height: lastFile.bottom - lastFile.top },
reuseFrom: [first],
}),
);
expect(measuredFragments(last, "a.ts")).toBeGreaterThan(0);
expect(measuredFragments(last, "b.ts")).toBe(0);
expect(measuredFragments(last, "c.ts")).toBeGreaterThan(0);
expect(last.height).toBe(eager.height);
expect(firstLine(last, "a.ts")).toBe(firstLine(first, "a.ts"));
expect(firstLine(last, "b.ts")).toBe(firstLine(first, "b.ts"));
expect(firstLineCells(last, "a.ts")).toBe(firstLineCells(first, "a.ts"));
expect(firstLineCells(last, "b.ts")).toBe(firstLineCells(first, "b.ts"));
});
it("keeps wrapped geometry fully measured", () => {
const model = buildDiffDocumentModel(
input({
files: [file("a.ts"), file("b.ts")],
wrapLines: true,
materializationWindow: { top: 0, height: FILE_HEADER_HEIGHT },
}),
);
expect(measuredFragments(model, "a.ts")).toBeGreaterThan(0);
expect(measuredFragments(model, "b.ts")).toBeGreaterThan(0);
});
});
function measuredFragments(model: ReturnType<typeof buildDiffDocumentModel>, path: string): number {
const fileSection = model.files.find((entry) => entry.path === path);
if (!fileSection) throw new Error(`Expected ${path}`);
return model.rows
.slice(fileSection.rowStart, fileSection.rowEnd)
.filter((row) => row.kind === "line")
.flatMap((row) => row.cells)
.reduce((count, cell) => count + (cell?.fragments.length ?? 0), 0);
}
function firstLineCells(model: ReturnType<typeof buildDiffDocumentModel>, path: string) {
return firstLine(model, path).cells;
}
function firstLine(model: ReturnType<typeof buildDiffDocumentModel>, path: string) {
const fileSection = model.files.find((entry) => entry.path === path);
if (!fileSection) throw new Error(`Expected ${path}`);
const row = model.rows[fileSection.rowStart];
if (!row || row.kind !== "line") throw new Error(`Expected a line in ${path}`);
return row;
}
function shapingMeasurer(measure: (text: string) => number): TextMeasurer {
return {
measure,
+351 -92
View File
@@ -37,9 +37,33 @@ interface CellSource {
sourceIdentity: DiffSourceIdentity;
}
interface GeometryLine {
cells: DiffLineRow["cells"];
reviewHeight: number;
height: number;
}
interface ReusableModelIndex {
model: DiffDocumentModel;
filesBySource: Map<BuildDiffDocumentModelInput["files"][number], DiffFileSection>;
measuredFiles: Set<DiffFileSection>;
}
interface ReusableFileRows {
model: DiffDocumentModel;
file: DiffFileSection;
}
interface FileRowsResult {
bottom: number;
maximumHorizontalOverflow: number;
}
export function buildDiffDocumentModel(input: BuildDiffDocumentModelInput): DiffDocumentModel {
const rows: DiffRow[] = [];
const files: DiffFileSection[] = [];
const reusableModels = indexReusableModels(input.reuseFrom);
const currentReviewGeometryKey = reviewGeometryKey(input.reviewActions);
let documentTop = 0;
for (const [fileIndex, file] of input.files.entries()) {
@@ -51,96 +75,40 @@ export function buildDiffDocumentModel(input: BuildDiffDocumentModelInput): Diff
const gutterWidth = lineNumberGutterWidth(maximumLineNumber(file), input.typography.size);
let maximumHorizontalOverflow = 0;
const reusableModel = input.reuseFrom?.find((candidate) =>
candidate.files.some((entry) => entry.file === file && !entry.isCollapsed),
);
const reusableFile = reusableModel?.files.find(
(candidate) => candidate.file === file && !candidate.isCollapsed,
);
const reusable = findReusableFileRows({
indexes: reusableModels,
buildInput: input,
sourceFile: file,
fileTop,
bodyTop,
});
const reusableModel = reusable?.model;
const reusableFile = reusable?.file;
if (!isCollapsed && reusableFile) {
const reusedRows = reusableModel!.rows.slice(reusableFile.rowStart, reusableFile.rowEnd);
for (const reusedRow of reusedRows) {
if (reusedRow.kind === "line") {
const reviewHeight = reviewHeightForCells(reusedRow.cells, input);
const textHeight = reusedRow.height - reusedRow.reviewHeight;
const height = textHeight + reviewHeight;
rows.push({
...reusedRow,
index: rows.length,
fileIndex,
top: documentTop,
height,
reviewHeight,
});
documentTop += height;
continue;
}
rows.push({
...reusedRow,
index: rows.length,
fileIndex,
top: documentTop,
});
documentTop += reusedRow.height;
}
maximumHorizontalOverflow = Math.max(0, reusableFile.contentWidth - input.viewportWidth);
documentTop += DIFF_BODY_BORDER_HEIGHT;
} else if (!isCollapsed) {
if (file.status === "binary" || file.status === "too_large") {
const height = input.typography.lineHeight + 24;
rows.push({
kind: "status",
index: rows.length,
fileIndex,
path: file.path,
top: documentTop,
height,
label: file.status === "binary" ? input.labels.binary : input.labels.tooLarge,
});
documentTop += height;
} else {
const sources = lineSources(file, input.layout);
for (const sourceCells of sources) {
const columnCount = sourceCells.length;
const columnWidth = input.viewportWidth / columnCount;
const availableWidth = Math.max(
input.measureText.measure("M"),
columnWidth - gutterWidth - CODE_HORIZONTAL_PADDING,
);
const cells = sourceCells.map((source) => {
if (!source) return null;
const cell = measureCell({
source,
availableWidth,
if (!isCollapsed) {
const result =
reusableModel && reusableFile
? appendReusableFileRows({
rows,
input,
model: reusableModel,
file: reusableFile,
fileIndex,
fileTop,
bodyTop,
reviewGeometryKey: currentReviewGeometryKey,
})
: appendNewFileRows({
rows,
input,
file,
fileIndex,
fileTop,
bodyTop,
gutterWidth,
});
maximumHorizontalOverflow = Math.max(
maximumHorizontalOverflow,
gutterWidth + CODE_HORIZONTAL_PADDING + measureCellWidth(cell) - columnWidth,
);
return cell;
}) as DiffLineRow["cells"];
const textHeight = Math.max(
input.typography.lineHeight,
...cells.map((cell) => (cell?.fragments.length ?? 1) * input.typography.lineHeight),
);
const reviewHeight = reviewHeightForCells(cells, input);
const height = textHeight + reviewHeight;
rows.push({
kind: "line",
index: rows.length,
fileIndex,
path: file.path,
top: documentTop,
height,
cells,
reviewHeight,
});
documentTop += height;
}
}
documentTop += DIFF_BODY_BORDER_HEIGHT;
documentTop = result.bottom;
maximumHorizontalOverflow = result.maximumHorizontalOverflow;
}
files.push({
@@ -170,9 +138,193 @@ export function buildDiffDocumentModel(input: BuildDiffDocumentModelInput): Diff
layout: input.layout,
wrapLines: input.wrapLines,
viewportWidth: input.viewportWidth,
reviewGeometryKey: currentReviewGeometryKey,
};
}
function appendReusableFileRows(candidate: {
rows: DiffRow[];
input: BuildDiffDocumentModelInput;
model: DiffDocumentModel;
file: DiffFileSection;
fileIndex: number;
fileTop: number;
bodyTop: number;
reviewGeometryKey: string;
}): FileRowsResult {
const reusedRows = candidate.model.rows.slice(candidate.file.rowStart, candidate.file.rowEnd);
const retainsRowGeometry =
!candidate.input.wrapLines &&
candidate.model.reviewGeometryKey === candidate.reviewGeometryKey &&
candidate.file.top === candidate.fileTop &&
candidate.file.rowStart === candidate.rows.length;
if (retainsRowGeometry) {
candidate.rows.push(...reusedRows);
return {
bottom: candidate.bodyTop + candidate.file.bodyHeight,
maximumHorizontalOverflow: Math.max(
0,
candidate.file.contentWidth - candidate.input.viewportWidth,
),
};
}
let top = candidate.bodyTop;
for (const reusedRow of reusedRows) {
const nextRow = repositionReusedRow(reusedRow, candidate, top);
candidate.rows.push(nextRow);
top += nextRow.height;
}
return {
bottom: top + DIFF_BODY_BORDER_HEIGHT,
maximumHorizontalOverflow: Math.max(
0,
candidate.file.contentWidth - candidate.input.viewportWidth,
),
};
}
function repositionReusedRow(
row: DiffRow,
candidate: Pick<Parameters<typeof appendReusableFileRows>[0], "rows" | "input" | "fileIndex">,
top: number,
): DiffRow {
if (row.kind !== "line") {
return { ...row, index: candidate.rows.length, fileIndex: candidate.fileIndex, top };
}
const reviewHeight = reviewHeightForCells(row.cells, candidate.input);
return {
...row,
index: candidate.rows.length,
fileIndex: candidate.fileIndex,
top,
height: row.height - row.reviewHeight + reviewHeight,
reviewHeight,
};
}
function appendNewFileRows(candidate: {
rows: DiffRow[];
input: BuildDiffDocumentModelInput;
file: BuildDiffDocumentModelInput["files"][number];
fileIndex: number;
fileTop: number;
bodyTop: number;
gutterWidth: number;
}): FileRowsResult {
if (candidate.file.status === "binary" || candidate.file.status === "too_large") {
const height = candidate.input.typography.lineHeight + 24;
candidate.rows.push({
kind: "status",
index: candidate.rows.length,
fileIndex: candidate.fileIndex,
path: candidate.file.path,
top: candidate.bodyTop,
height,
label:
candidate.file.status === "binary"
? candidate.input.labels.binary
: candidate.input.labels.tooLarge,
});
return {
bottom: candidate.bodyTop + height + DIFF_BODY_BORDER_HEIGHT,
maximumHorizontalOverflow: 0,
};
}
const deferredLines =
candidate.input.materializationWindow && !candidate.input.wrapLines
? geometryLines(lineSources(candidate.file, candidate.input.layout, false), candidate.input)
: null;
const deferredBodyHeight = deferredLines?.reduce((height, line) => height + line.height, 0) ?? 0;
const shouldMaterialize =
deferredLines === null ||
intersectsMaterializationWindow(
candidate.input,
candidate.fileTop,
candidate.bodyTop + deferredBodyHeight + DIFF_BODY_BORDER_HEIGHT,
);
return shouldMaterialize
? appendMeasuredFileRows(candidate)
: appendGeometryFileRows(candidate, deferredLines);
}
function appendGeometryFileRows(
candidate: Parameters<typeof appendNewFileRows>[0],
lines: readonly GeometryLine[],
): FileRowsResult {
let top = candidate.bodyTop;
for (const line of lines) {
candidate.rows.push({
kind: "line",
index: candidate.rows.length,
fileIndex: candidate.fileIndex,
path: candidate.file.path,
top,
height: line.height,
cells: line.cells,
reviewHeight: line.reviewHeight,
});
top += line.height;
}
return { bottom: top + DIFF_BODY_BORDER_HEIGHT, maximumHorizontalOverflow: 0 };
}
function appendMeasuredFileRows(
candidate: Parameters<typeof appendNewFileRows>[0],
): FileRowsResult {
let top = candidate.bodyTop;
let maximumHorizontalOverflow = 0;
for (const sourceCells of lineSources(candidate.file, candidate.input.layout, true)) {
const columnCount = sourceCells.length;
const columnWidth = candidate.input.viewportWidth / columnCount;
const availableWidth = Math.max(
candidate.input.measureText.measure("M"),
columnWidth - candidate.gutterWidth - CODE_HORIZONTAL_PADDING,
);
const cells = sourceCells.map((source) => {
if (!source) return null;
const cell = measureCell({ source, availableWidth, input: candidate.input });
maximumHorizontalOverflow = Math.max(
maximumHorizontalOverflow,
candidate.gutterWidth + CODE_HORIZONTAL_PADDING + measureCellWidth(cell) - columnWidth,
);
return cell;
}) as DiffLineRow["cells"];
const textHeight = Math.max(
candidate.input.typography.lineHeight,
...cells.map((cell) => (cell?.fragments.length ?? 1) * candidate.input.typography.lineHeight),
);
const reviewHeight = reviewHeightForCells(cells, candidate.input);
const height = textHeight + reviewHeight;
candidate.rows.push({
kind: "line",
index: candidate.rows.length,
fileIndex: candidate.fileIndex,
path: candidate.file.path,
top,
height,
cells,
reviewHeight,
});
top += height;
}
return { bottom: top + DIFF_BODY_BORDER_HEIGHT, maximumHorizontalOverflow };
}
export function reviewGeometryKey(
reviewActions: BuildDiffDocumentModelInput["reviewActions"],
): string {
if (!reviewActions) return "";
const comments = [...reviewActions.commentsByTarget.entries()]
.map(([target, targetComments]) => [target, targetComments.map((comment) => comment.id).sort()])
.sort(([left], [right]) => String(left).localeCompare(String(right)));
const editor = reviewActions.editor
? [reviewActions.editor.target.key, reviewActions.editor.commentId]
: null;
return JSON.stringify([comments, editor]);
}
export function expandedBodyBorderTop(file: DiffFileSection): number | null {
"worklet";
return file.isCollapsed ? null : file.bottom - DIFF_BODY_BORDER_HEIGHT;
@@ -183,7 +335,7 @@ export function retainReusableModels(
next: DiffDocumentModel,
): DiffDocumentModel[] {
const fullest = [...(previous ?? []), next].reduce((best, candidate) =>
candidate.rows.length > best.rows.length ? candidate : best,
measuredFileCount(candidate) > measuredFileCount(best) ? candidate : best,
);
return fullest === next ? [next] : [fullest, next];
}
@@ -191,13 +343,14 @@ export function retainReusableModels(
function lineSources(
file: BuildDiffDocumentModelInput["files"][number],
layout: "unified" | "split",
includeTokens: boolean,
): Array<[CellSource] | [CellSource | null, CellSource | null]> {
if (layout === "split") {
return buildSplitDiffRows(file).map((row) => {
if (row.kind === "header") {
return [headerSource(row.content, row.hunkIndex, row.lineIndex)];
}
return [cellSource(row.left), cellSource(row.right)];
return [cellSource(row.left, includeTokens), cellSource(row.right, includeTokens)];
});
}
return buildUnifiedDiffLines(file).map((entry) => [
@@ -206,7 +359,7 @@ function lineSources(
content: entry.line.content,
lineNumber: entry.lineNumber,
reviewTarget: entry.reviewTarget,
tokenText: compactHighlightTokens(entry.line.tokens ?? []),
tokenText: includeTokens ? compactHighlightTokens(entry.line.tokens ?? []) : [],
sourceIdentity: {
hunkIndex: entry.hunkIndex,
lineIndex: entry.lineIndex,
@@ -227,14 +380,14 @@ function headerSource(content: string, hunkIndex: number, lineIndex: number): Ce
};
}
function cellSource(line: SplitDiffDisplayLine | null): CellSource | null {
function cellSource(line: SplitDiffDisplayLine | null, includeTokens: boolean): CellSource | null {
if (!line) return null;
return {
type: line.type,
content: line.content,
lineNumber: line.lineNumber,
reviewTarget: line.reviewTarget,
tokenText: compactHighlightTokens(line.tokens ?? []),
tokenText: includeTokens ? compactHighlightTokens(line.tokens ?? []) : [],
sourceIdentity: {
hunkIndex: line.hunkIndex,
lineIndex: line.lineIndex,
@@ -243,6 +396,112 @@ function cellSource(line: SplitDiffDisplayLine | null): CellSource | null {
};
}
function geometryCell(source: CellSource): DiffCell {
return {
type: source.type,
content: source.content,
lineNumber: source.lineNumber,
tokens: [],
fragments: [],
reviewTarget: source.reviewTarget,
sourceIdentity: source.sourceIdentity,
};
}
function geometryLines(
sources: Array<[CellSource] | [CellSource | null, CellSource | null]>,
input: BuildDiffDocumentModelInput,
): GeometryLine[] {
return sources.map((sourceCells) => {
const cells = sourceCells.map((source) =>
source ? geometryCell(source) : null,
) as DiffLineRow["cells"];
const reviewHeight = reviewHeightForCells(cells, input);
return {
cells,
reviewHeight,
height: input.typography.lineHeight + reviewHeight,
};
});
}
function intersectsMaterializationWindow(
input: BuildDiffDocumentModelInput,
fileTop: number,
fileBottom: number,
): boolean {
const window = input.materializationWindow;
if (!window) return true;
return fileTop < window.top + window.height && fileBottom > window.top;
}
function hasMeasuredFileRows(model: DiffDocumentModel, file: DiffFileSection): boolean {
if (file.isCollapsed) return false;
return model.rows.slice(file.rowStart, file.rowEnd).every((row) => {
if (row.kind !== "line") return true;
return row.cells.every((cell) => !cell || cell.fragments.length > 0);
});
}
function indexReusableModels(
models: readonly DiffDocumentModel[] | undefined,
): ReusableModelIndex[] {
return (models ?? []).map((model) => {
const filesBySource = new Map<BuildDiffDocumentModelInput["files"][number], DiffFileSection>();
const measuredFiles = new Set<DiffFileSection>();
for (const file of model.files) {
filesBySource.set(file.file, file);
if (hasMeasuredFileRows(model, file)) measuredFiles.add(file);
}
return { model, filesBySource, measuredFiles };
});
}
function findReusableFileRows(candidate: {
indexes: readonly ReusableModelIndex[];
buildInput: BuildDiffDocumentModelInput;
sourceFile: BuildDiffDocumentModelInput["files"][number];
fileTop: number;
bodyTop: number;
}): ReusableFileRows | null {
for (const index of candidate.indexes) {
const file = index.filesBySource.get(candidate.sourceFile);
if (
file &&
canReuseFileRows({
buildInput: candidate.buildInput,
file,
isMeasured: index.measuredFiles.has(file),
fileTop: candidate.fileTop,
bodyTop: candidate.bodyTop,
})
) {
return { model: index.model, file };
}
}
return null;
}
function canReuseFileRows(candidate: {
buildInput: BuildDiffDocumentModelInput;
file: DiffFileSection;
isMeasured: boolean;
fileTop: number;
bodyTop: number;
}): boolean {
if (candidate.file.isCollapsed) return false;
if (candidate.isMeasured) return true;
return !intersectsMaterializationWindow(
candidate.buildInput,
candidate.fileTop,
candidate.bodyTop + candidate.file.bodyHeight,
);
}
function measuredFileCount(model: DiffDocumentModel): number {
return model.files.filter((file) => hasMeasuredFileRows(model, file)).length;
}
function measureCell(input: {
source: CellSource;
availableWidth: number;
@@ -107,6 +107,11 @@ function build(layout: "unified" | "split") {
deletionBackground: "#100",
emptyBackground: "#111",
selection: "blue",
headerActiveSurface: "#222",
headerBorder: "#333",
statusSuccess: "green",
statusDanger: "red",
statusWarning: "orange",
syntax: {},
},
labels: { binary: "Binary", tooLarge: "Too large" },
@@ -5,7 +5,6 @@ import {
nativeCanvasSlabsForViewport,
nativeCanvasWindowBucket,
nativeCanvasWindowTop,
nativeStickyHeaderIndices,
} from "./native-slabs";
import type { DiffDocumentModel } from "./types";
@@ -34,15 +33,11 @@ function modelWithBodies(
layout: "unified",
wrapLines: false,
viewportWidth: 400,
reviewGeometryKey: "",
};
}
describe("native canvas slabs", () => {
it("identifies each direct file header child for native sticky scrolling", () => {
expect(nativeStickyHeaderIndices(0)).toEqual([]);
expect(nativeStickyHeaderIndices(3)).toEqual([1, 3, 5]);
});
it("cuts large file bodies into fixed document-positioned slabs", () => {
const slabs = buildNativeCanvasSlabs(
modelWithBodies([
@@ -58,9 +53,11 @@ describe("native canvas slabs", () => {
expect(slabs.map(({ path, top, height }) => ({ path, top, height }))).toEqual([
{ path: "small.ts", top: 30, height: 200 },
{ path: "large.ts", top: 260, height: 1200 },
{ path: "large.ts", top: 1460, height: 1200 },
{ path: "large.ts", top: 2660, height: 800 },
{ path: "large.ts", top: 260, height: 768 },
{ path: "large.ts", top: 1028, height: 768 },
{ path: "large.ts", top: 1796, height: 768 },
{ path: "large.ts", top: 2564, height: 768 },
{ path: "large.ts", top: 3332, height: 128 },
]);
});
@@ -73,7 +70,7 @@ describe("native canvas slabs", () => {
);
expect(nativeCanvasSlabsForViewport(slabs, 1700, 600).map((slab) => slab.top)).toEqual([
30, 1230, 2430,
798, 1566, 2334,
]);
expect(nativeCanvasWindowBucket(899, 600)).toBe(2);
expect(nativeCanvasWindowTop(2, 600)).toBe(600);
@@ -8,15 +8,11 @@ export interface NativeCanvasSlab {
height: number;
}
export function nativeStickyHeaderIndices(fileCount: number): number[] {
return Array.from({ length: fileCount }, (_, index) => index * 2 + 1);
}
export function buildNativeCanvasSlabs(
model: DiffDocumentModel,
viewportHeight: number,
): NativeCanvasSlab[] {
const slabHeight = Math.max(768, viewportHeight * 2);
const slabHeight = Math.max(768, viewportHeight);
return model.files.flatMap((file) => {
const slabs: NativeCanvasSlab[] = [];
let slabIndex = 0;
@@ -39,8 +35,8 @@ export function nativeCanvasSlabsForViewport(
scrollTop: number,
viewportHeight: number,
): NativeCanvasSlab[] {
const top = Math.max(0, scrollTop - viewportHeight);
const bottom = scrollTop + viewportHeight * 2;
const top = Math.max(0, scrollTop - viewportHeight / 2);
const bottom = scrollTop + viewportHeight * 1.5;
return slabs.filter((slab) => slab.top < bottom && slab.top + slab.height > top);
}
@@ -1,16 +1,37 @@
import {
ClipOp,
PaintStyle,
Skia,
StrokeCap,
StrokeJoin,
type SkCanvas,
type SkPaint,
type SkPicture,
} from "@shopify/react-native-skia";
import {
DIFF_FILE_HEADER_CONTENT_HEIGHT,
DIFF_FILE_HEADER_HEIGHT,
DIFF_FILE_HEADER_ICON_SIZE,
DIFF_FILE_HEADER_LEFT,
DIFF_FILE_HEADER_RIGHT,
DIFF_FILE_HEADER_TEXT_GAP,
allocateDiffHeaderTextWidths,
diffFileChangeKind,
directorySuffix,
fileNameForPath,
formatDiffCount,
} from "@/git/file-header-presentation";
import { DIFF_BODY_BORDER_HEIGHT, expandedBodyBorderTop, visibleRowRange } from "./model";
import { nativeTextRuns } from "./native-text-runs";
import { horizontalOffsetForPath, type DiffHorizontalOffsets } from "./horizontal-offsets";
import { codeLineNumberTone } from "./palette";
import { reviewBackgroundPaint, reviewDividerHeight, reviewGapTop } from "./review-paint";
import type { NativeTextLayout } from "./text.native";
import {
shapeNativeHeaderText,
type NativeHeaderTextLayout,
type NativeShapedHeaderText,
type NativeTextLayout,
} from "./text.native";
import type {
DiffCell,
DiffDocumentModel,
@@ -31,6 +52,10 @@ export interface NativePaints {
additionBackground: SkPaint;
deletionBackground: SkPaint;
headerSurface: SkPaint;
headerBorder: SkPaint;
statusSuccess: SkPaint;
statusDanger: SkPaint;
statusWarning: SkPaint;
emptyBackground: SkPaint;
text: Record<string, SkPaint>;
}
@@ -46,6 +71,10 @@ export function createNativePaints(palette: DiffPalette): NativePaints {
additionBackground: paint(palette.additionBackground),
deletionBackground: paint(palette.deletionBackground),
headerSurface: paint(palette.headerSurface),
headerBorder: paint(palette.headerBorder),
statusSuccess: paint(palette.statusSuccess),
statusDanger: paint(palette.statusDanger),
statusWarning: paint(palette.statusWarning),
emptyBackground: paint(palette.emptyBackground),
text: Object.fromEntries(
[
@@ -55,6 +84,156 @@ export function createNativePaints(palette: DiffPalette): NativePaints {
};
}
export function recordNativeHeaderPicture(input: {
file: DiffFileSection;
viewportWidth: number;
textLayout: NativeHeaderTextLayout;
paints: NativePaints;
}): SkPicture {
const recorder = Skia.PictureRecorder();
const canvas = recorder.beginRecording(
Skia.XYWHRect(0, 0, input.viewportWidth, DIFF_FILE_HEADER_HEIGHT),
);
canvas.drawRect(
Skia.XYWHRect(0, DIFF_FILE_HEADER_HEIGHT - 1, input.viewportWidth, 1),
input.paints.headerBorder,
);
const iconX = input.viewportWidth - DIFF_FILE_HEADER_RIGHT - DIFF_FILE_HEADER_ICON_SIZE;
const additions = `+${formatDiffCount(input.file.file.additions)}`;
const deletions = `-${formatDiffCount(input.file.file.deletions)}`;
const additionsText = shapeNativeHeaderText({
layout: input.textLayout,
text: additions,
size: "stat",
tone: "statusSuccess",
});
const deletionsText = shapeNativeHeaderText({
layout: input.textLayout,
text: deletions,
size: "stat",
tone: "statusDanger",
});
const statX = iconX - 8 - additionsText.width - 4 - deletionsText.width;
paintNativeHeaderText(canvas, additionsText, statX);
paintNativeHeaderText(canvas, deletionsText, statX + additionsText.width + 4);
paintNativeChangeIcon(
canvas,
input.file,
iconX,
(DIFF_FILE_HEADER_CONTENT_HEIGHT - DIFF_FILE_HEADER_ICON_SIZE) / 2,
input.paints,
);
const available = Math.max(0, statX - DIFF_FILE_HEADER_LEFT);
const name = fileNameForPath(input.file.path);
const directory = directorySuffix(input.file.path).trimStart();
const fitted = fitNativeHeaderText(input.textLayout, name, directory, available);
canvas.save();
canvas.clipRect(
Skia.XYWHRect(DIFF_FILE_HEADER_LEFT, 0, available, DIFF_FILE_HEADER_CONTENT_HEIGHT),
ClipOp.Intersect,
false,
);
paintNativeHeaderText(canvas, fitted.name, DIFF_FILE_HEADER_LEFT);
if (fitted.directory.width > 0) {
paintNativeHeaderText(
canvas,
fitted.directory,
DIFF_FILE_HEADER_LEFT + fitted.name.width + DIFF_FILE_HEADER_TEXT_GAP,
);
}
canvas.restore();
const picture = recorder.finishRecordingAsPicture();
additionsText.paragraph.dispose();
deletionsText.paragraph.dispose();
fitted.name.paragraph.dispose();
fitted.directory.paragraph.dispose();
return picture;
}
function paintNativeHeaderText(canvas: SkCanvas, text: NativeShapedHeaderText, x: number): void {
text.paragraph.paint(canvas, x, (DIFF_FILE_HEADER_CONTENT_HEIGHT - text.height) / 2);
}
function fitNativeHeaderText(
layout: NativeHeaderTextLayout,
name: string,
directory: string,
available: number,
): { name: NativeShapedHeaderText; directory: NativeShapedHeaderText } {
let nameText = shapeNativeHeaderText({ layout, text: name, size: "body", tone: "foreground" });
let directoryText = shapeNativeHeaderText({
layout,
text: directory,
size: "body",
tone: "foregroundMuted",
});
if (nameText.width + (directory ? 4 + directoryText.width : 0) <= available) {
return { name: nameText, directory: directoryText };
}
const widths = allocateDiffHeaderTextWidths({
available,
nameWidth: nameText.width,
directoryWidth: directoryText.width,
});
if (widths.name < nameText.width) {
nameText.paragraph.dispose();
nameText = shapeNativeHeaderText({
layout,
text: name,
size: "body",
tone: "foreground",
maximumWidth: widths.name,
});
}
directoryText.paragraph.dispose();
directoryText = shapeNativeHeaderText({
layout,
text: widths.directory > 0 ? directory : "",
size: "body",
tone: "foregroundMuted",
maximumWidth: widths.directory,
});
return { name: nameText, directory: directoryText };
}
function paintNativeChangeIcon(
canvas: SkCanvas,
file: DiffFileSection,
x: number,
y: number,
paints: NativePaints,
): void {
const change = diffFileChangeKind(file.file);
let source = paints.statusWarning;
if (change === "added") source = paints.statusSuccess;
else if (change === "deleted") source = paints.statusDanger;
const stroke = source.copy();
stroke.setStyle(PaintStyle.Stroke);
stroke.setStrokeWidth(DIFF_FILE_HEADER_ICON_SIZE / 12);
stroke.setStrokeCap(StrokeCap.Round);
stroke.setStrokeJoin(StrokeJoin.Round);
const scale = DIFF_FILE_HEADER_ICON_SIZE / 24;
canvas.drawRRect(
Skia.RRectXY(
Skia.XYWHRect(x + 3 * scale, y + 3 * scale, 18 * scale, 18 * scale),
2 * scale,
2 * scale,
),
stroke,
);
if (change === "added") {
canvas.drawLine(x + 8 * scale, y + 12 * scale, x + 16 * scale, y + 12 * scale, stroke);
canvas.drawLine(x + 12 * scale, y + 8 * scale, x + 12 * scale, y + 16 * scale, stroke);
} else if (change === "deleted") {
canvas.drawLine(x + 8 * scale, y + 12 * scale, x + 16 * scale, y + 12 * scale, stroke);
} else {
canvas.drawCircle(x + 12 * scale, y + 12 * scale, scale, stroke);
}
stroke.dispose();
}
function paint(color: string): SkPaint {
const result = Skia.Paint();
result.setColor(Skia.Color(color));
@@ -1,8 +1,94 @@
import { describe, expect, it } from "vitest";
import { paintWebViewport } from "./paint.web";
import { paintWebHeaders, paintWebViewport } from "./paint.web";
import type { DiffCell, DiffDocumentModel, DiffLineRow, DiffPalette, DiffSelection } from "./types";
describe("web diff text shaping", () => {
it("matches the 30px file-header alignment rails exactly", () => {
const fills: Array<{ color: string; x: number; y: number; width: number; height: number }> = [];
const labels: Array<{ text: string; x: number; y: number; color: string; font: string }> = [];
let fillStyle = "";
let font = "";
const context = {
setTransform() {},
clearRect() {},
fillRect(x: number, y: number, width: number, height: number) {
fills.push({ color: fillStyle, x, y, width, height });
},
save() {},
restore() {},
beginPath() {},
rect() {},
clip() {},
translate() {},
scale() {},
roundRect() {},
moveTo() {},
lineTo() {},
stroke() {},
measureText(text: string) {
return {
width: text.length * 6,
actualBoundingBoxAscent: 8,
actualBoundingBoxDescent: 2,
} as TextMetrics;
},
fillText(text: string, x: number, y: number) {
labels.push({ text, x, y, color: fillStyle, font });
},
get fillStyle() {
return fillStyle;
},
set fillStyle(value: string | CanvasGradient | CanvasPattern) {
fillStyle = String(value);
},
get font() {
return font;
},
set font(value: string) {
font = value;
},
strokeStyle: "",
lineWidth: 1,
lineCap: "butt",
lineJoin: "miter",
} as unknown as CanvasRenderingContext2D;
paintWebHeaders({
context,
model: {
...model,
files: [
{
...model.files[0]!,
headerHeight: 30,
bodyTop: 30,
bodyHeight: 18,
bottom: 48,
},
],
height: 48,
},
palette,
typography: { family: "system-ui", size: 14, statSize: 12 },
scrollTop: 0,
viewportWidth: 200,
viewportHeight: 100,
devicePixelRatio: 1,
activePath: null,
});
expect(fills.slice(0, 2)).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([
{ 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" },
{ text: "src", x: 40, y: 18, color: "muted", font: "14px system-ui" },
]);
});
it("paints syntax colors by clipping the complete measured shaped fragment", () => {
const paintedText: string[] = [];
const context = {
@@ -314,6 +400,7 @@ function createSelectionModel(layout: "unified" | "split"): DiffDocumentModel {
layout,
wrapLines: false,
viewportWidth: 200,
reviewGeometryKey: "",
};
}
@@ -356,6 +443,11 @@ const palette: DiffPalette = {
deletionBackground: "red-bg",
emptyBackground: "empty",
selection: "selection",
headerActiveSurface: "active-header",
headerBorder: "header-border",
statusSuccess: "success",
statusDanger: "danger",
statusWarning: "warning",
syntax: { first: "red", second: "blue" },
};
@@ -427,6 +519,7 @@ const model: DiffDocumentModel = {
layout: "unified",
wrapLines: false,
viewportWidth: 200,
reviewGeometryKey: "",
};
const modelWithReview: DiffDocumentModel = {
@@ -6,10 +6,26 @@ import {
visibleRowRange,
} from "./model";
import { codeLineNumberTone, codeTextColor } from "./palette";
import {
DIFF_FILE_HEADER_CONTENT_HEIGHT,
DIFF_FILE_HEADER_HEIGHT,
DIFF_FILE_HEADER_ICON_SIZE,
DIFF_FILE_HEADER_LEFT,
DIFF_FILE_HEADER_RIGHT,
DIFF_FILE_HEADER_TEXT_GAP,
allocateDiffHeaderTextWidths,
diffFileChangeKind,
directorySuffix,
fileNameForPath,
formatDiffCount,
} from "@/git/file-header-presentation";
import { resolveVisibleFileSections } from "./header-layout";
import { reviewBackgroundPaint, reviewDividerHeight, reviewGapTop } from "./review-paint";
import type {
DiffCell,
DiffDocumentModel,
DiffFileSection,
DiffHeaderTypography,
DiffLineRow,
DiffPalette,
DiffSelection,
@@ -86,6 +102,167 @@ export function paintWebViewport(input: PaintWebViewportInput): void {
context.restore();
}
export function paintWebHeaders(input: {
context: CanvasRenderingContext2D;
model: DiffDocumentModel;
palette: DiffPalette;
typography: DiffHeaderTypography;
scrollTop: number;
viewportWidth: number;
viewportHeight: number;
devicePixelRatio: 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;
context.fillRect(0, y, viewportWidth, DIFF_FILE_HEADER_HEIGHT);
context.fillStyle = palette.headerBorder;
context.fillRect(0, y + DIFF_FILE_HEADER_HEIGHT - 1, viewportWidth, 1);
const iconX = viewportWidth - DIFF_FILE_HEADER_RIGHT - DIFF_FILE_HEADER_ICON_SIZE;
const statLabels = [
`+${formatDiffCount(file.file.additions)}`,
`-${formatDiffCount(file.file.deletions)}`,
];
context.font = `${typography.statSize}px ${typography.family}`;
const statWidths = statLabels.map((label) => context.measureText(label).width);
const statWidth = statWidths[0]! + 4 + statWidths[1]!;
const statX = iconX - 8 - statWidth;
const statBaseline = centeredTextBaseline(context, y + DIFF_FILE_HEADER_CONTENT_HEIGHT / 2);
context.fillStyle = palette.statusSuccess;
context.fillText(statLabels[0]!, statX, statBaseline);
context.fillStyle = palette.statusDanger;
context.fillText(statLabels[1]!, statX + statWidths[0]! + 4, statBaseline);
paintChangeIcon(
context,
file,
iconX,
y + (DIFF_FILE_HEADER_CONTENT_HEIGHT - DIFF_FILE_HEADER_ICON_SIZE) / 2,
palette,
);
context.font = `${typography.size}px ${typography.family}`;
const textBaseline = centeredTextBaseline(context, y + DIFF_FILE_HEADER_CONTENT_HEIGHT / 2);
const available = Math.max(0, statX - DIFF_FILE_HEADER_LEFT);
const name = fileNameForPath(file.path);
// React Native Web collapses the leading separator space at the start of the
// directory Text node. Canvas does not, so paint only the visible glyphs.
const directory = directorySuffix(file.path).trimStart();
const fitted = fitHeaderText(context, name, directory, available);
context.save();
context.beginPath();
context.rect(DIFF_FILE_HEADER_LEFT, y, available, DIFF_FILE_HEADER_CONTENT_HEIGHT);
context.clip();
context.fillStyle = palette.foreground;
context.fillText(fitted.name, DIFF_FILE_HEADER_LEFT, textBaseline);
if (fitted.directory) {
context.fillStyle = palette.foregroundMuted;
context.fillText(
fitted.directory,
DIFF_FILE_HEADER_LEFT + context.measureText(fitted.name).width + DIFF_FILE_HEADER_TEXT_GAP,
textBaseline,
);
}
context.restore();
}
function centeredTextBaseline(context: CanvasRenderingContext2D, centerY: number): number {
const metrics = context.measureText("Mg");
return centerY + (metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2 + 1;
}
function fitHeaderText(
context: CanvasRenderingContext2D,
name: string,
directory: string,
available: number,
): { name: string; directory: string } {
const nameWidth = context.measureText(name).width;
const directoryWidth = context.measureText(directory).width;
if (nameWidth + (directory ? 4 + directoryWidth : 0) <= available) return { name, directory };
const widths = allocateDiffHeaderTextWidths({ available, nameWidth, directoryWidth });
return {
name: truncateCanvasText(context, name, widths.name),
directory: truncateCanvasText(context, directory, widths.directory),
};
}
function truncateCanvasText(
context: CanvasRenderingContext2D,
text: string,
maximumWidth: number,
): string {
if (context.measureText(text).width <= maximumWidth) return text;
const ellipsis = "…";
if (context.measureText(ellipsis).width > maximumWidth) return "";
let low = 0;
let high = text.length;
while (low < high) {
const middle = Math.ceil((low + high) / 2);
if (context.measureText(`${text.slice(0, middle)}${ellipsis}`).width <= maximumWidth)
low = middle;
else high = middle - 1;
}
return `${text.slice(0, low)}${ellipsis}`;
}
function paintChangeIcon(
context: CanvasRenderingContext2D,
file: DiffFileSection,
x: number,
y: number,
palette: DiffPalette,
): void {
const change = diffFileChangeKind(file.file);
context.save();
context.translate(x, y);
context.scale(DIFF_FILE_HEADER_ICON_SIZE / 24, DIFF_FILE_HEADER_ICON_SIZE / 24);
if (change === "added") context.strokeStyle = palette.statusSuccess;
else if (change === "deleted") context.strokeStyle = palette.statusDanger;
else context.strokeStyle = palette.statusWarning;
context.lineWidth = 2;
context.lineCap = "round";
context.lineJoin = "round";
context.beginPath();
context.roundRect(3, 3, 18, 18, 2);
if (change === "added") {
context.moveTo(8, 12);
context.lineTo(16, 12);
context.moveTo(12, 8);
context.lineTo(12, 16);
} else if (change === "deleted") {
context.moveTo(8, 12);
context.lineTo(16, 12);
} else {
context.moveTo(12, 12);
context.lineTo(12.01, 12);
}
context.stroke();
context.restore();
}
function paintLine(
input: PaintWebViewportInput & {
row: DiffLineRow;
@@ -59,6 +59,11 @@ const palette: DiffPalette = {
deletionBackground: "red-bg",
emptyBackground: "empty",
selection: "selection",
headerActiveSurface: "active-header",
headerBorder: "header-border",
statusSuccess: "success",
statusDanger: "danger",
statusWarning: "warning",
syntax: {},
};
@@ -15,6 +15,11 @@ export function createDiffPalette(theme: Theme): DiffPalette {
deletionBackground: hexColorWithAlpha(theme.colors.statusDanger, 0.1),
emptyBackground: theme.colors.surface0,
selection: theme.colors.terminal.blue,
headerActiveSurface: theme.colors.surface1,
headerBorder: theme.colors.borderAccent,
statusSuccess: theme.colors.statusSuccess,
statusDanger: theme.colors.statusDanger,
statusWarning: theme.colors.statusWarning,
syntax: theme.colors.syntax,
};
}
@@ -32,6 +37,11 @@ export function retainDiffPalette(previous: DiffPalette, next: DiffPalette): Dif
previous.deletionBackground !== next.deletionBackground ||
previous.emptyBackground !== next.emptyBackground ||
previous.selection !== next.selection ||
previous.headerActiveSurface !== next.headerActiveSurface ||
previous.headerBorder !== next.headerBorder ||
previous.statusSuccess !== next.statusSuccess ||
previous.statusDanger !== next.statusDanger ||
previous.statusWarning !== next.statusWarning ||
!sameColorMap(previous.syntax, next.syntax)
) {
return next;
@@ -23,6 +23,11 @@ import { InlineReviewThread } from "@/review";
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { DocumentFileHeader } from "./document-file-header";
import {
diffInteractionWindowTop,
diffMaterializationWindow,
resolveVisibleFileSections,
} from "./header-layout";
import { hitTestDiffBodyPoint } from "./native-hit-testing";
import { retainDiffViewport } from "./viewport";
import { HorizontalScroll } from "./horizontal-scroll.native";
@@ -42,16 +47,22 @@ import {
nativeCanvasSlabsForViewport,
nativeCanvasWindowBucket,
nativeCanvasWindowTop,
nativeStickyHeaderIndices,
type NativeCanvasSlab,
} from "./native-slabs";
import { createNativePaints, recordNativeSlabPictures, type NativePaints } from "./paint.native";
import {
createNativePaints,
recordNativeHeaderPicture,
recordNativeSlabPictures,
type NativePaints,
} from "./paint.native";
import {
createNativeTextLayoutStore,
createNativeHeaderTextLayout,
createNativeTextMeasurer,
disposeNativeTextLayout,
prepareNativeTextLayout,
type NativeTextLayout,
type NativeHeaderTextLayout,
} from "./text.native";
import type {
DiffDocumentModel,
@@ -68,6 +79,7 @@ const CODE_LEFT_PADDING = 8;
export function DiffSurface(props: DiffSurfaceProps) {
const { t } = useTranslation();
const [viewport, setViewport] = useState({ width: 0, height: 0 });
const [fileWindowTop, setFileWindowTop] = useState(0);
const scrollRef = useRef<ScrollView>(null);
const previousModelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
const reusableModelRef = useRef<{
@@ -126,6 +138,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
binary: t("workspace.git.diff.binaryFile"),
tooLarge: t("workspace.git.diff.tooLarge"),
},
materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height),
reuseFrom,
});
reusableModelRef.current = {
@@ -135,6 +148,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
return next;
}, [
measurement,
fileWindowTop,
props.collapsedFilePaths,
props.displayPreferences.layout,
props.displayPreferences.wrapLines,
@@ -144,6 +158,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
t,
typography,
viewport.width,
viewport.height,
]);
const paints = useMemo(() => createNativePaints(props.palette), [props.palette]);
const textLayoutStore = useMemo(
@@ -161,6 +176,38 @@ export function DiffSurface(props: DiffSurfaceProps) {
[model, textLayoutStore],
);
useEffect(() => () => disposeNativeTextLayout(textLayoutStore), [textLayoutStore]);
const headerTextLayout = useMemo(
() =>
createNativeHeaderTextLayout({
configuredFamily: props.headerTypography.family,
fontSize: props.headerTypography.size,
statFontSize: props.headerTypography.statSize,
palette: props.palette,
}),
[props.headerTypography, props.palette],
);
const updateFileWindow = useCallback((nextTop: number) => {
setFileWindowTop((current) => (current === nextTop ? current : nextTop));
}, []);
useAnimatedReaction(
() => diffInteractionWindowTop(scrollTop.value, viewport.height),
(windowTop, previous) => {
if (windowTop !== previous) {
scheduleOnRN(updateFileWindow, windowTop);
}
},
[updateFileWindow, viewport.height],
);
const interactionFiles = useMemo(
() =>
resolveVisibleFileSections({
files: model.files,
scrollTop: fileWindowTop,
viewportHeight: viewport.height,
overscan: viewport.height * 2,
}).files,
[fileWindowTop, model.files, viewport.height],
);
const scrollHandler = useAnimatedScrollHandler((event) => {
scrollTop.value = event.contentOffset.y;
@@ -206,12 +253,8 @@ export function DiffSurface(props: DiffSurfaceProps) {
}
}, [collapsedFilePaths, mode, model.files, onToggleFile, scrollTop]);
const contentStyle = useMemo(
() => ({ minHeight: viewport.height, backgroundColor: "transparent" }),
[viewport.height],
);
const stickyHeaderIndices = useMemo(
() => nativeStickyHeaderIndices(model.files.length),
[model.files.length],
() => ({ minHeight: Math.max(model.height, viewport.height), backgroundColor: "transparent" }),
[model.height, viewport.height],
);
return (
<View style={[styles.root, { backgroundColor: props.palette.surface }]} onLayout={layout}>
@@ -222,7 +265,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
onScroll={scrollHandler}
scrollEventThrottle={16}
showsVerticalScrollIndicator
stickyHeaderIndices={stickyHeaderIndices}
testID="git-diff-scroll"
>
<NativeCanvasSlabLayer
@@ -233,23 +275,31 @@ export function DiffSurface(props: DiffSurfaceProps) {
paints={paints}
horizontalOffsets={horizontalOffsets}
/>
{model.files.flatMap((file) => [
<NativeStickyFileHeader
{interactionFiles.map((file) => (
<NativeCanvasFileHeader
key={`${file.path}:header`}
file={file}
viewportWidth={model.viewportWidth}
scrollTop={scrollTop}
textLayout={headerTextLayout}
paints={paints}
headerSurface={props.palette.headerSurface}
headerActiveSurface={props.palette.headerActiveSurface}
selectedPath={props.selectedPath}
mode={props.mode}
onToggleFile={props.onToggleFile}
onSelectPath={props.onSelectPath}
/>,
/>
))}
{interactionFiles.map((file) => (
<NativeFileBody
key={`${file.path}:body`}
file={file}
model={model}
mode={props.mode}
horizontalOffsets={horizontalOffsets}
/>,
])}
/>
))}
<NativeReviewOverlays model={model} mode={props.mode} />
<Animated.View pointerEvents="none" style={keyboardSpacerStyle} />
</AnimatedScrollView>
@@ -306,29 +356,69 @@ function NativeCanvasSlabLayer({
));
}
function NativeStickyFileHeader({
function NativeCanvasFileHeader({
file,
viewportWidth,
scrollTop,
textLayout,
paints,
headerSurface,
headerActiveSurface,
selectedPath,
mode,
onToggleFile,
onSelectPath,
}: {
file: DiffFileSection;
viewportWidth: number;
scrollTop: SharedValue<number>;
textLayout: NativeHeaderTextLayout;
paints: NativePaints;
headerSurface: string;
headerActiveSurface: string;
selectedPath: DiffSurfaceProps["selectedPath"];
mode: DiffSurfaceProps["mode"];
onToggleFile: DiffSurfaceProps["onToggleFile"];
onSelectPath: DiffSurfaceProps["onSelectPath"];
}) {
const [active, setActive] = useState(false);
const picture = useMemo(
() => recordNativeHeaderPicture({ file, viewportWidth, textLayout, paints }),
[file, paints, textLayout, viewportWidth],
);
const stickyStyle = useAnimatedStyle(() => {
const pinOffset = Math.max(0, scrollTop.value - file.top);
const maximumPinOffset = Math.max(0, file.bottom - file.headerHeight - file.top);
return { transform: [{ translateY: Math.min(pinOffset, maximumPinOffset) }] };
}, [file.bottom, file.headerHeight, file.top, scrollTop]);
const style = useMemo<ViewStyle>(
() => ({
position: "absolute",
top: file.top,
left: 0,
width: viewportWidth,
height: file.headerHeight,
backgroundColor: active ? headerActiveSurface : headerSurface,
zIndex: 5,
elevation: 2,
}),
[active, file.headerHeight, file.top, headerActiveSurface, headerSurface, viewportWidth],
);
return (
<View style={styles.header}>
<Animated.View style={[style, stickyStyle]}>
<Canvas pointerEvents="none" style={StyleSheet.absoluteFill}>
<Picture picture={picture} />
</Canvas>
<DocumentFileHeader
file={file}
selectedPath={selectedPath}
mode={mode}
onToggleFile={onToggleFile}
onSelectPath={onSelectPath}
canvasRendered
onActiveChange={setActive}
/>
</View>
</Animated.View>
);
}
@@ -382,7 +472,10 @@ function NativeFileBody({
<View
testID={`diff-file-${file.fileIndex}-body`}
style={inlineUnistylesStyle<ViewStyle>({
position: "relative",
position: "absolute",
top: file.bodyTop,
left: 0,
right: 0,
height: file.bodyHeight,
zIndex: 2,
})}
@@ -579,5 +672,4 @@ function NativeSlabCode({
const styles = StyleSheet.create({
root: { flex: 1, minHeight: 0, position: "relative", overflow: "hidden" },
scroll: { backgroundColor: "transparent" },
header: { zIndex: 5 },
});
@@ -5,11 +5,17 @@ import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overl
import { InlineReviewAddButton, InlineReviewThread } from "@/review";
import type { ReviewableDiffTarget } from "@/utils/diff-layout";
import { DocumentFileHeader } from "./document-file-header";
import {
diffHeaderViewportKey,
diffInteractionWindowTop,
diffMaterializationWindow,
resolveVisibleFileSections,
} from "./header-layout";
import { hitTestDiffDocument, selectedSourceText } from "./hit-testing";
import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets";
import { HorizontalScroll } from "./horizontal-scroll.web";
import { buildDiffDocumentModel, FILE_HEADER_HEIGHT, resolveRelayoutScrollTop } from "./model";
import { paintWebViewport } from "./paint.web";
import { paintWebHeaders, paintWebViewport } from "./paint.web";
import { hasPointerDragStarted } from "./pointer-gesture";
import { createMeasuredAdvances } from "./text-measurement";
import { retainDiffViewport } from "./viewport";
@@ -30,6 +36,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
const workspaceCache = useDiffDocumentWorkspaceCache();
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const headerCanvasRef = useRef<HTMLCanvasElement>(null);
const canvasScratchRef = useRef<HTMLCanvasElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const modelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
@@ -46,6 +53,17 @@ 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);
const resizePointerActiveRef = useRef(false);
@@ -53,6 +71,10 @@ export function DiffSurface(props: DiffSurfaceProps) {
const forcePaintRef = useRef(true);
const canvasWindowRef = useRef({ top: 0, height: 0 });
const [viewport, setViewport] = useState({ width: 0, height: 0 });
const [fileWindowTop, setFileWindowTop] = useState(0);
const [interactionFiles, setInteractionFiles] = useState<
ReturnType<typeof buildDiffDocumentModel>["files"]
>([]);
const [hoveredAffordance, setHoveredAffordance] = useState<{
hit: Extract<DiffHit, { kind: "cell" }>;
left: number;
@@ -81,12 +103,18 @@ export function DiffSurface(props: DiffSurfaceProps) {
await Promise.all([
document.fonts.load(`400 ${desiredTypography.size}px ${desiredTypography.family}`),
document.fonts.load(`600 ${desiredTypography.size}px ${desiredTypography.family}`),
document.fonts.load(
`400 ${props.headerTypography.size}px ${props.headerTypography.family}`,
),
document.fonts.load(
`400 ${props.headerTypography.statSize}px ${props.headerTypography.family}`,
),
]);
await document.fonts.ready;
},
createMeasurer: () => createWebTextMeasurer(desiredTypography),
}),
[desiredTypography, workspaceCache],
[desiredTypography, props.headerTypography, workspaceCache],
);
const [readyTypographyResource, setReadyTypographyResource] = useState<ReturnType<
typeof workspaceCache.typography
@@ -105,7 +133,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
lineHeight: desiredTypography.lineHeight,
});
}
return workspaceCache.buildModel({
const next = workspaceCache.buildModel({
files: props.files,
collapsedFilePaths: props.collapsedFilePaths,
layout: props.displayPreferences.layout,
@@ -119,8 +147,11 @@ export function DiffSurface(props: DiffSurfaceProps) {
binary: t("workspace.git.diff.binaryFile"),
tooLarge: t("workspace.git.diff.tooLarge"),
},
materializationWindow: diffMaterializationWindow(fileWindowTop, viewport.height),
});
return next;
}, [
fileWindowTop,
measurement,
props.collapsedFilePaths,
props.displayPreferences.layout,
@@ -132,6 +163,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
desiredTypography.lineHeight,
loadedTypography,
viewport.width,
viewport.height,
workspaceCache,
]);
modelRef.current = model;
@@ -213,6 +245,64 @@ 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;
@@ -300,13 +390,37 @@ export function DiffSurface(props: DiffSurfaceProps) {
props.files.map((file) => file.path),
);
}, [props.files]);
useLayoutEffect(schedulePaint, [model, schedulePaint]);
const updateInteractionFiles = useCallback(
(scrollTop: number) => {
const windowTop = diffInteractionWindowTop(scrollTop, viewport.height);
setFileWindowTop((current) => (current === windowTop ? current : windowTop));
const next = resolveVisibleFileSections({
files: model.files,
scrollTop: windowTop,
viewportHeight: viewport.height,
overscan: viewport.height * 2,
}).files;
setInteractionFiles((current) =>
current.length === next.length && current[0] === next[0] && current.at(-1) === next.at(-1)
? current
: next,
);
},
[model.files, viewport.height],
);
useLayoutEffect(() => {
schedulePaint();
scheduleHeaderPaint();
updateInteractionFiles(scrollTopRef.current);
}, [model, scheduleHeaderPaint, 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;
},
[],
);
@@ -331,6 +445,9 @@ export function DiffSurface(props: DiffSurfaceProps) {
(scrollElement: HTMLDivElement) => {
const scrollTop = scrollElement.scrollTop;
scrollTopRef.current = scrollTop;
if (activeHeaderPathRef.current !== null) activeHeaderPathRef.current = null;
scheduleHeaderPaint();
updateInteractionFiles(scrollTop);
if (hasHoveredAffordanceRef.current) {
hasHoveredAffordanceRef.current = false;
setHoveredAffordance(null);
@@ -355,7 +472,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
const nextTop = Math.round(requestedTop * ratio) / ratio;
if (nextTop !== currentWindow.top) schedulePaint(false);
},
[schedulePaint, viewport.height],
[scheduleHeaderPaint, schedulePaint, updateInteractionFiles, viewport.height],
);
useEffect(() => {
const scroll = scrollRef.current;
@@ -420,8 +537,20 @@ export function DiffSurface(props: DiffSurfaceProps) {
},
[pointHit, schedulePaint],
);
const updateActiveHeader = useCallback(
(target: EventTarget | null) => {
const header =
target instanceof Element ? target.closest<HTMLElement>("[data-diff-header]") : null;
const activeHeaderPath = header?.dataset.diffHeaderPath ?? null;
if (activeHeaderPathRef.current === activeHeaderPath) return;
activeHeaderPathRef.current = activeHeaderPath;
scheduleHeaderPaint();
},
[scheduleHeaderPaint],
);
const pointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
updateActiveHeader(event.target);
const drag = dragRef.current;
if (drag) {
drag.moved = hasPointerDragStarted({
@@ -463,7 +592,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
selectionRef.current = { anchor: drag.anchor, focus: hit.position };
schedulePaint();
},
[pointHit, schedulePaint, viewport.width],
[pointHit, schedulePaint, updateActiveHeader, viewport.width],
);
const pointerUp = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
@@ -505,6 +634,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
const cancelPointer = useCallback(() => {
dragRef.current = null;
}, []);
const pointerLeave = useCallback(() => updateActiveHeader(null), [updateActiveHeader]);
const copy = useCallback(
(event: React.ClipboardEvent<HTMLDivElement>) => {
if (!selectionRef.current) return;
@@ -554,12 +684,13 @@ export function DiffSurface(props: DiffSurfaceProps) {
onPointerUp={pointerUp}
onPointerCancel={cancelPointer}
onLostPointerCapture={cancelPointer}
onPointerLeave={pointerLeave}
onCopy={copy}
style={SCROLL_STYLE}
>
<div style={contentStyle} onMouseDown={preventDocumentMouseSelection}>
<canvas ref={canvasRef} data-testid="git-diff-canvas" style={canvasStyle} />
{model.files.map((file) => (
{interactionFiles.map((file) => (
<WebFileHeaderSection key={file.path} file={file}>
<DocumentFileHeader
file={file}
@@ -567,10 +698,11 @@ export function DiffSurface(props: DiffSurfaceProps) {
mode={props.mode}
onToggleFile={props.onToggleFile}
onSelectPath={props.onSelectPath}
canvasRendered
/>
</WebFileHeaderSection>
))}
{model.files
{interactionFiles
.filter((file) => !file.isCollapsed && !model.wrapLines)
.map((file) => (
<HorizontalScroll
@@ -609,6 +741,11 @@ 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} />
@@ -686,6 +823,7 @@ function WebFileHeaderSection({
<div style={style}>
<div
data-diff-header="true"
data-diff-header-path={file.path}
style={STICKY_HEADER_STYLE}
onMouseDown={preventDocumentMouseSelection}
>
@@ -766,6 +904,7 @@ const FILE_SECTION_STYLE: React.CSSProperties = {
right: 0,
zIndex: 3,
pointerEvents: "none",
userSelect: "none",
};
const STICKY_HEADER_STYLE: React.CSSProperties = {
position: "sticky",
@@ -789,6 +928,12 @@ 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,
@@ -808,6 +953,7 @@ function emptyDiffDocumentModel(input: {
layout: input.layout,
wrapLines: input.wrapLines,
viewportWidth: input.viewportWidth,
reviewGeometryKey: "",
};
}
@@ -23,6 +23,66 @@ export interface NativeTextLayout {
paragraphs: Array<Array<Array<SkParagraph | null>>>;
}
export interface NativeHeaderTextLayout {
families: string[] | undefined;
fontSize: number;
statFontSize: number;
palette: Pick<DiffPalette, "foreground" | "foregroundMuted" | "statusSuccess" | "statusDanger">;
}
export interface NativeShapedHeaderText {
paragraph: SkParagraph;
width: number;
height: number;
}
export type NativeHeaderTextTone = keyof NativeHeaderTextLayout["palette"];
export function createNativeHeaderTextLayout(input: {
configuredFamily: string;
fontSize: number;
statFontSize: number;
palette: NativeHeaderTextLayout["palette"];
}): NativeHeaderTextLayout {
const configured = input.configuredFamily
.split(",")
.map((family) => family.trim().replace(/^['"]|['"]$/g, ""))
// These are React Native's platform-default UI sentinels, not custom faces.
// Omitting the family lets Skia resolve the same platform system font.
.filter((family) => family && family !== "normal" && family !== "system-ui");
const families = configured.length > 0 ? [...new Set(configured)] : undefined;
return {
families,
fontSize: input.fontSize,
statFontSize: input.statFontSize,
palette: input.palette,
};
}
export function shapeNativeHeaderText(input: {
layout: NativeHeaderTextLayout;
text: string;
size: "body" | "stat";
tone: NativeHeaderTextTone;
maximumWidth?: number;
}): NativeShapedHeaderText {
const paragraph = Skia.ParagraphBuilder.Make({ maxLines: 1, ellipsis: "…" })
.pushStyle({
...(input.layout.families ? { fontFamilies: input.layout.families } : {}),
fontSize: input.size === "body" ? input.layout.fontSize : input.layout.statFontSize,
color: Skia.Color(input.layout.palette[input.tone]),
})
.addText(input.text)
.pop()
.build();
paragraph.layout(input.maximumWidth ?? PARAGRAPH_WIDTH);
return {
paragraph,
width: paragraph.getLongestLine(),
height: paragraph.getHeight(),
};
}
export interface NativeTextLayoutStore {
font: SkFont;
asciiMetrics: CachedAsciiTextMetrics;
@@ -61,9 +61,20 @@ export interface DiffPalette {
deletionBackground: string;
emptyBackground: string;
selection: string;
headerActiveSurface: string;
headerBorder: string;
statusSuccess: string;
statusDanger: string;
statusWarning: string;
syntax: Record<string, string>;
}
export interface DiffHeaderTypography {
family: string;
size: number;
statSize: number;
}
export interface DiffTokenRun {
start: number;
end: number;
@@ -150,6 +161,7 @@ export interface DiffDocumentModel {
layout: "unified" | "split";
wrapLines: boolean;
viewportWidth: number;
reviewGeometryKey: string;
}
export interface TextMeasurer {
@@ -168,6 +180,7 @@ export interface BuildDiffDocumentModelInput {
palette: DiffPalette;
reviewActions?: InlineReviewActions;
labels: { binary: string; tooLarge: string };
materializationWindow?: { top: number; height: number };
/** A geometry-compatible model whose unchanged file measurements may be reused. */
reuseFrom?: readonly DiffDocumentModel[];
}
@@ -206,6 +219,7 @@ export type DiffScrollAnchor =
export type DiffSurfaceProps = DiffDocumentProps & {
palette: DiffPalette;
headerTypography: DiffHeaderTypography;
collapsedFilePaths: ReadonlySet<string>;
onToggleFile: (path: string) => void;
selectedPath: string | null;
@@ -1,7 +1,12 @@
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
import { describe, expect, it } from "vitest";
import { createDiffDocumentWorkspaceCache } from "./workspace-cache";
import type { BuildDiffDocumentModelInput, DiffPalette, TextMeasurer } from "./types";
import type {
BuildDiffDocumentModelInput,
DiffDocumentModel,
DiffPalette,
TextMeasurer,
} from "./types";
const palette: DiffPalette = {
surface: "#000",
@@ -15,6 +20,11 @@ const palette: DiffPalette = {
deletionBackground: "#100",
emptyBackground: "#111",
selection: "blue",
headerActiveSurface: "#222",
headerBorder: "#333",
statusSuccess: "green",
statusDanger: "red",
statusWarning: "orange",
syntax: { keyword: "purple" },
};
@@ -137,6 +147,33 @@ describe("diff document workspace cache", () => {
expect(second.rows[0]).toMatchObject({ kind: "status", label: "Binary blob" });
});
it("materializes successive unwrapped windows without remeasuring retained files", () => {
const cache = createDiffDocumentWorkspaceCache();
const files = [diffFile(), { ...diffFile(), path: "src/b.ts" }];
const { measureText, stats } = countingMeasurer();
const first = cache.buildModel(
modelInput(files, measureText, { materializationWindow: { top: 0, height: 20 } }),
);
const firstCalls = stats.calls;
const secondFile = first.files[1]!;
const second = cache.buildModel(
modelInput(files, measureText, {
materializationWindow: {
top: secondFile.top,
height: secondFile.bottom - secondFile.top,
},
}),
);
expect(firstCalls).toBeGreaterThan(0);
expect(stats.calls).toBeGreaterThan(firstCalls);
expect(measuredFragmentCount(first, 0)).toBeGreaterThan(0);
expect(measuredFragmentCount(first, 1)).toBe(0);
expect(measuredFragmentCount(second, 0)).toBeGreaterThan(0);
expect(measuredFragmentCount(second, 1)).toBeGreaterThan(0);
});
it("shares loaded typography and its text measurer across mounts", async () => {
const cache = createDiffDocumentWorkspaceCache();
const { measureText } = countingMeasurer();
@@ -171,3 +208,13 @@ describe("diff document workspace cache", () => {
expect(resource.isReady()).toBe(true);
});
});
function measuredFragmentCount(model: DiffDocumentModel, fileIndex: number): number {
const file = model.files[fileIndex];
if (!file) throw new Error(`Expected file ${fileIndex}`);
return model.rows
.slice(file.rowStart, file.rowEnd)
.filter((row) => row.kind === "line")
.flatMap((row) => row.cells)
.reduce((count, cell) => count + (cell?.fragments.length ?? 0), 0);
}
@@ -1,6 +1,6 @@
import { createContext, useContext, useState, type ReactNode } from "react";
import invariant from "tiny-invariant";
import { buildDiffDocumentModel, retainReusableModels } from "./model";
import { buildDiffDocumentModel, retainReusableModels, reviewGeometryKey } from "./model";
import type {
BuildDiffDocumentModelInput,
DiffDocumentModel,
@@ -117,17 +117,11 @@ function modelVariantKey(input: Omit<BuildDiffDocumentModelInput, "reuseFrom">):
function exactModelKey(input: Omit<BuildDiffDocumentModelInput, "reuseFrom">): string {
const collapsedFilePaths = [...input.collapsedFilePaths].sort();
const reviewGeometry = input.reviewActions
? {
comments: [...input.reviewActions.commentsByTarget.entries()]
.map(([target, comments]) => [target, comments.map((comment) => comment.id).sort()])
.sort(([left], [right]) => String(left).localeCompare(String(right))),
editor: input.reviewActions.editor
? [input.reviewActions.editor.target.key, input.reviewActions.editor.commentId]
: null,
}
const reviewGeometry = reviewGeometryKey(input.reviewActions);
const materializationWindow = input.materializationWindow
? [input.materializationWindow.top, input.materializationWindow.height]
: null;
return JSON.stringify([collapsedFilePaths, reviewGeometry]);
return JSON.stringify([collapsedFilePaths, reviewGeometry, materializationWindow]);
}
function typographyKey(typography: DiffTypography): string {
@@ -150,6 +144,11 @@ function paletteKey(palette: DiffPalette): string {
palette.deletionBackground,
palette.emptyBackground,
palette.selection,
palette.headerActiveSurface,
palette.headerBorder,
palette.statusSuccess,
palette.statusDanger,
palette.statusWarning,
syntax,
]);
}