feat(explorer-sidebar): add right-side file explorer and git diff panel
- Add swipe-left gesture to open explorer sidebar on mobile - Implement Changes (git diff) and Files tabs with toggle - Add responsive gallery columns based on container width - Handle hardware back button to close sidebar first - Add gesture conflict resolution for horizontal scroll in diffs - Add resize handle with cursor change on desktop - Add sorting selector (name, modified, size) in file explorer 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
9d7e44851c
commit
8c33df472f
@@ -9,6 +9,7 @@ import {
|
|||||||
LayoutChangeEvent,
|
LayoutChangeEvent,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Platform,
|
Platform,
|
||||||
|
BackHandler,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useFocusEffect } from "@react-navigation/native";
|
import { useFocusEffect } from "@react-navigation/native";
|
||||||
@@ -17,8 +18,12 @@ import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller
|
|||||||
import ReanimatedAnimated, {
|
import ReanimatedAnimated, {
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
|
runOnJS,
|
||||||
|
interpolate,
|
||||||
|
Extrapolation,
|
||||||
} from "react-native-reanimated";
|
} 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 {
|
import {
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
@@ -28,12 +33,19 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
|
PanelRightOpen,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { MenuHeader } from "@/components/headers/menu-header";
|
import { MenuHeader } from "@/components/headers/menu-header";
|
||||||
import { BackHeader } from "@/components/headers/back-header";
|
import { BackHeader } from "@/components/headers/back-header";
|
||||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||||
import { AgentInputArea } from "@/components/agent-input-area";
|
import { AgentInputArea } from "@/components/agent-input-area";
|
||||||
import { ImportAgentModal } from "@/components/create-agent-modal";
|
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 { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||||
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
|
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||||
import { formatConnectionStatus } from "@/utils/daemons";
|
import { formatConnectionStatus } from "@/utils/daemons";
|
||||||
@@ -143,10 +155,12 @@ export default function AgentScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AgentScreenContent
|
<ExplorerSidebarAnimationProvider>
|
||||||
serverId={resolvedServerId}
|
<AgentScreenContent
|
||||||
agentId={resolvedAgentId}
|
serverId={resolvedServerId}
|
||||||
/>
|
agentId={resolvedAgentId}
|
||||||
|
/>
|
||||||
|
</ExplorerSidebarAnimationProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +184,84 @@ function AgentScreenContent({
|
|||||||
const menuButtonRef = useRef<View>(null);
|
const menuButtonRef = useRef<View>(null);
|
||||||
const [showImportAgentModal, setShowImportAgentModal] = useState(false);
|
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;
|
const resolvedAgentId = agentId;
|
||||||
|
|
||||||
// Select only the specific agent
|
// Select only the specific agent
|
||||||
@@ -573,50 +665,62 @@ function AgentScreenContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const mainContent = (
|
||||||
<>
|
<View style={styles.outerContainer}>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<MenuHeader
|
<MenuHeader
|
||||||
title={agent.title || "Agent"}
|
title={agent.title || "Agent"}
|
||||||
rightContent={
|
rightContent={
|
||||||
<View ref={menuButtonRef} collapsable={false}>
|
<View style={styles.headerRightContent}>
|
||||||
<Pressable onPress={handleOpenMenu} style={styles.menuButton}>
|
<Pressable onPress={toggleExplorer} style={styles.menuButton}>
|
||||||
<MoreVertical size={20} color={theme.colors.mutedForeground} />
|
<PanelRightOpen
|
||||||
|
size={20}
|
||||||
|
color={
|
||||||
|
isExplorerOpen
|
||||||
|
? theme.colors.foreground
|
||||||
|
: theme.colors.mutedForeground
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
<View ref={menuButtonRef} collapsable={false}>
|
||||||
|
<Pressable onPress={handleOpenMenu} style={styles.menuButton}>
|
||||||
|
<MoreVertical size={20} color={theme.colors.mutedForeground} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content Area with Keyboard Animation */}
|
{/* Content Area with Keyboard Animation */}
|
||||||
<View style={styles.contentContainer}>
|
<View style={styles.contentContainer}>
|
||||||
<ReanimatedAnimated.View
|
<ReanimatedAnimated.View
|
||||||
style={[styles.content, animatedKeyboardStyle]}
|
style={[styles.content, animatedKeyboardStyle]}
|
||||||
>
|
>
|
||||||
{isInitializing ? (
|
{isInitializing ? (
|
||||||
<View style={styles.loadingContainer}>
|
<View style={styles.loadingContainer}>
|
||||||
<ActivityIndicator
|
<ActivityIndicator
|
||||||
size="large"
|
size="large"
|
||||||
color={theme.colors.primary}
|
color={theme.colors.primary}
|
||||||
|
/>
|
||||||
|
<Text style={styles.loadingText}>Loading agent...</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<AgentStreamView
|
||||||
|
agentId={agent.id}
|
||||||
|
serverId={serverId}
|
||||||
|
agent={agent}
|
||||||
|
streamItems={streamItems}
|
||||||
|
pendingPermissions={pendingPermissions}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.loadingText}>Loading agent...</Text>
|
)}
|
||||||
</View>
|
</ReanimatedAnimated.View>
|
||||||
) : (
|
</View>
|
||||||
<AgentStreamView
|
|
||||||
agentId={agent.id}
|
|
||||||
serverId={serverId}
|
|
||||||
agent={agent}
|
|
||||||
streamItems={streamItems}
|
|
||||||
pendingPermissions={pendingPermissions}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ReanimatedAnimated.View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Agent Input Area */}
|
{/* Agent Input Area */}
|
||||||
{!isInitializing && agent && resolvedAgentId && (
|
{!isInitializing && agent && resolvedAgentId && (
|
||||||
<AgentInputArea agentId={resolvedAgentId} serverId={serverId} autoFocus />
|
<AgentInputArea agentId={resolvedAgentId} serverId={serverId} autoFocus />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dropdown Menu */}
|
{/* Dropdown Menu */}
|
||||||
<Modal
|
<Modal
|
||||||
@@ -778,7 +882,30 @@ function AgentScreenContent({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Explorer Sidebar - Desktop: inline, Mobile: overlay */}
|
||||||
|
{!isMobile && isExplorerOpen && resolvedAgentId && (
|
||||||
|
<ExplorerSidebar serverId={serverId} agentId={resolvedAgentId} />
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isMobile ? (
|
||||||
|
<GestureDetector gesture={explorerOpenGesture} touchAction="pan-y">
|
||||||
|
{mainContent}
|
||||||
|
</GestureDetector>
|
||||||
|
) : (
|
||||||
|
mainContent
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile Explorer Sidebar Overlay */}
|
||||||
|
{isMobile && resolvedAgentId && (
|
||||||
|
<ExplorerSidebar serverId={serverId} agentId={resolvedAgentId} />
|
||||||
|
)}
|
||||||
|
|
||||||
{importAgentModal}
|
{importAgentModal}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -853,10 +980,19 @@ function AgentSessionUnavailableState({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
outerContainer: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
},
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: theme.colors.background,
|
backgroundColor: theme.colors.background,
|
||||||
},
|
},
|
||||||
|
headerRightContent: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
contentContainer: {
|
contentContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
|
|||||||
@@ -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<ViewMode>("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 (
|
||||||
|
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
|
||||||
|
<Pressable style={styles.backdropPressable} onPress={handleClose} />
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.mobileSidebar,
|
||||||
|
{ width: windowWidth, paddingTop: insets.top, paddingBottom: insets.bottom },
|
||||||
|
sidebarAnimatedStyle,
|
||||||
|
]}
|
||||||
|
pointerEvents="auto"
|
||||||
|
>
|
||||||
|
<SidebarContent
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabPress={handleTabPress}
|
||||||
|
onClose={handleClose}
|
||||||
|
serverId={serverId}
|
||||||
|
agentId={agentId}
|
||||||
|
fileViewMode={fileViewMode}
|
||||||
|
onFileViewModeChange={setFileViewMode}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
</GestureDetector>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop: fixed width sidebar with resize handle
|
||||||
|
if (!isOpen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
|
||||||
|
{/* Resize handle on left edge */}
|
||||||
|
<GestureDetector gesture={resizeGesture}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.resizeHandle,
|
||||||
|
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.resizeHandleInner} />
|
||||||
|
</View>
|
||||||
|
</GestureDetector>
|
||||||
|
|
||||||
|
<SidebarContent
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabPress={handleTabPress}
|
||||||
|
onClose={handleClose}
|
||||||
|
serverId={serverId}
|
||||||
|
agentId={agentId}
|
||||||
|
fileViewMode={fileViewMode}
|
||||||
|
onFileViewModeChange={setFileViewMode}
|
||||||
|
isMobile={false}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||||
|
{/* Header with tabs and close button */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.tabsContainer}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.tab, activeTab === "changes" && styles.tabActive]}
|
||||||
|
onPress={() => onTabPress("changes")}
|
||||||
|
>
|
||||||
|
<GitBranch
|
||||||
|
size={16}
|
||||||
|
color={
|
||||||
|
activeTab === "changes"
|
||||||
|
? theme.colors.foreground
|
||||||
|
: theme.colors.mutedForeground
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.tabText,
|
||||||
|
activeTab === "changes" && styles.tabTextActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Changes
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.tab, activeTab === "files" && styles.tabActive]}
|
||||||
|
onPress={() => onTabPress("files")}
|
||||||
|
>
|
||||||
|
<Folder
|
||||||
|
size={16}
|
||||||
|
color={
|
||||||
|
activeTab === "files"
|
||||||
|
? theme.colors.foreground
|
||||||
|
: theme.colors.mutedForeground
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.tabText,
|
||||||
|
activeTab === "files" && styles.tabTextActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Files
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<View style={styles.headerRightSection}>
|
||||||
|
{activeTab === "files" && (
|
||||||
|
<ViewToggle viewMode={fileViewMode} onChange={onFileViewModeChange} />
|
||||||
|
)}
|
||||||
|
{isMobile && (
|
||||||
|
<Pressable onPress={onClose} style={styles.closeButton}>
|
||||||
|
<X size={18} color={theme.colors.mutedForeground} />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content based on active tab */}
|
||||||
|
<View style={styles.contentArea}>
|
||||||
|
{activeTab === "changes" ? (
|
||||||
|
<GitDiffPane serverId={serverId} agentId={agentId} />
|
||||||
|
) : (
|
||||||
|
<FileExplorerPane
|
||||||
|
serverId={serverId}
|
||||||
|
agentId={agentId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ViewToggle({
|
||||||
|
viewMode,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
viewMode: ViewMode;
|
||||||
|
onChange: (mode: ViewMode) => void;
|
||||||
|
}) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.viewToggleContainer}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.viewToggleButton, viewMode === "list" && styles.viewToggleActive]}
|
||||||
|
onPress={() => onChange("list")}
|
||||||
|
>
|
||||||
|
<ListIcon size={14} color={theme.colors.foreground} />
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.viewToggleButton, viewMode === "grid" && styles.viewToggleActive]}
|
||||||
|
onPress={() => onChange("grid")}
|
||||||
|
>
|
||||||
|
<LayoutGrid size={14} color={theme.colors.foreground} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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<string | null>(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 (
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Text style={styles.errorText}>Agent not found</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isError = diffText?.startsWith("Error:");
|
||||||
|
const parsedFiles = isError || !diffText ? [] : parseDiff(diffText);
|
||||||
|
const hasChanges = parsedFiles.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.scrollView} contentContainerStyle={styles.contentContainer}>
|
||||||
|
{isLoading ? (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" />
|
||||||
|
<Text style={styles.loadingText}>Loading changes...</Text>
|
||||||
|
</View>
|
||||||
|
) : isError ? (
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Text style={styles.errorText}>{diffText}</Text>
|
||||||
|
</View>
|
||||||
|
) : !hasChanges ? (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Text style={styles.emptyText}>No changes</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
parsedFiles.map((file, fileIndex) => (
|
||||||
|
<View key={fileIndex} style={styles.fileSection}>
|
||||||
|
<View style={styles.fileHeader}>
|
||||||
|
<Text style={styles.filePath}>{file.path}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.diffContent}>
|
||||||
|
<GestureDetector gesture={horizontalScrollGate}>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator
|
||||||
|
nestedScrollEnabled
|
||||||
|
directionalLockEnabled={Platform.OS === "ios"}
|
||||||
|
>
|
||||||
|
<View style={styles.diffLinesContainer}>
|
||||||
|
{file.lines.map((line, lineIndex) => (
|
||||||
|
<View
|
||||||
|
key={lineIndex}
|
||||||
|
style={[
|
||||||
|
styles.diffLineContainer,
|
||||||
|
line.type === "add" && styles.addLineContainer,
|
||||||
|
line.type === "remove" && styles.removeLineContainer,
|
||||||
|
line.type === "header" && styles.headerLineContainer,
|
||||||
|
line.type === "context" && styles.contextLineContainer,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.diffLineText,
|
||||||
|
line.type === "add" && styles.addLineText,
|
||||||
|
line.type === "remove" && styles.removeLineText,
|
||||||
|
line.type === "header" && styles.headerLineText,
|
||||||
|
line.type === "context" && styles.contextLineText,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{line.content}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</GestureDetector>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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<number>;
|
||||||
|
backdropOpacity: SharedValue<number>;
|
||||||
|
windowWidth: number;
|
||||||
|
animateToOpen: () => void;
|
||||||
|
animateToClose: () => void;
|
||||||
|
isGesturing: SharedValue<boolean>;
|
||||||
|
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExplorerSidebarAnimationContext = createContext<ExplorerSidebarAnimationContextValue | null>(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<GestureType | undefined>(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 (
|
||||||
|
<ExplorerSidebarAnimationContext.Provider
|
||||||
|
value={{
|
||||||
|
translateX,
|
||||||
|
backdropOpacity,
|
||||||
|
windowWidth,
|
||||||
|
animateToOpen,
|
||||||
|
animateToClose,
|
||||||
|
isGesturing,
|
||||||
|
closeGestureRef,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ExplorerSidebarAnimationContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExplorerSidebarAnimation() {
|
||||||
|
const context = useContext(ExplorerSidebarAnimationContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useExplorerSidebarAnimation must be used within ExplorerSidebarAnimationProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -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<ExplorerSidebarState>()(
|
||||||
|
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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user