refactor(app): implement canvas-based diff rendering

This commit is contained in:
Mohamed Boudra
2026-08-17 21:14:37 +02:00
parent 75adf53893
commit d03cf14703
66 changed files with 6147 additions and 3210 deletions
+88 -51
View File
@@ -11302,8 +11302,7 @@
"optional": true,
"os": [
"android"
],
"peer": true
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.59.0",
@@ -11316,8 +11315,7 @@
"optional": true,
"os": [
"android"
],
"peer": true
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.59.0",
@@ -11330,8 +11328,7 @@
"optional": true,
"os": [
"darwin"
],
"peer": true
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.59.0",
@@ -11344,8 +11341,7 @@
"optional": true,
"os": [
"darwin"
],
"peer": true
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.59.0",
@@ -11358,8 +11354,7 @@
"optional": true,
"os": [
"freebsd"
],
"peer": true
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.59.0",
@@ -11372,8 +11367,7 @@
"optional": true,
"os": [
"freebsd"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.59.0",
@@ -11386,8 +11380,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.59.0",
@@ -11400,8 +11393,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.59.0",
@@ -11414,8 +11406,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.59.0",
@@ -11428,8 +11419,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.59.0",
@@ -11442,8 +11432,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.59.0",
@@ -11456,8 +11445,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.59.0",
@@ -11470,8 +11458,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.59.0",
@@ -11484,8 +11471,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.59.0",
@@ -11498,8 +11484,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.59.0",
@@ -11512,8 +11497,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.59.0",
@@ -11526,8 +11510,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.59.0",
@@ -11540,8 +11523,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.59.0",
@@ -11554,8 +11536,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.59.0",
@@ -11568,8 +11549,7 @@
"optional": true,
"os": [
"openbsd"
],
"peer": true
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.59.0",
@@ -11582,8 +11562,7 @@
"optional": true,
"os": [
"openharmony"
],
"peer": true
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.59.0",
@@ -11596,8 +11575,7 @@
"optional": true,
"os": [
"win32"
],
"peer": true
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.59.0",
@@ -11610,8 +11588,7 @@
"optional": true,
"os": [
"win32"
],
"peer": true
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.59.0",
@@ -11624,8 +11601,7 @@
"optional": true,
"os": [
"win32"
],
"peer": true
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.59.0",
@@ -11638,8 +11614,7 @@
"optional": true,
"os": [
"win32"
],
"peer": true
]
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
@@ -11768,6 +11743,32 @@
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
"license": "MIT"
},
"node_modules/@shopify/react-native-skia": {
"version": "2.2.12",
"resolved": "https://registry.npmjs.org/@shopify/react-native-skia/-/react-native-skia-2.2.12.tgz",
"integrity": "sha512-P5wZSMPTp00hM0do+awNFtb5aPh5hSpodMGwy7NaxK90AV+SmUu7wZe6NGevzQIwgFa89Epn6xK3j4jKWdQi+A==",
"license": "MIT",
"dependencies": {
"canvaskit-wasm": "0.40.0",
"react-reconciler": "0.31.0"
},
"bin": {
"setup-skia-web": "scripts/setup-canvaskit.js"
},
"peerDependencies": {
"react": ">=19.0",
"react-native": ">=0.78",
"react-native-reanimated": ">=3.19.1"
},
"peerDependenciesMeta": {
"react-native": {
"optional": true
},
"react-native-reanimated": {
"optional": true
}
}
},
"node_modules/@sideway/address": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
@@ -14223,6 +14224,12 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@webgpu/types": {
"version": "0.1.21",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.21.tgz",
"integrity": "sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow==",
"license": "BSD-3-Clause"
},
"node_modules/@xmldom/xmldom": {
"version": "0.7.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.13.tgz",
@@ -16151,6 +16158,15 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvaskit-wasm": {
"version": "0.40.0",
"resolved": "https://registry.npmjs.org/canvaskit-wasm/-/canvaskit-wasm-0.40.0.tgz",
"integrity": "sha512-Od2o+ZmoEw9PBdN/yCGvzfu0WVqlufBPEWNG452wY7E9aT8RBE+ChpZF526doOlg7zumO4iCS+RAeht4P0Gbpw==",
"license": "BSD-3-Clause",
"dependencies": {
"@webgpu/types": "0.1.21"
}
},
"node_modules/cardinal": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/cardinal/-/cardinal-2.1.1.tgz",
@@ -19192,7 +19208,6 @@
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -31002,6 +31017,27 @@
"async-limiter": "~1.0.0"
}
},
"node_modules/react-reconciler": {
"version": "0.31.0",
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz",
"integrity": "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.25.0"
},
"engines": {
"node": ">=0.10.0"
},
"peerDependencies": {
"react": "^19.0.0"
}
},
"node_modules/react-reconciler/node_modules/scheduler": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==",
"license": "MIT"
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -36223,6 +36259,7 @@
"@react-native/normalize-colors": "^0.81.5",
"@react-navigation/native": "^7.1.8",
"@replit/codemirror-vim": "6.3.0",
"@shopify/react-native-skia": "2.2.12",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21",
"@xterm/addon-clipboard": "^0.3.0-beta.213",
+579 -90
View File
@@ -1,7 +1,7 @@
import { execFileSync } from "node:child_process";
import { readFile, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { type Page } from "@playwright/test";
import { type Locator, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes";
import { test, expect } from "../support/fixtures";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
@@ -293,6 +293,27 @@ test("changes file actions open below the right-click without a reserved kebab",
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
});
test("canvas file headers select without toggling for context menu and long press", async ({
page,
}) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const firstFile = page.getByTestId("diff-file-0-toggle");
const deletedFile = page.getByTestId("diff-file-1-toggle");
await deletedFile.click({ button: "right" });
await expect(deletedFile).toHaveAttribute("aria-selected", "true");
await expect(deletedFile).toHaveAttribute("aria-expanded", "true");
await expect(page.getByText("Copy path")).toBeVisible();
await page.keyboard.press("Escape");
await longPressFileHeader(page, firstFile);
await expect(firstFile).toHaveAttribute("aria-selected", "true");
await expect(deletedFile).toHaveAttribute("aria-selected", "false");
await expect(firstFile).toHaveAttribute("aria-expanded", "true");
});
test("changes context menus duplicate files and folders", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
@@ -487,6 +508,10 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
await expect(page.getByTestId("changes-primary-cta")).toContainText("Commit");
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).not.toBeVisible();
await visiblePanel.getByRole("button", { name: "Collapse all files" }).click();
await expect(visiblePanel.getByRole("button", { name: "Expand all files" })).toBeVisible();
await visiblePanel.getByRole("button", { name: "Expand all files" }).click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
const workingDiffLayoutToggle = visiblePanel.getByRole("button", {
name: "Switch to side-by-side diff",
@@ -502,7 +527,7 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
await visiblePanel.getByRole("button", { name: "Diff options" }).click();
await expect(page.getByText("Scroll long lines", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: /all files/i })).toHaveCount(0);
await expect(visiblePanel.getByRole("button", { name: "Collapse all files" })).toBeVisible();
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
@@ -611,14 +636,230 @@ test("changes diff applies code size changes to gutter and code typography", asy
await useCodeFont(page, 12);
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const before = await readDiffTypographyGeometry(page);
await changeCodeFontSizeFromSettings(page, 18);
await changeCodeTypographyFromSettings(page, {
fontSize: 18,
fontFamily: "Courier New, Courier, monospace",
});
await returnToWorkspaceChanges(page);
await expectStoredCodeFontSize(page, 18);
await scrollToLowerUnwrappedDiffRows(page);
await expectDiffCodeFontSize(page, 18);
await expectDiffCodeFontFamily(page, "Courier");
await expectVisibleDiffRowsShareTypography(page);
const after = await readDiffTypographyGeometry(page);
expect(after.horizontalExtent).toBeGreaterThan(before.horizontalExtent);
expect(after.canvasPixels).not.toEqual(before.canvasPixels);
});
test("canvas diff does not commit geometry before configured fonts are ready", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await holdBrowserFontLoads(page);
await useUnwrappedDiffLines(page);
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page);
await openChangesPanel(page);
await expect(page.getByTestId("git-diff-canvas")).toBeVisible();
await expect(page.getByTestId("diff-file-0-body")).toHaveCount(0);
await releaseBrowserFontLoads(page);
await expectExpandedMountedTabDiff(page);
});
test("canvas diff creates, edits, and deletes an inline review without DOM code rows", async ({
page,
}) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await startReviewOnFirstChangedLine(page);
await cancelInlineReview(page);
await startReviewOnFirstChangedLine(page);
await saveInlineReview(page, "Please keep this branch explicit");
await editInlineReview(page, "Please keep this branch named explicitly");
await deleteInlineReview(page);
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
});
test("split canvas creates a review on the changed side and keeps it in that column", async ({
page,
}) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await configureDiffPresentation(page, { layout: "split", wrapLines: false });
await openWorkspaceChanges(page, workspace);
await startReviewOnFirstChangedLine(page, "right");
const [editor, body] = await Promise.all([
page.getByTestId("inline-review-editor").boundingBox(),
page.getByTestId("diff-file-0-body").boundingBox(),
]);
expect(editor).not.toBeNull();
expect(body).not.toBeNull();
expect(editor!.x).toBeGreaterThanOrEqual(body!.x + body!.width / 2);
await cancelInlineReview(page);
});
test("scrolling clears the hovered review affordance", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await hoverFirstChangedGutter(page);
await expect(page.getByRole("button", { name: "Add review comment" })).toBeVisible();
await page.getByTestId("git-diff-scroll").evaluate((element) => {
element.scrollTop += 80;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
});
await expect(page.getByRole("button", { name: "Add review comment" })).toHaveCount(0);
});
test("canvas diff copies a dragged character selection without opening a review", async ({
context,
page,
}) => {
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await useUnwrappedDiffLines(page);
await openSelectionWorkspaceChanges(page, workspace);
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
await page.keyboard.press("ControlOrMeta+C");
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("CDEFGH");
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
});
test("canvas diff copies exact multiline text forwards and backwards", async ({
context,
page,
}) => {
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDE\nFGHIJ");
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await useUnwrappedDiffLines(page);
await openSelectionWorkspaceChanges(page, workspace);
for (const [start, end] of [
[
{ line: 0, offset: 2 },
{ line: 1, offset: 3 },
],
[
{ line: 1, offset: 3 },
{ line: 0, offset: 2 },
],
] as const) {
await dragAddedTextRange(page, { lines: ["ABCDE", "FGHIJ"], start, end });
await page.keyboard.press("ControlOrMeta+C");
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("CDE\nFGH");
}
});
test("canvas diff copies only the selected split side", async ({ context, page }) => {
const lines = ["RIGHT-ONE", "RIGHT-TWO"];
const workspace = await createWorkspaceWithExactSelectionDiff(lines.join("\n"));
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await configureDiffPresentation(page, { layout: "split", wrapLines: false });
await openSelectionWorkspaceChanges(page, workspace);
const before = await readSelectionPaintSamples(page, "right");
await dragAddedTextRange(page, {
lines,
side: "right",
start: { line: 0, offset: 1 },
end: { line: 1, offset: 5 },
});
await page.keyboard.press("ControlOrMeta+C");
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe("IGHT-ONE\nRIGHT");
const after = await readSelectionPaintSamples(page, "right");
expect(after.gutter).toEqual(before.gutter);
expect(after.opposite).toEqual(before.opposite);
expect(after.code).not.toEqual(before.code);
});
test("canvas diff copies exact wrapped fragments", async ({ context, page }) => {
const content = "abcdefghijklmnopqrstuvwxyz".repeat(8);
const workspace = await createWorkspaceWithExactSelectionDiff(content);
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await configureDiffPresentation(page, { layout: "unified", wrapLines: true });
await openSelectionWorkspaceChanges(page, workspace);
await dragAddedTextRange(page, {
lines: [content],
wrapped: true,
start: { line: 0, offset: 35 },
end: { line: 0, offset: 95 },
});
await page.keyboard.press("ControlOrMeta+C");
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe(content.slice(35, 95));
});
test("horizontally scrolled selection copies exactly and does not paint the gutter", async ({
context,
page,
}) => {
const content = "0123456789".repeat(50);
const workspace = await createWorkspaceWithExactSelectionDiff(content);
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await useUnwrappedDiffLines(page);
await openSelectionWorkspaceChanges(page, workspace);
const horizontalOffset = await horizontallyScrollFirstFile(page, 320);
const before = await readSelectionPaintSamples(page);
await dragAddedTextRange(page, {
lines: [content],
horizontalOffset,
start: { line: 0, offset: 48 },
end: { line: 0, offset: 58 },
});
await page.keyboard.press("ControlOrMeta+C");
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe(content.slice(48, 58));
const after = await readSelectionPaintSamples(page);
expect(after.gutter).toEqual(before.gutter);
expect(after.code).not.toEqual(before.code);
});
test("dragging within one wide grapheme or outside its cell never opens a review", async ({
page,
}) => {
const workspace = await createWorkspaceWithExactSelectionDiff("👨‍👩‍👧‍👦tail");
await useCodeFont(page, 40);
await useUnwrappedDiffLines(page);
await openSelectionWorkspaceChanges(page, workspace);
await dragWithinFirstAddedGrapheme(page);
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
await dragFirstAddedLineIntoHeader(page);
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
const body = await page.getByTestId("diff-file-0-body").boundingBox();
if (!body) throw new Error("Expanded diff body has no bounds");
await page.mouse.click(body.x + 60, body.y + 90, { button: "right" });
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
});
test("collapsing and expanding restores the real horizontal scroll offset", async ({ page }) => {
const workspace = await createWorkspaceWithExactSelectionDiff("x".repeat(400));
await useUnwrappedDiffLines(page);
await openSelectionWorkspaceChanges(page, workspace);
const retainedOffset = await horizontallyScrollFirstFile(page, 320);
await page.getByTestId("diff-file-0-toggle").click();
await expect(page.getByTestId("diff-file-0-horizontal-scroll")).toHaveCount(0);
await page.getByTestId("diff-file-0-toggle").click();
await expect
.poll(() =>
page.getByTestId("diff-file-0-horizontal-scroll").evaluate((element) => element.scrollLeft),
)
.toBe(retainedOffset);
});
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
@@ -647,22 +888,60 @@ async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
}
async function useUnwrappedDiffLines(page: Page): Promise<void> {
await configureDiffPresentation(page, { layout: "unified", wrapLines: false });
}
async function configureDiffPresentation(
page: Page,
requestedPresentation: { layout: "unified" | "split"; wrapLines: boolean },
): Promise<void> {
await page.addInitScript(
({ preferencesKey }) => {
({ preferencesKey, presentation }) => {
localStorage.setItem(
preferencesKey,
JSON.stringify({
layout: "unified",
layout: presentation.layout,
desktopTreeVisible: false,
wrapLines: false,
wrapLines: presentation.wrapLines,
hideWhitespace: false,
}),
);
},
{ preferencesKey: CHANGES_PREFERENCES_KEY },
{ preferencesKey: CHANGES_PREFERENCES_KEY, presentation: requestedPresentation },
);
}
async function holdBrowserFontLoads(page: Page): Promise<void> {
await page.addInitScript(() => {
const fontSet = document.fonts;
const originalLoad = fontSet.load.bind(fontSet);
const pending: Array<() => void> = [];
Object.defineProperty(fontSet, "load", {
configurable: true,
value(font: string, text?: string) {
return new Promise<FontFace[]>((resolve, reject) => {
pending.push(() => {
originalLoad(font, text).then(resolve, reject);
});
});
},
});
Object.assign(window, {
__releasePaseoDiffFontLoads() {
for (const release of pending.splice(0)) release();
},
});
});
}
async function releaseBrowserFontLoads(page: Page): Promise<void> {
await page.evaluate(() => {
(
window as typeof window & { __releasePaseoDiffFontLoads: () => void }
).__releasePaseoDiffFontLoads();
});
}
async function expectFlatFileList(page: Page): Promise<void> {
await expect(page.locator('[data-testid^="diff-folder-"]')).toHaveCount(0);
await expect(page.getByTestId("diff-file-0")).toContainText("use-mounted-tab-set.ts");
@@ -670,79 +949,27 @@ async function expectFlatFileList(page: Page): Promise<void> {
}
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
const visibleCodeText = page
.getByTestId(/^diff-code-text-/)
.filter({ visible: true })
.first();
const canvas = page.getByTestId("git-diff-canvas");
await expect
.poll(async () => {
return visibleCodeText.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
return canvas.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize));
})
.toBe(fontSize);
}
async function expectVisibleDiffRowsShareTypography(page: Page): Promise<void> {
const geometry = await readVisibleDiffRowGeometry(page);
expect(geometry.mismatchedTypography, JSON.stringify(geometry, null, 2)).toEqual([]);
async function expectDiffCodeFontFamily(page: Page, fontFamily: string): Promise<void> {
await expect
.poll(() =>
page
.getByTestId("git-diff-canvas")
.evaluate((element) => getComputedStyle(element).fontFamily),
)
.toContain(fontFamily);
}
async function readVisibleDiffRowGeometry(page: Page): Promise<{
mismatchedTypography: { index: number; gutterLineHeight: number; codeLineHeight: number }[];
rows: {
index: number;
gutterTop: number;
codeTop: number;
gutterLineHeight: number;
codeLineHeight: number;
}[];
}> {
return page.locator("body").evaluate(({ ownerDocument }) => {
const root = ownerDocument.querySelector('[data-testid="diff-file-0-body"]');
if (!root) {
throw new Error("Expanded diff body is not mounted");
}
const readRows = (prefix: string, textPrefix: string) =>
Array.from(root.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map((row) => {
const testId = row.getAttribute("data-testid") ?? "";
const index = Number(testId.slice(prefix.length));
const rect = row.getBoundingClientRect();
const text = root.querySelector<HTMLElement>(`[data-testid="${textPrefix}${index}"]`);
const lineHeight = text ? Number.parseFloat(getComputedStyle(text).lineHeight) : 0;
return { index, top: rect.top, height: rect.height, lineHeight };
});
const gutters = new Map(
readRows("diff-gutter-row-", "diff-gutter-text-").map((row) => [row.index, row]),
);
const codes = readRows("diff-code-row-", "diff-code-text-");
const rows = codes
.map((code) => {
const gutter = gutters.get(code.index);
if (!gutter) {
throw new Error(`Missing gutter row ${code.index}`);
}
return {
index: code.index,
gutterTop: gutter.top,
codeTop: code.top,
gutterLineHeight: gutter.lineHeight,
codeLineHeight: code.lineHeight,
};
})
.filter((row) => row.gutterTop >= 0 && row.codeTop >= 0);
return {
mismatchedTypography: rows
.filter((row) => Math.abs(row.gutterLineHeight - row.codeLineHeight) > 0.5)
.map((row) => ({
index: row.index,
gutterLineHeight: row.gutterLineHeight,
codeLineHeight: row.codeLineHeight,
})),
rows,
};
});
async function expectVisibleDiffRowsShareTypography(page: Page): Promise<void> {
await expect(page.getByTestId("git-diff-canvas")).toBeVisible();
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
}
async function createWorkspaceWithMountedTabDiff(
@@ -798,12 +1025,37 @@ async function createWorkspaceWithMountedTabDiff(
return { id: createdWorkspace.workspace.id, repoPath: repo.path };
}
async function createWorkspaceWithExactSelectionDiff(content: string): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("changes-canvas-selection-", {
files: [{ path: "src/selection.ts", content: "" }],
});
await writeFile(path.join(repo.path, "src/selection.ts"), `${content}\n`);
const client = await connectSeedClient();
cleanupTasks.push({
run: async () => {
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
},
});
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
if (!created.workspace) throw new Error(created.error ?? "Failed to create selection workspace");
return { id: created.workspace.id, repoPath: repo.path };
}
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page);
await openChangesInVisibleExplorer(page);
await page.getByTestId("diff-file-0").click();
await expectExpandedMountedTabDiff(page);
}
async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page);
await openChangesPanel(page);
await expect(page.getByText("selection.ts")).toBeVisible({ timeout: 30_000 });
await expectExpandedMountedTabDiff(page);
}
@@ -814,19 +1066,23 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
}
async function changeCodeFontSizeFromSettings(page: Page, codeFontSize: number): Promise<void> {
async function changeCodeTypographyFromSettings(
page: Page,
typography: { fontSize: number; fontFamily: string },
): Promise<void> {
await page.getByTestId("sidebar-settings").click();
await expect(page).toHaveURL(new RegExp(`${buildSettingsSectionRoute("general")}|/settings$`));
await page.getByRole("button", { name: "Appearance" }).click();
await page.getByLabel("Code font size").fill(String(codeFontSize));
await page.getByLabel("Code font family").fill(typography.fontFamily);
await page.getByLabel("Code font family").press("Enter");
await page.getByLabel("Code font size").fill(String(typography.fontSize));
await page.getByLabel("Code font size").press("Enter");
await expect(page.getByLabel("Code font size")).toHaveValue(String(codeFontSize));
await expectStoredCodeFontSize(page, codeFontSize);
await expect(page.getByLabel("Code font family")).toHaveValue(typography.fontFamily);
await expect(page.getByLabel("Code font size")).toHaveValue(String(typography.fontSize));
await expectStoredCodeFontSize(page, typography.fontSize);
}
async function expectStoredCodeFontSize(page: Page, codeFontSize: number): Promise<void> {
@@ -844,6 +1100,245 @@ async function expectStoredCodeFontSize(page: Page, codeFontSize: number): Promi
.toBe(codeFontSize);
}
async function startReviewOnFirstChangedLine(
page: Page,
side: "unified" | "right" = "unified",
): Promise<void> {
const body = page.getByTestId("diff-file-0-body");
const canvas = page.getByTestId("git-diff-canvas");
const [bodyBounds, fontSize] = await Promise.all([
body.boundingBox(),
canvas.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)),
]);
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
const lineHeight = Math.round(fontSize * 1.5);
const columnLeft = side === "right" ? bodyBounds.x + bodyBounds.width / 2 : bodyBounds.x;
await page.mouse.click(columnLeft + 20, bodyBounds.y + lineHeight * 1.5);
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
}
async function hoverFirstChangedGutter(page: Page): Promise<void> {
const body = page.getByTestId("diff-file-0-body");
const canvas = page.getByTestId("git-diff-canvas");
const [bodyBounds, fontSize] = await Promise.all([
body.boundingBox(),
canvas.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)),
]);
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
const lineHeight = Math.round(fontSize * 1.5);
const gutterWidth = 2 * Math.ceil(fontSize * 0.62) + 12;
await page.mouse.move(bodyBounds.x + gutterWidth, bodyBounds.y + lineHeight * 1.5);
}
async function saveInlineReview(page: Page, body: string): Promise<void> {
await page.getByTestId("inline-review-editor-input").fill(body);
await page.getByTestId("inline-review-editor-save").click();
await expect(page.getByText(body, { exact: true })).toBeVisible();
}
async function cancelInlineReview(page: Page): Promise<void> {
await page.getByTestId("inline-review-editor-cancel").click();
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
}
async function editInlineReview(page: Page, body: string): Promise<void> {
await page.getByTestId(/^review-comment-edit-/).click();
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
await page.getByTestId("inline-review-editor-input").fill(body);
await page.getByTestId("inline-review-editor-save").click();
await expect(page.getByText(body, { exact: true })).toBeVisible();
}
async function deleteInlineReview(page: Page): Promise<void> {
await page.getByTestId(/^review-comment-delete-/).click();
await expect(page.getByTestId(/^review-comment-delete-/)).toHaveCount(0);
}
async function dragExactAddedText(
page: Page,
offsets: { startOffset: number; endOffset: number },
): Promise<void> {
const body = page.getByTestId("diff-file-0-body");
const canvas = page.getByTestId("git-diff-canvas");
const [bodyBounds, metrics] = await Promise.all([
body.boundingBox(),
canvas.evaluate((element) => {
const style = getComputedStyle(element);
const fontSize = Number.parseFloat(style.fontSize);
const measurementCanvas = document.createElement("canvas");
const context = measurementCanvas.getContext("2d")!;
context.font = `${fontSize}px ${style.fontFamily}`;
return { fontSize, characterWidth: context.measureText("A").width };
}),
]);
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
const lineHeight = Math.round(metrics.fontSize * 1.5);
const gutterWidth = Math.max(2, String(1).length) * Math.ceil(metrics.fontSize * 0.62) + 12;
const textLeft = bodyBounds.x + gutterWidth + 8;
await page.mouse.move(
textLeft + offsets.startOffset * metrics.characterWidth + 1,
bodyBounds.y + lineHeight * 1.5,
);
await page.mouse.down();
await page.mouse.move(
textLeft + offsets.endOffset * metrics.characterWidth - 1,
bodyBounds.y + lineHeight * 1.5,
{ steps: 8 },
);
await page.mouse.up();
}
async function dragAddedTextRange(
page: Page,
input: {
lines: string[];
start: { line: number; offset: number };
end: { line: number; offset: number };
side?: "left" | "right";
wrapped?: boolean;
horizontalOffset?: number;
},
): Promise<void> {
const body = page.getByTestId("diff-file-0-body");
const canvas = page.getByTestId("git-diff-canvas");
const [bounds, metrics] = await Promise.all([
body.boundingBox(),
canvas.evaluate((element) => {
const style = getComputedStyle(element);
const fontSize = Number.parseFloat(style.fontSize);
const context = document.createElement("canvas").getContext("2d")!;
context.font = `${fontSize}px ${style.fontFamily}`;
return { fontSize, characterWidth: context.measureText("A").width };
}),
]);
if (!bounds) throw new Error("Expanded diff body has no bounds");
const lineHeight = Math.round(metrics.fontSize * 1.5);
const gutterWidth = 2 * Math.ceil(metrics.fontSize * 0.62) + 12;
const columnWidth = input.side ? bounds.width / 2 : bounds.width;
const columnLeft = input.side === "right" ? bounds.x + columnWidth : bounds.x;
const availableWidth = columnWidth - gutterWidth - 16;
const charactersPerFragment = Math.max(1, Math.floor(availableWidth / metrics.characterWidth));
const fragmentsBefore = (line: number) =>
input.wrapped
? input.lines
.slice(0, line)
.reduce(
(total, text) => total + Math.max(1, Math.ceil(text.length / charactersPerFragment)),
0,
)
: line;
const point = ({ line, offset }: { line: number; offset: number }) => {
const fragment = input.wrapped ? Math.floor(offset / charactersPerFragment) : 0;
const localOffset = input.wrapped ? offset % charactersPerFragment : offset;
return {
x:
columnLeft +
gutterWidth +
8 +
localOffset * metrics.characterWidth -
(input.horizontalOffset ?? 0),
y: bounds.y + (1 + fragmentsBefore(line) + fragment + 0.5) * lineHeight,
};
};
const start = point(input.start);
const end = point(input.end);
await page.mouse.move(start.x, start.y);
await page.mouse.down();
await page.mouse.move(end.x, end.y, { steps: 12 });
await page.mouse.up();
}
async function readSelectionPaintSamples(
page: Page,
side: "unified" | "right" = "unified",
): Promise<{ gutter: number[]; code: number[]; opposite: number[] }> {
return page.getByTestId("diff-file-0-body").evaluate((body, selectedSide) => {
const canvas = document.querySelector<HTMLCanvasElement>('[data-testid="git-diff-canvas"]')!;
const bodyBounds = body.getBoundingClientRect();
const canvasBounds = canvas.getBoundingClientRect();
const scaleX = canvas.width / canvasBounds.width;
const scaleY = canvas.height / canvasBounds.height;
const context = canvas.getContext("2d")!;
const sample = (left: number, top: number, width: number, height: number) =>
Array.from(
context.getImageData(
Math.round((left - canvasBounds.left) * scaleX),
Math.round((top - canvasBounds.top) * scaleY),
Math.max(1, Math.round(width * scaleX)),
Math.max(1, Math.round(height * scaleY)),
).data,
);
const columnLeft =
selectedSide === "right" ? bodyBounds.left + bodyBounds.width / 2 : bodyBounds.left;
return {
gutter: sample(columnLeft + 2, bodyBounds.top + 24, 8, 8),
code: sample(columnLeft + 80, bodyBounds.top + 24, 80, 10),
opposite: sample(bodyBounds.left + 80, bodyBounds.top + 24, 80, 10),
};
}, side);
}
async function dragWithinFirstAddedGrapheme(page: Page): Promise<void> {
const bodyBounds = await page.getByTestId("diff-file-0-body").boundingBox();
const fontSize = await page
.getByTestId("git-diff-canvas")
.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize));
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
const lineHeight = Math.round(fontSize * 1.5);
const gutterWidth = 2 * Math.ceil(fontSize * 0.62) + 12;
const x = bodyBounds.x + gutterWidth + 10;
const y = bodyBounds.y + lineHeight * 1.5;
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x + 5, y, { steps: 3 });
await page.mouse.up();
}
async function dragFirstAddedLineIntoHeader(page: Page): Promise<void> {
const bodyBounds = await page.getByTestId("diff-file-0-body").boundingBox();
const fontSize = await page
.getByTestId("git-diff-canvas")
.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize));
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
const lineHeight = Math.round(fontSize * 1.5);
const x = bodyBounds.x + 60;
await page.mouse.move(x, bodyBounds.y + lineHeight * 1.5);
await page.mouse.down();
await page.mouse.move(x, bodyBounds.y - 10, { steps: 4 });
await page.mouse.up();
}
async function horizontallyScrollFirstFile(page: Page, requestedOffset: number): Promise<number> {
const horizontalScroll = page.getByTestId("diff-file-0-horizontal-scroll");
const retainedOffset = await horizontalScroll.evaluate((element, offset) => {
element.scrollLeft = offset;
element.dispatchEvent(new Event("scroll"));
return element.scrollLeft;
}, requestedOffset);
expect(retainedOffset).toBeGreaterThan(0);
return retainedOffset;
}
async function longPressFileHeader(page: Page, header: Locator): Promise<void> {
const bounds = await header.boundingBox();
if (!bounds) throw new Error("File header has no bounds");
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
await page.mouse.down();
await page.waitForTimeout(600);
await page.mouse.up();
}
async function readDiffTypographyGeometry(page: Page): Promise<{
horizontalExtent: number;
canvasPixels: string;
}> {
const horizontalExtent = await page
.getByTestId("diff-file-0-horizontal-scroll")
.evaluate((element) => element.scrollWidth);
const canvasPixels = (await page.getByTestId("git-diff-canvas").screenshot()).toString("base64");
return { horizontalExtent, canvasPixels };
}
async function returnToWorkspaceChanges(page: Page): Promise<void> {
await page.getByTestId("settings-back-to-workspace").click();
await waitForWorkspaceTabsVisible(page);
@@ -852,15 +1347,9 @@ async function returnToWorkspaceChanges(page: Page): Promise<void> {
}
async function scrollToLowerUnwrappedDiffRows(page: Page): Promise<void> {
const lastRowIndex = await page.getByTestId("diff-file-0-body").evaluate((root) => {
const rows = Array.from(root.querySelectorAll<HTMLElement>('[data-testid^="diff-code-row-"]'));
if (rows.length === 0) {
throw new Error("No unwrapped code rows are mounted");
}
return Math.max(
...rows.map((row) => Number((row.getAttribute("data-testid") ?? "").slice(14))),
);
await page.getByTestId("git-diff-scroll").evaluate((element) => {
element.scrollTop = element.scrollHeight - element.clientHeight;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
});
await page.getByTestId(`diff-code-row-${lastRowIndex}`).scrollIntoViewIfNeeded();
await expect(page.getByTestId(`diff-code-row-${lastRowIndex}`)).toBeVisible();
await expect(page.getByTestId("git-diff-canvas")).toBeVisible();
}
@@ -1,6 +1,7 @@
import { execFileSync } from "node:child_process";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import type { Locator } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { openChangesPanel } from "../support/helpers/workspace-tabs";
@@ -51,11 +52,12 @@ test("commit history shows dates and shares diff layout preferences", async ({
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
const layoutToggle = panel.getByTestId("commit-diff-toggle-layout");
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
await expect(panel.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
await expectCommitDiffHeaderGeometry(panel);
await layoutToggle.click();
await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff");
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
await expect(panel.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId(/^workspace-tab-commit_diff_/).hover();
@@ -69,7 +71,7 @@ test("commit history shows dates and shares diff layout preferences", async ({
await page.setViewportSize({ width: 480, height: 900 });
await expect(panel.getByTestId("commit-diff-toolbar")).toHaveCount(0);
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible();
await expect(panel.getByTestId("git-diff-canvas")).toBeVisible();
});
async function createFeatureCommit(repoPath: string): Promise<void> {
@@ -86,3 +88,25 @@ async function createFeatureCommit(repoPath: string): Promise<void> {
},
});
}
async function expectCommitDiffHeaderGeometry(panel: Locator): Promise<void> {
const [header, content, name, stat] = await Promise.all([
panel.getByTestId("diff-file-0").boundingBox(),
panel.getByTestId("diff-file-0-header-content").boundingBox(),
panel.getByTestId("diff-file-0-name").boundingBox(),
panel.getByTestId("diff-file-0-stat").boundingBox(),
]);
expect(header).not.toBeNull();
expect(content).not.toBeNull();
expect(name).not.toBeNull();
expect(stat).not.toBeNull();
expect(header!.height).toBeCloseTo(44, 0);
expect(content!.x).toBeCloseTo(header!.x, 0);
expect(content!.width).toBeCloseTo(header!.width, 0);
expect(name!.x).toBeGreaterThan(header!.x);
expect(name!.x + name!.width).toBeLessThan(stat!.x);
expect(Math.abs(name!.y + name!.height / 2 - (stat!.y + stat!.height / 2))).toBeLessThanOrEqual(
1,
);
expect(stat!.x + stat!.width).toBeLessThan(header!.x + header!.width);
}
@@ -0,0 +1,5 @@
/** Successful Canvas2D POC run at 6× Chromium CPU slowdown on the checked-in fixture. */
export const DIFF_POC_BASELINE = {
medianFrameMs: 25,
p95FrameMs: 50,
} as const;
+466 -128
View File
@@ -1,41 +1,38 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import type { CDPSession } from "@playwright/test";
import type { CDPSession, Locator, Page, TestInfo } from "@playwright/test";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { test, expect } from "../support/fixtures";
import { getServerId } from "../support/helpers/server-id";
import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo } from "../support/helpers/workspace";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
import { DIFF_POC_BASELINE } from "./diff-performance-baseline";
const RUN_DIFF_PERF = process.env.PASEO_DIFF_PERF_E2E === "1";
const diffPerfDescribe = RUN_DIFF_PERF ? test.describe : test.describe.skip;
const LINE_COUNT = 2_400;
const LINE_COUNT_PER_FILE = 1_200;
const FILE_PATHS = ["src/large-a.ts", "src/large-b.ts"] as const;
const CPU_SLOWDOWN = Number(process.env.PASEO_DIFF_PERF_CPU_SLOWDOWN ?? 6);
const MAX_MOUNTED_CODE_ROWS = 220;
const MAX_EXPANSION_MS = 6_000;
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
const POC_MEDIAN_FRAME_MS = Number(
process.env.PASEO_DIFF_POC_MEDIAN_FRAME_MS ?? DIFF_POC_BASELINE.medianFrameMs,
);
const POC_P95_FRAME_MS = Number(
process.env.PASEO_DIFF_POC_P95_FRAME_MS ?? DIFF_POC_BASELINE.p95FrameMs,
);
const MAX_MEDIAN_FRAME_MS = POC_MEDIAN_FRAME_MS * 1.1;
const MAX_P95_FRAME_MS = POC_P95_FRAME_MS * 1.1;
diffPerfDescribe("Diff rendering performance", () => {
test("large unwrapped diffs keep mounting bounded under CPU slowdown", async ({
page,
}, testInfo) => {
test.setTimeout(90_000);
const repo = await createTempGitRepo("diff-perf-", {
files: [{ path: "src/large.ts", content: "export const baseline = 0;\n" }],
});
diffPerfDescribe("Diff canvas performance", () => {
test("one canvas stays filled through continuous fast scrolling", async ({ page }, testInfo) => {
test.setTimeout(180_000);
const repo = await createLargeDiffRepository();
const client = await connectSeedClient();
let cdp: CDPSession | null = null;
let traceStarted = false;
try {
const generatedLines = Array.from(
{ length: LINE_COUNT },
(_, index) => `export const generated_${index} = "${index}-${"wide".repeat(16)}";`,
).join("\n");
await writeFile(
path.join(repo.path, "src/large.ts"),
`export const baseline = 1;\n${generatedLines}\n`,
);
const created = await client.createWorkspace({
source: { kind: "directory", path: repo.path },
});
@@ -43,100 +40,89 @@ diffPerfDescribe("Diff rendering performance", () => {
throw new Error(created.error ?? "Failed to create diff performance workspace");
}
await page.addInitScript(
({ preferencesKey }) => {
localStorage.setItem(
preferencesKey,
JSON.stringify({
layout: "unified",
viewMode: "flat",
wrapLines: false,
hideWhitespace: false,
}),
);
},
{ preferencesKey: CHANGES_PREFERENCES_KEY },
);
await configureUnwrappedUnifiedDiff(page);
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
await waitForWorkspaceTabsVisible(page);
await page.getByRole("button", { name: "Open explorer" }).click();
const fileHeader = page.getByTestId("diff-file-0");
await expect(fileHeader).toContainText("large.ts", { timeout: 30_000 });
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
await expectSingleCanvasSurface(page);
cdp = await page.context().newCDPSession(page);
await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU_SLOWDOWN });
const expansionStart = await page.evaluate(() => performance.now());
await fileHeader.click();
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
const expansionMs = (await page.evaluate(() => performance.now())) - expansionStart;
await startChromiumTrace(cdp);
traceStarted = true;
await startRendererInstrumentation(page);
const scroll = page.getByTestId("git-diff-scroll");
const initial = await readDiffMountReport(page);
expect(initial.mountedCodeRows).toBeLessThanOrEqual(MAX_MOUNTED_CODE_ROWS);
expect(initial.mountedGutterRows).toBe(initial.mountedCodeRows);
const scrollReports = [];
const heapUsage = [];
for (let cycle = 0; cycle < 3; cycle += 1) {
scrollReports.push(await continuouslyScrollCanvas(page));
heapUsage.push(await collectHeapUsage(cdp));
}
await expectContinuousFramesMatchStableRender(
page,
scrollReports.flatMap((report) => report.frameSignatures),
);
const frameImages = await captureCanvasAtScrollIntervals(page);
await expectFramesMatchStableRender(page, frameImages);
for (const [frameIndex, frame] of frameImages.entries()) {
await writeFile(testInfo.outputPath(`diff-canvas-frame-${frameIndex}.png`), frame.image);
await testInfo.attach(`diff-canvas-frame-${frameIndex}`, {
body: frame.image,
contentType: "image/png",
});
expect(frame.image.byteLength).toBeGreaterThan(4_000);
}
await page.getByTestId("diff-gutter-row-1").hover();
await page.getByTestId("diff-gutter-action-1").click();
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
const withEditor = await readDiffMountReport(page);
expect(withEditor.mountedCodeRows).toBeLessThanOrEqual(MAX_MOUNTED_CODE_ROWS);
expect(withEditor.mountedGutterRows).toBe(withEditor.mountedCodeRows);
expect(await readDiffRowAlignment(page, 2)).toBeCloseTo(0, 0);
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
await page.getByTestId("inline-review-editor-cancel").click();
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
await expectIndependentHorizontalOffsetsSurviveVerticalScrolling(page);
const renderer = await readRendererInstrumentation(page);
const measuredCycles = scrollReports.slice(1);
expect(Math.max(...measuredCycles.map((cycle) => cycle.medianFrameMs))).toBeLessThanOrEqual(
MAX_MEDIAN_FRAME_MS,
);
expect(Math.max(...measuredCycles.map((cycle) => cycle.p95FrameMs))).toBeLessThanOrEqual(
MAX_P95_FRAME_MS,
);
for (const cycle of scrollReports) {
expect(Math.abs(cycle.maximumScrollTop - cycle.endScrollTop)).toBeLessThanOrEqual(1);
}
expect(heapUsage.at(-1)!.usedSize).toBeLessThanOrEqual(
heapUsage[0]!.usedSize * 1.1 + 1_000_000,
);
expect(renderer.addedElements).toBe(0);
expect(renderer.removedElements).toBe(0);
expect(renderer.reactCommits).toBeLessThanOrEqual(2);
await scroll.evaluate((element) => {
element.scrollTop = element.scrollHeight - element.clientHeight;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
});
await expect
.poll(async () => (await readDiffMountReport(page)).highestMountedRow)
.toBeGreaterThan(LINE_COUNT - 100);
const afterScroll = await readDiffMountReport(page);
expect(afterScroll.mountedCodeRows).toBeLessThanOrEqual(MAX_MOUNTED_CODE_ROWS);
expect(afterScroll.mountedGutterRows).toBe(afterScroll.mountedCodeRows);
await reloadDiffPresentation(page, { layout: "unified", wrapLines: true });
const wrapped = await measureAdditionalPerformanceScenario(page, cdp, testInfo, "wrapped");
await reloadDiffPresentation(page, { layout: "split", wrapLines: false });
const split = await measureAdditionalPerformanceScenario(page, cdp, testInfo, "split");
const report = {
cpuSlowdown: CPU_SLOWDOWN,
sourceLines: LINE_COUNT,
expansionMs: Math.round(expansionMs),
initial,
withEditor,
afterScroll,
pocBaseline: {
medianFrameMs: POC_MEDIAN_FRAME_MS,
p95FrameMs: POC_P95_FRAME_MS,
},
sourceFiles: FILE_PATHS.length,
sourceLines: FILE_PATHS.length * LINE_COUNT_PER_FILE,
scrollReports,
heapUsage,
renderer,
wrapped,
split,
canvasPngBytes: frameImages.map((frame) => frame.image.byteLength),
};
await testInfo.attach("diff-performance", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
console.log(`[perf] Diff rendering: ${JSON.stringify(report)}`);
expect(expansionMs).toBeLessThan(MAX_EXPANSION_MS);
await attachPerformanceReport(testInfo, report);
await stopChromiumTrace(cdp, testInfo);
traceStarted = false;
await cdp.send("Emulation.setCPUThrottlingRate", { rate: 1 });
await scroll.evaluate((element) => {
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
});
await page.getByTestId("changes-toggle-layout").click();
const oldLine = page.getByText("export const baseline = 0;", { exact: true });
const newLine = page.getByText("export const baseline = 1;", { exact: true });
await expect(oldLine).toBeVisible();
await expect(newLine).toBeVisible();
const [bodyBox, oldBox, newBox] = await Promise.all([
page.getByTestId("diff-file-0-body").boundingBox(),
oldLine.boundingBox(),
newLine.boundingBox(),
]);
expect(bodyBox).not.toBeNull();
expect(oldBox).not.toBeNull();
expect(newBox).not.toBeNull();
expect(oldBox!.x).toBeLessThan(newBox!.x);
expect(oldBox!.x).toBeGreaterThanOrEqual(bodyBox!.x);
expect(newBox!.x).toBeGreaterThan(bodyBox!.x + bodyBox!.width / 2);
expect(newBox!.x).toBeLessThan(bodyBox!.x + bodyBox!.width);
} finally {
if (cdp && traceStarted) {
await stopChromiumTrace(cdp, testInfo).catch(() => undefined);
}
await cdp?.send("Emulation.setCPUThrottlingRate", { rate: 1 }).catch(() => undefined);
await cdp?.detach().catch(() => undefined);
await client.close().catch(() => undefined);
@@ -145,40 +131,392 @@ diffPerfDescribe("Diff rendering performance", () => {
});
});
async function readDiffRowAlignment(
page: import("@playwright/test").Page,
rowIndex: number,
): Promise<number> {
const gutterTop = await page
.getByTestId(`diff-gutter-row-${rowIndex}`)
.evaluate((row) => row.getBoundingClientRect().top);
const codeTop = await page
.getByTestId(`diff-code-row-${rowIndex}`)
.evaluate((row) => row.getBoundingClientRect().top);
return gutterTop - codeTop;
async function measureAdditionalPerformanceScenario(
page: Page,
cdp: CDPSession,
testInfo: TestInfo,
name: "wrapped" | "split",
) {
await expectSingleCanvasSurface(page);
await startRendererInstrumentation(page);
const scrollReports = [];
const heapUsage = [];
for (let cycle = 0; cycle < 3; cycle += 1) {
scrollReports.push(await continuouslyScrollCanvas(page));
heapUsage.push(await collectHeapUsage(cdp));
}
await expectContinuousFramesMatchStableRender(
page,
scrollReports.flatMap((report) => report.frameSignatures),
);
const frames = await captureCanvasAtScrollIntervals(page);
await expectFramesMatchStableRender(page, frames);
for (const [index, frame] of frames.entries()) {
await writeFile(testInfo.outputPath(`diff-canvas-${name}-frame-${index}.png`), frame.image);
await testInfo.attach(`diff-canvas-${name}-frame-${index}`, {
body: frame.image,
contentType: "image/png",
});
expect(frame.image.byteLength).toBeGreaterThan(4_000);
}
const measuredCycles = scrollReports.slice(1);
expect(Math.max(...measuredCycles.map((cycle) => cycle.medianFrameMs))).toBeLessThanOrEqual(
MAX_MEDIAN_FRAME_MS,
);
expect(Math.max(...measuredCycles.map((cycle) => cycle.p95FrameMs))).toBeLessThanOrEqual(
MAX_P95_FRAME_MS,
);
for (const cycle of scrollReports) {
expect(Math.abs(cycle.maximumScrollTop - cycle.endScrollTop)).toBeLessThanOrEqual(1);
}
expect(heapUsage.at(-1)!.usedSize).toBeLessThanOrEqual(heapUsage[0]!.usedSize * 1.1 + 1_000_000);
const renderer = await readRendererInstrumentation(page);
expect(renderer.addedElements).toBe(0);
expect(renderer.removedElements).toBe(0);
expect(renderer.reactCommits).toBeLessThanOrEqual(2);
return {
scrollReports,
heapUsage,
renderer,
canvasPngBytes: frames.map((frame) => frame.image.byteLength),
};
}
async function readDiffMountReport(page: import("@playwright/test").Page): Promise<{
mountedCodeRows: number;
mountedGutterRows: number;
highestMountedRow: number;
bodyDomElements: number;
}> {
return page.getByTestId("diff-file-0-body").evaluate((body) => {
const codeRows = Array.from(
body.querySelectorAll<HTMLElement>('[data-testid^="diff-code-row-"]'),
async function reloadDiffPresentation(
page: Page,
requestedPresentation: { layout: "unified" | "split"; wrapLines: boolean },
): Promise<void> {
await page.evaluate(
({ preferencesKey, presentation }) => {
const current = JSON.parse(localStorage.getItem(preferencesKey) ?? "{}") as object;
localStorage.setItem(preferencesKey, JSON.stringify({ ...current, ...presentation }));
},
{ preferencesKey: CHANGES_PREFERENCES_KEY, presentation: requestedPresentation },
);
await page.reload();
await waitForWorkspaceTabsVisible(page);
await openChangesPanel(page);
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
}
async function createLargeDiffRepository() {
const repo = await createTempGitRepo("diff-perf-", {
files: FILE_PATHS.map((filePath, fileIndex) => ({
path: filePath,
content: `export const baseline_${fileIndex} = 0;\n`,
})),
});
for (const [fileIndex, filePath] of FILE_PATHS.entries()) {
const generatedLines = Array.from(
{ length: LINE_COUNT_PER_FILE },
(_, lineIndex) =>
`export const generated_${fileIndex}_${lineIndex} = "${lineIndex}-${"wide".repeat(16)}";`,
).join("\n");
await writeFile(
path.join(repo.path, filePath),
`export const baseline_${fileIndex} = 1;\n${generatedLines}\n`,
);
const gutterRows = body.querySelectorAll('[data-testid^="diff-gutter-row-"]');
}
return repo;
}
async function configureUnwrappedUnifiedDiff(page: Page): Promise<void> {
await page.addInitScript(
({ preferencesKey }) => {
const scope = globalThis as typeof globalThis & {
__PASEO_DIFF_REACT_STATS__?: { commits: number };
};
scope.__PASEO_DIFF_REACT_STATS__ = { commits: 0 };
if (!localStorage.getItem(preferencesKey)) {
localStorage.setItem(
preferencesKey,
JSON.stringify({
layout: "unified",
viewMode: "flat",
wrapLines: false,
hideWhitespace: false,
}),
);
}
},
{ preferencesKey: CHANGES_PREFERENCES_KEY },
);
}
async function expectSingleCanvasSurface(page: Page): Promise<void> {
await expect(page.locator("canvas")).toHaveCount(1);
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
const elementCount = await page
.getByTestId("git-diff-canvas-root")
.evaluate((root) => root.querySelectorAll("*").length);
expect(elementCount).toBeLessThan(150);
}
async function continuouslyScrollCanvas(page: Page): Promise<{
medianFrameMs: number;
p95FrameMs: number;
maximumFrameMs: number;
slowFrames: number;
endScrollTop: number;
maximumScrollTop: number;
frameSignatures: Array<{ scrollTop: number; signature: number }>;
}> {
return page.getByTestId("git-diff-scroll").evaluate(async (element) => {
const scroll = element as HTMLElement;
const maximumScrollTop = scroll.scrollHeight - scroll.clientHeight;
scroll.scrollTop = 0;
const frameDurations: number[] = [];
const frameSignatures: Array<{ scrollTop: number; signature: number }> = [];
let previousFrame = performance.now();
for (let step = 1; step <= 90; step += 1) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const now = performance.now();
frameDurations.push(now - previousFrame);
previousFrame = now;
scroll.scrollTop = (maximumScrollTop * step) / 90;
scroll.dispatchEvent(new Event("scroll", { bubbles: false }));
if (step % 10 === 0) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const canvas = document.querySelector<HTMLCanvasElement>(
'[data-testid="git-diff-canvas"]',
)!;
frameSignatures.push({
scrollTop: scroll.scrollTop,
signature: canvasSignature(canvas),
});
previousFrame = performance.now();
}
}
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const ordered = [...frameDurations].sort((left, right) => left - right);
const percentile = (fraction: number) =>
ordered[Math.min(ordered.length - 1, Math.floor(ordered.length * fraction))] ?? 0;
return {
mountedCodeRows: codeRows.length,
mountedGutterRows: gutterRows.length,
highestMountedRow: Math.max(
-1,
...codeRows.map((row) =>
Number((row.getAttribute("data-testid") ?? "").slice("diff-code-row-".length)),
),
),
bodyDomElements: body.querySelectorAll("*").length,
medianFrameMs: percentile(0.5),
p95FrameMs: percentile(0.95),
maximumFrameMs: Math.max(...frameDurations),
slowFrames: frameDurations.filter((duration) => duration > 50).length,
endScrollTop: scroll.scrollTop,
maximumScrollTop,
frameSignatures,
};
function canvasSignature(canvas: HTMLCanvasElement): number {
const sample = document.createElement("canvas");
sample.width = 64;
sample.height = 36;
const context = sample.getContext("2d")!;
context.drawImage(canvas, 0, 0, sample.width, sample.height);
const pixels = context.getImageData(0, 0, sample.width, sample.height).data;
let hash = 2166136261;
for (const value of pixels) hash = Math.imul(hash ^ value, 16777619);
return hash >>> 0;
}
});
}
interface CapturedCanvasFrame {
scrollTop: number;
image: Buffer;
}
async function captureCanvasAtScrollIntervals(page: Page): Promise<CapturedCanvasFrame[]> {
const scroll = page.getByTestId("git-diff-scroll");
const canvas = page.getByTestId("git-diff-canvas");
const frames: CapturedCanvasFrame[] = [];
for (let step = 0; step <= 8; step += 1) {
const scrollTop = await scroll.evaluate(async (element, progress) => {
const scrollElement = element as HTMLElement;
scrollElement.scrollTop =
(scrollElement.scrollHeight - scrollElement.clientHeight) * Number(progress);
scrollElement.dispatchEvent(new Event("scroll", { bubbles: false }));
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
return scrollElement.scrollTop;
}, step / 8);
frames.push({ scrollTop, image: await canvas.screenshot() });
}
return frames;
}
async function expectFramesMatchStableRender(
page: Page,
frames: CapturedCanvasFrame[],
): Promise<void> {
const scroll = page.getByTestId("git-diff-scroll");
const canvas = page.getByTestId("git-diff-canvas");
for (const frame of frames) {
await scroll.evaluate(async (element, scrollTop) => {
element.scrollTop = scrollTop;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
}, frame.scrollTop);
expect(await canvas.screenshot()).toEqual(frame.image);
}
}
async function expectContinuousFramesMatchStableRender(
page: Page,
samples: Array<{ scrollTop: number; signature: number }>,
): Promise<void> {
const stable = await page.getByTestId("git-diff-scroll").evaluate(async (element, frames) => {
const canvas = document.querySelector<HTMLCanvasElement>('[data-testid="git-diff-canvas"]')!;
const signatures: number[] = [];
for (const frame of frames) {
element.scrollTop = frame.scrollTop;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
const sample = document.createElement("canvas");
sample.width = 64;
sample.height = 36;
const context = sample.getContext("2d")!;
context.drawImage(canvas, 0, 0, sample.width, sample.height);
const pixels = context.getImageData(0, 0, sample.width, sample.height).data;
let hash = 2166136261;
for (const value of pixels) hash = Math.imul(hash ^ value, 16777619);
signatures.push(hash >>> 0);
}
return signatures;
}, samples);
expect(stable).toEqual(samples.map((sample) => sample.signature));
}
async function expectIndependentHorizontalOffsetsSurviveVerticalScrolling(
page: Page,
): Promise<void> {
const first = page.getByTestId("diff-file-0-horizontal-scroll");
const second = page.getByTestId("diff-file-1-horizontal-scroll");
const firstOffset = await setHorizontalOffset(first, 240);
const secondOffset = await setHorizontalOffset(second, 120);
expect(firstOffset).not.toBe(secondOffset);
await page.getByTestId("git-diff-scroll").evaluate(async (element) => {
element.scrollTop = element.scrollHeight - element.clientHeight;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
});
await expect.poll(() => first.evaluate((element) => element.scrollLeft)).toBe(firstOffset);
await expect.poll(() => second.evaluate((element) => element.scrollLeft)).toBe(secondOffset);
}
async function setHorizontalOffset(locator: Locator, offset: number) {
return locator.evaluate(async (element, nextOffset) => {
element.scrollLeft = nextOffset;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
return element.scrollLeft;
}, offset);
}
async function startRendererInstrumentation(page: Page): Promise<void> {
await page.getByTestId("git-diff-canvas-root").evaluate((root) => {
const scope = globalThis as typeof globalThis & {
__PASEO_DIFF_REACT_STATS__?: { commits: number };
__PASEO_DIFF_RENDERER_STATS__?: {
initialCommits: number;
addedElements: number;
removedElements: number;
};
};
const reactStats = scope.__PASEO_DIFF_REACT_STATS__ ?? { commits: 0, unmounts: 0 };
const stats = {
initialCommits: reactStats.commits,
addedElements: 0,
removedElements: 0,
};
scope.__PASEO_DIFF_RENDERER_STATS__ = stats;
new MutationObserver((records) => {
for (const record of records) {
stats.addedElements += Array.from(record.addedNodes).filter(
(node) => node.nodeType === Node.ELEMENT_NODE,
).length;
stats.removedElements += Array.from(record.removedNodes).filter(
(node) => node.nodeType === Node.ELEMENT_NODE,
).length;
}
}).observe(root, { childList: true, subtree: true });
});
}
async function readRendererInstrumentation(page: Page): Promise<{
reactCommits: number;
addedElements: number;
removedElements: number;
}> {
return page.evaluate(() => {
const scope = globalThis as typeof globalThis & {
__PASEO_DIFF_REACT_STATS__?: { commits: number };
__PASEO_DIFF_RENDERER_STATS__?: {
initialCommits: number;
addedElements: number;
removedElements: number;
};
};
const react = scope.__PASEO_DIFF_REACT_STATS__!;
const renderer = scope.__PASEO_DIFF_RENDERER_STATS__!;
return {
reactCommits: react.commits - renderer.initialCommits,
addedElements: renderer.addedElements,
removedElements: renderer.removedElements,
};
});
}
async function collectHeapUsage(cdp: CDPSession): Promise<{ usedSize: number; totalSize: number }> {
await cdp.send("HeapProfiler.collectGarbage");
return cdp.send("Runtime.getHeapUsage");
}
async function startChromiumTrace(cdp: CDPSession): Promise<void> {
await cdp.send("Tracing.start", {
categories: [
"devtools.timeline",
"v8.execute",
"blink.user_timing",
"disabled-by-default-v8.cpu_profiler",
].join(","),
transferMode: "ReturnAsStream",
});
}
async function stopChromiumTrace(cdp: CDPSession, testInfo: TestInfo): Promise<void> {
const completed = new Promise<string>((resolve, reject) => {
cdp.once("Tracing.tracingComplete", ({ stream }) => {
if (stream) resolve(stream);
else reject(new Error("Chromium trace completed without a stream"));
});
});
await cdp.send("Tracing.end");
const stream = await completed;
const chunks: string[] = [];
let eof = false;
while (!eof) {
const chunk = await cdp.send("IO.read", { handle: stream });
chunks.push(chunk.data);
eof = chunk.eof;
}
await cdp.send("IO.close", { handle: stream });
const trace = chunks.join("");
await writeFile(testInfo.outputPath("diff-canvas-chromium-trace.json"), trace);
await testInfo.attach("diff-canvas-chromium-trace", {
body: trace,
contentType: "application/json",
});
}
async function attachPerformanceReport(
testInfo: TestInfo,
report: Record<string, unknown>,
): Promise<void> {
await testInfo.attach("diff-canvas-performance", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
}
+2
View File
@@ -22,6 +22,7 @@
"test": "vitest run",
"test:browser": "vitest run --project browser",
"test:e2e": "playwright test --project=browser",
"test:e2e:diff-performance": "cross-env PASEO_DIFF_PERF_E2E=1 playwright test --project=browser e2e/browser/diff-performance.spec.ts",
"test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
"test:e2e:relay-deployment": "npm --prefix ../.. run build:server && npm --prefix ../.. run build:daemon-web-ui && cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=relay-deployment e2e/browser/relay-deployment-reconnect.real.spec.ts",
"test:e2e:ui": "playwright test --ui",
@@ -57,6 +58,7 @@
"@react-native/normalize-colors": "^0.81.5",
"@react-navigation/native": "^7.1.8",
"@replit/codemirror-vim": "6.3.0",
"@shopify/react-native-skia": "2.2.12",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21",
"@xterm/addon-clipboard": "^0.3.0-beta.213",
@@ -1,83 +0,0 @@
import { useState, useCallback, useEffect, useId, useRef } from "react";
import {
type LayoutChangeEvent,
type NativeSyntheticEvent,
type NativeScrollEvent,
type StyleProp,
type ViewStyle,
} from "react-native";
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures";
interface DiffScrollProps {
children: React.ReactNode;
scrollViewWidth: number;
onScrollViewWidthChange: (width: number) => void;
style?: StyleProp<ViewStyle>;
contentContainerStyle?: StyleProp<ViewStyle>;
}
export function DiffScroll({
children,
scrollViewWidth: _scrollViewWidth,
onScrollViewWidthChange,
style,
contentContainerStyle,
}: DiffScrollProps) {
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
const horizontalScroll = useHorizontalScrollOptional();
const scrollId = useId();
const scrollViewRef = useRef<ScrollViewType>(null);
const closeGestureRef = useFileExplorerCloseGestureRef();
// Register/unregister scroll offset tracking
useEffect(() => {
if (!horizontalScroll) return;
// Start at 0 (not scrolled)
horizontalScroll.registerScrollOffset(scrollId, 0);
return () => {
horizontalScroll.unregisterScrollOffset(scrollId);
};
}, [horizontalScroll, scrollId]);
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const offsetX = event.nativeEvent.contentOffset.x;
// Track if we're at the left edge (with small threshold for float precision)
setIsAtLeftEdge(offsetX <= 1);
if (horizontalScroll) {
horizontalScroll.registerScrollOffset(scrollId, offsetX);
}
},
[horizontalScroll, scrollId],
);
const handleLayout = useCallback(
(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width),
[onScrollViewWidthChange],
);
return (
<ScrollView
ref={scrollViewRef}
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
bounces={false}
style={style}
contentContainerStyle={contentContainerStyle}
onScroll={handleScroll}
scrollEventThrottle={16}
onLayout={handleLayout}
// When at left edge, wait for close gesture to fail before scrolling.
// The close gesture fails quickly on leftward swipes (failOffsetX=-10),
// so scrolling left works normally. On rightward swipes, close gesture
// activates and closes the sidebar.
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
>
{children}
</ScrollView>
);
}
@@ -1,35 +0,0 @@
import { useCallback } from "react";
import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
interface DiffScrollProps {
children: React.ReactNode;
scrollViewWidth: number;
onScrollViewWidthChange: (width: number) => void;
style?: StyleProp<ViewStyle>;
contentContainerStyle?: StyleProp<ViewStyle>;
}
export function DiffScroll({
children,
onScrollViewWidthChange,
style,
contentContainerStyle,
}: DiffScrollProps) {
const handleLayout = useCallback(
(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width),
[onScrollViewWidthChange],
);
return (
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={style}
contentContainerStyle={contentContainerStyle}
onLayout={handleLayout}
>
{children}
</ScrollView>
);
}
@@ -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;
}
+510
View File
@@ -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"])];
}
+210
View File
@@ -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);
});
});
-121
View File
@@ -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);
}
File diff suppressed because it is too large Load Diff
-59
View File
@@ -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);
});
});
-36
View File
@@ -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;
}
+10
View File
@@ -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", () => {
+5 -3
View File
@@ -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,
});
});
});
-143
View File
@@ -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 };
}
+315
View File
@@ -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 },
}));
+2
View File
@@ -861,6 +861,8 @@ export const ar: TranslationResources = {
wrapLongLines: "لف الخطوط الطويلة",
collapseAllFolders: "طي كافة المجلدات",
expandAllFolders: "توسيع كافة المجلدات",
collapseAllFiles: "طي كافة الملفات",
expandAllFiles: "توسيع كافة الملفات",
refreshing: "منعش",
refresh: "ينعش",
refreshState: "تحديث حالة git و{{brand}}",
+2
View File
@@ -870,6 +870,8 @@ export const en = {
wrapLongLines: "Wrap long lines",
collapseAllFolders: "Collapse all folders",
expandAllFolders: "Expand all folders",
collapseAllFiles: "Collapse all files",
expandAllFiles: "Expand all files",
refreshing: "Refreshing",
refresh: "Refresh",
refreshState: "Refresh git and {{brand}} state",
+2
View File
@@ -892,6 +892,8 @@ export const es: TranslationResources = {
wrapLongLines: "Envolver largas filas",
collapseAllFolders: "Contraer todas las carpetas",
expandAllFolders: "Expandir todas las carpetas",
collapseAllFiles: "Contraer todos los archivos",
expandAllFiles: "Expandir todos los archivos",
refreshing: "Refrescante",
refresh: "Refrescar",
refreshState: "Actualizar el estado de git y {{brand}}",
+2
View File
@@ -891,6 +891,8 @@ export const fr: TranslationResources = {
wrapLongLines: "Enroulez les longues lignes",
collapseAllFolders: "Réduire tous les dossiers",
expandAllFolders: "Développer tous les dossiers",
collapseAllFiles: "Réduire tous les fichiers",
expandAllFiles: "Développer tous les fichiers",
refreshing: "Rafraîchissant",
refresh: "Rafraîchir",
refreshState: "Actualiser l'état de git et de {{brand}}",
+2
View File
@@ -872,6 +872,8 @@ export const ja: TranslationResources = {
wrapLongLines: "長い行を折り返す",
collapseAllFolders: "すべてのフォルダを折りたたむ",
expandAllFolders: "すべてのフォルダを展開",
collapseAllFiles: "すべてのファイルを折りたたむ",
expandAllFiles: "すべてのファイルを展開",
refreshing: "更新中",
refresh: "更新",
refreshState: "gitと{{brand}}の状態を更新",
+2
View File
@@ -868,6 +868,8 @@ export const ko: TranslationResources = {
wrapLongLines: "긴 줄 줄바꿈",
collapseAllFolders: "모든 폴더 축소",
expandAllFolders: "모든 폴더 확장",
collapseAllFiles: "모든 파일 축소",
expandAllFiles: "모든 파일 확장",
refreshing: "새로고침 중",
refresh: "새로고침",
refreshState: "Git 및 {{brand}} 상태 새로고침",
+2
View File
@@ -883,6 +883,8 @@ export const ptBR: TranslationResources = {
wrapLongLines: "Quebrar linhas longas",
collapseAllFolders: "Recolher todas as pastas",
expandAllFolders: "Expandir todas as pastas",
collapseAllFiles: "Recolher todos os arquivos",
expandAllFiles: "Expandir todos os arquivos",
refreshing: "Atualizando",
refresh: "Atualizar",
refreshState: "Atualizar estado do git e do {{brand}}",
+2
View File
@@ -883,6 +883,8 @@ export const ru: TranslationResources = {
wrapLongLines: "Перенос длинных строк",
collapseAllFolders: "Свернуть все папки",
expandAllFolders: "Развернуть все папки",
collapseAllFiles: "Свернуть все файлы",
expandAllFiles: "Развернуть все файлы",
refreshing: "Освежающий",
refresh: "Обновить",
refreshState: "Обновить состояние git и {{brand}}",
+2
View File
@@ -853,6 +853,8 @@ export const zhCN: TranslationResources = {
wrapLongLines: "自动换行长行",
collapseAllFolders: "折叠所有文件夹",
expandAllFolders: "展开所有文件夹",
collapseAllFiles: "折叠所有文件",
expandAllFiles: "展开所有文件",
refreshing: "正在刷新",
refresh: "刷新",
refreshState: "刷新 git 和 {{brand}} 状态",
+2 -1
View File
@@ -7,7 +7,8 @@ import invariant from "tiny-invariant";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { ChangesSurface, DiffDocument, DiffLayoutToggle, resolveDiffLayout } from "@/git/diff-pane";
import { DiffDocument } from "@/git/diff-document";
import { ChangesSurface, DiffLayoutToggle, resolveDiffLayout } from "@/git/diff-pane";
import { useCommitDiffFiles } from "@/git/use-diff-files";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { useAppSettings } from "@/hooks/use-settings";
+89
View File
@@ -0,0 +1,89 @@
import type { ReviewableDiffTarget } from "@/utils/diff-layout";
import { buildReviewableDiffTargetKey } from "@/utils/diff-layout";
import type { ReviewDraftComment } from "./store";
export const INLINE_REVIEW_COMMENT_HEIGHT = 72;
export const INLINE_REVIEW_EDITOR_HEIGHT = 132;
export const INLINE_REVIEW_GAP = 6;
export const INLINE_REVIEW_VERTICAL_PADDING = 8;
export interface InlineReviewEditorState {
target: ReviewableDiffTarget;
commentId: string | null;
body: string;
}
export interface InlineReviewActions {
commentsByTarget: ReadonlyMap<string, ReviewDraftComment[]>;
editor: InlineReviewEditorState | null;
onStartComment: (target: ReviewableDiffTarget) => void;
onEditComment: (target: ReviewableDiffTarget, comment: ReviewDraftComment) => void;
onCancelEditor: () => void;
onSaveEditor: (body: string) => void;
onDeleteComment: (id: string) => void;
}
export function isInlineReviewEditorForTarget(
editor: InlineReviewEditorState | null,
target: ReviewableDiffTarget | null | undefined,
): boolean {
return Boolean(
editor &&
target &&
buildReviewableDiffTargetKey(editor.target) === buildReviewableDiffTargetKey(target),
);
}
export function getInlineReviewThreadState(input: {
reviewTarget: ReviewableDiffTarget | null | undefined;
reviewActions?: InlineReviewActions;
}): {
comments: ReviewDraftComment[];
hasEditor: boolean;
editingCommentId: string | null;
height: number;
} | null {
const { reviewTarget, reviewActions } = input;
if (!reviewTarget || !reviewActions) return null;
const comments = reviewActions.commentsByTarget.get(reviewTarget.key) ?? [];
const editorForTarget = isInlineReviewEditorForTarget(reviewActions.editor, reviewTarget)
? reviewActions.editor
: null;
const hasEditor = editorForTarget !== null;
const editingCommentId = editorForTarget?.commentId ?? null;
const editingExisting =
editingCommentId !== null && comments.some((comment) => comment.id === editingCommentId);
const visibleCommentCount = editingExisting ? comments.length - 1 : comments.length;
const editorCount = hasEditor ? 1 : 0;
const visibleBlockCount = visibleCommentCount + editorCount;
if (visibleBlockCount === 0) return null;
const height =
visibleCommentCount * INLINE_REVIEW_COMMENT_HEIGHT +
editorCount * INLINE_REVIEW_EDITOR_HEIGHT +
Math.max(0, visibleBlockCount - 1) * INLINE_REVIEW_GAP +
INLINE_REVIEW_VERTICAL_PADDING * 2;
return { comments, hasEditor, editingCommentId, height };
}
export function getSplitInlineReviewThreadState(input: {
left: ReviewableDiffTarget | null | undefined;
right: ReviewableDiffTarget | null | undefined;
reviewActions?: InlineReviewActions;
}): {
left: ReturnType<typeof getInlineReviewThreadState>;
right: ReturnType<typeof getInlineReviewThreadState>;
height: number;
} | null {
const left = getInlineReviewThreadState({
reviewTarget: input.left,
reviewActions: input.reviewActions,
});
const right = getInlineReviewThreadState({
reviewTarget: input.right,
reviewActions: input.reviewActions,
});
const height = Math.max(left?.height ?? 0, right?.height ?? 0);
return height === 0 ? null : { left, right, height };
}
+7 -4
View File
@@ -21,15 +21,18 @@ export {
export {
getInlineReviewThreadState,
getInlineReviewThreadViewportStyle,
getSplitInlineReviewThreadState,
isInlineReviewEditorForTarget,
type InlineReviewActions,
type InlineReviewEditorState,
} from "./geometry";
export {
getInlineReviewThreadViewportStyle,
groupInlineReviewCommentsByTarget,
InlineReviewEditor,
InlineReviewGutterCell,
InlineReviewThread,
isInlineReviewEditorForTarget,
SMALL_ACTION_HIT_SLOP,
useInlineReviewController,
type InlineReviewActions,
type InlineReviewEditorState,
} from "./surface";
+8 -94
View File
@@ -20,6 +20,14 @@ import type { Theme } from "@/styles/theme";
import { useWorkspaceFocusRestoration } from "@/workspace/focus";
import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store";
import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout";
import {
INLINE_REVIEW_EDITOR_HEIGHT,
INLINE_REVIEW_GAP,
INLINE_REVIEW_VERTICAL_PADDING,
isInlineReviewEditorForTarget,
type InlineReviewActions,
type InlineReviewEditorState,
} from "./geometry";
type PressableState = PressableStateCallbackType & { hovered?: boolean };
type WebTextInputRef = TextInput & {
@@ -44,10 +52,6 @@ function getWebTextInputElement(input: TextInput | null): HTMLElement | null {
return element instanceof HTMLElement ? element : null;
}
export const INLINE_REVIEW_COMMENT_HEIGHT = 72;
export const INLINE_REVIEW_EDITOR_HEIGHT = 132;
const INLINE_REVIEW_GAP = 6;
const INLINE_REVIEW_VERTICAL_PADDING = 8;
export const SMALL_ACTION_HIT_SLOP = 8;
const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const destructiveIconColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
@@ -58,22 +62,6 @@ const ThemedPencil = withUnistyles(Pencil);
const ThemedPlus = withUnistyles(Plus);
const ThemedTrash2 = withUnistyles(Trash2);
export interface InlineReviewEditorState {
target: ReviewableDiffTarget;
commentId: string | null;
body: string;
}
export interface InlineReviewActions {
commentsByTarget: ReadonlyMap<string, ReviewDraftComment[]>;
editor: InlineReviewEditorState | null;
onStartComment: (target: ReviewableDiffTarget) => void;
onEditComment: (target: ReviewableDiffTarget, comment: ReviewDraftComment) => void;
onCancelEditor: () => void;
onSaveEditor: (body: string) => void;
onDeleteComment: (id: string) => void;
}
export function groupInlineReviewCommentsByTarget(
comments: readonly ReviewDraftComment[],
): Map<string, ReviewDraftComment[]> {
@@ -174,80 +162,6 @@ export function useInlineReviewController(input: { reviewDraftKey: string }): In
);
}
export function isInlineReviewEditorForTarget(
editor: InlineReviewEditorState | null,
target: ReviewableDiffTarget | null | undefined,
): boolean {
return Boolean(
editor &&
target &&
buildReviewableDiffTargetKey(editor.target) === buildReviewableDiffTargetKey(target),
);
}
export function getInlineReviewThreadState(input: {
reviewTarget: ReviewableDiffTarget | null | undefined;
reviewActions?: InlineReviewActions;
}): {
comments: ReviewDraftComment[];
hasEditor: boolean;
editingCommentId: string | null;
height: number;
} | null {
const { reviewTarget, reviewActions } = input;
if (!reviewTarget || !reviewActions) {
return null;
}
const comments = reviewActions.commentsByTarget.get(reviewTarget.key) ?? [];
const editorForTarget = isInlineReviewEditorForTarget(reviewActions.editor, reviewTarget)
? reviewActions.editor
: null;
const hasEditor = editorForTarget !== null;
const editingCommentId = editorForTarget?.commentId ?? null;
const editingExisting =
editingCommentId !== null && comments.some((comment) => comment.id === editingCommentId);
const visibleCommentCount = editingExisting ? comments.length - 1 : comments.length;
const editorCount = hasEditor ? 1 : 0;
const visibleBlockCount = visibleCommentCount + editorCount;
if (visibleBlockCount === 0) {
return null;
}
const height =
visibleCommentCount * INLINE_REVIEW_COMMENT_HEIGHT +
editorCount * INLINE_REVIEW_EDITOR_HEIGHT +
Math.max(0, visibleBlockCount - 1) * INLINE_REVIEW_GAP +
INLINE_REVIEW_VERTICAL_PADDING * 2;
return { comments, hasEditor, editingCommentId, height };
}
export function getSplitInlineReviewThreadState(input: {
left: ReviewableDiffTarget | null | undefined;
right: ReviewableDiffTarget | null | undefined;
reviewActions?: InlineReviewActions;
}): {
left: ReturnType<typeof getInlineReviewThreadState>;
right: ReturnType<typeof getInlineReviewThreadState>;
height: number;
} | null {
const left = getInlineReviewThreadState({
reviewTarget: input.left,
reviewActions: input.reviewActions,
});
const right = getInlineReviewThreadState({
reviewTarget: input.right,
reviewActions: input.reviewActions,
});
const height = Math.max(left?.height ?? 0, right?.height ?? 0);
if (height === 0) {
return null;
}
return { left, right, height };
}
export function InlineReviewGutterCell({
children,
reviewTarget,
@@ -84,6 +84,7 @@ export interface PanelState {
// by full uncompressed dir path, so folders default to expanded and new
// folders stay expanded as the diff changes.
diffCollapsedFoldersByWorkspace: Record<string, string[]>;
collapsedFilePathsByWorkspace: Record<string, string[]>;
sidebarWidth: number;
explorerWidth: number;
explorerSortOption: SortOption;
@@ -111,6 +112,7 @@ export interface PanelState {
setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void;
setExpandedPathsForWorkspace: (workspaceKey: string, paths: ExpandedPathsUpdate) => void;
setDiffCollapsedFoldersForWorkspace: (workspaceKey: string, dirPaths: string[]) => void;
setCollapsedFilePathsForWorkspace: (workspaceKey: string, paths: string[]) => void;
activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void;
setSidebarWidth: (width: number) => void;
setExplorerWidth: (width: number) => void;
@@ -147,6 +149,7 @@ export const usePanelStore = create<PanelState>()(
explorerTabByCheckout: {},
expandedPathsByWorkspace: {},
diffCollapsedFoldersByWorkspace: {},
collapsedFilePathsByWorkspace: {},
sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
explorerSortOption: "name",
@@ -280,6 +283,13 @@ export const usePanelStore = create<PanelState>()(
[workspaceKey]: dirPaths,
},
})),
setCollapsedFilePathsForWorkspace: (workspaceKey, paths) =>
set((state) => ({
collapsedFilePathsByWorkspace: {
...state.collapsedFilePathsByWorkspace,
[workspaceKey]: paths,
},
})),
activateExplorerTabForCheckout: (checkout) =>
set((state) => ({
explorerTab: resolveExplorerTabForCheckout({
@@ -307,6 +317,7 @@ export const usePanelStore = create<PanelState>()(
explorerTabByCheckout: state.explorerTabByCheckout,
expandedPathsByWorkspace: state.expandedPathsByWorkspace,
diffCollapsedFoldersByWorkspace: state.diffCollapsedFoldersByWorkspace,
collapsedFilePathsByWorkspace: state.collapsedFilePathsByWorkspace,
sidebarWidth: state.sidebarWidth,
explorerWidth: state.explorerWidth,
explorerSortOption: state.explorerSortOption,
@@ -120,6 +120,15 @@ describe("panel-store migration", () => {
expect(state.diffCollapsedFoldersByWorkspace).toEqual({ ws: ["src/app"] });
});
it("initializes and preserves collapsed diff file paths by workspace", () => {
expect(migratePanelState({}, 14, { isWeb: false }).collapsedFilePathsByWorkspace).toEqual({});
expect(
migratePanelState({ collapsedFilePathsByWorkspace: { ws: ["src/app.ts"] } }, 14, {
isWeb: false,
}).collapsedFilePathsByWorkspace,
).toEqual({ ws: ["src/app.ts"] });
});
it("drops the retired per-file diff expansion state", () => {
const state = migratePanelState({ diffExpandedPathsByWorkspace: { ws: ["src/app.ts"] } }, 13, {
isWeb: false,
@@ -186,6 +186,7 @@ export const PanelPersistedStateSchema = z.strictObject({
// Accepted only so migration can discard the former per-file diff expansion state.
diffExpandedPathsByWorkspace: z.record(z.string(), z.array(z.string())).optional(),
diffCollapsedFoldersByWorkspace: z.record(z.string(), z.array(z.string())).optional(),
collapsedFilePathsByWorkspace: z.record(z.string(), z.array(z.string())).optional(),
sidebarWidth: z.number().optional(),
explorerWidth: z.number().optional(),
explorerSortOption: z.enum(["name", "modified", "size"]).optional(),
@@ -299,6 +300,12 @@ export function migratePanelState(
) {
state.diffCollapsedFoldersByWorkspace = {};
}
if (
typeof state.collapsedFilePathsByWorkspace !== "object" ||
!state.collapsedFilePathsByWorkspace
) {
state.collapsedFilePathsByWorkspace = {};
}
if (typeof state.explorerShowHiddenFiles !== "boolean") {
state.explorerShowHiddenFiles = true;
}
@@ -135,6 +135,8 @@ describe("buildSplitDiffRows", () => {
expect(rows[0]).toEqual({
kind: "header",
content: "@@ -10,1 +20,1 @@",
hunkIndex: 0,
lineIndex: 0,
});
expect(rows[1]).toMatchObject({
kind: "pair",
@@ -179,6 +181,8 @@ describe("buildSplitDiffRows", () => {
expect(rows[0]).toEqual({
kind: "header",
content: "@@ -10,1 +10,2 @@",
hunkIndex: 0,
lineIndex: 0,
});
expect(rows[1]).toMatchObject({
kind: "pair",
+15
View File
@@ -58,10 +58,15 @@ export interface SplitDiffDisplayLine {
tokens?: DiffLine["tokens"];
lineNumber: number | null;
reviewTarget: ReviewableDiffTarget | null;
hunkIndex: number;
lineIndex: number;
side: "old" | "new";
}
export interface UnifiedDiffDisplayLine {
key: string;
hunkIndex: number;
lineIndex: number;
line: DiffLine;
lineNumber: number | null;
reviewTarget: ReviewableDiffTarget | null;
@@ -71,6 +76,8 @@ export type SplitDiffRow =
| {
kind: "header";
content: string;
hunkIndex: number;
lineIndex: number;
}
| {
kind: "pair";
@@ -89,6 +96,9 @@ function toSplitDisplayLine(cell: NumberedDiffCell | null): SplitDiffDisplayLine
...(cell.line.tokens ? { tokens: cell.line.tokens } : {}),
lineNumber: cell.lineNumber,
reviewTarget: toReviewTarget(cell),
hunkIndex: cell.hunkIndex,
lineIndex: cell.lineIndex,
side: cell.side,
};
}
@@ -229,6 +239,8 @@ export function buildUnifiedDiffLines(file: ParsedDiffFile): UnifiedDiffDisplayL
return buildNumberedDiffHunks(file).flatMap((hunk) =>
hunk.lines.map((numberedLine) => ({
key: numberedLine.key,
hunkIndex: numberedLine.hunkIndex,
lineIndex: numberedLine.lineIndex,
line: numberedLine.line,
lineNumber: numberedLine.unifiedCell?.lineNumber ?? null,
reviewTarget: numberedLine.unifiedCell ? toReviewTarget(numberedLine.unifiedCell) : null,
@@ -240,9 +252,12 @@ export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] {
const rows: SplitDiffRow[] = [];
for (const hunk of buildNumberedDiffHunks(file)) {
const headerLine = hunk.lines.find((line) => line.line.type === "header");
rows.push({
kind: "header",
content: hunk.hunkHeader,
hunkIndex: hunk.hunkIndex,
lineIndex: headerLine?.lineIndex ?? 0,
});
let pendingRemovals: NumberedDiffCell[] = [];