Files
paseo/packages/app/src/git/diff-pane.tsx
Mohamed Boudra a1c8e1a1f9 Add appearance settings for theme, fonts, and syntax highlighting (#1236)
* Add appearance settings for theme, fonts, and syntax highlighting

New Appearance section in Settings to configure the app theme, UI and
mono font family + size, and a syntax-highlighting theme chosen
independently of light/dark. Font family fields default to empty and
show the active system stack as the placeholder; a live split-diff
preview reflects the choices as they change. The theme picker moves
here from General.

Preferences are client-only (AsyncStorage) and applied at runtime by
patching every registered Unistyles theme via
UnistylesRuntime.updateTheme, so existing StyleSheet consumers repaint
with no per-component preference reads and no useUnistyles. No
daemon/protocol change.

* Expand syntax themes and move the preview under the chooser

Replace the small fixed syntax-theme set with eight popular themes — GitHub,
Catppuccin, Dracula, Tokyo Night, One, Nord, Gruvbox, Solarized — built from a
compact per-theme role palette. The app theme and syntax theme stay separate;
the only coupling is the light/dark axis: a theme with both variants uses its
light palette on a light app and its dark palette on a dark app, while the code
frame (gutter, line numbers, background) keeps following the app theme. Default
is now GitHub (GitHub keeps its existing hand-tuned maps for an exact match).

Move the live preview directly under the syntax-theme chooser, drop the
"preview.ts" filename header, and use a clearer code snippet for it.

* Update settings e2e for the moved theme picker and Appearance section

The theme picker moved from General to the new Appearance section, so the
General-content assertion no longer finds "Theme" — point it at "Default send"
(which stays in General) and add Appearance to the e2e section map plus an
expectAppearanceContent check, and exercise the Appearance section in the
sidebar-navigation test.

* Render the appearance preview as a unified diff

Switch the live preview from side-by-side columns to a single unified diff:
unchanged context lines plus "-"/"+" rows for the change, matching how diffs
read elsewhere in the app. Same snippet, mono font, syntax colors, and live
code-font draft behavior.

* Fix code font size change shrinking all UI text

applyAppearance scaled the live (already-patched) theme ramp instead of the
authored FONT_SIZE ramp, so it was not idempotent: every appearance change
re-scaled the current sizes, compounding the whole-UI font scale. With any
non-default UI size, changing the code size — or any setting — shrank (or grew)
all app text, and it persisted into the theme. Build the ramp from the canonical
FONT_SIZE ramp each apply (code size still set absolutely), so applies are
idempotent and a code-size change never touches the UI ramp.

* Apply the interface font across the app on web

react-native-web stamps a default font onto every text element, so the interface
font only reached the few components that set a font explicitly. Inject one rule
that points all text at a CSS variable (updated live as the setting changes),
with high specificity (1,2,0) so it deterministically beats RN-web's base font
and Unistyles' generated classes — no stylesheet-order fragility. Code, diff, and
monospace surfaces carry a `data-pmono` marker (and their subtrees are excluded),
so they keep their code font.

Web-only: React Native has no global font cascade, so native still applies the UI
font only where components opt in. Inline `code` within chat prose is a known
minor gap (its render paths aren't tagged yet).

* Keep preview code in the code font

* Apply code font settings to terminals

* Fix settings host picker import after rebase

* Format terminal webview bundle
2026-05-31 16:09:01 +08:00

2629 lines
78 KiB
TypeScript

import {
useState,
useCallback,
useEffect,
useMemo,
useRef,
memo,
type ReactElement,
type ReactNode,
type RefObject,
} from "react";
import { DiffStat } from "@/components/diff-stat";
import {
View,
Text,
ActivityIndicator,
Pressable,
FlatList,
type LayoutChangeEvent,
type NativeSyntheticEvent,
type NativeScrollEvent,
type PressableStateCallbackType,
type FlatListProps,
type StyleProp,
type ViewStyle,
type TextStyle,
} from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
AlignJustify,
Archive,
ArrowDownUp,
ChevronDown,
Columns2,
Download,
GitBranch,
GitCommitHorizontal,
GitMerge,
ListChevronsDownUp,
ListChevronsUpDown,
Pilcrow,
RefreshCcw,
RotateCw,
Upload,
WrapText,
} from "lucide-react-native";
import {
useCheckoutDiffQuery,
type ParsedDiffFile,
type DiffLine,
type HighlightToken,
} from "@/git/use-diff-query";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { DiffScroll } from "@/components/diff-scroll";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { shouldAnchorHeaderBeforeCollapse } from "@/git/diff-scroll";
import {
buildSplitDiffRows,
buildUnifiedDiffLines,
type ReviewableDiffTarget,
type SplitDiffDisplayLine,
type SplitDiffRow,
} from "@/utils/diff-layout";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { GitHubIcon } from "@/components/icons/github-icon";
import { lineNumberGutterWidth } from "@/components/code-insets";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { GitActionsSplitButton } from "@/git/actions-split-button";
import { useGitActions } from "@/git/use-actions";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { useToast } from "@/contexts/toast-context";
import { useSessionStore } from "@/stores/session-store";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { usePanelStore } from "@/stores/panel-store";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import {
formatDiffContentText,
formatDiffGutterText,
hasVisibleDiffTokens,
} from "@/utils/diff-rendering";
import { isWeb, isNative } from "@/constants/platform";
import {
buildWorkspaceAttachmentScopeKey,
useWorkspaceAttachmentsStore,
} from "@/attachments/workspace-attachments-store";
import {
buildReviewDraftScopeKey,
buildReviewDraftKey,
useActiveReviewDraftMode,
useReviewAttachmentSnapshot,
useSetActiveReviewDraftMode,
type ReviewDraftComment,
type ReviewDraftMode,
getInlineReviewThreadState,
getSplitInlineReviewThreadState,
InlineReviewGutterCell,
InlineReviewThread,
isInlineReviewEditorForTarget,
useInlineReviewController,
type InlineReviewActions,
} from "@/review";
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) {
return [styles.fileHeader, pressed && styles.fileHeaderPressed];
}
interface HighlightedTextProps {
tokens: HighlightToken[];
wrapLines?: boolean;
}
type WrappedWebTextStyle = TextStyle & {
whiteSpace?: "pre" | "pre-wrap";
overflowWrap?: "normal" | "anywhere";
};
function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined {
if (isNative) {
return undefined;
}
return wrapLines
? { whiteSpace: "pre-wrap", overflowWrap: "anywhere" }
: { whiteSpace: "pre", overflowWrap: "normal" };
}
function HighlightedToken({ token }: { token: HighlightToken }) {
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
}
function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) {
const { theme } = useUnistyles();
const lineHeight = theme.lineHeight.diff;
const containerStyle = useMemo(
() => [styles.diffLineText, { lineHeight, ...getWrappedTextStyle(wrapLines) }],
[lineHeight, wrapLines],
);
const keyedTokens = useMemo(
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
[tokens],
);
return (
<Text style={containerStyle}>
{keyedTokens.map(({ key, token }) => (
<HighlightedToken key={key} token={token} />
))}
</Text>
);
}
interface DiffFileSectionProps {
file: ParsedDiffFile;
isExpanded: boolean;
onToggle: (path: string) => void;
onHeaderHeightChange?: (path: string, height: number) => void;
testID?: string;
}
const EMPTY_COMMENTS: readonly ReviewDraftComment[] = [];
function noopStartComment(): void {}
const DIFF_LINE_HOVER_STYLE = isWeb ? ({ cursor: "auto" } as const) : null;
function LongPressableLine({
reviewTarget,
reviewActions,
onHoverChange,
hoverTargetKey,
onHoverTargetChange,
style,
children,
}: {
reviewTarget: ReviewableDiffTarget | null | undefined;
reviewActions: InlineReviewActions | undefined;
onHoverChange?: (hovered: boolean) => void;
hoverTargetKey?: string | null;
onHoverTargetChange?: (key: string | null) => void;
style: StyleProp<ViewStyle>;
children: ReactNode;
}) {
const onStartComment = reviewActions?.onStartComment;
const handlePress = useCallback(() => {
if (reviewTarget && onStartComment) {
onStartComment(reviewTarget);
}
}, [reviewTarget, onStartComment]);
const handleHoverIn = useCallback(() => {
onHoverChange?.(true);
if (hoverTargetKey) {
onHoverTargetChange?.(hoverTargetKey);
}
}, [hoverTargetKey, onHoverChange, onHoverTargetChange]);
const handleHoverOut = useCallback(() => {
onHoverChange?.(false);
if (hoverTargetKey) {
onHoverTargetChange?.(null);
}
}, [hoverTargetKey, onHoverChange, onHoverTargetChange]);
const hoverStyle = useMemo(() => [style, DIFF_LINE_HOVER_STYLE], [style]);
if (isWeb && (onHoverChange || onHoverTargetChange)) {
return (
<Pressable onHoverIn={handleHoverIn} onHoverOut={handleHoverOut} style={hoverStyle}>
{children}
</Pressable>
);
}
if (!isNative || !reviewTarget || !onStartComment) {
return <View style={style}>{children}</View>;
}
return (
<Pressable onPress={handlePress} style={style}>
{children}
</Pressable>
);
}
function lineTypeBackground(type: DiffLine["type"] | undefined | null) {
if (!type) return styles.emptySplitCell;
if (type === "add") return styles.addLineContainer;
if (type === "remove") return styles.removeLineContainer;
if (type === "header") return styles.headerLineContainer;
return styles.contextLineContainer;
}
function DiffGutterCell({
lineNumber,
type,
gutterWidth,
reviewTarget,
reviewActions,
isLineHovered,
style,
}: {
lineNumber: number | null;
type: DiffLine["type"] | undefined | null;
gutterWidth: number;
reviewTarget?: ReviewableDiffTarget | null;
reviewActions?: InlineReviewActions;
isLineHovered?: boolean;
style?: StyleProp<ViewStyle>;
}) {
const containerStyle = useMemo(
() => [
styles.gutterCell,
lineTypeBackground(type),
inlineUnistylesStyle({ width: gutterWidth }),
style,
],
[type, gutterWidth, style],
);
const textStyle = useMemo(
() => [
styles.lineNumberText,
type === "add" && styles.addLineNumberText,
type === "remove" && styles.removeLineNumberText,
],
[type],
);
const comments = useMemo(
() =>
reviewTarget
? (reviewActions?.commentsByTarget.get(reviewTarget.key) ?? EMPTY_COMMENTS)
: EMPTY_COMMENTS,
[reviewTarget, reviewActions?.commentsByTarget],
);
const isEditorOpen = isInlineReviewEditorForTarget(reviewActions?.editor ?? null, reviewTarget);
const onStartComment = reviewActions?.onStartComment ?? noopStartComment;
return (
<InlineReviewGutterCell
reviewTarget={reviewTarget}
comments={comments}
isEditorOpen={isEditorOpen}
isLineHovered={isLineHovered}
onStartComment={onStartComment}
style={containerStyle}
>
<Text numberOfLines={1} style={textStyle}>
{formatDiffGutterText(lineNumber)}
</Text>
</InlineReviewGutterCell>
);
}
function DiffTextLine({
line,
wrapLines,
reviewTarget,
reviewActions,
onHoverChange,
hoverTargetKey,
onHoverTargetChange,
}: {
line: DiffLine;
wrapLines: boolean;
reviewTarget?: ReviewableDiffTarget | null;
reviewActions?: InlineReviewActions;
onHoverChange?: (hovered: boolean) => void;
hoverTargetKey?: string | null;
onHoverTargetChange?: (key: string | null) => void;
}) {
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
const containerStyle = useMemo(
() => [styles.textLineContainer, lineTypeBackground(line.type)],
[line.type],
);
const textStyle = useMemo(
() => [
styles.diffLineText,
getWrappedTextStyle(wrapLines),
line.type === "add" && styles.addLineText,
line.type === "remove" && styles.removeLineText,
line.type === "header" && styles.headerLineText,
line.type === "context" && styles.contextLineText,
],
[line.type, wrapLines],
);
return (
<LongPressableLine
reviewTarget={reviewTarget}
reviewActions={reviewActions}
onHoverChange={onHoverChange}
hoverTargetKey={hoverTargetKey}
onHoverTargetChange={onHoverTargetChange}
style={containerStyle}
>
{line.type !== "header" && visibleTokens ? (
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
) : (
<Text style={textStyle}>{formatDiffContentText(line.content)}</Text>
)}
</LongPressableLine>
);
}
function SplitTextLine({
line,
wrapLines,
reviewActions,
onHoverChange,
hoverTargetKey,
onHoverTargetChange,
}: {
line: SplitDiffDisplayLine | null;
wrapLines: boolean;
reviewActions?: InlineReviewActions;
onHoverChange?: (hovered: boolean) => void;
hoverTargetKey?: string | null;
onHoverTargetChange?: (key: string | null) => void;
}) {
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
const containerStyle = useMemo(
() => [styles.textLineContainer, lineTypeBackground(line?.type)],
[line?.type],
);
const textStyle = useMemo(
() => [
styles.diffLineText,
getWrappedTextStyle(wrapLines),
line?.type === "add" && styles.addLineText,
line?.type === "remove" && styles.removeLineText,
line?.type === "context" && styles.contextLineText,
!line && styles.emptySplitCellText,
],
[line, wrapLines],
);
return (
<LongPressableLine
reviewTarget={line?.reviewTarget}
reviewActions={reviewActions}
onHoverChange={onHoverChange}
hoverTargetKey={hoverTargetKey}
onHoverTargetChange={onHoverTargetChange}
style={containerStyle}
>
{visibleTokens ? (
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
) : (
<Text style={textStyle}>{formatDiffContentText(line?.content)}</Text>
)}
</LongPressableLine>
);
}
function DiffLineView({
line,
lineNumber,
gutterWidth,
wrapLines,
reviewTarget,
reviewActions,
}: {
line: DiffLine;
lineNumber: number | null;
gutterWidth: number;
wrapLines: boolean;
reviewTarget?: ReviewableDiffTarget | null;
reviewActions?: InlineReviewActions;
}) {
const [isLineHovered, setIsLineHovered] = useState(false);
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
const containerStyle = useMemo(
() => [styles.diffLineContainer, lineTypeBackground(line.type)],
[line.type],
);
const textStyle = useMemo(
() => [
styles.diffLineText,
getWrappedTextStyle(wrapLines),
line.type === "add" && styles.addLineText,
line.type === "remove" && styles.removeLineText,
line.type === "header" && styles.headerLineText,
line.type === "context" && styles.contextLineText,
],
[line.type, wrapLines],
);
return (
<LongPressableLine
reviewTarget={reviewTarget}
reviewActions={reviewActions}
onHoverChange={setIsLineHovered}
style={containerStyle}
>
<DiffGutterCell
lineNumber={lineNumber}
type={line.type}
gutterWidth={gutterWidth}
reviewTarget={reviewTarget}
reviewActions={reviewActions}
isLineHovered={isLineHovered}
style={styles.lineNumberGutter}
/>
{line.type !== "header" && visibleTokens ? (
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
) : (
<Text style={textStyle}>{formatDiffContentText(line.content)}</Text>
)}
</LongPressableLine>
);
}
function SplitDiffLine({
line,
gutterWidth,
wrapLines,
reviewActions,
}: {
line: SplitDiffDisplayLine | null;
gutterWidth: number;
wrapLines: boolean;
reviewActions?: InlineReviewActions;
}) {
const [isLineHovered, setIsLineHovered] = useState(false);
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
const containerStyle = useMemo(
() => [styles.diffLineContainer, lineTypeBackground(line?.type)],
[line?.type],
);
const textStyle = useMemo(
() => [
styles.diffLineText,
getWrappedTextStyle(wrapLines),
line?.type === "add" && styles.addLineText,
line?.type === "remove" && styles.removeLineText,
line?.type === "context" && styles.contextLineText,
!line && styles.emptySplitCellText,
],
[line, wrapLines],
);
return (
<LongPressableLine
reviewTarget={line?.reviewTarget}
reviewActions={reviewActions}
onHoverChange={setIsLineHovered}
style={containerStyle}
>
<DiffGutterCell
lineNumber={line?.lineNumber ?? null}
type={line?.type}
gutterWidth={gutterWidth}
reviewTarget={line?.reviewTarget}
reviewActions={reviewActions}
isLineHovered={isLineHovered}
style={styles.lineNumberGutter}
/>
{visibleTokens ? (
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
) : (
<Text style={textStyle}>{formatDiffContentText(line?.content)}</Text>
)}
</LongPressableLine>
);
}
function InlineReviewThreadContent({
reviewTarget,
reviewActions,
reservedHeight,
viewportWidth,
pinToViewport,
}: {
reviewTarget: ReviewableDiffTarget | null | undefined;
reviewActions?: InlineReviewActions;
reservedHeight?: number;
viewportWidth?: number;
pinToViewport?: boolean;
}) {
const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions });
const height = reservedHeight ?? threadState?.height ?? 0;
const placeholderStyle = useMemo<ViewStyle>(
() => inlineUnistylesStyle({ minHeight: height }),
[height],
);
if (height === 0) {
return null;
}
if (!reviewTarget || !reviewActions || !threadState) {
return <View style={placeholderStyle} />;
}
return (
<InlineReviewThread
reviewTarget={reviewTarget}
reviewActions={reviewActions}
height={height}
viewportWidth={viewportWidth}
pinToViewport={pinToViewport}
testID={`review-thread-${reviewTarget.key}`}
/>
);
}
function InlineReviewGutterSpacer({
reviewTarget,
reviewActions,
gutterWidth,
reservedHeight,
style,
}: {
reviewTarget: ReviewableDiffTarget | null | undefined;
reviewActions?: InlineReviewActions;
gutterWidth: number;
reservedHeight?: number;
style?: StyleProp<ViewStyle>;
}) {
const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions });
const height = reservedHeight ?? threadState?.height ?? 0;
const spacerStyle = useMemo<StyleProp<ViewStyle>>(
() => [
styles.inlineReviewGutterSpacer,
inlineUnistylesStyle({ width: gutterWidth, minHeight: height }),
style,
],
[gutterWidth, height, style],
);
if (height === 0) {
return null;
}
return <View style={spacerStyle} />;
}
function InlineReviewRow({
reviewTarget,
reviewActions,
gutterWidth,
reservedHeight,
}: {
reviewTarget: ReviewableDiffTarget | null | undefined;
reviewActions?: InlineReviewActions;
gutterWidth: number;
reservedHeight?: number;
}) {
const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions });
const height = reservedHeight ?? threadState?.height ?? 0;
const gutterSpacerStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.inlineReviewGutterSpacer, inlineUnistylesStyle({ width: gutterWidth })],
[gutterWidth],
);
const placeholderStyle = useMemo<ViewStyle>(
() => inlineUnistylesStyle({ minHeight: height }),
[height],
);
if (height === 0) {
return null;
}
return (
<View style={styles.inlineReviewRow}>
<View style={gutterSpacerStyle} />
{reviewTarget && reviewActions && threadState ? (
<InlineReviewThread
reviewTarget={reviewTarget}
reviewActions={reviewActions}
height={height}
testID={`review-thread-${reviewTarget.key}`}
/>
) : (
<View style={placeholderStyle} />
)}
</View>
);
}
function SplitDiffColumn({
rows,
side,
gutterWidth,
wrapLines,
reviewActions,
showDivider = false,
}: {
rows: SplitDiffRow[];
side: "left" | "right";
gutterWidth: number;
wrapLines: boolean;
reviewActions?: InlineReviewActions;
showDivider?: boolean;
}) {
const [scrollWidth, setScrollWidth] = useState(0);
const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState<string | null>(null);
const wrapCellStyle = useMemo(
() => [styles.splitCell, showDivider && styles.splitCellWithDivider],
[showDivider],
);
const rowCellStyle = useMemo(
() => [styles.splitCell, showDivider && styles.splitCellWithDivider, styles.splitCellRow],
[showDivider],
);
const linesContainerRowStyle = useMemo(
() => [
styles.linesContainer,
scrollWidth > 0 && inlineUnistylesStyle({ minWidth: scrollWidth }),
],
[scrollWidth],
);
const keyedRows = useMemo(() => rows.map((row, i) => ({ key: `row-${i}`, row })), [rows]);
if (wrapLines) {
return (
<View style={wrapCellStyle}>
<View style={styles.linesContainer}>
{keyedRows.map(({ key, row }) => {
if (row.kind === "header") {
return (
<View key={key} style={styles.splitHeaderRow}>
<Text style={HEADER_LINE_TEXT_STYLE}>{row.content}</Text>
</View>
);
}
const line = side === "left" ? row.left : row.right;
const reviewRowState = getSplitInlineReviewThreadState({
left: row.left?.reviewTarget,
right: row.right?.reviewTarget,
reviewActions,
});
return (
<View key={key}>
<SplitDiffLine
line={line}
gutterWidth={gutterWidth}
wrapLines={wrapLines}
reviewActions={reviewActions}
/>
<InlineReviewRow
reviewTarget={line?.reviewTarget}
reviewActions={reviewActions}
gutterWidth={gutterWidth}
reservedHeight={reviewRowState?.height}
/>
</View>
);
})}
</View>
</View>
);
}
return (
<View style={rowCellStyle}>
<View style={styles.gutterColumn}>
{keyedRows.map(({ key, row }) => {
if (row.kind === "header") {
return (
<DiffGutterCell key={key} lineNumber={null} type="header" gutterWidth={gutterWidth} />
);
}
const line = side === "left" ? row.left : row.right;
const reviewTargetKey = line?.reviewTarget?.key ?? null;
const reviewRowState = getSplitInlineReviewThreadState({
left: row.left?.reviewTarget,
right: row.right?.reviewTarget,
reviewActions,
});
return (
<View key={key}>
<DiffGutterCell
lineNumber={line?.lineNumber ?? null}
type={line?.type}
gutterWidth={gutterWidth}
reviewTarget={line?.reviewTarget}
reviewActions={reviewActions}
isLineHovered={
reviewTargetKey !== null && hoveredReviewTargetKey === reviewTargetKey
}
/>
<InlineReviewGutterSpacer
reviewTarget={line?.reviewTarget}
reviewActions={reviewActions}
gutterWidth={gutterWidth}
reservedHeight={reviewRowState?.height}
/>
</View>
);
})}
</View>
<DiffScroll
scrollViewWidth={scrollWidth}
onScrollViewWidthChange={setScrollWidth}
style={styles.splitColumnScroll}
contentContainerStyle={styles.diffContentInner}
>
<View style={linesContainerRowStyle}>
{keyedRows.map(({ key, row }) => {
if (row.kind === "header") {
return (
<View key={key} style={styles.splitHeaderRow}>
<Text style={HEADER_LINE_TEXT_STYLE}>{row.content}</Text>
</View>
);
}
const line = side === "left" ? row.left : row.right;
const reviewTargetKey = line?.reviewTarget?.key ?? null;
const reviewRowState = getSplitInlineReviewThreadState({
left: row.left?.reviewTarget,
right: row.right?.reviewTarget,
reviewActions,
});
return (
<View key={key}>
<SplitTextLine
line={line}
wrapLines={false}
reviewActions={reviewActions}
hoverTargetKey={reviewTargetKey}
onHoverTargetChange={setHoveredReviewTargetKey}
/>
<InlineReviewThreadContent
reviewTarget={line?.reviewTarget}
reviewActions={reviewActions}
reservedHeight={reviewRowState?.height}
viewportWidth={scrollWidth}
pinToViewport
/>
</View>
);
})}
</View>
</DiffScroll>
</View>
);
}
const DiffFileHeader = memo(function DiffFileHeader({
file,
isExpanded,
onToggle,
onHeaderHeightChange,
testID,
}: DiffFileSectionProps) {
const layoutYRef = useRef<number | null>(null);
const pressHandledRef = useRef(false);
const pressInRef = useRef<{ ts: number; pageX: number; pageY: number } | null>(null);
const toggleExpanded = useCallback(() => {
pressHandledRef.current = true;
onToggle(file.path);
}, [file.path, onToggle]);
const handleLayout = useCallback(
(event: LayoutChangeEvent) => {
layoutYRef.current = event.nativeEvent.layout.y;
onHeaderHeightChange?.(file.path, event.nativeEvent.layout.height);
},
[file.path, onHeaderHeightChange],
);
const handlePressIn = useCallback((event: { nativeEvent: { pageX: number; pageY: number } }) => {
pressHandledRef.current = false;
pressInRef.current = {
ts: Date.now(),
pageX: event.nativeEvent.pageX,
pageY: event.nativeEvent.pageY,
};
}, []);
const handlePressOut = useCallback(
(event: { nativeEvent: { pageX: number; pageY: number } }) => {
if (isNative && !pressHandledRef.current && layoutYRef.current === 0 && pressInRef.current) {
const durationMs = Date.now() - pressInRef.current.ts;
const dx = event.nativeEvent.pageX - pressInRef.current.pageX;
const dy = event.nativeEvent.pageY - pressInRef.current.pageY;
const distance = Math.hypot(dx, dy);
if (durationMs <= 500 && distance <= 12) {
toggleExpanded();
}
}
},
[toggleExpanded],
);
const containerStyle = useMemo(
() => [styles.fileSectionHeaderContainer, isExpanded && styles.fileSectionHeaderExpanded],
[isExpanded],
);
return (
<View style={containerStyle} onLayout={handleLayout} testID={testID}>
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
testID={testID ? `${testID}-toggle` : undefined}
style={fileHeaderPressableStyle}
// Android: prevent parent pan/scroll gestures from canceling the tap release.
cancelable={false}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
onPress={toggleExpanded}
>
<View style={styles.fileHeaderLeft}>
<Text style={styles.fileName} numberOfLines={1}>
{file.path.split("/").pop()}
</Text>
<Text style={styles.fileDir} numberOfLines={1}>
{file.path.includes("/")
? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}`
: ""}
</Text>
{file.isNew && (
<View style={styles.newBadge}>
<Text style={styles.newBadgeText}>New</Text>
</View>
)}
{file.isDeleted && (
<View style={styles.deletedBadge}>
<Text style={styles.deletedBadgeText}>Deleted</Text>
</View>
)}
</View>
<View style={styles.fileHeaderRight}>
<DiffStat additions={file.additions} deletions={file.deletions} />
</View>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" offset={6} maxWidth={520}>
<Text style={styles.tooltipText}>{file.path}</Text>
</TooltipContent>
</Tooltip>
</View>
);
});
function DiffFileBody({
file,
layout,
wrapLines,
reviewActions,
onBodyHeightChange,
testID,
}: {
file: ParsedDiffFile;
layout: "unified" | "split";
wrapLines: boolean;
reviewActions?: InlineReviewActions;
onBodyHeightChange?: (file: ParsedDiffFile, height: number) => void;
testID?: string;
}) {
const [scrollViewWidth, setScrollViewWidth] = useState(0);
const [bodyWidth, setBodyWidth] = useState(0);
const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState<string | null>(null);
const { theme } = useUnistyles();
const handleLayout = useCallback(
(event: LayoutChangeEvent) => {
setBodyWidth(event.nativeEvent.layout.width);
onBodyHeightChange?.(file, event.nativeEvent.layout.height);
},
[file, onBodyHeightChange],
);
const availableWidth = bodyWidth > 0 ? bodyWidth : scrollViewWidth;
const linesContainerRowStyle = useMemo(
() => [
styles.linesContainer,
availableWidth > 0 && inlineUnistylesStyle({ minWidth: availableWidth }),
],
[availableWidth],
);
return (
<View style={FILE_SECTION_BODY_STYLE} onLayout={handleLayout} testID={testID}>
{(() => {
if (file.status === "too_large" || file.status === "binary") {
return (
<View style={styles.statusMessageContainer}>
<Text style={styles.statusMessageText}>
{file.status === "binary" ? "Binary file" : "Diff too large to display"}
</Text>
</View>
);
}
let maxLineNo = 0;
for (const hunk of file.hunks) {
maxLineNo = Math.max(
maxLineNo,
hunk.oldStart + hunk.oldCount,
hunk.newStart + hunk.newCount,
);
}
const gutterWidth = lineNumberGutterWidth(maxLineNo, theme.fontSize.code);
if (layout === "split") {
const rows = buildSplitDiffRows(file);
return (
<View style={DIFF_CONTENT_SPLIT_ROW_STYLE} dataSet={CODE_SURFACE_DATASET}>
<SplitDiffColumn
rows={rows}
side="left"
gutterWidth={gutterWidth}
wrapLines={wrapLines}
reviewActions={reviewActions}
/>
<SplitDiffColumn
rows={rows}
side="right"
gutterWidth={gutterWidth}
wrapLines={wrapLines}
reviewActions={reviewActions}
showDivider
/>
</View>
);
}
const computedLines = buildUnifiedDiffLines(file);
if (wrapLines) {
return (
<View style={styles.diffContent} dataSet={CODE_SURFACE_DATASET}>
<View style={styles.linesContainer}>
{computedLines.map(({ line, lineNumber, key, reviewTarget }) => (
<View key={key}>
<DiffLineView
line={line}
lineNumber={lineNumber}
gutterWidth={gutterWidth}
wrapLines={wrapLines}
reviewTarget={reviewTarget}
reviewActions={reviewActions}
/>
<InlineReviewRow
reviewTarget={reviewTarget}
reviewActions={reviewActions}
gutterWidth={gutterWidth}
/>
</View>
))}
</View>
</View>
);
}
const textViewportWidth =
scrollViewWidth > 0 ? scrollViewWidth : Math.max(0, bodyWidth - gutterWidth);
return (
<View style={DIFF_CONTENT_ROW_STYLE} dataSet={CODE_SURFACE_DATASET}>
<View style={styles.gutterColumn}>
{computedLines.map(({ line, lineNumber, key, reviewTarget }) => (
<View key={key}>
<DiffGutterCell
lineNumber={lineNumber}
type={line.type}
gutterWidth={gutterWidth}
reviewTarget={reviewTarget}
reviewActions={reviewActions}
isLineHovered={
reviewTarget?.key !== undefined && hoveredReviewTargetKey === reviewTarget.key
}
/>
<InlineReviewGutterSpacer
reviewTarget={reviewTarget}
reviewActions={reviewActions}
gutterWidth={gutterWidth}
/>
</View>
))}
</View>
<DiffScroll
scrollViewWidth={scrollViewWidth}
onScrollViewWidthChange={setScrollViewWidth}
style={styles.splitColumnScroll}
contentContainerStyle={styles.diffContentInner}
>
<View style={linesContainerRowStyle}>
{computedLines.map(({ line, key, reviewTarget }) => (
<View key={key}>
<DiffTextLine
line={line}
wrapLines={false}
reviewTarget={reviewTarget}
reviewActions={reviewActions}
hoverTargetKey={reviewTarget?.key ?? null}
onHoverTargetChange={setHoveredReviewTargetKey}
/>
<InlineReviewThreadContent
reviewTarget={reviewTarget}
reviewActions={reviewActions}
viewportWidth={textViewportWidth}
pinToViewport
/>
</View>
))}
</View>
</DiffScroll>
</View>
);
})()}
</View>
);
}
interface GitDiffPaneProps {
serverId: string;
workspaceId?: string | null;
cwd: string;
hideHeaderRow?: boolean;
enabled?: boolean;
}
type PressableStyleFn = (
state: PressableStateCallbackType & { hovered?: boolean; open?: boolean },
) => StyleProp<ViewStyle>;
interface DiffLayoutToggleGroupProps {
layout: "unified" | "split";
unifiedToggleStyle: PressableStyleFn;
splitToggleStyle: PressableStyleFn;
onUnified: () => void;
onSplit: () => void;
}
function DiffLayoutToggleGroup({
layout,
unifiedToggleStyle,
splitToggleStyle,
onUnified,
onSplit,
}: DiffLayoutToggleGroupProps) {
const { theme } = useUnistyles();
return (
<View style={styles.toggleButtonGroup}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel="Unified diff"
testID="changes-layout-unified"
onPress={onUnified}
style={unifiedToggleStyle}
>
<AlignJustify
size={14}
color={layout === "unified" ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>Unified diff</Text>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel="Side-by-side diff"
testID="changes-layout-split"
onPress={onSplit}
style={splitToggleStyle}
>
<Columns2
size={14}
color={layout === "split" ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>Side-by-side diff</Text>
</TooltipContent>
</Tooltip>
</View>
);
}
interface DiffWhitespaceToggleProps {
hideWhitespace: boolean;
isMobile: boolean;
toggleStyle: PressableStyleFn;
onToggle: () => void;
}
function DiffWhitespaceToggle({
hideWhitespace,
isMobile,
toggleStyle,
onToggle,
}: DiffWhitespaceToggleProps) {
const { theme } = useUnistyles();
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel="Hide whitespace"
testID="changes-toggle-whitespace"
style={toggleStyle}
onPress={onToggle}
>
<Pilcrow
size={isMobile ? 18 : 14}
color={hideWhitespace ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>Hide whitespace</Text>
</TooltipContent>
</Tooltip>
);
}
interface DiffFilesToolbarProps {
wrapLines: boolean;
allExpanded: boolean;
isMobile: boolean;
wrapLinesToggleStyle: PressableStyleFn;
expandAllToggleStyle: PressableStyleFn;
onToggleWrapLines: () => void;
onToggleExpandAll: () => void;
}
function DiffFilesToolbar({
wrapLines,
allExpanded,
isMobile,
wrapLinesToggleStyle,
expandAllToggleStyle,
onToggleWrapLines,
onToggleExpandAll,
}: DiffFilesToolbarProps) {
const { theme } = useUnistyles();
return (
<View style={styles.diffStatusButtons}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable style={wrapLinesToggleStyle} onPress={onToggleWrapLines}>
<WrapText
size={isMobile ? 18 : 14}
color={wrapLines ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>
{wrapLines ? "Scroll long lines" : "Wrap long lines"}
</Text>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable style={expandAllToggleStyle} onPress={onToggleExpandAll}>
{allExpanded ? (
<ListChevronsDownUp size={isMobile ? 18 : 14} color={theme.colors.foregroundMuted} />
) : (
<ListChevronsUpDown size={isMobile ? 18 : 14} color={theme.colors.foregroundMuted} />
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>
{allExpanded ? "Collapse all files" : "Expand all files"}
</Text>
</TooltipContent>
</Tooltip>
</View>
);
}
interface DiffRefreshButtonProps {
isRefreshing: boolean;
toggleStyle: PressableStyleFn;
onPress: () => void;
}
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const refreshIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function DiffRefreshButton({ isRefreshing, toggleStyle, onPress }: DiffRefreshButtonProps) {
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={isRefreshing ? "Refreshing" : "Refresh git and GitHub state"}
testID="changes-refresh"
style={toggleStyle}
onPress={onPress}
disabled={isRefreshing}
>
<View style={styles.refreshIcon}>
{isRefreshing ? (
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={refreshIconColorMapping} />
) : (
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={refreshIconColorMapping} />
)}
</View>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>Refresh</Text>
</TooltipContent>
</Tooltip>
);
}
type DiffFlatItem =
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean }
| { type: "body"; file: ParsedDiffFile; fileIndex: number };
type DiffFlatItemLayoutGetter = NonNullable<FlatListProps<DiffFlatItem>["getItemLayout"]>;
function getUnifiedDiffLineCount(file: ParsedDiffFile): number {
let lineCount = 0;
for (const hunk of file.hunks) {
lineCount += hunk.lines.length;
}
return lineCount;
}
function getDiffContentLength(file: ParsedDiffFile): number {
let contentLength = 0;
for (const hunk of file.hunks) {
for (const line of hunk.lines) {
contentLength += line.content.length;
}
}
return contentLength;
}
function computeEmptyMessage(
hideWhitespace: boolean,
diffMode: "uncommitted" | "base",
baseRefLabel: string,
): string {
if (hideWhitespace) {
return "No visible changes after hiding whitespace";
}
if (diffMode === "uncommitted") {
return "No uncommitted changes";
}
return `No changes vs ${baseRefLabel}`;
}
interface DiffBodyContentProps {
isStatusLoading: boolean;
statusErrorMessage: string | null;
notGit: boolean;
isDiffLoading: boolean;
diffErrorMessage: string | null;
hasChanges: boolean;
emptyMessage: string;
flatItems: DiffFlatItem[];
stickyHeaderIndices: number[];
renderFlatItem: ({ item }: { item: DiffFlatItem }) => ReactElement;
flatKeyExtractor: (item: DiffFlatItem) => string;
getFlatItemLayout: DiffFlatItemLayoutGetter;
flatExtraData: unknown;
diffListRef: RefObject<FlatList<DiffFlatItem> | null>;
handleDiffListLayout: (event: LayoutChangeEvent) => void;
handleDiffListScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
onContentSizeChange: (width: number, height: number) => void;
showDesktopWebScrollbar: boolean;
foregroundMutedColor: string;
}
function DiffBodyContent({
isStatusLoading,
statusErrorMessage,
notGit,
isDiffLoading,
diffErrorMessage,
hasChanges,
emptyMessage,
flatItems,
stickyHeaderIndices,
renderFlatItem,
flatKeyExtractor,
getFlatItemLayout,
flatExtraData,
diffListRef,
handleDiffListLayout,
handleDiffListScroll,
onContentSizeChange,
showDesktopWebScrollbar,
foregroundMutedColor,
}: DiffBodyContentProps) {
if (isStatusLoading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={foregroundMutedColor} />
<Text style={styles.loadingText}>Checking repository...</Text>
</View>
);
}
if (statusErrorMessage) {
return (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{statusErrorMessage}</Text>
</View>
);
}
if (notGit) {
return (
<View style={styles.emptyContainer} testID="changes-not-git">
<Text style={styles.emptyText}>Not a git repository</Text>
</View>
);
}
if (isDiffLoading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={foregroundMutedColor} />
</View>
);
}
if (diffErrorMessage) {
return (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{diffErrorMessage}</Text>
</View>
);
}
if (!hasChanges) {
return (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>{emptyMessage}</Text>
</View>
);
}
return (
<FlatList
ref={diffListRef}
data={flatItems}
renderItem={renderFlatItem}
keyExtractor={flatKeyExtractor}
getItemLayout={getFlatItemLayout}
stickyHeaderIndices={stickyHeaderIndices}
extraData={flatExtraData}
style={styles.scrollView}
contentContainerStyle={styles.contentContainer}
testID="git-diff-scroll"
onLayout={handleDiffListLayout}
onScroll={handleDiffListScroll}
onContentSizeChange={onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
// Mixed-height rows (header + potentially very large body) are prone to clipping artifacts.
// Keep a larger render window and disable clipping to avoid bodies disappearing mid-scroll.
removeClippedSubviews={false}
initialNumToRender={12}
maxToRenderPerBatch={12}
windowSize={10}
/>
);
}
interface DeriveStatusStateInputs {
status: ReturnType<typeof useCheckoutStatusQuery>["status"];
isStatusLoading: boolean;
isStatusError: boolean;
statusError: unknown;
}
interface DerivedStatusState {
gitStatus: NonNullable<ReturnType<typeof useCheckoutStatusQuery>["status"]> | null;
isGit: boolean;
notGit: boolean;
statusErrorMessage: string | null;
baseRef: string | undefined;
hasUncommittedChanges: boolean;
actionsDisabled: boolean;
}
function deriveStatusState({
status,
isStatusLoading,
isStatusError,
statusError,
}: DeriveStatusStateInputs): DerivedStatusState {
const gitStatus = status && status.isGit ? status : null;
const isGit = Boolean(gitStatus);
const notGit = status !== null && !status.isGit && !status.error;
const statusErrorMessage =
status?.error?.message ??
(isStatusError && statusError instanceof Error ? statusError.message : null);
const baseRef = gitStatus?.baseRef ?? undefined;
const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
return {
gitStatus,
isGit,
notGit,
statusErrorMessage,
baseRef,
hasUncommittedChanges,
actionsDisabled,
};
}
function computeBaseRefLabel(baseRef: string | undefined): string {
if (!baseRef) return "base";
const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim();
return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed;
}
function computeCommittedDiffDescription(
branchLabel: string,
baseRefLabel: string,
): string | undefined {
if (!branchLabel || !baseRefLabel) {
return undefined;
}
return branchLabel === baseRefLabel ? undefined : `${branchLabel} -> ${baseRefLabel}`;
}
function computePrErrorMessage(
githubFeaturesEnabled: boolean,
prPayloadError: { message?: string } | null | undefined,
): string | null {
if (!githubFeaturesEnabled) return null;
return prPayloadError?.message ?? null;
}
function buildDiffModeTriggerStyle(surfaceColor: string): PressableStyleFn {
return ({ hovered, pressed, open }) => [
styles.diffModeTrigger,
(Boolean(hovered) || pressed || Boolean(open)) &&
inlineUnistylesStyle({ backgroundColor: surfaceColor }),
];
}
function buildExpandAllButtonStyle(surfaceColor: string): PressableStyleFn {
return ({ hovered, pressed }) => [
styles.expandAllButton,
(Boolean(hovered) || pressed) && inlineUnistylesStyle({ backgroundColor: surfaceColor }),
];
}
function buildToggleButtonStyle(
selected: boolean,
baseStyles: StyleProp<ViewStyle> | StyleProp<ViewStyle>[],
surfaceColor: string,
): PressableStyleFn {
return ({ hovered, pressed }) => [
baseStyles,
(selected || Boolean(hovered) || pressed) &&
inlineUnistylesStyle({ backgroundColor: surfaceColor }),
];
}
function shouldEnableCheckoutDiff(input: { paneEnabled: boolean; isGit: boolean }): boolean {
return input.paneEnabled && input.isGit;
}
export function GitDiffPane({
serverId,
workspaceId,
cwd,
hideHeaderRow,
enabled,
}: GitDiffPaneProps) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = isWeb && !isMobile;
const canUseSplitLayout = isWeb && !isMobile;
const [diffModeOverride, setDiffModeOverride] = useState<ReviewDraftMode | null>(null);
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
useChangesPreferences();
const wrapLines = changesPreferences.wrapLines;
const effectiveLayout = canUseSplitLayout ? changesPreferences.layout : "unified";
const handleToggleWrapLines = useCallback(() => {
void updateChangesPreferences({ wrapLines: !wrapLines });
}, [updateChangesPreferences, wrapLines]);
const handleLayoutChange = useCallback(
(nextLayout: "unified" | "split") => {
void updateChangesPreferences({ layout: nextLayout });
},
[updateChangesPreferences],
);
const handleToggleHideWhitespace = useCallback(() => {
void updateChangesPreferences({ hideWhitespace: !changesPreferences.hideWhitespace });
}, [changesPreferences.hideWhitespace, updateChangesPreferences]);
// handleSelectUncommitted/handleSelectBase are defined later, after reviewDraftScopeKey
// and setActiveReviewMode are available, so they can record the active review mode.
const handleLayoutUnified = useCallback(() => {
handleLayoutChange("unified");
}, [handleLayoutChange]);
const handleLayoutSplit = useCallback(() => {
handleLayoutChange("split");
}, [handleLayoutChange]);
const controlSurfaceColor = theme.colors.surface2;
const diffModeTriggerStyle = useMemo(
() => buildDiffModeTriggerStyle(controlSurfaceColor),
[controlSurfaceColor],
);
const unifiedToggleStyle = useMemo(
() =>
buildToggleButtonStyle(
changesPreferences.layout === "unified",
[styles.toggleButton, styles.toggleButtonGroupStart],
controlSurfaceColor,
),
[changesPreferences.layout, controlSurfaceColor],
);
const splitToggleStyle = useMemo(
() =>
buildToggleButtonStyle(
changesPreferences.layout === "split",
[styles.toggleButton, styles.toggleButtonGroupEnd],
controlSurfaceColor,
),
[changesPreferences.layout, controlSurfaceColor],
);
const hideWhitespaceToggleStyle = useMemo(
() =>
buildToggleButtonStyle(
changesPreferences.hideWhitespace,
styles.expandAllButton,
controlSurfaceColor,
),
[changesPreferences.hideWhitespace, controlSurfaceColor],
);
const wrapLinesToggleStyle = useMemo(
() => buildToggleButtonStyle(wrapLines, styles.expandAllButton, controlSurfaceColor),
[wrapLines, controlSurfaceColor],
);
const expandAllToggleStyle = useMemo(
() => buildExpandAllButtonStyle(controlSurfaceColor),
[controlSurfaceColor],
);
const refreshToggleStyle = useMemo(
() => buildExpandAllButtonStyle(controlSurfaceColor),
[controlSurfaceColor],
);
const toast = useToast();
const refreshSupported = useSessionStore(
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true,
);
const runRefresh = useCheckoutGitActionsStore((s) => s.refresh);
const isRefreshing =
useCheckoutGitActionsStore((s) => s.getStatus({ serverId, cwd, actionId: "refresh" })) ===
"pending";
const handleRefresh = useCallback(() => {
if (isRefreshing) {
return;
}
void runRefresh({ serverId, cwd }).catch((error) => {
toast.error(error instanceof Error ? error.message : "Failed to refresh git state.");
});
}, [cwd, isRefreshing, runRefresh, serverId, toast]);
const {
status,
isLoading: isStatusLoading,
isError: isStatusError,
error: statusError,
} = useCheckoutStatusQuery({ serverId, cwd });
const statusState = deriveStatusState({ status, isStatusLoading, isStatusError, statusError });
const { isGit, notGit, statusErrorMessage, baseRef, hasUncommittedChanges } = statusState;
// Auto-select diff mode based on state: uncommitted when dirty, base when clean
const autoDiffMode: ReviewDraftMode = hasUncommittedChanges ? "uncommitted" : "base";
const reviewDraftScopeKey = useMemo(
() =>
buildReviewDraftScopeKey({
serverId,
workspaceId,
cwd,
baseRef,
ignoreWhitespace: changesPreferences.hideWhitespace,
}),
[baseRef, changesPreferences.hideWhitespace, cwd, serverId, workspaceId],
);
const activeReviewMode = useActiveReviewDraftMode({ scopeKey: reviewDraftScopeKey });
const diffMode = diffModeOverride ?? activeReviewMode ?? autoDiffMode;
const {
files,
payloadError: diffPayloadError,
isLoading: isDiffLoading,
} = useCheckoutDiffQuery({
serverId,
cwd,
mode: diffMode,
baseRef,
ignoreWhitespace: changesPreferences.hideWhitespace,
enabled: shouldEnableCheckoutDiff({ paneEnabled: enabled !== false, isGit }),
});
const reviewDraftKey = useMemo(
() =>
buildReviewDraftKey({
serverId,
workspaceId,
cwd,
mode: diffMode,
baseRef,
ignoreWhitespace: changesPreferences.hideWhitespace,
}),
[baseRef, changesPreferences.hideWhitespace, cwd, diffMode, serverId, workspaceId],
);
const setActiveReviewMode = useSetActiveReviewDraftMode();
const handleSelectUncommitted = useCallback(() => {
setDiffModeOverride("uncommitted");
setActiveReviewMode({ scopeKey: reviewDraftScopeKey, mode: "uncommitted" });
}, [reviewDraftScopeKey, setActiveReviewMode]);
const handleSelectBase = useCallback(() => {
setDiffModeOverride("base");
setActiveReviewMode({ scopeKey: reviewDraftScopeKey, mode: "base" });
}, [reviewDraftScopeKey, setActiveReviewMode]);
const reviewActions = useInlineReviewController({
reviewDraftKey,
});
const reviewAttachment = useReviewAttachmentSnapshot({
key: reviewDraftKey,
diffFiles: files,
cwd,
mode: diffMode,
baseRef,
});
const workspaceAttachmentScopeKey = useMemo(
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd }),
[cwd, serverId, workspaceId],
);
const setWorkspaceAttachments = useWorkspaceAttachmentsStore(
(state) => state.setWorkspaceAttachments,
);
const clearWorkspaceAttachments = useWorkspaceAttachmentsStore(
(state) => state.clearWorkspaceAttachments,
);
useEffect(() => {
setWorkspaceAttachments({
scopeKey: workspaceAttachmentScopeKey,
attachments: reviewAttachment ? [reviewAttachment] : [],
});
return () => {
clearWorkspaceAttachments({ scopeKey: workspaceAttachmentScopeKey });
};
}, [
clearWorkspaceAttachments,
reviewAttachment,
setWorkspaceAttachments,
workspaceAttachmentScopeKey,
]);
const { githubFeaturesEnabled, payloadError: prPayloadError } = useCheckoutPrStatusQuery({
serverId,
cwd,
enabled: isGit,
});
const normalizedWorkspaceRoot = useMemo(() => cwd.trim(), [cwd]);
const workspaceStateKey = useMemo(
() =>
buildWorkspaceExplorerStateKey({
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
}),
[normalizedWorkspaceRoot, workspaceId],
);
const expandedPathsArray = usePanelStore((state) =>
workspaceStateKey ? state.diffExpandedPathsByWorkspace[workspaceStateKey] : undefined,
);
const setDiffExpandedPathsForWorkspace = usePanelStore(
(state) => state.setDiffExpandedPathsForWorkspace,
);
const expandedPaths = useMemo(() => new Set(expandedPathsArray ?? []), [expandedPathsArray]);
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
const scrollbar = useWebScrollViewScrollbar(diffListRef, {
enabled: showDesktopWebScrollbar,
});
const diffListScrollOffsetRef = useRef(0);
const diffListViewportHeightRef = useRef(0);
const headerHeightByPathRef = useRef<Record<string, number>>({});
const bodyHeightByKeyRef = useRef<Record<string, number>>({});
const defaultHeaderHeightRef = useRef<number>(44);
const [heightVersion, setHeightVersion] = useState(0);
const diffBodyLineHeight = theme.lineHeight.diff;
const diffBodyChromeHeight = theme.borderWidth[1] * 2;
const statusBodyHeightEstimate = diffBodyChromeHeight + theme.spacing[4] * 2 + diffBodyLineHeight;
const { flatItems, stickyHeaderIndices } = useMemo(() => {
const items: DiffFlatItem[] = [];
const stickyIndices: number[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const isExpanded = expandedPaths.has(file.path);
items.push({ type: "header", file, fileIndex: i, isExpanded });
if (isExpanded) {
stickyIndices.push(items.length - 1);
}
if (isExpanded) {
items.push({ type: "body", file, fileIndex: i });
}
}
return { flatItems: items, stickyHeaderIndices: stickyIndices };
}, [expandedPaths, files]);
const getBodyHeightKey = useCallback(
(file: ParsedDiffFile): string => {
if (file.status === "too_large" || file.status === "binary") {
return `${effectiveLayout}:${wrapLines ? "wrap" : "scroll"}:${file.path}:${file.status}`;
}
return [
effectiveLayout,
wrapLines ? "wrap" : "scroll",
file.path,
file.status ?? "ok",
file.additions,
file.deletions,
file.hunks.length,
getUnifiedDiffLineCount(file),
getDiffContentLength(file),
].join(":");
},
[effectiveLayout, wrapLines],
);
const estimateBodyHeight = useCallback(
(file: ParsedDiffFile): number => {
if (file.status === "too_large" || file.status === "binary") {
return statusBodyHeightEstimate;
}
const lineCount =
effectiveLayout === "split"
? buildSplitDiffRows(file).length
: getUnifiedDiffLineCount(file);
return diffBodyChromeHeight + lineCount * diffBodyLineHeight;
},
[diffBodyChromeHeight, diffBodyLineHeight, effectiveLayout, statusBodyHeightEstimate],
);
const handleHeaderHeightChange = useCallback((path: string, height: number) => {
if (!Number.isFinite(height) || height <= 0) {
return;
}
const previousHeight = headerHeightByPathRef.current[path];
if (
previousHeight !== undefined &&
Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON
) {
return;
}
headerHeightByPathRef.current[path] = height;
defaultHeaderHeightRef.current = height;
setHeightVersion((version) => version + 1);
}, []);
const handleBodyHeightChange = useCallback(
(file: ParsedDiffFile, height: number) => {
if (!Number.isFinite(height) || height < 0) {
return;
}
const heightKey = getBodyHeightKey(file);
const previousHeight = bodyHeightByKeyRef.current[heightKey];
if (
previousHeight !== undefined &&
Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON
) {
return;
}
bodyHeightByKeyRef.current[heightKey] = height;
setHeightVersion((version) => version + 1);
},
[getBodyHeightKey],
);
const handleDiffListScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
scrollbar.onScroll(event);
},
[scrollbar],
);
const handleDiffListLayout = useCallback(
(event: LayoutChangeEvent) => {
const height = event.nativeEvent.layout.height;
if (!Number.isFinite(height) || height <= 0) {
return;
}
diffListViewportHeightRef.current = height;
scrollbar.onLayout(event);
},
[scrollbar],
);
const computeHeaderOffset = useCallback(
(path: string): number => {
const defaultHeaderHeight = defaultHeaderHeightRef.current;
let offset = 0;
for (const file of files) {
if (file.path === path) {
break;
}
offset += headerHeightByPathRef.current[file.path] ?? defaultHeaderHeight;
if (expandedPaths.has(file.path)) {
const bodyHeightKey = getBodyHeightKey(file);
offset += bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(file);
}
}
return Math.max(0, offset);
},
[estimateBodyHeight, expandedPaths, files, getBodyHeightKey],
);
const handleToggleExpanded = useCallback(
(path: string) => {
if (!workspaceStateKey) {
return;
}
const isCurrentlyExpanded = expandedPaths.has(path);
const nextExpanded = !isCurrentlyExpanded;
const targetOffset = isCurrentlyExpanded ? computeHeaderOffset(path) : null;
const headerHeight = headerHeightByPathRef.current[path] ?? defaultHeaderHeightRef.current;
const shouldAnchor =
isCurrentlyExpanded &&
targetOffset !== null &&
shouldAnchorHeaderBeforeCollapse({
headerOffset: targetOffset,
headerHeight,
viewportOffset: diffListScrollOffsetRef.current,
viewportHeight: diffListViewportHeightRef.current,
});
// Anchor to the clicked header before collapsing so visual context is preserved.
if (shouldAnchor && targetOffset !== null) {
diffListRef.current?.scrollToOffset({
offset: targetOffset,
animated: false,
});
}
const nextPaths = nextExpanded
? [...expandedPaths, path]
: Array.from(expandedPaths).filter((expandedPath) => expandedPath !== path);
setDiffExpandedPathsForWorkspace(workspaceStateKey, nextPaths);
},
[computeHeaderOffset, expandedPaths, setDiffExpandedPathsForWorkspace, workspaceStateKey],
);
const allExpanded = useMemo(() => {
if (files.length === 0) return false;
return files.every((file) => expandedPaths.has(file.path));
}, [expandedPaths, files]);
const handleToggleExpandAll = useCallback(() => {
if (!workspaceStateKey) {
return;
}
if (allExpanded) {
setDiffExpandedPathsForWorkspace(workspaceStateKey, []);
} else {
setDiffExpandedPathsForWorkspace(
workspaceStateKey,
files.map((file) => file.path),
);
}
}, [allExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
// Clear diff mode override when auto mode changes (e.g., after commit)
useEffect(() => {
setDiffModeOverride(null);
}, [autoDiffMode]);
const renderFlatItem = useCallback(
({ item }: { item: DiffFlatItem }) => {
if (item.type === "header") {
return (
<DiffFileHeader
file={item.file}
isExpanded={item.isExpanded}
onToggle={handleToggleExpanded}
onHeaderHeightChange={handleHeaderHeightChange}
testID={`diff-file-${item.fileIndex}`}
/>
);
}
return (
<DiffFileBody
file={item.file}
layout={effectiveLayout}
wrapLines={wrapLines}
reviewActions={reviewActions}
onBodyHeightChange={handleBodyHeightChange}
testID={`diff-file-${item.fileIndex}-body`}
/>
);
},
[
effectiveLayout,
handleBodyHeightChange,
handleHeaderHeightChange,
handleToggleExpanded,
reviewActions,
wrapLines,
],
);
const flatKeyExtractor = useCallback(
(item: DiffFlatItem) => `${item.type}-${item.file.path}`,
[],
);
const getFlatItemHeight = useCallback(
(item: DiffFlatItem): number => {
if (item.type === "header") {
return headerHeightByPathRef.current[item.file.path] ?? defaultHeaderHeightRef.current;
}
const bodyHeightKey = getBodyHeightKey(item.file);
return bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(item.file);
},
[estimateBodyHeight, getBodyHeightKey],
);
const getFlatItemLayout = useCallback<DiffFlatItemLayoutGetter>(
(_data, index) => {
let offset = 0;
for (let itemIndex = 0; itemIndex < index; itemIndex += 1) {
const item = flatItems[itemIndex];
if (item) {
offset += getFlatItemHeight(item);
}
}
const item = flatItems[index];
const length = item ? getFlatItemHeight(item) : 0;
return { length, offset, index };
},
[flatItems, getFlatItemHeight],
);
const flatExtraData = useMemo(
() => ({
expandedPathsArray,
effectiveLayout,
heightVersion,
wrapLines,
reviewActions,
}),
[expandedPathsArray, effectiveLayout, heightVersion, wrapLines, reviewActions],
);
const hasChanges = files.length > 0;
const diffErrorMessage = diffPayloadError?.message ?? null;
const prErrorMessage = computePrErrorMessage(githubFeaturesEnabled, prPayloadError);
const baseRefLabel = useMemo(() => computeBaseRefLabel(baseRef), [baseRef]);
const iconColor = theme.colors.foregroundMuted;
const gitActionsIcons = useMemo(
() => ({
commit: <GitCommitHorizontal size={16} color={iconColor} />,
pull: <Download size={16} color={iconColor} />,
push: <Upload size={16} color={iconColor} />,
pullAndPush: <ArrowDownUp size={16} color={iconColor} />,
viewPr: <GitHubIcon size={16} color={iconColor} />,
createPr: <GitHubIcon size={16} color={iconColor} />,
mergePrSquash: <GitHubIcon size={16} color={iconColor} />,
mergePrMerge: <GitHubIcon size={16} color={iconColor} />,
mergePrRebase: <GitHubIcon size={16} color={iconColor} />,
merge: <GitMerge size={16} color={iconColor} />,
mergeFromBase: <RefreshCcw size={16} color={iconColor} />,
archive: <Archive size={16} color={iconColor} />,
}),
[iconColor],
);
const { gitActions, branchLabel } = useGitActions({ serverId, cwd, icons: gitActionsIcons });
const committedDiffDescription = useMemo(
() => computeCommittedDiffDescription(branchLabel, baseRefLabel),
[baseRefLabel, branchLabel],
);
const emptyMessage = computeEmptyMessage(
changesPreferences.hideWhitespace,
diffMode,
baseRefLabel,
);
const bodyContent: ReactElement = (
<DiffBodyContent
isStatusLoading={isStatusLoading}
statusErrorMessage={statusErrorMessage}
notGit={notGit}
isDiffLoading={isDiffLoading}
diffErrorMessage={diffErrorMessage}
hasChanges={hasChanges}
emptyMessage={emptyMessage}
flatItems={flatItems}
stickyHeaderIndices={stickyHeaderIndices}
renderFlatItem={renderFlatItem}
flatKeyExtractor={flatKeyExtractor}
getFlatItemLayout={getFlatItemLayout}
flatExtraData={flatExtraData}
diffListRef={diffListRef}
handleDiffListLayout={handleDiffListLayout}
handleDiffListScroll={handleDiffListScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
showDesktopWebScrollbar={showDesktopWebScrollbar}
foregroundMutedColor={theme.colors.foregroundMuted}
/>
);
return (
<View style={styles.container}>
{!hideHeaderRow ? (
<View style={styles.header} testID="changes-header">
<View style={styles.headerLeft}>
<GitBranch size={16} color={theme.colors.foregroundMuted} />
<Text style={styles.branchLabel} testID="changes-branch" numberOfLines={1}>
{branchLabel}
</Text>
</View>
{isGit ? <GitActionsSplitButton gitActions={gitActions} /> : null}
</View>
) : null}
{isGit ? (
<View style={styles.diffStatusContainer}>
<View style={styles.diffStatusInner}>
<DropdownMenu>
<DropdownMenuTrigger
style={diffModeTriggerStyle}
testID="changes-diff-status"
accessibilityRole="button"
accessibilityLabel="Diff mode"
>
<Text style={styles.diffStatusText} numberOfLines={1}>
{diffMode === "uncommitted" ? "Uncommitted" : "Committed"}
</Text>
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" width={260} testID="changes-diff-status-menu">
<DropdownMenuItem
testID="changes-diff-mode-uncommitted"
selected={diffMode === "uncommitted"}
onSelect={handleSelectUncommitted}
>
Uncommitted
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
testID="changes-diff-mode-committed"
selected={diffMode === "base"}
description={committedDiffDescription}
onSelect={handleSelectBase}
>
Committed
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<View style={styles.diffStatusButtons}>
{canUseSplitLayout ? (
<DiffLayoutToggleGroup
layout={changesPreferences.layout}
unifiedToggleStyle={unifiedToggleStyle}
splitToggleStyle={splitToggleStyle}
onUnified={handleLayoutUnified}
onSplit={handleLayoutSplit}
/>
) : null}
<DiffWhitespaceToggle
hideWhitespace={changesPreferences.hideWhitespace}
isMobile={isMobile}
toggleStyle={hideWhitespaceToggleStyle}
onToggle={handleToggleHideWhitespace}
/>
{files.length > 0 ? (
<DiffFilesToolbar
wrapLines={wrapLines}
allExpanded={allExpanded}
isMobile={isMobile}
wrapLinesToggleStyle={wrapLinesToggleStyle}
expandAllToggleStyle={expandAllToggleStyle}
onToggleWrapLines={handleToggleWrapLines}
onToggleExpandAll={handleToggleExpandAll}
/>
) : null}
{refreshSupported ? (
<DiffRefreshButton
isRefreshing={isRefreshing}
toggleStyle={refreshToggleStyle}
onPress={handleRefresh}
/>
) : null}
</View>
</View>
</View>
) : null}
{prErrorMessage ? <Text style={styles.actionErrorText}>{prErrorMessage}</Text> : null}
<View style={styles.diffContainer}>
{bodyContent}
{hasChanges ? scrollbar.overlay : null}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
minHeight: 0,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
headerLeft: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flex: 1,
minWidth: 0,
},
branchLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.medium,
flexShrink: 1,
},
diffStatusContainer: {
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
diffStatusInner: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingRight: theme.spacing[3],
},
diffModeTrigger: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[1],
// Align text with header branch icon (at spacing[3] from edge, minus our horizontal padding)
marginLeft: theme.spacing[3] - theme.spacing[1],
paddingHorizontal: theme.spacing[1],
height: {
xs: 28,
sm: 28,
md: 24,
},
borderRadius: theme.borderRadius.base,
flexShrink: 0,
},
diffModeTriggerHovered: {
backgroundColor: theme.colors.surface2,
},
diffModeTriggerPressed: {
backgroundColor: theme.colors.surface2,
},
diffStatusRowHovered: {
backgroundColor: theme.colors.surface2,
},
diffStatusText: {
fontSize: theme.fontSize.xs,
lineHeight: theme.fontSize.xs * 1.25,
color: theme.colors.foregroundMuted,
},
diffStatusIconHidden: {
opacity: 0,
},
diffStatusButtons: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flexWrap: "wrap",
},
toggleButtonGroup: {
flexDirection: "row",
alignItems: "center",
},
toggleButton: {
alignItems: "center",
justifyContent: "center",
minWidth: {
xs: 32,
sm: 32,
md: 24,
},
height: {
xs: 32,
sm: 32,
md: 24,
},
paddingHorizontal: {
xs: theme.spacing[2],
sm: theme.spacing[2],
md: theme.spacing[1],
},
},
toggleButtonGroupStart: {
borderTopLeftRadius: theme.borderRadius.base,
borderBottomLeftRadius: theme.borderRadius.base,
},
toggleButtonGroupEnd: {
borderTopRightRadius: theme.borderRadius.base,
borderBottomRightRadius: theme.borderRadius.base,
},
toggleButtonSelected: {
backgroundColor: theme.colors.surface2,
},
refreshIcon: {
width: ICON_SIZE.md,
height: ICON_SIZE.md,
alignItems: "center",
justifyContent: "center",
},
expandAllButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[1],
minWidth: {
xs: 32,
sm: 32,
md: 24,
},
height: {
xs: 32,
sm: 32,
md: 24,
},
paddingHorizontal: {
xs: theme.spacing[2],
sm: theme.spacing[2],
md: theme.spacing[1],
},
borderRadius: theme.borderRadius.base,
flexShrink: 0,
},
actionErrorText: {
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[1],
fontSize: theme.fontSize.xs,
color: theme.colors.destructive,
},
diffContainer: {
flex: 1,
minHeight: 0,
position: "relative",
},
scrollView: {
flex: 1,
},
contentContainer: {
paddingBottom: theme.spacing[8],
},
loadingContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingTop: theme.spacing[16],
gap: theme.spacing[4],
},
loadingText: {
fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,
},
errorContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingTop: theme.spacing[16],
paddingHorizontal: theme.spacing[6],
},
errorText: {
fontSize: theme.fontSize.base,
color: theme.colors.destructive,
textAlign: "center",
},
emptyContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingTop: theme.spacing[16],
},
emptyText: {
fontSize: theme.fontSize.lg,
color: theme.colors.foregroundMuted,
},
fileSection: {
overflow: "hidden",
backgroundColor: theme.colors.surface2,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
fileSectionHeaderContainer: {
overflow: "hidden",
},
fileSectionHeaderExpanded: {
backgroundColor: theme.colors.surface1,
},
fileSectionBodyContainer: {
overflow: "hidden",
backgroundColor: theme.colors.surface2,
},
fileSectionBorder: {
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
fileHeader: {
flexDirection: "row",
alignItems: "center",
paddingLeft: theme.spacing[3],
paddingRight: theme.spacing[2],
paddingVertical: theme.spacing[2],
gap: theme.spacing[1],
minWidth: 0,
zIndex: 2,
elevation: 2,
},
fileHeaderPressed: {
opacity: 0.7,
},
fileHeaderLeft: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flex: 1,
minWidth: 0,
},
fileHeaderRight: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flexShrink: 0,
},
fileName: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foreground,
flexShrink: 1,
minWidth: 0,
},
fileDir: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
flex: 1,
minWidth: 0,
},
newBadge: {
backgroundColor: "rgba(46, 160, 67, 0.2)",
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
flexShrink: 0,
},
newBadgeText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.diffAddition,
},
deletedBadge: {
backgroundColor: "rgba(248, 81, 73, 0.2)",
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
flexShrink: 0,
},
deletedBadgeText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.diffDeletion,
},
additions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.diffAddition,
},
deletions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.diffDeletion,
},
diffContent: {
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
},
diffContentRow: {
flexDirection: "row",
alignItems: "stretch",
},
diffContentInner: {
flexDirection: "column",
},
linesContainer: {
backgroundColor: theme.colors.surface1,
},
gutterColumn: {
backgroundColor: theme.colors.surface1,
zIndex: 4,
elevation: 4,
overflow: "visible",
},
gutterCell: {
borderRightWidth: theme.borderWidth[1],
borderRightColor: theme.colors.border,
justifyContent: "flex-start",
zIndex: 4,
elevation: 4,
overflow: "visible",
},
inlineReviewRow: {
flexDirection: "row",
alignItems: "stretch",
backgroundColor: theme.colors.surface1,
},
inlineReviewGutterSpacer: {
borderRightWidth: theme.borderWidth[1],
borderRightColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
flexShrink: 0,
},
textLineContainer: {
flexDirection: "row",
alignItems: "stretch",
paddingLeft: theme.spacing[2],
},
splitRow: {
flexDirection: "row",
alignItems: "stretch",
},
splitColumnScroll: {
flex: 1,
},
splitHeaderRow: {
backgroundColor: theme.colors.surface2,
paddingHorizontal: theme.spacing[3],
},
splitCell: {
flex: 1,
flexBasis: 0,
backgroundColor: theme.colors.surface2,
},
splitCellRow: {
flexDirection: "row",
alignItems: "stretch",
},
emptySplitCell: {
backgroundColor: theme.colors.surfaceDiffEmpty,
},
splitCellWithDivider: {
borderLeftWidth: theme.borderWidth[1],
borderLeftColor: theme.colors.border,
},
diffLineContainer: {
flexDirection: "row",
alignItems: "stretch",
overflow: "visible",
},
lineNumberGutter: {
borderRightWidth: theme.borderWidth[1],
borderRightColor: theme.colors.border,
marginRight: theme.spacing[2],
alignSelf: "stretch",
justifyContent: "flex-start",
zIndex: 4,
elevation: 4,
overflow: "visible",
},
lineNumberText: {
width: "100%",
textAlign: "right",
paddingRight: theme.spacing[2],
fontSize: theme.fontSize.code,
lineHeight: theme.lineHeight.diff,
fontFamily: theme.fontFamily.mono,
color: theme.colors.foregroundMuted,
userSelect: "none",
},
addLineNumberText: {
color: theme.colors.diffAddition,
},
removeLineNumberText: {
color: theme.colors.diffDeletion,
},
diffLineText: {
flex: 1,
paddingRight: theme.spacing[3],
fontSize: theme.fontSize.code,
lineHeight: theme.lineHeight.diff,
fontFamily: theme.fontFamily.mono,
color: theme.colors.foreground,
userSelect: "text",
},
addLineContainer: {
backgroundColor: "rgba(46, 160, 67, 0.15)", // GitHub green
},
addLineText: {
color: theme.colors.foreground,
},
removeLineContainer: {
backgroundColor: "rgba(248, 81, 73, 0.1)", // GitHub red
},
removeLineText: {
color: theme.colors.foreground,
},
headerLineContainer: {
backgroundColor: theme.colors.surface2,
},
headerLineText: {
color: theme.colors.foregroundMuted,
},
contextLineContainer: {
backgroundColor: theme.colors.surface1,
},
contextLineText: {
color: theme.colors.foregroundMuted,
},
emptySplitCellText: {
color: "transparent",
},
statusMessageContainer: {
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[4],
},
statusMessageText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
fontStyle: "italic",
},
tooltipText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
},
}));
const HEADER_LINE_TEXT_STYLE = [styles.diffLineText, styles.headerLineText];
const FILE_SECTION_BODY_STYLE = [styles.fileSectionBodyContainer, styles.fileSectionBorder];
const DIFF_CONTENT_SPLIT_ROW_STYLE = [styles.diffContent, styles.splitRow];
const DIFF_CONTENT_ROW_STYLE = [styles.diffContent, styles.diffContentRow];
const DIFF_HEIGHT_CHANGE_EPSILON = 0.5;