import { useState, useCallback, useEffect, useMemo, useRef, memo, type ReactElement, type ReactNode, } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { DiffStat } from "@/components/diff-stat"; import { View, Text, 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, ChevronDown, Columns2, FolderTree, List, ListChevronsDownUp, ListChevronsUpDown, Maximize2, Pilcrow, RotateCw, WrapText, } from "lucide-react-native"; import { type ParsedDiffFile, type DiffLine, type HighlightToken } from "@/git/use-diff-query"; import { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "@/git/diff-flat-items"; import { buildDiffTree, collectDirPaths, compressSingleChildChains } from "@/git/diff-tree"; import { DiffFolderRow } from "@/git/diff-folder-row"; import { TreeIndentGuides, treeRowPaddingLeft, WORKSPACE_FILE_ROW_TRAILING_PADDING, WORKSPACE_FILE_ROW_VERTICAL_PADDING, } from "@/components/tree-primitives"; import { MaterialFileIcon } from "@/components/material-file-icon"; import { FileChangeIcon } from "@/components/file-change-icon"; import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; import { CommitsSection } from "@/git/commits-section/commits-section"; 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 * as Clipboard from "expo-clipboard"; import { FileActionsContextMenuContent } from "@/components/file-actions-menu"; import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu"; import { useFileDownload } from "@/hooks/use-file-download"; import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { lineNumberGutterWidth } from "@/components/code-insets"; import { GitActionsSplitButton } from "@/git/actions-split-button"; import { BranchSwitcher } from "@/components/branch-switcher"; import { useGitActions } from "@/git/use-actions"; import { GIT_ACTION_ICONS } from "@/git/action-icons"; import { buildForgeSignInCommand, getForgePresentation, type Forge } from "@/git/forge"; import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; import type { ForgeAuthState } from "@getpaseo/protocol/messages"; 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 { useOverlayFlatListScrollbar } from "@/components/ui/overlay-scrollbar/use-overlay-flat-list-scrollbar"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { usePanelStore } from "@/stores/panel-store"; import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; import { formatDiffContentText, formatDiffGutterText, hasVisibleDiffTokens, } from "@/utils/diff-rendering"; import { isWeb, isNative } from "@/constants/platform"; import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source"; import { type ReviewDraftComment, getInlineReviewThreadState, getSplitInlineReviewThreadState, InlineReviewGutterCell, InlineReviewThread, isInlineReviewEditorForTarget, type InlineReviewActions, } from "@/review"; import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff"; import { DiffTooLargeState } from "@/git/diff-too-large-state"; export type { GitActionId, GitAction, GitActions } from "@/git/policy"; export function resolveDiffLayout( layout: "unified" | "split", canUseSplitLayout: boolean, ): "unified" | "split" { return canUseSplitLayout ? layout : "unified"; } function fileHeaderPressableStyle({ hovered, pressed, }: PressableStateCallbackType & { hovered?: boolean }) { return [styles.fileHeader, (Boolean(hovered) || pressed) && styles.fileHeaderActive]; } interface HighlightedTextProps { tokens: HighlightToken[]; textMetricsStyle: TextStyle; wrapLines?: boolean; testID?: string; } type WrappedWebTextStyle = TextStyle & { whiteSpace?: "pre" | "pre-wrap"; overflowWrap?: "normal" | "anywhere"; }; function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined { if (isNative) { return undefined; } return wrapLines ? { whiteSpace: "pre-wrap", overflowWrap: "anywhere" } : { whiteSpace: "pre", overflowWrap: "normal" }; } function getNumericLineHeight(textMetricsStyle: TextStyle): number | undefined { const { lineHeight } = textMetricsStyle; return typeof lineHeight === "number" && Number.isFinite(lineHeight) ? lineHeight : undefined; } function useDiffRowMetricsStyle(textMetricsStyle: TextStyle): StyleProp { const lineHeight = getNumericLineHeight(textMetricsStyle); return useMemo( () => (lineHeight !== undefined ? inlineUnistylesStyle({ minHeight: lineHeight }) : null), [lineHeight], ); } function HighlightedToken({ token }: { token: HighlightToken }) { return {token.text}; } function HighlightedText({ tokens, textMetricsStyle, wrapLines = false, testID, }: HighlightedTextProps) { const containerStyle = useMemo( () => [ styles.diffTextMetrics, textMetricsStyle, styles.diffLineText, getWrappedTextStyle(wrapLines), ], [textMetricsStyle, wrapLines], ); const keyedTokens = useMemo( () => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })), [tokens], ); return ( {keyedTokens.map(({ key, token }) => ( ))} ); } interface DiffFileSectionProps { file: ParsedDiffFile; workspaceFileDragScope?: { serverId: string; workspaceId: string }; isExpanded: boolean; /** Tree indentation level (0 on the flat/mobile path). */ depth?: number; /** Show the muted directory suffix (flat list); false inside the folder tree. */ showDir?: boolean; interactive?: boolean; onToggle?: (path: string) => void; onOpenFile?: (path: string) => void; onAddToChat?: (path: string) => void; onCopyPath?: (path: string) => void; onDownload?: (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, textMetricsStyle, reviewTarget, reviewActions, isLineHovered, style, textTestID, actionTestID, }: { lineNumber: number | null; type: DiffLine["type"] | undefined | null; gutterWidth: number; textMetricsStyle: TextStyle; reviewTarget?: ReviewableDiffTarget | null; reviewActions?: InlineReviewActions; isLineHovered?: boolean; style?: StyleProp; textTestID?: string; actionTestID?: string; }) { const lineHeight = getNumericLineHeight(textMetricsStyle); const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle); const containerStyle = useMemo( () => [ styles.gutterCell, lineTypeBackground(type), rowMetricsStyle, inlineUnistylesStyle({ width: gutterWidth }), style, ], [type, rowMetricsStyle, gutterWidth, style], ); const textStyle = useMemo( () => [ styles.diffTextMetrics, textMetricsStyle, styles.lineNumberText, type === "add" && styles.addLineNumberText, type === "remove" && styles.removeLineNumberText, ], [textMetricsStyle, type], ); const comments = useMemo( () => reviewTarget ? (reviewActions?.commentsByTarget.get(reviewTarget.key) ?? EMPTY_COMMENTS) : EMPTY_COMMENTS, [reviewTarget, reviewActions?.commentsByTarget], ); const isEditorOpen = isInlineReviewEditorForTarget(reviewActions?.editor ?? null, reviewTarget); const onStartComment = reviewActions?.onStartComment ?? noopStartComment; return ( {formatDiffGutterText(lineNumber)} ); } function DiffTextLine({ line, wrapLines, textMetricsStyle, reviewTarget, reviewActions, onHoverChange, hoverTargetKey, onHoverTargetChange, textTestID, }: { line: DiffLine; wrapLines: boolean; textMetricsStyle: TextStyle; reviewTarget?: ReviewableDiffTarget | null; reviewActions?: InlineReviewActions; onHoverChange?: (hovered: boolean) => void; hoverTargetKey?: string | null; onHoverTargetChange?: (key: string | null) => void; textTestID?: string; }) { const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null; const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle); const containerStyle = useMemo( () => [styles.textLineContainer, lineTypeBackground(line.type), rowMetricsStyle], [line.type, rowMetricsStyle], ); const textStyle = useMemo( () => [ styles.diffTextMetrics, textMetricsStyle, styles.diffLineText, getWrappedTextStyle(wrapLines), line.type === "add" && styles.addLineText, line.type === "remove" && styles.removeLineText, line.type === "header" && styles.headerLineText, line.type === "context" && styles.contextLineText, ], [line.type, textMetricsStyle, wrapLines], ); return ( {line.type !== "header" && visibleTokens ? ( ) : ( {formatDiffContentText(line.content)} )} ); } function SplitTextLine({ line, wrapLines, textMetricsStyle, reviewActions, onHoverChange, hoverTargetKey, onHoverTargetChange, }: { line: SplitDiffDisplayLine | null; wrapLines: boolean; textMetricsStyle: TextStyle; reviewActions?: InlineReviewActions; onHoverChange?: (hovered: boolean) => void; hoverTargetKey?: string | null; onHoverTargetChange?: (key: string | null) => void; }) { const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null; const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle); const containerStyle = useMemo( () => [styles.textLineContainer, lineTypeBackground(line?.type), rowMetricsStyle], [line?.type, rowMetricsStyle], ); const textStyle = useMemo( () => [ styles.diffTextMetrics, textMetricsStyle, styles.diffLineText, getWrappedTextStyle(wrapLines), line?.type === "add" && styles.addLineText, line?.type === "remove" && styles.removeLineText, line?.type === "context" && styles.contextLineText, !line && styles.emptySplitCellText, ], [line, textMetricsStyle, wrapLines], ); return ( {visibleTokens ? ( ) : ( {formatDiffContentText(line?.content)} )} ); } function DiffLineView({ line, lineNumber, gutterWidth, wrapLines, textMetricsStyle, reviewTarget, reviewActions, }: { line: DiffLine; lineNumber: number | null; gutterWidth: number; wrapLines: boolean; textMetricsStyle: TextStyle; reviewTarget?: ReviewableDiffTarget | null; reviewActions?: InlineReviewActions; }) { const [isLineHovered, setIsLineHovered] = useState(false); const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null; const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle); const containerStyle = useMemo( () => [styles.diffLineContainer, lineTypeBackground(line.type), rowMetricsStyle], [line.type, rowMetricsStyle], ); const textStyle = useMemo( () => [ styles.diffTextMetrics, textMetricsStyle, styles.diffLineText, getWrappedTextStyle(wrapLines), line.type === "add" && styles.addLineText, line.type === "remove" && styles.removeLineText, line.type === "header" && styles.headerLineText, line.type === "context" && styles.contextLineText, ], [line.type, textMetricsStyle, wrapLines], ); return ( {line.type !== "header" && visibleTokens ? ( ) : ( {formatDiffContentText(line.content)} )} ); } function SplitDiffLine({ line, gutterWidth, wrapLines, textMetricsStyle, reviewActions, }: { line: SplitDiffDisplayLine | null; gutterWidth: number; wrapLines: boolean; textMetricsStyle: TextStyle; reviewActions?: InlineReviewActions; }) { const [isLineHovered, setIsLineHovered] = useState(false); const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null; const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle); const containerStyle = useMemo( () => [styles.diffLineContainer, lineTypeBackground(line?.type), rowMetricsStyle], [line?.type, rowMetricsStyle], ); const textStyle = useMemo( () => [ styles.diffTextMetrics, textMetricsStyle, styles.diffLineText, getWrappedTextStyle(wrapLines), line?.type === "add" && styles.addLineText, line?.type === "remove" && styles.removeLineText, line?.type === "context" && styles.contextLineText, !line && styles.emptySplitCellText, ], [line, textMetricsStyle, wrapLines], ); return ( {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, textMetricsStyle, reviewActions, showDivider = false, }: { rows: SplitDiffRow[]; side: "left" | "right"; gutterWidth: number; wrapLines: boolean; textMetricsStyle: TextStyle; reviewActions?: InlineReviewActions; showDivider?: boolean; }) { const [scrollWidth, setScrollWidth] = useState(0); const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState(null); const wrapCellStyle = useMemo( () => [styles.splitCell, showDivider && styles.splitCellWithDivider], [showDivider], ); const rowCellStyle = useMemo( () => [styles.splitCell, showDivider && styles.splitCellWithDivider, styles.splitCellRow], [showDivider], ); const linesContainerRowStyle = useMemo( () => [ styles.linesContainer, scrollWidth > 0 && inlineUnistylesStyle({ minWidth: scrollWidth }), ], [scrollWidth], ); const headerLineTextStyle = useMemo( () => [styles.diffTextMetrics, textMetricsStyle, styles.diffLineText, styles.headerLineText], [textMetricsStyle], ); const keyedRows = useMemo(() => rows.map((row, i) => ({ key: `row-${i}`, row })), [rows]); if (wrapLines) { return ( {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, workspaceFileDragScope, isExpanded, depth = 0, showDir = true, interactive = true, onToggle, onOpenFile, onAddToChat, onCopyPath, onDownload, onHeaderHeightChange, testID, }: DiffFileSectionProps) { const dragSourceRef = useWorkspaceFileDragSource({ enabled: interactive, disabled: file.isDeleted, workspaceId: null, path: file.path, ...workspaceFileDragScope, }); const layoutYRef = useRef(null); const pressHandledRef = useRef(false); const pressInRef = useRef<{ ts: number; pageX: number; pageY: number } | null>(null); const toggleExpanded = useCallback(() => { if (!interactive) { return; } pressHandledRef.current = true; onToggle?.(file.path); }, [file.path, interactive, onToggle]); const handleOpenFile = useCallback(() => { onOpenFile?.(file.path); }, [file.path, onOpenFile]); const handleAddToChat = useCallback(() => { onAddToChat?.(file.path); }, [file.path, onAddToChat]); const handleCopyPath = useCallback(() => { onCopyPath?.(file.path); }, [file.path, onCopyPath]); const handleDownload = useCallback(() => { onDownload?.(file.path); }, [file.path, onDownload]); 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 handleLongPress = useCallback(() => { pressHandledRef.current = true; }, []); const handlePressOut = useCallback( (event: { nativeEvent: { pageX: number; pageY: number } }) => { if ( interactive && 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(); } } }, [interactive, toggleExpanded], ); const containerStyle = useMemo( () => [styles.fileSectionHeaderContainer, isExpanded && styles.fileSectionHeaderExpanded], [isExpanded], ); const headerPressableStyle = useCallback( (state: PressableStateCallbackType) => depth > 0 ? [ fileHeaderPressableStyle(state), inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) }), ] : fileHeaderPressableStyle(state), [depth], ); const fileName = file.path.split("/").pop() ?? file.path; const headerContent = ( <> {showDir ? null : ( )} {fileName} {showDir ? ( {file.path.includes("/") ? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}` : ""} ) : ( // Flex spacer in tree mode (no dir suffix) so the New/Deleted badge // stays right-aligned next to the diff stats, as in the flat list. )} {file.isNew && } {file.isDeleted && } ); let trigger: ReactElement; if (!interactive) { trigger = ( {headerContent} ); } else { trigger = ( {headerContent} ); } return ( {trigger} {file.path} {interactive ? ( ) : null} ); }); export function DiffFileBody({ file, layout, wrapLines, codeFontSize, textMetricsStyle, reviewActions, onBodyHeightChange, testID, }: { file: ParsedDiffFile; layout: "unified" | "split"; wrapLines: boolean; codeFontSize: number; textMetricsStyle: TextStyle; reviewActions?: InlineReviewActions; onBodyHeightChange?: (file: ParsedDiffFile, height: number) => void; testID?: string; }) { const [scrollViewWidth, setScrollViewWidth] = useState(0); const [bodyWidth, setBodyWidth] = useState(0); const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState(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; onOpenFile?: (path: string) => void; onAddToChat?: (path: string) => void; } type PressableStyleFn = ( state: PressableStateCallbackType & { hovered?: boolean; open?: boolean }, ) => StyleProp; const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedAlignJustify = withUnistyles(AlignJustify); const ThemedColumns2 = withUnistyles(Columns2); const ThemedPilcrow = withUnistyles(Pilcrow); const ThemedWrapText = withUnistyles(WrapText); const ThemedListChevronsDownUp = withUnistyles(ListChevronsDownUp); const ThemedListChevronsUpDown = withUnistyles(ListChevronsUpDown); const ThemedFolderTree = withUnistyles(FolderTree); const ThemedList = withUnistyles(List); const ThemedMaximize2 = withUnistyles(Maximize2); const ThemedChevronDown = withUnistyles(ChevronDown); const DIFF_OPTIONS_WHITESPACE_ICON = ( ); const DIFF_OPTIONS_WRAP_ICON = ( ); interface DiffLayoutToggleProps { layout: "unified" | "split"; isMobile: boolean; testID?: string; toggleStyle?: PressableStyleFn; onToggle: () => void; } export function DiffLayoutToggle({ layout, isMobile, testID = "changes-toggle-layout", toggleStyle, onToggle, }: DiffLayoutToggleProps) { const defaultToggleStyle = useMemo( () => buildToggleButtonStyle(false, styles.expandAllButton), [], ); const { t } = useTranslation(); const label = layout === "unified" ? t("workspace.git.diff.switchToSplit") : t("workspace.git.diff.switchToUnified"); return ( {layout === "unified" ? ( ) : ( )} {label} ); } interface ChangesTabToggleProps { isMobile: boolean; selected: boolean; onPress: () => void; } interface DiffModeMenuProps { diffMode: "uncommitted" | "base"; committedDescription?: string; testIDPrefix?: string; onSelectUncommitted: () => void; onSelectBase: () => void; } export function DiffModeMenu({ diffMode, committedDescription, testIDPrefix = "changes-diff", onSelectUncommitted, onSelectBase, }: DiffModeMenuProps) { const { t } = useTranslation(); const triggerStyle = useMemo(() => buildDiffModeTriggerStyle(), []); const uncommittedLabel = t("workspace.git.diff.uncommitted"); const committedLabel = t("workspace.git.diff.committed"); return ( {diffMode === "uncommitted" ? uncommittedLabel : committedLabel} {uncommittedLabel} {committedLabel} ); } function ChangesTabToggle({ isMobile, selected, onPress }: ChangesTabToggleProps) { const { t } = useTranslation(); const buttonStyle = useMemo( () => buildToggleButtonStyle(selected, styles.expandAllButton), [selected], ); const label = t( selected ? "workspace.git.diff.closeChangesTab" : "workspace.git.diff.openChangesTab", ); if (isMobile) { return null; } return ( {label} ); } interface DiffViewModeToggleProps { viewMode: "flat" | "tree"; isMobile: boolean; toggleStyle: PressableStyleFn; onToggle: () => void; } function DiffViewModeToggle({ viewMode, isMobile, toggleStyle, onToggle, }: DiffViewModeToggleProps) { const { t } = useTranslation(); const label = viewMode === "flat" ? t("workspace.git.diff.showTreeView") : t("workspace.git.diff.showFlatView"); return ( {viewMode === "flat" ? ( ) : ( )} {label} ); } interface DiffFilesToolbarProps { allFileDiffsExpanded: boolean; isMobile: boolean; testID?: string; expandAllToggleStyle?: PressableStyleFn; onToggleExpandAll: () => void; } export function DiffFilesToolbar({ allFileDiffsExpanded, isMobile, testID, expandAllToggleStyle, onToggleExpandAll, }: DiffFilesToolbarProps) { const defaultToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []); const { t } = useTranslation(); const expandAllLabel = allFileDiffsExpanded ? t("workspace.git.diff.collapseAll") : t("workspace.git.diff.expandAll"); return ( {allFileDiffsExpanded ? ( ) : ( )} {expandAllLabel} ); } interface DiffOptionsMenuProps { brand?: string; hideWhitespace: boolean; isMobile: boolean; isRefreshing?: boolean; overflowToggleStyle?: PressableStyleFn; refreshSupported?: boolean; testIDPrefix?: string; wrapLines: boolean; onRefresh?: () => void; onToggleHideWhitespace: () => void; onToggleWrapLines: () => void; } export function DiffOptionsMenu({ brand, hideWhitespace, isMobile, isRefreshing = false, overflowToggleStyle, refreshSupported = false, testIDPrefix = "changes", wrapLines, onRefresh, onToggleHideWhitespace, onToggleWrapLines, }: DiffOptionsMenuProps) { const { t } = useTranslation(); const defaultToggleStyle = useMemo(() => buildOverflowButtonStyle(), []); const whitespaceLabel = hideWhitespace ? t("workspace.git.diff.showWhitespace") : t("workspace.git.diff.hideWhitespace"); const wrapLinesLabel = wrapLines ? t("workspace.git.diff.scrollLongLines") : t("workspace.git.diff.wrapLongLines"); const optionsLabel = t("workspace.git.diff.options"); let refreshLabel = t("workspace.git.diff.refresh"); if (isRefreshing) { refreshLabel = t("workspace.git.diff.refreshing"); } else if (brand) { refreshLabel = t("workspace.git.diff.refreshState", { brand }); } const refreshIcon = useMemo( () => isRefreshing ? ( ) : ( ), [isRefreshing], ); return ( {optionsLabel} {whitespaceLabel} {wrapLinesLabel} {refreshSupported && onRefresh ? ( <> {refreshLabel} ) : null} ); } const ThemedRotateCw = withUnistyles(RotateCw); type DiffFlatItemLayoutGetter = NonNullable["getItemLayout"]>; const EMPTY_PATH_LIST: string[] = []; interface DiffFileMetrics { contentLength: number; splitLineCount?: number; unifiedLineCount: number; } const diffFileMetricsCache = new WeakMap(); function getDiffFileMetrics(file: ParsedDiffFile): DiffFileMetrics { const cached = diffFileMetricsCache.get(file); if (cached) { return cached; } let contentLength = 0; let unifiedLineCount = 0; for (const hunk of file.hunks) { unifiedLineCount += hunk.lines.length; for (const line of hunk.lines) { contentLength += line.content.length; } } const metrics = { contentLength, unifiedLineCount }; diffFileMetricsCache.set(file, metrics); return metrics; } function getSplitDiffLineCount(file: ParsedDiffFile): number { const metrics = getDiffFileMetrics(file); if (metrics.splitLineCount === undefined) { metrics.splitLineCount = buildSplitDiffRows(file).length; } return metrics.splitLineCount; } 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; diffTooLarge: boolean; hasChanges: boolean; emptyMessage: string; children: ReactElement; checkingRepositoryLabel: string; notRepositoryLabel: string; } function DiffBodyContent({ isStatusLoading, statusErrorMessage, notGit, isDiffLoading, diffErrorMessage, diffTooLarge, hasChanges, emptyMessage, children, checkingRepositoryLabel, notRepositoryLabel, }: DiffBodyContentProps) { if (isStatusLoading) { return ( {checkingRepositoryLabel} ); } if (statusErrorMessage) { return ( {statusErrorMessage} ); } if (notGit) { return ( {notRepositoryLabel} ); } if (isDiffLoading) { return ( ); } if (diffTooLarge) { return ; } if (diffErrorMessage) { return ( {diffErrorMessage} ); } if (!hasChanges) { return ( {emptyMessage} ); } return children; } interface SharedDiffViewProps { files: ParsedDiffFile[]; displayPreferences: { layout: "unified" | "split"; wrapLines: boolean; codeFontSize: number; monoFontFamily: string; }; mode: | { kind: "working_tree"; viewMode: "flat" | "tree"; expandedPaths: string[]; collapsedFolders: string[]; reviewActions?: InlineReviewActions; onFilePress?: (path: string) => void; workspaceFileDragScope?: { serverId: string; workspaceId: string }; onOpenFile?: (path: string) => void; onAddToChat?: (path: string) => void; onCopyPath?: (path: string) => void; onDownload?: (path: string) => void; onExpandedPathsChange: (paths: string[]) => void; onCollapsedFoldersChange: (paths: string[]) => void; } | { kind: "working_tab"; expandedPaths: string[] | null; reviewActions: InlineReviewActions; focusPath?: string; focusRequestId?: number; onExpandedPathsChange: (paths: string[]) => void; } | { kind: "commit"; }; } export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffViewProps) { const isCompact = useIsCompactFormFactor(); const { layout, wrapLines, codeFontSize, monoFontFamily } = displayPreferences; const diffBodyLineHeight = Math.round(codeFontSize * 1.5); const typographyKey = [monoFontFamily, codeFontSize, diffBodyLineHeight].join(":"); const textMetricsStyle = useMemo(() => { const trimmedMonoFontFamily = monoFontFamily.trim(); return { fontSize: codeFontSize, lineHeight: diffBodyLineHeight, ...(trimmedMonoFontFamily ? { fontFamily: trimmedMonoFontFamily } : null), }; }, [codeFontSize, diffBodyLineHeight, monoFontFamily]); const viewMode = mode.kind === "working_tree" ? mode.viewMode : "flat"; const expandedPathsArray = useMemo(() => { if (mode.kind === "working_tree") { return mode.expandedPaths; } if (mode.kind === "working_tab" && mode.expandedPaths !== null) { return mode.expandedPaths; } return files.map((file) => file.path); }, [files, mode]); const expandedPaths = useMemo(() => new Set(expandedPathsArray), [expandedPathsArray]); const collapsedFoldersArray = mode.kind === "working_tree" ? mode.collapsedFolders : EMPTY_PATH_LIST; const collapsedFolders = useMemo(() => new Set(collapsedFoldersArray), [collapsedFoldersArray]); const stickyHeaders = mode.kind !== "commit"; const interactive = mode.kind !== "commit"; const reviewActions = mode.kind === "commit" ? undefined : mode.reviewActions; const onFilePress = mode.kind === "working_tree" ? mode.onFilePress : undefined; const focusPath = mode.kind === "working_tab" ? mode.focusPath : undefined; const focusRequestId = mode.kind === "working_tab" ? mode.focusRequestId : undefined; const onOpenFile = mode.kind === "working_tree" ? mode.onOpenFile : undefined; const onAddToChat = mode.kind === "working_tree" ? mode.onAddToChat : undefined; const workspaceFileDragScope = mode.kind === "working_tree" ? mode.workspaceFileDragScope : undefined; const onCopyPath = mode.kind === "working_tree" ? mode.onCopyPath : undefined; const onDownload = mode.kind === "working_tree" ? mode.onDownload : undefined; const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]); const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]); const allFolderPathSet = useMemo(() => new Set(allFolderPaths), [allFolderPaths]); const effectiveCollapsedFolders = useMemo( () => new Set(Array.from(collapsedFolders).filter((path) => allFolderPathSet.has(path))), [allFolderPathSet, collapsedFolders], ); const diffListRef = useRef>(null); const scrollbar = useOverlayFlatListScrollbar(diffListRef, { enabled: !isCompact }); const { onLayout: updateScrollbarLayout, onScroll: updateScrollbarOffset } = scrollbar; const consumedFocusRequestRef = useRef(null); const pendingFocusRequestRef = useRef(null); const diffListScrollOffsetRef = useRef(0); const diffListViewportHeightRef = useRef(0); const headerHeightByPathRef = useRef>({}); const bodyHeightByKeyRef = useRef>({}); const folderRowHeightRef = useRef(0); const defaultHeaderHeightRef = useRef(44); const [heightVersion, setHeightVersion] = useState(0); const heightVersionFrameRef = useRef(null); const scheduleHeightVersionUpdate = useCallback(() => { if (heightVersionFrameRef.current !== null) { return; } heightVersionFrameRef.current = requestAnimationFrame(() => { heightVersionFrameRef.current = null; setHeightVersion((version) => version + 1); }); }, []); useEffect( () => () => { if (heightVersionFrameRef.current !== null) { cancelAnimationFrame(heightVersionFrameRef.current); } }, [], ); const diffBodyChromeHeight = BORDER_WIDTH[1] * 2; const statusBodyHeightEstimate = diffBodyChromeHeight + SPACING[4] * 2 + diffBodyLineHeight; const { flatItems, stickyHeaderIndices } = useMemo(() => { const { items, stickyHeaderIndices: stickyIndices } = buildDiffFlatItems({ files, viewMode, tree: compressedTree, collapsedFolders: effectiveCollapsedFolders, expandedPaths, }); return { flatItems: items, stickyHeaderIndices: stickyHeaders ? stickyIndices : [], }; }, [compressedTree, effectiveCollapsedFolders, expandedPaths, files, stickyHeaders, viewMode]); const getBodyHeightKey = useCallback( (file: ParsedDiffFile): string => { if (file.status === "too_large" || file.status === "binary") { return `${layout}:${wrapLines ? "wrap" : "scroll"}:${typographyKey}:${file.path}:${file.status}`; } const metrics = getDiffFileMetrics(file); return [ layout, wrapLines ? "wrap" : "scroll", typographyKey, file.path, file.status ?? "ok", file.additions, file.deletions, file.hunks.length, metrics.unifiedLineCount, metrics.contentLength, ].join(":"); }, [layout, typographyKey, wrapLines], ); const estimateBodyHeight = useCallback( (file: ParsedDiffFile): number => { if (file.status === "too_large" || file.status === "binary") { return statusBodyHeightEstimate; } const lineCount = layout === "split" ? getSplitDiffLineCount(file) : getDiffFileMetrics(file).unifiedLineCount; return diffBodyChromeHeight + lineCount * diffBodyLineHeight; }, [diffBodyChromeHeight, diffBodyLineHeight, layout, statusBodyHeightEstimate], ); const getFlatItemHeight = useCallback( (item: DiffFlatItem): number => { if (item.type === "folder") { return folderRowHeightRef.current || defaultHeaderHeightRef.current; } if (item.type === "header") { return headerHeightByPathRef.current[item.file.path] ?? defaultHeaderHeightRef.current; } const bodyHeightKey = getBodyHeightKey(item.file); return bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(item.file); }, [estimateBodyHeight, getBodyHeightKey], ); const handleFolderRowHeightChange = useCallback( (height: number) => { if (!Number.isFinite(height) || height <= 0) { return; } const previousHeight = folderRowHeightRef.current; if (previousHeight > 0 && Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON) { return; } folderRowHeightRef.current = height; scheduleHeightVersionUpdate(); }, [scheduleHeightVersionUpdate], ); 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; scheduleHeightVersionUpdate(); }, [scheduleHeightVersionUpdate], ); 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; scheduleHeightVersionUpdate(); }, [getBodyHeightKey, scheduleHeightVersionUpdate], ); const handleDiffListScroll = useCallback( (event: NativeSyntheticEvent) => { diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y; updateScrollbarOffset(event); }, [updateScrollbarOffset], ); const handleDiffListLayout = useCallback( (event: LayoutChangeEvent) => { const height = event.nativeEvent.layout.height; if (!Number.isFinite(height) || height <= 0) { return; } diffListViewportHeightRef.current = height; updateScrollbarLayout(event); }, [updateScrollbarLayout], ); const computeItemOffset = useCallback( (predicate: (item: DiffFlatItem) => boolean): number | null => { const index = flatItems.findIndex(predicate); if (index < 0) { return null; } return sumHeightsBefore(flatItems, index, getFlatItemHeight); }, [flatItems, getFlatItemHeight], ); const computeHeaderOffset = useCallback( (path: string): number => computeItemOffset((item) => item.type === "header" && item.file.path === path) ?? 0, [computeItemOffset], ); useEffect(() => { if (!focusPath) { return; } const focusRequestKey = `${focusRequestId ?? "initial"}:${focusPath}`; if ( consumedFocusRequestRef.current === focusRequestKey || pendingFocusRequestRef.current === focusRequestKey ) { return; } const hasTarget = flatItems.some( (item) => item.type === "header" && item.file.path === focusPath, ); if (!hasTarget) { return; } pendingFocusRequestRef.current = focusRequestKey; const frame = requestAnimationFrame(() => { diffListRef.current?.scrollToOffset({ offset: computeHeaderOffset(focusPath), animated: false, }); consumedFocusRequestRef.current = focusRequestKey; pendingFocusRequestRef.current = null; }); return () => { cancelAnimationFrame(frame); if (pendingFocusRequestRef.current === focusRequestKey) { pendingFocusRequestRef.current = null; } }; }, [computeHeaderOffset, flatItems, focusPath, focusRequestId]); const handleToggleExpanded = useCallback( (path: string) => { if (mode.kind === "commit") { 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, }); if (shouldAnchor && targetOffset !== null) { diffListRef.current?.scrollToOffset({ offset: targetOffset, animated: false, }); } mode.onExpandedPathsChange( nextExpanded ? [...expandedPaths, path] : Array.from(expandedPaths).filter((expandedPath) => expandedPath !== path), ); }, [computeHeaderOffset, expandedPaths, mode], ); const handleToggleFolder = useCallback( (dirPath: string) => { if (mode.kind !== "working_tree") { return; } const isCurrentlyCollapsed = effectiveCollapsedFolders.has(dirPath); if (!isCurrentlyCollapsed) { const targetOffset = computeItemOffset( (item) => item.type === "folder" && item.dirPath === dirPath, ); const folderHeight = folderRowHeightRef.current || defaultHeaderHeightRef.current; if ( targetOffset !== null && shouldAnchorHeaderBeforeCollapse({ headerOffset: targetOffset, headerHeight: folderHeight, viewportOffset: diffListScrollOffsetRef.current, viewportHeight: diffListViewportHeightRef.current, }) ) { diffListRef.current?.scrollToOffset({ offset: targetOffset, animated: false }); } } mode.onCollapsedFoldersChange( isCurrentlyCollapsed ? Array.from(effectiveCollapsedFolders).filter((path) => path !== dirPath) : [...effectiveCollapsedFolders, dirPath], ); }, [computeItemOffset, effectiveCollapsedFolders, mode], ); const renderFlatItem = useCallback( ({ item }: { item: DiffFlatItem }) => { if (item.type === "folder") { return ( ); } if (item.type === "header") { return ( ); } return ( ); }, [ codeFontSize, handleBodyHeightChange, handleFolderRowHeightChange, handleHeaderHeightChange, handleToggleExpanded, handleToggleFolder, layout, reviewActions, workspaceFileDragScope, textMetricsStyle, viewMode, wrapLines, interactive, onFilePress, onOpenFile, onAddToChat, onCopyPath, onDownload, ], ); const flatKeyExtractor = useCallback( (item: DiffFlatItem) => item.type === "folder" ? `folder-${item.dirPath}` : `${item.type}-${item.file.path}`, [], ); const getFlatItemLayout = useCallback( (_data, index) => { const offset = sumHeightsBefore(flatItems, index, getFlatItemHeight); const item = flatItems[index]; const length = item ? getFlatItemHeight(item) : 0; return { length, offset, index }; }, [flatItems, getFlatItemHeight], ); const flatExtraData = useMemo( () => ({ expandedPathsArray, collapsedFoldersArray, layout, typographyKey, heightVersion, viewMode, wrapLines, reviewActions, workspaceFileDragScope, }), [ expandedPathsArray, collapsedFoldersArray, heightVersion, layout, reviewActions, typographyKey, viewMode, workspaceFileDragScope, wrapLines, ], ); return ( {scrollbar.overlay} ); } 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; } // The precise setup step a workspace needs before its forge features work, or // null when nothing is actionable (authenticated, or no forge remote at all). type ForgeSetupAction = "install_cli" | "sign_in" | null; // Drive the onboarding callout from the forge's auth state so the message names // the exact next step (install the CLI vs sign in) for whichever forge backs the // workspace — GitHub included. GitLab additionally requires the host to advertise // GitLab support, matching the rest of the GitLab UI. function computeForgeSetupAction(input: { forge: Forge; forgeProvidersSupported: boolean; authState: ForgeAuthState | undefined; }): ForgeSetupAction { // A daemon without pluggable forge support can't operate any non-GitHub forge, // so don't offer a setup action for one it can't drive. if (input.forge !== "github" && !input.forgeProvidersSupported) { return null; } switch (input.authState) { case "cli_missing": return "install_cli"; case "unauthenticated": return "sign_in"; case "authenticated": case "no_remote": case "error": return null; default: return null; } } function parseForgeHost(url: string | null | undefined): string | null { return url ? (parseGitRemoteLocation(url)?.host ?? null) : null; } function buildForgeSetupMessage(input: { action: ForgeSetupAction; forge: Forge; host: string | null; t: TFunction; }): string | null { if (!input.action) { return null; } const { brandLabel, signInCli } = getForgePresentation(input.forge); // A forge with no known CLI (an unknown/third-party forge rendered neutrally) // has no install/sign-in command to interpolate — show neutral guidance // rather than the GitLab-specific callout or a null command. if (signInCli === null) { return input.t("workspace.git.forgeSetup.generic", { brand: brandLabel }); } if (input.action === "install_cli") { return input.t("workspace.git.forgeSetup.installCli", { cli: signInCli, brand: brandLabel }); } const command = buildForgeSignInCommand(input.forge, input.host); return input.t("workspace.git.forgeSetup.signIn", { command, brand: brandLabel }); } 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 buildOverflowButtonStyle(): PressableStyleFn { return ({ hovered, pressed }) => [ styles.overflowButton, (Boolean(hovered) || pressed) && styles.toggleButtonSelected, ]; } function buildToggleButtonStyle( selected: boolean, baseStyles: StyleProp | StyleProp[], ): PressableStyleFn { return ({ hovered, pressed }) => [ baseStyles, (selected || Boolean(hovered) || pressed) && styles.toggleButtonSelected, ]; } function useChangesTreeState({ workspaceId, cwd, files, viewMode, changesTabOpen, onViewModeChange, }: { workspaceId?: string | null; cwd: string; files: ParsedDiffFile[]; viewMode: "flat" | "tree"; changesTabOpen: boolean; onViewModeChange: (viewMode: "flat" | "tree") => void; }) { const workspaceStateKey = useMemo( () => buildWorkspaceExplorerStateKey({ workspaceId, workspaceRoot: cwd.trim(), }), [cwd, workspaceId], ); const expandedPaths = usePanelStore((state) => workspaceStateKey ? state.diffExpandedPathsByWorkspace[workspaceStateKey] : undefined, ); const collapsedFolders = usePanelStore((state) => workspaceStateKey ? state.diffCollapsedFoldersByWorkspace[workspaceStateKey] : undefined, ); const setExpandedPaths = usePanelStore((state) => state.setDiffExpandedPathsForWorkspace); const setCollapsedFolders = usePanelStore((state) => state.setDiffCollapsedFoldersForWorkspace); const stableExpandedPaths = expandedPaths ?? EMPTY_PATH_LIST; const stableCollapsedFolders = collapsedFolders ?? EMPTY_PATH_LIST; const folderPaths = useMemo( () => collectDirPaths(compressSingleChildChains(buildDiffTree(files))), [files], ); const folderPathSet = useMemo(() => new Set(folderPaths), [folderPaths]); const allExpanded = useMemo(() => { if (files.length === 0 || changesTabOpen) { return false; } const everyFileExpanded = files.every((file) => stableExpandedPaths.includes(file.path)); const everyFolderExpanded = viewMode !== "tree" || stableCollapsedFolders.every((folderPath) => !folderPathSet.has(folderPath)); return everyFileExpanded && everyFolderExpanded; }, [changesTabOpen, files, folderPathSet, stableCollapsedFolders, stableExpandedPaths, viewMode]); const toggleViewMode = useCallback(() => { const nextViewMode = viewMode === "flat" ? "tree" : "flat"; if (nextViewMode === "tree" && workspaceStateKey) { setCollapsedFolders(workspaceStateKey, []); } onViewModeChange(nextViewMode); }, [onViewModeChange, setCollapsedFolders, viewMode, workspaceStateKey]); const toggleExpandAll = useCallback(() => { if (!workspaceStateKey) { return; } if (allExpanded) { setExpandedPaths(workspaceStateKey, []); if (viewMode === "tree") { setCollapsedFolders(workspaceStateKey, folderPaths); } return; } setExpandedPaths( workspaceStateKey, files.map((file) => file.path), ); if (viewMode === "tree") { setCollapsedFolders(workspaceStateKey, []); } }, [ allExpanded, files, folderPaths, setCollapsedFolders, setExpandedPaths, viewMode, workspaceStateKey, ]); const updateExpandedPaths = useCallback( (paths: string[]) => { if (workspaceStateKey) { setExpandedPaths(workspaceStateKey, paths); } }, [setExpandedPaths, workspaceStateKey], ); const updateCollapsedFolders = useCallback( (paths: string[]) => { if (workspaceStateKey) { setCollapsedFolders(workspaceStateKey, paths); } }, [setCollapsedFolders, workspaceStateKey], ); return { expandedPaths: changesTabOpen ? EMPTY_PATH_LIST : stableExpandedPaths, collapsedFolders: stableCollapsedFolders, allExpanded, toggleViewMode, toggleExpandAll, updateExpandedPaths, updateCollapsedFolders, }; } function useDiffTabNavigation({ serverId, workspaceId, cwd, isMobile, }: { serverId: string; workspaceId?: string | null; cwd: string; isMobile: boolean; }) { const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused); const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); const persistenceKey = useMemo( () => buildWorkspaceTabPersistenceKey({ serverId, workspaceId: workspaceId ?? cwd }), [cwd, serverId, workspaceId], ); const changesTabId = useWorkspaceLayoutStore((state) => { if (!persistenceKey) { return null; } const layout = state.layoutByWorkspace[persistenceKey]; return ( layout && collectAllTabs(layout.root).find((tab) => tab.target.kind === "working_diff")?.tabId ); }); const changesTabOpen = !isMobile && Boolean(changesTabId); const openChanges = useCallback( (path?: string) => { if (!persistenceKey || isMobile) { return; } openWorkspaceTabFocused(persistenceKey, { kind: "working_diff", ...(path ? { focusPath: path, focusRequestId: Date.now() } : {}), }); }, [isMobile, openWorkspaceTabFocused, persistenceKey], ); const toggleChanges = useCallback(() => { if (!persistenceKey || isMobile) { return; } if (changesTabId) { closeWorkspaceTab(persistenceKey, changesTabId); return; } openChanges(); }, [changesTabId, closeWorkspaceTab, isMobile, openChanges, persistenceKey]); const openCommit = useCallback( (sha: string) => { if (persistenceKey) { openWorkspaceTabFocused(persistenceKey, { kind: "commit_diff", sha }); } }, [openWorkspaceTabFocused, persistenceKey], ); return { changesTabOpen, openChanges, toggleChanges, openCommit, onChangesFilePress: changesTabOpen ? openChanges : undefined, }; } export function GitDiffPane({ serverId, workspaceId, cwd, enabled, onOpenFile, onAddToChat, }: GitDiffPaneProps) { const { settings: appSettings } = useAppSettings(); const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); const canUseSplitLayout = isWeb && !isMobile; const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } = useChangesPreferences(); const wrapLines = changesPreferences.wrapLines; const viewMode = changesPreferences.viewMode; const effectiveLayout = resolveDiffLayout(changesPreferences.layout, canUseSplitLayout); const handleToggleWrapLines = useCallback(() => { void updateChangesPreferences({ wrapLines: !wrapLines }); }, [updateChangesPreferences, wrapLines]); const handleToggleHideWhitespace = useCallback(() => { void updateChangesPreferences({ hideWhitespace: !changesPreferences.hideWhitespace }); }, [changesPreferences.hideWhitespace, updateChangesPreferences]); const handleToggleLayout = useCallback(() => { void updateChangesPreferences({ layout: changesPreferences.layout === "unified" ? "split" : "unified", }); }, [changesPreferences.layout, updateChangesPreferences]); const codeFontSize = appSettings.codeFontSize; const layoutToggleStyle = useMemo( () => buildToggleButtonStyle(false, styles.expandAllButton), [], ); const viewModeToggleStyle = useMemo( () => buildToggleButtonStyle(viewMode === "tree", styles.expandAllButton), [viewMode], ); const expandAllToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []); const overflowToggleStyle = useMemo(() => buildOverflowButtonStyle(), []); const toast = useToast(); const { changesTabOpen, toggleChanges: handleToggleChangesTab, openCommit: handleCommitPress, onChangesFilePress, } = useDiffTabNavigation({ serverId, workspaceId, cwd, isMobile }); 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, isStatusLoading, isGit, notGit, statusErrorMessage, baseRef, currentBranchName, diffMode, selectUncommitted: handleSelectUncommitted, selectBase: handleSelectBase, files, diffPayloadError, diffTooLarge, isDiffLoading, reviewActions, reviewAttachment, } = useWorkingDiff({ serverId, workspaceId: workspaceId ?? undefined, cwd, ignoreWhitespace: changesPreferences.hideWhitespace, enabled: enabled !== false, }); usePublishWorkingDiffAttachment({ serverId, workspaceId: workspaceId ?? undefined, cwd, attachment: reviewAttachment, enabled: !changesTabOpen, }); const { githubFeaturesEnabled, forge, authState, payloadError: prPayloadError, } = useCheckoutPrStatusQuery({ serverId, cwd, enabled: isGit, }); const forgeProvidersSupported = useSessionStore( (s) => s.sessions[serverId]?.serverInfo?.features?.forgeProviders === true, ); const forgeSetupAction = computeForgeSetupAction({ forge, forgeProvidersSupported, authState, }); const forgeSetupMessage = useMemo( () => buildForgeSetupMessage({ action: forgeSetupAction, forge, host: parseForgeHost(status?.remoteUrl), t, }), [forgeSetupAction, forge, status?.remoteUrl, t], ); const handleViewModeChange = useCallback( (nextViewMode: "flat" | "tree") => { void updateChangesPreferences({ viewMode: nextViewMode }); }, [updateChangesPreferences], ); const changesTree = useChangesTreeState({ workspaceId, cwd, files, viewMode, changesTabOpen, onViewModeChange: handleViewModeChange, }); const sharedDisplayPreferences = useMemo( () => ({ layout: effectiveLayout, wrapLines, codeFontSize, monoFontFamily: appSettings.monoFontFamily, }), [appSettings.monoFontFamily, codeFontSize, effectiveLayout, wrapLines], ); const downloadFile = useFileDownload({ serverId, workspaceId, workspaceRoot: cwd }); const handleCopyPath = useCallback( (path: string) => { void Clipboard.setStringAsync( buildAbsoluteExplorerPath({ workspaceRoot: cwd, entryPath: path }), ); }, [cwd], ); const handleDownloadPath = useCallback( (path: string) => { downloadFile({ fileName: path.split("/").pop() ?? path, path }); }, [downloadFile], ); const workingTreeMode = useMemo( () => ({ kind: "working_tree" as const, viewMode, expandedPaths: changesTree.expandedPaths, collapsedFolders: changesTree.collapsedFolders, reviewActions, onFilePress: onChangesFilePress, workspaceFileDragScope: workspaceId ? { serverId, workspaceId } : undefined, onOpenFile, onAddToChat, onCopyPath: handleCopyPath, onDownload: handleDownloadPath, onExpandedPathsChange: changesTree.updateExpandedPaths, onCollapsedFoldersChange: changesTree.updateCollapsedFolders, }), [ viewMode, changesTree.expandedPaths, changesTree.collapsedFolders, reviewActions, onChangesFilePress, serverId, workspaceId, onOpenFile, onAddToChat, handleCopyPath, handleDownloadPath, changesTree.updateExpandedPaths, changesTree.updateCollapsedFolders, ], ); 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 { gitActions, branchLabel } = useGitActions({ serverId, cwd, icons: GIT_ACTION_ICONS, }); const committedDiffDescription = useMemo( () => computeCommittedDiffDescription(branchLabel, baseRefLabel), [baseRefLabel, branchLabel], ); 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} ) : null} {isGit ? ( {canUseSplitLayout && !changesTabOpen ? ( ) : null} {files.length > 0 ? ( ) : null} {files.length > 0 && !changesTabOpen ? ( ) : null} ) : null} {forgeSetupMessage ? ( {forgeSetupMessage} ) : null} {prErrorMessage ? {prErrorMessage} : null} {bodyContent} ); } const styles = StyleSheet.create((theme) => ({ container: { flex: 1, minHeight: 0, }, header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], borderBottomWidth: 1, borderBottomColor: theme.colors.border, }, diffStatusContainer: { height: WORKSPACE_SECONDARY_HEADER_HEIGHT, borderBottomWidth: 1, borderBottomColor: theme.colors.border, }, diffStatusInner: { flex: 1, flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingRight: theme.spacing[3], }, diffModeTrigger: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: theme.spacing[1], // Align text with header branch icon (at spacing[3] from edge, minus our horizontal padding) marginLeft: theme.spacing[3] - theme.spacing[1], paddingHorizontal: theme.spacing[1], height: { xs: 28, sm: 28, md: 24, }, borderRadius: theme.borderRadius.base, flexShrink: 0, }, diffModeTriggerHovered: { backgroundColor: theme.colors.surface2, }, diffModeTriggerPressed: { backgroundColor: theme.colors.surface2, }, diffStatusRowHovered: { backgroundColor: theme.colors.surface2, }, diffStatusText: { fontSize: theme.fontSize.xs, lineHeight: theme.fontSize.xs * 1.25, color: theme.colors.foregroundMuted, }, diffStatusIconHidden: { opacity: 0, }, diffStatusButtons: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], flexWrap: "wrap", }, toggleButtonSelected: { backgroundColor: theme.colors.surface2, }, expandAllButton: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: theme.spacing[1], minWidth: { xs: 32, sm: 32, md: 24, }, height: { xs: 32, sm: 32, md: 24, }, paddingHorizontal: { xs: theme.spacing[2], sm: theme.spacing[2], md: theme.spacing[1], }, borderRadius: theme.borderRadius.base, flexShrink: 0, }, overflowButton: { width: ICON_SIZE.sm + 2 * SPACING[1], height: { xs: 32, sm: 32, md: 24, }, alignItems: "center", justifyContent: "center", borderRadius: theme.borderRadius.base, flexShrink: 0, }, actionErrorText: { paddingHorizontal: theme.spacing[3], paddingBottom: theme.spacing[1], fontSize: theme.fontSize.xs, color: theme.colors.destructive, }, forgeSetupCallout: { marginHorizontal: theme.spacing[3], marginBottom: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], borderWidth: 1, borderColor: theme.colors.border, borderRadius: theme.borderRadius.md, backgroundColor: theme.colors.surface1, }, forgeSetupCalloutText: { fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, }, diffContainer: { flex: 1, minHeight: 0, position: "relative", }, scrollView: { flex: 1, }, scrollContainer: { flex: 1, minHeight: 0, position: "relative", }, 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: WORKSPACE_FILE_ROW_TRAILING_PADDING, paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING, gap: theme.spacing[1], minWidth: 0, zIndex: 2, elevation: 2, }, fileHeaderActive: { backgroundColor: theme.colors.surfaceSidebarHover, }, fileHeaderLeft: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], flex: 1, minWidth: 0, }, fileHeaderRight: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], flexShrink: 0, }, fileIcon: { flexShrink: 0, alignItems: "center", justifyContent: "center", }, fileName: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, color: theme.colors.foreground, flexShrink: 1, minWidth: 0, }, fileDir: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, flex: 1, minWidth: 0, }, fileDirSpacer: { flex: 1, minWidth: 0, }, 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 DIFF_HEIGHT_CHANGE_EPSILON = 0.5;