diff --git a/apps/desktop/src/renderer/screens/main/components/Sidebar/Sidebar.tsx b/apps/desktop/src/renderer/screens/main/components/Sidebar/Sidebar.tsx index 69e98a2a3c5..91d1535d480 100644 --- a/apps/desktop/src/renderer/screens/main/components/Sidebar/Sidebar.tsx +++ b/apps/desktop/src/renderer/screens/main/components/Sidebar/Sidebar.tsx @@ -10,7 +10,6 @@ import { WorktreeList, } from "./components"; import { ModeCarousel, type SidebarMode } from "./components/ModeCarousel"; -import { ModeSwitcher } from "./components/ModeSwitcher"; interface SidebarProps { workspaces: Workspace[]; @@ -328,12 +327,6 @@ export function Sidebar({ return (
- void; - children: (mode: SidebarMode, isActive: boolean) => ReactNode; - onScrollProgress: (progress: MotionValue) => void; - isDragging?: boolean; -} +import { useCallback, useRef, useState } from "react"; +import { ModeContent } from "./components/ModeContent"; +import { ModeHeader } from "./components/ModeHeader"; +import { ModeNavigation } from "./components/ModeNavigation"; +import { useModeDetection } from "./hooks/useModeDetection"; +import { useScrollProgress } from "./hooks/useScrollProgress"; +import { useScrollSnap } from "./hooks/useScrollSnap"; +import type { ModeCarouselProps } from "./types"; export function ModeCarousel({ modes, @@ -26,13 +15,8 @@ export function ModeCarousel({ onScrollProgress, isDragging = false, }: ModeCarouselProps) { - const scrollTimeoutRef = useRef(undefined); const isInitialMount = useRef(true); - const currentIndex = modes.findIndex((m) => m === currentMode); - const initialProgress = currentIndex >= 0 ? currentIndex : 0; - const modeProgress = useMotionValue(initialProgress); - const [scrollContainer, setScrollContainer] = useState( null, ); @@ -44,143 +28,87 @@ export function ModeCarousel({ } }, []); - // Track scroll position and update motion value - useEffect(() => { - if (!scrollContainer) return; - - let rafId: number | undefined; - - const updateProgress = () => { - const scrollLeft = scrollContainer.scrollLeft; - const containerWidth = scrollContainer.offsetWidth; - const progress = scrollLeft / containerWidth; - modeProgress.set(progress); - }; - - const handleScroll = () => { - // Use requestAnimationFrame for smooth updates - if (rafId !== undefined) { - cancelAnimationFrame(rafId); - } - rafId = requestAnimationFrame(updateProgress); - }; - - scrollContainer.addEventListener("scroll", handleScroll, { passive: true }); - - // Initial value - updateProgress(); - - return () => { - scrollContainer.removeEventListener("scroll", handleScroll); - if (rafId !== undefined) { - cancelAnimationFrame(rafId); - } - }; - }, [scrollContainer, modeProgress]); - - // Expose scroll progress to parent - useEffect(() => { - onScrollProgress(modeProgress); - }, [onScrollProgress, modeProgress]); - - // Scroll to current mode when it changes externally - useEffect(() => { - if (!scrollContainer || currentIndex < 0) return; - - const targetScrollX = currentIndex * scrollContainer.offsetWidth; - - // Only scroll if we're not already at the target position - if (Math.abs(scrollContainer.scrollLeft - targetScrollX) > 10) { - scrollContainer.scrollTo({ - left: targetScrollX, - behavior: isInitialMount.current ? "auto" : "smooth", - }); - } - - // Mark that initial mount is complete - isInitialMount.current = false; - }, [currentIndex, scrollContainer]); - - // Detect when user finishes scrolling and update current mode - useEffect(() => { - if (!scrollContainer || isDragging) return; - - const handleScroll = () => { - // Clear existing timeout - if (scrollTimeoutRef.current) { - clearTimeout(scrollTimeoutRef.current); - } - - // Wait for scroll to settle (150ms after last scroll event) - scrollTimeoutRef.current = setTimeout(() => { - const scrollLeft = scrollContainer.scrollLeft; - const containerWidth = scrollContainer.offsetWidth; - - // Calculate which mode we're closest to - const newIndex = Math.round(scrollLeft / containerWidth); - - // Update mode if it changed - if ( - newIndex >= 0 && - newIndex < modes.length && - modes[newIndex] && - modes[newIndex] !== currentMode - ) { - onModeSelect(modes[newIndex]); - } - }, 150); - }; - - scrollContainer.addEventListener("scroll", handleScroll); - - return () => { - scrollContainer.removeEventListener("scroll", handleScroll); - if (scrollTimeoutRef.current) { - clearTimeout(scrollTimeoutRef.current); - } - }; - }, [modes, currentMode, onModeSelect, scrollContainer, isDragging]); + // Track scroll progress + const modeProgress = useScrollProgress({ + scrollContainer, + currentIndex, + onScrollProgress, + }); + + // Handle scroll snapping + useScrollSnap({ + scrollContainer, + currentIndex, + isInitialMount, + }); + + // Detect mode changes from scrolling + useModeDetection({ + scrollContainer, + modes, + currentMode, + onModeSelect, + isDragging, + }); // If only one mode or no modes, disable carousel if (modes.length <= 1) { return ( -
- {children(currentMode, true)} +
+
+ +
{children(currentMode, true)}
+
); } return ( -
+
+ {/* Carousel content */}
- {modes.map((mode) => ( -
- {children(mode, mode === currentMode)} -
- ))} +
+ {modes.map((mode) => ( +
+ + {children(mode, mode === currentMode)} + +
+ ))} +
+ + {/* Bottom navigation bar */} +
); } - diff --git a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/AnimatedBackground/AnimatedBackground.tsx b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/AnimatedBackground/AnimatedBackground.tsx new file mode 100644 index 00000000000..91852fe33b3 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/AnimatedBackground/AnimatedBackground.tsx @@ -0,0 +1,41 @@ +import { type MotionValue, motion, useTransform } from "framer-motion"; + +interface AnimatedBackgroundProps { + progress: MotionValue; + modeCount: number; +} + +export function AnimatedBackground({ + progress, + modeCount, +}: AnimatedBackgroundProps) { + // Calculate the width of each button (36px = h-9 w-9) + gap (4px = gap-1) + const buttonWidth = 36; + const gap = 4; + const totalButtonWidth = buttonWidth + gap; + + // Transform progress (0-1) to translateX position + // For 2 modes: 0 -> 0px, 1 -> 40px (buttonWidth + gap) + const translateX = useTransform( + progress, + [0, modeCount - 1], + [0, (modeCount - 1) * totalButtonWidth] + ); + + return ( + + ); +} + diff --git a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/AnimatedBackground/index.ts b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/AnimatedBackground/index.ts new file mode 100644 index 00000000000..f57394e4fd0 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/AnimatedBackground/index.ts @@ -0,0 +1,2 @@ +export { AnimatedBackground } from "./AnimatedBackground"; + diff --git a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeContent/ModeContent.tsx b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeContent/ModeContent.tsx new file mode 100644 index 00000000000..5a55d2d7573 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeContent/ModeContent.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from "react"; +import { ModeHeader } from "../ModeHeader"; +import type { SidebarMode } from "../../types"; + +interface ModeContentProps { + mode: SidebarMode; + isActive: boolean; + children: ReactNode; +} + +export function ModeContent({ mode, children }: ModeContentProps) { + return ( +
+ +
{children}
+
+ ); +} + diff --git a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeContent/index.ts b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeContent/index.ts new file mode 100644 index 00000000000..d745b999a59 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeContent/index.ts @@ -0,0 +1,2 @@ +export { ModeContent } from "./ModeContent"; + diff --git a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeHeader/ModeHeader.tsx b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeHeader/ModeHeader.tsx new file mode 100644 index 00000000000..6abbe28631d --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeHeader/ModeHeader.tsx @@ -0,0 +1,17 @@ +import { modeLabels } from "../../constants"; +import type { SidebarMode } from "../../types"; + +interface ModeHeaderProps { + mode: SidebarMode; +} + +export function ModeHeader({ mode }: ModeHeaderProps) { + return ( +
+ + {modeLabels[mode]} + +
+ ); +} + diff --git a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeHeader/index.ts b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeHeader/index.ts new file mode 100644 index 00000000000..80cc09d0ea1 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeHeader/index.ts @@ -0,0 +1,2 @@ +export { ModeHeader } from "./ModeHeader"; + diff --git a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeNavigation/ModeNavigation.tsx b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeNavigation/ModeNavigation.tsx new file mode 100644 index 00000000000..ba4ed8709e8 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeNavigation/ModeNavigation.tsx @@ -0,0 +1,49 @@ +import { Button } from "@superset/ui/button"; +import type { MotionValue } from "framer-motion"; +import { AnimatedBackground } from "../AnimatedBackground"; +import { modeIcons } from "../../constants"; +import type { SidebarMode } from "../../types"; + +interface ModeNavigationProps { + modes: SidebarMode[]; + currentMode: SidebarMode; + onModeSelect: (mode: SidebarMode) => void; + scrollProgress: MotionValue; +} + +export function ModeNavigation({ + modes, + currentMode, + onModeSelect, + scrollProgress, +}: ModeNavigationProps) { + return ( +
+
+ + + {modes.map((mode) => { + const Icon = modeIcons[mode]; + const isActive = mode === currentMode; + + return ( + + ); + })} +
+
+ ); +} + diff --git a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeNavigation/index.ts b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeNavigation/index.ts new file mode 100644 index 00000000000..2d7c7a6c88e --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeNavigation/index.ts @@ -0,0 +1,2 @@ +export { ModeNavigation } from "./ModeNavigation"; + diff --git a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/constants.ts b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/constants.ts new file mode 100644 index 00000000000..17ea710233e --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/constants.ts @@ -0,0 +1,13 @@ +import { GitBranch, LayoutList } from "lucide-react"; +import type { SidebarMode } from "./types"; + +export const modeIcons: Record = { + tabs: LayoutList, + diff: GitBranch, +}; + +export const modeLabels: Record = { + tabs: "Tabs", + diff: "Diffs", +}; + diff --git a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/hooks/useModeDetection.ts b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/hooks/useModeDetection.ts new file mode 100644 index 00000000000..4c65b268477 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/hooks/useModeDetection.ts @@ -0,0 +1,71 @@ +import { useEffect } from "react"; +import type { SidebarMode } from "../types"; + +interface UseModeDetectionOptions { + scrollContainer: HTMLDivElement | null; + modes: SidebarMode[]; + currentMode: SidebarMode; + onModeSelect: (mode: SidebarMode) => void; + isDragging?: boolean; +} + +export function useModeDetection({ + scrollContainer, + modes, + currentMode, + onModeSelect, + isDragging = false, +}: UseModeDetectionOptions) { + // Detect when user finishes scrolling and update current mode + useEffect(() => { + if (!scrollContainer || isDragging) return; + + let scrollEndTimer: NodeJS.Timeout | undefined; + + const handleScroll = () => { + // Clear existing timeout + if (scrollEndTimer) { + clearTimeout(scrollEndTimer); + } + + // Wait for scroll to settle before updating mode (reduces jitter) + scrollEndTimer = setTimeout(() => { + const finalScrollLeft = scrollContainer.scrollLeft; + const finalContainerWidth = scrollContainer.offsetWidth; + + // Calculate which mode we're closest to and snap to it + const finalIndex = Math.round(finalScrollLeft / finalContainerWidth); + + if ( + finalIndex >= 0 && + finalIndex < modes.length && + modes[finalIndex] + ) { + // Snap to the nearest mode + const targetScrollX = finalIndex * finalContainerWidth; + if (Math.abs(finalScrollLeft - targetScrollX) > 5) { + scrollContainer.scrollTo({ + left: targetScrollX, + behavior: "smooth", + }); + } + + // Update mode if it changed + if (modes[finalIndex] !== currentMode) { + onModeSelect(modes[finalIndex]); + } + } + }, 150); + }; + + scrollContainer.addEventListener("scroll", handleScroll, { passive: true }); + + return () => { + scrollContainer.removeEventListener("scroll", handleScroll); + if (scrollEndTimer) { + clearTimeout(scrollEndTimer); + } + }; + }, [modes, currentMode, onModeSelect, scrollContainer, isDragging]); +} + diff --git a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/hooks/useScrollProgress.ts b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/hooks/useScrollProgress.ts new file mode 100644 index 00000000000..42d211285c2 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/hooks/useScrollProgress.ts @@ -0,0 +1,59 @@ +import { type MotionValue, useMotionValue } from "framer-motion"; +import { useEffect } from "react"; + +interface UseScrollProgressOptions { + scrollContainer: HTMLDivElement | null; + currentIndex: number; + onScrollProgress: (progress: MotionValue) => void; +} + +export function useScrollProgress({ + scrollContainer, + currentIndex, + onScrollProgress, +}: UseScrollProgressOptions) { + const initialProgress = currentIndex >= 0 ? currentIndex : 0; + const modeProgress = useMotionValue(initialProgress); + + // Track scroll position and update motion value + useEffect(() => { + if (!scrollContainer) return; + + let rafId: number | undefined; + + const updateProgress = () => { + const scrollLeft = scrollContainer.scrollLeft; + const containerWidth = scrollContainer.offsetWidth; + const progress = scrollLeft / containerWidth; + modeProgress.set(progress); + }; + + const handleScroll = () => { + // Use requestAnimationFrame for smooth updates + if (rafId !== undefined) { + cancelAnimationFrame(rafId); + } + rafId = requestAnimationFrame(updateProgress); + }; + + scrollContainer.addEventListener("scroll", handleScroll, { passive: true }); + + // Initial value + updateProgress(); + + return () => { + scrollContainer.removeEventListener("scroll", handleScroll); + if (rafId !== undefined) { + cancelAnimationFrame(rafId); + } + }; + }, [scrollContainer, modeProgress]); + + // Expose scroll progress to parent + useEffect(() => { + onScrollProgress(modeProgress); + }, [onScrollProgress, modeProgress]); + + return modeProgress; +} + diff --git a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/hooks/useScrollSnap.ts b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/hooks/useScrollSnap.ts new file mode 100644 index 00000000000..9439dc24856 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/hooks/useScrollSnap.ts @@ -0,0 +1,33 @@ +import { useEffect, useRef } from "react"; +import type { SidebarMode } from "../types"; + +interface UseScrollSnapOptions { + scrollContainer: HTMLDivElement | null; + currentIndex: number; + isInitialMount: React.MutableRefObject; +} + +export function useScrollSnap({ + scrollContainer, + currentIndex, + isInitialMount, +}: UseScrollSnapOptions) { + // Scroll to current mode when it changes externally + useEffect(() => { + if (!scrollContainer || currentIndex < 0) return; + + const targetScrollX = currentIndex * scrollContainer.offsetWidth; + + // Only scroll if we're not already at the target position + if (Math.abs(scrollContainer.scrollLeft - targetScrollX) > 5) { + scrollContainer.scrollTo({ + left: targetScrollX, + behavior: isInitialMount.current ? "auto" : "smooth", + }); + } + + // Mark that initial mount is complete + isInitialMount.current = false; + }, [currentIndex, scrollContainer, isInitialMount]); +} + diff --git a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/index.ts b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/index.ts index 8af51e0302b..de0e8d8c3dc 100644 --- a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/index.ts +++ b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/index.ts @@ -1,2 +1,2 @@ -export { ModeCarousel, type SidebarMode } from "./ModeCarousel"; - +export { ModeCarousel } from "./ModeCarousel"; +export type { SidebarMode, ModeCarouselProps } from "./types"; diff --git a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/types.ts b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/types.ts new file mode 100644 index 00000000000..136c1ce7bf8 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/types.ts @@ -0,0 +1,13 @@ +import type { MotionValue, ReactNode } from "react"; + +export type SidebarMode = "tabs" | "diff"; + +export interface ModeCarouselProps { + modes: SidebarMode[]; + currentMode: SidebarMode; + onModeSelect: (mode: SidebarMode) => void; + children: (mode: SidebarMode, isActive: boolean) => ReactNode; + onScrollProgress: (progress: MotionValue) => void; + isDragging?: boolean; +} +