import { useCallback, useEffect, useMemo, useRef } from "react"; import { View, Text, Pressable, useWindowDimensions, StyleSheet as RNStyleSheet, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { X } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { formatPrTabLabel, PullRequestPane, PullRequestPaneError, PullRequestPaneSkeleton, PullRequestTabIcon, usePrPaneData, } from "@/git/pull-request-panel"; import { useCheckoutGitActionsStore } from "@/git/actions-store"; import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data"; import { usePanelStore, selectIsFileExplorerOpen, MIN_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH, type ExplorerTab, } from "@/stores/panel-store"; import { useToast } from "@/contexts/toast-context"; import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; import { HEADER_INNER_HEIGHT } from "@/constants/layout"; import { GitDiffPane } from "@/git/diff-pane"; import { FileExplorerPane } from "./file-explorer-pane"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useWindowControlsPadding } from "@/utils/desktop-window"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { RetainedPanelActivity } from "@/components/retained-panel"; import { isWeb } from "@/constants/platform"; import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; const MIN_CHAT_WIDTH = 400; function logExplorerSidebar(_event: string, _details: Record): void {} interface ExplorerSidebarProps { serverId: string; workspaceId?: string | null; workspaceRoot: string; isGit: boolean; onOpenFile?: (filePath: string) => void; } interface ExplorerSidebarSharedState { explorerTab: ExplorerTab; handleTabPress: (tab: ExplorerTab) => void; } function useExplorerSidebarSharedState({ serverId, workspaceRoot, isGit, }: Pick): ExplorerSidebarSharedState { const explorerTab = usePanelStore((state) => state.explorerTab); const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); const handleTabPress = useCallback( (tab: ExplorerTab) => { setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab }); }, [isGit, serverId, setExplorerTabForCheckout, workspaceRoot], ); return { explorerTab, handleTabPress }; } export function CompactExplorerSidebar({ serverId, workspaceId, workspaceRoot, isGit, onOpenFile, }: ExplorerSidebarProps) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: true })); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({ serverId, workspaceRoot, isGit, }); const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({ mode: "padding", enabled: true, }); const { gesture: closeGesture } = useCloseFileExplorerGesture(); const handleClose = useCallback( (reason: string) => { logExplorerSidebar("handleClose", { reason, isOpen, }); showMobileAgent(); }, [isOpen, showMobileAgent], ); const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]); const mobileSidebarStyle = useMemo( () => [ { paddingTop: insets.top, backgroundColor: theme.colors.surfaceSidebar, }, mobileKeyboardInsetStyle, ], [insets.top, theme.colors.surfaceSidebar, mobileKeyboardInsetStyle], ); return ( ); } export function ExplorerSidebar({ serverId, workspaceId, workspaceRoot, isGit, onOpenFile, }: ExplorerSidebarProps) { const insets = useSafeAreaInsets(); const explorerWidth = usePanelStore((state) => state.explorerWidth); const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth); const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: false })); const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer); const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({ serverId, workspaceRoot, isGit, }); const { width: viewportWidth } = useWindowDimensions(); const startWidthRef = useRef(explorerWidth); const resizeWidth = useSharedValue(explorerWidth); useEffect(() => { const maxWidth = Math.max( MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH), ); if (explorerWidth > maxWidth) { setExplorerWidth(maxWidth); } }, [explorerWidth, setExplorerWidth, viewportWidth]); const handleDesktopClose = useCallback(() => { logExplorerSidebar("handleClose", { reason: "desktop-close-button", isOpen, }); closeDesktopFileExplorer(); }, [closeDesktopFileExplorer, isOpen]); const resizeGesture = useMemo( () => Gesture.Pan() .enabled(true) .hitSlop({ left: 8, right: 8, top: 0, bottom: 0 }) .onStart(() => { startWidthRef.current = explorerWidth; resizeWidth.value = explorerWidth; }) .onUpdate((event) => { const newWidth = startWidthRef.current - event.translationX; const maxWidth = Math.max( MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH), ); const clampedWidth = Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth)); resizeWidth.value = clampedWidth; }) .onEnd(() => { runOnJS(setExplorerWidth)(resizeWidth.value); }), [explorerWidth, resizeWidth, setExplorerWidth, viewportWidth], ); const resizeAnimatedStyle = useAnimatedStyle(() => ({ width: resizeWidth.value, })); const desktopSidebarStyle = useMemo( () => [explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }], [resizeAnimatedStyle, insets.top], ); if (!isOpen) { return null; } return ( ); } interface ExplorerTabButtonProps { tab: ExplorerTab; active: boolean; label?: string; onTabPress: (tab: ExplorerTab) => void; testID: string; children?: React.ReactNode; } function ExplorerTabButton({ tab, active, label, onTabPress, testID, children, }: ExplorerTabButtonProps) { const handlePress = useCallback(() => onTabPress(tab), [onTabPress, tab]); const tabStyle = useMemo(() => [styles.tab, active && styles.tabActive], [active]); const tabTextStyle = useMemo(() => [styles.tabText, active && styles.tabTextActive], [active]); return ( {children} {label !== undefined ? {label} : null} ); } interface SidebarContentProps { activeTab: ExplorerTab; onTabPress: (tab: ExplorerTab) => void; onClose: () => void; serverId: string; workspaceId?: string | null; workspaceRoot: string; isGit: boolean; isMobile: boolean; isOpen: boolean; onOpenFile?: (filePath: string) => void; } function ExplorerSidebarContent({ activeTab, onTabPress, onClose, serverId, workspaceId, workspaceRoot, isGit, isMobile, isOpen, onOpenFile, }: SidebarContentProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); const toast = useToast(); const padding = useWindowControlsPadding("explorerSidebar"); const canQueryPullRequest = isGit && Boolean(workspaceRoot); const prPane = usePrPaneData({ serverId, cwd: workspaceRoot, enabled: canQueryPullRequest && isOpen, timelineEnabled: activeTab === "pr" && canQueryPullRequest && isOpen, }); const hasPullRequest = prPane.prNumber !== null; const showPrTab = hasPullRequest || (activeTab === "pr" && prPane.isLoading); const requestedTab: ExplorerTab = !isGit && (activeTab === "changes" || activeTab === "pr") ? "files" : activeTab; const resolvedTab: ExplorerTab = requestedTab === "pr" && !showPrTab ? "changes" : requestedTab; const prTabLabel = formatPrTabLabel(prPane.prNumber); const refreshGitActions = useCheckoutGitActionsStore((s) => s.refresh); const handlePrRetry = useCallback(() => { refreshGitActions({ serverId, cwd: workspaceRoot }).catch((error) => { toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh")); }); }, [refreshGitActions, serverId, t, toast, workspaceRoot]); const workspaceAttachmentScopeKey = useMemo( () => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd: workspaceRoot }), [serverId, workspaceId, workspaceRoot], ); const headerStyle = useMemo( () => [styles.header, { paddingRight: padding.right }], [padding.right], ); return ( {/* Header with tabs and close button */} {isGit && ( )} {isGit && showPrTab && ( )} {isMobile && ( )} {/* Content based on active tab */} {resolvedTab === "changes" && ( )} {resolvedTab === "files" && ( )} {resolvedTab === "pr" && ( )} ); } interface PrTabContentProps { serverId: string; cwd: string; prPane: UsePrPaneDataResult; workspaceAttachmentScopeKey: string; onRetry: () => void; } function PrTabContent({ serverId, cwd, prPane, workspaceAttachmentScopeKey, onRetry, }: PrTabContentProps) { if (prPane.data) { return ( ); } if (prPane.error) { return ; } return ; } // Static styles for Animated.Views — must NOT use Unistyles dynamic theme to // avoid the "Unable to find node on an unmounted component" crash when Unistyles // tries to patch the native node that Reanimated also manages. const explorerStaticStyles = RNStyleSheet.create({ desktopSidebar: { position: "relative" as const, }, }); const styles = StyleSheet.create((theme) => ({ desktopSidebarBorder: { borderLeftWidth: 1, borderLeftColor: theme.colors.border, backgroundColor: theme.colors.surfaceSidebar, }, resizeHandle: { position: "absolute", left: -5, top: 0, bottom: 0, width: 10, zIndex: 10, }, sidebarContent: { flex: 1, minHeight: 0, overflow: "hidden", }, header: { position: "relative", height: HEADER_INNER_HEIGHT, flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: theme.spacing[2], borderBottomWidth: 1, borderBottomColor: theme.colors.border, }, tabsContainer: { flexDirection: "row", gap: theme.spacing[1], }, tab: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.md, }, tabActive: { backgroundColor: theme.colors.surfaceSidebarHover, }, tabText: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, }, tabTextActive: { color: theme.colors.foreground, }, tabTextMuted: { opacity: 0.8, }, headerRightSection: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, closeButton: { padding: theme.spacing[2], borderRadius: theme.borderRadius.md, }, contentArea: { flex: 1, minHeight: 0, }, })); const DESKTOP_SIDEBAR_BORDER_STYLE = [styles.desktopSidebarBorder, { flex: 1 }]; const RESIZE_HANDLE_STYLE = [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)];