From 9360c31a78255ee1c017eaf20d8d028e37830146 Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Sun, 7 Dec 2025 03:25:07 +0800 Subject: [PATCH] first commit --- .gitignore | 33 + App.tsx | 967 ++++++ README.md | 43 + components/AssistantPanel.tsx | 115 + components/HostDetailsPanel.tsx | 271 ++ components/HostForm.tsx | 212 ++ components/KeyManager.tsx | 319 ++ components/PortForwarding.tsx | 178 + components/SFTPPanel.tsx | 315 ++ components/SettingsDialog.tsx | 341 ++ components/SnippetsManager.tsx | 147 + components/Terminal.tsx | 583 ++++ components/ui/badge.tsx | 28 + components/ui/button.tsx | 38 + components/ui/card.tsx | 78 + components/ui/collapsible.tsx | 10 + components/ui/context-menu.tsx | 198 ++ components/ui/dialog.tsx | 120 + components/ui/input.tsx | 24 + components/ui/label.tsx | 19 + components/ui/popover.tsx | 29 + components/ui/scroll-area.tsx | 46 + components/ui/select.tsx | 158 + components/ui/tabs.tsx | 53 + components/ui/textarea.tsx | 23 + electron/launch.cjs | 12 + electron/main.cjs | 359 ++ electron/preload.cjs | 83 + global.d.ts | 51 + index.css | 88 + index.html | 215 ++ index.tsx | 15 + lib/terminalThemes.ts | 220 ++ lib/utils.ts | 6 + metadata.json | 5 + package-lock.json | 5444 +++++++++++++++++++++++++++++++ package.json | 45 + public/distro/alpine.svg | 1 + public/distro/amazon.svg | 7 + public/distro/arch.svg | 1 + public/distro/centos.svg | 1 + public/distro/debian.svg | 1 + public/distro/fedora.svg | 1 + public/distro/kali.svg | 1 + public/distro/opensuse.svg | 1 + public/distro/oracle.svg | 6 + public/distro/redhat.svg | 1 + public/distro/rocky.svg | 1 + public/distro/ubuntu.svg | 1 + public/ghostty-vt.wasm | Bin 0 -> 415487 bytes public/icon.png | Bin 0 -> 1530 bytes public/logo.svg | 1 + screenshot.png | Bin 0 -> 214934 bytes services/geminiService.ts | 111 + services/syncService.ts | 67 + tsconfig.json | 30 + types.ts | 109 + vite.config.ts | 27 + 58 files changed, 11259 insertions(+) create mode 100755 .gitignore create mode 100755 App.tsx create mode 100644 README.md create mode 100755 components/AssistantPanel.tsx create mode 100644 components/HostDetailsPanel.tsx create mode 100755 components/HostForm.tsx create mode 100755 components/KeyManager.tsx create mode 100644 components/PortForwarding.tsx create mode 100755 components/SFTPPanel.tsx create mode 100755 components/SettingsDialog.tsx create mode 100755 components/SnippetsManager.tsx create mode 100644 components/Terminal.tsx create mode 100755 components/ui/badge.tsx create mode 100755 components/ui/button.tsx create mode 100755 components/ui/card.tsx create mode 100755 components/ui/collapsible.tsx create mode 100755 components/ui/context-menu.tsx create mode 100755 components/ui/dialog.tsx create mode 100755 components/ui/input.tsx create mode 100755 components/ui/label.tsx create mode 100755 components/ui/popover.tsx create mode 100755 components/ui/scroll-area.tsx create mode 100755 components/ui/select.tsx create mode 100755 components/ui/tabs.tsx create mode 100755 components/ui/textarea.tsx create mode 100644 electron/launch.cjs create mode 100644 electron/main.cjs create mode 100644 electron/preload.cjs create mode 100644 global.d.ts create mode 100644 index.css create mode 100755 index.html create mode 100755 index.tsx create mode 100755 lib/terminalThemes.ts create mode 100755 lib/utils.ts create mode 100755 metadata.json create mode 100644 package-lock.json create mode 100755 package.json create mode 100644 public/distro/alpine.svg create mode 100644 public/distro/amazon.svg create mode 100644 public/distro/arch.svg create mode 100644 public/distro/centos.svg create mode 100644 public/distro/debian.svg create mode 100644 public/distro/fedora.svg create mode 100644 public/distro/kali.svg create mode 100644 public/distro/opensuse.svg create mode 100644 public/distro/oracle.svg create mode 100644 public/distro/redhat.svg create mode 100644 public/distro/rocky.svg create mode 100644 public/distro/ubuntu.svg create mode 100755 public/ghostty-vt.wasm create mode 100644 public/icon.png create mode 100644 public/logo.svg create mode 100644 screenshot.png create mode 100755 services/geminiService.ts create mode 100755 services/syncService.ts create mode 100755 tsconfig.json create mode 100755 types.ts create mode 100755 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100755 index 00000000..e4dd0bca --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +.env* +.DS_Store +.eslintcache +*.tsbuildinfo +coverage +/.vite +/build +/release +/out +*.asar + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/App.tsx b/App.tsx new file mode 100755 index 00000000..8109ff35 --- /dev/null +++ b/App.tsx @@ -0,0 +1,967 @@ +import React, { useState, useEffect, useMemo, useRef } from 'react'; +import Terminal from './components/Terminal'; +import AssistantPanel from './components/AssistantPanel'; +import KeyManager from './components/KeyManager'; +import SnippetsManager from './components/SnippetsManager'; +import SettingsDialog from './components/SettingsDialog'; +import PortForwarding from './components/PortForwarding'; +import HostDetailsPanel from './components/HostDetailsPanel'; +import { Host, SSHKey, GroupNode, Snippet, SyncConfig, TerminalSession } from './types'; +import { TERMINAL_THEMES } from './lib/terminalThemes'; +import { + Plus, Search, Settings, LayoutGrid, List as ListIcon, Monitor, Command, + Trash2, Edit2, Key, Folder, FolderOpen, ChevronRight, FolderPlus, FileCode, + X, TerminalSquare, Shield, Grid, Heart, Star, Bell, User, Plug, BookMarked, Activity, Sun, Moon +} from 'lucide-react'; +import { Button } from './components/ui/button'; +import { Input } from './components/ui/input'; +import { Card, CardContent } from './components/ui/card'; +import { Badge } from './components/ui/badge'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from './components/ui/dialog'; +import { Popover, PopoverContent, PopoverTrigger } from './components/ui/popover'; +import { Label } from './components/ui/label'; +import { cn } from './lib/utils'; +import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './components/ui/context-menu'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './components/ui/collapsible'; +import { ScrollArea } from './components/ui/scroll-area'; + +const STORAGE_KEY_HOSTS = 'nebula_hosts_v1'; +const STORAGE_KEY_KEYS = 'nebula_keys_v1'; +const STORAGE_KEY_GROUPS = 'nebula_groups_v1'; +const STORAGE_KEY_SNIPPETS = 'nebula_snippets_v1'; +const STORAGE_KEY_THEME = 'nebula_theme_v1'; +const STORAGE_KEY_COLOR = 'nebula_color_v1'; +const STORAGE_KEY_SYNC = 'nebula_sync_v1'; +const STORAGE_KEY_TERM_THEME = 'nebula_term_theme_v1'; + +const normalizeDistroId = (value?: string) => { + const v = (value || '').toLowerCase().trim(); + if (!v) return ''; + if (v.includes('ubuntu')) return 'ubuntu'; + if (v.includes('debian')) return 'debian'; + if (v.includes('centos')) return 'centos'; + if (v.includes('rocky')) return 'rocky'; + if (v.includes('fedora')) return 'fedora'; + if (v.includes('arch') || v.includes('manjaro')) return 'arch'; + if (v.includes('alpine')) return 'alpine'; + if (v.includes('amzn') || v.includes('amazon') || v.includes('aws')) return 'amazon'; + if (v.includes('opensuse') || v.includes('suse') || v.includes('sles')) return 'opensuse'; + if (v.includes('red hat') || v.includes('rhel')) return 'redhat'; + if (v.includes('oracle')) return 'oracle'; + if (v.includes('kali')) return 'kali'; + return ''; +}; + +const INITIAL_HOSTS: Host[] = [ + { id: '1', label: 'Production Web', hostname: '10.0.0.12', port: 22, username: 'ubuntu', group: 'AWS/Production', tags: ['prod', 'web'], os: 'linux' }, + { id: '2', label: 'DB Master', hostname: 'db-01.internal', port: 22, username: 'admin', group: 'AWS/Production', tags: ['prod', 'db'], os: 'linux' }, +]; + +const INITIAL_SNIPPETS: Snippet[] = [ + { id: '1', label: 'Check Disk Space', command: 'df -h', tags: [] }, + { id: '2', label: 'Tail System Log', command: 'tail -f /var/log/syslog', tags: [] }, + { id: '3', label: 'Update Ubuntu', command: 'sudo apt update && sudo apt upgrade -y', tags: [] }, +]; + +const DISTRO_LOGOS: Record = { + ubuntu: "/distro/ubuntu.svg", + debian: "/distro/debian.svg", + centos: "/distro/centos.svg", + rocky: "/distro/rocky.svg", + fedora: "/distro/fedora.svg", + arch: "/distro/arch.svg", + alpine: "/distro/alpine.svg", + amazon: "/distro/amazon.svg", + opensuse: "/distro/opensuse.svg", + redhat: "/distro/redhat.svg", + oracle: "/distro/oracle.svg", + kali: "/distro/kali.svg", +}; + +const DISTRO_COLORS: Record = { + ubuntu: "bg-[#E95420]", + debian: "bg-[#A81D33]", + centos: "bg-[#9C27B0]", + rocky: "bg-[#0B9B69]", + fedora: "bg-[#3C6EB4]", + arch: "bg-[#1793D1]", + alpine: "bg-[#0D597F]", + amazon: "bg-[#FF9900]", + opensuse: "bg-[#73BA25]", + redhat: "bg-[#EE0000]", + oracle: "bg-[#C74634]", + kali: "bg-[#0F6DB3]", + default: "bg-slate-600", +}; + +const DistroAvatar: React.FC<{ host: Host; fallback: string; className?: string }> = ({ host, fallback, className }) => { + const distro = (host.distro || '').toLowerCase(); + const logo = DISTRO_LOGOS[distro]; + const [errored, setErrored] = React.useState(false); + const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default; + + if (logo && !errored) { + return ( +
+ {host.distro setErrored(true)} + /> +
+ ); + } + + return ( +
+ {fallback} +
+ ); +}; + +// --- Group Tree Item --- +interface GroupTreeItemProps { + node: GroupNode; + depth: number; + expandedPaths: Set; + onToggle: (path: string) => void; + onSelectGroup: (path: string) => void; + selectedGroup: string | null; + onEditGroup: (path: string) => void; + onNewHost: (path: string) => void; + onNewSubfolder: (path: string) => void; +} + +const GroupTreeItem: React.FC = ({ + node, depth, expandedPaths, onToggle, onSelectGroup, selectedGroup, + onEditGroup, onNewHost, onNewSubfolder +}) => { + const isExpanded = expandedPaths.has(node.path); + const hasChildren = node.children && Object.keys(node.children).length > 0; + const paddingLeft = `${depth * 12 + 12}px`; + const isSelected = selectedGroup === node.path; + + // Convert children map to sorted array + const childNodes = useMemo(() => { + return node.children + ? (Object.values(node.children) as unknown as GroupNode[]).sort((a, b) => a.name.localeCompare(b.name)) + : []; + }, [node.children]); + + return ( + onToggle(node.path)}> + + + +
{ + onSelectGroup(node.path); + }} + > +
+ {hasChildren && ( +
+ +
+ )} +
+ +
+ {isExpanded ? : } +
+ + {node.name} + + {node.hosts.length > 0 && ( + + {node.hosts.length} + + )} +
+
+
+ + onNewHost(node.path)}> + New Host + + onNewSubfolder(node.path)}> + New Subfolder + + +
+ + {hasChildren && ( + + {childNodes.map(child => ( + + ))} + + )} +
+ ); +}; + +function App() { + const sanitizeHost = (host: Host): Host => { + const cleanHostname = (host.hostname || '').split(/\s+/)[0]; + const cleanDistro = normalizeDistroId(host.distro); + return { ...host, hostname: cleanHostname, distro: cleanDistro }; + }; + + const [theme, setTheme] = useState<'dark' | 'light'>(() => (localStorage.getItem(STORAGE_KEY_THEME) as any) || 'light'); + const [primaryColor, setPrimaryColor] = useState(() => localStorage.getItem(STORAGE_KEY_COLOR) || '221.2 83.2% 53.3%'); + const [syncConfig, setSyncConfig] = useState(() => { + const saved = localStorage.getItem(STORAGE_KEY_SYNC); + return saved ? JSON.parse(saved) : null; + }); + const [terminalThemeId, setTerminalThemeId] = useState(() => localStorage.getItem(STORAGE_KEY_TERM_THEME) || 'termius-dark'); + + // Data + const [hosts, setHosts] = useState([]); + const [keys, setKeys] = useState([]); + const [snippets, setSnippets] = useState([]); + const [customGroups, setCustomGroups] = useState([]); + + // Navigation & Sessions + const [sessions, setSessions] = useState([]); + const [activeTabId, setActiveTabId] = useState('vault'); // 'vault' or session.id + + // Modals + const [editingHost, setEditingHost] = useState(null); + const [isFormOpen, setIsFormOpen] = useState(false); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [isQuickSwitcherOpen, setIsQuickSwitcherOpen] = useState(false); + const [quickSearch, setQuickSearch] = useState(''); + + // Vault View State + const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); + const [currentSection, setCurrentSection] = useState<'hosts' | 'keys' | 'snippets' | 'port'>('hosts'); + const [showAssistant, setShowAssistant] = useState(false); + const [search, setSearch] = useState(''); + const [expandedPaths, setExpandedPaths] = useState>(new Set()); + const [selectedGroupPath, setSelectedGroupPath] = useState(null); + const [isNewFolderOpen, setIsNewFolderOpen] = useState(false); + const [newFolderName, setNewFolderName] = useState(''); + const [targetParentPath, setTargetParentPath] = useState(null); + + // --- Effects --- + useEffect(() => { + const root = window.document.documentElement; + root.classList.remove('light', 'dark'); + root.classList.add(theme); + root.style.setProperty('--primary', primaryColor); + root.style.setProperty('--ring', primaryColor); + localStorage.setItem(STORAGE_KEY_THEME, theme); + localStorage.setItem(STORAGE_KEY_COLOR, primaryColor); + }, [theme, primaryColor]); + + useEffect(() => { + localStorage.setItem(STORAGE_KEY_TERM_THEME, terminalThemeId); + }, [terminalThemeId]); + + useEffect(() => { + const savedHosts = localStorage.getItem(STORAGE_KEY_HOSTS); + const savedKeys = localStorage.getItem(STORAGE_KEY_KEYS); + const savedGroups = localStorage.getItem(STORAGE_KEY_GROUPS); + const savedSnippets = localStorage.getItem(STORAGE_KEY_SNIPPETS); + + if (savedHosts) { + const sanitized = JSON.parse(savedHosts).map((h: Host) => sanitizeHost(h)); + setHosts(sanitized); + localStorage.setItem(STORAGE_KEY_HOSTS, JSON.stringify(sanitized)); + } else updateHosts(INITIAL_HOSTS); + + if (savedKeys) setKeys(JSON.parse(savedKeys)); + if (savedSnippets) setSnippets(JSON.parse(savedSnippets)); + else updateSnippets(INITIAL_SNIPPETS); + + if (savedGroups) setCustomGroups(JSON.parse(savedGroups)); + }, []); + + const updateHosts = (d: Host[]) => { + const cleaned = d.map(sanitizeHost); + setHosts(cleaned); + localStorage.setItem(STORAGE_KEY_HOSTS, JSON.stringify(cleaned)); + }; + const updateKeys = (d: SSHKey[]) => { setKeys(d); localStorage.setItem(STORAGE_KEY_KEYS, JSON.stringify(d)); }; + const updateSnippets = (d: Snippet[]) => { setSnippets(d); localStorage.setItem(STORAGE_KEY_SNIPPETS, JSON.stringify(d)); }; + const updateCustomGroups = (d: string[]) => { setCustomGroups(d); localStorage.setItem(STORAGE_KEY_GROUPS, JSON.stringify(d)); }; + const updateSyncConfig = (d: SyncConfig | null) => { setSyncConfig(d); localStorage.setItem(STORAGE_KEY_SYNC, JSON.stringify(d)); }; + + // --- Session Management --- + const handleConnect = (host: Host) => { + const newSession: TerminalSession = { + id: crypto.randomUUID(), + hostId: host.id, + hostLabel: host.label, + hostname: host.hostname, + username: host.username, + status: 'connecting' + }; + setSessions(prev => [...prev, newSession]); + setActiveTabId(newSession.id); + }; + + const handleEditHost = (host: Host) => { + setEditingHost(host); + setIsFormOpen(true); + }; + + const handleDeleteHost = (hostId: string) => { + const target = hosts.find(h => h.id === hostId); + const confirmed = window.confirm(`Delete host "${target?.label || hostId}"?`); + if (!confirmed) return; + updateHosts(hosts.filter(h => h.id !== hostId)); + }; + + const updateSessionStatus = (sessionId: string, status: TerminalSession['status']) => { + setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s)); + }; + + const updateHostDistro = (hostId: string, distro: string) => { + const normalized = normalizeDistroId(distro); + setHosts(prev => { + const next = prev.map(h => h.id === hostId ? { ...h, distro: normalized } : h); + localStorage.setItem(STORAGE_KEY_HOSTS, JSON.stringify(next)); + return next; + }); + }; + + const closeSession = (sessionId: string, e?: React.MouseEvent) => { + e?.stopPropagation(); + setSessions(prev => { + const newSessions = prev.filter(s => s.id !== sessionId); + if (activeTabId === sessionId) { + // If we closed the active tab, switch to the last one, or vault + if (newSessions.length > 0) { + setActiveTabId(newSessions[newSessions.length - 1].id); + } else { + setActiveTabId('vault'); + } + } + return newSessions; + }); + }; + + // --- Data Logic --- + const getExportData = () => ({ hosts, keys, snippets, customGroups }); + const handleImportData = (jsonString: string) => { + const data = JSON.parse(jsonString); + if(data.hosts) updateHosts(data.hosts); + if(data.keys) updateKeys(data.keys); + if(data.snippets) updateSnippets(data.snippets); + if(data.customGroups) updateCustomGroups(data.customGroups); + }; + + const buildGroupTree = useMemo>(() => { + const root: Record = {}; + const insertPath = (path: string, host?: Host) => { + const parts = path.split('/').filter(Boolean); + let currentLevel = root; + let currentPath = ''; + parts.forEach((part, index) => { + currentPath = currentPath ? `${currentPath}/${part}` : part; + if (!currentLevel[part]) { + currentLevel[part] = { name: part, path: currentPath, children: {}, hosts: [] }; + } + if (host && index === parts.length - 1) currentLevel[part].hosts.push(host); + currentLevel = currentLevel[part].children; + }); + }; + customGroups.forEach(path => insertPath(path)); + hosts.forEach(host => insertPath(host.group || 'General', host)); + return root; + }, [hosts, customGroups]); + + const findGroupNode = (path: string | null): GroupNode | null => { + if (!path) return { name: 'root', path: '', children: buildGroupTree, hosts: [] } as any; + const parts = path.split('/').filter(Boolean); + let current: any = { children: buildGroupTree }; + for (const p of parts) { + current = current.children?.[p]; + if (!current) return null; + } + return current; + }; + + const quickResults = useMemo(() => { + const term = quickSearch.trim().toLowerCase(); + const filtered = term + ? hosts.filter(h => + h.label.toLowerCase().includes(term) || + h.hostname.toLowerCase().includes(term) || + (h.group || '').toLowerCase().includes(term) + ) + : hosts; + return filtered.slice(0, 8); + }, [hosts, quickSearch]); + + const toggleExpand = (path: string) => { + const newSet = new Set(expandedPaths); + newSet.has(path) ? newSet.delete(path) : newSet.add(path); + setExpandedPaths(newSet); + }; + + const displayedHosts = useMemo(() => { + let filtered = hosts; + if (selectedGroupPath) { + filtered = filtered.filter(h => (h.group || '') === selectedGroupPath); + } + if (search.trim()) { + const s = search.toLowerCase(); + filtered = filtered.filter(h => + h.label.toLowerCase().includes(s) || + h.hostname.toLowerCase().includes(s) || + h.tags.some(t => t.toLowerCase().includes(s)) + ); + } + return filtered; + }, [hosts, selectedGroupPath, search]); + + const displayedGroups = useMemo(() => { + if (!selectedGroupPath) { + return (Object.values(buildGroupTree) as GroupNode[]).sort((a, b) => a.name.localeCompare(b.name)); + } + const node = findGroupNode(selectedGroupPath); + if (!node || !node.children) return []; + return (Object.values(node.children) as GroupNode[]).sort((a, b) => a.name.localeCompare(b.name)); + }, [buildGroupTree, selectedGroupPath]); + + const submitNewFolder = () => { + if(!newFolderName.trim()) return; + const fullPath = targetParentPath ? `${targetParentPath}/${newFolderName.trim()}` : newFolderName.trim(); + updateCustomGroups(Array.from(new Set([...customGroups, fullPath]))); + if (targetParentPath) setExpandedPaths(prev => new Set(prev).add(targetParentPath)); + setIsNewFolderOpen(false); + }; + + const deleteGroupPath = (path: string) => { + const keepGroups = customGroups.filter(g => !(g === path || g.startsWith(path + '/'))); + const keepHosts = hosts.map(h => { + const g = h.group || ''; + if (g === path || g.startsWith(path + '/')) return { ...h, group: '' }; + return h; + }); + updateCustomGroups(keepGroups); + updateHosts(keepHosts); + if (selectedGroupPath && (selectedGroupPath === path || selectedGroupPath.startsWith(path + '/'))) { + setSelectedGroupPath(null); + } + }; + + const moveGroup = (sourcePath: string, targetParent: string | null) => { + const name = sourcePath.split('/').filter(Boolean).pop() || ''; + const newPath = targetParent ? `${targetParent}/${name}` : name; + if (newPath === sourcePath || newPath.startsWith(sourcePath + '/')) return; + const updatedGroups = customGroups.map(g => { + if (g === sourcePath) return newPath; + if (g.startsWith(sourcePath + '/')) return g.replace(sourcePath, newPath); + return g; + }); + const updatedHosts = hosts.map(h => { + const g = h.group || ''; + if (g === sourcePath) return { ...h, group: newPath }; + if (g.startsWith(sourcePath + '/')) return { ...h, group: g.replace(sourcePath, newPath) }; + return h; + }); + updateCustomGroups(Array.from(new Set(updatedGroups))); + updateHosts(updatedHosts); + if (selectedGroupPath && (selectedGroupPath === sourcePath || selectedGroupPath.startsWith(sourcePath + '/'))) { + setSelectedGroupPath(newPath); + } + }; + + const moveHostToGroup = (hostId: string, groupPath: string | null) => { + updateHosts(hosts.map(h => h.id === hostId ? { ...h, group: groupPath || '' } : h)); + }; + + const currentTerminalTheme = TERMINAL_THEMES.find(t => t.id === terminalThemeId) || TERMINAL_THEMES[0]; + const isVaultActive = activeTabId === 'vault'; + const isMacClient = typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent); + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isQuickSwitcherOpen) { + setIsQuickSwitcherOpen(false); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [isQuickSwitcherOpen]); + + // Sort root nodes for display + const rootNodes = useMemo( + () => (Object.values(buildGroupTree) as GroupNode[]).sort((a, b) => a.name.localeCompare(b.name)), + [buildGroupTree] + ); + + const topTabs = ( +
+
+
setActiveTabId('vault')} + className={cn( + "h-8 px-3 rounded-md border text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag", + isVaultActive ? "bg-primary/20 border-primary/60 text-foreground" : "border-border/60 text-muted-foreground hover:border-primary/40 hover:text-foreground" + )} + > + Vaults +
+
+ SFTP +
+ {sessions.map(session => ( +
setActiveTabId(session.id)} + className={cn( + "h-8 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-md border text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag", + activeTabId === session.id ? "bg-primary/20 border-primary/60 text-foreground" : "border-border/60 text-muted-foreground hover:border-primary/40 hover:text-foreground" + )} + > +
+ + {session.hostLabel} +
+ +
+ ))} + +
+ + + +
+
+
+ ); + + return ( +
e.preventDefault()}> + {topTabs} + +
+ {/* Vault layer */} +
+ {/* Sidebar */} +
+
+ netcatty logo +
+

Netcatty

+
+
+ +
+ + + + + + +
+ +
+ + +
+
+ + {/* Main Area */} +
+ {currentSection === 'hosts' && ( +
+
+
+ + setSearch(e.target.value)} /> +
+ +
+ + + + +
+
+ + + + + + + + + +
+
+
+ )} + +
+ {currentSection === 'hosts' && ( + <> +
+
+ + {selectedGroupPath && selectedGroupPath.split('/').filter(Boolean).map((part, idx, arr) => { + const crumbPath = arr.slice(0, idx + 1).join('/'); + const isLast = idx === arr.length - 1; + return ( + + β€Ί + + + ); + })} +
+ {displayedGroups.length > 0 && ( + <> +
+

Groups

+
{displayedGroups.length} total
+
+ + )} +
{ e.preventDefault(); }} + onDrop={(e) => { + e.preventDefault(); + e.stopPropagation(); + const hostId = e.dataTransfer.getData('host-id'); + const groupPath = e.dataTransfer.getData('group-path'); + if (hostId) moveHostToGroup(hostId, selectedGroupPath); + if (groupPath && selectedGroupPath !== null) moveGroup(groupPath, selectedGroupPath); + }}> + {displayedGroups.map(node => ( + + +
e.dataTransfer.setData('group-path', node.path)} + onDoubleClick={() => setSelectedGroupPath(node.path)} + onClick={() => setSelectedGroupPath(node.path)} + onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }} + onDrop={(e) => { + e.preventDefault(); + e.stopPropagation(); + const hostId = e.dataTransfer.getData('host-id'); + const groupPath = e.dataTransfer.getData('group-path'); + if (hostId) moveHostToGroup(hostId, node.path); + if (groupPath) moveGroup(groupPath, node.path); + }} + > +
+
+ +
+
+
{node.name}
+
{node.hosts.length} Hosts
+
+
+
+
+ + { setTargetParentPath(node.path); setIsNewFolderOpen(true); }}> + New Subgroup + + deleteGroupPath(node.path)}> + Delete Group + + +
+ ))} +
+
+ +
+
+

Hosts

+
+ {displayedHosts.length} entries +
{sessions.length} live
+
+
+
+ {displayedHosts.map((host, idx) => { + const safeHost = sanitizeHost(host); + const distro = (safeHost.distro || '').toLowerCase(); + const accentBg = 'bg-primary/15 text-primary'; + const distroBadge = { bg: accentBg, text: (safeHost.os || 'L')[0].toUpperCase(), label: safeHost.distro || safeHost.os || 'Linux' }; + return ( + + +
{ + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('host-id', host.id); + }} + onClick={() => handleConnect(safeHost)} + > +
+ +
+
{safeHost.label}
+
{safeHost.username}@{safeHost.hostname}
+ {safeHost.distro &&
{distroBadge.label}
} +
+
+
+
+ + handleConnect(host)}> + Connect + + handleEditHost(host)}> + Edit + + handleDeleteHost(host.id)}> + Delete + + +
+ ); + })} + {displayedHosts.length === 0 && ( +
+
+
+ +
+
No results found
+
Adjust your search or create a new host.
+
+ + +
+
+
+ )} +
+
+ + )} + + {currentSection === 'keys' && ( + updateKeys([...keys, k])} onDelete={id => updateKeys(keys.filter(k => k.id !== id))} /> + )} + {currentSection === 'snippets' && ( + updateSnippets(snippets.find(ex => ex.id === s.id) ? snippets.map(ex => ex.id === s.id ? s : ex) : [...snippets, s])} onDelete={id => updateSnippets(snippets.filter(s => s.id !== id))} /> + )} + {currentSection === 'port' && } +
+
+
+ + {/* Terminal layer (kept mounted) */} +
+ {sessions.map(session => { + const host = hosts.find(h => h.id === session.hostId); + if (!host) return null; + const isVisible = activeTabId === session.id && !isVaultActive; + return ( +
+ updateSessionStatus(session.id, next)} + onSessionExit={() => updateSessionStatus(session.id, 'disconnected')} + onOsDetected={(hid, distro) => updateHostDistro(hid, distro)} + /> +
+ ); + })} + {showAssistant && ( +
+ +
+ )} +
+
+ {isQuickSwitcherOpen && ( +
{ if (e.target === e.currentTarget) setIsQuickSwitcherOpen(false); }} + > +
+
+ setQuickSearch(e.target.value)} + placeholder="Search hosts or tabs..." + className="h-12 text-sm bg-secondary border-primary/50 focus-visible:ring-primary" + /> +
⌘K
+
+
+
+ Recent connections +
+ + +
+
+
+ {quickResults.length > 0 ? quickResults.map(host => ( +
{ e.stopPropagation(); handleConnect(host); setIsQuickSwitcherOpen(false); setQuickSearch(''); }} + > +
+
+ +
+
+
{host.label}
+
{host.username}@{host.hostname}
+
+
+
{host.group || 'Personal'}
+
+ )) : ( +
No matches. Start typing to search.
+ )} +
+
+
+
+ )} + {/* Host Panel */} + {isFormOpen && ( + h.group || 'General')]))} + onSave={host => { + updateHosts(editingHost ? hosts.map(h => h.id === host.id ? host : h) : [...hosts, host]); + setIsFormOpen(false); + setEditingHost(null); + }} + onCancel={() => { setIsFormOpen(false); setEditingHost(null); }} + /> + )} + + setIsSettingsOpen(false)} + onImport={handleImportData} + exportData={getExportData} + theme={theme} + onThemeChange={setTheme} + primaryColor={primaryColor} + onPrimaryColorChange={setPrimaryColor} + syncConfig={syncConfig} + onSyncConfigChange={updateSyncConfig} + terminalThemeId={terminalThemeId} + onTerminalThemeChange={setTerminalThemeId} + /> + + + + + {targetParentPath ? `Create Subfolder` : 'Create Root Group'} + Create a new group for organizing hosts. + +
+ + setNewFolderName(e.target.value)} placeholder="e.g. Production" autoFocus onKeyDown={e => e.key === 'Enter' && submitNewFolder()} /> + {targetParentPath &&

Parent: {targetParentPath}

} +
+ +
+
+
+ ); +} + +export default App; diff --git a/README.md b/README.md new file mode 100644 index 00000000..a6dedcfc --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# Netcatty β€” SSH workspace, SFTP, and terminals in one + +[![Netcatty UI](screenshot.png)](screenshot.png) + +Netcatty is a modern SSH manager and terminal app that brings host grouping, drag-to-organize, SFTP side panel, keychain, port forwarding, and a rich UI theme. It ships with the Ghostty Web terminal (VT100) and an Electron desktop bridge for native SSH/SFTP. + +--- + +## Highlights +- 🧭 **Host groups & breadcrumbs**: right-click to create/delete groups, drag hosts or groups into target groups, double-click to drill into a group, and breadcrumb navigation back to root. +- πŸ—‚οΈ **SFTP panel**: collapsible sidebar, single-click to enter directories, upload/delete/download, and loading animations. +- πŸ—οΈ **Keychain**: import/generate SSH keys, choose password/key auth; single-click a host to connect. +- πŸ–₯️ **Terminal UX**: Ghostty Web engine, auto-resize on window changes, connection logs, timeout/cancel, and connection progress UI. +- πŸŒ— **Themes & branding**: light/dark toggle, custom accent color, Netcatty logo baked in. +- πŸ”Œ **Electron bridge**: native SSH/SFTP channels without extra browser plugins. + +## Getting Started +```bash +# install dependencies +npm install +# dev mode (Vite + Electron) +npm run dev +``` + +### Key paths +- Web entry: `index.html` / `App.tsx` +- Terminal: `components/Terminal.tsx` +- SFTP: `components/SFTPPanel.tsx` +- Keychain: `components/KeyManager.tsx` +- Electron main: `electron/main.cjs` + +## Core Workflows +- **Create a host**: click β€œNew Host”, fill address/port/username, pick password or key; supports group path. +- **Organize groups**: right-click to create/delete, drag groups to become subgroups, drag hosts into groups, and use breadcrumbs to navigate. +- **SFTP**: after connect, open SFTP, single-click into dirs, upload/download/delete, with loading spinner and animated sidebar. +- **Keychain**: import/generate SSH keys; single-click a host to connect with the chosen auth method. + +## Scripts +- `npm run dev`: Vite frontend + Electron dev. +- `npm run build`: build frontend (Electron packaging configurable). + +## License +MIT diff --git a/components/AssistantPanel.tsx b/components/AssistantPanel.tsx new file mode 100755 index 00000000..bbbf27fe --- /dev/null +++ b/components/AssistantPanel.tsx @@ -0,0 +1,115 @@ +import React, { useState } from 'react'; +import { generateSSHCommand, explainLog } from '../services/geminiService'; +import { Sparkles, MessageSquare, Copy, Terminal, Check } from 'lucide-react'; +import { Button } from './ui/button'; +import { Textarea } from './ui/textarea'; +import { Card, CardContent } from './ui/card'; +import { Label } from './ui/label'; + +const AssistantPanel: React.FC = () => { + const [prompt, setPrompt] = useState(''); + const [response, setResponse] = useState(''); + const [loading, setLoading] = useState(false); + const [mode, setMode] = useState<'command' | 'explain'>('command'); + const [copied, setCopied] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!prompt.trim()) return; + + setLoading(true); + setResponse(''); + + try { + if (mode === 'command') { + const cmd = await generateSSHCommand(prompt); + setResponse(cmd); + } else { + const explanation = await explainLog(prompt); + setResponse(explanation); + } + } finally { + setLoading(false); + } + }; + + const handleCopy = () => { + navigator.clipboard.writeText(response); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+
+

+ + netcatty AI +

+

Generate commands or debug logs

+
+ +
+ + +
+ +
+
+
+ +