import { useState, useCallback, useEffect, useMemo, useRef, memo, type ReactElement, } from "react"; import { useRouter } from "expo-router"; import { View, Text, ActivityIndicator, Pressable, FlatList, Platform, type LayoutChangeEvent, type NativeSyntheticEvent, type NativeScrollEvent, } from "react-native"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { Archive, ChevronDown, GitBranch, GitCommitHorizontal, GitMerge, ListChevronsDownUp, ListChevronsUpDown, RefreshCcw, Upload, WrapText, } from "lucide-react-native"; import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store"; import { useCheckoutDiffQuery, type ParsedDiffFile, type DiffLine, type HighlightToken, } from "@/hooks/use-checkout-diff-query"; import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query"; import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query"; import { DiffScroll } from "./diff-scroll"; import { darkHighlightColors, lightHighlightColors, type HighlightStyle as HighlightStyleKey, } from "@getpaseo/highlight"; import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { Fonts } from "@/constants/theme"; import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll"; 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 { buildGitActions, type GitActions } from "@/components/git-actions-policy"; import { lineNumberGutterWidth } from "@/components/code-insets"; import { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics, } from "@/components/web-desktop-scrollbar"; import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing"; import { openExternalUrl } from "@/utils/open-external-url"; import { GitActionsSplitButton } from "@/components/git-actions-split-button"; export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy"; function openURLInNewTab(url: string): void { void openExternalUrl(url); } type HighlightStyle = NonNullable; interface HighlightedTextProps { tokens: HighlightToken[]; baseStyle: HighlightStyle | null; lineType: "add" | "remove" | "context" | "header"; } function HighlightedText({ tokens, lineType }: HighlightedTextProps) { const { theme } = useUnistyles(); const isDark = theme.colorScheme === "dark"; const getTokenColor = (style: HighlightStyle | null): string => { const baseColor = isDark ? "#c9d1d9" : "#24292f"; if (!style) return baseColor; const colors = isDark ? darkHighlightColors : lightHighlightColors; return colors[style as HighlightStyleKey] ?? baseColor; }; return ( {tokens.map((token, index) => ( {token.text} ))} ); } interface DiffFileSectionProps { file: ParsedDiffFile; isExpanded: boolean; onToggle: (path: string) => void; onHeaderHeightChange?: (path: string, height: number) => void; testID?: string; } function DiffLineView({ line, lineNumber, gutterWidth, }: { line: DiffLine; lineNumber: number | null; gutterWidth: number; }) { return ( {lineNumber != null ? String(lineNumber) : ""} {line.tokens && line.type !== "header" ? ( ) : ( {line.content || " "} )} ); } const DiffFileHeader = memo(function DiffFileHeader({ file, isExpanded, onToggle, onHeaderHeightChange, testID, }: DiffFileSectionProps) { 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]); return ( { layoutYRef.current = event.nativeEvent.layout.y; onHeaderHeightChange?.(file.path, event.nativeEvent.layout.height); }} testID={testID} > [styles.fileHeader, pressed && styles.fileHeaderPressed]} // Android: prevent parent pan/scroll gestures from canceling the tap release. cancelable={false} onPressIn={(event) => { pressHandledRef.current = false; pressInRef.current = { ts: Date.now(), pageX: event.nativeEvent.pageX, pageY: event.nativeEvent.pageY, }; }} onPressOut={(event) => { if ( Platform.OS !== "web" && !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); // Sticky headers on Android can emit pressIn/pressOut without onPress. // Treat short, low-movement interactions as taps. if (durationMs <= 500 && distance <= 12) { toggleExpanded(); } } }} onPress={toggleExpanded} > {file.path.split("/").pop()} {file.path.includes("/") ? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}` : ""} {file.isNew && ( New )} {file.isDeleted && ( Deleted )} +{file.additions} -{file.deletions} ); }); function DiffFileBody({ file, wrapLines, onBodyHeightChange, testID, }: { file: ParsedDiffFile; wrapLines: boolean; onBodyHeightChange?: (path: string, height: number) => void; testID?: string; }) { const [scrollViewWidth, setScrollViewWidth] = useState(0); return ( { onBodyHeightChange?.(file.path, event.nativeEvent.layout.height); }} testID={testID} > {(() => { if (file.status === "too_large" || file.status === "binary") { return ( {file.status === "binary" ? "Binary file" : "Diff too large to display"} ); } const linesContent = (() => { 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); return file.hunks.map((hunk, hunkIndex) => { let oldLineNo = hunk.oldStart; let newLineNo = hunk.newStart; return hunk.lines.map((line, lineIndex) => { let lineNumber: number | null = null; if (line.type === "remove") { lineNumber = oldLineNo; oldLineNo++; } else if (line.type === "add") { lineNumber = newLineNo; newLineNo++; } else if (line.type === "context") { lineNumber = newLineNo; oldLineNo++; newLineNo++; } return ( ); }); }); })(); if (wrapLines) { return ( {linesContent} ); } return ( 0 && { minWidth: scrollViewWidth }]} > {linesContent} ); })()} ); } interface GitDiffPaneProps { serverId: string; workspaceId?: string | null; cwd: string; hideHeaderRow?: boolean; } type DiffFlatItem = | { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean } | { type: "body"; file: ParsedDiffFile; fileIndex: number }; export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) { const { theme } = useUnistyles(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const router = useRouter(); const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null); const [actionError, setActionError] = useState(null); const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge"); const [wrapLines, setWrapLines] = useState(false); useEffect(() => { AsyncStorage.getItem("diff-wrap-lines").then((value) => { if (value === "true") setWrapLines(true); }); }, []); const handleToggleWrapLines = useCallback(() => { setWrapLines((prev) => { const next = !prev; AsyncStorage.setItem("diff-wrap-lines", String(next)); return next; }); }, []); const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus, } = useCheckoutStatusQuery({ serverId, cwd }); 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; // Auto-select diff mode based on state: uncommitted when dirty, base when clean const hasUncommittedChanges = Boolean(gitStatus?.isDirty); const autoDiffMode = hasUncommittedChanges ? "uncommitted" : "base"; const diffMode = diffModeOverride ?? autoDiffMode; const { files, payloadError: diffPayloadError, isLoading: isDiffLoading, isFetching: isDiffFetching, isError: isDiffError, error: diffError, refresh: refreshDiff, } = useCheckoutDiffQuery({ serverId, cwd, mode: diffMode, baseRef, enabled: isGit, }); const { status: prStatus, githubFeaturesEnabled, payloadError: prPayloadError, refresh: refreshPrStatus, } = useCheckoutPrStatusQuery({ serverId, cwd, enabled: isGit, }); // Track user-initiated refresh to avoid iOS RefreshControl animation on background fetches const [isManualRefresh, setIsManualRefresh] = useState(false); const [expandedByPath, setExpandedByPath] = useState>({}); const diffListRef = useRef>(null); const diffScrollbarMetrics = useWebDesktopScrollbarMetrics(); const diffListScrollOffsetRef = useRef(0); const diffListViewportHeightRef = useRef(0); const headerHeightByPathRef = useRef>({}); const bodyHeightByPathRef = useRef>({}); const defaultHeaderHeightRef = useRef(44); const handleRefresh = useCallback(() => { setIsManualRefresh(true); void refreshDiff(); void refreshStatus(); void refreshPrStatus(); }, [refreshDiff, refreshStatus, refreshPrStatus]); const shipDefaultStorageKey = useMemo(() => { if (!gitStatus?.repoRoot) { return null; } return `@paseo:changes-ship-default:${gitStatus.repoRoot}`; }, [gitStatus?.repoRoot]); useEffect(() => { if (!shipDefaultStorageKey) { return; } let isActive = true; AsyncStorage.getItem(shipDefaultStorageKey) .then((value) => { if (!isActive) return; if (value === "pr" || value === "merge") { setShipDefault(value); } }) .catch(() => undefined); return () => { isActive = false; }; }, [shipDefaultStorageKey]); const persistShipDefault = useCallback( async (next: "merge" | "pr") => { setShipDefault(next); if (!shipDefaultStorageKey) return; try { await AsyncStorage.setItem(shipDefaultStorageKey, next); } catch { // Ignore persistence failures; default will reset to "merge". } }, [shipDefaultStorageKey], ); const { flatItems, stickyHeaderIndices } = useMemo(() => { const items: DiffFlatItem[] = []; const stickyIndices: number[] = []; for (let i = 0; i < files.length; i++) { const file = files[i]; const isExpanded = expandedByPath[file.path] ?? false; 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 }; }, [files, expandedByPath]); const handleHeaderHeightChange = useCallback((path: string, height: number) => { if (!Number.isFinite(height) || height <= 0) { return; } headerHeightByPathRef.current[path] = height; defaultHeaderHeightRef.current = height; }, []); const handleBodyHeightChange = useCallback((path: string, height: number) => { if (!Number.isFinite(height) || height < 0) { return; } bodyHeightByPathRef.current[path] = height; }, []); const handleDiffListScroll = useCallback( (event: NativeSyntheticEvent) => { diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y; if (showDesktopWebScrollbar) { diffScrollbarMetrics.onScroll(event); } }, [diffScrollbarMetrics, showDesktopWebScrollbar], ); const handleDiffListLayout = useCallback( (event: LayoutChangeEvent) => { const height = event.nativeEvent.layout.height; if (!Number.isFinite(height) || height <= 0) { return; } diffListViewportHeightRef.current = height; if (showDesktopWebScrollbar) { diffScrollbarMetrics.onLayout(event); } }, [diffScrollbarMetrics, showDesktopWebScrollbar], ); 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 (expandedByPath[file.path]) { offset += bodyHeightByPathRef.current[file.path] ?? 0; } } return Math.max(0, offset); }, [expandedByPath, files], ); const handleToggleExpanded = useCallback( (path: string) => { const isCurrentlyExpanded = expandedByPath[path] ?? false; 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, }); } setExpandedByPath((prev) => ({ ...prev, // Use a deterministic target value (instead of toggling from prev) so duplicate // onPress events from sticky headers on Android can't flip back immediately. [path]: nextExpanded, })); }, [computeHeaderOffset, expandedByPath], ); const allExpanded = useMemo(() => { if (files.length === 0) return false; return files.every((file) => expandedByPath[file.path]); }, [files, expandedByPath]); const handleToggleExpandAll = useCallback(() => { if (allExpanded) { setExpandedByPath({}); } else { const newExpanded: Record = {}; for (const file of files) { newExpanded[file.path] = true; } setExpandedByPath(newExpanded); } }, [allExpanded, files]); // Reset manual refresh flag when fetch completes useEffect(() => { if (!(isDiffFetching || isStatusFetching) && isManualRefresh) { setIsManualRefresh(false); } }, [isDiffFetching, isStatusFetching, isManualRefresh]); // Clear diff mode override when auto mode changes (e.g., after commit) useEffect(() => { setDiffModeOverride(null); }, [autoDiffMode]); const commitStatus = useCheckoutGitActionsStore((state) => state.getStatus({ serverId, cwd, actionId: "commit" }), ); const pushStatus = useCheckoutGitActionsStore((state) => state.getStatus({ serverId, cwd, actionId: "push" }), ); const prCreateStatus = useCheckoutGitActionsStore((state) => state.getStatus({ serverId, cwd, actionId: "create-pr" }), ); const mergeStatus = useCheckoutGitActionsStore((state) => state.getStatus({ serverId, cwd, actionId: "merge-branch" }), ); const mergeFromBaseStatus = useCheckoutGitActionsStore((state) => state.getStatus({ serverId, cwd, actionId: "merge-from-base" }), ); const archiveStatus = useCheckoutGitActionsStore((state) => state.getStatus({ serverId, cwd, actionId: "archive-worktree" }), ); const runCommit = useCheckoutGitActionsStore((state) => state.commit); const runPush = useCheckoutGitActionsStore((state) => state.push); const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr); const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch); const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase); const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); const handleCommit = useCallback(() => { setActionError(null); void runCommit({ serverId, cwd }).catch((err) => { const message = err instanceof Error ? err.message : "Failed to commit"; setActionError(message); }); }, [runCommit, serverId, cwd]); const handlePush = useCallback(() => { setActionError(null); void runPush({ serverId, cwd }).catch((err) => { const message = err instanceof Error ? err.message : "Failed to push"; setActionError(message); }); }, [runPush, serverId, cwd]); const handleCreatePr = useCallback(() => { void persistShipDefault("pr"); setActionError(null); void runCreatePr({ serverId, cwd }).catch((err) => { const message = err instanceof Error ? err.message : "Failed to create PR"; setActionError(message); }); }, [persistShipDefault, runCreatePr, serverId, cwd]); const handleMergeBranch = useCallback(() => { if (!baseRef) { setActionError("Base ref unavailable"); return; } void persistShipDefault("merge"); setActionError(null); void runMergeBranch({ serverId, cwd, baseRef }) .then(() => { setPostShipArchiveSuggested(true); }) .catch((err) => { const message = err instanceof Error ? err.message : "Failed to merge"; setActionError(message); }); }, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]); const handleMergeFromBase = useCallback(() => { if (!baseRef) { setActionError("Base ref unavailable"); return; } setActionError(null); void runMergeFromBase({ serverId, cwd, baseRef }).catch((err) => { const message = err instanceof Error ? err.message : "Failed to merge from base"; setActionError(message); }); }, [baseRef, runMergeFromBase, serverId, cwd]); const handleArchiveWorktree = useCallback(() => { const worktreePath = status?.cwd; if (!worktreePath) { setActionError("Worktree path unavailable"); return; } setActionError(null); const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null); void runArchiveWorktree({ serverId, cwd, worktreePath }) .then(() => { router.replace(buildNewAgentRoute(serverId, targetWorkingDir) as any); }) .catch((err) => { const message = err instanceof Error ? err.message : "Failed to archive worktree"; setActionError(message); }); }, [runArchiveWorktree, router, serverId, cwd, status]); const renderFlatItem = useCallback( ({ item }: { item: DiffFlatItem }) => { if (item.type === "header") { return ( ); } return ( ); }, [handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded, wrapLines], ); const flatKeyExtractor = useCallback( (item: DiffFlatItem) => `${item.type}-${item.file.path}`, [], ); const hasChanges = files.length > 0; const diffErrorMessage = diffPayloadError?.message ?? (isDiffError && diffError instanceof Error ? diffError.message : null); const prErrorMessage = githubFeaturesEnabled ? (prPayloadError?.message ?? null) : null; const branchLabel = gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : notGit ? "Not a git repository" : "Unknown"; const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading; const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0; const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0; const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0; const baseRefLabel = useMemo(() => { if (!baseRef) return "base"; const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim(); return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed; }, [baseRef]); const committedDiffDescription = useMemo(() => { if (!branchLabel || !baseRefLabel) { return undefined; } return branchLabel === baseRefLabel ? undefined : `${branchLabel} -> ${baseRefLabel}`; }, [baseRefLabel, branchLabel]); const hasPullRequest = Boolean(prStatus?.url); const hasRemote = gitStatus?.hasRemote ?? false; const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false; const isMergedPullRequest = Boolean(prStatus?.isMerged); const currentBranch = gitStatus?.currentBranch; const isOnBaseBranch = currentBranch === baseRefLabel; const shouldPromoteArchive = isPaseoOwnedWorktree && !hasUncommittedChanges && (postShipArchiveSuggested || isMergedPullRequest); const commitDisabled = actionsDisabled || commitStatus === "pending"; const prDisabled = actionsDisabled || prCreateStatus === "pending"; const mergeDisabled = actionsDisabled || mergeStatus === "pending"; const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending"; const pushDisabled = actionsDisabled || pushStatus === "pending"; const archiveDisabled = actionsDisabled || archiveStatus === "pending"; let bodyContent: ReactElement; if (isStatusLoading) { bodyContent = ( Checking repository... ); } else if (statusErrorMessage) { bodyContent = ( {statusErrorMessage} ); } else if (notGit) { bodyContent = ( Not a git repository ); } else if (isDiffLoading) { bodyContent = ( ); } else if (diffErrorMessage) { bodyContent = ( {diffErrorMessage} ); } else if (!hasChanges) { bodyContent = ( {diffMode === "uncommitted" ? "No uncommitted changes" : `No changes vs ${baseRefLabel}`} ); } else { bodyContent = ( ); } useEffect(() => { setPostShipArchiveSuggested(false); }, [cwd]); // ========================================================================== // Git Actions (Data-Oriented) // ========================================================================== // All possible actions are computed as data, then partitioned into: // - primary: The main CTA button // - secondary: Dropdown next to primary button // - menu: Kebab overflow menu // ========================================================================== const gitActions: GitActions = useMemo(() => { return buildGitActions({ isGit, githubFeaturesEnabled, hasPullRequest, pullRequestUrl: prStatus?.url ?? null, hasRemote, isPaseoOwnedWorktree, isOnBaseBranch, hasUncommittedChanges, baseRefAvailable: Boolean(baseRef), baseRefLabel, aheadCount, aheadOfOrigin, behindOfOrigin, shouldPromoteArchive, shipDefault, runtime: { commit: { disabled: commitDisabled, status: commitStatus, icon: , handler: handleCommit, }, push: { disabled: pushDisabled, status: pushStatus, icon: , handler: handlePush, }, pr: { disabled: prDisabled, status: hasPullRequest ? "idle" : prCreateStatus, icon: , handler: () => { if (prStatus?.url) { openURLInNewTab(prStatus.url); return; } handleCreatePr(); }, }, "merge-branch": { disabled: mergeDisabled, status: mergeStatus, icon: , handler: handleMergeBranch, }, "merge-from-base": { disabled: mergeFromBaseDisabled, status: mergeFromBaseStatus, icon: , handler: handleMergeFromBase, }, "archive-worktree": { disabled: archiveDisabled, status: archiveStatus, icon: , handler: handleArchiveWorktree, }, }, }); }, [ isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled, hasUncommittedChanges, aheadOfOrigin, behindOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive, commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled, commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus, handleCommit, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree, theme.colors.foregroundMuted, ]); // Helper to get display label based on status return ( {!hideHeaderRow ? ( {branchLabel} {isGit ? : null} ) : null} {isGit ? ( [ styles.diffModeTrigger, hovered && styles.diffModeTriggerHovered, (pressed || open) && styles.diffModeTriggerPressed, ]} testID="changes-diff-status" accessibilityRole="button" accessibilityLabel="Diff mode" > {diffMode === "uncommitted" ? "Uncommitted" : "Committed"} setDiffModeOverride("uncommitted")} > Uncommitted setDiffModeOverride("base")} > Committed {files.length > 0 ? ( [ styles.expandAllButton, (hovered || pressed) && styles.diffStatusRowHovered, ]} onPress={handleToggleWrapLines} > {wrapLines ? "Scroll long lines" : "Wrap long lines"} [ styles.expandAllButton, (hovered || pressed) && styles.diffStatusRowHovered, ]} onPress={handleToggleExpandAll} > {allExpanded ? ( ) : ( )} {allExpanded ? "Collapse all files" : "Expand all files"} ) : null} ) : null} {actionError ? {actionError} : null} {prErrorMessage ? {prErrorMessage} : null} {bodyContent} { diffListRef.current?.scrollToOffset({ offset: nextOffset, animated: false, }); }} /> ); } const styles = StyleSheet.create((theme) => ({ container: { flex: 1, minHeight: 0, }, header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], borderBottomWidth: 1, borderBottomColor: theme.colors.border, }, headerLeft: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], flex: 1, minWidth: 0, }, branchLabel: { fontSize: theme.fontSize.sm, color: theme.colors.foreground, fontWeight: theme.fontWeight.medium, flexShrink: 1, }, diffStatusContainer: { height: WORKSPACE_SECONDARY_HEADER_HEIGHT, borderBottomWidth: 1, borderBottomColor: theme.colors.border, }, diffStatusInner: { flex: 1, flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingRight: theme.spacing[3], }, diffModeTrigger: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: theme.spacing[1], // Align text with header branch icon (at spacing[3] from edge, minus our horizontal padding) marginLeft: theme.spacing[3] - theme.spacing[1], paddingHorizontal: theme.spacing[1], height: { xs: 28, sm: 28, md: 24, }, borderRadius: theme.borderRadius.base, flexShrink: 0, }, diffModeTriggerHovered: { backgroundColor: theme.colors.surface2, }, diffModeTriggerPressed: { backgroundColor: theme.colors.surface2, }, diffStatusRowHovered: { backgroundColor: theme.colors.surface2, }, diffStatusText: { fontSize: theme.fontSize.xs, lineHeight: theme.fontSize.xs * 1.25, color: theme.colors.foregroundMuted, }, diffStatusIconHidden: { opacity: 0, }, diffStatusButtons: { flexDirection: "row", alignItems: "center", gap: { xs: theme.spacing[1], sm: theme.spacing[1], md: 0, }, }, 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", justifyContent: "space-between", paddingLeft: theme.spacing[3], paddingRight: theme.spacing[2], paddingVertical: theme.spacing[2], gap: theme.spacing[1], 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: 0, }, fileDir: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, flex: 1, }, 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.palette.green[400], }, 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.palette.red[500], }, additions: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, color: theme.colors.palette.green[400], }, deletions: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, color: theme.colors.palette.red[500], }, diffContent: { borderTopWidth: theme.borderWidth[1], borderTopColor: theme.colors.border, backgroundColor: theme.colors.surface1, }, diffContentInner: { flexDirection: "column", }, linesContainer: { backgroundColor: theme.colors.surface1, }, diffLineContainer: { flexDirection: "row", alignItems: "stretch", }, lineNumberGutter: { borderRightWidth: theme.borderWidth[1], borderRightColor: theme.colors.border, marginRight: theme.spacing[2], alignSelf: "stretch", justifyContent: "center", }, lineNumberText: { textAlign: "right", paddingRight: theme.spacing[2], paddingVertical: theme.spacing[1], fontSize: theme.fontSize.xs, fontFamily: Fonts.mono, color: theme.colors.foregroundMuted, userSelect: "none", }, addLineNumberText: { color: theme.colors.palette.green[400], }, removeLineNumberText: { color: theme.colors.palette.red[500], }, diffLineText: { flex: 1, paddingRight: theme.spacing[3], paddingVertical: theme.spacing[1], fontSize: theme.fontSize.xs, fontFamily: Fonts.mono, color: theme.colors.foreground, userSelect: "text", }, addLineContainer: { backgroundColor: "rgba(46, 160, 67, 0.15)", // GitHub green }, addLineText: { color: theme.colors.foreground, }, removeLineContainer: { backgroundColor: "rgba(248, 81, 73, 0.1)", // GitHub red }, removeLineText: { color: theme.colors.foreground, }, headerLineContainer: { backgroundColor: theme.colors.surface2, }, headerLineText: { color: theme.colors.foregroundMuted, }, contextLineContainer: { backgroundColor: theme.colors.surface1, }, contextLineText: { color: theme.colors.foregroundMuted, }, 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, }, }));