import { useState, useCallback, useEffect, useMemo, useRef, memo, type ReactElement, type RefObject, } from "react"; import { useRouter } from "expo-router"; 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, TextStyle, } from "react-native"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { AlignJustify, Archive, ChevronDown, Columns2, Download, GitBranch, GitCommitHorizontal, GitMerge, ListChevronsDownUp, ListChevronsUpDown, Pilcrow, RefreshCcw, Upload, WrapText, } from "lucide-react-native"; import { useCheckoutGitActionsStore, type CheckoutGitActionStatus, } 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 { useChangesPreferences } from "@/hooks/use-changes-preferences"; 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 { buildSplitDiffRows, buildUnifiedDiffLines, 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 { buildGitActions, type GitActions } from "@/components/git-actions-policy"; import { lineNumberGutterWidth } from "@/components/code-insets"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing"; import { openExternalUrl } from "@/utils/open-external-url"; import { GitActionsSplitButton } from "@/components/git-actions-split-button"; import { usePanelStore } from "@/stores/panel-store"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; import { useToast } from "@/contexts/toast-context"; import { formatDiffContentText, formatDiffGutterText, hasVisibleDiffTokens, } from "@/utils/diff-rendering"; import { isWeb, isNative } from "@/constants/platform"; export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy"; function openURLInNewTab(url: string): void { void openExternalUrl(url); } function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) { return [styles.fileHeader, pressed && styles.fileHeaderPressed]; } function diffModeTriggerStyle({ hovered, pressed, open, }: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) { return [ styles.diffModeTrigger, Boolean(hovered) && styles.diffModeTriggerHovered, (pressed || Boolean(open)) && styles.diffModeTriggerPressed, ]; } function expandAllButtonStyle({ hovered, pressed, }: PressableStateCallbackType & { hovered?: boolean }) { return [styles.expandAllButton, (Boolean(hovered) || pressed) && styles.diffStatusRowHovered]; } type HighlightStyle = NonNullable; interface HighlightedTextProps { tokens: HighlightToken[]; wrapLines?: boolean; } type WrappedWebTextStyle = TextStyle & { whiteSpace?: "pre" | "pre-wrap"; overflowWrap?: "normal" | "anywhere"; }; function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined { if (isNative) { return undefined; } return wrapLines ? { whiteSpace: "pre-wrap", overflowWrap: "anywhere" } : { whiteSpace: "pre", overflowWrap: "normal" }; } function HighlightedToken({ text, color, lineHeight, }: { text: string; color: string; lineHeight: number; }) { const tokenStyle = useMemo(() => ({ color, lineHeight }), [color, lineHeight]); return {text}; } function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) { const { theme } = useUnistyles(); const isDark = theme.colorScheme === "dark"; const lineHeight = theme.lineHeight.diff; 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; }; const containerStyle = useMemo( () => [styles.diffLineText, { lineHeight, ...getWrappedTextStyle(wrapLines) }], [lineHeight, 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; } 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, }: { lineNumber: number | null; type: DiffLine["type"] | undefined | null; gutterWidth: number; }) { const containerStyle = useMemo( () => [styles.gutterCell, lineTypeBackground(type), { width: gutterWidth }], [type, gutterWidth], ); const textStyle = useMemo( () => [ styles.lineNumberText, type === "add" && styles.addLineNumberText, type === "remove" && styles.removeLineNumberText, ], [type], ); return ( {formatDiffGutterText(lineNumber)} ); } function DiffTextLine({ line, wrapLines }: { line: DiffLine; wrapLines: boolean }) { const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null; const containerStyle = useMemo( () => [styles.textLineContainer, lineTypeBackground(line.type)], [line.type], ); const textStyle = useMemo( () => [ styles.diffLineText, getWrappedTextStyle(wrapLines), line.type === "add" && styles.addLineText, line.type === "remove" && styles.removeLineText, line.type === "header" && styles.headerLineText, line.type === "context" && styles.contextLineText, ], [line.type, wrapLines], ); return ( {line.type !== "header" && visibleTokens ? ( ) : ( {formatDiffContentText(line.content)} )} ); } function SplitTextLine({ line, wrapLines, }: { line: SplitDiffDisplayLine | null; wrapLines: boolean; }) { const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null; const containerStyle = useMemo( () => [styles.textLineContainer, lineTypeBackground(line?.type)], [line?.type], ); const textStyle = useMemo( () => [ styles.diffLineText, getWrappedTextStyle(wrapLines), line?.type === "add" && styles.addLineText, line?.type === "remove" && styles.removeLineText, line?.type === "context" && styles.contextLineText, !line && styles.emptySplitCellText, ], [line, wrapLines], ); return ( {visibleTokens ? ( ) : ( {formatDiffContentText(line?.content)} )} ); } function DiffLineView({ line, lineNumber, gutterWidth, wrapLines, }: { line: DiffLine; lineNumber: number | null; gutterWidth: number; wrapLines: boolean; }) { const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null; const containerStyle = useMemo( () => [styles.diffLineContainer, lineTypeBackground(line.type)], [line.type], ); const gutterStyle = useMemo( () => [styles.lineNumberGutter, { width: gutterWidth }], [gutterWidth], ); const gutterTextStyle = useMemo( () => [ styles.lineNumberText, line.type === "add" && styles.addLineNumberText, line.type === "remove" && styles.removeLineNumberText, ], [line.type], ); const textStyle = useMemo( () => [ styles.diffLineText, getWrappedTextStyle(wrapLines), line.type === "add" && styles.addLineText, line.type === "remove" && styles.removeLineText, line.type === "header" && styles.headerLineText, line.type === "context" && styles.contextLineText, ], [line.type, wrapLines], ); return ( {formatDiffGutterText(lineNumber)} {line.type !== "header" && visibleTokens ? ( ) : ( {formatDiffContentText(line.content)} )} ); } function SplitDiffLine({ line, gutterWidth, wrapLines, }: { line: SplitDiffDisplayLine | null; gutterWidth: number; wrapLines: boolean; }) { const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null; const containerStyle = useMemo( () => [styles.diffLineContainer, lineTypeBackground(line?.type)], [line?.type], ); const gutterStyle = useMemo( () => [styles.lineNumberGutter, { width: gutterWidth }], [gutterWidth], ); const gutterTextStyle = useMemo( () => [ styles.lineNumberText, line?.type === "add" && styles.addLineNumberText, line?.type === "remove" && styles.removeLineNumberText, ], [line?.type], ); const textStyle = useMemo( () => [ styles.diffLineText, getWrappedTextStyle(wrapLines), line?.type === "add" && styles.addLineText, line?.type === "remove" && styles.removeLineText, line?.type === "context" && styles.contextLineText, !line && styles.emptySplitCellText, ], [line, wrapLines], ); return ( {formatDiffGutterText(line?.lineNumber ?? null)} {visibleTokens ? ( ) : ( {formatDiffContentText(line?.content)} )} ); } function SplitDiffColumn({ rows, side, gutterWidth, wrapLines, showDivider = false, }: { rows: SplitDiffRow[]; side: "left" | "right"; gutterWidth: number; wrapLines: boolean; showDivider?: boolean; }) { const [scrollWidth, setScrollWidth] = useState(0); 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 && { 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} ); } return ( ); })} ); } return ( {keyedRows.map(({ key, row }) => { if (row.kind === "header") { return ( ); } const line = side === "left" ? row.left : row.right; return ( ); })} {keyedRows.map(({ key, row }) => { if (row.kind === "header") { return ( {row.content} ); } return ( ); })} ); } 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]); 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 && ( New )} {file.isDeleted && ( Deleted )} ); }); function DiffFileBody({ file, layout, wrapLines, onBodyHeightChange, testID, }: { file: ParsedDiffFile; layout: "unified" | "split"; wrapLines: boolean; onBodyHeightChange?: (file: ParsedDiffFile, height: number) => void; testID?: string; }) { const [scrollViewWidth, setScrollViewWidth] = useState(0); const [bodyWidth, setBodyWidth] = useState(0); 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 && { minWidth: availableWidth }], [availableWidth], ); return ( {(() => { if (file.status === "too_large" || file.status === "binary") { return ( {file.status === "binary" ? "Binary file" : "Diff too large to display"} ); } 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); if (layout === "split") { const rows = buildSplitDiffRows(file); return ( ); } const computedLines = buildUnifiedDiffLines(file); if (wrapLines) { return ( {computedLines.map(({ line, lineNumber, key }) => ( ))} ); } return ( {computedLines.map(({ line, lineNumber, key }) => ( ))} {computedLines.map(({ line, key }) => ( ))} ); })()} ); } interface GitDiffPaneProps { serverId: string; workspaceId?: string | null; cwd: string; hideHeaderRow?: boolean; } type PressableStyleFn = ( state: PressableStateCallbackType & { hovered?: boolean }, ) => StyleProp; interface DiffLayoutToggleGroupProps { layout: "unified" | "split"; unifiedToggleStyle: PressableStyleFn; splitToggleStyle: PressableStyleFn; onUnified: () => void; onSplit: () => void; } function DiffLayoutToggleGroup({ layout, unifiedToggleStyle, splitToggleStyle, onUnified, onSplit, }: DiffLayoutToggleGroupProps) { const { theme } = useUnistyles(); return ( Unified diff Side-by-side diff ); } interface DiffWhitespaceToggleProps { hideWhitespace: boolean; isMobile: boolean; toggleStyle: PressableStyleFn; onToggle: () => void; } function DiffWhitespaceToggle({ hideWhitespace, isMobile, toggleStyle, onToggle, }: DiffWhitespaceToggleProps) { const { theme } = useUnistyles(); return ( Hide whitespace ); } interface DiffFilesToolbarProps { wrapLines: boolean; allExpanded: boolean; isMobile: boolean; wrapLinesToggleStyle: PressableStyleFn; expandAllToggleStyle: PressableStyleFn; onToggleWrapLines: () => void; onToggleExpandAll: () => void; } function DiffFilesToolbar({ wrapLines, allExpanded, isMobile, wrapLinesToggleStyle, expandAllToggleStyle, onToggleWrapLines, onToggleExpandAll, }: DiffFilesToolbarProps) { const { theme } = useUnistyles(); return ( {wrapLines ? "Scroll long lines" : "Wrap long lines"} {allExpanded ? ( ) : ( )} {allExpanded ? "Collapse all files" : "Expand all files"} ); } 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 computeBranchLabel(currentBranch: string | null | undefined, notGit: boolean): string { if (currentBranch && currentBranch !== "HEAD") { return currentBranch; } if (notGit) { return "Not a git repository"; } return "Unknown"; } function computeEmptyMessage( hideWhitespace: boolean, diffMode: "uncommitted" | "base", baseRefLabel: string, ): string { if (hideWhitespace) { return "No visible changes after hiding whitespace"; } if (diffMode === "uncommitted") { return "No uncommitted changes"; } return `No changes vs ${baseRefLabel}`; } interface DiffBodyContentProps { isStatusLoading: boolean; statusErrorMessage: string | null; notGit: boolean; isDiffLoading: boolean; diffErrorMessage: string | null; hasChanges: boolean; emptyMessage: string; flatItems: DiffFlatItem[]; stickyHeaderIndices: number[]; renderFlatItem: ({ item }: { item: DiffFlatItem }) => ReactElement; flatKeyExtractor: (item: DiffFlatItem) => string; getFlatItemLayout: DiffFlatItemLayoutGetter; flatExtraData: unknown; diffListRef: RefObject | null>; handleDiffListLayout: (event: LayoutChangeEvent) => void; handleDiffListScroll: (event: NativeSyntheticEvent) => void; onContentSizeChange: (width: number, height: number) => void; showDesktopWebScrollbar: boolean; foregroundMutedColor: string; } function DiffBodyContent({ isStatusLoading, statusErrorMessage, notGit, isDiffLoading, diffErrorMessage, hasChanges, emptyMessage, flatItems, stickyHeaderIndices, renderFlatItem, flatKeyExtractor, getFlatItemLayout, flatExtraData, diffListRef, handleDiffListLayout, handleDiffListScroll, onContentSizeChange, showDesktopWebScrollbar, foregroundMutedColor, }: DiffBodyContentProps) { if (isStatusLoading) { return ( Checking repository... ); } if (statusErrorMessage) { return ( {statusErrorMessage} ); } if (notGit) { return ( Not a git repository ); } if (isDiffLoading) { return ( ); } if (diffErrorMessage) { return ( {diffErrorMessage} ); } if (!hasChanges) { return ( {emptyMessage} ); } return ( ); } interface GitActionRunners { runCommit: (args: { serverId: string; cwd: string }) => Promise; runPull: (args: { serverId: string; cwd: string }) => Promise; runPush: (args: { serverId: string; cwd: string }) => Promise; runCreatePr: (args: { serverId: string; cwd: string }) => Promise; runMergeBranch: (args: { serverId: string; cwd: string; baseRef: string }) => Promise; runMergeFromBase: (args: { serverId: string; cwd: string; baseRef: string }) => Promise; runArchiveWorktree: (args: { serverId: string; cwd: string; worktreePath: string; }) => Promise; } interface GitActionHandlersDeps { serverId: string; cwd: string; baseRef: string | undefined; status: ReturnType["status"]; runners: GitActionRunners; persistShipDefault: (next: "merge" | "pr") => Promise; toastError: (message: string) => void; toastActionError: (error: unknown, fallback: string) => void; toastActionSuccess: (message: string) => void; onMergeBranchSuccess: () => void; onArchiveSuccess: (targetWorkingDir: string) => void; } interface GitActionHandlers { handleCommit: () => void; handlePull: () => void; handlePush: () => void; handleCreatePr: () => void; handleMergeBranch: () => void; handleMergeFromBase: () => void; handleArchiveWorktree: () => void; } function useGitActionHandlers({ serverId, cwd, baseRef, status, runners, persistShipDefault, toastError, toastActionError, toastActionSuccess, onMergeBranchSuccess, onArchiveSuccess, }: GitActionHandlersDeps): GitActionHandlers { const handleCommit = useCallback(() => { void runners .runCommit({ serverId, cwd }) .then(() => { toastActionSuccess("Committed"); return; }) .catch((err) => { toastActionError(err, "Failed to commit"); }); }, [cwd, runners, serverId, toastActionError, toastActionSuccess]); const handlePull = useCallback(() => { void runners .runPull({ serverId, cwd }) .then(() => { toastActionSuccess("Pulled"); return; }) .catch((err) => { toastActionError(err, "Failed to pull"); }); }, [cwd, runners, serverId, toastActionError, toastActionSuccess]); const handlePush = useCallback(() => { void runners .runPush({ serverId, cwd }) .then(() => { toastActionSuccess("Pushed"); return; }) .catch((err) => { toastActionError(err, "Failed to push"); }); }, [cwd, runners, serverId, toastActionError, toastActionSuccess]); const handleCreatePr = useCallback(() => { void persistShipDefault("pr"); void runners .runCreatePr({ serverId, cwd }) .then(() => { toastActionSuccess("PR created"); return; }) .catch((err) => { toastActionError(err, "Failed to create PR"); }); }, [cwd, persistShipDefault, runners, serverId, toastActionError, toastActionSuccess]); const handleMergeBranch = useCallback(() => { if (!baseRef) { toastError("Base ref unavailable"); return; } void persistShipDefault("merge"); void runners .runMergeBranch({ serverId, cwd, baseRef }) .then(() => { onMergeBranchSuccess(); toastActionSuccess("Merged"); return; }) .catch((err) => { toastActionError(err, "Failed to merge"); }); }, [ baseRef, cwd, onMergeBranchSuccess, persistShipDefault, runners, serverId, toastActionError, toastActionSuccess, toastError, ]); const handleMergeFromBase = useCallback(() => { if (!baseRef) { toastError("Base ref unavailable"); return; } void runners .runMergeFromBase({ serverId, cwd, baseRef }) .then(() => { toastActionSuccess("Updated"); return; }) .catch((err) => { toastActionError(err, "Failed to merge from base"); }); }, [baseRef, cwd, runners, serverId, toastActionError, toastActionSuccess, toastError]); const handleArchiveWorktree = useCallback(() => { const worktreePath = status?.cwd; if (!worktreePath) { toastError("Worktree path unavailable"); return; } const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null); void runners .runArchiveWorktree({ serverId, cwd, worktreePath }) .then(() => { onArchiveSuccess(targetWorkingDir); return; }) .catch((err) => { toastActionError(err, "Failed to archive worktree"); }); }, [cwd, onArchiveSuccess, runners, serverId, status, toastActionError, toastError]); return { handleCommit, handlePull, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree, }; } 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; } function deriveStatusState({ status, isStatusLoading, isStatusError, statusError, }: DeriveStatusStateInputs): DerivedStatusState { const gitStatus = status && status.isGit ? status : null; const isGit = Boolean(gitStatus); const notGit = status !== null && !status.isGit && !status.error; const statusErrorMessage = status?.error?.message ?? (isStatusError && statusError instanceof Error ? statusError.message : null); const baseRef = gitStatus?.baseRef ?? undefined; const hasUncommittedChanges = Boolean(gitStatus?.isDirty); const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading; return { gitStatus, isGit, notGit, statusErrorMessage, baseRef, hasUncommittedChanges, actionsDisabled, }; } interface DerivedBranchState { aheadCount: number; behindBaseCount: number; aheadOfOrigin: number; behindOfOrigin: number; hasPullRequest: boolean; hasRemote: boolean; isPaseoOwnedWorktree: boolean; isMergedPullRequest: boolean; currentBranch: string | null | undefined; } function deriveBranchState( gitStatus: DerivedStatusState["gitStatus"], prStatus: { url?: string | null; isMerged?: boolean | null } | null | undefined, ): DerivedBranchState { return { aheadCount: gitStatus?.aheadBehind?.ahead ?? 0, behindBaseCount: gitStatus?.aheadBehind?.behind ?? 0, aheadOfOrigin: gitStatus?.aheadOfOrigin ?? 0, behindOfOrigin: gitStatus?.behindOfOrigin ?? 0, hasPullRequest: Boolean(prStatus?.url), hasRemote: gitStatus?.hasRemote ?? false, isPaseoOwnedWorktree: gitStatus?.isPaseoOwnedWorktree ?? false, isMergedPullRequest: Boolean(prStatus?.isMerged), currentBranch: gitStatus?.currentBranch, }; } function computeBaseRefLabel(baseRef: string | undefined): string { if (!baseRef) return "base"; const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim(); return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed; } function computeCommittedDiffDescription( branchLabel: string, baseRefLabel: string, ): string | undefined { if (!branchLabel || !baseRefLabel) { return undefined; } return branchLabel === baseRefLabel ? undefined : `${branchLabel} -> ${baseRefLabel}`; } function computeShouldPromoteArchive( isPaseoOwnedWorktree: boolean, hasUncommittedChanges: boolean, postShipArchiveSuggested: boolean, isMergedPullRequest: boolean, ): boolean { return ( isPaseoOwnedWorktree && !hasUncommittedChanges && (postShipArchiveSuggested || isMergedPullRequest) ); } function computeDisabledStates( actionsDisabled: boolean, statuses: GitActionsStatusInputs, ): GitActionsDisabledInputs { const pending = "pending"; return { commitDisabled: actionsDisabled || statuses.commitStatus === pending, pullDisabled: actionsDisabled || statuses.pullStatus === pending, pushDisabled: actionsDisabled || statuses.pushStatus === pending, prDisabled: actionsDisabled || statuses.prCreateStatus === pending, mergeDisabled: actionsDisabled || statuses.mergeStatus === pending, mergeFromBaseDisabled: actionsDisabled || statuses.mergeFromBaseStatus === pending, archiveDisabled: actionsDisabled || statuses.archiveStatus === pending, }; } function computePrErrorMessage( githubFeaturesEnabled: boolean, prPayloadError: { message?: string } | null | undefined, ): string | null { if (!githubFeaturesEnabled) return null; return prPayloadError?.message ?? null; } function buildToggleButtonStyle( selected: boolean, baseStyles: StyleProp | StyleProp[], ): PressableStyleFn { return ({ hovered, pressed }) => [ baseStyles, selected && styles.toggleButtonSelected, (Boolean(hovered) || pressed) && styles.diffStatusRowHovered, ]; } function createPrHandler( prStatus: { url?: string | null } | null | undefined, handleCreatePr: () => void, ): () => void { return () => { if (prStatus?.url) { openURLInNewTab(prStatus.url); return; } handleCreatePr(); }; } interface GitActionsStatusInputs { commitStatus: CheckoutGitActionStatus; pullStatus: CheckoutGitActionStatus; pushStatus: CheckoutGitActionStatus; prCreateStatus: CheckoutGitActionStatus; mergeStatus: CheckoutGitActionStatus; mergeFromBaseStatus: CheckoutGitActionStatus; archiveStatus: CheckoutGitActionStatus; } interface GitActionsDisabledInputs { commitDisabled: boolean; pullDisabled: boolean; pushDisabled: boolean; prDisabled: boolean; mergeDisabled: boolean; mergeFromBaseDisabled: boolean; archiveDisabled: boolean; } interface BuildGitActionsParams { policy: { isGit: boolean; githubFeaturesEnabled: boolean; hasPullRequest: boolean; pullRequestUrl: string | null; hasRemote: boolean; isPaseoOwnedWorktree: boolean; isOnBaseBranch: boolean; hasUncommittedChanges: boolean; baseRefAvailable: boolean; baseRefLabel: string; aheadCount: number; behindBaseCount: number; aheadOfOrigin: number; behindOfOrigin: number; shouldPromoteArchive: boolean; shipDefault: "merge" | "pr"; }; statuses: GitActionsStatusInputs; disabled: GitActionsDisabledInputs; handlers: GitActionHandlers & { handlePr: () => void }; iconColor: string; } function buildGitActionsForPane({ policy, statuses, disabled, handlers, iconColor, }: BuildGitActionsParams): GitActions { return buildGitActions({ ...policy, runtime: { commit: { disabled: disabled.commitDisabled, status: statuses.commitStatus, icon: , handler: handlers.handleCommit, }, pull: { disabled: disabled.pullDisabled, status: statuses.pullStatus, icon: , handler: handlers.handlePull, }, push: { disabled: disabled.pushDisabled, status: statuses.pushStatus, icon: , handler: handlers.handlePush, }, pr: { disabled: disabled.prDisabled, status: policy.hasPullRequest ? "idle" : statuses.prCreateStatus, icon: , handler: handlers.handlePr, }, "merge-branch": { disabled: disabled.mergeDisabled, status: statuses.mergeStatus, icon: , handler: handlers.handleMergeBranch, }, "merge-from-base": { disabled: disabled.mergeFromBaseDisabled, status: statuses.mergeFromBaseStatus, icon: , handler: handlers.handleMergeFromBase, }, "archive-worktree": { disabled: disabled.archiveDisabled, status: statuses.archiveStatus, icon: , handler: handlers.handleArchiveWorktree, }, }, }); } export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) { const { theme } = useUnistyles(); const toast = useToast(); const isMobile = useIsCompactFormFactor(); const showDesktopWebScrollbar = isWeb && !isMobile; const canUseSplitLayout = isWeb && !isMobile; const router = useRouter(); const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null); const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge"); 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 handleSelectUncommitted = useCallback(() => { setDiffModeOverride("uncommitted"); }, []); const handleSelectBase = useCallback(() => { setDiffModeOverride("base"); }, []); const handleLayoutUnified = useCallback(() => { handleLayoutChange("unified"); }, [handleLayoutChange]); const handleLayoutSplit = useCallback(() => { handleLayoutChange("split"); }, [handleLayoutChange]); 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 { status, isLoading: isStatusLoading, isError: isStatusError, error: statusError, } = useCheckoutStatusQuery({ serverId, cwd }); const statusState = deriveStatusState({ status, isStatusLoading, isStatusError, statusError }); const { gitStatus, isGit, notGit, statusErrorMessage, baseRef, hasUncommittedChanges, actionsDisabled, } = statusState; // Auto-select diff mode based on state: uncommitted when dirty, base when clean const autoDiffMode = hasUncommittedChanges ? "uncommitted" : "base"; const diffMode = diffModeOverride ?? autoDiffMode; const { files, payloadError: diffPayloadError, isLoading: isDiffLoading, } = useCheckoutDiffQuery({ serverId, cwd, mode: diffMode, baseRef, ignoreWhitespace: changesPreferences.hideWhitespace, enabled: isGit, }); const { status: prStatus, 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 diffBodyLineHeight = theme.lineHeight.diff; const diffBodyChromeHeight = theme.borderWidth[1] * 2; const statusBodyHeightEstimate = diffBodyChromeHeight + theme.spacing[4] * 2 + diffBodyLineHeight; 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); } return; }) .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 = expandedPaths.has(file.path); items.push({ type: "header", file, fileIndex: i, isExpanded }); if (isExpanded) { stickyIndices.push(items.length - 1); } if (isExpanded) { items.push({ type: "body", file, fileIndex: i }); } } return { flatItems: items, stickyHeaderIndices: stickyIndices }; }, [expandedPaths, files]); const getBodyHeightKey = useCallback( (file: ParsedDiffFile): string => { if (file.status === "too_large" || file.status === "binary") { return `${effectiveLayout}:${wrapLines ? "wrap" : "scroll"}:${file.path}:${file.status}`; } return [ effectiveLayout, wrapLines ? "wrap" : "scroll", file.path, file.status ?? "ok", file.additions, file.deletions, file.hunks.length, getUnifiedDiffLineCount(file), getDiffContentLength(file), ].join(":"); }, [effectiveLayout, wrapLines], ); const estimateBodyHeight = useCallback( (file: ParsedDiffFile): number => { if (file.status === "too_large" || file.status === "binary") { return statusBodyHeightEstimate; } const lineCount = effectiveLayout === "split" ? buildSplitDiffRows(file).length : getUnifiedDiffLineCount(file); return diffBodyChromeHeight + lineCount * diffBodyLineHeight; }, [diffBodyChromeHeight, diffBodyLineHeight, effectiveLayout, statusBodyHeightEstimate], ); const handleHeaderHeightChange = useCallback((path: string, height: number) => { if (!Number.isFinite(height) || height <= 0) { return; } const previousHeight = headerHeightByPathRef.current[path]; if ( previousHeight !== undefined && Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON ) { return; } headerHeightByPathRef.current[path] = height; defaultHeaderHeightRef.current = height; setHeightVersion((version) => version + 1); }, []); const handleBodyHeightChange = useCallback( (file: ParsedDiffFile, height: number) => { if (!Number.isFinite(height) || height < 0) { return; } const heightKey = getBodyHeightKey(file); const previousHeight = bodyHeightByKeyRef.current[heightKey]; if ( previousHeight !== undefined && Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON ) { return; } bodyHeightByKeyRef.current[heightKey] = height; setHeightVersion((version) => version + 1); }, [getBodyHeightKey], ); const handleDiffListScroll = useCallback( (event: NativeSyntheticEvent) => { diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y; scrollbar.onScroll(event); }, [scrollbar], ); const handleDiffListLayout = useCallback( (event: LayoutChangeEvent) => { const height = event.nativeEvent.layout.height; if (!Number.isFinite(height) || height <= 0) { return; } diffListViewportHeightRef.current = height; scrollbar.onLayout(event); }, [scrollbar], ); const computeHeaderOffset = useCallback( (path: string): number => { const defaultHeaderHeight = defaultHeaderHeightRef.current; let offset = 0; for (const file of files) { if (file.path === path) { break; } offset += headerHeightByPathRef.current[file.path] ?? defaultHeaderHeight; if (expandedPaths.has(file.path)) { const bodyHeightKey = getBodyHeightKey(file); offset += bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(file); } } return Math.max(0, offset); }, [estimateBodyHeight, expandedPaths, files, getBodyHeightKey], ); const handleToggleExpanded = useCallback( (path: string) => { if (!workspaceStateKey) { return; } const isCurrentlyExpanded = expandedPaths.has(path); const nextExpanded = !isCurrentlyExpanded; const targetOffset = isCurrentlyExpanded ? computeHeaderOffset(path) : null; const headerHeight = headerHeightByPathRef.current[path] ?? defaultHeaderHeightRef.current; const shouldAnchor = isCurrentlyExpanded && targetOffset !== null && shouldAnchorHeaderBeforeCollapse({ headerOffset: targetOffset, headerHeight, viewportOffset: diffListScrollOffsetRef.current, viewportHeight: diffListViewportHeightRef.current, }); // Anchor to the clicked header before collapsing so visual context is preserved. if (shouldAnchor && targetOffset !== null) { diffListRef.current?.scrollToOffset({ offset: targetOffset, animated: false, }); } const nextPaths = nextExpanded ? [...expandedPaths, path] : Array.from(expandedPaths).filter((expandedPath) => expandedPath !== path); setDiffExpandedPathsForWorkspace(workspaceStateKey, nextPaths); }, [computeHeaderOffset, expandedPaths, setDiffExpandedPathsForWorkspace, workspaceStateKey], ); const allExpanded = useMemo(() => { if (files.length === 0) return false; return files.every((file) => expandedPaths.has(file.path)); }, [expandedPaths, files]); const handleToggleExpandAll = useCallback(() => { if (!workspaceStateKey) { return; } if (allExpanded) { setDiffExpandedPathsForWorkspace(workspaceStateKey, []); } else { setDiffExpandedPathsForWorkspace( workspaceStateKey, files.map((file) => file.path), ); } }, [allExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]); // Clear diff mode override when auto mode changes (e.g., after commit) useEffect(() => { setDiffModeOverride(null); }, [autoDiffMode]); const commitStatus = useCheckoutGitActionsStore((state) => state.getStatus({ serverId, cwd, actionId: "commit" }), ); const pullStatus = useCheckoutGitActionsStore((state) => state.getStatus({ serverId, cwd, actionId: "pull" }), ); 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 runPull = useCheckoutGitActionsStore((state) => state.pull); 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 toastActionError = useCallback( (error: unknown, fallback: string) => { const message = error instanceof Error ? error.message : fallback; toast.error(message); }, [toast], ); const toastActionSuccess = useCallback( (message: string) => { toast.show(message, { variant: "success" }); }, [toast], ); const handleMergeBranchSuccess = useCallback(() => { setPostShipArchiveSuggested(true); }, []); const handleArchiveSuccess = useCallback( (targetWorkingDir: string) => { router.replace(buildNewAgentRoute(serverId, targetWorkingDir)); }, [router, serverId], ); const toastError = useCallback( (message: string) => { toast.error(message); }, [toast], ); const runners = useMemo( () => ({ runCommit, runPull, runPush, runCreatePr, runMergeBranch, runMergeFromBase, runArchiveWorktree, }), [ runArchiveWorktree, runCommit, runCreatePr, runMergeBranch, runMergeFromBase, runPull, runPush, ], ); const { handleCommit, handlePull, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree, } = useGitActionHandlers({ serverId, cwd, baseRef, status, runners, persistShipDefault, toastError, toastActionError, toastActionSuccess, onMergeBranchSuccess: handleMergeBranchSuccess, onArchiveSuccess: handleArchiveSuccess, }); const renderFlatItem = useCallback( ({ item }: { item: DiffFlatItem }) => { if (item.type === "header") { return ( ); } return ( ); }, [ effectiveLayout, handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded, 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, heightVersion, wrapLines }), [expandedPathsArray, effectiveLayout, heightVersion, wrapLines], ); const hasChanges = files.length > 0; const diffErrorMessage = diffPayloadError?.message ?? null; const prErrorMessage = computePrErrorMessage(githubFeaturesEnabled, prPayloadError); const branchLabel = computeBranchLabel(gitStatus?.currentBranch, notGit); const branchState = useMemo(() => deriveBranchState(gitStatus, prStatus), [gitStatus, prStatus]); const { aheadCount, behindBaseCount, aheadOfOrigin, behindOfOrigin, hasPullRequest, hasRemote, isPaseoOwnedWorktree, isMergedPullRequest, currentBranch, } = branchState; const baseRefLabel = useMemo(() => computeBaseRefLabel(baseRef), [baseRef]); const committedDiffDescription = useMemo( () => computeCommittedDiffDescription(branchLabel, baseRefLabel), [baseRefLabel, branchLabel], ); const isOnBaseBranch = currentBranch === baseRefLabel; const shouldPromoteArchive = computeShouldPromoteArchive( isPaseoOwnedWorktree, hasUncommittedChanges, postShipArchiveSuggested, isMergedPullRequest, ); const gitActionsStatuses = useMemo( () => ({ commitStatus, pullStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus, }), [ archiveStatus, commitStatus, mergeFromBaseStatus, mergeStatus, prCreateStatus, pullStatus, pushStatus, ], ); const gitActionsDisabled = useMemo( () => computeDisabledStates(actionsDisabled, gitActionsStatuses), [actionsDisabled, gitActionsStatuses], ); const emptyMessage = computeEmptyMessage( changesPreferences.hideWhitespace, diffMode, baseRefLabel, ); const bodyContent: ReactElement = ( ); 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 handlePr = useMemo( () => createPrHandler(prStatus, handleCreatePr), [handleCreatePr, prStatus], ); const gitActionsHandlers = useMemo( () => ({ handleCommit, handlePull, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree, handlePr, }), [ handleArchiveWorktree, handleCommit, handleCreatePr, handleMergeBranch, handleMergeFromBase, handlePr, handlePull, handlePush, ], ); const gitActions: GitActions = useMemo( () => buildGitActionsForPane({ policy: { isGit, githubFeaturesEnabled, hasPullRequest, pullRequestUrl: prStatus?.url ?? null, hasRemote, isPaseoOwnedWorktree, isOnBaseBranch, hasUncommittedChanges, baseRefAvailable: Boolean(baseRef), baseRefLabel, aheadCount, behindBaseCount, aheadOfOrigin, behindOfOrigin, shouldPromoteArchive, shipDefault, }, statuses: gitActionsStatuses, disabled: gitActionsDisabled, handlers: gitActionsHandlers, iconColor: theme.colors.foregroundMuted, }), [ aheadCount, aheadOfOrigin, baseRef, baseRefLabel, behindBaseCount, behindOfOrigin, gitActionsDisabled, gitActionsHandlers, gitActionsStatuses, githubFeaturesEnabled, hasPullRequest, hasRemote, hasUncommittedChanges, isGit, isOnBaseBranch, isPaseoOwnedWorktree, prStatus?.url, shipDefault, shouldPromoteArchive, theme.colors.foregroundMuted, ], ); // Helper to get display label based on status return ( {!hideHeaderRow ? ( {branchLabel} {isGit ? : null} ) : null} {isGit ? ( {diffMode === "uncommitted" ? "Uncommitted" : "Committed"} Uncommitted Committed {canUseSplitLayout ? ( ) : null} {files.length > 0 ? ( ) : 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, }, headerLeft: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], flex: 1, minWidth: 0, }, branchLabel: { fontSize: theme.fontSize.sm, color: theme.colors.foreground, fontWeight: theme.fontWeight.medium, flexShrink: 1, }, diffStatusContainer: { height: WORKSPACE_SECONDARY_HEADER_HEIGHT, borderBottomWidth: 1, borderBottomColor: theme.colors.border, }, diffStatusInner: { flex: 1, flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingRight: theme.spacing[3], }, diffModeTrigger: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: theme.spacing[1], // Align text with header branch icon (at spacing[3] from edge, minus our horizontal padding) marginLeft: theme.spacing[3] - theme.spacing[1], paddingHorizontal: theme.spacing[1], height: { xs: 28, sm: 28, md: 24, }, borderRadius: theme.borderRadius.base, flexShrink: 0, }, diffModeTriggerHovered: { backgroundColor: theme.colors.surface2, }, diffModeTriggerPressed: { backgroundColor: theme.colors.surface2, }, diffStatusRowHovered: { backgroundColor: theme.colors.surface2, }, diffStatusText: { fontSize: theme.fontSize.xs, lineHeight: theme.fontSize.xs * 1.25, color: theme.colors.foregroundMuted, }, diffStatusIconHidden: { opacity: 0, }, diffStatusButtons: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], flexWrap: "wrap", }, toggleButtonGroup: { flexDirection: "row", alignItems: "center", }, toggleButton: { alignItems: "center", justifyContent: "center", minWidth: { xs: 32, sm: 32, md: 24, }, height: { xs: 32, sm: 32, md: 24, }, paddingHorizontal: { xs: theme.spacing[2], sm: theme.spacing[2], md: theme.spacing[1], }, }, toggleButtonGroupStart: { borderTopLeftRadius: theme.borderRadius.base, borderBottomLeftRadius: theme.borderRadius.base, }, toggleButtonGroupEnd: { borderTopRightRadius: theme.borderRadius.base, borderBottomRightRadius: theme.borderRadius.base, }, toggleButtonSelected: { backgroundColor: theme.colors.surface2, }, 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, }, gutterCell: { borderRightWidth: theme.borderWidth[1], borderRightColor: theme.colors.border, justifyContent: "flex-start", }, 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", }, lineNumberGutter: { borderRightWidth: theme.borderWidth[1], borderRightColor: theme.colors.border, marginRight: theme.spacing[2], alignSelf: "stretch", justifyContent: "flex-start", }, lineNumberText: { textAlign: "right", paddingRight: theme.spacing[2], fontSize: theme.fontSize.xs, lineHeight: theme.lineHeight.diff, fontFamily: Fonts.mono, color: theme.colors.foregroundMuted, userSelect: "none", }, addLineNumberText: { color: theme.colors.diffAddition, }, removeLineNumberText: { color: theme.colors.diffDeletion, }, diffLineText: { flex: 1, paddingRight: theme.spacing[3], fontSize: theme.fontSize.xs, lineHeight: theme.lineHeight.diff, 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, }, emptySplitCellText: { color: "transparent", }, statusMessageContainer: { borderTopWidth: theme.borderWidth[1], borderTopColor: theme.colors.border, backgroundColor: theme.colors.surface1, paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[4], }, statusMessageText: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, fontStyle: "italic", }, tooltipText: { fontSize: theme.fontSize.xs, color: theme.colors.foreground, }, })); const HEADER_LINE_TEXT_STYLE = [styles.diffLineText, styles.headerLineText]; const FILE_SECTION_BODY_STYLE = [styles.fileSectionBodyContainer, styles.fileSectionBorder]; const DIFF_CONTENT_SPLIT_ROW_STYLE = [styles.diffContent, styles.splitRow]; const DIFF_CONTENT_ROW_STYLE = [styles.diffContent, styles.diffContentRow]; const DIFF_HEIGHT_CHANGE_EPSILON = 0.5;