mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 02:02:42 +00:00
* perf(app): optimize wrapped measurements and checkpoint native layout experiment Compute full grapheme geometry only for final wrap fragments. Preserve shaped-text search behavior while bounding additive wrap probes. Keep the Android background layout experiment isolated from the normal diff renderer; native renderer integration and iOS bindings remain unfinished. * perf(server): reduce cold diff generation work Keep active diff reads owned across invalidation and wait for the initial watcher inventory before cold generation. Batch patch and blob reads while preserving per-file size isolation, bounded output, and daemon responsiveness. Avoid constructing fallback tokens when full-file highlighting already covers both sides. * test(server): exercise oversized highlighted diff rejection The single-line fixture now bypasses highlighting under the long-line guard, so it no longer reaches the structured payload limit. Use short dense lines to keep testing oversized rejection and continued socket usability. * fix(server): queue fresh diffs for forced reads A forced request following a repository mutation must not reuse an older active build. Invalidate that result and coalesce waiting callers onto one subsequent build. Give the cache a named object input and completed-value type. * test(server): share deferred diff loader setup * test(server): name completed diff cache setup * refactor(server): use aliases for new diff imports * fix(server): keep shipped diff imports resolvable by Node The TypeScript build preserves path aliases without a runtime resolver. Restore relative imports for shipped code and declarations, and correct the server import guideline that prompted the regression.
799 lines
27 KiB
TypeScript
799 lines
27 KiB
TypeScript
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, 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() {},
|
||
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("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,
|
||
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() {},
|
||
onCancelEditor() {},
|
||
onSaveEditor() {},
|
||
onEditComment() {},
|
||
onDeleteComment() {},
|
||
};
|
||
}
|