fix(app): stabilize canvas diff rendering

Keep diff headers and painted content on the same scroll clock while preserving native scrolling and independent per-file horizontal offsets.

Complete review, selection, typography, tree, and palette parity without returning line rendering to React.
This commit is contained in:
Mohamed Boudra
2026-08-17 21:14:38 +02:00
parent 4d23905587
commit c7b103a2cd
50 changed files with 2180 additions and 580 deletions
+70 -12
View File
@@ -1,4 +1,5 @@
import { lineNumberGutterWidth } from "@/components/code-insets";
import { findClusterBreak } from "@marijn/find-cluster-break";
import {
buildSplitDiffRows,
buildUnifiedDiffLines,
@@ -22,7 +23,7 @@ import type {
TextMeasurer,
} from "./types";
export const FILE_HEADER_HEIGHT = 44;
export const FILE_HEADER_HEIGHT = 30;
const BODY_BORDER_HEIGHT = 1;
const CODE_HORIZONTAL_PADDING = 16;
@@ -49,7 +50,28 @@ export function buildDiffDocumentModel(input: BuildDiffDocumentModelInput): Diff
const gutterWidth = lineNumberGutterWidth(maximumLineNumber(file), input.typography.size);
let maximumHorizontalOverflow = 0;
if (!isCollapsed) {
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,
);
if (!isCollapsed && reusableFile) {
const reusedRows = reusableModel!.rows.slice(reusableFile.rowStart, reusableFile.rowEnd);
for (const reusedRow of reusedRows) {
const height = reusedRow.height;
rows.push({
...reusedRow,
index: rows.length,
fileIndex,
top: documentTop,
});
documentTop += height;
}
maximumHorizontalOverflow = Math.max(0, reusableFile.contentWidth - input.viewportWidth);
documentTop += BODY_BORDER_HEIGHT;
} else if (!isCollapsed) {
if (file.status === "binary" || file.status === "too_large") {
const height = input.typography.lineHeight + 24;
rows.push({
@@ -136,6 +158,16 @@ export function buildDiffDocumentModel(input: BuildDiffDocumentModelInput): Diff
};
}
export function retainReusableModels(
previous: readonly DiffDocumentModel[] | undefined,
next: DiffDocumentModel,
): DiffDocumentModel[] {
const fullest = [...(previous ?? []), next].reduce((best, candidate) =>
candidate.rows.length > best.rows.length ? candidate : best,
);
return fullest === next ? [next] : [fullest, next];
}
function lineSources(
file: BuildDiffDocumentModelInput["files"][number],
layout: "unified" | "split",
@@ -302,17 +334,19 @@ const TAB_SIZE = 4;
function segmentGraphemes(text: string): Array<Omit<DiffGrapheme, "width">> {
const segments: Array<Omit<DiffGrapheme, "width">> = [];
let column = 0;
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
for (const segment of segmenter.segment(text)) {
const isTab = segment.segment === "\t";
for (let index = 0; index < text.length; ) {
const end = findClusterBreak(text, index);
const segment = text.slice(index, end);
const isTab = segment === "\t";
const spaces = isTab ? TAB_SIZE - (column % TAB_SIZE) : 0;
const displayText = isTab ? " ".repeat(spaces) : segment.segment;
const displayText = isTab ? " ".repeat(spaces) : segment;
segments.push({
start: segment.index,
end: segment.index + segment.segment.length,
start: index,
end,
text: displayText,
});
column += isTab ? spaces : 1;
index = end;
}
return segments;
}
@@ -351,6 +385,7 @@ function measurePrefixAdvances(
}
export function fragmentTextForRange(fragment: DiffFragment, start: number, end: number): string {
"worklet";
return fragment.graphemes
.filter((grapheme) => grapheme.start >= start && grapheme.end <= end)
.map((grapheme) => grapheme.text)
@@ -365,10 +400,9 @@ export function fragmentWidthForRange(fragment: DiffFragment, start: number, end
export function graphemeBoundaries(text: string): number[] {
const boundaries = [0];
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
for (const segment of segmenter.segment(text)) {
const end = segment.index + segment.segment.length;
if (end > boundaries[boundaries.length - 1]) boundaries.push(end);
for (let index = 0; index < text.length; ) {
index = findClusterBreak(text, index);
boundaries.push(index);
}
return boundaries;
}
@@ -501,6 +535,30 @@ export function resolveScrollAnchor(model: DiffDocumentModel, anchor: DiffScroll
return Math.max(0, row.top + (fragment?.top ?? 0) - anchor.viewportDelta);
}
export function resolveRelayoutScrollTop(
previous: DiffDocumentModel,
next: DiffDocumentModel,
scrollTop: number,
): number {
const changedFiles = previous.files.filter((file) => {
const nextFile = next.files.find((candidate) => candidate.path === file.path);
return nextFile && nextFile.isCollapsed !== file.isCollapsed;
});
if (changedFiles.length === 1) {
const previousFile = changedFiles[0]!;
const nextFile = next.files.find((candidate) => candidate.path === previousFile.path);
if (nextFile) {
const headerDelta =
previousFile.top <= scrollTop && scrollTop < previousFile.bottom
? 0
: previousFile.top - scrollTop;
return Math.max(0, nextFile.top - headerDelta);
}
}
const anchor = captureScrollAnchor(previous, scrollTop);
return anchor ? resolveScrollAnchor(next, anchor) : scrollTop;
}
function sameSourceIdentity(left: DiffSourceIdentity, right: DiffSourceIdentity): boolean {
return (
left.hunkIndex === right.hunkIndex &&