Add theme dropdown in settings with Light, Dark, System plus Zinc, Midnight, Claude, and Ghostty dark variants. Each theme has its own accent color and surface tints. Desaturate terminal ANSI colors across all dark themes. Add surfaceSidebarHover semantic token so hover states go the right direction in both light and dark modes. Self-heal if a stored theme no longer exists.
502 lines
15 KiB
TypeScript
502 lines
15 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
import {
|
|
View,
|
|
Text,
|
|
Pressable,
|
|
Platform,
|
|
useWindowDimensions,
|
|
StyleSheet as RNStyleSheet,
|
|
} from "react-native";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import { useIsFocused } from "@react-navigation/native";
|
|
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
|
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
import { X } from "lucide-react-native";
|
|
import {
|
|
usePanelStore,
|
|
MIN_EXPLORER_SIDEBAR_WIDTH,
|
|
MAX_EXPLORER_SIDEBAR_WIDTH,
|
|
type ExplorerTab,
|
|
} from "@/stores/panel-store";
|
|
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
|
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
|
import { GitDiffPane } from "./git-diff-pane";
|
|
import { FileExplorerPane } from "./file-explorer-pane";
|
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
|
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
|
|
|
const MIN_CHAT_WIDTH = 400;
|
|
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
|
|
|
interface ExplorerSidebarProps {
|
|
serverId: string;
|
|
workspaceId?: string | null;
|
|
workspaceRoot: string;
|
|
isGit: boolean;
|
|
onOpenFile?: (filePath: string) => void;
|
|
}
|
|
|
|
export function ExplorerSidebar({
|
|
serverId,
|
|
workspaceId,
|
|
workspaceRoot,
|
|
isGit,
|
|
onOpenFile,
|
|
}: ExplorerSidebarProps) {
|
|
const { theme } = useUnistyles();
|
|
const isScreenFocused = useIsFocused();
|
|
const insets = useSafeAreaInsets();
|
|
const isMobile = useIsCompactFormFactor();
|
|
const mobileView = usePanelStore((state) => state.mobileView);
|
|
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
|
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
|
const explorerTab = usePanelStore((state) => state.explorerTab);
|
|
const explorerWidth = usePanelStore((state) => state.explorerWidth);
|
|
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
|
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
|
|
const { width: viewportWidth } = useWindowDimensions();
|
|
const closeTouchStartX = useSharedValue(0);
|
|
const closeTouchStartY = useSharedValue(0);
|
|
|
|
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
|
|
mode: "padding",
|
|
enabled: isMobile,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (isMobile) {
|
|
return;
|
|
}
|
|
const maxWidth = Math.max(
|
|
MIN_EXPLORER_SIDEBAR_WIDTH,
|
|
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
|
|
);
|
|
if (explorerWidth > maxWidth) {
|
|
setExplorerWidth(maxWidth);
|
|
}
|
|
}, [explorerWidth, isMobile, setExplorerWidth, viewportWidth]);
|
|
|
|
// Derive isOpen from the unified panel state
|
|
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
|
|
|
const {
|
|
translateX,
|
|
backdropOpacity,
|
|
windowWidth,
|
|
animateToOpen,
|
|
animateToClose,
|
|
isGesturing,
|
|
gestureAnimatingRef,
|
|
closeGestureRef,
|
|
} = useExplorerSidebarAnimation();
|
|
|
|
// For resize drag, track the starting width
|
|
const startWidthRef = useRef(explorerWidth);
|
|
const resizeWidth = useSharedValue(explorerWidth);
|
|
|
|
const handleClose = useCallback(
|
|
(reason: string) => {
|
|
logExplorerSidebar("handleClose", {
|
|
reason,
|
|
isOpen,
|
|
mobileView,
|
|
desktopFileExplorerOpen,
|
|
});
|
|
closeToAgent();
|
|
},
|
|
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView],
|
|
);
|
|
|
|
const handleCloseFromGesture = useCallback(() => {
|
|
gestureAnimatingRef.current = true;
|
|
closeToAgent();
|
|
}, [closeToAgent, gestureAnimatingRef]);
|
|
|
|
const enableSidebarCloseGesture = isMobile && isOpen;
|
|
|
|
const handleTabPress = useCallback(
|
|
(tab: ExplorerTab) => {
|
|
setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab });
|
|
},
|
|
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot],
|
|
);
|
|
|
|
// Swipe gesture to close (swipe right on mobile)
|
|
const closeGesture = useMemo(
|
|
() =>
|
|
Gesture.Pan()
|
|
.withRef(closeGestureRef)
|
|
.enabled(enableSidebarCloseGesture)
|
|
// Use manual activation so child views keep touch streams
|
|
// unless we detect an intentional right-swipe close.
|
|
.manualActivation(true)
|
|
.onTouchesDown((event) => {
|
|
const touch = event.changedTouches[0];
|
|
if (!touch) {
|
|
return;
|
|
}
|
|
closeTouchStartX.value = touch.absoluteX;
|
|
closeTouchStartY.value = touch.absoluteY;
|
|
})
|
|
.onTouchesMove((event, stateManager) => {
|
|
const touch = event.changedTouches[0];
|
|
if (!touch || event.numberOfTouches !== 1) {
|
|
stateManager.fail();
|
|
return;
|
|
}
|
|
|
|
const deltaX = touch.absoluteX - closeTouchStartX.value;
|
|
const deltaY = touch.absoluteY - closeTouchStartY.value;
|
|
const absDeltaX = Math.abs(deltaX);
|
|
const absDeltaY = Math.abs(deltaY);
|
|
|
|
// Fail quickly on clear leftward or vertical intent so child views keep control.
|
|
if (deltaX <= -10) {
|
|
stateManager.fail();
|
|
return;
|
|
}
|
|
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
|
|
stateManager.fail();
|
|
return;
|
|
}
|
|
|
|
// Activate only on intentional rightward movement.
|
|
if (deltaX >= 15 && absDeltaX > absDeltaY) {
|
|
stateManager.activate();
|
|
}
|
|
})
|
|
.onStart(() => {
|
|
isGesturing.value = true;
|
|
})
|
|
.onUpdate((event) => {
|
|
// Right sidebar: swipe right to close (positive translationX)
|
|
const newTranslateX = Math.max(0, Math.min(windowWidth, event.translationX));
|
|
translateX.value = newTranslateX;
|
|
const progress = 1 - newTranslateX / windowWidth;
|
|
backdropOpacity.value = Math.max(0, Math.min(1, progress));
|
|
})
|
|
.onEnd((event) => {
|
|
isGesturing.value = false;
|
|
const shouldClose = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
|
runOnJS(logExplorerSidebar)("closeGestureEnd", {
|
|
translationX: event.translationX,
|
|
velocityX: event.velocityX,
|
|
shouldClose,
|
|
windowWidth,
|
|
});
|
|
if (shouldClose) {
|
|
animateToClose();
|
|
runOnJS(handleCloseFromGesture)();
|
|
} else {
|
|
animateToOpen();
|
|
}
|
|
})
|
|
.onFinalize(() => {
|
|
isGesturing.value = false;
|
|
}),
|
|
[
|
|
enableSidebarCloseGesture,
|
|
windowWidth,
|
|
translateX,
|
|
backdropOpacity,
|
|
animateToOpen,
|
|
animateToClose,
|
|
handleCloseFromGesture,
|
|
isGesturing,
|
|
closeGestureRef,
|
|
closeTouchStartX,
|
|
closeTouchStartY,
|
|
],
|
|
);
|
|
|
|
// Desktop resize gesture (drag left edge)
|
|
const resizeGesture = useMemo(
|
|
() =>
|
|
Gesture.Pan()
|
|
.enabled(!isMobile)
|
|
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
|
|
.onStart(() => {
|
|
startWidthRef.current = explorerWidth;
|
|
resizeWidth.value = explorerWidth;
|
|
})
|
|
.onUpdate((event) => {
|
|
// Dragging left (negative translationX) increases width
|
|
const newWidth = startWidthRef.current - event.translationX;
|
|
const maxWidth = Math.max(
|
|
MIN_EXPLORER_SIDEBAR_WIDTH,
|
|
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
|
|
);
|
|
const clampedWidth = Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth));
|
|
resizeWidth.value = clampedWidth;
|
|
})
|
|
.onEnd(() => {
|
|
runOnJS(setExplorerWidth)(resizeWidth.value);
|
|
}),
|
|
[isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth],
|
|
);
|
|
|
|
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
|
|
transform: [{ translateX: translateX.value }],
|
|
}));
|
|
|
|
const backdropAnimatedStyle = useAnimatedStyle(() => ({
|
|
opacity: backdropOpacity.value,
|
|
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
|
|
}));
|
|
|
|
const resizeAnimatedStyle = useAnimatedStyle(() => ({
|
|
width: resizeWidth.value,
|
|
}));
|
|
|
|
// Mobile: full-screen overlay with gesture.
|
|
// On web, keep it interactive only while open so closed sidebars don't eat taps.
|
|
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
|
|
|
|
// Navigation stacks can keep previous screens mounted; hide sidebars for unfocused
|
|
// screens so only the active screen exposes explorer/terminal surfaces.
|
|
if (!isScreenFocused) {
|
|
return null;
|
|
}
|
|
|
|
if (isMobile) {
|
|
return (
|
|
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
|
{/* Backdrop */}
|
|
<Animated.View style={[explorerStaticStyles.backdrop, backdropAnimatedStyle]} />
|
|
|
|
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
|
<Animated.View
|
|
style={[
|
|
explorerStaticStyles.mobileSidebar,
|
|
{ width: windowWidth, paddingTop: insets.top, backgroundColor: theme.colors.surfaceSidebar },
|
|
sidebarAnimatedStyle,
|
|
mobileKeyboardInsetStyle,
|
|
]}
|
|
pointerEvents="auto"
|
|
>
|
|
<SidebarContent
|
|
activeTab={explorerTab}
|
|
onTabPress={handleTabPress}
|
|
onClose={() => handleClose("header-close-button")}
|
|
serverId={serverId}
|
|
workspaceId={workspaceId}
|
|
workspaceRoot={workspaceRoot}
|
|
isGit={isGit}
|
|
isMobile={isMobile}
|
|
onOpenFile={onOpenFile}
|
|
/>
|
|
</Animated.View>
|
|
</GestureDetector>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// Desktop: fixed width sidebar with resize handle
|
|
if (!isOpen) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Animated.View style={[explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}>
|
|
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
|
|
{/* Resize handle - absolutely positioned over left border */}
|
|
<GestureDetector gesture={resizeGesture}>
|
|
<View
|
|
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
|
|
/>
|
|
</GestureDetector>
|
|
|
|
<SidebarContent
|
|
activeTab={explorerTab}
|
|
onTabPress={handleTabPress}
|
|
onClose={() => handleClose("desktop-close-button")}
|
|
serverId={serverId}
|
|
workspaceId={workspaceId}
|
|
workspaceRoot={workspaceRoot}
|
|
isGit={isGit}
|
|
isMobile={false}
|
|
onOpenFile={onOpenFile}
|
|
/>
|
|
</View>
|
|
</Animated.View>
|
|
);
|
|
}
|
|
|
|
interface SidebarContentProps {
|
|
activeTab: ExplorerTab;
|
|
onTabPress: (tab: ExplorerTab) => void;
|
|
onClose: () => void;
|
|
serverId: string;
|
|
workspaceId?: string | null;
|
|
workspaceRoot: string;
|
|
isGit: boolean;
|
|
isMobile: boolean;
|
|
onOpenFile?: (filePath: string) => void;
|
|
}
|
|
|
|
function SidebarContent({
|
|
activeTab,
|
|
onTabPress,
|
|
onClose,
|
|
serverId,
|
|
workspaceId,
|
|
workspaceRoot,
|
|
isGit,
|
|
isMobile,
|
|
onOpenFile,
|
|
}: SidebarContentProps) {
|
|
const { theme } = useUnistyles();
|
|
const padding = useWindowControlsPadding("explorerSidebar");
|
|
const resolvedTab: ExplorerTab = !isGit && activeTab === "changes" ? "files" : activeTab;
|
|
|
|
return (
|
|
<View style={styles.sidebarContent} pointerEvents="auto">
|
|
{/* Header with tabs and close button */}
|
|
<View style={[styles.header, { paddingRight: padding.right }]} testID="explorer-header">
|
|
<TitlebarDragRegion />
|
|
<View style={styles.tabsContainer}>
|
|
{isGit && (
|
|
<Pressable
|
|
testID="explorer-tab-changes"
|
|
style={[styles.tab, resolvedTab === "changes" && styles.tabActive]}
|
|
onPress={() => onTabPress("changes")}
|
|
>
|
|
<Text style={[styles.tabText, resolvedTab === "changes" && styles.tabTextActive]}>
|
|
Changes
|
|
</Text>
|
|
</Pressable>
|
|
)}
|
|
<Pressable
|
|
testID="explorer-tab-files"
|
|
style={[styles.tab, resolvedTab === "files" && styles.tabActive]}
|
|
onPress={() => onTabPress("files")}
|
|
>
|
|
<Text style={[styles.tabText, resolvedTab === "files" && styles.tabTextActive]}>
|
|
Files
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
<View style={styles.headerRightSection}>
|
|
{isMobile && (
|
|
<Pressable onPress={onClose} style={styles.closeButton}>
|
|
<X size={18} color={theme.colors.foregroundMuted} />
|
|
</Pressable>
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Content based on active tab */}
|
|
<View style={styles.contentArea} testID="explorer-content-area">
|
|
{resolvedTab === "changes" && (
|
|
<GitDiffPane
|
|
serverId={serverId}
|
|
workspaceId={workspaceId}
|
|
cwd={workspaceRoot}
|
|
hideHeaderRow={!isMobile}
|
|
/>
|
|
)}
|
|
{resolvedTab === "files" && (
|
|
<FileExplorerPane
|
|
serverId={serverId}
|
|
workspaceId={workspaceId}
|
|
workspaceRoot={workspaceRoot}
|
|
onOpenFile={onOpenFile}
|
|
/>
|
|
)}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
|
|
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
|
// tries to patch the native node that Reanimated also manages.
|
|
const explorerStaticStyles = RNStyleSheet.create({
|
|
backdrop: {
|
|
...RNStyleSheet.absoluteFillObject,
|
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
},
|
|
mobileSidebar: {
|
|
position: "absolute" as const,
|
|
top: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
overflow: "hidden" as const,
|
|
},
|
|
desktopSidebar: {
|
|
position: "relative" as const,
|
|
},
|
|
});
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
desktopSidebarBorder: {
|
|
borderLeftWidth: 1,
|
|
borderLeftColor: theme.colors.border,
|
|
backgroundColor: theme.colors.surfaceSidebar,
|
|
},
|
|
resizeHandle: {
|
|
position: "absolute",
|
|
left: -5,
|
|
top: 0,
|
|
bottom: 0,
|
|
width: 10,
|
|
zIndex: 10,
|
|
},
|
|
sidebarContent: {
|
|
flex: 1,
|
|
minHeight: 0,
|
|
overflow: "hidden",
|
|
},
|
|
header: {
|
|
position: "relative",
|
|
height: HEADER_INNER_HEIGHT,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
paddingHorizontal: theme.spacing[2],
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: theme.colors.border,
|
|
},
|
|
tabsContainer: {
|
|
flexDirection: "row",
|
|
gap: theme.spacing[1],
|
|
},
|
|
tab: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
paddingVertical: theme.spacing[2],
|
|
paddingHorizontal: theme.spacing[3],
|
|
borderRadius: theme.borderRadius.md,
|
|
},
|
|
tabActive: {
|
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
|
},
|
|
tabText: {
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.normal,
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
tabTextActive: {
|
|
color: theme.colors.foreground,
|
|
},
|
|
tabTextMuted: {
|
|
opacity: 0.8,
|
|
},
|
|
headerRightSection: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
closeButton: {
|
|
padding: theme.spacing[2],
|
|
borderRadius: theme.borderRadius.md,
|
|
},
|
|
contentArea: {
|
|
flex: 1,
|
|
minHeight: 0,
|
|
},
|
|
}));
|