Files
paseo/packages/app/src/git/diff-pane.tsx
T
Mohamed BoudraandGitHub 1b8af2e58e Refine tool call summaries and scrolling (#2069)
* feat(app): refine tool call summaries and scrolling

Keep summary groups stable from the first tool call, preserve live activity feedback, and scroll expanded groups to the latest entry. Consolidate web scrollbar styling around the native browser mechanism.

* refactor(app): reuse tool call height constant

* fix(app): preserve tool call summary state

* fix(app): refresh web tool groups on expand

* fix(app): preserve grouped tool errors
2026-07-14 21:16:29 +02:00

2873 lines
89 KiB
TypeScript

import {
useState,
useCallback,
useEffect,
useMemo,
useRef,
memo,
type ReactElement,
type ReactNode,
type RefObject,
} from "react";
import { useTranslation } from "react-i18next";
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, withUnistyles } from "react-native-unistyles";
import { BORDER_WIDTH, ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import {
AlignJustify,
Archive,
ArrowDownUp,
ChevronDown,
Columns2,
Download,
FolderTree,
GitCommitHorizontal,
GitMerge,
List,
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 { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "@/git/diff-flat-items";
import { buildDiffTree, collectDirPaths, compressSingleChildChains } from "@/git/diff-tree";
import { DiffFolderRow } from "@/git/diff-folder-row";
import { TreeIndentGuides, treeRowPaddingLeft } from "@/components/tree-primitives";
import { SvgXml } from "react-native-svg";
import { getFileIconSvg } from "@/components/material-file-icons";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { useAppSettings } from "@/hooks/use-settings";
import { DiffScroll } from "@/components/diff-scroll";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
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 { GitActionsSplitButton } from "@/git/actions-split-button";
import { BranchSwitcher } from "@/components/branch-switcher";
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,
useReviewAttachmentSnapshot,
useResolvedDiffMode,
useSetDiffModeOverride,
type ReviewDraftComment,
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[];
textMetricsStyle: TextStyle;
wrapLines?: boolean;
testID?: string;
}
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 getNumericLineHeight(textMetricsStyle: TextStyle): number | undefined {
const { lineHeight } = textMetricsStyle;
return typeof lineHeight === "number" && Number.isFinite(lineHeight) ? lineHeight : undefined;
}
function useDiffRowMetricsStyle(textMetricsStyle: TextStyle): StyleProp<ViewStyle> {
const lineHeight = getNumericLineHeight(textMetricsStyle);
return useMemo(
() => (lineHeight !== undefined ? inlineUnistylesStyle({ minHeight: lineHeight }) : null),
[lineHeight],
);
}
function HighlightedToken({ token }: { token: HighlightToken }) {
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
}
function HighlightedText({
tokens,
textMetricsStyle,
wrapLines = false,
testID,
}: HighlightedTextProps) {
const containerStyle = useMemo(
() => [
styles.diffTextMetrics,
textMetricsStyle,
styles.diffLineText,
getWrappedTextStyle(wrapLines),
],
[textMetricsStyle, wrapLines],
);
const keyedTokens = useMemo(
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
[tokens],
);
return (
<Text style={containerStyle} testID={testID}>
{keyedTokens.map(({ key, token }) => (
<HighlightedToken key={key} token={token} />
))}
</Text>
);
}
interface DiffFileSectionProps {
file: ParsedDiffFile;
isExpanded: boolean;
/** Tree indentation level (0 on the flat/mobile path). */
depth?: number;
/** Show the muted directory suffix (flat list); false inside the folder tree. */
showDir?: 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,
textMetricsStyle,
reviewTarget,
reviewActions,
isLineHovered,
style,
textTestID,
actionTestID,
}: {
lineNumber: number | null;
type: DiffLine["type"] | undefined | null;
gutterWidth: number;
textMetricsStyle: TextStyle;
reviewTarget?: ReviewableDiffTarget | null;
reviewActions?: InlineReviewActions;
isLineHovered?: boolean;
style?: StyleProp<ViewStyle>;
textTestID?: string;
actionTestID?: string;
}) {
const lineHeight = getNumericLineHeight(textMetricsStyle);
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
const containerStyle = useMemo(
() => [
styles.gutterCell,
lineTypeBackground(type),
rowMetricsStyle,
inlineUnistylesStyle({ width: gutterWidth }),
style,
],
[type, rowMetricsStyle, gutterWidth, style],
);
const textStyle = useMemo(
() => [
styles.diffTextMetrics,
textMetricsStyle,
styles.lineNumberText,
type === "add" && styles.addLineNumberText,
type === "remove" && styles.removeLineNumberText,
],
[textMetricsStyle, 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}
lineHeight={lineHeight}
onStartComment={onStartComment}
style={containerStyle}
actionTestID={actionTestID}
>
<Text numberOfLines={1} style={textStyle} testID={textTestID}>
{formatDiffGutterText(lineNumber)}
</Text>
</InlineReviewGutterCell>
);
}
function DiffTextLine({
line,
wrapLines,
textMetricsStyle,
reviewTarget,
reviewActions,
onHoverChange,
hoverTargetKey,
onHoverTargetChange,
textTestID,
}: {
line: DiffLine;
wrapLines: boolean;
textMetricsStyle: TextStyle;
reviewTarget?: ReviewableDiffTarget | null;
reviewActions?: InlineReviewActions;
onHoverChange?: (hovered: boolean) => void;
hoverTargetKey?: string | null;
onHoverTargetChange?: (key: string | null) => void;
textTestID?: string;
}) {
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
const containerStyle = useMemo(
() => [styles.textLineContainer, lineTypeBackground(line.type), rowMetricsStyle],
[line.type, rowMetricsStyle],
);
const textStyle = useMemo(
() => [
styles.diffTextMetrics,
textMetricsStyle,
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, textMetricsStyle, wrapLines],
);
return (
<LongPressableLine
reviewTarget={reviewTarget}
reviewActions={reviewActions}
onHoverChange={onHoverChange}
hoverTargetKey={hoverTargetKey}
onHoverTargetChange={onHoverTargetChange}
style={containerStyle}
>
{line.type !== "header" && visibleTokens ? (
<HighlightedText
tokens={visibleTokens}
textMetricsStyle={textMetricsStyle}
wrapLines={wrapLines}
testID={textTestID}
/>
) : (
<Text style={textStyle} testID={textTestID}>
{formatDiffContentText(line.content)}
</Text>
)}
</LongPressableLine>
);
}
function SplitTextLine({
line,
wrapLines,
textMetricsStyle,
reviewActions,
onHoverChange,
hoverTargetKey,
onHoverTargetChange,
}: {
line: SplitDiffDisplayLine | null;
wrapLines: boolean;
textMetricsStyle: TextStyle;
reviewActions?: InlineReviewActions;
onHoverChange?: (hovered: boolean) => void;
hoverTargetKey?: string | null;
onHoverTargetChange?: (key: string | null) => void;
}) {
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
const containerStyle = useMemo(
() => [styles.textLineContainer, lineTypeBackground(line?.type), rowMetricsStyle],
[line?.type, rowMetricsStyle],
);
const textStyle = useMemo(
() => [
styles.diffTextMetrics,
textMetricsStyle,
styles.diffLineText,
getWrappedTextStyle(wrapLines),
line?.type === "add" && styles.addLineText,
line?.type === "remove" && styles.removeLineText,
line?.type === "context" && styles.contextLineText,
!line && styles.emptySplitCellText,
],
[line, textMetricsStyle, wrapLines],
);
return (
<LongPressableLine
reviewTarget={line?.reviewTarget}
reviewActions={reviewActions}
onHoverChange={onHoverChange}
hoverTargetKey={hoverTargetKey}
onHoverTargetChange={onHoverTargetChange}
style={containerStyle}
>
{visibleTokens ? (
<HighlightedText
tokens={visibleTokens}
textMetricsStyle={textMetricsStyle}
wrapLines={wrapLines}
/>
) : (
<Text style={textStyle}>{formatDiffContentText(line?.content)}</Text>
)}
</LongPressableLine>
);
}
function DiffLineView({
line,
lineNumber,
gutterWidth,
wrapLines,
textMetricsStyle,
reviewTarget,
reviewActions,
}: {
line: DiffLine;
lineNumber: number | null;
gutterWidth: number;
wrapLines: boolean;
textMetricsStyle: TextStyle;
reviewTarget?: ReviewableDiffTarget | null;
reviewActions?: InlineReviewActions;
}) {
const [isLineHovered, setIsLineHovered] = useState(false);
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
const containerStyle = useMemo(
() => [styles.diffLineContainer, lineTypeBackground(line.type), rowMetricsStyle],
[line.type, rowMetricsStyle],
);
const textStyle = useMemo(
() => [
styles.diffTextMetrics,
textMetricsStyle,
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, textMetricsStyle, wrapLines],
);
return (
<LongPressableLine
reviewTarget={reviewTarget}
reviewActions={reviewActions}
onHoverChange={setIsLineHovered}
style={containerStyle}
>
<DiffGutterCell
lineNumber={lineNumber}
type={line.type}
gutterWidth={gutterWidth}
textMetricsStyle={textMetricsStyle}
reviewTarget={reviewTarget}
reviewActions={reviewActions}
isLineHovered={isLineHovered}
style={styles.lineNumberGutter}
/>
{line.type !== "header" && visibleTokens ? (
<HighlightedText
tokens={visibleTokens}
textMetricsStyle={textMetricsStyle}
wrapLines={wrapLines}
/>
) : (
<Text style={textStyle}>{formatDiffContentText(line.content)}</Text>
)}
</LongPressableLine>
);
}
function SplitDiffLine({
line,
gutterWidth,
wrapLines,
textMetricsStyle,
reviewActions,
}: {
line: SplitDiffDisplayLine | null;
gutterWidth: number;
wrapLines: boolean;
textMetricsStyle: TextStyle;
reviewActions?: InlineReviewActions;
}) {
const [isLineHovered, setIsLineHovered] = useState(false);
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
const containerStyle = useMemo(
() => [styles.diffLineContainer, lineTypeBackground(line?.type), rowMetricsStyle],
[line?.type, rowMetricsStyle],
);
const textStyle = useMemo(
() => [
styles.diffTextMetrics,
textMetricsStyle,
styles.diffLineText,
getWrappedTextStyle(wrapLines),
line?.type === "add" && styles.addLineText,
line?.type === "remove" && styles.removeLineText,
line?.type === "context" && styles.contextLineText,
!line && styles.emptySplitCellText,
],
[line, textMetricsStyle, wrapLines],
);
return (
<LongPressableLine
reviewTarget={line?.reviewTarget}
reviewActions={reviewActions}
onHoverChange={setIsLineHovered}
style={containerStyle}
>
<DiffGutterCell
lineNumber={line?.lineNumber ?? null}
type={line?.type}
gutterWidth={gutterWidth}
textMetricsStyle={textMetricsStyle}
reviewTarget={line?.reviewTarget}
reviewActions={reviewActions}
isLineHovered={isLineHovered}
style={styles.lineNumberGutter}
/>
{visibleTokens ? (
<HighlightedText
tokens={visibleTokens}
textMetricsStyle={textMetricsStyle}
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,
textMetricsStyle,
reviewActions,
showDivider = false,
}: {
rows: SplitDiffRow[];
side: "left" | "right";
gutterWidth: number;
wrapLines: boolean;
textMetricsStyle: TextStyle;
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 headerLineTextStyle = useMemo(
() => [styles.diffTextMetrics, textMetricsStyle, styles.diffLineText, styles.headerLineText],
[textMetricsStyle],
);
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={headerLineTextStyle}>{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}
textMetricsStyle={textMetricsStyle}
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}
textMetricsStyle={textMetricsStyle}
/>
);
}
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}
textMetricsStyle={textMetricsStyle}
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={headerLineTextStyle}>{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}
textMetricsStyle={textMetricsStyle}
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,
depth = 0,
showDir = true,
onToggle,
onHeaderHeightChange,
testID,
}: DiffFileSectionProps) {
const { t } = useTranslation();
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],
);
const headerPressableStyle = useCallback(
(state: PressableStateCallbackType) =>
depth > 0
? [
fileHeaderPressableStyle(state),
inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) }),
]
: fileHeaderPressableStyle(state),
[depth],
);
const fileName = file.path.split("/").pop() ?? file.path;
return (
<View style={containerStyle} onLayout={handleLayout} testID={testID}>
<TreeIndentGuides depth={depth} />
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
testID={testID ? `${testID}-toggle` : undefined}
style={headerPressableStyle}
// Android: prevent parent pan/scroll gestures from canceling the tap release.
cancelable={false}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
onPress={toggleExpanded}
>
<View style={styles.fileHeaderLeft}>
{showDir ? null : (
<View style={styles.fileIcon}>
<SvgXml xml={getFileIconSvg(fileName)} width={16} height={16} />
</View>
)}
<Text style={styles.fileName} numberOfLines={1}>
{fileName}
</Text>
{showDir ? (
<Text style={styles.fileDir} numberOfLines={1}>
{file.path.includes("/")
? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}`
: ""}
</Text>
) : (
// Flex spacer in tree mode (no dir suffix) so the New/Deleted badge
// stays right-aligned next to the diff stats, as in the flat list.
<View style={styles.fileDirSpacer} />
)}
{file.isNew && (
<View style={styles.newBadge}>
<Text style={styles.newBadgeText}>{t("workspace.git.diff.newFile")}</Text>
</View>
)}
{file.isDeleted && (
<View style={styles.deletedBadge}>
<Text style={styles.deletedBadgeText}>{t("workspace.git.diff.deletedFile")}</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,
codeFontSize,
textMetricsStyle,
reviewActions,
onBodyHeightChange,
testID,
}: {
file: ParsedDiffFile;
layout: "unified" | "split";
wrapLines: boolean;
codeFontSize: number;
textMetricsStyle: TextStyle;
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 { t } = useTranslation();
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"
? t("workspace.git.diff.binaryFile")
: t("workspace.git.diff.tooLarge")}
</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, codeFontSize);
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}
textMetricsStyle={textMetricsStyle}
reviewActions={reviewActions}
/>
<SplitDiffColumn
rows={rows}
side="right"
gutterWidth={gutterWidth}
wrapLines={wrapLines}
textMetricsStyle={textMetricsStyle}
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 }, index) => (
<View key={key} testID={`diff-wrapped-row-${index}`}>
<DiffLineView
line={line}
lineNumber={lineNumber}
gutterWidth={gutterWidth}
wrapLines={wrapLines}
textMetricsStyle={textMetricsStyle}
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 }, index) => (
<View key={key} testID={`diff-gutter-row-${index}`}>
<DiffGutterCell
lineNumber={lineNumber}
type={line.type}
gutterWidth={gutterWidth}
textMetricsStyle={textMetricsStyle}
reviewTarget={reviewTarget}
reviewActions={reviewActions}
isLineHovered={
reviewTarget?.key !== undefined && hoveredReviewTargetKey === reviewTarget.key
}
textTestID={`diff-gutter-text-${index}`}
actionTestID={`diff-gutter-action-${index}`}
/>
<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 }, index) => (
<View key={key} testID={`diff-code-row-${index}`}>
<DiffTextLine
line={line}
wrapLines={false}
textMetricsStyle={textMetricsStyle}
reviewTarget={reviewTarget}
reviewActions={reviewActions}
hoverTargetKey={reviewTarget?.key ?? null}
onHoverTargetChange={setHoveredReviewTargetKey}
textTestID={`diff-code-text-${index}`}
/>
<InlineReviewThreadContent
reviewTarget={reviewTarget}
reviewActions={reviewActions}
viewportWidth={textViewportWidth}
pinToViewport
/>
</View>
))}
</View>
</DiffScroll>
</View>
);
})()}
</View>
);
}
interface GitDiffPaneProps {
serverId: string;
workspaceId?: string | null;
cwd: string;
enabled?: boolean;
}
type PressableStyleFn = (
state: PressableStateCallbackType & { hovered?: boolean; open?: boolean },
) => StyleProp<ViewStyle>;
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 ThemedFolderTree = withUnistyles(FolderTree);
const ThemedList = withUnistyles(List);
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 ThemedChevronDown = withUnistyles(ChevronDown);
const DIFF_OPTIONS_WHITESPACE_ICON = (
<ThemedPilcrow size={14} uniProps={foregroundMutedIconColorMapping} />
);
const DIFF_OPTIONS_WRAP_ICON = (
<ThemedWrapText size={14} uniProps={foregroundMutedIconColorMapping} />
);
interface DiffLayoutToggleProps {
layout: "unified" | "split";
isMobile: boolean;
toggleStyle: PressableStyleFn;
onToggle: () => void;
}
function DiffLayoutToggle({ layout, isMobile, toggleStyle, onToggle }: DiffLayoutToggleProps) {
const { t } = useTranslation();
const label =
layout === "unified"
? t("workspace.git.diff.switchToSplit")
: t("workspace.git.diff.switchToUnified");
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
testID="changes-toggle-layout"
onPress={onToggle}
style={toggleStyle}
>
{layout === "unified" ? (
<ThemedColumns2 size={isMobile ? 18 : 14} uniProps={foregroundMutedIconColorMapping} />
) : (
<ThemedAlignJustify
size={isMobile ? 18 : 14}
uniProps={foregroundMutedIconColorMapping}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{label}</Text>
</TooltipContent>
</Tooltip>
);
}
interface DiffViewModeToggleProps {
viewMode: "flat" | "tree";
isMobile: boolean;
toggleStyle: PressableStyleFn;
onToggle: () => void;
}
function DiffViewModeToggle({
viewMode,
isMobile,
toggleStyle,
onToggle,
}: DiffViewModeToggleProps) {
const { t } = useTranslation();
const label =
viewMode === "flat"
? t("workspace.git.diff.showTreeView")
: t("workspace.git.diff.showFlatView");
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
testID="changes-toggle-view-mode"
style={toggleStyle}
onPress={onToggle}
>
{viewMode === "flat" ? (
<ThemedFolderTree
size={isMobile ? 18 : 14}
uniProps={foregroundMutedIconColorMapping}
/>
) : (
<ThemedList size={isMobile ? 18 : 14} uniProps={foregroundMutedIconColorMapping} />
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{label}</Text>
</TooltipContent>
</Tooltip>
);
}
interface DiffFilesToolbarProps {
allFileDiffsExpanded: boolean;
isMobile: boolean;
expandAllToggleStyle: PressableStyleFn;
onToggleExpandAll: () => void;
}
function DiffFilesToolbar({
allFileDiffsExpanded,
isMobile,
expandAllToggleStyle,
onToggleExpandAll,
}: DiffFilesToolbarProps) {
const { t } = useTranslation();
const expandAllLabel = allFileDiffsExpanded
? t("workspace.git.diff.collapseAll")
: t("workspace.git.diff.expandAll");
return (
<View style={styles.diffStatusButtons}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={expandAllLabel}
style={expandAllToggleStyle}
onPress={onToggleExpandAll}
>
{allFileDiffsExpanded ? (
<ThemedListChevronsDownUp
size={isMobile ? 18 : 14}
uniProps={foregroundMutedIconColorMapping}
/>
) : (
<ThemedListChevronsUpDown
size={isMobile ? 18 : 14}
uniProps={foregroundMutedIconColorMapping}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{expandAllLabel}</Text>
</TooltipContent>
</Tooltip>
</View>
);
}
interface DiffOptionsMenuProps {
hideWhitespace: boolean;
isMobile: boolean;
isRefreshing: boolean;
overflowToggleStyle: PressableStyleFn;
refreshSupported: boolean;
wrapLines: boolean;
onRefresh: () => void;
onToggleHideWhitespace: () => void;
onToggleWrapLines: () => void;
}
function DiffOptionsMenu({
hideWhitespace,
isMobile,
isRefreshing,
overflowToggleStyle,
refreshSupported,
wrapLines,
onRefresh,
onToggleHideWhitespace,
onToggleWrapLines,
}: DiffOptionsMenuProps) {
const { t } = useTranslation();
const whitespaceLabel = hideWhitespace
? t("workspace.git.diff.showWhitespace")
: t("workspace.git.diff.hideWhitespace");
const wrapLinesLabel = wrapLines
? t("workspace.git.diff.scrollLongLines")
: t("workspace.git.diff.wrapLongLines");
const optionsLabel = t("workspace.git.diff.options");
const refreshIcon = useMemo(
() =>
isRefreshing ? (
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedIconColorMapping} />
) : (
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedIconColorMapping} />
),
[isRefreshing],
);
return (
<DropdownMenu>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<DropdownMenuTrigger
accessibilityRole="button"
accessibilityLabel={optionsLabel}
testID="changes-options-menu"
style={overflowToggleStyle}
>
<ThemedChevronDown
size={isMobile ? 18 : 14}
uniProps={foregroundMutedIconColorMapping}
/>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{optionsLabel}</Text>
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" width={240} testID="changes-options-menu-content">
<DropdownMenuItem
leading={DIFF_OPTIONS_WHITESPACE_ICON}
selected={hideWhitespace}
testID="changes-toggle-whitespace"
onSelect={onToggleHideWhitespace}
>
{whitespaceLabel}
</DropdownMenuItem>
<DropdownMenuItem
leading={DIFF_OPTIONS_WRAP_ICON}
selected={wrapLines}
testID="changes-toggle-wrap-lines"
onSelect={onToggleWrapLines}
>
{wrapLinesLabel}
</DropdownMenuItem>
{refreshSupported ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
leading={refreshIcon}
disabled={isRefreshing}
testID="changes-refresh"
onSelect={onRefresh}
>
{isRefreshing ? t("workspace.git.diff.refreshing") : t("workspace.git.diff.refresh")}
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
}
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
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,
labels: {
hiddenWhitespace: string;
uncommitted: string;
againstBase: (baseRefLabel: string) => string;
},
): string {
if (hideWhitespace) {
return labels.hiddenWhitespace;
}
if (diffMode === "uncommitted") {
return labels.uncommitted;
}
return labels.againstBase(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;
checkingRepositoryLabel: string;
notRepositoryLabel: string;
}
function DiffBodyContent({
isStatusLoading,
statusErrorMessage,
notGit,
isDiffLoading,
diffErrorMessage,
hasChanges,
emptyMessage,
flatItems,
stickyHeaderIndices,
renderFlatItem,
flatKeyExtractor,
getFlatItemLayout,
flatExtraData,
diffListRef,
handleDiffListLayout,
handleDiffListScroll,
checkingRepositoryLabel,
notRepositoryLabel,
}: DiffBodyContentProps) {
if (isStatusLoading) {
return (
<View style={styles.loadingContainer}>
<ThemedActivityIndicator size="large" uniProps={foregroundMutedIconColorMapping} />
<Text style={styles.loadingText}>{checkingRepositoryLabel}</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}>{notRepositoryLabel}</Text>
</View>
);
}
if (isDiffLoading) {
return (
<View style={styles.loadingContainer}>
<ThemedActivityIndicator size="large" uniProps={foregroundMutedIconColorMapping} />
</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}
scrollEventThrottle={16}
showsVerticalScrollIndicator
// 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;
currentBranchName: string | null;
}
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;
const currentBranchName =
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null;
return {
gitStatus,
isGit,
notGit,
statusErrorMessage,
baseRef,
hasUncommittedChanges,
actionsDisabled,
currentBranchName,
};
}
function computeBaseRefLabel(baseRef: string | undefined, fallbackLabel: string): string {
if (!baseRef) return fallbackLabel;
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(): PressableStyleFn {
return ({ hovered, pressed, open }) => [
styles.diffModeTrigger,
(Boolean(hovered) || pressed || Boolean(open)) && styles.diffModeTriggerHovered,
];
}
function buildExpandAllButtonStyle(): PressableStyleFn {
return ({ hovered, pressed }) => [
styles.expandAllButton,
(Boolean(hovered) || pressed) && styles.toggleButtonSelected,
];
}
function buildToggleButtonStyle(
selected: boolean,
baseStyles: StyleProp<ViewStyle> | StyleProp<ViewStyle>[],
): PressableStyleFn {
return ({ hovered, pressed }) => [
baseStyles,
(selected || Boolean(hovered) || pressed) && styles.toggleButtonSelected,
];
}
function shouldEnableCheckoutDiff(input: { paneEnabled: boolean; isGit: boolean }): boolean {
return input.paneEnabled && input.isGit;
}
export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPaneProps) {
const { settings: appSettings } = useAppSettings();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const canUseSplitLayout = isWeb && !isMobile;
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
useChangesPreferences();
const wrapLines = changesPreferences.wrapLines;
const viewMode = changesPreferences.viewMode;
const effectiveLayout = canUseSplitLayout ? changesPreferences.layout : "unified";
const handleToggleWrapLines = useCallback(() => {
void updateChangesPreferences({ wrapLines: !wrapLines });
}, [updateChangesPreferences, wrapLines]);
const handleToggleHideWhitespace = useCallback(() => {
void updateChangesPreferences({ hideWhitespace: !changesPreferences.hideWhitespace });
}, [changesPreferences.hideWhitespace, updateChangesPreferences]);
const handleToggleLayout = useCallback(() => {
void updateChangesPreferences({
layout: changesPreferences.layout === "unified" ? "split" : "unified",
});
}, [changesPreferences.layout, updateChangesPreferences]);
const codeFontSize = appSettings.codeFontSize;
const diffBodyLineHeight = Math.round(codeFontSize * 1.5);
const diffBodyTypographyKey = [appSettings.monoFontFamily, codeFontSize, diffBodyLineHeight].join(
":",
);
const diffTextMetricsStyle = useMemo<TextStyle>(() => {
const monoFontFamily = appSettings.monoFontFamily.trim();
return {
fontSize: codeFontSize,
lineHeight: diffBodyLineHeight,
...(monoFontFamily ? { fontFamily: monoFontFamily } : null),
};
}, [appSettings.monoFontFamily, codeFontSize, diffBodyLineHeight]);
const diffModeTriggerStyle = useMemo(() => buildDiffModeTriggerStyle(), []);
const layoutToggleStyle = useMemo(
() => buildToggleButtonStyle(false, styles.expandAllButton),
[],
);
const viewModeToggleStyle = useMemo(
() => buildToggleButtonStyle(viewMode === "tree", styles.expandAllButton),
[viewMode],
);
const expandAllToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
const overflowToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
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 : t("workspace.git.diff.failedRefresh"));
});
}, [cwd, isRefreshing, runRefresh, serverId, t, 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, currentBranchName } =
statusState;
const reviewDraftScopeKey = useMemo(
() =>
buildReviewDraftScopeKey({
serverId,
workspaceId,
cwd,
baseRef,
ignoreWhitespace: changesPreferences.hideWhitespace,
}),
[baseRef, changesPreferences.hideWhitespace, cwd, serverId, workspaceId],
);
const diffMode = useResolvedDiffMode({
scopeKey: reviewDraftScopeKey,
hasUncommittedChanges,
});
const setDiffModeOverride = useSetDiffModeOverride();
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 handleSelectUncommitted = useCallback(() => {
setDiffModeOverride({
scopeKey: reviewDraftScopeKey,
override: { serverId, cwd, mode: "uncommitted", isDirtyAtSelection: hasUncommittedChanges },
});
}, [cwd, hasUncommittedChanges, reviewDraftScopeKey, serverId, setDiffModeOverride]);
const handleSelectBase = useCallback(() => {
setDiffModeOverride({
scopeKey: reviewDraftScopeKey,
override: { serverId, cwd, mode: "base", isDirtyAtSelection: hasUncommittedChanges },
});
}, [cwd, hasUncommittedChanges, reviewDraftScopeKey, serverId, setDiffModeOverride]);
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]);
// The Changes view groups files into a directory tree on every form factor,
// consistent with the Files explorer (which is also a tree on mobile).
const collapsedFoldersArray = usePanelStore((state) =>
workspaceStateKey ? state.diffCollapsedFoldersByWorkspace[workspaceStateKey] : undefined,
);
const setDiffCollapsedFoldersForWorkspace = usePanelStore(
(state) => state.setDiffCollapsedFoldersForWorkspace,
);
// Build the directory tree once per files-change; collapse/expand toggles only
// re-flatten it (they don't change tree shape).
const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]);
// Every directory path currently in the tree — used by "collapse all folders" and to
// filter stale collapse state.
const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]);
const allFolderPathSet = useMemo(() => new Set(allFolderPaths), [allFolderPaths]);
// Effective collapsed set: intersect the persisted paths with the folders actually
// present, purely at render (no store-syncing effect). A folder that left the diff and
// reappears defaults to expanded; toggles write back this pruned set, so the stored
// array stays bounded. (empty = all folders expanded, the default)
const collapsedFolders = useMemo(
() => new Set((collapsedFoldersArray ?? []).filter((path) => allFolderPathSet.has(path))),
[collapsedFoldersArray, allFolderPathSet],
);
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
const handleToggleViewMode = useCallback(() => {
const nextViewMode = viewMode === "flat" ? "tree" : "flat";
if (nextViewMode === "tree") {
diffListRef.current?.scrollToOffset({ offset: 0, animated: false });
if (workspaceStateKey) {
setDiffCollapsedFoldersForWorkspace(workspaceStateKey, []);
}
}
void updateChangesPreferences({ viewMode: nextViewMode });
}, [setDiffCollapsedFoldersForWorkspace, updateChangesPreferences, viewMode, workspaceStateKey]);
const diffListScrollOffsetRef = useRef(0);
const diffListViewportHeightRef = useRef(0);
const headerHeightByPathRef = useRef<Record<string, number>>({});
const bodyHeightByKeyRef = useRef<Record<string, number>>({});
// Folder rows are a distinct kind; keep their height out of headerHeightByPathRef
// (Codex item 6) so file/folder heights can't collide by path.
const folderRowHeightRef = useRef<number>(0);
const defaultHeaderHeightRef = useRef<number>(44);
const [heightVersion, setHeightVersion] = useState(0);
const diffBodyChromeHeight = BORDER_WIDTH[1] * 2;
const statusBodyHeightEstimate = diffBodyChromeHeight + SPACING[4] * 2 + diffBodyLineHeight;
const { flatItems, stickyHeaderIndices } = useMemo(() => {
const { items, stickyHeaderIndices: stickyIndices } = buildDiffFlatItems({
files,
viewMode,
tree: compressedTree,
collapsedFolders,
expandedPaths,
});
return { flatItems: items, stickyHeaderIndices: stickyIndices };
}, [compressedTree, collapsedFolders, expandedPaths, files, viewMode]);
const getBodyHeightKey = useCallback(
(file: ParsedDiffFile): string => {
if (file.status === "too_large" || file.status === "binary") {
return `${effectiveLayout}:${wrapLines ? "wrap" : "scroll"}:${diffBodyTypographyKey}:${file.path}:${file.status}`;
}
return [
effectiveLayout,
wrapLines ? "wrap" : "scroll",
diffBodyTypographyKey,
file.path,
file.status ?? "ok",
file.additions,
file.deletions,
file.hunks.length,
getUnifiedDiffLineCount(file),
getDiffContentLength(file),
].join(":");
},
[diffBodyTypographyKey, 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],
);
// Single height source of truth for both getItemLayout and the collapse
// scroll-anchor math. Folder rows use their own measured height (Codex item 6),
// falling back to the default header height before first measurement.
const getFlatItemHeight = useCallback(
(item: DiffFlatItem): number => {
if (item.type === "folder") {
return folderRowHeightRef.current || defaultHeaderHeightRef.current;
}
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 handleFolderRowHeightChange = useCallback((height: number) => {
if (!Number.isFinite(height) || height <= 0) {
return;
}
const previousHeight = folderRowHeightRef.current;
if (previousHeight > 0 && Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON) {
return;
}
folderRowHeightRef.current = height;
setHeightVersion((version) => version + 1);
}, []);
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;
}, []);
const handleDiffListLayout = useCallback((event: LayoutChangeEvent) => {
const height = event.nativeEvent.layout.height;
if (!Number.isFinite(height) || height <= 0) {
return;
}
diffListViewportHeightRef.current = height;
}, []);
// Offset of the first item matching `predicate`, walking the SAME flatItems
// list getFlatItemLayout uses so folder rows are counted (single source of
// truth — Codex item 5 / finding 2).
const computeItemOffset = useCallback(
(predicate: (item: DiffFlatItem) => boolean): number | null => {
const index = flatItems.findIndex(predicate);
if (index < 0) {
return null;
}
return sumHeightsBefore(flatItems, index, getFlatItemHeight);
},
[flatItems, getFlatItemHeight],
);
const computeHeaderOffset = useCallback(
(path: string): number =>
computeItemOffset((item) => item.type === "header" && item.file.path === path) ?? 0,
[computeItemOffset],
);
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 handleToggleFolder = useCallback(
(dirPath: string) => {
if (!workspaceStateKey) {
return;
}
const isCurrentlyCollapsed = collapsedFolders.has(dirPath);
// Collapsing hides the subtree below this row; anchor to the folder row
// first so the viewport doesn't jump to a dead offset (Codex item 5).
if (!isCurrentlyCollapsed) {
const targetOffset = computeItemOffset(
(item) => item.type === "folder" && item.dirPath === dirPath,
);
const folderHeight = folderRowHeightRef.current || defaultHeaderHeightRef.current;
if (
targetOffset !== null &&
shouldAnchorHeaderBeforeCollapse({
headerOffset: targetOffset,
headerHeight: folderHeight,
viewportOffset: diffListScrollOffsetRef.current,
viewportHeight: diffListViewportHeightRef.current,
})
) {
diffListRef.current?.scrollToOffset({ offset: targetOffset, animated: false });
}
}
const nextCollapsed = isCurrentlyCollapsed
? Array.from(collapsedFolders).filter((path) => path !== dirPath)
: [...collapsedFolders, dirPath];
setDiffCollapsedFoldersForWorkspace(workspaceStateKey, nextCollapsed);
},
[collapsedFolders, computeItemOffset, setDiffCollapsedFoldersForWorkspace, workspaceStateKey],
);
const allFileDiffsExpanded = useMemo(() => {
if (files.length === 0) return false;
return files.every((file) => expandedPaths.has(file.path));
}, [expandedPaths, files]);
const handleToggleExpandAll = useCallback(() => {
if (!workspaceStateKey) {
return;
}
if (allFileDiffsExpanded) {
setDiffExpandedPathsForWorkspace(workspaceStateKey, []);
} else {
setDiffExpandedPathsForWorkspace(
workspaceStateKey,
files.map((file) => file.path),
);
}
}, [allFileDiffsExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
const renderFlatItem = useCallback(
({ item }: { item: DiffFlatItem }) => {
if (item.type === "folder") {
return (
<DiffFolderRow
dirPath={item.dirPath}
displayName={item.displayName}
depth={item.depth}
collapsed={item.collapsed}
additions={item.additions}
deletions={item.deletions}
onToggle={handleToggleFolder}
onHeightChange={handleFolderRowHeightChange}
testID={`diff-folder-${item.dirPath}`}
/>
);
}
if (item.type === "header") {
return (
<DiffFileHeader
file={item.file}
isExpanded={item.isExpanded}
depth={item.depth}
showDir={viewMode === "flat"}
onToggle={handleToggleExpanded}
onHeaderHeightChange={handleHeaderHeightChange}
testID={`diff-file-${item.fileIndex}`}
/>
);
}
return (
<DiffFileBody
file={item.file}
layout={effectiveLayout}
wrapLines={wrapLines}
codeFontSize={codeFontSize}
textMetricsStyle={diffTextMetricsStyle}
reviewActions={reviewActions}
onBodyHeightChange={handleBodyHeightChange}
testID={`diff-file-${item.fileIndex}-body`}
/>
);
},
[
codeFontSize,
diffTextMetricsStyle,
effectiveLayout,
handleBodyHeightChange,
handleFolderRowHeightChange,
handleHeaderHeightChange,
handleToggleExpanded,
handleToggleFolder,
reviewActions,
viewMode,
wrapLines,
],
);
const flatKeyExtractor = useCallback(
(item: DiffFlatItem) =>
item.type === "folder" ? `folder-${item.dirPath}` : `${item.type}-${item.file.path}`,
[],
);
const getFlatItemLayout = useCallback<DiffFlatItemLayoutGetter>(
(_data, index) => {
const offset = sumHeightsBefore(flatItems, index, getFlatItemHeight);
const item = flatItems[index];
const length = item ? getFlatItemHeight(item) : 0;
return { length, offset, index };
},
[flatItems, getFlatItemHeight],
);
const flatExtraData = useMemo(
() => ({
expandedPathsArray,
collapsedFoldersArray,
effectiveLayout,
diffBodyTypographyKey,
heightVersion,
viewMode,
wrapLines,
reviewActions,
}),
[
expandedPathsArray,
collapsedFoldersArray,
effectiveLayout,
diffBodyTypographyKey,
heightVersion,
viewMode,
wrapLines,
reviewActions,
],
);
const hasChanges = files.length > 0;
const diffErrorMessage = diffPayloadError?.message ?? null;
const prErrorMessage = computePrErrorMessage(githubFeaturesEnabled, prPayloadError);
const baseRefLabel = useMemo(
() => computeBaseRefLabel(baseRef, t("workspace.git.diff.base")),
[baseRef, t],
);
const gitActionsIcons = useMemo(
() => ({
commit: <ThemedGitCommitHorizontal size={16} uniProps={foregroundMutedIconColorMapping} />,
pull: <ThemedDownload size={16} uniProps={foregroundMutedIconColorMapping} />,
push: <ThemedUpload size={16} uniProps={foregroundMutedIconColorMapping} />,
pullAndPush: <ThemedArrowDownUp size={16} uniProps={foregroundMutedIconColorMapping} />,
viewPr: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
createPr: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
mergePrSquash: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
mergePrMerge: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
mergePrRebase: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
merge: <ThemedGitMerge size={16} uniProps={foregroundMutedIconColorMapping} />,
mergeFromBase: <ThemedRefreshCcw size={16} uniProps={foregroundMutedIconColorMapping} />,
archive: <ThemedArchive size={16} uniProps={foregroundMutedIconColorMapping} />,
}),
[],
);
const { gitActions, branchLabel } = useGitActions({
serverId,
cwd,
icons: gitActionsIcons,
});
const committedDiffDescription = useMemo(
() => computeCommittedDiffDescription(branchLabel, baseRefLabel),
[baseRefLabel, branchLabel],
);
const uncommittedLabel = t("workspace.git.diff.uncommitted");
const committedLabel = t("workspace.git.diff.committed");
const emptyMessage = computeEmptyMessage(
changesPreferences.hideWhitespace,
diffMode,
baseRefLabel,
{
hiddenWhitespace: t("workspace.git.diff.emptyHiddenWhitespace"),
uncommitted: t("workspace.git.diff.emptyUncommitted"),
againstBase: (label) => t("workspace.git.diff.emptyAgainstBase", { baseRef: label }),
},
);
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}
checkingRepositoryLabel={t("workspace.git.diff.checkingRepository")}
notRepositoryLabel={t("workspace.git.diff.notRepository")}
/>
);
return (
<View style={styles.container}>
{isGit && (currentBranchName || isMobile) ? (
<View style={styles.header} testID="changes-header">
<BranchSwitcher
currentBranchName={currentBranchName}
serverId={serverId}
workspaceId={workspaceId ?? cwd}
workspaceDirectory={cwd}
isGitCheckout={isGit}
testID="changes-branch-switcher"
/>
{isMobile ? <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={t("workspace.git.diff.diffMode")}
>
<Text style={styles.diffStatusText} numberOfLines={1}>
{diffMode === "uncommitted" ? uncommittedLabel : committedLabel}
</Text>
<ThemedChevronDown size={12} uniProps={foregroundMutedIconColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" width={260} testID="changes-diff-status-menu">
<DropdownMenuItem
testID="changes-diff-mode-uncommitted"
selected={diffMode === "uncommitted"}
onSelect={handleSelectUncommitted}
>
{uncommittedLabel}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
testID="changes-diff-mode-committed"
selected={diffMode === "base"}
description={committedDiffDescription}
onSelect={handleSelectBase}
>
{committedLabel}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<View style={styles.diffStatusButtons}>
{canUseSplitLayout ? (
<DiffLayoutToggle
layout={changesPreferences.layout}
isMobile={isMobile}
toggleStyle={layoutToggleStyle}
onToggle={handleToggleLayout}
/>
) : null}
{files.length > 0 ? (
<DiffViewModeToggle
viewMode={viewMode}
isMobile={isMobile}
toggleStyle={viewModeToggleStyle}
onToggle={handleToggleViewMode}
/>
) : null}
{files.length > 0 ? (
<DiffFilesToolbar
allFileDiffsExpanded={allFileDiffsExpanded}
isMobile={isMobile}
expandAllToggleStyle={expandAllToggleStyle}
onToggleExpandAll={handleToggleExpandAll}
/>
) : null}
<DiffOptionsMenu
hideWhitespace={changesPreferences.hideWhitespace}
isMobile={isMobile}
isRefreshing={isRefreshing}
overflowToggleStyle={overflowToggleStyle}
refreshSupported={refreshSupported}
wrapLines={wrapLines}
onRefresh={handleRefresh}
onToggleHideWhitespace={handleToggleHideWhitespace}
onToggleWrapLines={handleToggleWrapLines}
/>
</View>
</View>
</View>
) : null}
{prErrorMessage ? <Text style={styles.actionErrorText}>{prErrorMessage}</Text> : null}
<View style={styles.diffContainer}>{bodyContent}</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,
},
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",
},
toggleButtonSelected: {
backgroundColor: theme.colors.surface2,
},
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,
},
fileIcon: {
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
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,
},
fileDirSpacer: {
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",
},
diffTextMetrics: {
fontSize: theme.fontSize.code,
lineHeight: theme.lineHeight.diff,
fontFamily: theme.fontFamily.mono,
},
lineNumberText: {
width: "100%",
textAlign: "right",
paddingRight: theme.spacing[2],
color: theme.colors.foregroundMuted,
userSelect: "none",
},
addLineNumberText: {
color: theme.colors.diffAddition,
},
removeLineNumberText: {
color: theme.colors.diffDeletion,
},
diffLineText: {
flex: 1,
paddingRight: theme.spacing[3],
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 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;