From a65c5e72417efd056c4f4e302108e4702808c05e Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 12 Nov 2025 10:49:53 -0800 Subject: [PATCH 1/4] update mode change --- .../main/components/Sidebar/Sidebar.tsx | 7 - .../components/ModeCarousel/ModeCarousel.tsx | 135 ++++++++++++++---- 2 files changed, 105 insertions(+), 37 deletions(-) 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 (
- = { + tabs: LayoutList, + diff: GitBranch, +}; + +interface AnimatedBackgroundProps { + progress: MotionValue; + modeCount: number; +} + +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 ( + + ); +} + interface ModeCarouselProps { modes: SidebarMode[]; currentMode: SidebarMode; @@ -144,43 +187,75 @@ export function ModeCarousel({ // 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 - Arc browser style */} +
+
+ {/* Animated background indicator */} + + + {modes.map((mode) => { + const Icon = modeIcons[mode]; + const isActive = mode === currentMode; + + return ( + + ); + })} +
); } - From 131b6eac98955fd6cb1539c61d7cd6ddbf8315b0 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 12 Nov 2025 10:51:15 -0800 Subject: [PATCH 2/4] header --- .../components/ModeCarousel/ModeCarousel.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/ModeCarousel.tsx b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/ModeCarousel.tsx index e7dd0cce8ab..147b5c8a1ea 100644 --- a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/ModeCarousel.tsx +++ b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/ModeCarousel.tsx @@ -16,6 +16,11 @@ const modeIcons: Record = { diff: GitBranch, }; +const modeLabels: Record = { + tabs: "Tabs", + diff: "Diffs", +}; + interface AnimatedBackgroundProps { progress: MotionValue; modeCount: number; @@ -184,10 +189,16 @@ export function ModeCarousel({ }; }, [modes, currentMode, onModeSelect, scrollContainer, isDragging]); + const currentLabelSingle = modeLabels[currentMode]; + // If only one mode or no modes, disable carousel if (modes.length <= 1) { return (
+ {/* Header showing current mode */} +
+ {currentLabelSingle} +
{children(currentMode, true)}
@@ -195,8 +206,15 @@ export function ModeCarousel({ ); } + const currentLabel = modeLabels[currentMode]; + return (
+ {/* Header showing current mode */} +
+ {currentLabel} +
+ {/* Carousel content */}
Date: Wed, 12 Nov 2025 10:54:03 -0800 Subject: [PATCH 3/4] scroll --- .../components/ModeCarousel/ModeCarousel.tsx | 85 +++++++++++-------- 1 file changed, 49 insertions(+), 36 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/ModeCarousel.tsx b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/ModeCarousel.tsx index 147b5c8a1ea..464b5717d98 100644 --- a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/ModeCarousel.tsx +++ b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/ModeCarousel.tsx @@ -74,7 +74,6 @@ export function ModeCarousel({ onScrollProgress, isDragging = false, }: ModeCarouselProps) { - const scrollTimeoutRef = useRef(undefined); const isInitialMount = useRef(true); const currentIndex = modes.findIndex((m) => m === currentMode); @@ -138,7 +137,7 @@ export function ModeCarousel({ const targetScrollX = currentIndex * scrollContainer.offsetWidth; // Only scroll if we're not already at the target position - if (Math.abs(scrollContainer.scrollLeft - targetScrollX) > 10) { + if (Math.abs(scrollContainer.scrollLeft - targetScrollX) > 5) { scrollContainer.scrollTo({ left: targetScrollX, behavior: isInitialMount.current ? "auto" : "smooth", @@ -153,75 +152,83 @@ export function ModeCarousel({ useEffect(() => { if (!scrollContainer || isDragging) return; + let scrollEndTimer: NodeJS.Timeout | undefined; + const handleScroll = () => { // Clear existing timeout - if (scrollTimeoutRef.current) { - clearTimeout(scrollTimeoutRef.current); + if (scrollEndTimer) { + clearTimeout(scrollEndTimer); } - // Wait for scroll to settle (150ms after last scroll event) - scrollTimeoutRef.current = setTimeout(() => { - const scrollLeft = scrollContainer.scrollLeft; - const containerWidth = scrollContainer.offsetWidth; + // 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 - const newIndex = Math.round(scrollLeft / containerWidth); + // Calculate which mode we're closest to and snap to it + const finalIndex = Math.round(finalScrollLeft / finalContainerWidth); - // Update mode if it changed if ( - newIndex >= 0 && - newIndex < modes.length && - modes[newIndex] && - modes[newIndex] !== currentMode + finalIndex >= 0 && + finalIndex < modes.length && + modes[finalIndex] ) { - onModeSelect(modes[newIndex]); + // 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); + scrollContainer.addEventListener("scroll", handleScroll, { passive: true }); return () => { scrollContainer.removeEventListener("scroll", handleScroll); - if (scrollTimeoutRef.current) { - clearTimeout(scrollTimeoutRef.current); + if (scrollEndTimer) { + clearTimeout(scrollEndTimer); } }; }, [modes, currentMode, onModeSelect, scrollContainer, isDragging]); - const currentLabelSingle = modeLabels[currentMode]; - // If only one mode or no modes, disable carousel if (modes.length <= 1) { return (
- {/* Header showing current mode */} -
- {currentLabelSingle} -
-
- {children(currentMode, true)} +
+ {/* Header showing current mode */} +
+ {modeLabels[currentMode]} +
+
+ {children(currentMode, true)} +
); } - const currentLabel = modeLabels[currentMode]; - return (
- {/* Header showing current mode */} -
- {currentLabel} -
- {/* Carousel content */}
(
- {children(mode, mode === currentMode)} + {/* Header showing current mode */} +
+ {modeLabels[mode]} +
+
+ {children(mode, mode === currentMode)} +
))}
From 86e04f18e8c287900ef0f49ae350e5a586e953d8 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 12 Nov 2025 10:56:07 -0800 Subject: [PATCH 4/4] refactor --- .../components/ModeCarousel/ModeCarousel.tsx | 266 +++--------------- .../AnimatedBackground/AnimatedBackground.tsx | 41 +++ .../components/AnimatedBackground/index.ts | 2 + .../components/ModeContent/ModeContent.tsx | 25 ++ .../components/ModeContent/index.ts | 2 + .../components/ModeHeader/ModeHeader.tsx | 17 ++ .../components/ModeHeader/index.ts | 2 + .../ModeNavigation/ModeNavigation.tsx | 49 ++++ .../components/ModeNavigation/index.ts | 2 + .../components/ModeCarousel/constants.ts | 13 + .../ModeCarousel/hooks/useModeDetection.ts | 71 +++++ .../ModeCarousel/hooks/useScrollProgress.ts | 59 ++++ .../ModeCarousel/hooks/useScrollSnap.ts | 33 +++ .../Sidebar/components/ModeCarousel/index.ts | 4 +- .../Sidebar/components/ModeCarousel/types.ts | 13 + 15 files changed, 375 insertions(+), 224 deletions(-) create mode 100644 apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/AnimatedBackground/AnimatedBackground.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/AnimatedBackground/index.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeContent/ModeContent.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeContent/index.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeHeader/ModeHeader.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeHeader/index.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeNavigation/ModeNavigation.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/components/ModeNavigation/index.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/constants.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/hooks/useModeDetection.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/hooks/useScrollProgress.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/hooks/useScrollSnap.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/types.ts diff --git a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/ModeCarousel.tsx b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/ModeCarousel.tsx index 464b5717d98..3178c0be266 100644 --- a/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/ModeCarousel.tsx +++ b/apps/desktop/src/renderer/screens/main/components/Sidebar/components/ModeCarousel/ModeCarousel.tsx @@ -1,70 +1,11 @@ -import { Button } from "@superset/ui/button"; -import { type MotionValue, motion, useMotionValue, useTransform } from "framer-motion"; -import { GitBranch, LayoutList } from "lucide-react"; -import { - type ReactNode, - useCallback, - useEffect, - useRef, - useState, -} from "react"; - -export type SidebarMode = "tabs" | "diff"; - -const modeIcons: Record = { - tabs: LayoutList, - diff: GitBranch, -}; - -const modeLabels: Record = { - tabs: "Tabs", - diff: "Diffs", -}; - -interface AnimatedBackgroundProps { - progress: MotionValue; - modeCount: number; -} - -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 ( - - ); -} - -interface ModeCarouselProps { - modes: SidebarMode[]; - currentMode: SidebarMode; - onModeSelect: (mode: SidebarMode) => 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, @@ -75,11 +16,7 @@ export function ModeCarousel({ isDragging = false, }: ModeCarouselProps) { 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, ); @@ -91,127 +28,36 @@ 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) > 5) { - 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; - - 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]); + // 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 (
- {/* Header showing current mode */} -
- {modeLabels[currentMode]} -
-
- {children(currentMode, true)} -
+ +
{children(currentMode, true)}
); @@ -241,52 +87,28 @@ export function ModeCarousel({ {modes.map((mode) => (
- {/* Header showing current mode */} -
- {modeLabels[mode]} -
-
+ {children(mode, mode === currentMode)} -
+
))}
- {/* Bottom navigation bar - Arc browser style */} -
-
- {/* Animated background indicator */} - - - {modes.map((mode) => { - const Icon = modeIcons[mode]; - const isActive = mode === currentMode; - - return ( - - ); - })} -
-
+ {/* 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; +} +