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, 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 { 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 { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; 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[]; 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 HighlightedToken({ token }: { token: HighlightToken }) { return {token.text}; } function HighlightedText({ tokens, wrapLines = false, testID }: HighlightedTextProps) { const containerStyle = useMemo( () => [styles.diffTextMetrics, styles.diffLineText, getWrappedTextStyle(wrapLines)], [wrapLines], ); const keyedTokens = useMemo( () => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })), [tokens], ); return ( {keyedTokens.map(({ key, token }) => ( ))} ); } 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; 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 ( {children} ); } if (!isNative || !reviewTarget || !onStartComment) { return {children}; } return ( {children} ); } 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, textTestID, actionTestID, }: { lineNumber: number | null; type: DiffLine["type"] | undefined | null; gutterWidth: number; reviewTarget?: ReviewableDiffTarget | null; reviewActions?: InlineReviewActions; isLineHovered?: boolean; style?: StyleProp; textTestID?: string; actionTestID?: string; }) { const containerStyle = useMemo( () => [ styles.gutterCell, lineTypeBackground(type), inlineUnistylesStyle({ width: gutterWidth }), style, ], [type, gutterWidth, style], ); const textStyle = useMemo( () => [ styles.diffTextMetrics, 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 ( {formatDiffGutterText(lineNumber)} ); } function DiffTextLine({ line, wrapLines, reviewTarget, reviewActions, onHoverChange, hoverTargetKey, onHoverTargetChange, textTestID, }: { line: DiffLine; wrapLines: boolean; 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 containerStyle = useMemo( () => [styles.textLineContainer, lineTypeBackground(line.type)], [line.type], ); const textStyle = useMemo( () => [ styles.diffTextMetrics, 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 ( {line.type !== "header" && visibleTokens ? ( ) : ( {formatDiffContentText(line.content)} )} ); } 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.diffTextMetrics, 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 ( {visibleTokens ? ( ) : ( {formatDiffContentText(line?.content)} )} ); } 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.diffTextMetrics, 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 ( {line.type !== "header" && visibleTokens ? ( ) : ( {formatDiffContentText(line.content)} )} ); } 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.diffTextMetrics, 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 ( {visibleTokens ? ( ) : ( {formatDiffContentText(line?.content)} )} ); } 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( () => inlineUnistylesStyle({ minHeight: height }), [height], ); if (height === 0) { return null; } if (!reviewTarget || !reviewActions || !threadState) { return ; } return ( ); } function InlineReviewGutterSpacer({ reviewTarget, reviewActions, gutterWidth, reservedHeight, style, }: { reviewTarget: ReviewableDiffTarget | null | undefined; reviewActions?: InlineReviewActions; gutterWidth: number; reservedHeight?: number; style?: StyleProp; }) { const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions }); const height = reservedHeight ?? threadState?.height ?? 0; const spacerStyle = useMemo>( () => [ styles.inlineReviewGutterSpacer, inlineUnistylesStyle({ width: gutterWidth, minHeight: height }), style, ], [gutterWidth, height, style], ); if (height === 0) { return null; } return ; } 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>( () => [styles.inlineReviewGutterSpacer, inlineUnistylesStyle({ width: gutterWidth })], [gutterWidth], ); const placeholderStyle = useMemo( () => inlineUnistylesStyle({ minHeight: height }), [height], ); if (height === 0) { return null; } return ( {reviewTarget && reviewActions && threadState ? ( ) : ( )} ); } 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(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 ( {keyedRows.map(({ key, row }) => { if (row.kind === "header") { return ( {row.content} ); } const line = side === "left" ? row.left : row.right; const reviewRowState = getSplitInlineReviewThreadState({ left: row.left?.reviewTarget, right: row.right?.reviewTarget, reviewActions, }); return ( ); })} ); } return ( {keyedRows.map(({ key, row }) => { if (row.kind === "header") { return ( ); } 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 ( ); })} {keyedRows.map(({ key, row }) => { if (row.kind === "header") { return ( {row.content} ); } 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 ( ); })} ); } const DiffFileHeader = memo(function DiffFileHeader({ file, isExpanded, onToggle, onHeaderHeightChange, testID, }: DiffFileSectionProps) { const { t } = useTranslation(); const layoutYRef = useRef(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 ( {file.path.split("/").pop()} {file.path.includes("/") ? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}` : ""} {file.isNew && ( {t("workspace.git.diff.newFile")} )} {file.isDeleted && ( {t("workspace.git.diff.deletedFile")} )} {file.path} ); }); function DiffFileBody({ file, layout, wrapLines, codeFontSize, reviewActions, onBodyHeightChange, testID, }: { file: ParsedDiffFile; layout: "unified" | "split"; wrapLines: boolean; codeFontSize: number; reviewActions?: InlineReviewActions; onBodyHeightChange?: (file: ParsedDiffFile, height: number) => void; testID?: string; }) { const [scrollViewWidth, setScrollViewWidth] = useState(0); const [bodyWidth, setBodyWidth] = useState(0); const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState(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 ( {(() => { if (file.status === "too_large" || file.status === "binary") { return ( {file.status === "binary" ? t("workspace.git.diff.binaryFile") : t("workspace.git.diff.tooLarge")} ); } 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 ( ); } const computedLines = buildUnifiedDiffLines(file); if (wrapLines) { return ( {computedLines.map(({ line, lineNumber, key, reviewTarget }, index) => ( ))} ); } const textViewportWidth = scrollViewWidth > 0 ? scrollViewWidth : Math.max(0, bodyWidth - gutterWidth); return ( {computedLines.map(({ line, lineNumber, key, reviewTarget }, index) => ( ))} {computedLines.map(({ line, key, reviewTarget }, index) => ( ))} ); })()} ); } interface GitDiffPaneProps { serverId: string; workspaceId?: string | null; cwd: string; enabled?: boolean; } type PressableStyleFn = ( state: PressableStateCallbackType & { hovered?: boolean; open?: boolean }, ) => StyleProp; const foregroundIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const ThemedActivityIndicator = withUnistyles(ActivityIndicator); const ThemedAlignJustify = withUnistyles(AlignJustify); const ThemedColumns2 = withUnistyles(Columns2); const ThemedPilcrow = withUnistyles(Pilcrow); const ThemedWrapText = withUnistyles(WrapText); const ThemedListChevronsDownUp = withUnistyles(ListChevronsDownUp); const ThemedListChevronsUpDown = withUnistyles(ListChevronsUpDown); const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal); const ThemedDownload = withUnistyles(Download); const ThemedUpload = withUnistyles(Upload); const ThemedArrowDownUp = withUnistyles(ArrowDownUp); const ThemedGitHubIcon = withUnistyles(GitHubIcon); const ThemedGitMerge = withUnistyles(GitMerge); const ThemedRefreshCcw = withUnistyles(RefreshCcw); const ThemedArchive = withUnistyles(Archive); const ThemedChevronDown = withUnistyles(ChevronDown); interface DiffLayoutToggleGroupProps { layout: "unified" | "split"; unifiedToggleStyle: PressableStyleFn; splitToggleStyle: PressableStyleFn; onUnified: () => void; onSplit: () => void; } function DiffLayoutToggleGroup({ layout, unifiedToggleStyle, splitToggleStyle, onUnified, onSplit, }: DiffLayoutToggleGroupProps) { const { t } = useTranslation(); const unifiedLabel = t("workspace.git.diff.unified"); const splitLabel = t("workspace.git.diff.split"); return ( {unifiedLabel} {splitLabel} ); } interface DiffWhitespaceToggleProps { hideWhitespace: boolean; isMobile: boolean; toggleStyle: PressableStyleFn; onToggle: () => void; } function DiffWhitespaceToggle({ hideWhitespace, isMobile, toggleStyle, onToggle, }: DiffWhitespaceToggleProps) { const { t } = useTranslation(); const label = t("workspace.git.diff.hideWhitespace"); return ( {label} ); } 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 { t } = useTranslation(); const wrapLinesLabel = wrapLines ? t("workspace.git.diff.scrollLongLines") : t("workspace.git.diff.wrapLongLines"); const expandAllLabel = allExpanded ? t("workspace.git.diff.collapseAll") : t("workspace.git.diff.expandAll"); return ( {wrapLinesLabel} {allExpanded ? ( ) : ( )} {expandAllLabel} ); } interface DiffRefreshButtonProps { isRefreshing: boolean; toggleStyle: PressableStyleFn; onPress: () => void; } const ThemedRotateCw = withUnistyles(RotateCw); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); function DiffRefreshButton({ isRefreshing, toggleStyle, onPress }: DiffRefreshButtonProps) { const { t } = useTranslation(); const refreshLabel = t("workspace.git.diff.refresh"); return ( {isRefreshing ? ( ) : ( )} {refreshLabel} ); } type DiffFlatItem = | { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean } | { type: "body"; file: ParsedDiffFile; fileIndex: number }; type DiffFlatItemLayoutGetter = NonNullable["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 | null>; handleDiffListLayout: (event: LayoutChangeEvent) => void; handleDiffListScroll: (event: NativeSyntheticEvent) => void; onContentSizeChange: (width: number, height: number) => void; showDesktopWebScrollbar: boolean; checkingRepositoryLabel: string; notRepositoryLabel: string; } function DiffBodyContent({ isStatusLoading, statusErrorMessage, notGit, isDiffLoading, diffErrorMessage, hasChanges, emptyMessage, flatItems, stickyHeaderIndices, renderFlatItem, flatKeyExtractor, getFlatItemLayout, flatExtraData, diffListRef, handleDiffListLayout, handleDiffListScroll, onContentSizeChange, showDesktopWebScrollbar, checkingRepositoryLabel, notRepositoryLabel, }: DiffBodyContentProps) { if (isStatusLoading) { return ( {checkingRepositoryLabel} ); } if (statusErrorMessage) { return ( {statusErrorMessage} ); } if (notGit) { return ( {notRepositoryLabel} ); } if (isDiffLoading) { return ( ); } if (diffErrorMessage) { return ( {diffErrorMessage} ); } if (!hasChanges) { return ( {emptyMessage} ); } return ( ); } interface DeriveStatusStateInputs { status: ReturnType["status"]; isStatusLoading: boolean; isStatusError: boolean; statusError: unknown; } interface DerivedStatusState { gitStatus: NonNullable["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 | StyleProp[], ): 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 showDesktopWebScrollbar = isWeb && !isMobile; const canUseSplitLayout = isWeb && !isMobile; 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]); const handleLayoutUnified = useCallback(() => { handleLayoutChange("unified"); }, [handleLayoutChange]); const handleLayoutSplit = useCallback(() => { handleLayoutChange("split"); }, [handleLayoutChange]); const codeFontSize = appSettings.codeFontSize; const diffBodyLineHeight = Math.round(codeFontSize * 1.5); const diffBodyTypographyKey = [appSettings.monoFontFamily, codeFontSize, diffBodyLineHeight].join( ":", ); const diffModeTriggerStyle = useMemo(() => buildDiffModeTriggerStyle(), []); const unifiedToggleStyle = useMemo( () => buildToggleButtonStyle(changesPreferences.layout === "unified", [ styles.toggleButton, styles.toggleButtonGroupStart, ]), [changesPreferences.layout], ); const splitToggleStyle = useMemo( () => buildToggleButtonStyle(changesPreferences.layout === "split", [ styles.toggleButton, styles.toggleButtonGroupEnd, ]), [changesPreferences.layout], ); const hideWhitespaceToggleStyle = useMemo( () => buildToggleButtonStyle(changesPreferences.hideWhitespace, styles.expandAllButton), [changesPreferences.hideWhitespace], ); const wrapLinesToggleStyle = useMemo( () => buildToggleButtonStyle(wrapLines, styles.expandAllButton), [wrapLines], ); const expandAllToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []); const refreshToggleStyle = 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]); const diffListRef = useRef>(null); const scrollbar = useWebScrollViewScrollbar(diffListRef, { enabled: showDesktopWebScrollbar, }); const diffListScrollOffsetRef = useRef(0); const diffListViewportHeightRef = useRef(0); const headerHeightByPathRef = useRef>({}); const bodyHeightByKeyRef = useRef>({}); const defaultHeaderHeightRef = useRef(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: 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"}:${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], ); 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) => { 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]); const renderFlatItem = useCallback( ({ item }: { item: DiffFlatItem }) => { if (item.type === "header") { return ( ); } return ( ); }, [ codeFontSize, 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( (_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, diffBodyTypographyKey, heightVersion, wrapLines, reviewActions, }), [ expandedPathsArray, effectiveLayout, diffBodyTypographyKey, heightVersion, 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: , pull: , push: , pullAndPush: , viewPr: , createPr: , mergePrSquash: , mergePrMerge: , mergePrRebase: , merge: , mergeFromBase: , archive: , }), [], ); const { gitActions, branchLabel, worktreeDeletePrompt } = 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 = ( ); return ( {isGit && (currentBranchName || isMobile) ? ( {isMobile ? : null} {worktreeDeletePrompt} ) : null} {isGit ? ( {diffMode === "uncommitted" ? uncommittedLabel : committedLabel} {uncommittedLabel} {committedLabel} {canUseSplitLayout ? ( ) : null} {files.length > 0 ? ( ) : null} {refreshSupported ? ( ) : null} ) : null} {prErrorMessage ? {prErrorMessage} : null} {bodyContent} {hasChanges ? scrollbar.overlay : null} ); } 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", }, 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", }, 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 HEADER_LINE_TEXT_STYLE = [styles.diffTextMetrics, 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;