Merge branch 'streamline-file-explorer'

This commit is contained in:
Mohamed Boudra
2026-02-04 10:50:17 +07:00
2 changed files with 167 additions and 25 deletions

View File

@@ -10,9 +10,10 @@ import {
ScrollView as RNScrollView,
Text,
View,
Platform,
useWindowDimensions,
} from "react-native";
import { ScrollView } from "react-native-gesture-handler";
import { ScrollView, Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
@@ -83,6 +84,8 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
} = useFileExplorerActions(serverId);
const sortOption = usePanelStore((state) => state.explorerSortOption);
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
const splitRatio = usePanelStore((state) => state.explorerFilesSplitRatio);
const setSplitRatio = usePanelStore((state) => state.setExplorerFilesSplitRatio);
const directories = explorerState?.directories ?? new Map();
const files = explorerState?.files ?? new Map();
@@ -110,6 +113,7 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
const [menuEntry, setMenuEntry] = useState<ExplorerEntry | null>(null);
const [menuAnchor, setMenuAnchor] = useState({ top: 0, left: 0 });
const [menuHeight, setMenuHeight] = useState(0);
const [containerWidth, setContainerWidth] = useState(0);
// Bottom sheet for file preview (mobile)
const previewSheetRef = useRef<BottomSheetModal>(null);
@@ -261,7 +265,7 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
if (!menuEntry) {
return null;
}
const menuWidth = 180;
const menuWidth = 240;
const horizontalPadding = theme.spacing[2];
const verticalPadding = theme.spacing[2];
const maxLeft = Math.max(horizontalPadding, windowWidth - menuWidth - horizontalPadding);
@@ -301,6 +305,66 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
Boolean(isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === ".");
const shouldShowInlinePreview = !isMobile && Boolean(selectedEntryPath);
const dividerWidth = 10;
const minTreeWidth = 220;
const minPreviewWidth = 320;
const treePaneWidth = useMemo(() => {
if (!shouldShowInlinePreview || containerWidth <= 0) {
return null;
}
const available = Math.max(0, containerWidth - dividerWidth);
const maxTree = Math.max(minTreeWidth, available - minPreviewWidth);
const raw = Math.round(available * splitRatio);
return Math.max(minTreeWidth, Math.min(maxTree, raw));
}, [containerWidth, shouldShowInlinePreview, splitRatio]);
const resizeStartRef = useRef<{ startWidth: number; startX: number; available: number } | null>(
null
);
const splitResizeGesture = useMemo(() => {
if (!shouldShowInlinePreview || containerWidth <= 0 || treePaneWidth === null) {
return Gesture.Pan().enabled(false);
}
const available = Math.max(0, containerWidth - dividerWidth);
return Gesture.Pan()
.enabled(!isMobile)
.hitSlop({ left: 12, right: 12, top: 0, bottom: 0 })
.onBegin((event) => {
resizeStartRef.current = {
startWidth: treePaneWidth,
startX: event.absoluteX,
available,
};
})
.onUpdate((event) => {
const start = resizeStartRef.current;
if (!start) {
return;
}
const deltaX = event.absoluteX - start.startX;
const nextWidth = start.startWidth + deltaX;
const maxTree = Math.max(minTreeWidth, start.available - minPreviewWidth);
const clamped = Math.max(minTreeWidth, Math.min(maxTree, nextWidth));
const nextRatio = start.available > 0 ? clamped / start.available : splitRatio;
setSplitRatio(nextRatio);
})
.onFinalize(() => {
resizeStartRef.current = null;
});
}, [
containerWidth,
dividerWidth,
isMobile,
setSplitRatio,
shouldShowInlinePreview,
splitRatio,
treePaneWidth,
]);
const renderTreeRow = useCallback(
({ item }: ListRenderItemInfo<TreeRow>) => {
@@ -401,7 +465,10 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
}
return (
<View style={styles.container}>
<View
style={styles.container}
onLayout={(event) => setContainerWidth(event.nativeEvent.layout.width)}
>
{error ? (
<View style={styles.centerState}>
<Text style={styles.errorText}>{error}</Text>
@@ -430,7 +497,13 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
</View>
) : (
<View style={styles.desktopSplit}>
<View style={[styles.treePane, shouldShowInlinePreview && styles.treePaneWithPreview]}>
<View
style={[
styles.treePane,
shouldShowInlinePreview && styles.treePaneWithPreview,
shouldShowInlinePreview && treePaneWidth !== null ? { width: treePaneWidth } : null,
]}
>
<FlatList
data={treeRows}
renderItem={renderTreeRow}
@@ -444,7 +517,16 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
</View>
{shouldShowInlinePreview ? (
<View style={styles.previewPane}>
<>
<GestureDetector gesture={splitResizeGesture}>
<View
style={[
styles.splitResizeHandle,
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
]}
/>
</GestureDetector>
<View style={styles.previewPane}>
<View style={styles.previewHeaderContainer}>
<View style={styles.previewHeaderInner}>
<Pressable
@@ -482,6 +564,7 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
<FilePreviewBody preview={preview} isLoading={isPreviewLoading} variant="inline" />
</View>
</>
) : null}
</View>
)}
@@ -517,7 +600,10 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
</View>
<View style={styles.entryMenuDivider} />
<Pressable
style={styles.entryMenuItem}
style={({ hovered, pressed }) => [
styles.entryMenuItem,
(hovered || pressed) && styles.entryMenuItemHovered,
]}
onPress={() => {
handleCopyPath(menuEntry.path);
handleCloseMenu();
@@ -527,7 +613,10 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
</Pressable>
{menuEntry.kind === "file" ? (
<Pressable
style={styles.entryMenuItem}
style={({ hovered, pressed }) => [
styles.entryMenuItem,
(hovered || pressed) && styles.entryMenuItemHovered,
]}
onPress={async () => {
handleCloseMenu();
handleDownloadEntry(menuEntry);
@@ -869,8 +958,12 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0,
},
treePaneWithPreview: {
borderRightWidth: 1,
borderRightColor: theme.colors.border,
},
splitResizeHandle: {
width: 10,
backgroundColor: "transparent",
borderLeftWidth: 1,
borderLeftColor: theme.colors.border,
},
previewPane: {
flex: 1,
@@ -974,36 +1067,43 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: "rgba(0, 0, 0, 0.2)",
},
entryMenu: {
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface2,
padding: theme.spacing[1],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface1,
overflow: "hidden",
...(Platform.OS === "web"
? ({ boxShadow: "0 10px 30px rgba(0, 0, 0, 0.35)" } as any)
: {
shadowColor: "#000",
shadowOpacity: 0.35,
shadowRadius: 16,
shadowOffset: { width: 0, height: 10 },
elevation: 14,
}),
},
entryMenuHeader: {
flexDirection: "row",
justifyContent: "space-between",
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
},
entryMenuMeta: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontSize: theme.fontSize.sm,
},
entryMenuDivider: {
height: 1,
backgroundColor: theme.colors.border,
marginHorizontal: theme.spacing[2],
marginVertical: theme.spacing[1],
},
entryMenuItem: {
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
},
entryMenuItemHovered: {
backgroundColor: theme.colors.surface2,
},
entryMenuText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
},
previewHeaderContainer: {

View File

@@ -30,11 +30,15 @@ interface DesktopSidebarState {
export type ExplorerTab = "changes" | "files";
export type SortOption = "name" | "modified" | "size";
export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 400;
export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = Platform.OS === "web" ? 520 : 400;
export const MIN_EXPLORER_SIDEBAR_WIDTH = 280;
// Upper bound is intentionally generous; desktop resizing enforces a min-chat-width constraint.
export const MAX_EXPLORER_SIDEBAR_WIDTH = 2000;
export const DEFAULT_EXPLORER_FILES_SPLIT_RATIO = 0.38;
export const MIN_EXPLORER_FILES_SPLIT_RATIO = 0.2;
export const MAX_EXPLORER_FILES_SPLIT_RATIO = 0.8;
interface PanelState {
// Mobile: which panel is currently shown
mobileView: MobilePanelView;
@@ -46,6 +50,7 @@ interface PanelState {
explorerTab: ExplorerTab;
explorerWidth: number;
explorerSortOption: SortOption;
explorerFilesSplitRatio: number;
// Actions
openAgentList: () => void;
@@ -58,10 +63,19 @@ interface PanelState {
setExplorerTab: (tab: ExplorerTab) => void;
setExplorerWidth: (width: number) => void;
setExplorerSortOption: (option: SortOption) => void;
setExplorerFilesSplitRatio: (ratio: number) => void;
}
function clampNumber(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function clampWidth(width: number): number {
return Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, width));
return clampNumber(width, MIN_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH);
}
function clampExplorerFilesSplitRatio(ratio: number): number {
return clampNumber(ratio, MIN_EXPLORER_FILES_SPLIT_RATIO, MAX_EXPLORER_FILES_SPLIT_RATIO);
}
const DEFAULT_DESKTOP_OPEN = Platform.OS === "web";
@@ -82,6 +96,7 @@ export const usePanelStore = create<PanelState>()(
explorerTab: "changes",
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
explorerSortOption: "name",
explorerFilesSplitRatio: DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
openAgentList: () =>
set((state) => ({
@@ -137,16 +152,39 @@ export const usePanelStore = create<PanelState>()(
setExplorerTab: (tab) => set({ explorerTab: tab }),
setExplorerWidth: (width) => set({ explorerWidth: clampWidth(width) }),
setExplorerSortOption: (option) => set({ explorerSortOption: option }),
setExplorerFilesSplitRatio: (ratio) =>
set({ explorerFilesSplitRatio: clampExplorerFilesSplitRatio(ratio) }),
}),
{
name: "panel-state",
version: 2,
storage: createJSONStorage(() => AsyncStorage),
migrate: (persistedState, version) => {
const state = persistedState as Partial<PanelState> & Record<string, unknown>;
if (version < 2) {
if (
Platform.OS === "web" &&
typeof state.explorerWidth === "number" &&
state.explorerWidth === 400
) {
state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH;
}
if (typeof state.explorerFilesSplitRatio !== "number") {
state.explorerFilesSplitRatio = DEFAULT_EXPLORER_FILES_SPLIT_RATIO;
}
}
return state as PanelState;
},
partialize: (state) => ({
mobileView: state.mobileView,
desktop: state.desktop,
explorerTab: state.explorerTab,
explorerWidth: state.explorerWidth,
explorerSortOption: state.explorerSortOption,
explorerFilesSplitRatio: state.explorerFilesSplitRatio,
}),
}
)
@@ -177,9 +215,11 @@ export function usePanelState(isMobile: boolean) {
explorerTab: store.explorerTab,
explorerWidth: store.explorerWidth,
explorerSortOption: store.explorerSortOption,
explorerFilesSplitRatio: store.explorerFilesSplitRatio,
setExplorerTab: store.setExplorerTab,
setExplorerWidth: store.setExplorerWidth,
setExplorerSortOption: store.setExplorerSortOption,
setExplorerFilesSplitRatio: store.setExplorerFilesSplitRatio,
};
}
@@ -203,8 +243,10 @@ export function usePanelState(isMobile: boolean) {
explorerTab: store.explorerTab,
explorerWidth: store.explorerWidth,
explorerSortOption: store.explorerSortOption,
explorerFilesSplitRatio: store.explorerFilesSplitRatio,
setExplorerTab: store.setExplorerTab,
setExplorerWidth: store.setExplorerWidth,
setExplorerSortOption: store.setExplorerSortOption,
setExplorerFilesSplitRatio: store.setExplorerFilesSplitRatio,
};
}