Fix diff gutter row alignment

This commit is contained in:
Mohamed Boudra
2026-06-04 19:44:45 +07:00
parent 3d8ef237e3
commit 81d973ffc4
3 changed files with 679 additions and 128 deletions
+505
View File
@@ -0,0 +1,505 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { type Page } from "@playwright/test";
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../src/utils/host-routes";
import { test, expect } from "./fixtures";
import { getServerId } from "./helpers/server-id";
import { connectSeedClient } from "./helpers/seed-client";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
interface DirtyWorkspace {
id: string;
}
interface CleanupTask {
run: () => Promise<void>;
}
const cleanupTasks: CleanupTask[] = [];
const APP_SETTINGS_KEY = "@paseo:app-settings";
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
const BEFORE = `import { useLayoutEffect, useMemo, useRef, useState } from "react";
interface UseMountedTabSetInput {
activeTabId: string | null;
allTabIds: string[];
cap: number;
}
interface UseMountedTabSetResult {
mountedTabIds: Set<string>;
}
function createInitialMountedTabIds(input: UseMountedTabSetInput): Set<string> {
if (!input.activeTabId || !input.allTabIds.includes(input.activeTabId)) {
return new Set<string>();
}
return new Set<string>([input.activeTabId]);
}
function setsEqual(left: Set<string>, right: Set<string>): boolean {
if (left.size !== right.size) {
return false;
}
for (const value of left) {
if (!right.has(value)) {
return false;
}
}
return true;
}
export function useMountedTabSet(input: UseMountedTabSetInput): UseMountedTabSetResult {
const { activeTabId, allTabIds, cap } = input;
const allTabIdsKey = allTabIds.join("\\u0000");
const availableTabIds = useMemo(() => {
void allTabIdsKey;
return new Set(allTabIds);
}, [allTabIds, allTabIdsKey]);
const [mountedTabIds, setMountedTabIds] = useState(() => createInitialMountedTabIds(input));
const lruRef = useRef(activeTabId && allTabIds.includes(activeTabId) ? [activeTabId] : []);
useLayoutEffect(() => {
const nextLru = lruRef.current.filter((tabId) => availableTabIds.has(tabId));
if (activeTabId && availableTabIds.has(activeTabId)) {
const existingIndex = nextLru.indexOf(activeTabId);
if (existingIndex >= 0) {
nextLru.splice(existingIndex, 1);
}
nextLru.unshift(activeTabId);
}
if (nextLru.length > cap) {
nextLru.length = cap;
}
lruRef.current = nextLru;
setMountedTabIds((previousMountedTabIds) => {
const nextMountedTabIds = new Set(nextLru);
return setsEqual(previousMountedTabIds, nextMountedTabIds)
? previousMountedTabIds
: nextMountedTabIds;
});
}, [activeTabId, availableTabIds, cap]);
return { mountedTabIds };
}
`;
const AFTER = `import { useLayoutEffect, useMemo, useRef, useState } from "react";
interface UseMountedTabSetInput {
activeTabId: string | null;
allTabIds: string[];
cap: number;
}
interface UseMountedTabSetResult {
mountedTabIds: Set<string>;
}
interface DeriveRenderMountedTabIdsInput {
activeTabId: string | null;
availableTabIds: Set<string>;
cap: number;
mountedTabIds: Set<string>;
}
function createInitialMountedTabIds(input: UseMountedTabSetInput): Set<string> {
if (!input.activeTabId || !input.allTabIds.includes(input.activeTabId)) {
return new Set<string>();
}
return new Set<string>([input.activeTabId]);
}
function setsEqual(left: Set<string>, right: Set<string>): boolean {
if (left.size !== right.size) {
return false;
}
for (const value of left) {
if (!right.has(value)) {
return false;
}
}
return true;
}
function deriveRenderMountedTabIds(input: DeriveRenderMountedTabIdsInput): Set<string> {
const { activeTabId, availableTabIds, cap, mountedTabIds } = input;
if (!activeTabId || !availableTabIds.has(activeTabId) || mountedTabIds.has(activeTabId)) {
return mountedTabIds;
}
const next = new Set<string>([activeTabId]);
const maxSize = Math.max(1, cap);
for (const tabId of mountedTabIds) {
if (next.size >= maxSize) {
break;
}
if (availableTabIds.has(tabId)) {
next.add(tabId);
}
}
return next;
}
export function useMountedTabSet(input: UseMountedTabSetInput): UseMountedTabSetResult {
const { activeTabId, allTabIds, cap } = input;
const allTabIdsKey = allTabIds.join("\\u0000");
const availableTabIds = useMemo(() => {
void allTabIdsKey;
return new Set(allTabIds);
}, [allTabIds, allTabIdsKey]);
const [mountedTabIds, setMountedTabIds] = useState(() => createInitialMountedTabIds(input));
const lruRef = useRef(activeTabId && allTabIds.includes(activeTabId) ? [activeTabId] : []);
const renderMountedTabIds = useMemo(
() =>
deriveRenderMountedTabIds({
activeTabId,
availableTabIds,
cap,
mountedTabIds,
}),
[activeTabId, availableTabIds, cap, mountedTabIds],
);
useLayoutEffect(() => {
const nextLru = lruRef.current.filter((tabId) => availableTabIds.has(tabId));
if (activeTabId && availableTabIds.has(activeTabId)) {
const existingIndex = nextLru.indexOf(activeTabId);
if (existingIndex >= 0) {
nextLru.splice(existingIndex, 1);
}
nextLru.unshift(activeTabId);
}
if (nextLru.length > cap) {
nextLru.length = cap;
}
lruRef.current = nextLru;
setMountedTabIds((previousMountedTabIds) => {
const nextMountedTabIds = new Set(nextLru);
return setsEqual(previousMountedTabIds, nextMountedTabIds)
? previousMountedTabIds
: nextMountedTabIds;
});
}, [activeTabId, availableTabIds, cap]);
return { mountedTabIds: renderMountedTabIds };
}
`;
test.afterEach(async () => {
for (const task of cleanupTasks.splice(0)) {
await task.run();
}
});
test("changes diff keeps code rows aligned with the gutter", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useCodeFont(page, 9);
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await expectDiffCodeFontSize(page, 9);
await expectVisibleDiffRowsAligned(page);
await expectDiffCodeTextAlignedWithGutterText(page, [
{
codeText: "function createInitialMountedTabIds(input: UseMountedTabSetInput)",
lineNumber: "20",
},
{ codeText: "return next;", lineNumber: "55" },
{ codeText: "useLayoutEffect(() => {", lineNumber: "78" },
]);
await expectHoverCommentButtonAlignedWithCodeLine(page, {
codeText: "function createInitialMountedTabIds(input: UseMountedTabSetInput)",
lineNumber: "20",
});
});
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
page,
}) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useCodeFont(page, 12);
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await changeCodeFontSizeFromSettings(page, 18);
await returnToWorkspaceChanges(page);
await scrollToLowerUnwrappedDiffRows(page);
await expectDiffCodeFontSize(page, 18);
await expectVisibleDiffRowsShareTypography(page);
await expectVisibleDiffRowsAligned(page);
});
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
await page.addInitScript(
({ settingsKey, fontSize }) => {
localStorage.setItem(
settingsKey,
JSON.stringify({
theme: "dark",
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
terminalScrollbackLines: 10_000,
uiFontFamily: "",
monoFontFamily: "",
uiFontSize: 16,
codeFontSize: fontSize,
syntaxTheme: "one",
}),
);
},
{ settingsKey: APP_SETTINGS_KEY, fontSize: codeFontSize },
);
}
async function useUnwrappedDiffLines(page: Page): Promise<void> {
await page.addInitScript(
({ preferencesKey }) => {
localStorage.setItem(
preferencesKey,
JSON.stringify({ layout: "unified", wrapLines: false, hideWhitespace: false }),
);
},
{ preferencesKey: CHANGES_PREFERENCES_KEY },
);
}
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
const actualFontSize = await page
.getByTestId("diff-code-text-1")
.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
expect(actualFontSize).toBe(fontSize);
}
async function expectVisibleDiffRowsAligned(page: Page): Promise<void> {
const geometry = await readVisibleDiffRowGeometry(page);
expect(geometry.maxDelta, JSON.stringify(geometry.rows, null, 2)).toBeLessThanOrEqual(1);
}
async function expectVisibleDiffRowsShareTypography(page: Page): Promise<void> {
const geometry = await readVisibleDiffRowGeometry(page);
expect(geometry.mismatchedTypography, JSON.stringify(geometry, null, 2)).toEqual([]);
}
async function readVisibleDiffRowGeometry(page: Page): Promise<{
maxDelta: number;
mismatchedTypography: { index: number; gutterLineHeight: number; codeLineHeight: number }[];
rows: {
index: number;
gutterTop: number;
codeTop: number;
delta: number;
gutterLineHeight: number;
codeLineHeight: number;
}[];
}> {
return page.locator("body").evaluate(({ ownerDocument }) => {
const root = ownerDocument.querySelector('[data-testid="diff-file-0-body"]');
if (!root) {
throw new Error("Expanded diff body is not mounted");
}
const readRows = (prefix: string, textPrefix: string) =>
Array.from(root.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map((row) => {
const testId = row.getAttribute("data-testid") ?? "";
const index = Number(testId.slice(prefix.length));
const rect = row.getBoundingClientRect();
const text = root.querySelector<HTMLElement>(`[data-testid="${textPrefix}${index}"]`);
const lineHeight = text ? Number.parseFloat(getComputedStyle(text).lineHeight) : 0;
return { index, top: rect.top, height: rect.height, lineHeight };
});
const gutters = new Map(
readRows("diff-gutter-row-", "diff-gutter-text-").map((row) => [row.index, row]),
);
const codes = readRows("diff-code-row-", "diff-code-text-");
const rows = codes
.map((code) => {
const gutter = gutters.get(code.index);
if (!gutter) {
throw new Error(`Missing gutter row ${code.index}`);
}
return {
index: code.index,
gutterTop: gutter.top,
codeTop: code.top,
delta: Math.abs(code.top - gutter.top),
gutterLineHeight: gutter.lineHeight,
codeLineHeight: code.lineHeight,
};
})
.filter((row) => row.gutterTop >= 0 && row.codeTop >= 0);
return {
maxDelta: Math.max(...rows.map((row) => row.delta)),
mismatchedTypography: rows
.filter((row) => Math.abs(row.gutterLineHeight - row.codeLineHeight) > 0.5)
.map((row) => ({
index: row.index,
gutterLineHeight: row.gutterLineHeight,
codeLineHeight: row.codeLineHeight,
})),
rows,
};
});
}
async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("diff-row-alignment-", {
files: [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }],
});
const client = await connectSeedClient();
cleanupTasks.push({
run: async () => {
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
},
});
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
const opened = await client.openProject(repo.path);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${repo.path}`);
}
return { id: opened.workspace.id };
}
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page);
await page.getByRole("button", { name: "Open explorer" }).click();
await openChangesInVisibleExplorer(page);
await page.getByTestId("diff-file-0").click();
await expectExpandedMountedTabDiff(page);
}
async function openChangesInVisibleExplorer(page: Page): Promise<void> {
await expect(page.getByTestId("explorer-tab-changes")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
}
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
timeout: 30_000,
});
}
async function changeCodeFontSizeFromSettings(page: Page, codeFontSize: number): Promise<void> {
await page.getByTestId("sidebar-settings").click();
await expect(page).toHaveURL(new RegExp(`${buildSettingsSectionRoute("general")}|/settings$`));
await page.getByRole("button", { name: "Appearance" }).click();
await page.getByLabel("Code font size").fill(String(codeFontSize));
await page.getByLabel("Code font size").press("Enter");
await expect(page.getByLabel("Code font size")).toHaveValue(String(codeFontSize));
}
async function returnToWorkspaceChanges(page: Page): Promise<void> {
await page.getByTestId("settings-back-to-workspace").click();
await waitForWorkspaceTabsVisible(page);
await openChangesInVisibleExplorer(page);
await expectExpandedMountedTabDiff(page);
}
async function scrollToLowerUnwrappedDiffRows(page: Page): Promise<void> {
const lastRowIndex = await page.getByTestId("diff-file-0-body").evaluate((root) => {
const rows = Array.from(root.querySelectorAll<HTMLElement>('[data-testid^="diff-code-row-"]'));
if (rows.length === 0) {
throw new Error("No unwrapped code rows are mounted");
}
return Math.max(
...rows.map((row) => Number((row.getAttribute("data-testid") ?? "").slice(14))),
);
});
await page.getByTestId(`diff-code-row-${lastRowIndex}`).scrollIntoViewIfNeeded();
await expect(page.getByTestId(`diff-code-row-${lastRowIndex}`)).toBeVisible();
}
async function expectDiffCodeTextAlignedWithGutterText(
page: Page,
lines: { codeText: string; lineNumber: string }[],
): Promise<void> {
const geometries = await readDiffTextGeometry(page, lines);
for (const geometry of geometries) {
expect(geometry.codeTop, geometry.codeText).toBeCloseTo(geometry.gutterTop, 0);
}
}
async function expectHoverCommentButtonAlignedWithCodeLine(
page: Page,
line: { codeText: string; lineNumber: string },
): Promise<void> {
const target = await readDiffTextGeometry(page, [line]).then((rows) => rows[0]);
if (!target) {
throw new Error(`Could not find target line ${line.lineNumber}`);
}
await page.getByTestId(`diff-code-row-${target.index}`).hover();
const geometry = await page
.getByTestId(`diff-gutter-action-${target.index}`)
.evaluate((action, expectedCodeCenterY) => {
const rect = action.getBoundingClientRect();
return {
actionCenterY: rect.top + rect.height / 2,
codeCenterY: expectedCodeCenterY,
};
}, target.codeCenterY);
expect(geometry.actionCenterY).toBeCloseTo(geometry.codeCenterY, 0);
}
async function readDiffTextGeometry(
page: Page,
lines: { codeText: string; lineNumber: string }[],
): Promise<
{ index: number; codeText: string; codeTop: number; gutterTop: number; codeCenterY: number }[]
> {
return page.locator("body").evaluate(({ ownerDocument }, targets) => {
const root = ownerDocument.querySelector('[data-testid="explorer-content-area"]');
if (!root) {
throw new Error("Changes panel is not mounted");
}
const readIndexedElements = (prefix: string) =>
Array.from(root.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map(
(element) => {
const testId = element.getAttribute("data-testid") ?? "";
return { index: Number(testId.slice(prefix.length)), element };
},
);
const gutterTexts = readIndexedElements("diff-gutter-text-");
const codeTexts = readIndexedElements("diff-code-text-");
return targets.map((target) => {
const gutter = gutterTexts.find(
({ element }) => (element.textContent ?? "").trim() === target.lineNumber,
);
if (!gutter) {
throw new Error(`Could not find gutter line ${target.lineNumber}`);
}
const code = codeTexts.find(
({ index, element }) =>
index === gutter.index && (element.textContent ?? "").includes(target.codeText),
);
if (!code) {
throw new Error(`Could not find code row ${target.codeText}`);
}
const codeRect = code.element.getBoundingClientRect();
const gutterRect = gutter.element.getBoundingClientRect();
return {
index: gutter.index,
codeText: target.codeText,
codeTop: codeRect.top,
gutterTop: gutterRect.top,
codeCenterY: codeRect.top + codeRect.height / 2,
};
});
}, lines);
}
+150 -114
View File
@@ -25,9 +25,9 @@ import {
type ViewStyle, type ViewStyle,
type TextStyle, type TextStyle,
} from "react-native"; } from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ICON_SIZE, type Theme } from "@/styles/theme"; import { BORDER_WIDTH, ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { import {
AlignJustify, AlignJustify,
Archive, Archive,
@@ -55,10 +55,10 @@ import {
import { useCheckoutStatusQuery } from "@/git/use-status-query"; import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { useChangesPreferences } from "@/hooks/use-changes-preferences"; import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { useAppSettings } from "@/hooks/use-settings";
import { DiffScroll } from "@/components/diff-scroll"; import { DiffScroll } from "@/components/diff-scroll";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { shouldAnchorHeaderBeforeCollapse } from "@/git/diff-scroll"; import { shouldAnchorHeaderBeforeCollapse } from "@/git/diff-scroll";
import { import {
buildSplitDiffRows, buildSplitDiffRows,
@@ -123,6 +123,7 @@ function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) {
interface HighlightedTextProps { interface HighlightedTextProps {
tokens: HighlightToken[]; tokens: HighlightToken[];
wrapLines?: boolean; wrapLines?: boolean;
testID?: string;
} }
type WrappedWebTextStyle = TextStyle & { type WrappedWebTextStyle = TextStyle & {
@@ -143,13 +144,10 @@ function HighlightedToken({ token }: { token: HighlightToken }) {
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>; return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
} }
function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) { function HighlightedText({ tokens, wrapLines = false, testID }: HighlightedTextProps) {
const { theme } = useUnistyles();
const lineHeight = theme.lineHeight.diff;
const containerStyle = useMemo( const containerStyle = useMemo(
() => [styles.diffLineText, { lineHeight, ...getWrappedTextStyle(wrapLines) }], () => [styles.diffTextMetrics, styles.diffLineText, getWrappedTextStyle(wrapLines)],
[lineHeight, wrapLines], [wrapLines],
); );
const keyedTokens = useMemo( const keyedTokens = useMemo(
@@ -158,7 +156,7 @@ function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) {
); );
return ( return (
<Text style={containerStyle}> <Text style={containerStyle} testID={testID}>
{keyedTokens.map(({ key, token }) => ( {keyedTokens.map(({ key, token }) => (
<HighlightedToken key={key} token={token} /> <HighlightedToken key={key} token={token} />
))} ))}
@@ -252,6 +250,8 @@ function DiffGutterCell({
reviewActions, reviewActions,
isLineHovered, isLineHovered,
style, style,
textTestID,
actionTestID,
}: { }: {
lineNumber: number | null; lineNumber: number | null;
type: DiffLine["type"] | undefined | null; type: DiffLine["type"] | undefined | null;
@@ -260,6 +260,8 @@ function DiffGutterCell({
reviewActions?: InlineReviewActions; reviewActions?: InlineReviewActions;
isLineHovered?: boolean; isLineHovered?: boolean;
style?: StyleProp<ViewStyle>; style?: StyleProp<ViewStyle>;
textTestID?: string;
actionTestID?: string;
}) { }) {
const containerStyle = useMemo( const containerStyle = useMemo(
() => [ () => [
@@ -272,6 +274,7 @@ function DiffGutterCell({
); );
const textStyle = useMemo( const textStyle = useMemo(
() => [ () => [
styles.diffTextMetrics,
styles.lineNumberText, styles.lineNumberText,
type === "add" && styles.addLineNumberText, type === "add" && styles.addLineNumberText,
type === "remove" && styles.removeLineNumberText, type === "remove" && styles.removeLineNumberText,
@@ -296,8 +299,9 @@ function DiffGutterCell({
isLineHovered={isLineHovered} isLineHovered={isLineHovered}
onStartComment={onStartComment} onStartComment={onStartComment}
style={containerStyle} style={containerStyle}
actionTestID={actionTestID}
> >
<Text numberOfLines={1} style={textStyle}> <Text numberOfLines={1} style={textStyle} testID={textTestID}>
{formatDiffGutterText(lineNumber)} {formatDiffGutterText(lineNumber)}
</Text> </Text>
</InlineReviewGutterCell> </InlineReviewGutterCell>
@@ -312,6 +316,7 @@ function DiffTextLine({
onHoverChange, onHoverChange,
hoverTargetKey, hoverTargetKey,
onHoverTargetChange, onHoverTargetChange,
textTestID,
}: { }: {
line: DiffLine; line: DiffLine;
wrapLines: boolean; wrapLines: boolean;
@@ -320,6 +325,7 @@ function DiffTextLine({
onHoverChange?: (hovered: boolean) => void; onHoverChange?: (hovered: boolean) => void;
hoverTargetKey?: string | null; hoverTargetKey?: string | null;
onHoverTargetChange?: (key: string | null) => void; onHoverTargetChange?: (key: string | null) => void;
textTestID?: string;
}) { }) {
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null; const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
@@ -329,6 +335,7 @@ function DiffTextLine({
); );
const textStyle = useMemo( const textStyle = useMemo(
() => [ () => [
styles.diffTextMetrics,
styles.diffLineText, styles.diffLineText,
getWrappedTextStyle(wrapLines), getWrappedTextStyle(wrapLines),
line.type === "add" && styles.addLineText, line.type === "add" && styles.addLineText,
@@ -349,9 +356,11 @@ function DiffTextLine({
style={containerStyle} style={containerStyle}
> >
{line.type !== "header" && visibleTokens ? ( {line.type !== "header" && visibleTokens ? (
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} /> <HighlightedText tokens={visibleTokens} wrapLines={wrapLines} testID={textTestID} />
) : ( ) : (
<Text style={textStyle}>{formatDiffContentText(line.content)}</Text> <Text style={textStyle} testID={textTestID}>
{formatDiffContentText(line.content)}
</Text>
)} )}
</LongPressableLine> </LongPressableLine>
); );
@@ -380,6 +389,7 @@ function SplitTextLine({
); );
const textStyle = useMemo( const textStyle = useMemo(
() => [ () => [
styles.diffTextMetrics,
styles.diffLineText, styles.diffLineText,
getWrappedTextStyle(wrapLines), getWrappedTextStyle(wrapLines),
line?.type === "add" && styles.addLineText, line?.type === "add" && styles.addLineText,
@@ -432,6 +442,7 @@ function DiffLineView({
); );
const textStyle = useMemo( const textStyle = useMemo(
() => [ () => [
styles.diffTextMetrics,
styles.diffLineText, styles.diffLineText,
getWrappedTextStyle(wrapLines), getWrappedTextStyle(wrapLines),
line.type === "add" && styles.addLineText, line.type === "add" && styles.addLineText,
@@ -487,6 +498,7 @@ function SplitDiffLine({
); );
const textStyle = useMemo( const textStyle = useMemo(
() => [ () => [
styles.diffTextMetrics,
styles.diffLineText, styles.diffLineText,
getWrappedTextStyle(wrapLines), getWrappedTextStyle(wrapLines),
line?.type === "add" && styles.addLineText, line?.type === "add" && styles.addLineText,
@@ -896,6 +908,7 @@ function DiffFileBody({
file, file,
layout, layout,
wrapLines, wrapLines,
codeFontSize,
reviewActions, reviewActions,
onBodyHeightChange, onBodyHeightChange,
testID, testID,
@@ -903,6 +916,7 @@ function DiffFileBody({
file: ParsedDiffFile; file: ParsedDiffFile;
layout: "unified" | "split"; layout: "unified" | "split";
wrapLines: boolean; wrapLines: boolean;
codeFontSize: number;
reviewActions?: InlineReviewActions; reviewActions?: InlineReviewActions;
onBodyHeightChange?: (file: ParsedDiffFile, height: number) => void; onBodyHeightChange?: (file: ParsedDiffFile, height: number) => void;
testID?: string; testID?: string;
@@ -910,7 +924,6 @@ function DiffFileBody({
const [scrollViewWidth, setScrollViewWidth] = useState(0); const [scrollViewWidth, setScrollViewWidth] = useState(0);
const [bodyWidth, setBodyWidth] = useState(0); const [bodyWidth, setBodyWidth] = useState(0);
const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState<string | null>(null); const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState<string | null>(null);
const { theme } = useUnistyles();
const handleLayout = useCallback( const handleLayout = useCallback(
(event: LayoutChangeEvent) => { (event: LayoutChangeEvent) => {
@@ -950,7 +963,7 @@ function DiffFileBody({
hunk.newStart + hunk.newCount, hunk.newStart + hunk.newCount,
); );
} }
const gutterWidth = lineNumberGutterWidth(maxLineNo, theme.fontSize.code); const gutterWidth = lineNumberGutterWidth(maxLineNo, codeFontSize);
if (layout === "split") { if (layout === "split") {
const rows = buildSplitDiffRows(file); const rows = buildSplitDiffRows(file);
@@ -981,8 +994,8 @@ function DiffFileBody({
return ( return (
<View style={styles.diffContent} dataSet={CODE_SURFACE_DATASET}> <View style={styles.diffContent} dataSet={CODE_SURFACE_DATASET}>
<View style={styles.linesContainer}> <View style={styles.linesContainer}>
{computedLines.map(({ line, lineNumber, key, reviewTarget }) => ( {computedLines.map(({ line, lineNumber, key, reviewTarget }, index) => (
<View key={key}> <View key={key} testID={`diff-wrapped-row-${index}`}>
<DiffLineView <DiffLineView
line={line} line={line}
lineNumber={lineNumber} lineNumber={lineNumber}
@@ -1008,8 +1021,8 @@ function DiffFileBody({
return ( return (
<View style={DIFF_CONTENT_ROW_STYLE} dataSet={CODE_SURFACE_DATASET}> <View style={DIFF_CONTENT_ROW_STYLE} dataSet={CODE_SURFACE_DATASET}>
<View style={styles.gutterColumn}> <View style={styles.gutterColumn}>
{computedLines.map(({ line, lineNumber, key, reviewTarget }) => ( {computedLines.map(({ line, lineNumber, key, reviewTarget }, index) => (
<View key={key}> <View key={key} testID={`diff-gutter-row-${index}`}>
<DiffGutterCell <DiffGutterCell
lineNumber={lineNumber} lineNumber={lineNumber}
type={line.type} type={line.type}
@@ -1019,6 +1032,8 @@ function DiffFileBody({
isLineHovered={ isLineHovered={
reviewTarget?.key !== undefined && hoveredReviewTargetKey === reviewTarget.key reviewTarget?.key !== undefined && hoveredReviewTargetKey === reviewTarget.key
} }
textTestID={`diff-gutter-text-${index}`}
actionTestID={`diff-gutter-action-${index}`}
/> />
<InlineReviewGutterSpacer <InlineReviewGutterSpacer
reviewTarget={reviewTarget} reviewTarget={reviewTarget}
@@ -1035,8 +1050,8 @@ function DiffFileBody({
contentContainerStyle={styles.diffContentInner} contentContainerStyle={styles.diffContentInner}
> >
<View style={linesContainerRowStyle}> <View style={linesContainerRowStyle}>
{computedLines.map(({ line, key, reviewTarget }) => ( {computedLines.map(({ line, key, reviewTarget }, index) => (
<View key={key}> <View key={key} testID={`diff-code-row-${index}`}>
<DiffTextLine <DiffTextLine
line={line} line={line}
wrapLines={false} wrapLines={false}
@@ -1044,6 +1059,7 @@ function DiffFileBody({
reviewActions={reviewActions} reviewActions={reviewActions}
hoverTargetKey={reviewTarget?.key ?? null} hoverTargetKey={reviewTarget?.key ?? null}
onHoverTargetChange={setHoveredReviewTargetKey} onHoverTargetChange={setHoveredReviewTargetKey}
textTestID={`diff-code-text-${index}`}
/> />
<InlineReviewThreadContent <InlineReviewThreadContent
reviewTarget={reviewTarget} reviewTarget={reviewTarget}
@@ -1074,6 +1090,27 @@ type PressableStyleFn = (
state: PressableStateCallbackType & { hovered?: boolean; open?: boolean }, state: PressableStateCallbackType & { hovered?: boolean; open?: boolean },
) => StyleProp<ViewStyle>; ) => StyleProp<ViewStyle>;
const foregroundIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedAlignJustify = withUnistyles(AlignJustify);
const ThemedColumns2 = withUnistyles(Columns2);
const ThemedPilcrow = withUnistyles(Pilcrow);
const ThemedWrapText = withUnistyles(WrapText);
const ThemedListChevronsDownUp = withUnistyles(ListChevronsDownUp);
const ThemedListChevronsUpDown = withUnistyles(ListChevronsUpDown);
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
const ThemedDownload = withUnistyles(Download);
const ThemedUpload = withUnistyles(Upload);
const ThemedArrowDownUp = withUnistyles(ArrowDownUp);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const ThemedGitMerge = withUnistyles(GitMerge);
const ThemedRefreshCcw = withUnistyles(RefreshCcw);
const ThemedArchive = withUnistyles(Archive);
const ThemedGitBranch = withUnistyles(GitBranch);
const ThemedChevronDown = withUnistyles(ChevronDown);
interface DiffLayoutToggleGroupProps { interface DiffLayoutToggleGroupProps {
layout: "unified" | "split"; layout: "unified" | "split";
unifiedToggleStyle: PressableStyleFn; unifiedToggleStyle: PressableStyleFn;
@@ -1089,7 +1126,6 @@ function DiffLayoutToggleGroup({
onUnified, onUnified,
onSplit, onSplit,
}: DiffLayoutToggleGroupProps) { }: DiffLayoutToggleGroupProps) {
const { theme } = useUnistyles();
return ( return (
<View style={styles.toggleButtonGroup}> <View style={styles.toggleButtonGroup}>
<Tooltip delayDuration={300}> <Tooltip delayDuration={300}>
@@ -1101,9 +1137,11 @@ function DiffLayoutToggleGroup({
onPress={onUnified} onPress={onUnified}
style={unifiedToggleStyle} style={unifiedToggleStyle}
> >
<AlignJustify <ThemedAlignJustify
size={14} size={14}
color={layout === "unified" ? theme.colors.foreground : theme.colors.foregroundMuted} uniProps={
layout === "unified" ? foregroundIconColorMapping : foregroundMutedIconColorMapping
}
/> />
</Pressable> </Pressable>
</TooltipTrigger> </TooltipTrigger>
@@ -1120,9 +1158,11 @@ function DiffLayoutToggleGroup({
onPress={onSplit} onPress={onSplit}
style={splitToggleStyle} style={splitToggleStyle}
> >
<Columns2 <ThemedColumns2
size={14} size={14}
color={layout === "split" ? theme.colors.foreground : theme.colors.foregroundMuted} uniProps={
layout === "split" ? foregroundIconColorMapping : foregroundMutedIconColorMapping
}
/> />
</Pressable> </Pressable>
</TooltipTrigger> </TooltipTrigger>
@@ -1147,7 +1187,6 @@ function DiffWhitespaceToggle({
toggleStyle, toggleStyle,
onToggle, onToggle,
}: DiffWhitespaceToggleProps) { }: DiffWhitespaceToggleProps) {
const { theme } = useUnistyles();
return ( return (
<Tooltip delayDuration={300}> <Tooltip delayDuration={300}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -1158,9 +1197,9 @@ function DiffWhitespaceToggle({
style={toggleStyle} style={toggleStyle}
onPress={onToggle} onPress={onToggle}
> >
<Pilcrow <ThemedPilcrow
size={isMobile ? 18 : 14} size={isMobile ? 18 : 14}
color={hideWhitespace ? theme.colors.foreground : theme.colors.foregroundMuted} uniProps={hideWhitespace ? foregroundIconColorMapping : foregroundMutedIconColorMapping}
/> />
</Pressable> </Pressable>
</TooltipTrigger> </TooltipTrigger>
@@ -1190,15 +1229,14 @@ function DiffFilesToolbar({
onToggleWrapLines, onToggleWrapLines,
onToggleExpandAll, onToggleExpandAll,
}: DiffFilesToolbarProps) { }: DiffFilesToolbarProps) {
const { theme } = useUnistyles();
return ( return (
<View style={styles.diffStatusButtons}> <View style={styles.diffStatusButtons}>
<Tooltip delayDuration={300}> <Tooltip delayDuration={300}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Pressable style={wrapLinesToggleStyle} onPress={onToggleWrapLines}> <Pressable style={wrapLinesToggleStyle} onPress={onToggleWrapLines}>
<WrapText <ThemedWrapText
size={isMobile ? 18 : 14} size={isMobile ? 18 : 14}
color={wrapLines ? theme.colors.foreground : theme.colors.foregroundMuted} uniProps={wrapLines ? foregroundIconColorMapping : foregroundMutedIconColorMapping}
/> />
</Pressable> </Pressable>
</TooltipTrigger> </TooltipTrigger>
@@ -1212,9 +1250,15 @@ function DiffFilesToolbar({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Pressable style={expandAllToggleStyle} onPress={onToggleExpandAll}> <Pressable style={expandAllToggleStyle} onPress={onToggleExpandAll}>
{allExpanded ? ( {allExpanded ? (
<ListChevronsDownUp size={isMobile ? 18 : 14} color={theme.colors.foregroundMuted} /> <ThemedListChevronsDownUp
size={isMobile ? 18 : 14}
uniProps={foregroundMutedIconColorMapping}
/>
) : ( ) : (
<ListChevronsUpDown size={isMobile ? 18 : 14} color={theme.colors.foregroundMuted} /> <ThemedListChevronsUpDown
size={isMobile ? 18 : 14}
uniProps={foregroundMutedIconColorMapping}
/>
)} )}
</Pressable> </Pressable>
</TooltipTrigger> </TooltipTrigger>
@@ -1236,7 +1280,6 @@ interface DiffRefreshButtonProps {
const ThemedRotateCw = withUnistyles(RotateCw); const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const refreshIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function DiffRefreshButton({ isRefreshing, toggleStyle, onPress }: DiffRefreshButtonProps) { function DiffRefreshButton({ isRefreshing, toggleStyle, onPress }: DiffRefreshButtonProps) {
return ( return (
@@ -1252,9 +1295,12 @@ function DiffRefreshButton({ isRefreshing, toggleStyle, onPress }: DiffRefreshBu
> >
<View style={styles.refreshIcon}> <View style={styles.refreshIcon}>
{isRefreshing ? ( {isRefreshing ? (
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={refreshIconColorMapping} /> <ThemedLoadingSpinner
size={ICON_SIZE.sm}
uniProps={foregroundMutedIconColorMapping}
/>
) : ( ) : (
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={refreshIconColorMapping} /> <ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedIconColorMapping} />
)} )}
</View> </View>
</Pressable> </Pressable>
@@ -1322,7 +1368,6 @@ interface DiffBodyContentProps {
handleDiffListScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void; handleDiffListScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
onContentSizeChange: (width: number, height: number) => void; onContentSizeChange: (width: number, height: number) => void;
showDesktopWebScrollbar: boolean; showDesktopWebScrollbar: boolean;
foregroundMutedColor: string;
} }
function DiffBodyContent({ function DiffBodyContent({
@@ -1344,12 +1389,11 @@ function DiffBodyContent({
handleDiffListScroll, handleDiffListScroll,
onContentSizeChange, onContentSizeChange,
showDesktopWebScrollbar, showDesktopWebScrollbar,
foregroundMutedColor,
}: DiffBodyContentProps) { }: DiffBodyContentProps) {
if (isStatusLoading) { if (isStatusLoading) {
return ( return (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={foregroundMutedColor} /> <ThemedActivityIndicator size="large" uniProps={foregroundMutedIconColorMapping} />
<Text style={styles.loadingText}>Checking repository...</Text> <Text style={styles.loadingText}>Checking repository...</Text>
</View> </View>
); );
@@ -1371,7 +1415,7 @@ function DiffBodyContent({
if (isDiffLoading) { if (isDiffLoading) {
return ( return (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={foregroundMutedColor} /> <ThemedActivityIndicator size="large" uniProps={foregroundMutedIconColorMapping} />
</View> </View>
); );
} }
@@ -1483,30 +1527,27 @@ function computePrErrorMessage(
return prPayloadError?.message ?? null; return prPayloadError?.message ?? null;
} }
function buildDiffModeTriggerStyle(surfaceColor: string): PressableStyleFn { function buildDiffModeTriggerStyle(): PressableStyleFn {
return ({ hovered, pressed, open }) => [ return ({ hovered, pressed, open }) => [
styles.diffModeTrigger, styles.diffModeTrigger,
(Boolean(hovered) || pressed || Boolean(open)) && (Boolean(hovered) || pressed || Boolean(open)) && styles.diffModeTriggerHovered,
inlineUnistylesStyle({ backgroundColor: surfaceColor }),
]; ];
} }
function buildExpandAllButtonStyle(surfaceColor: string): PressableStyleFn { function buildExpandAllButtonStyle(): PressableStyleFn {
return ({ hovered, pressed }) => [ return ({ hovered, pressed }) => [
styles.expandAllButton, styles.expandAllButton,
(Boolean(hovered) || pressed) && inlineUnistylesStyle({ backgroundColor: surfaceColor }), (Boolean(hovered) || pressed) && styles.toggleButtonSelected,
]; ];
} }
function buildToggleButtonStyle( function buildToggleButtonStyle(
selected: boolean, selected: boolean,
baseStyles: StyleProp<ViewStyle> | StyleProp<ViewStyle>[], baseStyles: StyleProp<ViewStyle> | StyleProp<ViewStyle>[],
surfaceColor: string,
): PressableStyleFn { ): PressableStyleFn {
return ({ hovered, pressed }) => [ return ({ hovered, pressed }) => [
baseStyles, baseStyles,
(selected || Boolean(hovered) || pressed) && (selected || Boolean(hovered) || pressed) && styles.toggleButtonSelected,
inlineUnistylesStyle({ backgroundColor: surfaceColor }),
]; ];
} }
@@ -1521,7 +1562,7 @@ export function GitDiffPane({
hideHeaderRow, hideHeaderRow,
enabled, enabled,
}: GitDiffPaneProps) { }: GitDiffPaneProps) {
const { theme } = useUnistyles(); const { settings: appSettings } = useAppSettings();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = isWeb && !isMobile; const showDesktopWebScrollbar = isWeb && !isMobile;
const canUseSplitLayout = isWeb && !isMobile; const canUseSplitLayout = isWeb && !isMobile;
@@ -1557,56 +1598,44 @@ export function GitDiffPane({
handleLayoutChange("split"); handleLayoutChange("split");
}, [handleLayoutChange]); }, [handleLayoutChange]);
const controlSurfaceColor = theme.colors.surface2; const codeFontSize = appSettings.codeFontSize;
const diffModeTriggerStyle = useMemo( const diffBodyLineHeight = Math.round(codeFontSize * 1.5);
() => buildDiffModeTriggerStyle(controlSurfaceColor), const diffBodyTypographyKey = [appSettings.monoFontFamily, codeFontSize, diffBodyLineHeight].join(
[controlSurfaceColor], ":",
); );
const diffModeTriggerStyle = useMemo(() => buildDiffModeTriggerStyle(), []);
const unifiedToggleStyle = useMemo( const unifiedToggleStyle = useMemo(
() => () =>
buildToggleButtonStyle( buildToggleButtonStyle(changesPreferences.layout === "unified", [
changesPreferences.layout === "unified", styles.toggleButton,
[styles.toggleButton, styles.toggleButtonGroupStart], styles.toggleButtonGroupStart,
controlSurfaceColor, ]),
), [changesPreferences.layout],
[changesPreferences.layout, controlSurfaceColor],
); );
const splitToggleStyle = useMemo( const splitToggleStyle = useMemo(
() => () =>
buildToggleButtonStyle( buildToggleButtonStyle(changesPreferences.layout === "split", [
changesPreferences.layout === "split", styles.toggleButton,
[styles.toggleButton, styles.toggleButtonGroupEnd], styles.toggleButtonGroupEnd,
controlSurfaceColor, ]),
), [changesPreferences.layout],
[changesPreferences.layout, controlSurfaceColor],
); );
const hideWhitespaceToggleStyle = useMemo( const hideWhitespaceToggleStyle = useMemo(
() => () => buildToggleButtonStyle(changesPreferences.hideWhitespace, styles.expandAllButton),
buildToggleButtonStyle( [changesPreferences.hideWhitespace],
changesPreferences.hideWhitespace,
styles.expandAllButton,
controlSurfaceColor,
),
[changesPreferences.hideWhitespace, controlSurfaceColor],
); );
const wrapLinesToggleStyle = useMemo( const wrapLinesToggleStyle = useMemo(
() => buildToggleButtonStyle(wrapLines, styles.expandAllButton, controlSurfaceColor), () => buildToggleButtonStyle(wrapLines, styles.expandAllButton),
[wrapLines, controlSurfaceColor], [wrapLines],
); );
const expandAllToggleStyle = useMemo( const expandAllToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
() => buildExpandAllButtonStyle(controlSurfaceColor),
[controlSurfaceColor],
);
const refreshToggleStyle = useMemo( const refreshToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
() => buildExpandAllButtonStyle(controlSurfaceColor),
[controlSurfaceColor],
);
const toast = useToast(); const toast = useToast();
const refreshSupported = useSessionStore( const refreshSupported = useSessionStore(
@@ -1754,9 +1783,8 @@ export function GitDiffPane({
const bodyHeightByKeyRef = useRef<Record<string, number>>({}); const bodyHeightByKeyRef = useRef<Record<string, number>>({});
const defaultHeaderHeightRef = useRef<number>(44); const defaultHeaderHeightRef = useRef<number>(44);
const [heightVersion, setHeightVersion] = useState(0); const [heightVersion, setHeightVersion] = useState(0);
const diffBodyLineHeight = theme.lineHeight.diff; const diffBodyChromeHeight = BORDER_WIDTH[1] * 2;
const diffBodyChromeHeight = theme.borderWidth[1] * 2; const statusBodyHeightEstimate = diffBodyChromeHeight + SPACING[4] * 2 + diffBodyLineHeight;
const statusBodyHeightEstimate = diffBodyChromeHeight + theme.spacing[4] * 2 + diffBodyLineHeight;
const { flatItems, stickyHeaderIndices } = useMemo(() => { const { flatItems, stickyHeaderIndices } = useMemo(() => {
const items: DiffFlatItem[] = []; const items: DiffFlatItem[] = [];
const stickyIndices: number[] = []; const stickyIndices: number[] = [];
@@ -1777,12 +1805,13 @@ export function GitDiffPane({
const getBodyHeightKey = useCallback( const getBodyHeightKey = useCallback(
(file: ParsedDiffFile): string => { (file: ParsedDiffFile): string => {
if (file.status === "too_large" || file.status === "binary") { if (file.status === "too_large" || file.status === "binary") {
return `${effectiveLayout}:${wrapLines ? "wrap" : "scroll"}:${file.path}:${file.status}`; return `${effectiveLayout}:${wrapLines ? "wrap" : "scroll"}:${diffBodyTypographyKey}:${file.path}:${file.status}`;
} }
return [ return [
effectiveLayout, effectiveLayout,
wrapLines ? "wrap" : "scroll", wrapLines ? "wrap" : "scroll",
diffBodyTypographyKey,
file.path, file.path,
file.status ?? "ok", file.status ?? "ok",
file.additions, file.additions,
@@ -1792,7 +1821,7 @@ export function GitDiffPane({
getDiffContentLength(file), getDiffContentLength(file),
].join(":"); ].join(":");
}, },
[effectiveLayout, wrapLines], [diffBodyTypographyKey, effectiveLayout, wrapLines],
); );
const estimateBodyHeight = useCallback( const estimateBodyHeight = useCallback(
@@ -1961,6 +1990,7 @@ export function GitDiffPane({
file={item.file} file={item.file}
layout={effectiveLayout} layout={effectiveLayout}
wrapLines={wrapLines} wrapLines={wrapLines}
codeFontSize={codeFontSize}
reviewActions={reviewActions} reviewActions={reviewActions}
onBodyHeightChange={handleBodyHeightChange} onBodyHeightChange={handleBodyHeightChange}
testID={`diff-file-${item.fileIndex}-body`} testID={`diff-file-${item.fileIndex}-body`}
@@ -1968,6 +1998,7 @@ export function GitDiffPane({
); );
}, },
[ [
codeFontSize,
effectiveLayout, effectiveLayout,
handleBodyHeightChange, handleBodyHeightChange,
handleHeaderHeightChange, handleHeaderHeightChange,
@@ -2015,34 +2046,41 @@ export function GitDiffPane({
() => ({ () => ({
expandedPathsArray, expandedPathsArray,
effectiveLayout, effectiveLayout,
diffBodyTypographyKey,
heightVersion, heightVersion,
wrapLines, wrapLines,
reviewActions, reviewActions,
}), }),
[expandedPathsArray, effectiveLayout, heightVersion, wrapLines, reviewActions], [
expandedPathsArray,
effectiveLayout,
diffBodyTypographyKey,
heightVersion,
wrapLines,
reviewActions,
],
); );
const hasChanges = files.length > 0; const hasChanges = files.length > 0;
const diffErrorMessage = diffPayloadError?.message ?? null; const diffErrorMessage = diffPayloadError?.message ?? null;
const prErrorMessage = computePrErrorMessage(githubFeaturesEnabled, prPayloadError); const prErrorMessage = computePrErrorMessage(githubFeaturesEnabled, prPayloadError);
const baseRefLabel = useMemo(() => computeBaseRefLabel(baseRef), [baseRef]); const baseRefLabel = useMemo(() => computeBaseRefLabel(baseRef), [baseRef]);
const iconColor = theme.colors.foregroundMuted;
const gitActionsIcons = useMemo( const gitActionsIcons = useMemo(
() => ({ () => ({
commit: <GitCommitHorizontal size={16} color={iconColor} />, commit: <ThemedGitCommitHorizontal size={16} uniProps={foregroundMutedIconColorMapping} />,
pull: <Download size={16} color={iconColor} />, pull: <ThemedDownload size={16} uniProps={foregroundMutedIconColorMapping} />,
push: <Upload size={16} color={iconColor} />, push: <ThemedUpload size={16} uniProps={foregroundMutedIconColorMapping} />,
pullAndPush: <ArrowDownUp size={16} color={iconColor} />, pullAndPush: <ThemedArrowDownUp size={16} uniProps={foregroundMutedIconColorMapping} />,
viewPr: <GitHubIcon size={16} color={iconColor} />, viewPr: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
createPr: <GitHubIcon size={16} color={iconColor} />, createPr: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
mergePrSquash: <GitHubIcon size={16} color={iconColor} />, mergePrSquash: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
mergePrMerge: <GitHubIcon size={16} color={iconColor} />, mergePrMerge: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
mergePrRebase: <GitHubIcon size={16} color={iconColor} />, mergePrRebase: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
merge: <GitMerge size={16} color={iconColor} />, merge: <ThemedGitMerge size={16} uniProps={foregroundMutedIconColorMapping} />,
mergeFromBase: <RefreshCcw size={16} color={iconColor} />, mergeFromBase: <ThemedRefreshCcw size={16} uniProps={foregroundMutedIconColorMapping} />,
archive: <Archive size={16} color={iconColor} />, archive: <ThemedArchive size={16} uniProps={foregroundMutedIconColorMapping} />,
}), }),
[iconColor], [],
); );
const { gitActions, branchLabel } = useGitActions({ serverId, cwd, icons: gitActionsIcons }); const { gitActions, branchLabel } = useGitActions({ serverId, cwd, icons: gitActionsIcons });
const committedDiffDescription = useMemo( const committedDiffDescription = useMemo(
@@ -2076,7 +2114,6 @@ export function GitDiffPane({
handleDiffListScroll={handleDiffListScroll} handleDiffListScroll={handleDiffListScroll}
onContentSizeChange={scrollbar.onContentSizeChange} onContentSizeChange={scrollbar.onContentSizeChange}
showDesktopWebScrollbar={showDesktopWebScrollbar} showDesktopWebScrollbar={showDesktopWebScrollbar}
foregroundMutedColor={theme.colors.foregroundMuted}
/> />
); );
@@ -2085,7 +2122,7 @@ export function GitDiffPane({
{!hideHeaderRow ? ( {!hideHeaderRow ? (
<View style={styles.header} testID="changes-header"> <View style={styles.header} testID="changes-header">
<View style={styles.headerLeft}> <View style={styles.headerLeft}>
<GitBranch size={16} color={theme.colors.foregroundMuted} /> <ThemedGitBranch size={16} uniProps={foregroundMutedIconColorMapping} />
<Text style={styles.branchLabel} testID="changes-branch" numberOfLines={1}> <Text style={styles.branchLabel} testID="changes-branch" numberOfLines={1}>
{branchLabel} {branchLabel}
</Text> </Text>
@@ -2107,7 +2144,7 @@ export function GitDiffPane({
<Text style={styles.diffStatusText} numberOfLines={1}> <Text style={styles.diffStatusText} numberOfLines={1}>
{diffMode === "uncommitted" ? "Uncommitted" : "Committed"} {diffMode === "uncommitted" ? "Uncommitted" : "Committed"}
</Text> </Text>
<ChevronDown size={12} color={theme.colors.foregroundMuted} /> <ThemedChevronDown size={12} uniProps={foregroundMutedIconColorMapping} />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" width={260} testID="changes-diff-status-menu"> <DropdownMenuContent align="start" width={260} testID="changes-diff-status-menu">
<DropdownMenuItem <DropdownMenuItem
@@ -2551,13 +2588,15 @@ const styles = StyleSheet.create((theme) => ({
elevation: 4, elevation: 4,
overflow: "visible", overflow: "visible",
}, },
diffTextMetrics: {
fontSize: theme.fontSize.code,
lineHeight: theme.lineHeight.diff,
fontFamily: theme.fontFamily.mono,
},
lineNumberText: { lineNumberText: {
width: "100%", width: "100%",
textAlign: "right", textAlign: "right",
paddingRight: theme.spacing[2], paddingRight: theme.spacing[2],
fontSize: theme.fontSize.code,
lineHeight: theme.lineHeight.diff,
fontFamily: theme.fontFamily.mono,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
userSelect: "none", userSelect: "none",
}, },
@@ -2570,9 +2609,6 @@ const styles = StyleSheet.create((theme) => ({
diffLineText: { diffLineText: {
flex: 1, flex: 1,
paddingRight: theme.spacing[3], paddingRight: theme.spacing[3],
fontSize: theme.fontSize.code,
lineHeight: theme.lineHeight.diff,
fontFamily: theme.fontFamily.mono,
color: theme.colors.foreground, color: theme.colors.foreground,
userSelect: "text", userSelect: "text",
}, },
@@ -2621,7 +2657,7 @@ const styles = StyleSheet.create((theme) => ({
}, },
})); }));
const HEADER_LINE_TEXT_STYLE = [styles.diffLineText, styles.headerLineText]; const HEADER_LINE_TEXT_STYLE = [styles.diffTextMetrics, styles.diffLineText, styles.headerLineText];
const FILE_SECTION_BODY_STYLE = [styles.fileSectionBodyContainer, styles.fileSectionBorder]; const FILE_SECTION_BODY_STYLE = [styles.fileSectionBodyContainer, styles.fileSectionBorder];
const DIFF_CONTENT_SPLIT_ROW_STYLE = [styles.diffContent, styles.splitRow]; const DIFF_CONTENT_SPLIT_ROW_STYLE = [styles.diffContent, styles.splitRow];
const DIFF_CONTENT_ROW_STYLE = [styles.diffContent, styles.diffContentRow]; const DIFF_CONTENT_ROW_STYLE = [styles.diffContent, styles.diffContentRow];
+24 -14
View File
@@ -11,11 +11,12 @@ import {
type StyleProp, type StyleProp,
type ViewStyle, type ViewStyle,
} from "react-native"; } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import type { Theme } from "@/styles/theme";
import type { ShortcutKey } from "@/utils/format-shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut";
import { useWorkspaceFocusRestoration } from "@/workspace/focus"; import { useWorkspaceFocusRestoration } from "@/workspace/focus";
import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store"; import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store";
@@ -77,6 +78,14 @@ const INLINE_REVIEW_GAP = 6;
export const SMALL_ACTION_HIT_SLOP = 8; export const SMALL_ACTION_HIT_SLOP = 8;
const REVIEW_CANCEL_SHORTCUT_KEYS: ShortcutKey[] = ["Esc"]; const REVIEW_CANCEL_SHORTCUT_KEYS: ShortcutKey[] = ["Esc"];
const REVIEW_SAVE_SHORTCUT_KEYS: ShortcutKey[] = ["mod", "Enter"]; const REVIEW_SAVE_SHORTCUT_KEYS: ShortcutKey[] = ["mod", "Enter"];
const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const destructiveIconColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
const accentForegroundIconColorMapping = (theme: Theme) => ({
color: theme.colors.accentForeground,
});
const ThemedPencil = withUnistyles(Pencil);
const ThemedPlus = withUnistyles(Plus);
const ThemedTrash2 = withUnistyles(Trash2);
export interface InlineReviewEditorState { export interface InlineReviewEditorState {
target: ReviewableDiffTarget; target: ReviewableDiffTarget;
@@ -274,6 +283,7 @@ export function InlineReviewGutterCell({
isLineHovered = false, isLineHovered = false,
onStartComment, onStartComment,
style, style,
actionTestID,
}: { }: {
children: ReactNode; children: ReactNode;
reviewTarget: ReviewableDiffTarget | null | undefined; reviewTarget: ReviewableDiffTarget | null | undefined;
@@ -282,8 +292,8 @@ export function InlineReviewGutterCell({
isLineHovered?: boolean; isLineHovered?: boolean;
onStartComment: (target: ReviewableDiffTarget) => void; onStartComment: (target: ReviewableDiffTarget) => void;
style?: StyleProp<ViewStyle>; style?: StyleProp<ViewStyle>;
actionTestID?: string;
}) { }) {
const { theme } = useUnistyles();
const canComment = Boolean(reviewTarget); const canComment = Boolean(reviewTarget);
const hasComments = comments.length > 0; const hasComments = comments.length > 0;
const [isGutterHovered, setIsGutterHovered] = useState(false); const [isGutterHovered, setIsGutterHovered] = useState(false);
@@ -345,8 +355,8 @@ export function InlineReviewGutterCell({
<View style={labelStyle}> <View style={labelStyle}>
{children} {children}
{showAction ? ( {showAction ? (
<View style={styles.gutterActionIcon}> <View style={styles.gutterActionIcon} testID={actionTestID}>
<Plus size={16} strokeWidth={2.4} color={theme.colors.accentForeground} /> <ThemedPlus size={16} strokeWidth={2.4} uniProps={accentForegroundIconColorMapping} />
</View> </View>
) : null} ) : null}
</View> </View>
@@ -429,8 +439,6 @@ function CommentRow({
onEditComment: (target: ReviewableDiffTarget, comment: ReviewDraftComment) => void; onEditComment: (target: ReviewableDiffTarget, comment: ReviewDraftComment) => void;
onDeleteComment: (id: string) => void; onDeleteComment: (id: string) => void;
}) { }) {
const { theme } = useUnistyles();
const handleEdit = useCallback( const handleEdit = useCallback(
() => onEditComment(reviewTarget, comment), () => onEditComment(reviewTarget, comment),
[onEditComment, reviewTarget, comment], [onEditComment, reviewTarget, comment],
@@ -455,7 +463,7 @@ function CommentRow({
onPress={handleEdit} onPress={handleEdit}
style={iconButtonStyle} style={iconButtonStyle}
> >
<Pencil size={14} color={theme.colors.foregroundMuted} /> <ThemedPencil size={14} uniProps={foregroundMutedIconColorMapping} />
</Pressable> </Pressable>
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
@@ -465,7 +473,7 @@ function CommentRow({
onPress={handleDelete} onPress={handleDelete}
style={iconButtonDestructiveStyle} style={iconButtonDestructiveStyle}
> >
<Trash2 size={14} color={theme.colors.destructive} /> <ThemedTrash2 size={14} uniProps={destructiveIconColorMapping} />
</Pressable> </Pressable>
</View> </View>
</View> </View>
@@ -499,7 +507,6 @@ export function InlineReviewEditor({
onSave: (body: string) => void; onSave: (body: string) => void;
testID?: string; testID?: string;
}) { }) {
const { theme } = useUnistyles();
const inputRef = useRef<TextInput | null>(null); const inputRef = useRef<TextInput | null>(null);
const focus = useWorkspaceFocusRestoration(); const focus = useWorkspaceFocusRestoration();
const canShowKeyboardHints = useCanShowReviewKeyboardHints(); const canShowKeyboardHints = useCanShowReviewKeyboardHints();
@@ -577,7 +584,7 @@ export function InlineReviewEditor({
accessibilityLabel="Review comment" accessibilityLabel="Review comment"
testID={testID ? `${testID}-input` : undefined} testID={testID ? `${testID}-input` : undefined}
placeholder="Leave a comment" placeholder="Leave a comment"
placeholderTextColor={theme.colors.foregroundMuted} placeholderTextColor={styles.placeholderColor.color}
multiline multiline
value={body} value={body}
onChangeText={setBody} onChangeText={setBody}
@@ -617,16 +624,16 @@ export function InlineReviewEditor({
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
gutterInner: { gutterInner: {
minHeight: theme.lineHeight.diff, minHeight: theme.lineHeight.diff,
alignItems: "center", alignItems: "stretch",
justifyContent: "center", justifyContent: "flex-start",
overflow: "visible", overflow: "visible",
}, },
gutterLabel: { gutterLabel: {
width: "100%", width: "100%",
minWidth: 0, minWidth: 0,
height: theme.lineHeight.diff, height: theme.lineHeight.diff,
alignItems: "center", alignItems: "stretch",
justifyContent: "center", justifyContent: "flex-start",
position: "relative", position: "relative",
overflow: "visible", overflow: "visible",
}, },
@@ -646,6 +653,9 @@ const styles = StyleSheet.create((theme) => ({
zIndex: 10, zIndex: 10,
elevation: 10, elevation: 10,
}, },
placeholderColor: {
color: theme.colors.foregroundMuted,
},
threadContainer: { threadContainer: {
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,