import { useCallback, useEffect, useMemo, useRef } from "react"; import { View, Text, Pressable, Platform, useWindowDimensions, StyleSheet as RNStyleSheet, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useIsFocused } from "@react-navigation/native"; 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 { usePanelStore, MIN_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH, type ExplorerTab, } from "@/stores/panel-store"; import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } 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"; 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; } export function ExplorerSidebar({ serverId, workspaceId, workspaceRoot, isGit, onOpenFile, }: ExplorerSidebarProps) { const { theme } = useUnistyles(); const isScreenFocused = useIsFocused(); const insets = useSafeAreaInsets(); const isMobile = useIsCompactFormFactor(); const mobileView = usePanelStore((state) => state.mobileView); const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); const closeToAgent = usePanelStore((state) => state.closeToAgent); const explorerTab = usePanelStore((state) => state.explorerTab); const explorerWidth = usePanelStore((state) => state.explorerWidth); const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth); const { width: viewportWidth } = useWindowDimensions(); const closeTouchStartX = useSharedValue(0); const closeTouchStartY = useSharedValue(0); const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({ mode: "padding", enabled: isMobile, }); useEffect(() => { if (isMobile) { return; } const maxWidth = Math.max( MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH), ); if (explorerWidth > maxWidth) { setExplorerWidth(maxWidth); } }, [explorerWidth, isMobile, setExplorerWidth, viewportWidth]); // Derive isOpen from the unified panel state const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; const { translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing, gestureAnimatingRef, closeGestureRef, } = useExplorerSidebarAnimation(); // For resize drag, track the starting width const startWidthRef = useRef(explorerWidth); const resizeWidth = useSharedValue(explorerWidth); const handleClose = useCallback( (reason: string) => { logExplorerSidebar("handleClose", { reason, isOpen, mobileView, desktopFileExplorerOpen, }); closeToAgent(); }, [closeToAgent, desktopFileExplorerOpen, isOpen, mobileView], ); const handleCloseFromGesture = useCallback(() => { gestureAnimatingRef.current = true; closeToAgent(); }, [closeToAgent, gestureAnimatingRef]); const enableSidebarCloseGesture = isMobile && isOpen; const handleTabPress = useCallback( (tab: ExplorerTab) => { setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab }); }, [isGit, serverId, setExplorerTabForCheckout, workspaceRoot], ); // Swipe gesture to close (swipe right on mobile) const closeGesture = useMemo( () => Gesture.Pan() .withRef(closeGestureRef) .enabled(enableSidebarCloseGesture) // Use manual activation so child views keep touch streams // unless we detect an intentional right-swipe close. .manualActivation(true) .onTouchesDown((event) => { const touch = event.changedTouches[0]; if (!touch) { return; } closeTouchStartX.value = touch.absoluteX; closeTouchStartY.value = touch.absoluteY; }) .onTouchesMove((event, stateManager) => { const touch = event.changedTouches[0]; if (!touch || event.numberOfTouches !== 1) { stateManager.fail(); return; } const deltaX = touch.absoluteX - closeTouchStartX.value; const deltaY = touch.absoluteY - closeTouchStartY.value; const absDeltaX = Math.abs(deltaX); const absDeltaY = Math.abs(deltaY); // Fail quickly on clear leftward or vertical intent so child views keep control. if (deltaX <= -10) { stateManager.fail(); return; } if (absDeltaY > 10 && absDeltaY > absDeltaX) { stateManager.fail(); return; } // Activate only on intentional rightward movement. if (deltaX >= 15 && absDeltaX > absDeltaY) { stateManager.activate(); } }) .onStart(() => { isGesturing.value = true; }) .onUpdate((event) => { // Right sidebar: swipe right to close (positive translationX) const newTranslateX = Math.max(0, Math.min(windowWidth, event.translationX)); translateX.value = newTranslateX; const progress = 1 - newTranslateX / windowWidth; backdropOpacity.value = Math.max(0, Math.min(1, progress)); }) .onEnd((event) => { isGesturing.value = false; const shouldClose = event.translationX > windowWidth / 3 || event.velocityX > 500; runOnJS(logExplorerSidebar)("closeGestureEnd", { translationX: event.translationX, velocityX: event.velocityX, shouldClose, windowWidth, }); if (shouldClose) { animateToClose(); runOnJS(handleCloseFromGesture)(); } else { animateToOpen(); } }) .onFinalize(() => { isGesturing.value = false; }), [ enableSidebarCloseGesture, windowWidth, translateX, backdropOpacity, animateToOpen, animateToClose, handleCloseFromGesture, isGesturing, closeGestureRef, closeTouchStartX, closeTouchStartY, ], ); // Desktop resize gesture (drag left edge) const resizeGesture = useMemo( () => Gesture.Pan() .enabled(!isMobile) .hitSlop({ left: 8, right: 8, top: 0, bottom: 0 }) .onStart(() => { startWidthRef.current = explorerWidth; resizeWidth.value = explorerWidth; }) .onUpdate((event) => { // Dragging left (negative translationX) increases width 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); }), [isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth], ); const sidebarAnimatedStyle = useAnimatedStyle(() => ({ transform: [{ translateX: translateX.value }], })); const backdropAnimatedStyle = useAnimatedStyle(() => ({ opacity: backdropOpacity.value, pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none", })); const resizeAnimatedStyle = useAnimatedStyle(() => ({ width: resizeWidth.value, })); // Mobile: full-screen overlay with gesture. // On web, keep it interactive only while open so closed sidebars don't eat taps. const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none"; // Navigation stacks can keep previous screens mounted; hide sidebars for unfocused // screens so only the active screen exposes explorer/terminal surfaces. if (!isScreenFocused) { return null; } if (isMobile) { return ( {/* Backdrop */} handleClose("header-close-button")} serverId={serverId} workspaceId={workspaceId} workspaceRoot={workspaceRoot} isGit={isGit} isMobile={isMobile} onOpenFile={onOpenFile} /> ); } // Desktop: fixed width sidebar with resize handle if (!isOpen) { return null; } return ( {/* Resize handle - absolutely positioned over left border */} handleClose("desktop-close-button")} serverId={serverId} workspaceId={workspaceId} workspaceRoot={workspaceRoot} isGit={isGit} isMobile={false} onOpenFile={onOpenFile} /> ); } interface SidebarContentProps { activeTab: ExplorerTab; onTabPress: (tab: ExplorerTab) => void; onClose: () => void; serverId: string; workspaceId?: string | null; workspaceRoot: string; isGit: boolean; isMobile: boolean; onOpenFile?: (filePath: string) => void; } function SidebarContent({ activeTab, onTabPress, onClose, serverId, workspaceId, workspaceRoot, isGit, isMobile, onOpenFile, }: SidebarContentProps) { const { theme } = useUnistyles(); const padding = useWindowControlsPadding("explorerSidebar"); const resolvedTab: ExplorerTab = !isGit && activeTab === "changes" ? "files" : activeTab; return ( {/* Header with tabs and close button */} {isGit && ( onTabPress("changes")} > Changes )} onTabPress("files")} > Files {isMobile && ( )} {/* Content based on active tab */} {resolvedTab === "changes" && ( )} {resolvedTab === "files" && ( )} ); } // 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({ backdrop: { ...RNStyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)", }, mobileSidebar: { position: "absolute" as const, top: 0, right: 0, bottom: 0, overflow: "hidden" as const, }, 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, }, }));