From 266d54463be65f01ef3e5e0d57505427d70e598e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 17 Jul 2026 12:27:15 +0200 Subject: [PATCH] feat(app): highlight sidebar resize handles --- .../app/e2e/sidebar-resize-handle.spec.ts | 27 ++++++ .../app/src/components/explorer-sidebar.tsx | 21 ++--- packages/app/src/components/left-sidebar.tsx | 26 ++---- .../src/components/sidebar-resize-handle.tsx | 89 +++++++++++++++++++ 4 files changed, 130 insertions(+), 33 deletions(-) create mode 100644 packages/app/e2e/sidebar-resize-handle.spec.ts create mode 100644 packages/app/src/components/sidebar-resize-handle.tsx diff --git a/packages/app/e2e/sidebar-resize-handle.spec.ts b/packages/app/e2e/sidebar-resize-handle.spec.ts new file mode 100644 index 000000000..eda7f2c81 --- /dev/null +++ b/packages/app/e2e/sidebar-resize-handle.spec.ts @@ -0,0 +1,27 @@ +import { expect, test, type Page } from "./fixtures"; + +test.use({ viewport: { width: 1600, height: 900 } }); + +async function expectBorderHighlight(page: Page, testID: string) { + const handle = page.getByTestId(testID); + await expect(handle).toBeVisible(); + await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0); + + await handle.hover(); + await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0); + + const highlight = page.getByTestId(`${testID}-highlight`); + await expect(highlight).toBeVisible(); + await expect(highlight).toHaveCSS("width", "1px"); + await expect(highlight).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); +} + +test("both sidebar borders highlight on hover", async ({ page, withWorkspace }) => { + const workspace = await withWorkspace({ prefix: "sidebar-resize-handle-" }); + await workspace.navigateTo(); + + await expectBorderHighlight(page, "left-sidebar-resize-handle"); + + await page.getByTestId("workspace-explorer-toggle").first().click(); + await expectBorderHighlight(page, "explorer-sidebar-resize-handle"); +}); diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 0af8a0398..8b6fa0e49 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -8,7 +8,7 @@ import { } 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 { Gesture } from "react-native-gesture-handler"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { X } from "lucide-react-native"; import { useTranslation } from "react-i18next"; @@ -33,7 +33,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useHasOwnedWindowChromeObstruction, WindowChromeSafeArea } from "@/utils/desktop-window"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { RetainedPanelActivity } from "@/components/retained-panel"; -import { isWeb } from "@/constants/platform"; +import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout"; @@ -212,9 +212,11 @@ export function ExplorerSidebar({ return ( - - - + ({ 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, @@ -524,4 +518,3 @@ const styles = StyleSheet.create((theme) => ({ })); const DESKTOP_SIDEBAR_BORDER_STYLE = [styles.desktopSidebarBorder, { flex: 1 }]; -const RESIZE_HANDLE_STYLE = [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)]; diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 4dbac08be..0e36f9c78 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -20,7 +20,7 @@ import { View, type PressableStateCallbackType, } from "react-native"; -import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import { Gesture } from "react-native-gesture-handler"; import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -30,10 +30,10 @@ import { HostPicker } from "@/components/hosts/host-picker"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/sidebar-display-preferences-menu"; import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu"; +import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; -import { isWeb } from "@/constants/platform"; import { useOpenAddProject } from "@/hooks/use-open-add-project"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { canCreateWorktreeForProjectKind } from "@/projects/host-projects"; @@ -765,11 +765,6 @@ function DesktopSidebar({ () => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome], [ownsTopLeft], ); - const resizeHandleStyle = useMemo( - () => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)], - [], - ); - return ( - {/* Resize handle - absolutely positioned over right border */} - - - + ); @@ -998,14 +994,6 @@ const styles = StyleSheet.create((theme) => ({ borderRightColor: theme.colors.border, backgroundColor: theme.colors.surfaceSidebar, }, - resizeHandle: { - position: "absolute", - right: -5, - top: 0, - bottom: 0, - width: 10, - zIndex: 10, - }, sidebarDragArea: { position: "relative", }, diff --git a/packages/app/src/components/sidebar-resize-handle.tsx b/packages/app/src/components/sidebar-resize-handle.tsx new file mode 100644 index 000000000..98f4e918b --- /dev/null +++ b/packages/app/src/components/sidebar-resize-handle.tsx @@ -0,0 +1,89 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Pressable, View } from "react-native"; +import { GestureDetector, type GestureType } from "react-native-gesture-handler"; +import { StyleSheet } from "react-native-unistyles"; +import { isWeb } from "@/constants/platform"; + +interface SidebarResizeHandleProps { + edge: "left" | "right"; + gesture: GestureType; + testID: string; +} + +const HIGHLIGHT_DELAY_MS = 100; + +const webResizeCursorStyle = isWeb + ? ({ + cursor: "col-resize", + } as object) + : null; + +export function SidebarResizeHandle({ edge, gesture, testID }: SidebarResizeHandleProps) { + const [highlighted, setHighlighted] = useState(false); + const highlightTimerRef = useRef | null>(null); + const hitAreaStyle = edge === "left" ? LEFT_HIT_AREA_STYLE : RIGHT_HIT_AREA_STYLE; + + const cancelHighlightTimer = useCallback(() => { + if (highlightTimerRef.current === null) return; + clearTimeout(highlightTimerRef.current); + highlightTimerRef.current = null; + }, []); + + const handleHoverIn = useCallback(() => { + cancelHighlightTimer(); + highlightTimerRef.current = setTimeout(() => { + highlightTimerRef.current = null; + setHighlighted(true); + }, HIGHLIGHT_DELAY_MS); + }, [cancelHighlightTimer]); + + const handleHoverOut = useCallback(() => { + cancelHighlightTimer(); + setHighlighted(false); + }, [cancelHighlightTimer]); + + useEffect(() => cancelHighlightTimer, [cancelHighlightTimer]); + + return ( + + + {highlighted ? ( + + ) : null} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + hitArea: { + position: "absolute", + top: 0, + bottom: 0, + width: 10, + zIndex: 10, + }, + leftEdge: { + left: -5, + }, + rightEdge: { + right: -5, + }, + highlight: { + position: "absolute", + top: 0, + bottom: 0, + left: 5, + width: 1, + backgroundColor: theme.colors.foreground, + opacity: 0.25, + }, +})); + +const LEFT_HIT_AREA_STYLE = [styles.hitArea, styles.leftEdge, webResizeCursorStyle]; +const RIGHT_HIT_AREA_STYLE = [styles.hitArea, styles.rightEdge, webResizeCursorStyle];