Added SSH manager and terminals to tab system, now I need to do the server stats

This commit is contained in:
LukeGus
2025-08-16 00:29:40 -05:00
parent 58947f4455
commit 2667af9437
17 changed files with 954 additions and 1641 deletions

View File

@@ -1,10 +1,11 @@
import React, {useState, useEffect} from "react" import React, {useState, useEffect} from "react"
import {LeftSidebar} from "@/ui/Navigation/LeftSidebar.tsx" import {LeftSidebar} from "@/ui/Navigation/LeftSidebar.tsx"
import {Homepage} from "@/ui/Homepage/Homepage.tsx" import {Homepage} from "@/ui/Homepage/Homepage.tsx"
import {Terminal} from "@/ui/SSH/Terminal/Terminal.tsx" import {TerminalView} from "@/ui/SSH/Terminal/TerminalView.tsx"
import {SSHTunnel} from "@/ui/SSH/Tunnel/SSHTunnel.tsx" import {SSHTunnel} from "@/ui/SSH/Tunnel/SSHTunnel.tsx"
import {ConfigEditor} from "@/ui/SSH/Config Editor/ConfigEditor.tsx" import {ConfigEditor} from "@/ui/SSH/Config Editor/ConfigEditor.tsx"
import {SSHManager} from "@/ui/SSH/Manager/SSHManager.tsx" import {SSHManager} from "@/ui/SSH/Manager/SSHManager.tsx"
import {TabProvider, useTabs} from "@/contexts/TabContext"
import axios from "axios" import axios from "axios"
import {TopNavbar} from "@/ui/Navigation/TopNavbar.tsx"; import {TopNavbar} from "@/ui/Navigation/TopNavbar.tsx";
@@ -23,7 +24,7 @@ function setCookie(name: string, value: string, days = 7) {
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`; document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
} }
function App() { function AppContent() {
const [view, setView] = useState<string>("homepage") const [view, setView] = useState<string>("homepage")
const [mountedViews, setMountedViews] = useState<Set<string>>(new Set(["homepage"])) const [mountedViews, setMountedViews] = useState<Set<string>>(new Set(["homepage"]))
const [isAuthenticated, setIsAuthenticated] = useState(false) const [isAuthenticated, setIsAuthenticated] = useState(false)
@@ -31,6 +32,7 @@ function App() {
const [isAdmin, setIsAdmin] = useState(false) const [isAdmin, setIsAdmin] = useState(false)
const [authLoading, setAuthLoading] = useState(true) const [authLoading, setAuthLoading] = useState(true)
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true) const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true)
const {currentTab, tabs} = useTabs();
useEffect(() => { useEffect(() => {
const checkAuth = () => { const checkAuth = () => {
@@ -83,6 +85,17 @@ function App() {
setUsername(authData.username) setUsername(authData.username)
} }
// Determine what to show based on current tab
const currentTabData = tabs.find(tab => tab.id === currentTab);
const showTerminalView = currentTabData?.type === 'terminal';
const showHome = currentTabData?.type === 'home';
const showSshManager = currentTabData?.type === 'ssh_manager';
console.log('Current tab:', currentTab);
console.log('Current tab data:', currentTabData);
console.log('Show terminal view:', showTerminalView);
console.log('All tabs:', tabs);
return ( return (
<div> <div>
{/* Enhanced background overlay - detailed pattern when not authenticated */} {/* Enhanced background overlay - detailed pattern when not authenticated */}
@@ -139,20 +152,61 @@ function App() {
isAdmin={isAdmin} isAdmin={isAdmin}
username={username} username={username}
> >
{mountedViews.has("homepage") && ( {/* Always render TerminalView to maintain terminal persistence */}
<div style={{display: view === "homepage" ? "block" : "none"}}> <div
<Homepage className="h-screen w-full"
onSelectView={handleSelectView} style={{
isAuthenticated={isAuthenticated} visibility: showTerminalView ? "visible" : "hidden",
authLoading={authLoading} pointerEvents: showTerminalView ? "auto" : "none",
onAuthSuccess={handleAuthSuccess} height: showTerminalView ? "100vh" : 0,
isTopbarOpen={isTopbarOpen} width: showTerminalView ? "100%" : 0,
/> position: showTerminalView ? "static" : "absolute",
</div> overflow: "hidden",
)} }}
>
<TerminalView isTopbarOpen={isTopbarOpen} />
</div>
{/* Always render Homepage to keep it mounted */}
<div
className="h-screen w-full"
style={{
visibility: showHome ? "visible" : "hidden",
pointerEvents: showHome ? "auto" : "none",
height: showHome ? "100vh" : 0,
width: showHome ? "100%" : 0,
position: showHome ? "static" : "absolute",
overflow: "hidden",
}}
>
<Homepage
onSelectView={handleSelectView}
isAuthenticated={isAuthenticated}
authLoading={authLoading}
onAuthSuccess={handleAuthSuccess}
isTopbarOpen={isTopbarOpen}
/>
</div>
{/* Always render SSH Manager but toggle visibility for persistence */}
<div
className="h-screen w-full"
style={{
visibility: showSshManager ? "visible" : "hidden",
pointerEvents: showSshManager ? "auto" : "none",
height: showSshManager ? "100vh" : 0,
width: showSshManager ? "100%" : 0,
position: showSshManager ? "static" : "absolute",
overflow: "hidden",
}}
>
<SSHManager onSelectView={handleSelectView} isTopbarOpen={isTopbarOpen} />
</div>
{/* Legacy views - keep for compatibility (exclude homepage to avoid duplicate mounts) */}
{mountedViews.has("ssh_manager") && ( {mountedViews.has("ssh_manager") && (
<div style={{display: view === "ssh_manager" ? "block" : "none"}}> <div style={{display: view === "ssh_manager" ? "block" : "none"}}>
<SSHManager onSelectView={handleSelectView}/> <SSHManager onSelectView={handleSelectView} isTopbarOpen={isTopbarOpen}/>
</div> </div>
)} )}
{mountedViews.has("terminal") && ( {mountedViews.has("terminal") && (
@@ -177,4 +231,12 @@ function App() {
) )
} }
function App() {
return (
<TabProvider>
<AppContent />
</TabProvider>
);
}
export default App export default App

134
src/contexts/TabContext.tsx Normal file
View File

@@ -0,0 +1,134 @@
import React, { createContext, useContext, useState, useRef, ReactNode } from 'react';
export interface Tab {
id: number;
type: 'home' | 'terminal' | 'ssh_manager';
title: string;
hostConfig?: any;
terminalRef?: React.RefObject<any>;
}
interface TabContextType {
tabs: Tab[];
currentTab: number | null;
allSplitScreenTab: number[];
addTab: (tab: Omit<Tab, 'id'>) => number;
removeTab: (tabId: number) => void;
setCurrentTab: (tabId: number) => void;
setSplitScreenTab: (tabId: number) => void;
getTab: (tabId: number) => Tab | undefined;
}
const TabContext = createContext<TabContextType | undefined>(undefined);
export function useTabs() {
const context = useContext(TabContext);
if (context === undefined) {
throw new Error('useTabs must be used within a TabProvider');
}
return context;
}
interface TabProviderProps {
children: ReactNode;
}
export function TabProvider({ children }: TabProviderProps) {
const [tabs, setTabs] = useState<Tab[]>([
{ id: 1, type: 'home', title: 'Home' }
]);
const [currentTab, setCurrentTab] = useState<number>(1);
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
const nextTabId = useRef(2);
function computeUniqueTerminalTitle(desiredTitle: string | undefined): string {
const baseTitle = (desiredTitle || 'Terminal').trim();
// Extract base name without trailing " (n)"
const match = baseTitle.match(/^(.*) \((\d+)\)$/);
const root = match ? match[1] : baseTitle;
const usedNumbers = new Set<number>();
let rootUsed = false;
tabs.forEach(t => {
if (t.type !== 'terminal' || !t.title) return;
if (t.title === root) {
rootUsed = true;
return;
}
const m = t.title.match(new RegExp(`^${root.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")} \\((\\d+)\\)$`));
if (m) {
const n = parseInt(m[1], 10);
if (!isNaN(n)) usedNumbers.add(n);
}
});
if (!rootUsed) return root;
// Start at (2) for the second instance
let n = 2;
while (usedNumbers.has(n)) n += 1;
return `${root} (${n})`;
}
const addTab = (tabData: Omit<Tab, 'id'>): number => {
const id = nextTabId.current++;
const effectiveTitle = tabData.type === 'terminal' ? computeUniqueTerminalTitle(tabData.title) : (tabData.title || '');
const newTab: Tab = {
...tabData,
id,
title: effectiveTitle,
terminalRef: tabData.type === 'terminal' ? React.createRef<any>() : undefined
};
console.log('Adding new tab:', newTab);
setTabs(prev => [...prev, newTab]);
setCurrentTab(id);
setAllSplitScreenTab(prev => prev.filter(tid => tid !== id));
return id;
};
const removeTab = (tabId: number) => {
const tab = tabs.find(t => t.id === tabId);
if (tab && tab.terminalRef?.current && typeof tab.terminalRef.current.disconnect === "function") {
tab.terminalRef.current.disconnect();
}
setTabs(prev => prev.filter(tab => tab.id !== tabId));
setAllSplitScreenTab(prev => prev.filter(id => id !== tabId));
if (currentTab === tabId) {
const remainingTabs = tabs.filter(tab => tab.id !== tabId);
setCurrentTab(remainingTabs.length > 0 ? remainingTabs[0].id : 1);
}
};
const setSplitScreenTab = (tabId: number) => {
setAllSplitScreenTab(prev => {
if (prev.includes(tabId)) {
return prev.filter(id => id !== tabId);
} else if (prev.length < 3) {
return [...prev, tabId];
}
return prev;
});
};
const getTab = (tabId: number) => {
return tabs.find(tab => tab.id === tabId);
};
const value: TabContextType = {
tabs,
currentTab,
allSplitScreenTab,
addTab,
removeTab,
setCurrentTab,
setSplitScreenTab,
getTab,
};
return (
<TabContext.Provider value={value}>
{children}
</TabContext.Provider>
);
}

View File

@@ -130,4 +130,26 @@
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
}
/* Thin scrollbar utility for scrollable containers */
.thin-scrollbar {
scrollbar-width: thin; /* Firefox */
scrollbar-color: #303032 transparent; /* Firefox */
}
.thin-scrollbar::-webkit-scrollbar {
height: 6px; /* horizontal */
width: 6px; /* vertical, if any */
}
.thin-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.thin-scrollbar::-webkit-scrollbar-thumb {
background-color: #303032;
border-radius: 9999px;
border: 2px solid transparent;
background-clip: content-box;
} }

View File

@@ -3,6 +3,7 @@ import {HomepageAuth} from "@/ui/Homepage/HomepageAuth.tsx";
import axios from "axios"; import axios from "axios";
import {HomepageUpdateLog} from "@/ui/Homepage/HompageUpdateLog.tsx"; import {HomepageUpdateLog} from "@/ui/Homepage/HompageUpdateLog.tsx";
import {HomepageAlertManager} from "@/ui/Homepage/HomepageAlertManager.tsx"; import {HomepageAlertManager} from "@/ui/Homepage/HomepageAlertManager.tsx";
import {Button} from "@/components/ui/button.tsx";
interface HomepageProps { interface HomepageProps {
onSelectView: (view: string) => void; onSelectView: (view: string) => void;
@@ -30,7 +31,13 @@ const API = axios.create({
baseURL: apiBase, baseURL: apiBase,
}); });
export function Homepage({onSelectView, isAuthenticated, authLoading, onAuthSuccess, isTopbarOpen = true}: HomepageProps): React.ReactElement { export function Homepage({
onSelectView,
isAuthenticated,
authLoading,
onAuthSuccess,
isTopbarOpen = true
}: HomepageProps): React.ReactElement {
const [loggedIn, setLoggedIn] = useState(isAuthenticated); const [loggedIn, setLoggedIn] = useState(isAuthenticated);
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
const [username, setUsername] = useState<string | null>(null); const [username, setUsername] = useState<string | null>(null);
@@ -71,9 +78,10 @@ export function Homepage({onSelectView, isAuthenticated, authLoading, onAuthSucc
}, [isAuthenticated]); }, [isAuthenticated]);
return ( return (
<div className={`w-full min-h-svh grid place-items-center relative transition-[padding-top] duration-200 ease-linear ${ <div
isTopbarOpen ? 'pt-[66px]' : 'pt-2' className={`w-full min-h-svh grid place-items-center relative transition-[padding-top] duration-200 ease-linear ${
}`}> isTopbarOpen ? 'pt-[66px]' : 'pt-2'
}`}>
<div className="flex flex-row items-center justify-center gap-8 relative z-[10000]"> <div className="flex flex-row items-center justify-center gap-8 relative z-[10000]">
<HomepageAuth <HomepageAuth
setLoggedIn={setLoggedIn} setLoggedIn={setLoggedIn}
@@ -86,9 +94,60 @@ export function Homepage({onSelectView, isAuthenticated, authLoading, onAuthSucc
setDbError={setDbError} setDbError={setDbError}
onAuthSuccess={onAuthSuccess} onAuthSuccess={onAuthSuccess}
/> />
<HomepageUpdateLog
loggedIn={loggedIn} <div className="flex flex-row items-center justify-center gap-8">
/> {loggedIn && (
<div className="flex flex-col items-center gap-4 w-[350px]">
<div
className="my-2 text-center bg-muted/50 border-2 border-[#303032] rounded-lg p-4 w-full">
<h3 className="text-lg font-semibold mb-2">Logged in!</h3>
<p className="text-muted-foreground">
You are logged in! Use the sidebar to access all available tools. To get started,
create an SSH Host in the SSH Manager tab. Once created, you can connect to that
host using the other apps in the sidebar.
</p>
</div>
<div className="flex flex-row items-center gap-2">
<Button
variant="link"
className="text-sm"
onClick={() => window.open('https://github.com/LukeGus/Termix', '_blank')}
>
GitHub
</Button>
<div className="w-px h-4 bg-border"></div>
<Button
variant="link"
className="text-sm"
onClick={() => window.open('https://github.com/LukeGus/Termix/issues/new', '_blank')}
>
Feedback
</Button>
<div className="w-px h-4 bg-border"></div>
<Button
variant="link"
className="text-sm"
onClick={() => window.open('https://discord.com/invite/jVQGdvHDrf', '_blank')}
>
Discord
</Button>
<div className="w-px h-4 bg-border"></div>
<Button
variant="link"
className="text-sm"
onClick={() => window.open('https://github.com/sponsors/LukeGus', '_blank')}
>
Fund
</Button>
</div>
</div>
)}
<HomepageUpdateLog
loggedIn={loggedIn}
/>
</div>
</div> </div>
<HomepageAlertManager <HomepageAlertManager

View File

@@ -344,14 +344,9 @@ export function HomepageAuth({
return ( return (
<div <div
className={cn( className={`w-[420px] max-w-full p-6 flex flex-col ${className || ''}`}
"",
className
)}
{...props} {...props}
> >
<div
className={`w-[420px] max-w-full bg-background/95 backdrop-blur-md rounded-xl shadow-2xl p-6 flex flex-col relative ${internalLoggedIn ? '' : 'ring-1 ring-border/50'} focus-within:ring-2 focus-within:ring-primary/50 transition-all duration-200`}>
{dbError && ( {dbError && (
<Alert variant="destructive" className="mb-4"> <Alert variant="destructive" className="mb-4">
<AlertTitle>Error</AlertTitle> <AlertTitle>Error</AlertTitle>
@@ -384,52 +379,6 @@ export function HomepageAuth({
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
{(internalLoggedIn || getCookie("jwt")) && (
<div className="flex flex-col items-center gap-4">
<div className="my-2 text-center bg-muted/50 border border-border rounded-lg p-4 w-full">
<h3 className="text-lg font-semibold mb-2">Logged in!</h3>
<p className="text-muted-foreground">
You are logged in! Use the sidebar to access all available tools. To get started,
create an SSH Host in the SSH Manager tab. Once created, you can connect to that
host using the other apps in the sidebar.
</p>
</div>
<div className="flex flex-row items-center gap-2">
<Button
variant="link"
className="text-sm"
onClick={() => window.open('https://github.com/LukeGus/Termix', '_blank')}
>
GitHub
</Button>
<div className="w-px h-4 bg-border"></div>
<Button
variant="link"
className="text-sm"
onClick={() => window.open('https://github.com/LukeGus/Termix/issues/new', '_blank')}
>
Feedback
</Button>
<div className="w-px h-4 bg-border"></div>
<Button
variant="link"
className="text-sm"
onClick={() => window.open('https://discord.com/invite/jVQGdvHDrf', '_blank')}
>
Discord
</Button>
<div className="w-px h-4 bg-border"></div>
<Button
variant="link"
className="text-sm"
onClick={() => window.open('https://github.com/sponsors/LukeGus', '_blank')}
>
Fund
</Button>
</div>
</div>
)}
{(!internalLoggedIn && (!authLoading || !getCookie("jwt"))) && ( {(!internalLoggedIn && (!authLoading || !getCookie("jwt"))) && (
<> <>
<div className="flex gap-2 mb-6"> <div className="flex gap-2 mb-6">
@@ -734,6 +683,5 @@ export function HomepageAuth({
</Alert> </Alert>
)} )}
</div> </div>
</div>
); );
} }

View File

@@ -95,7 +95,7 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
}; };
return ( return (
<div className="w-[400px] h-[600px] flex flex-col border border-border rounded-lg bg-card p-4"> <div className="w-[400px] h-[600px] flex flex-col border-2 border-border rounded-lg bg-card p-4">
<div> <div>
<h3 className="text-lg font-semibold mb-3">Updates & Releases</h3> <h3 className="text-lg font-semibold mb-3">Updates & Releases</h3>

View File

@@ -3,6 +3,7 @@ import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
import {Button} from "@/components/ui/button.tsx"; import {Button} from "@/components/ui/button.tsx";
import {ButtonGroup} from "@/components/ui/button-group.tsx"; import {ButtonGroup} from "@/components/ui/button-group.tsx";
import {Server, Terminal} from "lucide-react"; import {Server, Terminal} from "lucide-react";
import {useTabs} from "@/contexts/TabContext";
interface SSHHost { interface SSHHost {
id: number; id: number;
@@ -32,9 +33,22 @@ interface HostProps {
} }
export function Host({ host }: HostProps): React.ReactElement { export function Host({ host }: HostProps): React.ReactElement {
const { addTab } = useTabs();
const tags = Array.isArray(host.tags) ? host.tags : []; const tags = Array.isArray(host.tags) ? host.tags : [];
const hasTags = tags.length > 0; const hasTags = tags.length > 0;
const handleTerminalClick = () => {
console.log('Terminal button clicked for host:', host);
const title = host.name?.trim() ? host.name : `${host.username}@${host.ip}:${host.port}`;
console.log('Creating terminal tab with title:', title);
const tabId = addTab({
type: 'terminal',
title,
hostConfig: host,
});
console.log('Created terminal tab with ID:', tabId);
};
return ( return (
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -48,7 +62,11 @@ export function Host({ host }: HostProps): React.ReactElement {
<Button variant="outline" className="!px-2 border-1 border-[#303032]"> <Button variant="outline" className="!px-2 border-1 border-[#303032]">
<Server/> <Server/>
</Button> </Button>
<Button variant="outline" className="!px-2 border-1 border-[#303032]"> <Button
variant="outline"
className="!px-2 border-1 border-[#303032]"
onClick={handleTerminalClick}
>
<Terminal/> <Terminal/>
</Button> </Button>
</ButtonGroup> </ButtonGroup>

View File

@@ -49,6 +49,7 @@ import axios from "axios";
import {Card} from "@/components/ui/card.tsx"; import {Card} from "@/components/ui/card.tsx";
import {FolderCard} from "@/ui/Navigation/Hosts/FolderCard.tsx"; import {FolderCard} from "@/ui/Navigation/Hosts/FolderCard.tsx";
import {getSSHHosts} from "@/ui/SSH/ssh-axios"; import {getSSHHosts} from "@/ui/SSH/ssh-axios";
import { useTabs } from "@/contexts/TabContext";
interface SSHHost { interface SSHHost {
id: number; id: number;
@@ -145,6 +146,16 @@ export function LeftSidebar({
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true); const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
// Tabs context for opening SSH Manager tab
const { tabs: tabList, addTab, setCurrentTab, allSplitScreenTab } = useTabs() as any;
const isSplitScreenActive = Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
const sshManagerTab = tabList.find((t) => t.type === 'ssh_manager');
const openSshManagerTab = () => {
if (sshManagerTab || isSplitScreenActive) return;
const id = addTab({ type: 'ssh_manager', title: 'SSH Manager' } as any);
setCurrentTab(id);
};
// SSH Hosts state management // SSH Hosts state management
const [hosts, setHosts] = useState<SSHHost[]>([]); const [hosts, setHosts] = useState<SSHHost[]>([]);
const [hostsLoading, setHostsLoading] = useState(false); const [hostsLoading, setHostsLoading] = useState(false);
@@ -505,7 +516,7 @@ export function LeftSidebar({
<Separator className="p-0.25"/> <Separator className="p-0.25"/>
<SidebarContent> <SidebarContent>
<SidebarGroup className="!m-0 !p-0 !-mb-2"> <SidebarGroup className="!m-0 !p-0 !-mb-2">
<Button className="m-2 flex flex-row font-semibold" variant="outline"> <Button className="m-2 flex flex-row font-semibold" variant="outline" onClick={openSshManagerTab} disabled={!!sshManagerTab || isSplitScreenActive} title={sshManagerTab ? 'SSH Manager already open' : isSplitScreenActive ? 'Disabled during split screen' : undefined}>
<HardDrive strokeWidth="2.5"/> <HardDrive strokeWidth="2.5"/>
Host Manager Host Manager
</Button> </Button>
@@ -632,7 +643,7 @@ export function LeftSidebar({
<Users className="h-4 w-4"/> <Users className="h-4 w-4"/>
Users Users
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="admins" className="flex items-center gap-2"> <TabsTrigger value="admins" className="h-4 w-4">
<Shield className="h-4 w-4"/> <Shield className="h-4 w-4"/>
Admins Admins
</TabsTrigger> </TabsTrigger>
@@ -759,7 +770,7 @@ export function LeftSidebar({
<Input <Input
id="scopes" id="scopes"
value={oidcConfig.scopes} value={oidcConfig.scopes}
onChange={(e) => handleOIDCConfigChange('scopes', e.target.value)} onChange={(e) => handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)}
placeholder="openid email profile" placeholder="openid email profile"
required required
/> />

View File

@@ -1,22 +1,94 @@
import React from "react"; import React from "react";
import {ButtonGroup} from "@/components/ui/button-group.tsx"; import {ButtonGroup} from "@/components/ui/button-group.tsx";
import {Button} from "@/components/ui/button.tsx"; import {Button} from "@/components/ui/button.tsx";
import {SeparatorVertical, X} from "lucide-react"; import {Home, SeparatorVertical, X} from "lucide-react";
export function Tab(): React.ReactElement { interface TabProps {
return ( tabType: string;
<div> title?: string;
isActive?: boolean;
onActivate?: () => void;
onClose?: () => void;
onSplit?: () => void;
canSplit?: boolean;
canClose?: boolean;
disableActivate?: boolean;
disableSplit?: boolean;
disableClose?: boolean;
}
export function Tab({tabType, title, isActive, onActivate, onClose, onSplit, canSplit = false, canClose = false, disableActivate = false, disableSplit = false, disableClose = false}: TabProps): React.ReactElement {
if (tabType === "home") {
return (
<Button
variant="outline"
className={`!px-2 border-1 border-[#303032] ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30]' : ''}`}
onClick={onActivate}
disabled={disableActivate}
>
<Home/>
</Button>
);
}
if (tabType === "terminal") {
return (
<ButtonGroup> <ButtonGroup>
<Button variant="outline" className="!px-2 border-1 border-[#303032]"> <Button
Server Name variant="outline"
className={`!px-2 border-1 border-[#303032] ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30]' : ''}`}
onClick={onActivate}
disabled={disableActivate}
>
{title || "Terminal"}
</Button> </Button>
<Button variant="outline" className="!px-2 border-1 border-[#303032]"> {canSplit && (
<SeparatorVertical className="w-[28px] h-[28px]" /> <Button
variant="outline"
className="!px-2 border-1 border-[#303032]"
onClick={onSplit}
disabled={disableSplit}
title={disableSplit ? 'Cannot split this tab' : 'Split'}
>
<SeparatorVertical className="w-[28px] h-[28px]" />
</Button>
)}
{canClose && (
<Button
variant="outline"
className="!px-2 border-1 border-[#303032]"
onClick={onClose}
disabled={disableClose}
>
<X/>
</Button>
)}
</ButtonGroup>
);
}
if (tabType === "ssh_manager") {
return (
<ButtonGroup>
<Button
variant="outline"
className={`!px-2 border-1 border-[#303032] ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30]' : ''}`}
onClick={onActivate}
disabled={disableActivate}
>
{title || "SSH Manager"}
</Button> </Button>
<Button variant="outline" className="!px-2 border-1 border-[#303032]"> <Button
variant="outline"
className="!px-2 border-1 border-[#303032]"
onClick={onClose}
disabled={disableClose}
>
<X/> <X/>
</Button> </Button>
</ButtonGroup> </ButtonGroup>
</div> );
) }
return null;
} }

View File

@@ -3,6 +3,7 @@ import {useSidebar} from "@/components/ui/sidebar";
import {Button} from "@/components/ui/button.tsx"; import {Button} from "@/components/ui/button.tsx";
import {ChevronDown, ChevronUpIcon} from "lucide-react"; import {ChevronDown, ChevronUpIcon} from "lucide-react";
import {Tab} from "@/ui/Navigation/Tabs/Tab.tsx"; import {Tab} from "@/ui/Navigation/Tabs/Tab.tsx";
import {useTabs} from "@/contexts/TabContext";
interface TopNavbarProps { interface TopNavbarProps {
isTopbarOpen: boolean; isTopbarOpen: boolean;
@@ -11,8 +12,26 @@ interface TopNavbarProps {
export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): React.ReactElement { export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): React.ReactElement {
const {state} = useSidebar(); const {state} = useSidebar();
const {tabs, currentTab, setCurrentTab, setSplitScreenTab, removeTab, allSplitScreenTab} = useTabs() as any;
const leftPosition = state === "collapsed" ? "26px" : "264px"; const leftPosition = state === "collapsed" ? "26px" : "264px";
const handleTabActivate = (tabId: number) => {
setCurrentTab(tabId);
};
const handleTabSplit = (tabId: number) => {
setSplitScreenTab(tabId);
};
const handleTabClose = (tabId: number) => {
removeTab(tabId);
};
const isSplitScreenActive = Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
const currentTabObj = tabs.find((t: any) => t.id === currentTab);
const currentTabIsHome = currentTabObj?.type === 'home';
const currentTabIsSshManager = currentTabObj?.type === 'ssh_manager';
return ( return (
<div> <div>
<div <div
@@ -27,15 +46,42 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
padding: "0" padding: "0"
}} }}
> >
<div className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-3rem)] flex items-center overflow-x-scroll gap-2"> <div className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-3rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
<Tab/> {tabs.map((tab: any) => {
const isActive = tab.id === currentTab;
const isSplit = Array.isArray(allSplitScreenTab) && allSplitScreenTab.includes(tab.id);
const isTerminal = tab.type === 'terminal';
const isSshManager = tab.type === 'ssh_manager';
// Old logic port:
const isSplitButtonDisabled = (isActive && !isSplitScreenActive) || ((allSplitScreenTab?.length || 0) >= 3 && !isSplit);
// Disable split entirely when on Home or SSH Manager
const disableSplit = isSplitButtonDisabled || isActive || currentTabIsHome || currentTabIsSshManager || isSshManager;
const disableActivate = isSplit || ((tab.type === 'home' || tab.type === 'ssh_manager') && isSplitScreenActive);
const disableClose = (isSplitScreenActive && isActive) || isSplit;
return (
<Tab
key={tab.id}
tabType={tab.type}
title={tab.title}
isActive={isActive}
onActivate={() => handleTabActivate(tab.id)}
onClose={isTerminal || isSshManager ? () => handleTabClose(tab.id) : undefined}
onSplit={isTerminal ? () => handleTabSplit(tab.id) : undefined}
canSplit={isTerminal}
canClose={isTerminal || isSshManager}
disableActivate={disableActivate}
disableSplit={disableSplit}
disableClose={disableClose}
/>
);
})}
</div> </div>
<div className="flex items-center justify-center flex-1"> <div className="flex items-center justify-center flex-1">
<Button <Button
variant="outline" variant="outline"
onClick={() => setIsTopbarOpen(false)} onClick={() => setIsTopbarOpen(false)}
className="w-[28px] h-[28px]" className="w-[28px] h-[28]"
> >
<ChevronUpIcon/> <ChevronUpIcon/>
</Button> </Button>

View File

@@ -6,85 +6,97 @@ import {SSHManagerHostEditor} from "@/ui/SSH/Manager/SSHManagerHostEditor.tsx";
import {useSidebar} from "@/components/ui/sidebar.tsx"; import {useSidebar} from "@/components/ui/sidebar.tsx";
interface ConfigEditorProps { interface ConfigEditorProps {
onSelectView: (view: string) => void; onSelectView: (view: string) => void;
isTopbarOpen?: boolean;
} }
interface SSHHost { interface SSHHost {
id: number; id: number;
name: string; name: string;
ip: string; ip: string;
port: number; port: number;
username: string; username: string;
folder: string; folder: string;
tags: string[]; tags: string[];
pin: boolean; pin: boolean;
authType: string; authType: string;
password?: string; password?: string;
key?: string; key?: string;
keyPassword?: string; keyPassword?: string;
keyType?: string; keyType?: string;
enableTerminal: boolean; enableTerminal: boolean;
enableTunnel: boolean; enableTunnel: boolean;
enableConfigEditor: boolean; enableConfigEditor: boolean;
defaultPath: string; defaultPath: string;
tunnelConnections: any[]; tunnelConnections: any[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
export function SSHManager({onSelectView}: ConfigEditorProps): React.ReactElement { export function SSHManager({onSelectView, isTopbarOpen}: ConfigEditorProps): React.ReactElement {
const [activeTab, setActiveTab] = useState("host_viewer"); const [activeTab, setActiveTab] = useState("host_viewer");
const [editingHost, setEditingHost] = useState<SSHHost | null>(null); const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
const {state: sidebarState} = useSidebar(); const {state: sidebarState} = useSidebar();
const handleEditHost = (host: SSHHost) => { const handleEditHost = (host: SSHHost) => {
setEditingHost(host); setEditingHost(host);
setActiveTab("add_host"); setActiveTab("add_host");
}; };
const handleFormSubmit = () => { const handleFormSubmit = () => {
setEditingHost(null); setEditingHost(null);
setActiveTab("host_viewer"); setActiveTab("host_viewer");
}; };
const handleTabChange = (value: string) => { const handleTabChange = (value: string) => {
setActiveTab(value); setActiveTab(value);
if (value === "host_viewer") { if (value === "host_viewer") {
setEditingHost(null); setEditingHost(null);
} }
}; };
return ( // Dynamic margins similar to TerminalView but with 16px gaps when retracted
<div> const topMarginPx = isTopbarOpen ? 74 : 26;
<div className="flex w-full h-screen overflow-hidden"> const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
<div const bottomMarginPx = 8;
className={`flex-1 bg-[#18181b] m-[8px] text-white p-4 pt-0 rounded-lg border mt-18.5 border-[#303032] flex flex-col min-h-0 ${
sidebarState === 'collapsed' ? 'ml-6' : '' return (
}`}> <div>
<Tabs value={activeTab} onValueChange={handleTabChange} <div className="w-full">
className="flex-1 flex flex-col h-full min-h-0"> <div
<TabsList className="mt-1.5"> className="bg-[#18181b] text-white p-4 pt-0 rounded-lg border-2 border-[#303032] flex flex-col min-h-0 overflow-hidden"
<TabsTrigger value="host_viewer">Host Viewer</TabsTrigger> style={{
<TabsTrigger value="add_host"> marginLeft: leftMarginPx,
{editingHost ? "Edit Host" : "Add Host"} marginRight: 17,
</TabsTrigger> marginTop: topMarginPx,
</TabsList> marginBottom: bottomMarginPx,
<TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0"> height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`
<Separator className="p-0.25 -mt-0.5 mb-1"/> }}
<SSHManagerHostViewer onEditHost={handleEditHost}/> >
</TabsContent> <Tabs value={activeTab} onValueChange={handleTabChange}
<TabsContent value="add_host" className="flex-1 flex flex-col h-full min-h-0"> className="flex-1 flex flex-col h-full min-h-0">
<Separator className="p-0.25 -mt-0.5 mb-1"/> <TabsList className="mt-1.5">
<div className="flex flex-col h-full min-h-0"> <TabsTrigger value="host_viewer">Host Viewer</TabsTrigger>
<SSHManagerHostEditor <TabsTrigger value="add_host">
editingHost={editingHost} {editingHost ? "Edit Host" : "Add Host"}
onFormSubmit={handleFormSubmit} </TabsTrigger>
/> </TabsList>
</div> <TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0">
</TabsContent> <Separator className="p-0.25 -mt-0.5 mb-1"/>
</Tabs> <SSHManagerHostViewer onEditHost={handleEditHost}/>
</div> </TabsContent>
</div> <TabsContent value="add_host" className="flex-1 flex flex-col h-full min-h-0">
</div> <Separator className="p-0.25 -mt-0.5 mb-1"/>
) <div className="flex flex-col h-full min-h-0">
<SSHManagerHostEditor
editingHost={editingHost}
onFormSubmit={handleFormSubmit}
/>
</div>
</TabsContent>
</Tabs>
</div>
</div>
</div>
)
} }

View File

@@ -1,784 +0,0 @@
import React, {useState, useRef, useEffect} from "react";
import {TerminalSidebar} from "@/ui/SSH/Terminal/TerminalSidebar.tsx";
import {TerminalComponent} from "./TerminalComponent.tsx";
import {TerminalTopbar} from "@/ui/SSH/Terminal/TerminalTopbar.tsx";
import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx';
import * as ResizablePrimitive from "react-resizable-panels";
import {ChevronDown, ChevronRight} from "lucide-react";
interface ConfigEditorProps {
onSelectView: (view: string) => void;
}
type Tab = {
id: number;
title: string;
hostConfig: any;
terminalRef: React.RefObject<any>;
};
export function Terminal({onSelectView}: ConfigEditorProps): React.ReactElement {
const [allTabs, setAllTabs] = useState<Tab[]>([]);
const [currentTab, setCurrentTab] = useState<number | null>(null);
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
const nextTabId = useRef(1);
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true);
const SIDEBAR_WIDTH = 256;
const HANDLE_THICKNESS = 10;
const [panelRects, setPanelRects] = useState<Record<string, DOMRect | null>>({});
const panelRefs = useRef<Record<string, HTMLDivElement | null>>({});
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<string, DOMRect | null> = {...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<number, React.CSSProperties> = {};
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 (
<div ref={el => {
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 (
<div key={tab.id} style={style} data-terminal-id={tab.id}>
<TerminalComponent
key={tab.id}
ref={tab.terminalRef}
hostConfig={tab.hostConfig}
isVisible={isVisible}
title={tab.title}
showTitle={false}
splitScreen={allSplitScreenTab.length > 0}
/>
</div>
);
})}
</div>
);
};
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 (
<div style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 10,
pointerEvents: 'none'
}}>
<ResizablePrimitive.PanelGroup
ref={el => {
panelGroupRefs.current['main'] = el;
}}
direction="horizontal"
className="h-full w-full"
id="main-horizontal"
>
<ResizablePanel key={tab1.id} defaultSize={50} minSize={20}
className="!overflow-hidden h-full w-full" id={`panel-${tab1.id}`} order={1}>
<div ref={el => {
panelRefs.current[String(tab1.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{
background: '#18181b',
color: '#fff',
fontSize: 13,
height: 28,
lineHeight: '28px',
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
}}>{tab1.title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
<ResizablePanel key={tab2.id} defaultSize={50} minSize={20}
className="!overflow-hidden h-full w-full" id={`panel-${tab2.id}`} order={2}>
<div ref={el => {
panelRefs.current[String(tab2.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{
background: '#18181b',
color: '#fff',
fontSize: 13,
height: 28,
lineHeight: '28px',
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
}}>{tab2.title}</div>
</div>
</ResizablePanel>
</ResizablePrimitive.PanelGroup>
</div>
);
}
if (layoutTabs.length === 3) {
return (
<div style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 10,
pointerEvents: 'none'
}}>
<ResizablePrimitive.PanelGroup
ref={el => {
panelGroupRefs.current['main'] = el;
}}
direction="vertical"
className="h-full w-full"
id="main-vertical"
>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id="top-panel" order={1}>
<ResizablePanelGroup ref={el => {
panelGroupRefs.current['top'] = el;
}} direction="horizontal" className="h-full w-full" id="top-horizontal">
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${layoutTabs[0].id}`} order={1}>
<div ref={el => {
panelRefs.current[String(layoutTabs[0].id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{
background: '#18181b',
color: '#fff',
fontSize: 13,
height: 28,
lineHeight: '28px',
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
}}>{layoutTabs[0].title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
<ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${layoutTabs[1].id}`} order={2}>
<div ref={el => {
panelRefs.current[String(layoutTabs[1].id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{
background: '#18181b',
color: '#fff',
fontSize: 13,
height: 28,
lineHeight: '28px',
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
}}>{layoutTabs[1].title}</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id="bottom-panel" order={2}>
<div ref={el => {
panelRefs.current[String(layoutTabs[2].id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{
background: '#18181b',
color: '#fff',
fontSize: 13,
height: 28,
lineHeight: '28px',
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
}}>{layoutTabs[2].title}</div>
</div>
</ResizablePanel>
</ResizablePrimitive.PanelGroup>
</div>
);
}
if (layoutTabs.length === 4) {
return (
<div style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 10,
pointerEvents: 'none'
}}>
<ResizablePrimitive.PanelGroup
ref={el => {
panelGroupRefs.current['main'] = el;
}}
direction="vertical"
className="h-full w-full"
id="main-vertical"
>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id="top-panel" order={1}>
<ResizablePanelGroup ref={el => {
panelGroupRefs.current['top'] = el;
}} direction="horizontal" className="h-full w-full" id="top-horizontal">
<ResizablePanel key={layoutTabs[0].id} defaultSize={50} minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${layoutTabs[0].id}`} order={1}>
<div ref={el => {
panelRefs.current[String(layoutTabs[0].id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{
background: '#18181b',
color: '#fff',
fontSize: 13,
height: 28,
lineHeight: '28px',
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
}}>{layoutTabs[0].title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
<ResizablePanel key={layoutTabs[1].id} defaultSize={50} minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${layoutTabs[1].id}`} order={2}>
<div ref={el => {
panelRefs.current[String(layoutTabs[1].id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{
background: '#18181b',
color: '#fff',
fontSize: 13,
height: 28,
lineHeight: '28px',
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
}}>{layoutTabs[1].title}</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id="bottom-panel" order={2}>
<ResizablePanelGroup ref={el => {
panelGroupRefs.current['bottom'] = el;
}} direction="horizontal" className="h-full w-full" id="bottom-horizontal">
<ResizablePanel key={layoutTabs[2].id} defaultSize={50} minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${layoutTabs[2].id}`} order={1}>
<div ref={el => {
panelRefs.current[String(layoutTabs[2].id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{
background: '#18181b',
color: '#fff',
fontSize: 13,
height: 28,
lineHeight: '28px',
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
}}>{layoutTabs[2].title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={{pointerEvents: 'auto', zIndex: 12}}/>
<ResizablePanel key={layoutTabs[3].id} defaultSize={50} minSize={20}
className="!overflow-hidden h-full w-full"
id={`panel-${layoutTabs[3].id}`} order={2}>
<div ref={el => {
panelRefs.current[String(layoutTabs[3].id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
margin: 0,
padding: 0,
position: 'relative'
}}>
<div style={{
background: '#18181b',
color: '#fff',
fontSize: 13,
height: 28,
lineHeight: '28px',
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
}}>{layoutTabs[3].title}</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePrimitive.PanelGroup>
</div>
);
}
return null;
};
const onAddHostSubmit = (data: any) => {
const id = nextTabId.current++;
const title = `${data.ip || "Host"}:${data.port || 22}`;
const terminalRef = React.createRef<any>();
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<any>();
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 (
<div style={{display: 'flex', width: '100vw', height: '100vh', overflow: 'hidden', position: 'relative'}}>
<div
style={{
width: isSidebarOpen ? SIDEBAR_WIDTH : 0,
flexShrink: 0,
height: '100vh',
position: 'relative',
zIndex: 2,
margin: 0,
padding: 0,
border: 'none',
overflow: 'hidden',
transition: 'width 240ms ease-in-out',
willChange: 'width',
}}
>
<TerminalSidebar
onSelectView={onSelectView}
onHostConnect={onHostConnect}
allTabs={allTabs}
runCommandOnTabs={(tabIds: number[], command: string) => {
allTabs.forEach(tab => {
if (tabIds.includes(tab.id) && tab.terminalRef?.current?.sendInput) {
tab.terminalRef.current.sendInput(command);
}
});
}}
onCloseSidebar={() => setIsSidebarOpen(false)}
open={isSidebarOpen}
onOpenChange={setIsSidebarOpen}
/>
</div>
<div
className="terminal-container"
style={{
flex: 1,
height: '100vh',
position: 'relative',
overflow: 'hidden',
margin: 0,
padding: 0,
paddingLeft: isSidebarOpen ? 0 : HANDLE_THICKNESS,
paddingTop: isTopbarOpen ? 0 : HANDLE_THICKNESS,
border: 'none',
transition: 'padding-left 240ms ease-in-out, padding-top 240ms ease-in-out',
willChange: 'padding',
}}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: isTopbarOpen ? 46 : 0,
overflow: 'hidden',
zIndex: 10,
transition: 'height 240ms ease-in-out',
willChange: 'height',
}}
>
<TerminalTopbar
allTabs={allTabs}
currentTab={currentTab ?? -1}
setActiveTab={setActiveTab}
allSplitScreenTab={allSplitScreenTab}
setSplitScreenTab={setSplitScreenTab}
setCloseTab={setCloseTab}
onHideTopbar={() => setIsTopbarOpen(false)}
/>
</div>
{!isTopbarOpen && (
<div
onClick={() => setIsTopbarOpen(true)}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: HANDLE_THICKNESS,
background: '#222224',
cursor: 'pointer',
zIndex: 12,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
title="Show top bar">
<ChevronDown size={HANDLE_THICKNESS} />
</div>
)}
<div
style={{
height: isTopbarOpen ? 'calc(100% - 46px)' : '100%',
marginTop: isTopbarOpen ? 46 : 0,
position: 'relative',
transition: 'margin-top 240ms ease-in-out, height 240ms ease-in-out',
}}
>
{allTabs.length === 0 && (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
background: '#18181b',
border: '1px solid #434345',
borderRadius: '8px',
padding: '24px',
textAlign: 'center',
color: '#f7f7f7',
maxWidth: '400px',
zIndex: 30
}}>
<div style={{fontSize: '18px', fontWeight: 'bold', marginBottom: '12px'}}>
Welcome to Termix SSH
</div>
<div style={{fontSize: '14px', color: '#a1a1aa', lineHeight: '1.5'}}>
Click on any host title in the sidebar to open a terminal connection, or use the "Add
Host" button to create a new connection.
</div>
</div>
)}
{allSplitScreenTab.length > 0 && (
<div style={{position: 'absolute', top: 0, right: 0, zIndex: 20, height: 28}}>
<button
style={{
background: '#18181b',
color: '#fff',
borderLeft: '1px solid #222224',
borderRight: '1px solid #222224',
borderTop: 'none',
borderBottom: '1px solid #222224',
borderRadius: 0,
padding: '2px 10px',
cursor: 'pointer',
fontSize: 13,
margin: 0,
height: 28,
display: 'flex',
alignItems: 'center',
}}
onClick={() => {
if (allSplitScreenTab.length === 1) {
panelGroupRefs.current['main']?.setLayout([50, 50]);
} else if (allSplitScreenTab.length === 2) {
panelGroupRefs.current['main']?.setLayout([50, 50]);
panelGroupRefs.current['top']?.setLayout([50, 50]);
} else if (allSplitScreenTab.length === 3) {
panelGroupRefs.current['main']?.setLayout([50, 50]);
panelGroupRefs.current['top']?.setLayout([50, 50]);
panelGroupRefs.current['bottom']?.setLayout([50, 50]);
}
}}
>
Reset Split Sizes
</button>
</div>
)}
{renderAllTerminals()}
{renderSplitOverlays()}
</div>
</div>
{!isSidebarOpen && (
<div
onClick={() => setIsSidebarOpen(true)}
style={{
position: 'absolute',
top: 0,
left: 0,
width: HANDLE_THICKNESS,
height: '100%',
background: '#222224',
cursor: 'pointer',
zIndex: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
title="Show sidebar">
<ChevronRight size={HANDLE_THICKNESS} />
</div>
)}
</div>
);
}

View File

@@ -17,6 +17,7 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
{hostConfig, isVisible, splitScreen = false}, {hostConfig, isVisible, splitScreen = false},
ref ref
) { ) {
console.log('TerminalComponent rendered with:', { hostConfig, isVisible, splitScreen });
const {instance: terminal, ref: xtermRef} = useXTerm(); const {instance: terminal, ref: xtermRef} = useXTerm();
const fitAddonRef = useRef<FitAddon | null>(null); const fitAddonRef = useRef<FitAddon | null>(null);
const webSocketRef = useRef<WebSocket | null>(null); const webSocketRef = useRef<WebSocket | null>(null);
@@ -24,6 +25,39 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
const wasDisconnectedBySSH = useRef(false); const wasDisconnectedBySSH = useRef(false);
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null); const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const isVisibleRef = useRef<boolean>(false);
// Debounce/stabilize resize notifications
const lastSentSizeRef = useRef<{cols:number; rows:number} | null>(null);
const pendingSizeRef = useRef<{cols:number; rows:number} | null>(null);
const notifyTimerRef = useRef<NodeJS.Timeout | null>(null);
const DEBOUNCE_MS = 140;
useEffect(() => { isVisibleRef.current = isVisible; }, [isVisible]);
function hardRefresh() {
try {
if (terminal && typeof (terminal as any).refresh === 'function') {
(terminal as any).refresh(0, terminal.rows - 1);
}
} catch (_) {}
}
function scheduleNotify(cols: number, rows: number) {
if (!(cols > 0 && rows > 0)) return;
pendingSizeRef.current = {cols, rows};
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
notifyTimerRef.current = setTimeout(() => {
const next = pendingSizeRef.current;
const last = lastSentSizeRef.current;
if (!next) return;
if (last && last.cols === next.cols && last.rows === next.rows) return;
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
webSocketRef.current.send(JSON.stringify({type: 'resize', data: next}));
lastSentSizeRef.current = next;
}
}, DEBOUNCE_MS);
}
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
disconnect: () => { disconnect: () => {
@@ -35,13 +69,26 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
}, },
fit: () => { fit: () => {
fitAddonRef.current?.fit(); fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
}, },
sendInput: (data: string) => { sendInput: (data: string) => {
if (webSocketRef.current?.readyState === 1) { if (webSocketRef.current?.readyState === 1) {
webSocketRef.current.send(JSON.stringify({type: 'input', data})); webSocketRef.current.send(JSON.stringify({type: 'input', data}));
} }
} },
}), []); notifyResize: () => {
try {
const cols = terminal?.cols ?? undefined;
const rows = terminal?.rows ?? undefined;
if (typeof cols === 'number' && typeof rows === 'number') {
scheduleNotify(cols, rows);
hardRefresh();
}
} catch (_) {}
},
refresh: () => hardRefresh(),
}), [terminal]);
useEffect(() => { useEffect(() => {
window.addEventListener('resize', handleWindowResize); window.addEventListener('resize', handleWindowResize);
@@ -49,7 +96,10 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
}, []); }, []);
function handleWindowResize() { function handleWindowResize() {
if (!isVisibleRef.current) return;
fitAddonRef.current?.fit(); fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
} }
function getCookie(name: string) { function getCookie(name: string) {
@@ -69,8 +119,7 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
return; return;
} }
} catch (_) { } catch (_) {}
}
const textarea = document.createElement('textarea'); const textarea = document.createElement('textarea');
textarea.value = text; textarea.value = text;
textarea.style.position = 'fixed'; textarea.style.position = 'fixed';
@@ -78,11 +127,7 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
document.body.appendChild(textarea); document.body.appendChild(textarea);
textarea.focus(); textarea.focus();
textarea.select(); textarea.select();
try { try { document.execCommand('copy'); } finally { document.body.removeChild(textarea); }
document.execCommand('copy');
} finally {
document.body.removeChild(textarea);
}
} }
async function readTextFromClipboard(): Promise<string> { async function readTextFromClipboard(): Promise<string> {
@@ -90,8 +135,7 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
if (navigator.clipboard && navigator.clipboard.readText) { if (navigator.clipboard && navigator.clipboard.readText) {
return await navigator.clipboard.readText(); return await navigator.clipboard.readText();
} }
} catch (_) { } catch (_) {}
}
return ''; return '';
} }
@@ -104,10 +148,7 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
scrollback: 10000, scrollback: 10000,
fontSize: 14, fontSize: 14,
fontFamily: '"JetBrains Mono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Cascadia Code", "JetBrains Mono", Consolas, "Courier New", monospace', fontFamily: '"JetBrains Mono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Cascadia Code", "JetBrains Mono", Consolas, "Courier New", monospace',
theme: { theme: { background: '#18181b', foreground: '#f7f7f7' },
background: '#09090b',
foreground: '#f7f7f7',
},
allowTransparency: true, allowTransparency: true,
convertEol: true, convertEol: true,
windowsMode: false, windowsMode: false,
@@ -134,130 +175,103 @@ export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHT
const element = xtermRef.current; const element = xtermRef.current;
const handleContextMenu = async (e: MouseEvent) => { const handleContextMenu = async (e: MouseEvent) => {
if (!getUseRightClickCopyPaste()) return; if (!getUseRightClickCopyPaste()) return;
e.preventDefault(); e.preventDefault(); e.stopPropagation();
e.stopPropagation();
try { try {
if (terminal.hasSelection()) { if (terminal.hasSelection()) {
const selection = terminal.getSelection(); const selection = terminal.getSelection();
if (selection) { if (selection) { await writeTextToClipboard(selection); terminal.clearSelection(); }
await writeTextToClipboard(selection);
terminal.clearSelection();
}
} else { } else {
const pasteText = await readTextFromClipboard(); const pasteText = await readTextFromClipboard();
if (pasteText) { if (pasteText) terminal.paste(pasteText);
terminal.paste(pasteText);
}
} }
} catch (_) { } catch (_) {}
}
}; };
if (element) { element?.addEventListener('contextmenu', handleContextMenu);
element.addEventListener('contextmenu', handleContextMenu);
}
const resizeObserver = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
if (resizeTimeout.current) clearTimeout(resizeTimeout.current); if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
resizeTimeout.current = setTimeout(() => { resizeTimeout.current = setTimeout(() => {
if (!isVisibleRef.current) return;
fitAddonRef.current?.fit(); fitAddonRef.current?.fit();
const cols = terminal.cols; if (terminal) scheduleNotify(terminal.cols, terminal.rows);
const rows = terminal.rows; hardRefresh();
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
webSocketRef.current.send(JSON.stringify({type: 'resize', data: {cols, rows}}));
}
}, 100); }, 100);
}); });
resizeObserver.observe(xtermRef.current); resizeObserver.observe(xtermRef.current);
setTimeout(() => {
fitAddon.fit();
setVisible(true);
const cols = terminal.cols; const readyFonts = (document as any).fonts?.ready instanceof Promise ? (document as any).fonts.ready : Promise.resolve();
const rows = terminal.rows; readyFonts.then(() => {
const wsUrl = window.location.hostname === 'localhost' setTimeout(() => {
? 'ws://localhost:8082' fitAddon.fit();
: `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`; setTimeout(() => {
fitAddon.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
setVisible(true);
}, 0);
const ws = new WebSocket(wsUrl); const cols = terminal.cols;
webSocketRef.current = ws; const rows = terminal.rows;
wasDisconnectedBySSH.current = false; const wsUrl = window.location.hostname === 'localhost' ? 'ws://localhost:8082' : `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
ws.addEventListener('open', () => { const ws = new WebSocket(wsUrl);
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}})); webSocketRef.current = ws;
terminal.onData((data) => { wasDisconnectedBySSH.current = false;
ws.send(JSON.stringify({type: 'input', data}));
ws.addEventListener('open', () => {
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
terminal.onData((data) => { ws.send(JSON.stringify({type: 'input', data})); });
pingIntervalRef.current = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({type: 'ping'})); } }, 30000);
}); });
pingIntervalRef.current = setInterval(() => { ws.addEventListener('message', (event) => {
if (ws.readyState === WebSocket.OPEN) { try {
ws.send(JSON.stringify({type: 'ping'})); const msg = JSON.parse(event.data);
} if (msg.type === 'data') terminal.write(msg.data);
}, 30000); else if (msg.type === 'error') terminal.writeln(`\r\n[ERROR] ${msg.message}`);
}); else if (msg.type === 'connected') { }
else if (msg.type === 'disconnected') { wasDisconnectedBySSH.current = true; terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`); }
} catch (error) { console.error('Error parsing WebSocket message:', error); }
});
ws.addEventListener('message', (event) => { ws.addEventListener('close', () => { if (!wasDisconnectedBySSH.current) terminal.writeln('\r\n[Connection closed]'); });
try { ws.addEventListener('error', () => { terminal.writeln('\r\n[Connection error]'); });
const msg = JSON.parse(event.data); }, 300);
if (msg.type === 'data') { });
terminal.write(msg.data);
} else if (msg.type === 'error') {
terminal.writeln(`\r\n[ERROR] ${msg.message}`);
} else if (msg.type === 'connected') {
} else if (msg.type === 'disconnected') {
wasDisconnectedBySSH.current = true;
terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`);
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
});
ws.addEventListener('close', () => {
if (!wasDisconnectedBySSH.current) {
terminal.writeln('\r\n[Connection closed]');
}
});
ws.addEventListener('error', () => {
terminal.writeln('\r\n[Connection error]');
});
}, 300);
return () => { return () => {
resizeObserver.disconnect(); resizeObserver.disconnect();
if (element) { element?.removeEventListener('contextmenu', handleContextMenu);
element.removeEventListener('contextmenu', handleContextMenu); if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
}
if (resizeTimeout.current) clearTimeout(resizeTimeout.current); if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
if (pingIntervalRef.current) { if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current); pingIntervalRef.current = null; }
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
webSocketRef.current?.close(); webSocketRef.current?.close();
}; };
}, [xtermRef, terminal, hostConfig]); }, [xtermRef, terminal, hostConfig]);
useEffect(() => { useEffect(() => {
if (isVisible && fitAddonRef.current) { if (isVisible && fitAddonRef.current) {
fitAddonRef.current.fit(); setTimeout(() => {
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
}, 0);
} }
}, [isVisible]); }, [isVisible]);
// Ensure a fit when split mode toggles to account for new pane geometry
useEffect(() => {
if (!fitAddonRef.current) return;
setTimeout(() => {
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
}, 0);
}, [splitScreen]);
return ( return (
<div <div ref={xtermRef} className="h-full w-full m-1" style={{ opacity: visible && isVisible ? 1 : 0, overflow: 'hidden' }} />
ref={xtermRef}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
marginLeft: 2,
opacity: visible && isVisible ? 1 : 0,
overflow: 'hidden',
}}
/>
); );
}); });

View File

@@ -1,440 +0,0 @@
import React, {useState} from 'react';
import {
CornerDownLeft,
Hammer, Pin, Menu
} from "lucide-react"
import {
Button
} from "@/components/ui/button.tsx"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem, SidebarProvider,
} from "@/components/ui/sidebar.tsx"
import {
Separator,
} from "@/components/ui/separator.tsx"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger
} from "@/components/ui/sheet.tsx";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion.tsx";
import {ScrollArea} from "@/components/ui/scroll-area.tsx";
import {Input} from "@/components/ui/input.tsx";
import {getSSHHosts} from "@/ui/SSH/ssh-axios";
import {Checkbox} from "@/components/ui/checkbox.tsx";
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableConfigEditor: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
export interface SidebarProps {
onSelectView: (view: string) => void;
onHostConnect: (hostConfig: any) => void;
allTabs: { id: number; title: string; terminalRef: React.RefObject<any> }[];
runCommandOnTabs: (tabIds: number[], command: string) => void;
onCloseSidebar?: () => void;
onAddHostSubmit?: (data: any) => void;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function TerminalSidebar({
onSelectView,
onHostConnect,
allTabs,
runCommandOnTabs,
onCloseSidebar,
open,
onOpenChange
}: SidebarProps): React.ReactElement {
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [hostsLoading, setHostsLoading] = useState(false);
const [hostsError, setHostsError] = useState<string | null>(null);
const prevHostsRef = React.useRef<SSHHost[]>([]);
const fetchHosts = React.useCallback(async () => {
setHostsLoading(true);
setHostsError(null);
try {
const newHosts = await getSSHHosts();
const terminalHosts = newHosts.filter(host => host.enableTerminal);
const prevHosts = prevHostsRef.current;
const isSame =
terminalHosts.length === prevHosts.length &&
terminalHosts.every((h: SSHHost, i: number) => {
const prev = prevHosts[i];
if (!prev) return false;
return (
h.id === prev.id &&
h.name === prev.name &&
h.folder === prev.folder &&
h.ip === prev.ip &&
h.port === prev.port &&
h.username === prev.username &&
h.password === prev.password &&
h.authType === prev.authType &&
h.key === prev.key &&
h.pin === prev.pin &&
JSON.stringify(h.tags) === JSON.stringify(prev.tags)
);
});
if (!isSame) {
setHosts(terminalHosts);
prevHostsRef.current = terminalHosts;
}
} catch (err: any) {
setHostsError('Failed to load hosts');
} finally {
setHostsLoading(false);
}
}, []);
React.useEffect(() => {
fetchHosts();
const interval = setInterval(fetchHosts, 10000);
return () => clearInterval(interval);
}, [fetchHosts]);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
React.useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(search), 200);
return () => clearTimeout(handler);
}, [search]);
const filteredHosts = React.useMemo(() => {
if (!debouncedSearch.trim()) return hosts;
const q = debouncedSearch.trim().toLowerCase();
return hosts.filter(h => {
const searchableText = [
h.name || '',
h.username,
h.ip,
h.folder || '',
...(h.tags || []),
h.authType,
h.defaultPath || ''
].join(' ').toLowerCase();
return searchableText.includes(q);
});
}, [hosts, debouncedSearch]);
const hostsByFolder = React.useMemo(() => {
const map: Record<string, SSHHost[]> = {};
filteredHosts.forEach(h => {
const folder = h.folder && h.folder.trim() ? h.folder : 'No Folder';
if (!map[folder]) map[folder] = [];
map[folder].push(h);
});
return map;
}, [filteredHosts]);
const sortedFolders = React.useMemo(() => {
const folders = Object.keys(hostsByFolder);
folders.sort((a, b) => {
if (a === 'No Folder') return -1;
if (b === 'No Folder') return 1;
return a.localeCompare(b);
});
return folders;
}, [hostsByFolder]);
const getSortedHosts = (arr: SSHHost[]) => {
const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
const rest = arr.filter(h => !h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
return [...pinned, ...rest];
};
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
const [toolsCommand, setToolsCommand] = useState("");
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
const handleTabToggle = (tabId: number) => {
setSelectedTabIds(prev => prev.includes(tabId) ? prev.filter(id => id !== tabId) : [...prev, tabId]);
};
const handleRunCommand = () => {
if (selectedTabIds.length && toolsCommand.trim()) {
let cmd = toolsCommand;
if (!cmd.endsWith("\n")) cmd += "\n";
runCommandOnTabs(selectedTabIds, cmd);
setToolsCommand("");
}
};
function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('=');
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
}, "");
}
const updateRightClickCopyPaste = (checked) => {
document.cookie = `rightClickCopyPaste=${checked}; expires=2147483647; path=/`;
}
return (
<SidebarProvider open={open} onOpenChange={onOpenChange}>
<Sidebar className="h-full flex flex-col overflow-hidden">
<SidebarContent className="flex flex-col flex-grow h-full overflow-hidden">
<SidebarGroup className="flex flex-col flex-grow h-full overflow-hidden">
<SidebarGroupLabel
className="text-lg font-bold text-white flex items-center justify-between gap-2 w-full">
<span>Termix / Terminal</span>
<button
type="button"
onClick={() => onCloseSidebar?.()}
title="Hide sidebar"
style={{
height: 28,
width: 28,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
background: 'hsl(240 5% 9%)',
color: 'hsl(240 5% 64.9%)',
border: '1px solid hsl(240 3.7% 15.9%)',
borderRadius: 6,
cursor: 'pointer',
}}
>
<Menu className="h-4 w-4"/>
</button>
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1"/>
<SidebarGroupContent className="flex flex-col flex-grow h-full overflow-hidden">
<SidebarMenu className="flex flex-col flex-grow h-full overflow-hidden">
<SidebarMenuItem key="Homepage">
<Button
className="w-full mt-2 mb-2 h-8"
onClick={() => onSelectView("homepage")}
variant="outline"
>
<CornerDownLeft/>
Return
</Button>
<Separator className="p-0.25 mt-1 mb-1"/>
</SidebarMenuItem>
<SidebarMenuItem key="Main" className="flex flex-col flex-grow overflow-hidden">
<div
className="w-full flex-grow rounded-md bg-[#09090b] border border-[#434345] overflow-hidden p-0 m-0 relative flex flex-col min-h-0">
<div className="w-full px-2 pt-2 pb-2 bg-[#09090b] z-10">
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search hosts by name, username, IP, folder, tags..."
className="w-full h-8 text-sm bg-background border border-border rounded"
autoComplete="off"
/>
</div>
<div style={{display: 'flex', justifyContent: 'center'}}>
<Separator className="w-full h-px bg-[#434345] my-2"
style={{maxWidth: 213, margin: '0 auto'}}/>
</div>
{hostsError && (
<div className="px-2 py-1 mt-2">
<div
className="text-xs text-red-500 bg-red-500/10 rounded px-2 py-1 border border-red-500/20">{hostsError}</div>
</div>
)}
<div className="flex-1 min-h-0">
<ScrollArea className="w-full h-full">
<Accordion key={`host-accordion-${sortedFolders.length}`}
type="multiple" className="w-full"
defaultValue={sortedFolders.length > 0 ? sortedFolders : undefined}>
{sortedFolders.map((folder, idx) => (
<React.Fragment key={folder}>
<AccordionItem value={folder}
className={idx === 0 ? "mt-0 !border-b-transparent" : "mt-2 !border-b-transparent"}>
<AccordionTrigger
className="text-base font-semibold rounded-t-none px-3 py-2"
style={{marginTop: idx === 0 ? 0 : undefined}}>{folder}</AccordionTrigger>
<AccordionContent
className="flex flex-col gap-1 px-3 pb-2 pt-1">
{getSortedHosts(hostsByFolder[folder]).map(host => (
<div key={host.id}
className="w-full overflow-hidden">
<HostMenuItem
host={host}
onHostConnect={onHostConnect}
/>
</div>
))}
</AccordionContent>
</AccordionItem>
{idx < sortedFolders.length - 1 && (
<div
style={{display: 'flex', justifyContent: 'center'}}>
<Separator className="h-px bg-[#434345] my-1"
style={{width: 213}}/>
</div>
)}
</React.Fragment>
))}
</Accordion>
</ScrollArea>
</div>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
<div className="bg-sidebar">
<Sheet open={toolsSheetOpen} onOpenChange={setToolsSheetOpen}>
<SheetTrigger asChild>
<Button
className="w-full h-8 mt-2"
variant="outline"
onClick={() => setToolsSheetOpen(true)}
>
<Hammer className="mr-2 h-4 w-4"/>
Tools
</Button>
</SheetTrigger>
<SheetContent side="left"
className="w-[256px] fixed top-0 left-0 h-full z-[100] flex flex-col">
<SheetHeader className="pb-0.5">
<SheetTitle>Tools</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-2 pt-2">
<Accordion type="single" collapsible defaultValue="multiwindow">
<AccordionItem value="multiwindow">
<AccordionTrigger className="text-base font-semibold">Run multiwindow
commands</AccordionTrigger>
<AccordionContent>
<textarea
className="w-full min-h-[120px] max-h-48 rounded-md border border-input text-foreground p-2 text-sm font-mono resize-vertical focus:outline-none focus:ring-0"
placeholder="Enter command(s) to run on selected tabs..."
value={toolsCommand}
onChange={e => setToolsCommand(e.target.value)}
style={{
fontFamily: 'monospace',
marginBottom: 8,
background: '#141416'
}}
/>
<div className="flex flex-wrap gap-2 mb-2">
{allTabs.map(tab => (
<Button
key={tab.id}
type="button"
variant={selectedTabIds.includes(tab.id) ? "secondary" : "outline"}
size="sm"
className="rounded-full px-3 py-1 text-xs flex items-center gap-1"
onClick={() => handleTabToggle(tab.id)}
>
{tab.title}
</Button>
))}
</div>
<Button
className="w-full"
variant="outline"
onClick={handleRunCommand}
disabled={!toolsCommand.trim() || !selectedTabIds.length}
>
Run Command
</Button>
</AccordionContent>
</AccordionItem>
</Accordion>
<Separator className="p-0.25"/>
<div className="flex items-center space-x-2 mt-5">
<Checkbox id="enable-copy-paste" onCheckedChange={updateRightClickCopyPaste}
defaultChecked={getCookie("rightClickCopyPaste") === "true"}/>
<label
htmlFor="enable-paste"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Enable rightclick copy/paste
</label>
</div>
</div>
</SheetContent>
</Sheet>
</div>
</SidebarGroup>
</SidebarContent>
</Sidebar>
</SidebarProvider>
);
}
const HostMenuItem = React.memo(function HostMenuItem({host, onHostConnect}: {
host: SSHHost;
onHostConnect: (hostConfig: any) => void
}) {
const tags = Array.isArray(host.tags) ? host.tags : [];
const hasTags = tags.length > 0;
return (
<div className="relative group flex flex-col mb-1 w-full overflow-hidden">
<div className={`flex flex-col w-full rounded overflow-hidden border border-[#434345] bg-[#18181b] h-full`}>
<div className="flex w-full h-10">
<div
className="flex items-center h-full px-2 w-full hover:bg-muted transition-colors cursor-pointer"
onClick={() => onHostConnect(host)}
>
<div className="flex items-center w-full">
{host.pin &&
<Pin className="h-4.5 mr-1 w-4.5 mt-0.5 text-yellow-500 flex-shrink-0"/>
}
<span className="font-medium truncate">{host.name || host.ip}</span>
</div>
</div>
</div>
{hasTags && (
<div
className="border-t border-border bg-[#18181b] flex items-center gap-1 px-2 py-2 overflow-x-auto overflow-y-hidden scrollbar-thin scrollbar-thumb-muted-foreground/30 scrollbar-track-transparent"
style={{height: 30}}>
{tags.map((tag: string) => (
<span key={tag}
className="bg-muted-foreground/10 text-xs rounded-full px-2 py-0.5 text-muted-foreground whitespace-nowrap border border-border flex-shrink-0 hover:bg-muted transition-colors">
{tag}
</span>
))}
</div>
)}
</div>
</div>
);
});

View File

@@ -1,76 +0,0 @@
import React from "react";
import {Button} from "@/components/ui/button.tsx";
import {X, SeparatorVertical} from "lucide-react"
interface TerminalTab {
id: number;
title: string;
}
interface SSHTabListProps {
allTabs: TerminalTab[];
currentTab: number;
setActiveTab: (tab: number) => void;
allSplitScreenTab: number[];
setSplitScreenTab: (tab: number) => void;
setCloseTab: (tab: number) => void;
}
export function TerminalTabList({
allTabs,
currentTab,
setActiveTab,
allSplitScreenTab = [],
setSplitScreenTab,
setCloseTab,
}: SSHTabListProps): React.ReactElement {
const isSplitScreenActive = allSplitScreenTab.length > 0;
return (
<div className="inline-flex items-center h-full px-[0.5rem] overflow-x-auto">
{allTabs.map((terminal, index) => {
const isActive = terminal.id === currentTab;
const isSplit = allSplitScreenTab.includes(terminal.id);
const isSplitButtonDisabled =
(isActive && !isSplitScreenActive) ||
(allSplitScreenTab.length >= 3 && !isSplit);
return (
<div
key={terminal.id}
className={index < allTabs.length - 1 ? "mr-[0.5rem]" : ""}
>
<div className="inline-flex rounded-md shadow-sm" role="group">
<Button
onClick={() => setActiveTab(terminal.id)}
disabled={isSplit}
variant="outline"
className={`rounded-r-none ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
>
{terminal.title}
</Button>
<Button
onClick={() => setSplitScreenTab(terminal.id)}
disabled={isSplitButtonDisabled || isActive}
variant="outline"
className="rounded-none p-0 !w-9 !h-9"
>
<SeparatorVertical className="!w-5 !h-5" strokeWidth={2.5}/>
</Button>
<Button
onClick={() => setCloseTab(terminal.id)}
disabled={(isSplitScreenActive && isActive) || isSplit}
variant="outline"
className="rounded-l-none p-0 !w-9 !h-9"
>
<X className="!w-5 !h-5" strokeWidth={2.5}/>
</Button>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -1,73 +0,0 @@
import {TerminalTabList} from "@/ui/SSH/Terminal/TerminalTabList.tsx";
import React from "react";
import {ChevronUp} from "lucide-react";
interface TerminalTab {
id: number;
title: string;
}
interface SSHTopbarProps {
allTabs: TerminalTab[];
currentTab: number;
setActiveTab: (tab: number) => void;
allSplitScreenTab: number[];
setSplitScreenTab: (tab: number) => void;
setCloseTab: (tab: number) => void;
onHideTopbar?: () => void;
}
export function TerminalTopbar({
allTabs,
currentTab,
setActiveTab,
allSplitScreenTab,
setSplitScreenTab,
setCloseTab,
onHideTopbar
}: SSHTopbarProps): React.ReactElement {
return (
<div className="flex h-11.5 z-100" style={{
position: 'absolute',
left: 0,
width: '100%',
backgroundColor: '#18181b',
borderBottom: '1px solid #222224',
display: 'flex',
alignItems: 'center',
}}>
<div style={{flex: 1, minWidth: 0, height: '100%', overflowX: 'auto'}}>
<div style={{minWidth: 'max-content', height: '100%', paddingLeft: 8, overflowY: 'hidden'}}>
<TerminalTabList
allTabs={allTabs}
currentTab={currentTab}
setActiveTab={setActiveTab}
allSplitScreenTab={allSplitScreenTab}
setSplitScreenTab={setSplitScreenTab}
setCloseTab={setCloseTab}
/>
</div>
</div>
<div style={{flex: '0 0 auto', paddingRight: 8, paddingLeft: 16}}>
<button
onClick={() => onHideTopbar?.()}
style={{
height: 28,
width: 28,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
background: 'hsl(240 5% 9%)',
color: 'hsl(240 5% 64.9%)',
border: '1px solid hsl(240 3.7% 15.9%)',
borderRadius: 6,
cursor: 'pointer',
}}
title="Hide top bar"
>
<ChevronUp size={16} strokeWidth={2}/>
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,288 @@
import React, { useEffect, useRef, useState } from "react";
import {TerminalComponent} from "./TerminalComponent.tsx";
import {useTabs} from "@/contexts/TabContext";
import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx';
import * as ResizablePrimitive from "react-resizable-panels";
import { useSidebar } from "@/components/ui/sidebar";
interface TerminalViewProps {
isTopbarOpen?: boolean;
}
export function TerminalView({ isTopbarOpen = true }: TerminalViewProps): React.ReactElement {
const {tabs, currentTab, allSplitScreenTab} = useTabs() as any;
const { state: sidebarState } = useSidebar();
const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal');
const containerRef = useRef<HTMLDivElement | null>(null);
const panelRefs = useRef<Record<string, HTMLDivElement | null>>({});
const [panelRects, setPanelRects] = useState<Record<string, DOMRect | null>>({});
const [ready, setReady] = useState<boolean>(true);
const updatePanelRects = () => {
const next: Record<string, DOMRect | null> = {};
Object.entries(panelRefs.current).forEach(([id, el]) => {
if (el) next[id] = el.getBoundingClientRect();
});
setPanelRects(next);
};
const fitActiveAndNotify = () => {
const visibleIds: number[] = [];
if (allSplitScreenTab.length === 0) {
if (currentTab) visibleIds.push(currentTab);
} else {
const splitIds = allSplitScreenTab as number[];
visibleIds.push(currentTab, ...splitIds.filter((i) => i !== currentTab));
}
terminalTabs.forEach((t: any) => {
if (visibleIds.includes(t.id)) {
const ref = t.terminalRef?.current;
if (ref?.fit) ref.fit();
if (ref?.notifyResize) ref.notifyResize();
if (ref?.refresh) ref.refresh();
}
});
};
// Coalesce layout → measure → fit callbacks
const layoutScheduleRef = useRef<number | null>(null);
const scheduleMeasureAndFit = () => {
if (layoutScheduleRef.current) cancelAnimationFrame(layoutScheduleRef.current);
layoutScheduleRef.current = requestAnimationFrame(() => {
updatePanelRects();
layoutScheduleRef.current = requestAnimationFrame(() => {
fitActiveAndNotify();
});
});
};
// Hide terminals until layout → rects → fit applied to prevent first-frame wrapping
const hideThenFit = () => {
setReady(false);
requestAnimationFrame(() => {
updatePanelRects();
requestAnimationFrame(() => {
fitActiveAndNotify();
setReady(true);
});
});
};
useEffect(() => {
hideThenFit();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTab, terminalTabs.length, allSplitScreenTab.join(',')]);
// When split layout toggles on/off, topbar toggles, or sidebar state changes → measure+fit
useEffect(() => {
scheduleMeasureAndFit();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allSplitScreenTab.length, isTopbarOpen, sidebarState]);
useEffect(() => {
const roContainer = containerRef.current ? new ResizeObserver(() => {
updatePanelRects();
fitActiveAndNotify();
}) : null;
if (containerRef.current && roContainer) roContainer.observe(containerRef.current);
return () => roContainer?.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const onWinResize = () => { updatePanelRects(); fitActiveAndNotify(); };
window.addEventListener('resize', onWinResize);
return () => window.removeEventListener('resize', onWinResize);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const HEADER_H = 28;
const renderTerminalsLayer = () => {
const styles: Record<number, React.CSSProperties> = {};
const splitTabs = terminalTabs.filter((tab: any) => allSplitScreenTab.includes(tab.id));
const mainTab = terminalTabs.find((tab: any) => tab.id === currentTab);
const layoutTabs = [mainTab, ...splitTabs.filter((t:any)=> t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
if (allSplitScreenTab.length === 0 && mainTab) {
styles[mainTab.id] = { position:'absolute', top:2, left:2, right:2, bottom:2, zIndex: 20, display: 'block', pointerEvents:'auto', opacity: ready ? 1 : 0 };
} else {
layoutTabs.forEach((t: any) => {
const rect = panelRects[String(t.id)];
const parentRect = containerRef.current?.getBoundingClientRect();
if (rect && parentRect) {
styles[t.id] = {
position:'absolute',
top: (rect.top - parentRect.top) + HEADER_H + 2,
left: (rect.left - parentRect.left) + 2,
width: rect.width - 4,
height: rect.height - HEADER_H - 4,
zIndex: 20,
display: 'block',
pointerEvents:'auto',
opacity: ready ? 1 : 0,
};
}
});
}
return (
<div style={{position:'absolute', inset:0, zIndex:1}}>
{terminalTabs.map((t:any) => {
const hasStyle = !!styles[t.id];
const isVisible = hasStyle || (allSplitScreenTab.length===0 && t.id===currentTab);
// Visible style from computed positions; otherwise keep mounted but hidden and non-interactive
const finalStyle: React.CSSProperties = hasStyle
? {...styles[t.id], overflow:'hidden'}
: {
position:'absolute', inset:0, visibility:'hidden', pointerEvents:'none', zIndex:0,
} as React.CSSProperties;
const effectiveVisible = isVisible && ready;
return (
<div key={t.id} style={finalStyle}>
<div className="absolute inset-0 rounded-md" style={{background:'#18181b'}}>
<TerminalComponent
ref={t.terminalRef}
hostConfig={t.hostConfig}
isVisible={effectiveVisible}
title={t.title}
showTitle={false}
splitScreen={allSplitScreenTab.length>0}
/>
</div>
</div>
);
})}
</div>
);
};
const renderSplitOverlays = () => {
const splitTabs = terminalTabs.filter((tab: any) => allSplitScreenTab.includes(tab.id));
const mainTab = terminalTabs.find((tab: any) => tab.id === currentTab);
const layoutTabs = [mainTab, ...splitTabs.filter((t:any)=> t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
if (allSplitScreenTab.length === 0) return null;
const handleStyle = { pointerEvents:'auto', zIndex:12, background:'#303032' } as React.CSSProperties;
const commonGroupProps = { onLayout: scheduleMeasureAndFit, onResize: scheduleMeasureAndFit } as any;
if (layoutTabs.length === 2) {
const [a,b] = layoutTabs as any[];
return (
<div style={{ position:'absolute', inset:0, zIndex:10, pointerEvents:'none' }}>
<ResizablePrimitive.PanelGroup direction="horizontal" className="h-full w-full" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${a.id}`} order={1}>
<div ref={el => { panelRefs.current[String(a.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',background:'transparent',position:'relative'}}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11}}>{a.title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${b.id}`} order={2}>
<div ref={el => { panelRefs.current[String(b.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',background:'transparent',position:'relative'}}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11}}>{b.title}</div>
</div>
</ResizablePanel>
</ResizablePrimitive.PanelGroup>
</div>
);
}
if (layoutTabs.length === 3) {
const [a,b,c] = layoutTabs as any[];
return (
<div style={{ position:'absolute', inset:0, zIndex:10, pointerEvents:'none' }}>
<ResizablePrimitive.PanelGroup direction="vertical" className="h-full w-full" id="main-vertical" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="top-panel" order={1}>
<ResizablePanelGroup direction="horizontal" className="h-full w-full" id="top-horizontal" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${a.id}`} order={1}>
<div ref={el => { panelRefs.current[String(a.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11}}>{a.title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${b.id}`} order={2}>
<div ref={el => { panelRefs.current[String(b.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11}}>{b.title}</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="bottom-panel" order={2}>
<div ref={el => { panelRefs.current[String(c.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11}}>{c.title}</div>
</div>
</ResizablePanel>
</ResizablePrimitive.PanelGroup>
</div>
);
}
if (layoutTabs.length === 4) {
const [a,b,c,d] = layoutTabs as any[];
return (
<div style={{ position:'absolute', inset:0, zIndex:10, pointerEvents:'none' }}>
<ResizablePrimitive.PanelGroup direction="vertical" className="h-full w-full" id="main-vertical" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id="top-panel" order={1}>
<ResizablePanelGroup direction="horizontal" className="h-full w-full" id="top-horizontal" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full" id={`panel-${a.id}`} order={1}>
<div ref={el => { panelRefs.current[String(a.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11}}>{a.title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w_full" id={`panel-${b.id}`} order={2}>
<div ref={el => { panelRefs.current[String(b.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11}}>{b.title}</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id="bottom-panel" order={2}>
<ResizablePanelGroup direction="horizontal" className="h-full w-full" id="bottom-horizontal" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id={`panel-${c.id}`} order={1}>
<div ref={el => { panelRefs.current[String(c.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11}}>{c.title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full" id={`panel-${d.id}`} order={2}>
<div ref={el => { panelRefs.current[String(d.id)] = el; }} style={{height:'100%',width:'100%',display:'flex',flexDirection:'column',position:'relative'}}>
<div style={{background:'#1b1b1e',color:'#fff',fontSize:13,height:HEADER_H,lineHeight:`${HEADER_H}px`,padding:'0 10px',borderBottom:'1px solid #222224',letterSpacing:1,margin:0,pointerEvents:'auto',zIndex:11}}>{d.title}</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePrimitive.PanelGroup>
</div>
);
}
return null;
};
const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
const bottomMarginPx = 15;
return (
<div
ref={containerRef}
className="border-2 border-[#303032] rounded-lg overflow-hidden overflow-x-hidden"
style={{
position:'relative',
background:'#18181b',
marginLeft: leftMarginPx,
marginRight: 17,
marginTop: topMarginPx,
marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
}}
>
{renderTerminalsLayer()}
{renderSplitOverlays()}
</div>
);
}