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 } 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 } from "@/main-axios"; 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.connectionType === "rdp", enableVnc: h.connectionType === "vnc", 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"; const DASHBOARD_TAB: Tab = { id: "dashboard", type: "dashboard", label: "Dashboard", }; const SINGLETON_TAB_LABELS: Partial> = { "host-manager": "Host Manager", docker: "Docker", tunnel: "Tunnels", network_graph: "Network Graph", }; // ─── AppShell ──────────────────────────────────────────────────────────────── export function AppShell({ username, onLogout, }: { username: string; onLogout: () => void; }) { const [tabs, setTabs] = useState([DASHBOARD_TAB]); 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 [sidebarOpen, setSidebarOpen] = useState(true); const [railView, setRailView] = useState("hosts"); const [profileDropdownOpen, setProfileDropdownOpen] = useState(false); const [hostManagerExpanded, setHostManagerExpanded] = useState(false); const [sidebarWidth, setSidebarWidth] = useState(256); 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]); const pendingHostManagerEditId = useRef(null); const pendingHostManagerAction = useRef<"add-host" | "add-credential" | null>( null, ); const lastShiftTime = useRef(0); 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]); // 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]); // 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); }, [tabs, allHosts]); // ─── Tab management ────────────────────────────────────────────────────── function openTab(host: Host, type: TabType) { setTabs((prev) => { const same = prev.filter( (t) => t.type === type && t.label.replace(/ \(\d+\)$/, "") === host.name, ); if (same.length === 0) { const tab = { id: `${host.name}-${type}`, type, label: host.name, host, }; setActiveTabId(tab.id); return [...prev, tab]; } const next = prev.map((t) => t.id === same[0].id && !/\(\d+\)$/.test(t.label) ? { ...t, label: `${host.name} (1)`, host } : t, ); const tab = { id: `${host.name}-${type}-${Date.now()}`, type, label: `${host.name} (${same.length + 1})`, host, }; setActiveTabId(tab.id); return [...next, tab]; }); } 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; return [...prev, { id, type, label: SINGLETON_TAB_LABELS[type] ?? type }]; }); setActiveTabId(id); } function closeTab(id: string) { setTabs((prev) => { const next = prev.filter((t) => t.id !== id); if (id === activeTabId) setActiveTabId(next[next.length - 1].id); return next; }); } // ─── 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" && } {railView === "ssh-tools" && (
)} {railView === "snippets" && (
)} {railView === "history" && (
)} {railView === "split-screen" && (
)} {railView === "user-profile" && (
)} {railView === "admin-settings" && (
)}
); // 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 ? ( ) : activeTab ? ( renderTabContent(activeTab, openSingletonTab, openTab) ) : null}
{/* 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); } }} /> ); }