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";
/** 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,
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;
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 [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"));
window.addEventListener("pinAppRailChanged", pinHandler);
window.addEventListener("expandAppRailOnHoverChanged", hoverHandler);
window.addEventListener(
"showPinAppRailButtonChanged",
showPinButtonHandler,
);
return () => {
window.removeEventListener("pinAppRailChanged", pinHandler);
window.removeEventListener("expandAppRailOnHoverChanged", hoverHandler);
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 (
{
e.dataTransfer.effectAllowed = "move";
setDragId(item.id);
}}
onDragEnd={() => {
setDragId(null);
setDropBefore(null);
}}
onDragOver={(e) => {
if (!dragId || dragId === item.id || !sameBand(dragId, item.id))
return;
e.preventDefault();
setDropBefore(item.id);
}}
onDrop={(e) => {
if (!dragId || !sameBand(dragId, item.id)) return;
e.preventDefault();
uiPrefs?.setOverride(
"rail",
"order",
moveRailItem(mainItems, dragId, item.id),
);
setDragId(null);
setDropBefore(null);
}}
onClick={(e) => {
if (isTab || (item.promotable && (e.ctrlKey || e.metaKey))) {
onOpenTab?.(item.id as TabType);
return;
}
onRailClick(item.id as RailView);
}}
onAuxClick={(e) => {
if (e.button !== 1 || !item.promotable) return;
e.preventDefault();
onOpenTab?.(item.id as TabType);
}}
onContextMenu={() =>
setMenuTarget({
id: item.id,
title,
promotable: item.promotable,
rightDockable: item.rightDockable,
hideable: !item.alwaysVisible && item.hideable !== false,
})
}
data-rail-item=""
aria-current={active ? "page" : undefined}
title={item.promotable ? `${title}\n${t("nav.openAsTabHint")}` : title}
style={btnStyle}
className={`${btnBase} ${active ? activeClass : idle} ${dragId === item.id ? "opacity-40" : ""} ${dropHere ? "relative before:absolute before:inset-x-1 before:-top-0.5 before:h-0.5 before:bg-accent-brand" : ""}`}
>
{title}
);
};
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 && (
<>
{t("nav.search")}
>
)}
{!pluginsSettled
? Array.from({ length: mainItems.length || 8 }, (_, i) => (
))
: bands.map((band, i) => (
{i > 0 && (
)}
{band.items.map(renderItem)}
))}
{pluginsSettled && hiddenItems.length > 0 && (
<>
setManaging((v) => !v)}
aria-expanded={managing}
title={t("nav.hiddenCount", { count: hiddenItems.length })}
style={btnStyle}
className={`${btnBase} ${managing ? activeClass : idle}`}
>
{t("nav.hiddenCount", { count: hiddenItems.length })}
{managing &&
hiddenItems.map((item) => {
const Icon = item.icon;
return (
void toggleHiddenRailTab(item.id)}
title={t("nav.showInRail")}
style={btnStyle}
className={`${btnBase} text-muted-foreground/70 hover:text-foreground hover:bg-muted/60`}
>
{t(item.labelKey)}
);
})}
>
)}
{showPinButton && (
<>
setRailPreference("pinAppRail", !pinned)}
style={btnStyle}
title={
pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")
}
className={`${btnBase} ${pinned ? activeClass : idle}`}
>
{pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")}
>
)}
{footerItems.map(renderItem)}
{footerItems.length > 0 &&
}
onLogout({ manual: true })}
style={btnStyle}
title={t("common.logout")}
aria-label={t("common.logout")}
className={`${btnBase} text-muted-foreground hover:text-destructive hover:bg-destructive/10`}
>
{t("common.logout")}
{username.charAt(0).toUpperCase() || "U"}
{username || t("nav.roleUser")}
{isAdmin ? t("nav.roleAdministrator") : t("nav.roleUser")}
{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={() => {
onOpenSettings();
setMenuPos(null);
}}
>
{t("nav.openSettings")}
)}
);
}
function MenuItem({
icon,
onClick,
checked,
children,
}: {
icon: React.ReactNode;
onClick: () => void;
checked?: boolean;
children: React.ReactNode;
}) {
return (
{icon}
{children}
);
}