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 } 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, 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 { useHasOwnedWindowChromeObstruction, WindowChromeSafeArea } from "@/utils/desktop-window"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { RetainedPanelActivity } from "@/components/retained-panel"; import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { resolveFocusedChatTarget } from "@/composer/focused-chat-target"; import { createWorkspaceFileAttachment } from "@/attachments/workspace-file"; import { useDraftStore } from "@/stores/draft-store"; 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 visibleExplorerWidth = resolveDesktopExplorerWidth({ requestedWidth: explorerWidth, viewportWidth, }); const startWidthRef = useRef(visibleExplorerWidth); const resizeWidth = useSharedValue(visibleExplorerWidth); useEffect(() => { resizeWidth.value = visibleExplorerWidth; }, [resizeWidth, visibleExplorerWidth]); 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 = visibleExplorerWidth; resizeWidth.value = visibleExplorerWidth; }) .onUpdate((event) => { const newWidth = startWidthRef.current - event.translationX; resizeWidth.value = resolveDesktopExplorerWidth({ requestedWidth: newWidth, viewportWidth, }); }) .onEnd(() => { runOnJS(setExplorerWidth)(resizeWidth.value); }), [resizeWidth, setExplorerWidth, viewportWidth, visibleExplorerWidth], ); 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; isOpen: boolean; onOpenFile?: (filePath: string) => void; } function ExplorerSidebarContent({ activeTab, onTabPress, onClose, serverId, workspaceId, workspaceRoot, isGit, isOpen, onOpenFile, }: SidebarContentProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); const toast = useToast(); const hasRightWindowControls = useHasOwnedWindowChromeObstruction("top-right"); 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], ); return ( {/* Header with tabs and close button */} {isGit && ( )} {isGit && showPrTab && ( )} {!hasRightWindowControls && ( {({ hovered, pressed }) => ( )} )} {/* Content based on active tab */} {resolvedTab === "changes" && ( )} {resolvedTab === "files" && ( )} {resolvedTab === "pr" && ( )} ); } /** * Shared add-to-chat state for the changes/files panes: both expose an "add file * to chat" action that attaches the file to the focused chat's composer. * Available only when a workspace with a focused chat is available. */ function useAddFileToChat({ serverId, workspaceId, }: Pick) { const workspaceKey = workspaceId ? buildWorkspaceTabPersistenceKey({ serverId, workspaceId }) : null; const layout = useWorkspaceLayoutStore((state) => workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined, ); const focusTab = useWorkspaceLayoutStore((state) => state.focusTab); const focusedChat = useMemo( () => resolveFocusedChatTarget({ serverId, layout }), [serverId, layout], ); const addFile = useCallback( (filePath: string) => { if (!focusedChat || !workspaceKey) { return; } void useDraftStore.getState().attachWorkspaceFile({ draftKey: focusedChat.draftKey, attachment: createWorkspaceFileAttachment({ path: filePath }), }); focusTab(workspaceKey, focusedChat.tabId); }, [focusTab, focusedChat, workspaceKey], ); return { addFile, canAddToChat: focusedChat !== null }; } function ChangedFilesPane({ serverId, workspaceId, workspaceRoot, isOpen, onOpenFile, }: Pick< SidebarContentProps, "serverId" | "workspaceId" | "workspaceRoot" | "isOpen" | "onOpenFile" >) { const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId }); return ( ); } function FilesPane({ serverId, workspaceId, workspaceRoot, onOpenFile, }: Pick) { const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId }); return ( ); } 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, }, sidebarContent: { flex: 1, minHeight: 0, overflow: "hidden", }, header: { position: "relative", height: HEADER_INNER_HEIGHT, flexDirection: "row", alignItems: "center", justifyContent: "space-between", 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, }, }));