Added SSH manager and terminals to tab system, now I need to do the server stats
This commit is contained in:
90
src/App.tsx
90
src/App.tsx
@@ -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
134
src/contexts/TabContext.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 right‑click 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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
288
src/ui/SSH/Terminal/TerminalView.tsx
Normal file
288
src/ui/SSH/Terminal/TerminalView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user