From d03cf147037fc1d67ec080ab40b342d8927bec96 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 17 Aug 2026 03:42:26 +0200 Subject: [PATCH] refactor(app): implement canvas-based diff rendering --- package-lock.json | 139 +- packages/app/e2e/browser/changes-pane.spec.ts | 669 ++++- .../app/e2e/browser/commit-diff-panel.spec.ts | 30 +- .../e2e/browser/diff-performance-baseline.ts | 5 + .../app/e2e/browser/diff-performance.spec.ts | 594 ++++- packages/app/package.json | 2 + packages/app/src/components/diff-scroll.tsx | 83 - .../app/src/components/diff-scroll.web.tsx | 35 - .../diff-document/document-file-header.tsx | 49 + .../src/git/diff-document/hit-testing.test.ts | 235 ++ .../app/src/git/diff-document/hit-testing.ts | 197 ++ .../diff-document/horizontal-offsets.test.ts | 59 + .../git/diff-document/horizontal-offsets.ts | 37 + .../horizontal-scroll.native.tsx | 77 + .../diff-document/horizontal-scroll.web.tsx | 51 + packages/app/src/git/diff-document/index.tsx | 47 + .../app/src/git/diff-document/model.test.ts | 422 +++ packages/app/src/git/diff-document/model.ts | 510 ++++ .../diff-document/native-hit-testing.test.ts | 123 + .../git/diff-document/native-hit-testing.ts | 28 + .../git/diff-document/paint.native.test.ts | 13 + .../app/src/git/diff-document/paint.native.ts | 164 ++ .../src/git/diff-document/paint.web.test.ts | 378 +++ .../app/src/git/diff-document/paint.web.ts | 205 ++ .../app/src/git/diff-document/palette.test.ts | 47 + packages/app/src/git/diff-document/palette.ts | 30 + .../git/diff-document/pointer-gesture.test.ts | 28 + .../src/git/diff-document/pointer-gesture.ts | 12 + .../app/src/git/diff-document/review-paint.ts | 14 + .../src/git/diff-document/surface.native.tsx | 287 +++ .../app/src/git/diff-document/surface.tsx | 1 + .../app/src/git/diff-document/surface.web.tsx | 613 +++++ .../diff-document/text-measurement.test.ts | 39 + .../src/git/diff-document/text-measurement.ts | 19 + .../app/src/git/diff-document/text.native.ts | 178 ++ packages/app/src/git/diff-document/types.ts | 210 ++ packages/app/src/git/diff-flat-items.test.ts | 134 - packages/app/src/git/diff-flat-items.ts | 121 - packages/app/src/git/diff-pane.tsx | 2280 +---------------- packages/app/src/git/diff-scroll.test.ts | 59 - packages/app/src/git/diff-scroll.ts | 36 - packages/app/src/git/diff-tree.test.ts | 10 + packages/app/src/git/diff-tree.ts | 8 +- .../app/src/git/diff-virtualization.test.ts | 54 - packages/app/src/git/diff-virtualization.ts | 143 -- .../src/git/file-header-interaction.test.ts | 109 + .../app/src/git/file-header-interaction.ts | 189 ++ packages/app/src/git/file-header.tsx | 315 +++ packages/app/src/i18n/resources/ar.ts | 2 + packages/app/src/i18n/resources/en.ts | 2 + packages/app/src/i18n/resources/es.ts | 2 + packages/app/src/i18n/resources/fr.ts | 2 + packages/app/src/i18n/resources/ja.ts | 2 + packages/app/src/i18n/resources/ko.ts | 2 + packages/app/src/i18n/resources/pt-BR.ts | 2 + packages/app/src/i18n/resources/ru.ts | 2 + packages/app/src/i18n/resources/zh-CN.ts | 2 + packages/app/src/panels/diff-panel.tsx | 3 +- packages/app/src/review/geometry.ts | 89 + packages/app/src/review/index.ts | 11 +- packages/app/src/review/surface.tsx | 102 +- packages/app/src/stores/panel-store/index.ts | 11 + .../app/src/stores/panel-store/state.test.ts | 9 + packages/app/src/stores/panel-store/state.ts | 7 + packages/app/src/utils/diff-layout.test.ts | 4 + packages/app/src/utils/diff-layout.ts | 15 + 66 files changed, 6147 insertions(+), 3210 deletions(-) create mode 100644 packages/app/e2e/browser/diff-performance-baseline.ts delete mode 100644 packages/app/src/components/diff-scroll.tsx delete mode 100644 packages/app/src/components/diff-scroll.web.tsx create mode 100644 packages/app/src/git/diff-document/document-file-header.tsx create mode 100644 packages/app/src/git/diff-document/hit-testing.test.ts create mode 100644 packages/app/src/git/diff-document/hit-testing.ts create mode 100644 packages/app/src/git/diff-document/horizontal-offsets.test.ts create mode 100644 packages/app/src/git/diff-document/horizontal-offsets.ts create mode 100644 packages/app/src/git/diff-document/horizontal-scroll.native.tsx create mode 100644 packages/app/src/git/diff-document/horizontal-scroll.web.tsx create mode 100644 packages/app/src/git/diff-document/index.tsx create mode 100644 packages/app/src/git/diff-document/model.test.ts create mode 100644 packages/app/src/git/diff-document/model.ts create mode 100644 packages/app/src/git/diff-document/native-hit-testing.test.ts create mode 100644 packages/app/src/git/diff-document/native-hit-testing.ts create mode 100644 packages/app/src/git/diff-document/paint.native.test.ts create mode 100644 packages/app/src/git/diff-document/paint.native.ts create mode 100644 packages/app/src/git/diff-document/paint.web.test.ts create mode 100644 packages/app/src/git/diff-document/paint.web.ts create mode 100644 packages/app/src/git/diff-document/palette.test.ts create mode 100644 packages/app/src/git/diff-document/palette.ts create mode 100644 packages/app/src/git/diff-document/pointer-gesture.test.ts create mode 100644 packages/app/src/git/diff-document/pointer-gesture.ts create mode 100644 packages/app/src/git/diff-document/review-paint.ts create mode 100644 packages/app/src/git/diff-document/surface.native.tsx create mode 100644 packages/app/src/git/diff-document/surface.tsx create mode 100644 packages/app/src/git/diff-document/surface.web.tsx create mode 100644 packages/app/src/git/diff-document/text-measurement.test.ts create mode 100644 packages/app/src/git/diff-document/text-measurement.ts create mode 100644 packages/app/src/git/diff-document/text.native.ts create mode 100644 packages/app/src/git/diff-document/types.ts delete mode 100644 packages/app/src/git/diff-flat-items.test.ts delete mode 100644 packages/app/src/git/diff-flat-items.ts delete mode 100644 packages/app/src/git/diff-scroll.test.ts delete mode 100644 packages/app/src/git/diff-scroll.ts delete mode 100644 packages/app/src/git/diff-virtualization.test.ts delete mode 100644 packages/app/src/git/diff-virtualization.ts create mode 100644 packages/app/src/git/file-header-interaction.test.ts create mode 100644 packages/app/src/git/file-header-interaction.ts create mode 100644 packages/app/src/git/file-header.tsx create mode 100644 packages/app/src/review/geometry.ts diff --git a/package-lock.json b/package-lock.json index 6415d189d..51ef873b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts index f7387190b..c15a75641 100644 --- a/packages/app/e2e/browser/changes-pane.spec.ts +++ b/packages/app/e2e/browser/changes-pane.spec.ts @@ -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 { @@ -647,22 +888,60 @@ async function useCodeFont(page: Page, codeFontSize: number): Promise { } async function useUnwrappedDiffLines(page: Page): Promise { + await configureDiffPresentation(page, { layout: "unified", wrapLines: false }); +} + +async function configureDiffPresentation( + page: Page, + requestedPresentation: { layout: "unified" | "split"; wrapLines: boolean }, +): Promise { 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 { + 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((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 { + await page.evaluate(() => { + ( + window as typeof window & { __releasePaseoDiffFontLoads: () => void } + ).__releasePaseoDiffFontLoads(); + }); +} + async function expectFlatFileList(page: Page): Promise { 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 { } async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise { - 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 { - const geometry = await readVisibleDiffRowGeometry(page); - expect(geometry.mismatchedTypography, JSON.stringify(geometry, null, 2)).toEqual([]); +async function expectDiffCodeFontFamily(page: Page, fontFamily: string): Promise { + 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(`[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(`[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 { + 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 { + 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 { 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 { + 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 { async function expectExpandedMountedTabDiff(page: Page): Promise { 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 { +async function changeCodeTypographyFromSettings( + page: Page, + typography: { fontSize: number; fontFamily: string }, +): Promise { 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 { @@ -844,6 +1100,245 @@ async function expectStoredCodeFontSize(page: Page, codeFontSize: number): Promi .toBe(codeFontSize); } +async function startReviewOnFirstChangedLine( + page: Page, + side: "unified" | "right" = "unified", +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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('[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 { + 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 { + 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 { + 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 { + 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 { await page.getByTestId("settings-back-to-workspace").click(); await waitForWorkspaceTabsVisible(page); @@ -852,15 +1347,9 @@ async function returnToWorkspaceChanges(page: Page): Promise { } async function scrollToLowerUnwrappedDiffRows(page: Page): Promise { - const lastRowIndex = await page.getByTestId("diff-file-0-body").evaluate((root) => { - const rows = Array.from(root.querySelectorAll('[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(); } diff --git a/packages/app/e2e/browser/commit-diff-panel.spec.ts b/packages/app/e2e/browser/commit-diff-panel.spec.ts index fae202d36..263cecc45 100644 --- a/packages/app/e2e/browser/commit-diff-panel.spec.ts +++ b/packages/app/e2e/browser/commit-diff-panel.spec.ts @@ -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 { @@ -86,3 +88,25 @@ async function createFeatureCommit(repoPath: string): Promise { }, }); } + +async function expectCommitDiffHeaderGeometry(panel: Locator): Promise { + 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); +} diff --git a/packages/app/e2e/browser/diff-performance-baseline.ts b/packages/app/e2e/browser/diff-performance-baseline.ts new file mode 100644 index 000000000..aa499ce8c --- /dev/null +++ b/packages/app/e2e/browser/diff-performance-baseline.ts @@ -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; diff --git a/packages/app/e2e/browser/diff-performance.spec.ts b/packages/app/e2e/browser/diff-performance.spec.ts index 46f776236..29e1b44bc 100644 --- a/packages/app/e2e/browser/diff-performance.spec.ts +++ b/packages/app/e2e/browser/diff-performance.spec.ts @@ -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 { - 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('[data-testid^="diff-code-row-"]'), +async function reloadDiffPresentation( + page: Page, + requestedPresentation: { layout: "unified" | "split"; wrapLines: boolean }, +): Promise { + 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 { + 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 { + 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((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((resolve) => requestAnimationFrame(() => resolve())); + const canvas = document.querySelector( + '[data-testid="git-diff-canvas"]', + )!; + frameSignatures.push({ + scrollTop: scroll.scrollTop, + signature: canvasSignature(canvas), + }); + previousFrame = performance.now(); + } + } + await new Promise((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 { + 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((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 { + 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((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 { + const stable = await page.getByTestId("git-diff-scroll").evaluate(async (element, frames) => { + const canvas = document.querySelector('[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((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 { + 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((resolve) => requestAnimationFrame(() => resolve())); + element.scrollTop = 0; + element.dispatchEvent(new Event("scroll", { bubbles: false })); + await new Promise((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((resolve) => requestAnimationFrame(() => resolve())); + return element.scrollLeft; + }, offset); +} + +async function startRendererInstrumentation(page: Page): Promise { + 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 { + 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 { + const completed = new Promise((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, +): Promise { + await testInfo.attach("diff-canvas-performance", { + body: JSON.stringify(report, null, 2), + contentType: "application/json", + }); +} diff --git a/packages/app/package.json b/packages/app/package.json index a7478325d..f1d6f4c2e 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/src/components/diff-scroll.tsx b/packages/app/src/components/diff-scroll.tsx deleted file mode 100644 index 49110c3c7..000000000 --- a/packages/app/src/components/diff-scroll.tsx +++ /dev/null @@ -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; - contentContainerStyle?: StyleProp; -} - -export function DiffScroll({ - children, - scrollViewWidth: _scrollViewWidth, - onScrollViewWidthChange, - style, - contentContainerStyle, -}: DiffScrollProps) { - const [isAtLeftEdge, setIsAtLeftEdge] = useState(true); - const horizontalScroll = useHorizontalScrollOptional(); - const scrollId = useId(); - const scrollViewRef = useRef(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) => { - 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 ( - - {children} - - ); -} diff --git a/packages/app/src/components/diff-scroll.web.tsx b/packages/app/src/components/diff-scroll.web.tsx deleted file mode 100644 index 4caa3ac40..000000000 --- a/packages/app/src/components/diff-scroll.web.tsx +++ /dev/null @@ -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; - contentContainerStyle?: StyleProp; -} - -export function DiffScroll({ - children, - onScrollViewWidthChange, - style, - contentContainerStyle, -}: DiffScrollProps) { - const handleLayout = useCallback( - (e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width), - [onScrollViewWidthChange], - ); - - return ( - - {children} - - ); -} diff --git a/packages/app/src/git/diff-document/document-file-header.tsx b/packages/app/src/git/diff-document/document-file-header.tsx new file mode 100644 index 000000000..92398d149 --- /dev/null +++ b/packages/app/src/git/diff-document/document-file-header.tsx @@ -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 ( + + ); +}); diff --git a/packages/app/src/git/diff-document/hit-testing.test.ts b/packages/app/src/git/diff-document/hit-testing.test.ts new file mode 100644 index 000000000..e7bc839f7 --- /dev/null +++ b/packages/app/src/git/diff-document/hit-testing.test.ts @@ -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 = {}) { + const file: ParsedDiffFile = { + path: "src/a.ts", + isNew: false, + isDeleted: false, + additions: 2, + deletions: 0, + hunks: [ + { + oldStart: 1, + oldCount: 0, + newStart: 1, + newCount: 2, + lines: [ + { type: "header", content: "@@ -1,0 +1,2 @@" }, + { type: "add", content: changedContent }, + { type: "add", content: "tail" }, + ], + }, + ], + }; + return buildDiffDocumentModel({ + files: [file], + collapsedFilePaths: new Set(), + layout: "unified", + wrapLines: true, + viewportWidth: 180, + typography: { family: "monospace", size: 12, lineHeight: 18 }, + measureText: measurer, + palette: { + surface: "#000", + headerSurface: "#111", + border: "#222", + foreground: "#fff", + foregroundMuted: "#aaa", + addition: "green", + deletion: "red", + additionBackground: "#010", + deletionBackground: "#100", + emptyBackground: "#111", + selection: "blue", + syntax: {}, + }, + labels: { binary: "Binary", tooLarge: "Too large" }, + ...overrides, + }); +} + +function changedCell(model: ReturnType): DiffCell { + const cell = model.rows + .filter((row) => row.kind === "line") + .flatMap((row) => row.cells) + .find((candidate) => candidate?.type === "add"); + if (!cell) throw new Error("Expected an added cell"); + return cell; +} + +function position( + model: ReturnType, + cell: DiffCell, + cellOffset: number, +): DiffSelection["anchor"] { + const row = model.rows.find( + (candidate) => candidate.kind === "line" && candidate.cells.includes(cell), + ); + if (!row || row.kind !== "line") throw new Error("Expected cell row"); + const cellIndex = row.cells.indexOf(cell); + return { + fileIndex: row.fileIndex, + rowIndex: row.index, + cellIndex, + side: cell.reviewTarget?.side ?? (cellIndex === 0 ? "old" : "new"), + sourceOffset: cellOffset, + }; +} + +function splitFile(): ParsedDiffFile { + return { + path: "src/split.ts", + isNew: false, + isDeleted: false, + additions: 2, + deletions: 2, + hunks: [ + { + oldStart: 1, + oldCount: 2, + newStart: 1, + newCount: 2, + lines: [ + { type: "header", content: "@@ -1,2 +1,2 @@" }, + { type: "remove", content: "old-one" }, + { type: "remove", content: "old-two" }, + { type: "add", content: "new-one" }, + { type: "add", content: "new-two" }, + ], + }, + ], + }; +} + +function reviewActionsForFirstAddition(): NonNullable< + BuildDiffDocumentModelInput["reviewActions"] +> { + const target = { + key: "src/a.ts:new:1", + filePath: "src/a.ts", + hunkHeader: "@@ -1,0 +1,2 @@", + hunkIndex: 0, + lineIndex: 1, + oldLineNumber: null, + newLineNumber: 1, + side: "new" as const, + lineNumber: 1, + lineType: "add" as const, + content: "review me", + }; + return { + commentsByTarget: new Map(), + editor: { target, commentId: null, body: "" }, + onStartComment() {}, + onCancelEditor() {}, + onSaveEditor() {}, + onEditComment() {}, + onDeleteComment() {}, + }; +} diff --git a/packages/app/src/git/diff-document/hit-testing.ts b/packages/app/src/git/diff-document/hit-testing.ts new file mode 100644 index 000000000..ea1239e37 --- /dev/null +++ b/packages/app/src/git/diff-document/hit-testing.ts @@ -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, + right: Pick, +): 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; +} diff --git a/packages/app/src/git/diff-document/horizontal-offsets.test.ts b/packages/app/src/git/diff-document/horizontal-offsets.test.ts new file mode 100644 index 000000000..374cd4ce6 --- /dev/null +++ b/packages/app/src/git/diff-document/horizontal-offsets.test.ts @@ -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); + }); +}); diff --git a/packages/app/src/git/diff-document/horizontal-offsets.ts b/packages/app/src/git/diff-document/horizontal-offsets.ts new file mode 100644 index 000000000..78c355b4f --- /dev/null +++ b/packages/app/src/git/diff-document/horizontal-offsets.ts @@ -0,0 +1,37 @@ +export type DiffHorizontalOffsets = Record; + +export function horizontalOffsetForPath( + offsets: Readonly, + path: string, +): number { + "worklet"; + const offset = offsets[path]; + return typeof offset === "number" ? offset : 0; +} + +export function retainHorizontalOffsetsForPaths( + offsets: Readonly, + 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, + paths: readonly string[], +): Map { + const retained = retainHorizontalOffsetsForPaths(Object.fromEntries(offsets), paths); + return new Map(Object.entries(retained)); +} diff --git a/packages/app/src/git/diff-document/horizontal-scroll.native.tsx b/packages/app/src/git/diff-document/horizontal-scroll.native.tsx new file mode 100644 index 000000000..1f5374043 --- /dev/null +++ b/packages/app/src/git/diff-document/horizontal-scroll.native.tsx @@ -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; +}) { + const scrollRef = useRef(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 ( + + + + ); +} diff --git a/packages/app/src/git/diff-document/horizontal-scroll.web.tsx b/packages/app/src/git/diff-document/horizontal-scroll.web.tsx new file mode 100644 index 000000000..3d0d95e68 --- /dev/null +++ b/packages/app/src/git/diff-document/horizontal-scroll.web.tsx @@ -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(null); + const scroll = useCallback( + (event: React.UIEvent) => onScroll(file.path, event.currentTarget.scrollLeft), + [file.path, onScroll], + ); + const rootStyle = useMemo( + () => ({ + 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( + () => ({ 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 ( +
+
+
+ ); +}); diff --git a/packages/app/src/git/diff-document/index.tsx b/packages/app/src/git/diff-document/index.tsx new file mode 100644 index 000000000..d60775d4e --- /dev/null +++ b/packages/app/src/git/diff-document/index.tsx @@ -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(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 ( + + ); +} + +const StyledDiffDocument = withUnistyles(ThemedDiffDocument, (theme) => ({ + palette: createDiffPalette(theme), +})); + +export function DiffDocument(props: DiffDocumentProps) { + return ; +} diff --git a/packages/app/src/git/diff-document/model.test.ts b/packages/app/src/git/diff-document/model.test.ts new file mode 100644 index 000000000..26d6db6d2 --- /dev/null +++ b/packages/app/src/git/diff-document/model.test.ts @@ -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 { + 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) { + 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, + 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, + cell: ReturnType, +) { + 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; +} diff --git a/packages/app/src/git/diff-document/model.ts b/packages/app/src/git/diff-document/model.ts new file mode 100644 index 000000000..18552c445 --- /dev/null +++ b/packages/app/src/git/diff-document/model.ts @@ -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>, + 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> { + const segments: Array> = []; + 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>, + 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 + ); +} diff --git a/packages/app/src/git/diff-document/native-hit-testing.test.ts b/packages/app/src/git/diff-document/native-hit-testing.test.ts new file mode 100644 index 000000000..2090069aa --- /dev/null +++ b/packages/app/src/git/diff-document/native-hit-testing.test.ts @@ -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, 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 }, + ], + }, + ], + }; +} diff --git a/packages/app/src/git/diff-document/native-hit-testing.ts b/packages/app/src/git/diff-document/native-hit-testing.ts new file mode 100644 index 000000000..31154f42f --- /dev/null +++ b/packages/app/src/git/diff-document/native-hit-testing.ts @@ -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, + }); +} diff --git a/packages/app/src/git/diff-document/paint.native.test.ts b/packages/app/src/git/diff-document/paint.native.test.ts new file mode 100644 index 000000000..7d3266520 --- /dev/null +++ b/packages/app/src/git/diff-document/paint.native.test.ts @@ -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); + }); +}); diff --git a/packages/app/src/git/diff-document/paint.native.ts b/packages/app/src/git/diff-document/paint.native.ts new file mode 100644 index 000000000..b3db4c904 --- /dev/null +++ b/packages/app/src/git/diff-document/paint.native.ts @@ -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; +} + +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; + 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)]; +} diff --git a/packages/app/src/git/diff-document/paint.web.test.ts b/packages/app/src/git/diff-document/paint.web.test.ts new file mode 100644 index 000000000..aac7837ec --- /dev/null +++ b/packages/app/src/git/diff-document/paint.web.test.ts @@ -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 = []; + 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, +}; diff --git a/packages/app/src/git/diff-document/paint.web.ts b/packages/app/src/git/diff-document/paint.web.ts new file mode 100644 index 000000000..108bf63e3 --- /dev/null +++ b/packages/app/src/git/diff-document/paint.web.ts @@ -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; + 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)]; +} diff --git a/packages/app/src/git/diff-document/palette.test.ts b/packages/app/src/git/diff-document/palette.test.ts new file mode 100644 index 000000000..7cf235b84 --- /dev/null +++ b/packages/app/src/git/diff-document/palette.test.ts @@ -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" }, + }; +} diff --git a/packages/app/src/git/diff-document/palette.ts b/packages/app/src/git/diff-document/palette.ts new file mode 100644 index 000000000..9e0e81854 --- /dev/null +++ b/packages/app/src/git/diff-document/palette.ts @@ -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"; +} diff --git a/packages/app/src/git/diff-document/pointer-gesture.test.ts b/packages/app/src/git/diff-document/pointer-gesture.test.ts new file mode 100644 index 000000000..498712939 --- /dev/null +++ b/packages/app/src/git/diff-document/pointer-gesture.test.ts @@ -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); + }); +}); diff --git a/packages/app/src/git/diff-document/pointer-gesture.ts b/packages/app/src/git/diff-document/pointer-gesture.ts new file mode 100644 index 000000000..e5da48aa0 --- /dev/null +++ b/packages/app/src/git/diff-document/pointer-gesture.ts @@ -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; +} diff --git a/packages/app/src/git/diff-document/review-paint.ts b/packages/app/src/git/diff-document/review-paint.ts new file mode 100644 index 000000000..aea5a2acd --- /dev/null +++ b/packages/app/src/git/diff-document/review-paint.ts @@ -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(surface: Paint): Paint { + "worklet"; + return surface; +} diff --git a/packages/app/src/git/diff-document/surface.native.tsx b/packages/app/src/git/diff-document/surface.native.tsx new file mode 100644 index 000000000..8842cad3e --- /dev/null +++ b/packages/app/src/git/diff-document/surface.native.tsx @@ -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(null); + const rootRef = useRef(null); + const surfaceOriginRef = useRef({ x: 0, y: 0 }); + const previousModelRef = useRef | null>(null); + const consumedFocusRef = useRef(null); + const scrollTop = useSharedValue(0); + const horizontalOffsets = useSharedValue({}); + const family = props.displayPreferences.monoFontFamily.trim() || SYSTEM_MONO; + const typography = useMemo( + () => ({ + family, + size: props.displayPreferences.codeFontSize, + lineHeight: Math.round(props.displayPreferences.codeFontSize * 1.5), + }), + [family, props.displayPreferences.codeFontSize], + ); + const measurement = useMemo( + () => 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 ( + + index * 2)} + testID="git-diff-scroll" + onTouchStart={touchStart} + onTouchMove={touchMove} + onTouchEnd={touchEnd} + > + {model.files.flatMap((file) => [ + + + , + ({ + position: "relative", + height: file.bodyHeight, + })} + > + {!file.isCollapsed && !model.wrapLines ? ( + + ) : 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 [ + ({ + position: "absolute", + top: row.top - file.bodyTop + row.height - row.reviewHeight, + left: index * columnWidth, + width: columnWidth, + height: row.reviewHeight, + zIndex: 4, + })} + > + + , + ]; + }); + }) + : null} + , + ])} + + + + + + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, minHeight: 0, position: "relative", overflow: "hidden" }, + canvas: { zIndex: 1 }, + scroll: { zIndex: 2 }, + header: { zIndex: 3 }, +}); diff --git a/packages/app/src/git/diff-document/surface.tsx b/packages/app/src/git/diff-document/surface.tsx new file mode 100644 index 000000000..b68a62fb1 --- /dev/null +++ b/packages/app/src/git/diff-document/surface.tsx @@ -0,0 +1 @@ +export { DiffSurface } from "./surface.native"; diff --git a/packages/app/src/git/diff-document/surface.web.tsx b/packages/app/src/git/diff-document/surface.web.tsx new file mode 100644 index 000000000..7168e48ce --- /dev/null +++ b/packages/app/src/git/diff-document/surface.web.tsx @@ -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(null); + const canvasRef = useRef(null); + const scrollRef = useRef(null); + const modelRef = useRef | null>(null); + const previousModelRef = useRef | null>(null); + const consumedFocusRef = useRef(null); + const scrollTopRef = useRef(0); + const horizontalOffsetsRef = useRef(new Map()); + const selectionRef = useRef(null); + const dragRef = useRef<{ + anchor: DiffSelection["anchor"]; + startX: number; + startY: number; + moved: boolean; + } | null>(null); + const frameRef = useRef(null); + const [viewport, setViewport] = useState({ width: 0, height: 0 }); + const [loadedTypography, setLoadedTypography] = useState(null); + const [hoveredAffordance, setHoveredAffordance] = useState<{ + hit: Extract; + 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( + () => ({ + 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) => { + 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) => { + 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) => { + 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) => { + 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) => { + 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) => { + if (!selectionRef.current) return; + event.clipboardData.setData("text/plain", selectedSourceText(model, selectionRef.current)); + event.preventDefault(); + }, + [model], + ); + const rootStyle = useMemo( + () => ({ ...ROOT_STYLE, background: props.palette.surface }), + [props.palette.surface], + ); + const contentStyle = useMemo( + () => ({ ...CONTENT_STYLE, height: Math.max(model.height, viewport.height) }), + [model.height, viewport.height], + ); + const affordanceStyle = useMemo( + () => ({ + ...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( + () => ({ + ...CANVAS_STYLE, + fontFamily: (loadedTypography ?? desiredTypography).family, + fontSize: (loadedTypography ?? desiredTypography).size, + }), + [desiredTypography, loadedTypography], + ); + + return ( +
+
+
+ {model.files.map((file) => ( + + + + ))} + {model.files + .filter((file) => !file.isCollapsed && !model.wrapLines) + .map((file) => ( + + ))} + {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 ( + + ); + }); + }) + : null} +
+
+ + {hoveredAffordance?.hit.target && reviewActions ? ( + + ) : null} +
+ ); +} + +function WebFileHeaderSection({ + file, + children, +}: { + file: ReturnType["files"][number]; + children: React.ReactNode; +}) { + const style = useMemo( + () => ({ ...FILE_SECTION_STYLE, top: file.top, height: file.bottom - file.top }), + [file.bottom, file.top], + ); + return ( +
+
+ {children} +
+
+
+ ); +} + +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["reviewActions"]>; + top: number; + left: number; + width: number; + height: number; + pinToViewport: boolean; +}) { + const style = useMemo( + () => ({ ...REVIEW_STYLE, top, left, width, height }), + [height, left, top, width], + ); + return ( +
+ +
+ ); +} + +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 { + 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; + }); + }, + }; +} diff --git a/packages/app/src/git/diff-document/text-measurement.test.ts b/packages/app/src/git/diff-document/text-measurement.test.ts new file mode 100644 index 000000000..b0caea483 --- /dev/null +++ b/packages/app/src/git/diff-document/text-measurement.test.ts @@ -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; + }, + }; +} diff --git a/packages/app/src/git/diff-document/text-measurement.ts b/packages/app/src/git/diff-document/text-measurement.ts new file mode 100644 index 000000000..8b9b38847 --- /dev/null +++ b/packages/app/src/git/diff-document/text-measurement.ts @@ -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); + }, + }; +} diff --git a/packages/app/src/git/diff-document/text.native.ts b/packages/app/src/git/diff-document/text.native.ts new file mode 100644 index 000000000..db6fe7876 --- /dev/null +++ b/packages/app/src/git/diff-document/text.native.ts @@ -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>>; +} + +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; +}): 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"])]; +} diff --git a/packages/app/src/git/diff-document/types.ts b/packages/app/src/git/diff-document/types.ts new file mode 100644 index 000000000..72a357337 --- /dev/null +++ b/packages/app/src/git/diff-document/types.ts @@ -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; +} + +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; + 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; + onToggleFile: (path: string) => void; + selectedPath: string | null; + onSelectPath: (path: string) => void; +}; diff --git a/packages/app/src/git/diff-flat-items.test.ts b/packages/app/src/git/diff-flat-items.test.ts deleted file mode 100644 index a3df76492..000000000 --- a/packages/app/src/git/diff-flat-items.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/app/src/git/diff-flat-items.ts b/packages/app/src/git/diff-flat-items.ts deleted file mode 100644 index cd60dd97c..000000000 --- a/packages/app/src/git/diff-flat-items.ts +++ /dev/null @@ -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; - /** File paths whose diff body is expanded. */ - expandedPaths: ReadonlySet; - /** - * 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); -} diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index b2eaebd9f..7d5fa33cc 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -1,34 +1,18 @@ -import { - useState, - useCallback, - useEffect, - useMemo, - useRef, - useSyncExternalStore, - memo, - type ReactElement, - type ReactNode, -} from "react"; +import { useState, useCallback, useMemo, type ReactElement } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; -import { DiffStat } from "@/components/diff-stat"; import { TreeRail } from "@/components/tree-rail"; import { View, Text, Pressable, FlatList, - type LayoutChangeEvent, - type NativeSyntheticEvent, - type NativeScrollEvent, type PressableStateCallbackType, - type FlatListProps, type StyleProp, type ViewStyle, - type TextStyle, } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { BORDER_WIDTH, ICON_SIZE, SPACING, type Theme } from "@/styles/theme"; +import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme"; import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { AlignJustify, @@ -36,44 +20,26 @@ import { Columns2, FolderTree, Maximize2, + Minimize2, Pilcrow, RotateCw, WrapText, } from "lucide-react-native"; -import { type ParsedDiffFile, type DiffLine, type HighlightToken } from "@/git/use-diff-query"; -import { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "@/git/diff-flat-items"; +import { type ParsedDiffFile } from "@/git/use-diff-query"; +import { DiffDocument, type WorkingDiffMode } from "@/git/diff-document"; +import { FileHeader } from "@/git/file-header"; import { - createDiffRowWindow, - createDiffViewport, - type DiffRowWindow, - type DiffViewport, -} from "@/git/diff-virtualization"; -import { buildDiffTree, collectDirPaths, compressSingleChildChains } from "@/git/diff-tree"; + buildDiffTree, + collectDirPaths, + compressSingleChildChains, + flattenDiffTree, + type DiffTreeRow, +} from "@/git/diff-tree"; import { DiffFolderRow } from "@/git/diff-folder-row"; -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 { MaterialFileIcon } from "@/components/material-file-icon"; -import { FileChangeIcon } from "@/components/file-change-icon"; import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; import { CommitsSection } from "@/git/commits-section/commits-section"; import { useChangesPreferences } from "@/hooks/use-changes-preferences"; import { useAppSettings } from "@/hooks/use-settings"; -import { DiffScroll } from "@/components/diff-scroll"; -import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; -import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; -import { - buildSplitDiffRows, - buildUnifiedDiffLines, - type ReviewableDiffTarget, - type SplitDiffDisplayLine, - type SplitDiffRow, -} from "@/utils/diff-layout"; import { DropdownMenu, DropdownMenuContent, @@ -82,13 +48,10 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import * as Clipboard from "expo-clipboard"; -import { FileActionsContextMenuContent } from "@/components/file-actions-menu"; -import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu"; import { useFileDownload } from "@/hooks/use-file-download"; import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; -import { lineNumberGutterWidth } from "@/components/code-insets"; import { GitActionsSplitButton } from "@/git/actions-split-button"; import type { GitActions } from "@/git/policy"; import { BranchSwitcher } from "@/components/branch-switcher"; @@ -102,29 +65,11 @@ import { useToast } from "@/contexts/toast-context"; import { useSessionStore } from "@/stores/session-store"; import { confirmDialog } from "@/utils/confirm-dialog"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; -import { useOverlayFlatListScrollbar } from "@/components/ui/overlay-scrollbar/use-overlay-flat-list-scrollbar"; -import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { usePanelStore } from "@/stores/panel-store"; import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; -import { - compactHighlightTokens, - formatDiffContentText, - formatDiffGutterText, - hasVisibleDiffTokens, -} from "@/utils/diff-rendering"; -import { isWeb, isNative } from "@/constants/platform"; -import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source"; -import { - type ReviewDraftComment, - getInlineReviewThreadState, - getSplitInlineReviewThreadState, - InlineReviewGutterCell, - InlineReviewThread, - isInlineReviewEditorForTarget, - type InlineReviewActions, -} from "@/review"; +import { isWeb } from "@/constants/platform"; import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff"; import { DiffTooLargeState } from "@/git/diff-too-large-state"; import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets"; @@ -138,118 +83,6 @@ export function resolveDiffLayout( return canUseSplitLayout ? layout : "unified"; } -function fileHeaderPressableStyle( - { hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }, - isSelected: boolean, -) { - return [ - styles.fileHeader, - (Boolean(hovered) || pressed || isSelected) && styles.fileHeaderActive, - ]; -} - -interface HighlightedTextProps { - tokens: HighlightToken[]; - textMetricsStyle: TextStyle; - wrapLines?: boolean; - testID?: string; -} - -type WrappedWebTextStyle = TextStyle & { - whiteSpace?: "pre" | "pre-wrap"; - overflowWrap?: "normal" | "anywhere"; -}; - -function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined { - if (isNative) { - return undefined; - } - return wrapLines - ? { whiteSpace: "pre-wrap", overflowWrap: "anywhere" } - : { whiteSpace: "pre", overflowWrap: "normal" }; -} - -function getNumericLineHeight(textMetricsStyle: TextStyle): number | undefined { - const { lineHeight } = textMetricsStyle; - return typeof lineHeight === "number" && Number.isFinite(lineHeight) ? lineHeight : undefined; -} - -function useDiffRowMetricsStyle(textMetricsStyle: TextStyle): StyleProp { - const lineHeight = getNumericLineHeight(textMetricsStyle); - return useMemo( - () => (lineHeight !== undefined ? inlineUnistylesStyle({ minHeight: lineHeight }) : null), - [lineHeight], - ); -} - -function HighlightedToken({ token }: { token: HighlightToken }) { - return {token.text}; -} - -const HighlightedText = memo(function HighlightedText({ - tokens, - textMetricsStyle, - wrapLines = false, - testID, -}: HighlightedTextProps) { - const containerStyle = useMemo( - () => [ - styles.diffTextMetrics, - textMetricsStyle, - styles.diffLineText, - getWrappedTextStyle(wrapLines), - ], - [textMetricsStyle, wrapLines], - ); - - const keyedTokens = useMemo(() => { - let characterOffset = 0; - return compactHighlightTokens(tokens).map((token) => { - const keyedToken = { key: `${characterOffset}-${token.style ?? "base"}`, token }; - characterOffset += token.text.length; - return keyedToken; - }); - }, [tokens]); - - return ( - - {keyedTokens.map(({ key, token }) => ( - - ))} - - ); -}); - -interface DiffFileSectionProps { - file: ParsedDiffFile; - workspaceFileDragScope?: { serverId: string; workspaceId: string }; - bodyVisible: boolean; - showsBodyState?: boolean; - isSelected?: boolean; - /** Tree indentation level (0 on the flat/mobile path). */ - depth?: number; - /** Show the muted directory suffix (flat list); false inside the folder tree. */ - 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; -} - -const EMPTY_COMMENTS: readonly ReviewDraftComment[] = []; - -function noopStartComment(): void {} - function useDiscardChangesAction({ serverId, cwd, @@ -301,1275 +134,6 @@ function useDiscardChangesAction({ return discardSupported && diffMode === "uncommitted" ? handleDiscardPath : undefined; } -const DIFF_LINE_HOVER_STYLE = isWeb ? ({ cursor: "auto" } as const) : null; - -function LongPressableLine({ - reviewTarget, - reviewActions, - onHoverChange, - hoverTargetKey, - onHoverTargetChange, - style, - children, -}: { - reviewTarget: ReviewableDiffTarget | null | undefined; - reviewActions: InlineReviewActions | undefined; - onHoverChange?: (hovered: boolean) => void; - hoverTargetKey?: string | null; - onHoverTargetChange?: (key: string | null) => void; - style: StyleProp; - children: ReactNode; -}) { - const onStartComment = reviewActions?.onStartComment; - const handlePress = useCallback(() => { - const selection = isWeb ? window.getSelection() : null; - if (selection && !selection.isCollapsed && selection.toString().length > 0) { - return; - } - if (reviewTarget && onStartComment) { - onStartComment(reviewTarget); - } - }, [reviewTarget, onStartComment]); - - const handleHoverIn = useCallback(() => { - onHoverChange?.(true); - if (hoverTargetKey) { - onHoverTargetChange?.(hoverTargetKey); - } - }, [hoverTargetKey, onHoverChange, onHoverTargetChange]); - const handleHoverOut = useCallback(() => { - onHoverChange?.(false); - if (hoverTargetKey) { - onHoverTargetChange?.(null); - } - }, [hoverTargetKey, onHoverChange, onHoverTargetChange]); - const hoverStyle = useMemo(() => [style, DIFF_LINE_HOVER_STYLE], [style]); - - if (isWeb && (onHoverChange || onHoverTargetChange)) { - return ( - - {children} - - ); - } - - if (!isNative || !reviewTarget || !onStartComment) { - return {children}; - } - return ( - - {children} - - ); -} - -function lineTypeBackground(type: DiffLine["type"] | undefined | null) { - if (!type) return styles.emptySplitCell; - if (type === "add") return styles.addLineContainer; - if (type === "remove") return styles.removeLineContainer; - if (type === "header") return styles.headerLineContainer; - return styles.contextLineContainer; -} - -function DiffGutterCell({ - lineNumber, - type, - gutterWidth, - textMetricsStyle, - reviewTarget, - reviewActions, - isLineHovered, - style, - textTestID, - actionTestID, -}: { - lineNumber: number | null; - type: DiffLine["type"] | undefined | null; - gutterWidth: number; - textMetricsStyle: TextStyle; - reviewTarget?: ReviewableDiffTarget | null; - reviewActions?: InlineReviewActions; - isLineHovered?: boolean; - style?: StyleProp; - textTestID?: string; - actionTestID?: string; -}) { - const lineHeight = getNumericLineHeight(textMetricsStyle); - const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle); - const containerStyle = useMemo( - () => [ - styles.gutterCell, - lineTypeBackground(type), - rowMetricsStyle, - inlineUnistylesStyle({ width: gutterWidth }), - style, - ], - [type, rowMetricsStyle, gutterWidth, style], - ); - const textStyle = useMemo( - () => [ - styles.diffTextMetrics, - textMetricsStyle, - styles.lineNumberText, - type === "add" && styles.addLineNumberText, - type === "remove" && styles.removeLineNumberText, - ], - [textMetricsStyle, type], - ); - const comments = useMemo( - () => - reviewTarget - ? (reviewActions?.commentsByTarget.get(reviewTarget.key) ?? EMPTY_COMMENTS) - : EMPTY_COMMENTS, - [reviewTarget, reviewActions?.commentsByTarget], - ); - const isEditorOpen = isInlineReviewEditorForTarget(reviewActions?.editor ?? null, reviewTarget); - const onStartComment = reviewActions?.onStartComment ?? noopStartComment; - - return ( - - - {formatDiffGutterText(lineNumber)} - - - ); -} - -function DiffTextLine({ - line, - wrapLines, - textMetricsStyle, - reviewTarget, - reviewActions, - onHoverChange, - hoverTargetKey, - onHoverTargetChange, - textTestID, -}: { - line: DiffLine; - wrapLines: boolean; - textMetricsStyle: TextStyle; - reviewTarget?: ReviewableDiffTarget | null; - reviewActions?: InlineReviewActions; - onHoverChange?: (hovered: boolean) => void; - hoverTargetKey?: string | null; - onHoverTargetChange?: (key: string | null) => void; - textTestID?: string; -}) { - const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null; - const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle); - - const containerStyle = useMemo( - () => [styles.textLineContainer, lineTypeBackground(line.type), rowMetricsStyle], - [line.type, rowMetricsStyle], - ); - const textStyle = useMemo( - () => [ - styles.diffTextMetrics, - textMetricsStyle, - styles.diffLineText, - getWrappedTextStyle(wrapLines), - line.type === "add" && styles.addLineText, - line.type === "remove" && styles.removeLineText, - line.type === "header" && styles.headerLineText, - line.type === "context" && styles.contextLineText, - ], - [line.type, textMetricsStyle, wrapLines], - ); - - return ( - - {line.type !== "header" && visibleTokens ? ( - - ) : ( - - {formatDiffContentText(line.content)} - - )} - - ); -} - -function SplitTextLine({ - line, - wrapLines, - textMetricsStyle, - reviewActions, - onHoverChange, - hoverTargetKey, - onHoverTargetChange, -}: { - line: SplitDiffDisplayLine | null; - wrapLines: boolean; - textMetricsStyle: TextStyle; - reviewActions?: InlineReviewActions; - onHoverChange?: (hovered: boolean) => void; - hoverTargetKey?: string | null; - onHoverTargetChange?: (key: string | null) => void; -}) { - const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null; - const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle); - - const containerStyle = useMemo( - () => [styles.textLineContainer, lineTypeBackground(line?.type), rowMetricsStyle], - [line?.type, rowMetricsStyle], - ); - const textStyle = useMemo( - () => [ - styles.diffTextMetrics, - textMetricsStyle, - styles.diffLineText, - getWrappedTextStyle(wrapLines), - line?.type === "add" && styles.addLineText, - line?.type === "remove" && styles.removeLineText, - line?.type === "context" && styles.contextLineText, - !line && styles.emptySplitCellText, - ], - [line, textMetricsStyle, wrapLines], - ); - - return ( - - {visibleTokens ? ( - - ) : ( - {formatDiffContentText(line?.content)} - )} - - ); -} - -function DiffLineView({ - line, - lineNumber, - gutterWidth, - wrapLines, - textMetricsStyle, - reviewTarget, - reviewActions, -}: { - line: DiffLine; - lineNumber: number | null; - gutterWidth: number; - wrapLines: boolean; - textMetricsStyle: TextStyle; - reviewTarget?: ReviewableDiffTarget | null; - reviewActions?: InlineReviewActions; -}) { - const [isLineHovered, setIsLineHovered] = useState(false); - const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null; - const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle); - - const containerStyle = useMemo( - () => [styles.diffLineContainer, lineTypeBackground(line.type), rowMetricsStyle], - [line.type, rowMetricsStyle], - ); - const textStyle = useMemo( - () => [ - styles.diffTextMetrics, - textMetricsStyle, - styles.diffLineText, - getWrappedTextStyle(wrapLines), - line.type === "add" && styles.addLineText, - line.type === "remove" && styles.removeLineText, - line.type === "header" && styles.headerLineText, - line.type === "context" && styles.contextLineText, - ], - [line.type, textMetricsStyle, wrapLines], - ); - - return ( - - - {line.type !== "header" && visibleTokens ? ( - - ) : ( - {formatDiffContentText(line.content)} - )} - - ); -} - -function SplitDiffLine({ - line, - gutterWidth, - wrapLines, - textMetricsStyle, - reviewActions, -}: { - line: SplitDiffDisplayLine | null; - gutterWidth: number; - wrapLines: boolean; - textMetricsStyle: TextStyle; - reviewActions?: InlineReviewActions; -}) { - const [isLineHovered, setIsLineHovered] = useState(false); - const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null; - const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle); - - const containerStyle = useMemo( - () => [styles.diffLineContainer, lineTypeBackground(line?.type), rowMetricsStyle], - [line?.type, rowMetricsStyle], - ); - const textStyle = useMemo( - () => [ - styles.diffTextMetrics, - textMetricsStyle, - styles.diffLineText, - getWrappedTextStyle(wrapLines), - line?.type === "add" && styles.addLineText, - line?.type === "remove" && styles.removeLineText, - line?.type === "context" && styles.contextLineText, - !line && styles.emptySplitCellText, - ], - [line, textMetricsStyle, wrapLines], - ); - - return ( - - - {visibleTokens ? ( - - ) : ( - {formatDiffContentText(line?.content)} - )} - - ); -} - -function InlineReviewThreadContent({ - reviewTarget, - reviewActions, - reservedHeight, - viewportWidth, - pinToViewport, -}: { - reviewTarget: ReviewableDiffTarget | null | undefined; - reviewActions?: InlineReviewActions; - reservedHeight?: number; - viewportWidth?: number; - pinToViewport?: boolean; -}) { - const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions }); - const height = reservedHeight ?? threadState?.height ?? 0; - const placeholderStyle = useMemo( - () => inlineUnistylesStyle({ minHeight: height }), - [height], - ); - if (height === 0) { - return null; - } - if (!reviewTarget || !reviewActions || !threadState) { - return ; - } - - return ( - - ); -} - -function InlineReviewGutterSpacer({ - reviewTarget, - reviewActions, - gutterWidth, - reservedHeight, - style, -}: { - reviewTarget: ReviewableDiffTarget | null | undefined; - reviewActions?: InlineReviewActions; - gutterWidth: number; - reservedHeight?: number; - style?: StyleProp; -}) { - const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions }); - const height = reservedHeight ?? threadState?.height ?? 0; - const spacerStyle = useMemo>( - () => [ - styles.inlineReviewGutterSpacer, - inlineUnistylesStyle({ width: gutterWidth, minHeight: height }), - style, - ], - [gutterWidth, height, style], - ); - if (height === 0) { - return null; - } - - return ; -} - -function InlineReviewRow({ - reviewTarget, - reviewActions, - gutterWidth, - reservedHeight, -}: { - reviewTarget: ReviewableDiffTarget | null | undefined; - reviewActions?: InlineReviewActions; - gutterWidth: number; - reservedHeight?: number; -}) { - const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions }); - const height = reservedHeight ?? threadState?.height ?? 0; - const gutterSpacerStyle = useMemo>( - () => [styles.inlineReviewGutterSpacer, inlineUnistylesStyle({ width: gutterWidth })], - [gutterWidth], - ); - const placeholderStyle = useMemo( - () => inlineUnistylesStyle({ minHeight: height }), - [height], - ); - if (height === 0) { - return null; - } - - return ( - - - {reviewTarget && reviewActions && threadState ? ( - - ) : ( - - )} - - ); -} - -const DIFF_ROW_OVERSCAN = 900; -const FULL_DIFF_VIEWPORT = { top: 0, height: Number.MAX_SAFE_INTEGER }; -const subscribeToNothing = () => () => {}; -const getFullDiffViewport = () => FULL_DIFF_VIEWPORT; - -function useDiffRowWindow({ - viewport, - bodyOffset, - rowHeights, - enabled, -}: { - viewport?: DiffViewport; - bodyOffset: number; - rowHeights: readonly number[]; - enabled: boolean; -}): DiffRowWindow { - const viewportSnapshot = useSyncExternalStore( - viewport?.subscribe ?? subscribeToNothing, - viewport?.getSnapshot ?? getFullDiffViewport, - viewport?.getSnapshot ?? getFullDiffViewport, - ); - return useMemo(() => { - if (!enabled) { - return createDiffRowWindow(rowHeights, { - viewportTop: 0, - viewportHeight: Number.MAX_SAFE_INTEGER, - overscan: 0, - }); - } - return createDiffRowWindow(rowHeights, { - viewportTop: viewportSnapshot.top - bodyOffset - BORDER_WIDTH[1], - viewportHeight: viewportSnapshot.height, - overscan: DIFF_ROW_OVERSCAN, - }); - }, [bodyOffset, enabled, rowHeights, viewportSnapshot.height, viewportSnapshot.top]); -} - -function DiffRowSpacer({ height }: { height: number }) { - if (height <= 0) { - return null; - } - return ; -} - -function SplitDiffColumn({ - rows, - rowWindow, - side, - gutterWidth, - wrapLines, - textMetricsStyle, - reviewActions, - showDivider = false, -}: { - rows: SplitDiffRow[]; - rowWindow: DiffRowWindow; - side: "left" | "right"; - gutterWidth: number; - wrapLines: boolean; - textMetricsStyle: TextStyle; - reviewActions?: InlineReviewActions; - showDivider?: boolean; -}) { - const [scrollWidth, setScrollWidth] = useState(0); - const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState(null); - - const wrapCellStyle = useMemo( - () => [styles.splitCell, showDivider && styles.splitCellWithDivider], - [showDivider], - ); - const rowCellStyle = useMemo( - () => [styles.splitCell, showDivider && styles.splitCellWithDivider, styles.splitCellRow], - [showDivider], - ); - const linesContainerRowStyle = useMemo( - () => [ - styles.linesContainer, - scrollWidth > 0 && inlineUnistylesStyle({ minWidth: scrollWidth }), - ], - [scrollWidth], - ); - const headerLineTextStyle = useMemo( - () => [styles.diffTextMetrics, textMetricsStyle, styles.diffLineText, styles.headerLineText], - [textMetricsStyle], - ); - - const keyedRows = useMemo( - () => - rows.slice(rowWindow.startIndex, rowWindow.endIndex).map((row, index) => ({ - key: `row-${rowWindow.startIndex + index}`, - row, - })), - [rowWindow.endIndex, rowWindow.startIndex, rows], - ); - - if (wrapLines) { - return ( - - - - {keyedRows.map(({ key, row }) => { - if (row.kind === "header") { - return ( - - {row.content} - - ); - } - const line = side === "left" ? row.left : row.right; - const reviewRowState = getSplitInlineReviewThreadState({ - left: row.left?.reviewTarget, - right: row.right?.reviewTarget, - reviewActions, - }); - return ( - - - - - ); - })} - - - - ); - } - - return ( - - - - {keyedRows.map(({ key, row }) => { - if (row.kind === "header") { - return ( - - ); - } - const line = side === "left" ? row.left : row.right; - const reviewTargetKey = line?.reviewTarget?.key ?? null; - const reviewRowState = getSplitInlineReviewThreadState({ - left: row.left?.reviewTarget, - right: row.right?.reviewTarget, - reviewActions, - }); - return ( - - - - - ); - })} - - - - - - {keyedRows.map(({ key, row }) => { - if (row.kind === "header") { - return ( - - {row.content} - - ); - } - const line = side === "left" ? row.left : row.right; - const reviewTargetKey = line?.reviewTarget?.key ?? null; - const reviewRowState = getSplitInlineReviewThreadState({ - left: row.left?.reviewTarget, - right: row.right?.reviewTarget, - reviewActions, - }); - return ( - - - - - ); - })} - - - - - ); -} - -function DiffFileActionsContextMenuContent({ - file, - onOpenFile, - onAddToChat, - onCopyPath, - onCopyRelativePath, - onReveal, - revealTargetName, - onDownload, - onDuplicate, - onRevert, - testID, -}: Pick< - DiffFileSectionProps, - | "file" - | "onOpenFile" - | "onAddToChat" - | "onCopyPath" - | "onCopyRelativePath" - | "onReveal" - | "revealTargetName" - | "onDownload" - | "onDuplicate" - | "onRevert" - | "testID" ->) { - const handleOpenFile = useCallback(() => onOpenFile?.(file.path), [file.path, onOpenFile]); - const handleAddToChat = useCallback(() => onAddToChat?.(file.path), [file.path, onAddToChat]); - const handleCopyPath = useCallback(() => onCopyPath?.(file.path), [file.path, onCopyPath]); - const handleCopyRelativePath = useCallback( - () => onCopyRelativePath?.(file.path), - [file.path, onCopyRelativePath], - ); - const handleReveal = useCallback(() => onReveal?.(file.path), [file.path, onReveal]); - const handleDownload = useCallback(() => onDownload?.(file.path), [file.path, onDownload]); - const handleDuplicate = useCallback(() => onDuplicate?.(file.path), [file.path, onDuplicate]); - const handleRevert = useCallback( - () => onRevert?.(file.path, file.oldPath), - [file.oldPath, file.path, onRevert], - ); - - return ( - - ); -} - -const DiffFileHeader = memo(function DiffFileHeader({ - file, - workspaceFileDragScope, - bodyVisible, - showsBodyState = true, - isSelected = false, - depth = 0, - showDir = true, - interactive = true, - onActivate, - onSelect, - onOpenFile, - onAddToChat, - onCopyPath, - onCopyRelativePath, - onReveal, - revealTargetName, - onDownload, - onDuplicate, - onRevert, - onHeaderHeightChange, - testID, -}: DiffFileSectionProps) { - const dragSourceRef = useWorkspaceFileDragSource({ - enabled: interactive, - disabled: file.isDeleted, - workspaceId: null, - path: file.path, - ...workspaceFileDragScope, - }); - const layoutYRef = useRef(null); - const pressHandledRef = useRef(false); - const pressInRef = useRef<{ ts: number; pageX: number; pageY: number } | null>(null); - - const handleSelect = useCallback(() => { - if (interactive) { - onSelect?.(file.path); - } - }, [file.path, interactive, onSelect]); - - const handleActivate = useCallback(() => { - if (!interactive) { - return; - } - const selection = isWeb ? window.getSelection() : null; - if (selection && !selection.isCollapsed && selection.toString().length > 0) { - return; - } - pressHandledRef.current = true; - handleSelect(); - onActivate?.(file.path); - }, [file.path, handleSelect, interactive, onActivate]); - - const handleLayout = useCallback( - (event: LayoutChangeEvent) => { - layoutYRef.current = event.nativeEvent.layout.y; - onHeaderHeightChange?.(file.path, event.nativeEvent.layout.height); - }, - [file.path, onHeaderHeightChange], - ); - - const handlePressIn = useCallback((event: { nativeEvent: { pageX: number; pageY: number } }) => { - pressHandledRef.current = false; - pressInRef.current = { - ts: Date.now(), - pageX: event.nativeEvent.pageX, - pageY: event.nativeEvent.pageY, - }; - }, []); - - const handleLongPress = useCallback(() => { - pressHandledRef.current = true; - handleSelect(); - }, [handleSelect]); - - const handlePressOut = useCallback( - (event: { nativeEvent: { pageX: number; pageY: number } }) => { - if ( - interactive && - isNative && - !pressHandledRef.current && - layoutYRef.current === 0 && - pressInRef.current - ) { - const durationMs = Date.now() - pressInRef.current.ts; - const dx = event.nativeEvent.pageX - pressInRef.current.pageX; - const dy = event.nativeEvent.pageY - pressInRef.current.pageY; - const distance = Math.hypot(dx, dy); - if (durationMs <= 500 && distance <= 12) { - handleActivate(); - } - } - }, - [handleActivate, interactive], - ); - - const containerStyle = useMemo( - () => [styles.fileSectionHeaderContainer, bodyVisible && styles.fileSectionHeaderExpanded], - [bodyVisible], - ); - const accessibilityState = useMemo( - () => ({ ...(showsBodyState ? { expanded: bodyVisible } : {}), selected: isSelected }), - [bodyVisible, isSelected, showsBodyState], - ); - - const headerPressableStyle = useCallback( - (state: PressableStateCallbackType) => - depth > 0 - ? [ - fileHeaderPressableStyle(state, isSelected), - inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) }), - ] - : fileHeaderPressableStyle(state, isSelected), - [depth, isSelected], - ); - - const fileName = file.path.split("/").pop() ?? file.path; - const headerContent = ( - <> - - {showDir ? null : ( - - - - )} - - {fileName} - - {showDir ? ( - - {file.path.includes("/") ? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}` : ""} - - ) : ( - // Flex spacer in tree mode (no dir suffix) so the New/Deleted badge - // stays right-aligned next to the diff stats, as in the flat list. - - )} - {file.isNew && } - {file.isDeleted && } - - - - - - ); - - let trigger: ReactElement; - if (!interactive) { - trigger = ( - void }) => event.preventDefault?.(), - }} - style={headerPressableStyle({ hovered: false, pressed: false })} - > - {headerContent} - - ); - } else { - trigger = ( - - {headerContent} - - ); - } - - return ( - - - - - {trigger} - - {file.path} - - - {interactive ? ( - - ) : null} - - - ); -}); - -export function DiffFileBody({ - file, - viewport, - bodyOffset = 0, - layout, - wrapLines, - codeFontSize, - textMetricsStyle, - reviewActions, - onBodyHeightChange, - testID, -}: { - file: ParsedDiffFile; - viewport?: DiffViewport; - bodyOffset?: number; - layout: "unified" | "split"; - wrapLines: boolean; - codeFontSize: number; - textMetricsStyle: TextStyle; - reviewActions?: InlineReviewActions; - onBodyHeightChange?: (file: ParsedDiffFile, height: number) => void; - testID?: string; -}) { - const [scrollViewWidth, setScrollViewWidth] = useState(0); - const [bodyWidth, setBodyWidth] = useState(0); - const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState(null); - const { t } = useTranslation(); - const canRenderRows = file.status !== "too_large" && file.status !== "binary"; - const splitRows = useMemo( - () => (canRenderRows && layout === "split" ? getCachedSplitDiffRows(file) : []), - [canRenderRows, file, layout], - ); - const unifiedLines = useMemo( - () => (canRenderRows && layout === "unified" ? getCachedUnifiedDiffLines(file) : []), - [canRenderRows, file, layout], - ); - const rowHeights = useMemo(() => { - const lineHeight = getNumericLineHeight(textMetricsStyle) ?? Math.round(codeFontSize * 1.5); - if (layout === "split") { - return splitRows.map((row) => { - if (row.kind === "header") { - return lineHeight; - } - return ( - lineHeight + - (getSplitInlineReviewThreadState({ - left: row.left?.reviewTarget, - right: row.right?.reviewTarget, - reviewActions, - })?.height ?? 0) - ); - }); - } - return unifiedLines.map( - ({ reviewTarget }) => - lineHeight + (getInlineReviewThreadState({ reviewTarget, reviewActions })?.height ?? 0), - ); - }, [codeFontSize, layout, reviewActions, splitRows, textMetricsStyle, unifiedLines]); - const rowWindow = useDiffRowWindow({ - viewport, - bodyOffset, - rowHeights, - enabled: Boolean(viewport) && canRenderRows && !wrapLines, - }); - const visibleUnifiedLines = useMemo( - () => - unifiedLines - .slice(rowWindow.startIndex, rowWindow.endIndex) - .map((entry, index) => ({ entry, index: rowWindow.startIndex + index })), - [rowWindow.endIndex, rowWindow.startIndex, unifiedLines], - ); - - const handleLayout = useCallback( - (event: LayoutChangeEvent) => { - setBodyWidth(event.nativeEvent.layout.width); - onBodyHeightChange?.(file, event.nativeEvent.layout.height); - }, - [file, onBodyHeightChange], - ); - - const availableWidth = bodyWidth > 0 ? bodyWidth : scrollViewWidth; - const linesContainerRowStyle = useMemo( - () => [ - styles.linesContainer, - availableWidth > 0 && inlineUnistylesStyle({ minWidth: availableWidth }), - ], - [availableWidth], - ); - - return ( - - {(() => { - if (file.status === "too_large" || file.status === "binary") { - return ( - - - {file.status === "binary" - ? t("workspace.git.diff.binaryFile") - : t("workspace.git.diff.tooLarge")} - - - ); - } - - let maxLineNo = 0; - for (const hunk of file.hunks) { - maxLineNo = Math.max( - maxLineNo, - hunk.oldStart + hunk.oldCount, - hunk.newStart + hunk.newCount, - ); - } - const gutterWidth = lineNumberGutterWidth(maxLineNo, codeFontSize); - - if (layout === "split") { - return ( - - - - - ); - } - - if (wrapLines) { - return ( - - - - {visibleUnifiedLines.map( - ({ entry: { line, lineNumber, key, reviewTarget }, index }) => ( - - - - - ), - )} - - - - ); - } - - const textViewportWidth = - scrollViewWidth > 0 ? scrollViewWidth : Math.max(0, bodyWidth - gutterWidth); - return ( - - - - {visibleUnifiedLines.map( - ({ entry: { line, lineNumber, key, reviewTarget }, index }) => ( - - - - - ), - )} - - - - - - {visibleUnifiedLines.map(({ entry: { line, key, reviewTarget }, index }) => ( - - - - - ))} - - - - - ); - })()} - - ); -} - interface ChangesSurfaceProps { serverId: string; workspaceId?: string | null; @@ -1595,6 +159,7 @@ const ThemedPilcrow = withUnistyles(Pilcrow); const ThemedWrapText = withUnistyles(WrapText); const ThemedFolderTree = withUnistyles(FolderTree); const ThemedMaximize2 = withUnistyles(Maximize2); +const ThemedMinimize2 = withUnistyles(Minimize2); const ThemedChevronDown = withUnistyles(ChevronDown); const DIFF_OPTIONS_WHITESPACE_ICON = ( @@ -1610,7 +175,6 @@ interface DiffLayoutToggleProps { toggleStyle?: PressableStyleFn; onToggle: () => void; } - export function DiffLayoutToggle({ layout, isMobile, @@ -1802,8 +366,51 @@ function DesktopTreeToggle({ visible, toggleStyle, onToggle }: DesktopTreeToggle ); } +interface CollapseAllToggleProps { + allCollapsed: boolean; + isMobile: boolean; + onCollapseAll: () => void; + onExpandAll: () => void; +} + +function CollapseAllToggle({ + allCollapsed, + isMobile, + onCollapseAll, + onExpandAll, +}: CollapseAllToggleProps) { + const { t } = useTranslation(); + const buttonStyle = useMemo(() => buildToggleButtonStyle(false, styles.toolbarIconButton), []); + const label = t( + allCollapsed ? "workspace.git.diff.expandAllFiles" : "workspace.git.diff.collapseAllFiles", + ); + return ( + + + + {allCollapsed ? ( + + ) : ( + + )} + + + + {label} + + + ); +} + interface ChangesToolbarProps { branchName: string | null; + allFilesCollapsed: boolean; canUseSplitLayout: boolean; changesTabOpen: boolean; committedDescription?: string; @@ -1826,6 +433,8 @@ interface ChangesToolbarProps { workspaceId?: string | null; wrapLines: boolean; onRefresh: () => void; + onCollapseAll: () => void; + onExpandAll: () => void; onSelectBase: () => void; onSelectUncommitted: () => void; onToggleChangesTab: () => void; @@ -1837,6 +446,7 @@ interface ChangesToolbarProps { function ChangesToolbar({ branchName, + allFilesCollapsed, canUseSplitLayout, changesTabOpen, committedDescription, @@ -1859,6 +469,8 @@ function ChangesToolbar({ workspaceId, wrapLines, onRefresh, + onCollapseAll, + onExpandAll, onSelectBase, onSelectUncommitted, onToggleChangesTab, @@ -1893,6 +505,14 @@ function ChangesToolbar({ selected={changesTabOpen} onPress={onToggleChangesTab} /> + {hasFiles ? ( + + ) : null} {canUseSplitLayout && !changesTabOpen ? ( ["getItemLayout"]>; const EMPTY_PATH_LIST: string[] = []; -interface DiffFileMetrics { - contentLength: number; - splitRows?: SplitDiffRow[]; - splitLineCount?: number; - unifiedLines?: ReturnType; - unifiedLineCount: number; -} - -const diffFileMetricsCache = new WeakMap(); - -function getDiffFileMetrics(file: ParsedDiffFile): DiffFileMetrics { - const cached = diffFileMetricsCache.get(file); - if (cached) { - return cached; - } - let contentLength = 0; - let unifiedLineCount = 0; - for (const hunk of file.hunks) { - unifiedLineCount += hunk.lines.length; - for (const line of hunk.lines) { - contentLength += line.content.length; - } - } - const metrics = { contentLength, unifiedLineCount }; - diffFileMetricsCache.set(file, metrics); - return metrics; -} - -function getSplitDiffLineCount(file: ParsedDiffFile): number { - const metrics = getDiffFileMetrics(file); - if (metrics.splitLineCount === undefined) { - metrics.splitLineCount = getCachedSplitDiffRows(file).length; - } - return metrics.splitLineCount; -} - -function getCachedSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] { - const metrics = getDiffFileMetrics(file); - metrics.splitRows ??= buildSplitDiffRows(file); - return metrics.splitRows; -} - -function getCachedUnifiedDiffLines(file: ParsedDiffFile): ReturnType { - const metrics = getDiffFileMetrics(file); - metrics.unifiedLines ??= buildUnifiedDiffLines(file); - return metrics.unifiedLines; -} - function computeEmptyMessage( hideWhitespace: boolean, diffMode: "uncommitted" | "base", @@ -2186,431 +757,6 @@ function DiffBodyContent({ return children; } -interface DiffDocumentProps { - files: ParsedDiffFile[]; - displayPreferences: { - layout: "unified" | "split"; - wrapLines: boolean; - codeFontSize: number; - monoFontFamily: string; - }; - mode: - | { - 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; - } - | { - kind: "commit"; - }; -} - -type WorkingDiffMode = Extract; - -export function DiffDocument({ files, displayPreferences, mode }: DiffDocumentProps) { - const isCompact = useIsCompactFormFactor(); - const { layout, wrapLines, codeFontSize, monoFontFamily } = displayPreferences; - const diffBodyLineHeight = Math.round(codeFontSize * 1.5); - const typographyKey = [monoFontFamily, codeFontSize, diffBodyLineHeight].join(":"); - const textMetricsStyle = useMemo(() => { - const trimmedMonoFontFamily = monoFontFamily.trim(); - return { - fontSize: codeFontSize, - lineHeight: diffBodyLineHeight, - ...(trimmedMonoFontFamily ? { fontFamily: trimmedMonoFontFamily } : null), - }; - }, [codeFontSize, diffBodyLineHeight, monoFontFamily]); - const expandedPathsArray = useMemo(() => files.map((file) => file.path), [files]); - const expandedPaths = useMemo(() => new Set(expandedPathsArray), [expandedPathsArray]); - const stickyHeaders = mode.kind !== "commit"; - const interactive = mode.kind !== "commit"; - const reviewActions = mode.kind === "commit" ? undefined : mode.reviewActions; - const onFilePress = mode.kind === "working" ? mode.onFilePress : undefined; - const focusPath = mode.kind === "working" ? mode.focusPath : undefined; - const focusRequestId = mode.kind === "working" ? mode.focusRequestId : undefined; - const onOpenFile = mode.kind === "working" ? mode.onOpenFile : undefined; - const onAddToChat = mode.kind === "working" ? mode.onAddToChat : undefined; - const workspaceFileDragScope = mode.kind === "working" ? mode.workspaceFileDragScope : undefined; - const onCopyPath = mode.kind === "working" ? mode.onCopyPath : undefined; - const onCopyRelativePath = mode.kind === "working" ? mode.onCopyRelativePath : undefined; - const onReveal = mode.kind === "working" ? mode.onReveal : undefined; - const revealTargetName = mode.kind === "working" ? mode.revealTargetName : undefined; - const onDownload = mode.kind === "working" ? mode.onDownload : undefined; - const onDuplicate = mode.kind === "working" ? mode.onDuplicate : undefined; - const onRevert = mode.kind === "working" ? mode.onRevert : undefined; - // Keep selection independent from expansion so future keyboard actions (such as R to rename) - // can target the current VCS file or folder without changing its open state. - const [selectedPath, setSelectedPath] = useState(null); - const handleSelectPath = useCallback((path: string) => setSelectedPath(path), []); - useEffect(() => { - if (selectedPath && !files.some((file) => file.path === selectedPath)) { - setSelectedPath(null); - } - }, [files, selectedPath]); - const diffListRef = useRef>(null); - const diffViewport = useMemo(() => createDiffViewport(), []); - useEffect(() => () => diffViewport.dispose(), [diffViewport]); - const scrollbar = useOverlayFlatListScrollbar(diffListRef, { enabled: !isCompact }); - const { onLayout: updateScrollbarLayout, onScroll: updateScrollbarOffset } = scrollbar; - const consumedFocusRequestRef = useRef(null); - const pendingFocusRequestRef = useRef(null); - const diffListScrollOffsetRef = useRef(0); - const diffListViewportHeightRef = useRef(0); - const headerHeightByPathRef = useRef>({}); - const bodyHeightByKeyRef = useRef>({}); - const defaultHeaderHeightRef = useRef(44); - const [heightVersion, setHeightVersion] = useState(0); - const heightVersionFrameRef = useRef(null); - const scheduleHeightVersionUpdate = useCallback(() => { - if (heightVersionFrameRef.current !== null) { - return; - } - heightVersionFrameRef.current = requestAnimationFrame(() => { - heightVersionFrameRef.current = null; - setHeightVersion((version) => version + 1); - }); - }, []); - useEffect( - () => () => { - if (heightVersionFrameRef.current !== null) { - cancelAnimationFrame(heightVersionFrameRef.current); - } - }, - [], - ); - const diffBodyChromeHeight = BORDER_WIDTH[1] * 2; - const statusBodyHeightEstimate = diffBodyChromeHeight + SPACING[4] * 2 + diffBodyLineHeight; - - const { flatItems, stickyHeaderIndices } = useMemo(() => { - const { items, stickyHeaderIndices: stickyIndices } = buildDiffFlatItems({ - files, - viewMode: "flat", - collapsedFolders: new Set(), - expandedPaths, - }); - return { - flatItems: items, - stickyHeaderIndices: stickyHeaders ? stickyIndices : [], - }; - }, [expandedPaths, files, stickyHeaders]); - - const getBodyHeightKey = useCallback( - (file: ParsedDiffFile): string => { - if (file.status === "too_large" || file.status === "binary") { - return `${layout}:${wrapLines ? "wrap" : "scroll"}:${typographyKey}:${file.path}:${file.status}`; - } - - const metrics = getDiffFileMetrics(file); - return [ - layout, - wrapLines ? "wrap" : "scroll", - typographyKey, - file.path, - file.status ?? "ok", - file.additions, - file.deletions, - file.hunks.length, - metrics.unifiedLineCount, - metrics.contentLength, - ].join(":"); - }, - [layout, typographyKey, wrapLines], - ); - - const estimateBodyHeight = useCallback( - (file: ParsedDiffFile): number => { - if (file.status === "too_large" || file.status === "binary") { - return statusBodyHeightEstimate; - } - - const lineCount = - layout === "split" - ? getSplitDiffLineCount(file) - : getDiffFileMetrics(file).unifiedLineCount; - return diffBodyChromeHeight + lineCount * diffBodyLineHeight; - }, - [diffBodyChromeHeight, diffBodyLineHeight, layout, statusBodyHeightEstimate], - ); - - const getFlatItemHeight = useCallback( - (item: DiffFlatItem): number => { - if (item.type === "folder") { - return defaultHeaderHeightRef.current; - } - if (item.type === "header") { - return headerHeightByPathRef.current[item.file.path] ?? defaultHeaderHeightRef.current; - } - const bodyHeightKey = getBodyHeightKey(item.file); - return bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(item.file); - }, - [estimateBodyHeight, getBodyHeightKey], - ); - - const handleHeaderHeightChange = useCallback( - (path: string, height: number) => { - if (!Number.isFinite(height) || height <= 0) { - return; - } - const previousHeight = headerHeightByPathRef.current[path]; - if ( - previousHeight !== undefined && - Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON - ) { - return; - } - headerHeightByPathRef.current[path] = height; - defaultHeaderHeightRef.current = height; - scheduleHeightVersionUpdate(); - }, - [scheduleHeightVersionUpdate], - ); - - const handleBodyHeightChange = useCallback( - (file: ParsedDiffFile, height: number) => { - if (!Number.isFinite(height) || height < 0) { - return; - } - const heightKey = getBodyHeightKey(file); - const previousHeight = bodyHeightByKeyRef.current[heightKey]; - if ( - previousHeight !== undefined && - Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON - ) { - return; - } - bodyHeightByKeyRef.current[heightKey] = height; - scheduleHeightVersionUpdate(); - }, - [getBodyHeightKey, scheduleHeightVersionUpdate], - ); - - const handleDiffListScroll = useCallback( - (event: NativeSyntheticEvent) => { - diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y; - diffViewport.update({ - top: diffListScrollOffsetRef.current, - height: diffListViewportHeightRef.current, - }); - updateScrollbarOffset(event); - }, - [diffViewport, updateScrollbarOffset], - ); - - const handleDiffListLayout = useCallback( - (event: LayoutChangeEvent) => { - const height = event.nativeEvent.layout.height; - if (!Number.isFinite(height) || height <= 0) { - return; - } - diffListViewportHeightRef.current = height; - diffViewport.update({ top: diffListScrollOffsetRef.current, height }); - updateScrollbarLayout(event); - }, - [diffViewport, updateScrollbarLayout], - ); - - const computeItemOffset = useCallback( - (predicate: (item: DiffFlatItem) => boolean): number | null => { - const index = flatItems.findIndex(predicate); - if (index < 0) { - return null; - } - return sumHeightsBefore(flatItems, index, getFlatItemHeight); - }, - [flatItems, getFlatItemHeight], - ); - - const computeHeaderOffset = useCallback( - (path: string): number => - computeItemOffset((item) => item.type === "header" && item.file.path === path) ?? 0, - [computeItemOffset], - ); - - useEffect(() => { - if (!focusPath) { - return; - } - const focusRequestKey = `${focusRequestId ?? "initial"}:${focusPath}`; - if ( - consumedFocusRequestRef.current === focusRequestKey || - pendingFocusRequestRef.current === focusRequestKey - ) { - return; - } - const hasTarget = flatItems.some( - (item) => item.type === "header" && item.file.path === focusPath, - ); - if (!hasTarget) { - return; - } - pendingFocusRequestRef.current = focusRequestKey; - const frame = requestAnimationFrame(() => { - diffListRef.current?.scrollToOffset({ - offset: computeHeaderOffset(focusPath), - animated: false, - }); - consumedFocusRequestRef.current = focusRequestKey; - pendingFocusRequestRef.current = null; - }); - return () => { - cancelAnimationFrame(frame); - if (pendingFocusRequestRef.current === focusRequestKey) { - pendingFocusRequestRef.current = null; - } - }; - }, [computeHeaderOffset, flatItems, focusPath, focusRequestId]); - - const renderFlatItem = useCallback( - ({ item, index }: { item: DiffFlatItem; index: number }) => { - if (item.type === "folder") { - return null; - } - if (item.type === "header") { - return ( - - ); - } - return ( - - ); - }, - [ - codeFontSize, - diffViewport, - flatItems, - getFlatItemHeight, - handleBodyHeightChange, - handleHeaderHeightChange, - handleSelectPath, - layout, - reviewActions, - workspaceFileDragScope, - textMetricsStyle, - wrapLines, - interactive, - onFilePress, - onOpenFile, - onAddToChat, - onCopyPath, - onCopyRelativePath, - onReveal, - revealTargetName, - onDownload, - onDuplicate, - onRevert, - selectedPath, - ], - ); - - const flatKeyExtractor = useCallback( - (item: DiffFlatItem) => - item.type === "folder" ? `folder-${item.dirPath}` : `${item.type}-${item.file.path}`, - [], - ); - - const getFlatItemLayout = useCallback( - (_data, index) => { - const offset = sumHeightsBefore(flatItems, index, getFlatItemHeight); - const item = flatItems[index]; - const length = item ? getFlatItemHeight(item) : 0; - return { length, offset, index }; - }, - [flatItems, getFlatItemHeight], - ); - - const flatExtraData = useMemo( - () => ({ - expandedPathsArray, - layout, - typographyKey, - heightVersion, - wrapLines, - reviewActions, - workspaceFileDragScope, - }), - [ - expandedPathsArray, - heightVersion, - layout, - reviewActions, - typographyKey, - workspaceFileDragScope, - wrapLines, - ], - ); - - return ( - - - {scrollbar.overlay} - - ); -} - function computeBaseRefLabel(baseRef: string | undefined, fallbackLabel: string): string { if (!baseRef) return fallbackLabel; const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim(); @@ -2773,15 +919,8 @@ function ChangedFilesTree({ const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]); const collapsedFolders = useMemo(() => new Set(collapsedFolderPaths), [collapsedFolderPaths]); const items = useMemo( - () => - buildDiffFlatItems({ - files, - viewMode: "tree", - tree: compressedTree, - collapsedFolders, - expandedPaths: new Set(), - }).items, - [collapsedFolders, compressedTree, files], + () => flattenDiffTree(compressedTree, collapsedFolders), + [collapsedFolders, compressedTree], ); const handleSelectPath = useCallback((path: string) => setSelectedPath(path), []); const handleSelectFile = useCallback( @@ -2815,14 +954,14 @@ function ChangedFilesTree({ [allFolderPaths, collapsedFolders, updateCollapsedFolders], ); const renderItem = useCallback( - ({ item }: { item: DiffFlatItem }) => { - if (item.type === "folder") { + ({ item }: { item: DiffTreeRow }) => { + if (item.kind === "folder") { return ( ); } - if (item.type === "body") { - return null; - } return ( - - item.type === "folder" ? `folder-${item.dirPath}` : `file-${item.file.path}`, + (item: DiffTreeRow) => + item.kind === "folder" ? `folder-${item.dirPath}` : `file-${item.file.path}`, [], ); @@ -3011,6 +1148,22 @@ export function ChangesSurface({ const wrapLines = changesPreferences.wrapLines; const desktopTreeVisible = changesPreferences.desktopTreeVisible; const effectiveLayout = resolveDiffLayout(changesPreferences.layout, canUseSplitLayout); + const collapsedFilesWorkspaceKey = `${serverId}:${workspaceId ?? cwd}`; + const persistedCollapsedFilePaths = usePanelStore( + (state) => state.collapsedFilePathsByWorkspace[collapsedFilesWorkspaceKey], + ); + const setCollapsedFilePaths = usePanelStore((state) => state.setCollapsedFilePathsForWorkspace); + const collapsedFilePaths = persistedCollapsedFilePaths ?? EMPTY_PATH_LIST; + const updateCollapsedFilePaths = useCallback( + (paths: string[]) => { + setCollapsedFilePaths(collapsedFilesWorkspaceKey, paths); + }, + [collapsedFilesWorkspaceKey, setCollapsedFilePaths], + ); + const collapseState = useMemo( + () => ({ paths: collapsedFilePaths, onChange: updateCollapsedFilePaths }), + [collapsedFilePaths, updateCollapsedFilePaths], + ); const handleToggleWrapLines = useCallback(() => { void updateChangesPreferences({ wrapLines: !wrapLines }); @@ -3258,6 +1411,16 @@ export function ChangesSurface({ ); const hasChanges = files.length > 0; + const allFilesCollapsed = + hasChanges && files.every((file) => collapsedFilePaths.includes(file.path)); + const handleCollapseAllFiles = useCallback( + () => updateCollapsedFilePaths(files.map((file) => file.path)), + [files, updateCollapsedFilePaths], + ); + const handleExpandAllFiles = useCallback( + () => updateCollapsedFilePaths([]), + [updateCollapsedFilePaths], + ); const diffErrorMessage = diffPayloadError?.message ?? null; const prErrorMessage = computePrErrorMessage(githubFeaturesEnabled, prPayloadError); const baseRefLabel = useMemo( @@ -3299,6 +1462,7 @@ export function ChangesSurface({ > @@ -3327,6 +1491,7 @@ export function ChangesSurface({ {isGit ? ( ({ fontSize: theme.fontSize.lg, color: theme.colors.foregroundMuted, }, - fileSection: { - overflow: "hidden", - backgroundColor: theme.colors.surface2, - borderBottomWidth: 1, - borderBottomColor: theme.colors.border, - }, - fileSectionHeaderContainer: { - overflow: "hidden", - }, - fileSectionHeaderExpanded: { - backgroundColor: theme.colors.surface1, - }, - fileSectionBodyContainer: { - overflow: "hidden", - backgroundColor: theme.colors.surface2, - }, - fileSectionBorder: { - borderBottomWidth: 1, - borderBottomColor: theme.colors.border, - }, - fileHeader: { - 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, - }, - fileHeaderActive: { - backgroundColor: theme.colors.surfaceSidebarHover, - }, - fileHeaderLeft: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - flex: 1, - minWidth: 0, - }, - fileHeaderLeftTree: { - gap: WORKSPACE_TREE_ICON_LABEL_GAP, - }, - fileHeaderRight: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - flexShrink: 0, - }, - fileIcon: { - width: WORKSPACE_TREE_ICON_SIZE, - height: WORKSPACE_TREE_ICON_SIZE, - flexShrink: 0, - alignItems: "center", - justifyContent: "center", - }, - fileName: { - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.normal, - color: theme.colors.foreground, - flexShrink: 1, - minWidth: 0, - userSelect: "none", - }, - fileDir: { - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.normal, - color: theme.colors.foregroundMuted, - flex: 1, - minWidth: 0, - userSelect: "none", - }, - fileDirSpacer: { - flex: 1, - minWidth: 0, - }, - diffContent: { - borderTopWidth: theme.borderWidth[1], - borderTopColor: theme.colors.border, - backgroundColor: theme.colors.surface1, - }, - diffContentRow: { - flexDirection: "row", - alignItems: "stretch", - }, - diffContentInner: { - flexDirection: "column", - }, - linesContainer: { - backgroundColor: theme.colors.surface1, - }, - gutterColumn: { - backgroundColor: theme.colors.surface1, - zIndex: 4, - elevation: 4, - overflow: "visible", - }, - gutterCell: { - borderRightWidth: theme.borderWidth[1], - borderRightColor: theme.colors.border, - justifyContent: "flex-start", - zIndex: 4, - elevation: 4, - overflow: "visible", - }, - inlineReviewRow: { - flexDirection: "row", - alignItems: "stretch", - backgroundColor: theme.colors.surface1, - }, - inlineReviewGutterSpacer: { - borderRightWidth: theme.borderWidth[1], - borderRightColor: theme.colors.border, - backgroundColor: theme.colors.surface1, - flexShrink: 0, - }, - textLineContainer: { - flexDirection: "row", - alignItems: "stretch", - paddingLeft: theme.spacing[2], - }, - splitRow: { - flexDirection: "row", - alignItems: "stretch", - }, - splitColumnScroll: { - flex: 1, - }, - splitHeaderRow: { - backgroundColor: theme.colors.surface2, - paddingHorizontal: theme.spacing[3], - }, - splitCell: { - flex: 1, - flexBasis: 0, - backgroundColor: theme.colors.surface2, - }, - splitCellRow: { - flexDirection: "row", - alignItems: "stretch", - }, - emptySplitCell: { - backgroundColor: theme.colors.surfaceDiffEmpty, - }, - splitCellWithDivider: { - borderLeftWidth: theme.borderWidth[1], - borderLeftColor: theme.colors.border, - }, - diffLineContainer: { - flexDirection: "row", - alignItems: "stretch", - overflow: "visible", - }, - lineNumberGutter: { - borderRightWidth: theme.borderWidth[1], - borderRightColor: theme.colors.border, - marginRight: theme.spacing[2], - alignSelf: "stretch", - justifyContent: "flex-start", - zIndex: 4, - elevation: 4, - overflow: "visible", - }, - diffTextMetrics: { - fontSize: theme.fontSize.code, - lineHeight: theme.lineHeight.diff, - fontFamily: theme.fontFamily.mono, - }, - lineNumberText: { - width: "100%", - textAlign: "right", - paddingRight: theme.spacing[2], - color: theme.colors.foregroundMuted, - userSelect: "none", - }, - addLineNumberText: { - color: theme.colors.diffAddition, - }, - removeLineNumberText: { - color: theme.colors.diffDeletion, - }, - diffLineText: { - flex: 1, - paddingRight: theme.spacing[3], - color: theme.colors.foreground, - userSelect: "text", - }, - addLineContainer: { - backgroundColor: "rgba(46, 160, 67, 0.15)", // GitHub green - }, - addLineText: { - color: theme.colors.foreground, - }, - removeLineContainer: { - backgroundColor: "rgba(248, 81, 73, 0.1)", // GitHub red - }, - removeLineText: { - color: theme.colors.foreground, - }, - headerLineContainer: { - backgroundColor: theme.colors.surface2, - }, - headerLineText: { - color: theme.colors.foregroundMuted, - }, - contextLineContainer: { - backgroundColor: theme.colors.surface1, - }, - contextLineText: { - color: theme.colors.foregroundMuted, - }, - emptySplitCellText: { - color: "transparent", - }, - statusMessageContainer: { - borderTopWidth: theme.borderWidth[1], - borderTopColor: theme.colors.border, - backgroundColor: theme.colors.surface1, - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[4], - }, - statusMessageText: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - fontStyle: "italic", - }, tooltipText: { fontSize: theme.fontSize.xs, color: theme.colors.foreground, }, })); - -const DIFF_HEIGHT_CHANGE_EPSILON = 0.5; diff --git a/packages/app/src/git/diff-scroll.test.ts b/packages/app/src/git/diff-scroll.test.ts deleted file mode 100644 index b5dccfe98..000000000 --- a/packages/app/src/git/diff-scroll.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/app/src/git/diff-scroll.ts b/packages/app/src/git/diff-scroll.ts deleted file mode 100644 index 4ad5d0912..000000000 --- a/packages/app/src/git/diff-scroll.ts +++ /dev/null @@ -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; -} diff --git a/packages/app/src/git/diff-tree.test.ts b/packages/app/src/git/diff-tree.test.ts index 89c11d8a5..b5ad071a6 100644 --- a/packages/app/src/git/diff-tree.test.ts +++ b/packages/app/src/git/diff-tree.test.ts @@ -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", () => { diff --git a/packages/app/src/git/diff-tree.ts b/packages/app/src/git/diff-tree.ts index 4725690b4..9069b608d 100644 --- a/packages/app/src/git/diff-tree.ts +++ b/packages/app/src/git/diff-tree.ts @@ -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; diff --git a/packages/app/src/git/diff-virtualization.test.ts b/packages/app/src/git/diff-virtualization.test.ts deleted file mode 100644 index 131582231..000000000 --- a/packages/app/src/git/diff-virtualization.test.ts +++ /dev/null @@ -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, - }); - }); -}); diff --git a/packages/app/src/git/diff-virtualization.ts b/packages/app/src/git/diff-virtualization.ts deleted file mode 100644 index aa474c51f..000000000 --- a/packages/app/src/git/diff-virtualization.ts +++ /dev/null @@ -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; -} diff --git a/packages/app/src/git/file-header-interaction.test.ts b/packages/app/src/git/file-header-interaction.test.ts new file mode 100644 index 000000000..cebadcd25 --- /dev/null +++ b/packages/app/src/git/file-header-interaction.test.ts @@ -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"]); + }); +}); diff --git a/packages/app/src/git/file-header-interaction.ts b/packages/app/src/git/file-header-interaction.ts new file mode 100644 index 000000000..5cbec5db5 --- /dev/null +++ b/packages/app/src/git/file-header-interaction.ts @@ -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(null); + const pressHandledRef = useRef(false); + const pressOriginRef = useRef(null); + const phaseRef = useRef("idle"); + const fallbackTimerRef = useRef | 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 }; +} diff --git a/packages/app/src/git/file-header.tsx b/packages/app/src/git/file-header.tsx new file mode 100644 index 000000000..2b102df70 --- /dev/null +++ b/packages/app/src/git/file-header.tsx @@ -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 ( + + ); +} + +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 = ( + + + {showDir ? null : ( + + + + )} + + {fileName} + + {showDir ? ( + + {directorySuffix(file.path)} + + ) : ( + + )} + {file.isNew ? : null} + {file.isDeleted ? : null} + + + + + + ); + let trigger: ReactElement; + if (interactive) { + trigger = ( + + {content} + + ); + } else { + trigger = {content}; + } + return ( + + + + + {trigger} + + {file.path} + + + {interactive ? ( + + ) : null} + + + ); +}); + +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 }, +})); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index df08f0930..3cee66058 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -861,6 +861,8 @@ export const ar: TranslationResources = { wrapLongLines: "لف الخطوط الطويلة", collapseAllFolders: "طي كافة المجلدات", expandAllFolders: "توسيع كافة المجلدات", + collapseAllFiles: "طي كافة الملفات", + expandAllFiles: "توسيع كافة الملفات", refreshing: "منعش", refresh: "ينعش", refreshState: "تحديث حالة git و{{brand}}", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index e72e0a00c..f4a4bf27f 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index fe6ca3899..69e0c0e20 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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}}", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 154f71d4a..a0f47a295 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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}}", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 91573a1b1..7179216b8 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -872,6 +872,8 @@ export const ja: TranslationResources = { wrapLongLines: "長い行を折り返す", collapseAllFolders: "すべてのフォルダを折りたたむ", expandAllFolders: "すべてのフォルダを展開", + collapseAllFiles: "すべてのファイルを折りたたむ", + expandAllFiles: "すべてのファイルを展開", refreshing: "更新中", refresh: "更新", refreshState: "gitと{{brand}}の状態を更新", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 6b9d19c73..77f93ff8c 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -868,6 +868,8 @@ export const ko: TranslationResources = { wrapLongLines: "긴 줄 줄바꿈", collapseAllFolders: "모든 폴더 축소", expandAllFolders: "모든 폴더 확장", + collapseAllFiles: "모든 파일 축소", + expandAllFiles: "모든 파일 확장", refreshing: "새로고침 중", refresh: "새로고침", refreshState: "Git 및 {{brand}} 상태 새로고침", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 356051c1c..f9b1acf3b 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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}}", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index b333fd1b2..38167d5b6 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -883,6 +883,8 @@ export const ru: TranslationResources = { wrapLongLines: "Перенос длинных строк", collapseAllFolders: "Свернуть все папки", expandAllFolders: "Развернуть все папки", + collapseAllFiles: "Свернуть все файлы", + expandAllFiles: "Развернуть все файлы", refreshing: "Освежающий", refresh: "Обновить", refreshState: "Обновить состояние git и {{brand}}", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 02dd80d9c..53010a092 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -853,6 +853,8 @@ export const zhCN: TranslationResources = { wrapLongLines: "自动换行长行", collapseAllFolders: "折叠所有文件夹", expandAllFolders: "展开所有文件夹", + collapseAllFiles: "折叠所有文件", + expandAllFiles: "展开所有文件", refreshing: "正在刷新", refresh: "刷新", refreshState: "刷新 git 和 {{brand}} 状态", diff --git a/packages/app/src/panels/diff-panel.tsx b/packages/app/src/panels/diff-panel.tsx index 176c0fed9..cd7f9df54 100644 --- a/packages/app/src/panels/diff-panel.tsx +++ b/packages/app/src/panels/diff-panel.tsx @@ -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"; diff --git a/packages/app/src/review/geometry.ts b/packages/app/src/review/geometry.ts new file mode 100644 index 000000000..819c1d29b --- /dev/null +++ b/packages/app/src/review/geometry.ts @@ -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; + 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; + right: ReturnType; + 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 }; +} diff --git a/packages/app/src/review/index.ts b/packages/app/src/review/index.ts index f15b0cbf5..7af7beed0 100644 --- a/packages/app/src/review/index.ts +++ b/packages/app/src/review/index.ts @@ -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"; diff --git a/packages/app/src/review/surface.tsx b/packages/app/src/review/surface.tsx index 9e9c6ca6f..d1f455575 100644 --- a/packages/app/src/review/surface.tsx +++ b/packages/app/src/review/surface.tsx @@ -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; - 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 { @@ -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; - right: ReturnType; - 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, diff --git a/packages/app/src/stores/panel-store/index.ts b/packages/app/src/stores/panel-store/index.ts index 081c295b4..dd19cffc4 100644 --- a/packages/app/src/stores/panel-store/index.ts +++ b/packages/app/src/stores/panel-store/index.ts @@ -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; + collapsedFilePathsByWorkspace: Record; 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()( explorerTabByCheckout: {}, expandedPathsByWorkspace: {}, diffCollapsedFoldersByWorkspace: {}, + collapsedFilePathsByWorkspace: {}, sidebarWidth: DEFAULT_SIDEBAR_WIDTH, explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH, explorerSortOption: "name", @@ -280,6 +283,13 @@ export const usePanelStore = create()( [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()( explorerTabByCheckout: state.explorerTabByCheckout, expandedPathsByWorkspace: state.expandedPathsByWorkspace, diffCollapsedFoldersByWorkspace: state.diffCollapsedFoldersByWorkspace, + collapsedFilePathsByWorkspace: state.collapsedFilePathsByWorkspace, sidebarWidth: state.sidebarWidth, explorerWidth: state.explorerWidth, explorerSortOption: state.explorerSortOption, diff --git a/packages/app/src/stores/panel-store/state.test.ts b/packages/app/src/stores/panel-store/state.test.ts index 1db82e361..c89d5cbee 100644 --- a/packages/app/src/stores/panel-store/state.test.ts +++ b/packages/app/src/stores/panel-store/state.test.ts @@ -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, diff --git a/packages/app/src/stores/panel-store/state.ts b/packages/app/src/stores/panel-store/state.ts index d133d73ee..2ff029298 100644 --- a/packages/app/src/stores/panel-store/state.ts +++ b/packages/app/src/stores/panel-store/state.ts @@ -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; } diff --git a/packages/app/src/utils/diff-layout.test.ts b/packages/app/src/utils/diff-layout.test.ts index 57ee71430..46b6b00c7 100644 --- a/packages/app/src/utils/diff-layout.test.ts +++ b/packages/app/src/utils/diff-layout.test.ts @@ -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", diff --git a/packages/app/src/utils/diff-layout.ts b/packages/app/src/utils/diff-layout.ts index ccf901e1d..2bb4a6665 100644 --- a/packages/app/src/utils/diff-layout.ts +++ b/packages/app/src/utils/diff-layout.ts @@ -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[] = [];