import React, {useEffect, useState, useRef, forwardRef, useImperativeHandle} from 'react'; import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarMenu, SidebarMenuItem, SidebarProvider } from '@/components/ui/sidebar.tsx'; import {Separator} from '@/components/ui/separator.tsx'; import {CornerDownLeft, Folder, File, Server, ArrowUp, Pin} from 'lucide-react'; import {ScrollArea} from '@/components/ui/scroll-area.tsx'; import {cn} from '@/lib/utils.ts'; import {Input} from '@/components/ui/input.tsx'; import {Button} from '@/components/ui/button.tsx'; import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from '@/components/ui/accordion.tsx'; import { getSSHHosts, listSSHFiles, connectSSH, getSSHStatus, getConfigEditorPinned, addConfigEditorPinned, removeConfigEditorPinned } from '@/apps/SSH/ssh-axios.ts'; 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; } const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar( {onSelectView, onOpenFile, tabs, onHostChange}: { onSelectView: (view: string) => void; onOpenFile: (file: any) => void; tabs: any[]; onHostChange?: (host: SSHHost | null) => void; }, ref ) { const [sshConnections, setSSHConnections] = useState([]); const [loadingSSH, setLoadingSSH] = useState(false); const [errorSSH, setErrorSSH] = useState(undefined); const [view, setView] = useState<'servers' | 'files'>('servers'); const [activeServer, setActiveServer] = useState(null); const [currentPath, setCurrentPath] = useState('/'); const [files, setFiles] = useState([]); const pathInputRef = useRef(null); const [search, setSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); const [fileSearch, setFileSearch] = useState(''); const [debouncedFileSearch, setDebouncedFileSearch] = useState(''); useEffect(() => { const handler = setTimeout(() => setDebouncedSearch(search), 200); return () => clearTimeout(handler); }, [search]); useEffect(() => { const handler = setTimeout(() => setDebouncedFileSearch(fileSearch), 200); return () => clearTimeout(handler); }, [fileSearch]); const [sshSessionId, setSshSessionId] = useState(null); const [filesLoading, setFilesLoading] = useState(false); const [filesError, setFilesError] = useState(null); const [connectingSSH, setConnectingSSH] = useState(false); const [connectionCache, setConnectionCache] = useState>({}); const [fetchingFiles, setFetchingFiles] = useState(false); useEffect(() => { fetchSSH(); }, []); async function fetchSSH() { setLoadingSSH(true); setErrorSSH(undefined); try { const hosts = await getSSHHosts(); const configEditorHosts = hosts.filter(host => host.enableConfigEditor); if (configEditorHosts.length > 0) { const firstHost = configEditorHosts[0]; } setSSHConnections(configEditorHosts); } catch (err: any) { setErrorSSH('Failed to load SSH connections'); } finally { setLoadingSSH(false); } } async function connectToSSH(server: SSHHost): Promise { const sessionId = server.id.toString(); const cached = connectionCache[sessionId]; if (cached && Date.now() - cached.timestamp < 30000) { setSshSessionId(cached.sessionId); return cached.sessionId; } if (connectingSSH) { return null; } setConnectingSSH(true); try { if (!server.password && !server.key) { setFilesError('No authentication credentials available for this SSH host'); return null; } const connectionConfig = { ip: server.ip, port: server.port, username: server.username, password: server.password, sshKey: server.key, keyPassword: server.keyPassword, }; await connectSSH(sessionId, connectionConfig); setSshSessionId(sessionId); setConnectionCache(prev => ({ ...prev, [sessionId]: {sessionId, timestamp: Date.now()} })); return sessionId; } catch (err: any) { setFilesError(err?.response?.data?.error || 'Failed to connect to SSH'); setSshSessionId(null); return null; } finally { setConnectingSSH(false); } } async function fetchFiles() { if (fetchingFiles) { return; } setFetchingFiles(true); setFiles([]); setFilesLoading(true); setFilesError(null); try { let pinnedFiles: any[] = []; try { if (activeServer) { pinnedFiles = await getConfigEditorPinned(activeServer.id); } } catch (err) { } if (activeServer && sshSessionId) { let res: any[] = []; try { const status = await getSSHStatus(sshSessionId); if (!status.connected) { const newSessionId = await connectToSSH(activeServer); if (newSessionId) { setSshSessionId(newSessionId); res = await listSSHFiles(newSessionId, currentPath); } else { throw new Error('Failed to reconnect SSH session'); } } else { res = await listSSHFiles(sshSessionId, currentPath); } } catch (sessionErr) { const newSessionId = await connectToSSH(activeServer); if (newSessionId) { setSshSessionId(newSessionId); res = await listSSHFiles(newSessionId, currentPath); } else { throw sessionErr; } } const processedFiles = (res || []).map((f: any) => { const filePath = currentPath + (currentPath.endsWith('/') ? '' : '/') + f.name; const isPinned = pinnedFiles.some(pinned => pinned.path === filePath); return { ...f, path: filePath, isPinned, isSSH: true, sshSessionId: sshSessionId }; }); setFiles(processedFiles); } } catch (err: any) { setFiles([]); setFilesError(err?.response?.data?.error || err?.message || 'Failed to list files'); } finally { setFilesLoading(false); setFetchingFiles(false); } } useEffect(() => { if (view === 'files' && activeServer && sshSessionId && !connectingSSH && !fetchingFiles) { const timeoutId = setTimeout(() => { fetchFiles(); }, 100); return () => clearTimeout(timeoutId); } }, [currentPath, view, activeServer, sshSessionId]); async function handleSelectServer(server: SSHHost) { if (connectingSSH) { return; } setFetchingFiles(false); setFilesLoading(false); setFilesError(null); setFiles([]); setActiveServer(server); setCurrentPath(server.defaultPath || '/'); setView('files'); const sessionId = await connectToSSH(server); if (sessionId) { setSshSessionId(sessionId); if (onHostChange) { onHostChange(server); } } else { w setView('servers'); setActiveServer(null); } } useImperativeHandle(ref, () => ({ openFolder: async (server: SSHHost, path: string) => { if (connectingSSH || fetchingFiles) { return; } if (activeServer?.id === server.id && currentPath === path) { setTimeout(() => fetchFiles(), 100); return; } setFetchingFiles(false); setFilesLoading(false); setFilesError(null); setFiles([]); setActiveServer(server); setCurrentPath(path); setView('files'); if (!sshSessionId || activeServer?.id !== server.id) { const sessionId = await connectToSSH(server); if (sessionId) { setSshSessionId(sessionId); if (onHostChange && activeServer?.id !== server.id) { onHostChange(server); } } else { setView('servers'); setActiveServer(null); } } else { if (onHostChange && activeServer?.id !== server.id) { onHostChange(server); } } }, fetchFiles: () => { if (activeServer && sshSessionId) { fetchFiles(); } } })); useEffect(() => { if (pathInputRef.current) { pathInputRef.current.scrollLeft = pathInputRef.current.scrollWidth; } }, [currentPath]); const sshByFolder: Record = {}; sshConnections.forEach(conn => { const folder = conn.folder && conn.folder.trim() ? conn.folder : 'No Folder'; if (!sshByFolder[folder]) sshByFolder[folder] = []; sshByFolder[folder].push(conn); }); const sortedFolders = Object.keys(sshByFolder); if (sortedFolders.includes('No Folder')) { sortedFolders.splice(sortedFolders.indexOf('No Folder'), 1); sortedFolders.unshift('No Folder'); } const filteredSshByFolder: Record = {}; Object.entries(sshByFolder).forEach(([folder, hosts]) => { filteredSshByFolder[folder] = hosts.filter(conn => { const q = debouncedSearch.trim().toLowerCase(); if (!q) return true; return (conn.name || '').toLowerCase().includes(q) || (conn.ip || '').toLowerCase().includes(q) || (conn.username || '').toLowerCase().includes(q) || (conn.folder || '').toLowerCase().includes(q) || (conn.tags || []).join(' ').toLowerCase().includes(q); }); }); const filteredFiles = files.filter(file => { const q = debouncedFileSearch.trim().toLowerCase(); if (!q) return true; return file.name.toLowerCase().includes(q); }); return ( Termix / Config
{view === 'servers' && ( <>
setSearch(e.target.value)} placeholder="Search hosts by name, username, IP, folder, tags..." className="w-full h-8 text-sm bg-[#18181b] border border-[#23232a] text-white placeholder:text-muted-foreground rounded" autoComplete="off" />
{sortedFolders.map((folder, idx) => ( {folder} {filteredSshByFolder[folder].map(conn => ( ))} {idx < sortedFolders.length - 1 && (
)}
))}
)} {view === 'files' && activeServer && (
setCurrentPath(e.target.value)} className="flex-1 bg-[#18181b] border border-[#434345] text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-[#5a5a5d]" />
setFileSearch(e.target.value)} />
{connectingSSH || filesLoading ? (
Loading...
) : filesError ? (
{filesError}
) : filteredFiles.length === 0 ? (
No files or folders found.
) : (
{filteredFiles.map((item: any) => { const isOpen = (tabs || []).some((t: any) => t.id === item.path); return (
!isOpen && (item.type === 'directory' ? setCurrentPath(item.path) : onOpenFile({ name: item.name, path: item.path, isSSH: item.isSSH, sshSessionId: item.sshSessionId }))} > {item.type === 'directory' ? : } {item.name}
{item.type === 'file' && ( )}
); })}
)}
)}
); }); export {ConfigEditorSidebar};