import React, { useState, useRef, useEffect } from "react"; import { SSHSidebar } from "@/apps/SSH/SSHSidebar.tsx"; import { SSHTerminal } from "./SSHTerminal.tsx"; import { SSHTopbar } from "@/apps/SSH/SSHTopbar.tsx"; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'; import * as ResizablePrimitive from "react-resizable-panels"; interface ConfigEditorProps { onSelectView: (view: string) => void; } type Tab = { id: number; title: string; hostConfig: any; terminalRef: React.RefObject; }; export function SSH({ onSelectView }: ConfigEditorProps): React.ReactElement { const [allTabs, setAllTabs] = useState([]); const [currentTab, setCurrentTab] = useState(null); const [allSplitScreenTab, setAllSplitScreenTab] = useState([]); const nextTabId = useRef(1); const [panelRects, setPanelRects] = useState>({}); const panelRefs = useRef>({}); const panelGroupRefs = useRef<{ [key: string]: any }>({}); const setActiveTab = (tabId: number) => { setCurrentTab(tabId); }; const fitVisibleTerminals = () => { allTabs.forEach((terminal) => { const isVisible = (allSplitScreenTab.length === 0 && terminal.id === currentTab) || (allSplitScreenTab.length > 0 && (terminal.id === currentTab || allSplitScreenTab.includes(terminal.id))); if (isVisible && terminal.terminalRef && terminal.terminalRef.current && typeof terminal.terminalRef.current.fit === 'function') { terminal.terminalRef.current.fit(); } }); }; const setSplitScreenTab = (tabId: number) => { fitVisibleTerminals(); setAllSplitScreenTab((prev) => { let next; if (prev.includes(tabId)) { next = prev.filter((id) => id !== tabId); } else if (prev.length < 3) { next = [...prev, tabId]; } else { next = prev; } setTimeout(() => fitVisibleTerminals(), 0); return next; }); }; const setCloseTab = (tabId: number) => { const tab = allTabs.find((t) => t.id === tabId); if (tab && tab.terminalRef && tab.terminalRef.current && typeof tab.terminalRef.current.disconnect === "function") { tab.terminalRef.current.disconnect(); } setAllTabs((prev) => prev.filter((tab) => tab.id !== tabId)); setAllSplitScreenTab((prev) => prev.filter((id) => id !== tabId)); if (currentTab === tabId) { const remainingTabs = allTabs.filter((tab) => tab.id !== tabId); setCurrentTab(remainingTabs.length > 0 ? remainingTabs[0].id : null); } }; const updatePanelRects = () => { setPanelRects((prev) => { const next: Record = { ...prev }; Object.entries(panelRefs.current).forEach(([id, ref]) => { if (ref) { next[id] = ref.getBoundingClientRect(); } }); return next; }); }; useEffect(() => { const observers: ResizeObserver[] = []; Object.entries(panelRefs.current).forEach(([id, ref]) => { if (ref) { const observer = new ResizeObserver(() => updatePanelRects()); observer.observe(ref); observers.push(observer); } }); updatePanelRects(); return () => { observers.forEach((observer) => observer.disconnect()); }; }, [allSplitScreenTab, currentTab, allTabs.length]); const renderAllTerminals = () => { const layoutStyles: Record = {}; const splitTabs = allTabs.filter((tab) => allSplitScreenTab.includes(tab.id)); const mainTab = allTabs.find((tab) => tab.id === currentTab); const layoutTabs = [mainTab, ...splitTabs.filter((t) => t && t.id !== (mainTab && mainTab.id))].filter((t): t is Tab => !!t); if (allSplitScreenTab.length === 0 && mainTab) { layoutStyles[mainTab.id] = { position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', zIndex: 20, display: 'block', pointerEvents: 'auto', }; } else { layoutTabs.forEach((tab) => { const rect = panelRects[String(tab.id)]; if (rect) { const parentRect = panelRefs.current['parent']?.getBoundingClientRect(); let top = rect.top, left = rect.left, width = rect.width, height = rect.height; if (parentRect) { top = rect.top - parentRect.top; left = rect.left - parentRect.left; } layoutStyles[tab.id] = { position: 'absolute', top: top + 28, left, width, height: height - 28, zIndex: 20, display: 'block', pointerEvents: 'auto', }; } }); } return (
{ panelRefs.current['parent'] = el; }} style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', zIndex: 1, overflow: 'hidden' }}> {allTabs.map((tab) => { const style = layoutStyles[tab.id] ? { ...layoutStyles[tab.id], overflow: 'hidden' } : { display: 'none', overflow: 'hidden' }; const isVisible = !!layoutStyles[tab.id]; return (
0} />
); })}
); }; const renderSplitOverlays = () => { const splitTabs = allTabs.filter((tab) => allSplitScreenTab.includes(tab.id)); const mainTab = allTabs.find((tab) => tab.id === currentTab); const layoutTabs = [mainTab, ...splitTabs.filter((t) => t && t.id !== (mainTab && mainTab.id))].filter((t): t is Tab => !!t); if (allSplitScreenTab.length === 0) return null; if (layoutTabs.length === 2) { const [tab1, tab2] = layoutTabs; return (
{ panelGroupRefs.current['main'] = el; }} direction="horizontal" className="h-full w-full" id="main-horizontal" >
{ panelRefs.current[String(tab1.id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
{tab1.title}
{ panelRefs.current[String(tab2.id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
{tab2.title}
); } if (layoutTabs.length === 3) { return (
{ panelGroupRefs.current['main'] = el; }} direction="vertical" className="h-full w-full" id="main-vertical" > { panelGroupRefs.current['top'] = el; }} direction="horizontal" className="h-full w-full" id="top-horizontal">
{ panelRefs.current[String(layoutTabs[0].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
{layoutTabs[0].title}
{ panelRefs.current[String(layoutTabs[1].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
{layoutTabs[1].title}
{ panelRefs.current[String(layoutTabs[2].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
{layoutTabs[2].title}
); } if (layoutTabs.length === 4) { return (
{ panelGroupRefs.current['main'] = el; }} direction="vertical" className="h-full w-full" id="main-vertical" > { panelGroupRefs.current['top'] = el; }} direction="horizontal" className="h-full w-full" id="top-horizontal">
{ panelRefs.current[String(layoutTabs[0].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
{layoutTabs[0].title}
{ panelRefs.current[String(layoutTabs[1].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
{layoutTabs[1].title}
{ panelGroupRefs.current['bottom'] = el; }} direction="horizontal" className="h-full w-full" id="bottom-horizontal">
{ panelRefs.current[String(layoutTabs[2].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
{layoutTabs[2].title}
{ panelRefs.current[String(layoutTabs[3].id)] = el; }} style={{height: '100%', width: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', margin: 0, padding: 0, position: 'relative'}}>
{layoutTabs[3].title}
); } return null; }; const onAddHostSubmit = (data: any) => { const id = nextTabId.current++; const title = `${data.ip || "Host"}:${data.port || 22}`; const terminalRef = React.createRef(); const newTab: Tab = { id, title, hostConfig: data, terminalRef, }; setAllTabs((prev) => [...prev, newTab]); setCurrentTab(id); setAllSplitScreenTab((prev) => prev.filter((tid) => tid !== id)); }; const getUniqueTabTitle = (baseTitle: string) => { let title = baseTitle; let count = 1; const existingTitles = allTabs.map(t => t.title); while (existingTitles.includes(title)) { title = `${baseTitle} (${count})`; count++; } return title; }; const onHostConnect = (hostConfig: any) => { const baseTitle = hostConfig.name?.trim() ? hostConfig.name : `${hostConfig.ip || "Host"}:${hostConfig.port || 22}`; const title = getUniqueTabTitle(baseTitle); const terminalRef = React.createRef(); const id = nextTabId.current++; const newTab: Tab = { id, title, hostConfig, terminalRef, }; setAllTabs((prev) => [...prev, newTab]); setCurrentTab(id); setAllSplitScreenTab((prev) => prev.filter((tid) => tid !== id)); }; return (
{/* Sidebar: fixed width */}
{ allTabs.forEach(tab => { if (tabIds.includes(tab.id) && tab.terminalRef?.current?.sendInput) { tab.terminalRef.current.sendInput(command); } }); }} />
{/* Main area: fills the rest */}
{/* Always render the topbar at the top */}
{/* Split area below the topbar */}
{/* Show alert when no terminals are rendered */} {allTabs.length === 0 && (
Welcome to Termix SSH
Click on any host title in the sidebar to open a terminal connection, or use the "Add Host" button to create a new connection.
)} {/* Absolutely render all terminals for persistence and layout */} {allSplitScreenTab.length > 0 && (
)} {renderAllTerminals()} {renderSplitOverlays()}
); }