mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 18:35:17 +00:00
refactor(app): implement canvas-based diff rendering
This commit is contained in:
@@ -0,0 +1,510 @@
|
||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import {
|
||||
buildSplitDiffRows,
|
||||
buildUnifiedDiffLines,
|
||||
type ReviewableDiffTarget,
|
||||
type SplitDiffDisplayLine,
|
||||
} from "@/utils/diff-layout";
|
||||
import { compactHighlightTokens } from "@/utils/diff-rendering";
|
||||
import { getInlineReviewThreadState, getSplitInlineReviewThreadState } from "@/review/geometry";
|
||||
import type {
|
||||
BuildDiffDocumentModelInput,
|
||||
DiffCell,
|
||||
DiffDocumentModel,
|
||||
DiffFileSection,
|
||||
DiffFragment,
|
||||
DiffGrapheme,
|
||||
DiffLineRow,
|
||||
DiffRow,
|
||||
DiffScrollAnchor,
|
||||
DiffSourceIdentity,
|
||||
DiffTokenRun,
|
||||
TextMeasurer,
|
||||
} from "./types";
|
||||
|
||||
export const FILE_HEADER_HEIGHT = 44;
|
||||
const 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;
|
||||
}
|
||||
|
||||
export function buildDiffDocumentModel(input: BuildDiffDocumentModelInput): DiffDocumentModel {
|
||||
const rows: DiffRow[] = [];
|
||||
const files: DiffFileSection[] = [];
|
||||
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;
|
||||
|
||||
if (!isCollapsed) {
|
||||
if (file.status === "binary" || file.status === "too_large") {
|
||||
const height = input.typography.lineHeight + 24;
|
||||
rows.push({
|
||||
kind: "status",
|
||||
index: rows.length,
|
||||
fileIndex,
|
||||
path: file.path,
|
||||
top: documentTop,
|
||||
height,
|
||||
label: file.status === "binary" ? input.labels.binary : input.labels.tooLarge,
|
||||
});
|
||||
documentTop += height;
|
||||
} else {
|
||||
const sources = lineSources(file, input.layout);
|
||||
for (const sourceCells of sources) {
|
||||
const columnCount = sourceCells.length;
|
||||
const columnWidth = input.viewportWidth / columnCount;
|
||||
const availableWidth = Math.max(
|
||||
input.measureText.measure("M"),
|
||||
columnWidth - gutterWidth - CODE_HORIZONTAL_PADDING,
|
||||
);
|
||||
const cells = sourceCells.map((source) => {
|
||||
if (!source) return null;
|
||||
const cell = measureCell({
|
||||
source,
|
||||
availableWidth,
|
||||
input,
|
||||
});
|
||||
maximumHorizontalOverflow = Math.max(
|
||||
maximumHorizontalOverflow,
|
||||
gutterWidth + CODE_HORIZONTAL_PADDING + measureCellWidth(cell) - columnWidth,
|
||||
);
|
||||
return cell;
|
||||
}) as DiffLineRow["cells"];
|
||||
const textHeight = Math.max(
|
||||
input.typography.lineHeight,
|
||||
...cells.map((cell) => (cell?.fragments.length ?? 1) * input.typography.lineHeight),
|
||||
);
|
||||
const reviewHeight = reviewHeightForCells(cells, input);
|
||||
const height = textHeight + reviewHeight;
|
||||
rows.push({
|
||||
kind: "line",
|
||||
index: rows.length,
|
||||
fileIndex,
|
||||
path: file.path,
|
||||
top: documentTop,
|
||||
height,
|
||||
cells,
|
||||
reviewHeight,
|
||||
});
|
||||
documentTop += height;
|
||||
}
|
||||
}
|
||||
documentTop += BODY_BORDER_HEIGHT;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function lineSources(
|
||||
file: BuildDiffDocumentModelInput["files"][number],
|
||||
layout: "unified" | "split",
|
||||
): 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), cellSource(row.right)];
|
||||
});
|
||||
}
|
||||
return buildUnifiedDiffLines(file).map((entry) => [
|
||||
{
|
||||
type: entry.line.type,
|
||||
content: entry.line.content,
|
||||
lineNumber: entry.lineNumber,
|
||||
reviewTarget: entry.reviewTarget,
|
||||
tokenText: 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): CellSource | null {
|
||||
if (!line) return null;
|
||||
return {
|
||||
type: line.type,
|
||||
content: line.content,
|
||||
lineNumber: line.lineNumber,
|
||||
reviewTarget: line.reviewTarget,
|
||||
tokenText: compactHighlightTokens(line.tokens ?? []),
|
||||
sourceIdentity: {
|
||||
hunkIndex: line.hunkIndex,
|
||||
lineIndex: line.lineIndex,
|
||||
side: line.side,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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[] = [];
|
||||
let graphemeIndex = 0;
|
||||
while (graphemeIndex < graphemes.length) {
|
||||
let low = graphemeIndex + 1;
|
||||
let high = graphemes.length;
|
||||
let fitting = low;
|
||||
while (low <= high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
const width = measureGraphemeSlice(graphemes, graphemeIndex, middle, input.measureText).width;
|
||||
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;
|
||||
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
for (const segment of segmenter.segment(text)) {
|
||||
const isTab = segment.segment === "\t";
|
||||
const spaces = isTab ? TAB_SIZE - (column % TAB_SIZE) : 0;
|
||||
const displayText = isTab ? " ".repeat(spaces) : segment.segment;
|
||||
segments.push({
|
||||
start: segment.index,
|
||||
end: segment.index + segment.segment.length,
|
||||
text: displayText,
|
||||
});
|
||||
column += isTab ? spaces : 1;
|
||||
}
|
||||
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 = measureText.measureAdvances
|
||||
? measureText.measureAdvances(displayGraphemes)
|
||||
: measurePrefixAdvances(displayGraphemes, measureText.measure);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function measurePrefixAdvances(
|
||||
graphemes: readonly string[],
|
||||
measureText: (text: string) => number,
|
||||
): number[] {
|
||||
let prefix = "";
|
||||
return graphemes.map((grapheme) => {
|
||||
prefix += grapheme;
|
||||
return measureText(prefix);
|
||||
});
|
||||
}
|
||||
|
||||
export function fragmentTextForRange(fragment: DiffFragment, start: number, end: number): string {
|
||||
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];
|
||||
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
for (const segment of segmenter.segment(text)) {
|
||||
const end = segment.index + segment.segment.length;
|
||||
if (end > boundaries[boundaries.length - 1]) boundaries.push(end);
|
||||
}
|
||||
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) => Boolean(cell?.fragments[visualIndex]));
|
||||
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[Math.min(cell.fragments.length - 1, visualIndex)];
|
||||
const anchorY = row.top + (fragment?.top ?? 0);
|
||||
return {
|
||||
kind: "text",
|
||||
path: file.path,
|
||||
sourceIdentity: cell.sourceIdentity,
|
||||
cellOffset: fragment?.start ?? 0,
|
||||
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);
|
||||
return Math.max(0, row.top + (fragment?.top ?? 0) - anchor.viewportDelta);
|
||||
}
|
||||
|
||||
function sameSourceIdentity(left: DiffSourceIdentity, right: DiffSourceIdentity): boolean {
|
||||
return (
|
||||
left.hunkIndex === right.hunkIndex &&
|
||||
left.lineIndex === right.lineIndex &&
|
||||
left.side === right.side
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user