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 { createPortal } from "react-dom"; 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 { CredentialsPanel } from "@/sidebar/CredentialsPanel"; import { SplitView } from "@/shell/SplitView"; import { renderTabContent } from "@/shell/tabUtils"; import { TabBar } from "@/shell/TabBar"; import type { Tab, TabType, Host, SplitMode, HostFolder, } from "@/types/ui-types"; import { PANE_COUNTS } from "@/lib/theme"; import { getSSHHosts, getUserInfo, getOpenTabs, addOpenTab, deleteOpenTab, patchOpenTab, getActiveSessions, getUserPreferences, type UserPreferences, type OpenTabRecord, } from "@/main-axios"; import { dbHealthMonitor } from "@/lib/db-health-monitor"; import type { SSHHostWithStatus } from "@/main-axios"; import { ConnectionsPanel } from "@/sidebar/ConnectionsPanel"; 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), })), jumpHosts: (h.jumpHosts ?? []).map((j) => ({ hostId: String(j.hostId), })), 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, socks5ProxyChain: h.socks5ProxyChain ?? [], }; } 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"; // ─── AppShell ──────────────────────────────────────────────────────────────── export function AppShell({ username, onLogout, }: { username: string; onLogout: () => void; }) { const { t } = useTranslation(); const [tabs, setTabs] = useState([ { id: "dashboard", instanceId: "dashboard", type: "dashboard", label: t("nav.dashboard"), openedAt: Date.now(), }, ]); const [activeTabId, setActiveTabId] = useState("dashboard"); const [userPrefs, setUserPrefs] = useState({ reopenTabsOnLogin: false, }); const [userPrefsLoaded, setUserPrefsLoaded] = useState(false); const [hostsLoaded, setHostsLoaded] = useState(false); // Flips to true once the initial DB read (restore or skip) is done — sync must not fire before this const [tabsReady, setTabsReady] = useState(false); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); const [splitMode, setSplitMode] = useState( () => (localStorage.getItem("termix_splitMode") as SplitMode) ?? "none", ); const [paneTabIds, setPaneTabIds] = useState<(string | null)[]>( () => JSON.parse(localStorage.getItem("termix_paneTabIds") ?? "null") ?? Array(6).fill(null), ); const [focusedPaneIndex, setFocusedPaneIndex] = useState(null); const [realHostTree, setRealHostTree] = useState(null); const [hostsLoading, setHostsLoading] = useState(true); const [allHosts, setAllHosts] = useState([]); const [isAdmin, setIsAdmin] = useState(false); const [backgroundTabRecords, setBackgroundTabRecords] = useState< OpenTabRecord[] >([]); const [sidebarOpen, setSidebarOpen] = useState(true); const [railView, setRailView] = useState("hosts"); const [profileDropdownOpen, setProfileDropdownOpen] = useState(false); const [sidebarWidth, setSidebarWidth] = useState(() => { const saved = localStorage.getItem("termix_sidebarWidth"); return saved ? parseInt(saved, 10) : 266; }); const [sidebarDragging, setSidebarDragging] = useState(false); const [sidebarEditing, setSidebarEditing] = useState(false); useEffect(() => { localStorage.setItem("termix_sidebarWidth", String(sidebarWidth)); }, [sidebarWidth]); useEffect(() => { localStorage.setItem("termix_splitMode", splitMode); }, [splitMode]); useEffect(() => { localStorage.setItem("termix_paneTabIds", JSON.stringify(paneTabIds)); }, [paneTabIds]); const isMobile = useIsMobile(); const sidebarOpenBeforeMobile = useRef(sidebarOpen); useEffect(() => { if (isMobile) { sidebarOpenBeforeMobile.current = sidebarOpen; setSidebarOpen(false); } else { setSidebarOpen(sidebarOpenBeforeMobile.current); } }, [isMobile]); useEffect(() => { getUserInfo() .then((info) => setIsAdmin(info.is_admin)) .catch(() => setIsAdmin(false)); }, []); const lastShiftTime = useRef(0); const [commandPaletteShortcutEnabled, setCommandPaletteShortcutEnabled] = useState(() => { const v = localStorage.getItem("commandPaletteShortcutEnabled"); return v !== null ? v === "true" : true; }); const terminalRefs = useRef>>( new Map(), ); const [paneContentEls, setPaneContentEls] = useState< (HTMLDivElement | null)[] >(Array(6).fill(null)); // Stable per-tab DOM nodes — created once per tab, never destroyed while the tab lives. // We always portal each tab's content into its own node, then move that node between // the normal-view container and the pane container via vanilla DOM so React's portal // target never changes (changing the target causes a remount). const tabNodesRef = useRef>(new Map()); const normalViewRef = useRef(null); const getTabNode = useCallback((tabId: string, isTerminal: boolean) => { if (!tabNodesRef.current.has(tabId)) { const el = document.createElement("div"); el.style.position = "absolute"; el.style.inset = "0"; el.style.overflow = "hidden"; if (!isTerminal) el.classList.add("bg-background"); tabNodesRef.current.set(tabId, el); } return tabNodesRef.current.get(tabId)!; }, []); const onPaneContentRef = useCallback( (paneIndex: number, el: HTMLDivElement | null) => { setPaneContentEls((prev) => { if (prev[paneIndex] === el) return prev; const next = [...prev]; next[paneIndex] = el; return next; }); }, [], ); const sidebarTitle: Record = { hosts: "Hosts", credentials: "Credentials", "quick-connect": "Quick Connect", "ssh-tools": "SSH Tools", snippets: "Snippets", history: "History", "split-screen": "Split Screen", connections: t("nav.connections"), "user-profile": "User Profile", "admin-settings": "Admin Settings", }; // Double-shift opens command palette useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.code === "ShiftLeft" && !e.repeat) { const now = Date.now(); if (now - lastShiftTime.current < 300 && commandPaletteShortcutEnabled) setCommandPaletteOpen((prev) => !prev); lastShiftTime.current = now; } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [commandPaletteShortcutEnabled]); useEffect(() => { const handler = () => { const v = localStorage.getItem("commandPaletteShortcutEnabled"); setCommandPaletteShortcutEnabled(v !== null ? v === "true" : true); }; window.addEventListener("commandPaletteShortcutEnabledChanged", handler); return () => window.removeEventListener( "commandPaletteShortcutEnabledChanged", handler, ); }, []); 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]); useEffect(() => { getUserPreferences() .then((prefs) => setUserPrefs(prefs)) .catch(() => {}) .finally(() => setUserPrefsLoaded(true)); }, []); // 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 } finally { setHostsLoading(false); setHostsLoaded(true); } }, []); 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]); const PERSISTENT_TAB_TYPES: TabType[] = [ "terminal", "rdp", "vnc", "telnet", "files", "docker", "stats", "tunnel", ]; // On load: always read saved tabs from DB so background sessions are preserved across refreshes. // If reopenTabsOnLogin is on, also restore them as open tabs in the tab bar. const tabRestoreAttemptedRef = useRef(false); useEffect(() => { if (!hostsLoaded || !userPrefsLoaded) return; if (tabRestoreAttemptedRef.current) return; tabRestoreAttemptedRef.current = true; async function loadSavedTabs() { try { const [savedTabs, activeSessions] = await Promise.all([ getOpenTabs(), getActiveSessions(), ]); if (!Array.isArray(savedTabs) || savedTabs.length === 0) return; const sessionByInstanceId = new Map( (Array.isArray(activeSessions) ? activeSessions : []) .filter((s) => s.tabInstanceId != null) .map((s) => [s.tabInstanceId, s]), ); if (userPrefs.reopenTabsOnLogin) { const hasPersistentTabs = tabs.some((t) => PERSISTENT_TAB_TYPES.includes(t.type), ); if (!hasPersistentTabs) { const restoredTabs: Tab[] = []; for (const saved of savedTabs as OpenTabRecord[]) { const host = saved.hostId ? allHosts.find((h) => h.id === String(saved.hostId)) : undefined; const hostlessTypes: TabType[] = ["dashboard", "tunnel"]; if (!host && !hostlessTypes.includes(saved.tabType as TabType)) continue; // Singleton tabs use their type as the stable ID; host-bound tabs get a unique ID const tabId = host ? `${host.name}-${saved.tabType}-${Date.now()}-${saved.tabOrder}` : saved.id; const liveSession = sessionByInstanceId.get(saved.id); const restoredSessionId = liveSession?.sessionId ?? saved.backendSessionId ?? null; restoredTabs.push({ id: tabId, instanceId: saved.id, type: saved.tabType as TabType, label: saved.label, host, openedAt: new Date(saved.createdAt).getTime(), restoredSessionId, terminalRef: saved.tabType === "terminal" ? createRef() : undefined, }); } if (restoredTabs.length > 0) { setTabs((prev) => { const existingIds = new Set(prev.map((t) => t.id)); const newTabs = restoredTabs.filter( (t) => !existingIds.has(t.id), ); return newTabs.length > 0 ? [...prev, ...newTabs] : prev; }); setActiveTabId(restoredTabs[0].id); } // Restored tabs are in the tab bar, not in background records } } else { // Not restoring to tab bar — keep as background records for ConnectionsPanel setBackgroundTabRecords(savedTabs as OpenTabRecord[]); } } catch { // silently fail } finally { setTabsReady(true); } } loadSavedTabs(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [hostsLoaded, userPrefsLoaded]); // Debounced tab-order sync: when tab order changes, patch each persistent tab's tabOrder in DB. const orderSyncTimeoutRef = useRef | null>( null, ); const prevTabOrderRef = useRef(""); useEffect(() => { if (!tabsReady) return; const persistable = tabs.filter((t) => PERSISTENT_TAB_TYPES.includes(t.type), ); const orderKey = persistable.map((t) => t.instanceId).join(","); if (orderKey === prevTabOrderRef.current) return; prevTabOrderRef.current = orderKey; if (orderSyncTimeoutRef.current) clearTimeout(orderSyncTimeoutRef.current); orderSyncTimeoutRef.current = setTimeout(() => { persistable.forEach((t, i) => { patchOpenTab(t.instanceId, { tabOrder: i }).catch(() => {}); }); }, 500); return () => { if (orderSyncTimeoutRef.current) clearTimeout(orderSyncTimeoutRef.current); }; }, [tabs, tabsReady]); // ─── Tab management ────────────────────────────────────────────────────── const openTab = useCallback(function openTab( host: Host, type: TabType, restore?: { instanceId: string; restoredSessionId: string | null }, ) { const tabId = `${host.name}-${type}-${Date.now()}`; const instanceId = restore?.instanceId ?? crypto.randomUUID(); const openedAt = Date.now(); const ref = type === "terminal" ? createRef() : undefined; if (ref) terminalRefs.current.set(tabId, ref); let finalLabel = host.name; setTabs((prev) => { const same = prev.filter( (t) => t.type === type && t.label.replace(/ \(\d+\)$/, "") === host.name, ); finalLabel = 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, instanceId, type, label: finalLabel, host, openedAt, terminalRef: ref, restoredSessionId: restore?.restoredSessionId ?? null, }, ]; }); setActiveTabId(tabId); if (PERSISTENT_TAB_TYPES.includes(type)) { addOpenTab({ id: instanceId, tabType: type, hostId: host ? parseInt(host.id) : null, label: finalLabel, tabOrder: 0, }).catch(() => {}); } }, []); 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); } const openSingletonTab = useCallback( function openSingletonTab(type: TabType, pendingEvent?: string) { if (type === "host-manager") { if (pendingEvent === "host-manager:add-credential") { setSidebarOpen(true); setRailView("credentials"); setTimeout( () => window.dispatchEvent( new CustomEvent("host-manager:add-credential"), ), 0, ); } else { setSidebarOpen(true); setRailView("hosts"); if (pendingEvent) { setTimeout( () => window.dispatchEvent(new CustomEvent(pendingEvent)), 0, ); } } return; } if (type === "user-profile" || type === "admin-settings") { setSidebarEditing(false); setRailView(type as RailView); setSidebarOpen(true); return; } const id = type; const singletonLabels: Partial> = { "host-manager": t("nav.hostManager"), docker: t("nav.docker"), tunnel: t("nav.tunnels"), network_graph: t("nav.networkGraph"), }; setTabs((prev) => { if (prev.find((t) => t.id === id)) return prev; return [ ...prev, { id, instanceId: id, type, label: singletonLabels[type] ?? type, openedAt: Date.now(), }, ]; }); setActiveTabId(id); if (PERSISTENT_TAB_TYPES.includes(type)) { addOpenTab({ id, tabType: type, hostId: null, label: singletonLabels[type] ?? type, tabOrder: 0, }).catch(() => {}); } }, [t], ); const SESSION_TAB_TYPES: TabType[] = ["terminal", "rdp", "vnc", "telnet"]; function doCloseTab(id: string) { const tabToClose = tabs.find((t) => t.id === id); if ( tabToClose?.instanceId && PERSISTENT_TAB_TYPES.includes(tabToClose.type) ) { deleteOpenTab(tabToClose.instanceId).catch(() => {}); } 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", ); } setPaneTabIds((prev) => prev.map((p) => (p === id ? null : p))); setTabs((prev) => { const next = prev.filter((t) => t.id !== id); if (next.length === 0) return [ { id: "dashboard", instanceId: "dashboard", type: "dashboard", label: t("nav.dashboard"), openedAt: Date.now(), }, ]; return next; }); } function refreshTab(id: string) { const tab = tabs.find((t) => t.id === id); if (!tab) return; if (tab.type === "terminal") { const ref = tab.terminalRef?.current; ref?.reconnect?.(); } else if (["rdp", "vnc", "telnet"].includes(tab.type)) { window.dispatchEvent( new CustomEvent("termix:refresh-guacamole", { detail: { tabId: id } }), ); } } 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); } function splitTabQuick(tabId: string, mode: SplitMode) { setSplitMode(mode); setPaneTabIds(() => { const count = PANE_COUNTS[mode]; const next: (string | null)[] = Array(6).fill(null); next[0] = tabId; // Fill remaining panes with other non-dashboard tabs in order let slot = 1; for (const tab of tabs) { if (slot >= count) break; if (tab.id !== tabId && tab.type !== "dashboard") { next[slot] = tab.id; slot++; } } return next; }); } function addTabToSplit(tabId: string) { setPaneTabIds((prev) => { // Remove from any current slot first const next = prev.map((p) => (p === tabId ? null : p)); // Find first empty slot within the current pane count const count = PANE_COUNTS[splitMode]; for (let i = 0; i < count; i++) { if (!next[i]) { next[i] = tabId; break; } } return next; }); } function removeTabFromSplit(tabId: string) { setPaneTabIds((prev) => prev.map((p) => (p === tabId ? null : p))); } function assignPane(paneIndex: number, tabId: string) { setPaneTabIds((prev) => { const next = prev.map((p) => (p === tabId ? null : p)); next[paneIndex] = tabId; return next; }); } // ─── Rail / sidebar ────────────────────────────────────────────────────── function handleRailClick(view: RailView) { if (railView === view && sidebarOpen) { setSidebarOpen(false); } else { if (view !== railView) setSidebarEditing(false); setRailView(view); setSidebarOpen(true); } } function editHostInManager(host: Host) { setSidebarOpen(true); setRailView("hosts"); setTimeout(() => { window.dispatchEvent( new CustomEvent("host-manager:edit-host", { detail: host.id }), ); }, 0); } 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], ); // Resize all terminals in panes + active terminal when split mode or sidebar changes const resizeAllTerminals = useCallback(() => { const id = requestAnimationFrame(() => { tabs.forEach((tab) => { if (!tab.terminalRef) return; const ref = tab.terminalRef.current as any; ref?.fit?.(); ref?.notifyResize?.(); }); }); return id; }, [tabs]); useEffect(() => { const id = resizeAllTerminals(); return () => cancelAnimationFrame(id); }, [splitMode, sidebarWidth, sidebarOpen]); const isSplit = splitMode !== "none"; // Move each tab's stable DOM node to the right container (pane or normal-view). // This is vanilla DOM so React's portal target never changes — changing the portal // target causes a remount which is exactly what we're trying to avoid. useEffect(() => { const normalView = normalViewRef.current; if (!normalView) return; const tabIds = new Set(tabs.map((t) => t.id)); // Remove nodes for closed tabs for (const [id, node] of tabNodesRef.current) { if (!tabIds.has(id)) { node.remove(); tabNodesRef.current.delete(id); } } for (const tab of tabs) { const isTerminal = tab.type === "terminal"; const node = getTabNode(tab.id, isTerminal); const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1; const inPane = paneIdx !== -1; const paneEl = inPane ? paneContentEls[paneIdx] : null; const activeInline = !inPane && tab.id === activeTabId; if (inPane && paneEl) { if (node.parentElement !== paneEl) paneEl.appendChild(node); node.style.visibility = "visible"; node.style.pointerEvents = "auto"; node.style.display = ""; node.style.zIndex = ""; } else { if (node.parentElement !== normalView) normalView.appendChild(node); if (isTerminal) { node.style.display = ""; node.style.visibility = activeInline ? "visible" : "hidden"; node.style.pointerEvents = activeInline ? "auto" : "none"; node.style.zIndex = activeInline && !isSplit ? "1" : "0"; } else { node.style.visibility = ""; node.style.pointerEvents = ""; node.style.zIndex = activeInline ? "2" : ""; node.style.display = activeInline ? "" : "none"; } } } }); const activeTab = tabs.find((t) => t.id === activeTabId)!; const terminalTabs = tabs.filter((t) => t.type === "terminal"); // Sidebar panel content — shared between desktop inline sidebar and mobile sheet const sidebarPanelContent = (
{ connectHost(host, type); if (isMobile) setSidebarOpen(false); }} onEditHost={editHostInManager} hostTree={realHostTree ?? undefined} loading={hostsLoading} onEditingChange={setSidebarEditing} active={railView === "hosts"} />
{railView === "quick-connect" && ( { openTab(host, type); if (isMobile) setSidebarOpen(false); }} /> )} {railView === "ssh-tools" && (
)} {railView === "snippets" && (
)} {railView === "history" && (
)} {railView === "split-screen" && (
)} {railView === "connections" && (
{ setActiveTabId(tabId); if (isMobile) setSidebarOpen(false); }} onCloseTab={closeTab} onReopenTab={(record, restoredSessionId) => { const host = record.hostId ? allHosts.find((h) => h.id === String(record.hostId)) : undefined; const hostlessTypes: TabType[] = ["tunnel"]; if (!host && !hostlessTypes.includes(record.tabType as TabType)) return; setBackgroundTabRecords((prev) => prev.filter((r) => r.id !== record.id), ); if (host) { const effectiveSessionId = restoredSessionId ?? record.backendSessionId ?? null; openTab(host, record.tabType as TabType, { instanceId: record.id, restoredSessionId: effectiveSessionId, }); } else { openSingletonTab(record.tabType as TabType); } if (isMobile) setSidebarOpen(false); }} onForgetBackground={(recordId) => { setBackgroundTabRecords((prev) => prev.filter((r) => r.id !== recordId), ); }} />
)} {railView === "user-profile" && (
)} {railView === "admin-settings" && isAdmin && (
)}
); // Sidebar header — shared const sidebarHeader = (
{sidebarTitle[railView]} {!isMobile && ( <> )}
); return ( <>
{/* Skinny icon rail — desktop only, hidden on mobile */} PERSISTENT_TAB_TYPES.includes(t.type)).length + backgroundTabRecords.length } username={username} isAdmin={isAdmin} profileDropdownOpen={profileDropdownOpen} onProfileDropdownChange={setProfileDropdownOpen} onRailClick={handleRailClick} onOpenTab={openSingletonTab} onLogout={onLogout} /> {/* Desktop: inline resizable sidebar */} {!isMobile && (
{sidebarHeader} {sidebarPanelContent} {sidebarOpen && !sidebarEditing && (
)}
)} {/* Mobile: sidebar as overlay sheet */} {isMobile && ( {sidebarHeader} {sidebarPanelContent} )} {/* Main content area */}
{!isMobile && !sidebarOpen && ( )}
{/* Split view — always mounted when not mobile, hidden via CSS when inactive */} {!isMobile && (
)} {/* Normal-view container. Tab nodes are appended here (or to pane elements) by the DOM-placement effect above. React portals each tab's content into its stable per-tab node so the component is never remounted. Hidden when split is active — pane-assigned nodes escape via vanilla DOM appendChild to paneEl, so hiding this doesn't affect them. */}
{tabs.map((tab) => { const tabNode = getTabNode(tab.id, tab.type === "terminal"); const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1; const inPane = paneIdx !== -1; const activeInline = !inPane && tab.id === activeTabId; return createPortal( renderTabContent( tab, openSingletonTab, openTab, closeTab, inPane || activeInline, ), tabNode, tab.id, ); })}
{/* 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); } }} /> ); }