mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
Speed up cold diffs and wrapped text measurement (#4676)
* 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.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildDiffDocumentModel } from "./model";
|
||||
import { buildDiffDocumentModel, measureFragments } from "./model";
|
||||
import type { BuildDiffDocumentModelInput, TextMeasurer } from "./types";
|
||||
|
||||
/**
|
||||
@@ -92,6 +92,33 @@ function shapedCharactersFor(options: { lineCount: number; lineLength: number })
|
||||
}
|
||||
|
||||
describe("diff document measurement cost", () => {
|
||||
it("keeps additive wrapped search work proportional to long source lines", () => {
|
||||
function measuredGraphemes(length: number): number {
|
||||
let count = 0;
|
||||
measureFragments({
|
||||
text: "a".repeat(length),
|
||||
availableWidth: 200,
|
||||
wrapLines: true,
|
||||
lineHeight: 18,
|
||||
measureText: {
|
||||
measure: (text) => text.length * 10,
|
||||
measureWidth(graphemes) {
|
||||
count += graphemes.length;
|
||||
return graphemes.length * 10;
|
||||
},
|
||||
measureAdvances(graphemes) {
|
||||
count += graphemes.length;
|
||||
return graphemes.map((_, index) => (index + 1) * 10);
|
||||
},
|
||||
},
|
||||
});
|
||||
return count;
|
||||
}
|
||||
const short = measuredGraphemes(100);
|
||||
const long = measuredGraphemes(1600);
|
||||
expect(long / short).toBeLessThan(32);
|
||||
});
|
||||
|
||||
it("shapes a bounded number of characters per character of text", () => {
|
||||
// The shape of the CHANGELOG.md working diff that froze the renderer: 42
|
||||
// lines, longest 597 characters.
|
||||
|
||||
@@ -71,6 +71,78 @@ function input(overrides: Partial<BuildDiffDocumentModelInput> = {}): BuildDiffD
|
||||
}
|
||||
|
||||
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) => ({
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "@/utils/diff-layout";
|
||||
import { compactHighlightTokens } from "@/utils/diff-rendering";
|
||||
import { getInlineReviewThreadState, getSplitInlineReviewThreadState } from "@/review/geometry";
|
||||
import { advancesFor } from "./text-measurement";
|
||||
import { advancesFor, requiresShaping } from "./text-measurement";
|
||||
import type {
|
||||
BuildDiffDocumentModelInput,
|
||||
DiffCell,
|
||||
@@ -571,14 +571,31 @@ export function measureFragments(input: {
|
||||
return [createFragment(graphemes, 0, graphemes.length, 0, input.lineHeight, input.measureText)];
|
||||
}
|
||||
const fragments: DiffFragment[] = [];
|
||||
const displayGraphemes = graphemes.map((grapheme) => grapheme.text);
|
||||
const measureWidth =
|
||||
input.measureText.measureWidth?.bind(input.measureText) ??
|
||||
((slice: readonly string[]) => advancesFor(input.measureText)(slice).at(-1) ?? 0);
|
||||
// Joining/ligatures can make a longer prefix narrower. Preserve the existing
|
||||
// search order for those runs; changing the probes can change their breaks.
|
||||
const canBoundSearch = !requiresShaping(input.text);
|
||||
let graphemeIndex = 0;
|
||||
while (graphemeIndex < graphemes.length) {
|
||||
let low = graphemeIndex + 1;
|
||||
let high = graphemes.length;
|
||||
// A small first probe finishes short lines in one query. Grow it only when
|
||||
// it fits, so narrow wrapping never repeatedly measures a huge line's tail.
|
||||
let high = canBoundSearch ? Math.min(graphemes.length, graphemeIndex + 64) : graphemes.length;
|
||||
let fitting = low;
|
||||
if (canBoundSearch) {
|
||||
while (measureWidth(displayGraphemes.slice(graphemeIndex, high)) <= input.availableWidth) {
|
||||
fitting = high;
|
||||
low = high + 1;
|
||||
if (high === graphemes.length) break;
|
||||
high = Math.min(graphemes.length, graphemeIndex + (high - graphemeIndex) * 2);
|
||||
}
|
||||
}
|
||||
while (low <= high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
const width = measureGraphemeSlice(graphemes, graphemeIndex, middle, input.measureText).width;
|
||||
const width = measureWidth(displayGraphemes.slice(graphemeIndex, middle));
|
||||
if (width <= input.availableWidth || middle === graphemeIndex + 1) {
|
||||
fitting = middle;
|
||||
low = middle + 1;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createCachedAsciiTextMetrics,
|
||||
createChunkedWidthMeasurer,
|
||||
createFallbackAwareTextMeasurer,
|
||||
createMeasuredAdvances,
|
||||
requiresShaping,
|
||||
@@ -56,6 +57,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(metrics.measureWidth(["a", "b", "b", "a"])).toBe(40);
|
||||
expect(measured).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
@@ -105,6 +107,27 @@ describe("chunked advance measurement", () => {
|
||||
return Array.from(text, (_, index) => shapingMeasure(text.slice(0, index + 1)));
|
||||
}
|
||||
|
||||
it.each([
|
||||
"",
|
||||
"a => b ",
|
||||
`${"a".repeat(63)}=>${"b".repeat(70)}`,
|
||||
"=>".repeat(100),
|
||||
"中文 é 👩💻 مرحبا".repeat(10),
|
||||
])("keeps width-only measurement equal to the last shaped advance: %s", (text) => {
|
||||
const graphemes = Array.from(text);
|
||||
const width = createChunkedWidthMeasurer((chunk) => shapingMeasure(chunk.join("")));
|
||||
expect(width(graphemes)).toBe(createMeasuredAdvances(shapingMeasure)(graphemes).at(-1) ?? 0);
|
||||
});
|
||||
|
||||
it("keeps expanded tabs on the same additive width path", () => {
|
||||
const metrics = createCachedAsciiTextMetrics({
|
||||
glyphIds: (text) => Array.from(text, () => 1),
|
||||
measure: (text) => text.length * 3.7,
|
||||
});
|
||||
const graphemes = ["a", " ", "b", " "];
|
||||
expect(metrics.measureWidth(graphemes)).toBe(metrics.measureAdvances(graphemes).at(-1));
|
||||
});
|
||||
|
||||
it("measures short runs in one shaping context", () => {
|
||||
expect(advancesOf("a => b")).toEqual(expected("a => b"));
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface CachedAsciiTextMetrics {
|
||||
measure(text: string): number;
|
||||
hasEveryGlyph(text: string): boolean;
|
||||
measureAdvances(graphemes: readonly string[]): number[];
|
||||
measureWidth(graphemes: readonly string[]): number;
|
||||
}
|
||||
|
||||
const CODE_LIGATURE_CANDIDATE = /(?:===?|!==?|=>|<=|>=|->|::|\+\+|--)/;
|
||||
@@ -68,6 +69,22 @@ export function createChunkedAdvanceMeasurer(
|
||||
};
|
||||
}
|
||||
|
||||
/** Preserve advance chunk boundaries when only the final width is needed. */
|
||||
export function createChunkedWidthMeasurer(
|
||||
measure: (graphemes: readonly string[]) => number,
|
||||
): (graphemes: readonly string[]) => number {
|
||||
return (graphemes) => {
|
||||
let width = 0;
|
||||
let start = 0;
|
||||
while (start < graphemes.length) {
|
||||
const end = chunkEnd(graphemes, start);
|
||||
width += measure(graphemes.slice(start, end));
|
||||
start = end;
|
||||
}
|
||||
return width;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance measurement for a backend that can only measure a whole string, which
|
||||
* is every backend except a native paragraph shaper.
|
||||
@@ -144,6 +161,11 @@ export function createCachedAsciiTextMetrics(primary: PrimaryTextFace): CachedAs
|
||||
return true;
|
||||
},
|
||||
measureAdvances: additiveAdvances(measure),
|
||||
measureWidth(graphemes) {
|
||||
let width = 0;
|
||||
for (const grapheme of graphemes) width += measure(grapheme);
|
||||
return width;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { codeTextColor } from "./palette";
|
||||
import {
|
||||
createCachedAsciiTextMetrics,
|
||||
createChunkedAdvanceMeasurer,
|
||||
createChunkedWidthMeasurer,
|
||||
requiresShaping,
|
||||
type CachedAsciiTextMetrics,
|
||||
} from "./text-measurement";
|
||||
@@ -202,43 +203,40 @@ export function createNativeTextMeasurer(input: {
|
||||
};
|
||||
const asciiMetrics = createCachedAsciiTextMetrics(primary);
|
||||
const black = Skia.Color("black");
|
||||
const measureParagraph = (text: string) => {
|
||||
const paragraph = createParagraph({
|
||||
const shapeParagraph = (text: string) =>
|
||||
createParagraph({
|
||||
text,
|
||||
families,
|
||||
fontSize: input.fontSize,
|
||||
lineHeight: Math.round(input.fontSize * 1.5),
|
||||
color: black,
|
||||
});
|
||||
const width = paragraph.getLongestLine();
|
||||
paragraph.dispose();
|
||||
return width;
|
||||
};
|
||||
return {
|
||||
measure(text) {
|
||||
return requiresRetainedParagraph(text, asciiMetrics)
|
||||
? measureParagraph(text)
|
||||
: asciiMetrics.measure(text);
|
||||
if (!requiresRetainedParagraph(text, asciiMetrics)) return asciiMetrics.measure(text);
|
||||
const paragraph = shapeParagraph(text);
|
||||
const width = paragraph.getLongestLine();
|
||||
paragraph.dispose();
|
||||
return width;
|
||||
},
|
||||
measureWidth: createChunkedWidthMeasurer((graphemes) => {
|
||||
const text = graphemes.join("");
|
||||
if (!requiresRetainedParagraph(text, asciiMetrics))
|
||||
return asciiMetrics.measureWidth(graphemes);
|
||||
const paragraph = shapeParagraph(text);
|
||||
const width = paragraphAdvance(paragraph, text.length);
|
||||
paragraph.dispose();
|
||||
return width;
|
||||
}),
|
||||
measureAdvances: createChunkedAdvanceMeasurer({
|
||||
requiresShaping: (text) => requiresRetainedParagraph(text, asciiMetrics),
|
||||
measureAdditive: (graphemes) => asciiMetrics.measureAdvances(graphemes),
|
||||
measureShaped(graphemes) {
|
||||
const paragraph = createParagraph({
|
||||
text: graphemes.join(""),
|
||||
families,
|
||||
fontSize: input.fontSize,
|
||||
lineHeight: Math.round(input.fontSize * 1.5),
|
||||
color: black,
|
||||
});
|
||||
const paragraph = shapeParagraph(graphemes.join(""));
|
||||
let end = 0;
|
||||
const advances = graphemes.map((grapheme) => {
|
||||
end += grapheme.length;
|
||||
const rectangles = paragraph.getRectsForRange(0, end);
|
||||
return rectangles.reduce(
|
||||
(right, rectangle) => Math.max(right, rectangle.x + rectangle.width),
|
||||
0,
|
||||
);
|
||||
return paragraphAdvance(paragraph, end);
|
||||
});
|
||||
paragraph.dispose();
|
||||
return advances;
|
||||
@@ -247,6 +245,12 @@ export function createNativeTextMeasurer(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function paragraphAdvance(paragraph: SkParagraph, end: number): number {
|
||||
return paragraph
|
||||
.getRectsForRange(0, end)
|
||||
.reduce((right, rectangle) => Math.max(right, rectangle.x + rectangle.width), 0);
|
||||
}
|
||||
|
||||
function requiresRetainedParagraph(text: string, asciiMetrics: CachedAsciiTextMetrics): boolean {
|
||||
return requiresShaping(text) || !asciiMetrics.hasEveryGlyph(text);
|
||||
}
|
||||
|
||||
@@ -167,6 +167,8 @@ export interface DiffDocumentModel {
|
||||
export interface TextMeasurer {
|
||||
measure(text: string, weight?: "regular" | "semibold"): number;
|
||||
measureAdvances?(graphemes: readonly string[]): number[];
|
||||
/** Same width as the last cumulative advance, without computing every position. */
|
||||
measureWidth?(graphemes: readonly string[]): number;
|
||||
}
|
||||
|
||||
export interface BuildDiffDocumentModelInput {
|
||||
|
||||
Reference in New Issue
Block a user