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:
Mohamed Boudra
2026-01-05 14:27:59 +07:00
parent 9d7e44851c
commit 8c33df472f
6 changed files with 2609 additions and 37 deletions

View File

@@ -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 (
<AgentScreenContent
serverId={resolvedServerId}
agentId={resolvedAgentId}
/>
<ExplorerSidebarAnimationProvider>
<AgentScreenContent
serverId={resolvedServerId}
agentId={resolvedAgentId}
/>
</ExplorerSidebarAnimationProvider>
);
}
@@ -170,6 +184,84 @@ function AgentScreenContent({
const menuButtonRef = useRef<View>(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 = (
<View style={styles.outerContainer}>
<View style={styles.container}>
{/* Header */}
<MenuHeader
title={agent.title || "Agent"}
rightContent={
<View ref={menuButtonRef} collapsable={false}>
<Pressable onPress={handleOpenMenu} style={styles.menuButton}>
<MoreVertical size={20} color={theme.colors.mutedForeground} />
<View style={styles.headerRightContent}>
<Pressable onPress={toggleExplorer} style={styles.menuButton}>
<PanelRightOpen
size={20}
color={
isExplorerOpen
? theme.colors.foreground
: theme.colors.mutedForeground
}
/>
</Pressable>
<View ref={menuButtonRef} collapsable={false}>
<Pressable onPress={handleOpenMenu} style={styles.menuButton}>
<MoreVertical size={20} color={theme.colors.mutedForeground} />
</Pressable>
</View>
</View>
}
/>
{/* Content Area with Keyboard Animation */}
<View style={styles.contentContainer}>
<ReanimatedAnimated.View
style={[styles.content, animatedKeyboardStyle]}
>
{isInitializing ? (
<View style={styles.loadingContainer}>
<ActivityIndicator
size="large"
color={theme.colors.primary}
{/* Content Area with Keyboard Animation */}
<View style={styles.contentContainer}>
<ReanimatedAnimated.View
style={[styles.content, animatedKeyboardStyle]}
>
{isInitializing ? (
<View style={styles.loadingContainer}>
<ActivityIndicator
size="large"
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>
) : (
<AgentStreamView
agentId={agent.id}
serverId={serverId}
agent={agent}
streamItems={streamItems}
pendingPermissions={pendingPermissions}
/>
)}
</ReanimatedAnimated.View>
</View>
)}
</ReanimatedAnimated.View>
</View>
{/* Agent Input Area */}
{!isInitializing && agent && resolvedAgentId && (
<AgentInputArea agentId={resolvedAgentId} serverId={serverId} autoFocus />
)}
{/* Agent Input Area */}
{!isInitializing && agent && resolvedAgentId && (
<AgentInputArea agentId={resolvedAgentId} serverId={serverId} autoFocus />
)}
{/* Dropdown Menu */}
<Modal
@@ -778,7 +882,30 @@ function AgentScreenContent({
</View>
</View>
</Modal>
</View>
{/* Explorer Sidebar - Desktop: inline, Mobile: overlay */}
{!isMobile && isExplorerOpen && resolvedAgentId && (
<ExplorerSidebar serverId={serverId} agentId={resolvedAgentId} />
)}
</View>
);
return (
<>
{isMobile ? (
<GestureDetector gesture={explorerOpenGesture} touchAction="pan-y">
{mainContent}
</GestureDetector>
) : (
mainContent
)}
{/* Mobile Explorer Sidebar Overlay */}
{isMobile && resolvedAgentId && (
<ExplorerSidebar serverId={serverId} agentId={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",

View File

@@ -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

View File

@@ -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,
},
}));

View File

@@ -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;
}

View File

@@ -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();
}