diff --git a/src/App.tsx b/src/App.tsx index 8d16f38f..fce95e0d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,11 @@ import React, {useState, useEffect} from "react" import {LeftSidebar} from "@/ui/Navigation/LeftSidebar.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 {ConfigEditor} from "@/ui/SSH/Config Editor/ConfigEditor.tsx" import {SSHManager} from "@/ui/SSH/Manager/SSHManager.tsx" +import {TabProvider, useTabs} from "@/contexts/TabContext" import axios from "axios" 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=/`; } -function App() { +function AppContent() { const [view, setView] = useState("homepage") const [mountedViews, setMountedViews] = useState>(new Set(["homepage"])) const [isAuthenticated, setIsAuthenticated] = useState(false) @@ -31,6 +32,7 @@ function App() { const [isAdmin, setIsAdmin] = useState(false) const [authLoading, setAuthLoading] = useState(true) const [isTopbarOpen, setIsTopbarOpen] = useState(true) + const {currentTab, tabs} = useTabs(); useEffect(() => { const checkAuth = () => { @@ -83,6 +85,17 @@ function App() { 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 (
{/* Enhanced background overlay - detailed pattern when not authenticated */} @@ -139,20 +152,61 @@ function App() { isAdmin={isAdmin} username={username} > - {mountedViews.has("homepage") && ( -
- -
- )} + {/* Always render TerminalView to maintain terminal persistence */} +
+ +
+ + {/* Always render Homepage to keep it mounted */} +
+ +
+ + {/* Always render SSH Manager but toggle visibility for persistence */} +
+ +
+ + {/* Legacy views - keep for compatibility (exclude homepage to avoid duplicate mounts) */} {mountedViews.has("ssh_manager") && (
- +
)} {mountedViews.has("terminal") && ( @@ -177,4 +231,12 @@ function App() { ) } +function App() { + return ( + + + + ); +} + export default App \ No newline at end of file diff --git a/src/contexts/TabContext.tsx b/src/contexts/TabContext.tsx new file mode 100644 index 00000000..b2c8bd95 --- /dev/null +++ b/src/contexts/TabContext.tsx @@ -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; +} + +interface TabContextType { + tabs: Tab[]; + currentTab: number | null; + allSplitScreenTab: number[]; + addTab: (tab: Omit) => number; + removeTab: (tabId: number) => void; + setCurrentTab: (tabId: number) => void; + setSplitScreenTab: (tabId: number) => void; + getTab: (tabId: number) => Tab | undefined; +} + +const TabContext = createContext(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([ + { id: 1, type: 'home', title: 'Home' } + ]); + const [currentTab, setCurrentTab] = useState(1); + const [allSplitScreenTab, setAllSplitScreenTab] = useState([]); + 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(); + 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): 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() : 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 ( + + {children} + + ); +} diff --git a/src/index.css b/src/index.css index df635134..fafaa8f0 100644 --- a/src/index.css +++ b/src/index.css @@ -130,4 +130,26 @@ body { @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; } \ No newline at end of file diff --git a/src/ui/Homepage/Homepage.tsx b/src/ui/Homepage/Homepage.tsx index cc894c7b..61bf8900 100644 --- a/src/ui/Homepage/Homepage.tsx +++ b/src/ui/Homepage/Homepage.tsx @@ -3,6 +3,7 @@ import {HomepageAuth} from "@/ui/Homepage/HomepageAuth.tsx"; import axios from "axios"; import {HomepageUpdateLog} from "@/ui/Homepage/HompageUpdateLog.tsx"; import {HomepageAlertManager} from "@/ui/Homepage/HomepageAlertManager.tsx"; +import {Button} from "@/components/ui/button.tsx"; interface HomepageProps { onSelectView: (view: string) => void; @@ -30,7 +31,13 @@ const API = axios.create({ 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 [isAdmin, setIsAdmin] = useState(false); const [username, setUsername] = useState(null); @@ -71,9 +78,10 @@ export function Homepage({onSelectView, isAuthenticated, authLoading, onAuthSucc }, [isAuthenticated]); return ( -
+
- + +
+ {loggedIn && ( +
+
+

Logged in!

+

+ 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. +

+
+ +
+ +
+ +
+ +
+ +
+
+ )} + + +
-
{dbError && ( Error @@ -384,52 +379,6 @@ export function HomepageAuth({ )} - {(internalLoggedIn || getCookie("jwt")) && ( -
-
-

Logged in!

-

- 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. -

-
- -
- -
- -
- -
- -
-
- )} {(!internalLoggedIn && (!authLoading || !getCookie("jwt"))) && ( <>
@@ -734,6 +683,5 @@ export function HomepageAuth({ )}
-
); } diff --git a/src/ui/Homepage/HompageUpdateLog.tsx b/src/ui/Homepage/HompageUpdateLog.tsx index 7fdb2383..203327e2 100644 --- a/src/ui/Homepage/HompageUpdateLog.tsx +++ b/src/ui/Homepage/HompageUpdateLog.tsx @@ -95,7 +95,7 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) { }; return ( -
+

Updates & Releases

diff --git a/src/ui/Navigation/Hosts/Host.tsx b/src/ui/Navigation/Hosts/Host.tsx index 48db28f3..63fa1ff5 100644 --- a/src/ui/Navigation/Hosts/Host.tsx +++ b/src/ui/Navigation/Hosts/Host.tsx @@ -3,6 +3,7 @@ import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status"; import {Button} from "@/components/ui/button.tsx"; import {ButtonGroup} from "@/components/ui/button-group.tsx"; import {Server, Terminal} from "lucide-react"; +import {useTabs} from "@/contexts/TabContext"; interface SSHHost { id: number; @@ -32,9 +33,22 @@ interface HostProps { } export function Host({ host }: HostProps): React.ReactElement { + const { addTab } = useTabs(); const tags = Array.isArray(host.tags) ? host.tags : []; 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 (
@@ -48,7 +62,11 @@ export function Host({ host }: HostProps): React.ReactElement { - diff --git a/src/ui/Navigation/LeftSidebar.tsx b/src/ui/Navigation/LeftSidebar.tsx index 56848ee6..95ee6dd7 100644 --- a/src/ui/Navigation/LeftSidebar.tsx +++ b/src/ui/Navigation/LeftSidebar.tsx @@ -49,6 +49,7 @@ import axios from "axios"; import {Card} from "@/components/ui/card.tsx"; import {FolderCard} from "@/ui/Navigation/Hosts/FolderCard.tsx"; import {getSSHHosts} from "@/ui/SSH/ssh-axios"; +import { useTabs } from "@/contexts/TabContext"; interface SSHHost { id: number; @@ -145,6 +146,16 @@ export function LeftSidebar({ const [isSidebarOpen, setIsSidebarOpen] = useState(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 const [hosts, setHosts] = useState([]); const [hostsLoading, setHostsLoading] = useState(false); @@ -505,7 +516,7 @@ export function LeftSidebar({ - @@ -632,7 +643,7 @@ export function LeftSidebar({ Users - + Admins @@ -759,7 +770,7 @@ export function LeftSidebar({ handleOIDCConfigChange('scopes', e.target.value)} + onChange={(e) => handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)} placeholder="openid email profile" required /> diff --git a/src/ui/Navigation/Tabs/Tab.tsx b/src/ui/Navigation/Tabs/Tab.tsx index 77f19280..1ab0d5c6 100644 --- a/src/ui/Navigation/Tabs/Tab.tsx +++ b/src/ui/Navigation/Tabs/Tab.tsx @@ -1,22 +1,94 @@ import React from "react"; import {ButtonGroup} from "@/components/ui/button-group.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 { - return ( -
+interface TabProps { + tabType: string; + 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 ( + + ); + } + + if (tabType === "terminal") { + return ( - - + )} + {canClose && ( + + )} + + ); + } + + if (tabType === "ssh_manager") { + return ( + + - -
- ) + ); + } + + return null; } diff --git a/src/ui/Navigation/TopNavbar.tsx b/src/ui/Navigation/TopNavbar.tsx index 12d98bd1..93911bdc 100644 --- a/src/ui/Navigation/TopNavbar.tsx +++ b/src/ui/Navigation/TopNavbar.tsx @@ -3,6 +3,7 @@ import {useSidebar} from "@/components/ui/sidebar"; import {Button} from "@/components/ui/button.tsx"; import {ChevronDown, ChevronUpIcon} from "lucide-react"; import {Tab} from "@/ui/Navigation/Tabs/Tab.tsx"; +import {useTabs} from "@/contexts/TabContext"; interface TopNavbarProps { isTopbarOpen: boolean; @@ -11,8 +12,26 @@ interface TopNavbarProps { export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): React.ReactElement { const {state} = useSidebar(); + const {tabs, currentTab, setCurrentTab, setSplitScreenTab, removeTab, allSplitScreenTab} = useTabs() as any; 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 (
-
- +
+ {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 ( + 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} + /> + ); + })}
diff --git a/src/ui/SSH/Manager/SSHManager.tsx b/src/ui/SSH/Manager/SSHManager.tsx index 3209f11b..432a6ed1 100644 --- a/src/ui/SSH/Manager/SSHManager.tsx +++ b/src/ui/SSH/Manager/SSHManager.tsx @@ -6,85 +6,97 @@ import {SSHManagerHostEditor} from "@/ui/SSH/Manager/SSHManagerHostEditor.tsx"; import {useSidebar} from "@/components/ui/sidebar.tsx"; interface ConfigEditorProps { - onSelectView: (view: string) => void; + onSelectView: (view: string) => void; + isTopbarOpen?: boolean; } 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; + 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 function SSHManager({onSelectView}: ConfigEditorProps): React.ReactElement { - const [activeTab, setActiveTab] = useState("host_viewer"); - const [editingHost, setEditingHost] = useState(null); - const {state: sidebarState} = useSidebar(); +export function SSHManager({onSelectView, isTopbarOpen}: ConfigEditorProps): React.ReactElement { + const [activeTab, setActiveTab] = useState("host_viewer"); + const [editingHost, setEditingHost] = useState(null); + const {state: sidebarState} = useSidebar(); - const handleEditHost = (host: SSHHost) => { - setEditingHost(host); - setActiveTab("add_host"); - }; + const handleEditHost = (host: SSHHost) => { + setEditingHost(host); + setActiveTab("add_host"); + }; - const handleFormSubmit = () => { - setEditingHost(null); - setActiveTab("host_viewer"); - }; + const handleFormSubmit = () => { + setEditingHost(null); + setActiveTab("host_viewer"); + }; - const handleTabChange = (value: string) => { - setActiveTab(value); - if (value === "host_viewer") { - setEditingHost(null); - } - }; + const handleTabChange = (value: string) => { + setActiveTab(value); + if (value === "host_viewer") { + setEditingHost(null); + } + }; - return ( -
-
-
- - - Host Viewer - - {editingHost ? "Edit Host" : "Add Host"} - - - - - - - - -
- -
-
-
-
-
-
- ) + // Dynamic margins similar to TerminalView but with 16px gaps when retracted + const topMarginPx = isTopbarOpen ? 74 : 26; + const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8; + const bottomMarginPx = 8; + + return ( +
+
+
+ + + Host Viewer + + {editingHost ? "Edit Host" : "Add Host"} + + + + + + + + +
+ +
+
+
+
+
+
+ ) } \ No newline at end of file diff --git a/src/ui/SSH/Terminal/Terminal.tsx b/src/ui/SSH/Terminal/Terminal.tsx deleted file mode 100644 index a812f41c..00000000 --- a/src/ui/SSH/Terminal/Terminal.tsx +++ /dev/null @@ -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; -}; - -export function Terminal({onSelectView}: ConfigEditorProps): React.ReactElement { - const [allTabs, setAllTabs] = useState([]); - const [currentTab, setCurrentTab] = useState(null); - const [allSplitScreenTab, setAllSplitScreenTab] = useState([]); - const nextTabId = useRef(1); - - const [isSidebarOpen, setIsSidebarOpen] = useState(true); - const [isTopbarOpen, setIsTopbarOpen] = useState(true); - const SIDEBAR_WIDTH = 256; - const HANDLE_THICKNESS = 10; - - const [panelRects, setPanelRects] = useState>({}); - const panelRefs = useRef>({}); - const panelGroupRefs = useRef<{ [key: string]: any }>({}); - - const setActiveTab = (tabId: number) => { - setCurrentTab(tabId); - }; - - const fitVisibleTerminals = () => { - allTabs.forEach((terminal) => { - const isVisible = - (allSplitScreenTab.length === 0 && terminal.id === currentTab) || - (allSplitScreenTab.length > 0 && (terminal.id === currentTab || allSplitScreenTab.includes(terminal.id))); - if (isVisible && terminal.terminalRef && terminal.terminalRef.current && typeof terminal.terminalRef.current.fit === 'function') { - terminal.terminalRef.current.fit(); - } - }); - }; - - const setSplitScreenTab = (tabId: number) => { - fitVisibleTerminals(); - setAllSplitScreenTab((prev) => { - let next; - if (prev.includes(tabId)) { - next = prev.filter((id) => id !== tabId); - } else if (prev.length < 3) { - next = [...prev, tabId]; - } else { - next = prev; - } - setTimeout(() => fitVisibleTerminals(), 0); - return next; - }); - }; - - const setCloseTab = (tabId: number) => { - const tab = allTabs.find((t) => t.id === tabId); - if (tab && tab.terminalRef && tab.terminalRef.current && typeof tab.terminalRef.current.disconnect === "function") { - tab.terminalRef.current.disconnect(); - } - setAllTabs((prev) => prev.filter((tab) => tab.id !== tabId)); - setAllSplitScreenTab((prev) => prev.filter((id) => id !== tabId)); - if (currentTab === tabId) { - const remainingTabs = allTabs.filter((tab) => tab.id !== tabId); - setCurrentTab(remainingTabs.length > 0 ? remainingTabs[0].id : null); - } - }; - - const updatePanelRects = () => { - setPanelRects((prev) => { - const next: Record = {...prev}; - Object.entries(panelRefs.current).forEach(([id, ref]) => { - if (ref) { - next[id] = ref.getBoundingClientRect(); - } - }); - return next; - }); - }; - - useEffect(() => { - const observers: ResizeObserver[] = []; - Object.entries(panelRefs.current).forEach(([id, ref]) => { - if (ref) { - const observer = new ResizeObserver(() => updatePanelRects()); - observer.observe(ref); - observers.push(observer); - } - }); - updatePanelRects(); - return () => { - observers.forEach((observer) => observer.disconnect()); - }; - }, [allSplitScreenTab, currentTab, allTabs.length]); - - const renderAllTerminals = () => { - const layoutStyles: Record = {}; - const splitTabs = allTabs.filter((tab) => allSplitScreenTab.includes(tab.id)); - const mainTab = allTabs.find((tab) => tab.id === currentTab); - const layoutTabs = [mainTab, ...splitTabs.filter((t) => t && t.id !== (mainTab && mainTab.id))].filter((t): t is Tab => !!t); - if (allSplitScreenTab.length === 0 && mainTab) { - layoutStyles[mainTab.id] = { - position: 'absolute', - top: 0, - left: 0, - width: '100%', - height: '100%', - zIndex: 20, - display: 'block', - pointerEvents: 'auto', - }; - } else { - layoutTabs.forEach((tab) => { - const rect = panelRects[String(tab.id)]; - if (rect) { - const parentRect = panelRefs.current['parent']?.getBoundingClientRect(); - let top = rect.top, left = rect.left, width = rect.width, height = rect.height; - if (parentRect) { - top = rect.top - parentRect.top; - left = rect.left - parentRect.left; - } - layoutStyles[tab.id] = { - position: 'absolute', - top: top + 28, - left, - width, - height: height - 28, - zIndex: 20, - display: 'block', - pointerEvents: 'auto', - }; - } - }); - } - return ( -
{ - panelRefs.current['parent'] = el; - }} style={{ - position: 'absolute', - top: 0, - left: 0, - width: '100%', - height: '100%', - zIndex: 1, - overflow: 'hidden' - }}> - {allTabs.map((tab) => { - const style = layoutStyles[tab.id] - ? {...layoutStyles[tab.id], overflow: 'hidden'} - : {display: 'none', overflow: 'hidden'}; - const isVisible = !!layoutStyles[tab.id]; - return ( -
- 0} - /> -
- ); - })} -
- ); - }; - - const renderSplitOverlays = () => { - const splitTabs = allTabs.filter((tab) => allSplitScreenTab.includes(tab.id)); - const mainTab = allTabs.find((tab) => tab.id === currentTab); - const layoutTabs = [mainTab, ...splitTabs.filter((t) => t && t.id !== (mainTab && mainTab.id))].filter((t): t is Tab => !!t); - if (allSplitScreenTab.length === 0) return null; - - if (layoutTabs.length === 2) { - const [tab1, tab2] = layoutTabs; - return ( -
- { - panelGroupRefs.current['main'] = el; - }} - direction="horizontal" - className="h-full w-full" - id="main-horizontal" - > - -
{ - panelRefs.current[String(tab1.id)] = el; - }} style={{ - height: '100%', - width: '100%', - display: 'flex', - flexDirection: 'column', - background: 'transparent', - margin: 0, - padding: 0, - position: 'relative' - }}> -
{tab1.title}
-
-
- - -
{ - panelRefs.current[String(tab2.id)] = el; - }} style={{ - height: '100%', - width: '100%', - display: 'flex', - flexDirection: 'column', - background: 'transparent', - margin: 0, - padding: 0, - position: 'relative' - }}> -
{tab2.title}
-
-
-
-
- ); - } - if (layoutTabs.length === 3) { - return ( -
- { - panelGroupRefs.current['main'] = el; - }} - direction="vertical" - className="h-full w-full" - id="main-vertical" - > - - { - panelGroupRefs.current['top'] = el; - }} direction="horizontal" className="h-full w-full" id="top-horizontal"> - -
{ - panelRefs.current[String(layoutTabs[0].id)] = el; - }} style={{ - height: '100%', - width: '100%', - display: 'flex', - flexDirection: 'column', - background: 'transparent', - margin: 0, - padding: 0, - position: 'relative' - }}> -
{layoutTabs[0].title}
-
-
- - -
{ - panelRefs.current[String(layoutTabs[1].id)] = el; - }} style={{ - height: '100%', - width: '100%', - display: 'flex', - flexDirection: 'column', - background: 'transparent', - margin: 0, - padding: 0, - position: 'relative' - }}> -
{layoutTabs[1].title}
-
-
-
-
- - -
{ - panelRefs.current[String(layoutTabs[2].id)] = el; - }} style={{ - height: '100%', - width: '100%', - display: 'flex', - flexDirection: 'column', - background: 'transparent', - margin: 0, - padding: 0, - position: 'relative' - }}> -
{layoutTabs[2].title}
-
-
-
-
- ); - } - if (layoutTabs.length === 4) { - return ( -
- { - panelGroupRefs.current['main'] = el; - }} - direction="vertical" - className="h-full w-full" - id="main-vertical" - > - - { - panelGroupRefs.current['top'] = el; - }} direction="horizontal" className="h-full w-full" id="top-horizontal"> - -
{ - panelRefs.current[String(layoutTabs[0].id)] = el; - }} style={{ - height: '100%', - width: '100%', - display: 'flex', - flexDirection: 'column', - background: 'transparent', - margin: 0, - padding: 0, - position: 'relative' - }}> -
{layoutTabs[0].title}
-
-
- - -
{ - panelRefs.current[String(layoutTabs[1].id)] = el; - }} style={{ - height: '100%', - width: '100%', - display: 'flex', - flexDirection: 'column', - background: 'transparent', - margin: 0, - padding: 0, - position: 'relative' - }}> -
{layoutTabs[1].title}
-
-
-
-
- - - { - panelGroupRefs.current['bottom'] = el; - }} direction="horizontal" className="h-full w-full" id="bottom-horizontal"> - -
{ - panelRefs.current[String(layoutTabs[2].id)] = el; - }} style={{ - height: '100%', - width: '100%', - display: 'flex', - flexDirection: 'column', - background: 'transparent', - margin: 0, - padding: 0, - position: 'relative' - }}> -
{layoutTabs[2].title}
-
-
- - -
{ - panelRefs.current[String(layoutTabs[3].id)] = el; - }} style={{ - height: '100%', - width: '100%', - display: 'flex', - flexDirection: 'column', - background: 'transparent', - margin: 0, - padding: 0, - position: 'relative' - }}> -
{layoutTabs[3].title}
-
-
-
-
-
-
- ); - } - return null; - }; - - const onAddHostSubmit = (data: any) => { - const id = nextTabId.current++; - const title = `${data.ip || "Host"}:${data.port || 22}`; - const terminalRef = React.createRef(); - const newTab: Tab = { - id, - title, - hostConfig: data, - terminalRef, - }; - setAllTabs((prev) => [...prev, newTab]); - setCurrentTab(id); - setAllSplitScreenTab((prev) => prev.filter((tid) => tid !== id)); - }; - - const getUniqueTabTitle = (baseTitle: string) => { - let title = baseTitle; - let count = 1; - const existingTitles = allTabs.map(t => t.title); - while (existingTitles.includes(title)) { - title = `${baseTitle} (${count})`; - count++; - } - return title; - }; - - const onHostConnect = (hostConfig: any) => { - const baseTitle = hostConfig.name?.trim() ? hostConfig.name : `${hostConfig.ip || "Host"}:${hostConfig.port || 22}`; - const title = getUniqueTabTitle(baseTitle); - const terminalRef = React.createRef(); - const id = nextTabId.current++; - const newTab: Tab = { - id, - title, - hostConfig, - terminalRef, - }; - setAllTabs((prev) => [...prev, newTab]); - setCurrentTab(id); - setAllSplitScreenTab((prev) => prev.filter((tid) => tid !== id)); - }; - - return ( -
-
- { - allTabs.forEach(tab => { - if (tabIds.includes(tab.id) && tab.terminalRef?.current?.sendInput) { - tab.terminalRef.current.sendInput(command); - } - }); - }} - onCloseSidebar={() => setIsSidebarOpen(false)} - open={isSidebarOpen} - onOpenChange={setIsSidebarOpen} - /> -
- -
-
- setIsTopbarOpen(false)} - /> -
- {!isTopbarOpen && ( -
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"> - -
- )} - -
- {allTabs.length === 0 && ( -
-
- Welcome to Termix SSH -
-
- Click on any host title in the sidebar to open a terminal connection, or use the "Add - Host" button to create a new connection. -
-
- )} - {allSplitScreenTab.length > 0 && ( -
- -
- )} - {renderAllTerminals()} - {renderSplitOverlays()} -
-
- - {!isSidebarOpen && ( -
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"> - -
- )} -
- ); -} \ No newline at end of file diff --git a/src/ui/SSH/Terminal/TerminalComponent.tsx b/src/ui/SSH/Terminal/TerminalComponent.tsx index c69ac609..f4fe4861 100644 --- a/src/ui/SSH/Terminal/TerminalComponent.tsx +++ b/src/ui/SSH/Terminal/TerminalComponent.tsx @@ -17,6 +17,7 @@ export const TerminalComponent = forwardRef(function SSHT {hostConfig, isVisible, splitScreen = false}, ref ) { + console.log('TerminalComponent rendered with:', { hostConfig, isVisible, splitScreen }); const {instance: terminal, ref: xtermRef} = useXTerm(); const fitAddonRef = useRef(null); const webSocketRef = useRef(null); @@ -24,6 +25,39 @@ export const TerminalComponent = forwardRef(function SSHT const wasDisconnectedBySSH = useRef(false); const pingIntervalRef = useRef(null); const [visible, setVisible] = useState(false); + const isVisibleRef = useRef(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(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, () => ({ disconnect: () => { @@ -35,13 +69,26 @@ export const TerminalComponent = forwardRef(function SSHT }, fit: () => { fitAddonRef.current?.fit(); + if (terminal) scheduleNotify(terminal.cols, terminal.rows); + hardRefresh(); }, sendInput: (data: string) => { if (webSocketRef.current?.readyState === 1) { 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(() => { window.addEventListener('resize', handleWindowResize); @@ -49,7 +96,10 @@ export const TerminalComponent = forwardRef(function SSHT }, []); function handleWindowResize() { + if (!isVisibleRef.current) return; fitAddonRef.current?.fit(); + if (terminal) scheduleNotify(terminal.cols, terminal.rows); + hardRefresh(); } function getCookie(name: string) { @@ -69,8 +119,7 @@ export const TerminalComponent = forwardRef(function SSHT await navigator.clipboard.writeText(text); return; } - } catch (_) { - } + } catch (_) {} const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.position = 'fixed'; @@ -78,11 +127,7 @@ export const TerminalComponent = forwardRef(function SSHT document.body.appendChild(textarea); textarea.focus(); textarea.select(); - try { - document.execCommand('copy'); - } finally { - document.body.removeChild(textarea); - } + try { document.execCommand('copy'); } finally { document.body.removeChild(textarea); } } async function readTextFromClipboard(): Promise { @@ -90,8 +135,7 @@ export const TerminalComponent = forwardRef(function SSHT if (navigator.clipboard && navigator.clipboard.readText) { return await navigator.clipboard.readText(); } - } catch (_) { - } + } catch (_) {} return ''; } @@ -104,10 +148,7 @@ export const TerminalComponent = forwardRef(function SSHT scrollback: 10000, fontSize: 14, fontFamily: '"JetBrains Mono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Cascadia Code", "JetBrains Mono", Consolas, "Courier New", monospace', - theme: { - background: '#09090b', - foreground: '#f7f7f7', - }, + theme: { background: '#18181b', foreground: '#f7f7f7' }, allowTransparency: true, convertEol: true, windowsMode: false, @@ -134,130 +175,103 @@ export const TerminalComponent = forwardRef(function SSHT const element = xtermRef.current; const handleContextMenu = async (e: MouseEvent) => { if (!getUseRightClickCopyPaste()) return; - e.preventDefault(); - e.stopPropagation(); + e.preventDefault(); e.stopPropagation(); try { if (terminal.hasSelection()) { const selection = terminal.getSelection(); - if (selection) { - await writeTextToClipboard(selection); - terminal.clearSelection(); - } + if (selection) { await writeTextToClipboard(selection); terminal.clearSelection(); } } else { const pasteText = await readTextFromClipboard(); - if (pasteText) { - terminal.paste(pasteText); - } + if (pasteText) terminal.paste(pasteText); } - } catch (_) { - } + } catch (_) {} }; - if (element) { - element.addEventListener('contextmenu', handleContextMenu); - } + element?.addEventListener('contextmenu', handleContextMenu); const resizeObserver = new ResizeObserver(() => { if (resizeTimeout.current) clearTimeout(resizeTimeout.current); resizeTimeout.current = setTimeout(() => { + if (!isVisibleRef.current) return; fitAddonRef.current?.fit(); - const cols = terminal.cols; - const rows = terminal.rows; - if (webSocketRef.current?.readyState === WebSocket.OPEN) { - webSocketRef.current.send(JSON.stringify({type: 'resize', data: {cols, rows}})); - } + if (terminal) scheduleNotify(terminal.cols, terminal.rows); + hardRefresh(); }, 100); }); resizeObserver.observe(xtermRef.current); - setTimeout(() => { - fitAddon.fit(); - setVisible(true); - const cols = terminal.cols; - const rows = terminal.rows; - const wsUrl = window.location.hostname === 'localhost' - ? 'ws://localhost:8082' - : `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`; + const readyFonts = (document as any).fonts?.ready instanceof Promise ? (document as any).fonts.ready : Promise.resolve(); + readyFonts.then(() => { + setTimeout(() => { + fitAddon.fit(); + setTimeout(() => { + fitAddon.fit(); + if (terminal) scheduleNotify(terminal.cols, terminal.rows); + hardRefresh(); + setVisible(true); + }, 0); - const ws = new WebSocket(wsUrl); - webSocketRef.current = ws; - wasDisconnectedBySSH.current = false; + const cols = terminal.cols; + const rows = terminal.rows; + const wsUrl = window.location.hostname === 'localhost' ? 'ws://localhost:8082' : `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`; - ws.addEventListener('open', () => { - ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}})); - terminal.onData((data) => { - ws.send(JSON.stringify({type: 'input', data})); + const ws = new WebSocket(wsUrl); + webSocketRef.current = ws; + wasDisconnectedBySSH.current = false; + + 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(() => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({type: 'ping'})); - } - }, 30000); - }); + ws.addEventListener('message', (event) => { + try { + const msg = JSON.parse(event.data); + 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('message', (event) => { - try { - const msg = JSON.parse(event.data); - 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); + ws.addEventListener('close', () => { if (!wasDisconnectedBySSH.current) terminal.writeln('\r\n[Connection closed]'); }); + ws.addEventListener('error', () => { terminal.writeln('\r\n[Connection error]'); }); + }, 300); + }); return () => { 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 (pingIntervalRef.current) { - clearInterval(pingIntervalRef.current); - pingIntervalRef.current = null; - } + if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current); pingIntervalRef.current = null; } webSocketRef.current?.close(); }; }, [xtermRef, terminal, hostConfig]); useEffect(() => { if (isVisible && fitAddonRef.current) { - fitAddonRef.current.fit(); + setTimeout(() => { + fitAddonRef.current?.fit(); + if (terminal) scheduleNotify(terminal.cols, terminal.rows); + hardRefresh(); + }, 0); } }, [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 ( -
+
); }); diff --git a/src/ui/SSH/Terminal/TerminalSidebar.tsx b/src/ui/SSH/Terminal/TerminalSidebar.tsx deleted file mode 100644 index 03d901cd..00000000 --- a/src/ui/SSH/Terminal/TerminalSidebar.tsx +++ /dev/null @@ -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 }[]; - 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([]); - const [hostsLoading, setHostsLoading] = useState(false); - const [hostsError, setHostsError] = useState(null); - const prevHostsRef = React.useRef([]); - - 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 = {}; - 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([]); - - 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 ( - - - - - - Termix / Terminal - - - - - - - - - - - - -
-
- 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" - /> -
-
- -
- {hostsError && ( -
-
{hostsError}
-
- )} -
- - 0 ? sortedFolders : undefined}> - {sortedFolders.map((folder, idx) => ( - - - {folder} - - {getSortedHosts(hostsByFolder[folder]).map(host => ( -
- -
- ))} -
-
- {idx < sortedFolders.length - 1 && ( -
- -
- )} -
- ))} -
-
-
-
-
-
-
-
- - - - - - - Tools - -
- - - Run multiwindow - commands - -