diff --git a/src/renderer/src/components/browser-ui/browser-sidebar.tsx b/src/renderer/src/components/browser-ui/browser-sidebar.tsx index 3e63b993..9f5b66b7 100644 --- a/src/renderer/src/components/browser-ui/browser-sidebar.tsx +++ b/src/renderer/src/components/browser-ui/browser-sidebar.tsx @@ -1,3 +1,12 @@ +import { CollapseMode, SidebarSide } from "@/components/browser-ui/main"; +import { ScrollableSidebarContent } from "@/components/browser-ui/sidebar/content/sidebar-content"; +import { SidebarFooterUpdate } from "@/components/browser-ui/sidebar/footer/update"; +import { NavigationControls } from "@/components/browser-ui/sidebar/header/action-buttons"; +import { SidebarAddressBar } from "@/components/browser-ui/sidebar/header/address-bar/address-bar"; +import { SidebarWindowControls } from "@/components/browser-ui/sidebar/header/window-controls"; +import { SidebarSpacesSwitcher } from "@/components/browser-ui/sidebar/spaces-switcher"; +import { PortalComponent } from "@/components/portal/portal"; +import { useSpaces } from "@/components/providers/spaces-provider"; import { Sidebar, SidebarFooter, @@ -6,21 +15,13 @@ import { SidebarMenuButton, SidebarMenuItem, SidebarRail, - useSidebar + useSidebar, + type SidebarVariant } from "@/components/ui/resizable-sidebar"; -import { useEffect, useRef, useState, useCallback } from "react"; import { cn } from "@/lib/utils"; -import { CollapseMode, SidebarVariant, SidebarSide } from "@/components/browser-ui/main"; import { PlusIcon, SettingsIcon } from "lucide-react"; -import { SidebarSpacesSwitcher } from "@/components/browser-ui/sidebar/spaces-switcher"; -import { ScrollableSidebarContent } from "@/components/browser-ui/sidebar/content/sidebar-content"; -import { useSpaces } from "@/components/providers/spaces-provider"; -import { NavigationControls } from "@/components/browser-ui/sidebar/header/action-buttons"; -import { SidebarAddressBar } from "@/components/browser-ui/sidebar/header/address-bar/address-bar"; -import { PortalComponent } from "@/components/portal/portal"; -import { SidebarWindowControls } from "@/components/browser-ui/sidebar/header/window-controls"; -import { motion, AnimatePresence } from "motion/react"; -import { SidebarFooterUpdate } from "@/components/browser-ui/sidebar/footer/update"; +import { AnimatePresence, motion } from "motion/react"; +import { useCallback, useEffect, useRef, useState } from "react"; type BrowserSidebarProps = { collapseMode: CollapseMode; @@ -56,6 +57,7 @@ function useSidebarAnimation(shouldRenderContent: boolean, setVariant: (variant: // Custom hook to handle sidebar hover state function useSidebarHover(setIsHoveringSidebar: (isHovering: boolean) => void) { + const { pinned } = useSidebar(); const isHoveringSidebarRef = useRef(false); const timeoutIdRef = useRef(null); @@ -72,16 +74,18 @@ function useSidebarHover(setIsHoveringSidebar: (isHovering: boolean) => void) { }, [setIsHoveringSidebar]); const handleMouseLeave = useCallback(() => { + if (pinned) return; + isHoveringSidebarRef.current = false; timeoutIdRef.current = setTimeout(() => { if (timeoutIdRef.current) { clearTimeout(timeoutIdRef.current); } - if (!isHoveringSidebarRef.current) { + if (!isHoveringSidebarRef.current && !pinned) { setIsHoveringSidebar(false); } }, 100); - }, [setIsHoveringSidebar]); + }, [pinned, setIsHoveringSidebar]); return { handleMouseEnter, handleMouseLeave }; } diff --git a/src/renderer/src/components/browser-ui/main.tsx b/src/renderer/src/components/browser-ui/main.tsx index 3ec22369..4c0f40d4 100644 --- a/src/renderer/src/components/browser-ui/main.tsx +++ b/src/renderer/src/components/browser-ui/main.tsx @@ -1,34 +1,30 @@ import BrowserContent from "@/components/browser-ui/browser-content"; -import { SidebarInset, SidebarProvider, useSidebar } from "@/components/ui/resizable-sidebar"; -import { motion, AnimatePresence } from "motion/react"; -import { cn } from "@/lib/utils"; import { BrowserSidebar } from "@/components/browser-ui/browser-sidebar"; -import { SpacesProvider } from "@/components/providers/spaces-provider"; -import { useEffect, useMemo, useRef } from "react"; -import { useState } from "react"; -import { TabsProvider, useTabs } from "@/components/providers/tabs-provider"; -import { SettingsProvider, useSettings } from "@/components/providers/settings-provider"; +import { SidebarAddressBar } from "@/components/browser-ui/sidebar/header/address-bar/address-bar"; +import { SidebarHoverDetector } from "@/components/browser-ui/sidebar/hover-detector"; import { TabDisabler } from "@/components/logic/tab-disabler"; +import { ActionsProvider } from "@/components/providers/actions-provider"; +import { AppUpdatesProvider } from "@/components/providers/app-updates-provider"; import { BrowserActionProvider } from "@/components/providers/browser-action-provider"; import { ExtensionsProviderWithSpaces } from "@/components/providers/extensions-provider"; -import { SidebarHoverDetector } from "@/components/browser-ui/sidebar/hover-detector"; import MinimalToastProvider from "@/components/providers/minimal-toast-provider"; -import { AppUpdatesProvider } from "@/components/providers/app-updates-provider"; -import { ActionsProvider } from "@/components/providers/actions-provider"; -import { SidebarAddressBar } from "@/components/browser-ui/sidebar/header/address-bar/address-bar"; +import { SettingsProvider, useSettings } from "@/components/providers/settings-provider"; +import { SpacesProvider } from "@/components/providers/spaces-provider"; +import { TabsProvider, useTabs } from "@/components/providers/tabs-provider"; +import { SidebarInset, SidebarProvider, useSidebar } from "@/components/ui/resizable-sidebar"; +import { cn } from "@/lib/utils"; +import { AnimatePresence, motion } from "motion/react"; +import { useEffect, useMemo, useRef, useState } from "react"; export type CollapseMode = "icon" | "offcanvas"; -export type SidebarVariant = "sidebar" | "floating"; export type SidebarSide = "left" | "right"; export type WindowType = "main" | "popup"; function InternalBrowserUI({ isReady, type }: { isReady: boolean; type: WindowType }) { - const { open, setOpen } = useSidebar(); + const { open, setOpen, pinned, variant, setVariant } = useSidebar(); const { getSetting } = useSettings(); const { focusedTab, tabGroups } = useTabs(); - - const [variant, setVariant] = useState("sidebar"); const [isHoveringSidebar, setIsHoveringSidebar] = useState(false); const side: SidebarSide = getSetting("sidebarSide") ?? "left"; @@ -54,10 +50,11 @@ function InternalBrowserUI({ isReady, type }: { isReady: boolean; type: WindowTy const isActiveTabLoading = focusedTab?.isLoading || false; useEffect(() => { - if (!isHoveringSidebar && open && variant === "floating") { + // Only auto-hide floating sidebar if it's not pinned + if (!isHoveringSidebar && open && variant === "floating" && !pinned) { setOpen(false); } - }, [isHoveringSidebar, open, variant, setOpen, setVariant]); + }, [isHoveringSidebar, open, variant, pinned, setOpen]); // Only show the browser content if the focused tab is in full screen mode if (focusedTab?.fullScreen) { diff --git a/src/renderer/src/components/browser-ui/sidebar/header/action-buttons.tsx b/src/renderer/src/components/browser-ui/sidebar/header/action-buttons.tsx index 90074c06..a11f16db 100644 --- a/src/renderer/src/components/browser-ui/sidebar/header/action-buttons.tsx +++ b/src/renderer/src/components/browser-ui/sidebar/header/action-buttons.tsx @@ -10,11 +10,11 @@ import { SidebarMenuItem, useSidebar } from "@/components/ui/resizable-sidebar"; -import { NavigationEntry } from "~/flow/interfaces/browser/navigation"; import { cn } from "@/lib/utils"; -import { SidebarCloseIcon, SidebarOpenIcon, XIcon } from "lucide-react"; +import { Pin, PinOff, SidebarCloseIcon, SidebarOpenIcon, XIcon } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { ComponentProps, useCallback, useEffect, useRef, useState } from "react"; +import { NavigationEntry } from "~/flow/interfaces/browser/navigation"; import { TabData } from "~/types/tabs"; export type NavigationEntryWithIndex = NavigationEntry & { index: number }; @@ -94,9 +94,22 @@ function StopLoadingIcon() { ); } +function PinButton() { + const { pinned, togglePin } = useSidebar(); + + return ( + : } + onClick={togglePin} + className={SIDEBAR_HOVER_COLOR} + title={pinned ? "Unpin sidebar" : "Pin sidebar"} + /> + ); +} + export function NavigationControls() { const { focusedTab } = useTabs(); - const { open, setOpen } = useSidebar(); + const { open, setOpen, variant } = useSidebar(); const [entries, setEntries] = useState([]); const [activeIndex, setActiveIndex] = useState(0); @@ -164,6 +177,7 @@ export function NavigationControls() { onClick={closeSidebar} className={SIDEBAR_HOVER_COLOR} /> + {variant === "floating" && } {/* Browser Actions */} diff --git a/src/renderer/src/components/ui/resizable-sidebar.tsx b/src/renderer/src/components/ui/resizable-sidebar.tsx index 25d23040..8bf7d280 100644 --- a/src/renderer/src/components/ui/resizable-sidebar.tsx +++ b/src/renderer/src/components/ui/resizable-sidebar.tsx @@ -1,21 +1,25 @@ -import * as React from "react"; -import { Slot as SlotPrimitive } from "radix-ui"; import { VariantProps, cva } from "class-variance-authority"; import { PanelLeftIcon } from "lucide-react"; +import { Slot as SlotPrimitive } from "radix-ui"; +import * as React from "react"; -import { useIsMobile } from "@/hooks/use-mobile"; -import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { useIsMobile } from "@/hooks/use-mobile"; import { useSidebarResize } from "@/hooks/use-sidebar-resize"; import { mergeButtonRefs } from "@/lib/merge-button-refs"; +import { cn } from "@/lib/utils"; + +export type SidebarVariant = "sidebar" | "floating"; const SIDEBAR_COOKIE_NAME = "sidebar_state"; const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +const SIDEBAR_PINNED_COOKIE_NAME = "sidebar_pinned"; +const SIDEBAR_PINNED_COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days const SIDEBAR_WIDTH = "16rem"; const SIDEBAR_WIDTH_MOBILE = "18rem"; const SIDEBAR_WIDTH_ICON = "3rem"; @@ -27,6 +31,8 @@ const MIN_SIDEBAR_WIDTH = "14rem"; const MAX_SIDEBAR_WIDTH = "22rem"; type SidebarContextProps = { + variant: SidebarVariant; + setVariant: (variant: SidebarVariant) => void; state: "expanded" | "collapsed"; open: boolean; setOpen: (open: boolean) => void; @@ -41,6 +47,11 @@ type SidebarContextProps = { isDraggingRail: boolean; setIsDraggingRail: (isDraggingRail: boolean) => void; + + // Pinning Properties + pinned: boolean; + setPinned: (pinned: boolean) => void; + togglePin: () => void; }; const SidebarContext = React.createContext(null); @@ -74,6 +85,28 @@ function SidebarProvider({ const [width, setWidth] = React.useState(defaultWidth); const [isDraggingRail, setIsDraggingRail] = React.useState(false); + const [variant, setVariant] = React.useState("sidebar"); + + // Pinning state management + const [pinned, setPinnedState] = React.useState(() => { + // Try to get pinned state from cookie first + const cookies = document.cookie.split(";"); + const pinnedCookie = cookies.find((cookie) => cookie.trim().startsWith(`${SIDEBAR_PINNED_COOKIE_NAME}=`)); + if (pinnedCookie) { + return pinnedCookie.split("=")[1] === "true"; + } + return false; + }); + + const setPinned = React.useCallback((value: boolean) => { + setPinnedState(value); + // Persist pinned state in cookie + document.cookie = `${SIDEBAR_PINNED_COOKIE_NAME}=${value}; path=/; max-age=${SIDEBAR_PINNED_COOKIE_MAX_AGE}`; + }, []); + + const togglePin = React.useCallback(() => { + setPinned(prev => !prev); + }, [setPinned]); // This is the internal state of the sidebar. // We use openProp and setOpenProp for control from outside the component. @@ -122,6 +155,8 @@ function SidebarProvider({ const contextValue = React.useMemo( () => ({ + variant, + setVariant, state, open, setOpen, @@ -134,9 +169,15 @@ function SidebarProvider({ setWidth, isDraggingRail, - setIsDraggingRail + setIsDraggingRail, + + pinned, + setPinned, + togglePin }), [ + variant, + setVariant, state, open, setOpen, @@ -147,7 +188,10 @@ function SidebarProvider({ width, setWidth, isDraggingRail, - setIsDraggingRail + setIsDraggingRail, + pinned, + setPinned, + togglePin ] ); @@ -185,7 +229,7 @@ function Sidebar({ variant?: "sidebar" | "floating" | "inset"; collapsible?: "offcanvas" | "icon" | "none"; }) { - const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + const { isMobile, state, openMobile, setOpenMobile, pinned } = useSidebar(); if (collapsible === "none") { return ( @@ -233,6 +277,7 @@ function Sidebar({ data-variant={variant} data-side={side} data-slot="sidebar" + data-pinned={pinned} > {/* This is what handles the sidebar gap on desktop */}