Files
paseo/packages/app/src/git/diff-document/paint.web.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

410 lines
14 KiB
TypeScript

import { selectionRectangles } from "./hit-testing";
import {
DIFF_BODY_BORDER_HEIGHT,
expandedBodyBorderTop,
fragmentWidthForRange,
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,
DiffTypography,
TextMeasurer,
} from "./types";
const CODE_LEFT_PADDING = 8;
export interface PaintWebViewportInput {
context: CanvasRenderingContext2D;
model: DiffDocumentModel;
palette: DiffPalette;
typography: DiffTypography;
measureText: TextMeasurer;
scrollTop: number;
viewportWidth: number;
viewportHeight: number;
horizontalOffsets: ReadonlyMap<string, number>;
selection: DiffSelection | null;
devicePixelRatio: number;
paintTop?: number;
paintHeight?: number;
}
export function paintWebViewport(input: PaintWebViewportInput): void {
const { context } = input;
const scale = input.devicePixelRatio;
const paintTop = input.paintTop ?? 0;
const paintHeight = input.paintHeight ?? input.viewportHeight;
context.setTransform(scale, 0, 0, scale, 0, 0);
context.save();
context.beginPath();
context.rect(0, paintTop, input.viewportWidth, paintHeight);
context.clip();
context.clearRect(0, paintTop, input.viewportWidth, paintHeight);
context.fillStyle = input.palette.surface;
context.fillRect(0, paintTop, input.viewportWidth, paintHeight);
context.font = `${input.typography.size}px ${input.typography.family}`;
context.textBaseline = "alphabetic";
const range = visibleRowRange(input.model.rows, input.scrollTop + paintTop, paintHeight);
for (let index = range.start; index < range.end; index += 1) {
const row = input.model.rows[index];
if (!row) continue;
const y = row.top - input.scrollTop;
if (row.kind === "status") {
context.fillStyle = input.palette.foregroundMuted;
context.fillText(row.label, 12, y + input.model.lineHeight + 6);
continue;
}
paintLine({
...input,
row,
y,
horizontalOffset: input.horizontalOffsets.get(row.path) ?? 0,
});
}
context.fillStyle = input.palette.border;
const paintDocumentTop = input.scrollTop + paintTop;
const paintDocumentBottom = paintDocumentTop + paintHeight;
for (const file of input.model.files) {
const borderTop = expandedBodyBorderTop(file);
const isVisible =
borderTop !== null &&
borderTop < paintDocumentBottom &&
borderTop + DIFF_BODY_BORDER_HEIGHT > paintDocumentTop;
if (!isVisible) continue;
context.fillRect(0, borderTop - input.scrollTop, input.viewportWidth, DIFF_BODY_BORDER_HEIGHT);
}
if (input.selection) paintSelection(input, input.selection);
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;
y: number;
horizontalOffset: number;
},
): void {
const columnWidth = input.viewportWidth / input.row.cells.length;
const file = input.model.files[input.row.fileIndex];
if (!file) return;
input.row.cells.forEach((cell, cellIndex) => {
const x = cellIndex * columnWidth;
input.context.fillStyle = cellBackground(cell, input.palette);
input.context.fillRect(x, input.y, columnWidth, input.row.height);
if (input.row.reviewHeight > 0) {
input.context.fillStyle = reviewBackgroundPaint(input.palette.surface);
input.context.fillRect(
x,
reviewGapTop(input.y, input.row.height, input.row.reviewHeight),
columnWidth,
input.row.reviewHeight,
);
}
if (!cell) return;
input.context.fillStyle = input.palette.border;
input.context.fillRect(x + file.gutterWidth, input.y, 1, reviewDividerHeight(input.row.height));
if (cell.lineNumber !== null) {
const label = String(cell.lineNumber);
input.context.fillStyle = lineNumberColor(cell, input.palette);
input.context.fillText(
label,
x + file.gutterWidth - 7 - input.measureText.measure(label),
input.y + input.model.lineHeight * 0.78,
);
}
input.context.save();
input.context.beginPath();
input.context.rect(
x + file.gutterWidth + CODE_LEFT_PADDING,
input.y,
columnWidth - file.gutterWidth - CODE_LEFT_PADDING,
input.row.height - input.row.reviewHeight,
);
input.context.clip();
paintCellText({ ...input, cell, x: x + file.gutterWidth + CODE_LEFT_PADDING });
input.context.restore();
});
}
function paintCellText(
input: PaintWebViewportInput & {
row: DiffLineRow;
cell: DiffCell;
x: number;
y: number;
horizontalOffset: number;
},
): void {
const offset = input.model.wrapLines ? 0 : input.horizontalOffset;
for (const fragment of input.cell.fragments) {
const baseline = input.y + fragment.baseline;
const textX = input.x - offset;
for (const band of fragmentColorBands(input.cell, fragment, input.palette)) {
input.context.save();
input.context.beginPath();
input.context.rect(
textX + band.x,
input.y + fragment.top,
band.width,
input.model.lineHeight,
);
input.context.clip();
input.context.fillStyle = band.color;
// Keep the complete shaped string and origin used during measurement. Clipping changes
// syntax color without breaking ligatures, kerning, or contextual forms into new runs.
input.context.fillText(fragment.text, textX, baseline);
input.context.restore();
}
}
}
function fragmentColorBands(
cell: DiffCell,
fragment: DiffCell["fragments"][number],
palette: DiffPalette,
): Array<{ x: number; width: number; color: string }> {
const ranges: Array<{ start: number; end: number; color: string }> = [];
for (const grapheme of fragment.graphemes) {
const syntaxRun = cell.tokens.find(
(run) => run.start <= grapheme.start && grapheme.start < run.end,
);
const color = syntaxRun?.color ?? cellForeground(cell, palette);
const previous = ranges.at(-1);
if (previous?.color === color) previous.end = grapheme.end;
else ranges.push({ start: grapheme.start, end: grapheme.end, color });
}
if (ranges.length === 0) {
return [{ x: 0, width: Math.max(1, fragment.width), color: cellForeground(cell, palette) }];
}
return ranges.map((range) => ({
x: fragmentWidthForRange(fragment, fragment.start, range.start),
width: fragmentWidthForRange(fragment, range.start, range.end),
color: range.color,
}));
}
function paintSelection(input: PaintWebViewportInput, selection: DiffSelection): void {
input.context.save();
input.context.globalAlpha = 0.45;
input.context.fillStyle = input.palette.selection;
for (const rectangle of selectionRectangles({
model: input.model,
selection,
})) {
const file = input.model.files[rectangle.fileIndex];
if (!file) continue;
const x =
rectangle.x - (input.model.wrapLines ? 0 : (input.horizontalOffsets.get(file.path) ?? 0));
const y = rectangle.y - input.scrollTop;
input.context.save();
input.context.beginPath();
input.context.rect(rectangle.clipX, y, rectangle.clipWidth, rectangle.height);
input.context.clip();
input.context.fillRect(x, y, rectangle.width, rectangle.height);
input.context.restore();
}
input.context.restore();
}
function cellBackground(cell: DiffCell | null, palette: DiffPalette): string {
if (!cell) return palette.emptyBackground;
if (cell.type === "add") return palette.additionBackground;
if (cell.type === "remove") return palette.deletionBackground;
if (cell.type === "header") return palette.headerSurface;
return palette.surface;
}
function cellForeground(cell: DiffCell, palette: DiffPalette): string {
return codeTextColor(cell, palette);
}
function lineNumberColor(cell: DiffCell, palette: DiffPalette): string {
return palette[codeLineNumberTone(cell)];
}