mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
Reduce stalls when opening large diffs (#4574)
* perf(app): reduce avoidable work when opening large diffs Large snapshots were recursively reconstructed for reference stability, while native scroll worklets captured complete file models. Preserve identity at the data producers and prepare text for the visible window without rebuilding unchanged files. * test(app): await commit diff query completion without polling * chore(app): remove profiling artifacts from the source tree
This commit is contained in:
@@ -26,9 +26,10 @@ export function HorizontalScroll({
|
||||
const scrollId = useId();
|
||||
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
|
||||
const edgeRef = useRef(true);
|
||||
const path = file.path;
|
||||
const handler = useAnimatedScrollHandler({
|
||||
onScroll(event) {
|
||||
offsets.value = { ...offsets.value, [file.path]: event.contentOffset.x };
|
||||
offsets.value = { ...offsets.value, [path]: event.contentOffset.x };
|
||||
},
|
||||
});
|
||||
useEffect(() => {
|
||||
|
||||
@@ -71,6 +71,55 @@ function input(overrides: Partial<BuildDiffDocumentModelInput> = {}): BuildDiffD
|
||||
}
|
||||
|
||||
describe("diff document model", () => {
|
||||
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);
|
||||
|
||||
@@ -157,21 +157,26 @@ function appendReusableFileRows(candidate: {
|
||||
!candidate.input.wrapLines &&
|
||||
candidate.model.reviewGeometryKey === candidate.reviewGeometryKey &&
|
||||
candidate.file.top === candidate.fileTop &&
|
||||
candidate.file.fileIndex === candidate.fileIndex &&
|
||||
candidate.file.rowStart === candidate.rows.length;
|
||||
if (retainsRowGeometry) {
|
||||
candidate.rows.push(...reusedRows);
|
||||
return {
|
||||
bottom: candidate.bodyTop + candidate.file.bodyHeight,
|
||||
maximumHorizontalOverflow: Math.max(
|
||||
0,
|
||||
candidate.file.contentWidth - candidate.input.viewportWidth,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
let top = candidate.bodyTop;
|
||||
for (const reusedRow of reusedRows) {
|
||||
const nextRow = repositionReusedRow(reusedRow, candidate, top);
|
||||
let nextRow = retainsRowGeometry ? reusedRow : repositionReusedRow(reusedRow, candidate, top);
|
||||
if (
|
||||
nextRow.kind === "line" &&
|
||||
intersectsMaterializationWindow(candidate.input, top, top + nextRow.height) &&
|
||||
nextRow.cells.some((cell) => cell && cell.fragments.length === 0)
|
||||
) {
|
||||
nextRow = {
|
||||
...nextRow,
|
||||
cells: materializeCells(
|
||||
nextRow.cells,
|
||||
candidate.file.file,
|
||||
candidate.file.gutterWidth,
|
||||
candidate.input,
|
||||
),
|
||||
};
|
||||
}
|
||||
candidate.rows.push(nextRow);
|
||||
top += nextRow.height;
|
||||
}
|
||||
@@ -232,71 +237,45 @@ function appendNewFileRows(candidate: {
|
||||
};
|
||||
}
|
||||
|
||||
const deferredLines =
|
||||
candidate.input.materializationWindow && !candidate.input.wrapLines
|
||||
? geometryLines(lineSources(candidate.file, candidate.input.layout, false), candidate.input)
|
||||
: null;
|
||||
const deferredBodyHeight = deferredLines?.reduce((height, line) => height + line.height, 0) ?? 0;
|
||||
const shouldMaterialize =
|
||||
deferredLines === null ||
|
||||
intersectsMaterializationWindow(
|
||||
candidate.input,
|
||||
candidate.fileTop,
|
||||
candidate.bodyTop + deferredBodyHeight + DIFF_BODY_BORDER_HEIGHT,
|
||||
);
|
||||
return shouldMaterialize
|
||||
? appendMeasuredFileRows(candidate)
|
||||
: appendGeometryFileRows(candidate, deferredLines);
|
||||
}
|
||||
|
||||
function appendGeometryFileRows(
|
||||
candidate: Parameters<typeof appendNewFileRows>[0],
|
||||
lines: readonly GeometryLine[],
|
||||
): FileRowsResult {
|
||||
let top = candidate.bodyTop;
|
||||
for (const line of lines) {
|
||||
candidate.rows.push({
|
||||
kind: "line",
|
||||
index: candidate.rows.length,
|
||||
fileIndex: candidate.fileIndex,
|
||||
path: candidate.file.path,
|
||||
top,
|
||||
height: line.height,
|
||||
cells: line.cells,
|
||||
reviewHeight: line.reviewHeight,
|
||||
});
|
||||
top += line.height;
|
||||
}
|
||||
return { bottom: top + DIFF_BODY_BORDER_HEIGHT, maximumHorizontalOverflow: 0 };
|
||||
}
|
||||
|
||||
function appendMeasuredFileRows(
|
||||
candidate: Parameters<typeof appendNewFileRows>[0],
|
||||
): FileRowsResult {
|
||||
const lines = geometryLines(
|
||||
lineSources(candidate.file, candidate.input.layout, false),
|
||||
candidate.input,
|
||||
);
|
||||
const fileBottom =
|
||||
candidate.bodyTop +
|
||||
lines.reduce((height, line) => height + line.height, 0) +
|
||||
DIFF_BODY_BORDER_HEIGHT;
|
||||
const measureWidth =
|
||||
!candidate.input.wrapLines &&
|
||||
intersectsMaterializationWindow(candidate.input, candidate.fileTop, fileBottom);
|
||||
let top = candidate.bodyTop;
|
||||
let maximumHorizontalOverflow = 0;
|
||||
for (const sourceCells of lineSources(candidate.file, candidate.input.layout, true)) {
|
||||
const columnCount = sourceCells.length;
|
||||
const columnWidth = candidate.input.viewportWidth / columnCount;
|
||||
const availableWidth = Math.max(
|
||||
candidate.input.measureText.measure("M"),
|
||||
columnWidth - candidate.gutterWidth - CODE_HORIZONTAL_PADDING,
|
||||
);
|
||||
const cells = sourceCells.map((source) => {
|
||||
if (!source) return null;
|
||||
const cell = measureCell({ source, availableWidth, input: candidate.input });
|
||||
maximumHorizontalOverflow = Math.max(
|
||||
maximumHorizontalOverflow,
|
||||
candidate.gutterWidth + CODE_HORIZONTAL_PADDING + measureCellWidth(cell) - columnWidth,
|
||||
);
|
||||
return cell;
|
||||
}) as DiffLineRow["cells"];
|
||||
for (const line of lines) {
|
||||
const shouldMaterialize =
|
||||
candidate.input.wrapLines ||
|
||||
intersectsMaterializationWindow(candidate.input, top, top + line.height);
|
||||
const cells = shouldMaterialize
|
||||
? materializeCells(line.cells, candidate.file, candidate.gutterWidth, candidate.input)
|
||||
: line.cells;
|
||||
const columnWidth = candidate.input.viewportWidth / cells.length;
|
||||
if (measureWidth) {
|
||||
for (const cell of cells) {
|
||||
if (!cell) continue;
|
||||
const width =
|
||||
cell.fragments.length > 0
|
||||
? measureCellWidth(cell)
|
||||
: candidate.input.measureText.measure(displayLineText(cell.content));
|
||||
maximumHorizontalOverflow = Math.max(
|
||||
maximumHorizontalOverflow,
|
||||
candidate.gutterWidth + CODE_HORIZONTAL_PADDING + width - columnWidth,
|
||||
);
|
||||
}
|
||||
}
|
||||
const textHeight = Math.max(
|
||||
candidate.input.typography.lineHeight,
|
||||
...cells.map((cell) => (cell?.fragments.length ?? 1) * candidate.input.typography.lineHeight),
|
||||
);
|
||||
const reviewHeight = reviewHeightForCells(cells, candidate.input);
|
||||
const height = textHeight + reviewHeight;
|
||||
const height = textHeight + line.reviewHeight;
|
||||
candidate.rows.push({
|
||||
kind: "line",
|
||||
index: candidate.rows.length,
|
||||
@@ -305,13 +284,46 @@ function appendMeasuredFileRows(
|
||||
top,
|
||||
height,
|
||||
cells,
|
||||
reviewHeight,
|
||||
reviewHeight: line.reviewHeight,
|
||||
});
|
||||
top += height;
|
||||
}
|
||||
return { bottom: top + DIFF_BODY_BORDER_HEIGHT, maximumHorizontalOverflow };
|
||||
}
|
||||
|
||||
function materializeCells(
|
||||
cells: DiffLineRow["cells"],
|
||||
file: BuildDiffDocumentModelInput["files"][number],
|
||||
gutterWidth: number,
|
||||
input: BuildDiffDocumentModelInput,
|
||||
): DiffLineRow["cells"] {
|
||||
const availableWidth = Math.max(
|
||||
input.measureText.measure("M"),
|
||||
input.viewportWidth / cells.length - gutterWidth - CODE_HORIZONTAL_PADDING,
|
||||
);
|
||||
return cells.map((cell) => {
|
||||
if (!cell || cell.fragments.length > 0) return cell;
|
||||
const sourceLine =
|
||||
file.hunks[cell.sourceIdentity.hunkIndex]!.lines[cell.sourceIdentity.lineIndex]!;
|
||||
return measureCell({
|
||||
source: {
|
||||
...cell,
|
||||
tokenText: cell.type === "header" ? [] : compactHighlightTokens(sourceLine.tokens ?? []),
|
||||
},
|
||||
availableWidth,
|
||||
input,
|
||||
});
|
||||
}) as DiffLineRow["cells"];
|
||||
}
|
||||
|
||||
function displayLineText(text: string): string {
|
||||
return text.includes("\t")
|
||||
? segmentGraphemes(text)
|
||||
.map((segment) => segment.text)
|
||||
.join("")
|
||||
: text;
|
||||
}
|
||||
|
||||
export function reviewGeometryKey(
|
||||
reviewActions: BuildDiffDocumentModelInput["reviewActions"],
|
||||
): string {
|
||||
@@ -334,7 +346,11 @@ export function retainReusableModels(
|
||||
previous: readonly DiffDocumentModel[] | undefined,
|
||||
next: DiffDocumentModel,
|
||||
): DiffDocumentModel[] {
|
||||
const fullest = [...(previous ?? []), next].reduce((best, candidate) =>
|
||||
const sources = new Set(next.files.map((file) => file.file));
|
||||
const compatible = previous?.filter((model) =>
|
||||
model.files.every((file) => sources.has(file.file)),
|
||||
);
|
||||
const fullest = [...(compatible ?? []), next].reduce((best, candidate) =>
|
||||
measuredFileCount(candidate) > measuredFileCount(best) ? candidate : best,
|
||||
);
|
||||
return fullest === next ? [next] : [fullest, next];
|
||||
@@ -437,16 +453,17 @@ function intersectsMaterializationWindow(
|
||||
|
||||
function hasMeasuredFileRows(model: DiffDocumentModel, file: DiffFileSection): boolean {
|
||||
if (file.isCollapsed) return false;
|
||||
return model.rows.slice(file.rowStart, file.rowEnd).every((row) => {
|
||||
return model.rows.slice(file.rowStart, file.rowEnd).some((row) => {
|
||||
if (row.kind !== "line") return true;
|
||||
return row.cells.every((cell) => !cell || cell.fragments.length > 0);
|
||||
return row.cells.some((cell) => cell && cell.fragments.length > 0);
|
||||
});
|
||||
}
|
||||
|
||||
function indexReusableModels(
|
||||
models: readonly DiffDocumentModel[] | undefined,
|
||||
): ReusableModelIndex[] {
|
||||
return (models ?? []).map((model) => {
|
||||
// The latest model includes text prepared by later scroll windows.
|
||||
return (models ?? []).toReversed().map((model) => {
|
||||
const filesBySource = new Map<BuildDiffDocumentModelInput["files"][number], DiffFileSection>();
|
||||
const measuredFiles = new Set<DiffFileSection>();
|
||||
for (const file of model.files) {
|
||||
|
||||
@@ -110,7 +110,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
const reviewActions = props.mode.kind === "working" ? props.mode.reviewActions : undefined;
|
||||
const model = useMemo(() => {
|
||||
const dependencies = [
|
||||
props.files,
|
||||
props.displayPreferences.layout,
|
||||
props.displayPreferences.wrapLines,
|
||||
viewport.width,
|
||||
@@ -172,8 +171,13 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
[family, props.palette, typography.lineHeight, typography.size],
|
||||
);
|
||||
const textLayout = useMemo(
|
||||
() => prepareNativeTextLayout(textLayoutStore, model),
|
||||
[model, textLayoutStore],
|
||||
() =>
|
||||
prepareNativeTextLayout(
|
||||
textLayoutStore,
|
||||
model,
|
||||
diffMaterializationWindow(fileWindowTop, viewport.height),
|
||||
),
|
||||
[model, textLayoutStore, fileWindowTop, viewport.height],
|
||||
);
|
||||
useEffect(() => () => disposeNativeTextLayout(textLayoutStore), [textLayoutStore]);
|
||||
const headerTextLayout = useMemo(
|
||||
@@ -386,11 +390,12 @@ function NativeCanvasFileHeader({
|
||||
() => recordNativeHeaderPicture({ file, viewportWidth, textLayout, paints }),
|
||||
[file, paints, textLayout, viewportWidth],
|
||||
);
|
||||
const { top, bottom, headerHeight } = file;
|
||||
const stickyStyle = useAnimatedStyle(() => {
|
||||
const pinOffset = Math.max(0, scrollTop.value - file.top);
|
||||
const maximumPinOffset = Math.max(0, file.bottom - file.headerHeight - file.top);
|
||||
const pinOffset = Math.max(0, scrollTop.value - top);
|
||||
const maximumPinOffset = Math.max(0, bottom - headerHeight - top);
|
||||
return { transform: [{ translateY: Math.min(pinOffset, maximumPinOffset) }] };
|
||||
}, [file.bottom, file.headerHeight, file.top, scrollTop]);
|
||||
}, [bottom, headerHeight, top, scrollTop]);
|
||||
const style = useMemo<ViewStyle>(
|
||||
() => ({
|
||||
position: "absolute",
|
||||
|
||||
@@ -55,6 +55,7 @@ describe("cached ASCII text metrics", () => {
|
||||
|
||||
expect(metrics.measureAdvances(["a", "b", "a"])).toEqual([10, 20, 30]);
|
||||
expect(metrics.measureAdvances(["b", "a"])).toEqual([10, 20]);
|
||||
expect(metrics.measure("abba")).toBe(40);
|
||||
expect(measured).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface PrimaryTextFace {
|
||||
}
|
||||
|
||||
export interface CachedAsciiTextMetrics {
|
||||
/** Whole-line width after checking glyph coverage and excluding shaping. */
|
||||
measure(text: string): number;
|
||||
hasEveryGlyph(text: string): boolean;
|
||||
measureAdvances(graphemes: readonly string[]): number[];
|
||||
}
|
||||
@@ -97,24 +99,39 @@ export function advancesFor(measurer: TextMeasurer): (graphemes: readonly string
|
||||
export function createAdditiveAdvances(
|
||||
measure: (text: string) => number,
|
||||
): (graphemes: readonly string[]) => number[] {
|
||||
return additiveAdvances(cachedWidths(measure));
|
||||
}
|
||||
|
||||
function cachedWidths(measure: (text: string) => number): (text: string) => number {
|
||||
const widths = new Map<string, number>();
|
||||
return (text) => {
|
||||
let width = widths.get(text);
|
||||
if (width === undefined) {
|
||||
width = measure(text);
|
||||
widths.set(text, width);
|
||||
}
|
||||
return width;
|
||||
};
|
||||
}
|
||||
|
||||
function additiveAdvances(
|
||||
measure: (text: string) => number,
|
||||
): (graphemes: readonly string[]) => number[] {
|
||||
return (graphemes) => {
|
||||
let advance = 0;
|
||||
return graphemes.map((grapheme) => {
|
||||
let width = widths.get(grapheme);
|
||||
if (width === undefined) {
|
||||
width = measure(grapheme);
|
||||
widths.set(grapheme, width);
|
||||
}
|
||||
advance += width;
|
||||
return advance;
|
||||
});
|
||||
return graphemes.map((grapheme) => (advance += measure(grapheme)));
|
||||
};
|
||||
}
|
||||
|
||||
export function createCachedAsciiTextMetrics(primary: PrimaryTextFace): CachedAsciiTextMetrics {
|
||||
const glyphCoverage = new Map<string, boolean>();
|
||||
const measure = cachedWidths((text) => primary.measure(text));
|
||||
return {
|
||||
measure(text) {
|
||||
let width = 0;
|
||||
for (const character of text) width += measure(character);
|
||||
return width;
|
||||
},
|
||||
hasEveryGlyph(text) {
|
||||
for (const character of text) {
|
||||
let hasGlyph = glyphCoverage.get(character);
|
||||
@@ -126,7 +143,7 @@ export function createCachedAsciiTextMetrics(primary: PrimaryTextFace): CachedAs
|
||||
}
|
||||
return true;
|
||||
},
|
||||
measureAdvances: createAdditiveAdvances((text) => primary.measure(text)),
|
||||
measureAdvances: additiveAdvances(measure),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,11 @@ import {
|
||||
type SkFont,
|
||||
type SkParagraph,
|
||||
} from "@shopify/react-native-skia";
|
||||
import { fragmentTextForRange } from "./model";
|
||||
import { fragmentTextForRange, visibleRowRange } from "./model";
|
||||
import { codeTextColor } from "./palette";
|
||||
import {
|
||||
createCachedAsciiTextMetrics,
|
||||
createChunkedAdvanceMeasurer,
|
||||
createFallbackAwareTextMeasurer,
|
||||
requiresShaping,
|
||||
type CachedAsciiTextMetrics,
|
||||
} from "./text-measurement";
|
||||
@@ -86,7 +85,8 @@ export function shapeNativeHeaderText(input: {
|
||||
export interface NativeTextLayoutStore {
|
||||
font: SkFont;
|
||||
asciiMetrics: CachedAsciiTextMetrics;
|
||||
paragraphsByCell: WeakMap<DiffCell, Array<SkParagraph | null>>;
|
||||
paragraphsByCell: Map<DiffCell, Array<SkParagraph | null>>;
|
||||
textStyles: Map<string, ReturnType<typeof textStyle>>;
|
||||
ownedParagraphs: Set<SkParagraph>;
|
||||
families: string[];
|
||||
fontSize: number;
|
||||
@@ -97,6 +97,8 @@ export interface NativeTextLayoutStore {
|
||||
export function disposeNativeTextLayout(layout: NativeTextLayoutStore): void {
|
||||
for (const paragraph of layout.ownedParagraphs) paragraph.dispose();
|
||||
layout.ownedParagraphs.clear();
|
||||
layout.paragraphsByCell.clear();
|
||||
layout.textStyles.clear();
|
||||
layout.font.dispose();
|
||||
}
|
||||
|
||||
@@ -114,7 +116,8 @@ export function createNativeTextLayoutStore(input: {
|
||||
glyphIds: (text) => font.getGlyphIDs(text),
|
||||
measure: (text) => font.getTextWidth(text),
|
||||
}),
|
||||
paragraphsByCell: new WeakMap(),
|
||||
paragraphsByCell: new Map(),
|
||||
textStyles: new Map(),
|
||||
ownedParagraphs: new Set(),
|
||||
families,
|
||||
fontSize: input.fontSize,
|
||||
@@ -126,30 +129,64 @@ export function createNativeTextLayoutStore(input: {
|
||||
export function prepareNativeTextLayout(
|
||||
store: NativeTextLayoutStore,
|
||||
model: DiffDocumentModel,
|
||||
window?: { top: number; height: number },
|
||||
): NativeTextLayout {
|
||||
const paragraphs = model.rows.map((row) => {
|
||||
if (row.kind !== "line") return [];
|
||||
return row.cells.map((cell) => {
|
||||
const range = window
|
||||
? visibleRowRange(model.rows, window.top, window.height)
|
||||
: { start: 0, end: model.rows.length };
|
||||
const retainedCells = new Set<DiffCell>();
|
||||
const retainedParagraphs = new Set<SkParagraph>();
|
||||
const paragraphs: NativeTextLayout["paragraphs"] = [];
|
||||
paragraphs.length = model.rows.length;
|
||||
for (let index = range.start; index < range.end; index++) {
|
||||
const row = model.rows[index]!;
|
||||
if (row.kind !== "line") continue;
|
||||
paragraphs[index] = row.cells.map((cell) => {
|
||||
if (!cell) return [];
|
||||
retainedCells.add(cell);
|
||||
const cached = store.paragraphsByCell.get(cell);
|
||||
if (cached) return cached;
|
||||
const cellParagraphs = cell.fragments.map((fragment) => {
|
||||
if (!requiresRetainedParagraph(fragment.text, store.asciiMetrics)) return null;
|
||||
const paragraph = createFragmentParagraph({
|
||||
cell,
|
||||
fragment,
|
||||
families: store.families,
|
||||
fontSize: store.fontSize,
|
||||
lineHeight: store.lineHeight,
|
||||
palette: store.palette,
|
||||
});
|
||||
const start = window ? Math.max(0, Math.floor((window.top - row.top) / model.lineHeight)) : 0;
|
||||
const end = window
|
||||
? Math.min(
|
||||
cell.fragments.length,
|
||||
Math.ceil((window.top + window.height - row.top) / model.lineHeight),
|
||||
)
|
||||
: cell.fragments.length;
|
||||
const cellParagraphs: Array<SkParagraph | null> = [];
|
||||
cellParagraphs.length = cell.fragments.length;
|
||||
for (let fragmentIndex = start; fragmentIndex < end; fragmentIndex++) {
|
||||
const fragment = cell.fragments[fragmentIndex]!;
|
||||
if (!requiresRetainedParagraph(fragment.text, store.asciiMetrics)) {
|
||||
cellParagraphs[fragmentIndex] = null;
|
||||
continue;
|
||||
}
|
||||
const paragraph =
|
||||
cached?.[fragmentIndex] ??
|
||||
createFragmentParagraph({
|
||||
cell,
|
||||
fragment,
|
||||
families: store.families,
|
||||
fontSize: store.fontSize,
|
||||
lineHeight: store.lineHeight,
|
||||
palette: store.palette,
|
||||
textStyles: store.textStyles,
|
||||
});
|
||||
store.ownedParagraphs.add(paragraph);
|
||||
return paragraph;
|
||||
});
|
||||
retainedParagraphs.add(paragraph);
|
||||
cellParagraphs[fragmentIndex] = paragraph;
|
||||
}
|
||||
store.paragraphsByCell.set(cell, cellParagraphs);
|
||||
return cellParagraphs;
|
||||
});
|
||||
});
|
||||
}
|
||||
for (const paragraph of store.ownedParagraphs) {
|
||||
if (retainedParagraphs.has(paragraph)) continue;
|
||||
paragraph.dispose();
|
||||
store.ownedParagraphs.delete(paragraph);
|
||||
}
|
||||
for (const cell of store.paragraphsByCell.keys()) {
|
||||
if (!retainedCells.has(cell)) store.paragraphsByCell.delete(cell);
|
||||
}
|
||||
return { font: store.font, paragraphs };
|
||||
}
|
||||
|
||||
@@ -164,23 +201,25 @@ export function createNativeTextMeasurer(input: {
|
||||
measure: (text: string) => font.getTextWidth(text),
|
||||
};
|
||||
const asciiMetrics = createCachedAsciiTextMetrics(primary);
|
||||
const fallbackAware = createFallbackAwareTextMeasurer({
|
||||
primary,
|
||||
measureWithSystemFallback(text) {
|
||||
const paragraph = createParagraph({
|
||||
text,
|
||||
families,
|
||||
fontSize: input.fontSize,
|
||||
lineHeight: Math.round(input.fontSize * 1.5),
|
||||
color: Skia.Color("black"),
|
||||
});
|
||||
const width = paragraph.getLongestLine();
|
||||
paragraph.dispose();
|
||||
return width;
|
||||
},
|
||||
});
|
||||
const black = Skia.Color("black");
|
||||
const measureParagraph = (text: string) => {
|
||||
const paragraph = createParagraph({
|
||||
text,
|
||||
families,
|
||||
fontSize: input.fontSize,
|
||||
lineHeight: Math.round(input.fontSize * 1.5),
|
||||
color: black,
|
||||
});
|
||||
const width = paragraph.getLongestLine();
|
||||
paragraph.dispose();
|
||||
return width;
|
||||
};
|
||||
return {
|
||||
...fallbackAware,
|
||||
measure(text) {
|
||||
return requiresRetainedParagraph(text, asciiMetrics)
|
||||
? measureParagraph(text)
|
||||
: asciiMetrics.measure(text);
|
||||
},
|
||||
measureAdvances: createChunkedAdvanceMeasurer({
|
||||
requiresShaping: (text) => requiresRetainedParagraph(text, asciiMetrics),
|
||||
measureAdditive: (graphemes) => asciiMetrics.measureAdvances(graphemes),
|
||||
@@ -190,7 +229,7 @@ export function createNativeTextMeasurer(input: {
|
||||
families,
|
||||
fontSize: input.fontSize,
|
||||
lineHeight: Math.round(input.fontSize * 1.5),
|
||||
color: Skia.Color("black"),
|
||||
color: black,
|
||||
});
|
||||
let end = 0;
|
||||
const advances = graphemes.map((grapheme) => {
|
||||
@@ -219,23 +258,21 @@ function createFragmentParagraph(input: {
|
||||
fontSize: number;
|
||||
lineHeight: number;
|
||||
palette: DiffPalette;
|
||||
textStyles: NativeTextLayoutStore["textStyles"];
|
||||
}): SkParagraph {
|
||||
const builder = Skia.ParagraphBuilder.Make(
|
||||
paragraphStyle(input.families, input.fontSize, input.lineHeight),
|
||||
);
|
||||
if (input.cell.tokens.length === 0 || input.cell.type === "header") {
|
||||
const color = codeTextColor(input.cell, input.palette);
|
||||
builder
|
||||
.pushStyle(textStyle(input.families, input.fontSize, color))
|
||||
.addText(input.fragment.text)
|
||||
.pop();
|
||||
builder.pushStyle(retainedTextStyle(input, color)).addText(input.fragment.text).pop();
|
||||
} else {
|
||||
for (const run of input.cell.tokens) {
|
||||
const start = Math.max(input.fragment.start, run.start);
|
||||
const end = Math.min(input.fragment.end, run.end);
|
||||
if (end <= start) continue;
|
||||
builder
|
||||
.pushStyle(textStyle(input.families, input.fontSize, run.color))
|
||||
.pushStyle(retainedTextStyle(input, run.color))
|
||||
.addText(fragmentTextForRange(input.fragment, start, end))
|
||||
.pop();
|
||||
}
|
||||
@@ -281,6 +318,18 @@ function paragraphStyle(families: string[], fontSize: number, lineHeight: number
|
||||
};
|
||||
}
|
||||
|
||||
function retainedTextStyle(
|
||||
input: { families: string[]; fontSize: number; textStyles: NativeTextLayoutStore["textStyles"] },
|
||||
color: string,
|
||||
) {
|
||||
let style = input.textStyles.get(color);
|
||||
if (!style) {
|
||||
style = textStyle(input.families, input.fontSize, color);
|
||||
input.textStyles.set(color, style);
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
function textStyle(families: string[], fontSize: number, color: string) {
|
||||
return { fontFamilies: families, fontSize, color: Skia.Color(color) };
|
||||
}
|
||||
|
||||
@@ -78,6 +78,40 @@ function modelInput(
|
||||
}
|
||||
|
||||
describe("diff document workspace cache", () => {
|
||||
it("keeps text prepared in each visited window of the same file", () => {
|
||||
const cache = createDiffDocumentWorkspaceCache();
|
||||
const source = diffFile();
|
||||
source.hunks[0]!.lines = Array.from({ length: 100 }, (_, index) => ({
|
||||
type: "add",
|
||||
content: `const value${index} = ${index};`,
|
||||
}));
|
||||
const { measureText, stats } = countingMeasurer();
|
||||
const input = modelInput([source], measureText);
|
||||
for (const top of [30, 300, 600]) {
|
||||
cache.buildModel({ ...input, materializationWindow: { top, height: 54 } });
|
||||
}
|
||||
stats.calls = 0;
|
||||
cache.buildModel({ ...input, materializationWindow: { top: 300, height: 54 } });
|
||||
expect(stats.calls).toBe(0);
|
||||
});
|
||||
|
||||
it("retains text cells for unchanged files when another file changes", () => {
|
||||
const cache = createDiffDocumentWorkspaceCache();
|
||||
const { measureText } = countingMeasurer();
|
||||
const files = [diffFile(), { ...diffFile(), path: "b.ts" }];
|
||||
const first = cache.buildModel(modelInput(files, measureText));
|
||||
const replacement = structuredClone(files[0]!);
|
||||
replacement.hunks[0]!.lines[0]!.content = "changed text";
|
||||
const next = cache.buildModel(modelInput([replacement, files[1]!], measureText));
|
||||
const oldRow = first.rows[first.files[1]!.rowStart]!;
|
||||
const nextRow = next.rows[next.files[1]!.rowStart]!;
|
||||
expect(oldRow.kind).toBe("line");
|
||||
expect(nextRow.kind).toBe("line");
|
||||
if (oldRow.kind !== "line" || nextRow.kind !== "line") throw new Error("Missing file text");
|
||||
expect(nextRow.cells).toBe(oldRow.cells);
|
||||
expect(next.files[0]!.file).toBe(replacement);
|
||||
});
|
||||
|
||||
it("reuses measured rows until a model-building input changes", () => {
|
||||
const cache = createDiffDocumentWorkspaceCache();
|
||||
const files = [diffFile()];
|
||||
@@ -115,7 +149,7 @@ describe("diff document workspace cache", () => {
|
||||
|
||||
stats.calls = 0;
|
||||
cache.buildModel({ ...input, files: [...files] });
|
||||
expect(stats.calls).toBeGreaterThan(0);
|
||||
expect(stats.calls).toBe(0);
|
||||
});
|
||||
|
||||
it("bounds retained geometry variants for one diff payload", () => {
|
||||
@@ -152,7 +186,7 @@ describe("diff document workspace cache", () => {
|
||||
const files = [diffFile(), { ...diffFile(), path: "src/b.ts" }];
|
||||
const { measureText, stats } = countingMeasurer();
|
||||
const first = cache.buildModel(
|
||||
modelInput(files, measureText, { materializationWindow: { top: 0, height: 20 } }),
|
||||
modelInput(files, measureText, { materializationWindow: { top: 30, height: 20 } }),
|
||||
);
|
||||
const firstCalls = stats.calls;
|
||||
const secondFile = first.files[1]!;
|
||||
|
||||
@@ -13,6 +13,7 @@ const MAX_MODEL_VARIANTS = 4;
|
||||
const MAX_TYPOGRAPHY_RESOURCES = 4;
|
||||
|
||||
interface ModelVariant {
|
||||
measureText: TextMeasurer;
|
||||
key: string;
|
||||
exactKey: string;
|
||||
model: DiffDocumentModel;
|
||||
@@ -38,23 +39,29 @@ export interface DiffDocumentWorkspaceCache {
|
||||
}
|
||||
|
||||
export function createDiffDocumentWorkspaceCache(): DiffDocumentWorkspaceCache {
|
||||
const variantsByFiles = new WeakMap<BuildDiffDocumentModelInput["files"], ModelVariant[]>();
|
||||
let variants: ModelVariant[] = [];
|
||||
const typographyResources = new Map<string, DiffTypographyResource>();
|
||||
|
||||
return {
|
||||
buildModel(input) {
|
||||
const key = modelVariantKey(input);
|
||||
const exactKey = exactModelKey(input);
|
||||
const variants = variantsByFiles.get(input.files) ?? [];
|
||||
const variantIndex = variants.findIndex((candidate) => candidate.key === key);
|
||||
const variantIndex = variants.findIndex(
|
||||
(candidate) => candidate.key === key && candidate.measureText === input.measureText,
|
||||
);
|
||||
const variant = variantIndex === -1 ? undefined : variants[variantIndex];
|
||||
if (variant?.exactKey === exactKey) {
|
||||
if (
|
||||
variant?.exactKey === exactKey &&
|
||||
variant.model.files.length === input.files.length &&
|
||||
variant.model.files.every((file, index) => file.file === input.files[index])
|
||||
) {
|
||||
variants.splice(variantIndex, 1);
|
||||
variants.unshift(variant);
|
||||
return variant.model;
|
||||
}
|
||||
const model = buildDiffDocumentModel({ ...input, reuseFrom: variant?.models });
|
||||
const nextVariant = {
|
||||
measureText: input.measureText,
|
||||
key,
|
||||
exactKey,
|
||||
model,
|
||||
@@ -62,7 +69,7 @@ export function createDiffDocumentWorkspaceCache(): DiffDocumentWorkspaceCache {
|
||||
};
|
||||
if (variantIndex !== -1) variants.splice(variantIndex, 1);
|
||||
variants.unshift(nextVariant);
|
||||
variantsByFiles.set(input.files, variants.slice(0, MAX_MODEL_VARIANTS));
|
||||
variants = variants.slice(0, MAX_MODEL_VARIANTS);
|
||||
return model;
|
||||
},
|
||||
typography(input) {
|
||||
|
||||
Reference in New Issue
Block a user