diff --git a/packages/app/src/app/agent/[serverId]/[agentId].tsx b/packages/app/src/app/agent/[serverId]/[agentId].tsx index 63bf8aac3..7c8ad2532 100644 --- a/packages/app/src/app/agent/[serverId]/[agentId].tsx +++ b/packages/app/src/app/agent/[serverId]/[agentId].tsx @@ -9,6 +9,7 @@ import { LayoutChangeEvent, ScrollView, Platform, + BackHandler, } from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useFocusEffect } from "@react-navigation/native"; @@ -17,8 +18,12 @@ import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller import ReanimatedAnimated, { useAnimatedStyle, useSharedValue, + runOnJS, + interpolate, + Extrapolation, } from "react-native-reanimated"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { MoreVertical, GitBranch, @@ -28,12 +33,19 @@ import { Users, ChevronRight, PlusIcon, + PanelRightOpen, } from "lucide-react-native"; import { MenuHeader } from "@/components/headers/menu-header"; import { BackHeader } from "@/components/headers/back-header"; import { AgentStreamView } from "@/components/agent-stream-view"; import { AgentInputArea } from "@/components/agent-input-area"; import { ImportAgentModal } from "@/components/create-agent-modal"; +import { ExplorerSidebar } from "@/components/explorer-sidebar"; +import { + ExplorerSidebarAnimationProvider, + useExplorerSidebarAnimation, +} from "@/contexts/explorer-sidebar-animation-context"; +import { useExplorerSidebarStore } from "@/stores/explorer-sidebar-store"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import type { ConnectionStatus } from "@/contexts/daemon-connections-context"; import { formatConnectionStatus } from "@/utils/daemons"; @@ -143,10 +155,12 @@ export default function AgentScreen() { } return ( - + + + ); } @@ -170,6 +184,84 @@ function AgentScreenContent({ const menuButtonRef = useRef(null); const [showImportAgentModal, setShowImportAgentModal] = useState(false); + const { isOpen: isExplorerOpen, toggle: toggleExplorer, open: openExplorer, close: closeExplorer } = useExplorerSidebarStore(); + const { + translateX: explorerTranslateX, + backdropOpacity: explorerBackdropOpacity, + windowWidth: explorerWindowWidth, + animateToOpen: animateExplorerToOpen, + animateToClose: animateExplorerToClose, + isGesturing: isExplorerGesturing, + } = useExplorerSidebarAnimation(); + const isMobile = + UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + + // Swipe-left gesture to open explorer sidebar on mobile + const explorerOpenGesture = useMemo( + () => + Gesture.Pan() + .enabled(isMobile && !isExplorerOpen) + // Only activate after 15px horizontal movement to the left (negative) + .activeOffsetX(-15) + // Fail if 10px vertical movement happens first (allow vertical scroll) + .failOffsetY([-10, 10]) + .onStart(() => { + isExplorerGesturing.value = true; + }) + .onUpdate((event) => { + // Right sidebar: start from closed position (+windowWidth) and move towards 0 + // Swiping left means negative translationX + const newTranslateX = Math.max(0, explorerWindowWidth + event.translationX); + explorerTranslateX.value = newTranslateX; + explorerBackdropOpacity.value = interpolate( + newTranslateX, + [explorerWindowWidth, 0], + [0, 1], + Extrapolation.CLAMP + ); + }) + .onEnd((event) => { + isExplorerGesturing.value = false; + // Open if dragged more than 1/3 of window or fast swipe left + const shouldOpen = event.translationX < -explorerWindowWidth / 3 || event.velocityX < -500; + if (shouldOpen) { + animateExplorerToOpen(); + runOnJS(openExplorer)(); + } else { + animateExplorerToClose(); + } + }) + .onFinalize(() => { + isExplorerGesturing.value = false; + }), + [ + isMobile, + isExplorerOpen, + explorerWindowWidth, + explorerTranslateX, + explorerBackdropOpacity, + animateExplorerToOpen, + animateExplorerToClose, + openExplorer, + isExplorerGesturing, + ] + ); + + // Handle hardware back button - close explorer sidebar first, then navigate back + useEffect(() => { + if (Platform.OS === "web") return; + + const handler = BackHandler.addEventListener("hardwareBackPress", () => { + if (isExplorerOpen) { + closeExplorer(); + return true; // Prevent default back navigation + } + return false; // Let default back navigation happen + }); + + return () => handler.remove(); + }, [isExplorerOpen, closeExplorer]); + const resolvedAgentId = agentId; // Select only the specific agent @@ -573,50 +665,62 @@ function AgentScreenContent({ ); } - return ( - <> + const mainContent = ( + {/* Header */} - - + + + + + + + + } /> - {/* Content Area with Keyboard Animation */} - - - {isInitializing ? ( - - + + {isInitializing ? ( + + + Loading agent... + + ) : ( + - Loading agent... - - ) : ( - - )} - - + )} + + - {/* Agent Input Area */} - {!isInitializing && agent && resolvedAgentId && ( - - )} + {/* Agent Input Area */} + {!isInitializing && agent && resolvedAgentId && ( + + )} {/* Dropdown Menu */} + + + {/* Explorer Sidebar - Desktop: inline, Mobile: overlay */} + {!isMobile && isExplorerOpen && resolvedAgentId && ( + + )} + ); + + return ( + <> + {isMobile ? ( + + {mainContent} + + ) : ( + mainContent + )} + + {/* Mobile Explorer Sidebar Overlay */} + {isMobile && resolvedAgentId && ( + + )} + {importAgentModal} ); @@ -853,10 +980,19 @@ function AgentSessionUnavailableState({ } const styles = StyleSheet.create((theme) => ({ + outerContainer: { + flex: 1, + flexDirection: "row", + backgroundColor: theme.colors.background, + }, container: { flex: 1, backgroundColor: theme.colors.background, }, + headerRightContent: { + flexDirection: "row", + alignItems: "center", + }, contentContainer: { flex: 1, overflow: "hidden", diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx new file mode 100644 index 000000000..f5db7eab6 --- /dev/null +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -0,0 +1,442 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { View, Text, Pressable, Platform } 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, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { X, GitBranch, Folder, LayoutGrid, List as ListIcon } from "lucide-react-native"; +import { + useExplorerSidebarStore, + MIN_EXPLORER_SIDEBAR_WIDTH, + MAX_EXPLORER_SIDEBAR_WIDTH, +} from "@/stores/explorer-sidebar-store"; +import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; +import { GitDiffPane } from "./git-diff-pane"; +import { FileExplorerPane } from "./file-explorer-pane"; + +type ViewMode = "list" | "grid"; + +type ExplorerTab = "changes" | "files"; + +interface ExplorerSidebarProps { + serverId: string; + agentId: string; +} + +export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) { + const { theme } = useUnistyles(); + const insets = useSafeAreaInsets(); + const { isOpen, activeTab, width, close, setActiveTab, setWidth } = + useExplorerSidebarStore(); + const { + translateX, + backdropOpacity, + windowWidth, + animateToOpen, + animateToClose, + isGesturing, + closeGestureRef, + } = useExplorerSidebarAnimation(); + + const isMobile = + UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + + // File explorer view mode state + const [fileViewMode, setFileViewMode] = useState("list"); + + // For resize drag, track the starting width + const startWidthRef = useRef(width); + const resizeWidth = useSharedValue(width); + + const handleClose = useCallback(() => { + close(); + }, [close]); + + const handleTabPress = useCallback( + (tab: ExplorerTab) => { + setActiveTab(tab); + }, + [setActiveTab] + ); + + // Swipe gesture to close (swipe right on mobile) + const closeGesture = useMemo( + () => + Gesture.Pan() + .withRef(closeGestureRef) + .enabled(isMobile && isOpen) + // Only activate after 15px horizontal movement (creates deadzone for taps) + .activeOffsetX([-15, 15]) + .failOffsetY([-10, 10]) + .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; + if (shouldClose) { + animateToClose(); + runOnJS(handleClose)(); + } else { + animateToOpen(); + } + }) + .onFinalize(() => { + isGesturing.value = false; + }), + [ + isMobile, + isOpen, + windowWidth, + translateX, + backdropOpacity, + animateToOpen, + animateToClose, + handleClose, + isGesturing, + closeGestureRef, + ] + ); + + // 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 = width; + resizeWidth.value = width; + }) + .onUpdate((event) => { + // Dragging left (negative translationX) increases width + const newWidth = startWidthRef.current - event.translationX; + const clampedWidth = Math.max( + MIN_EXPLORER_SIDEBAR_WIDTH, + Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, newWidth) + ); + resizeWidth.value = clampedWidth; + }) + .onEnd(() => { + runOnJS(setWidth)(resizeWidth.value); + }), + [isMobile, width, resizeWidth, setWidth] + ); + + 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 + const overlayPointerEvents = Platform.OS === "web" ? "auto" : "box-none"; + + if (isMobile) { + return ( + + {/* Backdrop */} + + + + + + + + + + + ); + } + + // Desktop: fixed width sidebar with resize handle + if (!isOpen) { + return null; + } + + return ( + + {/* Resize handle on left edge */} + + + + + + + + + ); +} + +interface SidebarContentProps { + activeTab: ExplorerTab; + onTabPress: (tab: ExplorerTab) => void; + onClose: () => void; + serverId: string; + agentId: string; + fileViewMode: ViewMode; + onFileViewModeChange: (mode: ViewMode) => void; + isMobile: boolean; +} + +function SidebarContent({ + activeTab, + onTabPress, + onClose, + serverId, + agentId, + fileViewMode, + onFileViewModeChange, + isMobile, +}: SidebarContentProps) { + const { theme } = useUnistyles(); + + return ( + + {/* Header with tabs and close button */} + + + onTabPress("changes")} + > + + + Changes + + + onTabPress("files")} + > + + + Files + + + + + {activeTab === "files" && ( + + )} + {isMobile && ( + + + + )} + + + + {/* Content based on active tab */} + + {activeTab === "changes" ? ( + + ) : ( + + )} + + + ); +} + +function ViewToggle({ + viewMode, + onChange, +}: { + viewMode: ViewMode; + onChange: (mode: ViewMode) => void; +}) { + const { theme } = useUnistyles(); + + return ( + + onChange("list")} + > + + + onChange("grid")} + > + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "rgba(0, 0, 0, 0.5)", + }, + backdropPressable: { + flex: 1, + }, + mobileSidebar: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + backgroundColor: theme.colors.background, + overflow: "hidden", + }, + desktopSidebar: { + flexDirection: "row", + borderLeftWidth: 1, + borderLeftColor: theme.colors.border, + backgroundColor: theme.colors.background, + }, + resizeHandle: { + width: 8, + alignItems: "center", + justifyContent: "center", + }, + resizeHandleInner: { + width: 2, + height: 32, + backgroundColor: theme.colors.border, + borderRadius: 1, + opacity: 0.5, + }, + sidebarContent: { + flex: 1, + minHeight: 0, + overflow: "hidden", + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: theme.spacing[3], + paddingVertical: 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.muted, + }, + tabText: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + color: theme.colors.mutedForeground, + }, + tabTextActive: { + color: theme.colors.foreground, + }, + headerRightSection: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + closeButton: { + padding: theme.spacing[2], + borderRadius: theme.borderRadius.md, + }, + contentArea: { + flex: 1, + minHeight: 0, + }, + viewToggleContainer: { + flexDirection: "row", + borderRadius: theme.borderRadius.md, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + overflow: "hidden", + }, + viewToggleButton: { + padding: theme.spacing[2], + }, + viewToggleActive: { + backgroundColor: theme.colors.muted, + }, +})); diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx new file mode 100644 index 000000000..e7429aa3e --- /dev/null +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -0,0 +1,1516 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ActivityIndicator, + FlatList, + Image as RNImage, + LayoutChangeEvent, + ListRenderItemInfo, + RefreshControl, + ViewToken, + NativeScrollEvent, + NativeSyntheticEvent, + Modal, + Platform, + Pressable, + ScrollView, + Text, + View, + useWindowDimensions, +} from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import * as Clipboard from "expo-clipboard"; +import { File as FSFile, Paths } from "expo-file-system"; +import * as Sharing from "expo-sharing"; +import { + BottomSheetModal, + BottomSheetScrollView, + BottomSheetBackdrop, + BottomSheetView, +} from "@gorhom/bottom-sheet"; +import { + Check, + ChevronDown, + Download, + File, + FileText, + Folder, + Image as ImageIcon, + MoreVertical, + X, + XCircle, +} from "lucide-react-native"; +import type { ExplorerEntry } from "@/stores/session-store"; +import { useDaemonConnections } from "@/contexts/daemon-connections-context"; +import type { DaemonProfile } from "@/contexts/daemon-registry-context"; +import { useSessionStore } from "@/stores/session-store"; +import { + useExplorerSidebarStore, + type SortOption, +} from "@/stores/explorer-sidebar-store"; + +const MAX_CONCURRENT_THUMBNAILS = 2; +const THUMBNAIL_TIMEOUT_MS = 15000; + +const SORT_OPTIONS: { value: SortOption; label: string }[] = [ + { value: "name", label: "Name" }, + { value: "modified", label: "Modified" }, + { value: "size", label: "Size" }, +]; + +interface FileExplorerPaneProps { + serverId: string; + agentId: string; +} + +export function FileExplorerPane({ + serverId, + agentId, +}: FileExplorerPaneProps) { + const { theme } = useUnistyles(); + const { connectionStates } = useDaemonConnections(); + const daemonProfile = connectionStates.get(serverId)?.daemon; + + const agent = useSessionStore((state) => + agentId && state.sessions[serverId] + ? state.sessions[serverId]?.agents.get(agentId) + : undefined + ); + + const explorerState = useSessionStore((state) => + agentId && state.sessions[serverId] + ? state.sessions[serverId]?.fileExplorer.get(agentId) + : undefined + ); + + const methods = useSessionStore((state) => state.sessions[serverId]?.methods); + const requestDirectoryListing = methods?.requestDirectoryListing; + const requestFilePreview = methods?.requestFilePreview; + const requestFileDownloadToken = methods?.requestFileDownloadToken; + const navigateExplorerBack = methods?.navigateExplorerBack; + const { viewMode, sortOption, setSortOption } = useExplorerSidebarStore(); + const [selectedEntryPath, setSelectedEntryPath] = useState(null); + const listScrollRef = useRef | null>(null); + const listScrollOffsetRef = useRef(0); + const scrollOffsetsByPathRef = useRef>(new Map()); + const pendingScrollRestoreRef = useRef(null); + const { width: windowWidth, height: windowHeight } = useWindowDimensions(); + + const history = explorerState?.history ?? []; + const lastKnownDirectory = history[history.length - 1]; + const rememberedDirectory = explorerState?.lastVisitedPath; + const initialTargetDirectory = rememberedDirectory ?? lastKnownDirectory ?? "."; + const currentPath = explorerState?.currentPath ?? "."; + const pendingRequest = explorerState?.pendingRequest ?? null; + const isExplorerLoading = explorerState?.isLoading ?? false; + const isListingLoading = Boolean( + isExplorerLoading && pendingRequest?.mode === "list" + ); + const pendingDirectoryPath = + isListingLoading && pendingRequest ? pendingRequest.path : null; + const activePath = pendingDirectoryPath ?? currentPath; + const directory = explorerState?.directories.get(activePath); + const rawEntries = directory?.entries ?? []; + const entries = useMemo(() => { + const sorted = [...rawEntries]; + sorted.sort((a, b) => { + // Directories always come first + if (a.kind !== b.kind) { + return a.kind === "directory" ? -1 : 1; + } + switch (sortOption) { + case "name": + return a.name.localeCompare(b.name); + case "modified": + return new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime(); + case "size": + return b.size - a.size; + default: + return 0; + } + }); + return sorted; + }, [rawEntries, sortOption]); + const showInitialListLoading = isListingLoading && entries.length === 0; + const showListLoadingBanner = isListingLoading && entries.length > 0; + const isPreviewLoading = Boolean( + isExplorerLoading && + pendingRequest?.mode === "file" && + pendingRequest?.path === selectedEntryPath + ); + const error = explorerState?.lastError ?? null; + const preview = selectedEntryPath + ? explorerState?.files.get(selectedEntryPath) + : null; + const shouldShowPreview = Boolean(selectedEntryPath); + const [thumbnailLoadingMap, setThumbnailLoadingMap] = useState>({}); + const viewabilityConfigRef = useRef({ + itemVisiblePercentThreshold: 10, + minimumViewTime: 0, + }); + + // Bottom sheet for file preview + const previewSheetRef = useRef(null); + const previewSnapPoints = useMemo(() => ["70%", "95%"], []); + + // Thumbnail queue state + const thumbnailQueueRef = useRef([]); + const inFlightPathsRef = useRef>(new Set()); + + // Responsive gallery columns based on container width + const [containerWidth, setContainerWidth] = useState(0); + const gridColumnCount = containerWidth > 0 && containerWidth >= 400 ? 4 : 3; + const listColumns = viewMode === "grid" ? gridColumnCount : 1; + const listKey = viewMode === "grid" ? `grid-${gridColumnCount}` : "list"; + const [menuEntry, setMenuEntry] = useState(null); + const [menuAnchor, setMenuAnchor] = useState({ top: 0, left: 0 }); + const [menuHeight, setMenuHeight] = useState(0); + const [isRefreshing, setIsRefreshing] = useState(false); + const [downloadToast, setDownloadToast] = useState<{ + status: "downloading" | "complete" | "error"; + fileName: string; + message?: string; + } | null>(null); + const downloadToastTimeoutRef = useRef | null>(null); + const agentIdRef = useRef(agentId); + const viewModeRef = useRef(viewMode); + const requestFilePreviewRef = useRef(requestFilePreview); + const explorerFilesRef = useRef(explorerState?.files); + const refreshPathRef = useRef(null); + const refreshStartedRef = useRef(false); + const hasInitializedRef = useRef(false); + + useEffect(() => { + agentIdRef.current = agentId; + }, [agentId]); + + useEffect(() => { + viewModeRef.current = viewMode; + }, [viewMode]); + + useEffect(() => { + requestFilePreviewRef.current = requestFilePreview; + }, [requestFilePreview]); + + useEffect(() => { + explorerFilesRef.current = explorerState?.files; + }, [explorerState?.files]); + + // Process items from the thumbnail queue + const processNextThumbnail = useCallback(() => { + const currentAgentId = agentIdRef.current; + const currentRequestFilePreview = requestFilePreviewRef.current; + + if (!currentAgentId || !currentRequestFilePreview) { + return; + } + + while ( + inFlightPathsRef.current.size < MAX_CONCURRENT_THUMBNAILS && + thumbnailQueueRef.current.length > 0 + ) { + const path = thumbnailQueueRef.current.shift()!; + + if (explorerFilesRef.current?.has(path) || inFlightPathsRef.current.has(path)) { + continue; + } + + inFlightPathsRef.current.add(path); + setThumbnailLoadingMap((prev) => ({ ...prev, [path]: true })); + currentRequestFilePreview(currentAgentId, path); + + setTimeout(() => { + if (inFlightPathsRef.current.has(path)) { + inFlightPathsRef.current.delete(path); + setThumbnailLoadingMap((prev) => { + const next = { ...prev }; + delete next[path]; + return next; + }); + processNextThumbnail(); + } + }, THUMBNAIL_TIMEOUT_MS); + } + }, []); + + // Enqueue a file preview request + const enqueueFilePreview = useCallback( + (path: string, options?: { priority?: boolean }) => { + const currentAgentId = agentIdRef.current; + const currentRequestFilePreview = requestFilePreviewRef.current; + + if (!currentAgentId || !currentRequestFilePreview) { + return; + } + + if (explorerFilesRef.current?.has(path)) { + return; + } + + if (options?.priority) { + thumbnailQueueRef.current = []; + + if (inFlightPathsRef.current.has(path)) { + return; + } + + if (inFlightPathsRef.current.size > 0) { + const abandonedPaths = Array.from(inFlightPathsRef.current); + setThumbnailLoadingMap((prev) => { + const next = { ...prev }; + abandonedPaths.forEach((p) => delete next[p]); + return next; + }); + inFlightPathsRef.current.clear(); + } + + inFlightPathsRef.current.add(path); + setThumbnailLoadingMap((prev) => ({ ...prev, [path]: true })); + currentRequestFilePreview(currentAgentId, path); + + setTimeout(() => { + if (inFlightPathsRef.current.has(path)) { + inFlightPathsRef.current.delete(path); + setThumbnailLoadingMap((prev) => { + const next = { ...prev }; + delete next[path]; + return next; + }); + processNextThumbnail(); + } + }, THUMBNAIL_TIMEOUT_MS); + + return; + } + + if ( + !thumbnailQueueRef.current.includes(path) && + !inFlightPathsRef.current.has(path) + ) { + thumbnailQueueRef.current.push(path); + processNextThumbnail(); + } + }, + [processNextThumbnail] + ); + + const handleViewableItemsChangedRef = useRef( + ({ viewableItems }: { viewableItems: Array }) => { + const currentViewMode = viewModeRef.current; + + if (currentViewMode !== "grid") { + return; + } + + viewableItems.forEach((token) => { + const item = token.item as ExplorerEntry | undefined; + if (!item || getEntryDisplayKind(item) !== "image") { + return; + } + enqueueFilePreviewRef.current?.(item.path); + }); + } + ); + + const enqueueFilePreviewRef = useRef(enqueueFilePreview); + useEffect(() => { + enqueueFilePreviewRef.current = enqueueFilePreview; + }, [enqueueFilePreview]); + + const restoreQueuedScrollOffset = useCallback(() => { + if (pendingScrollRestoreRef.current === null) { + return; + } + + if (!listScrollRef.current) { + return; + } + + const targetOffset = pendingScrollRestoreRef.current; + listScrollRef.current.scrollToOffset({ offset: targetOffset, animated: false }); + listScrollOffsetRef.current = targetOffset; + pendingScrollRestoreRef.current = null; + }, []); + + const queueScrollRestore = useCallback((offset: number) => { + pendingScrollRestoreRef.current = offset; + requestAnimationFrame(restoreQueuedScrollOffset); + }, [restoreQueuedScrollOffset]); + + useEffect(() => { + setSelectedEntryPath(null); + }, [activePath]); + + // Open/close preview sheet based on selection + useEffect(() => { + if (selectedEntryPath) { + previewSheetRef.current?.present(); + } else { + previewSheetRef.current?.dismiss(); + } + }, [selectedEntryPath]); + + useEffect(() => { + if (shouldShowPreview) { + return; + } + + const savedOffset = scrollOffsetsByPathRef.current.get(activePath) ?? listScrollOffsetRef.current; + queueScrollRestore(savedOffset); + }, [activePath, queueScrollRestore, shouldShowPreview]); + + useEffect(() => { + const savedOffset = scrollOffsetsByPathRef.current.get(activePath) ?? 0; + listScrollOffsetRef.current = savedOffset; + queueScrollRestore(savedOffset); + }, [activePath, queueScrollRestore]); + + // Initial directory listing request + useEffect(() => { + if (!agentId || !requestDirectoryListing) { + return; + } + + if (hasInitializedRef.current) { + return; + } + hasInitializedRef.current = true; + + requestDirectoryListing(agentId, initialTargetDirectory); + }, [agentId, initialTargetDirectory, requestDirectoryListing]); + + const handleEntryPress = useCallback( + (entry: ExplorerEntry) => { + if (!agentId || !requestDirectoryListing) { + return; + } + + if (entry.kind === "directory") { + setSelectedEntryPath(null); + requestDirectoryListing(agentId, entry.path); + return; + } + + setSelectedEntryPath(entry.path); + enqueueFilePreview(entry.path, { priority: true }); + }, + [agentId, requestDirectoryListing, enqueueFilePreview] + ); + + const handleCopyPath = useCallback(async (path: string) => { + await Clipboard.setStringAsync(path); + }, []); + + const handleOpenMenu = useCallback((entry: ExplorerEntry, event: any) => { + event.stopPropagation(); + const { pageX, pageY } = event.nativeEvent ?? {}; + setMenuAnchor({ + left: typeof pageX === "number" ? pageX : 0, + top: typeof pageY === "number" ? pageY : 0, + }); + setMenuEntry(entry); + }, []); + + const handleCloseMenu = useCallback(() => { + setMenuEntry(null); + setMenuHeight(0); + }, []); + + const handleMenuLayout = useCallback((event: LayoutChangeEvent) => { + const { height } = event.nativeEvent.layout; + setMenuHeight((current) => (current === height ? current : height)); + }, []); + + const showDownloadToast = useCallback( + (toast: { status: "downloading" | "complete" | "error"; fileName: string; message?: string }) => { + if (downloadToastTimeoutRef.current) { + clearTimeout(downloadToastTimeoutRef.current); + downloadToastTimeoutRef.current = null; + } + setDownloadToast(toast); + if (toast.status !== "downloading") { + downloadToastTimeoutRef.current = setTimeout(() => { + setDownloadToast(null); + }, 3000); + } + }, + [] + ); + + const handleDownloadEntry = useCallback( + async (entry: ExplorerEntry) => { + if (!agentId || !requestFileDownloadToken || entry.kind !== "file") { + return; + } + + const displayName = entry.name; + + try { + const tokenResponse = await requestFileDownloadToken(agentId, entry.path); + if (tokenResponse.error || !tokenResponse.token) { + throw new Error(tokenResponse.error ?? "Failed to request download token."); + } + + const downloadTarget = resolveDaemonDownloadTarget(daemonProfile); + if (!downloadTarget.baseUrl) { + throw new Error("Download host is unavailable."); + } + + const fileName = tokenResponse.fileName ?? entry.name; + const downloadUrl = buildDownloadUrl( + downloadTarget.baseUrl, + tokenResponse.token, + Platform.OS === "web" ? downloadTarget.authCredentials : null + ); + + if (Platform.OS === "web") { + triggerBrowserDownload(downloadUrl, fileName); + return; + } + + showDownloadToast({ status: "downloading", fileName: displayName }); + + const targetFile = resolveDownloadTargetFile(fileName); + const downloadedFile = await FSFile.downloadFileAsync( + downloadUrl, + targetFile, + downloadTarget.authHeader + ? { headers: { Authorization: downloadTarget.authHeader } } + : undefined + ); + + showDownloadToast({ status: "complete", fileName: displayName }); + + if (await Sharing.isAvailableAsync()) { + await Sharing.shareAsync(downloadedFile.uri, { + mimeType: tokenResponse.mimeType ?? undefined, + dialogTitle: fileName ? `Share ${fileName}` : "Share file", + }); + } + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to download file."; + if (Platform.OS === "web") { + console.warn("[FileExplorer] Download failed:", message); + return; + } + showDownloadToast({ status: "error", fileName: displayName, message }); + } + }, + [agentId, daemonProfile, requestFileDownloadToken, showDownloadToast] + ); + + const menuPosition = useMemo(() => { + if (!menuEntry) { + return null; + } + const menuWidth = 180; + const horizontalPadding = theme.spacing[2]; + const verticalPadding = theme.spacing[2]; + const maxLeft = Math.max(horizontalPadding, windowWidth - menuWidth - horizontalPadding); + const maxTop = Math.max(verticalPadding, windowHeight - menuHeight - verticalPadding); + const left = Math.min( + Math.max(menuAnchor.left - menuWidth + horizontalPadding, horizontalPadding), + maxLeft + ); + const top = Math.min(Math.max(menuAnchor.top + verticalPadding, verticalPadding), maxTop); + return { top, left, width: menuWidth }; + }, [menuEntry, menuAnchor.left, menuAnchor.top, menuHeight, theme.spacing, windowHeight, windowWidth]); + + const handleListScroll = useCallback( + (event: NativeSyntheticEvent) => { + const offset = event.nativeEvent.contentOffset.y; + listScrollOffsetRef.current = offset; + scrollOffsetsByPathRef.current.set(activePath, offset); + }, + [activePath] + ); + + const handleContainerLayout = useCallback((event: LayoutChangeEvent) => { + const { width } = event.nativeEvent.layout; + setContainerWidth(width); + }, []); + + const handleClosePreviewSheet = useCallback(() => { + setSelectedEntryPath(null); + }, []); + + const handlePreviewSheetChange = useCallback((index: number) => { + if (index === -1) { + setSelectedEntryPath(null); + } + }, []); + + const renderPreviewBackdrop = useCallback( + (props: React.ComponentProps) => ( + + ), + [] + ); + + const handleRetryDirectory = useCallback(() => { + if (!agentId || !requestDirectoryListing) { + return; + } + requestDirectoryListing(agentId, activePath); + }, [agentId, requestDirectoryListing, activePath]); + + const handleRefresh = useCallback(() => { + if (!agentId || !requestDirectoryListing) { + return; + } + refreshPathRef.current = activePath; + refreshStartedRef.current = false; + setIsRefreshing(true); + requestDirectoryListing(agentId, activePath, { recordHistory: false }); + }, [agentId, requestDirectoryListing, activePath]); + + useEffect(() => { + if (!isRefreshing) { + return; + } + + const refreshPath = refreshPathRef.current; + if (!refreshPath) { + return; + } + + const isMatchingList = + pendingRequest?.mode === "list" && pendingRequest?.path === refreshPath; + + if (isMatchingList) { + refreshStartedRef.current = true; + return; + } + + if (refreshStartedRef.current) { + setIsRefreshing(false); + refreshPathRef.current = null; + refreshStartedRef.current = false; + } + }, [isRefreshing, pendingRequest?.mode, pendingRequest?.path]); + + const handleNavigateBack = useCallback(() => { + if (!agentId || !navigateExplorerBack) { + return; + } + + if ((explorerState?.history?.length ?? 0) > 1) { + navigateExplorerBack(agentId); + } + }, [agentId, explorerState?.history?.length, navigateExplorerBack]); + + const renderEntry = useCallback( + ({ item }: ListRenderItemInfo) => { + if (viewMode === "grid") { + const preview = explorerState?.files.get(item.path); + const isImage = getEntryDisplayKind(item) === "image"; + const isLoadingThumb = Boolean(thumbnailLoadingMap[item.path]); + return ( + handleEntryPress(item)} + > + + {isImage && preview?.content ? ( + + ) : isImage && isLoadingThumb ? ( + + ) : ( + renderEntryIcon(getEntryDisplayKind(item), theme.colors) + )} + + + {item.name} + + + {formatFileSize({ size: item.size })} + + + ); + } + + const displayKind = getEntryDisplayKind(item); + return ( + handleEntryPress(item)} + > + + + {renderEntryIcon(displayKind, theme.colors)} + + + + {item.name} + + + {item.kind.toUpperCase()} · {formatFileSize({ size: item.size })} ·{" "} + {formatModifiedTime({ value: item.modifiedAt })} + + + + handleOpenMenu(item, event)} + hitSlop={8} + style={styles.menuButton} + > + + + + ); + }, + [ + explorerState?.files, + handleEntryPress, + handleOpenMenu, + theme.colors, + thumbnailLoadingMap, + viewMode, + ] + ); + + const handleSortCycle = useCallback(() => { + const currentIndex = SORT_OPTIONS.findIndex((opt) => opt.value === sortOption); + const nextIndex = (currentIndex + 1) % SORT_OPTIONS.length; + setSortOption(SORT_OPTIONS[nextIndex].value); + }, [sortOption, setSortOption]); + + const currentSortLabel = SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name"; + + const listHeaderComponent = useMemo(() => { + const canGoBack = (explorerState?.history?.length ?? 0) > 1; + return ( + + + + {canGoBack && ( + + + + )} + + {formatDirectoryLabel(activePath)} + + + + {currentSortLabel} + + + + {showListLoadingBanner && ( + + + + Loading {formatDirectoryLabel(activePath)}... + + + )} + + ); + }, [activePath, currentSortLabel, explorerState?.history?.length, handleNavigateBack, handleSortCycle, showListLoadingBanner, theme.colors.mutedForeground]); + + // Watch for completed file previews and process queue + useEffect(() => { + if (!explorerState) { + return; + } + + const completedPaths: string[] = []; + for (const path of inFlightPathsRef.current) { + if (explorerState.files.has(path)) { + completedPaths.push(path); + } + } + + if (completedPaths.length === 0) { + return; + } + + for (const path of completedPaths) { + inFlightPathsRef.current.delete(path); + } + + setThumbnailLoadingMap((prev) => { + const next = { ...prev }; + for (const path of completedPaths) { + delete next[path]; + } + return next; + }); + + queueMicrotask(() => { + processNextThumbnail(); + }); + }, [explorerState?.files.size, processNextThumbnail]); + + // Clear queue and loading state on path/view change + useEffect(() => { + thumbnailQueueRef.current = []; + inFlightPathsRef.current.clear(); + setThumbnailLoadingMap({}); + }, [activePath, viewMode]); + + if (!agent) { + return ( + + Agent not found + + ); + } + + return ( + + + + {error ? ( + + {error} + + Retry + + + ) : showInitialListLoading ? ( + + + Loading directory... + + ) : entries.length === 0 ? ( + + Directory is empty + + ) : ( + item.path} + contentContainerStyle={ + viewMode === "grid" ? styles.gridContent : styles.entriesContent + } + columnWrapperStyle={ + viewMode === "grid" && listColumns > 1 + ? styles.gridColumnWrapper + : undefined + } + numColumns={listColumns} + key={listKey} + onScroll={handleListScroll} + scrollEventThrottle={16} + onLayout={restoreQueuedScrollOffset} + onContentSizeChange={restoreQueuedScrollOffset} + ListHeaderComponent={listHeaderComponent} + extraData={{ viewMode, thumbnailLoadingMap }} + initialNumToRender={20} + maxToRenderPerBatch={30} + windowSize={15} + refreshControl={ + + } + onViewableItemsChanged={handleViewableItemsChangedRef.current} + viewabilityConfig={viewabilityConfigRef.current} + /> + )} + + + + + + + {menuEntry && menuPosition ? ( + + { + handleCopyPath(menuEntry.path); + handleCloseMenu(); + }} + > + Copy Path + + {menuEntry.kind === "file" ? ( + { + handleCloseMenu(); + await handleDownloadEntry(menuEntry); + }} + > + Download + + ) : null} + + ) : null} + + + + + + + {selectedEntryPath?.split("/").pop() ?? "Preview"} + + + + + + {isPreviewLoading && !preview ? ( + + + Loading file... + + ) : !preview ? ( + + No preview available yet + + ) : preview.kind === "text" ? ( + + + {preview.content} + + + ) : preview.kind === "image" && preview.content ? ( + + + + ) : ( + + Binary preview unavailable + + {formatFileSize({ size: preview.size })} + + + )} + + + {downloadToast && ( + + + {downloadToast.status === "downloading" ? ( + + ) : downloadToast.status === "complete" ? ( + + ) : ( + + )} + + + {downloadToast.fileName} + + + {downloadToast.status === "downloading" + ? "Downloading..." + : downloadToast.status === "complete" + ? "Download complete" + : downloadToast.message ?? "Download failed"} + + + {downloadToast.status !== "downloading" && ( + setDownloadToast(null)} + hitSlop={8} + style={styles.downloadToastDismiss} + > + + + )} + + + )} + + ); +} + +function formatDirectoryLabel(path: string): string { + return path === "." ? "workspace root" : path; +} + +function formatFileSize({ size }: { size: number }): string { + if (size < 1024) { + return `${size} B`; + } + if (size < 1024 * 1024) { + return `${(size / 1024).toFixed(1)} KB`; + } + return `${(size / (1024 * 1024)).toFixed(1)} MB`; +} + +function formatModifiedTime({ value }: { value: string }): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return date.toLocaleString(); +} + +type EntryDisplayKind = "directory" | "image" | "text" | "other"; + +const IMAGE_EXTENSIONS = new Set([ + "png", + "jpg", + "jpeg", + "gif", + "bmp", + "svg", + "webp", + "ico", +]); + +const TEXT_EXTENSIONS = new Set([ + "txt", + "md", + "markdown", + "ts", + "tsx", + "js", + "jsx", + "json", + "yml", + "yaml", + "toml", + "py", + "rb", + "go", + "rs", + "java", + "kt", + "c", + "cpp", + "cc", + "h", + "hpp", + "cs", + "swift", + "php", + "html", + "css", + "scss", + "less", + "xml", + "sh", + "bash", + "zsh", + "ini", + "cfg", + "conf", +]); + +function renderEntryIcon( + kind: EntryDisplayKind, + colors: { foreground: string; primary: string } +) { + const color = colors.foreground; + switch (kind) { + case "directory": + return ; + case "image": + return ; + case "text": + return ; + default: + return ; + } +} + +function getEntryDisplayKind(entry: ExplorerEntry): EntryDisplayKind { + if (entry.kind === "directory") { + return "directory"; + } + + const extension = getExtension(entry.name); + if (extension === null) { + return "other"; + } + + if (IMAGE_EXTENSIONS.has(extension)) { + return "image"; + } + + if (TEXT_EXTENSIONS.has(extension)) { + return "text"; + } + + return "other"; +} + +function getExtension(name: string): string | null { + const index = name.lastIndexOf("."); + if (index === -1 || index === name.length - 1) { + return null; + } + return name.slice(index + 1).toLowerCase(); +} + +type DownloadTarget = { + baseUrl: string | null; + authHeader: string | null; + authCredentials: { username: string; password: string } | null; +}; + +function resolveDaemonDownloadTarget(daemon?: DaemonProfile): DownloadTarget { + const rawUrl = daemon?.restUrl ?? daemon?.wsUrl; + if (!rawUrl) { + return { baseUrl: null, authHeader: null, authCredentials: null }; + } + + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + return { baseUrl: null, authHeader: null, authCredentials: null }; + } + + if (parsed.protocol === "ws:") { + parsed.protocol = "http:"; + } else if (parsed.protocol === "wss:") { + parsed.protocol = "https:"; + } + + let authCredentials: { username: string; password: string } | null = null; + if (parsed.username || parsed.password) { + authCredentials = { + username: decodeURIComponent(parsed.username), + password: decodeURIComponent(parsed.password), + }; + parsed.username = ""; + parsed.password = ""; + } + + parsed.pathname = parsed.pathname.replace(/\/ws\/?$/, "/"); + + const baseUrl = parsed.origin; + const authHeader = authCredentials + ? `Basic ${btoa(`${authCredentials.username}:${authCredentials.password}`)}` + : null; + + return { baseUrl, authHeader, authCredentials }; +} + +function buildDownloadUrl( + baseUrl: string, + token: string, + authCredentials: { username: string; password: string } | null +): string { + const url = new URL("/api/files/download", baseUrl); + url.searchParams.set("token", token); + if (authCredentials) { + url.username = authCredentials.username; + url.password = authCredentials.password; + } + return url.toString(); +} + +function triggerBrowserDownload(url: string, fileName: string) { + if (typeof document === "undefined") { + if (typeof window !== "undefined") { + window.open(url, "_blank", "noopener"); + } + return; + } + + const link = document.createElement("a"); + link.href = url; + link.download = fileName; + link.rel = "noopener"; + document.body.appendChild(link); + link.click(); + link.remove(); +} + +function resolveDownloadTargetFile(fileName: string): FSFile { + const directory = Paths.cache ?? Paths.document; + if (!directory) { + throw new Error("No download directory available."); + } + + const safeName = sanitizeDownloadFileName(fileName); + const split = splitFileName(safeName); + let targetFile = new FSFile(directory, safeName); + let suffix = 1; + + while (targetFile.exists) { + targetFile = new FSFile(directory, `${split.base} (${suffix})${split.ext}`); + suffix += 1; + } + + return targetFile; +} + +function sanitizeDownloadFileName(fileName: string): string { + const trimmed = fileName.trim(); + if (!trimmed) { + return "download"; + } + return trimmed.replace(/[\\/:*?"<>|]+/g, "_"); +} + +function splitFileName(fileName: string): { base: string; ext: string } { + const lastDot = fileName.lastIndexOf("."); + if (lastDot <= 0) { + return { base: fileName, ext: "" }; + } + return { + base: fileName.slice(0, lastDot), + ext: fileName.slice(lastDot), + }; +} + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.background, + }, + content: { + flex: 1, + flexDirection: "column", + paddingHorizontal: theme.spacing[3], + paddingBottom: theme.spacing[3], + gap: theme.spacing[3], + }, + listSection: { + flex: 1, + }, + entriesContent: { + paddingBottom: theme.spacing[4], + }, + headerContainer: { + gap: theme.spacing[2], + paddingBottom: theme.spacing[2], + }, + headerRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + pathContainer: { + flex: 1, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + marginRight: theme.spacing[2], + }, + pathText: { + flex: 1, + fontSize: theme.fontSize.sm, + color: theme.colors.mutedForeground, + fontFamily: "monospace", + }, + backButton: { + padding: theme.spacing[1], + }, + backButtonText: { + fontSize: theme.fontSize.lg, + color: theme.colors.foreground, + }, + loadingBanner: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + }, + loadingBannerText: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.sm, + }, + sortButton: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + paddingVertical: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius.md, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + backgroundColor: theme.colors.card, + }, + sortButtonText: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.xs, + }, + centerState: { + flex: 1, + alignItems: "center", + justifyContent: "center", + gap: theme.spacing[2], + padding: theme.spacing[4], + }, + loadingText: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.sm, + }, + errorText: { + color: theme.colors.destructive, + fontSize: theme.fontSize.base, + textAlign: "center", + }, + retryButton: { + borderRadius: theme.borderRadius.full, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.primary, + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[1], + }, + retryButtonText: { + color: theme.colors.primary, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + }, + emptyText: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.base, + textAlign: "center", + }, + entryRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.md, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + marginBottom: theme.spacing[1], + }, + entryRowBackground: { + backgroundColor: theme.colors.card, + }, + entryInfo: { + flex: 1, + flexDirection: "row", + alignItems: "center", + columnGap: theme.spacing[2], + marginRight: theme.spacing[3], + }, + entryIcon: { + width: 28, + alignItems: "center", + }, + entryTextContainer: { + flex: 1, + }, + entryName: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + }, + entryMeta: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.xs, + marginTop: theme.spacing[1], + }, + menuButton: { + width: 36, + height: 36, + borderRadius: theme.borderRadius.full, + alignItems: "center", + justifyContent: "center", + }, + menuOverlay: { + flex: 1, + }, + menuBackdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "rgba(0, 0, 0, 0.2)", + }, + entryMenu: { + borderRadius: theme.borderRadius.md, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + backgroundColor: theme.colors.card, + padding: theme.spacing[1], + }, + entryMenuItem: { + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.md, + }, + entryMenuText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + }, + codeText: { + color: theme.colors.foreground, + fontFamily: "monospace", + fontSize: theme.fontSize.sm, + flexShrink: 0, + }, + gridContent: { + paddingBottom: theme.spacing[4], + paddingHorizontal: theme.spacing[1], + }, + gridColumnWrapper: { + justifyContent: "space-between", + marginBottom: theme.spacing[2], + }, + gridCard: { + flex: 1, + borderRadius: theme.borderRadius.lg, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + padding: theme.spacing[2], + gap: theme.spacing[2], + backgroundColor: theme.colors.card, + marginHorizontal: theme.spacing[1], + marginBottom: theme.spacing[1], + minWidth: 0, + }, + gridThumbnail: { + width: "100%", + aspectRatio: 1, + borderRadius: theme.borderRadius.md, + alignItems: "center", + justifyContent: "center", + overflow: "hidden", + backgroundColor: theme.colors.muted, + }, + gridImageBackground: { + backgroundColor: theme.colors.background, + }, + gridImage: { + width: "100%", + height: "100%", + }, + gridName: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + }, + gridMeta: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.xs, + }, + sheetBackground: { + backgroundColor: theme.colors.card, + }, + handleIndicator: { + backgroundColor: theme.colors.palette.zinc[600], + }, + sheetHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + borderBottomWidth: theme.borderWidth[1], + borderBottomColor: theme.colors.border, + }, + sheetTitle: { + fontSize: theme.fontSize.lg, + fontWeight: theme.fontWeight.semibold, + color: theme.colors.foreground, + flex: 1, + }, + sheetCloseButton: { + padding: theme.spacing[2], + }, + sheetContent: { + flex: 1, + }, + sheetScrollContent: { + padding: theme.spacing[4], + }, + sheetCenterState: { + flex: 1, + alignItems: "center", + justifyContent: "center", + gap: theme.spacing[2], + padding: theme.spacing[4], + }, + sheetImageScrollContent: { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: theme.spacing[4], + }, + sheetImage: { + width: "100%", + aspectRatio: 1, + }, + downloadToast: { + position: "absolute", + bottom: theme.spacing[4], + left: theme.spacing[4], + right: theme.spacing[4], + zIndex: 1000, + }, + downloadToastContent: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + backgroundColor: theme.colors.card, + borderRadius: theme.borderRadius.lg, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[4], + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.15, + shadowRadius: 8, + elevation: 8, + }, + downloadToastTextContainer: { + flex: 1, + gap: theme.spacing[1], + }, + downloadToastFileName: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + }, + downloadToastStatus: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.xs, + }, + downloadToastDismiss: { + padding: theme.spacing[1], + }, +})); diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx new file mode 100644 index 000000000..bd7b32b68 --- /dev/null +++ b/packages/app/src/components/git-diff-pane.tsx @@ -0,0 +1,293 @@ +import { useEffect, useRef, useState } from "react"; +import { View, Text, ActivityIndicator, Platform } from "react-native"; +import { Gesture, GestureDetector, ScrollView } from "react-native-gesture-handler"; +import { StyleSheet, UnistylesRuntime } from "react-native-unistyles"; +import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; +import { useSessionStore } from "@/stores/session-store"; + +interface ParsedDiffFile { + path: string; + lines: Array<{ + type: "add" | "remove" | "context" | "header"; + content: string; + }>; +} + +function parseDiff(diffText: string): ParsedDiffFile[] { + if (!diffText || diffText.trim().length === 0) { + return []; + } + + const files: ParsedDiffFile[] = []; + const sections = diffText.split(/^diff --git /m).filter(Boolean); + + for (const section of sections) { + const lines = section.split("\n"); + const firstLine = lines[0]; + + const pathMatch = firstLine.match(/a\/(.*?) b\//); + const path = pathMatch ? pathMatch[1] : "unknown"; + + const parsedLines: ParsedDiffFile["lines"] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("@@") || line.startsWith("index ")) { + parsedLines.push({ type: "header", content: line }); + } else if (line.startsWith("+")) { + parsedLines.push({ type: "add", content: line }); + } else if (line.startsWith("-")) { + parsedLines.push({ type: "remove", content: line }); + } else { + parsedLines.push({ type: "context", content: line }); + } + } + + files.push({ path, lines: parsedLines }); + } + + return files; +} + +interface GitDiffPaneProps { + serverId: string; + agentId: string; +} + +export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { + const [isLoading, setIsLoading] = useState(true); + const hasRequestedRef = useRef(null); + const { closeGestureRef } = useExplorerSidebarAnimation(); + + const isMobile = + UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + + // Pan gesture gate: only allow horizontal scroll after deliberate horizontal movement + // This prevents diagonal scrolling from being captured by horizontal ScrollView + const horizontalScrollGate = Gesture.Pan() + .enabled(isMobile) + .activeOffsetX([-15, 15]) // Require 15px horizontal movement to activate + .failOffsetY([-10, 10]) // Fail if 10px vertical movement happens first + .blocksExternalGesture(closeGestureRef); // Block sidebar close while scrolling + + const agent = useSessionStore((state) => + state.sessions[serverId]?.agents?.get(agentId) + ); + + const diffText = useSessionStore((state) => + state.sessions[serverId]?.gitDiffs?.get(agentId) + ); + + const requestGitDiff = useSessionStore((state) => + state.sessions[serverId]?.methods?.requestGitDiff + ); + + useEffect(() => { + if (!agentId || !requestGitDiff) { + setIsLoading(false); + return; + } + + // Prevent duplicate requests for the same agentId + if (hasRequestedRef.current === agentId) { + return; + } + hasRequestedRef.current = agentId; + + setIsLoading(true); + requestGitDiff(agentId); + + const timeout = setTimeout(() => { + setIsLoading(false); + }, 5000); + + return () => clearTimeout(timeout); + }, [agentId, requestGitDiff]); + + useEffect(() => { + if (diffText !== undefined) { + setIsLoading(false); + } + }, [diffText]); + + if (!agent) { + return ( + + Agent not found + + ); + } + + const isError = diffText?.startsWith("Error:"); + const parsedFiles = isError || !diffText ? [] : parseDiff(diffText); + const hasChanges = parsedFiles.length > 0; + + return ( + + {isLoading ? ( + + + Loading changes... + + ) : isError ? ( + + {diffText} + + ) : !hasChanges ? ( + + No changes + + ) : ( + parsedFiles.map((file, fileIndex) => ( + + + {file.path} + + + + + + {file.lines.map((line, lineIndex) => ( + + + {line.content} + + + ))} + + + + + + )) + )} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + scrollView: { + flex: 1, + }, + contentContainer: { + padding: theme.spacing[4], + 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.mutedForeground, + }, + 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.mutedForeground, + }, + fileSection: { + marginBottom: theme.spacing[6], + borderRadius: theme.borderRadius.lg, + overflow: "hidden", + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + width: "100%", + }, + fileHeader: { + backgroundColor: theme.colors.muted, + padding: theme.spacing[3], + borderBottomWidth: theme.borderWidth[1], + borderBottomColor: theme.colors.border, + }, + filePath: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + color: theme.colors.foreground, + fontFamily: "monospace", + }, + diffContent: { + backgroundColor: theme.colors.card, + }, + diffLinesContainer: { + minWidth: "100%", + }, + diffLineContainer: { + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[1], + flexDirection: "row", + alignItems: "flex-start", + }, + diffLineText: { + fontSize: theme.fontSize.xs, + fontFamily: "monospace", + color: theme.colors.foreground, + }, + addLineContainer: { + backgroundColor: theme.colors.palette.green[900], + }, + addLineText: { + color: theme.colors.palette.green[200], + }, + removeLineContainer: { + backgroundColor: theme.colors.palette.red[900], + }, + removeLineText: { + color: theme.colors.palette.red[200], + }, + headerLineContainer: { + backgroundColor: theme.colors.muted, + }, + headerLineText: { + color: theme.colors.mutedForeground, + }, + contextLineContainer: { + backgroundColor: theme.colors.card, + }, + contextLineText: { + color: theme.colors.mutedForeground, + }, +})); diff --git a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx new file mode 100644 index 000000000..9de3773d9 --- /dev/null +++ b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx @@ -0,0 +1,121 @@ +import { createContext, useContext, useEffect, useRef, type ReactNode } from "react"; +import { useWindowDimensions } from "react-native"; +import { + useSharedValue, + withTiming, + Easing, + type SharedValue, +} from "react-native-reanimated"; +import { type GestureType } from "react-native-gesture-handler"; +import { useExplorerSidebarStore } from "@/stores/explorer-sidebar-store"; + +const ANIMATION_DURATION = 220; +const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1); + +interface ExplorerSidebarAnimationContextValue { + translateX: SharedValue; + backdropOpacity: SharedValue; + windowWidth: number; + animateToOpen: () => void; + animateToClose: () => void; + isGesturing: SharedValue; + closeGestureRef: React.MutableRefObject; +} + +const ExplorerSidebarAnimationContext = createContext(null); + +export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) { + const { width: windowWidth } = useWindowDimensions(); + const { isOpen } = useExplorerSidebarStore(); + + // Right sidebar: closed = +windowWidth (off-screen right), open = 0 + const translateX = useSharedValue(isOpen ? 0 : windowWidth); + const backdropOpacity = useSharedValue(isOpen ? 1 : 0); + const isGesturing = useSharedValue(false); + const closeGestureRef = useRef(undefined); + + // Track previous isOpen to detect changes + const prevIsOpen = useRef(isOpen); + + // Sync animation with store state changes (e.g., backdrop tap, programmatic open/close) + useEffect(() => { + // Skip if this is initial render or if we're mid-gesture + if (prevIsOpen.current === isOpen) { + return; + } + prevIsOpen.current = isOpen; + + // Don't animate if we're in the middle of a gesture - the gesture handler will handle it + if (isGesturing.value) { + return; + } + + if (isOpen) { + translateX.value = withTiming(0, { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); + backdropOpacity.value = withTiming(1, { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); + } else { + translateX.value = withTiming(windowWidth, { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); + backdropOpacity.value = withTiming(0, { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); + } + }, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]); + + const animateToOpen = () => { + "worklet"; + translateX.value = withTiming(0, { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); + backdropOpacity.value = withTiming(1, { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); + }; + + const animateToClose = () => { + "worklet"; + translateX.value = withTiming(windowWidth, { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); + backdropOpacity.value = withTiming(0, { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); + }; + + return ( + + {children} + + ); +} + +export function useExplorerSidebarAnimation() { + const context = useContext(ExplorerSidebarAnimationContext); + if (!context) { + throw new Error("useExplorerSidebarAnimation must be used within ExplorerSidebarAnimationProvider"); + } + return context; +} diff --git a/packages/app/src/stores/explorer-sidebar-store.ts b/packages/app/src/stores/explorer-sidebar-store.ts new file mode 100644 index 000000000..660a36247 --- /dev/null +++ b/packages/app/src/stores/explorer-sidebar-store.ts @@ -0,0 +1,64 @@ +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; +import AsyncStorage from "@react-native-async-storage/async-storage"; + +type ExplorerTab = "changes" | "files"; +export type ViewMode = "list" | "grid"; +export type SortOption = "name" | "modified" | "size"; + +export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 400; +export const MIN_EXPLORER_SIDEBAR_WIDTH = 280; +export const MAX_EXPLORER_SIDEBAR_WIDTH = 800; + +interface ExplorerSidebarState { + isOpen: boolean; + activeTab: ExplorerTab; + width: number; + viewMode: ViewMode; + sortOption: SortOption; + toggle: () => void; + open: () => void; + close: () => void; + setActiveTab: (tab: ExplorerTab) => void; + setWidth: (width: number) => void; + setViewMode: (mode: ViewMode) => void; + setSortOption: (option: SortOption) => void; +} + +function clampWidth(width: number): number { + return Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, width)); +} + +export const useExplorerSidebarStore = create()( + persist( + (set) => ({ + isOpen: false, + activeTab: "changes", + width: DEFAULT_EXPLORER_SIDEBAR_WIDTH, + viewMode: "list", + sortOption: "name", + toggle: () => set((state) => ({ isOpen: !state.isOpen })), + open: () => set({ isOpen: true }), + close: () => set({ isOpen: false }), + setActiveTab: (tab) => set({ activeTab: tab }), + setWidth: (width) => set({ width: clampWidth(width) }), + setViewMode: (mode) => set({ viewMode: mode }), + setSortOption: (option) => set({ sortOption: option }), + }), + { + name: "explorer-sidebar-state", + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ + isOpen: state.isOpen, + activeTab: state.activeTab, + width: state.width, + viewMode: state.viewMode, + sortOption: state.sortOption, + }), + } + ) +); + +export function useExplorerSidebar() { + return useExplorerSidebarStore(); +}