Files
paseo/packages/app/src/git/diff-document/model.test.ts
T
Mohamed Boudra 50623965b4 Make line wrapping responsive in large diffs (#8006)
* perf(app): make diff line wrapping responsive on large changes

Wrapping eagerly shaped every expanded line before the viewport could paint. Estimate off-screen heights and measure rows as they enter the window, preserving the scroll anchor as geometry becomes exact. Add shared layout and browser benchmarks to track toggle latency and shaping cost.

* fix(app): preserve wrapped diff anchors across bounded relayouts

Resizing can move the viewed row outside the old materialization window. Keep its estimated wrap geometry available to anchor resolution, and measure the anchored row in the same layout pass so inaccurate estimates cannot discard the exact fragment or pixel offset.
2026-10-27 16:09:53 +07:00

907 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
import { describe, expect, it } from "vitest";
import {
buildDiffDocumentModel,
captureScrollAnchor,
FILE_HEADER_HEIGHT,
graphemeBoundaries,
measureFragments,
resolveScrollAnchor,
resolveRelayoutScrollTop,
shouldApplyRelayoutScroll,
} from "./model";
import type { BuildDiffDocumentModelInput, DiffCell, TextMeasurer } from "./types";
const measurer: TextMeasurer = { measure: (text) => Array.from(text).length * 10 };
function file(path = "src/a.ts"): ParsedDiffFile {
return {
path,
isNew: false,
isDeleted: false,
additions: 1,
deletions: 1,
hunks: [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 1,
lines: [
{ type: "header", content: "@@ -1 +1 @@" },
{ type: "remove", content: "const oldValue = '👨‍👩‍👧‍👦';" },
{ type: "add", content: "const newValue = 'é';" },
],
},
],
};
}
function input(overrides: Partial<BuildDiffDocumentModelInput> = {}): BuildDiffDocumentModelInput {
return {
files: [file()],
collapsedFilePaths: new Set(),
layout: "unified",
wrapLines: true,
viewportWidth: 180,
typography: { family: "monospace", size: 12, lineHeight: 18 },
measureText: measurer,
palette: {
surface: "#000",
headerSurface: "#111",
border: "#222",
foreground: "#fff",
foregroundMuted: "#aaa",
addition: "green",
deletion: "red",
additionBackground: "#010",
deletionBackground: "#100",
emptyBackground: "#111",
selection: "blue",
headerActiveSurface: "#222",
headerBorder: "#333",
statusSuccess: "green",
statusDanger: "red",
statusWarning: "orange",
syntax: {},
},
labels: { binary: "Binary", tooLarge: "Too large" },
...overrides,
};
}
describe("diff document model", () => {
it("preserves shaped wrap breaks when joining makes a longer prefix narrower", () => {
const measure = (text: string) => {
if (text === "لا") return 30;
if (text === "لام") return 15;
return text.length * 10;
};
const fragments = measureFragments({
text: "لام",
availableWidth: 20,
wrapLines: true,
lineHeight: 18,
measureText: { measure, measureWidth: (graphemes) => measure(graphemes.join("")) },
});
expect(fragments.map((fragment) => fragment.text)).toEqual(["ل", "ام"]);
expect(fragments.map((fragment) => fragment.width)).toEqual([10, 20]);
});
it("does not search wrap boundaries when the remaining text already fits", () => {
let widthQueries = 0;
const fragments = measureFragments({
text: "const answer = value + 1;",
availableWidth: 300,
wrapLines: true,
lineHeight: 18,
measureText: {
measure: measurer.measure,
measureWidth(graphemes) {
widthQueries++;
return graphemes.length * 10;
},
},
});
expect(fragments).toHaveLength(1);
expect(fragments[0]?.width).toBe(250);
expect(widthQueries).toBe(1);
});
it("builds character geometry only for displayed wrapped fragments", () => {
let advancedGraphemes = 0;
const measureWidth = (graphemes: readonly string[]) => graphemes.length * 10;
const measureText = {
// Whole-string widths need not match the chunked advance contract.
measure: () => 999,
measureWidth,
measureAdvances(graphemes: readonly string[]) {
advancedGraphemes += graphemes.length;
return graphemes.map((_, index) => (index + 1) * 10);
},
};
const text = "const next = (value) => value + 1;".repeat(4);
const fragments = measureFragments({
text,
availableWidth: 80,
wrapLines: true,
lineHeight: 18,
measureText,
});
expect(fragments.map((fragment) => fragment.text).join("")).toBe(text);
expect(fragments.every((fragment) => fragment.width <= 80)).toBe(true);
expect(fragments).toEqual(
measureFragments({
text,
availableWidth: 80,
wrapLines: true,
lineHeight: 18,
measureText: { measure: measurer.measure },
}),
);
expect(advancedGraphemes).toBe(text.length);
});
it("materializes only visible rows of a large file while preserving full horizontal extent", () => {
const source = file("large.ts");
source.hunks[0]!.lines = Array.from({ length: 1000 }, (_, index) => ({
type: "add",
content: index === 900 ? "a very wide offscreen line ".repeat(20) : `line ${index}`,
tokens: [{ text: `line ${index}`, style: null }],
}));
let advancedCharacters = 0;
const measureText: TextMeasurer = {
measure: (text) => text.length * 10,
measureAdvances(graphemes) {
advancedCharacters += graphemes.join("").length;
return graphemes.map((_, index) => (index + 1) * 10);
},
};
const base = input({ files: [source], wrapLines: false, viewportWidth: 300, measureText });
const eager = buildDiffDocumentModel(base);
advancedCharacters = 0;
const first = buildDiffDocumentModel({
...base,
materializationWindow: { top: FILE_HEADER_HEIGHT, height: 54 },
});
expect(measuredFragments(first, source.path)).toBe(3);
expect(advancedCharacters).toBeLessThan(100);
expect(first.height).toBe(eager.height);
expect(first.files[0]!.contentWidth).toBe(eager.files[0]!.contentWidth);
const priorCells = firstLineCells(first, source.path);
advancedCharacters = 0;
const next = buildDiffDocumentModel({
...base,
reuseFrom: [first],
materializationWindow: { top: FILE_HEADER_HEIGHT + 900 * 18, height: 54 },
});
expect(firstLineCells(next, source.path)).toBe(priorCells);
expect(next.rows[900]).toMatchObject({
kind: "line",
cells: [{ fragments: [{ text: source.hunks[0]!.lines[900]!.content }] }],
});
expect(advancedCharacters).toBeLessThan(600);
expect(next.height).toBe(eager.height);
advancedCharacters = 0;
buildDiffDocumentModel({
...base,
reuseFrom: [next],
materializationWindow: { top: FILE_HEADER_HEIGHT, height: 54 },
});
expect(advancedCharacters).toBe(0);
});
it("does not imperatively scroll for a no-op relayout", () => {
expect(shouldApplyRelayoutScroll(320, 320)).toBe(false);
expect(shouldApplyRelayoutScroll(320, 320.4)).toBe(false);
expect(shouldApplyRelayoutScroll(320, 321)).toBe(true);
});
it("segments graphemes when Intl.Segmenter is unavailable", () => {
const originalSegmenter = Intl.Segmenter;
Object.defineProperty(Intl, "Segmenter", { configurable: true, value: undefined });
try {
const source = "中é👨‍👩‍👧‍👦";
const fragments = measureFragments({
text: source,
availableWidth: 100,
wrapLines: true,
lineHeight: 18,
measureText: measurer,
});
expect(fragments[0]?.graphemes.map((grapheme) => grapheme.text)).toEqual(["中", "é", "👨‍👩‍👧‍👦"]);
} finally {
Object.defineProperty(Intl, "Segmenter", { configurable: true, value: originalSegmenter });
}
});
it("measures grapheme-safe wrapped fragments", () => {
const text = "ab👨‍👩‍👧‍👦cd";
const fragments = measureFragments({
text,
availableWidth: 30,
wrapLines: true,
lineHeight: 18,
measureText: measurer,
});
const boundaries = new Set(graphemeBoundaries(text));
expect(fragments.length).toBeGreaterThan(1);
for (const fragment of fragments) {
expect(boundaries.has(fragment.start)).toBe(true);
expect(boundaries.has(fragment.end)).toBe(true);
}
});
it("measures wrapped ligatures in the exact fragment shaping context", () => {
const ligatureMeasurer = shapingMeasurer(ligatureWidth);
const fragments = measureFragments({
text: "xfi",
availableWidth: 12,
wrapLines: true,
lineHeight: 18,
measureText: ligatureMeasurer,
});
expect(fragments.map((fragment) => fragment.text)).toEqual(["x", "fi"]);
expect(fragments.map((fragment) => fragment.width)).toEqual([10, 12]);
expect(fragments[1]?.graphemes.map((grapheme) => grapheme.width)).toEqual([8, 4]);
});
it("remeasures contextual-script glyphs after wrapping", () => {
const contextualMeasurer = shapingMeasurer((text) => {
if (text === "اب") return 15;
if (text === "ا") return 10;
if (text === "ب") return 9;
return 0;
});
const fragments = measureFragments({
text: "اب",
availableWidth: 10,
wrapLines: true,
lineHeight: 18,
measureText: contextualMeasurer,
});
expect(fragments.map((fragment) => fragment.text)).toEqual(["ا", "ب"]);
expect(fragments.map((fragment) => fragment.width)).toEqual([10, 9]);
expect(fragments[1]?.graphemes[0]?.width).toBe(9);
});
it("expands tabs to stable stops while retaining source offsets", () => {
const source = "a\tb\t中👨‍👩‍👧‍👦é";
const model = buildDiffDocumentModel(
input({ files: [fileWithChangedLine(source)], wrapLines: false }),
);
const cell = addedCell(model);
expect(cell.content).toBe(source);
expect(cell.fragments[0]?.text).toBe("a b 中👨‍👩‍👧‍👦é");
expect(cell.fragments[0]?.graphemes.map((grapheme) => grapheme.text)).toEqual([
"a",
" ",
"b",
" ",
"中",
"👨‍👩‍👧‍👦",
"é",
]);
expect(cell.content).toBe(source);
expect(cell.fragments[0]?.width).toBe(180);
});
it("measures CJK, combining marks, and ZWJ emoji as fallback-capable grapheme runs", () => {
const model = buildDiffDocumentModel(
input({
files: [fileWithChangedLine("中é👨‍👩‍👧‍👦")],
}),
);
expect(addedCell(model).fragments[0]?.graphemes.map((grapheme) => grapheme.text)).toEqual([
"中",
"é",
"👨‍👩‍👧‍👦",
]);
});
it("removes collapsed bodies and leaves future files expanded by default", () => {
const expanded = buildDiffDocumentModel(input({ files: [file("a.ts"), file("b.ts")] }));
const collapsed = buildDiffDocumentModel(
input({ files: [file("a.ts"), file("b.ts")], collapsedFilePaths: new Set(["a.ts"]) }),
);
expect(collapsed.files[0]?.bodyHeight).toBe(0);
expect(collapsed.files[1]?.bodyHeight).toBeGreaterThan(0);
expect(collapsed.height).toBeLessThan(expanded.height);
});
it("reuses unchanged file measurements when collapse state changes", () => {
const files = [file("a.ts"), file("b.ts")];
let measurementCount = 0;
const countingMeasurer: TextMeasurer = {
measure(text) {
measurementCount += 1;
return Array.from(text).length * 10;
},
};
const expanded = buildDiffDocumentModel(input({ files, measureText: countingMeasurer }));
const initialMeasurementCount = measurementCount;
const collapsed = buildDiffDocumentModel(
input({
files,
collapsedFilePaths: new Set(["a.ts"]),
measureText: countingMeasurer,
reuseFrom: [expanded],
}),
);
expect(measurementCount).toBe(initialMeasurementCount);
expect(collapsed.files[1]?.bodyHeight).toBe(expanded.files[1]?.bodyHeight);
expect(collapsed.rows.every((row, index) => row.index === index)).toBe(true);
expect(collapsed.rows[0]?.top).toBe(FILE_HEADER_HEIGHT * 2);
const expandedAgain = buildDiffDocumentModel(
input({
files,
measureText: countingMeasurer,
reuseFrom: [expanded, collapsed],
}),
);
expect(measurementCount).toBe(initialMeasurementCount);
expect(expandedAgain.height).toBe(expanded.height);
});
it("keeps the toggled sticky header anchored through collapse and expansion", () => {
const files = [file("a.ts"), file("b.ts")];
const expanded = buildDiffDocumentModel(input({ files }));
const firstFile = expanded.files[0]!;
const scrollTop = firstFile.top + 20;
const collapsed = buildDiffDocumentModel(
input({ files, collapsedFilePaths: new Set(["a.ts"]), reuseFrom: [expanded] }),
);
expect(resolveRelayoutScrollTop(expanded, collapsed, scrollTop)).toBe(collapsed.files[0]!.top);
expect(resolveRelayoutScrollTop(collapsed, expanded, collapsed.files[0]!.top)).toBe(
expanded.files[0]!.top,
);
});
it("reserves review geometry in the measured row", () => {
const model = buildDiffDocumentModel(
input({
reviewActions: {
commentsByTarget: new Map(),
editor: {
target: {
key: "src/a.ts:old:1",
filePath: "src/a.ts",
hunkHeader: "@@ -1 +1 @@",
hunkIndex: 0,
lineIndex: 1,
oldLineNumber: 1,
newLineNumber: null,
side: "old",
lineNumber: 1,
lineType: "remove",
content: "const oldValue = '👨‍👩‍👧‍👦';",
},
body: "",
commentId: null,
},
onStartComment() {},
onChangeEditorBody() {},
onCancelEditor() {},
onSaveEditor() {},
onEditComment() {},
onDeleteComment() {},
},
}),
);
const reviewRow = model.rows.find((row) => {
if (row.kind !== "line") return false;
return (
row.cells[0]?.reviewTarget?.side === "old" || row.cells[1]?.reviewTarget?.side === "old"
);
});
expect(reviewRow?.kind === "line" ? reviewRow.reviewHeight : 0).toBe(148);
});
it("reflows review geometry without remeasuring unchanged text", () => {
const files = [file("a.ts"), file("b.ts")];
let measurementCount = 0;
const countingMeasurer: TextMeasurer = {
measure(text) {
measurementCount += 1;
return Array.from(text).length * 10;
},
};
const base = buildDiffDocumentModel(input({ files, measureText: countingMeasurer }));
const initialMeasurementCount = measurementCount;
const reviewCell = base.rows
.filter((row) => row.kind === "line")
.flatMap((row) => row.cells)
.find((cell) => cell?.reviewTarget);
if (!reviewCell?.reviewTarget) throw new Error("Expected a reviewable cell");
const baseReviewRow = rowForCell(base, reviewCell);
const baseSecondFileTop = base.files[1]!.top;
const withEditor = buildDiffDocumentModel(
input({
files,
measureText: countingMeasurer,
reviewActions: reviewActionsWithEditor(reviewCell.reviewTarget),
reuseFrom: [base],
}),
);
const editorRow = rowForReviewTarget(withEditor, reviewCell.reviewTarget.key);
expect(measurementCount).toBe(initialMeasurementCount);
expect(editorRow.reviewHeight).toBe(148);
expect(editorRow.cells).toBe(baseReviewRow.cells);
expect(withEditor.files[1]!.top).toBe(baseSecondFileTop + 148);
const withoutEditor = buildDiffDocumentModel(
input({
files,
measureText: countingMeasurer,
reviewActions: reviewActionsWithEditor(null),
reuseFrom: [base, withEditor],
}),
);
expect(measurementCount).toBe(initialMeasurementCount);
expect(withoutEditor.height).toBe(base.height);
expect(withoutEditor.files[1]!.top).toBe(baseSecondFileTop);
});
it("preserves a logical source anchor through width and font metric relayout", () => {
const narrow = buildDiffDocumentModel(
input({ files: [fileWithChangedLine("abcdefghij")], viewportWidth: 90 }),
);
const narrowCell = addedCell(narrow);
const narrowRow = narrow.rows.find(
(row) => row.kind === "line" && row.cells.includes(narrowCell),
);
expect(narrowRow?.kind).toBe("line");
expect(narrowCell.fragments.length).toBeGreaterThan(1);
const scrollTop = narrowRow!.top + narrowCell.fragments[1]!.top + 3;
const anchor = captureScrollAnchor(narrow, scrollTop);
expect(anchor?.kind).toBe("text");
const wide = buildDiffDocumentModel(
input({
files: [fileWithChangedLine("abcdefghij")],
viewportWidth: 300,
typography: { family: "wide-font", size: 18, lineHeight: 27 },
measureText: { measure: (text) => Array.from(text).length * 14 },
}),
);
const resolved = resolveScrollAnchor(wide, anchor!);
const wideCell = addedCell(wide);
const wideRow = wide.rows.find((row) => row.kind === "line" && row.cells.includes(wideCell));
const anchoredFragment = wideCell.fragments.find(
(fragment) =>
anchor?.kind === "text" &&
fragment.start <= anchor.cellOffset &&
anchor.cellOffset < fragment.end,
);
expect(resolved).toBe(wideRow!.top + anchoredFragment!.top - anchor!.viewportDelta);
const wideAnchor = captureScrollAnchor(wide, wideRow!.top + 2);
const backToNarrow = resolveScrollAnchor(narrow, wideAnchor!);
const firstNarrowFragment = narrowCell.fragments[0];
expect(backToNarrow).toBe(
narrowRow!.top + firstNarrowFragment!.top - wideAnchor!.viewportDelta,
);
});
it("preserves a source-line anchor from unified to split and back", () => {
const changedFile = fileWithUnevenChanges();
const unified = buildDiffDocumentModel(
input({ files: [changedFile], layout: "unified", viewportWidth: 100 }),
);
const identity = { hunkIndex: 0, lineIndex: 4, side: "new" } as const;
const unifiedCell = cellByIdentity(unified, identity);
const unifiedRow = rowForCell(unified, unifiedCell);
expect(unifiedCell.fragments.length).toBeGreaterThan(1);
const anchoredFragment = unifiedCell.fragments[1]!;
const anchor = captureScrollAnchor(unified, unifiedRow.top + anchoredFragment.top + 2);
expect(anchor).toMatchObject({
kind: "text",
sourceIdentity: identity,
cellOffset: anchoredFragment.start,
viewportDelta: -2,
});
const split = buildDiffDocumentModel(
input({ files: [changedFile], layout: "split", viewportWidth: 100 }),
);
const splitCell = cellByIdentity(split, identity);
const splitRow = rowForCell(split, splitCell);
const splitFragment = splitCell.fragments.find(
(fragment) =>
fragment.start <= anchoredFragment.start && anchoredFragment.start < fragment.end,
);
expect(resolveScrollAnchor(split, anchor!)).toBe(
splitRow.top + splitFragment!.top - anchor!.viewportDelta,
);
const splitAnchor = captureScrollAnchor(
split,
splitRow.top + splitFragment!.top - anchor!.viewportDelta,
);
expect(splitAnchor).toMatchObject({
kind: "text",
sourceIdentity: identity,
cellOffset: splitFragment!.start,
});
const unifiedAgain = buildDiffDocumentModel(
input({ files: [changedFile], layout: "unified", viewportWidth: 100 }),
);
const finalCell = cellByIdentity(unifiedAgain, identity);
const finalRow = rowForCell(unifiedAgain, finalCell);
const finalFragment = finalCell.fragments.find(
(fragment) =>
splitAnchor?.kind === "text" &&
fragment.start <= splitAnchor.cellOffset &&
splitAnchor.cellOffset < fragment.end,
);
expect(resolveScrollAnchor(unifiedAgain, splitAnchor!)).toBe(
finalRow.top + finalFragment!.top - splitAnchor!.viewportDelta,
);
});
it("uses the exact unified maximum horizontal scroll range", () => {
const model = buildDiffDocumentModel(
input({ files: [fileWithChangedLine("x".repeat(30))], wrapLines: false, viewportWidth: 240 }),
);
expect(model.files[0]?.contentWidth).toBe(344);
expect(model.files[0]!.contentWidth - model.viewportWidth).toBe(104);
});
it("uses the widest split-column overflow once for the shared file offset", () => {
const model = buildDiffDocumentModel(
input({
files: [fileWithChangedLine("x".repeat(30))],
layout: "split",
wrapLines: false,
viewportWidth: 240,
}),
);
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("replaces estimated wrapped heights through reuse and preserves the scroll anchor", () => {
const changed = fileWithChangedLine("x".repeat(100));
const files = [changed, file("later.ts")];
// Deliberately inaccurate M width makes the estimate differ from exact layout.
const measureText = {
measure: (text: string) => (text === "M" ? 5 : Array.from(text).length * 10),
};
const options = input({ files, measureText });
const first = buildDiffDocumentModel({
...options,
materializationWindow: { top: 30, height: 1 },
});
const eager = buildDiffDocumentModel(options);
const exact = buildDiffDocumentModel({ ...options, reuseFrom: [first] });
expect(first.height).not.toBe(eager.height);
expect(exact.rows).toEqual(eager.rows);
expect(exact.height).toBe(eager.height);
expect(firstLineCells(exact, changed.path)).toBe(firstLineCells(first, changed.path));
const scrollTop = first.files[1]!.bodyTop + 3;
const nextScrollTop = resolveRelayoutScrollTop(first, exact, scrollTop);
expect(nextScrollTop).toBe(exact.files[1]!.bodyTop + 3);
expect(captureScrollAnchor(exact, nextScrollTop)).toEqual(
captureScrollAnchor(first, scrollTop),
);
});
it("preserves the wrapped source offset when resize leaves the anchor outside the window", () => {
const source = file();
source.hunks[0]!.lines = Array.from({ length: 200 }, () => ({
type: "add" as const,
content: "x".repeat(200),
}));
const options = input({ files: [source], viewportWidth: 500 });
const before = buildDiffDocumentModel(options);
const scrollTop = before.rows[100]!.top + 36;
const anchor = captureScrollAnchor(before, scrollTop)!;
expect(anchor).toMatchObject({ kind: "text", cellOffset: 90 });
const resizedOptions = { ...options, viewportWidth: 180 };
const estimated = buildDiffDocumentModel({
...resizedOptions,
materializationWindow: {
top: Math.floor(scrollTop / 800) * 800 - 800,
height: 2000,
},
});
const estimatedRow = estimated.rows[100]!;
expect(estimatedRow.kind === "line" && estimatedRow.cells[0]?.fragments).toEqual([]);
const restored = resolveRelayoutScrollTop(before, estimated, scrollTop);
const recaptured = captureScrollAnchor(estimated, restored)!;
expect(recaptured).toMatchObject({ kind: "text", cellOffset: 78 });
const materialized = buildDiffDocumentModel({
...resizedOptions,
materializationWindow: { top: restored - 800, height: 2000 },
reuseFrom: [estimated],
});
const eager = buildDiffDocumentModel(resizedOptions);
const expected = resolveRelayoutScrollTop(before, eager, scrollTop);
expect(expected).toBe(28_938);
expect(Math.abs(restored - expected)).toBeLessThan(before.lineHeight);
expect(resolveRelayoutScrollTop(estimated, materialized, restored)).toBe(expected);
});
it("materializes the relayout anchor even when off-screen height estimates are inaccurate", () => {
const source = file();
source.hunks[0]!.lines = Array.from({ length: 200 }, () => ({
type: "add" as const,
content: "x".repeat(200),
}));
const options = input({ files: [source], viewportWidth: 500 });
const before = buildDiffDocumentModel(options);
const scrollTop = before.rows[100]!.top + 38;
const anchor = captureScrollAnchor(before, scrollTop)!;
const resizedOptions = {
...options,
viewportWidth: 180,
measureText: { measure: (text: string) => (text === "M" ? 5 : text.length * 10) },
};
const resized = buildDiffDocumentModel({
...resizedOptions,
materializationWindow: { top: scrollTop - 800, height: 2000 },
materializationAnchor: anchor,
});
const row = resized.rows[100]!;
expect(row.kind === "line" && row.cells[0]!.fragments.length).toBeGreaterThan(0);
const restored = resolveRelayoutScrollTop(before, resized, scrollTop);
const materialized = buildDiffDocumentModel({
...resizedOptions,
materializationWindow: { top: restored - 800, height: 2000 },
materializationAnchor: captureScrollAnchor(resized, restored),
reuseFrom: [resized],
});
const nextScrollTop = resolveRelayoutScrollTop(resized, materialized, restored);
const nextAnchor = captureScrollAnchor(materialized, nextScrollTop);
// Offset 90 belongs to the fragment beginning at 78 after narrowing.
expect(nextAnchor).toMatchObject({ kind: "text", cellOffset: 78, viewportDelta: -2 });
expect(nextScrollTop).toBe(resolveScrollAnchor(materialized, anchor));
});
it("materializes wrapped text only inside the window", () => {
const model = buildDiffDocumentModel(
input({
files: [file("a.ts"), file("b.ts")],
wrapLines: true,
materializationWindow: { top: FILE_HEADER_HEIGHT, height: 1 },
}),
);
expect(measuredFragments(model, "a.ts")).toBeGreaterThan(0);
expect(measuredFragments(model, "b.ts")).toBe(0);
expect(
model.rows.filter((row) => row.kind === "line" && row.cells.some(hasFragments)),
).toHaveLength(1);
});
});
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,
measureAdvances(graphemes) {
let prefix = "";
return graphemes.map((grapheme) => measure((prefix += grapheme)));
},
};
}
function ligatureWidth(text: string): number {
if (text === "fi") return 12;
if (text === "xfi") return 25;
let width = 0;
for (const character of text) width += { x: 10, f: 8, i: 7 }[character] ?? 0;
return width;
}
function fileWithChangedLine(content: string): ParsedDiffFile {
const changed = file();
const hunk = changed.hunks[0]!;
return {
...changed,
hunks: [
{
oldStart: hunk.oldStart,
oldCount: hunk.oldCount,
newStart: hunk.newStart,
newCount: hunk.newCount,
lines: hunk.lines.map((line) =>
line.type === "add" ? Object.assign({}, line, { content }) : line,
),
},
],
};
}
function fileWithUnevenChanges(): ParsedDiffFile {
return {
path: "src/anchor.ts",
isNew: false,
isDeleted: false,
additions: 3,
deletions: 2,
hunks: [
{
oldStart: 10,
oldCount: 2,
newStart: 20,
newCount: 3,
lines: [
{ type: "header", content: "@@ -10,2 +20,3 @@" },
{ type: "remove", content: "removed first" },
{ type: "remove", content: "x" },
{ type: "add", content: "added first" },
{ type: "add", content: "anchored addition has several wrapped fragments" },
{ type: "add", content: "added third" },
],
},
],
};
}
function addedCell(model: ReturnType<typeof buildDiffDocumentModel>) {
const cell = model.rows
.filter((row) => row.kind === "line")
.flatMap((row) => row.cells)
.find((candidate) => candidate?.type === "add");
if (!cell) throw new Error("Expected an added cell");
return cell;
}
function cellByIdentity(
model: ReturnType<typeof buildDiffDocumentModel>,
identity: { hunkIndex: number; lineIndex: number; side: "old" | "new" },
) {
const cell = model.rows
.filter((row) => row.kind === "line")
.flatMap((row) => row.cells)
.find(
(candidate) =>
candidate?.sourceIdentity.hunkIndex === identity.hunkIndex &&
candidate.sourceIdentity.lineIndex === identity.lineIndex &&
candidate.sourceIdentity.side === identity.side,
);
if (!cell) throw new Error("Expected a cell with the source identity");
return cell;
}
function rowForCell(
model: ReturnType<typeof buildDiffDocumentModel>,
cell: ReturnType<typeof cellByIdentity>,
) {
const row = model.rows.find(
(candidate) => candidate.kind === "line" && candidate.cells.includes(cell),
);
if (!row || row.kind !== "line") throw new Error("Expected the cell row");
return row;
}
function rowForReviewTarget(model: ReturnType<typeof buildDiffDocumentModel>, key: string) {
const row = model.rows.find(
(candidate) =>
candidate.kind === "line" && candidate.cells.some((cell) => cell?.reviewTarget?.key === key),
);
if (!row || row.kind !== "line") throw new Error("Expected the review row");
return row;
}
function reviewActionsWithEditor(
target: NonNullable<ReturnType<typeof addedCell>["reviewTarget"]> | null,
): NonNullable<BuildDiffDocumentModelInput["reviewActions"]> {
return {
commentsByTarget: new Map(),
editor: target ? { target, body: "", commentId: null } : null,
onStartComment() {},
onChangeEditorBody() {},
onCancelEditor() {},
onSaveEditor() {},
onEditComment() {},
onDeleteComment() {},
};
}
function hasFragments(cell: DiffCell | null): boolean {
return cell !== null && cell.fragments.length > 0;
}