Add resizable left sidebar on desktop
Mirror the right sidebar's resize pattern: Gesture.Pan() drag handle on the right edge, animated width, persisted to panel store.
This commit is contained in:
@@ -10,7 +10,7 @@ import {
|
|||||||
type RefObject,
|
type RefObject,
|
||||||
type SetStateAction,
|
type SetStateAction,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { View, Pressable, Text, Platform } from "react-native";
|
import { View, Pressable, Text, Platform, useWindowDimensions } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
@@ -26,7 +26,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import { router, usePathname } from "expo-router";
|
import { router, usePathname } from "expo-router";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH } from "@/stores/panel-store";
|
||||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||||
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
|
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
|
||||||
@@ -48,7 +48,8 @@ import {
|
|||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||||
|
|
||||||
const DESKTOP_SIDEBAR_WIDTH = 320;
|
const MIN_CHAT_WIDTH = 400;
|
||||||
|
|
||||||
type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>;
|
type SidebarShortcutModel = ReturnType<typeof useSidebarShortcutModel>;
|
||||||
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
||||||
|
|
||||||
@@ -631,17 +632,55 @@ function DesktopSidebar({
|
|||||||
const newAgentKeys = useShortcutKeys("new-agent");
|
const newAgentKeys = useShortcutKeys("new-agent");
|
||||||
const dragHandlers = useDesktopDragHandlers();
|
const dragHandlers = useDesktopDragHandlers();
|
||||||
const trafficLightPadding = useTrafficLightPadding();
|
const trafficLightPadding = useTrafficLightPadding();
|
||||||
|
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||||
|
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
||||||
|
const { width: viewportWidth } = useWindowDimensions();
|
||||||
const hostStatusDotStyle = useMemo(
|
const hostStatusDotStyle = useMemo(
|
||||||
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
|
() => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }],
|
||||||
[activeHostStatusColor],
|
[activeHostStatusColor],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const startWidthRef = useRef(sidebarWidth);
|
||||||
|
const resizeWidth = useSharedValue(sidebarWidth);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resizeWidth.value = sidebarWidth;
|
||||||
|
}, [sidebarWidth, resizeWidth]);
|
||||||
|
|
||||||
|
const resizeGesture = useMemo(
|
||||||
|
() =>
|
||||||
|
Gesture.Pan()
|
||||||
|
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
|
||||||
|
.onStart(() => {
|
||||||
|
startWidthRef.current = sidebarWidth;
|
||||||
|
resizeWidth.value = sidebarWidth;
|
||||||
|
})
|
||||||
|
.onUpdate((event) => {
|
||||||
|
// Dragging right (positive translationX) increases width
|
||||||
|
const newWidth = startWidthRef.current + event.translationX;
|
||||||
|
const maxWidth = Math.max(
|
||||||
|
MIN_SIDEBAR_WIDTH,
|
||||||
|
Math.min(MAX_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
|
||||||
|
);
|
||||||
|
const clampedWidth = Math.max(MIN_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth));
|
||||||
|
resizeWidth.value = clampedWidth;
|
||||||
|
})
|
||||||
|
.onEnd(() => {
|
||||||
|
runOnJS(setSidebarWidth)(resizeWidth.value);
|
||||||
|
}),
|
||||||
|
[sidebarWidth, resizeWidth, setSidebarWidth, viewportWidth],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resizeAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
width: resizeWidth.value,
|
||||||
|
}));
|
||||||
|
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
|
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
|
||||||
{trafficLightPadding.top > 0 ? (
|
{trafficLightPadding.top > 0 ? (
|
||||||
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
|
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -740,7 +779,14 @@ function DesktopSidebar({
|
|||||||
anchorRef={hostTriggerRef}
|
anchorRef={hostTriggerRef}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
|
{/* Resize handle - absolutely positioned over right border */}
|
||||||
|
<GestureDetector gesture={resizeGesture}>
|
||||||
|
<View
|
||||||
|
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
|
||||||
|
/>
|
||||||
|
</GestureDetector>
|
||||||
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -766,10 +812,19 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
desktopSidebar: {
|
desktopSidebar: {
|
||||||
|
position: "relative",
|
||||||
borderRightWidth: 1,
|
borderRightWidth: 1,
|
||||||
borderRightColor: theme.colors.border,
|
borderRightColor: theme.colors.border,
|
||||||
backgroundColor: theme.colors.surfaceSidebar,
|
backgroundColor: theme.colors.surfaceSidebar,
|
||||||
},
|
},
|
||||||
|
resizeHandle: {
|
||||||
|
position: "absolute",
|
||||||
|
right: -5,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: 10,
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
sidebarHeader: {
|
sidebarHeader: {
|
||||||
height: {
|
height: {
|
||||||
xs: HEADER_INNER_HEIGHT_MOBILE,
|
xs: HEADER_INNER_HEIGHT_MOBILE,
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ export interface ExplorerCheckoutContext {
|
|||||||
isGit: boolean;
|
isGit: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SIDEBAR_WIDTH = 320;
|
||||||
|
export const MIN_SIDEBAR_WIDTH = 200;
|
||||||
|
export const MAX_SIDEBAR_WIDTH = 600;
|
||||||
|
|
||||||
export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 400;
|
export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 400;
|
||||||
export const MIN_EXPLORER_SIDEBAR_WIDTH = 280;
|
export const MIN_EXPLORER_SIDEBAR_WIDTH = 280;
|
||||||
// Upper bound is intentionally generous; desktop resizing enforces a min-chat-width constraint.
|
// Upper bound is intentionally generous; desktop resizing enforces a min-chat-width constraint.
|
||||||
@@ -62,6 +66,7 @@ interface PanelState {
|
|||||||
explorerTab: ExplorerTab;
|
explorerTab: ExplorerTab;
|
||||||
explorerTabByCheckout: Record<string, ExplorerTab>;
|
explorerTabByCheckout: Record<string, ExplorerTab>;
|
||||||
activeExplorerCheckout: ExplorerCheckoutContext | null;
|
activeExplorerCheckout: ExplorerCheckoutContext | null;
|
||||||
|
sidebarWidth: number;
|
||||||
explorerWidth: number;
|
explorerWidth: number;
|
||||||
explorerSortOption: SortOption;
|
explorerSortOption: SortOption;
|
||||||
explorerFilesSplitRatio: number;
|
explorerFilesSplitRatio: number;
|
||||||
@@ -79,6 +84,7 @@ interface PanelState {
|
|||||||
setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void;
|
setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void;
|
||||||
activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void;
|
activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void;
|
||||||
setActiveExplorerCheckout: (checkout: ExplorerCheckoutContext | null) => void;
|
setActiveExplorerCheckout: (checkout: ExplorerCheckoutContext | null) => void;
|
||||||
|
setSidebarWidth: (width: number) => void;
|
||||||
setExplorerWidth: (width: number) => void;
|
setExplorerWidth: (width: number) => void;
|
||||||
setExplorerSortOption: (option: SortOption) => void;
|
setExplorerSortOption: (option: SortOption) => void;
|
||||||
setExplorerFilesSplitRatio: (ratio: number) => void;
|
setExplorerFilesSplitRatio: (ratio: number) => void;
|
||||||
@@ -91,6 +97,10 @@ function clampNumber(value: number, min: number, max: number): number {
|
|||||||
return Math.max(min, Math.min(max, value));
|
return Math.max(min, Math.min(max, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clampSidebarWidth(width: number): number {
|
||||||
|
return clampNumber(width, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH);
|
||||||
|
}
|
||||||
|
|
||||||
function clampWidth(width: number): number {
|
function clampWidth(width: number): number {
|
||||||
return clampNumber(width, MIN_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH);
|
return clampNumber(width, MIN_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH);
|
||||||
}
|
}
|
||||||
@@ -129,6 +139,7 @@ export const usePanelStore = create<PanelState>()(
|
|||||||
explorerTab: "changes",
|
explorerTab: "changes",
|
||||||
explorerTabByCheckout: {},
|
explorerTabByCheckout: {},
|
||||||
activeExplorerCheckout: null,
|
activeExplorerCheckout: null,
|
||||||
|
sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
|
||||||
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
|
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
|
||||||
explorerSortOption: "name",
|
explorerSortOption: "name",
|
||||||
explorerFilesSplitRatio: DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
|
explorerFilesSplitRatio: DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
|
||||||
@@ -244,6 +255,7 @@ export const usePanelStore = create<PanelState>()(
|
|||||||
}
|
}
|
||||||
return { activeExplorerCheckout: checkout };
|
return { activeExplorerCheckout: checkout };
|
||||||
}),
|
}),
|
||||||
|
setSidebarWidth: (width) => set({ sidebarWidth: clampSidebarWidth(width) }),
|
||||||
setExplorerWidth: (width) => set({ explorerWidth: clampWidth(width) }),
|
setExplorerWidth: (width) => set({ explorerWidth: clampWidth(width) }),
|
||||||
setExplorerSortOption: (option) => set({ explorerSortOption: option }),
|
setExplorerSortOption: (option) => set({ explorerSortOption: option }),
|
||||||
setExplorerFilesSplitRatio: (ratio) =>
|
setExplorerFilesSplitRatio: (ratio) =>
|
||||||
@@ -255,7 +267,7 @@ export const usePanelStore = create<PanelState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "panel-state",
|
name: "panel-state",
|
||||||
version: 5,
|
version: 6,
|
||||||
storage: createJSONStorage(() => AsyncStorage),
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
migrate: (persistedState, version) => {
|
migrate: (persistedState, version) => {
|
||||||
const state = persistedState as Partial<PanelState> & Record<string, unknown>;
|
const state = persistedState as Partial<PanelState> & Record<string, unknown>;
|
||||||
@@ -310,6 +322,10 @@ export const usePanelStore = create<PanelState>()(
|
|||||||
state.explorerTabByCheckout = next;
|
state.explorerTabByCheckout = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (version < 6 || typeof state.sidebarWidth !== "number") {
|
||||||
|
state.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
state.activeExplorerCheckout = null;
|
state.activeExplorerCheckout = null;
|
||||||
|
|
||||||
return state as PanelState;
|
return state as PanelState;
|
||||||
@@ -319,6 +335,7 @@ export const usePanelStore = create<PanelState>()(
|
|||||||
desktop: state.desktop,
|
desktop: state.desktop,
|
||||||
explorerTab: state.explorerTab,
|
explorerTab: state.explorerTab,
|
||||||
explorerTabByCheckout: state.explorerTabByCheckout,
|
explorerTabByCheckout: state.explorerTabByCheckout,
|
||||||
|
sidebarWidth: state.sidebarWidth,
|
||||||
explorerWidth: state.explorerWidth,
|
explorerWidth: state.explorerWidth,
|
||||||
explorerSortOption: state.explorerSortOption,
|
explorerSortOption: state.explorerSortOption,
|
||||||
explorerFilesSplitRatio: state.explorerFilesSplitRatio,
|
explorerFilesSplitRatio: state.explorerFilesSplitRatio,
|
||||||
|
|||||||
Reference in New Issue
Block a user