mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
* 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.
968 lines
30 KiB
TypeScript
968 lines
30 KiB
TypeScript
import { lineNumberGutterWidth } from "@/components/code-insets";
|
|
import { findClusterBreak } from "@marijn/find-cluster-break";
|
|
import {
|
|
buildSplitDiffRows,
|
|
buildUnifiedDiffLines,
|
|
type ReviewableDiffTarget,
|
|
type SplitDiffDisplayLine,
|
|
} from "@/utils/diff-layout";
|
|
import { compactHighlightTokens } from "@/utils/diff-rendering";
|
|
import { getInlineReviewThreadState, getSplitInlineReviewThreadState } from "@/review/geometry";
|
|
import { advancesFor, requiresShaping } from "./text-measurement";
|
|
import type {
|
|
BuildDiffDocumentModelInput,
|
|
DiffCell,
|
|
DiffDocumentModel,
|
|
DiffFileSection,
|
|
DiffFragment,
|
|
DiffGrapheme,
|
|
DiffLineRow,
|
|
DiffRow,
|
|
DiffScrollAnchor,
|
|
DiffSourceIdentity,
|
|
DiffTokenRun,
|
|
TextMeasurer,
|
|
} from "./types";
|
|
|
|
export const FILE_HEADER_HEIGHT = 30;
|
|
export const DIFF_BODY_BORDER_HEIGHT = 1;
|
|
const CODE_HORIZONTAL_PADDING = 16;
|
|
|
|
interface CellSource {
|
|
type: DiffCell["type"];
|
|
content: string;
|
|
lineNumber: number | null;
|
|
reviewTarget: ReviewableDiffTarget | null;
|
|
tokenText: readonly { text: string; style?: string | null }[];
|
|
sourceIdentity: DiffSourceIdentity;
|
|
}
|
|
|
|
interface GeometryLine {
|
|
cells: DiffLineRow["cells"];
|
|
reviewHeight: number;
|
|
height: number;
|
|
}
|
|
|
|
interface ReusableModelIndex {
|
|
model: DiffDocumentModel;
|
|
filesBySource: Map<BuildDiffDocumentModelInput["files"][number], DiffFileSection>;
|
|
measuredFiles: Set<DiffFileSection>;
|
|
}
|
|
|
|
interface ReusableFileRows {
|
|
model: DiffDocumentModel;
|
|
file: DiffFileSection;
|
|
}
|
|
|
|
interface FileRowsResult {
|
|
bottom: number;
|
|
maximumHorizontalOverflow: number;
|
|
}
|
|
|
|
export function buildDiffDocumentModel(input: BuildDiffDocumentModelInput): DiffDocumentModel {
|
|
const rows: DiffRow[] = [];
|
|
const files: DiffFileSection[] = [];
|
|
const reusableModels = indexReusableModels(input.reuseFrom);
|
|
const currentReviewGeometryKey = reviewGeometryKey(input.reviewActions);
|
|
let documentTop = 0;
|
|
|
|
for (const [fileIndex, file] of input.files.entries()) {
|
|
const fileTop = documentTop;
|
|
const isCollapsed = input.collapsedFilePaths.has(file.path);
|
|
documentTop += FILE_HEADER_HEIGHT;
|
|
const bodyTop = documentTop;
|
|
const rowStart = rows.length;
|
|
const gutterWidth = lineNumberGutterWidth(maximumLineNumber(file), input.typography.size);
|
|
let maximumHorizontalOverflow = 0;
|
|
|
|
const reusable = findReusableFileRows({
|
|
indexes: reusableModels,
|
|
buildInput: input,
|
|
sourceFile: file,
|
|
fileTop,
|
|
bodyTop,
|
|
});
|
|
const reusableModel = reusable?.model;
|
|
const reusableFile = reusable?.file;
|
|
|
|
if (!isCollapsed) {
|
|
const result =
|
|
reusableModel && reusableFile
|
|
? appendReusableFileRows({
|
|
rows,
|
|
input,
|
|
model: reusableModel,
|
|
file: reusableFile,
|
|
fileIndex,
|
|
fileTop,
|
|
bodyTop,
|
|
reviewGeometryKey: currentReviewGeometryKey,
|
|
})
|
|
: appendNewFileRows({
|
|
rows,
|
|
input,
|
|
file,
|
|
fileIndex,
|
|
fileTop,
|
|
bodyTop,
|
|
gutterWidth,
|
|
});
|
|
documentTop = result.bottom;
|
|
maximumHorizontalOverflow = result.maximumHorizontalOverflow;
|
|
}
|
|
|
|
files.push({
|
|
file,
|
|
fileIndex,
|
|
path: file.path,
|
|
top: fileTop,
|
|
headerHeight: FILE_HEADER_HEIGHT,
|
|
bodyTop,
|
|
bodyHeight: documentTop - bodyTop,
|
|
bottom: documentTop,
|
|
gutterWidth,
|
|
contentWidth: input.wrapLines
|
|
? input.viewportWidth
|
|
: input.viewportWidth + Math.max(0, maximumHorizontalOverflow),
|
|
rowStart,
|
|
rowEnd: rows.length,
|
|
isCollapsed,
|
|
});
|
|
}
|
|
|
|
return {
|
|
files,
|
|
rows,
|
|
height: documentTop,
|
|
lineHeight: input.typography.lineHeight,
|
|
layout: input.layout,
|
|
wrapLines: input.wrapLines,
|
|
viewportWidth: input.viewportWidth,
|
|
reviewGeometryKey: currentReviewGeometryKey,
|
|
};
|
|
}
|
|
|
|
function appendReusableFileRows(candidate: {
|
|
rows: DiffRow[];
|
|
input: BuildDiffDocumentModelInput;
|
|
model: DiffDocumentModel;
|
|
file: DiffFileSection;
|
|
fileIndex: number;
|
|
fileTop: number;
|
|
bodyTop: number;
|
|
reviewGeometryKey: string;
|
|
}): FileRowsResult {
|
|
const reusedRows = candidate.model.rows.slice(candidate.file.rowStart, candidate.file.rowEnd);
|
|
const retainsRowGeometry =
|
|
candidate.model.reviewGeometryKey === candidate.reviewGeometryKey &&
|
|
candidate.file.top === candidate.fileTop &&
|
|
candidate.file.fileIndex === candidate.fileIndex &&
|
|
candidate.file.rowStart === candidate.rows.length;
|
|
let top = candidate.bodyTop;
|
|
for (const reusedRow of reusedRows) {
|
|
let nextRow =
|
|
retainsRowGeometry && reusedRow.top === top
|
|
? reusedRow
|
|
: repositionReusedRow(reusedRow, candidate, top);
|
|
if (
|
|
nextRow.kind === "line" &&
|
|
(intersectsMaterializationWindow(candidate.input, top, top + nextRow.height) ||
|
|
containsMaterializationAnchor(candidate.input, candidate.file.path, nextRow.cells)) &&
|
|
nextRow.cells.some((cell) => cell && cell.fragments.length === 0)
|
|
) {
|
|
const cells = materializeCells(
|
|
nextRow.cells,
|
|
candidate.file.file,
|
|
candidate.file.gutterWidth,
|
|
candidate.input,
|
|
);
|
|
nextRow = {
|
|
...nextRow,
|
|
cells,
|
|
height:
|
|
Math.max(
|
|
candidate.input.typography.lineHeight,
|
|
...cells.map(
|
|
(cell) => (cell?.fragments.length ?? 1) * candidate.input.typography.lineHeight,
|
|
),
|
|
) + nextRow.reviewHeight,
|
|
};
|
|
}
|
|
candidate.rows.push(nextRow);
|
|
top += nextRow.height;
|
|
}
|
|
return {
|
|
bottom: top + DIFF_BODY_BORDER_HEIGHT,
|
|
maximumHorizontalOverflow: Math.max(
|
|
0,
|
|
candidate.file.contentWidth - candidate.input.viewportWidth,
|
|
),
|
|
};
|
|
}
|
|
|
|
function repositionReusedRow(
|
|
row: DiffRow,
|
|
candidate: Pick<Parameters<typeof appendReusableFileRows>[0], "rows" | "input" | "fileIndex">,
|
|
top: number,
|
|
): DiffRow {
|
|
if (row.kind !== "line") {
|
|
return { ...row, index: candidate.rows.length, fileIndex: candidate.fileIndex, top };
|
|
}
|
|
const reviewHeight = reviewHeightForCells(row.cells, candidate.input);
|
|
return {
|
|
...row,
|
|
index: candidate.rows.length,
|
|
fileIndex: candidate.fileIndex,
|
|
top,
|
|
height: row.height - row.reviewHeight + reviewHeight,
|
|
reviewHeight,
|
|
};
|
|
}
|
|
|
|
function appendNewFileRows(candidate: {
|
|
rows: DiffRow[];
|
|
input: BuildDiffDocumentModelInput;
|
|
file: BuildDiffDocumentModelInput["files"][number];
|
|
fileIndex: number;
|
|
fileTop: number;
|
|
bodyTop: number;
|
|
gutterWidth: number;
|
|
}): FileRowsResult {
|
|
if (candidate.file.status === "binary" || candidate.file.status === "too_large") {
|
|
const height = candidate.input.typography.lineHeight + 24;
|
|
candidate.rows.push({
|
|
kind: "status",
|
|
index: candidate.rows.length,
|
|
fileIndex: candidate.fileIndex,
|
|
path: candidate.file.path,
|
|
top: candidate.bodyTop,
|
|
height,
|
|
label:
|
|
candidate.file.status === "binary"
|
|
? candidate.input.labels.binary
|
|
: candidate.input.labels.tooLarge,
|
|
});
|
|
return {
|
|
bottom: candidate.bodyTop + height + DIFF_BODY_BORDER_HEIGHT,
|
|
maximumHorizontalOverflow: 0,
|
|
};
|
|
}
|
|
|
|
const lines = geometryLines(
|
|
lineSources(candidate.file, candidate.input.layout, false),
|
|
candidate.input,
|
|
candidate.gutterWidth,
|
|
);
|
|
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 line of lines) {
|
|
const shouldMaterialize =
|
|
intersectsMaterializationWindow(candidate.input, top, top + line.height) ||
|
|
containsMaterializationAnchor(candidate.input, candidate.file.path, line.cells);
|
|
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 height = shouldMaterialize ? textHeight + line.reviewHeight : line.height;
|
|
candidate.rows.push({
|
|
kind: "line",
|
|
index: candidate.rows.length,
|
|
fileIndex: candidate.fileIndex,
|
|
path: candidate.file.path,
|
|
top,
|
|
height,
|
|
cells,
|
|
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 {
|
|
if (!reviewActions) return "";
|
|
const comments = [...reviewActions.commentsByTarget.entries()]
|
|
.map(([target, targetComments]) => [target, targetComments.map((comment) => comment.id).sort()])
|
|
.sort(([left], [right]) => String(left).localeCompare(String(right)));
|
|
const editor = reviewActions.editor
|
|
? [reviewActions.editor.target.key, reviewActions.editor.commentId]
|
|
: null;
|
|
return JSON.stringify([comments, editor]);
|
|
}
|
|
|
|
export function expandedBodyBorderTop(file: DiffFileSection): number | null {
|
|
"worklet";
|
|
return file.isCollapsed ? null : file.bottom - DIFF_BODY_BORDER_HEIGHT;
|
|
}
|
|
|
|
export function retainReusableModels(
|
|
previous: readonly DiffDocumentModel[] | undefined,
|
|
next: DiffDocumentModel,
|
|
): DiffDocumentModel[] {
|
|
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];
|
|
}
|
|
|
|
function lineSources(
|
|
file: BuildDiffDocumentModelInput["files"][number],
|
|
layout: "unified" | "split",
|
|
includeTokens: boolean,
|
|
): Array<[CellSource] | [CellSource | null, CellSource | null]> {
|
|
if (layout === "split") {
|
|
return buildSplitDiffRows(file).map((row) => {
|
|
if (row.kind === "header") {
|
|
return [headerSource(row.content, row.hunkIndex, row.lineIndex)];
|
|
}
|
|
return [cellSource(row.left, includeTokens), cellSource(row.right, includeTokens)];
|
|
});
|
|
}
|
|
return buildUnifiedDiffLines(file).map((entry) => [
|
|
{
|
|
type: entry.line.type,
|
|
content: entry.line.content,
|
|
lineNumber: entry.lineNumber,
|
|
reviewTarget: entry.reviewTarget,
|
|
tokenText: includeTokens ? compactHighlightTokens(entry.line.tokens ?? []) : [],
|
|
sourceIdentity: {
|
|
hunkIndex: entry.hunkIndex,
|
|
lineIndex: entry.lineIndex,
|
|
side: entry.reviewTarget?.side ?? "new",
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
|
|
function headerSource(content: string, hunkIndex: number, lineIndex: number): CellSource {
|
|
return {
|
|
type: "header",
|
|
content,
|
|
lineNumber: null,
|
|
reviewTarget: null,
|
|
tokenText: [],
|
|
sourceIdentity: { hunkIndex, lineIndex, side: "new" },
|
|
};
|
|
}
|
|
|
|
function cellSource(line: SplitDiffDisplayLine | null, includeTokens: boolean): CellSource | null {
|
|
if (!line) return null;
|
|
return {
|
|
type: line.type,
|
|
content: line.content,
|
|
lineNumber: line.lineNumber,
|
|
reviewTarget: line.reviewTarget,
|
|
tokenText: includeTokens ? compactHighlightTokens(line.tokens ?? []) : [],
|
|
sourceIdentity: {
|
|
hunkIndex: line.hunkIndex,
|
|
lineIndex: line.lineIndex,
|
|
side: line.side,
|
|
},
|
|
};
|
|
}
|
|
|
|
function geometryCell(source: CellSource, estimatedWrapColumns: number): DiffCell {
|
|
return {
|
|
type: source.type,
|
|
content: source.content,
|
|
lineNumber: source.lineNumber,
|
|
tokens: [],
|
|
fragments: [],
|
|
estimatedWrapColumns,
|
|
reviewTarget: source.reviewTarget,
|
|
sourceIdentity: source.sourceIdentity,
|
|
};
|
|
}
|
|
|
|
function geometryLines(
|
|
sources: Array<[CellSource] | [CellSource | null, CellSource | null]>,
|
|
input: BuildDiffDocumentModelInput,
|
|
gutterWidth: number,
|
|
): GeometryLine[] {
|
|
const characterWidth = input.wrapLines ? input.measureText.measure("M") : 0;
|
|
return sources.map((sourceCells) => {
|
|
const columns = input.wrapLines
|
|
? Math.max(
|
|
1,
|
|
Math.floor(
|
|
(input.viewportWidth / sourceCells.length - gutterWidth - CODE_HORIZONTAL_PADDING) /
|
|
characterWidth,
|
|
),
|
|
)
|
|
: Infinity;
|
|
const cells = sourceCells.map((source) =>
|
|
source ? geometryCell(source, columns) : null,
|
|
) as DiffLineRow["cells"];
|
|
const reviewHeight = reviewHeightForCells(cells, input);
|
|
return {
|
|
cells,
|
|
reviewHeight,
|
|
// Unseen wrapped rows use a monospace estimate; materialization replaces
|
|
// it with the exact fragment count without changing source identity.
|
|
height:
|
|
input.typography.lineHeight *
|
|
Math.max(
|
|
1,
|
|
...cells.map((cell) => {
|
|
return cell && input.wrapLines
|
|
? Math.ceil(estimatedDisplayOffset(cell.content) / columns)
|
|
: 1;
|
|
}),
|
|
) +
|
|
reviewHeight,
|
|
};
|
|
});
|
|
}
|
|
|
|
// Use the same tab expansion and column count for estimated heights and anchors.
|
|
function estimatedDisplayOffset(text: string): number {
|
|
return text.length + (text.match(/\t/g)?.length ?? 0) * 3;
|
|
}
|
|
|
|
function estimatedVisualLine(cell: DiffCell, sourceOffset: number): number {
|
|
return Math.floor(
|
|
estimatedDisplayOffset(cell.content.slice(0, sourceOffset)) /
|
|
(cell.estimatedWrapColumns ?? Infinity),
|
|
);
|
|
}
|
|
|
|
function estimatedFragment(cell: DiffCell, visualIndex: number, lineHeight: number) {
|
|
const columns = cell.estimatedWrapColumns ?? Infinity;
|
|
const lastIndex = Math.max(0, Math.ceil(estimatedDisplayOffset(cell.content) / columns) - 1);
|
|
const index = Math.min(visualIndex, lastIndex);
|
|
const displayOffset = index === 0 ? 0 : index * columns;
|
|
let start = Math.min(displayOffset, cell.content.length);
|
|
if (cell.content.includes("\t")) {
|
|
let offset = 0;
|
|
start = 0;
|
|
while (start < cell.content.length && offset < displayOffset) {
|
|
offset += cell.content[start] === "\t" ? 4 : 1;
|
|
start += 1;
|
|
}
|
|
}
|
|
return { start, top: index * lineHeight };
|
|
}
|
|
|
|
// A resize can move the anchor outside the old window. Measure that row in the
|
|
// same pass: estimated columns cannot identify exact shaped-text fragments.
|
|
function containsMaterializationAnchor(
|
|
input: BuildDiffDocumentModelInput,
|
|
path: string,
|
|
cells: DiffLineRow["cells"],
|
|
): boolean {
|
|
const anchor = input.materializationAnchor;
|
|
return (
|
|
anchor?.kind === "text" &&
|
|
anchor.path === path &&
|
|
cells.some((cell) => cell && sameSourceIdentity(cell.sourceIdentity, anchor.sourceIdentity))
|
|
);
|
|
}
|
|
|
|
function intersectsMaterializationWindow(
|
|
input: BuildDiffDocumentModelInput,
|
|
fileTop: number,
|
|
fileBottom: number,
|
|
): boolean {
|
|
const window = input.materializationWindow;
|
|
if (!window) return true;
|
|
return fileTop < window.top + window.height && fileBottom > window.top;
|
|
}
|
|
|
|
function hasMeasuredFileRows(model: DiffDocumentModel, file: DiffFileSection): boolean {
|
|
if (file.isCollapsed) return false;
|
|
return model.rows.slice(file.rowStart, file.rowEnd).some((row) => {
|
|
if (row.kind !== "line") return true;
|
|
return row.cells.some((cell) => cell && cell.fragments.length > 0);
|
|
});
|
|
}
|
|
|
|
function indexReusableModels(
|
|
models: readonly DiffDocumentModel[] | undefined,
|
|
): ReusableModelIndex[] {
|
|
// 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) {
|
|
filesBySource.set(file.file, file);
|
|
if (hasMeasuredFileRows(model, file)) measuredFiles.add(file);
|
|
}
|
|
return { model, filesBySource, measuredFiles };
|
|
});
|
|
}
|
|
|
|
function findReusableFileRows(candidate: {
|
|
indexes: readonly ReusableModelIndex[];
|
|
buildInput: BuildDiffDocumentModelInput;
|
|
sourceFile: BuildDiffDocumentModelInput["files"][number];
|
|
fileTop: number;
|
|
bodyTop: number;
|
|
}): ReusableFileRows | null {
|
|
for (const index of candidate.indexes) {
|
|
const file = index.filesBySource.get(candidate.sourceFile);
|
|
if (
|
|
file &&
|
|
canReuseFileRows({
|
|
buildInput: candidate.buildInput,
|
|
file,
|
|
isMeasured: index.measuredFiles.has(file),
|
|
fileTop: candidate.fileTop,
|
|
bodyTop: candidate.bodyTop,
|
|
})
|
|
) {
|
|
return { model: index.model, file };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function canReuseFileRows(candidate: {
|
|
buildInput: BuildDiffDocumentModelInput;
|
|
file: DiffFileSection;
|
|
isMeasured: boolean;
|
|
fileTop: number;
|
|
bodyTop: number;
|
|
}): boolean {
|
|
if (candidate.file.isCollapsed) return false;
|
|
if (candidate.isMeasured) return true;
|
|
return !intersectsMaterializationWindow(
|
|
candidate.buildInput,
|
|
candidate.fileTop,
|
|
candidate.bodyTop + candidate.file.bodyHeight,
|
|
);
|
|
}
|
|
|
|
function measuredFileCount(model: DiffDocumentModel): number {
|
|
return model.files.filter((file) => hasMeasuredFileRows(model, file)).length;
|
|
}
|
|
|
|
function measureCell(input: {
|
|
source: CellSource;
|
|
availableWidth: number;
|
|
input: BuildDiffDocumentModelInput;
|
|
}): DiffCell {
|
|
const fragments = measureFragments({
|
|
text: input.source.content,
|
|
availableWidth: input.availableWidth,
|
|
wrapLines: input.input.wrapLines,
|
|
lineHeight: input.input.typography.lineHeight,
|
|
measureText: input.input.measureText,
|
|
});
|
|
return {
|
|
type: input.source.type,
|
|
content: input.source.content,
|
|
lineNumber: input.source.lineNumber,
|
|
tokens: tokenRuns(input.source, input.input.palette),
|
|
fragments,
|
|
reviewTarget: input.source.reviewTarget,
|
|
sourceIdentity: input.source.sourceIdentity,
|
|
};
|
|
}
|
|
|
|
function tokenRuns(
|
|
source: CellSource,
|
|
palette: BuildDiffDocumentModelInput["palette"],
|
|
): DiffTokenRun[] {
|
|
const runs: DiffTokenRun[] = [];
|
|
let offset = 0;
|
|
for (const token of source.tokenText) {
|
|
const end = offset + token.text.length;
|
|
const color = token.style
|
|
? (palette.syntax[token.style] ?? palette.foreground)
|
|
: palette.foreground;
|
|
runs.push({ start: offset, end, color });
|
|
offset = end;
|
|
}
|
|
return runs;
|
|
}
|
|
|
|
export function measureFragments(input: {
|
|
text: string;
|
|
availableWidth: number;
|
|
wrapLines: boolean;
|
|
lineHeight: number;
|
|
measureText: TextMeasurer;
|
|
}): DiffFragment[] {
|
|
const graphemes = segmentGraphemes(input.text);
|
|
if (!input.wrapLines || input.text.length === 0) {
|
|
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;
|
|
// 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 = measureWidth(displayGraphemes.slice(graphemeIndex, middle));
|
|
if (width <= input.availableWidth || middle === graphemeIndex + 1) {
|
|
fitting = middle;
|
|
low = middle + 1;
|
|
} else {
|
|
high = middle - 1;
|
|
}
|
|
}
|
|
fragments.push(
|
|
createFragment(
|
|
graphemes,
|
|
graphemeIndex,
|
|
fitting,
|
|
fragments.length,
|
|
input.lineHeight,
|
|
input.measureText,
|
|
),
|
|
);
|
|
graphemeIndex = fitting;
|
|
}
|
|
return fragments;
|
|
}
|
|
|
|
function createFragment(
|
|
graphemes: Array<Omit<DiffGrapheme, "width">>,
|
|
startIndex: number,
|
|
endIndex: number,
|
|
visualIndex: number,
|
|
lineHeight: number,
|
|
measureText: TextMeasurer,
|
|
): DiffFragment {
|
|
const measured = measureGraphemeSlice(graphemes, startIndex, endIndex, measureText);
|
|
const fragmentGraphemes = measured.graphemes;
|
|
const start = fragmentGraphemes[0]?.start ?? 0;
|
|
const end = fragmentGraphemes.at(-1)?.end ?? start;
|
|
const top = visualIndex * lineHeight;
|
|
return {
|
|
start,
|
|
end,
|
|
text: fragmentGraphemes.map((grapheme) => grapheme.text).join(""),
|
|
width: measured.width,
|
|
top,
|
|
baseline: top + lineHeight * 0.78,
|
|
graphemes: fragmentGraphemes,
|
|
};
|
|
}
|
|
|
|
const TAB_SIZE = 4;
|
|
|
|
function segmentGraphemes(text: string): Array<Omit<DiffGrapheme, "width">> {
|
|
const segments: Array<Omit<DiffGrapheme, "width">> = [];
|
|
let column = 0;
|
|
for (let index = 0; index < text.length; ) {
|
|
const end = findClusterBreak(text, index);
|
|
const segment = text.slice(index, end);
|
|
const isTab = segment === "\t";
|
|
const spaces = isTab ? TAB_SIZE - (column % TAB_SIZE) : 0;
|
|
const displayText = isTab ? " ".repeat(spaces) : segment;
|
|
segments.push({
|
|
start: index,
|
|
end,
|
|
text: displayText,
|
|
});
|
|
column += isTab ? spaces : 1;
|
|
index = end;
|
|
}
|
|
return segments;
|
|
}
|
|
|
|
function measureGraphemeSlice(
|
|
graphemes: Array<Omit<DiffGrapheme, "width">>,
|
|
startIndex: number,
|
|
endIndex: number,
|
|
measureText: TextMeasurer,
|
|
): { graphemes: DiffGrapheme[]; width: number } {
|
|
const segments = graphemes.slice(startIndex, endIndex);
|
|
const displayGraphemes = segments.map((segment) => segment.text);
|
|
const advances = advancesFor(measureText)(displayGraphemes);
|
|
return {
|
|
graphemes: segments.map((segment, index) => ({
|
|
start: segment.start,
|
|
end: segment.end,
|
|
text: segment.text,
|
|
width: (advances[index] ?? 0) - (advances[index - 1] ?? 0),
|
|
})),
|
|
width: advances.at(-1) ?? 0,
|
|
};
|
|
}
|
|
|
|
export function fragmentTextForRange(fragment: DiffFragment, start: number, end: number): string {
|
|
"worklet";
|
|
return fragment.graphemes
|
|
.filter((grapheme) => grapheme.start >= start && grapheme.end <= end)
|
|
.map((grapheme) => grapheme.text)
|
|
.join("");
|
|
}
|
|
|
|
export function fragmentWidthForRange(fragment: DiffFragment, start: number, end: number): number {
|
|
return fragment.graphemes
|
|
.filter((grapheme) => grapheme.start >= start && grapheme.end <= end)
|
|
.reduce((width, grapheme) => width + grapheme.width, 0);
|
|
}
|
|
|
|
export function graphemeBoundaries(text: string): number[] {
|
|
const boundaries = [0];
|
|
for (let index = 0; index < text.length; ) {
|
|
index = findClusterBreak(text, index);
|
|
boundaries.push(index);
|
|
}
|
|
return boundaries;
|
|
}
|
|
|
|
function measureCellWidth(cell: DiffCell): number {
|
|
return Math.max(0, ...cell.fragments.map((part) => part.width));
|
|
}
|
|
|
|
function reviewHeightForCells(
|
|
cells: DiffLineRow["cells"],
|
|
input: BuildDiffDocumentModelInput,
|
|
): number {
|
|
if (cells.length === 2) {
|
|
return (
|
|
getSplitInlineReviewThreadState({
|
|
left: cells[0]?.reviewTarget,
|
|
right: cells[1]?.reviewTarget,
|
|
reviewActions: input.reviewActions,
|
|
})?.height ?? 0
|
|
);
|
|
}
|
|
return (
|
|
getInlineReviewThreadState({
|
|
reviewTarget: cells[0]?.reviewTarget,
|
|
reviewActions: input.reviewActions,
|
|
})?.height ?? 0
|
|
);
|
|
}
|
|
|
|
function maximumLineNumber(file: BuildDiffDocumentModelInput["files"][number]): number {
|
|
let maximum = 0;
|
|
for (const hunk of file.hunks) {
|
|
maximum = Math.max(maximum, hunk.oldStart + hunk.oldCount, hunk.newStart + hunk.newCount);
|
|
}
|
|
return maximum;
|
|
}
|
|
|
|
export function visibleRowRange(
|
|
rows: readonly DiffRow[],
|
|
viewportTop: number,
|
|
viewportHeight: number,
|
|
): { start: number; end: number } {
|
|
"worklet";
|
|
const viewportBottom = viewportTop + viewportHeight;
|
|
let low = 0;
|
|
let high = rows.length;
|
|
while (low < high) {
|
|
const middle = (low + high) >>> 1;
|
|
const row = rows[middle];
|
|
if (row && row.top + row.height <= viewportTop) low = middle + 1;
|
|
else high = middle;
|
|
}
|
|
const start = low;
|
|
high = rows.length;
|
|
while (low < high) {
|
|
const middle = (low + high) >>> 1;
|
|
const row = rows[middle];
|
|
if (row && row.top < viewportBottom) low = middle + 1;
|
|
else high = middle;
|
|
}
|
|
return { start, end: low };
|
|
}
|
|
|
|
export function fileAtDocumentOffset(
|
|
files: readonly DiffFileSection[],
|
|
documentY: number,
|
|
): DiffFileSection | null {
|
|
let low = 0;
|
|
let high = files.length;
|
|
while (low < high) {
|
|
const middle = (low + high) >>> 1;
|
|
const file = files[middle];
|
|
if (file && file.bottom <= documentY) low = middle + 1;
|
|
else high = middle;
|
|
}
|
|
return files[low] ?? null;
|
|
}
|
|
|
|
export function captureScrollAnchor(
|
|
model: DiffDocumentModel,
|
|
scrollTop: number,
|
|
): DiffScrollAnchor | null {
|
|
const file = fileAtDocumentOffset(model.files, scrollTop);
|
|
if (!file) return null;
|
|
const row = model.rows
|
|
.slice(file.rowStart, file.rowEnd)
|
|
.find((candidate) => candidate.top + candidate.height > scrollTop);
|
|
if (!row || row.kind !== "line") {
|
|
return { kind: "header", path: file.path, viewportDelta: file.top - scrollTop };
|
|
}
|
|
const textY = Math.max(0, Math.min(scrollTop - row.top, row.height - row.reviewHeight));
|
|
const visualIndex = Math.max(0, Math.floor(textY / model.lineHeight));
|
|
let cellIndex = row.cells.findIndex(
|
|
(cell) =>
|
|
cell &&
|
|
(cell.fragments.length > 0
|
|
? Boolean(cell.fragments[visualIndex])
|
|
: estimatedFragment(cell, visualIndex, model.lineHeight).top ===
|
|
visualIndex * model.lineHeight),
|
|
);
|
|
if (cellIndex < 0) cellIndex = row.cells.findIndex((cell) => cell !== null);
|
|
const cell = row.cells[cellIndex];
|
|
if (!cell) return { kind: "header", path: file.path, viewportDelta: row.top - scrollTop };
|
|
const fragment =
|
|
cell.fragments.length > 0
|
|
? cell.fragments[Math.min(cell.fragments.length - 1, visualIndex)]!
|
|
: estimatedFragment(cell, visualIndex, model.lineHeight);
|
|
const anchorY = row.top + fragment.top;
|
|
return {
|
|
kind: "text",
|
|
path: file.path,
|
|
sourceIdentity: cell.sourceIdentity,
|
|
cellOffset: fragment.start,
|
|
viewportDelta: anchorY - scrollTop,
|
|
};
|
|
}
|
|
|
|
export function resolveScrollAnchor(model: DiffDocumentModel, anchor: DiffScrollAnchor): number {
|
|
const file = model.files.find((candidate) => candidate.path === anchor.path);
|
|
if (!file) return 0;
|
|
if (anchor.kind === "header") return Math.max(0, file.top - anchor.viewportDelta);
|
|
const row = model.rows
|
|
.slice(file.rowStart, file.rowEnd)
|
|
.find(
|
|
(candidate) =>
|
|
candidate.kind === "line" &&
|
|
candidate.cells.some(
|
|
(cell) => cell && sameSourceIdentity(cell.sourceIdentity, anchor.sourceIdentity),
|
|
),
|
|
);
|
|
if (!row || row.kind !== "line") return Math.max(0, file.top - anchor.viewportDelta);
|
|
const cell = row.cells.find(
|
|
(candidate) => candidate && sameSourceIdentity(candidate.sourceIdentity, anchor.sourceIdentity),
|
|
);
|
|
if (!cell) return Math.max(0, row.top - anchor.viewportDelta);
|
|
const fragment =
|
|
cell.fragments.find(
|
|
(candidate) => candidate.start <= anchor.cellOffset && anchor.cellOffset < candidate.end,
|
|
) ??
|
|
cell.fragments.at(-1) ??
|
|
estimatedFragment(cell, estimatedVisualLine(cell, anchor.cellOffset), model.lineHeight);
|
|
return Math.max(0, row.top + fragment.top - anchor.viewportDelta);
|
|
}
|
|
|
|
export function resolveRelayoutScrollTop(
|
|
previous: DiffDocumentModel,
|
|
next: DiffDocumentModel,
|
|
scrollTop: number,
|
|
): number {
|
|
const changedFiles = previous.files.filter((file) => {
|
|
const nextFile = next.files.find((candidate) => candidate.path === file.path);
|
|
return nextFile && nextFile.isCollapsed !== file.isCollapsed;
|
|
});
|
|
if (changedFiles.length === 1) {
|
|
const previousFile = changedFiles[0]!;
|
|
const nextFile = next.files.find((candidate) => candidate.path === previousFile.path);
|
|
if (nextFile) {
|
|
const headerDelta =
|
|
previousFile.top <= scrollTop && scrollTop < previousFile.bottom
|
|
? 0
|
|
: previousFile.top - scrollTop;
|
|
return Math.max(0, nextFile.top - headerDelta);
|
|
}
|
|
}
|
|
const anchor = captureScrollAnchor(previous, scrollTop);
|
|
return anchor ? resolveScrollAnchor(next, anchor) : scrollTop;
|
|
}
|
|
|
|
export function shouldApplyRelayoutScroll(
|
|
currentScrollTop: number,
|
|
nextScrollTop: number,
|
|
): boolean {
|
|
return Math.abs(nextScrollTop - currentScrollTop) > 0.5;
|
|
}
|
|
|
|
function sameSourceIdentity(left: DiffSourceIdentity, right: DiffSourceIdentity): boolean {
|
|
return (
|
|
left.hunkIndex === right.hunkIndex &&
|
|
left.lineIndex === right.lineIndex &&
|
|
left.side === right.side
|
|
);
|
|
}
|