Merge branch 'streamline-file-explorer'

This commit is contained in:
Mohamed Boudra
2026-02-04 13:17:23 +07:00
2 changed files with 155 additions and 132 deletions

View File

@@ -15,6 +15,11 @@ import {
} from "react-native";
import { ScrollView, Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
} from "react-native-reanimated";
import { Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
import {
@@ -309,7 +314,6 @@ 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;
@@ -317,60 +321,78 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
? splitRatio
: DEFAULT_EXPLORER_FILES_SPLIT_RATIO;
const treePaneWidth = useMemo(() => {
if (!shouldShowInlinePreview || containerWidth <= 0) {
return null;
const splitAvailableWidth = useSharedValue(0);
const splitMaxTreeWidth = useSharedValue(minTreeWidth);
const splitTreeWidth = useSharedValue(minTreeWidth);
const splitStartTreeWidth = useSharedValue(minTreeWidth);
useEffect(() => {
if (!shouldShowInlinePreview) {
return;
}
if (containerWidth <= 0) {
return;
}
const available = Math.max(0, containerWidth - dividerWidth);
const available = Math.max(0, containerWidth);
const maxTree = Math.max(minTreeWidth, available - minPreviewWidth);
const raw = Math.round(available * safeSplitRatio);
return Math.max(minTreeWidth, Math.min(maxTree, raw));
}, [containerWidth, safeSplitRatio, shouldShowInlinePreview]);
const desired = Math.round(available * safeSplitRatio);
const clamped = Math.max(minTreeWidth, Math.min(maxTree, desired));
const resizeStartRef = useRef<{ startWidth: number; available: number } | null>(null);
splitAvailableWidth.value = available;
splitMaxTreeWidth.value = maxTree;
splitTreeWidth.value = clamped;
}, [
containerWidth,
minPreviewWidth,
minTreeWidth,
safeSplitRatio,
shouldShowInlinePreview,
splitAvailableWidth,
splitMaxTreeWidth,
splitTreeWidth,
]);
const treePaneAnimatedStyle = useAnimatedStyle(() => ({
width: splitTreeWidth.value,
flexBasis: splitTreeWidth.value,
flexGrow: 0,
flexShrink: 0,
}));
const splitResizeGesture = useMemo(() => {
if (!shouldShowInlinePreview || containerWidth <= 0 || treePaneWidth === null) {
if (isMobile || !shouldShowInlinePreview) {
return Gesture.Pan().enabled(false);
}
const available = Math.max(0, containerWidth - dividerWidth);
return Gesture.Pan()
.enabled(!isMobile)
.runOnJS(true)
.activeOffsetX([-2, 2])
.failOffsetY([-5, 5])
.hitSlop({ left: 12, right: 12, top: 0, bottom: 0 })
.onBegin(() => {
resizeStartRef.current = {
startWidth: treePaneWidth,
available,
};
.onStart(() => {
splitStartTreeWidth.value = splitTreeWidth.value;
})
.onUpdate((event) => {
const start = resizeStartRef.current;
if (!start) {
return;
}
const nextWidth = start.startWidth + (event.translationX ?? 0);
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 : safeSplitRatio;
setSplitRatio(nextRatio);
const nextWidth = splitStartTreeWidth.value + event.translationX;
const clamped = Math.max(
minTreeWidth,
Math.min(splitMaxTreeWidth.value, nextWidth)
);
splitTreeWidth.value = clamped;
})
.onFinalize(() => {
resizeStartRef.current = null;
.onEnd(() => {
const available = splitAvailableWidth.value;
const ratio = available > 0 ? splitTreeWidth.value / available : safeSplitRatio;
runOnJS(setSplitRatio)(ratio);
});
}, [
containerWidth,
dividerWidth,
isMobile,
minTreeWidth,
safeSplitRatio,
setSplitRatio,
shouldShowInlinePreview,
safeSplitRatio,
treePaneWidth,
splitAvailableWidth,
splitMaxTreeWidth,
splitStartTreeWidth,
splitTreeWidth,
]);
const renderTreeRow = useCallback(
@@ -432,16 +454,6 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
]
);
const listHeaderComponent = useMemo(() => {
return (
<View style={styles.headerContainer}>
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
</Pressable>
</View>
);
}, [currentSortLabel, handleSortCycle]);
const handlePreviewSheetChange = useCallback(
(index: number) => {
if (index === -1) {
@@ -504,32 +516,36 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
</View>
) : (
<View style={styles.desktopSplit}>
<View
<Animated.View
style={[
styles.treePane,
shouldShowInlinePreview && styles.treePaneWithPreview,
shouldShowInlinePreview && treePaneWidth !== null
? { width: treePaneWidth, flex: 0, flexGrow: 0, flexShrink: 0 }
: null,
shouldShowInlinePreview
? [styles.treePaneWithPreview, { minWidth: minTreeWidth }, treePaneAnimatedStyle]
: styles.treePaneFill,
]}
>
<View style={styles.paneHeader} testID="files-pane-header">
<View style={styles.paneHeaderLeft} />
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
</Pressable>
</View>
<FlatList
style={styles.treeList}
data={treeRows}
renderItem={renderTreeRow}
keyExtractor={(row) => row.entry.path}
contentContainerStyle={styles.entriesContent}
ListHeaderComponent={listHeaderComponent}
initialNumToRender={24}
maxToRenderPerBatch={40}
windowSize={12}
/>
</View>
</Animated.View>
{shouldShowInlinePreview ? (
<>
<View style={styles.previewPane}>
<GestureDetector gesture={splitResizeGesture}>
<View
pointerEvents="box-only"
style={[
styles.splitResizeHandle,
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
@@ -537,33 +553,20 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
]}
/>
</GestureDetector>
<View style={styles.previewPane}>
<View style={styles.previewHeaderContainer}>
<View style={styles.previewHeaderInner}>
<Pressable
onPress={() => {
if (selectedEntryPath) {
void Clipboard.setStringAsync(selectedEntryPath);
}
}}
style={({ hovered, pressed }) => [
styles.previewHeaderRow,
(hovered || pressed) && styles.previewHeaderRowHovered,
]}
>
<Text style={styles.previewHeaderText} numberOfLines={1}>
{selectedEntryPath?.split("/").pop() ?? "Preview"}
</Text>
{isPreviewLoading ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : null}
</Pressable>
<View style={styles.paneHeader} testID="preview-pane-header">
<Text style={styles.previewHeaderText} numberOfLines={1}>
{selectedEntryPath?.split("/").pop() ?? "Preview"}
</Text>
<View style={styles.previewHeaderRight}>
{isPreviewLoading ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : null}
<Pressable
onPress={handleClosePreview}
hitSlop={8}
style={({ hovered, pressed }) => [
styles.iconButton,
(hovered || pressed) && styles.previewHeaderRowHovered,
(hovered || pressed) && styles.iconButtonHovered,
]}
accessibilityRole="button"
accessibilityLabel="Close preview"
@@ -575,7 +578,6 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
<FilePreviewBody preview={preview} isLoading={isPreviewLoading} variant="inline" />
</View>
</>
) : null}
</View>
)}
@@ -697,22 +699,26 @@ function FilePreviewBody({
if (preview.kind === "text") {
if (variant === "sheet") {
return (
<BottomSheetScrollView
style={styles.previewContent}
contentContainerStyle={styles.previewTextContent}
>
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
<BottomSheetScrollView style={styles.previewContent}>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.previewCodeScrollContent}
>
<Text style={styles.codeText}>{preview.content}</Text>
</ScrollView>
</BottomSheetScrollView>
);
}
return (
<RNScrollView
style={styles.previewContent}
contentContainerStyle={styles.previewTextContent}
>
<RNScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
<RNScrollView style={styles.previewContent}>
<RNScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.previewCodeScrollContent}
>
<Text style={styles.codeText}>{preview.content}</Text>
</RNScrollView>
</RNScrollView>
@@ -965,33 +971,56 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 0,
},
treePane: {
flex: 1,
minWidth: 0,
},
treePaneFill: {
flex: 1,
},
treePaneWithPreview: {
flex: 0,
flexGrow: 0,
flexShrink: 0,
},
splitResizeHandle: {
position: "absolute",
left: -5,
top: 0,
bottom: 0,
width: 10,
backgroundColor: theme.colors.surface0,
borderLeftWidth: 1,
borderLeftColor: theme.colors.border,
borderRightWidth: 1,
borderRightColor: theme.colors.border,
zIndex: 20,
},
previewPane: {
flex: 1,
minWidth: 0,
position: "relative",
borderLeftWidth: 1,
borderLeftColor: theme.colors.border,
},
headerContainer: {
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1],
},
sortButton: {
alignSelf: "flex-end",
paneHeader: {
flexDirection: "row",
alignItems: "center",
paddingVertical: theme.spacing[1],
justifyContent: "space-between",
height: 32 + theme.spacing[2] * 2,
paddingHorizontal: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
},
paneHeaderLeft: {
flex: 1,
minWidth: 0,
},
previewHeaderRight: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flexShrink: 0,
},
sortButton: {
height: 32,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
@@ -1001,6 +1030,10 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
treeList: {
flex: 1,
minHeight: 0,
},
entriesContent: {
paddingBottom: theme.spacing[4],
},
@@ -1119,36 +1152,11 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
},
previewHeaderContainer: {
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
previewHeaderInner: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[2],
},
previewHeaderRow: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
borderRadius: theme.borderRadius.md,
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
},
previewHeaderRowHovered: {
backgroundColor: theme.colors.surface2,
},
previewHeaderText: {
flex: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
fontWeight: theme.fontWeight.normal,
},
iconButton: {
width: 32,
@@ -1157,11 +1165,16 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "center",
},
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
previewContent: {
flex: 1,
},
previewTextContent: {
padding: theme.spacing[3],
previewCodeScrollContent: {
paddingTop: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[3] + theme.spacing[2],
},
codeText: {
color: theme.colors.foreground,

View File

@@ -30,7 +30,7 @@ interface DesktopSidebarState {
export type ExplorerTab = "changes" | "files";
export type SortOption = "name" | "modified" | "size";
export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = Platform.OS === "web" ? 520 : 400;
export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = Platform.OS === "web" ? 640 : 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;
@@ -164,7 +164,7 @@ export const usePanelStore = create<PanelState>()(
}),
{
name: "panel-state",
version: 2,
version: 3,
storage: createJSONStorage(() => AsyncStorage),
migrate: (persistedState, version) => {
const state = persistedState as Partial<PanelState> & Record<string, unknown>;
@@ -187,6 +187,16 @@ export const usePanelStore = create<PanelState>()(
}
}
if (version < 3) {
if (
Platform.OS === "web" &&
typeof state.explorerWidth === "number" &&
(state.explorerWidth === 400 || state.explorerWidth === 520)
) {
state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH;
}
}
return state as PanelState;
},
partialize: (state) => ({