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:
Generated
+1
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -879,6 +879,7 @@ export const ar: TranslationResources = {
|
||||
base: "قاعدة",
|
||||
newFile: "جديد",
|
||||
deletedFile: "تم الحذف",
|
||||
modifiedFile: "معدّل",
|
||||
commits: {
|
||||
title: "الإيداعات",
|
||||
countLabel: "{{count}} من إيداعات مساحة العمل",
|
||||
|
||||
@@ -888,6 +888,7 @@ export const en = {
|
||||
base: "base",
|
||||
newFile: "New",
|
||||
deletedFile: "Deleted",
|
||||
modifiedFile: "Modified",
|
||||
commits: {
|
||||
title: "Commits",
|
||||
countLabel: "{{count}} workspace commits",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -890,6 +890,7 @@ export const ja: TranslationResources = {
|
||||
base: "ベース",
|
||||
newFile: "新規",
|
||||
deletedFile: "削除済み",
|
||||
modifiedFile: "変更済み",
|
||||
commits: {
|
||||
title: "コミット",
|
||||
countLabel: "ワークスペースのコミット数: {{count}}",
|
||||
|
||||
@@ -886,6 +886,7 @@ export const ko: TranslationResources = {
|
||||
base: "기준",
|
||||
newFile: "신규",
|
||||
deletedFile: "삭제됨",
|
||||
modifiedFile: "수정됨",
|
||||
commits: {
|
||||
title: "커밋",
|
||||
countLabel: "워크스페이스 커밋 {{count}}개",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -901,6 +901,7 @@ export const ru: TranslationResources = {
|
||||
base: "база",
|
||||
newFile: "Новый",
|
||||
deletedFile: "Удалено",
|
||||
modifiedFile: "Изменено",
|
||||
commits: {
|
||||
title: "Коммиты",
|
||||
countLabel: "{{count}} коммитов рабочего пространства",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -30,6 +30,7 @@ export {
|
||||
export {
|
||||
getInlineReviewThreadViewportStyle,
|
||||
groupInlineReviewCommentsByTarget,
|
||||
InlineReviewAddButton,
|
||||
InlineReviewEditor,
|
||||
InlineReviewGutterCell,
|
||||
InlineReviewThread,
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user