import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Bell, Check, LogOut, PanelRight, Settings, SlidersHorizontal, SquareArrowOutUpRight, User, } from "lucide-react"; import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types"; import { getAlertFirings } from "@/api/alerts-api"; import { isElectron } from "@/lib/electron"; import { readRailPreference, setRailPreference } from "./rail-preferences"; import { visibleRailItems } from "./rail-items"; import { useAiAvailability } from "@/hooks/use-ai-availability"; export type RailView = | "hosts" | "credentials" | "termix-id" | "quick-connect" | "serial" | ToolsTab | "connections" | "session-logs" | "user-profile" | "admin-settings" | "alerts" | "automations" | "ai" | "fleets" | "workspaces" | "collab"; export type HideableRailView = | Exclude | "network_graph" | "homepage"; type RailItem = | { kind?: undefined; view: RailView; icon: React.ReactNode; title: string; dot?: boolean; promotable?: boolean; rightDockable?: boolean; } | { kind: "tab"; tabType: TabType; icon: React.ReactNode; title: string } | { kind: "separator" }; function buildRailButtons( splitMode: SplitMode, t: (key: string) => string, hidden: Set, ): RailItem[] { const all: RailItem[] = []; for (const item of visibleRailItems()) { const Icon = item.icon; if (item.kind === "tab") { all.push({ kind: "tab", tabType: item.id as TabType, icon: , title: t(item.labelKey), }); } else { all.push({ view: item.id as RailView, icon: , title: t(item.labelKey), dot: item.id === "split-screen" ? splitMode !== "none" : undefined, promotable: item.promotable, rightDockable: item.rightDockable, }); } if (item.separatorAfter) all.push({ kind: "separator" }); } // Filter out hidden items, then collapse consecutive/leading/trailing separators const filtered = all.filter((item) => { if (item.kind === "separator") return true; if ("tabType" in item) return !hidden.has(item.tabType); return !hidden.has(item.view); }); const result: RailItem[] = []; for (const item of filtered) { if (item.kind === "separator") { if (result.length === 0 || result[result.length - 1].kind === "separator") continue; result.push(item); } else { result.push(item); } } if (result[result.length - 1]?.kind === "separator") result.pop(); return result; } const btnBase = "relative flex items-center h-7 rounded shrink-0 transition-colors gap-2.5"; const btnStyle = { margin: "0 4px", padding: "0 8px" }; export function AppRail({ railView, sidebarOpen, splitMode, username, isAdmin, onRailClick, onOpenTab, onOpenInRightDock, onLogout, }: { railView: RailView; sidebarOpen: boolean; splitMode: SplitMode; username: string; isAdmin: boolean; onRailClick: (view: RailView) => void; onOpenTab?: (type: TabType) => void; onOpenInRightDock?: (view: RailView) => void; onLogout: () => void; }) { const { t } = useTranslation(); const [hovered, setHovered] = useState(false); const [pinned, setPinned] = useState(() => readRailPreference("pinAppRail")); const [expandOnHover, setExpandOnHover] = useState(() => readRailPreference("expandAppRailOnHover"), ); const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null); // Which promotable item was right-clicked, so the menu can offer to open it // as a tab. Null when the right-click landed on empty rail space. const [menuTarget, setMenuTarget] = useState<{ view: RailView; title: string; promotable?: boolean; rightDockable?: boolean; } | null>(null); const [unreadAlerts, setUnreadAlerts] = useState(0); useEffect(() => { let cancelled = false; let intervalId: ReturnType | null = null; const poll = () => { if (document.visibilityState === "hidden") return; getAlertFirings({ acknowledged: false, limit: 50 }) .then((firings) => { if (!cancelled) setUnreadAlerts(firings.length); }) .catch(() => {}); }; const start = () => { if (intervalId !== null) return; intervalId = setInterval(poll, 30000); }; const stop = () => { if (intervalId === null) return; clearInterval(intervalId); intervalId = null; }; const onVisibility = () => { if (document.visibilityState === "hidden") { stop(); return; } poll(); start(); }; poll(); if (document.visibilityState !== "hidden") start(); document.addEventListener("visibilitychange", onVisibility); return () => { cancelled = true; stop(); document.removeEventListener("visibilitychange", onVisibility); }; }, []); const [hiddenTabs, setHiddenTabs] = useState>(() => { try { const stored = localStorage.getItem("hiddenRailTabs"); return stored ? new Set(JSON.parse(stored)) : new Set(); } catch { return new Set(); } }); useEffect(() => { const pinHandler = () => setPinned(readRailPreference("pinAppRail")); const hoverHandler = () => setExpandOnHover(readRailPreference("expandAppRailOnHover")); window.addEventListener("pinAppRailChanged", pinHandler); window.addEventListener("expandAppRailOnHoverChanged", hoverHandler); return () => { window.removeEventListener("pinAppRailChanged", pinHandler); window.removeEventListener("expandAppRailOnHoverChanged", hoverHandler); }; }, []); 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]); const { userEnabled: aiEnabled } = useAiAvailability(); useEffect(() => { const handler = () => { try { const stored = localStorage.getItem("hiddenRailTabs"); setHiddenTabs(stored ? new Set(JSON.parse(stored)) : new Set()); } catch { setHiddenTabs(new Set()); } }; window.addEventListener("hiddenRailTabsChanged", handler); return () => window.removeEventListener("hiddenRailTabsChanged", handler); }, []); // Termix ID publishes SSH public keys under a claimed public handle for // other servers to fetch -- meaningless for a standalone desktop install // with no synced multi-device account, so it stays hidden until a remote // server is actually connected. const [isRemoteSyncConnected, setIsRemoteSyncConnected] = useState( () => !isElectron(), ); useEffect(() => { if (!isElectron()) return; let cancelled = false; const refreshSyncStatus = () => { window.electronAPI ?.invoke?.("get-remote-sync-config") .then((config) => { if (!cancelled) { setIsRemoteSyncConnected( !!(config as { serverUrl?: string } | null)?.serverUrl, ); } }) .catch(() => {}); }; refreshSyncStatus(); const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.(() => refreshSyncStatus(), ); return () => { cancelled = true; unsubscribe?.(); }; }, []); const railExpanded = pinned || (expandOnHover && hovered); const effectiveHiddenTabs = new Set([ ...hiddenTabs, ...(isRemoteSyncConnected ? [] : ["termix-id"]), // The assistant is hidden until an admin has enabled it instance-wide and // the user has said yes, so someone who declined never sees the entry. ...(aiEnabled ? [] : ["ai"]), ]); const railButtons = buildRailButtons(splitMode, t, effectiveHiddenTabs); const togglePinned = () => { setRailPreference("pinAppRail", !pinned); setMenuPos(null); }; const toggleExpandOnHover = () => { setRailPreference("expandAppRailOnHover", !expandOnHover); setMenuPos(null); }; 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 = 190; const MENU_H = 96; 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-promotable]")) { setMenuTarget(null); } }} >
{railButtons.map((item, i) => item.kind === "separator" ? (
) : "tabType" in item ? ( ) : ( ), )}
{[ { view: "alerts" as RailView, icon: , title: t("nav.alerts"), promotable: true, }, { view: "user-profile" as RailView, icon: , title: t("nav.userProfile"), }, ...(isAdmin ? [ { view: "admin-settings" as RailView, icon: , title: t("nav.admin"), }, ] : []), ].map((item) => ( ))}
{menuPos && (
{menuTarget && ( <> {menuTarget.promotable && ( )} {menuTarget.rightDockable && ( )}
)}
)}
); }