refactor(app): implement canvas-based diff rendering
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user