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.
This commit is contained in:
Mohamed Boudra
2026-08-17 21:14:38 +02:00
parent 4d23905587
commit c7b103a2cd
50 changed files with 2180 additions and 580 deletions
+1
View File
@@ -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",
@@ -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<void> {
const body = page.getByTestId("diff-file-0-body");
const canvas = page.getByTestId("git-diff-canvas");
const [bodyBounds, fontSize] = await Promise.all([
body.boundingBox(),
canvas.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)),
]);
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
const lineHeight = Math.round(fontSize * 1.5);
await page.mouse.click(bodyBounds.x + 120, bodyBounds.y + lineHeight * 1.5);
}
async function hoverFirstChangedGutter(page: Page): Promise<void> {
const body = page.getByTestId("diff-file-0-body");
const canvas = page.getByTestId("git-diff-canvas");
@@ -100,10 +100,10 @@ async function expectCommitDiffHeaderGeometry(panel: Locator): Promise<void> {
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,
@@ -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<CapturedCanvasFrame[]> {
const scroll = page.getByTestId("git-diff-scroll");
const canvas = page.getByTestId("git-diff-canvas");
const frames: CapturedCanvasFrame[] = [];
for (let step = 0; step <= 8; step += 1) {
const scrollTop = await scroll.evaluate(async (element, progress) => {
@@ -335,29 +346,11 @@ async function captureCanvasAtScrollIntervals(page: Page): Promise<CapturedCanva
await new Promise<void>((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<void> {
const scroll = page.getByTestId("git-diff-scroll");
const canvas = page.getByTestId("git-diff-canvas");
for (const frame of frames) {
await scroll.evaluate(async (element, scrollTop) => {
element.scrollTop = scrollTop;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
}, frame.scrollTop);
expect(await canvas.screenshot()).toEqual(frame.image);
}
}
async function expectContinuousFramesMatchStableRender(
page: Page,
samples: Array<{ scrollTop: number; signature: number }>,
@@ -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));
}
@@ -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,
+1
View File
@@ -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",
@@ -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 (
<ThemedSquareMinus
size={ICON_SIZE}
uniProps={dangerMapping}
accessibilityLabel={t("workspace.git.diff.deletedFile")}
/>
);
}
return (
<ThemedSquareMinus
<ThemedSquareDot
size={ICON_SIZE}
uniProps={dangerMapping}
accessibilityLabel={t("workspace.git.diff.deletedFile")}
uniProps={warningMapping}
accessibilityLabel={t("workspace.git.diff.modifiedFile")}
/>
);
}
@@ -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 (
<View style={[styles.entryRow, { paddingLeft: treeRowPaddingLeft(depth) }]}>
<TreeIndentGuides depth={depth} />
<View style={[workspaceTreeRowStyles.row, { paddingLeft: treeRowPaddingLeft(depth) }]}>
<View style={styles.entryInfo}>
<View style={styles.entryIcon}>
{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}
>
<TreeIndentGuides depth={depth} />
<View ref={dragSourceRef} style={styles.entryInfo}>
<View style={styles.entryIcon}>
{isDirectory ? (
@@ -377,7 +378,15 @@ function TreeRowItem({
<MaterialFileIcon fileName={entry.name} size={WORKSPACE_TREE_ICON_SIZE} />
)}
</View>
<Text style={styles.entryName} numberOfLines={1}>
<Text
style={[
styles.entryName,
workspaceTreeRowStyles.name,
isHovered && workspaceTreeRowStyles.nameHovered,
]}
numberOfLines={1}
testID={testID ? `${testID}-name` : undefined}
>
{entry.name}
</Text>
</View>
@@ -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",
+18 -39
View File
@@ -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) => (
<View key={guide.key} style={guide.style} pointerEvents="none" />
))}
</>
);
}
/** 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,
@@ -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<boolean>;
// 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<boolean>;
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<HorizontalScrollContextValue | nul
export function HorizontalScrollProvider({ children }: { children: ReactNode }) {
const isAnyScrolledRight = useSharedValue(false);
const activeGestureStartedScrolled = useSharedValue(false);
const scrollOffsetsRef = useRef<Map<string, number>>(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 (
@@ -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
);
}
@@ -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 (
<ScrollView
<AnimatedHorizontalScrollView
ref={scrollRef}
horizontal
nestedScrollEnabled
@@ -66,12 +89,15 @@ export function HorizontalScroll({
style={rootStyle}
contentContainerStyle={spacerStyle}
onScroll={handler}
onTouchStart={beginHorizontalGesture}
onTouchEnd={endHorizontalGesture}
onTouchCancel={endHorizontalGesture}
onScrollEndDrag={reportOffset}
scrollEventThrottle={16}
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
onMomentumScrollEnd={reportOffset}
>
<View style={spacerStyle} />
</ScrollView>
</AnimatedHorizontalScrollView>
);
}
+6 -2
View File
@@ -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<string | null>(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 (
<DiffSurface
{...props}
palette={palette}
collapsedFilePaths={collapsedFilePaths}
onToggleFile={toggleFile}
selectedPath={selectedPath}
@@ -3,9 +3,11 @@ import { describe, expect, it } from "vitest";
import {
buildDiffDocumentModel,
captureScrollAnchor,
FILE_HEADER_HEIGHT,
graphemeBoundaries,
measureFragments,
resolveScrollAnchor,
resolveRelayoutScrollTop,
} from "./model";
import type { BuildDiffDocumentModelInput, TextMeasurer } from "./types";
@@ -63,6 +65,24 @@ function input(overrides: Partial<BuildDiffDocumentModelInput> = {}): 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({
+70 -12
View File
@@ -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<Omit<DiffGrapheme, "width">> {
const segments: Array<Omit<DiffGrapheme, "width">> = [];
let column = 0;
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
for (const segment of segmenter.segment(text)) {
const isTab = segment.segment === "\t";
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 &&
@@ -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<typeof buildDiffDocumentModel>, 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,
});
}
@@ -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,
});
}
@@ -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);
});
});
@@ -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);
}
@@ -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" },
]);
});
});
@@ -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) }];
});
}
@@ -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<string, SkPaint>;
text: Record<string, SkPaint>;
}
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<DiffHorizontalOffsets>;
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<DiffCell["type"], SkPaint> = {
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<DiffCell["type"], SkPaint>;
}): 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();
}
@@ -26,19 +26,27 @@ export interface PaintWebViewportInput {
horizontalOffsets: ReadonlyMap<string, number>;
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(
@@ -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",
+35 -8
View File
@@ -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<string, string>, right: Record<string, string>): 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;
}
@@ -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<ScrollView>(null);
const rootRef = useRef<View>(null);
const surfaceOriginRef = useRef<SurfaceWindowOrigin>({ x: 0, y: 0 });
const previousModelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
const reusableModelRef = useRef<{
dependencies: readonly unknown[];
models: ReturnType<typeof buildDiffDocumentModel>[];
} | null>(null);
const consumedFocusRef = useRef<string | null>(null);
const scrollTop = useSharedValue(0);
const horizontalOffsets = useSharedValue<DiffHorizontalOffsets>({});
@@ -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 (
<View
ref={rootRef}
style={[styles.root, { backgroundColor: props.palette.surface }]}
onLayout={layout}
>
<View style={[styles.root, { backgroundColor: props.palette.surface }]} onLayout={layout}>
<AnimatedScrollView
ref={scrollRef}
style={[StyleSheet.absoluteFill, styles.scroll]}
@@ -198,90 +212,372 @@ export function DiffSurface(props: DiffSurfaceProps) {
onScroll={scrollHandler}
scrollEventThrottle={16}
showsVerticalScrollIndicator
stickyHeaderIndices={model.files.map((_, index) => index * 2)}
stickyHeaderIndices={stickyHeaderIndices}
testID="git-diff-scroll"
onTouchStart={touchStart}
onTouchMove={touchMove}
onTouchEnd={touchEnd}
>
{model.files.flatMap((file) => [
<View key={`${file.path}:header`} style={styles.header}>
<DocumentFileHeader
file={file}
selectedPath={props.selectedPath}
mode={props.mode}
onToggleFile={props.onToggleFile}
onSelectPath={props.onSelectPath}
/>
</View>,
<View
<NativeCanvasSlabLayer
model={model}
viewportHeight={viewport.height}
scrollTop={scrollTop}
textLayout={textLayout}
paints={paints}
horizontalOffsets={horizontalOffsets}
/>
{model.files.flatMap((file, index) => [
<NativeStickyFileHeader
key={`${file.path}:header`}
file={file}
showTopBorder={index > 0 && !model.files[index - 1]?.isCollapsed}
selectedPath={props.selectedPath}
mode={props.mode}
onToggleFile={props.onToggleFile}
onSelectPath={props.onSelectPath}
/>,
<NativeFileBody
key={`${file.path}:body`}
testID={`diff-file-${file.fileIndex}-body`}
style={inlineUnistylesStyle<ViewStyle>({
position: "relative",
height: file.bodyHeight,
})}
>
{!file.isCollapsed && !model.wrapLines ? (
<HorizontalScroll file={file} offsets={horizontalOffsets} />
) : null}
{props.mode.kind === "working" && reviewActions
? model.rows.slice(file.rowStart, file.rowEnd).flatMap((row) => {
if (row.kind !== "line" || row.reviewHeight === 0) return [];
const columnWidth = model.viewportWidth / row.cells.length;
return row.cells.flatMap((cell, index) => {
if (!cell?.reviewTarget) return [];
const comments =
reviewActions.commentsByTarget.get(cell.reviewTarget.key) ?? [];
const editor = reviewActions.editor;
const hasEditor =
editor?.target.filePath === cell.reviewTarget.filePath &&
editor.target.side === cell.reviewTarget.side &&
editor.target.lineNumber === cell.reviewTarget.lineNumber;
if (comments.length === 0 && !hasEditor) return [];
return [
<View
key={cell.reviewTarget.key}
style={inlineUnistylesStyle<ViewStyle>({
position: "absolute",
top: row.top - file.bodyTop + row.height - row.reviewHeight,
left: index * columnWidth,
width: columnWidth,
height: row.reviewHeight,
zIndex: 4,
})}
>
<InlineReviewThread
reviewTarget={cell.reviewTarget}
reviewActions={reviewActions}
height={row.reviewHeight}
viewportWidth={columnWidth}
pinToViewport={!model.wrapLines}
/>
</View>,
];
});
})
: null}
</View>,
file={file}
model={model}
mode={props.mode}
horizontalOffsets={horizontalOffsets}
/>,
])}
<NativeReviewOverlays model={model} mode={props.mode} />
</AnimatedScrollView>
<View
pointerEvents="none"
style={[StyleSheet.absoluteFill, styles.canvas]}
testID="git-diff-canvas"
>
</View>
);
}
function NativeCanvasSlabLayer({
model,
viewportHeight,
scrollTop,
textLayout,
paints,
horizontalOffsets,
}: {
model: DiffDocumentModel;
viewportHeight: number;
scrollTop: SharedValue<number>;
textLayout: NativeTextLayout;
paints: NativePaints;
horizontalOffsets: SharedValue<DiffHorizontalOffsets>;
}) {
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) => (
<NativeCanvasSlabView
key={slab.key}
slab={slab}
model={model}
textLayout={textLayout}
paints={paints}
horizontalOffsets={horizontalOffsets}
/>
));
}
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 (
<View style={styles.header}>
<DocumentFileHeader
file={file}
showTopBorder={showTopBorder}
selectedPath={selectedPath}
mode={mode}
onToggleFile={onToggleFile}
onSelectPath={onSelectPath}
/>
</View>
);
}
function NativeFileBody({
file,
model,
mode,
horizontalOffsets,
}: {
file: DiffFileSection;
model: DiffDocumentModel;
mode: DiffSurfaceProps["mode"];
horizontalOffsets: SharedValue<DiffHorizontalOffsets>;
}) {
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 (
<View
testID={`diff-file-${file.fileIndex}-body`}
style={inlineUnistylesStyle<ViewStyle>({
position: "relative",
height: file.bodyHeight,
zIndex: 2,
})}
onTouchStart={touchStart}
onTouchMove={touchMove}
onTouchEnd={touchEnd}
>
{!file.isCollapsed && !model.wrapLines ? (
<HorizontalScroll file={file} offsets={horizontalOffsets} />
) : null}
</View>
);
}
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 [
<View
key={cell.reviewTarget.key}
style={inlineUnistylesStyle<ViewStyle>({
position: "absolute",
top: row.top + row.height - row.reviewHeight,
left: index * columnWidth,
width: columnWidth,
height: row.reviewHeight,
zIndex: 6,
})}
>
<InlineReviewThread
reviewTarget={cell.reviewTarget}
reviewActions={reviewActions}
height={row.reviewHeight}
viewportWidth={columnWidth}
pinToViewport={!model.wrapLines}
/>
</View>,
];
});
});
}
function NativeCanvasSlabView({
slab,
model,
textLayout,
paints,
horizontalOffsets,
}: {
slab: NativeCanvasSlab;
model: DiffDocumentModel;
textLayout: NativeTextLayout;
paints: NativePaints;
horizontalOffsets: SharedValue<DiffHorizontalOffsets>;
}) {
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<ViewStyle>(
() => ({
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 (
<View pointerEvents="none" style={style} testID={`git-diff-canvas-${slab.key}`}>
<Canvas style={StyleSheet.absoluteFill}>
<Picture picture={pictures.fixed} />
</Canvas>
<NativeSlabCode
picture={pictures.content.unified}
path={slab.path}
horizontalOffsets={horizontalOffsets}
wrapLines={model.wrapLines}
clipX={unifiedClip.x}
clipWidth={unifiedClip.width}
contentWidth={file.contentWidth}
height={slab.height}
/>
{model.layout === "split" ? (
<>
<NativeSlabCode
picture={pictures.content.left}
path={slab.path}
horizontalOffsets={horizontalOffsets}
wrapLines={model.wrapLines}
clipX={file.gutterWidth + CODE_LEFT_PADDING}
clipWidth={splitClipWidth}
contentWidth={file.contentWidth}
height={slab.height}
/>
<NativeSlabCode
picture={pictures.content.right}
path={slab.path}
horizontalOffsets={horizontalOffsets}
wrapLines={model.wrapLines}
clipX={columnWidth + file.gutterWidth + CODE_LEFT_PADDING}
clipWidth={splitClipWidth}
contentWidth={file.contentWidth}
height={slab.height}
/>
</>
) : null}
<Canvas pointerEvents="none" style={StyleSheet.absoluteFill}>
<Picture picture={pictures.gutter} />
</Canvas>
</View>
);
}
function NativeSlabCode({
picture,
path,
horizontalOffsets,
wrapLines,
clipX,
clipWidth,
contentWidth,
height,
}: {
picture: SkPicture;
path: string;
horizontalOffsets: SharedValue<DiffHorizontalOffsets>;
wrapLines: boolean;
clipX: number;
clipWidth: number;
contentWidth: number;
height: number;
}) {
const transform = useAnimatedStyle(() => ({
transform: [
{ translateX: wrapLines ? 0 : -horizontalOffsetForPath(horizontalOffsets.value, path) },
],
}));
const clipStyle = useMemo<ViewStyle>(
() => ({
position: "absolute",
top: 0,
left: clipX,
width: clipWidth,
height,
overflow: "hidden",
}),
[clipWidth, clipX, height],
);
const contentStyle = useMemo<ViewStyle>(
() => ({ position: "absolute", top: 0, left: -clipX, width: contentWidth, height }),
[clipX, contentWidth, height],
);
return (
<View pointerEvents="none" style={clipStyle}>
<AnimatedCodeLayer style={[contentStyle, transform]}>
<Canvas style={StyleSheet.absoluteFill}>
<Picture picture={picture} />
</Canvas>
</View>
</AnimatedCodeLayer>
</View>
);
}
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 },
});
@@ -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<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const canvasScratchRef = useRef<HTMLCanvasElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const modelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
const previousModelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
const reusableModelRef = useRef<{
dependencies: readonly unknown[];
models: ReturnType<typeof buildDiffDocumentModel>[];
} | null>(null);
const consumedFocusRef = useRef<string | null>(null);
const scrollTopRef = useRef(0);
const horizontalOffsetsRef = useRef(new Map<string, number>());
@@ -37,6 +49,8 @@ export function DiffSurface(props: DiffSurfaceProps) {
moved: boolean;
} | null>(null);
const frameRef = useRef<number | null>(null);
const forcePaintRef = useRef(true);
const canvasWindowRef = useRef({ top: 0, height: 0 });
const [viewport, setViewport] = useState({ width: 0, height: 0 });
const [loadedTypography, setLoadedTypography] = useState<DiffTypography | null>(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<HTMLDivElement>) => {
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<React.CSSProperties>(
const affordanceStyle = useMemo<ViewStyle>(
() => ({
...AFFORDANCE_STYLE,
left: hoveredAffordance?.left ?? 0,
@@ -377,8 +488,8 @@ export function DiffSurface(props: DiffSurfaceProps) {
<div
ref={scrollRef}
data-testid="git-diff-scroll"
data-overlay-scrollbar="true"
tabIndex={0}
onScroll={handleVerticalScroll}
onPointerDown={pointerDown}
onPointerMove={pointerMove}
onPointerUp={pointerUp}
@@ -388,10 +499,12 @@ export function DiffSurface(props: DiffSurfaceProps) {
style={SCROLL_STYLE}
>
<div style={contentStyle} onMouseDown={preventDocumentMouseSelection}>
{model.files.map((file) => (
<canvas ref={canvasRef} data-testid="git-diff-canvas" style={canvasStyle} />
{model.files.map((file, index) => (
<WebFileHeaderSection key={file.path} file={file}>
<DocumentFileHeader
file={file}
showTopBorder={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}
</div>
</div>
<canvas ref={canvasRef} data-testid="git-diff-canvas" style={canvasStyle} />
<DomOverlayScrollbar scrollContainerRef={scrollRef} onUserScrollUp={noop} />
{hoveredAffordance?.hit.target && reviewActions ? (
<button
type="button"
aria-label={t("review.comment.add")}
onClick={addHoveredComment}
style={affordanceStyle}
>
+
</button>
<InlineReviewAddButton onPress={addHoveredComment} style={affordanceStyle} />
) : null}
</div>
);
}
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: {
@@ -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) {
@@ -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;
@@ -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<Array<Array<SkParagraph | null>>>;
}
export function createNativeTextLayout(input: {
model: DiffDocumentModel;
export interface NativeTextLayoutStore {
font: SkFont;
paragraphsByCell: WeakMap<DiffCell, Array<SkParagraph | null>>;
ownedParagraphs: Set<SkParagraph>;
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;
@@ -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 {
+26 -19
View File
@@ -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 (
<View style={styles.container} onLayout={handleLayout} testID={testID}>
<TreeIndentGuides depth={depth} />
<ContextMenu>
<ContextMenuTrigger
onPress={handlePress}
onLongPress={handleSelect}
onContextMenu={handleSelect}
style={pressableStyle}
onHoverIn={showNameHover}
onHoverOut={hideNameHover}
accessibilityRole="button"
accessibilityState={accessibilityState}
aria-selected={isSelected}
@@ -144,7 +154,15 @@ export function DiffFolderRow({
<View style={styles.chevronSlot}>
<TreeChevron expanded={!collapsed} />
</View>
<Text style={styles.folderName} numberOfLines={1}>
<Text
style={[
styles.folderName,
workspaceTreeRowStyles.name,
isHovered && workspaceTreeRowStyles.nameHovered,
]}
numberOfLines={1}
testID={testID ? `${testID}-name` : undefined}
>
{displayName}
</Text>
</View>
@@ -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",
@@ -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([
@@ -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<number | null>(null);
const {
path,
enabled,
onSelect,
onActivate,
onLayout: notifyLayout,
stickyPressFallback = false,
} = input;
const pressHandledRef = useRef(false);
const pressOriginRef = useRef<PressOrigin | null>(null);
const phaseRef = useRef<FileHeaderPressPhase>("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 };
+117 -25
View File
@@ -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 <FileChangeIcon change="added" />;
if (file.isDeleted) return <FileChangeIcon change="deleted" />;
return null;
}
function treeFileChangeIcon(file: ParsedDiffFile, showsBodyState: boolean) {
return showsBodyState ? null : <FileChangeIcon change={fileChange(file)} />;
}
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 = (
<View
style={[styles.content, showsBodyState && styles.documentContent]}
@@ -169,7 +254,7 @@ export const FileHeader = memo(function FileHeader({
<MaterialFileIcon fileName={fileName} size={WORKSPACE_TREE_ICON_SIZE} />
</View>
)}
<Text style={styles.name} numberOfLines={1} testID={testID ? `${testID}-name` : undefined}>
<Text style={nameStyle} numberOfLines={1} testID={testID ? `${testID}-name` : undefined}>
{fileName}
</Text>
{showDir ? (
@@ -179,15 +264,15 @@ export const FileHeader = memo(function FileHeader({
) : (
<View style={styles.directorySpacer} />
)}
{file.isNew ? <FileChangeIcon change="added" /> : null}
{file.isDeleted ? <FileChangeIcon change="deleted" /> : null}
{documentChangeIcon}
</View>
<View style={styles.right}>
<View style={rightStyle}>
<DiffStat
additions={file.additions}
deletions={file.deletions}
testID={testID ? `${testID}-stat` : undefined}
/>
{treeChangeIcon}
</View>
</View>
);
@@ -197,6 +282,7 @@ export const FileHeader = memo(function FileHeader({
<ContextMenuTrigger
testID={testID ? `${testID}-toggle` : undefined}
style={pressableStyle}
highlightStyle={fileHeaderPressFeedbackStyle(showsBodyState)}
cancelable={false}
onPressIn={interaction.onPressIn}
onPressOut={interaction.onPressOut}
@@ -211,19 +297,16 @@ export const FileHeader = memo(function FileHeader({
</ContextMenuTrigger>
);
} else {
trigger = <View style={pressableStyle({ hovered: false, pressed: false })}>{content}</View>;
trigger = <View style={pressableStyle({ pressed: false })}>{content}</View>;
}
return (
<View
style={[
styles.container,
showsBodyState && styles.documentContainer,
bodyVisible && styles.expanded,
]}
style={[styles.container, fileHeaderContainerStyle(showsBodyState, showTopBorder)]}
onLayout={interaction.onLayout}
onPointerEnter={hover.handlePointerEnter}
onPointerLeave={hover.handlePointerLeave}
testID={testID}
>
<TreeIndentGuides depth={depth} />
<ContextMenu>
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
@@ -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,
+1
View File
@@ -879,6 +879,7 @@ export const ar: TranslationResources = {
base: "قاعدة",
newFile: "جديد",
deletedFile: "تم الحذف",
modifiedFile: "معدّل",
commits: {
title: "الإيداعات",
countLabel: "{{count}} من إيداعات مساحة العمل",
+1
View File
@@ -888,6 +888,7 @@ export const en = {
base: "base",
newFile: "New",
deletedFile: "Deleted",
modifiedFile: "Modified",
commits: {
title: "Commits",
countLabel: "{{count}} workspace commits",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -890,6 +890,7 @@ export const ja: TranslationResources = {
base: "ベース",
newFile: "新規",
deletedFile: "削除済み",
modifiedFile: "変更済み",
commits: {
title: "コミット",
countLabel: "ワークスペースのコミット数: {{count}}",
+1
View File
@@ -886,6 +886,7 @@ export const ko: TranslationResources = {
base: "기준",
newFile: "신규",
deletedFile: "삭제됨",
modifiedFile: "수정됨",
commits: {
title: "커밋",
countLabel: "워크스페이스 커밋 {{count}}개",
+1
View File
@@ -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",
+1
View File
@@ -901,6 +901,7 @@ export const ru: TranslationResources = {
base: "база",
newFile: "Новый",
deletedFile: "Удалено",
modifiedFile: "Изменено",
commits: {
title: "Коммиты",
countLabel: "{{count}} коммитов рабочего пространства",
+1
View File
@@ -871,6 +871,7 @@ export const zhCN: TranslationResources = {
base: "base",
newFile: "新增",
deletedFile: "已删除",
modifiedFile: "已修改",
commits: {
title: "提交",
countLabel: "{{count}} 个工作区提交",
@@ -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,
+1
View File
@@ -30,6 +30,7 @@ export {
export {
getInlineReviewThreadViewportStyle,
groupInlineReviewCommentsByTarget,
InlineReviewAddButton,
InlineReviewEditor,
InlineReviewGutterCell,
InlineReviewThread,
+32 -3
View File
@@ -62,6 +62,35 @@ const ThemedPencil = withUnistyles(Pencil);
const ThemedPlus = withUnistyles(Plus);
const ThemedTrash2 = withUnistyles(Trash2);
function InlineReviewAddIcon({ style, testID }: { style?: StyleProp<ViewStyle>; testID?: string }) {
return (
<View style={[styles.gutterActionVisual, style]} testID={testID}>
<ThemedPlus size={16} strokeWidth={2.4} uniProps={accentForegroundIconColorMapping} />
</View>
);
}
export function InlineReviewAddButton({
onPress,
style,
}: {
onPress: () => void;
style?: StyleProp<ViewStyle>;
}) {
const { t } = useTranslation();
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={t("review.comment.add")}
hitSlop={SMALL_ACTION_HIT_SLOP}
onPress={onPress}
style={[styles.gutterActionVisual, style]}
>
<ThemedPlus size={16} strokeWidth={2.4} uniProps={accentForegroundIconColorMapping} />
</Pressable>
);
}
export function groupInlineReviewCommentsByTarget(
comments: readonly ReviewDraftComment[],
): Map<string, ReviewDraftComment[]> {
@@ -262,9 +291,7 @@ export function InlineReviewGutterCell({
<View style={labelStyle}>
{children}
{showAction ? (
<View style={actionIconStyle} testID={actionTestID}>
<ThemedPlus size={16} strokeWidth={2.4} uniProps={accentForegroundIconColorMapping} />
</View>
<InlineReviewAddIcon style={actionIconStyle} testID={actionTestID} />
) : null}
</View>
</View>
@@ -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,
@@ -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() {
</SettingsSection>
<SettingsSection title={t("settings.appearance.fonts.title")}>
<View style={settingsStyles.card}>
{showFontFamilyRows ? (
{showInterfaceFontFamilyRow ? (
<FontFamilyRow
title={t("settings.appearance.fonts.interfaceFont")}
hint={t("settings.appearance.fonts.interfaceFontHint")}
@@ -633,23 +633,21 @@ export function AppearanceSection() {
title={t("settings.appearance.fonts.interfaceSize")}
accessibilityLabel={t("settings.appearance.fonts.interfaceSizeAccessibility")}
draft={uiSizeDraft}
withBorder={showFontFamilyRows}
withBorder={showInterfaceFontFamilyRow}
onChangeDraft={handleUiSizeChange}
onCommit={commitUiSize}
/>
{showFontFamilyRows ? (
<FontFamilyRow
title={t("settings.appearance.fonts.codeFont")}
hint={t("settings.appearance.fonts.codeFontHint")}
accessibilityLabel={t("settings.appearance.fonts.codeFontAccessibility")}
placeholder={monoFontPlaceholder}
value={settings.monoFontFamily}
draft={monoFontDraft}
withBorder
onChangeDraft={setMonoFontDraft}
onCommit={commitMonoFontFamily}
/>
) : null}
<FontFamilyRow
title={t("settings.appearance.fonts.codeFont")}
hint={t("settings.appearance.fonts.codeFontHint")}
accessibilityLabel={t("settings.appearance.fonts.codeFontAccessibility")}
placeholder={monoFontPlaceholder}
value={settings.monoFontFamily}
draft={monoFontDraft}
withBorder
onChangeDraft={setMonoFontDraft}
onCommit={commitMonoFontFamily}
/>
<FontSizeRow
title={t("settings.appearance.fonts.codeSize")}
accessibilityLabel={t("settings.appearance.fonts.codeSizeAccessibility")}
+12 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { desaturateHexColor, parseHexColor } from "./color";
import { desaturateHexColor, hexColorWithAlpha, parseHexColor } from "./color";
describe("parseHexColor", () => {
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");
+11
View File
@@ -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");