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": "*",
|
"@getpaseo/protocol": "*",
|
||||||
"@gorhom/bottom-sheet": "^5.2.14",
|
"@gorhom/bottom-sheet": "^5.2.14",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
|
"@marijn/find-cluster-break": "^1.0.2",
|
||||||
"@mattermost/react-native-paste-input": "2.0.1",
|
"@mattermost/react-native-paste-input": "2.0.1",
|
||||||
"@paseo/plugin": "*",
|
"@paseo/plugin": "*",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@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");
|
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 }) => {
|
test("changes context menus duplicate files and folders", async ({ page }) => {
|
||||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
@@ -333,6 +385,32 @@ test("changes context menus duplicate files and folders", async ({ page }) => {
|
|||||||
.toBe(AFTER);
|
.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 }) => {
|
test("changes context menu recursively collapses descendant folders", async ({ page }) => {
|
||||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true });
|
const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true });
|
||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
@@ -342,6 +420,22 @@ test("changes context menu recursively collapses descendant folders", async ({ p
|
|||||||
const tree = changesTree(page);
|
const tree = changesTree(page);
|
||||||
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||||
await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).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 tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||||
await page.getByTestId("diff-folder-src-collapse-folder").click();
|
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);
|
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 ({
|
test("canvas diff copies a dragged character selection without opening a review", async ({
|
||||||
context,
|
context,
|
||||||
page,
|
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);
|
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 ({
|
test("canvas diff copies exact multiline text forwards and backwards", async ({
|
||||||
context,
|
context,
|
||||||
page,
|
page,
|
||||||
@@ -1117,6 +1274,18 @@ async function startReviewOnFirstChangedLine(
|
|||||||
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
|
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> {
|
async function hoverFirstChangedGutter(page: Page): Promise<void> {
|
||||||
const body = page.getByTestId("diff-file-0-body");
|
const body = page.getByTestId("diff-file-0-body");
|
||||||
const canvas = page.getByTestId("git-diff-canvas");
|
const canvas = page.getByTestId("git-diff-canvas");
|
||||||
|
|||||||
@@ -100,10 +100,10 @@ async function expectCommitDiffHeaderGeometry(panel: Locator): Promise<void> {
|
|||||||
expect(content).not.toBeNull();
|
expect(content).not.toBeNull();
|
||||||
expect(name).not.toBeNull();
|
expect(name).not.toBeNull();
|
||||||
expect(stat).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!.x).toBeCloseTo(header!.x, 0);
|
||||||
expect(content!.width).toBeCloseTo(header!.width, 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(name!.x + name!.width).toBeLessThan(stat!.x);
|
||||||
expect(Math.abs(name!.y + name!.height / 2 - (stat!.y + stat!.height / 2))).toBeLessThanOrEqual(
|
expect(Math.abs(name!.y + name!.height / 2 - (stat!.y + stat!.height / 2))).toBeLessThanOrEqual(
|
||||||
1,
|
1,
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ diffPerfDescribe("Diff canvas performance", () => {
|
|||||||
scrollReports.flatMap((report) => report.frameSignatures),
|
scrollReports.flatMap((report) => report.frameSignatures),
|
||||||
);
|
);
|
||||||
const frameImages = await captureCanvasAtScrollIntervals(page);
|
const frameImages = await captureCanvasAtScrollIntervals(page);
|
||||||
await expectFramesMatchStableRender(page, frameImages);
|
|
||||||
for (const [frameIndex, frame] of frameImages.entries()) {
|
for (const [frameIndex, frame] of frameImages.entries()) {
|
||||||
await writeFile(testInfo.outputPath(`diff-canvas-frame-${frameIndex}.png`), frame.image);
|
await writeFile(testInfo.outputPath(`diff-canvas-frame-${frameIndex}.png`), frame.image);
|
||||||
await testInfo.attach(`diff-canvas-frame-${frameIndex}`, {
|
await testInfo.attach(`diff-canvas-frame-${frameIndex}`, {
|
||||||
@@ -150,7 +149,6 @@ async function measureAdditionalPerformanceScenario(
|
|||||||
scrollReports.flatMap((report) => report.frameSignatures),
|
scrollReports.flatMap((report) => report.frameSignatures),
|
||||||
);
|
);
|
||||||
const frames = await captureCanvasAtScrollIntervals(page);
|
const frames = await captureCanvasAtScrollIntervals(page);
|
||||||
await expectFramesMatchStableRender(page, frames);
|
|
||||||
for (const [index, frame] of frames.entries()) {
|
for (const [index, frame] of frames.entries()) {
|
||||||
await writeFile(testInfo.outputPath(`diff-canvas-${name}-frame-${index}.png`), frame.image);
|
await writeFile(testInfo.outputPath(`diff-canvas-${name}-frame-${index}.png`), frame.image);
|
||||||
await testInfo.attach(`diff-canvas-${name}-frame-${index}`, {
|
await testInfo.attach(`diff-canvas-${name}-frame-${index}`, {
|
||||||
@@ -283,7 +281,7 @@ async function continuouslyScrollCanvas(page: Page): Promise<{
|
|||||||
)!;
|
)!;
|
||||||
frameSignatures.push({
|
frameSignatures.push({
|
||||||
scrollTop: scroll.scrollTop,
|
scrollTop: scroll.scrollTop,
|
||||||
signature: canvasSignature(canvas),
|
signature: canvasSignature(canvas, scroll),
|
||||||
});
|
});
|
||||||
previousFrame = performance.now();
|
previousFrame = performance.now();
|
||||||
}
|
}
|
||||||
@@ -303,12 +301,26 @@ async function continuouslyScrollCanvas(page: Page): Promise<{
|
|||||||
frameSignatures,
|
frameSignatures,
|
||||||
};
|
};
|
||||||
|
|
||||||
function canvasSignature(canvas: HTMLCanvasElement): number {
|
function canvasSignature(canvasElement: HTMLCanvasElement, scrollElement: HTMLElement): number {
|
||||||
const sample = document.createElement("canvas");
|
const sample = document.createElement("canvas");
|
||||||
sample.width = 64;
|
sample.width = 64;
|
||||||
sample.height = 36;
|
sample.height = 36;
|
||||||
const context = sample.getContext("2d")!;
|
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;
|
const pixels = context.getImageData(0, 0, sample.width, sample.height).data;
|
||||||
let hash = 2166136261;
|
let hash = 2166136261;
|
||||||
for (const value of pixels) hash = Math.imul(hash ^ value, 16777619);
|
for (const value of pixels) hash = Math.imul(hash ^ value, 16777619);
|
||||||
@@ -324,7 +336,6 @@ interface CapturedCanvasFrame {
|
|||||||
|
|
||||||
async function captureCanvasAtScrollIntervals(page: Page): Promise<CapturedCanvasFrame[]> {
|
async function captureCanvasAtScrollIntervals(page: Page): Promise<CapturedCanvasFrame[]> {
|
||||||
const scroll = page.getByTestId("git-diff-scroll");
|
const scroll = page.getByTestId("git-diff-scroll");
|
||||||
const canvas = page.getByTestId("git-diff-canvas");
|
|
||||||
const frames: CapturedCanvasFrame[] = [];
|
const frames: CapturedCanvasFrame[] = [];
|
||||||
for (let step = 0; step <= 8; step += 1) {
|
for (let step = 0; step <= 8; step += 1) {
|
||||||
const scrollTop = await scroll.evaluate(async (element, progress) => {
|
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()));
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||||
return scrollElement.scrollTop;
|
return scrollElement.scrollTop;
|
||||||
}, step / 8);
|
}, step / 8);
|
||||||
frames.push({ scrollTop, image: await canvas.screenshot() });
|
frames.push({ scrollTop, image: await scroll.screenshot() });
|
||||||
}
|
}
|
||||||
return frames;
|
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(
|
async function expectContinuousFramesMatchStableRender(
|
||||||
page: Page,
|
page: Page,
|
||||||
samples: Array<{ scrollTop: number; signature: number }>,
|
samples: Array<{ scrollTop: number; signature: number }>,
|
||||||
@@ -375,13 +368,37 @@ async function expectContinuousFramesMatchStableRender(
|
|||||||
sample.width = 64;
|
sample.width = 64;
|
||||||
sample.height = 36;
|
sample.height = 36;
|
||||||
const context = sample.getContext("2d")!;
|
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;
|
const pixels = context.getImageData(0, 0, sample.width, sample.height).data;
|
||||||
let hash = 2166136261;
|
let hash = 2166136261;
|
||||||
for (const value of pixels) hash = Math.imul(hash ^ value, 16777619);
|
for (const value of pixels) hash = Math.imul(hash ^ value, 16777619);
|
||||||
signatures.push(hash >>> 0);
|
signatures.push(hash >>> 0);
|
||||||
}
|
}
|
||||||
return signatures;
|
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);
|
}, samples);
|
||||||
expect(stable).toEqual(samples.map((sample) => sample.signature));
|
expect(stable).toEqual(samples.map((sample) => sample.signature));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,21 @@ test.afterEach(async () => {
|
|||||||
await workspace?.cleanup();
|
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 ({
|
test("creates, renames, copies, and deletes entries through the file explorer", async ({
|
||||||
context,
|
context,
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
"@getpaseo/protocol": "*",
|
"@getpaseo/protocol": "*",
|
||||||
"@gorhom/bottom-sheet": "^5.2.14",
|
"@gorhom/bottom-sheet": "^5.2.14",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
|
"@marijn/find-cluster-break": "^1.0.2",
|
||||||
"@mattermost/react-native-paste-input": "2.0.1",
|
"@mattermost/react-native-paste-input": "2.0.1",
|
||||||
"@paseo/plugin": "*",
|
"@paseo/plugin": "*",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
|||||||
@@ -1,26 +1,29 @@
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { withUnistyles } from "react-native-unistyles";
|
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 { useTranslation } from "react-i18next";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
|
|
||||||
const ThemedSquarePlus = withUnistyles(SquarePlus);
|
const ThemedSquarePlus = withUnistyles(SquarePlus);
|
||||||
const ThemedSquareMinus = withUnistyles(SquareMinus);
|
const ThemedSquareMinus = withUnistyles(SquareMinus);
|
||||||
|
const ThemedSquareDot = withUnistyles(SquareDot);
|
||||||
|
|
||||||
const successMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess });
|
const successMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess });
|
||||||
const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
|
const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
|
||||||
|
const warningMapping = (theme: Theme) => ({ color: theme.colors.statusWarning });
|
||||||
|
|
||||||
const ICON_SIZE = 14;
|
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
|
* The square variants form one aligned visual family for added, deleted, and modified files.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export function FileChangeIcon({ change }: { change: "added" | "deleted" }): ReactElement {
|
export function FileChangeIcon({
|
||||||
|
change,
|
||||||
|
}: {
|
||||||
|
change: "added" | "deleted" | "modified";
|
||||||
|
}): ReactElement {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (change === "added") {
|
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 (
|
return (
|
||||||
<ThemedSquareMinus
|
<ThemedSquareDot
|
||||||
size={ICON_SIZE}
|
size={ICON_SIZE}
|
||||||
uniProps={dangerMapping}
|
uniProps={warningMapping}
|
||||||
accessibilityLabel={t("workspace.git.diff.deletedFile")}
|
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 { MaterialFileIcon } from "@/components/material-file-icon";
|
||||||
import {
|
import {
|
||||||
TreeChevron,
|
TreeChevron,
|
||||||
TreeIndentGuides,
|
|
||||||
treeRowPaddingLeft,
|
treeRowPaddingLeft,
|
||||||
WORKSPACE_FILE_ROW_TRAILING_PADDING,
|
workspaceTreeRowStyles,
|
||||||
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
|
||||||
WORKSPACE_TREE_ICON_LABEL_GAP,
|
WORKSPACE_TREE_ICON_LABEL_GAP,
|
||||||
WORKSPACE_TREE_ICON_SIZE,
|
WORKSPACE_TREE_ICON_SIZE,
|
||||||
WORKSPACE_TREE_LOADING_ICON_SIZE,
|
WORKSPACE_TREE_LOADING_ICON_SIZE,
|
||||||
@@ -201,8 +199,7 @@ function EntryNameInputRow({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.entryRow, { paddingLeft: treeRowPaddingLeft(depth) }]}>
|
<View style={[workspaceTreeRowStyles.row, { paddingLeft: treeRowPaddingLeft(depth) }]}>
|
||||||
<TreeIndentGuides depth={depth} />
|
|
||||||
<View style={styles.entryInfo}>
|
<View style={styles.entryInfo}>
|
||||||
<View style={styles.entryIcon}>
|
<View style={styles.entryIcon}>
|
||||||
{kind === "directory" ? (
|
{kind === "directory" ? (
|
||||||
@@ -259,6 +256,9 @@ function TreeRowItem({
|
|||||||
testID,
|
testID,
|
||||||
}: TreeRowItemProps) {
|
}: TreeRowItemProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const showNameHover = useCallback(() => setIsHovered(true), []);
|
||||||
|
const hideNameHover = useCallback(() => setIsHovered(false), []);
|
||||||
const isDirectory = entry.kind === "directory";
|
const isDirectory = entry.kind === "directory";
|
||||||
const dragSourceRef = useWorkspaceFileDragSource({
|
const dragSourceRef = useWorkspaceFileDragSource({
|
||||||
enabled: !isDirectory,
|
enabled: !isDirectory,
|
||||||
@@ -282,9 +282,9 @@ function TreeRowItem({
|
|||||||
|
|
||||||
const pressableStyle = useCallback(
|
const pressableStyle = useCallback(
|
||||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
styles.entryRow,
|
workspaceTreeRowStyles.row,
|
||||||
{ paddingLeft: treeRowPaddingLeft(depth) },
|
{ paddingLeft: treeRowPaddingLeft(depth) },
|
||||||
(Boolean(hovered) || pressed || isSelected) && styles.entryRowActive,
|
(Boolean(hovered) || pressed || isSelected) && workspaceTreeRowStyles.active,
|
||||||
],
|
],
|
||||||
[depth, isSelected],
|
[depth, isSelected],
|
||||||
);
|
);
|
||||||
@@ -364,11 +364,12 @@ function TreeRowItem({
|
|||||||
onLongPress={handleSelect}
|
onLongPress={handleSelect}
|
||||||
onContextMenu={handleSelect}
|
onContextMenu={handleSelect}
|
||||||
style={pressableStyle}
|
style={pressableStyle}
|
||||||
|
onHoverIn={showNameHover}
|
||||||
|
onHoverOut={hideNameHover}
|
||||||
accessibilityState={accessibilityState}
|
accessibilityState={accessibilityState}
|
||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
testID={testID}
|
testID={testID}
|
||||||
>
|
>
|
||||||
<TreeIndentGuides depth={depth} />
|
|
||||||
<View ref={dragSourceRef} style={styles.entryInfo}>
|
<View ref={dragSourceRef} style={styles.entryInfo}>
|
||||||
<View style={styles.entryIcon}>
|
<View style={styles.entryIcon}>
|
||||||
{isDirectory ? (
|
{isDirectory ? (
|
||||||
@@ -377,7 +378,15 @@ function TreeRowItem({
|
|||||||
<MaterialFileIcon fileName={entry.name} size={WORKSPACE_TREE_ICON_SIZE} />
|
<MaterialFileIcon fileName={entry.name} size={WORKSPACE_TREE_ICON_SIZE} />
|
||||||
)}
|
)}
|
||||||
</View>
|
</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}
|
{entry.name}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -1738,16 +1747,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
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: {
|
entryInfo: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { ChevronRight } from "lucide-react-native";
|
import { ChevronRight } from "lucide-react-native";
|
||||||
import { SPACING, type Theme } from "@/styles/theme";
|
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
|
// Shared presentation primitives for the app's directory trees. Both the Files
|
||||||
// explorer (server-loaded listings) and the Changes view (client-built from diff
|
// explorer (server-loaded listings) and the Changes view (client-built from diff
|
||||||
// paths) render different data, but their ROWS should look identical — same
|
// 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.
|
// 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_FILE_ROW_VERTICAL_PADDING = SPACING[1.5];
|
||||||
export const WORKSPACE_TREE_ICON_SIZE = 16;
|
export const WORKSPACE_TREE_ICON_SIZE = 16;
|
||||||
export const WORKSPACE_TREE_LOADING_ICON_SIZE = 14;
|
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;
|
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
|
/** Left padding for a row in either workspace tree. */
|
||||||
* in the Changes tree so their indentation can't drift apart. */
|
|
||||||
export function treeRowPaddingLeft(depth: number): number {
|
export function treeRowPaddingLeft(depth: number): number {
|
||||||
return SPACING[3] + depth * TREE_INDENT_PER_LEVEL;
|
return SPACING[3] + depth * TREE_INDENT_PER_LEVEL;
|
||||||
}
|
}
|
||||||
@@ -34,32 +31,6 @@ const foregroundExtraMutedIconColorMapping = (theme: Theme) => ({
|
|||||||
|
|
||||||
const ThemedChevronRight = withUnistyles(ChevronRight);
|
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). */
|
/** Rotating disclosure chevron for a directory row (points right; rotates down when expanded). */
|
||||||
export function TreeChevron({ expanded }: { expanded: boolean }) {
|
export function TreeChevron({ expanded }: { expanded: boolean }) {
|
||||||
return (
|
return (
|
||||||
@@ -72,14 +43,22 @@ export function TreeChevron({ expanded }: { expanded: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme: Theme) => ({
|
export const workspaceTreeRowStyles = StyleSheet.create((theme: Theme) => ({
|
||||||
indentGuide: {
|
row: {
|
||||||
position: "absolute",
|
flexDirection: "row",
|
||||||
top: 0,
|
alignItems: "center",
|
||||||
bottom: 0,
|
justifyContent: "space-between",
|
||||||
width: 1,
|
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||||
backgroundColor: theme.colors.surface2,
|
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: {
|
chevron: {
|
||||||
width: WORKSPACE_TREE_ICON_SIZE,
|
width: WORKSPACE_TREE_ICON_SIZE,
|
||||||
height: WORKSPACE_TREE_ICON_SIZE,
|
height: WORKSPACE_TREE_ICON_SIZE,
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import { useSharedValue, type SharedValue } from "react-native-reanimated";
|
|||||||
interface HorizontalScrollContextValue {
|
interface HorizontalScrollContextValue {
|
||||||
// Shared value indicating if any registered horizontal scroll is scrolled (offset > 0)
|
// Shared value indicating if any registered horizontal scroll is scrolled (offset > 0)
|
||||||
isAnyScrolledRight: SharedValue<boolean>;
|
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
|
// Register a scroll view's offset - returns an unregister function
|
||||||
registerScrollOffset: (id: string, offset: number) => void;
|
registerScrollOffset: (id: string, offset: number) => void;
|
||||||
unregisterScrollOffset: (id: string) => void;
|
unregisterScrollOffset: (id: string) => void;
|
||||||
@@ -13,8 +19,20 @@ const HorizontalScrollContext = createContext<HorizontalScrollContextValue | nul
|
|||||||
|
|
||||||
export function HorizontalScrollProvider({ children }: { children: ReactNode }) {
|
export function HorizontalScrollProvider({ children }: { children: ReactNode }) {
|
||||||
const isAnyScrolledRight = useSharedValue(false);
|
const isAnyScrolledRight = useSharedValue(false);
|
||||||
|
const activeGestureStartedScrolled = useSharedValue(false);
|
||||||
const scrollOffsetsRef = useRef<Map<string, number>>(new Map());
|
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(() => {
|
const updateIsAnyScrolled = useCallback(() => {
|
||||||
let anyScrolled = false;
|
let anyScrolled = false;
|
||||||
for (const offset of scrollOffsetsRef.current.values()) {
|
for (const offset of scrollOffsetsRef.current.values()) {
|
||||||
@@ -44,11 +62,21 @@ export function HorizontalScrollProvider({ children }: { children: ReactNode })
|
|||||||
|
|
||||||
const contextValue = useMemo(
|
const contextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
activeGestureStartedScrolled,
|
||||||
|
beginHorizontalGesture,
|
||||||
|
endHorizontalGesture,
|
||||||
isAnyScrolledRight,
|
isAnyScrolledRight,
|
||||||
registerScrollOffset,
|
registerScrollOffset,
|
||||||
unregisterScrollOffset,
|
unregisterScrollOffset,
|
||||||
}),
|
}),
|
||||||
[isAnyScrolledRight, registerScrollOffset, unregisterScrollOffset],
|
[
|
||||||
|
activeGestureStartedScrolled,
|
||||||
|
beginHorizontalGesture,
|
||||||
|
endHorizontalGesture,
|
||||||
|
isAnyScrolledRight,
|
||||||
|
registerScrollOffset,
|
||||||
|
unregisterScrollOffset,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,19 +3,23 @@ import { FileHeader } from "@/git/file-header";
|
|||||||
import type { DiffFileSection } from "./types";
|
import type { DiffFileSection } from "./types";
|
||||||
import type { DiffDocumentProps } 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({
|
export const DocumentFileHeader = memo(function DocumentFileHeader({
|
||||||
file,
|
file,
|
||||||
selectedPath,
|
selectedPath,
|
||||||
mode,
|
mode,
|
||||||
onToggleFile,
|
onToggleFile,
|
||||||
onSelectPath,
|
onSelectPath,
|
||||||
}: {
|
showTopBorder,
|
||||||
file: DiffFileSection;
|
}: DocumentFileHeaderProps) {
|
||||||
selectedPath: string | null;
|
|
||||||
mode: DiffDocumentProps["mode"];
|
|
||||||
onToggleFile: (path: string) => void;
|
|
||||||
onSelectPath: (path: string) => void;
|
|
||||||
}) {
|
|
||||||
const activate = useCallback(
|
const activate = useCallback(
|
||||||
(path: string) => {
|
(path: string) => {
|
||||||
if (mode.kind !== "working") return;
|
if (mode.kind !== "working") return;
|
||||||
@@ -31,6 +35,7 @@ export const DocumentFileHeader = memo(function DocumentFileHeader({
|
|||||||
bodyVisible={!file.isCollapsed}
|
bodyVisible={!file.isCollapsed}
|
||||||
isSelected={selectedPath === file.path}
|
isSelected={selectedPath === file.path}
|
||||||
interactive={mode.kind === "working"}
|
interactive={mode.kind === "working"}
|
||||||
|
showTopBorder={showTopBorder}
|
||||||
workspaceFileDragScope={working?.workspaceFileDragScope}
|
workspaceFileDragScope={working?.workspaceFileDragScope}
|
||||||
onActivate={activate}
|
onActivate={activate}
|
||||||
onSelect={onSelectPath}
|
onSelect={onSelectPath}
|
||||||
@@ -46,4 +51,41 @@ export const DocumentFileHeader = memo(function DocumentFileHeader({
|
|||||||
testID={`diff-file-${file.fileIndex}`}
|
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 { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
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 { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
|
||||||
import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures";
|
import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures";
|
||||||
import { horizontalOffsetForPath, type DiffHorizontalOffsets } from "./horizontal-offsets";
|
import { horizontalOffsetForPath, type DiffHorizontalOffsets } from "./horizontal-offsets";
|
||||||
import type { DiffFileSection } from "./types";
|
import type { DiffFileSection } from "./types";
|
||||||
|
|
||||||
|
const AnimatedHorizontalScrollView = createAnimatedComponent(ScrollView);
|
||||||
|
|
||||||
export function HorizontalScroll({
|
export function HorizontalScroll({
|
||||||
file,
|
file,
|
||||||
offsets,
|
offsets,
|
||||||
@@ -48,16 +54,33 @@ export function HorizontalScroll({
|
|||||||
},
|
},
|
||||||
[horizontalScroll, scrollId],
|
[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(
|
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],
|
[file.bodyHeight],
|
||||||
);
|
);
|
||||||
const spacerStyle = useMemo(
|
const spacerStyle = useMemo(
|
||||||
() => ({ width: file.contentWidth, height: file.bodyHeight }),
|
() => ({
|
||||||
|
width: file.contentWidth,
|
||||||
|
height: file.bodyHeight,
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
}),
|
||||||
[file.bodyHeight, file.contentWidth],
|
[file.bodyHeight, file.contentWidth],
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<AnimatedHorizontalScrollView
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
horizontal
|
horizontal
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
@@ -66,12 +89,15 @@ export function HorizontalScroll({
|
|||||||
style={rootStyle}
|
style={rootStyle}
|
||||||
contentContainerStyle={spacerStyle}
|
contentContainerStyle={spacerStyle}
|
||||||
onScroll={handler}
|
onScroll={handler}
|
||||||
|
onTouchStart={beginHorizontalGesture}
|
||||||
|
onTouchEnd={endHorizontalGesture}
|
||||||
|
onTouchCancel={endHorizontalGesture}
|
||||||
onScrollEndDrag={reportOffset}
|
onScrollEndDrag={reportOffset}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
|
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
|
||||||
onMomentumScrollEnd={reportOffset}
|
onMomentumScrollEnd={reportOffset}
|
||||||
>
|
>
|
||||||
<View style={spacerStyle} />
|
<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 { withUnistyles } from "react-native-unistyles";
|
||||||
import { createDiffPalette } from "./palette";
|
import { createDiffPalette, retainDiffPalette } from "./palette";
|
||||||
import { DiffSurface } from "./surface";
|
import { DiffSurface } from "./surface";
|
||||||
import type { DiffDocumentProps, DiffPalette } from "./types";
|
import type { DiffDocumentProps, DiffPalette } from "./types";
|
||||||
|
|
||||||
@@ -14,6 +14,9 @@ const EMPTY_PATHS: string[] = [];
|
|||||||
|
|
||||||
function ThemedDiffDocument(props: ThemedDiffDocumentProps) {
|
function ThemedDiffDocument(props: ThemedDiffDocumentProps) {
|
||||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
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 collapseState = props.mode.kind === "working" ? props.collapseState : null;
|
||||||
const paths = collapseState?.paths ?? EMPTY_PATHS;
|
const paths = collapseState?.paths ?? EMPTY_PATHS;
|
||||||
const collapsedFilePaths = useMemo(() => new Set(paths), [paths]);
|
const collapsedFilePaths = useMemo(() => new Set(paths), [paths]);
|
||||||
@@ -30,6 +33,7 @@ function ThemedDiffDocument(props: ThemedDiffDocumentProps) {
|
|||||||
return (
|
return (
|
||||||
<DiffSurface
|
<DiffSurface
|
||||||
{...props}
|
{...props}
|
||||||
|
palette={palette}
|
||||||
collapsedFilePaths={collapsedFilePaths}
|
collapsedFilePaths={collapsedFilePaths}
|
||||||
onToggleFile={toggleFile}
|
onToggleFile={toggleFile}
|
||||||
selectedPath={selectedPath}
|
selectedPath={selectedPath}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
buildDiffDocumentModel,
|
buildDiffDocumentModel,
|
||||||
captureScrollAnchor,
|
captureScrollAnchor,
|
||||||
|
FILE_HEADER_HEIGHT,
|
||||||
graphemeBoundaries,
|
graphemeBoundaries,
|
||||||
measureFragments,
|
measureFragments,
|
||||||
resolveScrollAnchor,
|
resolveScrollAnchor,
|
||||||
|
resolveRelayoutScrollTop,
|
||||||
} from "./model";
|
} from "./model";
|
||||||
import type { BuildDiffDocumentModelInput, TextMeasurer } from "./types";
|
import type { BuildDiffDocumentModelInput, TextMeasurer } from "./types";
|
||||||
|
|
||||||
@@ -63,6 +65,24 @@ function input(overrides: Partial<BuildDiffDocumentModelInput> = {}): BuildDiffD
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("diff document model", () => {
|
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", () => {
|
it("measures grapheme-safe wrapped fragments", () => {
|
||||||
const text = "ab👨👩👧👦cd";
|
const text = "ab👨👩👧👦cd";
|
||||||
const fragments = measureFragments({
|
const fragments = measureFragments({
|
||||||
@@ -161,6 +181,58 @@ describe("diff document model", () => {
|
|||||||
expect(collapsed.height).toBeLessThan(expanded.height);
|
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", () => {
|
it("reserves review geometry in the measured row", () => {
|
||||||
const model = buildDiffDocumentModel(
|
const model = buildDiffDocumentModel(
|
||||||
input({
|
input({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||||
|
import { findClusterBreak } from "@marijn/find-cluster-break";
|
||||||
import {
|
import {
|
||||||
buildSplitDiffRows,
|
buildSplitDiffRows,
|
||||||
buildUnifiedDiffLines,
|
buildUnifiedDiffLines,
|
||||||
@@ -22,7 +23,7 @@ import type {
|
|||||||
TextMeasurer,
|
TextMeasurer,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const FILE_HEADER_HEIGHT = 44;
|
export const FILE_HEADER_HEIGHT = 30;
|
||||||
const BODY_BORDER_HEIGHT = 1;
|
const BODY_BORDER_HEIGHT = 1;
|
||||||
const CODE_HORIZONTAL_PADDING = 16;
|
const CODE_HORIZONTAL_PADDING = 16;
|
||||||
|
|
||||||
@@ -49,7 +50,28 @@ export function buildDiffDocumentModel(input: BuildDiffDocumentModelInput): Diff
|
|||||||
const gutterWidth = lineNumberGutterWidth(maximumLineNumber(file), input.typography.size);
|
const gutterWidth = lineNumberGutterWidth(maximumLineNumber(file), input.typography.size);
|
||||||
let maximumHorizontalOverflow = 0;
|
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") {
|
if (file.status === "binary" || file.status === "too_large") {
|
||||||
const height = input.typography.lineHeight + 24;
|
const height = input.typography.lineHeight + 24;
|
||||||
rows.push({
|
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(
|
function lineSources(
|
||||||
file: BuildDiffDocumentModelInput["files"][number],
|
file: BuildDiffDocumentModelInput["files"][number],
|
||||||
layout: "unified" | "split",
|
layout: "unified" | "split",
|
||||||
@@ -302,17 +334,19 @@ const TAB_SIZE = 4;
|
|||||||
function segmentGraphemes(text: string): Array<Omit<DiffGrapheme, "width">> {
|
function segmentGraphemes(text: string): Array<Omit<DiffGrapheme, "width">> {
|
||||||
const segments: Array<Omit<DiffGrapheme, "width">> = [];
|
const segments: Array<Omit<DiffGrapheme, "width">> = [];
|
||||||
let column = 0;
|
let column = 0;
|
||||||
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
for (let index = 0; index < text.length; ) {
|
||||||
for (const segment of segmenter.segment(text)) {
|
const end = findClusterBreak(text, index);
|
||||||
const isTab = segment.segment === "\t";
|
const segment = text.slice(index, end);
|
||||||
|
const isTab = segment === "\t";
|
||||||
const spaces = isTab ? TAB_SIZE - (column % TAB_SIZE) : 0;
|
const spaces = isTab ? TAB_SIZE - (column % TAB_SIZE) : 0;
|
||||||
const displayText = isTab ? " ".repeat(spaces) : segment.segment;
|
const displayText = isTab ? " ".repeat(spaces) : segment;
|
||||||
segments.push({
|
segments.push({
|
||||||
start: segment.index,
|
start: index,
|
||||||
end: segment.index + segment.segment.length,
|
end,
|
||||||
text: displayText,
|
text: displayText,
|
||||||
});
|
});
|
||||||
column += isTab ? spaces : 1;
|
column += isTab ? spaces : 1;
|
||||||
|
index = end;
|
||||||
}
|
}
|
||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
@@ -351,6 +385,7 @@ function measurePrefixAdvances(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function fragmentTextForRange(fragment: DiffFragment, start: number, end: number): string {
|
export function fragmentTextForRange(fragment: DiffFragment, start: number, end: number): string {
|
||||||
|
"worklet";
|
||||||
return fragment.graphemes
|
return fragment.graphemes
|
||||||
.filter((grapheme) => grapheme.start >= start && grapheme.end <= end)
|
.filter((grapheme) => grapheme.start >= start && grapheme.end <= end)
|
||||||
.map((grapheme) => grapheme.text)
|
.map((grapheme) => grapheme.text)
|
||||||
@@ -365,10 +400,9 @@ export function fragmentWidthForRange(fragment: DiffFragment, start: number, end
|
|||||||
|
|
||||||
export function graphemeBoundaries(text: string): number[] {
|
export function graphemeBoundaries(text: string): number[] {
|
||||||
const boundaries = [0];
|
const boundaries = [0];
|
||||||
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
for (let index = 0; index < text.length; ) {
|
||||||
for (const segment of segmenter.segment(text)) {
|
index = findClusterBreak(text, index);
|
||||||
const end = segment.index + segment.segment.length;
|
boundaries.push(index);
|
||||||
if (end > boundaries[boundaries.length - 1]) boundaries.push(end);
|
|
||||||
}
|
}
|
||||||
return boundaries;
|
return boundaries;
|
||||||
}
|
}
|
||||||
@@ -501,6 +535,30 @@ export function resolveScrollAnchor(model: DiffDocumentModel, anchor: DiffScroll
|
|||||||
return Math.max(0, row.top + (fragment?.top ?? 0) - anchor.viewportDelta);
|
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 {
|
function sameSourceIdentity(left: DiffSourceIdentity, right: DiffSourceIdentity): boolean {
|
||||||
return (
|
return (
|
||||||
left.hunkIndex === right.hunkIndex &&
|
left.hunkIndex === right.hunkIndex &&
|
||||||
|
|||||||
@@ -1,24 +1,21 @@
|
|||||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { buildDiffDocumentModel } from "./model";
|
import { buildDiffDocumentModel } from "./model";
|
||||||
import { hitTestDiffPagePoint } from "./native-hit-testing";
|
import { hitTestDiffBodyPoint } from "./native-hit-testing";
|
||||||
import type { BuildDiffDocumentModelInput } from "./types";
|
import type { BuildDiffDocumentModelInput } from "./types";
|
||||||
|
|
||||||
const origin = { x: 80, y: 120 };
|
describe("native diff body-coordinate hit testing", () => {
|
||||||
|
it("resolves first and later files from their body-local coordinates", () => {
|
||||||
describe("native diff page-coordinate hit testing", () => {
|
|
||||||
it("resolves first and later files through the measured surface origin and vertical scroll", () => {
|
|
||||||
const model = build("unified");
|
const model = build("unified");
|
||||||
const rows = model.rows.filter((row) => row.kind === "line" && row.cells[0]?.type === "add");
|
const rows = model.rows.filter((row) => row.kind === "line" && row.cells[0]?.type === "add");
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const scrollTop = Math.max(0, row.top - 30);
|
const file = model.files[row.fileIndex]!;
|
||||||
const hit = hitTestDiffPagePoint({
|
const hit = hitTestDiffBodyPoint({
|
||||||
model,
|
model,
|
||||||
pageX: origin.x + model.files[row.fileIndex]!.gutterWidth + 18,
|
file,
|
||||||
pageY: origin.y + row.top - scrollTop + 2,
|
locationX: file.gutterWidth + 18,
|
||||||
surfaceOrigin: origin,
|
locationY: row.top - file.bodyTop + 2,
|
||||||
scrollTop,
|
horizontalOffset: 0,
|
||||||
horizontalOffsetForPath: () => 0,
|
|
||||||
});
|
});
|
||||||
expect(hit?.kind).toBe("cell");
|
expect(hit?.kind).toBe("cell");
|
||||||
if (hit?.kind === "cell") expect(hit.position.fileIndex).toBe(row.fileIndex);
|
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.path === "src/second.ts" &&
|
||||||
candidate.cells[0]?.type === "add",
|
candidate.cells[0]?.type === "add",
|
||||||
)!;
|
)!;
|
||||||
const hit = hitTestDiffPagePoint({
|
const file = model.files[1]!;
|
||||||
|
const hit = hitTestDiffBodyPoint({
|
||||||
model,
|
model,
|
||||||
pageX: origin.x + model.files[1]!.gutterWidth + 8,
|
file,
|
||||||
pageY: origin.y + row.top + 2,
|
locationX: file.gutterWidth + 8,
|
||||||
surfaceOrigin: origin,
|
locationY: row.top - file.bodyTop + 2,
|
||||||
scrollTop: 0,
|
horizontalOffset: 30,
|
||||||
horizontalOffsetForPath: (path) => (path === "src/second.ts" ? 30 : 0),
|
|
||||||
});
|
});
|
||||||
expect(hit?.kind === "cell" ? hit.position.sourceOffset : -1).toBe(3);
|
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 model = build("split");
|
||||||
const row = model.rows.find(
|
const row = model.rows.find(
|
||||||
(candidate) =>
|
(candidate) =>
|
||||||
@@ -58,13 +55,13 @@ describe("native diff page-coordinate hit testing", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function hitAt(model: ReturnType<typeof buildDiffDocumentModel>, rowTop: number, x: number) {
|
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,
|
model,
|
||||||
pageX: origin.x + x,
|
file,
|
||||||
pageY: origin.y + rowTop + 2,
|
locationX: x,
|
||||||
surfaceOrigin: origin,
|
locationY: rowTop - file.bodyTop + 2,
|
||||||
scrollTop: 0,
|
horizontalOffset: 0,
|
||||||
horizontalOffsetForPath: () => 0,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,17 @@
|
|||||||
import { hitTestDiffDocument } from "./hit-testing";
|
import { hitTestDiffDocument } from "./hit-testing";
|
||||||
import type { DiffDocumentModel, DiffHit } from "./types";
|
import type { DiffDocumentModel, DiffFileSection, DiffHit } from "./types";
|
||||||
|
|
||||||
export interface SurfaceWindowOrigin {
|
export function hitTestDiffBodyPoint(input: {
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hitTestDiffPagePoint(input: {
|
|
||||||
model: DiffDocumentModel;
|
model: DiffDocumentModel;
|
||||||
pageX: number;
|
file: DiffFileSection;
|
||||||
pageY: number;
|
locationX: number;
|
||||||
surfaceOrigin: SurfaceWindowOrigin;
|
locationY: number;
|
||||||
scrollTop: number;
|
horizontalOffset: number;
|
||||||
horizontalOffsetForPath: (path: string) => number;
|
|
||||||
}): DiffHit | null {
|
}): 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({
|
return hitTestDiffDocument({
|
||||||
model: input.model,
|
model: input.model,
|
||||||
x,
|
x: input.locationX,
|
||||||
documentY,
|
documentY: input.file.bodyTop + input.locationY,
|
||||||
horizontalOffset: file ? input.horizontalOffsetForPath(file.path) : 0,
|
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 { visibleRowRange } from "./model";
|
||||||
|
import { nativeTextRuns } from "./native-text-runs";
|
||||||
import { horizontalOffsetForPath, type DiffHorizontalOffsets } from "./horizontal-offsets";
|
import { horizontalOffsetForPath, type DiffHorizontalOffsets } from "./horizontal-offsets";
|
||||||
import { codeLineNumberTone } from "./palette";
|
import { codeLineNumberTone } from "./palette";
|
||||||
import { reviewBackgroundPaint, reviewDividerHeight, reviewGapTop } from "./review-paint";
|
import { reviewBackgroundPaint, reviewDividerHeight, reviewGapTop } from "./review-paint";
|
||||||
import type { NativeTextLayout } from "./text.native";
|
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;
|
const CODE_LEFT_PADDING = 8;
|
||||||
|
|
||||||
interface NativePaints {
|
export interface NativePaints {
|
||||||
surface: SkPaint;
|
surface: SkPaint;
|
||||||
border: SkPaint;
|
border: SkPaint;
|
||||||
|
foreground: SkPaint;
|
||||||
foregroundMuted: SkPaint;
|
foregroundMuted: SkPaint;
|
||||||
addition: SkPaint;
|
addition: SkPaint;
|
||||||
deletion: SkPaint;
|
deletion: SkPaint;
|
||||||
@@ -18,13 +32,14 @@ interface NativePaints {
|
|||||||
deletionBackground: SkPaint;
|
deletionBackground: SkPaint;
|
||||||
headerSurface: SkPaint;
|
headerSurface: SkPaint;
|
||||||
emptyBackground: SkPaint;
|
emptyBackground: SkPaint;
|
||||||
syntax: Record<string, SkPaint>;
|
text: Record<string, SkPaint>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createNativePaints(palette: DiffPalette): NativePaints {
|
export function createNativePaints(palette: DiffPalette): NativePaints {
|
||||||
return {
|
return {
|
||||||
surface: paint(palette.surface),
|
surface: paint(palette.surface),
|
||||||
border: paint(palette.border),
|
border: paint(palette.border),
|
||||||
|
foreground: paint(palette.foreground),
|
||||||
foregroundMuted: paint(palette.foregroundMuted),
|
foregroundMuted: paint(palette.foregroundMuted),
|
||||||
addition: paint(palette.addition),
|
addition: paint(palette.addition),
|
||||||
deletion: paint(palette.deletion),
|
deletion: paint(palette.deletion),
|
||||||
@@ -32,7 +47,11 @@ export function createNativePaints(palette: DiffPalette): NativePaints {
|
|||||||
deletionBackground: paint(palette.deletionBackground),
|
deletionBackground: paint(palette.deletionBackground),
|
||||||
headerSurface: paint(palette.headerSurface),
|
headerSurface: paint(palette.headerSurface),
|
||||||
emptyBackground: paint(palette.emptyBackground),
|
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;
|
textLayout: NativeTextLayout;
|
||||||
paints: NativePaints;
|
paints: NativePaints;
|
||||||
}): void {
|
}): void {
|
||||||
"worklet";
|
paintNativeRange({ ...input, layer: "all" });
|
||||||
input.canvas.drawRect(
|
}
|
||||||
Skia.XYWHRect(0, 0, input.viewportWidth, input.viewportHeight),
|
|
||||||
input.paints.surface,
|
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);
|
const range = visibleRowRange(input.model.rows, input.scrollTop, input.viewportHeight);
|
||||||
for (let index = range.start; index < range.end; index += 1) {
|
for (let index = range.start; index < range.end; index += 1) {
|
||||||
const row = input.model.rows[index];
|
const row = input.model.rows[index];
|
||||||
if (!row) continue;
|
if (!row) continue;
|
||||||
const y = row.top - input.scrollTop;
|
const y = row.top - input.scrollTop;
|
||||||
if (row.kind === "status") {
|
if (row.kind === "status") {
|
||||||
input.canvas.drawText(
|
if (paintsFixedContent) {
|
||||||
row.label,
|
input.canvas.drawText(
|
||||||
12,
|
row.label,
|
||||||
y + input.model.lineHeight,
|
12,
|
||||||
input.paints.foregroundMuted,
|
y + input.model.lineHeight,
|
||||||
input.textLayout.font,
|
input.paints.foregroundMuted,
|
||||||
);
|
input.textLayout.font,
|
||||||
|
);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
paintLine({
|
const file = input.model.files[row.fileIndex];
|
||||||
...input,
|
if (!file) continue;
|
||||||
row,
|
const horizontalOffset = horizontalOffsetForPath(input.horizontalOffsets, row.path);
|
||||||
y,
|
const columnWidth = input.viewportWidth / row.cells.length;
|
||||||
horizontalOffset: horizontalOffsetForPath(input.horizontalOffsets, row.path),
|
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: {
|
function paintsNativeContentCell(
|
||||||
canvas: SkCanvas;
|
input: PaintNativeRangeInput,
|
||||||
model: DiffDocumentModel;
|
columnCount: number,
|
||||||
viewportWidth: 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;
|
row: DiffLineRow;
|
||||||
|
file: DiffFileSection;
|
||||||
|
cell: DiffCell | null;
|
||||||
|
columnX: number;
|
||||||
|
columnWidth: number;
|
||||||
y: number;
|
y: number;
|
||||||
horizontalOffset: number;
|
backgroundPaints: Record<DiffCell["type"], SkPaint>;
|
||||||
textLayout: NativeTextLayout;
|
|
||||||
paints: NativePaints;
|
|
||||||
}): void {
|
}): void {
|
||||||
"worklet";
|
const backgroundPaint = input.cell
|
||||||
const file = input.model.files[input.row.fileIndex];
|
? input.backgroundPaints[input.cell.type]
|
||||||
if (!file) return;
|
: input.input.paints.emptyBackground;
|
||||||
const columnWidth = input.viewportWidth / input.row.cells.length;
|
input.input.canvas.drawRect(
|
||||||
input.row.cells.forEach((cell, cellIndex) => {
|
Skia.XYWHRect(input.columnX, input.y, input.columnWidth, input.row.height),
|
||||||
const columnX = cellIndex * columnWidth;
|
backgroundPaint,
|
||||||
input.canvas.drawRect(
|
);
|
||||||
Skia.XYWHRect(columnX, input.y, columnWidth, input.row.height),
|
input.input.canvas.drawRect(
|
||||||
background(cell, input.paints),
|
Skia.XYWHRect(
|
||||||
);
|
input.columnX,
|
||||||
if (input.row.reviewHeight > 0) {
|
reviewGapTop(input.y, input.row.height, input.row.reviewHeight),
|
||||||
input.canvas.drawRect(
|
input.columnWidth,
|
||||||
Skia.XYWHRect(
|
input.row.reviewHeight,
|
||||||
columnX,
|
),
|
||||||
reviewGapTop(input.y, input.row.height, input.row.reviewHeight),
|
reviewBackgroundPaint(input.input.paints.surface),
|
||||||
columnWidth,
|
);
|
||||||
input.row.reviewHeight,
|
if (!input.cell) return;
|
||||||
),
|
input.input.canvas.drawRect(
|
||||||
reviewBackgroundPaint(input.paints.surface),
|
Skia.XYWHRect(
|
||||||
);
|
input.columnX + input.file.gutterWidth,
|
||||||
}
|
input.y,
|
||||||
if (!cell) return;
|
1,
|
||||||
input.canvas.drawRect(
|
reviewDividerHeight(input.row.height),
|
||||||
Skia.XYWHRect(columnX + file.gutterWidth, input.y, 1, reviewDividerHeight(input.row.height)),
|
),
|
||||||
input.paints.border,
|
input.input.paints.border,
|
||||||
);
|
);
|
||||||
if (cell.lineNumber !== null) {
|
const label = String(input.cell.lineNumber ?? "");
|
||||||
const label = String(cell.lineNumber);
|
const font = input.input.textLayout.font;
|
||||||
const font = input.textLayout.font;
|
input.input.canvas.drawText(
|
||||||
input.canvas.drawText(
|
label,
|
||||||
label,
|
input.columnX + input.file.gutterWidth - 7 - font.getTextWidth(label),
|
||||||
columnX + file.gutterWidth - 7 - font.getTextWidth(label),
|
input.y + input.input.model.lineHeight * 0.78,
|
||||||
input.y + input.model.lineHeight * 0.78,
|
input.input.paints[codeLineNumberTone(input.cell)],
|
||||||
lineNumberPaint(cell, input.paints),
|
font,
|
||||||
font,
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
const textX = columnX + file.gutterWidth + CODE_LEFT_PADDING;
|
function paintNativeCodeCell(input: {
|
||||||
input.canvas.save();
|
input: PaintNativeRangeInput;
|
||||||
input.canvas.clipRect(
|
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(
|
Skia.XYWHRect(
|
||||||
textX,
|
textX,
|
||||||
input.y,
|
input.y,
|
||||||
columnWidth - file.gutterWidth - CODE_LEFT_PADDING,
|
input.columnWidth - input.file.gutterWidth - CODE_LEFT_PADDING,
|
||||||
input.row.height,
|
input.row.height,
|
||||||
),
|
),
|
||||||
ClipOp.Intersect,
|
ClipOp.Intersect,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
const offset = input.model.wrapLines ? 0 : input.horizontalOffset;
|
}
|
||||||
for (const [fragmentIndex, fragment] of cell.fragments.entries()) {
|
const offset = input.input.model.wrapLines ? 0 : input.horizontalOffset;
|
||||||
const paragraph = input.textLayout.paragraphs[input.row.index]?.[cellIndex]?.[fragmentIndex];
|
for (const [fragmentIndex, fragment] of input.cell.fragments.entries()) {
|
||||||
paragraph?.paint(input.canvas, textX - offset, input.y + fragment.top);
|
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();
|
const fragmentX = textX - offset;
|
||||||
});
|
const baseline = input.y + fragment.baseline;
|
||||||
}
|
if (input.cell.tokens.length === 0 || input.cell.type === "header") {
|
||||||
|
input.input.canvas.drawText(
|
||||||
function background(cell: DiffCell | null, paints: NativePaints): SkPaint {
|
fragment.text,
|
||||||
"worklet";
|
fragmentX,
|
||||||
if (!cell) return paints.emptyBackground;
|
baseline,
|
||||||
if (cell.type === "add") return paints.additionBackground;
|
input.cell.type === "header"
|
||||||
if (cell.type === "remove") return paints.deletionBackground;
|
? input.input.paints.foregroundMuted
|
||||||
if (cell.type === "header") return paints.headerSurface;
|
: input.input.paints.foreground,
|
||||||
return paints.surface;
|
input.input.textLayout.font,
|
||||||
}
|
);
|
||||||
|
continue;
|
||||||
function lineNumberPaint(cell: DiffCell, paints: NativePaints): SkPaint {
|
}
|
||||||
"worklet";
|
for (const run of nativeTextRuns(input.cell, fragment)) {
|
||||||
return paints[codeLineNumberTone(cell)];
|
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>;
|
horizontalOffsets: ReadonlyMap<string, number>;
|
||||||
selection: DiffSelection | null;
|
selection: DiffSelection | null;
|
||||||
devicePixelRatio: number;
|
devicePixelRatio: number;
|
||||||
|
paintTop?: number;
|
||||||
|
paintHeight?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function paintWebViewport(input: PaintWebViewportInput): void {
|
export function paintWebViewport(input: PaintWebViewportInput): void {
|
||||||
const { context } = input;
|
const { context } = input;
|
||||||
const scale = input.devicePixelRatio;
|
const scale = input.devicePixelRatio;
|
||||||
|
const paintTop = input.paintTop ?? 0;
|
||||||
|
const paintHeight = input.paintHeight ?? input.viewportHeight;
|
||||||
context.setTransform(scale, 0, 0, scale, 0, 0);
|
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.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.font = `${input.typography.size}px ${input.typography.family}`;
|
||||||
context.textBaseline = "alphabetic";
|
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) {
|
for (let index = range.start; index < range.end; index += 1) {
|
||||||
const row = input.model.rows[index];
|
const row = input.model.rows[index];
|
||||||
if (!row) continue;
|
if (!row) continue;
|
||||||
@@ -57,6 +65,7 @@ export function paintWebViewport(input: PaintWebViewportInput): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (input.selection) paintSelection(input, input.selection);
|
if (input.selection) paintSelection(input, input.selection);
|
||||||
|
context.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
function paintLine(
|
function paintLine(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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";
|
import type { DiffCell, DiffPalette } from "./types";
|
||||||
|
|
||||||
describe("diff text color", () => {
|
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 = {
|
const palette: DiffPalette = {
|
||||||
surface: "surface",
|
surface: "surface",
|
||||||
headerSurface: "header",
|
headerSurface: "header",
|
||||||
|
|||||||
@@ -1,23 +1,50 @@
|
|||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
|
import { hexColorWithAlpha } from "@/utils/color";
|
||||||
import type { DiffCell, DiffPalette } from "./types";
|
import type { DiffCell, DiffPalette } from "./types";
|
||||||
|
|
||||||
export function createDiffPalette(theme: Theme): DiffPalette {
|
export function createDiffPalette(theme: Theme): DiffPalette {
|
||||||
return {
|
return {
|
||||||
surface: theme.colors.surface1,
|
surface: theme.colors.surface0,
|
||||||
headerSurface: theme.colors.surface2,
|
headerSurface: theme.colors.surface0,
|
||||||
border: theme.colors.border,
|
border: theme.colors.border,
|
||||||
foreground: theme.colors.foreground,
|
foreground: theme.colors.foreground,
|
||||||
foregroundMuted: theme.colors.foregroundMuted,
|
foregroundMuted: theme.colors.foregroundMuted,
|
||||||
addition: theme.colors.diffAddition,
|
addition: theme.colors.statusSuccess,
|
||||||
deletion: theme.colors.diffDeletion,
|
deletion: theme.colors.statusDanger,
|
||||||
additionBackground: "rgba(46, 160, 67, 0.15)",
|
additionBackground: hexColorWithAlpha(theme.colors.statusSuccess, 0.15),
|
||||||
deletionBackground: "rgba(248, 81, 73, 0.1)",
|
deletionBackground: hexColorWithAlpha(theme.colors.statusDanger, 0.1),
|
||||||
emptyBackground: theme.colors.surfaceDiffEmpty,
|
emptyBackground: theme.colors.surface0,
|
||||||
selection: theme.colors.surfaceSidebarHover,
|
selection: theme.colors.terminal.blue,
|
||||||
syntax: theme.colors.syntax,
|
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 {
|
export function codeTextColor(cell: DiffCell, palette: DiffPalette): string {
|
||||||
return cell.type === "header" ? palette.foregroundMuted : palette.foreground;
|
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 { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 {
|
import {
|
||||||
createAnimatedComponent,
|
createAnimatedComponent,
|
||||||
|
useAnimatedReaction,
|
||||||
useAnimatedScrollHandler,
|
useAnimatedScrollHandler,
|
||||||
useDerivedValue,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
|
type SharedValue,
|
||||||
} from "react-native-reanimated";
|
} 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 { 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 { HorizontalScroll } from "./horizontal-scroll.native";
|
||||||
import {
|
import {
|
||||||
horizontalOffsetForPath,
|
horizontalOffsetForPath,
|
||||||
retainHorizontalOffsetsForPaths,
|
retainHorizontalOffsetsForPaths,
|
||||||
type DiffHorizontalOffsets,
|
type DiffHorizontalOffsets,
|
||||||
} from "./horizontal-offsets";
|
} from "./horizontal-offsets";
|
||||||
import { buildDiffDocumentModel, captureScrollAnchor, resolveScrollAnchor } from "./model";
|
import { buildDiffDocumentModel, resolveRelayoutScrollTop, retainReusableModels } from "./model";
|
||||||
import { createNativePaints, paintNativeViewport } from "./paint.native";
|
import {
|
||||||
import { createNativeTextLayout, createNativeTextMeasurer } from "./text.native";
|
buildNativeCanvasSlabs,
|
||||||
import { InlineReviewThread } from "@/review";
|
nativeCanvasSlabsForViewport,
|
||||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
nativeCanvasWindowBucket,
|
||||||
import type { DiffSurfaceProps, DiffTypography, TextMeasurer } from "./types";
|
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 AnimatedScrollView = createAnimatedComponent(ScrollView);
|
||||||
|
const AnimatedCodeLayer = createAnimatedComponent(View);
|
||||||
const SYSTEM_MONO = "monospace";
|
const SYSTEM_MONO = "monospace";
|
||||||
|
const CODE_LEFT_PADDING = 8;
|
||||||
|
|
||||||
export function DiffSurface(props: DiffSurfaceProps) {
|
export function DiffSurface(props: DiffSurfaceProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [viewport, setViewport] = useState({ width: 0, height: 0 });
|
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 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 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 consumedFocusRef = useRef<string | null>(null);
|
||||||
const scrollTop = useSharedValue(0);
|
const scrollTop = useSharedValue(0);
|
||||||
const horizontalOffsets = useSharedValue<DiffHorizontalOffsets>({});
|
const horizontalOffsets = useSharedValue<DiffHorizontalOffsets>({});
|
||||||
@@ -51,81 +84,89 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
[family, typography.size],
|
[family, typography.size],
|
||||||
);
|
);
|
||||||
const reviewActions = props.mode.kind === "working" ? props.mode.reviewActions : undefined;
|
const reviewActions = props.mode.kind === "working" ? props.mode.reviewActions : undefined;
|
||||||
const model = useMemo(
|
const model = useMemo(() => {
|
||||||
() =>
|
const dependencies = [
|
||||||
buildDiffDocumentModel({
|
props.files,
|
||||||
files: props.files,
|
|
||||||
collapsedFilePaths: props.collapsedFilePaths,
|
|
||||||
layout: props.displayPreferences.layout,
|
|
||||||
wrapLines: props.displayPreferences.wrapLines,
|
|
||||||
viewportWidth: viewport.width,
|
|
||||||
typography,
|
|
||||||
measureText: measurement,
|
|
||||||
palette: props.palette,
|
|
||||||
reviewActions,
|
|
||||||
labels: {
|
|
||||||
binary: t("workspace.git.diff.binaryFile"),
|
|
||||||
tooLarge: t("workspace.git.diff.tooLarge"),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
measurement,
|
|
||||||
props.collapsedFilePaths,
|
|
||||||
props.displayPreferences.layout,
|
props.displayPreferences.layout,
|
||||||
props.displayPreferences.wrapLines,
|
props.displayPreferences.wrapLines,
|
||||||
props.files,
|
viewport.width,
|
||||||
|
typography,
|
||||||
|
measurement,
|
||||||
props.palette,
|
props.palette,
|
||||||
reviewActions,
|
reviewActions,
|
||||||
t,
|
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,
|
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 paints = useMemo(() => createNativePaints(props.palette), [props.palette]);
|
||||||
const textLayout = useMemo(
|
const textLayoutStore = useMemo(
|
||||||
() =>
|
() =>
|
||||||
createNativeTextLayout({
|
createNativeTextLayoutStore({
|
||||||
model,
|
|
||||||
configuredFamily: family,
|
configuredFamily: family,
|
||||||
fontSize: typography.size,
|
fontSize: typography.size,
|
||||||
|
lineHeight: typography.lineHeight,
|
||||||
palette: props.palette,
|
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) => {
|
const scrollHandler = useAnimatedScrollHandler((event) => {
|
||||||
scrollTop.value = event.contentOffset.y;
|
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 layout = useCallback((event: LayoutChangeEvent) => {
|
||||||
const { width, height } = event.nativeEvent.layout;
|
const { width, height } = event.nativeEvent.layout;
|
||||||
setViewport({ width, height });
|
setViewport((current) =>
|
||||||
rootRef.current?.measureInWindow((x, y) => {
|
current.width === width && current.height === height ? current : { width, height },
|
||||||
surfaceOriginRef.current = { x, y };
|
);
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const previous = previousModelRef.current;
|
const previous = previousModelRef.current;
|
||||||
if (previous && previous !== model) {
|
if (previous && previous !== model) {
|
||||||
const anchor = captureScrollAnchor(previous, scrollTop.value);
|
const nextScrollTop = resolveRelayoutScrollTop(previous, model, scrollTop.value);
|
||||||
if (anchor) {
|
scrollTop.value = nextScrollTop;
|
||||||
const nextScrollTop = resolveScrollAnchor(model, anchor);
|
scrollRef.current?.scrollTo({ y: nextScrollTop, animated: false });
|
||||||
scrollTop.value = nextScrollTop;
|
|
||||||
scrollRef.current?.scrollTo({ y: nextScrollTop, animated: false });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
previousModelRef.current = model;
|
previousModelRef.current = model;
|
||||||
}, [model, scrollTop]);
|
}, [model, scrollTop]);
|
||||||
@@ -135,6 +176,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
model.files.map((file) => file.path),
|
model.files.map((file) => file.path),
|
||||||
);
|
);
|
||||||
}, [horizontalOffsets, model.files]);
|
}, [horizontalOffsets, model.files]);
|
||||||
|
|
||||||
const mode = props.mode;
|
const mode = props.mode;
|
||||||
const collapsedFilePaths = props.collapsedFilePaths;
|
const collapsedFilePaths = props.collapsedFilePaths;
|
||||||
const onToggleFile = props.onToggleFile;
|
const onToggleFile = props.onToggleFile;
|
||||||
@@ -147,50 +189,22 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
if (collapsedFilePaths.has(focusPath)) onToggleFile(focusPath);
|
if (collapsedFilePaths.has(focusPath)) onToggleFile(focusPath);
|
||||||
const file = model.files.find((entry) => entry.path === focusPath);
|
const file = model.files.find((entry) => entry.path === focusPath);
|
||||||
if (file) {
|
if (file) {
|
||||||
|
scrollTop.value = file.top;
|
||||||
scrollRef.current?.scrollTo({ y: file.top, animated: false });
|
scrollRef.current?.scrollTo({ y: file.top, animated: false });
|
||||||
consumedFocusRef.current = requestKey;
|
consumedFocusRef.current = requestKey;
|
||||||
}
|
}
|
||||||
}, [collapsedFilePaths, mode, model.files, onToggleFile]);
|
}, [collapsedFilePaths, mode, model.files, onToggleFile, scrollTop]);
|
||||||
const touchStart = useCallback((event: { nativeEvent: { pageX: number; pageY: number } }) => {
|
const contentStyle = useMemo(
|
||||||
touchRef.current = {
|
() => ({ minHeight: viewport.height, backgroundColor: "transparent" }),
|
||||||
x: event.nativeEvent.pageX,
|
[viewport.height],
|
||||||
y: event.nativeEvent.pageY,
|
|
||||||
startedAt: Date.now(),
|
|
||||||
moved: false,
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
const touchMove = useCallback((event: { nativeEvent: { pageX: number; pageY: number } }) => {
|
|
||||||
const touch = touchRef.current;
|
|
||||||
if (
|
|
||||||
touch &&
|
|
||||||
Math.hypot(event.nativeEvent.pageX - touch.x, event.nativeEvent.pageY - touch.y) > 10
|
|
||||||
)
|
|
||||||
touch.moved = true;
|
|
||||||
}, []);
|
|
||||||
const touchEnd = useCallback(
|
|
||||||
(event: { nativeEvent: { pageX: number; pageY: number } }) => {
|
|
||||||
const touch = touchRef.current;
|
|
||||||
touchRef.current = null;
|
|
||||||
if (!touch || touch.moved || Date.now() - touch.startedAt > 500 || !reviewActions) return;
|
|
||||||
const hit = hitTestDiffPagePoint({
|
|
||||||
model,
|
|
||||||
pageX: event.nativeEvent.pageX,
|
|
||||||
pageY: event.nativeEvent.pageY,
|
|
||||||
surfaceOrigin: surfaceOriginRef.current,
|
|
||||||
scrollTop: scrollTop.value,
|
|
||||||
horizontalOffsetForPath: (path) => horizontalOffsetForPath(horizontalOffsets.value, path),
|
|
||||||
});
|
|
||||||
if (hit?.kind === "cell" && hit.target) reviewActions.onStartComment(hit.target);
|
|
||||||
},
|
|
||||||
[horizontalOffsets, model, reviewActions, scrollTop],
|
|
||||||
);
|
);
|
||||||
const contentStyle = useMemo(() => ({ minHeight: viewport.height }), [viewport.height]);
|
const stickyHeaderIndices = useMemo(
|
||||||
|
() => nativeStickyHeaderIndices(model.files.length),
|
||||||
|
[model.files.length],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View style={[styles.root, { backgroundColor: props.palette.surface }]} onLayout={layout}>
|
||||||
ref={rootRef}
|
|
||||||
style={[styles.root, { backgroundColor: props.palette.surface }]}
|
|
||||||
onLayout={layout}
|
|
||||||
>
|
|
||||||
<AnimatedScrollView
|
<AnimatedScrollView
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
style={[StyleSheet.absoluteFill, styles.scroll]}
|
style={[StyleSheet.absoluteFill, styles.scroll]}
|
||||||
@@ -198,90 +212,372 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
onScroll={scrollHandler}
|
onScroll={scrollHandler}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
showsVerticalScrollIndicator
|
showsVerticalScrollIndicator
|
||||||
stickyHeaderIndices={model.files.map((_, index) => index * 2)}
|
stickyHeaderIndices={stickyHeaderIndices}
|
||||||
testID="git-diff-scroll"
|
testID="git-diff-scroll"
|
||||||
onTouchStart={touchStart}
|
|
||||||
onTouchMove={touchMove}
|
|
||||||
onTouchEnd={touchEnd}
|
|
||||||
>
|
>
|
||||||
{model.files.flatMap((file) => [
|
<NativeCanvasSlabLayer
|
||||||
<View key={`${file.path}:header`} style={styles.header}>
|
model={model}
|
||||||
<DocumentFileHeader
|
viewportHeight={viewport.height}
|
||||||
file={file}
|
scrollTop={scrollTop}
|
||||||
selectedPath={props.selectedPath}
|
textLayout={textLayout}
|
||||||
mode={props.mode}
|
paints={paints}
|
||||||
onToggleFile={props.onToggleFile}
|
horizontalOffsets={horizontalOffsets}
|
||||||
onSelectPath={props.onSelectPath}
|
/>
|
||||||
/>
|
{model.files.flatMap((file, index) => [
|
||||||
</View>,
|
<NativeStickyFileHeader
|
||||||
<View
|
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`}
|
key={`${file.path}:body`}
|
||||||
testID={`diff-file-${file.fileIndex}-body`}
|
file={file}
|
||||||
style={inlineUnistylesStyle<ViewStyle>({
|
model={model}
|
||||||
position: "relative",
|
mode={props.mode}
|
||||||
height: file.bodyHeight,
|
horizontalOffsets={horizontalOffsets}
|
||||||
})}
|
/>,
|
||||||
>
|
|
||||||
{!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>,
|
|
||||||
])}
|
])}
|
||||||
|
<NativeReviewOverlays model={model} mode={props.mode} />
|
||||||
</AnimatedScrollView>
|
</AnimatedScrollView>
|
||||||
<View
|
</View>
|
||||||
pointerEvents="none"
|
);
|
||||||
style={[StyleSheet.absoluteFill, styles.canvas]}
|
}
|
||||||
testID="git-diff-canvas"
|
|
||||||
>
|
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}>
|
<Canvas style={StyleSheet.absoluteFill}>
|
||||||
<Picture picture={picture} />
|
<Picture picture={picture} />
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</View>
|
</AnimatedCodeLayer>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
root: { flex: 1, minHeight: 0, position: "relative", overflow: "hidden" },
|
root: { flex: 1, minHeight: 0, position: "relative", overflow: "hidden" },
|
||||||
canvas: { zIndex: 1 },
|
scroll: { backgroundColor: "transparent" },
|
||||||
scroll: { zIndex: 2 },
|
header: { zIndex: 5 },
|
||||||
header: { zIndex: 3 },
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 type { ReviewableDiffTarget } from "@/utils/diff-layout";
|
||||||
import { DocumentFileHeader } from "./document-file-header";
|
import { DocumentFileHeader } from "./document-file-header";
|
||||||
import { hitTestDiffDocument, selectedSourceText } from "./hit-testing";
|
import { hitTestDiffDocument, selectedSourceText } from "./hit-testing";
|
||||||
import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets";
|
import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets";
|
||||||
import { HorizontalScroll } from "./horizontal-scroll.web";
|
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 { paintWebViewport } from "./paint.web";
|
||||||
import { hasPointerDragStarted } from "./pointer-gesture";
|
import { hasPointerDragStarted } from "./pointer-gesture";
|
||||||
import type {
|
import type {
|
||||||
@@ -23,9 +30,14 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const canvasScratchRef = useRef<HTMLCanvasElement>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const modelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
|
const modelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
|
||||||
const previousModelRef = 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 consumedFocusRef = useRef<string | null>(null);
|
||||||
const scrollTopRef = useRef(0);
|
const scrollTopRef = useRef(0);
|
||||||
const horizontalOffsetsRef = useRef(new Map<string, number>());
|
const horizontalOffsetsRef = useRef(new Map<string, number>());
|
||||||
@@ -37,6 +49,8 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
moved: boolean;
|
moved: boolean;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const frameRef = useRef<number | 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 [viewport, setViewport] = useState({ width: 0, height: 0 });
|
||||||
const [loadedTypography, setLoadedTypography] = useState<DiffTypography | null>(null);
|
const [loadedTypography, setLoadedTypography] = useState<DiffTypography | null>(null);
|
||||||
const [hoveredAffordance, setHoveredAffordance] = useState<{
|
const [hoveredAffordance, setHoveredAffordance] = useState<{
|
||||||
@@ -73,7 +87,23 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
lineHeight: desiredTypography.lineHeight,
|
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,
|
files: props.files,
|
||||||
collapsedFilePaths: props.collapsedFilePaths,
|
collapsedFilePaths: props.collapsedFilePaths,
|
||||||
layout: props.displayPreferences.layout,
|
layout: props.displayPreferences.layout,
|
||||||
@@ -87,7 +117,13 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
binary: t("workspace.git.diff.binaryFile"),
|
binary: t("workspace.git.diff.binaryFile"),
|
||||||
tooLarge: t("workspace.git.diff.tooLarge"),
|
tooLarge: t("workspace.git.diff.tooLarge"),
|
||||||
},
|
},
|
||||||
|
reuseFrom,
|
||||||
});
|
});
|
||||||
|
reusableModelRef.current = {
|
||||||
|
dependencies,
|
||||||
|
models: retainReusableModels(reuseFrom, next),
|
||||||
|
};
|
||||||
|
return next;
|
||||||
}, [
|
}, [
|
||||||
measurement,
|
measurement,
|
||||||
props.collapsedFilePaths,
|
props.collapsedFilePaths,
|
||||||
@@ -108,31 +144,77 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
const currentModel = modelRef.current;
|
const currentModel = modelRef.current;
|
||||||
if (!canvas || !currentModel || viewport.width <= 0 || viewport.height <= 0) return;
|
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 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 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.width !== pixelWidth) canvas.width = pixelWidth;
|
||||||
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
|
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
|
||||||
|
canvas.style.top = `${canvasTop}px`;
|
||||||
|
canvas.style.height = `${canvasHeight}px`;
|
||||||
const context = canvas.getContext("2d");
|
const context = canvas.getContext("2d");
|
||||||
if (!context) return;
|
if (!context) return;
|
||||||
if (!measurement || !loadedTypography) 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({
|
paintWebViewport({
|
||||||
context,
|
context,
|
||||||
model: currentModel,
|
model: currentModel,
|
||||||
palette: props.palette,
|
palette: props.palette,
|
||||||
typography: loadedTypography,
|
typography: loadedTypography,
|
||||||
measureText: measurement,
|
measureText: measurement,
|
||||||
scrollTop: scrollTopRef.current,
|
scrollTop: canvasTop,
|
||||||
viewportWidth: viewport.width,
|
viewportWidth: viewport.width,
|
||||||
viewportHeight: viewport.height,
|
viewportHeight: canvasHeight,
|
||||||
horizontalOffsets: horizontalOffsetsRef.current,
|
horizontalOffsets: horizontalOffsetsRef.current,
|
||||||
selection: selectionRef.current,
|
selection: selectionRef.current,
|
||||||
devicePixelRatio: ratio,
|
devicePixelRatio: ratio,
|
||||||
|
paintTop,
|
||||||
|
paintHeight,
|
||||||
});
|
});
|
||||||
}, [loadedTypography, measurement, props.palette, viewport.height, viewport.width]);
|
}, [loadedTypography, measurement, props.palette, viewport.height, viewport.width]);
|
||||||
const schedulePaint = useCallback(() => {
|
const schedulePaint = useCallback(
|
||||||
if (frameRef.current === null) frameRef.current = requestAnimationFrame(paint);
|
(force = true) => {
|
||||||
}, [paint]);
|
if (force) forcePaintRef.current = true;
|
||||||
|
if (frameRef.current === null) frameRef.current = requestAnimationFrame(paint);
|
||||||
|
},
|
||||||
|
[paint],
|
||||||
|
);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const root = rootRef.current;
|
const root = rootRef.current;
|
||||||
@@ -162,12 +244,9 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
const previous = previousModelRef.current;
|
const previous = previousModelRef.current;
|
||||||
const scroll = scrollRef.current;
|
const scroll = scrollRef.current;
|
||||||
if (previous && scroll && previous !== model) {
|
if (previous && scroll && previous !== model) {
|
||||||
const anchor = captureScrollAnchor(previous, scroll.scrollTop);
|
const nextScrollTop = resolveRelayoutScrollTop(previous, model, scroll.scrollTop);
|
||||||
if (anchor) {
|
scrollTopRef.current = nextScrollTop;
|
||||||
const nextScrollTop = resolveScrollAnchor(model, anchor);
|
scroll.scrollTop = nextScrollTop;
|
||||||
scrollTopRef.current = nextScrollTop;
|
|
||||||
scroll.scrollTop = nextScrollTop;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
previousModelRef.current = model;
|
previousModelRef.current = model;
|
||||||
}, [model]);
|
}, [model]);
|
||||||
@@ -202,16 +281,42 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
}, [collapsedFilePaths, mode, model.files, onToggleFile]);
|
}, [collapsedFilePaths, mode, model.files, onToggleFile]);
|
||||||
|
|
||||||
const handleVerticalScroll = useCallback(
|
const handleVerticalScroll = useCallback(
|
||||||
(event: React.UIEvent<HTMLDivElement>) => {
|
(scrollElement: HTMLDivElement) => {
|
||||||
scrollTopRef.current = event.currentTarget.scrollTop;
|
const scrollTop = scrollElement.scrollTop;
|
||||||
|
scrollTopRef.current = scrollTop;
|
||||||
if (hasHoveredAffordanceRef.current) {
|
if (hasHoveredAffordanceRef.current) {
|
||||||
hasHoveredAffordanceRef.current = false;
|
hasHoveredAffordanceRef.current = false;
|
||||||
setHoveredAffordance(null);
|
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(
|
const handleHorizontalScroll = useCallback(
|
||||||
(path: string, offset: number) => {
|
(path: string, offset: number) => {
|
||||||
horizontalOffsetsRef.current.set(path, offset);
|
horizontalOffsetsRef.current.set(path, offset);
|
||||||
@@ -247,6 +352,12 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
if (selectionRef.current) {
|
||||||
|
selectionRef.current = null;
|
||||||
|
dragRef.current = null;
|
||||||
|
schedulePaint();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const hit = pointHit(event);
|
const hit = pointHit(event);
|
||||||
if (hit?.kind !== "cell") return;
|
if (hit?.kind !== "cell") return;
|
||||||
dragRef.current = {
|
dragRef.current = {
|
||||||
@@ -285,12 +396,12 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
const gutterBorder = sideIndex * columnWidth + file.gutterWidth;
|
const gutterBorder = sideIndex * columnWidth + file.gutterWidth;
|
||||||
const rootBounds = rootRef.current?.getBoundingClientRect();
|
const rootBounds = rootRef.current?.getBoundingClientRect();
|
||||||
const pointerX = rootBounds ? event.clientX - rootBounds.left : Number.NaN;
|
const pointerX = rootBounds ? event.clientX - rootBounds.left : Number.NaN;
|
||||||
if (Math.abs(pointerX - gutterBorder) <= 14) {
|
if (hit.target && Number.isFinite(pointerX)) {
|
||||||
hasHoveredAffordanceRef.current = true;
|
hasHoveredAffordanceRef.current = true;
|
||||||
setHoveredAffordance({
|
setHoveredAffordance({
|
||||||
hit,
|
hit,
|
||||||
left: gutterBorder - 14,
|
left: gutterBorder - 12,
|
||||||
top: row.top - scrollTopRef.current + (modelRef.current!.lineHeight - 28) / 2,
|
top: row.top - scrollTopRef.current + (modelRef.current!.lineHeight - 22) / 2,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
hasHoveredAffordanceRef.current = false;
|
hasHoveredAffordanceRef.current = false;
|
||||||
@@ -351,7 +462,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
() => ({ ...CONTENT_STYLE, height: Math.max(model.height, viewport.height) }),
|
() => ({ ...CONTENT_STYLE, height: Math.max(model.height, viewport.height) }),
|
||||||
[model.height, viewport.height],
|
[model.height, viewport.height],
|
||||||
);
|
);
|
||||||
const affordanceStyle = useMemo<React.CSSProperties>(
|
const affordanceStyle = useMemo<ViewStyle>(
|
||||||
() => ({
|
() => ({
|
||||||
...AFFORDANCE_STYLE,
|
...AFFORDANCE_STYLE,
|
||||||
left: hoveredAffordance?.left ?? 0,
|
left: hoveredAffordance?.left ?? 0,
|
||||||
@@ -377,8 +488,8 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
data-testid="git-diff-scroll"
|
data-testid="git-diff-scroll"
|
||||||
|
data-overlay-scrollbar="true"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onScroll={handleVerticalScroll}
|
|
||||||
onPointerDown={pointerDown}
|
onPointerDown={pointerDown}
|
||||||
onPointerMove={pointerMove}
|
onPointerMove={pointerMove}
|
||||||
onPointerUp={pointerUp}
|
onPointerUp={pointerUp}
|
||||||
@@ -388,10 +499,12 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
style={SCROLL_STYLE}
|
style={SCROLL_STYLE}
|
||||||
>
|
>
|
||||||
<div style={contentStyle} onMouseDown={preventDocumentMouseSelection}>
|
<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}>
|
<WebFileHeaderSection key={file.path} file={file}>
|
||||||
<DocumentFileHeader
|
<DocumentFileHeader
|
||||||
file={file}
|
file={file}
|
||||||
|
showTopBorder={index > 0 && !model.files[index - 1]?.isCollapsed}
|
||||||
selectedPath={props.selectedPath}
|
selectedPath={props.selectedPath}
|
||||||
mode={props.mode}
|
mode={props.mode}
|
||||||
onToggleFile={props.onToggleFile}
|
onToggleFile={props.onToggleFile}
|
||||||
@@ -438,21 +551,68 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
: null}
|
: null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<canvas ref={canvasRef} data-testid="git-diff-canvas" style={canvasStyle} />
|
<DomOverlayScrollbar scrollContainerRef={scrollRef} onUserScrollUp={noop} />
|
||||||
{hoveredAffordance?.hit.target && reviewActions ? (
|
{hoveredAffordance?.hit.target && reviewActions ? (
|
||||||
<button
|
<InlineReviewAddButton onPress={addHoveredComment} style={affordanceStyle} />
|
||||||
type="button"
|
|
||||||
aria-label={t("review.comment.add")}
|
|
||||||
onClick={addHoveredComment}
|
|
||||||
style={affordanceStyle}
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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({
|
function WebFileHeaderSection({
|
||||||
file,
|
file,
|
||||||
children,
|
children,
|
||||||
@@ -532,7 +692,11 @@ const SCROLL_STYLE: React.CSSProperties = {
|
|||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
overflowX: "hidden",
|
overflowX: "hidden",
|
||||||
|
scrollbarWidth: "none",
|
||||||
|
cursor: "text",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function noop(): void {}
|
||||||
const CONTENT_STYLE: React.CSSProperties = {
|
const CONTENT_STYLE: React.CSSProperties = {
|
||||||
position: "relative",
|
position: "relative",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -553,7 +717,7 @@ const STICKY_HEADER_STYLE: React.CSSProperties = {
|
|||||||
};
|
};
|
||||||
const BODY_MARKER_STYLE: React.CSSProperties = {
|
const BODY_MARKER_STYLE: React.CSSProperties = {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 44,
|
top: FILE_HEADER_HEIGHT,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 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 REVIEW_STYLE: React.CSSProperties = { position: "absolute", zIndex: 4, userSelect: "text" };
|
||||||
const CANVAS_STYLE: React.CSSProperties = {
|
const CANVAS_STYLE: React.CSSProperties = {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
inset: 0,
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
};
|
};
|
||||||
const AFFORDANCE_STYLE: React.CSSProperties = {
|
const AFFORDANCE_STYLE: ViewStyle = {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
zIndex: 5,
|
zIndex: 5,
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function emptyDiffDocumentModel(input: {
|
function emptyDiffDocumentModel(input: {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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) => {
|
describe.each(["ios", "android"])("%s native text measurement", (platform) => {
|
||||||
it("routes CJK and emoji missing glyphs through the system fallback shaper", () => {
|
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 {
|
function primaryFaceWithout(...missing: string[]): PrimaryTextFace {
|
||||||
return {
|
return {
|
||||||
glyphIds(text) {
|
glyphIds(text) {
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ export interface PrimaryTextFace {
|
|||||||
measure(text: string): number;
|
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: {
|
export function createFallbackAwareTextMeasurer(input: {
|
||||||
primary: PrimaryTextFace;
|
primary: PrimaryTextFace;
|
||||||
measureWithSystemFallback: (text: string) => number;
|
measureWithSystemFallback: (text: string) => number;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from "@shopify/react-native-skia";
|
} from "@shopify/react-native-skia";
|
||||||
import { fragmentTextForRange } from "./model";
|
import { fragmentTextForRange } from "./model";
|
||||||
import { codeTextColor } from "./palette";
|
import { codeTextColor } from "./palette";
|
||||||
import { createFallbackAwareTextMeasurer } from "./text-measurement";
|
import { createFallbackAwareTextMeasurer, requiresNativeParagraph } from "./text-measurement";
|
||||||
import type { DiffCell, DiffDocumentModel, DiffFragment, DiffPalette, TextMeasurer } from "./types";
|
import type { DiffCell, DiffDocumentModel, DiffFragment, DiffPalette, TextMeasurer } from "./types";
|
||||||
|
|
||||||
const PARAGRAPH_WIDTH = 100_000;
|
const PARAGRAPH_WIDTH = 100_000;
|
||||||
@@ -17,31 +17,69 @@ export interface NativeTextLayout {
|
|||||||
paragraphs: Array<Array<Array<SkParagraph | null>>>;
|
paragraphs: Array<Array<Array<SkParagraph | null>>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createNativeTextLayout(input: {
|
export interface NativeTextLayoutStore {
|
||||||
model: DiffDocumentModel;
|
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;
|
configuredFamily: string;
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
|
lineHeight: number;
|
||||||
palette: DiffPalette;
|
palette: DiffPalette;
|
||||||
}): NativeTextLayout {
|
}): NativeTextLayoutStore {
|
||||||
const families = nativeFontFamilies(input.configuredFamily);
|
const families = nativeFontFamilies(input.configuredFamily);
|
||||||
const font = primaryFont(families, input.fontSize);
|
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 [];
|
if (row.kind !== "line") return [];
|
||||||
return row.cells.map((cell) => {
|
return row.cells.map((cell) => {
|
||||||
if (!cell) return [];
|
if (!cell) return [];
|
||||||
return cell.fragments.map((fragment) =>
|
const cached = store.paragraphsByCell.get(cell);
|
||||||
createFragmentParagraph({
|
if (cached) return cached;
|
||||||
|
const cellParagraphs = cell.fragments.map((fragment) => {
|
||||||
|
if (!requiresRetainedParagraph(fragment.text, store.font)) return null;
|
||||||
|
const paragraph = createFragmentParagraph({
|
||||||
cell,
|
cell,
|
||||||
fragment,
|
fragment,
|
||||||
families,
|
families: store.families,
|
||||||
fontSize: input.fontSize,
|
fontSize: store.fontSize,
|
||||||
lineHeight: input.model.lineHeight,
|
lineHeight: store.lineHeight,
|
||||||
palette: input.palette,
|
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: {
|
export function createNativeTextMeasurer(input: {
|
||||||
@@ -63,13 +101,22 @@ export function createNativeTextMeasurer(input: {
|
|||||||
lineHeight: Math.round(input.fontSize * 1.5),
|
lineHeight: Math.round(input.fontSize * 1.5),
|
||||||
color: Skia.Color("black"),
|
color: Skia.Color("black"),
|
||||||
});
|
});
|
||||||
return paragraph.getLongestLine();
|
const width = paragraph.getLongestLine();
|
||||||
|
paragraph.dispose();
|
||||||
|
return width;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
...fallbackAware,
|
...fallbackAware,
|
||||||
measureAdvances(graphemes) {
|
measureAdvances(graphemes) {
|
||||||
const text = graphemes.join("");
|
const text = graphemes.join("");
|
||||||
|
if (!requiresRetainedParagraph(text, font)) {
|
||||||
|
let prefix = "";
|
||||||
|
return graphemes.map((grapheme) => {
|
||||||
|
prefix += grapheme;
|
||||||
|
return font.getTextWidth(prefix);
|
||||||
|
});
|
||||||
|
}
|
||||||
const paragraph = createParagraph({
|
const paragraph = createParagraph({
|
||||||
text,
|
text,
|
||||||
families,
|
families,
|
||||||
@@ -78,7 +125,7 @@ export function createNativeTextMeasurer(input: {
|
|||||||
color: Skia.Color("black"),
|
color: Skia.Color("black"),
|
||||||
});
|
});
|
||||||
let end = 0;
|
let end = 0;
|
||||||
return graphemes.map((grapheme) => {
|
const advances = graphemes.map((grapheme) => {
|
||||||
end += grapheme.length;
|
end += grapheme.length;
|
||||||
const rectangles = paragraph.getRectsForRange(0, end);
|
const rectangles = paragraph.getRectsForRange(0, end);
|
||||||
return rectangles.reduce(
|
return rectangles.reduce(
|
||||||
@@ -86,10 +133,16 @@ export function createNativeTextMeasurer(input: {
|
|||||||
0,
|
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: {
|
function createFragmentParagraph(input: {
|
||||||
cell: DiffCell;
|
cell: DiffCell;
|
||||||
fragment: DiffFragment;
|
fragment: DiffFragment;
|
||||||
|
|||||||
@@ -167,6 +167,8 @@ export interface BuildDiffDocumentModelInput {
|
|||||||
palette: DiffPalette;
|
palette: DiffPalette;
|
||||||
reviewActions?: InlineReviewActions;
|
reviewActions?: InlineReviewActions;
|
||||||
labels: { binary: string; tooLarge: string };
|
labels: { binary: string; tooLarge: string };
|
||||||
|
/** A geometry-compatible model whose unchanged file measurements may be reused. */
|
||||||
|
reuseFrom?: readonly DiffDocumentModel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiffCharacterPosition {
|
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 { View, Text, type LayoutChangeEvent, type PressableStateCallbackType } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { DiffStat } from "@/components/diff-stat";
|
import { DiffStat } from "@/components/diff-stat";
|
||||||
import {
|
import {
|
||||||
TreeChevron,
|
TreeChevron,
|
||||||
TreeIndentGuides,
|
|
||||||
treeRowPaddingLeft,
|
treeRowPaddingLeft,
|
||||||
WORKSPACE_FILE_ROW_TRAILING_PADDING,
|
workspaceTreeRowStyles,
|
||||||
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
|
||||||
WORKSPACE_TREE_ICON_LABEL_GAP,
|
WORKSPACE_TREE_ICON_LABEL_GAP,
|
||||||
WORKSPACE_TREE_ICON_SIZE,
|
WORKSPACE_TREE_ICON_SIZE,
|
||||||
} from "@/components/tree-primitives";
|
} from "@/components/tree-primitives";
|
||||||
@@ -43,7 +41,10 @@ function folderRowPressableStyle(
|
|||||||
{ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean },
|
{ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean },
|
||||||
isSelected: boolean,
|
isSelected: boolean,
|
||||||
) {
|
) {
|
||||||
return [styles.folderRow, (Boolean(hovered) || pressed || isSelected) && styles.folderRowActive];
|
return [
|
||||||
|
workspaceTreeRowStyles.row,
|
||||||
|
(Boolean(hovered) || pressed || isSelected) && workspaceTreeRowStyles.active,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DiffFolderRow({
|
export function DiffFolderRow({
|
||||||
@@ -69,6 +70,9 @@ export function DiffFolderRow({
|
|||||||
const handleSelect = useCallback(() => {
|
const handleSelect = useCallback(() => {
|
||||||
onSelect(dirPath);
|
onSelect(dirPath);
|
||||||
}, [dirPath, onSelect]);
|
}, [dirPath, onSelect]);
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const showNameHover = useCallback(() => setIsHovered(true), []);
|
||||||
|
const hideNameHover = useCallback(() => setIsHovered(false), []);
|
||||||
|
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
const selection = isWeb ? window.getSelection() : null;
|
const selection = isWeb ? window.getSelection() : null;
|
||||||
@@ -117,7 +121,12 @@ export function DiffFolderRow({
|
|||||||
}, [dirPath, onRevert]);
|
}, [dirPath, onRevert]);
|
||||||
|
|
||||||
const leftStyle = useMemo(
|
const leftStyle = useMemo(
|
||||||
() => [styles.left, inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) })],
|
() => [
|
||||||
|
styles.left,
|
||||||
|
inlineUnistylesStyle({
|
||||||
|
paddingLeft: treeRowPaddingLeft(depth),
|
||||||
|
}),
|
||||||
|
],
|
||||||
[depth],
|
[depth],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -128,13 +137,14 @@ export function DiffFolderRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container} onLayout={handleLayout} testID={testID}>
|
<View style={styles.container} onLayout={handleLayout} testID={testID}>
|
||||||
<TreeIndentGuides depth={depth} />
|
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger
|
<ContextMenuTrigger
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
onLongPress={handleSelect}
|
onLongPress={handleSelect}
|
||||||
onContextMenu={handleSelect}
|
onContextMenu={handleSelect}
|
||||||
style={pressableStyle}
|
style={pressableStyle}
|
||||||
|
onHoverIn={showNameHover}
|
||||||
|
onHoverOut={hideNameHover}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityState={accessibilityState}
|
accessibilityState={accessibilityState}
|
||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
@@ -144,7 +154,15 @@ export function DiffFolderRow({
|
|||||||
<View style={styles.chevronSlot}>
|
<View style={styles.chevronSlot}>
|
||||||
<TreeChevron expanded={!collapsed} />
|
<TreeChevron expanded={!collapsed} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.folderName} numberOfLines={1}>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.folderName,
|
||||||
|
workspaceTreeRowStyles.name,
|
||||||
|
isHovered && workspaceTreeRowStyles.nameHovered,
|
||||||
|
]}
|
||||||
|
numberOfLines={1}
|
||||||
|
testID={testID ? `${testID}-name` : undefined}
|
||||||
|
>
|
||||||
{displayName}
|
{displayName}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -176,17 +194,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
|||||||
container: {
|
container: {
|
||||||
overflow: "hidden",
|
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: {
|
left: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ describe("file header interaction arbitration", () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
native: true,
|
native: true,
|
||||||
pressHandled: false,
|
pressHandled: false,
|
||||||
layoutY: 0,
|
stickyPressFallback: true,
|
||||||
pressOrigin,
|
pressOrigin,
|
||||||
releasedAt: 1_500,
|
releasedAt: 1_500,
|
||||||
pageX: 32,
|
pageX: 32,
|
||||||
@@ -27,7 +27,7 @@ describe("file header interaction arbitration", () => {
|
|||||||
it.each([
|
it.each([
|
||||||
["an already handled long press", { pressHandled: true }],
|
["an already handled long press", { pressHandled: true }],
|
||||||
["a non-native release", { native: false }],
|
["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 held press", { releasedAt: 1_501 }],
|
||||||
["a moved press", { pageX: 33 }],
|
["a moved press", { pageX: 33 }],
|
||||||
])("does not activate %s", (_label, override) => {
|
])("does not activate %s", (_label, override) => {
|
||||||
@@ -36,7 +36,7 @@ describe("file header interaction arbitration", () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
native: true,
|
native: true,
|
||||||
pressHandled: false,
|
pressHandled: false,
|
||||||
layoutY: 0,
|
stickyPressFallback: true,
|
||||||
pressOrigin,
|
pressOrigin,
|
||||||
releasedAt: 1_500,
|
releasedAt: 1_500,
|
||||||
pageX: 32,
|
pageX: 32,
|
||||||
@@ -68,6 +68,17 @@ describe("file header interaction arbitration", () => {
|
|||||||
).toEqual(["defer-activate", "activate"]);
|
).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", () => {
|
it("falls back once for a canceled sticky press", () => {
|
||||||
expect(
|
expect(
|
||||||
run([
|
run([
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface FileHeaderInteractionInput {
|
|||||||
onSelect?: (path: string) => void;
|
onSelect?: (path: string) => void;
|
||||||
onActivate?: (path: string) => void;
|
onActivate?: (path: string) => void;
|
||||||
onLayout?: (height: number) => void;
|
onLayout?: (height: number) => void;
|
||||||
|
stickyPressFallback?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PressPointEvent {
|
interface PressPointEvent {
|
||||||
@@ -27,14 +28,14 @@ interface StickyPressReleaseInput {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
native: boolean;
|
native: boolean;
|
||||||
pressHandled: boolean;
|
pressHandled: boolean;
|
||||||
layoutY: number | null;
|
stickyPressFallback: boolean;
|
||||||
pressOrigin: PressOrigin | null;
|
pressOrigin: PressOrigin | null;
|
||||||
releasedAt: number;
|
releasedAt: number;
|
||||||
pageX: number;
|
pageX: number;
|
||||||
pageY: number;
|
pageY: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileHeaderInteraction {
|
export interface FileHeaderInteraction {
|
||||||
activate: () => void;
|
activate: () => void;
|
||||||
select: () => void;
|
select: () => void;
|
||||||
onLayout: (event: LayoutChangeEvent) => 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 === "press-in") return { phase: "pressed", effect: "none" };
|
||||||
if (event.type === "long-press") return { phase: "long", effect: "select" };
|
if (event.type === "long-press") return { phase: "long", effect: "select" };
|
||||||
if (event.type === "press-out") {
|
if (event.type === "press-out") {
|
||||||
|
if (phase === "released" || phase === "handled" || phase === "long") {
|
||||||
|
return { phase, effect: "none" };
|
||||||
|
}
|
||||||
return event.stickyFallbackEligible && phase === "pressed"
|
return event.stickyFallbackEligible && phase === "pressed"
|
||||||
? { phase: "released", effect: "defer-activate" }
|
? { phase: "released", effect: "defer-activate" }
|
||||||
: { phase: phase === "long" ? "long" : "idle", effect: "none" };
|
: { phase: "idle", effect: "none" };
|
||||||
}
|
}
|
||||||
if (event.type === "press") {
|
if (event.type === "press") {
|
||||||
return phase === "long" || phase === "handled"
|
return phase === "long" || phase === "handled"
|
||||||
@@ -78,7 +82,7 @@ export function shouldActivateStickyHeaderPress(input: StickyPressReleaseInput):
|
|||||||
!input.enabled ||
|
!input.enabled ||
|
||||||
!input.native ||
|
!input.native ||
|
||||||
input.pressHandled ||
|
input.pressHandled ||
|
||||||
input.layoutY !== 0 ||
|
!input.stickyPressFallback ||
|
||||||
!input.pressOrigin
|
!input.pressOrigin
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
@@ -92,8 +96,14 @@ export function shouldActivateStickyHeaderPress(input: StickyPressReleaseInput):
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useFileHeaderInteraction(input: FileHeaderInteractionInput): FileHeaderInteraction {
|
export function useFileHeaderInteraction(input: FileHeaderInteractionInput): FileHeaderInteraction {
|
||||||
const { path, enabled, onSelect, onActivate, onLayout: notifyLayout } = input;
|
const {
|
||||||
const layoutYRef = useRef<number | null>(null);
|
path,
|
||||||
|
enabled,
|
||||||
|
onSelect,
|
||||||
|
onActivate,
|
||||||
|
onLayout: notifyLayout,
|
||||||
|
stickyPressFallback = false,
|
||||||
|
} = input;
|
||||||
const pressHandledRef = useRef(false);
|
const pressHandledRef = useRef(false);
|
||||||
const pressOriginRef = useRef<PressOrigin | null>(null);
|
const pressOriginRef = useRef<PressOrigin | null>(null);
|
||||||
const phaseRef = useRef<FileHeaderPressPhase>("idle");
|
const phaseRef = useRef<FileHeaderPressPhase>("idle");
|
||||||
@@ -123,7 +133,6 @@ export function useFileHeaderInteraction(input: FileHeaderInteractionInput): Fil
|
|||||||
|
|
||||||
const onLayout = useCallback(
|
const onLayout = useCallback(
|
||||||
(event: LayoutChangeEvent) => {
|
(event: LayoutChangeEvent) => {
|
||||||
layoutYRef.current = event.nativeEvent.layout.y;
|
|
||||||
notifyLayout?.(event.nativeEvent.layout.height);
|
notifyLayout?.(event.nativeEvent.layout.height);
|
||||||
},
|
},
|
||||||
[notifyLayout],
|
[notifyLayout],
|
||||||
@@ -157,7 +166,7 @@ export function useFileHeaderInteraction(input: FileHeaderInteractionInput): Fil
|
|||||||
enabled,
|
enabled,
|
||||||
native: isNative,
|
native: isNative,
|
||||||
pressHandled: pressHandledRef.current,
|
pressHandled: pressHandledRef.current,
|
||||||
layoutY: layoutYRef.current,
|
stickyPressFallback,
|
||||||
pressOrigin: pressOriginRef.current,
|
pressOrigin: pressOriginRef.current,
|
||||||
releasedAt: Date.now(),
|
releasedAt: Date.now(),
|
||||||
pageX: event.nativeEvent.pageX,
|
pageX: event.nativeEvent.pageX,
|
||||||
@@ -182,7 +191,7 @@ export function useFileHeaderInteraction(input: FileHeaderInteractionInput): Fil
|
|||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[clearFallback, enabled, onActivate, path, select],
|
[clearFallback, enabled, onActivate, path, select, stickyPressFallback],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { activate, select, onLayout, onPressIn, onPressOut, onLongPress };
|
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 { Text, View, type PressableStateCallbackType } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
|
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 { FileChangeIcon } from "@/components/file-change-icon";
|
||||||
import { MaterialFileIcon } from "@/components/material-file-icon";
|
import { MaterialFileIcon } from "@/components/material-file-icon";
|
||||||
import {
|
import {
|
||||||
TreeIndentGuides,
|
|
||||||
treeRowPaddingLeft,
|
treeRowPaddingLeft,
|
||||||
|
workspaceTreeRowStyles,
|
||||||
WORKSPACE_FILE_ROW_TRAILING_PADDING,
|
WORKSPACE_FILE_ROW_TRAILING_PADDING,
|
||||||
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||||
WORKSPACE_TREE_ICON_LABEL_GAP,
|
WORKSPACE_TREE_ICON_LABEL_GAP,
|
||||||
@@ -29,6 +29,7 @@ export interface FileHeaderProps {
|
|||||||
depth?: number;
|
depth?: number;
|
||||||
showDir?: boolean;
|
showDir?: boolean;
|
||||||
interactive?: boolean;
|
interactive?: boolean;
|
||||||
|
showTopBorder?: boolean;
|
||||||
onActivate?: (path: string) => void;
|
onActivate?: (path: string) => void;
|
||||||
onSelect?: (path: string) => void;
|
onSelect?: (path: string) => void;
|
||||||
onOpenFile?: (path: string) => void;
|
onOpenFile?: (path: string) => void;
|
||||||
@@ -67,6 +68,73 @@ function expandedAriaValue(showsBodyState: boolean, bodyVisible: boolean): boole
|
|||||||
return showsBodyState ? bodyVisible : undefined;
|
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({
|
function FileHeaderMenu({
|
||||||
file,
|
file,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
@@ -121,12 +189,14 @@ export const FileHeader = memo(function FileHeader({
|
|||||||
depth = 0,
|
depth = 0,
|
||||||
showDir = true,
|
showDir = true,
|
||||||
interactive = true,
|
interactive = true,
|
||||||
|
showTopBorder = true,
|
||||||
onActivate,
|
onActivate,
|
||||||
onSelect,
|
onSelect,
|
||||||
onHeaderHeightChange,
|
onHeaderHeightChange,
|
||||||
testID,
|
testID,
|
||||||
...actions
|
...actions
|
||||||
}: FileHeaderProps) {
|
}: FileHeaderProps) {
|
||||||
|
const hover = useFileHeaderHover(interactive);
|
||||||
const dragSourceRef = useWorkspaceFileDragSource({
|
const dragSourceRef = useWorkspaceFileDragSource({
|
||||||
enabled: interactive,
|
enabled: interactive,
|
||||||
disabled: file.isDeleted,
|
disabled: file.isDeleted,
|
||||||
@@ -139,25 +209,40 @@ export const FileHeader = memo(function FileHeader({
|
|||||||
enabled: interactive,
|
enabled: interactive,
|
||||||
onSelect,
|
onSelect,
|
||||||
onActivate,
|
onActivate,
|
||||||
|
stickyPressFallback: showsBodyState,
|
||||||
onLayout: useCallback(
|
onLayout: useCallback(
|
||||||
(height: number) => onHeaderHeightChange?.(file.path, height),
|
(height: number) => onHeaderHeightChange?.(file.path, height),
|
||||||
[file.path, onHeaderHeightChange],
|
[file.path, onHeaderHeightChange],
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
const pressableStyle = useCallback(
|
const pressableStyle = useCallback(
|
||||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
({ pressed }: PressableStateCallbackType) => [
|
||||||
styles.header,
|
styles.header,
|
||||||
|
!showsBodyState && workspaceTreeRowStyles.row,
|
||||||
showsBodyState && styles.documentHeader,
|
showsBodyState && styles.documentHeader,
|
||||||
depth > 0 ? inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) }) : null,
|
depth > 0
|
||||||
(Boolean(hovered) || pressed || isSelected) && styles.active,
|
? inlineUnistylesStyle({
|
||||||
|
paddingLeft: treeRowPaddingLeft(depth),
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
fileHeaderInteractionStyle({
|
||||||
|
isHovered: hover.isHovered,
|
||||||
|
pressed,
|
||||||
|
showsBodyState,
|
||||||
|
isSelected,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
[depth, isSelected, showsBodyState],
|
[depth, hover.isHovered, isSelected, showsBodyState],
|
||||||
);
|
);
|
||||||
const accessibilityState = useMemo(
|
const accessibilityState = useMemo(
|
||||||
() => fileHeaderAccessibilityState({ showsBodyState, bodyVisible, isSelected }),
|
() => fileHeaderAccessibilityState({ showsBodyState, bodyVisible, isSelected }),
|
||||||
[bodyVisible, isSelected, showsBodyState],
|
[bodyVisible, isSelected, showsBodyState],
|
||||||
);
|
);
|
||||||
const fileName = fileNameForPath(file.path);
|
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 = (
|
const content = (
|
||||||
<View
|
<View
|
||||||
style={[styles.content, showsBodyState && styles.documentContent]}
|
style={[styles.content, showsBodyState && styles.documentContent]}
|
||||||
@@ -169,7 +254,7 @@ export const FileHeader = memo(function FileHeader({
|
|||||||
<MaterialFileIcon fileName={fileName} size={WORKSPACE_TREE_ICON_SIZE} />
|
<MaterialFileIcon fileName={fileName} size={WORKSPACE_TREE_ICON_SIZE} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<Text style={styles.name} numberOfLines={1} testID={testID ? `${testID}-name` : undefined}>
|
<Text style={nameStyle} numberOfLines={1} testID={testID ? `${testID}-name` : undefined}>
|
||||||
{fileName}
|
{fileName}
|
||||||
</Text>
|
</Text>
|
||||||
{showDir ? (
|
{showDir ? (
|
||||||
@@ -179,15 +264,15 @@ export const FileHeader = memo(function FileHeader({
|
|||||||
) : (
|
) : (
|
||||||
<View style={styles.directorySpacer} />
|
<View style={styles.directorySpacer} />
|
||||||
)}
|
)}
|
||||||
{file.isNew ? <FileChangeIcon change="added" /> : null}
|
{documentChangeIcon}
|
||||||
{file.isDeleted ? <FileChangeIcon change="deleted" /> : null}
|
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.right}>
|
<View style={rightStyle}>
|
||||||
<DiffStat
|
<DiffStat
|
||||||
additions={file.additions}
|
additions={file.additions}
|
||||||
deletions={file.deletions}
|
deletions={file.deletions}
|
||||||
testID={testID ? `${testID}-stat` : undefined}
|
testID={testID ? `${testID}-stat` : undefined}
|
||||||
/>
|
/>
|
||||||
|
{treeChangeIcon}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -197,6 +282,7 @@ export const FileHeader = memo(function FileHeader({
|
|||||||
<ContextMenuTrigger
|
<ContextMenuTrigger
|
||||||
testID={testID ? `${testID}-toggle` : undefined}
|
testID={testID ? `${testID}-toggle` : undefined}
|
||||||
style={pressableStyle}
|
style={pressableStyle}
|
||||||
|
highlightStyle={fileHeaderPressFeedbackStyle(showsBodyState)}
|
||||||
cancelable={false}
|
cancelable={false}
|
||||||
onPressIn={interaction.onPressIn}
|
onPressIn={interaction.onPressIn}
|
||||||
onPressOut={interaction.onPressOut}
|
onPressOut={interaction.onPressOut}
|
||||||
@@ -211,19 +297,16 @@ export const FileHeader = memo(function FileHeader({
|
|||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
trigger = <View style={pressableStyle({ hovered: false, pressed: false })}>{content}</View>;
|
trigger = <View style={pressableStyle({ pressed: false })}>{content}</View>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[styles.container, fileHeaderContainerStyle(showsBodyState, showTopBorder)]}
|
||||||
styles.container,
|
|
||||||
showsBodyState && styles.documentContainer,
|
|
||||||
bodyVisible && styles.expanded,
|
|
||||||
]}
|
|
||||||
onLayout={interaction.onLayout}
|
onLayout={interaction.onLayout}
|
||||||
|
onPointerEnter={hover.handlePointerEnter}
|
||||||
|
onPointerLeave={hover.handlePointerLeave}
|
||||||
testID={testID}
|
testID={testID}
|
||||||
>
|
>
|
||||||
<TreeIndentGuides depth={depth} />
|
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
|
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
|
||||||
@@ -240,14 +323,16 @@ export const FileHeader = memo(function FileHeader({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: { overflow: "hidden", userSelect: "none" },
|
container: { width: "100%", overflow: "hidden", userSelect: "none" },
|
||||||
documentContainer: {
|
documentContainer: {
|
||||||
height: 44,
|
height: 30,
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface0,
|
||||||
|
borderTopWidth: theme.borderWidth[1],
|
||||||
|
borderTopColor: theme.colors.borderAccent,
|
||||||
borderBottomWidth: theme.borderWidth[1],
|
borderBottomWidth: theme.borderWidth[1],
|
||||||
borderBottomColor: theme.colors.border,
|
borderBottomColor: theme.colors.borderAccent,
|
||||||
},
|
},
|
||||||
expanded: { backgroundColor: theme.colors.surface1 },
|
documentContainerWithoutTopBorder: { borderTopColor: "transparent" },
|
||||||
header: {
|
header: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -260,7 +345,12 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
elevation: 2,
|
elevation: 2,
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
},
|
},
|
||||||
documentHeader: { height: 43, paddingHorizontal: 0, paddingVertical: 0 },
|
documentHeader: {
|
||||||
|
height: 28,
|
||||||
|
paddingLeft: 0,
|
||||||
|
paddingRight: 0,
|
||||||
|
paddingVertical: 0,
|
||||||
|
},
|
||||||
content: {
|
content: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
@@ -269,8 +359,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
},
|
},
|
||||||
documentContent: { height: 43, paddingHorizontal: theme.spacing[3] },
|
documentContent: { height: 28, paddingHorizontal: theme.spacing[3] },
|
||||||
active: { backgroundColor: theme.colors.surfaceSidebarHover },
|
documentActive: { backgroundColor: theme.colors.surface1 },
|
||||||
|
documentPressFeedback: { backgroundColor: theme.colors.surface1 },
|
||||||
left: {
|
left: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -287,6 +378,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
},
|
},
|
||||||
|
treeRight: { gap: theme.spacing[2] },
|
||||||
icon: {
|
icon: {
|
||||||
width: WORKSPACE_TREE_ICON_SIZE,
|
width: WORKSPACE_TREE_ICON_SIZE,
|
||||||
height: WORKSPACE_TREE_ICON_SIZE,
|
height: WORKSPACE_TREE_ICON_SIZE,
|
||||||
|
|||||||
@@ -879,6 +879,7 @@ export const ar: TranslationResources = {
|
|||||||
base: "قاعدة",
|
base: "قاعدة",
|
||||||
newFile: "جديد",
|
newFile: "جديد",
|
||||||
deletedFile: "تم الحذف",
|
deletedFile: "تم الحذف",
|
||||||
|
modifiedFile: "معدّل",
|
||||||
commits: {
|
commits: {
|
||||||
title: "الإيداعات",
|
title: "الإيداعات",
|
||||||
countLabel: "{{count}} من إيداعات مساحة العمل",
|
countLabel: "{{count}} من إيداعات مساحة العمل",
|
||||||
|
|||||||
@@ -888,6 +888,7 @@ export const en = {
|
|||||||
base: "base",
|
base: "base",
|
||||||
newFile: "New",
|
newFile: "New",
|
||||||
deletedFile: "Deleted",
|
deletedFile: "Deleted",
|
||||||
|
modifiedFile: "Modified",
|
||||||
commits: {
|
commits: {
|
||||||
title: "Commits",
|
title: "Commits",
|
||||||
countLabel: "{{count}} workspace commits",
|
countLabel: "{{count}} workspace commits",
|
||||||
|
|||||||
@@ -910,6 +910,7 @@ export const es: TranslationResources = {
|
|||||||
base: "base",
|
base: "base",
|
||||||
newFile: "Nuevo",
|
newFile: "Nuevo",
|
||||||
deletedFile: "Eliminado",
|
deletedFile: "Eliminado",
|
||||||
|
modifiedFile: "Modificado",
|
||||||
commits: {
|
commits: {
|
||||||
title: "Commits",
|
title: "Commits",
|
||||||
countLabel: "{{count}} commits del espacio de trabajo",
|
countLabel: "{{count}} commits del espacio de trabajo",
|
||||||
|
|||||||
@@ -909,6 +909,7 @@ export const fr: TranslationResources = {
|
|||||||
base: "base",
|
base: "base",
|
||||||
newFile: "Nouveau",
|
newFile: "Nouveau",
|
||||||
deletedFile: "Supprimé",
|
deletedFile: "Supprimé",
|
||||||
|
modifiedFile: "Modifié",
|
||||||
commits: {
|
commits: {
|
||||||
title: "Commits",
|
title: "Commits",
|
||||||
countLabel: "{{count}} commits de l’espace de travail",
|
countLabel: "{{count}} commits de l’espace de travail",
|
||||||
|
|||||||
@@ -890,6 +890,7 @@ export const ja: TranslationResources = {
|
|||||||
base: "ベース",
|
base: "ベース",
|
||||||
newFile: "新規",
|
newFile: "新規",
|
||||||
deletedFile: "削除済み",
|
deletedFile: "削除済み",
|
||||||
|
modifiedFile: "変更済み",
|
||||||
commits: {
|
commits: {
|
||||||
title: "コミット",
|
title: "コミット",
|
||||||
countLabel: "ワークスペースのコミット数: {{count}}",
|
countLabel: "ワークスペースのコミット数: {{count}}",
|
||||||
|
|||||||
@@ -886,6 +886,7 @@ export const ko: TranslationResources = {
|
|||||||
base: "기준",
|
base: "기준",
|
||||||
newFile: "신규",
|
newFile: "신규",
|
||||||
deletedFile: "삭제됨",
|
deletedFile: "삭제됨",
|
||||||
|
modifiedFile: "수정됨",
|
||||||
commits: {
|
commits: {
|
||||||
title: "커밋",
|
title: "커밋",
|
||||||
countLabel: "워크스페이스 커밋 {{count}}개",
|
countLabel: "워크스페이스 커밋 {{count}}개",
|
||||||
|
|||||||
@@ -901,6 +901,7 @@ export const ptBR: TranslationResources = {
|
|||||||
base: "base",
|
base: "base",
|
||||||
newFile: "Novo",
|
newFile: "Novo",
|
||||||
deletedFile: "Excluído",
|
deletedFile: "Excluído",
|
||||||
|
modifiedFile: "Modificado",
|
||||||
commits: {
|
commits: {
|
||||||
title: "Commits",
|
title: "Commits",
|
||||||
countLabel: "{{count}} commits do espaço de trabalho",
|
countLabel: "{{count}} commits do espaço de trabalho",
|
||||||
|
|||||||
@@ -901,6 +901,7 @@ export const ru: TranslationResources = {
|
|||||||
base: "база",
|
base: "база",
|
||||||
newFile: "Новый",
|
newFile: "Новый",
|
||||||
deletedFile: "Удалено",
|
deletedFile: "Удалено",
|
||||||
|
modifiedFile: "Изменено",
|
||||||
commits: {
|
commits: {
|
||||||
title: "Коммиты",
|
title: "Коммиты",
|
||||||
countLabel: "{{count}} коммитов рабочего пространства",
|
countLabel: "{{count}} коммитов рабочего пространства",
|
||||||
|
|||||||
@@ -871,6 +871,7 @@ export const zhCN: TranslationResources = {
|
|||||||
base: "base",
|
base: "base",
|
||||||
newFile: "新增",
|
newFile: "新增",
|
||||||
deletedFile: "已删除",
|
deletedFile: "已删除",
|
||||||
|
modifiedFile: "已修改",
|
||||||
commits: {
|
commits: {
|
||||||
title: "提交",
|
title: "提交",
|
||||||
countLabel: "{{count}} 个工作区提交",
|
countLabel: "{{count}} 个工作区提交",
|
||||||
|
|||||||
@@ -337,6 +337,7 @@ export function useCloseFileExplorerGesture() {
|
|||||||
windowWidth,
|
windowWidth,
|
||||||
} = useMobilePanelsRuntime();
|
} = useMobilePanelsRuntime();
|
||||||
const { startedRevision, touchStartX, touchStartY } = useGestureState();
|
const { startedRevision, touchStartX, touchStartY } = useGestureState();
|
||||||
|
const horizontalScroll = useHorizontalScrollOptional();
|
||||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||||
const commit = useRevisionCommit(showMobileAgent);
|
const commit = useRevisionCommit(showMobileAgent);
|
||||||
|
|
||||||
@@ -372,6 +373,7 @@ export function useCloseFileExplorerGesture() {
|
|||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
!canBeginMobilePanelGesture(motionState.value, "file-explorer", position.value) ||
|
!canBeginMobilePanelGesture(motionState.value, "file-explorer", position.value) ||
|
||||||
|
horizontalScroll?.activeGestureStartedScrolled.value ||
|
||||||
panIntent === "fail"
|
panIntent === "fail"
|
||||||
) {
|
) {
|
||||||
stateManager.fail();
|
stateManager.fail();
|
||||||
@@ -405,6 +407,7 @@ export function useCloseFileExplorerGesture() {
|
|||||||
beginGesture,
|
beginGesture,
|
||||||
commit,
|
commit,
|
||||||
finishGesture,
|
finishGesture,
|
||||||
|
horizontalScroll?.activeGestureStartedScrolled,
|
||||||
motionState,
|
motionState,
|
||||||
position,
|
position,
|
||||||
rightCloseGestureRef,
|
rightCloseGestureRef,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export {
|
|||||||
export {
|
export {
|
||||||
getInlineReviewThreadViewportStyle,
|
getInlineReviewThreadViewportStyle,
|
||||||
groupInlineReviewCommentsByTarget,
|
groupInlineReviewCommentsByTarget,
|
||||||
|
InlineReviewAddButton,
|
||||||
InlineReviewEditor,
|
InlineReviewEditor,
|
||||||
InlineReviewGutterCell,
|
InlineReviewGutterCell,
|
||||||
InlineReviewThread,
|
InlineReviewThread,
|
||||||
|
|||||||
@@ -62,6 +62,35 @@ const ThemedPencil = withUnistyles(Pencil);
|
|||||||
const ThemedPlus = withUnistyles(Plus);
|
const ThemedPlus = withUnistyles(Plus);
|
||||||
const ThemedTrash2 = withUnistyles(Trash2);
|
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(
|
export function groupInlineReviewCommentsByTarget(
|
||||||
comments: readonly ReviewDraftComment[],
|
comments: readonly ReviewDraftComment[],
|
||||||
): Map<string, ReviewDraftComment[]> {
|
): Map<string, ReviewDraftComment[]> {
|
||||||
@@ -262,9 +291,7 @@ export function InlineReviewGutterCell({
|
|||||||
<View style={labelStyle}>
|
<View style={labelStyle}>
|
||||||
{children}
|
{children}
|
||||||
{showAction ? (
|
{showAction ? (
|
||||||
<View style={actionIconStyle} testID={actionTestID}>
|
<InlineReviewAddIcon style={actionIconStyle} testID={actionTestID} />
|
||||||
<ThemedPlus size={16} strokeWidth={2.4} uniProps={accentForegroundIconColorMapping} />
|
|
||||||
</View>
|
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -541,6 +568,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
position: "absolute",
|
position: "absolute",
|
||||||
right: -10,
|
right: -10,
|
||||||
top: Math.floor((theme.lineHeight.diff - 22) / 2),
|
top: Math.floor((theme.lineHeight.diff - 22) / 2),
|
||||||
|
},
|
||||||
|
gutterActionVisual: {
|
||||||
width: 22,
|
width: 22,
|
||||||
height: 22,
|
height: 22,
|
||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
|
|||||||
@@ -464,7 +464,7 @@ function SyntaxRow({ value, onChange }: SyntaxRowProps) {
|
|||||||
export function AppearanceSection() {
|
export function AppearanceSection() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { settings, updateSettings } = useAppSettings();
|
const { settings, updateSettings } = useAppSettings();
|
||||||
const showFontFamilyRows = !isNative;
|
const showInterfaceFontFamilyRow = !isNative;
|
||||||
const uiFontPlaceholder = resolveDefaultStackPlaceholder(t, DEFAULT_UI_FONT_STACK);
|
const uiFontPlaceholder = resolveDefaultStackPlaceholder(t, DEFAULT_UI_FONT_STACK);
|
||||||
const monoFontPlaceholder = resolveDefaultStackPlaceholder(t, DEFAULT_MONO_FONT_STACK);
|
const monoFontPlaceholder = resolveDefaultStackPlaceholder(t, DEFAULT_MONO_FONT_STACK);
|
||||||
|
|
||||||
@@ -616,7 +616,7 @@ export function AppearanceSection() {
|
|||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
<SettingsSection title={t("settings.appearance.fonts.title")}>
|
<SettingsSection title={t("settings.appearance.fonts.title")}>
|
||||||
<View style={settingsStyles.card}>
|
<View style={settingsStyles.card}>
|
||||||
{showFontFamilyRows ? (
|
{showInterfaceFontFamilyRow ? (
|
||||||
<FontFamilyRow
|
<FontFamilyRow
|
||||||
title={t("settings.appearance.fonts.interfaceFont")}
|
title={t("settings.appearance.fonts.interfaceFont")}
|
||||||
hint={t("settings.appearance.fonts.interfaceFontHint")}
|
hint={t("settings.appearance.fonts.interfaceFontHint")}
|
||||||
@@ -633,23 +633,21 @@ export function AppearanceSection() {
|
|||||||
title={t("settings.appearance.fonts.interfaceSize")}
|
title={t("settings.appearance.fonts.interfaceSize")}
|
||||||
accessibilityLabel={t("settings.appearance.fonts.interfaceSizeAccessibility")}
|
accessibilityLabel={t("settings.appearance.fonts.interfaceSizeAccessibility")}
|
||||||
draft={uiSizeDraft}
|
draft={uiSizeDraft}
|
||||||
withBorder={showFontFamilyRows}
|
withBorder={showInterfaceFontFamilyRow}
|
||||||
onChangeDraft={handleUiSizeChange}
|
onChangeDraft={handleUiSizeChange}
|
||||||
onCommit={commitUiSize}
|
onCommit={commitUiSize}
|
||||||
/>
|
/>
|
||||||
{showFontFamilyRows ? (
|
<FontFamilyRow
|
||||||
<FontFamilyRow
|
title={t("settings.appearance.fonts.codeFont")}
|
||||||
title={t("settings.appearance.fonts.codeFont")}
|
hint={t("settings.appearance.fonts.codeFontHint")}
|
||||||
hint={t("settings.appearance.fonts.codeFontHint")}
|
accessibilityLabel={t("settings.appearance.fonts.codeFontAccessibility")}
|
||||||
accessibilityLabel={t("settings.appearance.fonts.codeFontAccessibility")}
|
placeholder={monoFontPlaceholder}
|
||||||
placeholder={monoFontPlaceholder}
|
value={settings.monoFontFamily}
|
||||||
value={settings.monoFontFamily}
|
draft={monoFontDraft}
|
||||||
draft={monoFontDraft}
|
withBorder
|
||||||
withBorder
|
onChangeDraft={setMonoFontDraft}
|
||||||
onChangeDraft={setMonoFontDraft}
|
onCommit={commitMonoFontFamily}
|
||||||
onCommit={commitMonoFontFamily}
|
/>
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<FontSizeRow
|
<FontSizeRow
|
||||||
title={t("settings.appearance.fonts.codeSize")}
|
title={t("settings.appearance.fonts.codeSize")}
|
||||||
accessibilityLabel={t("settings.appearance.fonts.codeSizeAccessibility")}
|
accessibilityLabel={t("settings.appearance.fonts.codeSizeAccessibility")}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { desaturateHexColor, parseHexColor } from "./color";
|
import { desaturateHexColor, hexColorWithAlpha, parseHexColor } from "./color";
|
||||||
|
|
||||||
describe("parseHexColor", () => {
|
describe("parseHexColor", () => {
|
||||||
it("parses six-digit hex", () => {
|
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", () => {
|
describe("desaturateHexColor", () => {
|
||||||
it("leaves a colour alone at full chroma", () => {
|
it("leaves a colour alone at full chroma", () => {
|
||||||
expect(desaturateHexColor("#0288d1", 1)).toBe("#0288d1");
|
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 {
|
function toHexChannel(channel: number): string {
|
||||||
const clamped = Math.min(255, Math.max(0, Math.round(channel * 255)));
|
const clamped = Math.min(255, Math.max(0, Math.round(channel * 255)));
|
||||||
return clamped.toString(16).padStart(2, "0");
|
return clamped.toString(16).padStart(2, "0");
|
||||||
|
|||||||
Reference in New Issue
Block a user