From c7b103a2cdf5812fdcd5512ce85bbac32614c0e7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 17 Aug 2026 13:13:47 +0200 Subject: [PATCH] fix(app): stabilize canvas diff rendering Keep diff headers and painted content on the same scroll clock while preserving native scrolling and independent per-file horizontal offsets. Complete review, selection, typography, tree, and palette parity without returning line rendering to React. --- package-lock.json | 1 + packages/app/e2e/browser/changes-pane.spec.ts | 169 +++++ .../app/e2e/browser/commit-diff-panel.spec.ts | 4 +- .../app/e2e/browser/diff-performance.spec.ts | 69 +- .../file-explorer-context-actions.spec.ts | 15 + packages/app/package.json | 1 + .../app/src/components/file-change-icon.tsx | 33 +- .../app/src/components/file-explorer-pane.tsx | 37 +- .../app/src/components/tree-primitives.tsx | 57 +- .../contexts/horizontal-scroll-context.tsx | 30 +- .../diff-document/document-file-header.tsx | 58 +- .../horizontal-scroll.native.tsx | 36 +- packages/app/src/git/diff-document/index.tsx | 8 +- .../app/src/git/diff-document/model.test.ts | 72 ++ packages/app/src/git/diff-document/model.ts | 82 ++- .../diff-document/native-hit-testing.test.ts | 47 +- .../git/diff-document/native-hit-testing.ts | 29 +- .../git/diff-document/native-slabs.test.ts | 81 +++ .../app/src/git/diff-document/native-slabs.ts | 54 ++ .../diff-document/native-text-runs.test.ts | 39 ++ .../src/git/diff-document/native-text-runs.ts | 23 + .../app/src/git/diff-document/paint.native.ts | 392 ++++++++--- .../app/src/git/diff-document/paint.web.ts | 15 +- .../app/src/git/diff-document/palette.test.ts | 30 +- packages/app/src/git/diff-document/palette.ts | 43 +- .../src/git/diff-document/surface.native.tsx | 650 +++++++++++++----- .../app/src/git/diff-document/surface.web.tsx | 243 +++++-- .../diff-document/text-measurement.test.ts | 16 +- .../src/git/diff-document/text-measurement.ts | 6 + .../app/src/git/diff-document/text.native.ts | 85 ++- packages/app/src/git/diff-document/types.ts | 2 + packages/app/src/git/diff-folder-row.tsx | 45 +- .../src/git/file-header-interaction.test.ts | 17 +- .../app/src/git/file-header-interaction.ts | 27 +- packages/app/src/git/file-header.tsx | 142 +++- packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ja.ts | 1 + packages/app/src/i18n/resources/ko.ts | 1 + packages/app/src/i18n/resources/pt-BR.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + packages/app/src/mobile-panels/gestures.ts | 3 + packages/app/src/review/index.ts | 1 + packages/app/src/review/surface.tsx | 35 +- .../appearance/appearance-section.tsx | 30 +- packages/app/src/utils/color.test.ts | 13 +- packages/app/src/utils/color.ts | 11 + 50 files changed, 2180 insertions(+), 580 deletions(-) create mode 100644 packages/app/src/git/diff-document/native-slabs.test.ts create mode 100644 packages/app/src/git/diff-document/native-slabs.ts create mode 100644 packages/app/src/git/diff-document/native-text-runs.test.ts create mode 100644 packages/app/src/git/diff-document/native-text-runs.ts diff --git a/package-lock.json b/package-lock.json index 51ef873b1..27d33df12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36252,6 +36252,7 @@ "@getpaseo/protocol": "*", "@gorhom/bottom-sheet": "^5.2.14", "@gorhom/portal": "^1.0.14", + "@marijn/find-cluster-break": "^1.0.2", "@mattermost/react-native-paste-input": "2.0.1", "@paseo/plugin": "*", "@react-native-async-storage/async-storage": "2.2.0", diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts index c15a75641..8d8c9c2d5 100644 --- a/packages/app/e2e/browser/changes-pane.spec.ts +++ b/packages/app/e2e/browser/changes-pane.spec.ts @@ -314,6 +314,58 @@ test("canvas file headers select without toggling for context menu and long pres await expect(firstFile).toHaveAttribute("aria-expanded", "true"); }); +test("every interactive file header has the same hover feedback", async ({ page }) => { + const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true }); + await useUnwrappedDiffLines(page); + await openWorkspaceChanges(page, workspace); + + const first = page.getByTestId("diff-file-0-toggle"); + const second = page.getByTestId("diff-file-1-toggle"); + const normalBackground = await first.evaluate( + (element) => getComputedStyle(element.parentElement!).backgroundColor, + ); + await expect + .poll(() => + first.evaluate((element) => getComputedStyle(element.parentElement!).borderTopColor), + ) + .toBe("rgba(0, 0, 0, 0)"); + + await first.hover(); + const hoverBackground = await first.evaluate( + (element) => getComputedStyle(element).backgroundColor, + ); + expect(hoverBackground).not.toBe(normalBackground); + + await first.click(); + await page.mouse.move(0, 0); + await expect(first).toHaveAttribute("aria-expanded", "false"); + await expect + .poll(() => + first.evaluate((element) => getComputedStyle(element.parentElement!).backgroundColor), + ) + .toBe(normalBackground); + const [sharedBorder, suppressedDuplicateBorder] = await Promise.all([ + first.evaluate((element) => getComputedStyle(element.parentElement!).borderBottomColor), + second.evaluate((element) => getComputedStyle(element.parentElement!).borderTopColor), + ]); + expect(sharedBorder).not.toBe("rgba(0, 0, 0, 0)"); + expect(suppressedDuplicateBorder).toBe("rgba(0, 0, 0, 0)"); + + await first.hover(); + await expect + .poll(() => first.evaluate((element) => getComputedStyle(element).backgroundColor)) + .toBe(hoverBackground); + await second.hover(); + await expect + .poll(() => + first.evaluate((element) => getComputedStyle(element.parentElement!).backgroundColor), + ) + .toBe(normalBackground); + await expect + .poll(() => second.evaluate((element) => getComputedStyle(element).backgroundColor)) + .toBe(hoverBackground); +}); + test("changes context menus duplicate files and folders", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff(); await useUnwrappedDiffLines(page); @@ -333,6 +385,32 @@ test("changes context menus duplicate files and folders", async ({ page }) => { .toBe(AFTER); }); +test("changes tree aligns every file status after its diff stat", async ({ page }) => { + const workspace = await createWorkspaceWithMountedTabDiff({ + includeDeletedFile: true, + includeUntrackedFile: true, + }); + await useUnwrappedDiffLines(page); + await openWorkspaceChanges(page, workspace); + await page.getByTestId("changes-toggle-tree").click(); + + const tree = changesTree(page); + const modifiedRow = tree.getByTestId("diff-tree-file-0"); + const deletedRow = tree.getByTestId("diff-tree-file-1"); + const addedRow = tree.getByTestId("diff-tree-file-2"); + const modifiedStatus = modifiedRow.getByRole("img", { name: "Modified" }); + await expect(modifiedStatus).toBeVisible(); + await expect(deletedRow.getByRole("img", { name: "Deleted" })).toBeVisible(); + await expect(addedRow.getByRole("img", { name: "New" })).toBeVisible(); + + const [statBounds, statusBounds] = await Promise.all([ + modifiedRow.getByTestId("diff-tree-file-0-stat").boundingBox(), + modifiedStatus.boundingBox(), + ]); + if (!statBounds || !statusBounds) throw new Error("Changes tree trailing status has no bounds"); + expect(statusBounds.x - (statBounds.x + statBounds.width)).toBeGreaterThanOrEqual(8); +}); + test("changes context menu recursively collapses descendant folders", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true }); await useUnwrappedDiffLines(page); @@ -342,6 +420,22 @@ test("changes context menu recursively collapses descendant folders", async ({ p const tree = changesTree(page); await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible(); await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible(); + const rootRow = tree.getByTestId("diff-folder-src-toggle"); + const rootLabel = tree.getByTestId("diff-folder-src-toggle").getByText("src", { exact: true }); + await expect(rootRow).toHaveCSS("opacity", "1"); + await expect(rootLabel).toHaveCSS("opacity", "0.76"); + await rootRow.hover(); + await expect(rootLabel).toHaveCSS("opacity", "1"); + await page.mouse.move(0, 0); + const nestedLabel = tree + .getByTestId("diff-folder-src/zz-folder-toggle") + .getByText("zz-folder", { exact: true }); + const [rootBounds, nestedBounds] = await Promise.all([ + rootLabel.boundingBox(), + nestedLabel.boundingBox(), + ]); + if (!rootBounds || !nestedBounds) throw new Error("Changes tree rows have no bounds"); + expect(nestedBounds.x - rootBounds.x).toBe(12); await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" }); await page.getByTestId("diff-folder-src-collapse-folder").click(); @@ -718,6 +812,56 @@ test("scrolling clears the hovered review affordance", async ({ page }) => { await expect(page.getByRole("button", { name: "Add review comment" })).toHaveCount(0); }); +test("canvas diff uses the overlay scrollbar and its thumb controls vertical scrolling", async ({ + page, +}) => { + const lines = Array.from({ length: 240 }, (_, index) => `export const line${index} = ${index};`); + const workspace = await createWorkspaceWithExactSelectionDiff(lines.join("\n")); + await openSelectionWorkspaceChanges(page, workspace); + + const root = page.getByTestId("git-diff-canvas-root"); + const scroller = page.getByTestId("git-diff-scroll"); + const grab = root.getByTestId("workspace-overlay-scrollbar-grab"); + await expect(grab).toBeVisible(); + await expect(scroller).toHaveCSS("scrollbar-width", "none"); + + const bounds = await grab.boundingBox(); + if (!bounds) throw new Error("Diff overlay scrollbar thumb has no bounds"); + await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); + await page.mouse.down(); + await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 + 180); + await page.mouse.up(); + + await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(0); +}); + +test("the whole reviewable row reveals the gutter affordance and uses a text cursor", async ({ + page, +}) => { + const workspace = await createWorkspaceWithMountedTabDiff(); + await useUnwrappedDiffLines(page); + await openWorkspaceChanges(page, workspace); + + 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); + await page.mouse.move(bodyBounds.x + bodyBounds.width - 32, bodyBounds.y + lineHeight * 1.5); + + const affordance = page.getByRole("button", { name: "Add review comment" }); + await expect(affordance).toBeVisible(); + await expect(affordance.locator("svg")).toBeVisible(); + const affordanceBounds = await affordance.boundingBox(); + expect(affordanceBounds?.width).toBeCloseTo(22, 0); + expect(affordanceBounds?.height).toBeCloseTo(22, 0); + await expect(affordance).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); + await expect(page.getByTestId("git-diff-scroll")).toHaveCSS("cursor", "text"); +}); + test("canvas diff copies a dragged character selection without opening a review", async ({ context, page, @@ -734,6 +878,19 @@ test("canvas diff copies a dragged character selection without opening a review" await expect(page.getByTestId("inline-review-editor")).toHaveCount(0); }); +test("the first click after a canvas selection only dismisses the selection", async ({ page }) => { + const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ"); + await useUnwrappedDiffLines(page); + await openSelectionWorkspaceChanges(page, workspace); + + await dragExactAddedText(page, { startOffset: 2, endOffset: 8 }); + await clickFirstChangedLine(page); + await expect(page.getByTestId("inline-review-editor")).toHaveCount(0); + + await clickFirstChangedLine(page); + await expect(page.getByTestId("inline-review-editor")).toBeVisible(); +}); + test("canvas diff copies exact multiline text forwards and backwards", async ({ context, page, @@ -1117,6 +1274,18 @@ async function startReviewOnFirstChangedLine( await expect(page.getByTestId("inline-review-editor")).toBeVisible(); } +async function clickFirstChangedLine(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); + await page.mouse.click(bodyBounds.x + 120, bodyBounds.y + lineHeight * 1.5); +} + async function hoverFirstChangedGutter(page: Page): Promise { const body = page.getByTestId("diff-file-0-body"); const canvas = page.getByTestId("git-diff-canvas"); diff --git a/packages/app/e2e/browser/commit-diff-panel.spec.ts b/packages/app/e2e/browser/commit-diff-panel.spec.ts index 263cecc45..0eac05516 100644 --- a/packages/app/e2e/browser/commit-diff-panel.spec.ts +++ b/packages/app/e2e/browser/commit-diff-panel.spec.ts @@ -100,10 +100,10 @@ async function expectCommitDiffHeaderGeometry(panel: Locator): Promise { expect(content).not.toBeNull(); expect(name).not.toBeNull(); expect(stat).not.toBeNull(); - expect(header!.height).toBeCloseTo(44, 0); + expect(header!.height).toBeCloseTo(30, 0); expect(content!.x).toBeCloseTo(header!.x, 0); expect(content!.width).toBeCloseTo(header!.width, 0); - expect(name!.x).toBeGreaterThan(header!.x); + expect(name!.x - header!.x).toBeCloseTo(12, 0); expect(name!.x + name!.width).toBeLessThan(stat!.x); expect(Math.abs(name!.y + name!.height / 2 - (stat!.y + stat!.height / 2))).toBeLessThanOrEqual( 1, diff --git a/packages/app/e2e/browser/diff-performance.spec.ts b/packages/app/e2e/browser/diff-performance.spec.ts index 29e1b44bc..6e11a23dd 100644 --- a/packages/app/e2e/browser/diff-performance.spec.ts +++ b/packages/app/e2e/browser/diff-performance.spec.ts @@ -65,7 +65,6 @@ diffPerfDescribe("Diff canvas performance", () => { 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}`, { @@ -150,7 +149,6 @@ async function measureAdditionalPerformanceScenario( 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}`, { @@ -283,7 +281,7 @@ async function continuouslyScrollCanvas(page: Page): Promise<{ )!; frameSignatures.push({ scrollTop: scroll.scrollTop, - signature: canvasSignature(canvas), + signature: canvasSignature(canvas, scroll), }); previousFrame = performance.now(); } @@ -303,12 +301,26 @@ async function continuouslyScrollCanvas(page: Page): Promise<{ frameSignatures, }; - function canvasSignature(canvas: HTMLCanvasElement): number { + function canvasSignature(canvasElement: HTMLCanvasElement, scrollElement: HTMLElement): 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 canvasCssHeight = Number.parseFloat(canvasElement.style.height); + const canvasTop = Number.parseFloat(canvasElement.style.top); + const pixelRatio = canvasElement.height / canvasCssHeight; + const sourceY = (scrollElement.scrollTop - canvasTop) * pixelRatio; + context.drawImage( + canvasElement, + 0, + sourceY, + canvasElement.width, + scrollElement.clientHeight * pixelRatio, + 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); @@ -324,7 +336,6 @@ interface CapturedCanvasFrame { 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) => { @@ -335,29 +346,11 @@ async function captureCanvasAtScrollIntervals(page: Page): Promise((resolve) => requestAnimationFrame(() => resolve())); return scrollElement.scrollTop; }, step / 8); - frames.push({ scrollTop, image: await canvas.screenshot() }); + frames.push({ scrollTop, image: await scroll.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 }>, @@ -375,13 +368,37 @@ async function expectContinuousFramesMatchStableRender( sample.width = 64; sample.height = 36; const context = sample.getContext("2d")!; - context.drawImage(canvas, 0, 0, sample.width, sample.height); + drawVisibleCanvas(context, canvas, element as HTMLElement, 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; + + function drawVisibleCanvas( + context: CanvasRenderingContext2D, + canvasElement: HTMLCanvasElement, + scroll: HTMLElement, + width: number, + height: number, + ): void { + const canvasCssHeight = Number.parseFloat(canvasElement.style.height); + const canvasTop = Number.parseFloat(canvasElement.style.top); + const pixelRatio = canvasElement.height / canvasCssHeight; + const sourceY = (scroll.scrollTop - canvasTop) * pixelRatio; + context.drawImage( + canvasElement, + 0, + sourceY, + canvasElement.width, + scroll.clientHeight * pixelRatio, + 0, + 0, + width, + height, + ); + } }, samples); expect(stable).toEqual(samples.map((sample) => sample.signature)); } diff --git a/packages/app/e2e/browser/file-explorer-context-actions.spec.ts b/packages/app/e2e/browser/file-explorer-context-actions.spec.ts index bbc73b797..3d19f77cf 100644 --- a/packages/app/e2e/browser/file-explorer-context-actions.spec.ts +++ b/packages/app/e2e/browser/file-explorer-context-actions.spec.ts @@ -111,6 +111,21 @@ test.afterEach(async () => { await workspace?.cleanup(); }); +test("file explorer rows share the workspace title opacity treatment", async ({ page }) => { + await gotoWorkspace(page, workspace.workspaceId); + await openFileExplorer(page); + + const row = page + .getByTestId("file-explorer-tree-scroll") + .getByTestId(/^file-explorer-row-\d+$/) + .first(); + const name = row.getByTestId(/-name$/); + await expect(row).toHaveCSS("opacity", "1"); + await expect(name).toHaveCSS("opacity", "0.76"); + await row.hover(); + await expect(name).toHaveCSS("opacity", "1"); +}); + test("creates, renames, copies, and deletes entries through the file explorer", async ({ context, page, diff --git a/packages/app/package.json b/packages/app/package.json index f1d6f4c2e..c40d37b5e 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -51,6 +51,7 @@ "@getpaseo/protocol": "*", "@gorhom/bottom-sheet": "^5.2.14", "@gorhom/portal": "^1.0.14", + "@marijn/find-cluster-break": "^1.0.2", "@mattermost/react-native-paste-input": "2.0.1", "@paseo/plugin": "*", "@react-native-async-storage/async-storage": "2.2.0", diff --git a/packages/app/src/components/file-change-icon.tsx b/packages/app/src/components/file-change-icon.tsx index 0c908048a..2f30c5773 100644 --- a/packages/app/src/components/file-change-icon.tsx +++ b/packages/app/src/components/file-change-icon.tsx @@ -1,26 +1,29 @@ import type { ReactElement } from "react"; import { withUnistyles } from "react-native-unistyles"; -import { SquareMinus, SquarePlus } from "lucide-react-native"; +import { SquareDot, SquareMinus, SquarePlus } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import type { Theme } from "@/styles/theme"; const ThemedSquarePlus = withUnistyles(SquarePlus); const ThemedSquareMinus = withUnistyles(SquareMinus); +const ThemedSquareDot = withUnistyles(SquareDot); const successMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess }); const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger }); +const warningMapping = (theme: Theme) => ({ color: theme.colors.statusWarning }); const ICON_SIZE = 14; /** - * Whether a file was added or removed outright, shown beside its diff stat. + * The file's change kind, shown beside its diff stat. * - * Square-plus / square-minus because that is what every forge uses for the same fact, so it needs - * no label — the shape is the word. It sits at the same muted weight as the "+12 −3" next to it: - * these are footnotes on a file header, not the diff body where colour carries the line-by-line - * signal. + * The square variants form one aligned visual family for added, deleted, and modified files. */ -export function FileChangeIcon({ change }: { change: "added" | "deleted" }): ReactElement { +export function FileChangeIcon({ + change, +}: { + change: "added" | "deleted" | "modified"; +}): ReactElement { const { t } = useTranslation(); if (change === "added") { @@ -33,11 +36,21 @@ export function FileChangeIcon({ change }: { change: "added" | "deleted" }): Rea ); } + if (change === "deleted") { + return ( + + ); + } + return ( - ); } diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index c24268a6d..cc9661c5a 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -31,10 +31,8 @@ import { ChevronDown, Eye, EyeOff, FilePlus, FolderPlus, RotateCw } from "lucide import { MaterialFileIcon } from "@/components/material-file-icon"; import { TreeChevron, - TreeIndentGuides, treeRowPaddingLeft, - WORKSPACE_FILE_ROW_TRAILING_PADDING, - WORKSPACE_FILE_ROW_VERTICAL_PADDING, + workspaceTreeRowStyles, WORKSPACE_TREE_ICON_LABEL_GAP, WORKSPACE_TREE_ICON_SIZE, WORKSPACE_TREE_LOADING_ICON_SIZE, @@ -201,8 +199,7 @@ function EntryNameInputRow({ ); return ( - - + {kind === "directory" ? ( @@ -259,6 +256,9 @@ function TreeRowItem({ testID, }: TreeRowItemProps) { const { t } = useTranslation(); + const [isHovered, setIsHovered] = useState(false); + const showNameHover = useCallback(() => setIsHovered(true), []); + const hideNameHover = useCallback(() => setIsHovered(false), []); const isDirectory = entry.kind === "directory"; const dragSourceRef = useWorkspaceFileDragSource({ enabled: !isDirectory, @@ -282,9 +282,9 @@ function TreeRowItem({ const pressableStyle = useCallback( ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ - styles.entryRow, + workspaceTreeRowStyles.row, { paddingLeft: treeRowPaddingLeft(depth) }, - (Boolean(hovered) || pressed || isSelected) && styles.entryRowActive, + (Boolean(hovered) || pressed || isSelected) && workspaceTreeRowStyles.active, ], [depth, isSelected], ); @@ -364,11 +364,12 @@ function TreeRowItem({ onLongPress={handleSelect} onContextMenu={handleSelect} style={pressableStyle} + onHoverIn={showNameHover} + onHoverOut={hideNameHover} accessibilityState={accessibilityState} aria-selected={isSelected} testID={testID} > - {isDirectory ? ( @@ -377,7 +378,15 @@ function TreeRowItem({ )} - + {entry.name} @@ -1738,16 +1747,6 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, - entryRow: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING, - paddingRight: WORKSPACE_FILE_ROW_TRAILING_PADDING, - }, - entryRowActive: { - backgroundColor: theme.colors.surfaceSidebarHover, - }, entryInfo: { flex: 1, flexDirection: "row", diff --git a/packages/app/src/components/tree-primitives.tsx b/packages/app/src/components/tree-primitives.tsx index 9d3c129d4..b1da728ad 100644 --- a/packages/app/src/components/tree-primitives.tsx +++ b/packages/app/src/components/tree-primitives.tsx @@ -1,16 +1,14 @@ -import { useMemo } from "react"; import { View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { ChevronRight } from "lucide-react-native"; import { SPACING, type Theme } from "@/styles/theme"; -import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; // Shared presentation primitives for the app's directory trees. Both the Files // explorer (server-loaded listings) and the Changes view (client-built from diff // paths) render different data, but their ROWS should look identical — same -// indentation, guide lines, and chevron. Keep those here so the two trees can't +// indentation, density, name emphasis, and chevron. Keep those here so the two trees can't // drift apart. -export const TREE_INDENT_PER_LEVEL = 16; +export const TREE_INDENT_PER_LEVEL = 12; export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5]; export const WORKSPACE_TREE_ICON_SIZE = 16; export const WORKSPACE_TREE_LOADING_ICON_SIZE = 14; @@ -22,8 +20,7 @@ export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[2]; */ export const WORKSPACE_FILE_ROW_TRAILING_PADDING = SPACING[4] + 2; -/** Left padding for a tree row at `depth`. Shared by folder rows and file headers - * in the Changes tree so their indentation can't drift apart. */ +/** Left padding for a row in either workspace tree. */ export function treeRowPaddingLeft(depth: number): number { return SPACING[3] + depth * TREE_INDENT_PER_LEVEL; } @@ -34,32 +31,6 @@ const foregroundExtraMutedIconColorMapping = (theme: Theme) => ({ const ThemedChevronRight = withUnistyles(ChevronRight); -/** - * Vertical guide lines connecting nested rows to their ancestors — one line per - * ancestor depth level, positioned absolutely within the (relative) row. Renders - * nothing at depth 0. - */ -export function TreeIndentGuides({ depth }: { depth: number }) { - const guides = useMemo( - () => - Array.from({ length: depth }, (_, index) => ({ - key: index, - style: [ - styles.indentGuide, - inlineUnistylesStyle({ left: SPACING[3] + index * TREE_INDENT_PER_LEVEL + 4 }), - ], - })), - [depth], - ); - return ( - <> - {guides.map((guide) => ( - - ))} - - ); -} - /** Rotating disclosure chevron for a directory row (points right; rotates down when expanded). */ export function TreeChevron({ expanded }: { expanded: boolean }) { return ( @@ -72,14 +43,22 @@ export function TreeChevron({ expanded }: { expanded: boolean }) { ); } -const styles = StyleSheet.create((theme: Theme) => ({ - indentGuide: { - position: "absolute", - top: 0, - bottom: 0, - width: 1, - backgroundColor: theme.colors.surface2, +export const workspaceTreeRowStyles = StyleSheet.create((theme: Theme) => ({ + row: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING, + paddingRight: WORKSPACE_FILE_ROW_TRAILING_PADDING, }, + active: { + backgroundColor: theme.colors.surfaceSidebarHover, + }, + name: { color: theme.colors.foreground, opacity: 0.76 }, + nameHovered: { opacity: 1 }, +})); + +const styles = StyleSheet.create((_theme: Theme) => ({ chevron: { width: WORKSPACE_TREE_ICON_SIZE, height: WORKSPACE_TREE_ICON_SIZE, diff --git a/packages/app/src/contexts/horizontal-scroll-context.tsx b/packages/app/src/contexts/horizontal-scroll-context.tsx index a48c740c1..a84f53f11 100644 --- a/packages/app/src/contexts/horizontal-scroll-context.tsx +++ b/packages/app/src/contexts/horizontal-scroll-context.tsx @@ -4,6 +4,12 @@ import { useSharedValue, type SharedValue } from "react-native-reanimated"; interface HorizontalScrollContextValue { // Shared value indicating if any registered horizontal scroll is scrolled (offset > 0) isAnyScrolledRight: SharedValue; + // Captures whether the horizontal surface under the current touch started + // away from its leading edge. The value stays true for the whole gesture so + // returning to zero cannot also close a mobile panel in the same swipe. + activeGestureStartedScrolled: SharedValue; + beginHorizontalGesture: (offset: number) => void; + endHorizontalGesture: () => void; // Register a scroll view's offset - returns an unregister function registerScrollOffset: (id: string, offset: number) => void; unregisterScrollOffset: (id: string) => void; @@ -13,8 +19,20 @@ const HorizontalScrollContext = createContext>(new Map()); + const beginHorizontalGesture = useCallback( + (offset: number) => { + activeGestureStartedScrolled.value = offset > 1; + }, + [activeGestureStartedScrolled], + ); + + const endHorizontalGesture = useCallback(() => { + activeGestureStartedScrolled.value = false; + }, [activeGestureStartedScrolled]); + const updateIsAnyScrolled = useCallback(() => { let anyScrolled = false; for (const offset of scrollOffsetsRef.current.values()) { @@ -44,11 +62,21 @@ export function HorizontalScrollProvider({ children }: { children: ReactNode }) const contextValue = useMemo( () => ({ + activeGestureStartedScrolled, + beginHorizontalGesture, + endHorizontalGesture, isAnyScrolledRight, registerScrollOffset, unregisterScrollOffset, }), - [isAnyScrolledRight, registerScrollOffset, unregisterScrollOffset], + [ + activeGestureStartedScrolled, + beginHorizontalGesture, + endHorizontalGesture, + isAnyScrolledRight, + registerScrollOffset, + unregisterScrollOffset, + ], ); return ( diff --git a/packages/app/src/git/diff-document/document-file-header.tsx b/packages/app/src/git/diff-document/document-file-header.tsx index 92398d149..6f8c6e800 100644 --- a/packages/app/src/git/diff-document/document-file-header.tsx +++ b/packages/app/src/git/diff-document/document-file-header.tsx @@ -3,19 +3,23 @@ import { FileHeader } from "@/git/file-header"; import type { DiffFileSection } from "./types"; import type { DiffDocumentProps } from "./types"; +interface DocumentFileHeaderProps { + file: DiffFileSection; + selectedPath: string | null; + mode: DiffDocumentProps["mode"]; + onToggleFile: (path: string) => void; + onSelectPath: (path: string) => void; + showTopBorder: boolean; +} + 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; -}) { + showTopBorder, +}: DocumentFileHeaderProps) { const activate = useCallback( (path: string) => { if (mode.kind !== "working") return; @@ -31,6 +35,7 @@ export const DocumentFileHeader = memo(function DocumentFileHeader({ bodyVisible={!file.isCollapsed} isSelected={selectedPath === file.path} interactive={mode.kind === "working"} + showTopBorder={showTopBorder} workspaceFileDragScope={working?.workspaceFileDragScope} onActivate={activate} onSelect={onSelectPath} @@ -46,4 +51,41 @@ export const DocumentFileHeader = memo(function DocumentFileHeader({ testID={`diff-file-${file.fileIndex}`} /> ); -}); +}, documentFileHeaderPropsEqual); + +function documentFileHeaderPropsEqual( + previous: DocumentFileHeaderProps, + next: DocumentFileHeaderProps, +): boolean { + if (!documentFileHeaderIdentityMatches(previous, next)) return false; + if (previous.mode.kind === "commit" || next.mode.kind === "commit") return true; + return ( + previous.mode.onFilePress === next.mode.onFilePress && + previous.mode.workspaceFileDragScope === next.mode.workspaceFileDragScope && + previous.mode.onOpenFile === next.mode.onOpenFile && + previous.mode.onAddToChat === next.mode.onAddToChat && + previous.mode.onCopyPath === next.mode.onCopyPath && + previous.mode.onCopyRelativePath === next.mode.onCopyRelativePath && + previous.mode.onReveal === next.mode.onReveal && + previous.mode.revealTargetName === next.mode.revealTargetName && + previous.mode.onDownload === next.mode.onDownload && + previous.mode.onDuplicate === next.mode.onDuplicate && + previous.mode.onRevert === next.mode.onRevert + ); +} + +function documentFileHeaderIdentityMatches( + previous: DocumentFileHeaderProps, + next: DocumentFileHeaderProps, +): boolean { + return !( + previous.file.file !== next.file.file || + previous.file.fileIndex !== next.file.fileIndex || + previous.file.isCollapsed !== next.file.isCollapsed || + previous.showTopBorder !== next.showTopBorder || + (previous.selectedPath === previous.file.path) !== (next.selectedPath === next.file.path) || + previous.onToggleFile !== next.onToggleFile || + previous.onSelectPath !== next.onSelectPath || + previous.mode.kind !== next.mode.kind + ); +} diff --git a/packages/app/src/git/diff-document/horizontal-scroll.native.tsx b/packages/app/src/git/diff-document/horizontal-scroll.native.tsx index 1f5374043..993b2048b 100644 --- a/packages/app/src/git/diff-document/horizontal-scroll.native.tsx +++ b/packages/app/src/git/diff-document/horizontal-scroll.native.tsx @@ -1,12 +1,18 @@ 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 { + createAnimatedComponent, + 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"; +const AnimatedHorizontalScrollView = createAnimatedComponent(ScrollView); + export function HorizontalScroll({ file, offsets, @@ -48,16 +54,33 @@ export function HorizontalScroll({ }, [horizontalScroll, scrollId], ); + const beginHorizontalGesture = useCallback(() => { + horizontalScroll?.beginHorizontalGesture(horizontalOffsetForPath(offsets.value, file.path)); + }, [file.path, horizontalScroll, offsets]); + const endHorizontalGesture = useCallback(() => { + horizontalScroll?.endHorizontalGesture(); + }, [horizontalScroll]); const rootStyle = useMemo( - () => ({ position: "absolute" as const, top: 0, left: 0, right: 0, height: file.bodyHeight }), + () => ({ + position: "absolute" as const, + top: 0, + left: 0, + right: 0, + height: file.bodyHeight, + backgroundColor: "transparent", + }), [file.bodyHeight], ); const spacerStyle = useMemo( - () => ({ width: file.contentWidth, height: file.bodyHeight }), + () => ({ + width: file.contentWidth, + height: file.bodyHeight, + backgroundColor: "transparent", + }), [file.bodyHeight, file.contentWidth], ); return ( - - + ); } diff --git a/packages/app/src/git/diff-document/index.tsx b/packages/app/src/git/diff-document/index.tsx index d60775d4e..b83f52604 100644 --- a/packages/app/src/git/diff-document/index.tsx +++ b/packages/app/src/git/diff-document/index.tsx @@ -1,6 +1,6 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { withUnistyles } from "react-native-unistyles"; -import { createDiffPalette } from "./palette"; +import { createDiffPalette, retainDiffPalette } from "./palette"; import { DiffSurface } from "./surface"; import type { DiffDocumentProps, DiffPalette } from "./types"; @@ -14,6 +14,9 @@ const EMPTY_PATHS: string[] = []; function ThemedDiffDocument(props: ThemedDiffDocumentProps) { const [selectedPath, setSelectedPath] = useState(null); + const paletteRef = useRef(props.palette); + paletteRef.current = retainDiffPalette(paletteRef.current, props.palette); + const palette = paletteRef.current; const collapseState = props.mode.kind === "working" ? props.collapseState : null; const paths = collapseState?.paths ?? EMPTY_PATHS; const collapsedFilePaths = useMemo(() => new Set(paths), [paths]); @@ -30,6 +33,7 @@ function ThemedDiffDocument(props: ThemedDiffDocumentProps) { return ( = {}): BuildDiffD } describe("diff document model", () => { + it("segments graphemes when Intl.Segmenter is unavailable", () => { + const originalSegmenter = Intl.Segmenter; + Object.defineProperty(Intl, "Segmenter", { configurable: true, value: undefined }); + try { + const source = "中é👨‍👩‍👧‍👦"; + const fragments = measureFragments({ + text: source, + availableWidth: 100, + wrapLines: true, + lineHeight: 18, + measureText: measurer, + }); + expect(fragments[0]?.graphemes.map((grapheme) => grapheme.text)).toEqual(["中", "é", "👨‍👩‍👧‍👦"]); + } finally { + Object.defineProperty(Intl, "Segmenter", { configurable: true, value: originalSegmenter }); + } + }); + it("measures grapheme-safe wrapped fragments", () => { const text = "ab👨‍👩‍👧‍👦cd"; const fragments = measureFragments({ @@ -161,6 +181,58 @@ describe("diff document model", () => { expect(collapsed.height).toBeLessThan(expanded.height); }); + it("reuses unchanged file measurements when collapse state changes", () => { + const files = [file("a.ts"), file("b.ts")]; + let measurementCount = 0; + const countingMeasurer: TextMeasurer = { + measure(text) { + measurementCount += 1; + return Array.from(text).length * 10; + }, + }; + const expanded = buildDiffDocumentModel(input({ files, measureText: countingMeasurer })); + const initialMeasurementCount = measurementCount; + + const collapsed = buildDiffDocumentModel( + input({ + files, + collapsedFilePaths: new Set(["a.ts"]), + measureText: countingMeasurer, + reuseFrom: [expanded], + }), + ); + + expect(measurementCount).toBe(initialMeasurementCount); + expect(collapsed.files[1]?.bodyHeight).toBe(expanded.files[1]?.bodyHeight); + expect(collapsed.rows.every((row, index) => row.index === index)).toBe(true); + expect(collapsed.rows[0]?.top).toBe(FILE_HEADER_HEIGHT * 2); + + const expandedAgain = buildDiffDocumentModel( + input({ + files, + measureText: countingMeasurer, + reuseFrom: [expanded, collapsed], + }), + ); + expect(measurementCount).toBe(initialMeasurementCount); + expect(expandedAgain.height).toBe(expanded.height); + }); + + it("keeps the toggled sticky header anchored through collapse and expansion", () => { + const files = [file("a.ts"), file("b.ts")]; + const expanded = buildDiffDocumentModel(input({ files })); + const firstFile = expanded.files[0]!; + const scrollTop = firstFile.top + 20; + const collapsed = buildDiffDocumentModel( + input({ files, collapsedFilePaths: new Set(["a.ts"]), reuseFrom: [expanded] }), + ); + + expect(resolveRelayoutScrollTop(expanded, collapsed, scrollTop)).toBe(collapsed.files[0]!.top); + expect(resolveRelayoutScrollTop(collapsed, expanded, collapsed.files[0]!.top)).toBe( + expanded.files[0]!.top, + ); + }); + it("reserves review geometry in the measured row", () => { const model = buildDiffDocumentModel( input({ diff --git a/packages/app/src/git/diff-document/model.ts b/packages/app/src/git/diff-document/model.ts index 18552c445..814552734 100644 --- a/packages/app/src/git/diff-document/model.ts +++ b/packages/app/src/git/diff-document/model.ts @@ -1,4 +1,5 @@ import { lineNumberGutterWidth } from "@/components/code-insets"; +import { findClusterBreak } from "@marijn/find-cluster-break"; import { buildSplitDiffRows, buildUnifiedDiffLines, @@ -22,7 +23,7 @@ import type { TextMeasurer, } from "./types"; -export const FILE_HEADER_HEIGHT = 44; +export const FILE_HEADER_HEIGHT = 30; const BODY_BORDER_HEIGHT = 1; const CODE_HORIZONTAL_PADDING = 16; @@ -49,7 +50,28 @@ export function buildDiffDocumentModel(input: BuildDiffDocumentModelInput): Diff const gutterWidth = lineNumberGutterWidth(maximumLineNumber(file), input.typography.size); let maximumHorizontalOverflow = 0; - if (!isCollapsed) { + const reusableModel = input.reuseFrom?.find((candidate) => + candidate.files.some((entry) => entry.file === file && !entry.isCollapsed), + ); + const reusableFile = reusableModel?.files.find( + (candidate) => candidate.file === file && !candidate.isCollapsed, + ); + + if (!isCollapsed && reusableFile) { + const reusedRows = reusableModel!.rows.slice(reusableFile.rowStart, reusableFile.rowEnd); + for (const reusedRow of reusedRows) { + const height = reusedRow.height; + rows.push({ + ...reusedRow, + index: rows.length, + fileIndex, + top: documentTop, + }); + documentTop += height; + } + maximumHorizontalOverflow = Math.max(0, reusableFile.contentWidth - input.viewportWidth); + documentTop += BODY_BORDER_HEIGHT; + } else if (!isCollapsed) { if (file.status === "binary" || file.status === "too_large") { const height = input.typography.lineHeight + 24; rows.push({ @@ -136,6 +158,16 @@ export function buildDiffDocumentModel(input: BuildDiffDocumentModelInput): Diff }; } +export function retainReusableModels( + previous: readonly DiffDocumentModel[] | undefined, + next: DiffDocumentModel, +): DiffDocumentModel[] { + const fullest = [...(previous ?? []), next].reduce((best, candidate) => + candidate.rows.length > best.rows.length ? candidate : best, + ); + return fullest === next ? [next] : [fullest, next]; +} + function lineSources( file: BuildDiffDocumentModelInput["files"][number], layout: "unified" | "split", @@ -302,17 +334,19 @@ 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"; + for (let index = 0; index < text.length; ) { + const end = findClusterBreak(text, index); + const segment = text.slice(index, end); + const isTab = segment === "\t"; const spaces = isTab ? TAB_SIZE - (column % TAB_SIZE) : 0; - const displayText = isTab ? " ".repeat(spaces) : segment.segment; + const displayText = isTab ? " ".repeat(spaces) : segment; segments.push({ - start: segment.index, - end: segment.index + segment.segment.length, + start: index, + end, text: displayText, }); column += isTab ? spaces : 1; + index = end; } return segments; } @@ -351,6 +385,7 @@ function measurePrefixAdvances( } export function fragmentTextForRange(fragment: DiffFragment, start: number, end: number): string { + "worklet"; return fragment.graphemes .filter((grapheme) => grapheme.start >= start && grapheme.end <= end) .map((grapheme) => grapheme.text) @@ -365,10 +400,9 @@ export function fragmentWidthForRange(fragment: DiffFragment, start: number, end 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); + for (let index = 0; index < text.length; ) { + index = findClusterBreak(text, index); + boundaries.push(index); } return boundaries; } @@ -501,6 +535,30 @@ export function resolveScrollAnchor(model: DiffDocumentModel, anchor: DiffScroll return Math.max(0, row.top + (fragment?.top ?? 0) - anchor.viewportDelta); } +export function resolveRelayoutScrollTop( + previous: DiffDocumentModel, + next: DiffDocumentModel, + scrollTop: number, +): number { + const changedFiles = previous.files.filter((file) => { + const nextFile = next.files.find((candidate) => candidate.path === file.path); + return nextFile && nextFile.isCollapsed !== file.isCollapsed; + }); + if (changedFiles.length === 1) { + const previousFile = changedFiles[0]!; + const nextFile = next.files.find((candidate) => candidate.path === previousFile.path); + if (nextFile) { + const headerDelta = + previousFile.top <= scrollTop && scrollTop < previousFile.bottom + ? 0 + : previousFile.top - scrollTop; + return Math.max(0, nextFile.top - headerDelta); + } + } + const anchor = captureScrollAnchor(previous, scrollTop); + return anchor ? resolveScrollAnchor(next, anchor) : scrollTop; +} + function sameSourceIdentity(left: DiffSourceIdentity, right: DiffSourceIdentity): boolean { return ( left.hunkIndex === right.hunkIndex && 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 index 2090069aa..2b0b27890 100644 --- a/packages/app/src/git/diff-document/native-hit-testing.test.ts +++ b/packages/app/src/git/diff-document/native-hit-testing.test.ts @@ -1,24 +1,21 @@ import type { ParsedDiffFile } from "@getpaseo/protocol/messages"; import { describe, expect, it } from "vitest"; import { buildDiffDocumentModel } from "./model"; -import { hitTestDiffPagePoint } from "./native-hit-testing"; +import { hitTestDiffBodyPoint } 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", () => { +describe("native diff body-coordinate hit testing", () => { + it("resolves first and later files from their body-local coordinates", () => { 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({ + const file = model.files[row.fileIndex]!; + const hit = hitTestDiffBodyPoint({ model, - pageX: origin.x + model.files[row.fileIndex]!.gutterWidth + 18, - pageY: origin.y + row.top - scrollTop + 2, - surfaceOrigin: origin, - scrollTop, - horizontalOffsetForPath: () => 0, + file, + locationX: file.gutterWidth + 18, + locationY: row.top - file.bodyTop + 2, + horizontalOffset: 0, }); expect(hit?.kind).toBe("cell"); if (hit?.kind === "cell") expect(hit.position.fileIndex).toBe(row.fileIndex); @@ -33,18 +30,18 @@ describe("native diff page-coordinate hit testing", () => { candidate.path === "src/second.ts" && candidate.cells[0]?.type === "add", )!; - const hit = hitTestDiffPagePoint({ + const file = model.files[1]!; + const hit = hitTestDiffBodyPoint({ 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), + file, + locationX: file.gutterWidth + 8, + locationY: row.top - file.bodyTop + 2, + horizontalOffset: 30, }); 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", () => { + it("resolves old and new split cells from body-local coordinates", () => { const model = build("split"); const row = model.rows.find( (candidate) => @@ -58,13 +55,13 @@ describe("native diff page-coordinate hit testing", () => { }); function hitAt(model: ReturnType, rowTop: number, x: number) { - return hitTestDiffPagePoint({ + const file = model.files.find((entry) => entry.bodyTop <= rowTop && rowTop < entry.bottom)!; + return hitTestDiffBodyPoint({ model, - pageX: origin.x + x, - pageY: origin.y + rowTop + 2, - surfaceOrigin: origin, - scrollTop: 0, - horizontalOffsetForPath: () => 0, + file, + locationX: x, + locationY: rowTop - file.bodyTop + 2, + horizontalOffset: 0, }); } diff --git a/packages/app/src/git/diff-document/native-hit-testing.ts b/packages/app/src/git/diff-document/native-hit-testing.ts index 31154f42f..dfa4c0c32 100644 --- a/packages/app/src/git/diff-document/native-hit-testing.ts +++ b/packages/app/src/git/diff-document/native-hit-testing.ts @@ -1,28 +1,17 @@ import { hitTestDiffDocument } from "./hit-testing"; -import type { DiffDocumentModel, DiffHit } from "./types"; +import type { DiffDocumentModel, DiffFileSection, DiffHit } from "./types"; -export interface SurfaceWindowOrigin { - x: number; - y: number; -} - -export function hitTestDiffPagePoint(input: { +export function hitTestDiffBodyPoint(input: { model: DiffDocumentModel; - pageX: number; - pageY: number; - surfaceOrigin: SurfaceWindowOrigin; - scrollTop: number; - horizontalOffsetForPath: (path: string) => number; + file: DiffFileSection; + locationX: number; + locationY: number; + horizontalOffset: 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, + x: input.locationX, + documentY: input.file.bodyTop + input.locationY, + horizontalOffset: input.horizontalOffset, }); } diff --git a/packages/app/src/git/diff-document/native-slabs.test.ts b/packages/app/src/git/diff-document/native-slabs.test.ts new file mode 100644 index 000000000..a2821711f --- /dev/null +++ b/packages/app/src/git/diff-document/native-slabs.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { FILE_HEADER_HEIGHT } from "./model"; +import { + buildNativeCanvasSlabs, + nativeCanvasSlabsForViewport, + nativeCanvasWindowBucket, + nativeCanvasWindowTop, + nativeStickyHeaderIndices, +} from "./native-slabs"; +import type { DiffDocumentModel } from "./types"; + +function modelWithBodies( + bodies: Array<{ path: string; bodyTop: number; bottom: number }>, +): DiffDocumentModel { + return { + files: bodies.map((body, fileIndex) => ({ + file: {} as never, + fileIndex, + path: body.path, + top: body.bodyTop - FILE_HEADER_HEIGHT, + headerHeight: FILE_HEADER_HEIGHT, + bodyTop: body.bodyTop, + bodyHeight: body.bottom - body.bodyTop, + bottom: body.bottom, + gutterWidth: 48, + contentWidth: 500, + rowStart: 0, + rowEnd: 0, + isCollapsed: body.bottom === body.bodyTop, + })), + rows: [], + height: bodies.at(-1)?.bottom ?? 0, + lineHeight: 20, + layout: "unified", + wrapLines: false, + viewportWidth: 400, + }; +} + +describe("native canvas slabs", () => { + it("identifies each direct file header child for native sticky scrolling", () => { + expect(nativeStickyHeaderIndices(0)).toEqual([]); + expect(nativeStickyHeaderIndices(3)).toEqual([1, 3, 5]); + }); + + it("cuts large file bodies into fixed document-positioned slabs", () => { + const slabs = buildNativeCanvasSlabs( + modelWithBodies([ + { path: "small.ts", bodyTop: FILE_HEADER_HEIGHT, bottom: FILE_HEADER_HEIGHT + 200 }, + { + path: "large.ts", + bodyTop: FILE_HEADER_HEIGHT * 2 + 200, + bottom: FILE_HEADER_HEIGHT * 2 + 3400, + }, + ]), + 600, + ); + + expect(slabs.map(({ path, top, height }) => ({ path, top, height }))).toEqual([ + { path: "small.ts", top: 30, height: 200 }, + { path: "large.ts", top: 260, height: 1200 }, + { path: "large.ts", top: 1460, height: 1200 }, + { path: "large.ts", top: 2660, height: 800 }, + ]); + }); + + it("retains only viewport-adjacent slabs and updates on half-viewport boundaries", () => { + const slabs = buildNativeCanvasSlabs( + modelWithBodies([ + { path: "large.ts", bodyTop: FILE_HEADER_HEIGHT, bottom: FILE_HEADER_HEIGHT + 4800 }, + ]), + 600, + ); + + expect(nativeCanvasSlabsForViewport(slabs, 1700, 600).map((slab) => slab.top)).toEqual([ + 30, 1230, 2430, + ]); + expect(nativeCanvasWindowBucket(899, 600)).toBe(2); + expect(nativeCanvasWindowTop(2, 600)).toBe(600); + }); +}); diff --git a/packages/app/src/git/diff-document/native-slabs.ts b/packages/app/src/git/diff-document/native-slabs.ts new file mode 100644 index 000000000..bcbd2d0fb --- /dev/null +++ b/packages/app/src/git/diff-document/native-slabs.ts @@ -0,0 +1,54 @@ +import type { DiffDocumentModel } from "./types"; + +export interface NativeCanvasSlab { + key: string; + path: string; + fileIndex: number; + top: number; + height: number; +} + +export function nativeStickyHeaderIndices(fileCount: number): number[] { + return Array.from({ length: fileCount }, (_, index) => index * 2 + 1); +} + +export function buildNativeCanvasSlabs( + model: DiffDocumentModel, + viewportHeight: number, +): NativeCanvasSlab[] { + const slabHeight = Math.max(768, viewportHeight * 2); + return model.files.flatMap((file) => { + const slabs: NativeCanvasSlab[] = []; + for (let top = file.bodyTop; top < file.bottom; top += slabHeight) { + const height = Math.min(slabHeight, file.bottom - top); + slabs.push({ + key: `${file.path}:${top}:${height}`, + path: file.path, + fileIndex: file.fileIndex, + top, + height, + }); + } + return slabs; + }); +} + +export function nativeCanvasSlabsForViewport( + slabs: readonly NativeCanvasSlab[], + scrollTop: number, + viewportHeight: number, +): NativeCanvasSlab[] { + const top = Math.max(0, scrollTop - viewportHeight); + const bottom = scrollTop + viewportHeight * 2; + return slabs.filter((slab) => slab.top < bottom && slab.top + slab.height > top); +} + +export function nativeCanvasWindowBucket(scrollTop: number, viewportHeight: number): number { + "worklet"; + return Math.floor(Math.max(0, scrollTop) / Math.max(1, viewportHeight / 2)); +} + +export function nativeCanvasWindowTop(bucket: number, viewportHeight: number): number { + "worklet"; + return bucket * Math.max(1, viewportHeight / 2); +} diff --git a/packages/app/src/git/diff-document/native-text-runs.test.ts b/packages/app/src/git/diff-document/native-text-runs.test.ts new file mode 100644 index 000000000..48bec83ff --- /dev/null +++ b/packages/app/src/git/diff-document/native-text-runs.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { nativeTextRuns } from "./native-text-runs"; +import type { DiffCell, DiffFragment } from "./types"; + +describe("native diff text runs", () => { + it("positions adjacent token text without redrawing through clipped color bands", () => { + const fragment: DiffFragment = { + start: 0, + end: 6, + text: "import", + width: 42, + top: 0, + baseline: 14, + graphemes: [..."import"].map((text, index) => ({ + start: index, + end: index + 1, + text, + width: 7, + })), + }; + const cell: DiffCell = { + type: "context", + content: "import", + lineNumber: 1, + tokens: [ + { start: 0, end: 2, color: "purple" }, + { start: 2, end: 6, color: "blue" }, + ], + fragments: [fragment], + reviewTarget: null, + sourceIdentity: { hunkIndex: 0, lineIndex: 1, side: "new" }, + }; + + expect(nativeTextRuns(cell, fragment)).toEqual([ + { color: "purple", left: 0, text: "im" }, + { color: "blue", left: 14, text: "port" }, + ]); + }); +}); diff --git a/packages/app/src/git/diff-document/native-text-runs.ts b/packages/app/src/git/diff-document/native-text-runs.ts new file mode 100644 index 000000000..00d9d23ed --- /dev/null +++ b/packages/app/src/git/diff-document/native-text-runs.ts @@ -0,0 +1,23 @@ +import { fragmentTextForRange } from "./model"; +import type { DiffCell, DiffFragment } from "./types"; + +export interface NativeTextRun { + color: string; + left: number; + text: string; +} + +/** Converts syntax ranges into disjoint positioned runs so native paints every glyph once. */ +export function nativeTextRuns(cell: DiffCell, fragment: DiffFragment): NativeTextRun[] { + "worklet"; + return cell.tokens.flatMap((token) => { + const start = Math.max(fragment.start, token.start); + const end = Math.min(fragment.end, token.end); + if (end <= start) return []; + let left = 0; + for (const grapheme of fragment.graphemes) { + if (grapheme.end <= start) left += grapheme.width; + } + return [{ color: token.color, left, text: fragmentTextForRange(fragment, start, end) }]; + }); +} diff --git a/packages/app/src/git/diff-document/paint.native.ts b/packages/app/src/git/diff-document/paint.native.ts index b3db4c904..fb1116041 100644 --- a/packages/app/src/git/diff-document/paint.native.ts +++ b/packages/app/src/git/diff-document/paint.native.ts @@ -1,16 +1,30 @@ -import { ClipOp, Skia, type SkCanvas, type SkPaint } from "@shopify/react-native-skia"; +import { + ClipOp, + Skia, + type SkCanvas, + type SkPaint, + type SkPicture, +} from "@shopify/react-native-skia"; import { visibleRowRange } from "./model"; +import { nativeTextRuns } from "./native-text-runs"; 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"; +import type { + DiffCell, + DiffDocumentModel, + DiffFileSection, + DiffLineRow, + DiffPalette, +} from "./types"; const CODE_LEFT_PADDING = 8; -interface NativePaints { +export interface NativePaints { surface: SkPaint; border: SkPaint; + foreground: SkPaint; foregroundMuted: SkPaint; addition: SkPaint; deletion: SkPaint; @@ -18,13 +32,14 @@ interface NativePaints { deletionBackground: SkPaint; headerSurface: SkPaint; emptyBackground: SkPaint; - syntax: Record; + text: Record; } export function createNativePaints(palette: DiffPalette): NativePaints { return { surface: paint(palette.surface), border: paint(palette.border), + foreground: paint(palette.foreground), foregroundMuted: paint(palette.foregroundMuted), addition: paint(palette.addition), deletion: paint(palette.deletion), @@ -32,7 +47,11 @@ export function createNativePaints(palette: DiffPalette): NativePaints { deletionBackground: paint(palette.deletionBackground), headerSurface: paint(palette.headerSurface), emptyBackground: paint(palette.emptyBackground), - syntax: Object.fromEntries(Object.values(palette.syntax).map((color) => [color, paint(color)])), + text: Object.fromEntries( + [ + ...new Set([palette.foreground, palette.foregroundMuted, ...Object.values(palette.syntax)]), + ].map((color) => [color, paint(color)]), + ), }; } @@ -52,113 +71,308 @@ export function paintNativeViewport(input: { textLayout: NativeTextLayout; paints: NativePaints; }): void { - "worklet"; - input.canvas.drawRect( - Skia.XYWHRect(0, 0, input.viewportWidth, input.viewportHeight), - input.paints.surface, + paintNativeRange({ ...input, layer: "all" }); +} + +export function recordNativeSlabPictures(input: { + model: DiffDocumentModel; + fileIndex: number; + top: number; + height: number; + viewportWidth: number; + textLayout: NativeTextLayout; + paints: NativePaints; +}): { + fixed: SkPicture; + gutter: SkPicture; + content: { unified: SkPicture; left: SkPicture; right: SkPicture }; +} { + const file = input.model.files[input.fileIndex]; + const fixedRecorder = Skia.PictureRecorder(); + const fixedCanvas = fixedRecorder.beginRecording( + Skia.XYWHRect(0, 0, input.viewportWidth, input.height), ); + paintNativeRange({ + canvas: fixedCanvas, + model: input.model, + viewportWidth: input.viewportWidth, + viewportHeight: input.height, + scrollTop: input.top, + horizontalOffsets: {}, + textLayout: input.textLayout, + paints: input.paints, + layer: "fixed", + }); + + const recordContent = (contentCell: "unified" | "left" | "right") => { + const recorder = Skia.PictureRecorder(); + const canvas = recorder.beginRecording( + Skia.XYWHRect(0, 0, Math.max(input.viewportWidth, file?.contentWidth ?? 0), input.height), + ); + paintNativeRange({ + canvas, + model: input.model, + viewportWidth: input.viewportWidth, + viewportHeight: input.height, + scrollTop: input.top, + horizontalOffsets: {}, + textLayout: input.textLayout, + paints: input.paints, + layer: "content", + contentCell, + }); + return recorder.finishRecordingAsPicture(); + }; + const gutterRecorder = Skia.PictureRecorder(); + const gutterCanvas = gutterRecorder.beginRecording( + Skia.XYWHRect(0, 0, input.viewportWidth, input.height), + ); + paintNativeGutter({ + canvas: gutterCanvas, + model: input.model, + top: input.top, + height: input.height, + textLayout: input.textLayout, + paints: input.paints, + }); + return { + fixed: fixedRecorder.finishRecordingAsPicture(), + gutter: gutterRecorder.finishRecordingAsPicture(), + content: { + unified: recordContent("unified"), + left: recordContent("left"), + right: recordContent("right"), + }, + }; +} + +function paintNativeGutter(input: { + canvas: SkCanvas; + model: DiffDocumentModel; + top: number; + height: number; + textLayout: NativeTextLayout; + paints: NativePaints; +}): void { + const range = visibleRowRange(input.model.rows, input.top, input.height); + for (let index = range.start; index < range.end; index += 1) { + const row = input.model.rows[index]; + if (!row || row.kind !== "line") continue; + const file = input.model.files[row.fileIndex]; + if (!file) continue; + const y = row.top - input.top; + const columnWidth = input.model.viewportWidth / row.cells.length; + row.cells.forEach((cell, cellIndex) => { + if (!cell) return; + const columnX = cellIndex * columnWidth; + input.canvas.drawRect( + Skia.XYWHRect(columnX + file.gutterWidth, y, 1, reviewDividerHeight(row.height)), + input.paints.border, + ); + const label = String(cell.lineNumber ?? ""); + const font = input.textLayout.font; + input.canvas.drawText( + label, + columnX + file.gutterWidth - 7 - font.getTextWidth(label), + y + input.model.lineHeight * 0.78, + input.paints[codeLineNumberTone(cell)], + font, + ); + }); + } +} + +interface PaintNativeRangeInput { + canvas: SkCanvas; + model: DiffDocumentModel; + viewportWidth: number; + viewportHeight: number; + scrollTop: number; + horizontalOffsets: Readonly; + textLayout: NativeTextLayout; + paints: NativePaints; + layer: "all" | "fixed" | "content"; + contentCell?: "unified" | "left" | "right"; +} + +function paintNativeRange(input: PaintNativeRangeInput): void { + "worklet"; + const paintsFixedContent = input.layer !== "content"; + const paintsCodeContent = input.layer !== "fixed"; + if (paintsFixedContent) { + input.canvas.drawRect( + Skia.XYWHRect(0, 0, input.viewportWidth, input.viewportHeight), + input.paints.surface, + ); + } + const backgroundPaints: Record = { + add: input.paints.additionBackground, + context: input.paints.surface, + empty: input.paints.emptyBackground, + header: input.paints.headerSurface, + remove: input.paints.deletionBackground, + }; 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, - ); + if (paintsFixedContent) { + 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), + const file = input.model.files[row.fileIndex]; + if (!file) continue; + const horizontalOffset = horizontalOffsetForPath(input.horizontalOffsets, row.path); + const columnWidth = input.viewportWidth / row.cells.length; + row.cells.forEach((cell, cellIndex) => { + const columnX = cellIndex * columnWidth; + if (paintsFixedContent) { + paintNativeFixedCell({ input, row, file, cell, columnX, columnWidth, y, backgroundPaints }); + } + if ( + paintsCodeContent && + cell && + paintsNativeContentCell(input, row.cells.length, cellIndex) + ) { + paintNativeCodeCell({ + input, + row, + file, + cell, + cellIndex, + columnX, + columnWidth, + horizontalOffset, + y, + }); + } }); } } -function paintLine(input: { - canvas: SkCanvas; - model: DiffDocumentModel; - viewportWidth: number; +function paintsNativeContentCell( + input: PaintNativeRangeInput, + columnCount: number, + cellIndex: number, +): boolean { + if (input.layer !== "content") return true; + if (columnCount === 1) return input.contentCell === "unified"; + return input.contentCell === (cellIndex === 0 ? "left" : "right"); +} + +function paintNativeFixedCell(input: { + input: PaintNativeRangeInput; row: DiffLineRow; + file: DiffFileSection; + cell: DiffCell | null; + columnX: number; + columnWidth: number; y: number; - horizontalOffset: number; - textLayout: NativeTextLayout; - paints: NativePaints; + backgroundPaints: Record; }): 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( + const backgroundPaint = input.cell + ? input.backgroundPaints[input.cell.type] + : input.input.paints.emptyBackground; + input.input.canvas.drawRect( + Skia.XYWHRect(input.columnX, input.y, input.columnWidth, input.row.height), + backgroundPaint, + ); + input.input.canvas.drawRect( + Skia.XYWHRect( + input.columnX, + reviewGapTop(input.y, input.row.height, input.row.reviewHeight), + input.columnWidth, + input.row.reviewHeight, + ), + reviewBackgroundPaint(input.input.paints.surface), + ); + if (!input.cell) return; + input.input.canvas.drawRect( + Skia.XYWHRect( + input.columnX + input.file.gutterWidth, + input.y, + 1, + reviewDividerHeight(input.row.height), + ), + input.input.paints.border, + ); + const label = String(input.cell.lineNumber ?? ""); + const font = input.input.textLayout.font; + input.input.canvas.drawText( + label, + input.columnX + input.file.gutterWidth - 7 - font.getTextWidth(label), + input.y + input.input.model.lineHeight * 0.78, + input.input.paints[codeLineNumberTone(input.cell)], + font, + ); +} + +function paintNativeCodeCell(input: { + input: PaintNativeRangeInput; + row: DiffLineRow; + file: DiffFileSection; + cell: DiffCell; + cellIndex: number; + columnX: number; + columnWidth: number; + horizontalOffset: number; + y: number; +}): void { + const textX = input.columnX + input.file.gutterWidth + CODE_LEFT_PADDING; + const clipsContent = input.input.layer === "all"; + if (clipsContent) { + input.input.canvas.save(); + input.input.canvas.clipRect( Skia.XYWHRect( textX, input.y, - columnWidth - file.gutterWidth - CODE_LEFT_PADDING, + input.columnWidth - input.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); + } + const offset = input.input.model.wrapLines ? 0 : input.horizontalOffset; + for (const [fragmentIndex, fragment] of input.cell.fragments.entries()) { + const paragraph = + input.input.textLayout.paragraphs[input.row.index]?.[input.cellIndex]?.[fragmentIndex]; + if (paragraph) { + paragraph.paint(input.input.canvas, textX - offset, input.y + fragment.top); + continue; } - 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)]; + const fragmentX = textX - offset; + const baseline = input.y + fragment.baseline; + if (input.cell.tokens.length === 0 || input.cell.type === "header") { + input.input.canvas.drawText( + fragment.text, + fragmentX, + baseline, + input.cell.type === "header" + ? input.input.paints.foregroundMuted + : input.input.paints.foreground, + input.input.textLayout.font, + ); + continue; + } + for (const run of nativeTextRuns(input.cell, fragment)) { + input.input.canvas.drawText( + run.text, + fragmentX + run.left, + baseline, + input.input.paints.text[run.color] ?? input.input.paints.foreground, + input.input.textLayout.font, + ); + } + } + if (clipsContent) input.input.canvas.restore(); } diff --git a/packages/app/src/git/diff-document/paint.web.ts b/packages/app/src/git/diff-document/paint.web.ts index 108bf63e3..09610ddf3 100644 --- a/packages/app/src/git/diff-document/paint.web.ts +++ b/packages/app/src/git/diff-document/paint.web.ts @@ -26,19 +26,27 @@ export interface PaintWebViewportInput { horizontalOffsets: ReadonlyMap; selection: DiffSelection | null; devicePixelRatio: number; + paintTop?: number; + paintHeight?: number; } export function paintWebViewport(input: PaintWebViewportInput): void { const { context } = input; const scale = input.devicePixelRatio; + const paintTop = input.paintTop ?? 0; + const paintHeight = input.paintHeight ?? input.viewportHeight; context.setTransform(scale, 0, 0, scale, 0, 0); - context.clearRect(0, 0, input.viewportWidth, input.viewportHeight); + context.save(); + context.beginPath(); + context.rect(0, paintTop, input.viewportWidth, paintHeight); + context.clip(); + context.clearRect(0, paintTop, input.viewportWidth, paintHeight); context.fillStyle = input.palette.surface; - context.fillRect(0, 0, input.viewportWidth, input.viewportHeight); + context.fillRect(0, paintTop, input.viewportWidth, paintHeight); context.font = `${input.typography.size}px ${input.typography.family}`; context.textBaseline = "alphabetic"; - const range = visibleRowRange(input.model.rows, input.scrollTop, input.viewportHeight); + const range = visibleRowRange(input.model.rows, input.scrollTop + paintTop, paintHeight); for (let index = range.start; index < range.end; index += 1) { const row = input.model.rows[index]; if (!row) continue; @@ -57,6 +65,7 @@ export function paintWebViewport(input: PaintWebViewportInput): void { } if (input.selection) paintSelection(input, input.selection); + context.restore(); } function paintLine( diff --git a/packages/app/src/git/diff-document/palette.test.ts b/packages/app/src/git/diff-document/palette.test.ts index 7cf235b84..75856a015 100644 --- a/packages/app/src/git/diff-document/palette.test.ts +++ b/packages/app/src/git/diff-document/palette.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; -import { codeLineNumberTone, codeTextColor } from "./palette"; +import { darkTheme, lightTheme } from "@/styles/theme"; +import { hexColorWithAlpha } from "@/utils/color"; +import { codeLineNumberTone, codeTextColor, createDiffPalette, retainDiffPalette } from "./palette"; import type { DiffCell, DiffPalette } from "./types"; describe("diff text color", () => { @@ -19,6 +21,32 @@ describe("diff text color", () => { }); }); +describe("diff palette retention", () => { + it("retains the previous value when a theme wrapper recreates equal colors", () => { + const recreated = { ...palette, syntax: { keyword: "purple" } }; + const previous = { ...recreated, syntax: { keyword: "purple" } }; + + expect(retainDiffPalette(previous, recreated)).toBe(previous); + }); + + it("accepts a real color change", () => { + const changed = { ...palette, foreground: "new-foreground" }; + + expect(retainDiffPalette(palette, changed)).toBe(changed); + }); +}); + +describe.each([lightTheme, darkTheme])("semantic diff colors", (theme) => { + it("uses the app status palette for gutter text and derived row tints", () => { + const created = createDiffPalette(theme); + + expect(created.addition).toBe(theme.colors.statusSuccess); + expect(created.deletion).toBe(theme.colors.statusDanger); + expect(created.additionBackground).toBe(hexColorWithAlpha(theme.colors.statusSuccess, 0.15)); + expect(created.deletionBackground).toBe(hexColorWithAlpha(theme.colors.statusDanger, 0.1)); + }); +}); + const palette: DiffPalette = { surface: "surface", headerSurface: "header", diff --git a/packages/app/src/git/diff-document/palette.ts b/packages/app/src/git/diff-document/palette.ts index 9e0e81854..4fea12cca 100644 --- a/packages/app/src/git/diff-document/palette.ts +++ b/packages/app/src/git/diff-document/palette.ts @@ -1,23 +1,50 @@ import type { Theme } from "@/styles/theme"; +import { hexColorWithAlpha } from "@/utils/color"; import type { DiffCell, DiffPalette } from "./types"; export function createDiffPalette(theme: Theme): DiffPalette { return { - surface: theme.colors.surface1, - headerSurface: theme.colors.surface2, + surface: theme.colors.surface0, + headerSurface: theme.colors.surface0, 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, + addition: theme.colors.statusSuccess, + deletion: theme.colors.statusDanger, + additionBackground: hexColorWithAlpha(theme.colors.statusSuccess, 0.15), + deletionBackground: hexColorWithAlpha(theme.colors.statusDanger, 0.1), + emptyBackground: theme.colors.surface0, + selection: theme.colors.terminal.blue, syntax: theme.colors.syntax, }; } +export function retainDiffPalette(previous: DiffPalette, next: DiffPalette): DiffPalette { + if ( + previous.surface !== next.surface || + previous.headerSurface !== next.headerSurface || + previous.border !== next.border || + previous.foreground !== next.foreground || + previous.foregroundMuted !== next.foregroundMuted || + previous.addition !== next.addition || + previous.deletion !== next.deletion || + previous.additionBackground !== next.additionBackground || + previous.deletionBackground !== next.deletionBackground || + previous.emptyBackground !== next.emptyBackground || + previous.selection !== next.selection || + !sameColorMap(previous.syntax, next.syntax) + ) { + return next; + } + return previous; +} + +function sameColorMap(left: Record, right: Record): boolean { + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + return leftKeys.length === rightKeys.length && leftKeys.every((key) => left[key] === right[key]); +} + export function codeTextColor(cell: DiffCell, palette: DiffPalette): string { return cell.type === "header" ? palette.foregroundMuted : palette.foreground; } diff --git a/packages/app/src/git/diff-document/surface.native.tsx b/packages/app/src/git/diff-document/surface.native.tsx index 8842cad3e..4b018f46d 100644 --- a/packages/app/src/git/diff-document/surface.native.tsx +++ b/packages/app/src/git/diff-document/surface.native.tsx @@ -1,39 +1,72 @@ -import { Canvas, Picture, Skia } from "@shopify/react-native-skia"; +import { Canvas, Picture, type SkPicture } 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 { + ScrollView, + StyleSheet, + View, + type GestureResponderEvent, + type LayoutChangeEvent, + type ViewStyle, +} from "react-native"; import { createAnimatedComponent, + useAnimatedReaction, useAnimatedScrollHandler, - useDerivedValue, + useAnimatedStyle, useSharedValue, + type SharedValue, } from "react-native-reanimated"; +import { scheduleOnRN } from "react-native-worklets"; +import { InlineReviewThread } from "@/review"; +import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { DocumentFileHeader } from "./document-file-header"; -import { hitTestDiffPagePoint, type SurfaceWindowOrigin } from "./native-hit-testing"; +import { hitTestDiffBodyPoint } 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"; +import { buildDiffDocumentModel, resolveRelayoutScrollTop, retainReusableModels } from "./model"; +import { + buildNativeCanvasSlabs, + nativeCanvasSlabsForViewport, + nativeCanvasWindowBucket, + nativeCanvasWindowTop, + nativeStickyHeaderIndices, + type NativeCanvasSlab, +} from "./native-slabs"; +import { createNativePaints, recordNativeSlabPictures, type NativePaints } from "./paint.native"; +import { + createNativeTextLayoutStore, + createNativeTextMeasurer, + disposeNativeTextLayout, + prepareNativeTextLayout, + type NativeTextLayout, +} from "./text.native"; +import type { + DiffDocumentModel, + DiffFileSection, + DiffSurfaceProps, + DiffTypography, + TextMeasurer, +} from "./types"; const AnimatedScrollView = createAnimatedComponent(ScrollView); +const AnimatedCodeLayer = createAnimatedComponent(View); const SYSTEM_MONO = "monospace"; +const CODE_LEFT_PADDING = 8; 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 reusableModelRef = useRef<{ + dependencies: readonly unknown[]; + models: ReturnType[]; + } | null>(null); const consumedFocusRef = useRef(null); const scrollTop = useSharedValue(0); const horizontalOffsets = useSharedValue({}); @@ -51,81 +84,89 @@ export function DiffSurface(props: DiffSurfaceProps) { [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, + const model = useMemo(() => { + const dependencies = [ + props.files, props.displayPreferences.layout, props.displayPreferences.wrapLines, - props.files, + viewport.width, + typography, + measurement, props.palette, reviewActions, t, + ] as const; + const previous = reusableModelRef.current; + const canReuse = previous?.dependencies.every( + (dependency, index) => dependency === dependencies[index], + ); + const reuseFrom = canReuse ? previous?.models : undefined; + const next = buildDiffDocumentModel({ + files: props.files, + collapsedFilePaths: props.collapsedFilePaths, + layout: props.displayPreferences.layout, + wrapLines: props.displayPreferences.wrapLines, + viewportWidth: viewport.width, typography, - viewport.width, - ], - ); + measureText: measurement, + palette: props.palette, + reviewActions, + labels: { + binary: t("workspace.git.diff.binaryFile"), + tooLarge: t("workspace.git.diff.tooLarge"), + }, + reuseFrom, + }); + reusableModelRef.current = { + dependencies, + models: retainReusableModels(reuseFrom, next), + }; + return next; + }, [ + 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( + const textLayoutStore = useMemo( () => - createNativeTextLayout({ - model, + createNativeTextLayoutStore({ configuredFamily: family, fontSize: typography.size, + lineHeight: typography.lineHeight, palette: props.palette, }), - [family, model, props.palette, typography.size], + [family, props.palette, typography.lineHeight, typography.size], ); + const textLayout = useMemo( + () => prepareNativeTextLayout(textLayoutStore, model), + [model, textLayoutStore], + ); + useEffect(() => () => disposeNativeTextLayout(textLayoutStore), [textLayoutStore]); + 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 }; - }); + setViewport((current) => + current.width === width && current.height === height ? current : { width, height }, + ); }, []); 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 }); - } + const nextScrollTop = resolveRelayoutScrollTop(previous, model, scrollTop.value); + scrollTop.value = nextScrollTop; + scrollRef.current?.scrollTo({ y: nextScrollTop, animated: false }); } previousModelRef.current = model; }, [model, scrollTop]); @@ -135,6 +176,7 @@ export function DiffSurface(props: DiffSurfaceProps) { model.files.map((file) => file.path), ); }, [horizontalOffsets, model.files]); + const mode = props.mode; const collapsedFilePaths = props.collapsedFilePaths; const onToggleFile = props.onToggleFile; @@ -147,50 +189,22 @@ export function DiffSurface(props: DiffSurfaceProps) { if (collapsedFilePaths.has(focusPath)) onToggleFile(focusPath); const file = model.files.find((entry) => entry.path === focusPath); if (file) { + scrollTop.value = file.top; 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], + }, [collapsedFilePaths, mode, model.files, onToggleFile, scrollTop]); + const contentStyle = useMemo( + () => ({ minHeight: viewport.height, backgroundColor: "transparent" }), + [viewport.height], ); - const contentStyle = useMemo(() => ({ minHeight: viewport.height }), [viewport.height]); + const stickyHeaderIndices = useMemo( + () => nativeStickyHeaderIndices(model.files.length), + [model.files.length], + ); + return ( - + index * 2)} + stickyHeaderIndices={stickyHeaderIndices} testID="git-diff-scroll" - onTouchStart={touchStart} - onTouchMove={touchMove} - onTouchEnd={touchEnd} > - {model.files.flatMap((file) => [ - - - , - + {model.files.flatMap((file, index) => [ + 0 && !model.files[index - 1]?.isCollapsed} + selectedPath={props.selectedPath} + mode={props.mode} + onToggleFile={props.onToggleFile} + onSelectPath={props.onSelectPath} + />, + ({ - 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} - , + file={file} + model={model} + mode={props.mode} + horizontalOffsets={horizontalOffsets} + />, ])} + - + + ); +} + +function NativeCanvasSlabLayer({ + model, + viewportHeight, + scrollTop, + textLayout, + paints, + horizontalOffsets, +}: { + model: DiffDocumentModel; + viewportHeight: number; + scrollTop: SharedValue; + textLayout: NativeTextLayout; + paints: NativePaints; + horizontalOffsets: SharedValue; +}) { + const [windowTop, setWindowTop] = useState(0); + const updateWindow = useCallback((nextTop: number) => { + setWindowTop((current) => (current === nextTop ? current : nextTop)); + }, []); + useAnimatedReaction( + () => nativeCanvasWindowBucket(scrollTop.value, viewportHeight), + (bucket, previous) => { + if (bucket !== previous) { + scheduleOnRN(updateWindow, nativeCanvasWindowTop(bucket, viewportHeight)); + } + }, + [updateWindow, viewportHeight], + ); + const descriptors = useMemo( + () => buildNativeCanvasSlabs(model, viewportHeight), + [model, viewportHeight], + ); + const visible = useMemo( + () => nativeCanvasSlabsForViewport(descriptors, windowTop, viewportHeight), + [descriptors, viewportHeight, windowTop], + ); + return visible.map((slab) => ( + + )); +} + +function NativeStickyFileHeader({ + file, + showTopBorder, + selectedPath, + mode, + onToggleFile, + onSelectPath, +}: { + file: DiffFileSection; + showTopBorder: boolean; + selectedPath: DiffSurfaceProps["selectedPath"]; + mode: DiffSurfaceProps["mode"]; + onToggleFile: DiffSurfaceProps["onToggleFile"]; + onSelectPath: DiffSurfaceProps["onSelectPath"]; +}) { + return ( + + + + ); +} + +function NativeFileBody({ + file, + model, + mode, + horizontalOffsets, +}: { + file: DiffFileSection; + model: DiffDocumentModel; + mode: DiffSurfaceProps["mode"]; + horizontalOffsets: SharedValue; +}) { + const touchRef = useRef<{ x: number; y: number; startedAt: number; moved: boolean } | null>(null); + const reviewActions = mode.kind === "working" ? mode.reviewActions : undefined; + const touchStart = useCallback((event: GestureResponderEvent) => { + touchRef.current = { + x: event.nativeEvent.pageX, + y: event.nativeEvent.pageY, + startedAt: Date.now(), + moved: false, + }; + }, []); + const touchMove = useCallback((event: GestureResponderEvent) => { + 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: GestureResponderEvent) => { + const touch = touchRef.current; + touchRef.current = null; + if (!touch || touch.moved || Date.now() - touch.startedAt > 500 || !reviewActions) return; + const hit = hitTestDiffBodyPoint({ + model, + file, + locationX: event.nativeEvent.locationX, + locationY: event.nativeEvent.locationY, + horizontalOffset: horizontalOffsetForPath(horizontalOffsets.value, file.path), + }); + if (hit?.kind === "cell" && hit.target) reviewActions.onStartComment(hit.target); + }, + [file, horizontalOffsets, model, reviewActions], + ); + return ( + ({ + position: "relative", + height: file.bodyHeight, + zIndex: 2, + })} + onTouchStart={touchStart} + onTouchMove={touchMove} + onTouchEnd={touchEnd} + > + {!file.isCollapsed && !model.wrapLines ? ( + + ) : null} + + ); +} + +function NativeReviewOverlays({ + model, + mode, +}: { + model: DiffDocumentModel; + mode: DiffSurfaceProps["mode"]; +}) { + if (mode.kind !== "working" || !mode.reviewActions) return null; + const reviewActions = mode.reviewActions; + return model.rows.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 + row.height - row.reviewHeight, + left: index * columnWidth, + width: columnWidth, + height: row.reviewHeight, + zIndex: 6, + })} + > + + , + ]; + }); + }); +} + +function NativeCanvasSlabView({ + slab, + model, + textLayout, + paints, + horizontalOffsets, +}: { + slab: NativeCanvasSlab; + model: DiffDocumentModel; + textLayout: NativeTextLayout; + paints: NativePaints; + horizontalOffsets: SharedValue; +}) { + const file = model.files[slab.fileIndex]; + const pictures = useMemo( + () => + recordNativeSlabPictures({ + model, + fileIndex: slab.fileIndex, + top: slab.top, + height: slab.height, + viewportWidth: model.viewportWidth, + textLayout, + paints, + }), + [model, paints, slab.fileIndex, slab.height, slab.top, textLayout], + ); + const style = useMemo( + () => ({ + position: "absolute", + top: slab.top, + left: 0, + right: 0, + height: slab.height, + zIndex: 3, + }), + [slab.height, slab.top], + ); + if (!file) return null; + const unifiedClip = { + x: file.gutterWidth + CODE_LEFT_PADDING, + width: model.viewportWidth - file.gutterWidth - CODE_LEFT_PADDING, + }; + const columnWidth = model.viewportWidth / 2; + const splitClipWidth = columnWidth - file.gutterWidth - CODE_LEFT_PADDING; + return ( + + + + + + {model.layout === "split" ? ( + <> + + + + ) : null} + + + + + ); +} + +function NativeSlabCode({ + picture, + path, + horizontalOffsets, + wrapLines, + clipX, + clipWidth, + contentWidth, + height, +}: { + picture: SkPicture; + path: string; + horizontalOffsets: SharedValue; + wrapLines: boolean; + clipX: number; + clipWidth: number; + contentWidth: number; + height: number; +}) { + const transform = useAnimatedStyle(() => ({ + transform: [ + { translateX: wrapLines ? 0 : -horizontalOffsetForPath(horizontalOffsets.value, path) }, + ], + })); + const clipStyle = useMemo( + () => ({ + position: "absolute", + top: 0, + left: clipX, + width: clipWidth, + height, + overflow: "hidden", + }), + [clipWidth, clipX, height], + ); + const contentStyle = useMemo( + () => ({ position: "absolute", top: 0, left: -clipX, width: contentWidth, height }), + [clipX, contentWidth, height], + ); + return ( + + - + ); } const styles = StyleSheet.create({ root: { flex: 1, minHeight: 0, position: "relative", overflow: "hidden" }, - canvas: { zIndex: 1 }, - scroll: { zIndex: 2 }, - header: { zIndex: 3 }, + scroll: { backgroundColor: "transparent" }, + header: { zIndex: 5 }, }); diff --git a/packages/app/src/git/diff-document/surface.web.tsx b/packages/app/src/git/diff-document/surface.web.tsx index 7168e48ce..da563e48c 100644 --- a/packages/app/src/git/diff-document/surface.web.tsx +++ b/packages/app/src/git/diff-document/surface.web.tsx @@ -1,12 +1,19 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { InlineReviewThread } from "@/review"; +import type { ViewStyle } from "react-native"; +import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar"; +import { InlineReviewAddButton, 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 { + buildDiffDocumentModel, + FILE_HEADER_HEIGHT, + resolveRelayoutScrollTop, + retainReusableModels, +} from "./model"; import { paintWebViewport } from "./paint.web"; import { hasPointerDragStarted } from "./pointer-gesture"; import type { @@ -23,9 +30,14 @@ export function DiffSurface(props: DiffSurfaceProps) { const { t } = useTranslation(); const rootRef = useRef(null); const canvasRef = useRef(null); + const canvasScratchRef = useRef(null); const scrollRef = useRef(null); const modelRef = useRef | null>(null); const previousModelRef = useRef | null>(null); + const reusableModelRef = useRef<{ + dependencies: readonly unknown[]; + models: ReturnType[]; + } | null>(null); const consumedFocusRef = useRef(null); const scrollTopRef = useRef(0); const horizontalOffsetsRef = useRef(new Map()); @@ -37,6 +49,8 @@ export function DiffSurface(props: DiffSurfaceProps) { moved: boolean; } | null>(null); const frameRef = useRef(null); + const forcePaintRef = useRef(true); + const canvasWindowRef = useRef({ top: 0, height: 0 }); const [viewport, setViewport] = useState({ width: 0, height: 0 }); const [loadedTypography, setLoadedTypography] = useState(null); const [hoveredAffordance, setHoveredAffordance] = useState<{ @@ -73,7 +87,23 @@ export function DiffSurface(props: DiffSurfaceProps) { lineHeight: desiredTypography.lineHeight, }); } - return buildDiffDocumentModel({ + const dependencies = [ + props.files, + props.displayPreferences.layout, + props.displayPreferences.wrapLines, + viewport.width, + loadedTypography, + measurement, + props.palette, + reviewActions, + t, + ] as const; + const previous = reusableModelRef.current; + const canReuse = previous?.dependencies.every( + (dependency, index) => dependency === dependencies[index], + ); + const reuseFrom = canReuse ? previous?.models : undefined; + const next = buildDiffDocumentModel({ files: props.files, collapsedFilePaths: props.collapsedFilePaths, layout: props.displayPreferences.layout, @@ -87,7 +117,13 @@ export function DiffSurface(props: DiffSurfaceProps) { binary: t("workspace.git.diff.binaryFile"), tooLarge: t("workspace.git.diff.tooLarge"), }, + reuseFrom, }); + reusableModelRef.current = { + dependencies, + models: retainReusableModels(reuseFrom, next), + }; + return next; }, [ measurement, props.collapsedFilePaths, @@ -108,31 +144,77 @@ export function DiffSurface(props: DiffSurfaceProps) { const canvas = canvasRef.current; const currentModel = modelRef.current; if (!canvas || !currentModel || viewport.width <= 0 || viewport.height <= 0) return; + const desiredHeight = Math.min( + Math.max(currentModel.height, viewport.height), + viewport.height * 3, + ); + const currentWindow = canvasWindowRef.current; + const viewportTop = scrollTopRef.current; + const viewportBottom = viewportTop + viewport.height; + const safeInset = viewport.height / 2; + const mustRecenter = + currentWindow.height !== desiredHeight || + viewportTop < currentWindow.top + safeInset || + viewportBottom > currentWindow.top + currentWindow.height - safeInset; + if (!forcePaintRef.current && !mustRecenter) return; + const forceFullPaint = forcePaintRef.current; + forcePaintRef.current = false; + const canvasHeight = desiredHeight; const ratio = window.devicePixelRatio || 1; + const requestedCanvasTop = mustRecenter + ? Math.min( + Math.max(0, viewportTop - viewport.height), + Math.max(0, currentModel.height - canvasHeight), + ) + : currentWindow.top; + const canvasTop = Math.round(requestedCanvasTop * ratio) / ratio; + canvasWindowRef.current = { top: canvasTop, height: canvasHeight }; const pixelWidth = Math.ceil(viewport.width * ratio); - const pixelHeight = Math.ceil(viewport.height * ratio); + const pixelHeight = Math.ceil(canvasHeight * ratio); + const resized = canvas.width !== pixelWidth || canvas.height !== pixelHeight; if (canvas.width !== pixelWidth) canvas.width = pixelWidth; if (canvas.height !== pixelHeight) canvas.height = pixelHeight; + canvas.style.top = `${canvasTop}px`; + canvas.style.height = `${canvasHeight}px`; const context = canvas.getContext("2d"); if (!context) return; if (!measurement || !loadedTypography) return; + const { paintTop, paintHeight } = shiftCanvasPixels({ + canvas, + scratch: + canvasScratchRef.current ?? (canvasScratchRef.current = document.createElement("canvas")), + context, + currentTop: currentWindow.top, + nextTop: canvasTop, + canvasHeight, + pixelWidth, + pixelHeight, + ratio, + canReuse: !forceFullPaint && !resized && currentWindow.height === canvasHeight, + }); paintWebViewport({ context, model: currentModel, palette: props.palette, typography: loadedTypography, measureText: measurement, - scrollTop: scrollTopRef.current, + scrollTop: canvasTop, viewportWidth: viewport.width, - viewportHeight: viewport.height, + viewportHeight: canvasHeight, horizontalOffsets: horizontalOffsetsRef.current, selection: selectionRef.current, devicePixelRatio: ratio, + paintTop, + paintHeight, }); }, [loadedTypography, measurement, props.palette, viewport.height, viewport.width]); - const schedulePaint = useCallback(() => { - if (frameRef.current === null) frameRef.current = requestAnimationFrame(paint); - }, [paint]); + const schedulePaint = useCallback( + (force = true) => { + if (force) forcePaintRef.current = true; + if (frameRef.current === null) frameRef.current = requestAnimationFrame(paint); + }, + [paint], + ); useLayoutEffect(() => { const root = rootRef.current; @@ -162,12 +244,9 @@ export function DiffSurface(props: DiffSurfaceProps) { 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; - } + const nextScrollTop = resolveRelayoutScrollTop(previous, model, scroll.scrollTop); + scrollTopRef.current = nextScrollTop; + scroll.scrollTop = nextScrollTop; } previousModelRef.current = model; }, [model]); @@ -202,16 +281,42 @@ export function DiffSurface(props: DiffSurfaceProps) { }, [collapsedFilePaths, mode, model.files, onToggleFile]); const handleVerticalScroll = useCallback( - (event: React.UIEvent) => { - scrollTopRef.current = event.currentTarget.scrollTop; + (scrollElement: HTMLDivElement) => { + const scrollTop = scrollElement.scrollTop; + scrollTopRef.current = scrollTop; if (hasHoveredAffordanceRef.current) { hasHoveredAffordanceRef.current = false; setHoveredAffordance(null); } - schedulePaint(); + const currentModel = modelRef.current; + const currentWindow = canvasWindowRef.current; + if (!currentModel || currentWindow.height === 0) { + schedulePaint(false); + return; + } + const viewportBottom = scrollTop + viewport.height; + const safeInset = viewport.height / 2; + const crossedSafeInset = + scrollTop < currentWindow.top + safeInset || + viewportBottom > currentWindow.top + currentWindow.height - safeInset; + if (!crossedSafeInset) return; + const requestedTop = Math.min( + Math.max(0, scrollTop - viewport.height), + Math.max(0, currentModel.height - currentWindow.height), + ); + const ratio = window.devicePixelRatio || 1; + const nextTop = Math.round(requestedTop * ratio) / ratio; + if (nextTop !== currentWindow.top) schedulePaint(false); }, - [schedulePaint], + [schedulePaint, viewport.height], ); + useEffect(() => { + const scroll = scrollRef.current; + if (!scroll) return; + const onScroll = () => handleVerticalScroll(scroll); + scroll.addEventListener("scroll", onScroll, { passive: true }); + return () => scroll.removeEventListener("scroll", onScroll); + }, [handleVerticalScroll]); const handleHorizontalScroll = useCallback( (path: string, offset: number) => { horizontalOffsetsRef.current.set(path, offset); @@ -247,6 +352,12 @@ export function DiffSurface(props: DiffSurfaceProps) { ) ) return; + if (selectionRef.current) { + selectionRef.current = null; + dragRef.current = null; + schedulePaint(); + return; + } const hit = pointHit(event); if (hit?.kind !== "cell") return; dragRef.current = { @@ -285,12 +396,12 @@ export function DiffSurface(props: DiffSurfaceProps) { 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) { + if (hit.target && Number.isFinite(pointerX)) { hasHoveredAffordanceRef.current = true; setHoveredAffordance({ hit, - left: gutterBorder - 14, - top: row.top - scrollTopRef.current + (modelRef.current!.lineHeight - 28) / 2, + left: gutterBorder - 12, + top: row.top - scrollTopRef.current + (modelRef.current!.lineHeight - 22) / 2, }); } else { hasHoveredAffordanceRef.current = false; @@ -351,7 +462,7 @@ export function DiffSurface(props: DiffSurfaceProps) { () => ({ ...CONTENT_STYLE, height: Math.max(model.height, viewport.height) }), [model.height, viewport.height], ); - const affordanceStyle = useMemo( + const affordanceStyle = useMemo( () => ({ ...AFFORDANCE_STYLE, left: hoveredAffordance?.left ?? 0, @@ -377,8 +488,8 @@ export function DiffSurface(props: DiffSurfaceProps) {
- {model.files.map((file) => ( + + {model.files.map((file, index) => ( 0 && !model.files[index - 1]?.isCollapsed} selectedPath={props.selectedPath} mode={props.mode} onToggleFile={props.onToggleFile} @@ -438,21 +551,68 @@ export function DiffSurface(props: DiffSurfaceProps) { : null}
- + {hoveredAffordance?.hit.target && reviewActions ? ( - + ) : null} ); } +function shiftCanvasPixels(input: { + canvas: HTMLCanvasElement; + scratch: HTMLCanvasElement; + context: CanvasRenderingContext2D; + currentTop: number; + nextTop: number; + canvasHeight: number; + pixelWidth: number; + pixelHeight: number; + ratio: number; + canReuse: boolean; +}): { paintTop: number; paintHeight: number } { + const delta = input.nextTop - input.currentTop; + if (!input.canReuse || delta === 0 || Math.abs(delta) >= input.canvasHeight) { + return { paintTop: 0, paintHeight: input.canvasHeight }; + } + const deltaPixels = Math.round(Math.abs(delta) * input.ratio); + const overlapPixels = input.pixelHeight - deltaPixels; + if (input.scratch.width !== input.pixelWidth) input.scratch.width = input.pixelWidth; + if (input.scratch.height !== input.pixelHeight) input.scratch.height = input.pixelHeight; + const scratchContext = input.scratch.getContext("2d"); + if (!scratchContext) return { paintTop: 0, paintHeight: input.canvasHeight }; + scratchContext.setTransform(1, 0, 0, 1, 0, 0); + scratchContext.globalCompositeOperation = "copy"; + scratchContext.drawImage(input.canvas, 0, 0); + input.context.setTransform(1, 0, 0, 1, 0, 0); + if (delta > 0) { + input.context.drawImage( + input.scratch, + 0, + deltaPixels, + input.pixelWidth, + overlapPixels, + 0, + 0, + input.pixelWidth, + overlapPixels, + ); + return { paintTop: input.canvasHeight - Math.abs(delta), paintHeight: Math.abs(delta) }; + } + input.context.drawImage( + input.scratch, + 0, + 0, + input.pixelWidth, + overlapPixels, + 0, + deltaPixels, + input.pixelWidth, + overlapPixels, + ); + return { paintTop: 0, paintHeight: Math.abs(delta) }; +} + function WebFileHeaderSection({ file, children, @@ -532,7 +692,11 @@ const SCROLL_STYLE: React.CSSProperties = { zIndex: 2, overflowY: "auto", overflowX: "hidden", + scrollbarWidth: "none", + cursor: "text", }; + +function noop(): void {} const CONTENT_STYLE: React.CSSProperties = { position: "relative", width: "100%", @@ -553,7 +717,7 @@ const STICKY_HEADER_STYLE: React.CSSProperties = { }; const BODY_MARKER_STYLE: React.CSSProperties = { position: "absolute", - top: 44, + top: FILE_HEADER_HEIGHT, bottom: 0, left: 0, right: 0, @@ -562,17 +726,16 @@ const BODY_MARKER_STYLE: React.CSSProperties = { const REVIEW_STYLE: React.CSSProperties = { position: "absolute", zIndex: 4, userSelect: "text" }; const CANVAS_STYLE: React.CSSProperties = { position: "absolute", - inset: 0, + top: 0, + left: 0, + right: 0, zIndex: 1, width: "100%", - height: "100%", pointerEvents: "none", }; -const AFFORDANCE_STYLE: React.CSSProperties = { +const AFFORDANCE_STYLE: ViewStyle = { position: "absolute", zIndex: 5, - width: 28, - height: 28, }; function emptyDiffDocumentModel(input: { diff --git a/packages/app/src/git/diff-document/text-measurement.test.ts b/packages/app/src/git/diff-document/text-measurement.test.ts index b0caea483..bb741f915 100644 --- a/packages/app/src/git/diff-document/text-measurement.test.ts +++ b/packages/app/src/git/diff-document/text-measurement.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { createFallbackAwareTextMeasurer, type PrimaryTextFace } from "./text-measurement"; +import { + createFallbackAwareTextMeasurer, + requiresNativeParagraph, + 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", () => { @@ -26,6 +30,16 @@ describe.each(["ios", "android"])("%s native text measurement", (platform) => { }); }); +describe("native paragraph retention", () => { + it("keeps ordinary code on the allocation-free font path", () => { + expect(requiresNativeParagraph("const answer = value + 1;")).toBe(false); + }); + + it.each(["value => next", "中", "e\u0301", "👩‍💻", "مرحبا"])("retains shaping for %s", (text) => { + expect(requiresNativeParagraph(text)).toBe(true); + }); +}); + function primaryFaceWithout(...missing: string[]): PrimaryTextFace { return { glyphIds(text) { diff --git a/packages/app/src/git/diff-document/text-measurement.ts b/packages/app/src/git/diff-document/text-measurement.ts index 8b9b38847..4f010aa1e 100644 --- a/packages/app/src/git/diff-document/text-measurement.ts +++ b/packages/app/src/git/diff-document/text-measurement.ts @@ -5,6 +5,12 @@ export interface PrimaryTextFace { measure(text: string): number; } +const CODE_LIGATURE_CANDIDATE = /(?:===?|!==?|=>|<=|>=|->|::|\+\+|--)/; + +export function requiresNativeParagraph(text: string): boolean { + return /[^\x20-\x7e\t]/u.test(text) || CODE_LIGATURE_CANDIDATE.test(text); +} + export function createFallbackAwareTextMeasurer(input: { primary: PrimaryTextFace; measureWithSystemFallback: (text: string) => number; diff --git a/packages/app/src/git/diff-document/text.native.ts b/packages/app/src/git/diff-document/text.native.ts index db6fe7876..7307e959a 100644 --- a/packages/app/src/git/diff-document/text.native.ts +++ b/packages/app/src/git/diff-document/text.native.ts @@ -7,7 +7,7 @@ import { } from "@shopify/react-native-skia"; import { fragmentTextForRange } from "./model"; import { codeTextColor } from "./palette"; -import { createFallbackAwareTextMeasurer } from "./text-measurement"; +import { createFallbackAwareTextMeasurer, requiresNativeParagraph } from "./text-measurement"; import type { DiffCell, DiffDocumentModel, DiffFragment, DiffPalette, TextMeasurer } from "./types"; const PARAGRAPH_WIDTH = 100_000; @@ -17,31 +17,69 @@ export interface NativeTextLayout { paragraphs: Array>>; } -export function createNativeTextLayout(input: { - model: DiffDocumentModel; +export interface NativeTextLayoutStore { + font: SkFont; + paragraphsByCell: WeakMap>; + ownedParagraphs: Set; + families: string[]; + fontSize: number; + lineHeight: number; + palette: DiffPalette; +} + +export function disposeNativeTextLayout(layout: NativeTextLayoutStore): void { + for (const paragraph of layout.ownedParagraphs) paragraph.dispose(); + layout.ownedParagraphs.clear(); + layout.font.dispose(); +} + +export function createNativeTextLayoutStore(input: { configuredFamily: string; fontSize: number; + lineHeight: number; palette: DiffPalette; -}): NativeTextLayout { +}): NativeTextLayoutStore { const families = nativeFontFamilies(input.configuredFamily); const font = primaryFont(families, input.fontSize); - const paragraphs = input.model.rows.map((row) => { + return { + font, + paragraphsByCell: new WeakMap(), + ownedParagraphs: new Set(), + families, + fontSize: input.fontSize, + lineHeight: input.lineHeight, + palette: input.palette, + }; +} + +export function prepareNativeTextLayout( + store: NativeTextLayoutStore, + model: DiffDocumentModel, +): NativeTextLayout { + const paragraphs = model.rows.map((row) => { if (row.kind !== "line") return []; return row.cells.map((cell) => { if (!cell) return []; - return cell.fragments.map((fragment) => - createFragmentParagraph({ + const cached = store.paragraphsByCell.get(cell); + if (cached) return cached; + const cellParagraphs = cell.fragments.map((fragment) => { + if (!requiresRetainedParagraph(fragment.text, store.font)) return null; + const paragraph = createFragmentParagraph({ cell, fragment, - families, - fontSize: input.fontSize, - lineHeight: input.model.lineHeight, - palette: input.palette, - }), - ); + families: store.families, + fontSize: store.fontSize, + lineHeight: store.lineHeight, + palette: store.palette, + }); + store.ownedParagraphs.add(paragraph); + return paragraph; + }); + store.paragraphsByCell.set(cell, cellParagraphs); + return cellParagraphs; }); }); - return { font, paragraphs }; + return { font: store.font, paragraphs }; } export function createNativeTextMeasurer(input: { @@ -63,13 +101,22 @@ export function createNativeTextMeasurer(input: { lineHeight: Math.round(input.fontSize * 1.5), color: Skia.Color("black"), }); - return paragraph.getLongestLine(); + const width = paragraph.getLongestLine(); + paragraph.dispose(); + return width; }, }); return { ...fallbackAware, measureAdvances(graphemes) { const text = graphemes.join(""); + if (!requiresRetainedParagraph(text, font)) { + let prefix = ""; + return graphemes.map((grapheme) => { + prefix += grapheme; + return font.getTextWidth(prefix); + }); + } const paragraph = createParagraph({ text, families, @@ -78,7 +125,7 @@ export function createNativeTextMeasurer(input: { color: Skia.Color("black"), }); let end = 0; - return graphemes.map((grapheme) => { + const advances = graphemes.map((grapheme) => { end += grapheme.length; const rectangles = paragraph.getRectsForRange(0, end); return rectangles.reduce( @@ -86,10 +133,16 @@ export function createNativeTextMeasurer(input: { 0, ); }); + paragraph.dispose(); + return advances; }, }; } +function requiresRetainedParagraph(text: string, font: SkFont): boolean { + return requiresNativeParagraph(text) || font.getGlyphIDs(text).some((glyph) => glyph === 0); +} + function createFragmentParagraph(input: { cell: DiffCell; fragment: DiffFragment; diff --git a/packages/app/src/git/diff-document/types.ts b/packages/app/src/git/diff-document/types.ts index 72a357337..923b77b8c 100644 --- a/packages/app/src/git/diff-document/types.ts +++ b/packages/app/src/git/diff-document/types.ts @@ -167,6 +167,8 @@ export interface BuildDiffDocumentModelInput { palette: DiffPalette; reviewActions?: InlineReviewActions; labels: { binary: string; tooLarge: string }; + /** A geometry-compatible model whose unchanged file measurements may be reused. */ + reuseFrom?: readonly DiffDocumentModel[]; } export interface DiffCharacterPosition { diff --git a/packages/app/src/git/diff-folder-row.tsx b/packages/app/src/git/diff-folder-row.tsx index be5b76fc7..34628228c 100644 --- a/packages/app/src/git/diff-folder-row.tsx +++ b/packages/app/src/git/diff-folder-row.tsx @@ -1,13 +1,11 @@ -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; import { View, Text, type LayoutChangeEvent, type PressableStateCallbackType } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { DiffStat } from "@/components/diff-stat"; import { TreeChevron, - TreeIndentGuides, treeRowPaddingLeft, - WORKSPACE_FILE_ROW_TRAILING_PADDING, - WORKSPACE_FILE_ROW_VERTICAL_PADDING, + workspaceTreeRowStyles, WORKSPACE_TREE_ICON_LABEL_GAP, WORKSPACE_TREE_ICON_SIZE, } from "@/components/tree-primitives"; @@ -43,7 +41,10 @@ function folderRowPressableStyle( { hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }, isSelected: boolean, ) { - return [styles.folderRow, (Boolean(hovered) || pressed || isSelected) && styles.folderRowActive]; + return [ + workspaceTreeRowStyles.row, + (Boolean(hovered) || pressed || isSelected) && workspaceTreeRowStyles.active, + ]; } export function DiffFolderRow({ @@ -69,6 +70,9 @@ export function DiffFolderRow({ const handleSelect = useCallback(() => { onSelect(dirPath); }, [dirPath, onSelect]); + const [isHovered, setIsHovered] = useState(false); + const showNameHover = useCallback(() => setIsHovered(true), []); + const hideNameHover = useCallback(() => setIsHovered(false), []); const handlePress = useCallback(() => { const selection = isWeb ? window.getSelection() : null; @@ -117,7 +121,12 @@ export function DiffFolderRow({ }, [dirPath, onRevert]); const leftStyle = useMemo( - () => [styles.left, inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) })], + () => [ + styles.left, + inlineUnistylesStyle({ + paddingLeft: treeRowPaddingLeft(depth), + }), + ], [depth], ); @@ -128,13 +137,14 @@ export function DiffFolderRow({ return ( - - + {displayName}
@@ -176,17 +194,6 @@ const styles = StyleSheet.create((theme: Theme) => ({ container: { overflow: "hidden", }, - folderRow: { - flexDirection: "row", - alignItems: "center", - paddingRight: WORKSPACE_FILE_ROW_TRAILING_PADDING, - paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING, - gap: theme.spacing[1], - minWidth: 0, - }, - folderRowActive: { - backgroundColor: theme.colors.surfaceSidebarHover, - }, left: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/git/file-header-interaction.test.ts b/packages/app/src/git/file-header-interaction.test.ts index cebadcd25..969560799 100644 --- a/packages/app/src/git/file-header-interaction.test.ts +++ b/packages/app/src/git/file-header-interaction.test.ts @@ -15,7 +15,7 @@ describe("file header interaction arbitration", () => { enabled: true, native: true, pressHandled: false, - layoutY: 0, + stickyPressFallback: true, pressOrigin, releasedAt: 1_500, pageX: 32, @@ -27,7 +27,7 @@ describe("file header interaction arbitration", () => { it.each([ ["an already handled long press", { pressHandled: true }], ["a non-native release", { native: false }], - ["a non-sticky header", { layoutY: 1 }], + ["a non-sticky header", { stickyPressFallback: false }], ["a held press", { releasedAt: 1_501 }], ["a moved press", { pageX: 33 }], ])("does not activate %s", (_label, override) => { @@ -36,7 +36,7 @@ describe("file header interaction arbitration", () => { enabled: true, native: true, pressHandled: false, - layoutY: 0, + stickyPressFallback: true, pressOrigin, releasedAt: 1_500, pageX: 32, @@ -68,6 +68,17 @@ describe("file header interaction arbitration", () => { ).toEqual(["defer-activate", "activate"]); }); + it("keeps a duplicated wrapper press-out eligible for one fallback", () => { + expect( + run([ + { type: "press-in" }, + { type: "press-out", stickyFallbackEligible: true }, + { type: "press-out", stickyFallbackEligible: true }, + { type: "fallback" }, + ]), + ).toEqual(["defer-activate", "activate"]); + }); + it("falls back once for a canceled sticky press", () => { expect( run([ diff --git a/packages/app/src/git/file-header-interaction.ts b/packages/app/src/git/file-header-interaction.ts index 5cbec5db5..c0a0fd01e 100644 --- a/packages/app/src/git/file-header-interaction.ts +++ b/packages/app/src/git/file-header-interaction.ts @@ -11,6 +11,7 @@ interface FileHeaderInteractionInput { onSelect?: (path: string) => void; onActivate?: (path: string) => void; onLayout?: (height: number) => void; + stickyPressFallback?: boolean; } interface PressPointEvent { @@ -27,14 +28,14 @@ interface StickyPressReleaseInput { enabled: boolean; native: boolean; pressHandled: boolean; - layoutY: number | null; + stickyPressFallback: boolean; pressOrigin: PressOrigin | null; releasedAt: number; pageX: number; pageY: number; } -interface FileHeaderInteraction { +export interface FileHeaderInteraction { activate: () => void; select: () => void; onLayout: (event: LayoutChangeEvent) => void; @@ -59,9 +60,12 @@ export function reduceFileHeaderPress( 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") { + if (phase === "released" || phase === "handled" || phase === "long") { + return { phase, effect: "none" }; + } return event.stickyFallbackEligible && phase === "pressed" ? { phase: "released", effect: "defer-activate" } - : { phase: phase === "long" ? "long" : "idle", effect: "none" }; + : { phase: "idle", effect: "none" }; } if (event.type === "press") { return phase === "long" || phase === "handled" @@ -78,7 +82,7 @@ export function shouldActivateStickyHeaderPress(input: StickyPressReleaseInput): !input.enabled || !input.native || input.pressHandled || - input.layoutY !== 0 || + !input.stickyPressFallback || !input.pressOrigin ) { return false; @@ -92,8 +96,14 @@ export function shouldActivateStickyHeaderPress(input: StickyPressReleaseInput): } export function useFileHeaderInteraction(input: FileHeaderInteractionInput): FileHeaderInteraction { - const { path, enabled, onSelect, onActivate, onLayout: notifyLayout } = input; - const layoutYRef = useRef(null); + const { + path, + enabled, + onSelect, + onActivate, + onLayout: notifyLayout, + stickyPressFallback = false, + } = input; const pressHandledRef = useRef(false); const pressOriginRef = useRef(null); const phaseRef = useRef("idle"); @@ -123,7 +133,6 @@ export function useFileHeaderInteraction(input: FileHeaderInteractionInput): Fil const onLayout = useCallback( (event: LayoutChangeEvent) => { - layoutYRef.current = event.nativeEvent.layout.y; notifyLayout?.(event.nativeEvent.layout.height); }, [notifyLayout], @@ -157,7 +166,7 @@ export function useFileHeaderInteraction(input: FileHeaderInteractionInput): Fil enabled, native: isNative, pressHandled: pressHandledRef.current, - layoutY: layoutYRef.current, + stickyPressFallback, pressOrigin: pressOriginRef.current, releasedAt: Date.now(), pageX: event.nativeEvent.pageX, @@ -182,7 +191,7 @@ export function useFileHeaderInteraction(input: FileHeaderInteractionInput): Fil }, 0); } }, - [clearFallback, enabled, onActivate, path, select], + [clearFallback, enabled, onActivate, path, select, stickyPressFallback], ); 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 index 2b102df70..2031f5fc7 100644 --- a/packages/app/src/git/file-header.tsx +++ b/packages/app/src/git/file-header.tsx @@ -1,4 +1,4 @@ -import { memo, type ReactElement, useCallback, useMemo } from "react"; +import { memo, type ReactElement, useCallback, useMemo, useState } 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"; @@ -7,8 +7,8 @@ import { FileActionsContextMenuContent } from "@/components/file-actions-menu"; import { FileChangeIcon } from "@/components/file-change-icon"; import { MaterialFileIcon } from "@/components/material-file-icon"; import { - TreeIndentGuides, treeRowPaddingLeft, + workspaceTreeRowStyles, WORKSPACE_FILE_ROW_TRAILING_PADDING, WORKSPACE_FILE_ROW_VERTICAL_PADDING, WORKSPACE_TREE_ICON_LABEL_GAP, @@ -29,6 +29,7 @@ export interface FileHeaderProps { depth?: number; showDir?: boolean; interactive?: boolean; + showTopBorder?: boolean; onActivate?: (path: string) => void; onSelect?: (path: string) => void; onOpenFile?: (path: string) => void; @@ -67,6 +68,73 @@ function expandedAriaValue(showsBodyState: boolean, bodyVisible: boolean): boole return showsBodyState ? bodyVisible : undefined; } +function useFileHeaderHover(enabled: boolean) { + const [isHovered, setIsHovered] = useState(false); + const handlePointerEnter = useCallback(() => { + if (enabled) setIsHovered(true); + }, [enabled]); + const handlePointerLeave = useCallback(() => setIsHovered(false), []); + return { isHovered, handlePointerEnter, handlePointerLeave }; +} + +function fileHeaderIsActive(input: { + isHovered: boolean; + pressed: boolean; + showsBodyState: boolean; + isSelected: boolean; +}): boolean { + return input.isHovered || input.pressed || (!input.showsBodyState && input.isSelected); +} + +function fileHeaderInteractionStyle(input: { + isHovered: boolean; + pressed: boolean; + showsBodyState: boolean; + isSelected: boolean; +}) { + if (!fileHeaderIsActive(input)) return null; + return input.showsBodyState ? styles.documentActive : workspaceTreeRowStyles.active; +} + +function fileHeaderPressFeedbackStyle(showsBodyState: boolean) { + return showsBodyState ? styles.documentPressFeedback : workspaceTreeRowStyles.active; +} + +function fileHeaderContainerStyle(showsBodyState: boolean, showTopBorder: boolean) { + if (!showsBodyState) return null; + return [styles.documentContainer, !showTopBorder && styles.documentContainerWithoutTopBorder]; +} + +function fileHeaderNameStyle(showsBodyState: boolean, isHovered: boolean) { + if (showsBodyState) return styles.name; + return [ + styles.name, + workspaceTreeRowStyles.name, + isHovered ? workspaceTreeRowStyles.nameHovered : null, + ]; +} + +function fileChange(file: ParsedDiffFile): "added" | "deleted" | "modified" { + if (file.isNew) return "added"; + if (file.isDeleted) return "deleted"; + return "modified"; +} + +function documentFileChangeIcon(file: ParsedDiffFile, showsBodyState: boolean) { + if (!showsBodyState) return null; + if (file.isNew) return ; + if (file.isDeleted) return ; + return null; +} + +function treeFileChangeIcon(file: ParsedDiffFile, showsBodyState: boolean) { + return showsBodyState ? null : ; +} + +function fileHeaderRightStyle(showsBodyState: boolean) { + return showsBodyState ? styles.right : [styles.right, styles.treeRight]; +} + function FileHeaderMenu({ file, onOpenFile, @@ -121,12 +189,14 @@ export const FileHeader = memo(function FileHeader({ depth = 0, showDir = true, interactive = true, + showTopBorder = true, onActivate, onSelect, onHeaderHeightChange, testID, ...actions }: FileHeaderProps) { + const hover = useFileHeaderHover(interactive); const dragSourceRef = useWorkspaceFileDragSource({ enabled: interactive, disabled: file.isDeleted, @@ -139,25 +209,40 @@ export const FileHeader = memo(function FileHeader({ enabled: interactive, onSelect, onActivate, + stickyPressFallback: showsBodyState, onLayout: useCallback( (height: number) => onHeaderHeightChange?.(file.path, height), [file.path, onHeaderHeightChange], ), }); const pressableStyle = useCallback( - ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + ({ pressed }: PressableStateCallbackType) => [ styles.header, + !showsBodyState && workspaceTreeRowStyles.row, showsBodyState && styles.documentHeader, - depth > 0 ? inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) }) : null, - (Boolean(hovered) || pressed || isSelected) && styles.active, + depth > 0 + ? inlineUnistylesStyle({ + paddingLeft: treeRowPaddingLeft(depth), + }) + : null, + fileHeaderInteractionStyle({ + isHovered: hover.isHovered, + pressed, + showsBodyState, + isSelected, + }), ], - [depth, isSelected, showsBodyState], + [depth, hover.isHovered, isSelected, showsBodyState], ); const accessibilityState = useMemo( () => fileHeaderAccessibilityState({ showsBodyState, bodyVisible, isSelected }), [bodyVisible, isSelected, showsBodyState], ); const fileName = fileNameForPath(file.path); + const nameStyle = fileHeaderNameStyle(showsBodyState, hover.isHovered); + const documentChangeIcon = documentFileChangeIcon(file, showsBodyState); + const treeChangeIcon = treeFileChangeIcon(file, showsBodyState); + const rightStyle = fileHeaderRightStyle(showsBodyState); const content = ( )} - + {fileName} {showDir ? ( @@ -179,15 +264,15 @@ export const FileHeader = memo(function FileHeader({ ) : ( )} - {file.isNew ? : null} - {file.isDeleted ? : null} + {documentChangeIcon} - + + {treeChangeIcon} ); @@ -197,6 +282,7 @@ export const FileHeader = memo(function FileHeader({ ); } else { - trigger = {content}; + trigger = {content}; } return ( - {trigger} @@ -240,14 +323,16 @@ export const FileHeader = memo(function FileHeader({ }); const styles = StyleSheet.create((theme) => ({ - container: { overflow: "hidden", userSelect: "none" }, + container: { width: "100%", overflow: "hidden", userSelect: "none" }, documentContainer: { - height: 44, - backgroundColor: theme.colors.surface2, + height: 30, + backgroundColor: theme.colors.surface0, + borderTopWidth: theme.borderWidth[1], + borderTopColor: theme.colors.borderAccent, borderBottomWidth: theme.borderWidth[1], - borderBottomColor: theme.colors.border, + borderBottomColor: theme.colors.borderAccent, }, - expanded: { backgroundColor: theme.colors.surface1 }, + documentContainerWithoutTopBorder: { borderTopColor: "transparent" }, header: { flexDirection: "row", alignItems: "center", @@ -260,7 +345,12 @@ const styles = StyleSheet.create((theme) => ({ elevation: 2, userSelect: "none", }, - documentHeader: { height: 43, paddingHorizontal: 0, paddingVertical: 0 }, + documentHeader: { + height: 28, + paddingLeft: 0, + paddingRight: 0, + paddingVertical: 0, + }, content: { flex: 1, minWidth: 0, @@ -269,8 +359,9 @@ const styles = StyleSheet.create((theme) => ({ justifyContent: "space-between", userSelect: "none", }, - documentContent: { height: 43, paddingHorizontal: theme.spacing[3] }, - active: { backgroundColor: theme.colors.surfaceSidebarHover }, + documentContent: { height: 28, paddingHorizontal: theme.spacing[3] }, + documentActive: { backgroundColor: theme.colors.surface1 }, + documentPressFeedback: { backgroundColor: theme.colors.surface1 }, left: { flexDirection: "row", alignItems: "center", @@ -287,6 +378,7 @@ const styles = StyleSheet.create((theme) => ({ flexShrink: 0, userSelect: "none", }, + treeRight: { gap: theme.spacing[2] }, icon: { width: WORKSPACE_TREE_ICON_SIZE, height: WORKSPACE_TREE_ICON_SIZE, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 3cee66058..43e368888 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -879,6 +879,7 @@ export const ar: TranslationResources = { base: "قاعدة", newFile: "جديد", deletedFile: "تم الحذف", + modifiedFile: "معدّل", commits: { title: "الإيداعات", countLabel: "{{count}} من إيداعات مساحة العمل", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index f4a4bf27f..4fdd654fb 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -888,6 +888,7 @@ export const en = { base: "base", newFile: "New", deletedFile: "Deleted", + modifiedFile: "Modified", commits: { title: "Commits", countLabel: "{{count}} workspace commits", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 69e0c0e20..23fb61297 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -910,6 +910,7 @@ export const es: TranslationResources = { base: "base", newFile: "Nuevo", deletedFile: "Eliminado", + modifiedFile: "Modificado", commits: { title: "Commits", countLabel: "{{count}} commits del espacio de trabajo", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index a0f47a295..d312bcf50 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -909,6 +909,7 @@ export const fr: TranslationResources = { base: "base", newFile: "Nouveau", deletedFile: "Supprimé", + modifiedFile: "Modifié", commits: { title: "Commits", countLabel: "{{count}} commits de l’espace de travail", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 7179216b8..c85c2aeda 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -890,6 +890,7 @@ export const ja: TranslationResources = { base: "ベース", newFile: "新規", deletedFile: "削除済み", + modifiedFile: "変更済み", commits: { title: "コミット", countLabel: "ワークスペースのコミット数: {{count}}", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 77f93ff8c..9273756bf 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -886,6 +886,7 @@ export const ko: TranslationResources = { base: "기준", newFile: "신규", deletedFile: "삭제됨", + modifiedFile: "수정됨", commits: { title: "커밋", countLabel: "워크스페이스 커밋 {{count}}개", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index f9b1acf3b..36244f611 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -901,6 +901,7 @@ export const ptBR: TranslationResources = { base: "base", newFile: "Novo", deletedFile: "Excluído", + modifiedFile: "Modificado", commits: { title: "Commits", countLabel: "{{count}} commits do espaço de trabalho", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 38167d5b6..f878ca4ca 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -901,6 +901,7 @@ export const ru: TranslationResources = { base: "база", newFile: "Новый", deletedFile: "Удалено", + modifiedFile: "Изменено", commits: { title: "Коммиты", countLabel: "{{count}} коммитов рабочего пространства", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 53010a092..03ef8359b 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -871,6 +871,7 @@ export const zhCN: TranslationResources = { base: "base", newFile: "新增", deletedFile: "已删除", + modifiedFile: "已修改", commits: { title: "提交", countLabel: "{{count}} 个工作区提交", diff --git a/packages/app/src/mobile-panels/gestures.ts b/packages/app/src/mobile-panels/gestures.ts index aca2d64ce..a3b26adc7 100644 --- a/packages/app/src/mobile-panels/gestures.ts +++ b/packages/app/src/mobile-panels/gestures.ts @@ -337,6 +337,7 @@ export function useCloseFileExplorerGesture() { windowWidth, } = useMobilePanelsRuntime(); const { startedRevision, touchStartX, touchStartY } = useGestureState(); + const horizontalScroll = useHorizontalScrollOptional(); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const commit = useRevisionCommit(showMobileAgent); @@ -372,6 +373,7 @@ export function useCloseFileExplorerGesture() { }); if ( !canBeginMobilePanelGesture(motionState.value, "file-explorer", position.value) || + horizontalScroll?.activeGestureStartedScrolled.value || panIntent === "fail" ) { stateManager.fail(); @@ -405,6 +407,7 @@ export function useCloseFileExplorerGesture() { beginGesture, commit, finishGesture, + horizontalScroll?.activeGestureStartedScrolled, motionState, position, rightCloseGestureRef, diff --git a/packages/app/src/review/index.ts b/packages/app/src/review/index.ts index 7af7beed0..a98a54579 100644 --- a/packages/app/src/review/index.ts +++ b/packages/app/src/review/index.ts @@ -30,6 +30,7 @@ export { export { getInlineReviewThreadViewportStyle, groupInlineReviewCommentsByTarget, + InlineReviewAddButton, InlineReviewEditor, InlineReviewGutterCell, InlineReviewThread, diff --git a/packages/app/src/review/surface.tsx b/packages/app/src/review/surface.tsx index d1f455575..4d254bd81 100644 --- a/packages/app/src/review/surface.tsx +++ b/packages/app/src/review/surface.tsx @@ -62,6 +62,35 @@ const ThemedPencil = withUnistyles(Pencil); const ThemedPlus = withUnistyles(Plus); const ThemedTrash2 = withUnistyles(Trash2); +function InlineReviewAddIcon({ style, testID }: { style?: StyleProp; testID?: string }) { + return ( + + + + ); +} + +export function InlineReviewAddButton({ + onPress, + style, +}: { + onPress: () => void; + style?: StyleProp; +}) { + const { t } = useTranslation(); + return ( + + + + ); +} + export function groupInlineReviewCommentsByTarget( comments: readonly ReviewDraftComment[], ): Map { @@ -262,9 +291,7 @@ export function InlineReviewGutterCell({ {children} {showAction ? ( - - - + ) : null} @@ -541,6 +568,8 @@ const styles = StyleSheet.create((theme) => ({ position: "absolute", right: -10, top: Math.floor((theme.lineHeight.diff - 22) / 2), + }, + gutterActionVisual: { width: 22, height: 22, borderRadius: theme.borderRadius.md, diff --git a/packages/app/src/screens/settings/appearance/appearance-section.tsx b/packages/app/src/screens/settings/appearance/appearance-section.tsx index 8dfda2353..72a5577af 100644 --- a/packages/app/src/screens/settings/appearance/appearance-section.tsx +++ b/packages/app/src/screens/settings/appearance/appearance-section.tsx @@ -464,7 +464,7 @@ function SyntaxRow({ value, onChange }: SyntaxRowProps) { export function AppearanceSection() { const { t } = useTranslation(); const { settings, updateSettings } = useAppSettings(); - const showFontFamilyRows = !isNative; + const showInterfaceFontFamilyRow = !isNative; const uiFontPlaceholder = resolveDefaultStackPlaceholder(t, DEFAULT_UI_FONT_STACK); const monoFontPlaceholder = resolveDefaultStackPlaceholder(t, DEFAULT_MONO_FONT_STACK); @@ -616,7 +616,7 @@ export function AppearanceSection() { - {showFontFamilyRows ? ( + {showInterfaceFontFamilyRow ? ( - {showFontFamilyRows ? ( - - ) : null} + { it("parses six-digit hex", () => { @@ -17,6 +17,17 @@ describe("parseHexColor", () => { }); }); +describe("hexColorWithAlpha", () => { + it("creates a cross-platform rgba color from an owned theme color", () => { + expect(hexColorWithAlpha("#3e704a", 0.15)).toBe("rgba(62, 112, 74, 0.15)"); + }); + + it("rejects colors and alpha values outside its contract", () => { + expect(() => hexColorWithAlpha("currentColor", 0.15)).toThrow(); + expect(() => hexColorWithAlpha("#3e704a", 1.1)).toThrow(); + }); +}); + describe("desaturateHexColor", () => { it("leaves a colour alone at full chroma", () => { expect(desaturateHexColor("#0288d1", 1)).toBe("#0288d1"); diff --git a/packages/app/src/utils/color.ts b/packages/app/src/utils/color.ts index 078283e97..64bc75533 100644 --- a/packages/app/src/utils/color.ts +++ b/packages/app/src/utils/color.ts @@ -67,6 +67,17 @@ export function parseHexColor(hex: string): [number, number, number] | null { ]; } +/** Adds alpha to a theme-owned hex color in a format accepted by Canvas and Skia. */ +export function hexColorWithAlpha(hex: string, alpha: number): string { + const rgb = parseHexColor(hex); + if (!rgb) throw new TypeError(`Expected a hex color, received ${hex}`); + if (!Number.isFinite(alpha) || alpha < 0 || alpha > 1) { + throw new RangeError(`Color alpha must be between 0 and 1, received ${alpha}`); + } + const [r, g, b] = rgb.map((channel) => Math.round(channel * 255)); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + function toHexChannel(channel: number): string { const clamped = Math.min(255, Math.max(0, Math.round(channel * 255))); return clamped.toString(16).padStart(2, "0");