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 = {}) { 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): 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, 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() {}, }; }