refactor(app): implement canvas-based diff rendering
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { memo, useCallback } from "react";
|
||||
import { FileHeader } from "@/git/file-header";
|
||||
import type { DiffFileSection } from "./types";
|
||||
import type { DiffDocumentProps } from "./types";
|
||||
|
||||
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;
|
||||
}) {
|
||||
const activate = useCallback(
|
||||
(path: string) => {
|
||||
if (mode.kind !== "working") return;
|
||||
mode.onFilePress?.(path);
|
||||
onToggleFile(path);
|
||||
},
|
||||
[mode, onToggleFile],
|
||||
);
|
||||
const working = mode.kind === "working" ? mode : null;
|
||||
return (
|
||||
<FileHeader
|
||||
file={file.file}
|
||||
bodyVisible={!file.isCollapsed}
|
||||
isSelected={selectedPath === file.path}
|
||||
interactive={mode.kind === "working"}
|
||||
workspaceFileDragScope={working?.workspaceFileDragScope}
|
||||
onActivate={activate}
|
||||
onSelect={onSelectPath}
|
||||
onOpenFile={working?.onOpenFile}
|
||||
onAddToChat={working?.onAddToChat}
|
||||
onCopyPath={working?.onCopyPath}
|
||||
onCopyRelativePath={working?.onCopyRelativePath}
|
||||
onReveal={working?.onReveal}
|
||||
revealTargetName={working?.revealTargetName}
|
||||
onDownload={working?.onDownload}
|
||||
onDuplicate={working?.onDuplicate}
|
||||
onRevert={working?.onRevert}
|
||||
testID={`diff-file-${file.fileIndex}`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
characterOffsetAtPoint,
|
||||
hitTestDiffDocument,
|
||||
orderedSelection,
|
||||
selectedSourceText,
|
||||
} from "./hit-testing";
|
||||
import { buildDiffDocumentModel } from "./model";
|
||||
import type { BuildDiffDocumentModelInput, DiffCell, DiffSelection } from "./types";
|
||||
|
||||
const measurer = { measure: (text: string) => Array.from(text).length * 10 };
|
||||
|
||||
describe("diff hit testing", () => {
|
||||
it("locates the closest character from measured wrapped geometry", () => {
|
||||
const cell = changedCell(buildModel("abcdef", { viewportWidth: 90 }));
|
||||
expect(characterOffsetAtPoint({ cell, x: 6, y: 20, lineHeight: 18 })).toBe(5);
|
||||
});
|
||||
|
||||
it("never places a character hit inside combining or ZWJ graphemes", () => {
|
||||
const cell = changedCell(buildModel("a👨👩👧👦éb", { viewportWidth: 300 }));
|
||||
const emoji = cell.fragments[0]?.graphemes.find((grapheme) => grapheme.text.includes("👨"));
|
||||
const combining = cell.fragments[0]?.graphemes.find((grapheme) => grapheme.text === "é");
|
||||
expect(emoji).toBeDefined();
|
||||
expect(combining).toBeDefined();
|
||||
expect([emoji?.start, emoji?.end]).toContain(
|
||||
characterOffsetAtPoint({ cell, x: 15, y: 0, lineHeight: 18 }),
|
||||
);
|
||||
const combiningIndex = cell.fragments[0]!.graphemes.indexOf(combining!);
|
||||
const beforeCombining = cell.fragments[0]!.graphemes.slice(0, combiningIndex).reduce(
|
||||
(width, grapheme) => width + grapheme.width,
|
||||
0,
|
||||
);
|
||||
expect(
|
||||
characterOffsetAtPoint({
|
||||
cell,
|
||||
x: beforeCombining + combining!.width / 2 + 1,
|
||||
y: 0,
|
||||
lineHeight: 18,
|
||||
}),
|
||||
).toBe(combining?.end);
|
||||
});
|
||||
|
||||
it("normalizes backwards selection and copies exact source including empty lines", () => {
|
||||
const model = buildModel("", { wrapLines: false });
|
||||
const cell = changedCell(model);
|
||||
const nextCell = model.rows
|
||||
.filter((row) => row.kind === "line")
|
||||
.flatMap((row) => row.cells)
|
||||
.find((candidate) => candidate?.content === "tail");
|
||||
expect(nextCell).toBeDefined();
|
||||
const selection: DiffSelection = {
|
||||
anchor: position(model, nextCell!, nextCell!.content.length),
|
||||
focus: position(model, cell, 0),
|
||||
};
|
||||
expect(orderedSelection(selection).start.sourceOffset).toBe(0);
|
||||
expect(selectedSourceText(model, selection)).toBe("\ntail");
|
||||
});
|
||||
|
||||
it("serializes exact canonical source for forward and backward wrapped selections", () => {
|
||||
const model = buildModel("A\t中👨👩👧👦éZ", { viewportWidth: 90 });
|
||||
const cell = changedCell(model);
|
||||
const start = position(model, cell, 1);
|
||||
const end = position(model, cell, cell.content.length - 1);
|
||||
expect(selectedSourceText(model, { anchor: start, focus: end })).toBe("\t中👨👩👧👦é");
|
||||
expect(selectedSourceText(model, { anchor: end, focus: start })).toBe("\t中👨👩👧👦é");
|
||||
});
|
||||
|
||||
it("serializes one split side without interleaving the opposite side", () => {
|
||||
const model = buildModel("unused", { files: [splitFile()], layout: "split" });
|
||||
const lineRows = model.rows.filter(
|
||||
(row) => row.kind === "line" && row.cells.length === 2 && row.cells.every(Boolean),
|
||||
);
|
||||
const first = lineRows[0];
|
||||
const second = lineRows[1];
|
||||
expect(first?.kind).toBe("line");
|
||||
expect(second?.kind).toBe("line");
|
||||
const firstRight = first!.kind === "line" ? first!.cells[1]! : null;
|
||||
const secondRight = second!.kind === "line" ? second!.cells[1]! : null;
|
||||
expect(firstRight).not.toBeNull();
|
||||
expect(secondRight).not.toBeNull();
|
||||
const selection = {
|
||||
anchor: position(model, firstRight!, 1),
|
||||
focus: position(model, secondRight!, secondRight!.content.length),
|
||||
};
|
||||
expect(selectedSourceText(model, selection)).toBe("ew-one\nnew-two");
|
||||
expect(selectedSourceText(model, { anchor: selection.focus, focus: selection.anchor })).toBe(
|
||||
"ew-one\nnew-two",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not hit-test reserved review geometry as source text", () => {
|
||||
const model = buildModel("review me", { reviewActions: reviewActionsForFirstAddition() });
|
||||
const row = model.rows.find(
|
||||
(candidate) => candidate.kind === "line" && candidate.reviewHeight > 0,
|
||||
);
|
||||
expect(row?.kind).toBe("line");
|
||||
expect(
|
||||
hitTestDiffDocument({
|
||||
model,
|
||||
x: 20,
|
||||
documentY: row!.top + row!.height - 1,
|
||||
horizontalOffset: 0,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function buildModel(changedContent: string, overrides: Partial<BuildDiffDocumentModelInput> = {}) {
|
||||
const file: ParsedDiffFile = {
|
||||
path: "src/a.ts",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 2,
|
||||
deletions: 0,
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldCount: 0,
|
||||
newStart: 1,
|
||||
newCount: 2,
|
||||
lines: [
|
||||
{ type: "header", content: "@@ -1,0 +1,2 @@" },
|
||||
{ type: "add", content: changedContent },
|
||||
{ type: "add", content: "tail" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
return buildDiffDocumentModel({
|
||||
files: [file],
|
||||
collapsedFilePaths: new Set(),
|
||||
layout: "unified",
|
||||
wrapLines: true,
|
||||
viewportWidth: 180,
|
||||
typography: { family: "monospace", size: 12, lineHeight: 18 },
|
||||
measureText: measurer,
|
||||
palette: {
|
||||
surface: "#000",
|
||||
headerSurface: "#111",
|
||||
border: "#222",
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
addition: "green",
|
||||
deletion: "red",
|
||||
additionBackground: "#010",
|
||||
deletionBackground: "#100",
|
||||
emptyBackground: "#111",
|
||||
selection: "blue",
|
||||
syntax: {},
|
||||
},
|
||||
labels: { binary: "Binary", tooLarge: "Too large" },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function changedCell(model: ReturnType<typeof buildModel>): DiffCell {
|
||||
const cell = model.rows
|
||||
.filter((row) => row.kind === "line")
|
||||
.flatMap((row) => row.cells)
|
||||
.find((candidate) => candidate?.type === "add");
|
||||
if (!cell) throw new Error("Expected an added cell");
|
||||
return cell;
|
||||
}
|
||||
|
||||
function position(
|
||||
model: ReturnType<typeof buildModel>,
|
||||
cell: DiffCell,
|
||||
cellOffset: number,
|
||||
): DiffSelection["anchor"] {
|
||||
const row = model.rows.find(
|
||||
(candidate) => candidate.kind === "line" && candidate.cells.includes(cell),
|
||||
);
|
||||
if (!row || row.kind !== "line") throw new Error("Expected cell row");
|
||||
const cellIndex = row.cells.indexOf(cell);
|
||||
return {
|
||||
fileIndex: row.fileIndex,
|
||||
rowIndex: row.index,
|
||||
cellIndex,
|
||||
side: cell.reviewTarget?.side ?? (cellIndex === 0 ? "old" : "new"),
|
||||
sourceOffset: cellOffset,
|
||||
};
|
||||
}
|
||||
|
||||
function splitFile(): ParsedDiffFile {
|
||||
return {
|
||||
path: "src/split.ts",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 2,
|
||||
deletions: 2,
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldCount: 2,
|
||||
newStart: 1,
|
||||
newCount: 2,
|
||||
lines: [
|
||||
{ type: "header", content: "@@ -1,2 +1,2 @@" },
|
||||
{ type: "remove", content: "old-one" },
|
||||
{ type: "remove", content: "old-two" },
|
||||
{ type: "add", content: "new-one" },
|
||||
{ type: "add", content: "new-two" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function reviewActionsForFirstAddition(): NonNullable<
|
||||
BuildDiffDocumentModelInput["reviewActions"]
|
||||
> {
|
||||
const target = {
|
||||
key: "src/a.ts:new:1",
|
||||
filePath: "src/a.ts",
|
||||
hunkHeader: "@@ -1,0 +1,2 @@",
|
||||
hunkIndex: 0,
|
||||
lineIndex: 1,
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 1,
|
||||
side: "new" as const,
|
||||
lineNumber: 1,
|
||||
lineType: "add" as const,
|
||||
content: "review me",
|
||||
};
|
||||
return {
|
||||
commentsByTarget: new Map(),
|
||||
editor: { target, commentId: null, body: "" },
|
||||
onStartComment() {},
|
||||
onCancelEditor() {},
|
||||
onSaveEditor() {},
|
||||
onEditComment() {},
|
||||
onDeleteComment() {},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { fileAtDocumentOffset, fragmentWidthForRange } from "./model";
|
||||
import type {
|
||||
DiffCell,
|
||||
DiffCharacterPosition,
|
||||
DiffDocumentModel,
|
||||
DiffHit,
|
||||
DiffSelection,
|
||||
} from "./types";
|
||||
|
||||
const CODE_LEFT_PADDING = 8;
|
||||
|
||||
export function hitTestDiffDocument(input: {
|
||||
model: DiffDocumentModel;
|
||||
x: number;
|
||||
documentY: number;
|
||||
horizontalOffset: number;
|
||||
}): DiffHit | null {
|
||||
const file = fileAtDocumentOffset(input.model.files, input.documentY);
|
||||
if (!file) return null;
|
||||
if (input.documentY < file.bodyTop) return { kind: "header", path: file.path };
|
||||
const row = rowAtOffset(input.model.rows, file.rowStart, file.rowEnd, input.documentY);
|
||||
if (!row || row.kind !== "line") return null;
|
||||
const textHeight = row.height - row.reviewHeight;
|
||||
if (input.documentY >= row.top + textHeight) return null;
|
||||
const columnCount = row.cells.length;
|
||||
const columnWidth = input.model.viewportWidth / columnCount;
|
||||
const cellIndex = Math.min(columnCount - 1, Math.max(0, Math.floor(input.x / columnWidth)));
|
||||
const cell = row.cells[cellIndex];
|
||||
if (!cell) return null;
|
||||
const localX = input.x - cellIndex * columnWidth;
|
||||
const contentX = Math.max(
|
||||
0,
|
||||
localX - file.gutterWidth - CODE_LEFT_PADDING + input.horizontalOffset,
|
||||
);
|
||||
const textY = input.documentY - row.top;
|
||||
const sourceOffset = characterOffsetAtPoint({
|
||||
cell,
|
||||
x: contentX,
|
||||
y: textY,
|
||||
lineHeight: input.model.lineHeight,
|
||||
});
|
||||
return {
|
||||
kind: "cell",
|
||||
target: cell.reviewTarget,
|
||||
position: {
|
||||
fileIndex: file.fileIndex,
|
||||
rowIndex: row.index,
|
||||
cellIndex,
|
||||
side: cell.reviewTarget?.side ?? (cellIndex === 0 ? "old" : "new"),
|
||||
sourceOffset,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function characterOffsetAtPoint(input: {
|
||||
cell: DiffCell;
|
||||
x: number;
|
||||
y: number;
|
||||
lineHeight: number;
|
||||
}): number {
|
||||
const fragmentIndex = Math.min(
|
||||
input.cell.fragments.length - 1,
|
||||
Math.max(0, Math.floor(input.y / input.lineHeight)),
|
||||
);
|
||||
const fragment = input.cell.fragments[fragmentIndex];
|
||||
if (!fragment) return 0;
|
||||
let width = 0;
|
||||
for (const grapheme of fragment.graphemes) {
|
||||
if (input.x <= width + grapheme.width / 2) return grapheme.start;
|
||||
width += grapheme.width;
|
||||
}
|
||||
return fragment.end;
|
||||
}
|
||||
|
||||
function rowAtOffset(
|
||||
rows: readonly DiffDocumentModel["rows"][number][],
|
||||
start: number,
|
||||
end: number,
|
||||
documentY: number,
|
||||
): DiffDocumentModel["rows"][number] | null {
|
||||
let low = start;
|
||||
let high = end;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
const row = rows[middle];
|
||||
if (row && row.top + row.height <= documentY) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
const row = rows[low];
|
||||
return row && row.top <= documentY && documentY < row.top + row.height ? row : null;
|
||||
}
|
||||
|
||||
export function orderedSelection(selection: DiffSelection): {
|
||||
start: DiffCharacterPosition;
|
||||
end: DiffCharacterPosition;
|
||||
} {
|
||||
return comparePosition(selection.anchor, selection.focus) <= 0
|
||||
? { start: selection.anchor, end: selection.focus }
|
||||
: { start: selection.focus, end: selection.anchor };
|
||||
}
|
||||
|
||||
export function selectedSourceText(model: DiffDocumentModel, selection: DiffSelection): string {
|
||||
return selectedCellRanges(model, selection)
|
||||
.map(({ cell, start, end }) => cell.content.slice(start, end))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function comparePosition(left: DiffCharacterPosition, right: DiffCharacterPosition): number {
|
||||
return (
|
||||
left.rowIndex - right.rowIndex ||
|
||||
left.cellIndex - right.cellIndex ||
|
||||
left.sourceOffset - right.sourceOffset
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectedCellRange {
|
||||
cell: DiffCell;
|
||||
cellIndex: number;
|
||||
rowIndex: number;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
function selectedCellRanges(
|
||||
model: DiffDocumentModel,
|
||||
selection: DiffSelection,
|
||||
): SelectedCellRange[] {
|
||||
const { start, end } = orderedSelection(selection);
|
||||
const sameSplitSide = model.layout === "split" && start.cellIndex === end.cellIndex;
|
||||
const ranges: SelectedCellRange[] = [];
|
||||
for (let rowIndex = start.rowIndex; rowIndex <= end.rowIndex; rowIndex += 1) {
|
||||
const row = model.rows[rowIndex];
|
||||
if (!row || row.kind !== "line") continue;
|
||||
row.cells.forEach((cell, cellIndex) => {
|
||||
if (!cell || (sameSplitSide && cellIndex !== start.cellIndex)) return;
|
||||
const position = { rowIndex, cellIndex };
|
||||
if (compareCell(position, start) < 0 || compareCell(position, end) > 0) return;
|
||||
const rangeStart =
|
||||
rowIndex === start.rowIndex && cellIndex === start.cellIndex ? start.sourceOffset : 0;
|
||||
const rangeEnd =
|
||||
rowIndex === end.rowIndex && cellIndex === end.cellIndex
|
||||
? end.sourceOffset
|
||||
: cell.content.length;
|
||||
ranges.push({ cell, cellIndex, rowIndex, start: rangeStart, end: rangeEnd });
|
||||
});
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function compareCell(
|
||||
left: Pick<DiffCharacterPosition, "rowIndex" | "cellIndex">,
|
||||
right: Pick<DiffCharacterPosition, "rowIndex" | "cellIndex">,
|
||||
): number {
|
||||
return left.rowIndex - right.rowIndex || left.cellIndex - right.cellIndex;
|
||||
}
|
||||
|
||||
export function selectionRectangles(input: {
|
||||
model: DiffDocumentModel;
|
||||
selection: DiffSelection;
|
||||
}): SelectionRectangle[] {
|
||||
const rectangles: SelectionRectangle[] = [];
|
||||
for (const range of selectedCellRanges(input.model, input.selection)) {
|
||||
const row = input.model.rows[range.rowIndex];
|
||||
if (!row || row.kind !== "line") continue;
|
||||
const file = input.model.files[row.fileIndex];
|
||||
if (!file) continue;
|
||||
const columnWidth = input.model.viewportWidth / row.cells.length;
|
||||
const cell = range.cell;
|
||||
for (const fragment of cell.fragments) {
|
||||
const partStart = Math.max(fragment.start, range.start);
|
||||
const partEnd = Math.min(fragment.end, range.end);
|
||||
if (partEnd <= partStart) continue;
|
||||
const before = fragmentWidthForRange(fragment, fragment.start, partStart);
|
||||
const selected = fragmentWidthForRange(fragment, partStart, partEnd);
|
||||
rectangles.push({
|
||||
x: range.cellIndex * columnWidth + file.gutterWidth + CODE_LEFT_PADDING + before,
|
||||
y: row.top + fragment.top,
|
||||
width: selected,
|
||||
height: input.model.lineHeight,
|
||||
fileIndex: row.fileIndex,
|
||||
clipX: range.cellIndex * columnWidth + file.gutterWidth + CODE_LEFT_PADDING,
|
||||
clipWidth: columnWidth - file.gutterWidth - CODE_LEFT_PADDING,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rectangles;
|
||||
}
|
||||
|
||||
interface SelectionRectangle {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fileIndex: number;
|
||||
clipX: number;
|
||||
clipWidth: number;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
horizontalOffsetForPath,
|
||||
retainHorizontalOffsetMapForPaths,
|
||||
retainHorizontalOffsetsForPaths,
|
||||
} from "./horizontal-offsets";
|
||||
|
||||
describe("native diff horizontal offsets", () => {
|
||||
it("retains independent offsets by path through reorder, removal, insertion, and re-addition", () => {
|
||||
const initial = { "src/a.ts": 120, "src/b.ts": 340 };
|
||||
const reordered = retainHorizontalOffsetsForPaths(initial, ["src/b.ts", "src/a.ts"]);
|
||||
expect(horizontalOffsetForPath(reordered, "src/a.ts")).toBe(120);
|
||||
expect(horizontalOffsetForPath(reordered, "src/b.ts")).toBe(340);
|
||||
|
||||
const removed = retainHorizontalOffsetsForPaths(reordered, ["src/b.ts"]);
|
||||
const inserted = retainHorizontalOffsetsForPaths(removed, ["src/new.ts", "src/b.ts"]);
|
||||
expect(horizontalOffsetForPath(inserted, "src/new.ts")).toBe(0);
|
||||
expect(horizontalOffsetForPath(inserted, "src/b.ts")).toBe(340);
|
||||
|
||||
const readded = retainHorizontalOffsetsForPaths(inserted, [
|
||||
"src/a.ts",
|
||||
"src/new.ts",
|
||||
"src/b.ts",
|
||||
]);
|
||||
expect(horizontalOffsetForPath(readded, "src/a.ts")).toBe(0);
|
||||
expect(horizontalOffsetForPath(readded, "src/new.ts")).toBe(0);
|
||||
expect(horizontalOffsetForPath(readded, "src/b.ts")).toBe(340);
|
||||
});
|
||||
});
|
||||
|
||||
describe("web diff horizontal offsets", () => {
|
||||
it("forgets removed paths before they are reintroduced", () => {
|
||||
const initial = new Map([
|
||||
["src/collapsed.ts", 120],
|
||||
["src/offscreen.ts", 340],
|
||||
["src/removed.ts", 560],
|
||||
]);
|
||||
|
||||
const afterRemoval = retainHorizontalOffsetMapForPaths(initial, [
|
||||
"src/collapsed.ts",
|
||||
"src/offscreen.ts",
|
||||
]);
|
||||
expect([...afterRemoval]).toEqual([
|
||||
["src/collapsed.ts", 120],
|
||||
["src/offscreen.ts", 340],
|
||||
]);
|
||||
|
||||
const afterReintroduction = retainHorizontalOffsetMapForPaths(afterRemoval, [
|
||||
"src/removed.ts",
|
||||
"src/offscreen.ts",
|
||||
"src/collapsed.ts",
|
||||
]);
|
||||
expect([...afterReintroduction]).toEqual([
|
||||
["src/offscreen.ts", 340],
|
||||
["src/collapsed.ts", 120],
|
||||
]);
|
||||
expect(afterReintroduction.get("src/removed.ts") ?? 0).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
export type DiffHorizontalOffsets = Record<string, number>;
|
||||
|
||||
export function horizontalOffsetForPath(
|
||||
offsets: Readonly<DiffHorizontalOffsets>,
|
||||
path: string,
|
||||
): number {
|
||||
"worklet";
|
||||
const offset = offsets[path];
|
||||
return typeof offset === "number" ? offset : 0;
|
||||
}
|
||||
|
||||
export function retainHorizontalOffsetsForPaths(
|
||||
offsets: Readonly<DiffHorizontalOffsets>,
|
||||
paths: readonly string[],
|
||||
): DiffHorizontalOffsets {
|
||||
const retained: DiffHorizontalOffsets = {};
|
||||
for (const path of paths) {
|
||||
const offset = offsets[path];
|
||||
if (typeof offset === "number") {
|
||||
Object.defineProperty(retained, path, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: offset,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return retained;
|
||||
}
|
||||
|
||||
export function retainHorizontalOffsetMapForPaths(
|
||||
offsets: ReadonlyMap<string, number>,
|
||||
paths: readonly string[],
|
||||
): Map<string, number> {
|
||||
const retained = retainHorizontalOffsetsForPaths(Object.fromEntries(offsets), paths);
|
||||
return new Map(Object.entries(retained));
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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 { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
|
||||
import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures";
|
||||
import { horizontalOffsetForPath, type DiffHorizontalOffsets } from "./horizontal-offsets";
|
||||
import type { DiffFileSection } from "./types";
|
||||
|
||||
export function HorizontalScroll({
|
||||
file,
|
||||
offsets,
|
||||
}: {
|
||||
file: DiffFileSection;
|
||||
offsets: SharedValue<DiffHorizontalOffsets>;
|
||||
}) {
|
||||
const scrollRef = useRef<ScrollViewType>(null);
|
||||
const closeGestureRef = useFileExplorerCloseGestureRef();
|
||||
const horizontalScroll = useHorizontalScrollOptional();
|
||||
const scrollId = useId();
|
||||
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
|
||||
const edgeRef = useRef(true);
|
||||
const handler = useAnimatedScrollHandler({
|
||||
onScroll(event) {
|
||||
offsets.value = { ...offsets.value, [file.path]: event.contentOffset.x };
|
||||
},
|
||||
});
|
||||
useEffect(() => {
|
||||
return () => horizontalScroll?.unregisterScrollOffset(scrollId);
|
||||
}, [horizontalScroll, scrollId]);
|
||||
useLayoutEffect(() => {
|
||||
const retainedOffset = horizontalOffsetForPath(offsets.value, file.path);
|
||||
scrollRef.current?.scrollTo({ x: retainedOffset, animated: false });
|
||||
const nextAtEdge = retainedOffset <= 1;
|
||||
edgeRef.current = nextAtEdge;
|
||||
setIsAtLeftEdge(nextAtEdge);
|
||||
horizontalScroll?.registerScrollOffset(scrollId, retainedOffset);
|
||||
}, [file.path, horizontalScroll, offsets, scrollId]);
|
||||
const reportOffset = useCallback(
|
||||
(event: { nativeEvent: { contentOffset: { x: number } } }) => {
|
||||
const offset = event.nativeEvent.contentOffset.x;
|
||||
const nextAtEdge = offset <= 1;
|
||||
if (nextAtEdge !== edgeRef.current) {
|
||||
edgeRef.current = nextAtEdge;
|
||||
setIsAtLeftEdge(nextAtEdge);
|
||||
}
|
||||
horizontalScroll?.registerScrollOffset(scrollId, offset);
|
||||
},
|
||||
[horizontalScroll, scrollId],
|
||||
);
|
||||
const rootStyle = useMemo(
|
||||
() => ({ position: "absolute" as const, top: 0, left: 0, right: 0, height: file.bodyHeight }),
|
||||
[file.bodyHeight],
|
||||
);
|
||||
const spacerStyle = useMemo(
|
||||
() => ({ width: file.contentWidth, height: file.bodyHeight }),
|
||||
[file.bodyHeight, file.contentWidth],
|
||||
);
|
||||
return (
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
bounces={false}
|
||||
style={rootStyle}
|
||||
contentContainerStyle={spacerStyle}
|
||||
onScroll={handler}
|
||||
onScrollEndDrag={reportOffset}
|
||||
scrollEventThrottle={16}
|
||||
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
|
||||
onMomentumScrollEnd={reportOffset}
|
||||
>
|
||||
<View style={spacerStyle} />
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { memo, useCallback, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import type { DiffFileSection } from "./types";
|
||||
|
||||
export const HorizontalScroll = memo(function HorizontalScroll({
|
||||
file,
|
||||
initialOffset,
|
||||
onScroll,
|
||||
}: {
|
||||
file: DiffFileSection;
|
||||
initialOffset: number;
|
||||
onScroll: (path: string, offset: number) => void;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const scroll = useCallback(
|
||||
(event: React.UIEvent<HTMLDivElement>) => onScroll(file.path, event.currentTarget.scrollLeft),
|
||||
[file.path, onScroll],
|
||||
);
|
||||
const rootStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: file.bodyTop,
|
||||
width: "100%",
|
||||
height: file.bodyHeight,
|
||||
overflowX: "auto",
|
||||
overflowY: "hidden",
|
||||
pointerEvents: "auto",
|
||||
}),
|
||||
[file.bodyHeight, file.bodyTop],
|
||||
);
|
||||
const spacerStyle = useMemo<React.CSSProperties>(
|
||||
() => ({ width: file.contentWidth, height: Math.max(1, file.bodyHeight) }),
|
||||
[file.bodyHeight, file.contentWidth],
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
const element = scrollRef.current;
|
||||
if (!element) return;
|
||||
element.scrollLeft = initialOffset;
|
||||
onScroll(file.path, element.scrollLeft);
|
||||
}, [file.path, initialOffset, onScroll]);
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
data-testid={`diff-file-${file.fileIndex}-horizontal-scroll`}
|
||||
onScroll={scroll}
|
||||
style={rootStyle}
|
||||
>
|
||||
<div style={spacerStyle} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { createDiffPalette } from "./palette";
|
||||
import { DiffSurface } from "./surface";
|
||||
import type { DiffDocumentProps, DiffPalette } from "./types";
|
||||
|
||||
export type { DiffDocumentProps, WorkingDiffMode } from "./types";
|
||||
|
||||
type ThemedDiffDocumentProps = DiffDocumentProps & {
|
||||
palette: DiffPalette;
|
||||
};
|
||||
|
||||
const EMPTY_PATHS: string[] = [];
|
||||
|
||||
function ThemedDiffDocument(props: ThemedDiffDocumentProps) {
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const collapseState = props.mode.kind === "working" ? props.collapseState : null;
|
||||
const paths = collapseState?.paths ?? EMPTY_PATHS;
|
||||
const collapsedFilePaths = useMemo(() => new Set(paths), [paths]);
|
||||
const toggleFile = useCallback(
|
||||
(path: string) => {
|
||||
if (!collapseState) return;
|
||||
const next = collapsedFilePaths.has(path)
|
||||
? paths.filter((entry) => entry !== path)
|
||||
: [...paths, path];
|
||||
collapseState.onChange(next);
|
||||
},
|
||||
[collapseState, collapsedFilePaths, paths],
|
||||
);
|
||||
return (
|
||||
<DiffSurface
|
||||
{...props}
|
||||
collapsedFilePaths={collapsedFilePaths}
|
||||
onToggleFile={toggleFile}
|
||||
selectedPath={selectedPath}
|
||||
onSelectPath={setSelectedPath}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledDiffDocument = withUnistyles(ThemedDiffDocument, (theme) => ({
|
||||
palette: createDiffPalette(theme),
|
||||
}));
|
||||
|
||||
export function DiffDocument(props: DiffDocumentProps) {
|
||||
return <StyledDiffDocument {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildDiffDocumentModel,
|
||||
captureScrollAnchor,
|
||||
graphemeBoundaries,
|
||||
measureFragments,
|
||||
resolveScrollAnchor,
|
||||
} from "./model";
|
||||
import type { BuildDiffDocumentModelInput, TextMeasurer } from "./types";
|
||||
|
||||
const measurer: TextMeasurer = { measure: (text) => Array.from(text).length * 10 };
|
||||
|
||||
function file(path = "src/a.ts"): ParsedDiffFile {
|
||||
return {
|
||||
path,
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldCount: 1,
|
||||
newStart: 1,
|
||||
newCount: 1,
|
||||
lines: [
|
||||
{ type: "header", content: "@@ -1 +1 @@" },
|
||||
{ type: "remove", content: "const oldValue = '👨👩👧👦';" },
|
||||
{ type: "add", content: "const newValue = 'é';" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function input(overrides: Partial<BuildDiffDocumentModelInput> = {}): BuildDiffDocumentModelInput {
|
||||
return {
|
||||
files: [file()],
|
||||
collapsedFilePaths: new Set(),
|
||||
layout: "unified",
|
||||
wrapLines: true,
|
||||
viewportWidth: 180,
|
||||
typography: { family: "monospace", size: 12, lineHeight: 18 },
|
||||
measureText: measurer,
|
||||
palette: {
|
||||
surface: "#000",
|
||||
headerSurface: "#111",
|
||||
border: "#222",
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
addition: "green",
|
||||
deletion: "red",
|
||||
additionBackground: "#010",
|
||||
deletionBackground: "#100",
|
||||
emptyBackground: "#111",
|
||||
selection: "blue",
|
||||
syntax: {},
|
||||
},
|
||||
labels: { binary: "Binary", tooLarge: "Too large" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("diff document model", () => {
|
||||
it("measures grapheme-safe wrapped fragments", () => {
|
||||
const text = "ab👨👩👧👦cd";
|
||||
const fragments = measureFragments({
|
||||
text,
|
||||
availableWidth: 30,
|
||||
wrapLines: true,
|
||||
lineHeight: 18,
|
||||
measureText: measurer,
|
||||
});
|
||||
const boundaries = new Set(graphemeBoundaries(text));
|
||||
expect(fragments.length).toBeGreaterThan(1);
|
||||
for (const fragment of fragments) {
|
||||
expect(boundaries.has(fragment.start)).toBe(true);
|
||||
expect(boundaries.has(fragment.end)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("measures wrapped ligatures in the exact fragment shaping context", () => {
|
||||
const ligatureMeasurer = shapingMeasurer(ligatureWidth);
|
||||
|
||||
const fragments = measureFragments({
|
||||
text: "xfi",
|
||||
availableWidth: 12,
|
||||
wrapLines: true,
|
||||
lineHeight: 18,
|
||||
measureText: ligatureMeasurer,
|
||||
});
|
||||
|
||||
expect(fragments.map((fragment) => fragment.text)).toEqual(["x", "fi"]);
|
||||
expect(fragments.map((fragment) => fragment.width)).toEqual([10, 12]);
|
||||
expect(fragments[1]?.graphemes.map((grapheme) => grapheme.width)).toEqual([8, 4]);
|
||||
});
|
||||
|
||||
it("remeasures contextual-script glyphs after wrapping", () => {
|
||||
const contextualMeasurer = shapingMeasurer((text) => {
|
||||
if (text === "اب") return 15;
|
||||
if (text === "ا") return 10;
|
||||
if (text === "ب") return 9;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const fragments = measureFragments({
|
||||
text: "اب",
|
||||
availableWidth: 10,
|
||||
wrapLines: true,
|
||||
lineHeight: 18,
|
||||
measureText: contextualMeasurer,
|
||||
});
|
||||
|
||||
expect(fragments.map((fragment) => fragment.text)).toEqual(["ا", "ب"]);
|
||||
expect(fragments.map((fragment) => fragment.width)).toEqual([10, 9]);
|
||||
expect(fragments[1]?.graphemes[0]?.width).toBe(9);
|
||||
});
|
||||
|
||||
it("expands tabs to stable stops while retaining source offsets", () => {
|
||||
const source = "a\tb\t中👨👩👧👦é";
|
||||
const model = buildDiffDocumentModel(
|
||||
input({ files: [fileWithChangedLine(source)], wrapLines: false }),
|
||||
);
|
||||
const cell = addedCell(model);
|
||||
expect(cell.content).toBe(source);
|
||||
expect(cell.fragments[0]?.text).toBe("a b 中👨👩👧👦é");
|
||||
expect(cell.fragments[0]?.graphemes.map((grapheme) => grapheme.text)).toEqual([
|
||||
"a",
|
||||
" ",
|
||||
"b",
|
||||
" ",
|
||||
"中",
|
||||
"👨👩👧👦",
|
||||
"é",
|
||||
]);
|
||||
expect(cell.content).toBe(source);
|
||||
expect(cell.fragments[0]?.width).toBe(180);
|
||||
});
|
||||
|
||||
it("measures CJK, combining marks, and ZWJ emoji as fallback-capable grapheme runs", () => {
|
||||
const model = buildDiffDocumentModel(
|
||||
input({
|
||||
files: [fileWithChangedLine("中é👨👩👧👦")],
|
||||
}),
|
||||
);
|
||||
expect(addedCell(model).fragments[0]?.graphemes.map((grapheme) => grapheme.text)).toEqual([
|
||||
"中",
|
||||
"é",
|
||||
"👨👩👧👦",
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes collapsed bodies and leaves future files expanded by default", () => {
|
||||
const expanded = buildDiffDocumentModel(input({ files: [file("a.ts"), file("b.ts")] }));
|
||||
const collapsed = buildDiffDocumentModel(
|
||||
input({ files: [file("a.ts"), file("b.ts")], collapsedFilePaths: new Set(["a.ts"]) }),
|
||||
);
|
||||
expect(collapsed.files[0]?.bodyHeight).toBe(0);
|
||||
expect(collapsed.files[1]?.bodyHeight).toBeGreaterThan(0);
|
||||
expect(collapsed.height).toBeLessThan(expanded.height);
|
||||
});
|
||||
|
||||
it("reserves review geometry in the measured row", () => {
|
||||
const model = buildDiffDocumentModel(
|
||||
input({
|
||||
reviewActions: {
|
||||
commentsByTarget: new Map(),
|
||||
editor: {
|
||||
target: {
|
||||
key: "src/a.ts:old:1",
|
||||
filePath: "src/a.ts",
|
||||
hunkHeader: "@@ -1 +1 @@",
|
||||
hunkIndex: 0,
|
||||
lineIndex: 1,
|
||||
oldLineNumber: 1,
|
||||
newLineNumber: null,
|
||||
side: "old",
|
||||
lineNumber: 1,
|
||||
lineType: "remove",
|
||||
content: "const oldValue = '👨👩👧👦';",
|
||||
},
|
||||
body: "",
|
||||
commentId: null,
|
||||
},
|
||||
onStartComment() {},
|
||||
onCancelEditor() {},
|
||||
onSaveEditor() {},
|
||||
onEditComment() {},
|
||||
onDeleteComment() {},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const reviewRow = model.rows.find((row) => {
|
||||
if (row.kind !== "line") return false;
|
||||
return (
|
||||
row.cells[0]?.reviewTarget?.side === "old" || row.cells[1]?.reviewTarget?.side === "old"
|
||||
);
|
||||
});
|
||||
expect(reviewRow?.kind === "line" ? reviewRow.reviewHeight : 0).toBe(148);
|
||||
});
|
||||
|
||||
it("preserves a logical source anchor through width and font metric relayout", () => {
|
||||
const narrow = buildDiffDocumentModel(
|
||||
input({ files: [fileWithChangedLine("abcdefghij")], viewportWidth: 90 }),
|
||||
);
|
||||
const narrowCell = addedCell(narrow);
|
||||
const narrowRow = narrow.rows.find(
|
||||
(row) => row.kind === "line" && row.cells.includes(narrowCell),
|
||||
);
|
||||
expect(narrowRow?.kind).toBe("line");
|
||||
expect(narrowCell.fragments.length).toBeGreaterThan(1);
|
||||
const scrollTop = narrowRow!.top + narrowCell.fragments[1]!.top + 3;
|
||||
const anchor = captureScrollAnchor(narrow, scrollTop);
|
||||
expect(anchor?.kind).toBe("text");
|
||||
|
||||
const wide = buildDiffDocumentModel(
|
||||
input({
|
||||
files: [fileWithChangedLine("abcdefghij")],
|
||||
viewportWidth: 300,
|
||||
typography: { family: "wide-font", size: 18, lineHeight: 27 },
|
||||
measureText: { measure: (text) => Array.from(text).length * 14 },
|
||||
}),
|
||||
);
|
||||
const resolved = resolveScrollAnchor(wide, anchor!);
|
||||
const wideCell = addedCell(wide);
|
||||
const wideRow = wide.rows.find((row) => row.kind === "line" && row.cells.includes(wideCell));
|
||||
const anchoredFragment = wideCell.fragments.find(
|
||||
(fragment) =>
|
||||
anchor?.kind === "text" &&
|
||||
fragment.start <= anchor.cellOffset &&
|
||||
anchor.cellOffset < fragment.end,
|
||||
);
|
||||
expect(resolved).toBe(wideRow!.top + anchoredFragment!.top - anchor!.viewportDelta);
|
||||
|
||||
const wideAnchor = captureScrollAnchor(wide, wideRow!.top + 2);
|
||||
const backToNarrow = resolveScrollAnchor(narrow, wideAnchor!);
|
||||
const firstNarrowFragment = narrowCell.fragments[0];
|
||||
expect(backToNarrow).toBe(
|
||||
narrowRow!.top + firstNarrowFragment!.top - wideAnchor!.viewportDelta,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a source-line anchor from unified to split and back", () => {
|
||||
const changedFile = fileWithUnevenChanges();
|
||||
const unified = buildDiffDocumentModel(
|
||||
input({ files: [changedFile], layout: "unified", viewportWidth: 100 }),
|
||||
);
|
||||
const identity = { hunkIndex: 0, lineIndex: 4, side: "new" } as const;
|
||||
const unifiedCell = cellByIdentity(unified, identity);
|
||||
const unifiedRow = rowForCell(unified, unifiedCell);
|
||||
expect(unifiedCell.fragments.length).toBeGreaterThan(1);
|
||||
const anchoredFragment = unifiedCell.fragments[1]!;
|
||||
const anchor = captureScrollAnchor(unified, unifiedRow.top + anchoredFragment.top + 2);
|
||||
expect(anchor).toMatchObject({
|
||||
kind: "text",
|
||||
sourceIdentity: identity,
|
||||
cellOffset: anchoredFragment.start,
|
||||
viewportDelta: -2,
|
||||
});
|
||||
|
||||
const split = buildDiffDocumentModel(
|
||||
input({ files: [changedFile], layout: "split", viewportWidth: 100 }),
|
||||
);
|
||||
const splitCell = cellByIdentity(split, identity);
|
||||
const splitRow = rowForCell(split, splitCell);
|
||||
const splitFragment = splitCell.fragments.find(
|
||||
(fragment) =>
|
||||
fragment.start <= anchoredFragment.start && anchoredFragment.start < fragment.end,
|
||||
);
|
||||
expect(resolveScrollAnchor(split, anchor!)).toBe(
|
||||
splitRow.top + splitFragment!.top - anchor!.viewportDelta,
|
||||
);
|
||||
|
||||
const splitAnchor = captureScrollAnchor(
|
||||
split,
|
||||
splitRow.top + splitFragment!.top - anchor!.viewportDelta,
|
||||
);
|
||||
expect(splitAnchor).toMatchObject({
|
||||
kind: "text",
|
||||
sourceIdentity: identity,
|
||||
cellOffset: splitFragment!.start,
|
||||
});
|
||||
const unifiedAgain = buildDiffDocumentModel(
|
||||
input({ files: [changedFile], layout: "unified", viewportWidth: 100 }),
|
||||
);
|
||||
const finalCell = cellByIdentity(unifiedAgain, identity);
|
||||
const finalRow = rowForCell(unifiedAgain, finalCell);
|
||||
const finalFragment = finalCell.fragments.find(
|
||||
(fragment) =>
|
||||
splitAnchor?.kind === "text" &&
|
||||
fragment.start <= splitAnchor.cellOffset &&
|
||||
splitAnchor.cellOffset < fragment.end,
|
||||
);
|
||||
expect(resolveScrollAnchor(unifiedAgain, splitAnchor!)).toBe(
|
||||
finalRow.top + finalFragment!.top - splitAnchor!.viewportDelta,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the exact unified maximum horizontal scroll range", () => {
|
||||
const model = buildDiffDocumentModel(
|
||||
input({ files: [fileWithChangedLine("x".repeat(30))], wrapLines: false, viewportWidth: 240 }),
|
||||
);
|
||||
|
||||
expect(model.files[0]?.contentWidth).toBe(344);
|
||||
expect(model.files[0]!.contentWidth - model.viewportWidth).toBe(104);
|
||||
});
|
||||
|
||||
it("uses the widest split-column overflow once for the shared file offset", () => {
|
||||
const model = buildDiffDocumentModel(
|
||||
input({
|
||||
files: [fileWithChangedLine("x".repeat(30))],
|
||||
layout: "split",
|
||||
wrapLines: false,
|
||||
viewportWidth: 240,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(model.files[0]?.contentWidth).toBe(464);
|
||||
expect(model.files[0]!.contentWidth - model.viewportWidth).toBe(224);
|
||||
});
|
||||
});
|
||||
|
||||
function shapingMeasurer(measure: (text: string) => number): TextMeasurer {
|
||||
return {
|
||||
measure,
|
||||
measureAdvances(graphemes) {
|
||||
let prefix = "";
|
||||
return graphemes.map((grapheme) => measure((prefix += grapheme)));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function ligatureWidth(text: string): number {
|
||||
if (text === "fi") return 12;
|
||||
if (text === "xfi") return 25;
|
||||
let width = 0;
|
||||
for (const character of text) width += { x: 10, f: 8, i: 7 }[character] ?? 0;
|
||||
return width;
|
||||
}
|
||||
|
||||
function fileWithChangedLine(content: string): ParsedDiffFile {
|
||||
const changed = file();
|
||||
const hunk = changed.hunks[0]!;
|
||||
return {
|
||||
...changed,
|
||||
hunks: [
|
||||
{
|
||||
oldStart: hunk.oldStart,
|
||||
oldCount: hunk.oldCount,
|
||||
newStart: hunk.newStart,
|
||||
newCount: hunk.newCount,
|
||||
lines: hunk.lines.map((line) =>
|
||||
line.type === "add" ? Object.assign({}, line, { content }) : line,
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function fileWithUnevenChanges(): ParsedDiffFile {
|
||||
return {
|
||||
path: "src/anchor.ts",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 3,
|
||||
deletions: 2,
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 10,
|
||||
oldCount: 2,
|
||||
newStart: 20,
|
||||
newCount: 3,
|
||||
lines: [
|
||||
{ type: "header", content: "@@ -10,2 +20,3 @@" },
|
||||
{ type: "remove", content: "removed first" },
|
||||
{ type: "remove", content: "x" },
|
||||
{ type: "add", content: "added first" },
|
||||
{ type: "add", content: "anchored addition has several wrapped fragments" },
|
||||
{ type: "add", content: "added third" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function addedCell(model: ReturnType<typeof buildDiffDocumentModel>) {
|
||||
const cell = model.rows
|
||||
.filter((row) => row.kind === "line")
|
||||
.flatMap((row) => row.cells)
|
||||
.find((candidate) => candidate?.type === "add");
|
||||
if (!cell) throw new Error("Expected an added cell");
|
||||
return cell;
|
||||
}
|
||||
|
||||
function cellByIdentity(
|
||||
model: ReturnType<typeof buildDiffDocumentModel>,
|
||||
identity: { hunkIndex: number; lineIndex: number; side: "old" | "new" },
|
||||
) {
|
||||
const cell = model.rows
|
||||
.filter((row) => row.kind === "line")
|
||||
.flatMap((row) => row.cells)
|
||||
.find(
|
||||
(candidate) =>
|
||||
candidate?.sourceIdentity.hunkIndex === identity.hunkIndex &&
|
||||
candidate.sourceIdentity.lineIndex === identity.lineIndex &&
|
||||
candidate.sourceIdentity.side === identity.side,
|
||||
);
|
||||
if (!cell) throw new Error("Expected a cell with the source identity");
|
||||
return cell;
|
||||
}
|
||||
|
||||
function rowForCell(
|
||||
model: ReturnType<typeof buildDiffDocumentModel>,
|
||||
cell: ReturnType<typeof cellByIdentity>,
|
||||
) {
|
||||
const row = model.rows.find(
|
||||
(candidate) => candidate.kind === "line" && candidate.cells.includes(cell),
|
||||
);
|
||||
if (!row || row.kind !== "line") throw new Error("Expected the cell row");
|
||||
return row;
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import {
|
||||
buildSplitDiffRows,
|
||||
buildUnifiedDiffLines,
|
||||
type ReviewableDiffTarget,
|
||||
type SplitDiffDisplayLine,
|
||||
} from "@/utils/diff-layout";
|
||||
import { compactHighlightTokens } from "@/utils/diff-rendering";
|
||||
import { getInlineReviewThreadState, getSplitInlineReviewThreadState } from "@/review/geometry";
|
||||
import type {
|
||||
BuildDiffDocumentModelInput,
|
||||
DiffCell,
|
||||
DiffDocumentModel,
|
||||
DiffFileSection,
|
||||
DiffFragment,
|
||||
DiffGrapheme,
|
||||
DiffLineRow,
|
||||
DiffRow,
|
||||
DiffScrollAnchor,
|
||||
DiffSourceIdentity,
|
||||
DiffTokenRun,
|
||||
TextMeasurer,
|
||||
} from "./types";
|
||||
|
||||
export const FILE_HEADER_HEIGHT = 44;
|
||||
const BODY_BORDER_HEIGHT = 1;
|
||||
const CODE_HORIZONTAL_PADDING = 16;
|
||||
|
||||
interface CellSource {
|
||||
type: DiffCell["type"];
|
||||
content: string;
|
||||
lineNumber: number | null;
|
||||
reviewTarget: ReviewableDiffTarget | null;
|
||||
tokenText: readonly { text: string; style?: string | null }[];
|
||||
sourceIdentity: DiffSourceIdentity;
|
||||
}
|
||||
|
||||
export function buildDiffDocumentModel(input: BuildDiffDocumentModelInput): DiffDocumentModel {
|
||||
const rows: DiffRow[] = [];
|
||||
const files: DiffFileSection[] = [];
|
||||
let documentTop = 0;
|
||||
|
||||
for (const [fileIndex, file] of input.files.entries()) {
|
||||
const fileTop = documentTop;
|
||||
const isCollapsed = input.collapsedFilePaths.has(file.path);
|
||||
documentTop += FILE_HEADER_HEIGHT;
|
||||
const bodyTop = documentTop;
|
||||
const rowStart = rows.length;
|
||||
const gutterWidth = lineNumberGutterWidth(maximumLineNumber(file), input.typography.size);
|
||||
let maximumHorizontalOverflow = 0;
|
||||
|
||||
if (!isCollapsed) {
|
||||
if (file.status === "binary" || file.status === "too_large") {
|
||||
const height = input.typography.lineHeight + 24;
|
||||
rows.push({
|
||||
kind: "status",
|
||||
index: rows.length,
|
||||
fileIndex,
|
||||
path: file.path,
|
||||
top: documentTop,
|
||||
height,
|
||||
label: file.status === "binary" ? input.labels.binary : input.labels.tooLarge,
|
||||
});
|
||||
documentTop += height;
|
||||
} else {
|
||||
const sources = lineSources(file, input.layout);
|
||||
for (const sourceCells of sources) {
|
||||
const columnCount = sourceCells.length;
|
||||
const columnWidth = input.viewportWidth / columnCount;
|
||||
const availableWidth = Math.max(
|
||||
input.measureText.measure("M"),
|
||||
columnWidth - gutterWidth - CODE_HORIZONTAL_PADDING,
|
||||
);
|
||||
const cells = sourceCells.map((source) => {
|
||||
if (!source) return null;
|
||||
const cell = measureCell({
|
||||
source,
|
||||
availableWidth,
|
||||
input,
|
||||
});
|
||||
maximumHorizontalOverflow = Math.max(
|
||||
maximumHorizontalOverflow,
|
||||
gutterWidth + CODE_HORIZONTAL_PADDING + measureCellWidth(cell) - columnWidth,
|
||||
);
|
||||
return cell;
|
||||
}) as DiffLineRow["cells"];
|
||||
const textHeight = Math.max(
|
||||
input.typography.lineHeight,
|
||||
...cells.map((cell) => (cell?.fragments.length ?? 1) * input.typography.lineHeight),
|
||||
);
|
||||
const reviewHeight = reviewHeightForCells(cells, input);
|
||||
const height = textHeight + reviewHeight;
|
||||
rows.push({
|
||||
kind: "line",
|
||||
index: rows.length,
|
||||
fileIndex,
|
||||
path: file.path,
|
||||
top: documentTop,
|
||||
height,
|
||||
cells,
|
||||
reviewHeight,
|
||||
});
|
||||
documentTop += height;
|
||||
}
|
||||
}
|
||||
documentTop += BODY_BORDER_HEIGHT;
|
||||
}
|
||||
|
||||
files.push({
|
||||
file,
|
||||
fileIndex,
|
||||
path: file.path,
|
||||
top: fileTop,
|
||||
headerHeight: FILE_HEADER_HEIGHT,
|
||||
bodyTop,
|
||||
bodyHeight: documentTop - bodyTop,
|
||||
bottom: documentTop,
|
||||
gutterWidth,
|
||||
contentWidth: input.wrapLines
|
||||
? input.viewportWidth
|
||||
: input.viewportWidth + Math.max(0, maximumHorizontalOverflow),
|
||||
rowStart,
|
||||
rowEnd: rows.length,
|
||||
isCollapsed,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
files,
|
||||
rows,
|
||||
height: documentTop,
|
||||
lineHeight: input.typography.lineHeight,
|
||||
layout: input.layout,
|
||||
wrapLines: input.wrapLines,
|
||||
viewportWidth: input.viewportWidth,
|
||||
};
|
||||
}
|
||||
|
||||
function lineSources(
|
||||
file: BuildDiffDocumentModelInput["files"][number],
|
||||
layout: "unified" | "split",
|
||||
): Array<[CellSource] | [CellSource | null, CellSource | null]> {
|
||||
if (layout === "split") {
|
||||
return buildSplitDiffRows(file).map((row) => {
|
||||
if (row.kind === "header") {
|
||||
return [headerSource(row.content, row.hunkIndex, row.lineIndex)];
|
||||
}
|
||||
return [cellSource(row.left), cellSource(row.right)];
|
||||
});
|
||||
}
|
||||
return buildUnifiedDiffLines(file).map((entry) => [
|
||||
{
|
||||
type: entry.line.type,
|
||||
content: entry.line.content,
|
||||
lineNumber: entry.lineNumber,
|
||||
reviewTarget: entry.reviewTarget,
|
||||
tokenText: compactHighlightTokens(entry.line.tokens ?? []),
|
||||
sourceIdentity: {
|
||||
hunkIndex: entry.hunkIndex,
|
||||
lineIndex: entry.lineIndex,
|
||||
side: entry.reviewTarget?.side ?? "new",
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function headerSource(content: string, hunkIndex: number, lineIndex: number): CellSource {
|
||||
return {
|
||||
type: "header",
|
||||
content,
|
||||
lineNumber: null,
|
||||
reviewTarget: null,
|
||||
tokenText: [],
|
||||
sourceIdentity: { hunkIndex, lineIndex, side: "new" },
|
||||
};
|
||||
}
|
||||
|
||||
function cellSource(line: SplitDiffDisplayLine | null): CellSource | null {
|
||||
if (!line) return null;
|
||||
return {
|
||||
type: line.type,
|
||||
content: line.content,
|
||||
lineNumber: line.lineNumber,
|
||||
reviewTarget: line.reviewTarget,
|
||||
tokenText: compactHighlightTokens(line.tokens ?? []),
|
||||
sourceIdentity: {
|
||||
hunkIndex: line.hunkIndex,
|
||||
lineIndex: line.lineIndex,
|
||||
side: line.side,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function measureCell(input: {
|
||||
source: CellSource;
|
||||
availableWidth: number;
|
||||
input: BuildDiffDocumentModelInput;
|
||||
}): DiffCell {
|
||||
const fragments = measureFragments({
|
||||
text: input.source.content,
|
||||
availableWidth: input.availableWidth,
|
||||
wrapLines: input.input.wrapLines,
|
||||
lineHeight: input.input.typography.lineHeight,
|
||||
measureText: input.input.measureText,
|
||||
});
|
||||
return {
|
||||
type: input.source.type,
|
||||
content: input.source.content,
|
||||
lineNumber: input.source.lineNumber,
|
||||
tokens: tokenRuns(input.source, input.input.palette),
|
||||
fragments,
|
||||
reviewTarget: input.source.reviewTarget,
|
||||
sourceIdentity: input.source.sourceIdentity,
|
||||
};
|
||||
}
|
||||
|
||||
function tokenRuns(
|
||||
source: CellSource,
|
||||
palette: BuildDiffDocumentModelInput["palette"],
|
||||
): DiffTokenRun[] {
|
||||
const runs: DiffTokenRun[] = [];
|
||||
let offset = 0;
|
||||
for (const token of source.tokenText) {
|
||||
const end = offset + token.text.length;
|
||||
const color = token.style
|
||||
? (palette.syntax[token.style] ?? palette.foreground)
|
||||
: palette.foreground;
|
||||
runs.push({ start: offset, end, color });
|
||||
offset = end;
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
export function measureFragments(input: {
|
||||
text: string;
|
||||
availableWidth: number;
|
||||
wrapLines: boolean;
|
||||
lineHeight: number;
|
||||
measureText: TextMeasurer;
|
||||
}): DiffFragment[] {
|
||||
const graphemes = segmentGraphemes(input.text);
|
||||
if (!input.wrapLines || input.text.length === 0) {
|
||||
return [createFragment(graphemes, 0, graphemes.length, 0, input.lineHeight, input.measureText)];
|
||||
}
|
||||
const fragments: DiffFragment[] = [];
|
||||
let graphemeIndex = 0;
|
||||
while (graphemeIndex < graphemes.length) {
|
||||
let low = graphemeIndex + 1;
|
||||
let high = graphemes.length;
|
||||
let fitting = low;
|
||||
while (low <= high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
const width = measureGraphemeSlice(graphemes, graphemeIndex, middle, input.measureText).width;
|
||||
if (width <= input.availableWidth || middle === graphemeIndex + 1) {
|
||||
fitting = middle;
|
||||
low = middle + 1;
|
||||
} else {
|
||||
high = middle - 1;
|
||||
}
|
||||
}
|
||||
fragments.push(
|
||||
createFragment(
|
||||
graphemes,
|
||||
graphemeIndex,
|
||||
fitting,
|
||||
fragments.length,
|
||||
input.lineHeight,
|
||||
input.measureText,
|
||||
),
|
||||
);
|
||||
graphemeIndex = fitting;
|
||||
}
|
||||
return fragments;
|
||||
}
|
||||
|
||||
function createFragment(
|
||||
graphemes: Array<Omit<DiffGrapheme, "width">>,
|
||||
startIndex: number,
|
||||
endIndex: number,
|
||||
visualIndex: number,
|
||||
lineHeight: number,
|
||||
measureText: TextMeasurer,
|
||||
): DiffFragment {
|
||||
const measured = measureGraphemeSlice(graphemes, startIndex, endIndex, measureText);
|
||||
const fragmentGraphemes = measured.graphemes;
|
||||
const start = fragmentGraphemes[0]?.start ?? 0;
|
||||
const end = fragmentGraphemes.at(-1)?.end ?? start;
|
||||
const top = visualIndex * lineHeight;
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
text: fragmentGraphemes.map((grapheme) => grapheme.text).join(""),
|
||||
width: measured.width,
|
||||
top,
|
||||
baseline: top + lineHeight * 0.78,
|
||||
graphemes: fragmentGraphemes,
|
||||
};
|
||||
}
|
||||
|
||||
const TAB_SIZE = 4;
|
||||
|
||||
function segmentGraphemes(text: string): Array<Omit<DiffGrapheme, "width">> {
|
||||
const segments: Array<Omit<DiffGrapheme, "width">> = [];
|
||||
let column = 0;
|
||||
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
for (const segment of segmenter.segment(text)) {
|
||||
const isTab = segment.segment === "\t";
|
||||
const spaces = isTab ? TAB_SIZE - (column % TAB_SIZE) : 0;
|
||||
const displayText = isTab ? " ".repeat(spaces) : segment.segment;
|
||||
segments.push({
|
||||
start: segment.index,
|
||||
end: segment.index + segment.segment.length,
|
||||
text: displayText,
|
||||
});
|
||||
column += isTab ? spaces : 1;
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
function measureGraphemeSlice(
|
||||
graphemes: Array<Omit<DiffGrapheme, "width">>,
|
||||
startIndex: number,
|
||||
endIndex: number,
|
||||
measureText: TextMeasurer,
|
||||
): { graphemes: DiffGrapheme[]; width: number } {
|
||||
const segments = graphemes.slice(startIndex, endIndex);
|
||||
const displayGraphemes = segments.map((segment) => segment.text);
|
||||
const advances = measureText.measureAdvances
|
||||
? measureText.measureAdvances(displayGraphemes)
|
||||
: measurePrefixAdvances(displayGraphemes, measureText.measure);
|
||||
return {
|
||||
graphemes: segments.map((segment, index) => ({
|
||||
start: segment.start,
|
||||
end: segment.end,
|
||||
text: segment.text,
|
||||
width: (advances[index] ?? 0) - (advances[index - 1] ?? 0),
|
||||
})),
|
||||
width: advances.at(-1) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function measurePrefixAdvances(
|
||||
graphemes: readonly string[],
|
||||
measureText: (text: string) => number,
|
||||
): number[] {
|
||||
let prefix = "";
|
||||
return graphemes.map((grapheme) => {
|
||||
prefix += grapheme;
|
||||
return measureText(prefix);
|
||||
});
|
||||
}
|
||||
|
||||
export function fragmentTextForRange(fragment: DiffFragment, start: number, end: number): string {
|
||||
return fragment.graphemes
|
||||
.filter((grapheme) => grapheme.start >= start && grapheme.end <= end)
|
||||
.map((grapheme) => grapheme.text)
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function fragmentWidthForRange(fragment: DiffFragment, start: number, end: number): number {
|
||||
return fragment.graphemes
|
||||
.filter((grapheme) => grapheme.start >= start && grapheme.end <= end)
|
||||
.reduce((width, grapheme) => width + grapheme.width, 0);
|
||||
}
|
||||
|
||||
export function graphemeBoundaries(text: string): number[] {
|
||||
const boundaries = [0];
|
||||
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
for (const segment of segmenter.segment(text)) {
|
||||
const end = segment.index + segment.segment.length;
|
||||
if (end > boundaries[boundaries.length - 1]) boundaries.push(end);
|
||||
}
|
||||
return boundaries;
|
||||
}
|
||||
|
||||
function measureCellWidth(cell: DiffCell): number {
|
||||
return Math.max(0, ...cell.fragments.map((part) => part.width));
|
||||
}
|
||||
|
||||
function reviewHeightForCells(
|
||||
cells: DiffLineRow["cells"],
|
||||
input: BuildDiffDocumentModelInput,
|
||||
): number {
|
||||
if (cells.length === 2) {
|
||||
return (
|
||||
getSplitInlineReviewThreadState({
|
||||
left: cells[0]?.reviewTarget,
|
||||
right: cells[1]?.reviewTarget,
|
||||
reviewActions: input.reviewActions,
|
||||
})?.height ?? 0
|
||||
);
|
||||
}
|
||||
return (
|
||||
getInlineReviewThreadState({
|
||||
reviewTarget: cells[0]?.reviewTarget,
|
||||
reviewActions: input.reviewActions,
|
||||
})?.height ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
function maximumLineNumber(file: BuildDiffDocumentModelInput["files"][number]): number {
|
||||
let maximum = 0;
|
||||
for (const hunk of file.hunks) {
|
||||
maximum = Math.max(maximum, hunk.oldStart + hunk.oldCount, hunk.newStart + hunk.newCount);
|
||||
}
|
||||
return maximum;
|
||||
}
|
||||
|
||||
export function visibleRowRange(
|
||||
rows: readonly DiffRow[],
|
||||
viewportTop: number,
|
||||
viewportHeight: number,
|
||||
): { start: number; end: number } {
|
||||
"worklet";
|
||||
const viewportBottom = viewportTop + viewportHeight;
|
||||
let low = 0;
|
||||
let high = rows.length;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
const row = rows[middle];
|
||||
if (row && row.top + row.height <= viewportTop) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
const start = low;
|
||||
high = rows.length;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
const row = rows[middle];
|
||||
if (row && row.top < viewportBottom) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
return { start, end: low };
|
||||
}
|
||||
|
||||
export function fileAtDocumentOffset(
|
||||
files: readonly DiffFileSection[],
|
||||
documentY: number,
|
||||
): DiffFileSection | null {
|
||||
let low = 0;
|
||||
let high = files.length;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
const file = files[middle];
|
||||
if (file && file.bottom <= documentY) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
return files[low] ?? null;
|
||||
}
|
||||
|
||||
export function captureScrollAnchor(
|
||||
model: DiffDocumentModel,
|
||||
scrollTop: number,
|
||||
): DiffScrollAnchor | null {
|
||||
const file = fileAtDocumentOffset(model.files, scrollTop);
|
||||
if (!file) return null;
|
||||
const row = model.rows
|
||||
.slice(file.rowStart, file.rowEnd)
|
||||
.find((candidate) => candidate.top + candidate.height > scrollTop);
|
||||
if (!row || row.kind !== "line") {
|
||||
return { kind: "header", path: file.path, viewportDelta: file.top - scrollTop };
|
||||
}
|
||||
const textY = Math.max(0, Math.min(scrollTop - row.top, row.height - row.reviewHeight));
|
||||
const visualIndex = Math.max(0, Math.floor(textY / model.lineHeight));
|
||||
let cellIndex = row.cells.findIndex((cell) => Boolean(cell?.fragments[visualIndex]));
|
||||
if (cellIndex < 0) cellIndex = row.cells.findIndex((cell) => cell !== null);
|
||||
const cell = row.cells[cellIndex];
|
||||
if (!cell) return { kind: "header", path: file.path, viewportDelta: row.top - scrollTop };
|
||||
const fragment = cell.fragments[Math.min(cell.fragments.length - 1, visualIndex)];
|
||||
const anchorY = row.top + (fragment?.top ?? 0);
|
||||
return {
|
||||
kind: "text",
|
||||
path: file.path,
|
||||
sourceIdentity: cell.sourceIdentity,
|
||||
cellOffset: fragment?.start ?? 0,
|
||||
viewportDelta: anchorY - scrollTop,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveScrollAnchor(model: DiffDocumentModel, anchor: DiffScrollAnchor): number {
|
||||
const file = model.files.find((candidate) => candidate.path === anchor.path);
|
||||
if (!file) return 0;
|
||||
if (anchor.kind === "header") return Math.max(0, file.top - anchor.viewportDelta);
|
||||
const row = model.rows
|
||||
.slice(file.rowStart, file.rowEnd)
|
||||
.find(
|
||||
(candidate) =>
|
||||
candidate.kind === "line" &&
|
||||
candidate.cells.some(
|
||||
(cell) => cell && sameSourceIdentity(cell.sourceIdentity, anchor.sourceIdentity),
|
||||
),
|
||||
);
|
||||
if (!row || row.kind !== "line") return Math.max(0, file.top - anchor.viewportDelta);
|
||||
const cell = row.cells.find(
|
||||
(candidate) => candidate && sameSourceIdentity(candidate.sourceIdentity, anchor.sourceIdentity),
|
||||
);
|
||||
if (!cell) return Math.max(0, row.top - anchor.viewportDelta);
|
||||
const fragment =
|
||||
cell.fragments.find(
|
||||
(candidate) => candidate.start <= anchor.cellOffset && anchor.cellOffset < candidate.end,
|
||||
) ?? cell.fragments.at(-1);
|
||||
return Math.max(0, row.top + (fragment?.top ?? 0) - anchor.viewportDelta);
|
||||
}
|
||||
|
||||
function sameSourceIdentity(left: DiffSourceIdentity, right: DiffSourceIdentity): boolean {
|
||||
return (
|
||||
left.hunkIndex === right.hunkIndex &&
|
||||
left.lineIndex === right.lineIndex &&
|
||||
left.side === right.side
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildDiffDocumentModel } from "./model";
|
||||
import { hitTestDiffPagePoint } 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", () => {
|
||||
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({
|
||||
model,
|
||||
pageX: origin.x + model.files[row.fileIndex]!.gutterWidth + 18,
|
||||
pageY: origin.y + row.top - scrollTop + 2,
|
||||
surfaceOrigin: origin,
|
||||
scrollTop,
|
||||
horizontalOffsetForPath: () => 0,
|
||||
});
|
||||
expect(hit?.kind).toBe("cell");
|
||||
if (hit?.kind === "cell") expect(hit.position.fileIndex).toBe(row.fileIndex);
|
||||
}
|
||||
});
|
||||
|
||||
it("applies the touched file's retained horizontal offset", () => {
|
||||
const model = build("unified");
|
||||
const row = model.rows.find(
|
||||
(candidate) =>
|
||||
candidate.kind === "line" &&
|
||||
candidate.path === "src/second.ts" &&
|
||||
candidate.cells[0]?.type === "add",
|
||||
)!;
|
||||
const hit = hitTestDiffPagePoint({
|
||||
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),
|
||||
});
|
||||
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", () => {
|
||||
const model = build("split");
|
||||
const row = model.rows.find(
|
||||
(candidate) =>
|
||||
candidate.kind === "line" && candidate.cells.length === 2 && candidate.cells.every(Boolean),
|
||||
)!;
|
||||
const left = hitAt(model, row.top, model.viewportWidth * 0.25);
|
||||
const right = hitAt(model, row.top, model.viewportWidth * 0.75);
|
||||
expect(left?.kind === "cell" ? left.position.side : null).toBe("old");
|
||||
expect(right?.kind === "cell" ? right.position.side : null).toBe("new");
|
||||
});
|
||||
});
|
||||
|
||||
function hitAt(model: ReturnType<typeof buildDiffDocumentModel>, rowTop: number, x: number) {
|
||||
return hitTestDiffPagePoint({
|
||||
model,
|
||||
pageX: origin.x + x,
|
||||
pageY: origin.y + rowTop + 2,
|
||||
surfaceOrigin: origin,
|
||||
scrollTop: 0,
|
||||
horizontalOffsetForPath: () => 0,
|
||||
});
|
||||
}
|
||||
|
||||
function build(layout: "unified" | "split") {
|
||||
const input: BuildDiffDocumentModelInput = {
|
||||
files: [
|
||||
diffFile("src/first.ts", "first-long-content"),
|
||||
diffFile("src/second.ts", "second-long-content"),
|
||||
],
|
||||
collapsedFilePaths: new Set(),
|
||||
layout,
|
||||
wrapLines: false,
|
||||
viewportWidth: 400,
|
||||
typography: { family: "monospace", size: 12, lineHeight: 18 },
|
||||
measureText: { measure: (text) => Array.from(text).length * 10 },
|
||||
palette: {
|
||||
surface: "#000",
|
||||
headerSurface: "#111",
|
||||
border: "#222",
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
addition: "green",
|
||||
deletion: "red",
|
||||
additionBackground: "#010",
|
||||
deletionBackground: "#100",
|
||||
emptyBackground: "#111",
|
||||
selection: "blue",
|
||||
syntax: {},
|
||||
},
|
||||
labels: { binary: "Binary", tooLarge: "Too large" },
|
||||
};
|
||||
return buildDiffDocumentModel(input);
|
||||
}
|
||||
|
||||
function diffFile(path: string, content: string): ParsedDiffFile {
|
||||
return {
|
||||
path,
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldCount: 1,
|
||||
newStart: 1,
|
||||
newCount: 1,
|
||||
lines: [
|
||||
{ type: "header", content: "@@ -1 +1 @@" },
|
||||
{ type: "remove", content },
|
||||
{ type: "add", content },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { hitTestDiffDocument } from "./hit-testing";
|
||||
import type { DiffDocumentModel, DiffHit } from "./types";
|
||||
|
||||
export interface SurfaceWindowOrigin {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export function hitTestDiffPagePoint(input: {
|
||||
model: DiffDocumentModel;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
surfaceOrigin: SurfaceWindowOrigin;
|
||||
scrollTop: number;
|
||||
horizontalOffsetForPath: (path: string) => 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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { reviewBackgroundPaint, reviewDividerHeight, reviewGapTop } from "./review-paint";
|
||||
|
||||
describe("native diff review painting", () => {
|
||||
it("paints review space with the surface paint and continues the gutter divider", () => {
|
||||
const surface = { name: "surface" };
|
||||
const border = { name: "border" };
|
||||
expect(reviewBackgroundPaint(surface)).toBe(surface);
|
||||
expect(reviewBackgroundPaint(surface)).not.toBe(border);
|
||||
expect(reviewGapTop(0, 58, 40)).toBe(18);
|
||||
expect(reviewDividerHeight(58)).toBe(58);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { ClipOp, Skia, type SkCanvas, type SkPaint } from "@shopify/react-native-skia";
|
||||
import { visibleRowRange } from "./model";
|
||||
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";
|
||||
|
||||
const CODE_LEFT_PADDING = 8;
|
||||
|
||||
interface NativePaints {
|
||||
surface: SkPaint;
|
||||
border: SkPaint;
|
||||
foregroundMuted: SkPaint;
|
||||
addition: SkPaint;
|
||||
deletion: SkPaint;
|
||||
additionBackground: SkPaint;
|
||||
deletionBackground: SkPaint;
|
||||
headerSurface: SkPaint;
|
||||
emptyBackground: SkPaint;
|
||||
syntax: Record<string, SkPaint>;
|
||||
}
|
||||
|
||||
export function createNativePaints(palette: DiffPalette): NativePaints {
|
||||
return {
|
||||
surface: paint(palette.surface),
|
||||
border: paint(palette.border),
|
||||
foregroundMuted: paint(palette.foregroundMuted),
|
||||
addition: paint(palette.addition),
|
||||
deletion: paint(palette.deletion),
|
||||
additionBackground: paint(palette.additionBackground),
|
||||
deletionBackground: paint(palette.deletionBackground),
|
||||
headerSurface: paint(palette.headerSurface),
|
||||
emptyBackground: paint(palette.emptyBackground),
|
||||
syntax: Object.fromEntries(Object.values(palette.syntax).map((color) => [color, paint(color)])),
|
||||
};
|
||||
}
|
||||
|
||||
function paint(color: string): SkPaint {
|
||||
const result = Skia.Paint();
|
||||
result.setColor(Skia.Color(color));
|
||||
return result;
|
||||
}
|
||||
|
||||
export function paintNativeViewport(input: {
|
||||
canvas: SkCanvas;
|
||||
model: DiffDocumentModel;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
scrollTop: number;
|
||||
horizontalOffsets: Readonly<DiffHorizontalOffsets>;
|
||||
textLayout: NativeTextLayout;
|
||||
paints: NativePaints;
|
||||
}): void {
|
||||
"worklet";
|
||||
input.canvas.drawRect(
|
||||
Skia.XYWHRect(0, 0, input.viewportWidth, input.viewportHeight),
|
||||
input.paints.surface,
|
||||
);
|
||||
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,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
paintLine({
|
||||
...input,
|
||||
row,
|
||||
y,
|
||||
horizontalOffset: horizontalOffsetForPath(input.horizontalOffsets, row.path),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function paintLine(input: {
|
||||
canvas: SkCanvas;
|
||||
model: DiffDocumentModel;
|
||||
viewportWidth: number;
|
||||
row: DiffLineRow;
|
||||
y: number;
|
||||
horizontalOffset: number;
|
||||
textLayout: NativeTextLayout;
|
||||
paints: NativePaints;
|
||||
}): 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(
|
||||
Skia.XYWHRect(
|
||||
textX,
|
||||
input.y,
|
||||
columnWidth - 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);
|
||||
}
|
||||
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)];
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { paintWebViewport } from "./paint.web";
|
||||
import type { DiffCell, DiffDocumentModel, DiffLineRow, DiffPalette, DiffSelection } from "./types";
|
||||
|
||||
describe("web diff text shaping", () => {
|
||||
it("paints syntax colors by clipping the complete measured shaped fragment", () => {
|
||||
const paintedText: string[] = [];
|
||||
const context = {
|
||||
setTransform() {},
|
||||
clearRect() {},
|
||||
fillRect() {},
|
||||
save() {},
|
||||
restore() {},
|
||||
beginPath() {},
|
||||
rect() {},
|
||||
clip() {},
|
||||
fillText(text: string) {
|
||||
paintedText.push(text);
|
||||
},
|
||||
fillStyle: "",
|
||||
globalAlpha: 1,
|
||||
font: "",
|
||||
textBaseline: "alphabetic",
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
|
||||
paintWebViewport({
|
||||
context,
|
||||
model,
|
||||
palette,
|
||||
typography: { family: "ligature-font", size: 12, lineHeight: 18 },
|
||||
measureText: { measure: () => 0 },
|
||||
scrollTop: 0,
|
||||
viewportWidth: 200,
|
||||
viewportHeight: 100,
|
||||
horizontalOffsets: new Map(),
|
||||
selection: null,
|
||||
devicePixelRatio: 1,
|
||||
});
|
||||
|
||||
expect(paintedText).toEqual(["fi", "fi"]);
|
||||
});
|
||||
|
||||
it("paints review space with the surface color and continues the gutter divider", () => {
|
||||
const fills: Array<{ color: string; x: number; y: number; width: number; height: number }> = [];
|
||||
let fillStyle = "";
|
||||
const context = {
|
||||
setTransform() {},
|
||||
clearRect() {},
|
||||
fillRect(x: number, y: number, width: number, height: number) {
|
||||
fills.push({ color: fillStyle, x, y, width, height });
|
||||
},
|
||||
save() {},
|
||||
restore() {},
|
||||
beginPath() {},
|
||||
rect() {},
|
||||
clip() {},
|
||||
fillText() {},
|
||||
get fillStyle() {
|
||||
return fillStyle;
|
||||
},
|
||||
set fillStyle(value: string | CanvasGradient | CanvasPattern) {
|
||||
fillStyle = String(value);
|
||||
},
|
||||
globalAlpha: 1,
|
||||
font: "",
|
||||
textBaseline: "alphabetic",
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
|
||||
paintWebViewport({
|
||||
context,
|
||||
model: modelWithReview,
|
||||
palette,
|
||||
typography: { family: "monospace", size: 12, lineHeight: 18 },
|
||||
measureText: { measure: () => 0 },
|
||||
scrollTop: 0,
|
||||
viewportWidth: 200,
|
||||
viewportHeight: 100,
|
||||
horizontalOffsets: new Map(),
|
||||
selection: null,
|
||||
devicePixelRatio: 1,
|
||||
});
|
||||
|
||||
expect(fills).toContainEqual({ color: "surface", x: 0, y: 18, width: 200, height: 40 });
|
||||
expect(fills).toContainEqual({ color: "border", x: 20, y: 0, width: 1, height: 58 });
|
||||
});
|
||||
|
||||
it("clips a horizontally scrolled unified selection to the code viewport", () => {
|
||||
const selectionModel = createSelectionModel("unified");
|
||||
const paints = paintSelection(selectionModel, selectionForCell(0));
|
||||
|
||||
expect(paints).toEqual([
|
||||
{
|
||||
x: -22,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 18,
|
||||
clip: { x: 28, y: 0, width: 172, height: 18 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("clips horizontally scrolled split selections to their own code columns", () => {
|
||||
const selectionModel = createSelectionModel("split");
|
||||
|
||||
expect(paintSelection(selectionModel, selectionForCell(0))).toEqual([
|
||||
{
|
||||
x: -22,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 18,
|
||||
clip: { x: 28, y: 0, width: 72, height: 18 },
|
||||
},
|
||||
]);
|
||||
expect(paintSelection(selectionModel, selectionForCell(1))).toEqual([
|
||||
{
|
||||
x: 78,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 18,
|
||||
clip: { x: 128, y: 0, width: 72, height: 18 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
interface Rectangle {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface SelectionPaint extends Rectangle {
|
||||
clip: Rectangle | null;
|
||||
}
|
||||
|
||||
function paintSelection(model: DiffDocumentModel, selection: DiffSelection): SelectionPaint[] {
|
||||
const paints: SelectionPaint[] = [];
|
||||
const clipStack: Array<Rectangle | null> = [];
|
||||
let fillStyle = "";
|
||||
let pendingRectangle: Rectangle | null = null;
|
||||
let currentClip: Rectangle | null = null;
|
||||
const context = {
|
||||
setTransform() {},
|
||||
clearRect() {},
|
||||
fillRect(x: number, y: number, width: number, height: number) {
|
||||
if (fillStyle === palette.selection) {
|
||||
paints.push({ x, y, width, height, clip: currentClip });
|
||||
}
|
||||
},
|
||||
save() {
|
||||
clipStack.push(currentClip);
|
||||
},
|
||||
restore() {
|
||||
currentClip = clipStack.pop() ?? null;
|
||||
},
|
||||
beginPath() {
|
||||
pendingRectangle = null;
|
||||
},
|
||||
rect(x: number, y: number, width: number, height: number) {
|
||||
pendingRectangle = { x, y, width, height };
|
||||
},
|
||||
clip() {
|
||||
currentClip = pendingRectangle;
|
||||
},
|
||||
fillText() {},
|
||||
get fillStyle() {
|
||||
return fillStyle;
|
||||
},
|
||||
set fillStyle(value: string | CanvasGradient | CanvasPattern) {
|
||||
fillStyle = String(value);
|
||||
},
|
||||
globalAlpha: 1,
|
||||
font: "",
|
||||
textBaseline: "alphabetic",
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
|
||||
paintWebViewport({
|
||||
context,
|
||||
model,
|
||||
palette,
|
||||
typography: { family: "monospace", size: 12, lineHeight: 18 },
|
||||
measureText: { measure: () => 0 },
|
||||
scrollTop: 0,
|
||||
viewportWidth: 200,
|
||||
viewportHeight: 100,
|
||||
horizontalOffsets: new Map([["src/selection.ts", 50]]),
|
||||
selection,
|
||||
devicePixelRatio: 1,
|
||||
});
|
||||
return paints;
|
||||
}
|
||||
|
||||
function selectionForCell(cellIndex: number): DiffSelection {
|
||||
const position = {
|
||||
fileIndex: 0,
|
||||
rowIndex: 0,
|
||||
cellIndex,
|
||||
side: cellIndex === 0 ? ("old" as const) : ("new" as const),
|
||||
};
|
||||
return {
|
||||
anchor: { ...position, sourceOffset: 0 },
|
||||
focus: { ...position, sourceOffset: 10 },
|
||||
};
|
||||
}
|
||||
|
||||
function createSelectionModel(layout: "unified" | "split"): DiffDocumentModel {
|
||||
const cells: DiffLineRow["cells"] =
|
||||
layout === "unified" ? [selectionCell("new")] : [selectionCell("old"), selectionCell("new")];
|
||||
return {
|
||||
files: [
|
||||
{
|
||||
file: {
|
||||
path: "src/selection.ts",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
hunks: [],
|
||||
},
|
||||
fileIndex: 0,
|
||||
path: "src/selection.ts",
|
||||
top: 0,
|
||||
headerHeight: 0,
|
||||
bodyTop: 0,
|
||||
bodyHeight: 18,
|
||||
bottom: 18,
|
||||
gutterWidth: 20,
|
||||
contentWidth: 300,
|
||||
rowStart: 0,
|
||||
rowEnd: 1,
|
||||
isCollapsed: false,
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
kind: "line",
|
||||
index: 0,
|
||||
fileIndex: 0,
|
||||
path: "src/selection.ts",
|
||||
top: 0,
|
||||
height: 18,
|
||||
reviewHeight: 0,
|
||||
cells,
|
||||
},
|
||||
],
|
||||
height: 18,
|
||||
lineHeight: 18,
|
||||
layout,
|
||||
wrapLines: false,
|
||||
viewportWidth: 200,
|
||||
};
|
||||
}
|
||||
|
||||
function selectionCell(side: "old" | "new"): DiffCell {
|
||||
return {
|
||||
type: side === "old" ? "remove" : "add",
|
||||
content: "abcdefghij",
|
||||
lineNumber: 1,
|
||||
tokens: [],
|
||||
fragments: [
|
||||
{
|
||||
start: 0,
|
||||
end: 10,
|
||||
text: "abcdefghij",
|
||||
width: 100,
|
||||
top: 0,
|
||||
baseline: 14,
|
||||
graphemes: Array.from("abcdefghij", (text, index) => ({
|
||||
start: index,
|
||||
end: index + 1,
|
||||
text,
|
||||
width: 10,
|
||||
})),
|
||||
},
|
||||
],
|
||||
reviewTarget: null,
|
||||
sourceIdentity: { hunkIndex: 0, lineIndex: 1, side },
|
||||
};
|
||||
}
|
||||
|
||||
const palette: DiffPalette = {
|
||||
surface: "surface",
|
||||
headerSurface: "header",
|
||||
border: "border",
|
||||
foreground: "foreground",
|
||||
foregroundMuted: "muted",
|
||||
addition: "green",
|
||||
deletion: "red",
|
||||
additionBackground: "green-bg",
|
||||
deletionBackground: "red-bg",
|
||||
emptyBackground: "empty",
|
||||
selection: "selection",
|
||||
syntax: { first: "red", second: "blue" },
|
||||
};
|
||||
|
||||
const model: DiffDocumentModel = {
|
||||
files: [
|
||||
{
|
||||
file: {
|
||||
path: "src/a.ts",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
hunks: [],
|
||||
},
|
||||
fileIndex: 0,
|
||||
path: "src/a.ts",
|
||||
top: 0,
|
||||
headerHeight: 0,
|
||||
bodyTop: 0,
|
||||
bodyHeight: 18,
|
||||
bottom: 18,
|
||||
gutterWidth: 20,
|
||||
contentWidth: 200,
|
||||
rowStart: 0,
|
||||
rowEnd: 1,
|
||||
isCollapsed: false,
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
kind: "line",
|
||||
index: 0,
|
||||
fileIndex: 0,
|
||||
path: "src/a.ts",
|
||||
top: 0,
|
||||
height: 18,
|
||||
reviewHeight: 0,
|
||||
cells: [
|
||||
{
|
||||
type: "add",
|
||||
content: "fi",
|
||||
lineNumber: null,
|
||||
tokens: [
|
||||
{ start: 0, end: 1, color: "red" },
|
||||
{ start: 1, end: 2, color: "blue" },
|
||||
],
|
||||
fragments: [
|
||||
{
|
||||
start: 0,
|
||||
end: 2,
|
||||
text: "fi",
|
||||
width: 15,
|
||||
top: 0,
|
||||
baseline: 14,
|
||||
graphemes: [
|
||||
{ start: 0, end: 1, text: "f", width: 10 },
|
||||
{ start: 1, end: 2, text: "i", width: 5 },
|
||||
],
|
||||
},
|
||||
],
|
||||
reviewTarget: null,
|
||||
sourceIdentity: { hunkIndex: 0, lineIndex: 1, side: "new" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
height: 18,
|
||||
lineHeight: 18,
|
||||
layout: "unified",
|
||||
wrapLines: false,
|
||||
viewportWidth: 200,
|
||||
};
|
||||
|
||||
const modelWithReview: DiffDocumentModel = {
|
||||
...model,
|
||||
files: [{ ...model.files[0]!, bodyHeight: 58, bottom: 58 }],
|
||||
rows: [
|
||||
{
|
||||
...(model.rows[0] as DiffLineRow),
|
||||
height: 58,
|
||||
reviewHeight: 40,
|
||||
},
|
||||
],
|
||||
height: 58,
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import { selectionRectangles } from "./hit-testing";
|
||||
import { fragmentWidthForRange, visibleRowRange } from "./model";
|
||||
import { codeLineNumberTone, codeTextColor } from "./palette";
|
||||
import { reviewBackgroundPaint, reviewDividerHeight, reviewGapTop } from "./review-paint";
|
||||
import type {
|
||||
DiffCell,
|
||||
DiffDocumentModel,
|
||||
DiffLineRow,
|
||||
DiffPalette,
|
||||
DiffSelection,
|
||||
DiffTypography,
|
||||
TextMeasurer,
|
||||
} from "./types";
|
||||
|
||||
const CODE_LEFT_PADDING = 8;
|
||||
|
||||
export interface PaintWebViewportInput {
|
||||
context: CanvasRenderingContext2D;
|
||||
model: DiffDocumentModel;
|
||||
palette: DiffPalette;
|
||||
typography: DiffTypography;
|
||||
measureText: TextMeasurer;
|
||||
scrollTop: number;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
horizontalOffsets: ReadonlyMap<string, number>;
|
||||
selection: DiffSelection | null;
|
||||
devicePixelRatio: number;
|
||||
}
|
||||
|
||||
export function paintWebViewport(input: PaintWebViewportInput): void {
|
||||
const { context } = input;
|
||||
const scale = input.devicePixelRatio;
|
||||
context.setTransform(scale, 0, 0, scale, 0, 0);
|
||||
context.clearRect(0, 0, input.viewportWidth, input.viewportHeight);
|
||||
context.fillStyle = input.palette.surface;
|
||||
context.fillRect(0, 0, input.viewportWidth, input.viewportHeight);
|
||||
context.font = `${input.typography.size}px ${input.typography.family}`;
|
||||
context.textBaseline = "alphabetic";
|
||||
|
||||
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") {
|
||||
context.fillStyle = input.palette.foregroundMuted;
|
||||
context.fillText(row.label, 12, y + input.model.lineHeight + 6);
|
||||
continue;
|
||||
}
|
||||
paintLine({
|
||||
...input,
|
||||
row,
|
||||
y,
|
||||
horizontalOffset: input.horizontalOffsets.get(row.path) ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.selection) paintSelection(input, input.selection);
|
||||
}
|
||||
|
||||
function paintLine(
|
||||
input: PaintWebViewportInput & {
|
||||
row: DiffLineRow;
|
||||
y: number;
|
||||
horizontalOffset: number;
|
||||
},
|
||||
): void {
|
||||
const columnWidth = input.viewportWidth / input.row.cells.length;
|
||||
const file = input.model.files[input.row.fileIndex];
|
||||
if (!file) return;
|
||||
input.row.cells.forEach((cell, cellIndex) => {
|
||||
const x = cellIndex * columnWidth;
|
||||
input.context.fillStyle = cellBackground(cell, input.palette);
|
||||
input.context.fillRect(x, input.y, columnWidth, input.row.height);
|
||||
if (input.row.reviewHeight > 0) {
|
||||
input.context.fillStyle = reviewBackgroundPaint(input.palette.surface);
|
||||
input.context.fillRect(
|
||||
x,
|
||||
reviewGapTop(input.y, input.row.height, input.row.reviewHeight),
|
||||
columnWidth,
|
||||
input.row.reviewHeight,
|
||||
);
|
||||
}
|
||||
if (!cell) return;
|
||||
input.context.fillStyle = input.palette.border;
|
||||
input.context.fillRect(x + file.gutterWidth, input.y, 1, reviewDividerHeight(input.row.height));
|
||||
if (cell.lineNumber !== null) {
|
||||
const label = String(cell.lineNumber);
|
||||
input.context.fillStyle = lineNumberColor(cell, input.palette);
|
||||
input.context.fillText(
|
||||
label,
|
||||
x + file.gutterWidth - 7 - input.measureText.measure(label),
|
||||
input.y + input.model.lineHeight * 0.78,
|
||||
);
|
||||
}
|
||||
input.context.save();
|
||||
input.context.beginPath();
|
||||
input.context.rect(
|
||||
x + file.gutterWidth + CODE_LEFT_PADDING,
|
||||
input.y,
|
||||
columnWidth - file.gutterWidth - CODE_LEFT_PADDING,
|
||||
input.row.height - input.row.reviewHeight,
|
||||
);
|
||||
input.context.clip();
|
||||
paintCellText({ ...input, cell, x: x + file.gutterWidth + CODE_LEFT_PADDING });
|
||||
input.context.restore();
|
||||
});
|
||||
}
|
||||
|
||||
function paintCellText(
|
||||
input: PaintWebViewportInput & {
|
||||
row: DiffLineRow;
|
||||
cell: DiffCell;
|
||||
x: number;
|
||||
y: number;
|
||||
horizontalOffset: number;
|
||||
},
|
||||
): void {
|
||||
const offset = input.model.wrapLines ? 0 : input.horizontalOffset;
|
||||
for (const fragment of input.cell.fragments) {
|
||||
const baseline = input.y + fragment.baseline;
|
||||
const textX = input.x - offset;
|
||||
for (const band of fragmentColorBands(input.cell, fragment, input.palette)) {
|
||||
input.context.save();
|
||||
input.context.beginPath();
|
||||
input.context.rect(
|
||||
textX + band.x,
|
||||
input.y + fragment.top,
|
||||
band.width,
|
||||
input.model.lineHeight,
|
||||
);
|
||||
input.context.clip();
|
||||
input.context.fillStyle = band.color;
|
||||
// Keep the complete shaped string and origin used during measurement. Clipping changes
|
||||
// syntax color without breaking ligatures, kerning, or contextual forms into new runs.
|
||||
input.context.fillText(fragment.text, textX, baseline);
|
||||
input.context.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fragmentColorBands(
|
||||
cell: DiffCell,
|
||||
fragment: DiffCell["fragments"][number],
|
||||
palette: DiffPalette,
|
||||
): Array<{ x: number; width: number; color: string }> {
|
||||
const ranges: Array<{ start: number; end: number; color: string }> = [];
|
||||
for (const grapheme of fragment.graphemes) {
|
||||
const syntaxRun = cell.tokens.find(
|
||||
(run) => run.start <= grapheme.start && grapheme.start < run.end,
|
||||
);
|
||||
const color = syntaxRun?.color ?? cellForeground(cell, palette);
|
||||
const previous = ranges.at(-1);
|
||||
if (previous?.color === color) previous.end = grapheme.end;
|
||||
else ranges.push({ start: grapheme.start, end: grapheme.end, color });
|
||||
}
|
||||
if (ranges.length === 0) {
|
||||
return [{ x: 0, width: Math.max(1, fragment.width), color: cellForeground(cell, palette) }];
|
||||
}
|
||||
return ranges.map((range) => ({
|
||||
x: fragmentWidthForRange(fragment, fragment.start, range.start),
|
||||
width: fragmentWidthForRange(fragment, range.start, range.end),
|
||||
color: range.color,
|
||||
}));
|
||||
}
|
||||
|
||||
function paintSelection(input: PaintWebViewportInput, selection: DiffSelection): void {
|
||||
input.context.save();
|
||||
input.context.globalAlpha = 0.45;
|
||||
input.context.fillStyle = input.palette.selection;
|
||||
for (const rectangle of selectionRectangles({
|
||||
model: input.model,
|
||||
selection,
|
||||
})) {
|
||||
const file = input.model.files[rectangle.fileIndex];
|
||||
if (!file) continue;
|
||||
const x =
|
||||
rectangle.x - (input.model.wrapLines ? 0 : (input.horizontalOffsets.get(file.path) ?? 0));
|
||||
const y = rectangle.y - input.scrollTop;
|
||||
input.context.save();
|
||||
input.context.beginPath();
|
||||
input.context.rect(rectangle.clipX, y, rectangle.clipWidth, rectangle.height);
|
||||
input.context.clip();
|
||||
input.context.fillRect(x, y, rectangle.width, rectangle.height);
|
||||
input.context.restore();
|
||||
}
|
||||
input.context.restore();
|
||||
}
|
||||
|
||||
function cellBackground(cell: DiffCell | null, palette: DiffPalette): string {
|
||||
if (!cell) return palette.emptyBackground;
|
||||
if (cell.type === "add") return palette.additionBackground;
|
||||
if (cell.type === "remove") return palette.deletionBackground;
|
||||
if (cell.type === "header") return palette.headerSurface;
|
||||
return palette.surface;
|
||||
}
|
||||
|
||||
function cellForeground(cell: DiffCell, palette: DiffPalette): string {
|
||||
return codeTextColor(cell, palette);
|
||||
}
|
||||
|
||||
function lineNumberColor(cell: DiffCell, palette: DiffPalette): string {
|
||||
return palette[codeLineNumberTone(cell)];
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { codeLineNumberTone, codeTextColor } from "./palette";
|
||||
import type { DiffCell, DiffPalette } from "./types";
|
||||
|
||||
describe("diff text color", () => {
|
||||
it.each(["add", "remove", "context"] as const)(
|
||||
"uses normal code foreground for an untokenized %s line",
|
||||
(type) => {
|
||||
expect(codeTextColor(cell(type), palette)).toBe("foreground");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["add", "addition"],
|
||||
["remove", "deletion"],
|
||||
["context", "foregroundMuted"],
|
||||
] as const)("uses the %s gutter tone for native and web line numbers", (type, tone) => {
|
||||
expect(codeLineNumberTone(cell(type))).toBe(tone);
|
||||
});
|
||||
});
|
||||
|
||||
const palette: DiffPalette = {
|
||||
surface: "surface",
|
||||
headerSurface: "header",
|
||||
border: "border",
|
||||
foreground: "foreground",
|
||||
foregroundMuted: "muted",
|
||||
addition: "green",
|
||||
deletion: "red",
|
||||
additionBackground: "green-bg",
|
||||
deletionBackground: "red-bg",
|
||||
emptyBackground: "empty",
|
||||
selection: "selection",
|
||||
syntax: {},
|
||||
};
|
||||
|
||||
function cell(type: DiffCell["type"]): DiffCell {
|
||||
return {
|
||||
type,
|
||||
content: "code",
|
||||
lineNumber: 1,
|
||||
tokens: [],
|
||||
fragments: [],
|
||||
reviewTarget: null,
|
||||
sourceIdentity: { hunkIndex: 0, lineIndex: 1, side: "new" },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { DiffCell, DiffPalette } from "./types";
|
||||
|
||||
export function createDiffPalette(theme: Theme): DiffPalette {
|
||||
return {
|
||||
surface: theme.colors.surface1,
|
||||
headerSurface: theme.colors.surface2,
|
||||
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,
|
||||
syntax: theme.colors.syntax,
|
||||
};
|
||||
}
|
||||
|
||||
export function codeTextColor(cell: DiffCell, palette: DiffPalette): string {
|
||||
return cell.type === "header" ? palette.foregroundMuted : palette.foreground;
|
||||
}
|
||||
|
||||
export function codeLineNumberTone(cell: DiffCell): "addition" | "deletion" | "foregroundMuted" {
|
||||
"worklet";
|
||||
if (cell.type === "add") return "addition";
|
||||
if (cell.type === "remove") return "deletion";
|
||||
return "foregroundMuted";
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasPointerDragStarted } from "./pointer-gesture";
|
||||
|
||||
describe("diff pointer gesture", () => {
|
||||
it("classifies pointer movement as a drag without requiring a character change", () => {
|
||||
expect(
|
||||
hasPointerDragStarted({
|
||||
startX: 10,
|
||||
startY: 10,
|
||||
x: 15,
|
||||
y: 10,
|
||||
alreadyDragging: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a gesture classified as a drag after it crosses the threshold", () => {
|
||||
expect(
|
||||
hasPointerDragStarted({
|
||||
startX: 10,
|
||||
startY: 10,
|
||||
x: 10,
|
||||
y: 10,
|
||||
alreadyDragging: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
const DRAG_THRESHOLD = 4;
|
||||
|
||||
export function hasPointerDragStarted(input: {
|
||||
startX: number;
|
||||
startY: number;
|
||||
x: number;
|
||||
y: number;
|
||||
alreadyDragging: boolean;
|
||||
}): boolean {
|
||||
if (input.alreadyDragging) return true;
|
||||
return Math.hypot(input.x - input.startX, input.y - input.startY) >= DRAG_THRESHOLD;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export function reviewGapTop(rowY: number, rowHeight: number, reviewHeight: number): number {
|
||||
"worklet";
|
||||
return rowY + rowHeight - reviewHeight;
|
||||
}
|
||||
|
||||
export function reviewDividerHeight(rowHeight: number): number {
|
||||
"worklet";
|
||||
return rowHeight;
|
||||
}
|
||||
|
||||
export function reviewBackgroundPaint<Paint>(surface: Paint): Paint {
|
||||
"worklet";
|
||||
return surface;
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { Canvas, Picture, Skia } 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 {
|
||||
createAnimatedComponent,
|
||||
useAnimatedScrollHandler,
|
||||
useDerivedValue,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { DocumentFileHeader } from "./document-file-header";
|
||||
import { hitTestDiffPagePoint, type SurfaceWindowOrigin } 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";
|
||||
|
||||
const AnimatedScrollView = createAnimatedComponent(ScrollView);
|
||||
const SYSTEM_MONO = "monospace";
|
||||
|
||||
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 consumedFocusRef = useRef<string | null>(null);
|
||||
const scrollTop = useSharedValue(0);
|
||||
const horizontalOffsets = useSharedValue<DiffHorizontalOffsets>({});
|
||||
const family = props.displayPreferences.monoFontFamily.trim() || SYSTEM_MONO;
|
||||
const typography = useMemo<DiffTypography>(
|
||||
() => ({
|
||||
family,
|
||||
size: props.displayPreferences.codeFontSize,
|
||||
lineHeight: Math.round(props.displayPreferences.codeFontSize * 1.5),
|
||||
}),
|
||||
[family, props.displayPreferences.codeFontSize],
|
||||
);
|
||||
const measurement = useMemo<TextMeasurer>(
|
||||
() => createNativeTextMeasurer({ configuredFamily: family, fontSize: typography.size }),
|
||||
[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,
|
||||
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(
|
||||
() =>
|
||||
createNativeTextLayout({
|
||||
model,
|
||||
configuredFamily: family,
|
||||
fontSize: typography.size,
|
||||
palette: props.palette,
|
||||
}),
|
||||
[family, model, props.palette, typography.size],
|
||||
);
|
||||
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 };
|
||||
});
|
||||
}, []);
|
||||
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 });
|
||||
}
|
||||
}
|
||||
previousModelRef.current = model;
|
||||
}, [model, scrollTop]);
|
||||
useLayoutEffect(() => {
|
||||
horizontalOffsets.value = retainHorizontalOffsetsForPaths(
|
||||
horizontalOffsets.value,
|
||||
model.files.map((file) => file.path),
|
||||
);
|
||||
}, [horizontalOffsets, model.files]);
|
||||
const mode = props.mode;
|
||||
const collapsedFilePaths = props.collapsedFilePaths;
|
||||
const onToggleFile = props.onToggleFile;
|
||||
useEffect(() => {
|
||||
if (mode.kind !== "working") return;
|
||||
const focusPath = mode.focusPath;
|
||||
if (!focusPath) return;
|
||||
const requestKey = `${mode.focusRequestId ?? "initial"}:${focusPath}`;
|
||||
if (consumedFocusRef.current === requestKey) return;
|
||||
if (collapsedFilePaths.has(focusPath)) onToggleFile(focusPath);
|
||||
const file = model.files.find((entry) => entry.path === focusPath);
|
||||
if (file) {
|
||||
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],
|
||||
);
|
||||
const contentStyle = useMemo(() => ({ minHeight: viewport.height }), [viewport.height]);
|
||||
return (
|
||||
<View
|
||||
ref={rootRef}
|
||||
style={[styles.root, { backgroundColor: props.palette.surface }]}
|
||||
onLayout={layout}
|
||||
>
|
||||
<AnimatedScrollView
|
||||
ref={scrollRef}
|
||||
style={[StyleSheet.absoluteFill, styles.scroll]}
|
||||
contentContainerStyle={contentStyle}
|
||||
onScroll={scrollHandler}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator
|
||||
stickyHeaderIndices={model.files.map((_, index) => index * 2)}
|
||||
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
|
||||
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>,
|
||||
])}
|
||||
</AnimatedScrollView>
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={[StyleSheet.absoluteFill, styles.canvas]}
|
||||
testID="git-diff-canvas"
|
||||
>
|
||||
<Canvas style={StyleSheet.absoluteFill}>
|
||||
<Picture picture={picture} />
|
||||
</Canvas>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1, minHeight: 0, position: "relative", overflow: "hidden" },
|
||||
canvas: { zIndex: 1 },
|
||||
scroll: { zIndex: 2 },
|
||||
header: { zIndex: 3 },
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { DiffSurface } from "./surface.native";
|
||||
@@ -0,0 +1,613 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { 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 { paintWebViewport } from "./paint.web";
|
||||
import { hasPointerDragStarted } from "./pointer-gesture";
|
||||
import type {
|
||||
DiffHit,
|
||||
DiffSelection,
|
||||
DiffSurfaceProps,
|
||||
DiffTypography,
|
||||
TextMeasurer,
|
||||
} from "./types";
|
||||
|
||||
const DEFAULT_MONO_STACK = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace";
|
||||
|
||||
export function DiffSurface(props: DiffSurfaceProps) {
|
||||
const { t } = useTranslation();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = 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 consumedFocusRef = useRef<string | null>(null);
|
||||
const scrollTopRef = useRef(0);
|
||||
const horizontalOffsetsRef = useRef(new Map<string, number>());
|
||||
const selectionRef = useRef<DiffSelection | null>(null);
|
||||
const dragRef = useRef<{
|
||||
anchor: DiffSelection["anchor"];
|
||||
startX: number;
|
||||
startY: number;
|
||||
moved: boolean;
|
||||
} | null>(null);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
const [viewport, setViewport] = useState({ width: 0, height: 0 });
|
||||
const [loadedTypography, setLoadedTypography] = useState<DiffTypography | null>(null);
|
||||
const [hoveredAffordance, setHoveredAffordance] = useState<{
|
||||
hit: Extract<DiffHit, { kind: "cell" }>;
|
||||
left: number;
|
||||
top: number;
|
||||
} | null>(null);
|
||||
const hasHoveredAffordanceRef = useRef(false);
|
||||
const family = props.displayPreferences.monoFontFamily.trim() || DEFAULT_MONO_STACK;
|
||||
useLayoutEffect(() => {
|
||||
const stats = (window as typeof window & { __PASEO_DIFF_REACT_STATS__?: { commits: number } })
|
||||
.__PASEO_DIFF_REACT_STATS__;
|
||||
if (stats) stats.commits += 1;
|
||||
});
|
||||
const desiredTypography = useMemo<DiffTypography>(
|
||||
() => ({
|
||||
family,
|
||||
size: props.displayPreferences.codeFontSize,
|
||||
lineHeight: Math.round(props.displayPreferences.codeFontSize * 1.5),
|
||||
}),
|
||||
[family, props.displayPreferences.codeFontSize],
|
||||
);
|
||||
const measurement = useMemo(
|
||||
() => (loadedTypography ? createWebTextMeasurer(loadedTypography) : null),
|
||||
[loadedTypography],
|
||||
);
|
||||
const reviewActions = props.mode.kind === "working" ? props.mode.reviewActions : undefined;
|
||||
const model = useMemo(() => {
|
||||
if (!loadedTypography || !measurement) {
|
||||
return emptyDiffDocumentModel({
|
||||
layout: props.displayPreferences.layout,
|
||||
wrapLines: props.displayPreferences.wrapLines,
|
||||
viewportWidth: viewport.width,
|
||||
lineHeight: desiredTypography.lineHeight,
|
||||
});
|
||||
}
|
||||
return buildDiffDocumentModel({
|
||||
files: props.files,
|
||||
collapsedFilePaths: props.collapsedFilePaths,
|
||||
layout: props.displayPreferences.layout,
|
||||
wrapLines: props.displayPreferences.wrapLines,
|
||||
viewportWidth: viewport.width,
|
||||
typography: loadedTypography,
|
||||
measureText: measurement,
|
||||
palette: props.palette,
|
||||
reviewActions,
|
||||
labels: {
|
||||
binary: t("workspace.git.diff.binaryFile"),
|
||||
tooLarge: t("workspace.git.diff.tooLarge"),
|
||||
},
|
||||
});
|
||||
}, [
|
||||
measurement,
|
||||
props.collapsedFilePaths,
|
||||
props.displayPreferences.layout,
|
||||
props.displayPreferences.wrapLines,
|
||||
props.files,
|
||||
props.palette,
|
||||
reviewActions,
|
||||
t,
|
||||
desiredTypography.lineHeight,
|
||||
loadedTypography,
|
||||
viewport.width,
|
||||
]);
|
||||
modelRef.current = model;
|
||||
|
||||
const paint = useCallback(() => {
|
||||
frameRef.current = null;
|
||||
const canvas = canvasRef.current;
|
||||
const currentModel = modelRef.current;
|
||||
if (!canvas || !currentModel || viewport.width <= 0 || viewport.height <= 0) return;
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
const pixelWidth = Math.ceil(viewport.width * ratio);
|
||||
const pixelHeight = Math.ceil(viewport.height * ratio);
|
||||
if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
|
||||
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
if (!measurement || !loadedTypography) return;
|
||||
paintWebViewport({
|
||||
context,
|
||||
model: currentModel,
|
||||
palette: props.palette,
|
||||
typography: loadedTypography,
|
||||
measureText: measurement,
|
||||
scrollTop: scrollTopRef.current,
|
||||
viewportWidth: viewport.width,
|
||||
viewportHeight: viewport.height,
|
||||
horizontalOffsets: horizontalOffsetsRef.current,
|
||||
selection: selectionRef.current,
|
||||
devicePixelRatio: ratio,
|
||||
});
|
||||
}, [loadedTypography, measurement, props.palette, viewport.height, viewport.width]);
|
||||
const schedulePaint = useCallback(() => {
|
||||
if (frameRef.current === null) frameRef.current = requestAnimationFrame(paint);
|
||||
}, [paint]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
if (!entry) return;
|
||||
setViewport({ width: entry.contentRect.width, height: entry.contentRect.height });
|
||||
});
|
||||
observer.observe(root);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
await Promise.all([
|
||||
document.fonts.load(`400 ${desiredTypography.size}px ${desiredTypography.family}`),
|
||||
document.fonts.load(`600 ${desiredTypography.size}px ${desiredTypography.family}`),
|
||||
]);
|
||||
await document.fonts.ready;
|
||||
if (!cancelled) setLoadedTypography(desiredTypography);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [desiredTypography]);
|
||||
useLayoutEffect(() => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
previousModelRef.current = model;
|
||||
}, [model]);
|
||||
useLayoutEffect(() => {
|
||||
horizontalOffsetsRef.current = retainHorizontalOffsetMapForPaths(
|
||||
horizontalOffsetsRef.current,
|
||||
props.files.map((file) => file.path),
|
||||
);
|
||||
}, [props.files]);
|
||||
useEffect(schedulePaint, [model, schedulePaint]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const mode = props.mode;
|
||||
const collapsedFilePaths = props.collapsedFilePaths;
|
||||
const onToggleFile = props.onToggleFile;
|
||||
useEffect(() => {
|
||||
if (mode.kind !== "working") return;
|
||||
const focusPath = mode.focusPath;
|
||||
if (!focusPath) return;
|
||||
const requestKey = `${mode.focusRequestId ?? "initial"}:${focusPath}`;
|
||||
if (consumedFocusRef.current === requestKey) return;
|
||||
if (collapsedFilePaths.has(focusPath)) onToggleFile(focusPath);
|
||||
const file = model.files.find((entry) => entry.path === focusPath);
|
||||
if (file && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = file.top;
|
||||
consumedFocusRef.current = requestKey;
|
||||
}
|
||||
}, [collapsedFilePaths, mode, model.files, onToggleFile]);
|
||||
|
||||
const handleVerticalScroll = useCallback(
|
||||
(event: React.UIEvent<HTMLDivElement>) => {
|
||||
scrollTopRef.current = event.currentTarget.scrollTop;
|
||||
if (hasHoveredAffordanceRef.current) {
|
||||
hasHoveredAffordanceRef.current = false;
|
||||
setHoveredAffordance(null);
|
||||
}
|
||||
schedulePaint();
|
||||
},
|
||||
[schedulePaint],
|
||||
);
|
||||
const handleHorizontalScroll = useCallback(
|
||||
(path: string, offset: number) => {
|
||||
horizontalOffsetsRef.current.set(path, offset);
|
||||
schedulePaint();
|
||||
},
|
||||
[schedulePaint],
|
||||
);
|
||||
const pointHit = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const currentModel = modelRef.current;
|
||||
const root = rootRef.current;
|
||||
if (!currentModel || !root) return null;
|
||||
const bounds = root.getBoundingClientRect();
|
||||
const x = event.clientX - bounds.left;
|
||||
const documentY = event.clientY - bounds.top + scrollTopRef.current;
|
||||
const file = currentModel.files.find(
|
||||
(entry) => entry.top <= documentY && documentY < entry.bottom,
|
||||
);
|
||||
return hitTestDiffDocument({
|
||||
model: currentModel,
|
||||
x,
|
||||
documentY,
|
||||
horizontalOffset: file ? (horizontalOffsetsRef.current.get(file.path) ?? 0) : 0,
|
||||
});
|
||||
}, []);
|
||||
const pointerDown = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
const target = event.target;
|
||||
if (
|
||||
target instanceof Element &&
|
||||
target.closest(
|
||||
'[data-diff-header="true"], [data-diff-review="true"], [role="menuitem"], button, input, textarea',
|
||||
)
|
||||
)
|
||||
return;
|
||||
const hit = pointHit(event);
|
||||
if (hit?.kind !== "cell") return;
|
||||
dragRef.current = {
|
||||
anchor: hit.position,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
moved: false,
|
||||
};
|
||||
selectionRef.current = { anchor: hit.position, focus: hit.position };
|
||||
event.currentTarget.focus();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
schedulePaint();
|
||||
},
|
||||
[pointHit, schedulePaint],
|
||||
);
|
||||
const pointerMove = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (drag) {
|
||||
drag.moved = hasPointerDragStarted({
|
||||
startX: drag.startX,
|
||||
startY: drag.startY,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
alreadyDragging: drag.moved,
|
||||
});
|
||||
}
|
||||
const hit = pointHit(event);
|
||||
if (hit?.kind === "cell") {
|
||||
const row = modelRef.current?.rows[hit.position.rowIndex];
|
||||
const file = modelRef.current?.files[hit.position.fileIndex];
|
||||
const sideIndex =
|
||||
row?.kind === "line" && row.cells.length === 2 && hit.position.side === "new" ? 1 : 0;
|
||||
if (row && file) {
|
||||
const columnWidth = viewport.width / (row.kind === "line" ? row.cells.length : 1);
|
||||
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) {
|
||||
hasHoveredAffordanceRef.current = true;
|
||||
setHoveredAffordance({
|
||||
hit,
|
||||
left: gutterBorder - 14,
|
||||
top: row.top - scrollTopRef.current + (modelRef.current!.lineHeight - 28) / 2,
|
||||
});
|
||||
} else {
|
||||
hasHoveredAffordanceRef.current = false;
|
||||
setHoveredAffordance(null);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hasHoveredAffordanceRef.current = false;
|
||||
setHoveredAffordance(null);
|
||||
}
|
||||
if (!drag || hit?.kind !== "cell") return;
|
||||
selectionRef.current = { anchor: drag.anchor, focus: hit.position };
|
||||
schedulePaint();
|
||||
},
|
||||
[pointHit, schedulePaint, viewport.width],
|
||||
);
|
||||
const pointerUp = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
const hit = pointHit(event);
|
||||
dragRef.current = null;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
const moved = drag
|
||||
? hasPointerDragStarted({
|
||||
startX: drag.startX,
|
||||
startY: drag.startY,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
alreadyDragging: drag.moved,
|
||||
})
|
||||
: false;
|
||||
if (drag && !moved && hit?.kind === "cell" && hit.target && reviewActions) {
|
||||
selectionRef.current = null;
|
||||
reviewActions.onStartComment(hit.target);
|
||||
schedulePaint();
|
||||
}
|
||||
},
|
||||
[pointHit, reviewActions, schedulePaint],
|
||||
);
|
||||
const cancelPointer = useCallback(() => {
|
||||
dragRef.current = null;
|
||||
}, []);
|
||||
const copy = useCallback(
|
||||
(event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
if (!selectionRef.current) return;
|
||||
event.clipboardData.setData("text/plain", selectedSourceText(model, selectionRef.current));
|
||||
event.preventDefault();
|
||||
},
|
||||
[model],
|
||||
);
|
||||
const rootStyle = useMemo<React.CSSProperties>(
|
||||
() => ({ ...ROOT_STYLE, background: props.palette.surface }),
|
||||
[props.palette.surface],
|
||||
);
|
||||
const contentStyle = useMemo<React.CSSProperties>(
|
||||
() => ({ ...CONTENT_STYLE, height: Math.max(model.height, viewport.height) }),
|
||||
[model.height, viewport.height],
|
||||
);
|
||||
const affordanceStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
...AFFORDANCE_STYLE,
|
||||
left: hoveredAffordance?.left ?? 0,
|
||||
top: hoveredAffordance?.top ?? 0,
|
||||
}),
|
||||
[hoveredAffordance?.left, hoveredAffordance?.top],
|
||||
);
|
||||
const addHoveredComment = useCallback(() => {
|
||||
const target = hoveredAffordance?.hit.target;
|
||||
if (target) reviewActions?.onStartComment(target);
|
||||
}, [hoveredAffordance?.hit.target, reviewActions]);
|
||||
const canvasStyle = useMemo<React.CSSProperties>(
|
||||
() => ({
|
||||
...CANVAS_STYLE,
|
||||
fontFamily: (loadedTypography ?? desiredTypography).family,
|
||||
fontSize: (loadedTypography ?? desiredTypography).size,
|
||||
}),
|
||||
[desiredTypography, loadedTypography],
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="git-diff-canvas-root" ref={rootRef} style={rootStyle}>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
data-testid="git-diff-scroll"
|
||||
tabIndex={0}
|
||||
onScroll={handleVerticalScroll}
|
||||
onPointerDown={pointerDown}
|
||||
onPointerMove={pointerMove}
|
||||
onPointerUp={pointerUp}
|
||||
onPointerCancel={cancelPointer}
|
||||
onLostPointerCapture={cancelPointer}
|
||||
onCopy={copy}
|
||||
style={SCROLL_STYLE}
|
||||
>
|
||||
<div style={contentStyle} onMouseDown={preventDocumentMouseSelection}>
|
||||
{model.files.map((file) => (
|
||||
<WebFileHeaderSection key={file.path} file={file}>
|
||||
<DocumentFileHeader
|
||||
file={file}
|
||||
selectedPath={props.selectedPath}
|
||||
mode={props.mode}
|
||||
onToggleFile={props.onToggleFile}
|
||||
onSelectPath={props.onSelectPath}
|
||||
/>
|
||||
</WebFileHeaderSection>
|
||||
))}
|
||||
{model.files
|
||||
.filter((file) => !file.isCollapsed && !model.wrapLines)
|
||||
.map((file) => (
|
||||
<HorizontalScroll
|
||||
key={file.path}
|
||||
file={file}
|
||||
initialOffset={horizontalOffsetsRef.current.get(file.path) ?? 0}
|
||||
onScroll={handleHorizontalScroll}
|
||||
/>
|
||||
))}
|
||||
{props.mode.kind === "working" && reviewActions
|
||||
? model.rows.map((row) => {
|
||||
if (row.kind !== "line" || row.reviewHeight === 0) return null;
|
||||
const columnWidth = model.viewportWidth / row.cells.length;
|
||||
return row.cells.map((cell, index) => {
|
||||
if (!cell?.reviewTarget) return null;
|
||||
const comments = reviewActions.commentsByTarget.get(cell.reviewTarget.key) ?? [];
|
||||
const hasEditor =
|
||||
reviewActions.editor?.target.filePath === cell.reviewTarget.filePath &&
|
||||
reviewActions.editor.target.side === cell.reviewTarget.side &&
|
||||
reviewActions.editor.target.lineNumber === cell.reviewTarget.lineNumber;
|
||||
if (comments.length === 0 && !hasEditor) return null;
|
||||
return (
|
||||
<WebReviewThread
|
||||
key={cell.reviewTarget.key}
|
||||
target={cell.reviewTarget}
|
||||
actions={reviewActions}
|
||||
top={row.top + row.height - row.reviewHeight}
|
||||
left={index * columnWidth}
|
||||
width={columnWidth}
|
||||
height={row.reviewHeight}
|
||||
pinToViewport={!model.wrapLines}
|
||||
/>
|
||||
);
|
||||
});
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
<canvas ref={canvasRef} data-testid="git-diff-canvas" style={canvasStyle} />
|
||||
{hoveredAffordance?.hit.target && reviewActions ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("review.comment.add")}
|
||||
onClick={addHoveredComment}
|
||||
style={affordanceStyle}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WebFileHeaderSection({
|
||||
file,
|
||||
children,
|
||||
}: {
|
||||
file: ReturnType<typeof buildDiffDocumentModel>["files"][number];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const style = useMemo<React.CSSProperties>(
|
||||
() => ({ ...FILE_SECTION_STYLE, top: file.top, height: file.bottom - file.top }),
|
||||
[file.bottom, file.top],
|
||||
);
|
||||
return (
|
||||
<div style={style}>
|
||||
<div
|
||||
data-diff-header="true"
|
||||
style={STICKY_HEADER_STYLE}
|
||||
onMouseDown={preventDocumentMouseSelection}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div data-testid={`diff-file-${file.fileIndex}-body`} style={BODY_MARKER_STYLE} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function preventDocumentMouseSelection(event: React.MouseEvent): void {
|
||||
if (event.detail < 2) return;
|
||||
const target = event.target;
|
||||
if (target instanceof Element && target.closest('[data-diff-review="true"]')) return;
|
||||
event.preventDefault();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}
|
||||
|
||||
function WebReviewThread({
|
||||
target,
|
||||
actions,
|
||||
top,
|
||||
left,
|
||||
width,
|
||||
height,
|
||||
pinToViewport,
|
||||
}: {
|
||||
target: ReviewableDiffTarget;
|
||||
actions: NonNullable<Extract<DiffSurfaceProps["mode"], { kind: "working" }>["reviewActions"]>;
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
pinToViewport: boolean;
|
||||
}) {
|
||||
const style = useMemo<React.CSSProperties>(
|
||||
() => ({ ...REVIEW_STYLE, top, left, width, height }),
|
||||
[height, left, top, width],
|
||||
);
|
||||
return (
|
||||
<div style={style} data-diff-review="true">
|
||||
<InlineReviewThread
|
||||
reviewTarget={target}
|
||||
reviewActions={actions}
|
||||
height={height}
|
||||
viewportWidth={width}
|
||||
pinToViewport={pinToViewport}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ROOT_STYLE: React.CSSProperties = {
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
};
|
||||
const SCROLL_STYLE: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 2,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
};
|
||||
const CONTENT_STYLE: React.CSSProperties = {
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
userSelect: "none",
|
||||
};
|
||||
const FILE_SECTION_STYLE: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 3,
|
||||
pointerEvents: "none",
|
||||
};
|
||||
const STICKY_HEADER_STYLE: React.CSSProperties = {
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
pointerEvents: "auto",
|
||||
userSelect: "none",
|
||||
};
|
||||
const BODY_MARKER_STYLE: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
top: 44,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
pointerEvents: "none",
|
||||
};
|
||||
const REVIEW_STYLE: React.CSSProperties = { position: "absolute", zIndex: 4, userSelect: "text" };
|
||||
const CANVAS_STYLE: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 1,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
};
|
||||
const AFFORDANCE_STYLE: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
zIndex: 5,
|
||||
width: 28,
|
||||
height: 28,
|
||||
};
|
||||
|
||||
function emptyDiffDocumentModel(input: {
|
||||
layout: "unified" | "split";
|
||||
wrapLines: boolean;
|
||||
viewportWidth: number;
|
||||
lineHeight: number;
|
||||
}): ReturnType<typeof buildDiffDocumentModel> {
|
||||
return {
|
||||
files: [],
|
||||
rows: [],
|
||||
height: 0,
|
||||
lineHeight: input.lineHeight,
|
||||
layout: input.layout,
|
||||
wrapLines: input.wrapLines,
|
||||
viewportWidth: input.viewportWidth,
|
||||
};
|
||||
}
|
||||
|
||||
function createWebTextMeasurer(typography: DiffTypography): TextMeasurer {
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return { measure: () => 0 };
|
||||
return {
|
||||
measure(text, weight = "regular") {
|
||||
context.font = `${weight === "semibold" ? 600 : 400} ${typography.size}px ${typography.family}`;
|
||||
return context.measureText(text).width;
|
||||
},
|
||||
measureAdvances(graphemes) {
|
||||
context.font = `${typography.size}px ${typography.family}`;
|
||||
let prefix = "";
|
||||
return graphemes.map((grapheme) => {
|
||||
prefix += grapheme;
|
||||
return context.measureText(prefix).width;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createFallbackAwareTextMeasurer, 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", () => {
|
||||
const fallbackInputs: string[] = [];
|
||||
const measurer = createFallbackAwareTextMeasurer({
|
||||
primary: primaryFaceWithout("中", "👨👩👧👦"),
|
||||
measureWithSystemFallback(text) {
|
||||
fallbackInputs.push(text);
|
||||
return platform === "ios" ? 24 : 22;
|
||||
},
|
||||
});
|
||||
|
||||
expect(measurer.measure("中")).toBe(platform === "ios" ? 24 : 22);
|
||||
expect(measurer.measure("👨👩👧👦")).toBe(platform === "ios" ? 24 : 22);
|
||||
expect(fallbackInputs).toEqual(["中", "👨👩👧👦"]);
|
||||
});
|
||||
|
||||
it("keeps combining graphemes intact when the primary face supports them", () => {
|
||||
const measurer = createFallbackAwareTextMeasurer({
|
||||
primary: primaryFaceWithout(),
|
||||
measureWithSystemFallback: () => 99,
|
||||
});
|
||||
expect(measurer.measure("é")).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
function primaryFaceWithout(...missing: string[]): PrimaryTextFace {
|
||||
return {
|
||||
glyphIds(text) {
|
||||
const isMissing = missing.some((candidate) => text.includes(candidate));
|
||||
return Array.from(text, () => (isMissing ? 0 : 1));
|
||||
},
|
||||
measure(text) {
|
||||
return Array.from(text).length * 10;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { TextMeasurer } from "./types";
|
||||
|
||||
export interface PrimaryTextFace {
|
||||
glyphIds(text: string): number[];
|
||||
measure(text: string): number;
|
||||
}
|
||||
|
||||
export function createFallbackAwareTextMeasurer(input: {
|
||||
primary: PrimaryTextFace;
|
||||
measureWithSystemFallback: (text: string) => number;
|
||||
}): TextMeasurer {
|
||||
return {
|
||||
measure(text) {
|
||||
if (text.length === 0) return 0;
|
||||
const hasEveryGlyph = input.primary.glyphIds(text).every((glyph) => glyph !== 0);
|
||||
return hasEveryGlyph ? input.primary.measure(text) : input.measureWithSystemFallback(text);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
Skia,
|
||||
listFontFamilies,
|
||||
matchFont,
|
||||
type SkFont,
|
||||
type SkParagraph,
|
||||
} from "@shopify/react-native-skia";
|
||||
import { fragmentTextForRange } from "./model";
|
||||
import { codeTextColor } from "./palette";
|
||||
import { createFallbackAwareTextMeasurer } from "./text-measurement";
|
||||
import type { DiffCell, DiffDocumentModel, DiffFragment, DiffPalette, TextMeasurer } from "./types";
|
||||
|
||||
const PARAGRAPH_WIDTH = 100_000;
|
||||
|
||||
export interface NativeTextLayout {
|
||||
font: SkFont;
|
||||
paragraphs: Array<Array<Array<SkParagraph | null>>>;
|
||||
}
|
||||
|
||||
export function createNativeTextLayout(input: {
|
||||
model: DiffDocumentModel;
|
||||
configuredFamily: string;
|
||||
fontSize: number;
|
||||
palette: DiffPalette;
|
||||
}): NativeTextLayout {
|
||||
const families = nativeFontFamilies(input.configuredFamily);
|
||||
const font = primaryFont(families, input.fontSize);
|
||||
const paragraphs = input.model.rows.map((row) => {
|
||||
if (row.kind !== "line") return [];
|
||||
return row.cells.map((cell) => {
|
||||
if (!cell) return [];
|
||||
return cell.fragments.map((fragment) =>
|
||||
createFragmentParagraph({
|
||||
cell,
|
||||
fragment,
|
||||
families,
|
||||
fontSize: input.fontSize,
|
||||
lineHeight: input.model.lineHeight,
|
||||
palette: input.palette,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
return { font, paragraphs };
|
||||
}
|
||||
|
||||
export function createNativeTextMeasurer(input: {
|
||||
configuredFamily: string;
|
||||
fontSize: number;
|
||||
}): TextMeasurer {
|
||||
const families = nativeFontFamilies(input.configuredFamily);
|
||||
const font = primaryFont(families, input.fontSize);
|
||||
const fallbackAware = createFallbackAwareTextMeasurer({
|
||||
primary: {
|
||||
glyphIds: (text) => font.getGlyphIDs(text),
|
||||
measure: (text) => font.getTextWidth(text),
|
||||
},
|
||||
measureWithSystemFallback(text) {
|
||||
const paragraph = createParagraph({
|
||||
text,
|
||||
families,
|
||||
fontSize: input.fontSize,
|
||||
lineHeight: Math.round(input.fontSize * 1.5),
|
||||
color: Skia.Color("black"),
|
||||
});
|
||||
return paragraph.getLongestLine();
|
||||
},
|
||||
});
|
||||
return {
|
||||
...fallbackAware,
|
||||
measureAdvances(graphemes) {
|
||||
const text = graphemes.join("");
|
||||
const paragraph = createParagraph({
|
||||
text,
|
||||
families,
|
||||
fontSize: input.fontSize,
|
||||
lineHeight: Math.round(input.fontSize * 1.5),
|
||||
color: Skia.Color("black"),
|
||||
});
|
||||
let end = 0;
|
||||
return graphemes.map((grapheme) => {
|
||||
end += grapheme.length;
|
||||
const rectangles = paragraph.getRectsForRange(0, end);
|
||||
return rectangles.reduce(
|
||||
(right, rectangle) => Math.max(right, rectangle.x + rectangle.width),
|
||||
0,
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFragmentParagraph(input: {
|
||||
cell: DiffCell;
|
||||
fragment: DiffFragment;
|
||||
families: string[];
|
||||
fontSize: number;
|
||||
lineHeight: number;
|
||||
palette: DiffPalette;
|
||||
}): SkParagraph {
|
||||
const builder = Skia.ParagraphBuilder.Make(
|
||||
paragraphStyle(input.families, input.fontSize, input.lineHeight),
|
||||
);
|
||||
if (input.cell.tokens.length === 0 || input.cell.type === "header") {
|
||||
const color = codeTextColor(input.cell, input.palette);
|
||||
builder
|
||||
.pushStyle(textStyle(input.families, input.fontSize, color))
|
||||
.addText(input.fragment.text)
|
||||
.pop();
|
||||
} else {
|
||||
for (const run of input.cell.tokens) {
|
||||
const start = Math.max(input.fragment.start, run.start);
|
||||
const end = Math.min(input.fragment.end, run.end);
|
||||
if (end <= start) continue;
|
||||
builder
|
||||
.pushStyle(textStyle(input.families, input.fontSize, run.color))
|
||||
.addText(fragmentTextForRange(input.fragment, start, end))
|
||||
.pop();
|
||||
}
|
||||
}
|
||||
const paragraph = builder.build();
|
||||
paragraph.layout(PARAGRAPH_WIDTH);
|
||||
return paragraph;
|
||||
}
|
||||
|
||||
function createParagraph(input: {
|
||||
text: string;
|
||||
families: string[];
|
||||
fontSize: number;
|
||||
lineHeight: number;
|
||||
color: ReturnType<typeof Skia.Color>;
|
||||
}): SkParagraph {
|
||||
const paragraph = Skia.ParagraphBuilder.Make(
|
||||
paragraphStyle(input.families, input.fontSize, input.lineHeight),
|
||||
)
|
||||
.pushStyle({
|
||||
fontFamilies: input.families,
|
||||
fontSize: input.fontSize,
|
||||
color: input.color,
|
||||
})
|
||||
.addText(input.text)
|
||||
.pop()
|
||||
.build();
|
||||
paragraph.layout(PARAGRAPH_WIDTH);
|
||||
return paragraph;
|
||||
}
|
||||
|
||||
function paragraphStyle(families: string[], fontSize: number, lineHeight: number) {
|
||||
return {
|
||||
maxLines: 1,
|
||||
strutStyle: {
|
||||
strutEnabled: true,
|
||||
forceStrutHeight: true,
|
||||
fontFamilies: families,
|
||||
fontSize,
|
||||
heightMultiplier: lineHeight / fontSize,
|
||||
halfLeading: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function textStyle(families: string[], fontSize: number, color: string) {
|
||||
return { fontFamilies: families, fontSize, color: Skia.Color(color) };
|
||||
}
|
||||
|
||||
function primaryFont(families: string[], size: number): SkFont {
|
||||
const available = new Set(listFontFamilies());
|
||||
const family = families.find((candidate) => available.has(candidate)) ?? "System";
|
||||
return matchFont({ fontFamily: family, fontSize: size });
|
||||
}
|
||||
|
||||
export function nativeFontFamilies(configuredFamily: string): string[] {
|
||||
const configured = configuredFamily
|
||||
.split(",")
|
||||
.map((family) => family.trim().replace(/^['"]|['"]$/g, ""))
|
||||
.filter(Boolean);
|
||||
return [...new Set([...configured, "Menlo", "SF Mono", "monospace", "System"])];
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import type { InlineReviewActions } from "@/review";
|
||||
import type { ReviewableDiffTarget } from "@/utils/diff-layout";
|
||||
|
||||
interface DiffDocumentBaseProps {
|
||||
files: ParsedDiffFile[];
|
||||
displayPreferences: {
|
||||
layout: "unified" | "split";
|
||||
wrapLines: boolean;
|
||||
codeFontSize: number;
|
||||
monoFontFamily: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkingDiffMode {
|
||||
kind: "working";
|
||||
reviewActions?: InlineReviewActions;
|
||||
onFilePress?: (path: string) => void;
|
||||
focusPath?: string;
|
||||
focusRequestId?: number;
|
||||
workspaceFileDragScope?: { serverId: string; workspaceId: string };
|
||||
onOpenFile?: (path: string) => void;
|
||||
onAddToChat?: (path: string) => void;
|
||||
onCopyPath?: (path: string) => void;
|
||||
onCopyRelativePath?: (path: string) => void;
|
||||
onReveal?: (path: string) => void;
|
||||
revealTargetName?: string;
|
||||
onDownload?: (path: string) => void;
|
||||
onDuplicate?: (path: string) => void;
|
||||
onRevert?: (path: string, oldPath?: string) => void;
|
||||
}
|
||||
|
||||
export type DiffDocumentProps = DiffDocumentBaseProps &
|
||||
(
|
||||
| {
|
||||
mode: WorkingDiffMode;
|
||||
collapseState: {
|
||||
paths: readonly string[];
|
||||
onChange: (paths: string[]) => void;
|
||||
};
|
||||
}
|
||||
| { mode: { kind: "commit" }; collapseState?: never }
|
||||
);
|
||||
|
||||
export interface DiffTypography {
|
||||
family: string;
|
||||
size: number;
|
||||
lineHeight: number;
|
||||
}
|
||||
|
||||
export interface DiffPalette {
|
||||
surface: string;
|
||||
headerSurface: string;
|
||||
border: string;
|
||||
foreground: string;
|
||||
foregroundMuted: string;
|
||||
addition: string;
|
||||
deletion: string;
|
||||
additionBackground: string;
|
||||
deletionBackground: string;
|
||||
emptyBackground: string;
|
||||
selection: string;
|
||||
syntax: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface DiffTokenRun {
|
||||
start: number;
|
||||
end: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface DiffFragment {
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
width: number;
|
||||
top: number;
|
||||
baseline: number;
|
||||
graphemes: DiffGrapheme[];
|
||||
}
|
||||
|
||||
export interface DiffGrapheme {
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface DiffSourceIdentity {
|
||||
hunkIndex: number;
|
||||
lineIndex: number;
|
||||
side: "old" | "new";
|
||||
}
|
||||
|
||||
export interface DiffCell {
|
||||
type: "add" | "remove" | "context" | "header" | "empty";
|
||||
content: string;
|
||||
lineNumber: number | null;
|
||||
tokens: DiffTokenRun[];
|
||||
fragments: DiffFragment[];
|
||||
reviewTarget: ReviewableDiffTarget | null;
|
||||
sourceIdentity: DiffSourceIdentity;
|
||||
}
|
||||
|
||||
export interface DiffLineRow {
|
||||
kind: "line";
|
||||
index: number;
|
||||
fileIndex: number;
|
||||
path: string;
|
||||
top: number;
|
||||
height: number;
|
||||
cells: [DiffCell] | [DiffCell | null, DiffCell | null];
|
||||
reviewHeight: number;
|
||||
}
|
||||
|
||||
export interface DiffStatusRow {
|
||||
kind: "status";
|
||||
index: number;
|
||||
fileIndex: number;
|
||||
path: string;
|
||||
top: number;
|
||||
height: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type DiffRow = DiffLineRow | DiffStatusRow;
|
||||
|
||||
export interface DiffFileSection {
|
||||
file: ParsedDiffFile;
|
||||
fileIndex: number;
|
||||
path: string;
|
||||
top: number;
|
||||
headerHeight: number;
|
||||
bodyTop: number;
|
||||
bodyHeight: number;
|
||||
bottom: number;
|
||||
gutterWidth: number;
|
||||
contentWidth: number;
|
||||
rowStart: number;
|
||||
rowEnd: number;
|
||||
isCollapsed: boolean;
|
||||
}
|
||||
|
||||
export interface DiffDocumentModel {
|
||||
files: DiffFileSection[];
|
||||
rows: DiffRow[];
|
||||
height: number;
|
||||
lineHeight: number;
|
||||
layout: "unified" | "split";
|
||||
wrapLines: boolean;
|
||||
viewportWidth: number;
|
||||
}
|
||||
|
||||
export interface TextMeasurer {
|
||||
measure(text: string, weight?: "regular" | "semibold"): number;
|
||||
measureAdvances?(graphemes: readonly string[]): number[];
|
||||
}
|
||||
|
||||
export interface BuildDiffDocumentModelInput {
|
||||
files: readonly ParsedDiffFile[];
|
||||
collapsedFilePaths: ReadonlySet<string>;
|
||||
layout: "unified" | "split";
|
||||
wrapLines: boolean;
|
||||
viewportWidth: number;
|
||||
typography: DiffTypography;
|
||||
measureText: TextMeasurer;
|
||||
palette: DiffPalette;
|
||||
reviewActions?: InlineReviewActions;
|
||||
labels: { binary: string; tooLarge: string };
|
||||
}
|
||||
|
||||
export interface DiffCharacterPosition {
|
||||
fileIndex: number;
|
||||
rowIndex: number;
|
||||
cellIndex: number;
|
||||
side: "old" | "new";
|
||||
/** UTF-16 source offset within the canonical cell content. */
|
||||
sourceOffset: number;
|
||||
}
|
||||
|
||||
export type DiffHit =
|
||||
| { kind: "header"; path: string }
|
||||
| {
|
||||
kind: "cell";
|
||||
target: ReviewableDiffTarget | null;
|
||||
position: DiffCharacterPosition;
|
||||
};
|
||||
|
||||
export interface DiffSelection {
|
||||
anchor: DiffCharacterPosition;
|
||||
focus: DiffCharacterPosition;
|
||||
}
|
||||
|
||||
export type DiffScrollAnchor =
|
||||
| { kind: "header"; path: string; viewportDelta: number }
|
||||
| {
|
||||
kind: "text";
|
||||
path: string;
|
||||
sourceIdentity: DiffSourceIdentity;
|
||||
cellOffset: number;
|
||||
viewportDelta: number;
|
||||
};
|
||||
|
||||
export type DiffSurfaceProps = DiffDocumentProps & {
|
||||
palette: DiffPalette;
|
||||
collapsedFilePaths: ReadonlySet<string>;
|
||||
onToggleFile: (path: string) => void;
|
||||
selectedPath: string | null;
|
||||
onSelectPath: (path: string) => void;
|
||||
};
|
||||
@@ -1,134 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "./diff-flat-items";
|
||||
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||
|
||||
function createFile(path: string, additions = 1, deletions = 0): ParsedDiffFile {
|
||||
return { path, isNew: false, isDeleted: false, additions, deletions, hunks: [] };
|
||||
}
|
||||
|
||||
function summarize(items: DiffFlatItem[]): string[] {
|
||||
return items.map((item) => {
|
||||
if (item.type === "folder") {
|
||||
return `${" ".repeat(item.depth)}[${item.displayName}]${item.collapsed ? " (collapsed)" : ""}`;
|
||||
}
|
||||
const base = item.file.path.split("/").pop();
|
||||
return `${" ".repeat(item.depth)}${item.type === "body" ? "body:" : ""}${base}`;
|
||||
});
|
||||
}
|
||||
|
||||
describe("buildDiffFlatItems", () => {
|
||||
const files = [createFile("src/app/a.ts"), createFile("src/app/nested/b.ts")];
|
||||
|
||||
it("emits a body and a sticky index for each expanded file", () => {
|
||||
const { items, stickyHeaderIndices } = buildDiffFlatItems({
|
||||
files: [createFile("a.ts"), createFile("b.ts")],
|
||||
viewMode: "tree",
|
||||
collapsedFolders: new Set(),
|
||||
expandedPaths: new Set(["a.ts"]),
|
||||
});
|
||||
// Root-level files (no folder): header, body (for expanded a.ts), header
|
||||
expect(summarize(items)).toEqual(["a.ts", "body:a.ts", "b.ts"]);
|
||||
// sticky points at the header (index 0), not the body
|
||||
expect(stickyHeaderIndices).toEqual([0]);
|
||||
expect(items[0].type).toBe("header");
|
||||
});
|
||||
|
||||
it("emits the old flat list without folder rows or indentation", () => {
|
||||
const { items, stickyHeaderIndices } = buildDiffFlatItems({
|
||||
files,
|
||||
viewMode: "flat",
|
||||
collapsedFolders: new Set(),
|
||||
expandedPaths: new Set(["src/app/a.ts"]),
|
||||
});
|
||||
|
||||
expect(summarize(items)).toEqual(["a.ts", "body:a.ts", "b.ts"]);
|
||||
expect(stickyHeaderIndices).toEqual([0]);
|
||||
expect(items.every((item) => item.type !== "folder")).toBe(true);
|
||||
});
|
||||
|
||||
it("groups files under compressed folder rows, all expanded by default", () => {
|
||||
const { items } = buildDiffFlatItems({
|
||||
files,
|
||||
viewMode: "tree",
|
||||
collapsedFolders: new Set(),
|
||||
expandedPaths: new Set(),
|
||||
});
|
||||
// dirs sort before files within a level: [nested] precedes a.ts
|
||||
expect(summarize(items)).toEqual(["[src/app]", " [nested]", " b.ts", " a.ts"]);
|
||||
});
|
||||
|
||||
it("collapsing a folder hides its descendants but keeps the row", () => {
|
||||
const { items } = buildDiffFlatItems({
|
||||
files,
|
||||
viewMode: "tree",
|
||||
collapsedFolders: new Set(["src/app/nested"]),
|
||||
expandedPaths: new Set(),
|
||||
});
|
||||
expect(summarize(items)).toEqual(["[src/app]", " [nested] (collapsed)", " a.ts"]);
|
||||
});
|
||||
|
||||
it("collapsing an ancestor hides everything below it", () => {
|
||||
const { items } = buildDiffFlatItems({
|
||||
files,
|
||||
viewMode: "tree",
|
||||
collapsedFolders: new Set(["src/app"]),
|
||||
expandedPaths: new Set(),
|
||||
});
|
||||
expect(summarize(items)).toEqual(["[src/app] (collapsed)"]);
|
||||
});
|
||||
|
||||
it("derives sticky indices file-headers-only from the post-collapse list", () => {
|
||||
// dirs-first: [src/app], [nested], b.ts, a.ts. Expanding a.ts puts its
|
||||
// header at index 3, with the body right after.
|
||||
const { items, stickyHeaderIndices } = buildDiffFlatItems({
|
||||
files,
|
||||
viewMode: "tree",
|
||||
collapsedFolders: new Set(),
|
||||
expandedPaths: new Set(["src/app/a.ts"]),
|
||||
});
|
||||
expect(summarize(items)).toEqual([
|
||||
"[src/app]",
|
||||
" [nested]",
|
||||
" b.ts",
|
||||
" a.ts",
|
||||
" body:a.ts",
|
||||
]);
|
||||
expect(stickyHeaderIndices).toEqual([3]);
|
||||
for (const idx of stickyHeaderIndices) {
|
||||
expect(items[idx].type).toBe("header");
|
||||
}
|
||||
});
|
||||
|
||||
it("maps tree file rows back to their original files array index", () => {
|
||||
const { items } = buildDiffFlatItems({
|
||||
files,
|
||||
viewMode: "tree",
|
||||
collapsedFolders: new Set(),
|
||||
expandedPaths: new Set(),
|
||||
});
|
||||
// Tree order is b.ts (fileIndex 1) then a.ts (fileIndex 0)
|
||||
const headers = items.filter((i) => i.type === "header");
|
||||
expect(headers.map((h) => (h.type === "header" ? h.fileIndex : -1))).toEqual([1, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sumHeightsBefore", () => {
|
||||
const items = buildDiffFlatItems({
|
||||
files: [createFile("src/a.ts"), createFile("src/b.ts")],
|
||||
viewMode: "tree",
|
||||
collapsedFolders: new Set(),
|
||||
expandedPaths: new Set(),
|
||||
}).items; // [folder, a.ts, b.ts]
|
||||
|
||||
const heightFor = (item: DiffFlatItem) => (item.type === "folder" ? 10 : 20);
|
||||
|
||||
it("sums the heights of items before the index, counting folder rows", () => {
|
||||
expect(sumHeightsBefore(items, 0, heightFor)).toBe(0);
|
||||
expect(sumHeightsBefore(items, 1, heightFor)).toBe(10); // folder above a.ts
|
||||
expect(sumHeightsBefore(items, 2, heightFor)).toBe(30); // folder + a.ts above b.ts
|
||||
});
|
||||
|
||||
it("clamps an out-of-range index to the list length", () => {
|
||||
expect(sumHeightsBefore(items, 999, heightFor)).toBe(50);
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||
import {
|
||||
buildDiffTree,
|
||||
compressSingleChildChains,
|
||||
flattenDiffTree,
|
||||
type DiffTreeDirNode,
|
||||
} from "@/git/diff-tree";
|
||||
|
||||
// The row model for the Changes FlatList. `fileIndex` is the file's index within
|
||||
// the (path-sorted) `files` array — a stable identity for testIDs/keys that is
|
||||
// independent of folder rows and collapse state. `depth` drives indentation.
|
||||
export type DiffFlatItem =
|
||||
| {
|
||||
type: "folder";
|
||||
dirPath: string;
|
||||
displayName: string;
|
||||
depth: number;
|
||||
collapsed: boolean;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
}
|
||||
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean; depth: number }
|
||||
| { type: "body"; file: ParsedDiffFile; fileIndex: number; depth: number };
|
||||
|
||||
export interface DiffFlatItemsResult {
|
||||
items: DiffFlatItem[];
|
||||
/** Indices into `items` of expanded file headers — folder rows are never sticky. */
|
||||
stickyHeaderIndices: number[];
|
||||
}
|
||||
|
||||
export interface BuildDiffFlatItemsInput {
|
||||
files: ParsedDiffFile[];
|
||||
viewMode: "flat" | "tree";
|
||||
/** Full uncompressed directory paths that are collapsed (empty = all expanded). */
|
||||
collapsedFolders: ReadonlySet<string>;
|
||||
/** File paths whose diff body is expanded. */
|
||||
expandedPaths: ReadonlySet<string>;
|
||||
/**
|
||||
* Pre-built compressed tree (from the same `files`). Pass it to avoid rebuilding
|
||||
* the tree on every collapse toggle; omitted callers (tests) build it internally.
|
||||
*/
|
||||
tree?: DiffTreeDirNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the FlatList item model plus its sticky-header indices: files grouped
|
||||
* under directory rows, single-child chains compressed, descendants of collapsed
|
||||
* folders omitted.
|
||||
*
|
||||
* Sticky indices are derived from the FINAL (post-collapse) item list and only
|
||||
* ever point at expanded FILE headers, so folder rows can't stack or collide in
|
||||
* the sticky header stack.
|
||||
*/
|
||||
export function buildDiffFlatItems({
|
||||
files,
|
||||
viewMode,
|
||||
collapsedFolders,
|
||||
expandedPaths,
|
||||
tree,
|
||||
}: BuildDiffFlatItemsInput): DiffFlatItemsResult {
|
||||
const items: DiffFlatItem[] = [];
|
||||
const stickyHeaderIndices: number[] = [];
|
||||
|
||||
const pushFile = (file: ParsedDiffFile, fileIndex: number, depth: number): void => {
|
||||
const isExpanded = expandedPaths.has(file.path);
|
||||
items.push({ type: "header", file, fileIndex, isExpanded, depth });
|
||||
if (isExpanded) {
|
||||
stickyHeaderIndices.push(items.length - 1);
|
||||
items.push({ type: "body", file, fileIndex, depth });
|
||||
}
|
||||
};
|
||||
|
||||
if (viewMode === "flat") {
|
||||
for (const [fileIndex, file] of files.entries()) {
|
||||
pushFile(file, fileIndex, 0);
|
||||
}
|
||||
return { items, stickyHeaderIndices };
|
||||
}
|
||||
|
||||
const indexByPath = new Map(files.map((file, index) => [file.path, index]));
|
||||
const compressedTree = tree ?? compressSingleChildChains(buildDiffTree(files));
|
||||
const rows = flattenDiffTree(compressedTree, collapsedFolders);
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.kind === "folder") {
|
||||
items.push({
|
||||
type: "folder",
|
||||
dirPath: row.dirPath,
|
||||
displayName: row.displayName,
|
||||
depth: row.depth,
|
||||
collapsed: collapsedFolders.has(row.dirPath),
|
||||
additions: row.additions,
|
||||
deletions: row.deletions,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const fileIndex = indexByPath.get(row.file.path);
|
||||
if (fileIndex === undefined) {
|
||||
// Should never happen: the tree is built from the same `files` array.
|
||||
continue;
|
||||
}
|
||||
pushFile(row.file, fileIndex, row.depth);
|
||||
}
|
||||
|
||||
return { items, stickyHeaderIndices };
|
||||
}
|
||||
|
||||
/** Cumulative height of every item before `index`. Single source of truth for
|
||||
* FlatList's getItemLayout AND the collapse scroll-anchor math. */
|
||||
export function sumHeightsBefore(
|
||||
items: DiffFlatItem[],
|
||||
index: number,
|
||||
heightFor: (item: DiffFlatItem) => number,
|
||||
): number {
|
||||
let offset = 0;
|
||||
const end = Math.min(index, items.length);
|
||||
for (let i = 0; i < end; i++) {
|
||||
offset += heightFor(items[i]);
|
||||
}
|
||||
return Math.max(0, offset);
|
||||
}
|
||||
+109
-2171
File diff suppressed because it is too large
Load Diff
@@ -1,59 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldAnchorHeaderBeforeCollapse } from "./diff-scroll";
|
||||
|
||||
describe("shouldAnchorHeaderBeforeCollapse", () => {
|
||||
it("skips anchor when header is fully visible in viewport", () => {
|
||||
expect(
|
||||
shouldAnchorHeaderBeforeCollapse({
|
||||
headerOffset: 120,
|
||||
headerHeight: 44,
|
||||
viewportOffset: 80,
|
||||
viewportHeight: 400,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("skips anchor when header is partially visible in viewport", () => {
|
||||
expect(
|
||||
shouldAnchorHeaderBeforeCollapse({
|
||||
headerOffset: 60,
|
||||
headerHeight: 44,
|
||||
viewportOffset: 80,
|
||||
viewportHeight: 300,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("anchors when header is above viewport", () => {
|
||||
expect(
|
||||
shouldAnchorHeaderBeforeCollapse({
|
||||
headerOffset: 200,
|
||||
headerHeight: 44,
|
||||
viewportOffset: 500,
|
||||
viewportHeight: 300,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("anchors when header is below viewport", () => {
|
||||
expect(
|
||||
shouldAnchorHeaderBeforeCollapse({
|
||||
headerOffset: 600,
|
||||
headerHeight: 44,
|
||||
viewportOffset: 100,
|
||||
viewportHeight: 300,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("anchors when viewport metrics are unavailable", () => {
|
||||
expect(
|
||||
shouldAnchorHeaderBeforeCollapse({
|
||||
headerOffset: 0,
|
||||
headerHeight: 44,
|
||||
viewportOffset: 0,
|
||||
viewportHeight: 0,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
interface AnchorVisibilityInput {
|
||||
headerOffset: number;
|
||||
headerHeight: number;
|
||||
viewportOffset: number;
|
||||
viewportHeight: number;
|
||||
edgeThreshold?: number;
|
||||
}
|
||||
|
||||
export function shouldAnchorHeaderBeforeCollapse({
|
||||
headerOffset,
|
||||
headerHeight,
|
||||
viewportOffset,
|
||||
viewportHeight,
|
||||
edgeThreshold = 1,
|
||||
}: AnchorVisibilityInput): boolean {
|
||||
if (
|
||||
!Number.isFinite(headerOffset) ||
|
||||
!Number.isFinite(headerHeight) ||
|
||||
!Number.isFinite(viewportOffset) ||
|
||||
!Number.isFinite(viewportHeight) ||
|
||||
viewportHeight <= 0
|
||||
) {
|
||||
// Preserve current behavior when metrics are unavailable.
|
||||
return true;
|
||||
}
|
||||
|
||||
const clampedThreshold = Math.max(0, edgeThreshold);
|
||||
const clampedHeaderHeight = Math.max(0, headerHeight);
|
||||
const headerStart = headerOffset;
|
||||
const headerEnd = headerOffset + clampedHeaderHeight;
|
||||
const viewportStart = viewportOffset + clampedThreshold;
|
||||
const viewportEnd = viewportOffset + viewportHeight - clampedThreshold;
|
||||
const headerVisible = headerEnd > viewportStart && headerStart < viewportEnd;
|
||||
|
||||
return !headerVisible;
|
||||
}
|
||||
@@ -139,6 +139,16 @@ describe("flattenDiffTree", () => {
|
||||
const rows = flattenDiffTree(root, new Set(["src"]));
|
||||
expect(rows[0]).toMatchObject({ kind: "folder", additions: 7, deletions: 1 });
|
||||
});
|
||||
|
||||
it("keeps each file's original index when tree ordering differs from diff ordering", () => {
|
||||
const rows = flattenDiffTree(compressed(["src/a.ts", "src/nested/b.ts"]), new Set());
|
||||
const fileRows = rows.filter((row) => row.kind === "file");
|
||||
|
||||
expect(fileRows.map((row) => [row.file.path, row.fileIndex])).toEqual([
|
||||
["src/nested/b.ts", 1],
|
||||
["src/a.ts", 0],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectDirPaths", () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||
export interface DiffTreeFileNode {
|
||||
kind: "file";
|
||||
file: ParsedDiffFile;
|
||||
fileIndex: number;
|
||||
/** basename, e.g. "diff-pane.tsx" */
|
||||
name: string;
|
||||
}
|
||||
@@ -42,6 +43,7 @@ export interface DiffTreeFolderRow {
|
||||
export interface DiffTreeFileRow {
|
||||
kind: "file";
|
||||
file: ParsedDiffFile;
|
||||
fileIndex: number;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
@@ -85,11 +87,11 @@ export function buildDiffTree(files: ParsedDiffFile[]): DiffTreeDirNode {
|
||||
return node;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
for (const [fileIndex, file] of files.entries()) {
|
||||
const parts = file.path.split("/");
|
||||
const name = parts[parts.length - 1];
|
||||
const dirPath = parts.slice(0, -1).join("/");
|
||||
ensureDir(dirPath).children.push({ kind: "file", file, name });
|
||||
ensureDir(dirPath).children.push({ kind: "file", file, fileIndex, name });
|
||||
}
|
||||
|
||||
sortTree(root);
|
||||
@@ -169,7 +171,7 @@ export function flattenDiffTree(
|
||||
function walk(node: DiffTreeDirNode, depth: number): void {
|
||||
for (const child of node.children) {
|
||||
if (child.kind === "file") {
|
||||
rows.push({ kind: "file", file: child.file, depth });
|
||||
rows.push({ kind: "file", file: child.file, fileIndex: child.fileIndex, depth });
|
||||
continue;
|
||||
}
|
||||
const stats = statsByNode.get(child) ?? EMPTY_DIR_STATS;
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createDiffRowWindow } from "./diff-virtualization";
|
||||
|
||||
describe("createDiffRowWindow", () => {
|
||||
it("renders only the viewport and overscan while preserving the full height", () => {
|
||||
const rows = Array.from({ length: 1_200 }, () => 18);
|
||||
const window = createDiffRowWindow(rows, {
|
||||
viewportTop: 0,
|
||||
viewportHeight: 900,
|
||||
overscan: 900,
|
||||
});
|
||||
|
||||
expect(window.startIndex).toBe(0);
|
||||
expect(window.endIndex).toBe(100);
|
||||
expect(window.beforeHeight).toBe(0);
|
||||
expect(window.afterHeight).toBe(19_800);
|
||||
expect(window.beforeHeight + window.renderedHeight + window.afterHeight).toBe(21_600);
|
||||
});
|
||||
|
||||
it("finds a deep variable-height window without replaying earlier rows", () => {
|
||||
const rows = [18, 18, 90, 18, 150, 18, 18];
|
||||
const window = createDiffRowWindow(rows, {
|
||||
viewportTop: 120,
|
||||
viewportHeight: 60,
|
||||
overscan: 0,
|
||||
});
|
||||
|
||||
expect(window).toEqual({
|
||||
startIndex: 2,
|
||||
endIndex: 5,
|
||||
beforeHeight: 36,
|
||||
renderedHeight: 258,
|
||||
afterHeight: 36,
|
||||
totalHeight: 330,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an empty row window when an overscanned body is still offscreen", () => {
|
||||
const window = createDiffRowWindow([18, 18, 18], {
|
||||
viewportTop: -2_000,
|
||||
viewportHeight: 800,
|
||||
overscan: 600,
|
||||
});
|
||||
|
||||
expect(window).toEqual({
|
||||
startIndex: 0,
|
||||
endIndex: 0,
|
||||
beforeHeight: 0,
|
||||
renderedHeight: 0,
|
||||
afterHeight: 54,
|
||||
totalHeight: 54,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
export interface DiffViewportWindow {
|
||||
viewportTop: number;
|
||||
viewportHeight: number;
|
||||
overscan: number;
|
||||
}
|
||||
|
||||
export interface DiffRowWindow {
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
beforeHeight: number;
|
||||
renderedHeight: number;
|
||||
afterHeight: number;
|
||||
totalHeight: number;
|
||||
}
|
||||
|
||||
export interface DiffViewportSnapshot {
|
||||
top: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface DiffViewport {
|
||||
getSnapshot: () => DiffViewportSnapshot;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
update: (snapshot: DiffViewportSnapshot) => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
/** Coalesce native/web scroll events so diff bodies select rows at most once per frame. */
|
||||
export function createDiffViewport(): DiffViewport {
|
||||
let snapshot: DiffViewportSnapshot = { top: 0, height: 0 };
|
||||
let pending = snapshot;
|
||||
let frame: number | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const flush = () => {
|
||||
frame = null;
|
||||
if (pending.top === snapshot.top && pending.height === snapshot.height) {
|
||||
return;
|
||||
}
|
||||
snapshot = pending;
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
update(nextSnapshot) {
|
||||
pending = nextSnapshot;
|
||||
frame ??= requestAnimationFrame(flush);
|
||||
},
|
||||
dispose() {
|
||||
if (frame !== null) {
|
||||
cancelAnimationFrame(frame);
|
||||
}
|
||||
frame = null;
|
||||
listeners.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the contiguous diff rows that intersect a viewport while reserving
|
||||
* exact spacer heights for everything outside it.
|
||||
*/
|
||||
export function createDiffRowWindow(
|
||||
rowHeights: readonly number[],
|
||||
viewport: DiffViewportWindow,
|
||||
): DiffRowWindow {
|
||||
const offsets = Array.from({ length: rowHeights.length + 1 }, () => 0);
|
||||
offsets[0] = 0;
|
||||
for (let index = 0; index < rowHeights.length; index += 1) {
|
||||
offsets[index + 1] = offsets[index] + rowHeights[index];
|
||||
}
|
||||
|
||||
const totalHeight = offsets[rowHeights.length];
|
||||
const visibleTop = viewport.viewportTop - viewport.overscan;
|
||||
const visibleBottom = viewport.viewportTop + viewport.viewportHeight + viewport.overscan;
|
||||
|
||||
if (visibleBottom <= 0) {
|
||||
return emptyWindow(0, 0, totalHeight);
|
||||
}
|
||||
if (visibleTop >= totalHeight) {
|
||||
return emptyWindow(rowHeights.length, totalHeight, 0);
|
||||
}
|
||||
|
||||
const startIndex = firstIndexWhoseEndExceeds(offsets, Math.max(0, visibleTop));
|
||||
const endIndex = firstIndexWhoseStartReaches(offsets, Math.min(totalHeight, visibleBottom));
|
||||
const beforeHeight = offsets[startIndex];
|
||||
const renderedHeight = offsets[endIndex] - beforeHeight;
|
||||
|
||||
return {
|
||||
startIndex,
|
||||
endIndex,
|
||||
beforeHeight,
|
||||
renderedHeight,
|
||||
afterHeight: totalHeight - offsets[endIndex],
|
||||
totalHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyWindow(index: number, beforeHeight: number, afterHeight: number): DiffRowWindow {
|
||||
return {
|
||||
startIndex: index,
|
||||
endIndex: index,
|
||||
beforeHeight,
|
||||
renderedHeight: 0,
|
||||
afterHeight,
|
||||
totalHeight: beforeHeight + afterHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function firstIndexWhoseEndExceeds(offsets: readonly number[], target: number): number {
|
||||
let low = 1;
|
||||
let high = offsets.length;
|
||||
while (low < high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
if ((offsets[middle] ?? Number.POSITIVE_INFINITY) > target) {
|
||||
high = middle;
|
||||
} else {
|
||||
low = middle + 1;
|
||||
}
|
||||
}
|
||||
return Math.min(offsets.length - 1, low - 1);
|
||||
}
|
||||
|
||||
function firstIndexWhoseStartReaches(offsets: readonly number[], target: number): number {
|
||||
let low = 0;
|
||||
let high = offsets.length - 1;
|
||||
while (low < high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
if (offsets[middle] >= target) {
|
||||
high = middle;
|
||||
} else {
|
||||
low = middle + 1;
|
||||
}
|
||||
}
|
||||
return low;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
reduceFileHeaderPress,
|
||||
shouldActivateStickyHeaderPress,
|
||||
type FileHeaderPressEvent,
|
||||
type FileHeaderPressPhase,
|
||||
} from "./file-header-interaction";
|
||||
|
||||
const pressOrigin = { startedAt: 1_000, pageX: 20, pageY: 30 };
|
||||
|
||||
describe("file header interaction arbitration", () => {
|
||||
it("activates an unhandled native sticky-header tap on press out", () => {
|
||||
expect(
|
||||
shouldActivateStickyHeaderPress({
|
||||
enabled: true,
|
||||
native: true,
|
||||
pressHandled: false,
|
||||
layoutY: 0,
|
||||
pressOrigin,
|
||||
releasedAt: 1_500,
|
||||
pageX: 32,
|
||||
pageY: 30,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an already handled long press", { pressHandled: true }],
|
||||
["a non-native release", { native: false }],
|
||||
["a non-sticky header", { layoutY: 1 }],
|
||||
["a held press", { releasedAt: 1_501 }],
|
||||
["a moved press", { pageX: 33 }],
|
||||
])("does not activate %s", (_label, override) => {
|
||||
expect(
|
||||
shouldActivateStickyHeaderPress({
|
||||
enabled: true,
|
||||
native: true,
|
||||
pressHandled: false,
|
||||
layoutY: 0,
|
||||
pressOrigin,
|
||||
releasedAt: 1_500,
|
||||
pageX: 32,
|
||||
pageY: 30,
|
||||
...override,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
function run(events: FileHeaderPressEvent[]) {
|
||||
let phase: FileHeaderPressPhase = "idle";
|
||||
const effects: string[] = [];
|
||||
for (const event of events) {
|
||||
const next = reduceFileHeaderPress(phase, event);
|
||||
phase = next.phase;
|
||||
if (next.effect !== "none") effects.push(next.effect);
|
||||
}
|
||||
return effects;
|
||||
}
|
||||
|
||||
it("lets a normal press claim a deferred sticky release exactly once", () => {
|
||||
expect(
|
||||
run([
|
||||
{ type: "press-in" },
|
||||
{ type: "press-out", stickyFallbackEligible: true },
|
||||
{ type: "press" },
|
||||
{ type: "fallback" },
|
||||
]),
|
||||
).toEqual(["defer-activate", "activate"]);
|
||||
});
|
||||
|
||||
it("falls back once for a canceled sticky press", () => {
|
||||
expect(
|
||||
run([
|
||||
{ type: "press-in" },
|
||||
{ type: "press-out", stickyFallbackEligible: true },
|
||||
{ type: "fallback" },
|
||||
]),
|
||||
).toEqual(["defer-activate", "activate"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"canceled non-sticky",
|
||||
[{ type: "press-in" }, { type: "press-out", stickyFallbackEligible: false }],
|
||||
],
|
||||
[
|
||||
"moved",
|
||||
[
|
||||
{ type: "press-in" },
|
||||
{ type: "press-out", stickyFallbackEligible: false },
|
||||
{ type: "fallback" },
|
||||
],
|
||||
],
|
||||
] as const)("does not activate a %s sequence", (_label, events) => {
|
||||
expect(run([...events])).toEqual([]);
|
||||
});
|
||||
|
||||
it("selects a long press without activating when Pressability later emits press-out/press", () => {
|
||||
expect(
|
||||
run([
|
||||
{ type: "press-in" },
|
||||
{ type: "long-press" },
|
||||
{ type: "press-out", stickyFallbackEligible: false },
|
||||
{ type: "press" },
|
||||
{ type: "fallback" },
|
||||
]),
|
||||
).toEqual(["select"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import type { LayoutChangeEvent } from "react-native";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
const STICKY_PRESS_MAX_DURATION_MS = 500;
|
||||
const STICKY_PRESS_MAX_DISTANCE = 12;
|
||||
|
||||
interface FileHeaderInteractionInput {
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
onSelect?: (path: string) => void;
|
||||
onActivate?: (path: string) => void;
|
||||
onLayout?: (height: number) => void;
|
||||
}
|
||||
|
||||
interface PressPointEvent {
|
||||
nativeEvent: { pageX: number; pageY: number };
|
||||
}
|
||||
|
||||
interface PressOrigin {
|
||||
startedAt: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
}
|
||||
|
||||
interface StickyPressReleaseInput {
|
||||
enabled: boolean;
|
||||
native: boolean;
|
||||
pressHandled: boolean;
|
||||
layoutY: number | null;
|
||||
pressOrigin: PressOrigin | null;
|
||||
releasedAt: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
}
|
||||
|
||||
interface FileHeaderInteraction {
|
||||
activate: () => void;
|
||||
select: () => void;
|
||||
onLayout: (event: LayoutChangeEvent) => void;
|
||||
onPressIn: (event: PressPointEvent) => void;
|
||||
onPressOut: (event: PressPointEvent) => void;
|
||||
onLongPress: () => void;
|
||||
}
|
||||
|
||||
export type FileHeaderPressPhase = "idle" | "pressed" | "released" | "handled" | "long";
|
||||
export type FileHeaderPressEvent =
|
||||
| { type: "press-in" }
|
||||
| { type: "press-out"; stickyFallbackEligible: boolean }
|
||||
| { type: "press" }
|
||||
| { type: "long-press" }
|
||||
| { type: "fallback" };
|
||||
export type FileHeaderPressEffect = "none" | "select" | "activate" | "defer-activate";
|
||||
|
||||
export function reduceFileHeaderPress(
|
||||
phase: FileHeaderPressPhase,
|
||||
event: FileHeaderPressEvent,
|
||||
): { phase: FileHeaderPressPhase; effect: FileHeaderPressEffect } {
|
||||
if (event.type === "press-in") return { phase: "pressed", effect: "none" };
|
||||
if (event.type === "long-press") return { phase: "long", effect: "select" };
|
||||
if (event.type === "press-out") {
|
||||
return event.stickyFallbackEligible && phase === "pressed"
|
||||
? { phase: "released", effect: "defer-activate" }
|
||||
: { phase: phase === "long" ? "long" : "idle", effect: "none" };
|
||||
}
|
||||
if (event.type === "press") {
|
||||
return phase === "long" || phase === "handled"
|
||||
? { phase: "handled", effect: "none" }
|
||||
: { phase: "handled", effect: "activate" };
|
||||
}
|
||||
return phase === "released"
|
||||
? { phase: "handled", effect: "activate" }
|
||||
: { phase, effect: "none" };
|
||||
}
|
||||
|
||||
export function shouldActivateStickyHeaderPress(input: StickyPressReleaseInput): boolean {
|
||||
if (
|
||||
!input.enabled ||
|
||||
!input.native ||
|
||||
input.pressHandled ||
|
||||
input.layoutY !== 0 ||
|
||||
!input.pressOrigin
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const durationMs = input.releasedAt - input.pressOrigin.startedAt;
|
||||
const dx = input.pageX - input.pressOrigin.pageX;
|
||||
const dy = input.pageY - input.pressOrigin.pageY;
|
||||
return (
|
||||
durationMs <= STICKY_PRESS_MAX_DURATION_MS && Math.hypot(dx, dy) <= STICKY_PRESS_MAX_DISTANCE
|
||||
);
|
||||
}
|
||||
|
||||
export function useFileHeaderInteraction(input: FileHeaderInteractionInput): FileHeaderInteraction {
|
||||
const { path, enabled, onSelect, onActivate, onLayout: notifyLayout } = input;
|
||||
const layoutYRef = useRef<number | null>(null);
|
||||
const pressHandledRef = useRef(false);
|
||||
const pressOriginRef = useRef<PressOrigin | null>(null);
|
||||
const phaseRef = useRef<FileHeaderPressPhase>("idle");
|
||||
const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearFallback = useCallback(() => {
|
||||
if (fallbackTimerRef.current !== null) clearTimeout(fallbackTimerRef.current);
|
||||
fallbackTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => clearFallback, [clearFallback]);
|
||||
|
||||
const select = useCallback(() => {
|
||||
if (enabled) onSelect?.(path);
|
||||
}, [enabled, onSelect, path]);
|
||||
|
||||
const activate = useCallback(() => {
|
||||
if (!enabled) return;
|
||||
clearFallback();
|
||||
const transition = reduceFileHeaderPress(phaseRef.current, { type: "press" });
|
||||
phaseRef.current = transition.phase;
|
||||
if (transition.effect !== "activate") return;
|
||||
pressHandledRef.current = true;
|
||||
select();
|
||||
onActivate?.(path);
|
||||
}, [clearFallback, enabled, onActivate, path, select]);
|
||||
|
||||
const onLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
layoutYRef.current = event.nativeEvent.layout.y;
|
||||
notifyLayout?.(event.nativeEvent.layout.height);
|
||||
},
|
||||
[notifyLayout],
|
||||
);
|
||||
|
||||
const onPressIn = useCallback(
|
||||
(event: PressPointEvent) => {
|
||||
clearFallback();
|
||||
phaseRef.current = reduceFileHeaderPress(phaseRef.current, { type: "press-in" }).phase;
|
||||
pressHandledRef.current = false;
|
||||
pressOriginRef.current = {
|
||||
startedAt: Date.now(),
|
||||
pageX: event.nativeEvent.pageX,
|
||||
pageY: event.nativeEvent.pageY,
|
||||
};
|
||||
},
|
||||
[clearFallback],
|
||||
);
|
||||
|
||||
const onLongPress = useCallback(() => {
|
||||
clearFallback();
|
||||
const transition = reduceFileHeaderPress(phaseRef.current, { type: "long-press" });
|
||||
phaseRef.current = transition.phase;
|
||||
pressHandledRef.current = true;
|
||||
if (transition.effect === "select") select();
|
||||
}, [clearFallback, select]);
|
||||
|
||||
const onPressOut = useCallback(
|
||||
(event: PressPointEvent) => {
|
||||
const eligible = shouldActivateStickyHeaderPress({
|
||||
enabled,
|
||||
native: isNative,
|
||||
pressHandled: pressHandledRef.current,
|
||||
layoutY: layoutYRef.current,
|
||||
pressOrigin: pressOriginRef.current,
|
||||
releasedAt: Date.now(),
|
||||
pageX: event.nativeEvent.pageX,
|
||||
pageY: event.nativeEvent.pageY,
|
||||
});
|
||||
const transition = reduceFileHeaderPress(phaseRef.current, {
|
||||
type: "press-out",
|
||||
stickyFallbackEligible: eligible,
|
||||
});
|
||||
phaseRef.current = transition.phase;
|
||||
if (transition.effect === "defer-activate") {
|
||||
clearFallback();
|
||||
fallbackTimerRef.current = setTimeout(() => {
|
||||
fallbackTimerRef.current = null;
|
||||
const fallback = reduceFileHeaderPress(phaseRef.current, { type: "fallback" });
|
||||
phaseRef.current = fallback.phase;
|
||||
if (fallback.effect === "activate") {
|
||||
pressHandledRef.current = true;
|
||||
select();
|
||||
onActivate?.(path);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
[clearFallback, enabled, onActivate, path, select],
|
||||
);
|
||||
|
||||
return { activate, select, onLayout, onPressIn, onPressOut, onLongPress };
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { memo, type ReactElement, useCallback, useMemo } from "react";
|
||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
import { FileActionsContextMenuContent } from "@/components/file-actions-menu";
|
||||
import { FileChangeIcon } from "@/components/file-change-icon";
|
||||
import { MaterialFileIcon } from "@/components/material-file-icon";
|
||||
import {
|
||||
TreeIndentGuides,
|
||||
treeRowPaddingLeft,
|
||||
WORKSPACE_FILE_ROW_TRAILING_PADDING,
|
||||
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||
WORKSPACE_TREE_ICON_LABEL_GAP,
|
||||
WORKSPACE_TREE_ICON_SIZE,
|
||||
} from "@/components/tree-primitives";
|
||||
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useFileHeaderInteraction } from "@/git/file-header-interaction";
|
||||
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
|
||||
export interface FileHeaderProps {
|
||||
file: ParsedDiffFile;
|
||||
workspaceFileDragScope?: { serverId: string; workspaceId: string };
|
||||
bodyVisible: boolean;
|
||||
showsBodyState?: boolean;
|
||||
isSelected?: boolean;
|
||||
depth?: number;
|
||||
showDir?: boolean;
|
||||
interactive?: boolean;
|
||||
onActivate?: (path: string) => void;
|
||||
onSelect?: (path: string) => void;
|
||||
onOpenFile?: (path: string) => void;
|
||||
onAddToChat?: (path: string) => void;
|
||||
onCopyPath?: (path: string) => void;
|
||||
onCopyRelativePath?: (path: string) => void;
|
||||
onReveal?: (path: string) => void;
|
||||
revealTargetName?: string;
|
||||
onDownload?: (path: string) => void;
|
||||
onDuplicate?: (path: string) => void;
|
||||
onRevert?: (path: string, oldPath?: string) => void;
|
||||
onHeaderHeightChange?: (path: string, height: number) => void;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
function fileNameForPath(path: string): string {
|
||||
return path.split("/").pop() ?? path;
|
||||
}
|
||||
|
||||
function directorySuffix(path: string): string {
|
||||
return path.includes("/") ? ` ${path.slice(0, path.lastIndexOf("/"))}` : "";
|
||||
}
|
||||
|
||||
function fileHeaderAccessibilityState(input: {
|
||||
showsBodyState: boolean;
|
||||
bodyVisible: boolean;
|
||||
isSelected: boolean;
|
||||
}) {
|
||||
return {
|
||||
...(input.showsBodyState ? { expanded: input.bodyVisible } : {}),
|
||||
selected: input.isSelected,
|
||||
};
|
||||
}
|
||||
|
||||
function expandedAriaValue(showsBodyState: boolean, bodyVisible: boolean): boolean | undefined {
|
||||
return showsBodyState ? bodyVisible : undefined;
|
||||
}
|
||||
|
||||
function FileHeaderMenu({
|
||||
file,
|
||||
onOpenFile,
|
||||
onAddToChat,
|
||||
onCopyPath,
|
||||
onCopyRelativePath,
|
||||
onReveal,
|
||||
revealTargetName,
|
||||
onDownload,
|
||||
onDuplicate,
|
||||
onRevert,
|
||||
testID,
|
||||
}: FileHeaderProps) {
|
||||
const openFile = useCallback(() => onOpenFile?.(file.path), [file.path, onOpenFile]);
|
||||
const addToChat = useCallback(() => onAddToChat?.(file.path), [file.path, onAddToChat]);
|
||||
const copyPath = useCallback(() => onCopyPath?.(file.path), [file.path, onCopyPath]);
|
||||
const copyRelativePath = useCallback(
|
||||
() => onCopyRelativePath?.(file.path),
|
||||
[file.path, onCopyRelativePath],
|
||||
);
|
||||
const reveal = useCallback(() => onReveal?.(file.path), [file.path, onReveal]);
|
||||
const download = useCallback(() => onDownload?.(file.path), [file.path, onDownload]);
|
||||
const duplicate = useCallback(() => onDuplicate?.(file.path), [file.path, onDuplicate]);
|
||||
const revert = useCallback(
|
||||
() => onRevert?.(file.path, file.oldPath),
|
||||
[file.oldPath, file.path, onRevert],
|
||||
);
|
||||
return (
|
||||
<FileActionsContextMenuContent
|
||||
fileKind="file"
|
||||
fileExists={!file.isDeleted}
|
||||
onOpenFile={onOpenFile ? openFile : undefined}
|
||||
onCopyPath={onCopyPath ? copyPath : undefined}
|
||||
onCopyRelativePath={onCopyRelativePath ? copyRelativePath : undefined}
|
||||
onReveal={onReveal ? reveal : undefined}
|
||||
revealTargetName={revealTargetName}
|
||||
onDownload={onDownload ? download : undefined}
|
||||
onAddToChat={onAddToChat ? addToChat : undefined}
|
||||
onDuplicate={!file.isDeleted && onDuplicate ? duplicate : undefined}
|
||||
onRevert={onRevert ? revert : undefined}
|
||||
testIDPrefix={testID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const FileHeader = memo(function FileHeader({
|
||||
file,
|
||||
workspaceFileDragScope,
|
||||
bodyVisible,
|
||||
showsBodyState = true,
|
||||
isSelected = false,
|
||||
depth = 0,
|
||||
showDir = true,
|
||||
interactive = true,
|
||||
onActivate,
|
||||
onSelect,
|
||||
onHeaderHeightChange,
|
||||
testID,
|
||||
...actions
|
||||
}: FileHeaderProps) {
|
||||
const dragSourceRef = useWorkspaceFileDragSource({
|
||||
enabled: interactive,
|
||||
disabled: file.isDeleted,
|
||||
workspaceId: null,
|
||||
path: file.path,
|
||||
...workspaceFileDragScope,
|
||||
});
|
||||
const interaction = useFileHeaderInteraction({
|
||||
path: file.path,
|
||||
enabled: interactive,
|
||||
onSelect,
|
||||
onActivate,
|
||||
onLayout: useCallback(
|
||||
(height: number) => onHeaderHeightChange?.(file.path, height),
|
||||
[file.path, onHeaderHeightChange],
|
||||
),
|
||||
});
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.header,
|
||||
showsBodyState && styles.documentHeader,
|
||||
depth > 0 ? inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) }) : null,
|
||||
(Boolean(hovered) || pressed || isSelected) && styles.active,
|
||||
],
|
||||
[depth, isSelected, showsBodyState],
|
||||
);
|
||||
const accessibilityState = useMemo(
|
||||
() => fileHeaderAccessibilityState({ showsBodyState, bodyVisible, isSelected }),
|
||||
[bodyVisible, isSelected, showsBodyState],
|
||||
);
|
||||
const fileName = fileNameForPath(file.path);
|
||||
const content = (
|
||||
<View
|
||||
style={[styles.content, showsBodyState && styles.documentContent]}
|
||||
testID={testID ? `${testID}-header-content` : undefined}
|
||||
>
|
||||
<View ref={dragSourceRef} style={showDir ? styles.left : [styles.left, styles.leftTree]}>
|
||||
{showDir ? null : (
|
||||
<View style={styles.icon}>
|
||||
<MaterialFileIcon fileName={fileName} size={WORKSPACE_TREE_ICON_SIZE} />
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.name} numberOfLines={1} testID={testID ? `${testID}-name` : undefined}>
|
||||
{fileName}
|
||||
</Text>
|
||||
{showDir ? (
|
||||
<Text style={styles.directory} numberOfLines={1}>
|
||||
{directorySuffix(file.path)}
|
||||
</Text>
|
||||
) : (
|
||||
<View style={styles.directorySpacer} />
|
||||
)}
|
||||
{file.isNew ? <FileChangeIcon change="added" /> : null}
|
||||
{file.isDeleted ? <FileChangeIcon change="deleted" /> : null}
|
||||
</View>
|
||||
<View style={styles.right}>
|
||||
<DiffStat
|
||||
additions={file.additions}
|
||||
deletions={file.deletions}
|
||||
testID={testID ? `${testID}-stat` : undefined}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
let trigger: ReactElement;
|
||||
if (interactive) {
|
||||
trigger = (
|
||||
<ContextMenuTrigger
|
||||
testID={testID ? `${testID}-toggle` : undefined}
|
||||
style={pressableStyle}
|
||||
cancelable={false}
|
||||
onPressIn={interaction.onPressIn}
|
||||
onPressOut={interaction.onPressOut}
|
||||
onLongPress={interaction.onLongPress}
|
||||
onContextMenu={interaction.select}
|
||||
onPress={interaction.activate}
|
||||
accessibilityState={accessibilityState}
|
||||
aria-expanded={expandedAriaValue(showsBodyState, bodyVisible)}
|
||||
aria-selected={isSelected}
|
||||
>
|
||||
{content}
|
||||
</ContextMenuTrigger>
|
||||
);
|
||||
} else {
|
||||
trigger = <View style={pressableStyle({ hovered: false, pressed: false })}>{content}</View>;
|
||||
}
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
showsBodyState && styles.documentContainer,
|
||||
bodyVisible && styles.expanded,
|
||||
]}
|
||||
onLayout={interaction.onLayout}
|
||||
testID={testID}
|
||||
>
|
||||
<TreeIndentGuides depth={depth} />
|
||||
<ContextMenu>
|
||||
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" offset={6} maxWidth={520}>
|
||||
<Text style={styles.tooltip}>{file.path}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{interactive ? (
|
||||
<FileHeaderMenu file={file} testID={testID} {...actions} bodyVisible={bodyVisible} />
|
||||
) : null}
|
||||
</ContextMenu>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: { overflow: "hidden", userSelect: "none" },
|
||||
documentContainer: {
|
||||
height: 44,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderBottomWidth: theme.borderWidth[1],
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
expanded: { backgroundColor: theme.colors.surface1 },
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingLeft: theme.spacing[3],
|
||||
paddingRight: WORKSPACE_FILE_ROW_TRAILING_PADDING,
|
||||
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||
gap: theme.spacing[1],
|
||||
minWidth: 0,
|
||||
zIndex: 2,
|
||||
elevation: 2,
|
||||
userSelect: "none",
|
||||
},
|
||||
documentHeader: { height: 43, paddingHorizontal: 0, paddingVertical: 0 },
|
||||
content: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
userSelect: "none",
|
||||
},
|
||||
documentContent: { height: 43, paddingHorizontal: theme.spacing[3] },
|
||||
active: { backgroundColor: theme.colors.surfaceSidebarHover },
|
||||
left: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
userSelect: "none",
|
||||
},
|
||||
leftTree: { gap: WORKSPACE_TREE_ICON_LABEL_GAP },
|
||||
right: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
flexShrink: 0,
|
||||
userSelect: "none",
|
||||
},
|
||||
icon: {
|
||||
width: WORKSPACE_TREE_ICON_SIZE,
|
||||
height: WORKSPACE_TREE_ICON_SIZE,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
name: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foreground,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
userSelect: "none",
|
||||
},
|
||||
directory: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
userSelect: "none",
|
||||
},
|
||||
directorySpacer: { flex: 1, minWidth: 0 },
|
||||
tooltip: { color: theme.colors.popoverForeground, fontSize: theme.fontSize.sm },
|
||||
}));
|
||||
Reference in New Issue
Block a user