236 lines
7.6 KiB
TypeScript
236 lines
7.6 KiB
TypeScript
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() {},
|
|
};
|
|
}
|