Files
paseo/packages/app/src/git/diff-document/paint.web.test.ts
T
Mohamed Boudra 3998ea11d9 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
2026-09-01 23:52:11 +02:00

537 lines
14 KiB
TypeScript

import { describe, expect, it } from "vitest";
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 = {
setTransform() {},
clearRect() {},
fillRect() {},
save() {},
restore() {},
beginPath() {},
rect() {},
clip() {},
fillText(text: string) {
paintedText.push(text);
},
fillStyle: "",
globalAlpha: 1,
font: "",
textBaseline: "alphabetic",
} as unknown as CanvasRenderingContext2D;
paintWebViewport({
context,
model,
palette,
typography: { family: "ligature-font", size: 12, lineHeight: 18 },
measureText: { measure: () => 0 },
scrollTop: 0,
viewportWidth: 200,
viewportHeight: 100,
horizontalOffsets: new Map(),
selection: null,
devicePixelRatio: 1,
});
expect(paintedText).toEqual(["fi", "fi"]);
});
it("paints review space with the surface color and continues the gutter divider", () => {
const fills: Array<{ color: string; x: number; y: number; width: number; height: number }> = [];
let fillStyle = "";
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() {},
fillText() {},
get fillStyle() {
return fillStyle;
},
set fillStyle(value: string | CanvasGradient | CanvasPattern) {
fillStyle = String(value);
},
globalAlpha: 1,
font: "",
textBaseline: "alphabetic",
} as unknown as CanvasRenderingContext2D;
paintWebViewport({
context,
model: modelWithReview,
palette,
typography: { family: "monospace", size: 12, lineHeight: 18 },
measureText: { measure: () => 0 },
scrollTop: 0,
viewportWidth: 200,
viewportHeight: 100,
horizontalOffsets: new Map(),
selection: null,
devicePixelRatio: 1,
});
expect(fills).toContainEqual({ color: "surface", x: 0, y: 18, width: 200, height: 40 });
expect(fills).toContainEqual({ color: "border", x: 20, y: 0, width: 1, height: 58 });
});
it("paints every expanded file body's bottom border", () => {
const fills: Array<{ color: string; x: number; y: number; width: number; height: number }> = [];
let fillStyle = "";
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() {},
fillText() {},
get fillStyle() {
return fillStyle;
},
set fillStyle(value: string | CanvasGradient | CanvasPattern) {
fillStyle = String(value);
},
globalAlpha: 1,
font: "",
textBaseline: "alphabetic",
} as unknown as CanvasRenderingContext2D;
const borderedModel: DiffDocumentModel = {
...model,
height: 38,
files: [
{ ...model.files[0]!, bodyHeight: 19, bottom: 19 },
{
...model.files[0]!,
file: { ...model.files[0]!.file, path: "src/b.ts" },
fileIndex: 1,
path: "src/b.ts",
top: 19,
bodyTop: 19,
bodyHeight: 19,
bottom: 38,
rowStart: 1,
rowEnd: 1,
},
],
};
paintWebViewport({
context,
model: borderedModel,
palette,
typography: { family: "monospace", size: 12, lineHeight: 18 },
measureText: { measure: () => 0 },
scrollTop: 0,
viewportWidth: 200,
viewportHeight: 100,
horizontalOffsets: new Map(),
selection: null,
devicePixelRatio: 1,
});
expect(fills.filter((fill) => fill.color === "border" && fill.x === 0)).toEqual([
{ color: "border", x: 0, y: 18, width: 200, height: 1 },
{ color: "border", x: 0, y: 37, width: 200, height: 1 },
]);
});
it("clips a horizontally scrolled unified selection to the code viewport", () => {
const selectionModel = createSelectionModel("unified");
const paints = paintSelection(selectionModel, selectionForCell(0));
expect(paints).toEqual([
{
x: -22,
y: 0,
width: 100,
height: 18,
clip: { x: 28, y: 0, width: 172, height: 18 },
},
]);
});
it("clips horizontally scrolled split selections to their own code columns", () => {
const selectionModel = createSelectionModel("split");
expect(paintSelection(selectionModel, selectionForCell(0))).toEqual([
{
x: -22,
y: 0,
width: 100,
height: 18,
clip: { x: 28, y: 0, width: 72, height: 18 },
},
]);
expect(paintSelection(selectionModel, selectionForCell(1))).toEqual([
{
x: 78,
y: 0,
width: 100,
height: 18,
clip: { x: 128, y: 0, width: 72, height: 18 },
},
]);
});
});
interface Rectangle {
x: number;
y: number;
width: number;
height: number;
}
interface SelectionPaint extends Rectangle {
clip: Rectangle | null;
}
function paintSelection(model: DiffDocumentModel, selection: DiffSelection): SelectionPaint[] {
const paints: SelectionPaint[] = [];
const clipStack: Array<Rectangle | null> = [];
let fillStyle = "";
let pendingRectangle: Rectangle | null = null;
let currentClip: Rectangle | null = null;
const context = {
setTransform() {},
clearRect() {},
fillRect(x: number, y: number, width: number, height: number) {
if (fillStyle === palette.selection) {
paints.push({ x, y, width, height, clip: currentClip });
}
},
save() {
clipStack.push(currentClip);
},
restore() {
currentClip = clipStack.pop() ?? null;
},
beginPath() {
pendingRectangle = null;
},
rect(x: number, y: number, width: number, height: number) {
pendingRectangle = { x, y, width, height };
},
clip() {
currentClip = pendingRectangle;
},
fillText() {},
get fillStyle() {
return fillStyle;
},
set fillStyle(value: string | CanvasGradient | CanvasPattern) {
fillStyle = String(value);
},
globalAlpha: 1,
font: "",
textBaseline: "alphabetic",
} as unknown as CanvasRenderingContext2D;
paintWebViewport({
context,
model,
palette,
typography: { family: "monospace", size: 12, lineHeight: 18 },
measureText: { measure: () => 0 },
scrollTop: 0,
viewportWidth: 200,
viewportHeight: 100,
horizontalOffsets: new Map([["src/selection.ts", 50]]),
selection,
devicePixelRatio: 1,
});
return paints;
}
function selectionForCell(cellIndex: number): DiffSelection {
const position = {
fileIndex: 0,
rowIndex: 0,
cellIndex,
side: cellIndex === 0 ? ("old" as const) : ("new" as const),
};
return {
anchor: { ...position, sourceOffset: 0 },
focus: { ...position, sourceOffset: 10 },
};
}
function createSelectionModel(layout: "unified" | "split"): DiffDocumentModel {
const cells: DiffLineRow["cells"] =
layout === "unified" ? [selectionCell("new")] : [selectionCell("old"), selectionCell("new")];
return {
files: [
{
file: {
path: "src/selection.ts",
isNew: false,
isDeleted: false,
additions: 1,
deletions: 1,
hunks: [],
},
fileIndex: 0,
path: "src/selection.ts",
top: 0,
headerHeight: 0,
bodyTop: 0,
bodyHeight: 18,
bottom: 18,
gutterWidth: 20,
contentWidth: 300,
rowStart: 0,
rowEnd: 1,
isCollapsed: false,
},
],
rows: [
{
kind: "line",
index: 0,
fileIndex: 0,
path: "src/selection.ts",
top: 0,
height: 18,
reviewHeight: 0,
cells,
},
],
height: 18,
lineHeight: 18,
layout,
wrapLines: false,
viewportWidth: 200,
reviewGeometryKey: "",
};
}
function selectionCell(side: "old" | "new"): DiffCell {
return {
type: side === "old" ? "remove" : "add",
content: "abcdefghij",
lineNumber: 1,
tokens: [],
fragments: [
{
start: 0,
end: 10,
text: "abcdefghij",
width: 100,
top: 0,
baseline: 14,
graphemes: Array.from("abcdefghij", (text, index) => ({
start: index,
end: index + 1,
text,
width: 10,
})),
},
],
reviewTarget: null,
sourceIdentity: { hunkIndex: 0, lineIndex: 1, side },
};
}
const palette: DiffPalette = {
surface: "surface",
headerSurface: "header",
border: "border",
foreground: "foreground",
foregroundMuted: "muted",
addition: "green",
deletion: "red",
additionBackground: "green-bg",
deletionBackground: "red-bg",
emptyBackground: "empty",
selection: "selection",
headerActiveSurface: "active-header",
headerBorder: "header-border",
statusSuccess: "success",
statusDanger: "danger",
statusWarning: "warning",
syntax: { first: "red", second: "blue" },
};
const model: DiffDocumentModel = {
files: [
{
file: {
path: "src/a.ts",
isNew: false,
isDeleted: false,
additions: 1,
deletions: 0,
hunks: [],
},
fileIndex: 0,
path: "src/a.ts",
top: 0,
headerHeight: 0,
bodyTop: 0,
bodyHeight: 18,
bottom: 18,
gutterWidth: 20,
contentWidth: 200,
rowStart: 0,
rowEnd: 1,
isCollapsed: false,
},
],
rows: [
{
kind: "line",
index: 0,
fileIndex: 0,
path: "src/a.ts",
top: 0,
height: 18,
reviewHeight: 0,
cells: [
{
type: "add",
content: "fi",
lineNumber: null,
tokens: [
{ start: 0, end: 1, color: "red" },
{ start: 1, end: 2, color: "blue" },
],
fragments: [
{
start: 0,
end: 2,
text: "fi",
width: 15,
top: 0,
baseline: 14,
graphemes: [
{ start: 0, end: 1, text: "f", width: 10 },
{ start: 1, end: 2, text: "i", width: 5 },
],
},
],
reviewTarget: null,
sourceIdentity: { hunkIndex: 0, lineIndex: 1, side: "new" },
},
],
},
],
height: 18,
lineHeight: 18,
layout: "unified",
wrapLines: false,
viewportWidth: 200,
reviewGeometryKey: "",
};
const modelWithReview: DiffDocumentModel = {
...model,
files: [{ ...model.files[0]!, bodyHeight: 58, bottom: 58 }],
rows: [
{
...(model.rows[0] as DiffLineRow),
height: 58,
reviewHeight: 40,
},
],
height: 58,
};