import React, { useState, useEffect, useMemo, useRef } from "react"; import { Button } from "@/components/ui/button.tsx"; import { Badge } from "@/components/ui/badge.tsx"; import { ScrollArea } from "@/components/ui/scroll-area.tsx"; import { Input } from "@/components/ui/input.tsx"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion.tsx"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip.tsx"; import { getSSHHosts, deleteSSHHost, bulkImportSSHHosts, updateSSHHost, renameFolder, exportSSHHostWithCredentials, } from "@/ui/main-axios.ts"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { useConfirmation } from "@/hooks/use-confirmation.ts"; import { Edit, Trash2, Server, Folder, Tag, Pin, Terminal, Network, FileEdit, Search, Upload, X, Check, Pencil, FolderMinus, Copy, } from "lucide-react"; import type { SSHHost, SSHManagerHostViewerProps, } from "../../../../types/index.js"; export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { const { t } = useTranslation(); const { confirmWithToast } = useConfirmation(); const [hosts, setHosts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [importing, setImporting] = useState(false); const [draggedHost, setDraggedHost] = useState(null); const [dragOverFolder, setDragOverFolder] = useState(null); const [editingFolder, setEditingFolder] = useState(null); const [editingFolderName, setEditingFolderName] = useState(""); const [operationLoading, setOperationLoading] = useState(false); const dragCounter = useRef(0); useEffect(() => { fetchHosts(); const handleHostsRefresh = () => { fetchHosts(); }; window.addEventListener("hosts:refresh", handleHostsRefresh); window.addEventListener("ssh-hosts:changed", handleHostsRefresh); window.addEventListener("folders:changed", handleHostsRefresh); return () => { window.removeEventListener("hosts:refresh", handleHostsRefresh); window.removeEventListener("ssh-hosts:changed", handleHostsRefresh); window.removeEventListener("folders:changed", handleHostsRefresh); }; }, []); const fetchHosts = async () => { try { setLoading(true); const data = await getSSHHosts(); const cleanedHosts = data.map((host) => { const cleanedHost = { ...host }; if (cleanedHost.credentialId && cleanedHost.key) { cleanedHost.key = undefined; cleanedHost.keyPassword = undefined; cleanedHost.keyType = undefined; cleanedHost.authType = "credential"; } else if (cleanedHost.credentialId && cleanedHost.password) { cleanedHost.password = undefined; cleanedHost.authType = "credential"; } else if (cleanedHost.key && cleanedHost.password) { cleanedHost.password = undefined; cleanedHost.authType = "key"; } return cleanedHost; }); setHosts(cleanedHosts); setError(null); } catch (err) { setError(t("hosts.failedToLoadHosts")); } finally { setLoading(false); } }; const handleDelete = async (hostId: number, hostName: string) => { confirmWithToast( t("hosts.confirmDelete", { name: hostName }), async () => { try { await deleteSSHHost(hostId); toast.success(t("hosts.hostDeletedSuccessfully", { name: hostName })); await fetchHosts(); window.dispatchEvent(new CustomEvent("ssh-hosts:changed")); } catch (err) { toast.error(t("hosts.failedToDeleteHost")); } }, "destructive", ); }; const handleExport = (host: SSHHost) => { const actualAuthType = host.credentialId ? "credential" : host.key ? "key" : "password"; if (actualAuthType === "credential") { const confirmMessage = t("hosts.exportCredentialWarning", { name: host.name || `${host.username}@${host.ip}`, }); confirmWithToast(confirmMessage, () => { performExport(host, actualAuthType); }); return; } else if (actualAuthType === "password" || actualAuthType === "key") { const confirmMessage = t("hosts.exportSensitiveDataWarning", { name: host.name || `${host.username}@${host.ip}`, }); confirmWithToast(confirmMessage, () => { performExport(host, actualAuthType); }); return; } performExport(host, actualAuthType); }; const performExport = async (host: SSHHost, actualAuthType: string) => { try { const decryptedHost = await exportSSHHostWithCredentials(host.id); const cleanExportData = Object.fromEntries( Object.entries(decryptedHost).filter( ([_, value]) => value !== undefined, ), ); const blob = new Blob([JSON.stringify(cleanExportData, null, 2)], { type: "application/json", }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${host.name || host.username + "@" + host.ip}-host-config.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); toast.success( `Exported host configuration for ${host.name || host.username}@${host.ip}`, ); } catch (error) { toast.error(t("hosts.failedToExportHost")); } }; const handleEdit = (host: SSHHost) => { if (onEditHost) { onEditHost(host); } }; const handleClone = (host: SSHHost) => { if (onEditHost) { const clonedHost = { ...host }; delete clonedHost.id; onEditHost(clonedHost); } }; const handleRemoveFromFolder = async (host: SSHHost) => { confirmWithToast( t("hosts.confirmRemoveFromFolder", { name: host.name || `${host.username}@${host.ip}`, folder: host.folder, }), async () => { try { setOperationLoading(true); const updatedHost = { ...host, folder: "" }; await updateSSHHost(host.id, updatedHost); toast.success( t("hosts.removedFromFolder", { name: host.name || `${host.username}@${host.ip}`, }), ); await fetchHosts(); window.dispatchEvent(new CustomEvent("ssh-hosts:changed")); } catch (err) { toast.error(t("hosts.failedToRemoveFromFolder")); } finally { setOperationLoading(false); } }, ); }; const handleFolderRename = async (oldName: string) => { if (!editingFolderName.trim() || editingFolderName === oldName) { setEditingFolder(null); setEditingFolderName(""); return; } try { setOperationLoading(true); await renameFolder(oldName, editingFolderName.trim()); toast.success( t("hosts.folderRenamed", { oldName, newName: editingFolderName.trim(), }), ); await fetchHosts(); window.dispatchEvent(new CustomEvent("ssh-hosts:changed")); setEditingFolder(null); setEditingFolderName(""); } catch (err) { toast.error(t("hosts.failedToRenameFolder")); } finally { setOperationLoading(false); } }; const startFolderEdit = (folderName: string) => { setEditingFolder(folderName); setEditingFolderName(folderName); }; const cancelFolderEdit = () => { setEditingFolder(null); setEditingFolderName(""); }; const handleDragStart = (e: React.DragEvent, host: SSHHost) => { setDraggedHost(host); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", ""); }; const handleDragEnd = () => { setDraggedHost(null); setDragOverFolder(null); dragCounter.current = 0; }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; }; const handleDragEnter = (e: React.DragEvent, folderName: string) => { e.preventDefault(); dragCounter.current++; setDragOverFolder(folderName); }; const handleDragLeave = (e: React.DragEvent) => { dragCounter.current--; if (dragCounter.current === 0) { setDragOverFolder(null); } }; const handleDrop = async (e: React.DragEvent, targetFolder: string) => { e.preventDefault(); dragCounter.current = 0; setDragOverFolder(null); if (!draggedHost) return; const newFolder = targetFolder === t("hosts.uncategorized") ? "" : targetFolder; if (draggedHost.folder === newFolder) { setDraggedHost(null); return; } try { setOperationLoading(true); const updatedHost = { ...draggedHost, folder: newFolder }; await updateSSHHost(draggedHost.id, updatedHost); toast.success( t("hosts.movedToFolder", { name: draggedHost.name || `${draggedHost.username}@${draggedHost.ip}`, folder: targetFolder, }), ); await fetchHosts(); window.dispatchEvent(new CustomEvent("ssh-hosts:changed")); } catch (err) { toast.error(t("hosts.failedToMoveToFolder")); } finally { setOperationLoading(false); setDraggedHost(null); } }; const handleJsonImport = async ( event: React.ChangeEvent, ) => { const file = event.target.files?.[0]; if (!file) return; try { setImporting(true); const text = await file.text(); const data = JSON.parse(text); if (!Array.isArray(data.hosts) && !Array.isArray(data)) { throw new Error(t("hosts.jsonMustContainHosts")); } const hostsArray = Array.isArray(data.hosts) ? data.hosts : data; if (hostsArray.length === 0) { throw new Error(t("hosts.noHostsInJson")); } if (hostsArray.length > 100) { throw new Error(t("hosts.maxHostsAllowed")); } const result = await bulkImportSSHHosts(hostsArray); if (result.success > 0) { toast.success( t("hosts.importCompleted", { success: result.success, failed: result.failed, }), ); if (result.errors.length > 0) { toast.error(`Import errors: ${result.errors.join(", ")}`); } await fetchHosts(); window.dispatchEvent(new CustomEvent("ssh-hosts:changed")); } else { toast.error(t("hosts.importFailed") + `: ${result.errors.join(", ")}`); } } catch (err) { const errorMessage = err instanceof Error ? err.message : t("hosts.failedToImportJson"); toast.error(t("hosts.importError") + `: ${errorMessage}`); } finally { setImporting(false); event.target.value = ""; } }; const filteredAndSortedHosts = useMemo(() => { let filtered = hosts; if (searchQuery.trim()) { const query = searchQuery.toLowerCase(); filtered = hosts.filter((host) => { const searchableText = [ host.name || "", host.username, host.ip, host.folder || "", ...(host.tags || []), host.authType, host.defaultPath || "", ] .join(" ") .toLowerCase(); return searchableText.includes(query); }); } return filtered.sort((a, b) => { if (a.pin && !b.pin) return -1; if (!a.pin && b.pin) return 1; const aName = a.name || a.username; const bName = b.name || b.username; return aName.localeCompare(bName); }); }, [hosts, searchQuery]); const hostsByFolder = useMemo(() => { const grouped: { [key: string]: SSHHost[] } = {}; filteredAndSortedHosts.forEach((host) => { const folder = host.folder || t("hosts.uncategorized"); if (!grouped[folder]) { grouped[folder] = []; } grouped[folder].push(host); }); const sortedFolders = Object.keys(grouped).sort((a, b) => { if (a === t("hosts.uncategorized")) return -1; if (b === t("hosts.uncategorized")) return 1; return a.localeCompare(b); }); const sortedGrouped: { [key: string]: SSHHost[] } = {}; sortedFolders.forEach((folder) => { sortedGrouped[folder] = grouped[folder]; }); return sortedGrouped; }, [filteredAndSortedHosts]); if (loading) { return (

{t("hosts.loadingHosts")}

); } if (error) { return (

{error}

); } if (hosts.length === 0) { return (

{t("hosts.sshHosts")}

{t("hosts.hostsCount", { count: 0 })}

{t("hosts.importJsonTitle")}

{t("hosts.importJsonDesc")}

{t("hosts.noHosts")}

{t("hosts.noHostsMessage")}

); } return (

{t("hosts.sshHosts")}

{t("hosts.hostsCount", { count: filteredAndSortedHosts.length })}

{t("hosts.importJsonTitle")}

{t("hosts.importJsonDesc")}

setSearchQuery(e.target.value)} className="pl-10" />
{Object.entries(hostsByFolder).map(([folder, folderHosts]) => (
handleDragEnter(e, folder)} onDragLeave={handleDragLeave} onDrop={(e) => handleDrop(e, folder)} >
{editingFolder === folder ? (
e.stopPropagation()} > setEditingFolderName(e.target.value) } onKeyDown={(e) => { if (e.key === "Enter") handleFolderRename(folder); if (e.key === "Escape") cancelFolderEdit(); }} className="h-6 text-sm px-2 flex-1" autoFocus disabled={operationLoading} />
) : ( <> { e.stopPropagation(); if (folder !== t("hosts.uncategorized")) { startFolderEdit(folder); } }} title={ folder !== t("hosts.uncategorized") ? "Click to rename folder" : "" } > {folder} {folder !== t("hosts.uncategorized") && ( )} )} {folderHosts.length}
{folderHosts.map((host) => (
handleDragStart(e, host)} onDragEnd={handleDragEnd} className={`bg-dark-bg-input border border-input rounded-lg cursor-pointer hover:shadow-lg hover:border-blue-400/50 hover:bg-dark-hover-alt transition-all duration-200 p-3 group relative ${ draggedHost?.id === host.id ? "opacity-50 scale-95" : "" }`} onClick={() => handleEdit(host)} >
{host.pin && ( )}

{host.name || `${host.username}@${host.ip}`}

{host.ip}:{host.port}

{host.username}

{host.folder && host.folder !== "" && (

Remove from folder "{host.folder}"

)}

Edit host

Delete host

Export host

Clone host

{host.tags && host.tags.length > 0 && (
{host.tags .slice(0, 6) .map((tag, index) => ( {tag} ))} {host.tags.length > 6 && ( +{host.tags.length - 6} )}
)}
{host.enableTerminal && ( {t("hosts.terminalBadge")} )} {host.enableTunnel && ( {t("hosts.tunnelBadge")} {host.tunnelConnections && host.tunnelConnections.length > 0 && ( ({host.tunnelConnections.length}) )} )} {host.enableFileManager && ( {t("hosts.fileManagerBadge")} )}

Click to edit host

Drag to move between folders

))}
))}
); }