import { useState, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Check, Eye, EyeOff, LogOut, PanelRight, Pin, Search, SlidersHorizontal, SquareArrowOutUpRight, } from "lucide-react"; import type { TabType, ToolsTab } from "@/types/ui-types"; import { Skeleton } from "@/components/skeleton"; import { readRailPreference, setRailPreference } from "./rail-preferences"; import { groupRailItems, moveRailItem, useRailItems, type RailGroup, type RailItemDef, } from "./rail-items"; import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext"; import { toggleHiddenRailTab, useHiddenRailTabs } from "./hidden-rail-tabs"; import { RailBadge } from "./RailBadge"; import { rem } from "@/lib/rem"; import { isElectron } from "@/lib/electron"; /** Core rail views; plugins add their own ids at runtime. */ export type CoreRailView = "hosts" | "credentials" | "quick-connect" | ToolsTab | "connections"; export type RailView = CoreRailView | (string & {}); const btnBase = "relative flex items-center h-7 shrink-0 transition-colors gap-2.5 focus-visible:ring-1 focus-visible:ring-ring"; const btnStyle = { margin: `0 ${rem(4)}`, padding: `0 ${rem(8)}` }; const idle = "text-muted-foreground hover:text-foreground hover:bg-muted/60"; const activeClass = "text-accent-brand bg-accent-brand/10"; function RailLabel({ expanded, children, }: { expanded: boolean; children: React.ReactNode; }) { return ( {children} ); } function RailIcon({ children, useBadge, }: { children: React.ReactNode; useBadge?: () => number | null | undefined; }) { return ( {children} {useBadge && ( )} ); } function Rule({ expanded }: { expanded: boolean }) { return (
); } /** * The band name rides on the divider between bands, so expanding the rail * never pushes items down from under the pointer. */ function GroupDivider({ group, expanded, }: { group: RailGroup; expanded: boolean; }) { const { t } = useTranslation(); return (
{t(`nav.group.${group}`)}
); } interface MenuTarget { id: string; title: string; promotable?: boolean; rightDockable?: boolean; hideable: boolean; } export function AppRail({ railView, sidebarOpen, username, isAdmin, pluginsSettled = true, onRailClick, onOpenTab, onOpenInRightDock, onOpenSettings, onOpenNavigationSettings, onOpenPalette, onLogout, }: { railView: RailView; sidebarOpen: boolean; username: string; isAdmin: boolean; /** False while plugins are still registering their rail items. */ pluginsSettled?: boolean; onRailClick: (view: RailView) => void; onOpenTab?: (type: TabType) => void; onOpenInRightDock?: (view: RailView) => void; onOpenSettings: () => void; /** The rail menu's own entry, which jumps to the navigation settings. */ onOpenNavigationSettings: () => void; onOpenPalette?: () => void; onLogout: (options?: { manual?: boolean }) => void; }) { const { t } = useTranslation(); const [hovered, setHovered] = useState(false); const [pinned, setPinned] = useState(() => readRailPreference("pinAppRail")); const [expandOnHover, setExpandOnHover] = useState(() => readRailPreference("expandAppRailOnHover"), ); const [showPinButton, setShowPinButton] = useState(() => readRailPreference("showPinAppRailButton"), ); const [hoverPushes, setHoverPushes] = useState(() => readRailPreference("appRailHoverPushesContent"), ); const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null); const [menuTarget, setMenuTarget] = useState(null); const [managing, setManaging] = useState(false); const hiddenTabs = useHiddenRailTabs(); useEffect(() => { const pinHandler = () => setPinned(readRailPreference("pinAppRail")); const hoverHandler = () => setExpandOnHover(readRailPreference("expandAppRailOnHover")); const showPinButtonHandler = () => setShowPinButton(readRailPreference("showPinAppRailButton")); const pushHandler = () => setHoverPushes(readRailPreference("appRailHoverPushesContent")); window.addEventListener("pinAppRailChanged", pinHandler); window.addEventListener("expandAppRailOnHoverChanged", hoverHandler); window.addEventListener("appRailHoverPushesContentChanged", pushHandler); window.addEventListener( "showPinAppRailButtonChanged", showPinButtonHandler, ); return () => { window.removeEventListener("pinAppRailChanged", pinHandler); window.removeEventListener("expandAppRailOnHoverChanged", hoverHandler); window.removeEventListener( "appRailHoverPushesContentChanged", pushHandler, ); window.removeEventListener( "showPinAppRailButtonChanged", showPinButtonHandler, ); }; }, []); useEffect(() => { if (!menuPos) return; const onDown = (e: MouseEvent) => { if (!(e.target as HTMLElement).closest("[data-rail-context-menu]")) { setMenuPos(null); } }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setMenuPos(null); }; window.addEventListener("mousedown", onDown); window.addEventListener("keydown", onKey); return () => { window.removeEventListener("mousedown", onDown); window.removeEventListener("keydown", onKey); }; }, [menuPos]); // Plugins add and remove rail items at runtime, and permissions hide some. const railItems = useRailItems(); const uiPrefs = useUiPreferencesContext(); const [dragId, setDragId] = useState(null); const [dropBefore, setDropBefore] = useState(null); const railExpanded = pinned || (expandOnHover && hovered) || managing; const mainItems = railItems.filter((item) => item.placement !== "footer"); const bands = useMemo( () => groupRailItems(mainItems.filter((item) => !hiddenTabs.has(item.id))), [mainItems, hiddenTabs], ); const hiddenItems = mainItems.filter((item) => hiddenTabs.has(item.id)); const footerItems = railItems.filter( (item) => item.placement === "footer" && !hiddenTabs.has(item.id), ); useEffect(() => { if (hiddenItems.length === 0) setManaging(false); }, [hiddenItems.length]); const togglePinned = () => { setRailPreference("pinAppRail", !pinned); setMenuPos(null); }; const toggleExpandOnHover = () => { setRailPreference("expandAppRailOnHover", !expandOnHover); setMenuPos(null); }; const sameBand = (a: string, b: string) => (mainItems.find((item) => item.id === a)?.group ?? "tools") === (mainItems.find((item) => item.id === b)?.group ?? "tools"); const renderItem = (item: RailItemDef) => { const Icon = item.icon; const title = t(item.labelKey); const isTab = item.kind === "tab"; const active = !isTab && sidebarOpen && railView === item.id; const reorderable = item.placement !== "footer" && !!uiPrefs; const dropHere = dragId !== null && dropBefore === item.id && dragId !== item.id; return ( ); }; // Expanding on hover draws over the content instead of pushing it, so open // sessions like remote desktop are not resized each time, unless the user // asked for the old behavior. const takesSpace = hoverPushes ? railExpanded : pinned || managing; return (
setHovered(true)} onMouseLeave={() => setHovered(false)} onContextMenu={(e) => { e.preventDefault(); // Keep the menu on screen when right-clicking near the viewport edges const MENU_W = 200; const MENU_H = 180; setMenuPos({ x: Math.min(e.clientX, window.innerWidth - MENU_W - 8), y: Math.min(e.clientY, window.innerHeight - MENU_H - 8), }); if (!(e.target as HTMLElement).closest("[data-rail-item]")) { setMenuTarget(null); } }} > {onOpenPalette && ( <> )}
{!pluginsSettled ? Array.from({ length: mainItems.length || 8 }, (_, i) => (
)) : bands.map((band, i) => (
{i > 0 && ( )} {band.items.map(renderItem)}
))} {pluginsSettled && hiddenItems.length > 0 && ( <> {managing && hiddenItems.map((item) => { const Icon = item.icon; return ( ); })} )}
{showPinButton && ( <> )} {footerItems.map(renderItem)} {footerItems.length > 0 && !isElectron() && ( )} {/* The desktop signs in to its own local profile by itself. */} {!isElectron() && ( )}
{menuPos && (
{menuTarget && ( <> {menuTarget.promotable && ( } onClick={() => { onOpenTab?.(menuTarget.id as TabType); setMenuPos(null); }} > {t("nav.openAsTab")} )} {menuTarget.rightDockable && ( } onClick={() => { onOpenInRightDock?.(menuTarget.id); setMenuPos(null); }} > {t("nav.openInRightDock")} )} {menuTarget.hideable && ( } onClick={() => { void toggleHiddenRailTab(menuTarget.id); setMenuPos(null); }} > {t("nav.hideFromRail")} )}
)} : null} onClick={togglePinned} checked={pinned} > {t("newUi.sidebar.userProfile.pinAppRail")} : null} onClick={toggleExpandOnHover} checked={expandOnHover} > {t("newUi.sidebar.userProfile.expandAppRailOnHover")}
} onClick={() => { onOpenNavigationSettings(); setMenuPos(null); }} > {t("nav.openSettings")}
)}
); } function MenuItem({ icon, onClick, checked, children, }: { icon: React.ReactNode; onClick: () => void; checked?: boolean; children: React.ReactNode; }) { return ( ); }