import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { Separator } from "@/components/separator"; import { Button } from "@/components/button"; import { Sheet, SheetContent } from "@/components/sheet"; import { ChevronLeft, ChevronRight, Maximize2 } from "lucide-react"; import { useState, useRef, useCallback, useEffect, createRef } from "react"; import { useIsMobile } from "@/hooks/use-mobile"; import { MobileBottomBar } from "@/shell/MobileBottomBar"; import { CommandPalette } from "@/shell/CommandPalette"; import { AppRail } from "@/sidebar/AppRail"; import type { RailView } from "@/sidebar/AppRail"; import { HostsPanel } from "@/sidebar/HostsPanel"; import { QuickConnectPanel } from "@/sidebar/QuickConnectPanel"; import { SshToolsPanel } from "@/sidebar/SshToolsPanel"; import { SnippetsPanel } from "@/sidebar/SnippetsPanel"; import { HistoryPanel } from "@/sidebar/HistoryPanel"; import { SplitScreenPanel } from "@/sidebar/SplitScreenPanel"; import { UserProfilePanel } from "@/sidebar/UserProfilePanel"; import { AdminSettingsPanel } from "@/sidebar/AdminSettingsPanel"; import { SplitView } from "@/shell/SplitView"; import { TabBar } from "@/shell/TabBar"; import type { Tab, TabType, Host, SplitMode, HostFolder, } from "@/types/ui-types"; import { getSSHHosts, getUserInfo } from "@/main-axios"; import { dbHealthMonitor } from "@/lib/db-health-monitor"; import type { SSHHostWithStatus } from "@/main-axios"; function sshHostToHost(h: SSHHostWithStatus): Host { return { id: String(h.id), name: h.name, username: h.username, ip: h.ip, port: h.port, folder: h.folder ?? "", online: h.status === "online", cpu: 0, ram: 0, lastAccess: "", tags: h.tags ?? [], authType: h.authType, password: h.password, key: typeof h.key === "string" ? h.key : undefined, keyPassword: h.keyPassword, keyType: h.keyType, credentialId: h.credentialId != null ? String(h.credentialId) : undefined, notes: h.notes, pin: h.pin ?? false, macAddress: h.macAddress, enableSsh: h.enableSsh ?? (h.connectionType === "ssh" || !h.connectionType), enableTerminal: h.enableTerminal ?? true, enableTunnel: h.enableTunnel ?? false, enableFileManager: h.enableFileManager ?? false, enableDocker: h.enableDocker ?? false, enableRdp: h.enableRdp ?? h.connectionType === "rdp", enableVnc: h.enableVnc ?? h.connectionType === "vnc", enableTelnet: h.enableTelnet ?? h.connectionType === "telnet", sshPort: h.port, rdpPort: 3389, vncPort: 5900, telnetPort: 23, quickActions: (h.quickActions ?? []).map((a) => ({ name: a.name, snippetId: String(a.snippetId), })), serverTunnels: [], defaultPath: h.defaultPath, terminalConfig: h.terminalConfig as Host["terminalConfig"], useSocks5: h.useSocks5, socks5Host: h.socks5Host, socks5Port: h.socks5Port, socks5Username: h.socks5Username, socks5Password: h.socks5Password, }; } function buildHostTree(hosts: SSHHostWithStatus[]): HostFolder { const root: HostFolder = { name: "root", children: [] }; const folderMap = new Map(); const getOrCreateFolder = (path: string): HostFolder => { if (folderMap.has(path)) return folderMap.get(path)!; const parts = path.split(" / "); let current = root; let accumulated = ""; for (const part of parts) { accumulated = accumulated ? `${accumulated} / ${part}` : part; if (!folderMap.has(accumulated)) { const folder: HostFolder = { name: part, children: [] }; folderMap.set(accumulated, folder); current.children.push(folder); } current = folderMap.get(accumulated)!; } return current; }; for (const h of hosts) { const host = sshHostToHost(h); if (h.folder) { getOrCreateFolder(h.folder).children.push(host); } else { root.children.push(host); } } return root; } export { tabIcon, renderTabContent } from "@/shell/tabUtils"; import { renderTabContent } from "@/shell/tabUtils"; // ─── AppShell ──────────────────────────────────────────────────────────────── export function AppShell({ username, onLogout, }: { username: string; onLogout: () => void; }) { const { t } = useTranslation(); const [tabs, setTabs] = useState([ { id: "dashboard", type: "dashboard", label: t("nav.dashboard") }, ]); const [activeTabId, setActiveTabId] = useState("dashboard"); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); const [splitMode, setSplitMode] = useState("none"); const [paneTabIds, setPaneTabIds] = useState<(string | null)[]>( Array(6).fill(null), ); const [realHostTree, setRealHostTree] = useState(null); const [allHosts, setAllHosts] = useState([]); const [isAdmin, setIsAdmin] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(true); const [railView, setRailView] = useState("hosts"); const [profileDropdownOpen, setProfileDropdownOpen] = useState(false); const [hostManagerExpanded, setHostManagerExpanded] = useState(false); const [sidebarWidth, setSidebarWidth] = useState(266); const [sidebarDragging, setSidebarDragging] = useState(false); const isMobile = useIsMobile(); // Close the sidebar when switching to mobile (it becomes a sheet overlay) useEffect(() => { if (isMobile) setSidebarOpen(false); }, [isMobile]); useEffect(() => { getUserInfo() .then((info) => setIsAdmin(info.is_admin)) .catch(() => setIsAdmin(false)); }, []); const pendingHostManagerEditId = useRef(null); const pendingHostManagerAction = useRef<"add-host" | "add-credential" | null>( null, ); const lastShiftTime = useRef(0); const terminalRefs = useRef>>( new Map(), ); const sidebarTitle: Record = { hosts: "Hosts", "quick-connect": "Quick Connect", "ssh-tools": "SSH Tools", snippets: "Snippets", history: "History", "split-screen": "Split Screen", "user-profile": "User Profile", "admin-settings": "Admin Settings", }; // Double-shift opens command palette useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.code === "ShiftLeft") { const now = Date.now(); if (now - lastShiftTime.current < 300) setCommandPaletteOpen((prev) => !prev); lastShiftTime.current = now; } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, []); useEffect(() => { const handle = () => onLogout(); window.addEventListener("termix:logout", handle); return () => window.removeEventListener("termix:logout", handle); }, [onLogout]); useEffect(() => { const handleSessionExpired = () => onLogout(); dbHealthMonitor.on("session-expired", handleSessionExpired); return () => dbHealthMonitor.off("session-expired", handleSessionExpired); }, [onLogout]); useEffect(() => { const activeTab = tabs.find((t) => t.id === activeTabId); if (!activeTab?.terminalRef) return; let innerRafId: number; const outerRafId = requestAnimationFrame(() => { innerRafId = requestAnimationFrame(() => { const ref = activeTab.terminalRef?.current; ref?.fit?.(); ref?.notifyResize?.(); ref?.refresh?.(); }); }); return () => { cancelAnimationFrame(outerRafId); cancelAnimationFrame(innerRafId); }; }, [activeTabId]); useEffect(() => { const handleDegraded = () => { toast.loading(t("common.connectionDegraded"), { id: "db-connection-degraded", duration: Infinity, dismissible: false, action: { label: t("common.reload"), onClick: () => window.location.reload(), }, }); }; const handleRestored = () => { toast.dismiss("db-connection-degraded"); toast.success(t("common.backendReconnected"), { duration: 3000 }); }; dbHealthMonitor.on("database-connection-degraded", handleDegraded); dbHealthMonitor.on("database-connection-degraded-cleared", handleRestored); return () => { dbHealthMonitor.off("database-connection-degraded", handleDegraded); dbHealthMonitor.off( "database-connection-degraded-cleared", handleRestored, ); }; }, [t]); // Load real hosts from API const loadHosts = useCallback(async () => { try { const raw = await getSSHHosts(); const converted = raw.map(sshHostToHost); setAllHosts(converted); setRealHostTree(buildHostTree(raw)); } catch { // Keep empty state on error } }, []); useEffect(() => { loadHosts(); }, [loadHosts]); useEffect(() => { window.addEventListener("termix:hosts-changed", loadHosts); return () => window.removeEventListener("termix:hosts-changed", loadHosts); }, [loadHosts]); // Sync tab host data when allHosts updates (e.g. after editing terminal theme in host settings) useEffect(() => { if (allHosts.length === 0) return; setTabs((prev) => prev.map((t) => t.host ? { ...t, host: allHosts.find((h) => h.id === t.host!.id) ?? t.host } : t, ), ); }, [allHosts]); // Let HostManager trigger tab opens via custom event useEffect(() => { const handle = (e: Event) => { const { hostId, type } = ( e as CustomEvent<{ hostId: string; type?: TabType }> ).detail; const host = allHosts.find((h) => h.id === hostId); if (host) connectHost(host, type); }; window.addEventListener("termix:open-tab", handle); return () => window.removeEventListener("termix:open-tab", handle); }, [allHosts]); // ─── Tab management ────────────────────────────────────────────────────── function openTab(host: Host, type: TabType) { const tabId = `${host.name}-${type}-${Date.now()}`; const ref = type === "terminal" ? createRef() : undefined; if (ref) terminalRefs.current.set(tabId, ref); setTabs((prev) => { const same = prev.filter( (t) => t.type === type && t.label.replace(/ \(\d+\)$/, "") === host.name, ); const label = same.length === 0 ? host.name : `${host.name} (${same.length + 1})`; // Retrofit the first duplicate's label to "(1)" if needed const next = same.length === 1 && !/\(\d+\)$/.test(same[0].label) ? prev.map((t) => t.id === same[0].id ? { ...t, label: `${host.name} (1)` } : t, ) : prev; return [...next, { id: tabId, type, label, host, terminalRef: ref }]; }); setActiveTabId(tabId); } function connectHost(host: Host, preferredType?: TabType) { const type: TabType = preferredType ?? (host.enableSsh ? "terminal" : host.enableRdp ? "rdp" : host.enableVnc ? "vnc" : host.enableTelnet ? "telnet" : "terminal"); openTab(host, type); } function openSingletonTab(type: TabType, pendingEvent?: string) { if (type === "host-manager") { if (pendingEvent === "host-manager:add-host") pendingHostManagerAction.current = "add-host"; else if (pendingEvent === "host-manager:add-credential") pendingHostManagerAction.current = "add-credential"; setHostManagerExpanded(true); setSidebarOpen(true); setRailView("hosts"); if (pendingEvent) { // Use a small delay to ensure HostManager is mounted if it wasn't already, // and to allow the current render cycle to complete. setTimeout(() => { window.dispatchEvent(new CustomEvent(pendingEvent)); }, 0); } return; } if (type === "user-profile" || type === "admin-settings") { handleRailClick(type as RailView); return; } const id = type; setTabs((prev) => { if (prev.find((t) => t.id === id)) return prev; const singletonLabels: Partial> = { "host-manager": t("nav.hostManager"), docker: t("nav.docker"), tunnel: t("nav.tunnels"), network_graph: t("nav.networkGraph"), }; return [...prev, { id, type, label: singletonLabels[type] ?? type }]; }); setActiveTabId(id); } const SESSION_TAB_TYPES: TabType[] = ["terminal", "rdp", "vnc", "telnet"]; function doCloseTab(id: string) { terminalRefs.current.delete(id); if (id === activeTabId) { const remaining = tabs.filter((t) => t.id !== id); setActiveTabId( remaining.length > 0 ? remaining[remaining.length - 1].id : "dashboard", ); } setTabs((prev) => { const next = prev.filter((t) => t.id !== id); if (next.length === 0) return [ { id: "dashboard", type: "dashboard", label: t("nav.dashboard") }, ]; return next; }); } function closeTab(id: string) { const tab = tabs.find((t) => t.id === id); const confirmEnabled = localStorage.getItem("confirmTabClose") === "true"; if (tab && SESSION_TAB_TYPES.includes(tab.type) && confirmEnabled) { toast(t("nav.confirmClose"), { duration: 5000, action: { label: t("nav.close"), onClick: () => doCloseTab(id), }, cancel: { label: t("nav.cancel"), onClick: () => {}, }, }); return; } doCloseTab(id); } // ─── Rail / sidebar ────────────────────────────────────────────────────── function handleRailClick(view: RailView) { if (railView === view && sidebarOpen) { setSidebarOpen(false); } else { setRailView(view); setSidebarOpen(true); } } function editHostInManager(host: Host) { pendingHostManagerEditId.current = host.id; setHostManagerExpanded(true); setSidebarOpen(true); setRailView("hosts"); } const onSidebarMouseDown = useCallback( (e: React.MouseEvent) => { e.preventDefault(); setSidebarDragging(true); const startX = e.clientX; const startW = sidebarWidth; function onMove(ev: MouseEvent) { setSidebarWidth( Math.max(160, Math.min(480, startW + ev.clientX - startX)), ); } function onUp() { setSidebarDragging(false); window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); } window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); }, [sidebarWidth], ); const activeTab = tabs.find((t) => t.id === activeTabId)!; const isSplit = splitMode !== "none"; const terminalTabs = tabs.filter((t) => t.type === "terminal"); // Sidebar panel content — shared between desktop inline sidebar and mobile sheet const sidebarPanelContent = (
{railView === "hosts" && ( setHostManagerExpanded(true)} onCollapse={() => { setHostManagerExpanded(false); loadHosts(); }} pendingEditId={pendingHostManagerEditId} pendingAction={pendingHostManagerAction} onOpenTab={(host, type) => { connectHost(host, type); if (isMobile) setSidebarOpen(false); }} onEditHost={editHostInManager} hostTree={realHostTree ?? undefined} /> )} {railView === "quick-connect" && ( { openTab(host, type); if (isMobile) setSidebarOpen(false); }} /> )} {railView === "ssh-tools" && (
)} {railView === "snippets" && (
)} {railView === "history" && (
)} {railView === "split-screen" && (
)} {railView === "user-profile" && (
)} {railView === "admin-settings" && isAdmin && (
)}
); // Sidebar header — shared const sidebarHeader = (
{sidebarTitle[railView]} {!hostManagerExpanded && !isMobile && ( <> )}
); return ( <>
{/* Skinny icon rail — desktop only, hidden on mobile */} {/* Desktop: inline resizable sidebar */} {!isMobile && (
{sidebarHeader} {sidebarPanelContent} {sidebarOpen && !(hostManagerExpanded && railView === "hosts") && (
)}
)} {/* Mobile: sidebar as overlay sheet */} {isMobile && ( { setSidebarOpen(open); if (!open) setHostManagerExpanded(false); }} > {sidebarHeader} {sidebarPanelContent} )} {/* Main content area */}
{!isMobile && !sidebarOpen && ( )}
{isSplit && !isMobile ? ( ) : ( <> {/* Terminal tabs: always in DOM, absolutely positioned so xterm always has real dimensions */} {tabs .filter((tab) => tab.type === "terminal") .map((tab) => { const visible = tab.id === activeTabId; return (
{renderTabContent( tab, openSingletonTab, openTab, closeTab, visible, )}
); })} {/* Non-terminal tabs: absolutely positioned above terminals when active */} {tabs .filter((tab) => tab.type !== "terminal") .map((tab) => { const visible = tab.id === activeTabId; return (
{renderTabContent( tab, openSingletonTab, openTab, closeTab, visible, )}
); })} )}
{/* Bottom nav bar — mobile only */}
{ if ( [ "dashboard", "host-manager", "user-profile", "admin-settings", ].includes(type) ) { openSingletonTab(type, pendingEvent); } else if (label) { const host = allHosts.find((h) => h.name === label); if (host) openTab(host, type); } }} /> ); }