fix(app): stabilize canvas diff rendering
Keep diff headers and painted content on the same scroll clock while preserving native scrolling and independent per-file horizontal offsets. Complete review, selection, typography, tree, and palette parity without returning line rendering to React.
This commit is contained in:
@@ -3,19 +3,23 @@ import { FileHeader } from "@/git/file-header";
|
||||
import type { DiffFileSection } from "./types";
|
||||
import type { DiffDocumentProps } from "./types";
|
||||
|
||||
interface DocumentFileHeaderProps {
|
||||
file: DiffFileSection;
|
||||
selectedPath: string | null;
|
||||
mode: DiffDocumentProps["mode"];
|
||||
onToggleFile: (path: string) => void;
|
||||
onSelectPath: (path: string) => void;
|
||||
showTopBorder: boolean;
|
||||
}
|
||||
|
||||
export const DocumentFileHeader = memo(function DocumentFileHeader({
|
||||
file,
|
||||
selectedPath,
|
||||
mode,
|
||||
onToggleFile,
|
||||
onSelectPath,
|
||||
}: {
|
||||
file: DiffFileSection;
|
||||
selectedPath: string | null;
|
||||
mode: DiffDocumentProps["mode"];
|
||||
onToggleFile: (path: string) => void;
|
||||
onSelectPath: (path: string) => void;
|
||||
}) {
|
||||
showTopBorder,
|
||||
}: DocumentFileHeaderProps) {
|
||||
const activate = useCallback(
|
||||
(path: string) => {
|
||||
if (mode.kind !== "working") return;
|
||||
@@ -31,6 +35,7 @@ export const DocumentFileHeader = memo(function DocumentFileHeader({
|
||||
bodyVisible={!file.isCollapsed}
|
||||
isSelected={selectedPath === file.path}
|
||||
interactive={mode.kind === "working"}
|
||||
showTopBorder={showTopBorder}
|
||||
workspaceFileDragScope={working?.workspaceFileDragScope}
|
||||
onActivate={activate}
|
||||
onSelect={onSelectPath}
|
||||
@@ -46,4 +51,41 @@ export const DocumentFileHeader = memo(function DocumentFileHeader({
|
||||
testID={`diff-file-${file.fileIndex}`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}, documentFileHeaderPropsEqual);
|
||||
|
||||
function documentFileHeaderPropsEqual(
|
||||
previous: DocumentFileHeaderProps,
|
||||
next: DocumentFileHeaderProps,
|
||||
): boolean {
|
||||
if (!documentFileHeaderIdentityMatches(previous, next)) return false;
|
||||
if (previous.mode.kind === "commit" || next.mode.kind === "commit") return true;
|
||||
return (
|
||||
previous.mode.onFilePress === next.mode.onFilePress &&
|
||||
previous.mode.workspaceFileDragScope === next.mode.workspaceFileDragScope &&
|
||||
previous.mode.onOpenFile === next.mode.onOpenFile &&
|
||||
previous.mode.onAddToChat === next.mode.onAddToChat &&
|
||||
previous.mode.onCopyPath === next.mode.onCopyPath &&
|
||||
previous.mode.onCopyRelativePath === next.mode.onCopyRelativePath &&
|
||||
previous.mode.onReveal === next.mode.onReveal &&
|
||||
previous.mode.revealTargetName === next.mode.revealTargetName &&
|
||||
previous.mode.onDownload === next.mode.onDownload &&
|
||||
previous.mode.onDuplicate === next.mode.onDuplicate &&
|
||||
previous.mode.onRevert === next.mode.onRevert
|
||||
);
|
||||
}
|
||||
|
||||
function documentFileHeaderIdentityMatches(
|
||||
previous: DocumentFileHeaderProps,
|
||||
next: DocumentFileHeaderProps,
|
||||
): boolean {
|
||||
return !(
|
||||
previous.file.file !== next.file.file ||
|
||||
previous.file.fileIndex !== next.file.fileIndex ||
|
||||
previous.file.isCollapsed !== next.file.isCollapsed ||
|
||||
previous.showTopBorder !== next.showTopBorder ||
|
||||
(previous.selectedPath === previous.file.path) !== (next.selectedPath === next.file.path) ||
|
||||
previous.onToggleFile !== next.onToggleFile ||
|
||||
previous.onSelectPath !== next.onSelectPath ||
|
||||
previous.mode.kind !== next.mode.kind
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
||||
import { useAnimatedScrollHandler, type SharedValue } from "react-native-reanimated";
|
||||
import {
|
||||
createAnimatedComponent,
|
||||
useAnimatedScrollHandler,
|
||||
type SharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
|
||||
import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures";
|
||||
import { horizontalOffsetForPath, type DiffHorizontalOffsets } from "./horizontal-offsets";
|
||||
import type { DiffFileSection } from "./types";
|
||||
|
||||
const AnimatedHorizontalScrollView = createAnimatedComponent(ScrollView);
|
||||
|
||||
export function HorizontalScroll({
|
||||
file,
|
||||
offsets,
|
||||
@@ -48,16 +54,33 @@ export function HorizontalScroll({
|
||||
},
|
||||
[horizontalScroll, scrollId],
|
||||
);
|
||||
const beginHorizontalGesture = useCallback(() => {
|
||||
horizontalScroll?.beginHorizontalGesture(horizontalOffsetForPath(offsets.value, file.path));
|
||||
}, [file.path, horizontalScroll, offsets]);
|
||||
const endHorizontalGesture = useCallback(() => {
|
||||
horizontalScroll?.endHorizontalGesture();
|
||||
}, [horizontalScroll]);
|
||||
const rootStyle = useMemo(
|
||||
() => ({ position: "absolute" as const, top: 0, left: 0, right: 0, height: file.bodyHeight }),
|
||||
() => ({
|
||||
position: "absolute" as const,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: file.bodyHeight,
|
||||
backgroundColor: "transparent",
|
||||
}),
|
||||
[file.bodyHeight],
|
||||
);
|
||||
const spacerStyle = useMemo(
|
||||
() => ({ width: file.contentWidth, height: file.bodyHeight }),
|
||||
() => ({
|
||||
width: file.contentWidth,
|
||||
height: file.bodyHeight,
|
||||
backgroundColor: "transparent",
|
||||
}),
|
||||
[file.bodyHeight, file.contentWidth],
|
||||
);
|
||||
return (
|
||||
<ScrollView
|
||||
<AnimatedHorizontalScrollView
|
||||
ref={scrollRef}
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
@@ -66,12 +89,15 @@ export function HorizontalScroll({
|
||||
style={rootStyle}
|
||||
contentContainerStyle={spacerStyle}
|
||||
onScroll={handler}
|
||||
onTouchStart={beginHorizontalGesture}
|
||||
onTouchEnd={endHorizontalGesture}
|
||||
onTouchCancel={endHorizontalGesture}
|
||||
onScrollEndDrag={reportOffset}
|
||||
scrollEventThrottle={16}
|
||||
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
|
||||
onMomentumScrollEnd={reportOffset}
|
||||
>
|
||||
<View style={spacerStyle} />
|
||||
</ScrollView>
|
||||
</AnimatedHorizontalScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { createDiffPalette } from "./palette";
|
||||
import { createDiffPalette, retainDiffPalette } from "./palette";
|
||||
import { DiffSurface } from "./surface";
|
||||
import type { DiffDocumentProps, DiffPalette } from "./types";
|
||||
|
||||
@@ -14,6 +14,9 @@ const EMPTY_PATHS: string[] = [];
|
||||
|
||||
function ThemedDiffDocument(props: ThemedDiffDocumentProps) {
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const paletteRef = useRef(props.palette);
|
||||
paletteRef.current = retainDiffPalette(paletteRef.current, props.palette);
|
||||
const palette = paletteRef.current;
|
||||
const collapseState = props.mode.kind === "working" ? props.collapseState : null;
|
||||
const paths = collapseState?.paths ?? EMPTY_PATHS;
|
||||
const collapsedFilePaths = useMemo(() => new Set(paths), [paths]);
|
||||
@@ -30,6 +33,7 @@ function ThemedDiffDocument(props: ThemedDiffDocumentProps) {
|
||||
return (
|
||||
<DiffSurface
|
||||
{...props}
|
||||
palette={palette}
|
||||
collapsedFilePaths={collapsedFilePaths}
|
||||
onToggleFile={toggleFile}
|
||||
selectedPath={selectedPath}
|
||||
|
||||
@@ -3,9 +3,11 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildDiffDocumentModel,
|
||||
captureScrollAnchor,
|
||||
FILE_HEADER_HEIGHT,
|
||||
graphemeBoundaries,
|
||||
measureFragments,
|
||||
resolveScrollAnchor,
|
||||
resolveRelayoutScrollTop,
|
||||
} from "./model";
|
||||
import type { BuildDiffDocumentModelInput, TextMeasurer } from "./types";
|
||||
|
||||
@@ -63,6 +65,24 @@ function input(overrides: Partial<BuildDiffDocumentModelInput> = {}): BuildDiffD
|
||||
}
|
||||
|
||||
describe("diff document model", () => {
|
||||
it("segments graphemes when Intl.Segmenter is unavailable", () => {
|
||||
const originalSegmenter = Intl.Segmenter;
|
||||
Object.defineProperty(Intl, "Segmenter", { configurable: true, value: undefined });
|
||||
try {
|
||||
const source = "中é👨👩👧👦";
|
||||
const fragments = measureFragments({
|
||||
text: source,
|
||||
availableWidth: 100,
|
||||
wrapLines: true,
|
||||
lineHeight: 18,
|
||||
measureText: measurer,
|
||||
});
|
||||
expect(fragments[0]?.graphemes.map((grapheme) => grapheme.text)).toEqual(["中", "é", "👨👩👧👦"]);
|
||||
} finally {
|
||||
Object.defineProperty(Intl, "Segmenter", { configurable: true, value: originalSegmenter });
|
||||
}
|
||||
});
|
||||
|
||||
it("measures grapheme-safe wrapped fragments", () => {
|
||||
const text = "ab👨👩👧👦cd";
|
||||
const fragments = measureFragments({
|
||||
@@ -161,6 +181,58 @@ describe("diff document model", () => {
|
||||
expect(collapsed.height).toBeLessThan(expanded.height);
|
||||
});
|
||||
|
||||
it("reuses unchanged file measurements when collapse state changes", () => {
|
||||
const files = [file("a.ts"), file("b.ts")];
|
||||
let measurementCount = 0;
|
||||
const countingMeasurer: TextMeasurer = {
|
||||
measure(text) {
|
||||
measurementCount += 1;
|
||||
return Array.from(text).length * 10;
|
||||
},
|
||||
};
|
||||
const expanded = buildDiffDocumentModel(input({ files, measureText: countingMeasurer }));
|
||||
const initialMeasurementCount = measurementCount;
|
||||
|
||||
const collapsed = buildDiffDocumentModel(
|
||||
input({
|
||||
files,
|
||||
collapsedFilePaths: new Set(["a.ts"]),
|
||||
measureText: countingMeasurer,
|
||||
reuseFrom: [expanded],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(measurementCount).toBe(initialMeasurementCount);
|
||||
expect(collapsed.files[1]?.bodyHeight).toBe(expanded.files[1]?.bodyHeight);
|
||||
expect(collapsed.rows.every((row, index) => row.index === index)).toBe(true);
|
||||
expect(collapsed.rows[0]?.top).toBe(FILE_HEADER_HEIGHT * 2);
|
||||
|
||||
const expandedAgain = buildDiffDocumentModel(
|
||||
input({
|
||||
files,
|
||||
measureText: countingMeasurer,
|
||||
reuseFrom: [expanded, collapsed],
|
||||
}),
|
||||
);
|
||||
expect(measurementCount).toBe(initialMeasurementCount);
|
||||
expect(expandedAgain.height).toBe(expanded.height);
|
||||
});
|
||||
|
||||
it("keeps the toggled sticky header anchored through collapse and expansion", () => {
|
||||
const files = [file("a.ts"), file("b.ts")];
|
||||
const expanded = buildDiffDocumentModel(input({ files }));
|
||||
const firstFile = expanded.files[0]!;
|
||||
const scrollTop = firstFile.top + 20;
|
||||
const collapsed = buildDiffDocumentModel(
|
||||
input({ files, collapsedFilePaths: new Set(["a.ts"]), reuseFrom: [expanded] }),
|
||||
);
|
||||
|
||||
expect(resolveRelayoutScrollTop(expanded, collapsed, scrollTop)).toBe(collapsed.files[0]!.top);
|
||||
expect(resolveRelayoutScrollTop(collapsed, expanded, collapsed.files[0]!.top)).toBe(
|
||||
expanded.files[0]!.top,
|
||||
);
|
||||
});
|
||||
|
||||
it("reserves review geometry in the measured row", () => {
|
||||
const model = buildDiffDocumentModel(
|
||||
input({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import { findClusterBreak } from "@marijn/find-cluster-break";
|
||||
import {
|
||||
buildSplitDiffRows,
|
||||
buildUnifiedDiffLines,
|
||||
@@ -22,7 +23,7 @@ import type {
|
||||
TextMeasurer,
|
||||
} from "./types";
|
||||
|
||||
export const FILE_HEADER_HEIGHT = 44;
|
||||
export const FILE_HEADER_HEIGHT = 30;
|
||||
const BODY_BORDER_HEIGHT = 1;
|
||||
const CODE_HORIZONTAL_PADDING = 16;
|
||||
|
||||
@@ -49,7 +50,28 @@ export function buildDiffDocumentModel(input: BuildDiffDocumentModelInput): Diff
|
||||
const gutterWidth = lineNumberGutterWidth(maximumLineNumber(file), input.typography.size);
|
||||
let maximumHorizontalOverflow = 0;
|
||||
|
||||
if (!isCollapsed) {
|
||||
const reusableModel = input.reuseFrom?.find((candidate) =>
|
||||
candidate.files.some((entry) => entry.file === file && !entry.isCollapsed),
|
||||
);
|
||||
const reusableFile = reusableModel?.files.find(
|
||||
(candidate) => candidate.file === file && !candidate.isCollapsed,
|
||||
);
|
||||
|
||||
if (!isCollapsed && reusableFile) {
|
||||
const reusedRows = reusableModel!.rows.slice(reusableFile.rowStart, reusableFile.rowEnd);
|
||||
for (const reusedRow of reusedRows) {
|
||||
const height = reusedRow.height;
|
||||
rows.push({
|
||||
...reusedRow,
|
||||
index: rows.length,
|
||||
fileIndex,
|
||||
top: documentTop,
|
||||
});
|
||||
documentTop += height;
|
||||
}
|
||||
maximumHorizontalOverflow = Math.max(0, reusableFile.contentWidth - input.viewportWidth);
|
||||
documentTop += BODY_BORDER_HEIGHT;
|
||||
} else if (!isCollapsed) {
|
||||
if (file.status === "binary" || file.status === "too_large") {
|
||||
const height = input.typography.lineHeight + 24;
|
||||
rows.push({
|
||||
@@ -136,6 +158,16 @@ export function buildDiffDocumentModel(input: BuildDiffDocumentModelInput): Diff
|
||||
};
|
||||
}
|
||||
|
||||
export function retainReusableModels(
|
||||
previous: readonly DiffDocumentModel[] | undefined,
|
||||
next: DiffDocumentModel,
|
||||
): DiffDocumentModel[] {
|
||||
const fullest = [...(previous ?? []), next].reduce((best, candidate) =>
|
||||
candidate.rows.length > best.rows.length ? candidate : best,
|
||||
);
|
||||
return fullest === next ? [next] : [fullest, next];
|
||||
}
|
||||
|
||||
function lineSources(
|
||||
file: BuildDiffDocumentModelInput["files"][number],
|
||||
layout: "unified" | "split",
|
||||
@@ -302,17 +334,19 @@ 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";
|
||||
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.segment;
|
||||
const displayText = isTab ? " ".repeat(spaces) : segment;
|
||||
segments.push({
|
||||
start: segment.index,
|
||||
end: segment.index + segment.segment.length,
|
||||
start: index,
|
||||
end,
|
||||
text: displayText,
|
||||
});
|
||||
column += isTab ? spaces : 1;
|
||||
index = end;
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
@@ -351,6 +385,7 @@ function measurePrefixAdvances(
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -365,10 +400,9 @@ export function fragmentWidthForRange(fragment: DiffFragment, start: number, end
|
||||
|
||||
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);
|
||||
for (let index = 0; index < text.length; ) {
|
||||
index = findClusterBreak(text, index);
|
||||
boundaries.push(index);
|
||||
}
|
||||
return boundaries;
|
||||
}
|
||||
@@ -501,6 +535,30 @@ export function resolveScrollAnchor(model: DiffDocumentModel, anchor: DiffScroll
|
||||
return Math.max(0, row.top + (fragment?.top ?? 0) - 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;
|
||||
}
|
||||
|
||||
function sameSourceIdentity(left: DiffSourceIdentity, right: DiffSourceIdentity): boolean {
|
||||
return (
|
||||
left.hunkIndex === right.hunkIndex &&
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildDiffDocumentModel } from "./model";
|
||||
import { hitTestDiffPagePoint } from "./native-hit-testing";
|
||||
import { hitTestDiffBodyPoint } from "./native-hit-testing";
|
||||
import type { BuildDiffDocumentModelInput } from "./types";
|
||||
|
||||
const origin = { x: 80, y: 120 };
|
||||
|
||||
describe("native diff page-coordinate hit testing", () => {
|
||||
it("resolves first and later files through the measured surface origin and vertical scroll", () => {
|
||||
describe("native diff body-coordinate hit testing", () => {
|
||||
it("resolves first and later files from their body-local coordinates", () => {
|
||||
const model = build("unified");
|
||||
const rows = model.rows.filter((row) => row.kind === "line" && row.cells[0]?.type === "add");
|
||||
for (const row of rows) {
|
||||
const scrollTop = Math.max(0, row.top - 30);
|
||||
const hit = hitTestDiffPagePoint({
|
||||
const file = model.files[row.fileIndex]!;
|
||||
const hit = hitTestDiffBodyPoint({
|
||||
model,
|
||||
pageX: origin.x + model.files[row.fileIndex]!.gutterWidth + 18,
|
||||
pageY: origin.y + row.top - scrollTop + 2,
|
||||
surfaceOrigin: origin,
|
||||
scrollTop,
|
||||
horizontalOffsetForPath: () => 0,
|
||||
file,
|
||||
locationX: file.gutterWidth + 18,
|
||||
locationY: row.top - file.bodyTop + 2,
|
||||
horizontalOffset: 0,
|
||||
});
|
||||
expect(hit?.kind).toBe("cell");
|
||||
if (hit?.kind === "cell") expect(hit.position.fileIndex).toBe(row.fileIndex);
|
||||
@@ -33,18 +30,18 @@ describe("native diff page-coordinate hit testing", () => {
|
||||
candidate.path === "src/second.ts" &&
|
||||
candidate.cells[0]?.type === "add",
|
||||
)!;
|
||||
const hit = hitTestDiffPagePoint({
|
||||
const file = model.files[1]!;
|
||||
const hit = hitTestDiffBodyPoint({
|
||||
model,
|
||||
pageX: origin.x + model.files[1]!.gutterWidth + 8,
|
||||
pageY: origin.y + row.top + 2,
|
||||
surfaceOrigin: origin,
|
||||
scrollTop: 0,
|
||||
horizontalOffsetForPath: (path) => (path === "src/second.ts" ? 30 : 0),
|
||||
file,
|
||||
locationX: file.gutterWidth + 8,
|
||||
locationY: row.top - file.bodyTop + 2,
|
||||
horizontalOffset: 30,
|
||||
});
|
||||
expect(hit?.kind === "cell" ? hit.position.sourceOffset : -1).toBe(3);
|
||||
});
|
||||
|
||||
it("resolves old and new split cells from page coordinates independent of the touch target", () => {
|
||||
it("resolves old and new split cells from body-local coordinates", () => {
|
||||
const model = build("split");
|
||||
const row = model.rows.find(
|
||||
(candidate) =>
|
||||
@@ -58,13 +55,13 @@ describe("native diff page-coordinate hit testing", () => {
|
||||
});
|
||||
|
||||
function hitAt(model: ReturnType<typeof buildDiffDocumentModel>, rowTop: number, x: number) {
|
||||
return hitTestDiffPagePoint({
|
||||
const file = model.files.find((entry) => entry.bodyTop <= rowTop && rowTop < entry.bottom)!;
|
||||
return hitTestDiffBodyPoint({
|
||||
model,
|
||||
pageX: origin.x + x,
|
||||
pageY: origin.y + rowTop + 2,
|
||||
surfaceOrigin: origin,
|
||||
scrollTop: 0,
|
||||
horizontalOffsetForPath: () => 0,
|
||||
file,
|
||||
locationX: x,
|
||||
locationY: rowTop - file.bodyTop + 2,
|
||||
horizontalOffset: 0,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +1,17 @@
|
||||
import { hitTestDiffDocument } from "./hit-testing";
|
||||
import type { DiffDocumentModel, DiffHit } from "./types";
|
||||
import type { DiffDocumentModel, DiffFileSection, DiffHit } from "./types";
|
||||
|
||||
export interface SurfaceWindowOrigin {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export function hitTestDiffPagePoint(input: {
|
||||
export function hitTestDiffBodyPoint(input: {
|
||||
model: DiffDocumentModel;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
surfaceOrigin: SurfaceWindowOrigin;
|
||||
scrollTop: number;
|
||||
horizontalOffsetForPath: (path: string) => number;
|
||||
file: DiffFileSection;
|
||||
locationX: number;
|
||||
locationY: number;
|
||||
horizontalOffset: number;
|
||||
}): DiffHit | null {
|
||||
const x = input.pageX - input.surfaceOrigin.x;
|
||||
const documentY = input.pageY - input.surfaceOrigin.y + input.scrollTop;
|
||||
const file = input.model.files.find(
|
||||
(entry) => entry.top <= documentY && documentY < entry.bottom,
|
||||
);
|
||||
return hitTestDiffDocument({
|
||||
model: input.model,
|
||||
x,
|
||||
documentY,
|
||||
horizontalOffset: file ? input.horizontalOffsetForPath(file.path) : 0,
|
||||
x: input.locationX,
|
||||
documentY: input.file.bodyTop + input.locationY,
|
||||
horizontalOffset: input.horizontalOffset,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FILE_HEADER_HEIGHT } from "./model";
|
||||
import {
|
||||
buildNativeCanvasSlabs,
|
||||
nativeCanvasSlabsForViewport,
|
||||
nativeCanvasWindowBucket,
|
||||
nativeCanvasWindowTop,
|
||||
nativeStickyHeaderIndices,
|
||||
} from "./native-slabs";
|
||||
import type { DiffDocumentModel } from "./types";
|
||||
|
||||
function modelWithBodies(
|
||||
bodies: Array<{ path: string; bodyTop: number; bottom: number }>,
|
||||
): DiffDocumentModel {
|
||||
return {
|
||||
files: bodies.map((body, fileIndex) => ({
|
||||
file: {} as never,
|
||||
fileIndex,
|
||||
path: body.path,
|
||||
top: body.bodyTop - FILE_HEADER_HEIGHT,
|
||||
headerHeight: FILE_HEADER_HEIGHT,
|
||||
bodyTop: body.bodyTop,
|
||||
bodyHeight: body.bottom - body.bodyTop,
|
||||
bottom: body.bottom,
|
||||
gutterWidth: 48,
|
||||
contentWidth: 500,
|
||||
rowStart: 0,
|
||||
rowEnd: 0,
|
||||
isCollapsed: body.bottom === body.bodyTop,
|
||||
})),
|
||||
rows: [],
|
||||
height: bodies.at(-1)?.bottom ?? 0,
|
||||
lineHeight: 20,
|
||||
layout: "unified",
|
||||
wrapLines: false,
|
||||
viewportWidth: 400,
|
||||
};
|
||||
}
|
||||
|
||||
describe("native canvas slabs", () => {
|
||||
it("identifies each direct file header child for native sticky scrolling", () => {
|
||||
expect(nativeStickyHeaderIndices(0)).toEqual([]);
|
||||
expect(nativeStickyHeaderIndices(3)).toEqual([1, 3, 5]);
|
||||
});
|
||||
|
||||
it("cuts large file bodies into fixed document-positioned slabs", () => {
|
||||
const slabs = buildNativeCanvasSlabs(
|
||||
modelWithBodies([
|
||||
{ path: "small.ts", bodyTop: FILE_HEADER_HEIGHT, bottom: FILE_HEADER_HEIGHT + 200 },
|
||||
{
|
||||
path: "large.ts",
|
||||
bodyTop: FILE_HEADER_HEIGHT * 2 + 200,
|
||||
bottom: FILE_HEADER_HEIGHT * 2 + 3400,
|
||||
},
|
||||
]),
|
||||
600,
|
||||
);
|
||||
|
||||
expect(slabs.map(({ path, top, height }) => ({ path, top, height }))).toEqual([
|
||||
{ path: "small.ts", top: 30, height: 200 },
|
||||
{ path: "large.ts", top: 260, height: 1200 },
|
||||
{ path: "large.ts", top: 1460, height: 1200 },
|
||||
{ path: "large.ts", top: 2660, height: 800 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains only viewport-adjacent slabs and updates on half-viewport boundaries", () => {
|
||||
const slabs = buildNativeCanvasSlabs(
|
||||
modelWithBodies([
|
||||
{ path: "large.ts", bodyTop: FILE_HEADER_HEIGHT, bottom: FILE_HEADER_HEIGHT + 4800 },
|
||||
]),
|
||||
600,
|
||||
);
|
||||
|
||||
expect(nativeCanvasSlabsForViewport(slabs, 1700, 600).map((slab) => slab.top)).toEqual([
|
||||
30, 1230, 2430,
|
||||
]);
|
||||
expect(nativeCanvasWindowBucket(899, 600)).toBe(2);
|
||||
expect(nativeCanvasWindowTop(2, 600)).toBe(600);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { DiffDocumentModel } from "./types";
|
||||
|
||||
export interface NativeCanvasSlab {
|
||||
key: string;
|
||||
path: string;
|
||||
fileIndex: number;
|
||||
top: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function nativeStickyHeaderIndices(fileCount: number): number[] {
|
||||
return Array.from({ length: fileCount }, (_, index) => index * 2 + 1);
|
||||
}
|
||||
|
||||
export function buildNativeCanvasSlabs(
|
||||
model: DiffDocumentModel,
|
||||
viewportHeight: number,
|
||||
): NativeCanvasSlab[] {
|
||||
const slabHeight = Math.max(768, viewportHeight * 2);
|
||||
return model.files.flatMap((file) => {
|
||||
const slabs: NativeCanvasSlab[] = [];
|
||||
for (let top = file.bodyTop; top < file.bottom; top += slabHeight) {
|
||||
const height = Math.min(slabHeight, file.bottom - top);
|
||||
slabs.push({
|
||||
key: `${file.path}:${top}:${height}`,
|
||||
path: file.path,
|
||||
fileIndex: file.fileIndex,
|
||||
top,
|
||||
height,
|
||||
});
|
||||
}
|
||||
return slabs;
|
||||
});
|
||||
}
|
||||
|
||||
export function nativeCanvasSlabsForViewport(
|
||||
slabs: readonly NativeCanvasSlab[],
|
||||
scrollTop: number,
|
||||
viewportHeight: number,
|
||||
): NativeCanvasSlab[] {
|
||||
const top = Math.max(0, scrollTop - viewportHeight);
|
||||
const bottom = scrollTop + viewportHeight * 2;
|
||||
return slabs.filter((slab) => slab.top < bottom && slab.top + slab.height > top);
|
||||
}
|
||||
|
||||
export function nativeCanvasWindowBucket(scrollTop: number, viewportHeight: number): number {
|
||||
"worklet";
|
||||
return Math.floor(Math.max(0, scrollTop) / Math.max(1, viewportHeight / 2));
|
||||
}
|
||||
|
||||
export function nativeCanvasWindowTop(bucket: number, viewportHeight: number): number {
|
||||
"worklet";
|
||||
return bucket * Math.max(1, viewportHeight / 2);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { nativeTextRuns } from "./native-text-runs";
|
||||
import type { DiffCell, DiffFragment } from "./types";
|
||||
|
||||
describe("native diff text runs", () => {
|
||||
it("positions adjacent token text without redrawing through clipped color bands", () => {
|
||||
const fragment: DiffFragment = {
|
||||
start: 0,
|
||||
end: 6,
|
||||
text: "import",
|
||||
width: 42,
|
||||
top: 0,
|
||||
baseline: 14,
|
||||
graphemes: [..."import"].map((text, index) => ({
|
||||
start: index,
|
||||
end: index + 1,
|
||||
text,
|
||||
width: 7,
|
||||
})),
|
||||
};
|
||||
const cell: DiffCell = {
|
||||
type: "context",
|
||||
content: "import",
|
||||
lineNumber: 1,
|
||||
tokens: [
|
||||
{ start: 0, end: 2, color: "purple" },
|
||||
{ start: 2, end: 6, color: "blue" },
|
||||
],
|
||||
fragments: [fragment],
|
||||
reviewTarget: null,
|
||||
sourceIdentity: { hunkIndex: 0, lineIndex: 1, side: "new" },
|
||||
};
|
||||
|
||||
expect(nativeTextRuns(cell, fragment)).toEqual([
|
||||
{ color: "purple", left: 0, text: "im" },
|
||||
{ color: "blue", left: 14, text: "port" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { fragmentTextForRange } from "./model";
|
||||
import type { DiffCell, DiffFragment } from "./types";
|
||||
|
||||
export interface NativeTextRun {
|
||||
color: string;
|
||||
left: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Converts syntax ranges into disjoint positioned runs so native paints every glyph once. */
|
||||
export function nativeTextRuns(cell: DiffCell, fragment: DiffFragment): NativeTextRun[] {
|
||||
"worklet";
|
||||
return cell.tokens.flatMap((token) => {
|
||||
const start = Math.max(fragment.start, token.start);
|
||||
const end = Math.min(fragment.end, token.end);
|
||||
if (end <= start) return [];
|
||||
let left = 0;
|
||||
for (const grapheme of fragment.graphemes) {
|
||||
if (grapheme.end <= start) left += grapheme.width;
|
||||
}
|
||||
return [{ color: token.color, left, text: fragmentTextForRange(fragment, start, end) }];
|
||||
});
|
||||
}
|
||||
@@ -1,16 +1,30 @@
|
||||
import { ClipOp, Skia, type SkCanvas, type SkPaint } from "@shopify/react-native-skia";
|
||||
import {
|
||||
ClipOp,
|
||||
Skia,
|
||||
type SkCanvas,
|
||||
type SkPaint,
|
||||
type SkPicture,
|
||||
} from "@shopify/react-native-skia";
|
||||
import { visibleRowRange } from "./model";
|
||||
import { nativeTextRuns } from "./native-text-runs";
|
||||
import { horizontalOffsetForPath, type DiffHorizontalOffsets } from "./horizontal-offsets";
|
||||
import { codeLineNumberTone } from "./palette";
|
||||
import { reviewBackgroundPaint, reviewDividerHeight, reviewGapTop } from "./review-paint";
|
||||
import type { NativeTextLayout } from "./text.native";
|
||||
import type { DiffCell, DiffDocumentModel, DiffLineRow, DiffPalette } from "./types";
|
||||
import type {
|
||||
DiffCell,
|
||||
DiffDocumentModel,
|
||||
DiffFileSection,
|
||||
DiffLineRow,
|
||||
DiffPalette,
|
||||
} from "./types";
|
||||
|
||||
const CODE_LEFT_PADDING = 8;
|
||||
|
||||
interface NativePaints {
|
||||
export interface NativePaints {
|
||||
surface: SkPaint;
|
||||
border: SkPaint;
|
||||
foreground: SkPaint;
|
||||
foregroundMuted: SkPaint;
|
||||
addition: SkPaint;
|
||||
deletion: SkPaint;
|
||||
@@ -18,13 +32,14 @@ interface NativePaints {
|
||||
deletionBackground: SkPaint;
|
||||
headerSurface: SkPaint;
|
||||
emptyBackground: SkPaint;
|
||||
syntax: Record<string, SkPaint>;
|
||||
text: Record<string, SkPaint>;
|
||||
}
|
||||
|
||||
export function createNativePaints(palette: DiffPalette): NativePaints {
|
||||
return {
|
||||
surface: paint(palette.surface),
|
||||
border: paint(palette.border),
|
||||
foreground: paint(palette.foreground),
|
||||
foregroundMuted: paint(palette.foregroundMuted),
|
||||
addition: paint(palette.addition),
|
||||
deletion: paint(palette.deletion),
|
||||
@@ -32,7 +47,11 @@ export function createNativePaints(palette: DiffPalette): NativePaints {
|
||||
deletionBackground: paint(palette.deletionBackground),
|
||||
headerSurface: paint(palette.headerSurface),
|
||||
emptyBackground: paint(palette.emptyBackground),
|
||||
syntax: Object.fromEntries(Object.values(palette.syntax).map((color) => [color, paint(color)])),
|
||||
text: Object.fromEntries(
|
||||
[
|
||||
...new Set([palette.foreground, palette.foregroundMuted, ...Object.values(palette.syntax)]),
|
||||
].map((color) => [color, paint(color)]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,113 +71,308 @@ export function paintNativeViewport(input: {
|
||||
textLayout: NativeTextLayout;
|
||||
paints: NativePaints;
|
||||
}): void {
|
||||
"worklet";
|
||||
input.canvas.drawRect(
|
||||
Skia.XYWHRect(0, 0, input.viewportWidth, input.viewportHeight),
|
||||
input.paints.surface,
|
||||
paintNativeRange({ ...input, layer: "all" });
|
||||
}
|
||||
|
||||
export function recordNativeSlabPictures(input: {
|
||||
model: DiffDocumentModel;
|
||||
fileIndex: number;
|
||||
top: number;
|
||||
height: number;
|
||||
viewportWidth: number;
|
||||
textLayout: NativeTextLayout;
|
||||
paints: NativePaints;
|
||||
}): {
|
||||
fixed: SkPicture;
|
||||
gutter: SkPicture;
|
||||
content: { unified: SkPicture; left: SkPicture; right: SkPicture };
|
||||
} {
|
||||
const file = input.model.files[input.fileIndex];
|
||||
const fixedRecorder = Skia.PictureRecorder();
|
||||
const fixedCanvas = fixedRecorder.beginRecording(
|
||||
Skia.XYWHRect(0, 0, input.viewportWidth, input.height),
|
||||
);
|
||||
paintNativeRange({
|
||||
canvas: fixedCanvas,
|
||||
model: input.model,
|
||||
viewportWidth: input.viewportWidth,
|
||||
viewportHeight: input.height,
|
||||
scrollTop: input.top,
|
||||
horizontalOffsets: {},
|
||||
textLayout: input.textLayout,
|
||||
paints: input.paints,
|
||||
layer: "fixed",
|
||||
});
|
||||
|
||||
const recordContent = (contentCell: "unified" | "left" | "right") => {
|
||||
const recorder = Skia.PictureRecorder();
|
||||
const canvas = recorder.beginRecording(
|
||||
Skia.XYWHRect(0, 0, Math.max(input.viewportWidth, file?.contentWidth ?? 0), input.height),
|
||||
);
|
||||
paintNativeRange({
|
||||
canvas,
|
||||
model: input.model,
|
||||
viewportWidth: input.viewportWidth,
|
||||
viewportHeight: input.height,
|
||||
scrollTop: input.top,
|
||||
horizontalOffsets: {},
|
||||
textLayout: input.textLayout,
|
||||
paints: input.paints,
|
||||
layer: "content",
|
||||
contentCell,
|
||||
});
|
||||
return recorder.finishRecordingAsPicture();
|
||||
};
|
||||
const gutterRecorder = Skia.PictureRecorder();
|
||||
const gutterCanvas = gutterRecorder.beginRecording(
|
||||
Skia.XYWHRect(0, 0, input.viewportWidth, input.height),
|
||||
);
|
||||
paintNativeGutter({
|
||||
canvas: gutterCanvas,
|
||||
model: input.model,
|
||||
top: input.top,
|
||||
height: input.height,
|
||||
textLayout: input.textLayout,
|
||||
paints: input.paints,
|
||||
});
|
||||
return {
|
||||
fixed: fixedRecorder.finishRecordingAsPicture(),
|
||||
gutter: gutterRecorder.finishRecordingAsPicture(),
|
||||
content: {
|
||||
unified: recordContent("unified"),
|
||||
left: recordContent("left"),
|
||||
right: recordContent("right"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function paintNativeGutter(input: {
|
||||
canvas: SkCanvas;
|
||||
model: DiffDocumentModel;
|
||||
top: number;
|
||||
height: number;
|
||||
textLayout: NativeTextLayout;
|
||||
paints: NativePaints;
|
||||
}): void {
|
||||
const range = visibleRowRange(input.model.rows, input.top, input.height);
|
||||
for (let index = range.start; index < range.end; index += 1) {
|
||||
const row = input.model.rows[index];
|
||||
if (!row || row.kind !== "line") continue;
|
||||
const file = input.model.files[row.fileIndex];
|
||||
if (!file) continue;
|
||||
const y = row.top - input.top;
|
||||
const columnWidth = input.model.viewportWidth / row.cells.length;
|
||||
row.cells.forEach((cell, cellIndex) => {
|
||||
if (!cell) return;
|
||||
const columnX = cellIndex * columnWidth;
|
||||
input.canvas.drawRect(
|
||||
Skia.XYWHRect(columnX + file.gutterWidth, y, 1, reviewDividerHeight(row.height)),
|
||||
input.paints.border,
|
||||
);
|
||||
const label = String(cell.lineNumber ?? "");
|
||||
const font = input.textLayout.font;
|
||||
input.canvas.drawText(
|
||||
label,
|
||||
columnX + file.gutterWidth - 7 - font.getTextWidth(label),
|
||||
y + input.model.lineHeight * 0.78,
|
||||
input.paints[codeLineNumberTone(cell)],
|
||||
font,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface PaintNativeRangeInput {
|
||||
canvas: SkCanvas;
|
||||
model: DiffDocumentModel;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
scrollTop: number;
|
||||
horizontalOffsets: Readonly<DiffHorizontalOffsets>;
|
||||
textLayout: NativeTextLayout;
|
||||
paints: NativePaints;
|
||||
layer: "all" | "fixed" | "content";
|
||||
contentCell?: "unified" | "left" | "right";
|
||||
}
|
||||
|
||||
function paintNativeRange(input: PaintNativeRangeInput): void {
|
||||
"worklet";
|
||||
const paintsFixedContent = input.layer !== "content";
|
||||
const paintsCodeContent = input.layer !== "fixed";
|
||||
if (paintsFixedContent) {
|
||||
input.canvas.drawRect(
|
||||
Skia.XYWHRect(0, 0, input.viewportWidth, input.viewportHeight),
|
||||
input.paints.surface,
|
||||
);
|
||||
}
|
||||
const backgroundPaints: Record<DiffCell["type"], SkPaint> = {
|
||||
add: input.paints.additionBackground,
|
||||
context: input.paints.surface,
|
||||
empty: input.paints.emptyBackground,
|
||||
header: input.paints.headerSurface,
|
||||
remove: input.paints.deletionBackground,
|
||||
};
|
||||
const range = visibleRowRange(input.model.rows, input.scrollTop, input.viewportHeight);
|
||||
for (let index = range.start; index < range.end; index += 1) {
|
||||
const row = input.model.rows[index];
|
||||
if (!row) continue;
|
||||
const y = row.top - input.scrollTop;
|
||||
if (row.kind === "status") {
|
||||
input.canvas.drawText(
|
||||
row.label,
|
||||
12,
|
||||
y + input.model.lineHeight,
|
||||
input.paints.foregroundMuted,
|
||||
input.textLayout.font,
|
||||
);
|
||||
if (paintsFixedContent) {
|
||||
input.canvas.drawText(
|
||||
row.label,
|
||||
12,
|
||||
y + input.model.lineHeight,
|
||||
input.paints.foregroundMuted,
|
||||
input.textLayout.font,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
paintLine({
|
||||
...input,
|
||||
row,
|
||||
y,
|
||||
horizontalOffset: horizontalOffsetForPath(input.horizontalOffsets, row.path),
|
||||
const file = input.model.files[row.fileIndex];
|
||||
if (!file) continue;
|
||||
const horizontalOffset = horizontalOffsetForPath(input.horizontalOffsets, row.path);
|
||||
const columnWidth = input.viewportWidth / row.cells.length;
|
||||
row.cells.forEach((cell, cellIndex) => {
|
||||
const columnX = cellIndex * columnWidth;
|
||||
if (paintsFixedContent) {
|
||||
paintNativeFixedCell({ input, row, file, cell, columnX, columnWidth, y, backgroundPaints });
|
||||
}
|
||||
if (
|
||||
paintsCodeContent &&
|
||||
cell &&
|
||||
paintsNativeContentCell(input, row.cells.length, cellIndex)
|
||||
) {
|
||||
paintNativeCodeCell({
|
||||
input,
|
||||
row,
|
||||
file,
|
||||
cell,
|
||||
cellIndex,
|
||||
columnX,
|
||||
columnWidth,
|
||||
horizontalOffset,
|
||||
y,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function paintLine(input: {
|
||||
canvas: SkCanvas;
|
||||
model: DiffDocumentModel;
|
||||
viewportWidth: number;
|
||||
function paintsNativeContentCell(
|
||||
input: PaintNativeRangeInput,
|
||||
columnCount: number,
|
||||
cellIndex: number,
|
||||
): boolean {
|
||||
if (input.layer !== "content") return true;
|
||||
if (columnCount === 1) return input.contentCell === "unified";
|
||||
return input.contentCell === (cellIndex === 0 ? "left" : "right");
|
||||
}
|
||||
|
||||
function paintNativeFixedCell(input: {
|
||||
input: PaintNativeRangeInput;
|
||||
row: DiffLineRow;
|
||||
file: DiffFileSection;
|
||||
cell: DiffCell | null;
|
||||
columnX: number;
|
||||
columnWidth: number;
|
||||
y: number;
|
||||
horizontalOffset: number;
|
||||
textLayout: NativeTextLayout;
|
||||
paints: NativePaints;
|
||||
backgroundPaints: Record<DiffCell["type"], SkPaint>;
|
||||
}): void {
|
||||
"worklet";
|
||||
const file = input.model.files[input.row.fileIndex];
|
||||
if (!file) return;
|
||||
const columnWidth = input.viewportWidth / input.row.cells.length;
|
||||
input.row.cells.forEach((cell, cellIndex) => {
|
||||
const columnX = cellIndex * columnWidth;
|
||||
input.canvas.drawRect(
|
||||
Skia.XYWHRect(columnX, input.y, columnWidth, input.row.height),
|
||||
background(cell, input.paints),
|
||||
);
|
||||
if (input.row.reviewHeight > 0) {
|
||||
input.canvas.drawRect(
|
||||
Skia.XYWHRect(
|
||||
columnX,
|
||||
reviewGapTop(input.y, input.row.height, input.row.reviewHeight),
|
||||
columnWidth,
|
||||
input.row.reviewHeight,
|
||||
),
|
||||
reviewBackgroundPaint(input.paints.surface),
|
||||
);
|
||||
}
|
||||
if (!cell) return;
|
||||
input.canvas.drawRect(
|
||||
Skia.XYWHRect(columnX + file.gutterWidth, input.y, 1, reviewDividerHeight(input.row.height)),
|
||||
input.paints.border,
|
||||
);
|
||||
if (cell.lineNumber !== null) {
|
||||
const label = String(cell.lineNumber);
|
||||
const font = input.textLayout.font;
|
||||
input.canvas.drawText(
|
||||
label,
|
||||
columnX + file.gutterWidth - 7 - font.getTextWidth(label),
|
||||
input.y + input.model.lineHeight * 0.78,
|
||||
lineNumberPaint(cell, input.paints),
|
||||
font,
|
||||
);
|
||||
}
|
||||
const textX = columnX + file.gutterWidth + CODE_LEFT_PADDING;
|
||||
input.canvas.save();
|
||||
input.canvas.clipRect(
|
||||
const backgroundPaint = input.cell
|
||||
? input.backgroundPaints[input.cell.type]
|
||||
: input.input.paints.emptyBackground;
|
||||
input.input.canvas.drawRect(
|
||||
Skia.XYWHRect(input.columnX, input.y, input.columnWidth, input.row.height),
|
||||
backgroundPaint,
|
||||
);
|
||||
input.input.canvas.drawRect(
|
||||
Skia.XYWHRect(
|
||||
input.columnX,
|
||||
reviewGapTop(input.y, input.row.height, input.row.reviewHeight),
|
||||
input.columnWidth,
|
||||
input.row.reviewHeight,
|
||||
),
|
||||
reviewBackgroundPaint(input.input.paints.surface),
|
||||
);
|
||||
if (!input.cell) return;
|
||||
input.input.canvas.drawRect(
|
||||
Skia.XYWHRect(
|
||||
input.columnX + input.file.gutterWidth,
|
||||
input.y,
|
||||
1,
|
||||
reviewDividerHeight(input.row.height),
|
||||
),
|
||||
input.input.paints.border,
|
||||
);
|
||||
const label = String(input.cell.lineNumber ?? "");
|
||||
const font = input.input.textLayout.font;
|
||||
input.input.canvas.drawText(
|
||||
label,
|
||||
input.columnX + input.file.gutterWidth - 7 - font.getTextWidth(label),
|
||||
input.y + input.input.model.lineHeight * 0.78,
|
||||
input.input.paints[codeLineNumberTone(input.cell)],
|
||||
font,
|
||||
);
|
||||
}
|
||||
|
||||
function paintNativeCodeCell(input: {
|
||||
input: PaintNativeRangeInput;
|
||||
row: DiffLineRow;
|
||||
file: DiffFileSection;
|
||||
cell: DiffCell;
|
||||
cellIndex: number;
|
||||
columnX: number;
|
||||
columnWidth: number;
|
||||
horizontalOffset: number;
|
||||
y: number;
|
||||
}): void {
|
||||
const textX = input.columnX + input.file.gutterWidth + CODE_LEFT_PADDING;
|
||||
const clipsContent = input.input.layer === "all";
|
||||
if (clipsContent) {
|
||||
input.input.canvas.save();
|
||||
input.input.canvas.clipRect(
|
||||
Skia.XYWHRect(
|
||||
textX,
|
||||
input.y,
|
||||
columnWidth - file.gutterWidth - CODE_LEFT_PADDING,
|
||||
input.columnWidth - input.file.gutterWidth - CODE_LEFT_PADDING,
|
||||
input.row.height,
|
||||
),
|
||||
ClipOp.Intersect,
|
||||
false,
|
||||
);
|
||||
const offset = input.model.wrapLines ? 0 : input.horizontalOffset;
|
||||
for (const [fragmentIndex, fragment] of cell.fragments.entries()) {
|
||||
const paragraph = input.textLayout.paragraphs[input.row.index]?.[cellIndex]?.[fragmentIndex];
|
||||
paragraph?.paint(input.canvas, textX - offset, input.y + fragment.top);
|
||||
}
|
||||
const offset = input.input.model.wrapLines ? 0 : input.horizontalOffset;
|
||||
for (const [fragmentIndex, fragment] of input.cell.fragments.entries()) {
|
||||
const paragraph =
|
||||
input.input.textLayout.paragraphs[input.row.index]?.[input.cellIndex]?.[fragmentIndex];
|
||||
if (paragraph) {
|
||||
paragraph.paint(input.input.canvas, textX - offset, input.y + fragment.top);
|
||||
continue;
|
||||
}
|
||||
input.canvas.restore();
|
||||
});
|
||||
}
|
||||
|
||||
function background(cell: DiffCell | null, paints: NativePaints): SkPaint {
|
||||
"worklet";
|
||||
if (!cell) return paints.emptyBackground;
|
||||
if (cell.type === "add") return paints.additionBackground;
|
||||
if (cell.type === "remove") return paints.deletionBackground;
|
||||
if (cell.type === "header") return paints.headerSurface;
|
||||
return paints.surface;
|
||||
}
|
||||
|
||||
function lineNumberPaint(cell: DiffCell, paints: NativePaints): SkPaint {
|
||||
"worklet";
|
||||
return paints[codeLineNumberTone(cell)];
|
||||
const fragmentX = textX - offset;
|
||||
const baseline = input.y + fragment.baseline;
|
||||
if (input.cell.tokens.length === 0 || input.cell.type === "header") {
|
||||
input.input.canvas.drawText(
|
||||
fragment.text,
|
||||
fragmentX,
|
||||
baseline,
|
||||
input.cell.type === "header"
|
||||
? input.input.paints.foregroundMuted
|
||||
: input.input.paints.foreground,
|
||||
input.input.textLayout.font,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for (const run of nativeTextRuns(input.cell, fragment)) {
|
||||
input.input.canvas.drawText(
|
||||
run.text,
|
||||
fragmentX + run.left,
|
||||
baseline,
|
||||
input.input.paints.text[run.color] ?? input.input.paints.foreground,
|
||||
input.input.textLayout.font,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (clipsContent) input.input.canvas.restore();
|
||||
}
|
||||
|
||||
@@ -26,19 +26,27 @@ export interface PaintWebViewportInput {
|
||||
horizontalOffsets: ReadonlyMap<string, number>;
|
||||
selection: DiffSelection | null;
|
||||
devicePixelRatio: number;
|
||||
paintTop?: number;
|
||||
paintHeight?: number;
|
||||
}
|
||||
|
||||
export function paintWebViewport(input: PaintWebViewportInput): void {
|
||||
const { context } = input;
|
||||
const scale = input.devicePixelRatio;
|
||||
const paintTop = input.paintTop ?? 0;
|
||||
const paintHeight = input.paintHeight ?? input.viewportHeight;
|
||||
context.setTransform(scale, 0, 0, scale, 0, 0);
|
||||
context.clearRect(0, 0, input.viewportWidth, input.viewportHeight);
|
||||
context.save();
|
||||
context.beginPath();
|
||||
context.rect(0, paintTop, input.viewportWidth, paintHeight);
|
||||
context.clip();
|
||||
context.clearRect(0, paintTop, input.viewportWidth, paintHeight);
|
||||
context.fillStyle = input.palette.surface;
|
||||
context.fillRect(0, 0, input.viewportWidth, input.viewportHeight);
|
||||
context.fillRect(0, paintTop, input.viewportWidth, paintHeight);
|
||||
context.font = `${input.typography.size}px ${input.typography.family}`;
|
||||
context.textBaseline = "alphabetic";
|
||||
|
||||
const range = visibleRowRange(input.model.rows, input.scrollTop, input.viewportHeight);
|
||||
const range = visibleRowRange(input.model.rows, input.scrollTop + paintTop, paintHeight);
|
||||
for (let index = range.start; index < range.end; index += 1) {
|
||||
const row = input.model.rows[index];
|
||||
if (!row) continue;
|
||||
@@ -57,6 +65,7 @@ export function paintWebViewport(input: PaintWebViewportInput): void {
|
||||
}
|
||||
|
||||
if (input.selection) paintSelection(input, input.selection);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function paintLine(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { codeLineNumberTone, codeTextColor } from "./palette";
|
||||
import { darkTheme, lightTheme } from "@/styles/theme";
|
||||
import { hexColorWithAlpha } from "@/utils/color";
|
||||
import { codeLineNumberTone, codeTextColor, createDiffPalette, retainDiffPalette } from "./palette";
|
||||
import type { DiffCell, DiffPalette } from "./types";
|
||||
|
||||
describe("diff text color", () => {
|
||||
@@ -19,6 +21,32 @@ describe("diff text color", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("diff palette retention", () => {
|
||||
it("retains the previous value when a theme wrapper recreates equal colors", () => {
|
||||
const recreated = { ...palette, syntax: { keyword: "purple" } };
|
||||
const previous = { ...recreated, syntax: { keyword: "purple" } };
|
||||
|
||||
expect(retainDiffPalette(previous, recreated)).toBe(previous);
|
||||
});
|
||||
|
||||
it("accepts a real color change", () => {
|
||||
const changed = { ...palette, foreground: "new-foreground" };
|
||||
|
||||
expect(retainDiffPalette(palette, changed)).toBe(changed);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([lightTheme, darkTheme])("semantic diff colors", (theme) => {
|
||||
it("uses the app status palette for gutter text and derived row tints", () => {
|
||||
const created = createDiffPalette(theme);
|
||||
|
||||
expect(created.addition).toBe(theme.colors.statusSuccess);
|
||||
expect(created.deletion).toBe(theme.colors.statusDanger);
|
||||
expect(created.additionBackground).toBe(hexColorWithAlpha(theme.colors.statusSuccess, 0.15));
|
||||
expect(created.deletionBackground).toBe(hexColorWithAlpha(theme.colors.statusDanger, 0.1));
|
||||
});
|
||||
});
|
||||
|
||||
const palette: DiffPalette = {
|
||||
surface: "surface",
|
||||
headerSurface: "header",
|
||||
|
||||
@@ -1,23 +1,50 @@
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { hexColorWithAlpha } from "@/utils/color";
|
||||
import type { DiffCell, DiffPalette } from "./types";
|
||||
|
||||
export function createDiffPalette(theme: Theme): DiffPalette {
|
||||
return {
|
||||
surface: theme.colors.surface1,
|
||||
headerSurface: theme.colors.surface2,
|
||||
surface: theme.colors.surface0,
|
||||
headerSurface: theme.colors.surface0,
|
||||
border: theme.colors.border,
|
||||
foreground: theme.colors.foreground,
|
||||
foregroundMuted: theme.colors.foregroundMuted,
|
||||
addition: theme.colors.diffAddition,
|
||||
deletion: theme.colors.diffDeletion,
|
||||
additionBackground: "rgba(46, 160, 67, 0.15)",
|
||||
deletionBackground: "rgba(248, 81, 73, 0.1)",
|
||||
emptyBackground: theme.colors.surfaceDiffEmpty,
|
||||
selection: theme.colors.surfaceSidebarHover,
|
||||
addition: theme.colors.statusSuccess,
|
||||
deletion: theme.colors.statusDanger,
|
||||
additionBackground: hexColorWithAlpha(theme.colors.statusSuccess, 0.15),
|
||||
deletionBackground: hexColorWithAlpha(theme.colors.statusDanger, 0.1),
|
||||
emptyBackground: theme.colors.surface0,
|
||||
selection: theme.colors.terminal.blue,
|
||||
syntax: theme.colors.syntax,
|
||||
};
|
||||
}
|
||||
|
||||
export function retainDiffPalette(previous: DiffPalette, next: DiffPalette): DiffPalette {
|
||||
if (
|
||||
previous.surface !== next.surface ||
|
||||
previous.headerSurface !== next.headerSurface ||
|
||||
previous.border !== next.border ||
|
||||
previous.foreground !== next.foreground ||
|
||||
previous.foregroundMuted !== next.foregroundMuted ||
|
||||
previous.addition !== next.addition ||
|
||||
previous.deletion !== next.deletion ||
|
||||
previous.additionBackground !== next.additionBackground ||
|
||||
previous.deletionBackground !== next.deletionBackground ||
|
||||
previous.emptyBackground !== next.emptyBackground ||
|
||||
previous.selection !== next.selection ||
|
||||
!sameColorMap(previous.syntax, next.syntax)
|
||||
) {
|
||||
return next;
|
||||
}
|
||||
return previous;
|
||||
}
|
||||
|
||||
function sameColorMap(left: Record<string, string>, right: Record<string, string>): boolean {
|
||||
const leftKeys = Object.keys(left);
|
||||
const rightKeys = Object.keys(right);
|
||||
return leftKeys.length === rightKeys.length && leftKeys.every((key) => left[key] === right[key]);
|
||||
}
|
||||
|
||||
export function codeTextColor(cell: DiffCell, palette: DiffPalette): string {
|
||||
return cell.type === "header" ? palette.foregroundMuted : palette.foreground;
|
||||
}
|
||||
|
||||
@@ -1,39 +1,72 @@
|
||||
import { Canvas, Picture, Skia } from "@shopify/react-native-skia";
|
||||
import { Canvas, Picture, type SkPicture } from "@shopify/react-native-skia";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, StyleSheet, View, type LayoutChangeEvent, type ViewStyle } from "react-native";
|
||||
import {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type LayoutChangeEvent,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import {
|
||||
createAnimatedComponent,
|
||||
useAnimatedReaction,
|
||||
useAnimatedScrollHandler,
|
||||
useDerivedValue,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
type SharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { InlineReviewThread } from "@/review";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { DocumentFileHeader } from "./document-file-header";
|
||||
import { hitTestDiffPagePoint, type SurfaceWindowOrigin } from "./native-hit-testing";
|
||||
import { hitTestDiffBodyPoint } from "./native-hit-testing";
|
||||
import { HorizontalScroll } from "./horizontal-scroll.native";
|
||||
import {
|
||||
horizontalOffsetForPath,
|
||||
retainHorizontalOffsetsForPaths,
|
||||
type DiffHorizontalOffsets,
|
||||
} from "./horizontal-offsets";
|
||||
import { buildDiffDocumentModel, captureScrollAnchor, resolveScrollAnchor } from "./model";
|
||||
import { createNativePaints, paintNativeViewport } from "./paint.native";
|
||||
import { createNativeTextLayout, createNativeTextMeasurer } from "./text.native";
|
||||
import { InlineReviewThread } from "@/review";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import type { DiffSurfaceProps, DiffTypography, TextMeasurer } from "./types";
|
||||
import { buildDiffDocumentModel, resolveRelayoutScrollTop, retainReusableModels } from "./model";
|
||||
import {
|
||||
buildNativeCanvasSlabs,
|
||||
nativeCanvasSlabsForViewport,
|
||||
nativeCanvasWindowBucket,
|
||||
nativeCanvasWindowTop,
|
||||
nativeStickyHeaderIndices,
|
||||
type NativeCanvasSlab,
|
||||
} from "./native-slabs";
|
||||
import { createNativePaints, recordNativeSlabPictures, type NativePaints } from "./paint.native";
|
||||
import {
|
||||
createNativeTextLayoutStore,
|
||||
createNativeTextMeasurer,
|
||||
disposeNativeTextLayout,
|
||||
prepareNativeTextLayout,
|
||||
type NativeTextLayout,
|
||||
} from "./text.native";
|
||||
import type {
|
||||
DiffDocumentModel,
|
||||
DiffFileSection,
|
||||
DiffSurfaceProps,
|
||||
DiffTypography,
|
||||
TextMeasurer,
|
||||
} from "./types";
|
||||
|
||||
const AnimatedScrollView = createAnimatedComponent(ScrollView);
|
||||
const AnimatedCodeLayer = createAnimatedComponent(View);
|
||||
const SYSTEM_MONO = "monospace";
|
||||
const CODE_LEFT_PADDING = 8;
|
||||
|
||||
export function DiffSurface(props: DiffSurfaceProps) {
|
||||
const { t } = useTranslation();
|
||||
const [viewport, setViewport] = useState({ width: 0, height: 0 });
|
||||
const touchRef = useRef<{ x: number; y: number; startedAt: number; moved: boolean } | null>(null);
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const rootRef = useRef<View>(null);
|
||||
const surfaceOriginRef = useRef<SurfaceWindowOrigin>({ x: 0, y: 0 });
|
||||
const previousModelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
|
||||
const reusableModelRef = useRef<{
|
||||
dependencies: readonly unknown[];
|
||||
models: ReturnType<typeof buildDiffDocumentModel>[];
|
||||
} | null>(null);
|
||||
const consumedFocusRef = useRef<string | null>(null);
|
||||
const scrollTop = useSharedValue(0);
|
||||
const horizontalOffsets = useSharedValue<DiffHorizontalOffsets>({});
|
||||
@@ -51,81 +84,89 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
[family, typography.size],
|
||||
);
|
||||
const reviewActions = props.mode.kind === "working" ? props.mode.reviewActions : undefined;
|
||||
const model = useMemo(
|
||||
() =>
|
||||
buildDiffDocumentModel({
|
||||
files: props.files,
|
||||
collapsedFilePaths: props.collapsedFilePaths,
|
||||
layout: props.displayPreferences.layout,
|
||||
wrapLines: props.displayPreferences.wrapLines,
|
||||
viewportWidth: viewport.width,
|
||||
typography,
|
||||
measureText: measurement,
|
||||
palette: props.palette,
|
||||
reviewActions,
|
||||
labels: {
|
||||
binary: t("workspace.git.diff.binaryFile"),
|
||||
tooLarge: t("workspace.git.diff.tooLarge"),
|
||||
},
|
||||
}),
|
||||
[
|
||||
measurement,
|
||||
props.collapsedFilePaths,
|
||||
const model = useMemo(() => {
|
||||
const dependencies = [
|
||||
props.files,
|
||||
props.displayPreferences.layout,
|
||||
props.displayPreferences.wrapLines,
|
||||
props.files,
|
||||
viewport.width,
|
||||
typography,
|
||||
measurement,
|
||||
props.palette,
|
||||
reviewActions,
|
||||
t,
|
||||
] as const;
|
||||
const previous = reusableModelRef.current;
|
||||
const canReuse = previous?.dependencies.every(
|
||||
(dependency, index) => dependency === dependencies[index],
|
||||
);
|
||||
const reuseFrom = canReuse ? previous?.models : undefined;
|
||||
const next = buildDiffDocumentModel({
|
||||
files: props.files,
|
||||
collapsedFilePaths: props.collapsedFilePaths,
|
||||
layout: props.displayPreferences.layout,
|
||||
wrapLines: props.displayPreferences.wrapLines,
|
||||
viewportWidth: viewport.width,
|
||||
typography,
|
||||
viewport.width,
|
||||
],
|
||||
);
|
||||
measureText: measurement,
|
||||
palette: props.palette,
|
||||
reviewActions,
|
||||
labels: {
|
||||
binary: t("workspace.git.diff.binaryFile"),
|
||||
tooLarge: t("workspace.git.diff.tooLarge"),
|
||||
},
|
||||
reuseFrom,
|
||||
});
|
||||
reusableModelRef.current = {
|
||||
dependencies,
|
||||
models: retainReusableModels(reuseFrom, next),
|
||||
};
|
||||
return next;
|
||||
}, [
|
||||
measurement,
|
||||
props.collapsedFilePaths,
|
||||
props.displayPreferences.layout,
|
||||
props.displayPreferences.wrapLines,
|
||||
props.files,
|
||||
props.palette,
|
||||
reviewActions,
|
||||
t,
|
||||
typography,
|
||||
viewport.width,
|
||||
]);
|
||||
const paints = useMemo(() => createNativePaints(props.palette), [props.palette]);
|
||||
const textLayout = useMemo(
|
||||
const textLayoutStore = useMemo(
|
||||
() =>
|
||||
createNativeTextLayout({
|
||||
model,
|
||||
createNativeTextLayoutStore({
|
||||
configuredFamily: family,
|
||||
fontSize: typography.size,
|
||||
lineHeight: typography.lineHeight,
|
||||
palette: props.palette,
|
||||
}),
|
||||
[family, model, props.palette, typography.size],
|
||||
[family, props.palette, typography.lineHeight, typography.size],
|
||||
);
|
||||
const textLayout = useMemo(
|
||||
() => prepareNativeTextLayout(textLayoutStore, model),
|
||||
[model, textLayoutStore],
|
||||
);
|
||||
useEffect(() => () => disposeNativeTextLayout(textLayoutStore), [textLayoutStore]);
|
||||
|
||||
const scrollHandler = useAnimatedScrollHandler((event) => {
|
||||
scrollTop.value = event.contentOffset.y;
|
||||
});
|
||||
const picture = useDerivedValue(() => {
|
||||
const recorder = Skia.PictureRecorder();
|
||||
const canvas = recorder.beginRecording(Skia.XYWHRect(0, 0, viewport.width, viewport.height));
|
||||
paintNativeViewport({
|
||||
canvas,
|
||||
model,
|
||||
viewportWidth: viewport.width,
|
||||
viewportHeight: viewport.height,
|
||||
scrollTop: scrollTop.value,
|
||||
horizontalOffsets: horizontalOffsets.value,
|
||||
textLayout,
|
||||
paints,
|
||||
});
|
||||
return recorder.finishRecordingAsPicture();
|
||||
}, [model, paints, textLayout, viewport.height, viewport.width]);
|
||||
|
||||
const layout = useCallback((event: LayoutChangeEvent) => {
|
||||
const { width, height } = event.nativeEvent.layout;
|
||||
setViewport({ width, height });
|
||||
rootRef.current?.measureInWindow((x, y) => {
|
||||
surfaceOriginRef.current = { x, y };
|
||||
});
|
||||
setViewport((current) =>
|
||||
current.width === width && current.height === height ? current : { width, height },
|
||||
);
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
const previous = previousModelRef.current;
|
||||
if (previous && previous !== model) {
|
||||
const anchor = captureScrollAnchor(previous, scrollTop.value);
|
||||
if (anchor) {
|
||||
const nextScrollTop = resolveScrollAnchor(model, anchor);
|
||||
scrollTop.value = nextScrollTop;
|
||||
scrollRef.current?.scrollTo({ y: nextScrollTop, animated: false });
|
||||
}
|
||||
const nextScrollTop = resolveRelayoutScrollTop(previous, model, scrollTop.value);
|
||||
scrollTop.value = nextScrollTop;
|
||||
scrollRef.current?.scrollTo({ y: nextScrollTop, animated: false });
|
||||
}
|
||||
previousModelRef.current = model;
|
||||
}, [model, scrollTop]);
|
||||
@@ -135,6 +176,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
model.files.map((file) => file.path),
|
||||
);
|
||||
}, [horizontalOffsets, model.files]);
|
||||
|
||||
const mode = props.mode;
|
||||
const collapsedFilePaths = props.collapsedFilePaths;
|
||||
const onToggleFile = props.onToggleFile;
|
||||
@@ -147,50 +189,22 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
if (collapsedFilePaths.has(focusPath)) onToggleFile(focusPath);
|
||||
const file = model.files.find((entry) => entry.path === focusPath);
|
||||
if (file) {
|
||||
scrollTop.value = file.top;
|
||||
scrollRef.current?.scrollTo({ y: file.top, animated: false });
|
||||
consumedFocusRef.current = requestKey;
|
||||
}
|
||||
}, [collapsedFilePaths, mode, model.files, onToggleFile]);
|
||||
const touchStart = useCallback((event: { nativeEvent: { pageX: number; pageY: number } }) => {
|
||||
touchRef.current = {
|
||||
x: event.nativeEvent.pageX,
|
||||
y: event.nativeEvent.pageY,
|
||||
startedAt: Date.now(),
|
||||
moved: false,
|
||||
};
|
||||
}, []);
|
||||
const touchMove = useCallback((event: { nativeEvent: { pageX: number; pageY: number } }) => {
|
||||
const touch = touchRef.current;
|
||||
if (
|
||||
touch &&
|
||||
Math.hypot(event.nativeEvent.pageX - touch.x, event.nativeEvent.pageY - touch.y) > 10
|
||||
)
|
||||
touch.moved = true;
|
||||
}, []);
|
||||
const touchEnd = useCallback(
|
||||
(event: { nativeEvent: { pageX: number; pageY: number } }) => {
|
||||
const touch = touchRef.current;
|
||||
touchRef.current = null;
|
||||
if (!touch || touch.moved || Date.now() - touch.startedAt > 500 || !reviewActions) return;
|
||||
const hit = hitTestDiffPagePoint({
|
||||
model,
|
||||
pageX: event.nativeEvent.pageX,
|
||||
pageY: event.nativeEvent.pageY,
|
||||
surfaceOrigin: surfaceOriginRef.current,
|
||||
scrollTop: scrollTop.value,
|
||||
horizontalOffsetForPath: (path) => horizontalOffsetForPath(horizontalOffsets.value, path),
|
||||
});
|
||||
if (hit?.kind === "cell" && hit.target) reviewActions.onStartComment(hit.target);
|
||||
},
|
||||
[horizontalOffsets, model, reviewActions, scrollTop],
|
||||
}, [collapsedFilePaths, mode, model.files, onToggleFile, scrollTop]);
|
||||
const contentStyle = useMemo(
|
||||
() => ({ minHeight: viewport.height, backgroundColor: "transparent" }),
|
||||
[viewport.height],
|
||||
);
|
||||
const contentStyle = useMemo(() => ({ minHeight: viewport.height }), [viewport.height]);
|
||||
const stickyHeaderIndices = useMemo(
|
||||
() => nativeStickyHeaderIndices(model.files.length),
|
||||
[model.files.length],
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={rootRef}
|
||||
style={[styles.root, { backgroundColor: props.palette.surface }]}
|
||||
onLayout={layout}
|
||||
>
|
||||
<View style={[styles.root, { backgroundColor: props.palette.surface }]} onLayout={layout}>
|
||||
<AnimatedScrollView
|
||||
ref={scrollRef}
|
||||
style={[StyleSheet.absoluteFill, styles.scroll]}
|
||||
@@ -198,90 +212,372 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
onScroll={scrollHandler}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator
|
||||
stickyHeaderIndices={model.files.map((_, index) => index * 2)}
|
||||
stickyHeaderIndices={stickyHeaderIndices}
|
||||
testID="git-diff-scroll"
|
||||
onTouchStart={touchStart}
|
||||
onTouchMove={touchMove}
|
||||
onTouchEnd={touchEnd}
|
||||
>
|
||||
{model.files.flatMap((file) => [
|
||||
<View key={`${file.path}:header`} style={styles.header}>
|
||||
<DocumentFileHeader
|
||||
file={file}
|
||||
selectedPath={props.selectedPath}
|
||||
mode={props.mode}
|
||||
onToggleFile={props.onToggleFile}
|
||||
onSelectPath={props.onSelectPath}
|
||||
/>
|
||||
</View>,
|
||||
<View
|
||||
<NativeCanvasSlabLayer
|
||||
model={model}
|
||||
viewportHeight={viewport.height}
|
||||
scrollTop={scrollTop}
|
||||
textLayout={textLayout}
|
||||
paints={paints}
|
||||
horizontalOffsets={horizontalOffsets}
|
||||
/>
|
||||
{model.files.flatMap((file, index) => [
|
||||
<NativeStickyFileHeader
|
||||
key={`${file.path}:header`}
|
||||
file={file}
|
||||
showTopBorder={index > 0 && !model.files[index - 1]?.isCollapsed}
|
||||
selectedPath={props.selectedPath}
|
||||
mode={props.mode}
|
||||
onToggleFile={props.onToggleFile}
|
||||
onSelectPath={props.onSelectPath}
|
||||
/>,
|
||||
<NativeFileBody
|
||||
key={`${file.path}:body`}
|
||||
testID={`diff-file-${file.fileIndex}-body`}
|
||||
style={inlineUnistylesStyle<ViewStyle>({
|
||||
position: "relative",
|
||||
height: file.bodyHeight,
|
||||
})}
|
||||
>
|
||||
{!file.isCollapsed && !model.wrapLines ? (
|
||||
<HorizontalScroll file={file} offsets={horizontalOffsets} />
|
||||
) : null}
|
||||
{props.mode.kind === "working" && reviewActions
|
||||
? model.rows.slice(file.rowStart, file.rowEnd).flatMap((row) => {
|
||||
if (row.kind !== "line" || row.reviewHeight === 0) return [];
|
||||
const columnWidth = model.viewportWidth / row.cells.length;
|
||||
return row.cells.flatMap((cell, index) => {
|
||||
if (!cell?.reviewTarget) return [];
|
||||
const comments =
|
||||
reviewActions.commentsByTarget.get(cell.reviewTarget.key) ?? [];
|
||||
const editor = reviewActions.editor;
|
||||
const hasEditor =
|
||||
editor?.target.filePath === cell.reviewTarget.filePath &&
|
||||
editor.target.side === cell.reviewTarget.side &&
|
||||
editor.target.lineNumber === cell.reviewTarget.lineNumber;
|
||||
if (comments.length === 0 && !hasEditor) return [];
|
||||
return [
|
||||
<View
|
||||
key={cell.reviewTarget.key}
|
||||
style={inlineUnistylesStyle<ViewStyle>({
|
||||
position: "absolute",
|
||||
top: row.top - file.bodyTop + row.height - row.reviewHeight,
|
||||
left: index * columnWidth,
|
||||
width: columnWidth,
|
||||
height: row.reviewHeight,
|
||||
zIndex: 4,
|
||||
})}
|
||||
>
|
||||
<InlineReviewThread
|
||||
reviewTarget={cell.reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
height={row.reviewHeight}
|
||||
viewportWidth={columnWidth}
|
||||
pinToViewport={!model.wrapLines}
|
||||
/>
|
||||
</View>,
|
||||
];
|
||||
});
|
||||
})
|
||||
: null}
|
||||
</View>,
|
||||
file={file}
|
||||
model={model}
|
||||
mode={props.mode}
|
||||
horizontalOffsets={horizontalOffsets}
|
||||
/>,
|
||||
])}
|
||||
<NativeReviewOverlays model={model} mode={props.mode} />
|
||||
</AnimatedScrollView>
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={[StyleSheet.absoluteFill, styles.canvas]}
|
||||
testID="git-diff-canvas"
|
||||
>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function NativeCanvasSlabLayer({
|
||||
model,
|
||||
viewportHeight,
|
||||
scrollTop,
|
||||
textLayout,
|
||||
paints,
|
||||
horizontalOffsets,
|
||||
}: {
|
||||
model: DiffDocumentModel;
|
||||
viewportHeight: number;
|
||||
scrollTop: SharedValue<number>;
|
||||
textLayout: NativeTextLayout;
|
||||
paints: NativePaints;
|
||||
horizontalOffsets: SharedValue<DiffHorizontalOffsets>;
|
||||
}) {
|
||||
const [windowTop, setWindowTop] = useState(0);
|
||||
const updateWindow = useCallback((nextTop: number) => {
|
||||
setWindowTop((current) => (current === nextTop ? current : nextTop));
|
||||
}, []);
|
||||
useAnimatedReaction(
|
||||
() => nativeCanvasWindowBucket(scrollTop.value, viewportHeight),
|
||||
(bucket, previous) => {
|
||||
if (bucket !== previous) {
|
||||
scheduleOnRN(updateWindow, nativeCanvasWindowTop(bucket, viewportHeight));
|
||||
}
|
||||
},
|
||||
[updateWindow, viewportHeight],
|
||||
);
|
||||
const descriptors = useMemo(
|
||||
() => buildNativeCanvasSlabs(model, viewportHeight),
|
||||
[model, viewportHeight],
|
||||
);
|
||||
const visible = useMemo(
|
||||
() => nativeCanvasSlabsForViewport(descriptors, windowTop, viewportHeight),
|
||||
[descriptors, viewportHeight, windowTop],
|
||||
);
|
||||
return visible.map((slab) => (
|
||||
<NativeCanvasSlabView
|
||||
key={slab.key}
|
||||
slab={slab}
|
||||
model={model}
|
||||
textLayout={textLayout}
|
||||
paints={paints}
|
||||
horizontalOffsets={horizontalOffsets}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
function NativeStickyFileHeader({
|
||||
file,
|
||||
showTopBorder,
|
||||
selectedPath,
|
||||
mode,
|
||||
onToggleFile,
|
||||
onSelectPath,
|
||||
}: {
|
||||
file: DiffFileSection;
|
||||
showTopBorder: boolean;
|
||||
selectedPath: DiffSurfaceProps["selectedPath"];
|
||||
mode: DiffSurfaceProps["mode"];
|
||||
onToggleFile: DiffSurfaceProps["onToggleFile"];
|
||||
onSelectPath: DiffSurfaceProps["onSelectPath"];
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
<DocumentFileHeader
|
||||
file={file}
|
||||
showTopBorder={showTopBorder}
|
||||
selectedPath={selectedPath}
|
||||
mode={mode}
|
||||
onToggleFile={onToggleFile}
|
||||
onSelectPath={onSelectPath}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function NativeFileBody({
|
||||
file,
|
||||
model,
|
||||
mode,
|
||||
horizontalOffsets,
|
||||
}: {
|
||||
file: DiffFileSection;
|
||||
model: DiffDocumentModel;
|
||||
mode: DiffSurfaceProps["mode"];
|
||||
horizontalOffsets: SharedValue<DiffHorizontalOffsets>;
|
||||
}) {
|
||||
const touchRef = useRef<{ x: number; y: number; startedAt: number; moved: boolean } | null>(null);
|
||||
const reviewActions = mode.kind === "working" ? mode.reviewActions : undefined;
|
||||
const touchStart = useCallback((event: GestureResponderEvent) => {
|
||||
touchRef.current = {
|
||||
x: event.nativeEvent.pageX,
|
||||
y: event.nativeEvent.pageY,
|
||||
startedAt: Date.now(),
|
||||
moved: false,
|
||||
};
|
||||
}, []);
|
||||
const touchMove = useCallback((event: GestureResponderEvent) => {
|
||||
const touch = touchRef.current;
|
||||
if (
|
||||
touch &&
|
||||
Math.hypot(event.nativeEvent.pageX - touch.x, event.nativeEvent.pageY - touch.y) > 10
|
||||
) {
|
||||
touch.moved = true;
|
||||
}
|
||||
}, []);
|
||||
const touchEnd = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
const touch = touchRef.current;
|
||||
touchRef.current = null;
|
||||
if (!touch || touch.moved || Date.now() - touch.startedAt > 500 || !reviewActions) return;
|
||||
const hit = hitTestDiffBodyPoint({
|
||||
model,
|
||||
file,
|
||||
locationX: event.nativeEvent.locationX,
|
||||
locationY: event.nativeEvent.locationY,
|
||||
horizontalOffset: horizontalOffsetForPath(horizontalOffsets.value, file.path),
|
||||
});
|
||||
if (hit?.kind === "cell" && hit.target) reviewActions.onStartComment(hit.target);
|
||||
},
|
||||
[file, horizontalOffsets, model, reviewActions],
|
||||
);
|
||||
return (
|
||||
<View
|
||||
testID={`diff-file-${file.fileIndex}-body`}
|
||||
style={inlineUnistylesStyle<ViewStyle>({
|
||||
position: "relative",
|
||||
height: file.bodyHeight,
|
||||
zIndex: 2,
|
||||
})}
|
||||
onTouchStart={touchStart}
|
||||
onTouchMove={touchMove}
|
||||
onTouchEnd={touchEnd}
|
||||
>
|
||||
{!file.isCollapsed && !model.wrapLines ? (
|
||||
<HorizontalScroll file={file} offsets={horizontalOffsets} />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function NativeReviewOverlays({
|
||||
model,
|
||||
mode,
|
||||
}: {
|
||||
model: DiffDocumentModel;
|
||||
mode: DiffSurfaceProps["mode"];
|
||||
}) {
|
||||
if (mode.kind !== "working" || !mode.reviewActions) return null;
|
||||
const reviewActions = mode.reviewActions;
|
||||
return model.rows.flatMap((row) => {
|
||||
if (row.kind !== "line" || row.reviewHeight === 0) return [];
|
||||
const columnWidth = model.viewportWidth / row.cells.length;
|
||||
return row.cells.flatMap((cell, index) => {
|
||||
if (!cell?.reviewTarget) return [];
|
||||
const comments = reviewActions.commentsByTarget.get(cell.reviewTarget.key) ?? [];
|
||||
const editor = reviewActions.editor;
|
||||
const hasEditor =
|
||||
editor?.target.filePath === cell.reviewTarget.filePath &&
|
||||
editor.target.side === cell.reviewTarget.side &&
|
||||
editor.target.lineNumber === cell.reviewTarget.lineNumber;
|
||||
if (comments.length === 0 && !hasEditor) return [];
|
||||
return [
|
||||
<View
|
||||
key={cell.reviewTarget.key}
|
||||
style={inlineUnistylesStyle<ViewStyle>({
|
||||
position: "absolute",
|
||||
top: row.top + row.height - row.reviewHeight,
|
||||
left: index * columnWidth,
|
||||
width: columnWidth,
|
||||
height: row.reviewHeight,
|
||||
zIndex: 6,
|
||||
})}
|
||||
>
|
||||
<InlineReviewThread
|
||||
reviewTarget={cell.reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
height={row.reviewHeight}
|
||||
viewportWidth={columnWidth}
|
||||
pinToViewport={!model.wrapLines}
|
||||
/>
|
||||
</View>,
|
||||
];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function NativeCanvasSlabView({
|
||||
slab,
|
||||
model,
|
||||
textLayout,
|
||||
paints,
|
||||
horizontalOffsets,
|
||||
}: {
|
||||
slab: NativeCanvasSlab;
|
||||
model: DiffDocumentModel;
|
||||
textLayout: NativeTextLayout;
|
||||
paints: NativePaints;
|
||||
horizontalOffsets: SharedValue<DiffHorizontalOffsets>;
|
||||
}) {
|
||||
const file = model.files[slab.fileIndex];
|
||||
const pictures = useMemo(
|
||||
() =>
|
||||
recordNativeSlabPictures({
|
||||
model,
|
||||
fileIndex: slab.fileIndex,
|
||||
top: slab.top,
|
||||
height: slab.height,
|
||||
viewportWidth: model.viewportWidth,
|
||||
textLayout,
|
||||
paints,
|
||||
}),
|
||||
[model, paints, slab.fileIndex, slab.height, slab.top, textLayout],
|
||||
);
|
||||
const style = useMemo<ViewStyle>(
|
||||
() => ({
|
||||
position: "absolute",
|
||||
top: slab.top,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: slab.height,
|
||||
zIndex: 3,
|
||||
}),
|
||||
[slab.height, slab.top],
|
||||
);
|
||||
if (!file) return null;
|
||||
const unifiedClip = {
|
||||
x: file.gutterWidth + CODE_LEFT_PADDING,
|
||||
width: model.viewportWidth - file.gutterWidth - CODE_LEFT_PADDING,
|
||||
};
|
||||
const columnWidth = model.viewportWidth / 2;
|
||||
const splitClipWidth = columnWidth - file.gutterWidth - CODE_LEFT_PADDING;
|
||||
return (
|
||||
<View pointerEvents="none" style={style} testID={`git-diff-canvas-${slab.key}`}>
|
||||
<Canvas style={StyleSheet.absoluteFill}>
|
||||
<Picture picture={pictures.fixed} />
|
||||
</Canvas>
|
||||
<NativeSlabCode
|
||||
picture={pictures.content.unified}
|
||||
path={slab.path}
|
||||
horizontalOffsets={horizontalOffsets}
|
||||
wrapLines={model.wrapLines}
|
||||
clipX={unifiedClip.x}
|
||||
clipWidth={unifiedClip.width}
|
||||
contentWidth={file.contentWidth}
|
||||
height={slab.height}
|
||||
/>
|
||||
{model.layout === "split" ? (
|
||||
<>
|
||||
<NativeSlabCode
|
||||
picture={pictures.content.left}
|
||||
path={slab.path}
|
||||
horizontalOffsets={horizontalOffsets}
|
||||
wrapLines={model.wrapLines}
|
||||
clipX={file.gutterWidth + CODE_LEFT_PADDING}
|
||||
clipWidth={splitClipWidth}
|
||||
contentWidth={file.contentWidth}
|
||||
height={slab.height}
|
||||
/>
|
||||
<NativeSlabCode
|
||||
picture={pictures.content.right}
|
||||
path={slab.path}
|
||||
horizontalOffsets={horizontalOffsets}
|
||||
wrapLines={model.wrapLines}
|
||||
clipX={columnWidth + file.gutterWidth + CODE_LEFT_PADDING}
|
||||
clipWidth={splitClipWidth}
|
||||
contentWidth={file.contentWidth}
|
||||
height={slab.height}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<Canvas pointerEvents="none" style={StyleSheet.absoluteFill}>
|
||||
<Picture picture={pictures.gutter} />
|
||||
</Canvas>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function NativeSlabCode({
|
||||
picture,
|
||||
path,
|
||||
horizontalOffsets,
|
||||
wrapLines,
|
||||
clipX,
|
||||
clipWidth,
|
||||
contentWidth,
|
||||
height,
|
||||
}: {
|
||||
picture: SkPicture;
|
||||
path: string;
|
||||
horizontalOffsets: SharedValue<DiffHorizontalOffsets>;
|
||||
wrapLines: boolean;
|
||||
clipX: number;
|
||||
clipWidth: number;
|
||||
contentWidth: number;
|
||||
height: number;
|
||||
}) {
|
||||
const transform = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ translateX: wrapLines ? 0 : -horizontalOffsetForPath(horizontalOffsets.value, path) },
|
||||
],
|
||||
}));
|
||||
const clipStyle = useMemo<ViewStyle>(
|
||||
() => ({
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: clipX,
|
||||
width: clipWidth,
|
||||
height,
|
||||
overflow: "hidden",
|
||||
}),
|
||||
[clipWidth, clipX, height],
|
||||
);
|
||||
const contentStyle = useMemo<ViewStyle>(
|
||||
() => ({ position: "absolute", top: 0, left: -clipX, width: contentWidth, height }),
|
||||
[clipX, contentWidth, height],
|
||||
);
|
||||
return (
|
||||
<View pointerEvents="none" style={clipStyle}>
|
||||
<AnimatedCodeLayer style={[contentStyle, transform]}>
|
||||
<Canvas style={StyleSheet.absoluteFill}>
|
||||
<Picture picture={picture} />
|
||||
</Canvas>
|
||||
</View>
|
||||
</AnimatedCodeLayer>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1, minHeight: 0, position: "relative", overflow: "hidden" },
|
||||
canvas: { zIndex: 1 },
|
||||
scroll: { zIndex: 2 },
|
||||
header: { zIndex: 3 },
|
||||
scroll: { backgroundColor: "transparent" },
|
||||
header: { zIndex: 5 },
|
||||
});
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { InlineReviewThread } from "@/review";
|
||||
import type { ViewStyle } from "react-native";
|
||||
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
|
||||
import { InlineReviewAddButton, InlineReviewThread } from "@/review";
|
||||
import type { ReviewableDiffTarget } from "@/utils/diff-layout";
|
||||
import { DocumentFileHeader } from "./document-file-header";
|
||||
import { hitTestDiffDocument, selectedSourceText } from "./hit-testing";
|
||||
import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets";
|
||||
import { HorizontalScroll } from "./horizontal-scroll.web";
|
||||
import { buildDiffDocumentModel, captureScrollAnchor, resolveScrollAnchor } from "./model";
|
||||
import {
|
||||
buildDiffDocumentModel,
|
||||
FILE_HEADER_HEIGHT,
|
||||
resolveRelayoutScrollTop,
|
||||
retainReusableModels,
|
||||
} from "./model";
|
||||
import { paintWebViewport } from "./paint.web";
|
||||
import { hasPointerDragStarted } from "./pointer-gesture";
|
||||
import type {
|
||||
@@ -23,9 +30,14 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
const { t } = useTranslation();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const canvasScratchRef = useRef<HTMLCanvasElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const modelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
|
||||
const previousModelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
|
||||
const reusableModelRef = useRef<{
|
||||
dependencies: readonly unknown[];
|
||||
models: ReturnType<typeof buildDiffDocumentModel>[];
|
||||
} | null>(null);
|
||||
const consumedFocusRef = useRef<string | null>(null);
|
||||
const scrollTopRef = useRef(0);
|
||||
const horizontalOffsetsRef = useRef(new Map<string, number>());
|
||||
@@ -37,6 +49,8 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
moved: boolean;
|
||||
} | null>(null);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
const forcePaintRef = useRef(true);
|
||||
const canvasWindowRef = useRef({ top: 0, height: 0 });
|
||||
const [viewport, setViewport] = useState({ width: 0, height: 0 });
|
||||
const [loadedTypography, setLoadedTypography] = useState<DiffTypography | null>(null);
|
||||
const [hoveredAffordance, setHoveredAffordance] = useState<{
|
||||
@@ -73,7 +87,23 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
lineHeight: desiredTypography.lineHeight,
|
||||
});
|
||||
}
|
||||
return buildDiffDocumentModel({
|
||||
const dependencies = [
|
||||
props.files,
|
||||
props.displayPreferences.layout,
|
||||
props.displayPreferences.wrapLines,
|
||||
viewport.width,
|
||||
loadedTypography,
|
||||
measurement,
|
||||
props.palette,
|
||||
reviewActions,
|
||||
t,
|
||||
] as const;
|
||||
const previous = reusableModelRef.current;
|
||||
const canReuse = previous?.dependencies.every(
|
||||
(dependency, index) => dependency === dependencies[index],
|
||||
);
|
||||
const reuseFrom = canReuse ? previous?.models : undefined;
|
||||
const next = buildDiffDocumentModel({
|
||||
files: props.files,
|
||||
collapsedFilePaths: props.collapsedFilePaths,
|
||||
layout: props.displayPreferences.layout,
|
||||
@@ -87,7 +117,13 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
binary: t("workspace.git.diff.binaryFile"),
|
||||
tooLarge: t("workspace.git.diff.tooLarge"),
|
||||
},
|
||||
reuseFrom,
|
||||
});
|
||||
reusableModelRef.current = {
|
||||
dependencies,
|
||||
models: retainReusableModels(reuseFrom, next),
|
||||
};
|
||||
return next;
|
||||
}, [
|
||||
measurement,
|
||||
props.collapsedFilePaths,
|
||||
@@ -108,31 +144,77 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
const canvas = canvasRef.current;
|
||||
const currentModel = modelRef.current;
|
||||
if (!canvas || !currentModel || viewport.width <= 0 || viewport.height <= 0) return;
|
||||
const desiredHeight = Math.min(
|
||||
Math.max(currentModel.height, viewport.height),
|
||||
viewport.height * 3,
|
||||
);
|
||||
const currentWindow = canvasWindowRef.current;
|
||||
const viewportTop = scrollTopRef.current;
|
||||
const viewportBottom = viewportTop + viewport.height;
|
||||
const safeInset = viewport.height / 2;
|
||||
const mustRecenter =
|
||||
currentWindow.height !== desiredHeight ||
|
||||
viewportTop < currentWindow.top + safeInset ||
|
||||
viewportBottom > currentWindow.top + currentWindow.height - safeInset;
|
||||
if (!forcePaintRef.current && !mustRecenter) return;
|
||||
const forceFullPaint = forcePaintRef.current;
|
||||
forcePaintRef.current = false;
|
||||
const canvasHeight = desiredHeight;
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
const requestedCanvasTop = mustRecenter
|
||||
? Math.min(
|
||||
Math.max(0, viewportTop - viewport.height),
|
||||
Math.max(0, currentModel.height - canvasHeight),
|
||||
)
|
||||
: currentWindow.top;
|
||||
const canvasTop = Math.round(requestedCanvasTop * ratio) / ratio;
|
||||
canvasWindowRef.current = { top: canvasTop, height: canvasHeight };
|
||||
const pixelWidth = Math.ceil(viewport.width * ratio);
|
||||
const pixelHeight = Math.ceil(viewport.height * ratio);
|
||||
const pixelHeight = Math.ceil(canvasHeight * ratio);
|
||||
const resized = canvas.width !== pixelWidth || canvas.height !== pixelHeight;
|
||||
if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
|
||||
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
|
||||
canvas.style.top = `${canvasTop}px`;
|
||||
canvas.style.height = `${canvasHeight}px`;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
if (!measurement || !loadedTypography) return;
|
||||
const { paintTop, paintHeight } = shiftCanvasPixels({
|
||||
canvas,
|
||||
scratch:
|
||||
canvasScratchRef.current ?? (canvasScratchRef.current = document.createElement("canvas")),
|
||||
context,
|
||||
currentTop: currentWindow.top,
|
||||
nextTop: canvasTop,
|
||||
canvasHeight,
|
||||
pixelWidth,
|
||||
pixelHeight,
|
||||
ratio,
|
||||
canReuse: !forceFullPaint && !resized && currentWindow.height === canvasHeight,
|
||||
});
|
||||
paintWebViewport({
|
||||
context,
|
||||
model: currentModel,
|
||||
palette: props.palette,
|
||||
typography: loadedTypography,
|
||||
measureText: measurement,
|
||||
scrollTop: scrollTopRef.current,
|
||||
scrollTop: canvasTop,
|
||||
viewportWidth: viewport.width,
|
||||
viewportHeight: viewport.height,
|
||||
viewportHeight: canvasHeight,
|
||||
horizontalOffsets: horizontalOffsetsRef.current,
|
||||
selection: selectionRef.current,
|
||||
devicePixelRatio: ratio,
|
||||
paintTop,
|
||||
paintHeight,
|
||||
});
|
||||
}, [loadedTypography, measurement, props.palette, viewport.height, viewport.width]);
|
||||
const schedulePaint = useCallback(() => {
|
||||
if (frameRef.current === null) frameRef.current = requestAnimationFrame(paint);
|
||||
}, [paint]);
|
||||
const schedulePaint = useCallback(
|
||||
(force = true) => {
|
||||
if (force) forcePaintRef.current = true;
|
||||
if (frameRef.current === null) frameRef.current = requestAnimationFrame(paint);
|
||||
},
|
||||
[paint],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const root = rootRef.current;
|
||||
@@ -162,12 +244,9 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
const previous = previousModelRef.current;
|
||||
const scroll = scrollRef.current;
|
||||
if (previous && scroll && previous !== model) {
|
||||
const anchor = captureScrollAnchor(previous, scroll.scrollTop);
|
||||
if (anchor) {
|
||||
const nextScrollTop = resolveScrollAnchor(model, anchor);
|
||||
scrollTopRef.current = nextScrollTop;
|
||||
scroll.scrollTop = nextScrollTop;
|
||||
}
|
||||
const nextScrollTop = resolveRelayoutScrollTop(previous, model, scroll.scrollTop);
|
||||
scrollTopRef.current = nextScrollTop;
|
||||
scroll.scrollTop = nextScrollTop;
|
||||
}
|
||||
previousModelRef.current = model;
|
||||
}, [model]);
|
||||
@@ -202,16 +281,42 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
}, [collapsedFilePaths, mode, model.files, onToggleFile]);
|
||||
|
||||
const handleVerticalScroll = useCallback(
|
||||
(event: React.UIEvent<HTMLDivElement>) => {
|
||||
scrollTopRef.current = event.currentTarget.scrollTop;
|
||||
(scrollElement: HTMLDivElement) => {
|
||||
const scrollTop = scrollElement.scrollTop;
|
||||
scrollTopRef.current = scrollTop;
|
||||
if (hasHoveredAffordanceRef.current) {
|
||||
hasHoveredAffordanceRef.current = false;
|
||||
setHoveredAffordance(null);
|
||||
}
|
||||
schedulePaint();
|
||||
const currentModel = modelRef.current;
|
||||
const currentWindow = canvasWindowRef.current;
|
||||
if (!currentModel || currentWindow.height === 0) {
|
||||
schedulePaint(false);
|
||||
return;
|
||||
}
|
||||
const viewportBottom = scrollTop + viewport.height;
|
||||
const safeInset = viewport.height / 2;
|
||||
const crossedSafeInset =
|
||||
scrollTop < currentWindow.top + safeInset ||
|
||||
viewportBottom > currentWindow.top + currentWindow.height - safeInset;
|
||||
if (!crossedSafeInset) return;
|
||||
const requestedTop = Math.min(
|
||||
Math.max(0, scrollTop - viewport.height),
|
||||
Math.max(0, currentModel.height - currentWindow.height),
|
||||
);
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
const nextTop = Math.round(requestedTop * ratio) / ratio;
|
||||
if (nextTop !== currentWindow.top) schedulePaint(false);
|
||||
},
|
||||
[schedulePaint],
|
||||
[schedulePaint, viewport.height],
|
||||
);
|
||||
useEffect(() => {
|
||||
const scroll = scrollRef.current;
|
||||
if (!scroll) return;
|
||||
const onScroll = () => handleVerticalScroll(scroll);
|
||||
scroll.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => scroll.removeEventListener("scroll", onScroll);
|
||||
}, [handleVerticalScroll]);
|
||||
const handleHorizontalScroll = useCallback(
|
||||
(path: string, offset: number) => {
|
||||
horizontalOffsetsRef.current.set(path, offset);
|
||||
@@ -247,6 +352,12 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
)
|
||||
)
|
||||
return;
|
||||
if (selectionRef.current) {
|
||||
selectionRef.current = null;
|
||||
dragRef.current = null;
|
||||
schedulePaint();
|
||||
return;
|
||||
}
|
||||
const hit = pointHit(event);
|
||||
if (hit?.kind !== "cell") return;
|
||||
dragRef.current = {
|
||||
@@ -285,12 +396,12 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
const gutterBorder = sideIndex * columnWidth + file.gutterWidth;
|
||||
const rootBounds = rootRef.current?.getBoundingClientRect();
|
||||
const pointerX = rootBounds ? event.clientX - rootBounds.left : Number.NaN;
|
||||
if (Math.abs(pointerX - gutterBorder) <= 14) {
|
||||
if (hit.target && Number.isFinite(pointerX)) {
|
||||
hasHoveredAffordanceRef.current = true;
|
||||
setHoveredAffordance({
|
||||
hit,
|
||||
left: gutterBorder - 14,
|
||||
top: row.top - scrollTopRef.current + (modelRef.current!.lineHeight - 28) / 2,
|
||||
left: gutterBorder - 12,
|
||||
top: row.top - scrollTopRef.current + (modelRef.current!.lineHeight - 22) / 2,
|
||||
});
|
||||
} else {
|
||||
hasHoveredAffordanceRef.current = false;
|
||||
@@ -351,7 +462,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
() => ({ ...CONTENT_STYLE, height: Math.max(model.height, viewport.height) }),
|
||||
[model.height, viewport.height],
|
||||
);
|
||||
const affordanceStyle = useMemo<React.CSSProperties>(
|
||||
const affordanceStyle = useMemo<ViewStyle>(
|
||||
() => ({
|
||||
...AFFORDANCE_STYLE,
|
||||
left: hoveredAffordance?.left ?? 0,
|
||||
@@ -377,8 +488,8 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
<div
|
||||
ref={scrollRef}
|
||||
data-testid="git-diff-scroll"
|
||||
data-overlay-scrollbar="true"
|
||||
tabIndex={0}
|
||||
onScroll={handleVerticalScroll}
|
||||
onPointerDown={pointerDown}
|
||||
onPointerMove={pointerMove}
|
||||
onPointerUp={pointerUp}
|
||||
@@ -388,10 +499,12 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
style={SCROLL_STYLE}
|
||||
>
|
||||
<div style={contentStyle} onMouseDown={preventDocumentMouseSelection}>
|
||||
{model.files.map((file) => (
|
||||
<canvas ref={canvasRef} data-testid="git-diff-canvas" style={canvasStyle} />
|
||||
{model.files.map((file, index) => (
|
||||
<WebFileHeaderSection key={file.path} file={file}>
|
||||
<DocumentFileHeader
|
||||
file={file}
|
||||
showTopBorder={index > 0 && !model.files[index - 1]?.isCollapsed}
|
||||
selectedPath={props.selectedPath}
|
||||
mode={props.mode}
|
||||
onToggleFile={props.onToggleFile}
|
||||
@@ -438,21 +551,68 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
<canvas ref={canvasRef} data-testid="git-diff-canvas" style={canvasStyle} />
|
||||
<DomOverlayScrollbar scrollContainerRef={scrollRef} onUserScrollUp={noop} />
|
||||
{hoveredAffordance?.hit.target && reviewActions ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("review.comment.add")}
|
||||
onClick={addHoveredComment}
|
||||
style={affordanceStyle}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<InlineReviewAddButton onPress={addHoveredComment} style={affordanceStyle} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function shiftCanvasPixels(input: {
|
||||
canvas: HTMLCanvasElement;
|
||||
scratch: HTMLCanvasElement;
|
||||
context: CanvasRenderingContext2D;
|
||||
currentTop: number;
|
||||
nextTop: number;
|
||||
canvasHeight: number;
|
||||
pixelWidth: number;
|
||||
pixelHeight: number;
|
||||
ratio: number;
|
||||
canReuse: boolean;
|
||||
}): { paintTop: number; paintHeight: number } {
|
||||
const delta = input.nextTop - input.currentTop;
|
||||
if (!input.canReuse || delta === 0 || Math.abs(delta) >= input.canvasHeight) {
|
||||
return { paintTop: 0, paintHeight: input.canvasHeight };
|
||||
}
|
||||
const deltaPixels = Math.round(Math.abs(delta) * input.ratio);
|
||||
const overlapPixels = input.pixelHeight - deltaPixels;
|
||||
if (input.scratch.width !== input.pixelWidth) input.scratch.width = input.pixelWidth;
|
||||
if (input.scratch.height !== input.pixelHeight) input.scratch.height = input.pixelHeight;
|
||||
const scratchContext = input.scratch.getContext("2d");
|
||||
if (!scratchContext) return { paintTop: 0, paintHeight: input.canvasHeight };
|
||||
scratchContext.setTransform(1, 0, 0, 1, 0, 0);
|
||||
scratchContext.globalCompositeOperation = "copy";
|
||||
scratchContext.drawImage(input.canvas, 0, 0);
|
||||
input.context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
if (delta > 0) {
|
||||
input.context.drawImage(
|
||||
input.scratch,
|
||||
0,
|
||||
deltaPixels,
|
||||
input.pixelWidth,
|
||||
overlapPixels,
|
||||
0,
|
||||
0,
|
||||
input.pixelWidth,
|
||||
overlapPixels,
|
||||
);
|
||||
return { paintTop: input.canvasHeight - Math.abs(delta), paintHeight: Math.abs(delta) };
|
||||
}
|
||||
input.context.drawImage(
|
||||
input.scratch,
|
||||
0,
|
||||
0,
|
||||
input.pixelWidth,
|
||||
overlapPixels,
|
||||
0,
|
||||
deltaPixels,
|
||||
input.pixelWidth,
|
||||
overlapPixels,
|
||||
);
|
||||
return { paintTop: 0, paintHeight: Math.abs(delta) };
|
||||
}
|
||||
|
||||
function WebFileHeaderSection({
|
||||
file,
|
||||
children,
|
||||
@@ -532,7 +692,11 @@ const SCROLL_STYLE: React.CSSProperties = {
|
||||
zIndex: 2,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
scrollbarWidth: "none",
|
||||
cursor: "text",
|
||||
};
|
||||
|
||||
function noop(): void {}
|
||||
const CONTENT_STYLE: React.CSSProperties = {
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
@@ -553,7 +717,7 @@ const STICKY_HEADER_STYLE: React.CSSProperties = {
|
||||
};
|
||||
const BODY_MARKER_STYLE: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
top: 44,
|
||||
top: FILE_HEADER_HEIGHT,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
@@ -562,17 +726,16 @@ const BODY_MARKER_STYLE: React.CSSProperties = {
|
||||
const REVIEW_STYLE: React.CSSProperties = { position: "absolute", zIndex: 4, userSelect: "text" };
|
||||
const CANVAS_STYLE: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
};
|
||||
const AFFORDANCE_STYLE: React.CSSProperties = {
|
||||
const AFFORDANCE_STYLE: ViewStyle = {
|
||||
position: "absolute",
|
||||
zIndex: 5,
|
||||
width: 28,
|
||||
height: 28,
|
||||
};
|
||||
|
||||
function emptyDiffDocumentModel(input: {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createFallbackAwareTextMeasurer, type PrimaryTextFace } from "./text-measurement";
|
||||
import {
|
||||
createFallbackAwareTextMeasurer,
|
||||
requiresNativeParagraph,
|
||||
type PrimaryTextFace,
|
||||
} from "./text-measurement";
|
||||
|
||||
describe.each(["ios", "android"])("%s native text measurement", (platform) => {
|
||||
it("routes CJK and emoji missing glyphs through the system fallback shaper", () => {
|
||||
@@ -26,6 +30,16 @@ describe.each(["ios", "android"])("%s native text measurement", (platform) => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("native paragraph retention", () => {
|
||||
it("keeps ordinary code on the allocation-free font path", () => {
|
||||
expect(requiresNativeParagraph("const answer = value + 1;")).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["value => next", "中", "e\u0301", "👩💻", "مرحبا"])("retains shaping for %s", (text) => {
|
||||
expect(requiresNativeParagraph(text)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function primaryFaceWithout(...missing: string[]): PrimaryTextFace {
|
||||
return {
|
||||
glyphIds(text) {
|
||||
|
||||
@@ -5,6 +5,12 @@ export interface PrimaryTextFace {
|
||||
measure(text: string): number;
|
||||
}
|
||||
|
||||
const CODE_LIGATURE_CANDIDATE = /(?:===?|!==?|=>|<=|>=|->|::|\+\+|--)/;
|
||||
|
||||
export function requiresNativeParagraph(text: string): boolean {
|
||||
return /[^\x20-\x7e\t]/u.test(text) || CODE_LIGATURE_CANDIDATE.test(text);
|
||||
}
|
||||
|
||||
export function createFallbackAwareTextMeasurer(input: {
|
||||
primary: PrimaryTextFace;
|
||||
measureWithSystemFallback: (text: string) => number;
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "@shopify/react-native-skia";
|
||||
import { fragmentTextForRange } from "./model";
|
||||
import { codeTextColor } from "./palette";
|
||||
import { createFallbackAwareTextMeasurer } from "./text-measurement";
|
||||
import { createFallbackAwareTextMeasurer, requiresNativeParagraph } from "./text-measurement";
|
||||
import type { DiffCell, DiffDocumentModel, DiffFragment, DiffPalette, TextMeasurer } from "./types";
|
||||
|
||||
const PARAGRAPH_WIDTH = 100_000;
|
||||
@@ -17,31 +17,69 @@ export interface NativeTextLayout {
|
||||
paragraphs: Array<Array<Array<SkParagraph | null>>>;
|
||||
}
|
||||
|
||||
export function createNativeTextLayout(input: {
|
||||
model: DiffDocumentModel;
|
||||
export interface NativeTextLayoutStore {
|
||||
font: SkFont;
|
||||
paragraphsByCell: WeakMap<DiffCell, Array<SkParagraph | null>>;
|
||||
ownedParagraphs: Set<SkParagraph>;
|
||||
families: string[];
|
||||
fontSize: number;
|
||||
lineHeight: number;
|
||||
palette: DiffPalette;
|
||||
}
|
||||
|
||||
export function disposeNativeTextLayout(layout: NativeTextLayoutStore): void {
|
||||
for (const paragraph of layout.ownedParagraphs) paragraph.dispose();
|
||||
layout.ownedParagraphs.clear();
|
||||
layout.font.dispose();
|
||||
}
|
||||
|
||||
export function createNativeTextLayoutStore(input: {
|
||||
configuredFamily: string;
|
||||
fontSize: number;
|
||||
lineHeight: number;
|
||||
palette: DiffPalette;
|
||||
}): NativeTextLayout {
|
||||
}): NativeTextLayoutStore {
|
||||
const families = nativeFontFamilies(input.configuredFamily);
|
||||
const font = primaryFont(families, input.fontSize);
|
||||
const paragraphs = input.model.rows.map((row) => {
|
||||
return {
|
||||
font,
|
||||
paragraphsByCell: new WeakMap(),
|
||||
ownedParagraphs: new Set(),
|
||||
families,
|
||||
fontSize: input.fontSize,
|
||||
lineHeight: input.lineHeight,
|
||||
palette: input.palette,
|
||||
};
|
||||
}
|
||||
|
||||
export function prepareNativeTextLayout(
|
||||
store: NativeTextLayoutStore,
|
||||
model: DiffDocumentModel,
|
||||
): NativeTextLayout {
|
||||
const paragraphs = model.rows.map((row) => {
|
||||
if (row.kind !== "line") return [];
|
||||
return row.cells.map((cell) => {
|
||||
if (!cell) return [];
|
||||
return cell.fragments.map((fragment) =>
|
||||
createFragmentParagraph({
|
||||
const cached = store.paragraphsByCell.get(cell);
|
||||
if (cached) return cached;
|
||||
const cellParagraphs = cell.fragments.map((fragment) => {
|
||||
if (!requiresRetainedParagraph(fragment.text, store.font)) return null;
|
||||
const paragraph = createFragmentParagraph({
|
||||
cell,
|
||||
fragment,
|
||||
families,
|
||||
fontSize: input.fontSize,
|
||||
lineHeight: input.model.lineHeight,
|
||||
palette: input.palette,
|
||||
}),
|
||||
);
|
||||
families: store.families,
|
||||
fontSize: store.fontSize,
|
||||
lineHeight: store.lineHeight,
|
||||
palette: store.palette,
|
||||
});
|
||||
store.ownedParagraphs.add(paragraph);
|
||||
return paragraph;
|
||||
});
|
||||
store.paragraphsByCell.set(cell, cellParagraphs);
|
||||
return cellParagraphs;
|
||||
});
|
||||
});
|
||||
return { font, paragraphs };
|
||||
return { font: store.font, paragraphs };
|
||||
}
|
||||
|
||||
export function createNativeTextMeasurer(input: {
|
||||
@@ -63,13 +101,22 @@ export function createNativeTextMeasurer(input: {
|
||||
lineHeight: Math.round(input.fontSize * 1.5),
|
||||
color: Skia.Color("black"),
|
||||
});
|
||||
return paragraph.getLongestLine();
|
||||
const width = paragraph.getLongestLine();
|
||||
paragraph.dispose();
|
||||
return width;
|
||||
},
|
||||
});
|
||||
return {
|
||||
...fallbackAware,
|
||||
measureAdvances(graphemes) {
|
||||
const text = graphemes.join("");
|
||||
if (!requiresRetainedParagraph(text, font)) {
|
||||
let prefix = "";
|
||||
return graphemes.map((grapheme) => {
|
||||
prefix += grapheme;
|
||||
return font.getTextWidth(prefix);
|
||||
});
|
||||
}
|
||||
const paragraph = createParagraph({
|
||||
text,
|
||||
families,
|
||||
@@ -78,7 +125,7 @@ export function createNativeTextMeasurer(input: {
|
||||
color: Skia.Color("black"),
|
||||
});
|
||||
let end = 0;
|
||||
return graphemes.map((grapheme) => {
|
||||
const advances = graphemes.map((grapheme) => {
|
||||
end += grapheme.length;
|
||||
const rectangles = paragraph.getRectsForRange(0, end);
|
||||
return rectangles.reduce(
|
||||
@@ -86,10 +133,16 @@ export function createNativeTextMeasurer(input: {
|
||||
0,
|
||||
);
|
||||
});
|
||||
paragraph.dispose();
|
||||
return advances;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function requiresRetainedParagraph(text: string, font: SkFont): boolean {
|
||||
return requiresNativeParagraph(text) || font.getGlyphIDs(text).some((glyph) => glyph === 0);
|
||||
}
|
||||
|
||||
function createFragmentParagraph(input: {
|
||||
cell: DiffCell;
|
||||
fragment: DiffFragment;
|
||||
|
||||
@@ -167,6 +167,8 @@ export interface BuildDiffDocumentModelInput {
|
||||
palette: DiffPalette;
|
||||
reviewActions?: InlineReviewActions;
|
||||
labels: { binary: string; tooLarge: string };
|
||||
/** A geometry-compatible model whose unchanged file measurements may be reused. */
|
||||
reuseFrom?: readonly DiffDocumentModel[];
|
||||
}
|
||||
|
||||
export interface DiffCharacterPosition {
|
||||
|
||||
Reference in New Issue
Block a user