first commit

This commit is contained in:
bincxz
2025-12-07 03:25:07 +08:00
commit 9360c31a78
58 changed files with 11259 additions and 0 deletions

33
.gitignore vendored Executable file
View File

@@ -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?

967
App.tsx Executable file
View File

@@ -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<string, string> = {
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<string, string> = {
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 (
<div className={cn("h-12 w-12 rounded-lg flex items-center justify-center border border-border/40 overflow-hidden", bg, className)}>
<img
src={logo}
alt={host.distro || host.os}
className="h-7 w-7 object-contain invert brightness-0"
onError={() => setErrored(true)}
/>
</div>
);
}
return (
<div className={cn("h-10 w-10 rounded-lg flex items-center justify-center bg-slate-600/20", className)}>
<span className="text-xs font-semibold">{fallback}</span>
</div>
);
};
// --- Group Tree Item ---
interface GroupTreeItemProps {
node: GroupNode;
depth: number;
expandedPaths: Set<string>;
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<GroupTreeItemProps> = ({
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 (
<Collapsible open={isExpanded} onOpenChange={() => onToggle(node.path)}>
<ContextMenu>
<ContextMenuTrigger>
<CollapsibleTrigger asChild>
<div
className={cn(
"flex items-center py-1.5 pr-2 text-sm font-medium cursor-pointer transition-colors select-none group relative rounded-r-md",
isSelected ? "bg-primary/10 text-primary border-l-2 border-primary" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}
style={{ paddingLeft }}
onClick={(e) => {
onSelectGroup(node.path);
}}
>
<div className="mr-1.5 flex-shrink-0 w-4 h-4 flex items-center justify-center">
{hasChildren && (
<div className={cn("transition-transform duration-200", isExpanded ? "rotate-90" : "")}>
<ChevronRight size={12} />
</div>
)}
</div>
<div className="mr-2 text-primary/80 group-hover:text-primary transition-colors">
{isExpanded ? <FolderOpen size={16} /> : <Folder size={16} />}
</div>
<span className="truncate flex-1">{node.name}</span>
{node.hosts.length > 0 && (
<span className="text-[10px] opacity-70 bg-background/50 px-1.5 rounded-full border border-border">
{node.hosts.length}
</span>
)}
</div>
</CollapsibleTrigger>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => onNewHost(node.path)}>
<Plus className="mr-2 h-4 w-4" /> New Host
</ContextMenuItem>
<ContextMenuItem onClick={() => onNewSubfolder(node.path)}>
<FolderPlus className="mr-2 h-4 w-4" /> New Subfolder
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{hasChildren && (
<CollapsibleContent>
{childNodes.map(child => (
<GroupTreeItem
key={child.path}
node={child}
depth={depth + 1}
expandedPaths={expandedPaths}
onToggle={onToggle}
onSelectGroup={onSelectGroup}
selectedGroup={selectedGroup}
onEditGroup={onEditGroup}
onNewHost={onNewHost}
onNewSubfolder={onNewSubfolder}
/>
))}
</CollapsibleContent>
)}
</Collapsible>
);
};
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<string>(() => localStorage.getItem(STORAGE_KEY_COLOR) || '221.2 83.2% 53.3%');
const [syncConfig, setSyncConfig] = useState<SyncConfig | null>(() => {
const saved = localStorage.getItem(STORAGE_KEY_SYNC);
return saved ? JSON.parse(saved) : null;
});
const [terminalThemeId, setTerminalThemeId] = useState<string>(() => localStorage.getItem(STORAGE_KEY_TERM_THEME) || 'termius-dark');
// Data
const [hosts, setHosts] = useState<Host[]>([]);
const [keys, setKeys] = useState<SSHKey[]>([]);
const [snippets, setSnippets] = useState<Snippet[]>([]);
const [customGroups, setCustomGroups] = useState<string[]>([]);
// Navigation & Sessions
const [sessions, setSessions] = useState<TerminalSession[]>([]);
const [activeTabId, setActiveTabId] = useState<string>('vault'); // 'vault' or session.id
// Modals
const [editingHost, setEditingHost] = useState<Host | null>(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<Set<string>>(new Set());
const [selectedGroupPath, setSelectedGroupPath] = useState<string | null>(null);
const [isNewFolderOpen, setIsNewFolderOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [targetParentPath, setTargetParentPath] = useState<string | null>(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<Record<string, GroupNode>>(() => {
const root: Record<string, GroupNode> = {};
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<GroupNode[]>(
() => (Object.values(buildGroupTree) as GroupNode[]).sort((a, b) => a.name.localeCompare(b.name)),
[buildGroupTree]
);
const topTabs = (
<div className="w-full bg-secondary/90 border-b border-border/60 backdrop-blur app-drag">
<div
className="h-10 px-3 flex items-center gap-2"
style={{ paddingLeft: isMacClient ? 76 : 12 }}
>
<div
onClick={() => 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"
)}
>
<Shield size={14} /> Vaults
</div>
<div className="h-8 px-3 rounded-md border border-border/60 text-muted-foreground text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag">
<Folder size={14} /> SFTP
</div>
{sessions.map(session => (
<div
key={session.id}
onClick={() => 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"
)}
>
<div className="flex items-center gap-2 truncate">
<TerminalSquare size={14} className={cn("shrink-0", activeTabId === session.id ? "text-primary" : "text-muted-foreground")} />
<span className="truncate">{session.hostLabel}</span>
</div>
<button
onClick={(e) => closeSession(session.id, e)}
className="p-1 rounded-full hover:bg-destructive/10 hover:text-destructive transition-colors"
aria-label="Close session"
>
<X size={12} />
</button>
</div>
))}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 app-no-drag"
onClick={() => setIsQuickSwitcherOpen(true)}
title="Open quick switcher"
>
<Plus size={14} />
</Button>
<div className="ml-auto flex items-center gap-2 app-no-drag">
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-foreground">
<Bell size={16} />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-foreground">
<User size={16} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
onClick={() => setTheme(prev => prev === 'dark' ? 'light' : 'dark')}
title="Toggle theme"
>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</Button>
</div>
</div>
</div>
);
return (
<div className="flex flex-col h-screen text-foreground font-sans nebula-shell" onContextMenu={(e) => e.preventDefault()}>
{topTabs}
<div className="flex-1 relative min-h-0">
{/* Vault layer */}
<div className={cn("absolute inset-0 flex min-h-0", isVaultActive ? "opacity-100 z-20" : "opacity-0 pointer-events-none z-0")}>
{/* Sidebar */}
<div className="w-64 bg-secondary/80 border-r border-border/60 flex flex-col">
<div className="px-4 py-4 flex items-center gap-3">
<img src="/logo.svg" alt="netcatty logo" className="h-10 w-10 rounded-xl bg-transparent" />
<div>
<p className="text-sm font-bold text-foreground">Netcatty</p>
</div>
</div>
<div className="px-3 space-y-1">
<Button variant={currentSection === 'hosts' ? 'secondary' : 'ghost'} className="w-full justify-start gap-3 h-10" onClick={() => { setCurrentSection('hosts'); setSelectedGroupPath(null); }}>
<ListIcon size={16} /> Hosts
</Button>
<Button variant={currentSection === 'keys' ? 'secondary' : 'ghost'} className="w-full justify-start gap-3 h-10" onClick={() => { setCurrentSection('keys'); }}>
<Key size={16} /> Keychain
</Button>
<Button variant={currentSection === 'port' ? 'secondary' : 'ghost'} className="w-full justify-start gap-3 h-10" onClick={() => setCurrentSection('port')}>
<Plug size={16} /> Port Forwarding
</Button>
<Button variant={currentSection === 'snippets' ? 'secondary' : 'ghost'} className="w-full justify-start gap-3 h-10" onClick={() => { setCurrentSection('snippets'); }}>
<FileCode size={16} /> Snippets
</Button>
<Button variant="ghost" className="w-full justify-start gap-3 h-10">
<BookMarked size={16} /> Known Hosts
</Button>
<Button variant="ghost" className="w-full justify-start gap-3 h-10">
<Activity size={16} /> Logs
</Button>
</div>
<div className="mt-auto px-3 pb-4 space-y-2">
<Button variant={showAssistant ? "secondary" : "ghost"} className="w-full justify-start gap-3" onClick={() => setShowAssistant(!showAssistant)}>
<Command size={16} /> AI Assistant
</Button>
<Button variant="ghost" className="w-full justify-start gap-3" onClick={() => setIsSettingsOpen(true)}>
<Settings size={16} /> Settings
</Button>
</div>
</div>
{/* Main Area */}
<div className="flex-1 flex flex-col min-h-0 relative">
{currentSection === 'hosts' && (
<header className="border-b border-border/50 bg-secondary/80 backdrop-blur">
<div className="h-14 px-4 py-2 flex items-center gap-3">
<div className="relative flex-1">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input placeholder="Find a host or ssh user@hostname..." className="pl-9 h-11 bg-secondary border-border/60 text-sm" value={search} onChange={e => setSearch(e.target.value)} />
</div>
<Button variant="secondary" className="h-11 px-4" onClick={() => setIsQuickSwitcherOpen(true)}>Connect</Button>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-10 w-10 text-muted-foreground hover:text-foreground"><LayoutGrid size={16} /></Button>
<Button variant="ghost" size="icon" className="h-10 w-10 text-muted-foreground hover:text-foreground"><Grid size={16} /></Button>
<Button variant="ghost" size="icon" className="h-10 w-10 text-muted-foreground hover:text-foreground"><Heart size={16} /></Button>
<Button variant="ghost" size="icon" className="h-10 w-10 text-muted-foreground hover:text-foreground"><Star size={16} /></Button>
</div>
<div className="flex items-center gap-2">
<Button size="sm" className="h-11 px-3" onClick={() => { setEditingHost(null); setIsFormOpen(true); }}>
<Plus size={14} className="mr-2" /> New Host
</Button>
<Popover>
<PopoverTrigger asChild>
<Button size="sm" variant="ghost" className="h-11 w-10 px-0">
<ChevronRight size={16} />
</Button>
</PopoverTrigger>
<PopoverContent className="w-44 p-1">
<Button
variant="ghost"
className="w-full justify-start gap-2"
onClick={() => { setTargetParentPath(selectedGroupPath); setIsNewFolderOpen(true); }}
>
<Grid size={14} /> New Group
</Button>
</PopoverContent>
</Popover>
</div>
</div>
</header>
)}
<div className="flex-1 overflow-auto px-4 py-4 space-y-6">
{currentSection === 'hosts' && (
<>
<section className="space-y-2">
<div className="flex items-center gap-2 text-sm font-semibold">
<button className="text-primary hover:underline" onClick={() => setSelectedGroupPath(null)}>All hosts</button>
{selectedGroupPath && selectedGroupPath.split('/').filter(Boolean).map((part, idx, arr) => {
const crumbPath = arr.slice(0, idx + 1).join('/');
const isLast = idx === arr.length - 1;
return (
<span key={crumbPath} className="flex items-center gap-2">
<span className="text-muted-foreground"></span>
<button className={cn(isLast ? "text-foreground font-semibold" : "text-primary hover:underline")} onClick={() => setSelectedGroupPath(crumbPath)}>
{part}
</button>
</span>
);
})}
</div>
{displayedGroups.length > 0 && (
<>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">Groups</h3>
<div className="text-xs text-muted-foreground">{displayedGroups.length} total</div>
</div>
</>
)}
<div className={cn("grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4", displayedGroups.length === 0 ? "hidden" : "")}
onDragOver={(e) => { 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 => (
<ContextMenu key={node.path}>
<ContextMenuTrigger asChild>
<div
className="soft-card elevate rounded-lg p-4 cursor-pointer"
draggable
onDragStart={(e) => 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);
}}
>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-primary/15 text-primary flex items-center justify-center">
<Grid size={18} />
</div>
<div>
<div className="text-sm font-semibold">{node.name}</div>
<div className="text-[11px] text-muted-foreground">{node.hosts.length} Hosts</div>
</div>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => { setTargetParentPath(node.path); setIsNewFolderOpen(true); }}>
<FolderPlus className="mr-2 h-4 w-4" /> New Subgroup
</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={() => deleteGroupPath(node.path)}>
<Trash2 className="mr-2 h-4 w-4" /> Delete Group
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
</div>
</section>
<section className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">Hosts</h3>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{displayedHosts.length} entries</span>
<div className="bg-secondary/80 border border-border/70 rounded-md px-2 py-1 text-[11px]">{sessions.length} live</div>
</div>
</div>
<div className="grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{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 (
<ContextMenu key={host.id}>
<ContextMenuTrigger>
<div
className="soft-card elevate rounded-xl cursor-pointer h-[72px] px-3 py-2"
draggable
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('host-id', host.id);
}}
onClick={() => handleConnect(safeHost)}
>
<div className="flex items-center gap-3 h-full">
<DistroAvatar host={safeHost} fallback={distroBadge.text} />
<div className="min-w-0 flex flex-col justify-center gap-0.5">
<div className="text-sm font-semibold truncate leading-5">{safeHost.label}</div>
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">{safeHost.username}@{safeHost.hostname}</div>
{safeHost.distro && <div className="text-[10px] text-muted-foreground truncate leading-4">{distroBadge.label}</div>}
</div>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => handleConnect(host)}>
<Plug className="mr-2 h-4 w-4" /> Connect
</ContextMenuItem>
<ContextMenuItem onClick={() => handleEditHost(host)}>
<Edit2 className="mr-2 h-4 w-4" /> Edit
</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={() => handleDeleteHost(host.id)}>
<Trash2 className="mr-2 h-4 w-4" /> Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
})}
{displayedHosts.length === 0 && (
<div className="col-span-full flex items-center justify-center py-16">
<div className="max-w-sm w-full rounded-2xl bg-secondary/60 px-6 py-8 text-center shadow-lg">
<div className="mx-auto mb-4 flex h-10 w-10 items-center justify-center rounded-xl bg-background text-muted-foreground shadow-sm">
<Search size={20} />
</div>
<div className="text-sm font-semibold text-foreground">No results found</div>
<div className="text-xs text-muted-foreground mt-1">Adjust your search or create a new host.</div>
<div className="mt-4 flex items-center justify-center gap-2">
<Button size="sm" variant="secondary" onClick={() => { setEditingHost(null); setIsFormOpen(true); }}>
<Plus size={14} className="mr-1" /> New Host
</Button>
<Button size="sm" variant="ghost" onClick={() => setSearch('')}>Clear search</Button>
</div>
</div>
</div>
)}
</div>
</section>
</>
)}
{currentSection === 'keys' && (
<KeyManager keys={keys} onSave={k => updateKeys([...keys, k])} onDelete={id => updateKeys(keys.filter(k => k.id !== id))} />
)}
{currentSection === 'snippets' && (
<SnippetsManager snippets={snippets} onSave={s => 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' && <PortForwarding />}
</div>
</div>
</div>
{/* Terminal layer (kept mounted) */}
<div className={cn("absolute inset-0 bg-background", isVaultActive ? "opacity-0 pointer-events-none z-0" : "opacity-100 z-10")}>
{sessions.map(session => {
const host = hosts.find(h => h.id === session.hostId);
if (!host) return null;
const isVisible = activeTabId === session.id && !isVaultActive;
return (
<div
key={session.id}
className={cn("absolute inset-0 bg-background", isVisible ? "z-10" : "opacity-0 pointer-events-none")}
>
<Terminal
host={host}
keys={keys}
snippets={snippets}
isVisible={isVisible}
fontSize={14}
terminalTheme={currentTerminalTheme}
sessionId={session.id}
onStatusChange={(next) => updateSessionStatus(session.id, next)}
onSessionExit={() => updateSessionStatus(session.id, 'disconnected')}
onOsDetected={(hid, distro) => updateHostDistro(hid, distro)}
/>
</div>
);
})}
{showAssistant && (
<div className="absolute right-0 top-0 bottom-0 z-20 shadow-2xl animate-in slide-in-from-right-10">
<AssistantPanel />
</div>
)}
</div>
</div>
{isQuickSwitcherOpen && (
<div
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-lg flex flex-col"
onClick={(e) => { if (e.target === e.currentTarget) setIsQuickSwitcherOpen(false); }}
>
<div className="max-w-5xl w-full mx-auto px-6 pt-14 space-y-4 app-no-drag">
<div className="flex items-center gap-3">
<Input
autoFocus
value={quickSearch}
onChange={e => setQuickSearch(e.target.value)}
placeholder="Search hosts or tabs..."
className="h-12 text-sm bg-secondary border-primary/50 focus-visible:ring-primary"
/>
<div className="text-xs text-muted-foreground">K</div>
</div>
<div className="bg-secondary/90 border border-border/70 rounded-2xl shadow-2xl overflow-hidden">
<div className="px-4 py-3 flex items-center justify-between text-xs font-semibold text-muted-foreground/90">
<span>Recent connections</span>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" className="h-7 px-2 text-[11px]" disabled>Create a workspace</Button>
<Button size="sm" variant="ghost" className="h-7 px-2 text-[11px]" disabled>Restore</Button>
</div>
</div>
<div className="divide-y divide-border/70">
{quickResults.length > 0 ? quickResults.map(host => (
<div
key={host.id}
className="flex items-center justify-between px-4 py-3 hover:bg-primary/10 cursor-pointer transition-colors"
onClick={(e) => { e.stopPropagation(); handleConnect(host); setIsQuickSwitcherOpen(false); setQuickSearch(''); }}
>
<div className="flex items-center gap-3 min-w-0">
<div className="h-8 w-8 rounded-md flex items-center justify-center bg-primary/15 text-primary">
<Monitor size={14} />
</div>
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{host.label}</div>
<div className="text-[11px] text-muted-foreground font-mono truncate">{host.username}@{host.hostname}</div>
</div>
</div>
<div className="text-[11px] text-muted-foreground">{host.group || 'Personal'}</div>
</div>
)) : (
<div className="px-4 py-6 text-sm text-muted-foreground text-center">No matches. Start typing to search.</div>
)}
</div>
</div>
</div>
</div>
)}
{/* Host Panel */}
{isFormOpen && (
<HostDetailsPanel
initialData={editingHost}
availableKeys={keys}
groups={Array.from(new Set([...customGroups, ...hosts.map(h => 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); }}
/>
)}
<SettingsDialog
isOpen={isSettingsOpen}
onClose={() => setIsSettingsOpen(false)}
onImport={handleImportData}
exportData={getExportData}
theme={theme}
onThemeChange={setTheme}
primaryColor={primaryColor}
onPrimaryColorChange={setPrimaryColor}
syncConfig={syncConfig}
onSyncConfigChange={updateSyncConfig}
terminalThemeId={terminalThemeId}
onTerminalThemeChange={setTerminalThemeId}
/>
<Dialog open={isNewFolderOpen} onOpenChange={setIsNewFolderOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{targetParentPath ? `Create Subfolder` : 'Create Root Group'}</DialogTitle>
<DialogDescription className="sr-only">Create a new group for organizing hosts.</DialogDescription>
</DialogHeader>
<div className="py-4">
<Label>Group Name</Label>
<Input value={newFolderName} onChange={e => setNewFolderName(e.target.value)} placeholder="e.g. Production" autoFocus onKeyDown={e => e.key === 'Enter' && submitNewFolder()} />
{targetParentPath && <p className="text-xs text-muted-foreground mt-2">Parent: <span className="font-mono">{targetParentPath}</span></p>}
</div>
<DialogFooter><Button variant="ghost" onClick={() => setIsNewFolderOpen(false)}>Cancel</Button><Button onClick={submitNewFolder}>Create</Button></DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export default App;

43
README.md Normal file
View File

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

115
components/AssistantPanel.tsx Executable file
View File

@@ -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 (
<div className="h-full flex flex-col glass-panel border-l border-border/70 w-80 z-20">
<div className="p-4 border-b border-border/70 bg-gradient-to-r from-primary/10 to-transparent">
<h2 className="text-foreground font-semibold flex items-center gap-2">
<Sparkles className="text-primary" size={18} />
netcatty AI
</h2>
<p className="text-xs text-muted-foreground mt-1">Generate commands or debug logs</p>
</div>
<div className="p-4 grid grid-cols-2 gap-2">
<Button
variant={mode === 'command' ? "default" : "outline"}
size="sm"
onClick={() => setMode('command')}
>
Generate
</Button>
<Button
variant={mode === 'explain' ? "default" : "outline"}
size="sm"
onClick={() => setMode('explain')}
>
Explain
</Button>
</div>
<div className="flex-1 overflow-y-auto px-4 pb-4">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-2">
<Label className="uppercase text-xs text-muted-foreground">
{mode === 'command' ? 'Describe Task' : 'Paste Log/Error'}
</Label>
<Textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="h-32 resize-none font-mono text-sm"
placeholder={mode === 'command' ? "e.g. Find all files larger than 50MB in /var/log" : "e.g. Error: Connection refused on port 22"}
/>
</div>
<Button
type="submit"
disabled={loading}
className="w-full"
>
{loading ? <div className="animate-spin w-4 h-4 border-2 border-primary-foreground border-t-transparent rounded-full" /> : <Sparkles size={14} className="mr-2" />}
{mode === 'command' ? 'Generate' : 'Analyze'}
</Button>
</form>
{response && (
<div className="mt-6 animate-fade-in">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Result</span>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleCopy}>
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
</Button>
</div>
<Card className="bg-muted/50 border-border">
<CardContent className="p-3">
<pre className="text-sm font-mono whitespace-pre-wrap break-words text-foreground">
{response}
</pre>
</CardContent>
</Card>
</div>
)}
</div>
</div>
);
};
export default AssistantPanel;

View File

@@ -0,0 +1,271 @@
import React, { useEffect, useMemo, useState } from "react";
import { Host, SSHKey } from "../types";
import { Input } from "./ui/input";
import { Button } from "./ui/button";
import { Card } from "./ui/card";
import { Badge } from "./ui/badge";
import { cn } from "../lib/utils";
import { Network, KeyRound, Lock, Share2, Server, Shield, Zap, TerminalSquare, Tag, ChevronLeft, Navigation, PhoneCall } from "lucide-react";
type Protocol = "ssh" | "telnet";
type AuthMethod = "password" | "key" | "certificate" | "fido2";
interface HostDetailsPanelProps {
initialData?: Host | null;
availableKeys: SSHKey[];
groups: string[];
onSave: (host: Host) => void;
onCancel: () => void;
}
const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
initialData,
availableKeys,
groups,
onSave,
onCancel
}) => {
const [form, setForm] = useState<Host>(() => initialData || ({
id: crypto.randomUUID(),
label: "",
hostname: "",
port: 22,
username: "root",
protocol: "ssh",
tags: [],
os: "linux",
agentForwarding: false,
authMethod: "password",
charset: "UTF-8",
theme: "Flexoki Dark"
} as Host));
const tagsInput = useMemo(() => form.tags?.join(", "), [form.tags]);
useEffect(() => {
if (initialData) setForm(initialData);
}, [initialData]);
const update = <K extends keyof Host>(key: K, value: Host[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const handleSubmit = () => {
if (!form.hostname || !form.label) return;
const cleaned: Host = {
...form,
tags: form.tags || [],
port: form.port || 22,
};
onSave(cleaned);
};
const setTelnetDefaults = () => {
setForm((prev) => ({
...prev,
protocol: "telnet",
port: prev.port || 23,
authMethod: "password",
identityFileId: "",
}));
};
return (
<div className="absolute right-0 top-0 bottom-0 w-[380px] border-l border-border/60 bg-secondary/90 backdrop-blur z-30 overflow-y-auto">
<div className="p-4 flex items-center justify-between border-b border-border/60">
<div>
<p className="text-sm font-semibold">{initialData ? "Edit Host" : "New Host"}</p>
<p className="text-xs text-muted-foreground">Personal vault</p>
</div>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onCancel} aria-label="Close">
<ChevronLeft size={16} />
</Button>
</div>
<div className="p-4 space-y-4">
<Card className="p-3 space-y-2 bg-card border-border/80">
<p className="text-xs font-semibold">Address</p>
<Input
placeholder="IP or Hostname"
value={form.hostname}
onChange={(e) => update("hostname", e.target.value)}
className="h-10"
/>
<div className="flex gap-2">
<div className="flex items-center gap-2 bg-secondary/70 border border-border/70 rounded-md px-2 py-1 w-full">
<span className="text-xs text-muted-foreground">SSH on</span>
<Input
type="number"
value={form.port}
onChange={(e) => update("port", Number(e.target.value))}
className="h-8 w-16 text-center"
/>
<span className="text-xs text-muted-foreground">port</span>
</div>
<Button
variant={form.protocol === "ssh" ? "secondary" : "ghost"}
className="h-10 flex-1"
onClick={() => update("protocol", "ssh")}
>
SSH
</Button>
<Button
variant={form.protocol === "telnet" ? "secondary" : "ghost"}
className="h-10 flex-1"
onClick={() => { update("protocol", "telnet"); update("port", 23); }}
>
Telnet
</Button>
</div>
</Card>
<Card className="p-3 space-y-2 bg-card border-border/80">
<p className="text-xs font-semibold">General</p>
<Input placeholder="Label" value={form.label} onChange={(e) => update("label", e.target.value)} className="h-10" />
<Input placeholder="Parent Group" value={form.group || ""} onChange={(e) => update("group", e.target.value)} list="group-options" className="h-10" />
<datalist id="group-options">
{groups.map((g) => <option key={g} value={g} />)}
</datalist>
<Input
placeholder="Tags (comma separated)"
value={tagsInput}
onChange={(e) => update("tags", e.target.value.split(",").map((t) => t.trim()).filter(Boolean))}
className="h-10"
/>
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold">Credentials</p>
<Badge variant="secondary" className="gap-1 text-xs"><Network size={12} /> {form.protocol?.toUpperCase() || "SSH"}</Badge>
</div>
<div className="grid gap-2">
<Input placeholder="Username" value={form.username} onChange={(e) => update("username", e.target.value)} className="h-10" />
{form.authMethod !== "key" && (
<Input placeholder="Password" type="password" value={form.password || ""} onChange={(e) => update("password", e.target.value)} className="h-10" />
)}
<div className="flex gap-2">
{(["password", "key", "certificate", "fido2"] as AuthMethod[]).map((m) => (
<Button
key={m}
variant={form.authMethod === m ? "secondary" : "ghost"}
size="sm"
className={cn("flex-1 capitalize", form.authMethod === m && "bg-primary/15")}
onClick={() => update("authMethod", m)}
>
{m}
</Button>
))}
</div>
{form.authMethod === "key" && (
<select
value={form.identityFileId || ""}
onChange={(e) => update("identityFileId", e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="">Select key</option>
{availableKeys.map((k) => (
<option key={k.id} value={k.id}>{k.label}</option>
))}
</select>
)}
</div>
</Card>
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold">Extras</p>
<Badge variant="secondary" className="gap-1 text-xs"><Shield size={12} /> Secure</Badge>
</div>
<div className="grid gap-2">
<ToggleRow
label="Agent Forwarding"
enabled={!!form.agentForwarding}
onToggle={() => update("agentForwarding", !form.agentForwarding)}
/>
<Input placeholder="Startup Command" value={form.startupCommand || ""} onChange={(e) => update("startupCommand", e.target.value)} className="h-10" />
<Input placeholder="Host Chaining" value={form.hostChaining || ""} onChange={(e) => update("hostChaining", e.target.value)} className="h-10" />
<Input placeholder="Proxy" value={form.proxy || ""} onChange={(e) => update("proxy", e.target.value)} className="h-10" />
<Input placeholder="Environment Variables" value={form.envVars || ""} onChange={(e) => update("envVars", e.target.value)} className="h-10" />
<Input placeholder="Charset (e.g., UTF-8)" value={form.charset || ""} onChange={(e) => update("charset", e.target.value)} className="h-10" />
<ToggleRow
label="Mosh"
enabled={!!form.moshEnabled}
onToggle={() => update("moshEnabled", !form.moshEnabled)}
/>
</div>
</Card>
<Card className="p-3 space-y-2 bg-card border-border/80">
<p className="text-xs font-semibold">Theme</p>
<div className="flex items-center gap-3">
<div className="h-10 w-16 rounded-md border border-border/60 bg-gradient-to-r from-gray-900 to-gray-700" />
<div className="space-y-1">
<p className="text-sm font-semibold">{form.theme || "Flexoki Dark"}</p>
<p className="text-[11px] text-muted-foreground">Terminal appearance</p>
</div>
</div>
<Button variant="secondary" className="h-9 w-full">Select theme</Button>
</Card>
{form.protocol === "telnet" && (
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center gap-2 text-xs font-semibold">
<Network size={14} /> Telnet
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Telnet on</span>
<Input
type="number"
value={form.port}
onChange={(e) => update("port", Number(e.target.value))}
className="h-9 w-16 text-center"
/>
<span className="text-xs text-muted-foreground">port</span>
</div>
<div className="grid gap-2">
<Input placeholder="Username" value={form.username} onChange={(e) => update("username", e.target.value)} className="h-10" />
<Input placeholder="Password" type="password" value={form.password || ""} onChange={(e) => update("password", e.target.value)} className="h-10" />
<Input placeholder="Charset (e.g., UTF-8)" value={form.charset || ""} onChange={(e) => update("charset", e.target.value)} className="h-10" />
</div>
<div className="flex items-center gap-3">
<div className="h-10 w-16 rounded-md border border-border/60 bg-gradient-to-r from-gray-900 to-gray-700" />
<div className="space-y-1">
<p className="text-sm font-semibold">{form.theme || "Flexoki Dark"}</p>
<p className="text-[11px] text-muted-foreground">Telnet appearance</p>
</div>
</div>
</Card>
)}
<div className="flex gap-2">
<Button variant="ghost" className="flex-1 h-10" onClick={onCancel}>Cancel</Button>
<Button className="flex-1 h-10" onClick={handleSubmit} disabled={!form.hostname || !form.label}>
Save &amp; Connect
</Button>
</div>
<Button variant="ghost" className="w-full h-10 gap-2" onClick={setTelnetDefaults}>
<PhoneCall size={16} /> Add Telnet
</Button>
</div>
</div>
);
};
interface ToggleRowProps {
label: string;
enabled: boolean;
onToggle: () => void;
}
const ToggleRow: React.FC<ToggleRowProps> = ({ label, enabled, onToggle }) => (
<div className="flex items-center justify-between h-10 px-3 rounded-md border border-border/70 bg-secondary/70">
<span className="text-sm">{label}</span>
<Button variant={enabled ? "secondary" : "ghost"} size="sm" className={cn("h-8 min-w-[72px]", enabled && "bg-primary/20")} onClick={onToggle}>
{enabled ? "Enabled" : "Disabled"}
</Button>
</div>
);
export default HostDetailsPanel;

212
components/HostForm.tsx Executable file
View File

@@ -0,0 +1,212 @@
import React, { useState, useEffect } from 'react';
import { Host, SSHKey } from '../types';
import { Server, Save, Key, Lock } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from './ui/dialog';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { cn } from '../lib/utils';
interface HostFormProps {
initialData?: Host | null;
availableKeys: SSHKey[];
groups: string[];
onSave: (host: Host) => void;
onCancel: () => void;
}
const HostForm: React.FC<HostFormProps> = ({ initialData, availableKeys, groups, onSave, onCancel }) => {
const [formData, setFormData] = useState<Partial<Host>>(
initialData || {
label: '',
hostname: '',
port: 22,
username: 'root',
tags: [],
os: 'linux',
group: 'General',
identityFileId: ''
}
);
const [authType, setAuthType] = useState<'password' | 'key'>(
initialData?.identityFileId ? 'key' : 'password'
);
// Effect to ensure we have a valid auth state if switching back and forth
useEffect(() => {
if (authType === 'password') {
setFormData(prev => ({ ...prev, identityFileId: '' }));
} else if (authType === 'key' && !formData.identityFileId && availableKeys.length > 0) {
// Default to first key if none selected
setFormData(prev => ({ ...prev, identityFileId: availableKeys[0].id }));
}
}, [authType, availableKeys, formData.identityFileId]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (formData.label && formData.hostname && formData.username) {
onSave({
...formData,
id: initialData?.id || crypto.randomUUID(),
tags: formData.tags || [],
port: formData.port || 22,
group: formData.group || 'General',
identityFileId: authType === 'key' ? formData.identityFileId : undefined
} as Host);
}
};
return (
<Dialog open={true} onOpenChange={() => onCancel()}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Server className="h-5 w-5 text-primary" />
{initialData ? 'Edit Host' : 'New Host'}
</DialogTitle>
<DialogDescription className="sr-only">
{initialData ? 'Update connection details for this host' : 'Create a new SSH host entry'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="label">Label</Label>
<Input
id="label"
placeholder="My Production Server"
value={formData.label}
onChange={e => setFormData({...formData, label: e.target.value})}
required
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 grid gap-2">
<Label htmlFor="hostname">Hostname / IP</Label>
<Input
id="hostname"
placeholder="192.168.1.1"
value={formData.hostname}
onChange={e => setFormData({...formData, hostname: e.target.value})}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="port">Port</Label>
<Input
id="port"
type="number"
value={formData.port}
onChange={e => setFormData({...formData, port: parseInt(e.target.value)})}
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
value={formData.username}
onChange={e => setFormData({...formData, username: e.target.value})}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="os">OS Type</Label>
<Select value={formData.os} onValueChange={(val: any) => setFormData({...formData, os: val})}>
<SelectTrigger>
<SelectValue placeholder="Select OS" />
</SelectTrigger>
<SelectContent>
<SelectItem value="linux">Linux</SelectItem>
<SelectItem value="windows">Windows</SelectItem>
<SelectItem value="macos">macOS</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="group">Group</Label>
<Input
id="group"
placeholder="e.g. AWS, DigitalOcean"
value={formData.group}
onChange={e => setFormData({...formData, group: e.target.value})}
list="group-suggestions"
autoComplete="off"
/>
<datalist id="group-suggestions">
{groups.map(g => (
<option key={g} value={g} />
))}
</datalist>
</div>
<div className="space-y-3 pt-2">
<Label>Authentication Method</Label>
<div className="grid grid-cols-2 gap-4">
<div
className={cn(
"border rounded-md p-3 flex flex-col items-center justify-center gap-2 cursor-pointer transition-all hover:bg-accent/50",
authType === 'password' ? "border-primary bg-primary/5 text-primary ring-1 ring-primary" : "text-muted-foreground"
)}
onClick={() => setAuthType('password')}
>
<Lock size={20} />
<span className="text-xs font-medium">Password</span>
</div>
<div
className={cn(
"border rounded-md p-3 flex flex-col items-center justify-center gap-2 cursor-pointer transition-all hover:bg-accent/50",
authType === 'key' ? "border-primary bg-primary/5 text-primary ring-1 ring-primary" : "text-muted-foreground"
)}
onClick={() => setAuthType('key')}
>
<Key size={20} />
<span className="text-xs font-medium">SSH Key</span>
</div>
</div>
{authType === 'key' && (
<div className="animate-in fade-in zoom-in-95 duration-200">
<Select value={formData.identityFileId || ""} onValueChange={(val) => setFormData({...formData, identityFileId: val})}>
<SelectTrigger>
<SelectValue placeholder="Select an SSH Key" />
</SelectTrigger>
<SelectContent>
{availableKeys.map(key => (
<SelectItem key={key.id} value={key.id}>{key.label} ({key.type})</SelectItem>
))}
{availableKeys.length === 0 && <SelectItem value="none" disabled>No keys available</SelectItem>}
</SelectContent>
</Select>
{availableKeys.length === 0 && (
<p className="text-[10px] text-destructive mt-1">
No SSH keys found in Keychain. Please create one first.
</p>
)}
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button type="submit">
<Save className="mr-2 h-4 w-4" /> Save Host
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
export default HostForm;

319
components/KeyManager.tsx Executable file
View File

@@ -0,0 +1,319 @@
import React, { useMemo, useState } from 'react';
import { SSHKey } from '../types';
import { Key, Plus, Trash2, Shield, Search, LayoutGrid, List as ListIcon, Pencil } from 'lucide-react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import { Label } from './ui/label';
import { Textarea } from './ui/textarea';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from './ui/dialog';
import { cn } from '../lib/utils';
interface KeyManagerProps {
keys: SSHKey[];
onSave: (key: SSHKey) => void;
onDelete: (id: string) => void;
}
const KeyManager: React.FC<KeyManagerProps> = ({ keys, onSave, onDelete }) => {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [panelMode, setPanelMode] = useState<'new' | 'edit'>('new');
const [draftKey, setDraftKey] = useState<Partial<SSHKey>>({
id: '',
label: '',
type: 'RSA',
privateKey: '',
publicKey: '',
created: Date.now(),
});
const [generateMode, setGenerateMode] = useState(false);
const [search, setSearch] = useState('');
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const handleGenerate = () => {
// Simulate Key Generation
const mockKey = `-----BEGIN ${draftKey.type} PRIVATE KEY-----\n` +
`MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC${Math.random().toString(36).substring(7)}\n` +
`... (simulated generated content) ...\n` +
`-----END ${draftKey.type} PRIVATE KEY-----`;
setDraftKey({ ...draftKey, privateKey: mockKey });
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!draftKey.label || !draftKey.privateKey) return;
const payload: SSHKey = {
id: draftKey.id || crypto.randomUUID(),
label: draftKey.label,
type: (draftKey.type as any) || 'RSA',
privateKey: draftKey.privateKey,
publicKey: draftKey.publicKey?.trim() || undefined,
created: draftKey.created || Date.now(),
};
onSave(payload);
setIsDialogOpen(false);
setGenerateMode(false);
};
const openPanelForKey = (key: SSHKey) => {
setPanelMode('edit');
setDraftKey({ ...key });
setIsDialogOpen(true);
setGenerateMode(false);
};
const openPanelNew = (isGenerate = false) => {
setPanelMode('new');
setGenerateMode(isGenerate);
setDraftKey({
id: '',
label: '',
type: 'RSA',
privateKey: isGenerate ? 'Click generate to create a new key pair...' : '',
publicKey: '',
created: Date.now(),
});
setIsDialogOpen(true);
};
const handleDelete = (id: string) => {
onDelete(id);
if (draftKey.id === id) {
setIsDialogOpen(false);
setDraftKey({ id: '', label: '', type: 'RSA', privateKey: '', publicKey: '', created: Date.now() });
}
};
const filteredKeys = useMemo(() => {
const term = search.trim().toLowerCase();
return keys.filter(k => {
if (!term) return true;
return (
k.label.toLowerCase().includes(term) ||
(k.type || '').toString().toLowerCase().includes(term)
);
});
}, [keys, search]);
const derivedPublicKey = useMemo(() => {
if (draftKey.publicKey) return draftKey.publicKey;
if (!draftKey.label) return 'Generated By netcatty';
return `ssh-${(draftKey.type || 'ed25519').toLowerCase()} AAAAC3NzaC1lZDI1NTE5AAAA${(draftKey.label || 'nebula')
.replace(/\s+/g, '')
.slice(0, 8)} Generated By netcatty`;
}, [draftKey.label, draftKey.type, draftKey.publicKey]);
return (
<div className="px-2.5 py-2.5 lg:px-3 lg:py-3 h-full overflow-y-auto space-y-3.5 relative">
<div className="flex flex-wrap items-center gap-3 bg-secondary/60 border border-border/70 rounded-xl px-2 py-1.5 shadow-sm">
<Button
size="sm"
variant="secondary"
className="h-8 px-3 gap-2"
disabled
>
Key
<span className="text-[10px] px-2 rounded-full h-5 min-w-[22px] flex items-center justify-center bg-primary/10 text-primary border border-border/70">
{keys.length}
</span>
</Button>
<div className="ml-auto flex items-center gap-2">
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search keys..."
className="h-9 pl-8 w-44 md:w-56"
/>
</div>
<Button
size="icon"
variant={viewMode === 'grid' ? 'secondary' : 'ghost'}
className="h-9 w-9"
onClick={() => setViewMode('grid')}
>
<LayoutGrid size={16} />
</Button>
<Button
size="icon"
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
className="h-9 w-9"
onClick={() => setViewMode('list')}
>
<ListIcon size={16} />
</Button>
<Button size="sm" onClick={() => openPanelNew(false)}>
<Plus size={14} className="mr-2" /> Import
</Button>
<Button size="sm" variant="secondary" onClick={() => openPanelNew(true)}>
Generate
</Button>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between px-1">
<h2 className="text-base font-semibold text-muted-foreground">Keys</h2>
</div>
<div className="space-y-3">
{filteredKeys.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground border-2 border-dashed rounded-xl">
<Shield size={48} className="mb-3 opacity-60" />
<p className="text-sm">No keys found. Import or generate to get started.</p>
</div>
)}
<div className={viewMode === 'grid' ? "grid gap-2.5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" : "space-y-2"}>
{filteredKeys.map((key) => (
<Card
key={key.id}
className={cn(
"group relative overflow-hidden bg-secondary/60 border transition-shadow cursor-pointer",
viewMode === 'grid' ? "h-[72px] px-3 py-2" : "h-[72px] px-3 py-2 w-full",
"border-border/60 shadow-sm hover:shadow-[0_0_0_2px_var(--ring)]"
)}
onClick={() => openPanelForKey(key)}
>
<div className="flex items-center gap-3 h-full">
<div className="h-9 w-9 rounded-md bg-primary/15 text-primary flex items-center justify-center">
<Key size={16} />
</div>
<div className="min-w-0 flex-1">
<CardTitle className="text-sm font-semibold truncate">{key.label}</CardTitle>
<CardDescription className="text-[11px] font-mono text-muted-foreground truncate">
Type {key.type}
</CardDescription>
<div className="text-[10px] text-muted-foreground/80 font-mono truncate">SHA256:{key.id.substring(0, 16)}...</div>
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
openPanelForKey(key);
}}
>
<Pencil size={14} />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDelete(key.id);
}}
>
<Trash2 size={14} />
</Button>
</div>
</div>
</Card>
))}
</div>
</div>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{panelMode === 'new' ? 'New Key' : 'Edit Key'}</DialogTitle>
<DialogDescription className="sr-only">
{panelMode === 'new' ? 'Create a new SSH key entry' : 'Edit the selected SSH key entry'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label>Label</Label>
<Input
value={draftKey.label}
onChange={e => setDraftKey({ ...draftKey, label: e.target.value })}
placeholder="Key label"
required
/>
</div>
<div className="space-y-2">
<Label>Private key *</Label>
<Textarea
value={draftKey.privateKey}
onChange={e => setDraftKey({ ...draftKey, privateKey: e.target.value })}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
className="min-h-[160px] font-mono text-xs"
required
/>
{generateMode && (
<Button type="button" size="sm" variant="secondary" onClick={handleGenerate}>
Generate
</Button>
)}
</div>
<div className="space-y-2">
<Label>Public key</Label>
<Textarea
value={derivedPublicKey}
onChange={e => setDraftKey({ ...draftKey, publicKey: e.target.value })}
placeholder="ssh-ed25519 AAAAC3... user@host"
className="min-h-[90px] font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2">
Certificate <span className="text-[10px] px-2 py-0.5 rounded-full bg-muted text-muted-foreground">Optional</span>
</Label>
<Textarea
placeholder="Paste certificate..."
className="min-h-[80px] text-xs"
/>
</div>
<div className="border border-dashed border-border/80 rounded-xl p-4 text-center space-y-2 bg-background/60">
<div className="text-sm text-muted-foreground">Drag and drop a private key file to import</div>
<Button
type="button"
variant="secondary"
onClick={() => {
// mock file import
setDraftKey({
...draftKey,
label: draftKey.label || 'Imported Key',
privateKey:
draftKey.privateKey ||
'-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAC3NzaC1lZDI1NTE5AAAA\n-----END OPENSSH PRIVATE KEY-----',
});
}}
>
Import from key file
</Button>
</div>
<DialogFooter>
{panelMode === 'edit' && draftKey.id && (
<Button
type="button"
variant="ghost"
className="text-destructive mr-auto"
onClick={() => handleDelete(draftKey.id!)}
>
Delete
</Button>
)}
<Button type="button" variant="ghost" onClick={() => setIsDialogOpen(false)}>Cancel</Button>
<Button type="submit">{panelMode === 'new' ? 'Save Key' : 'Update Key'}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
};
export default KeyManager;

View File

@@ -0,0 +1,178 @@
import React, { useMemo, useState } from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Card, CardContent } from "./ui/card";
import { cn } from "../lib/utils";
import { Badge } from "./ui/badge";
import { ChevronLeft, Shield, Wifi, Cloud, Radio, ArrowRightLeft, Zap } from "lucide-react";
type RuleType = "local" | "remote" | "dynamic";
interface Rule {
id: string;
label: string;
type: RuleType;
desc: string;
}
const TYPE_COPY: Record<RuleType, { title: string; body: string }> = {
local: {
title: "Local Port Forwarding",
body: "Local forwarding lets you access a remote server's listening port as though it were local."
},
remote: {
title: "Remote Port Forwarding",
body: "Remote forwarding opens a port on the remote machine and forwards connections to your local host."
},
dynamic: {
title: "Dynamic Port Forwarding",
body: "Dynamic forwarding turns the client into a SOCKS proxy to request connections via the remote host."
}
};
const TYPE_TAG: Record<RuleType, string> = {
local: "Local Rule",
remote: "Remote Rule",
dynamic: "Dynamic Rule"
};
interface PortForwardingProps {
initialRules?: Rule[];
}
const PortForwarding: React.FC<PortForwardingProps> = ({ initialRules }) => {
const [rules, setRules] = useState<Rule[]>(() =>
initialRules ?? [
{ id: "1", label: "Local Rule", type: "local", desc: "ssh, root" },
{ id: "2", label: "Remote Rule", type: "remote", desc: "ssh, root" },
{ id: "3", label: "Dynamic Rule", type: "dynamic", desc: "ssh, root" }
]
);
const [selectedRuleId, setSelectedRuleId] = useState<string>(rules[0]?.id);
const [wizardType, setWizardType] = useState<RuleType>("local");
const selectedRule = useMemo(() => rules.find((r) => r.id === selectedRuleId), [rules, selectedRuleId]);
const addRule = (type: RuleType) => {
const newRule: Rule = {
id: crypto.randomUUID(),
type,
label: TYPE_TAG[type],
desc: "ssh, root"
};
setRules((prev) => [...prev, newRule]);
setSelectedRuleId(newRule.id);
setWizardType(type);
};
return (
<div className="flex h-full">
<div className="flex-1 px-6 py-4 space-y-3">
<div className="flex items-center gap-3">
<Button variant="secondary" className="h-9 px-3 rounded-md shadow-sm">
NEW FORWARDING
</Button>
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Badge variant="secondary" className="rounded-full px-2">Port Forwarding</Badge>
</div>
</div>
<div className="space-y-2">
<h3 className="text-base font-semibold">Port Forwarding</h3>
<div className="grid gap-3 grid-cols-1 md:grid-cols-2">
{rules.map((rule) => (
<Card
key={rule.id}
onClick={() => { setSelectedRuleId(rule.id); setWizardType(rule.type); }}
className={cn(
"cursor-pointer soft-card elevate rounded-xl border border-transparent hover:border-primary/60 transition-all",
selectedRuleId === rule.id && "border-primary/70"
)}
>
<CardContent className="p-4 flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center font-semibold">
{rule.label.slice(0, 1)}
</div>
<div className="min-w-0">
<div className="text-sm font-semibold">{rule.label}</div>
<div className="text-[11px] text-muted-foreground truncate">{rule.desc}</div>
</div>
</CardContent>
</Card>
))}
<Card
onClick={() => addRule("local")}
className="cursor-pointer soft-card elevate rounded-xl border border-dashed border-border/80 hover:border-primary/60 transition-all"
>
<CardContent className="p-4 flex items-center justify-center text-sm text-muted-foreground">
+ Add rule
</CardContent>
</Card>
</div>
</div>
</div>
<div className="w-[360px] border-l border-border/70 bg-secondary/90 p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold">
{TYPE_TAG[wizardType]}
</p>
<p className="text-xs text-muted-foreground">Personal vault</p>
</div>
<ArrowRightLeft className="text-muted-foreground" size={16} />
</div>
<div className="bg-card rounded-xl border border-border/70 p-4 space-y-4 soft-card">
<div className="flex gap-2">
{(["local", "remote", "dynamic"] as RuleType[]).map((type) => (
<Button
key={type}
variant={wizardType === type ? "secondary" : "ghost"}
size="sm"
className={cn("flex-1", wizardType === type && "bg-primary/15 text-foreground")}
onClick={() => setWizardType(type)}
>
{type[0].toUpperCase() + type.slice(1)}
</Button>
))}
</div>
<div className="flex items-center justify-center gap-6 py-6 text-muted-foreground">
<Shield size={36} />
<ArrowRightLeft />
{wizardType === "local" && <Wifi size={36} />}
{wizardType === "remote" && <Cloud size={36} />}
{wizardType === "dynamic" && <Radio size={36} />}
</div>
<div className="space-y-2">
<p className="text-xs font-semibold">{TYPE_COPY[wizardType].title}</p>
<p className="text-xs text-muted-foreground leading-relaxed">{TYPE_COPY[wizardType].body}</p>
</div>
<div className="space-y-2">
<Input placeholder="Port number" className="h-10" />
{wizardType !== "remote" && <Input placeholder="Bind address" defaultValue="127.0.0.1" className="h-10" />}
{wizardType === "remote" && <Input placeholder="Select a host" className="h-10" />}
</div>
<div className="flex flex-col gap-2 pt-2">
<Button className="w-full h-10 bg-emerald-500 hover:bg-emerald-600">
Continue
</Button>
<Button variant="ghost" className="w-full h-10 text-muted-foreground hover:text-foreground">
Skip wizard
</Button>
</div>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Zap size={14} /> This is a mock UI scaffold for port forwarding.
</div>
</div>
</div>
);
};
export default PortForwarding;

315
components/SFTPPanel.tsx Executable file
View File

@@ -0,0 +1,315 @@
import React, { useState, useEffect, useRef } from 'react';
import { RemoteFile, Host } from '../types';
import { Folder, FileText, Download, Upload, ArrowUp, RefreshCw, HardDrive, Trash2, File, Loader2, Plus } from 'lucide-react';
import { Button } from './ui/button';
import { cn } from '../lib/utils';
import { Input } from './ui/input';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
interface SFTPPanelProps {
host: Host;
credentials: {
username: string;
hostname: string;
port?: number;
password?: string;
privateKey?: string;
};
isVisible: boolean;
onClose: () => void;
}
const SFTPPanel: React.FC<SFTPPanelProps> = ({ host, credentials, isVisible, onClose }) => {
const [currentPath, setCurrentPath] = useState('/');
const [files, setFiles] = useState<RemoteFile[]>([]);
const [loading, setLoading] = useState(false);
const [dragActive, setDragActive] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const sftpIdRef = useRef<string | null>(null);
const initializedRef = useRef(false);
const ensureSftp = async () => {
if (sftpIdRef.current) return sftpIdRef.current;
if (!window.nebula?.openSftp) throw new Error("SFTP bridge unavailable");
const sftpId = await window.nebula.openSftp({
sessionId: `sftp-${host.id}`,
...credentials,
});
sftpIdRef.current = sftpId;
return sftpId;
};
const loadFiles = async (path: string) => {
try {
setError(null);
const sftpId = await ensureSftp();
setLoading(true);
const list = await window.nebula.listSftp(sftpId, path);
setFiles(list);
} catch (e) {
console.error("Failed to load files", e);
setError(e instanceof Error ? e.message : 'Failed to load directory');
setFiles([]);
} finally {
setLoading(false);
}
};
const closeSftp = async () => {
if (sftpIdRef.current && window.nebula?.closeSftp) {
try { await window.nebula.closeSftp(sftpIdRef.current); } catch {}
}
sftpIdRef.current = null;
};
useEffect(() => {
return () => {
closeSftp();
};
}, []);
const handleNavigate = (path: string) => {
setCurrentPath(path);
};
useEffect(() => {
if (isVisible) {
if (!initializedRef.current) {
initializedRef.current = true;
setCurrentPath('/');
}
loadFiles(currentPath);
} else {
closeSftp();
}
}, [isVisible, currentPath]);
const handleUp = () => {
if (currentPath === '/') return;
const parent = currentPath.split('/').slice(0, -1).join('/') || '/';
setCurrentPath(parent);
};
const handleDownload = async (file: RemoteFile) => {
try {
const sftpId = await ensureSftp();
const fullPath = currentPath === '/' ? `/${file.name}` : `${currentPath}/${file.name}`;
setLoading(true);
const content = await window.nebula.readSftp(sftpId, fullPath);
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} finally {
setLoading(false);
}
};
const handleUpload = async (file: File) => {
const sftpId = await ensureSftp();
setLoading(true);
try {
const reader = new FileReader();
reader.onload = async (e) => {
const content = e.target?.result as string;
const fullPath = currentPath === '/' ? `/${file.name}` : `${currentPath}/${file.name}`;
await window.nebula.writeSftp(sftpId, fullPath, content);
await loadFiles(currentPath);
};
reader.readAsText(file);
} finally {
setLoading(false);
}
};
const handleClose = async () => {
await closeSftp();
onClose();
};
const handleCreateFolder = async () => {
try {
const folderName = prompt("New folder name?");
if (!folderName) return;
const sftpId = await ensureSftp();
const fullPath = currentPath === '/' ? `/${folderName}` : `${currentPath}/${folderName}`;
await window.nebula.mkdirSftp(sftpId, fullPath);
await loadFiles(currentPath);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to create folder');
}
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
handleUpload(e.target.files[0]);
}
};
// Drag and Drop
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleUpload(e.dataTransfer.files[0]);
}
};
return (
<div className="h-full flex flex-col glass-panel border-l border-border/70 w-full max-w-md bg-background/95">
{/* Header */}
<div className="h-10 border-b border-border/70 flex items-center px-4 bg-gradient-to-r from-primary/5 to-transparent justify-between shrink-0">
<div className="flex items-center gap-2 text-sm font-semibold">
<HardDrive size={14} className="text-primary" /> SFTP
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => loadFiles(currentPath)}>
<RefreshCw size={12} className={cn(loading && "animate-spin")} />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-foreground" onClick={handleClose}>
<ArrowUp className="rotate-90" size={14} />
</Button>
</div>
</div>
{/* Toolbar & Breadcrumbs */}
<div className="p-3 border-b border-border space-y-3 bg-muted/10">
<div className="flex gap-2">
<Button variant="outline" size="sm" className="h-7 px-2" onClick={handleUp} disabled={currentPath === '/'}>
<ArrowUp size={14} />
</Button>
<Input
value={currentPath}
onChange={(e) => setCurrentPath(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && loadFiles(currentPath)}
className="h-7 text-xs font-mono bg-background"
/>
</div>
{error && <div className="text-[10px] text-destructive">{error}</div>}
<div className="flex gap-2">
<Button size="sm" className="h-7 text-xs w-full" onClick={() => inputRef.current?.click()}>
<Upload size={12} className="mr-2" /> Upload
</Button>
<input type="file" className="hidden" ref={inputRef} onChange={handleFileSelect} />
</div>
</div>
{/* File List */}
<div
className={cn(
"flex-1 overflow-y-auto p-2 relative flex flex-col",
dragActive && "bg-primary/5 ring-2 ring-inset ring-primary"
)}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
{dragActive && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="bg-background/90 p-4 rounded-lg shadow-lg border border-primary text-primary font-medium">
Drop to upload
</div>
</div>
)}
<ContextMenu>
<ContextMenuTrigger className="flex-1 flex flex-col">
<div className="space-y-0.5 relative flex-1 flex flex-col">
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{files.length === 0 && !loading && (
<div className="text-center text-xs text-muted-foreground py-10">
<Folder className="mx-auto mb-2 opacity-50" size={32} />
Empty Directory
</div>
)}
{!loading && files.map((file, idx) => (
<ContextMenu key={idx}>
<ContextMenuTrigger>
<div
className="flex items-center justify-between px-3 py-2 rounded-md hover:bg-muted/50 group text-xs cursor-pointer select-none transition-colors"
onClick={() => file.type === 'directory' && handleNavigate(currentPath === '/' ? `/${file.name}` : `${currentPath}/${file.name}`)}
>
<div className="flex items-center gap-3 min-w-0">
<div className={cn("shrink-0", file.type === 'directory' ? "text-blue-400" : "text-muted-foreground")}>
{file.type === 'directory' ? <Folder size={16} fill="currentColor" fillOpacity={0.2} /> : <FileText size={16} />}
</div>
<div className="truncate">
<div className="font-medium truncate">{file.name}</div>
<div className="text-[10px] text-muted-foreground opacity-70 flex gap-2">
<span>{file.size}</span>
<span></span>
<span>{file.lastModified}</span>
</div>
</div>
</div>
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity gap-1">
{file.type === 'file' && (
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDownload(file)} title="Download">
<Download size={12} />
</Button>
)}
<Button variant="ghost" size="icon" className="h-6 w-6 hover:text-destructive" title="Delete">
<Trash2 size={12} />
</Button>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
{file.type === 'directory' && (
<ContextMenuItem onClick={() => handleNavigate(currentPath === '/' ? `/${file.name}` : `${currentPath}/${file.name}`)}>
Open
</ContextMenuItem>
)}
{file.type === 'file' && (
<ContextMenuItem onClick={() => handleDownload(file)}>
Download
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
))}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={handleCreateFolder}>
<Plus className="h-4 w-4 mr-2" /> New folder
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</div>
{/* Footer Status */}
<div className="h-6 bg-muted/30 border-t border-border flex items-center px-3 text-[10px] text-muted-foreground justify-between shrink-0">
<span>{files.length} items</span>
<span>{loading ? "Syncing..." : "Ready"}</span>
</div>
</div>
);
};
export default SFTPPanel;

341
components/SettingsDialog.tsx Executable file
View File

@@ -0,0 +1,341 @@
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
import { Button } from './ui/button';
import { Label } from './ui/label';
import { Input } from './ui/input';
import { Textarea } from './ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { ScrollArea } from './ui/scroll-area';
import { Sun, Moon, Cloud, Download, Upload, Palette, Github, Loader2, Check, TerminalSquare } from 'lucide-react';
import { cn } from '../lib/utils';
import { SyncConfig } from '../types';
import { syncToGist, loadFromGist } from '../services/syncService';
import { TERMINAL_THEMES } from '../lib/terminalThemes';
interface SettingsDialogProps {
isOpen: boolean;
onClose: () => void;
onImport: (data: string) => void;
exportData: () => any;
theme: 'dark' | 'light';
onThemeChange: (theme: 'dark' | 'light') => void;
primaryColor: string;
onPrimaryColorChange: (color: string) => void;
syncConfig: SyncConfig | null;
onSyncConfigChange: (config: SyncConfig | null) => void;
terminalThemeId: string;
onTerminalThemeChange: (id: string) => void;
}
const COLORS = [
{ name: 'Blue', value: '221.2 83.2% 53.3%' },
{ name: 'Violet', value: '262.1 83.3% 57.8%' },
{ name: 'Rose', value: '346.8 77.2% 49.8%' },
{ name: 'Orange', value: '24.6 95% 53.1%' },
{ name: 'Green', value: '142.1 76.2% 36.3%' },
];
const SettingsDialog: React.FC<SettingsDialogProps> = ({
isOpen, onClose, onImport, exportData, theme, onThemeChange,
primaryColor, onPrimaryColorChange, syncConfig, onSyncConfigChange,
terminalThemeId, onTerminalThemeChange
}) => {
const [importText, setImportText] = useState('');
// Sync State
const [githubToken, setGithubToken] = useState(syncConfig?.githubToken || '');
const [gistId, setGistId] = useState(syncConfig?.gistId || '');
const [isSyncing, setIsSyncing] = useState(false);
const [syncStatus, setSyncStatus] = useState<'idle' | 'success' | 'error'>('idle');
const handleManualExport = () => {
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(exportData(), null, 2));
const downloadAnchorNode = document.createElement('a');
downloadAnchorNode.setAttribute("href", dataStr);
downloadAnchorNode.setAttribute("download", "nebula_backup.json");
document.body.appendChild(downloadAnchorNode);
downloadAnchorNode.click();
downloadAnchorNode.remove();
};
const handleManualImport = () => {
try {
JSON.parse(importText);
onImport(importText);
alert('Configuration imported successfully!');
setImportText('');
} catch(e) {
alert('Invalid JSON format.');
}
};
const handleSaveSyncConfig = async () => {
if (!githubToken) return;
setIsSyncing(true);
setSyncStatus('idle');
try {
if (gistId) {
await loadFromGist(githubToken, gistId);
}
onSyncConfigChange({ githubToken, gistId });
setSyncStatus('success');
} catch (e) {
console.error(e);
setSyncStatus('error');
alert("Failed to verify Gist or Token.");
} finally {
setIsSyncing(false);
}
};
const performSyncUpload = async () => {
if (!githubToken) return;
setIsSyncing(true);
try {
const data = exportData();
const newGistId = await syncToGist(githubToken, gistId || undefined, data);
if (!gistId) {
setGistId(newGistId);
onSyncConfigChange({ githubToken, gistId: newGistId, lastSync: Date.now() });
} else {
onSyncConfigChange({ ...syncConfig!, lastSync: Date.now() });
}
alert("Backup uploaded to Gist successfully!");
} catch(e) {
alert("Upload failed: " + e);
} finally {
setIsSyncing(false);
}
};
const performSyncDownload = async () => {
if (!githubToken || !gistId) return;
setIsSyncing(true);
try {
const data = await loadFromGist(githubToken, gistId);
onImport(JSON.stringify(data));
onSyncConfigChange({ ...syncConfig!, lastSync: Date.now() });
alert("Configuration restored from Gist!");
} catch (e) {
alert("Download failed: " + e);
} finally {
setIsSyncing(false);
}
};
const getHslStyle = (hsl: string) => ({ backgroundColor: `hsl(${hsl})` });
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-4xl p-0 h-[600px] gap-0 overflow-hidden flex flex-row">
<DialogHeader className="sr-only">
<DialogTitle>Settings</DialogTitle>
<DialogDescription>Configure appearance, terminal theme, sync and data options.</DialogDescription>
</DialogHeader>
<Tabs defaultValue="appearance" orientation="vertical" className="flex-1 flex h-full">
{/* Sidebar using TabsList */}
<div className="w-64 border-r bg-muted/20 p-4 flex flex-col gap-2 shrink-0 h-full">
<h2 className="text-lg font-bold px-2 mb-2">Settings</h2>
<TabsList className="flex flex-col h-auto bg-transparent gap-1 p-0 justify-start">
<TabsTrigger value="appearance" className="w-full justify-start gap-3 px-3 py-2 data-[state=active]:bg-background">
<Palette size={16} /> Appearance
</TabsTrigger>
<TabsTrigger value="terminal" className="w-full justify-start gap-3 px-3 py-2 data-[state=active]:bg-background">
<TerminalSquare size={16} /> Terminal
</TabsTrigger>
<TabsTrigger value="sync" className="w-full justify-start gap-3 px-3 py-2 data-[state=active]:bg-background">
<Cloud size={16} /> Sync & Cloud
</TabsTrigger>
<TabsTrigger value="data" className="w-full justify-start gap-3 px-3 py-2 data-[state=active]:bg-background">
<Download size={16} /> Data Management
</TabsTrigger>
</TabsList>
</div>
{/* Content Area */}
<ScrollArea className="flex-1 h-full">
<div className="p-8">
<TabsContent value="appearance" className="space-y-8 mt-0 border-0">
<section>
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider mb-4">UI Theme</h3>
<div className="grid grid-cols-2 gap-4 max-w-sm">
<ThemeCard
active={theme === 'light'}
onClick={() => onThemeChange('light')}
icon={<Sun size={24} className="text-orange-500" />}
label="Light"
/>
<ThemeCard
active={theme === 'dark'}
onClick={() => onThemeChange('dark')}
icon={<Moon size={24} className="text-blue-400" />}
label="Dark"
/>
</div>
</section>
<div className="h-px bg-border" />
<section>
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider mb-4">Accent Color</h3>
<div className="flex flex-wrap gap-4">
{COLORS.map(c => (
<button
key={c.name}
onClick={() => onPrimaryColorChange(c.value)}
className={cn(
"w-12 h-12 rounded-full flex items-center justify-center transition-all shadow-sm",
primaryColor === c.value ? "ring-2 ring-offset-2 ring-foreground scale-110" : "hover:scale-105"
)}
style={getHslStyle(c.value)}
title={c.name}
>
{primaryColor === c.value && <Check className="text-white drop-shadow-md" size={18} />}
</button>
))}
</div>
</section>
</TabsContent>
<TabsContent value="terminal" className="space-y-6 mt-0 border-0">
<div>
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider mb-4">Terminal Themes</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{TERMINAL_THEMES.map(t => (
<div
key={t.id}
onClick={() => onTerminalThemeChange(t.id)}
className={cn(
"cursor-pointer border-2 rounded-lg p-3 flex items-center gap-4 transition-all hover:bg-muted/10",
terminalThemeId === t.id ? "border-primary bg-primary/5" : "border-border hover:border-primary/50"
)}
>
{/* Mini Terminal Preview */}
<div
className="w-24 h-16 rounded border flex flex-col p-1.5 gap-1 shrink-0 shadow-sm"
style={{ backgroundColor: t.colors.background, borderColor: t.colors.selection }}
>
<div className="w-12 h-1.5 rounded-full" style={{ backgroundColor: t.colors.foreground, opacity: 0.3 }} />
<div className="flex gap-1">
<div className="w-6 h-1.5 rounded-full" style={{ backgroundColor: t.colors.blue }} />
<div className="w-8 h-1.5 rounded-full" style={{ backgroundColor: t.colors.green }} />
</div>
<div className="w-3 h-3 mt-auto rounded-sm" style={{ backgroundColor: t.colors.cursor }} />
</div>
<div>
<div className="font-semibold text-sm">{t.name}</div>
<div className="flex gap-1 mt-1.5">
{[t.colors.black, t.colors.red, t.colors.green, t.colors.blue].map((c, i) => (
<div key={i} className="w-2 h-2 rounded-full" style={{ backgroundColor: c }} />
))}
</div>
</div>
</div>
))}
</div>
</div>
</TabsContent>
<TabsContent value="sync" className="space-y-6 max-w-lg mt-0 border-0">
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4 text-sm text-blue-500 flex gap-3">
<Github className="shrink-0 mt-0.5" size={18} />
<div>
<h4 className="font-semibold mb-1">GitHub Gist Sync</h4>
<p className="opacity-90">Backup and sync your hosts, keys, and snippets across devices securely using a private GitHub Gist.</p>
</div>
</div>
<div className="space-y-4">
<div className="grid gap-2">
<Label>GitHub Personal Access Token</Label>
<Input
type="password"
placeholder="ghp_xxxxxxxxxxxx"
value={githubToken}
onChange={e => setGithubToken(e.target.value)}
className="font-mono"
/>
<p className="text-[10px] text-muted-foreground">Token needs <code>gist</code> scope.</p>
</div>
<div className="grid gap-2">
<Label>Gist ID (Optional)</Label>
<Input
placeholder="Leave empty to create new"
value={gistId}
onChange={e => setGistId(e.target.value)}
className="font-mono"
/>
</div>
<div className="flex justify-end pt-2">
<Button onClick={handleSaveSyncConfig} disabled={isSyncing} className="w-full sm:w-auto">
{isSyncing && <Loader2 className="animate-spin mr-2 h-4 w-4" />}
{syncStatus === 'success' ? 'Verified & Saved' : 'Verify Connection'}
</Button>
</div>
</div>
{syncConfig?.githubToken && (
<>
<div className="h-px bg-border" />
<div className="grid grid-cols-2 gap-4">
<Button variant="outline" className="h-auto py-4 flex flex-col gap-2" onClick={performSyncUpload} disabled={isSyncing}>
<Upload size={20} />
<span>Upload Backup</span>
</Button>
<Button variant="outline" className="h-auto py-4 flex flex-col gap-2" onClick={performSyncDownload} disabled={isSyncing}>
<Download size={20} />
<span>Restore Backup</span>
</Button>
</div>
{syncConfig.lastSync && (
<p className="text-xs text-center text-muted-foreground">
Last Sync: {new Date(syncConfig.lastSync).toLocaleString()}
</p>
)}
</>
)}
</TabsContent>
<TabsContent value="data" className="space-y-6 max-w-lg mt-0 border-0">
<div className="p-5 border rounded-lg bg-card hover:bg-muted/20 transition-colors">
<h4 className="font-medium mb-2 flex items-center gap-2"><Download size={16} /> Export Data</h4>
<p className="text-sm text-muted-foreground mb-4">Download a JSON file containing all your hosts, keys, and snippets.</p>
<Button size="sm" onClick={handleManualExport} variant="outline">Download JSON</Button>
</div>
<div className="p-5 border rounded-lg bg-card hover:bg-muted/20 transition-colors">
<h4 className="font-medium mb-2 flex items-center gap-2"><Upload size={16} /> Import Data</h4>
<p className="text-sm text-muted-foreground mb-4">Restore your configuration from a previously exported JSON file.</p>
<Textarea
placeholder="Paste JSON content here..."
className="h-24 font-mono text-xs mb-3 resize-none bg-muted/50"
value={importText}
onChange={e => setImportText(e.target.value)}
/>
<Button size="sm" onClick={handleManualImport} disabled={!importText}>Import JSON</Button>
</div>
</TabsContent>
</div>
</ScrollArea>
</Tabs>
</DialogContent>
</Dialog>
);
};
const ThemeCard = ({ active, onClick, icon, label }: any) => (
<div
onClick={onClick}
className={cn(
"cursor-pointer rounded-xl border-2 p-6 flex flex-col items-center gap-4 transition-all duration-200 bg-card",
active ? "border-primary bg-primary/5 ring-1 ring-primary/20" : "border-muted hover:border-primary/50"
)}
>
<div className={cn("p-3 rounded-full bg-background", active && "shadow-sm")}>{icon}</div>
<span className="text-sm font-semibold">{label}</span>
</div>
);
export default SettingsDialog;

147
components/SnippetsManager.tsx Executable file
View File

@@ -0,0 +1,147 @@
import React, { useState } from 'react';
import { Snippet } from '../types';
import { FileCode, Plus, Trash2, Edit2, Play, Copy, Check } from 'lucide-react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Textarea } from './ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import { Dialog, DialogHeader, DialogTitle, DialogFooter } from './ui/dialog';
import { Label } from './ui/label';
import { Badge } from './ui/badge';
interface SnippetsManagerProps {
snippets: Snippet[];
onSave: (snippet: Snippet) => void;
onDelete: (id: string) => void;
}
const SnippetsManager: React.FC<SnippetsManagerProps> = ({ snippets, onSave, onDelete }) => {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingSnippet, setEditingSnippet] = useState<Partial<Snippet>>({
label: '',
command: '',
});
const [copiedId, setCopiedId] = useState<string | null>(null);
const handleEdit = (snippet?: Snippet) => {
if (snippet) {
setEditingSnippet(snippet);
} else {
setEditingSnippet({ label: '', command: '' });
}
setIsDialogOpen(true);
};
const handleSubmit = () => {
if (editingSnippet.label && editingSnippet.command) {
onSave({
id: editingSnippet.id || crypto.randomUUID(),
label: editingSnippet.label,
command: editingSnippet.command,
tags: editingSnippet.tags || []
});
setIsDialogOpen(false);
}
};
const handleCopy = (id: string, text: string) => {
navigator.clipboard.writeText(text);
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
};
return (
<div className="p-6 h-full overflow-y-auto">
<div className="flex items-center justify-between mb-8">
<div>
<h2 className="text-2xl font-bold tracking-tight mb-1">Snippets Library</h2>
<p className="text-muted-foreground">Save commonly used commands and scripts.</p>
</div>
<Button onClick={() => handleEdit()}>
<Plus size={16} className="mr-2" /> New Snippet
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{snippets.length === 0 && (
<div className="col-span-full flex flex-col items-center justify-center p-12 text-muted-foreground border-2 border-dashed rounded-lg">
<FileCode size={48} className="mb-4 opacity-50" />
<p>No snippets found. Create one to automate tasks.</p>
</div>
)}
{snippets.map((snippet) => (
<Card key={snippet.id} className="group relative hover:border-primary/50 transition-colors">
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded bg-accent flex items-center justify-center text-accent-foreground">
<FileCode size={16} />
</div>
<CardTitle className="text-sm font-medium">{snippet.label}</CardTitle>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleEdit(snippet)}>
<Edit2 size={14} />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => onDelete(snippet.id)}>
<Trash2 size={14} />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="relative bg-muted rounded-md p-3 font-mono text-xs text-muted-foreground h-24 overflow-hidden mb-2 group-hover:text-foreground transition-colors">
<pre className="whitespace-pre-wrap break-all line-clamp-4">{snippet.command}</pre>
<div className="absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-muted to-transparent" />
<Button
variant="secondary"
size="icon"
className="absolute bottom-1 right-1 h-6 w-6 shadow-sm"
onClick={() => handleCopy(snippet.id, snippet.command)}
>
{copiedId === snippet.id ? <Check size={12} className="text-green-500" /> : <Copy size={12} />}
</Button>
</div>
</CardContent>
</Card>
))}
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogHeader>
<DialogTitle>{editingSnippet.id ? 'Edit Snippet' : 'New Snippet'}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Label</Label>
<Input
placeholder="e.g. Update System, Check Disk Usage"
value={editingSnippet.label}
onChange={e => setEditingSnippet({...editingSnippet, label: e.target.value})}
/>
</div>
<div className="grid gap-2">
<Label>Command / Script</Label>
<Textarea
placeholder="#!/bin/bash..."
className="h-48 font-mono text-xs"
value={editingSnippet.command}
onChange={e => setEditingSnippet({...editingSnippet, command: e.target.value})}
/>
<p className="text-[10px] text-muted-foreground">Multi-line commands are supported.</p>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSubmit}>Save Snippet</Button>
</DialogFooter>
</Dialog>
</div>
);
};
export default SnippetsManager;

583
components/Terminal.tsx Normal file
View File

@@ -0,0 +1,583 @@
import React, { useEffect, useRef, useState } from 'react';
import { Ghostty, Terminal as GhosttyTerminal, FitAddon } from 'ghostty-web';
import { Host, SSHKey, Snippet, TerminalSession, TerminalTheme } from '../types';
import { Zap, FolderInput, Activity, Loader2, AlertCircle, ShieldCheck, Clock, Play } from 'lucide-react';
import { Button } from './ui/button';
import { cn } from '../lib/utils';
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
import { ScrollArea } from './ui/scroll-area';
import SFTPPanel from './SFTPPanel';
interface TerminalProps {
host: Host;
keys: SSHKey[];
snippets: Snippet[];
isVisible: boolean;
fontSize: number;
terminalTheme: TerminalTheme;
sessionId: string;
onStatusChange?: (status: TerminalSession['status']) => void;
onSessionExit?: (sessionId: string) => void;
onOsDetected?: (hostId: string, distro: string) => void;
}
let ghosttyPromise: Promise<Ghostty> | null = null;
const ensureGhostty = () => {
if (!ghosttyPromise) {
ghosttyPromise = Ghostty.load('ghostty-vt.wasm');
}
return ghosttyPromise;
};
const TerminalComponent: React.FC<TerminalProps> = ({
host,
keys,
snippets,
isVisible,
fontSize,
terminalTheme,
sessionId,
onStatusChange,
onSessionExit,
onOsDetected,
}) => {
const CONNECTION_TIMEOUT = 12000;
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<GhosttyTerminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const disposeDataRef = useRef<(() => void) | null>(null);
const disposeExitRef = useRef<(() => void) | null>(null);
const sessionRef = useRef<string | null>(null);
const hasConnectedRef = useRef(false);
const [isScriptsOpen, setIsScriptsOpen] = useState(false);
const [status, setStatus] = useState<TerminalSession['status']>('connecting');
const [error, setError] = useState<string | null>(null);
const [showLogs, setShowLogs] = useState(false);
const [progressLogs, setProgressLogs] = useState<string[]>([]);
const [timeLeft, setTimeLeft] = useState(CONNECTION_TIMEOUT / 1000);
const [isCancelling, setIsCancelling] = useState(false);
const [showSFTP, setShowSFTP] = useState(false);
const [progressValue, setProgressValue] = useState(15);
const updateStatus = (next: TerminalSession['status']) => {
setStatus(next);
hasConnectedRef.current = next === 'connected';
onStatusChange?.(next);
};
const cleanupSession = () => {
disposeDataRef.current?.();
disposeDataRef.current = null;
disposeExitRef.current?.();
disposeExitRef.current = null;
if (sessionRef.current && window.nebula?.closeSession) {
try {
window.nebula.closeSession(sessionRef.current);
} catch (err) {
console.warn("Failed to close SSH session", err);
}
}
sessionRef.current = null;
};
const teardown = () => {
cleanupSession();
termRef.current?.dispose();
termRef.current = null;
fitAddonRef.current?.dispose();
fitAddonRef.current = null;
};
const runDistroDetection = async (key?: SSHKey) => {
if (!window.nebula?.execCommand) return;
try {
const res = await window.nebula.execCommand({
hostname: host.hostname,
username: host.username || 'root',
port: host.port || 22,
password: host.authMethod !== 'key' ? host.password : undefined,
privateKey: key?.privateKey,
command: 'cat /etc/os-release 2>/dev/null || uname -a',
timeout: 8000,
});
const data = `${res.stdout || ''}\n${res.stderr || ''}`;
const idMatch = data.match(/ID=([\\w\\-]+)/i);
const distro = idMatch ? idMatch[1].replace(/"/g, '') : (data.split(/\\s+/)[0] || '').toLowerCase();
if (distro) onOsDetected?.(host.id, distro);
} catch (err) {
console.warn("OS probe failed", err);
}
};
useEffect(() => {
let disposed = false;
setStatus('connecting');
setError(null);
hasConnectedRef.current = false;
setProgressLogs(['Initializing secure channel...']);
setShowLogs(false);
setIsCancelling(false);
const boot = async () => {
try {
const ghostty = await ensureGhostty();
if (disposed || !containerRef.current) return;
const term = new GhosttyTerminal({
cursorBlink: true,
fontSize,
fontFamily: '"JetBrains Mono", monospace',
theme: {
...terminalTheme.colors,
selectionBackground: terminalTheme.colors.selection,
},
ghostty,
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
termRef.current = term;
fitAddonRef.current = fitAddon;
term.open(containerRef.current);
fitAddon.fit();
term.focus();
term.onData((data) => {
const id = sessionRef.current;
if (id && window.nebula?.writeToSession) {
window.nebula.writeToSession(id, data);
}
});
term.onResize(({ cols, rows }) => {
const id = sessionRef.current;
if (id && window.nebula?.resizeSession) {
window.nebula.resizeSession(id, cols, rows);
}
});
await startSSH(term);
} catch (err) {
console.error("Failed to initialize terminal", err);
setError(err instanceof Error ? err.message : String(err));
updateStatus('disconnected');
}
};
boot();
return () => {
disposed = true;
teardown();
};
}, [host.id, sessionId]);
// Connection timeline and timeout visuals
useEffect(() => {
if (status !== 'connecting') return;
const scripted = [
'Resolving host and keys...',
'Negotiating ciphers...',
'Exchanging keys...',
'Authenticating user...',
'Waiting for server greeting...',
];
let idx = 0;
const stepTimer = setInterval(() => {
setProgressLogs((prev) => {
if (idx >= scripted.length) return prev;
const next = scripted[idx++];
return prev.includes(next) ? prev : [...prev, next];
});
}, 900);
setTimeLeft(CONNECTION_TIMEOUT / 1000);
const countdown = setInterval(() => {
setTimeLeft((prev) => (prev > 0 ? prev - 1 : 0));
}, 1000);
const timeout = setTimeout(() => {
setError('Connection timed out. Please try again.');
updateStatus('disconnected');
setProgressLogs((prev) => [...prev, 'Connection timed out.']);
}, CONNECTION_TIMEOUT);
setProgressValue(15);
const prog = setInterval(() => {
setProgressValue((prev) => {
if (prev >= 92) return 35;
return prev + Math.random() * 8 + 4;
});
}, 450);
return () => {
clearInterval(stepTimer);
clearInterval(countdown);
clearTimeout(timeout);
clearInterval(prog);
};
}, [status]);
const safeFit = () => {
if (!fitAddonRef.current) return;
try {
fitAddonRef.current.fit();
} catch (err) {
console.warn("Fit failed", err);
}
};
useEffect(() => {
if (termRef.current) {
termRef.current.options.fontSize = fontSize;
termRef.current.options.theme = {
...terminalTheme.colors,
selectionBackground: terminalTheme.colors.selection,
};
}
if (isVisible) {
safeFit();
}
}, [fontSize, terminalTheme, isVisible]);
useEffect(() => {
if (!containerRef.current || !fitAddonRef.current) return;
const observer = new ResizeObserver(() => {
safeFit();
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, [isVisible]);
useEffect(() => {
const handler = () => {
// Defer slightly to allow layout to settle after window resize.
requestAnimationFrame(() => safeFit());
};
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
const startSSH = async (term: GhosttyTerminal) => {
try {
term.clear?.();
} catch (err) {
console.warn("Failed to clear terminal before connect", err);
}
if (!window.nebula?.startSSHSession) {
setError("Native SSH bridge unavailable. Launch via Electron app.");
term.writeln(
"\r\n[netcatty SSH bridge unavailable. Please run the desktop build to connect.]"
);
updateStatus('disconnected');
return;
}
const key = host.identityFileId
? keys.find((k) => k.id === host.identityFileId)
: undefined;
try {
const id = await window.nebula.startSSHSession({
sessionId,
hostname: host.hostname,
username: host.username || 'root',
port: host.port || 22,
password: host.authMethod !== 'key' ? host.password : undefined,
privateKey: key?.privateKey,
keyId: key?.id,
agentForwarding: host.agentForwarding,
cols: term.cols,
rows: term.rows,
charset: host.charset,
});
sessionRef.current = id;
disposeDataRef.current = window.nebula.onSessionData(id, (chunk) => {
term.write(chunk);
if (!hasConnectedRef.current) updateStatus('connected');
});
disposeExitRef.current = window.nebula.onSessionExit(id, (evt) => {
updateStatus('disconnected');
term.writeln(
`\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`
);
onSessionExit?.(sessionId);
});
if (host.startupCommand) {
setTimeout(() => {
if (sessionRef.current) {
window.nebula?.writeToSession(
sessionRef.current,
`${host.startupCommand}\r`
);
}
}, 600);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
term.writeln(`\r\n[Failed to start SSH: ${message}]`);
updateStatus('disconnected');
}
// Trigger distro detection once connected (hidden exec, no terminal output)
setTimeout(() => runDistroDetection(key), 600);
};
const handleSnippetClick = (cmd: string) => {
if (sessionRef.current && window.nebula?.writeToSession) {
window.nebula.writeToSession(sessionRef.current, `${cmd}\r`);
setIsScriptsOpen(false);
termRef.current?.focus();
return;
}
termRef.current?.writeln("\r\n[No active SSH session]");
};
const handleCancelConnect = () => {
setIsCancelling(true);
setError('Connection cancelled');
setProgressLogs((prev) => [...prev, 'Cancelled by user.']);
cleanupSession();
updateStatus('disconnected');
setTimeout(() => setIsCancelling(false), 600);
};
const handleRetry = () => {
if (!termRef.current) return;
cleanupSession();
setStatus('connecting');
setError(null);
setProgressLogs(['Retrying secure channel...']);
setShowLogs(true);
startSSH(termRef.current);
};
return (
<div className="relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220] border-l border-border/70">
<div className="absolute top-4 right-6 z-10 flex gap-2">
<Button
variant="secondary"
size="sm"
className="h-8 px-3 text-xs backdrop-blur-md border border-white/10 shadow-lg"
disabled={status !== 'connected'}
title={status === 'connected' ? "Open SFTP" : "Available after connect"}
onClick={() => setShowSFTP((v) => !v)}
>
<FolderInput size={12} className="mr-2" /> SFTP
</Button>
<Popover open={isScriptsOpen} onOpenChange={setIsScriptsOpen}>
<PopoverTrigger asChild>
<Button
variant="secondary"
size="sm"
className="h-8 px-3 text-xs bg-muted/20 hover:bg-muted/80 backdrop-blur-md border border-white/10 text-white shadow-lg"
>
<Zap size={12} className="mr-2" /> Scripts
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="end">
<div className="px-3 py-2 text-[10px] uppercase text-muted-foreground font-semibold bg-muted/30 border-b">
Library
</div>
<ScrollArea className="h-64">
<div className="py-1">
{snippets.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground italic">
No snippets available
</div>
) : (
snippets.map((s) => (
<button
key={s.id}
onClick={() => handleSnippetClick(s.command)}
className="w-full text-left px-3 py-2 text-xs hover:bg-accent transition-colors flex flex-col gap-0.5"
>
<span className="font-medium">{s.label}</span>
<span className="text-muted-foreground truncate font-mono text-[10px]">
{s.command}
</span>
</button>
))
)}
</div>
</ScrollArea>
</PopoverContent>
</Popover>
<div
className={cn(
"px-3 h-8 rounded-md border border-white/10 text-[11px] flex items-center gap-2 shadow",
status === 'connected'
? "bg-emerald-500/20 text-emerald-100"
: status === 'connecting'
? "bg-amber-500/20 text-amber-100"
: "bg-muted/40 text-muted-foreground"
)}
>
<Activity size={12} />
<span className="capitalize">{status}</span>
</div>
</div>
<div
className="h-full flex-1 p-2 min-w-0 transition-all duration-300 relative"
style={{ backgroundColor: terminalTheme.colors.background }}
>
<div ref={containerRef} className="h-full w-full" />
{error && (
<div className="absolute bottom-3 left-3 text-xs text-destructive bg-background/80 border border-destructive/40 rounded px-3 py-2 shadow-lg">
{error}
</div>
)}
{status !== 'connected' && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/30">
<div className="w-[560px] max-w-[90vw] bg-background/95 border border-border/60 rounded-2xl shadow-xl p-6 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-primary/20 text-primary flex items-center justify-center text-sm font-semibold">
{host.label.slice(0, 1).toUpperCase()}
</div>
<div>
<div className="text-sm font-semibold">{host.label}</div>
<div className="text-[11px] text-muted-foreground font-mono">
{host.username}@{host.hostname}:{host.port || 22}
</div>
</div>
</div>
<Button
size="sm"
variant="outline"
className="h-8 text-xs"
onClick={() => setShowLogs((v) => !v)}
>
{showLogs ? 'Hide logs' : 'Show logs'}
</Button>
</div>
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-primary/15 text-primary flex items-center justify-center shadow-inner">
{status === 'connecting' ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<AlertCircle className="h-4 w-4" />
)}
</div>
<div className="flex-1 h-1.5 rounded-full bg-border/60 overflow-hidden relative">
<div
className={cn(
"absolute inset-y-0 left-0 bg-gradient-to-r from-primary via-primary/60 to-primary/80 transition-all duration-400 ease-out"
)}
style={{ width: status === 'connecting' ? `${progressValue}%` : '100%' }}
/>
<div
className="absolute inset-y-0 left-0 w-full opacity-20 bg-[radial-gradient(circle,_rgba(255,255,255,0.6)_0%,_rgba(255,255,255,0)_60%)] animate-[shimmer_1.6s_linear_infinite]"
style={{
backgroundSize: '120px 120px',
maskImage: 'linear-gradient(90deg, transparent 0%, black 15%, black 85%, transparent 100%)'
}}
/>
</div>
<div className="h-8 w-8 rounded-full border border-border/70 flex items-center justify-center">
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
</div>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<Clock className="h-3 w-3" />
<span>
{status === 'connecting'
? `Timeout in ${timeLeft}s`
: error || 'Disconnected'}
</span>
</div>
<div className="flex items-center gap-2">
{status === 'connecting' ? (
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={handleCancelConnect}
disabled={isCancelling}
>
{isCancelling ? 'Cancelling...' : 'Cancel'}
</Button>
) : (
<div className="flex gap-2">
<Button variant="ghost" size="sm" className="h-8" onClick={handleCancelConnect}>
Close
</Button>
<Button size="sm" className="h-8" onClick={handleRetry}>
<Play className="h-3 w-3 mr-2" /> Start over
</Button>
</div>
)}
</div>
</div>
{showLogs && (
<div className="rounded-xl border border-border/60 bg-background/70 shadow-inner">
<ScrollArea className="max-h-52 p-3">
<div className="space-y-2 text-sm text-foreground/90">
{progressLogs.map((line, idx) => (
<div key={idx} className="flex items-start gap-2">
<div className="mt-0.5">
<ShieldCheck className="h-3.5 w-3.5 text-primary" />
</div>
<div>{line}</div>
</div>
))}
{error && (
<div className="flex items-start gap-2 text-destructive">
<AlertCircle className="h-3.5 w-3.5 mt-0.5" />
<div>{error}</div>
</div>
)}
</div>
</ScrollArea>
</div>
)}
</div>
</div>
)}
<div
className={cn(
"absolute inset-y-0 right-0 w-[360px] z-30 border-l border-border/60 bg-background/95 shadow-2xl transform transition-transform duration-200 ease-out",
showSFTP && status === 'connected' ? "translate-x-0 opacity-100" : "translate-x-full opacity-0 pointer-events-none"
)}
>
<SFTPPanel
host={host}
credentials={{
username: host.username,
hostname: host.hostname,
port: host.port,
password: host.authMethod !== 'key' ? host.password : undefined,
privateKey: host.authMethod === 'key'
? keys.find(k => k.id === host.identityFileId)?.privateKey
: undefined,
}}
isVisible={showSFTP && status === 'connected'}
onClose={() => setShowSFTP(false)}
/>
</div>
</div>
</div>
);
};
export default TerminalComponent;

28
components/ui/badge.tsx Executable file
View File

@@ -0,0 +1,28 @@
import * as React from "react"
import { cn } from "../../lib/utils"
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode
className?: string
variant?: "default" | "secondary" | "destructive" | "outline"
}
function Badge({ className, variant = "default", ...props }: BadgeProps) {
return (
<div
className={cn(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80": variant === "default",
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80": variant === "secondary",
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80": variant === "destructive",
"text-foreground": variant === "outline",
},
className
)}
{...props}
/>
)
}
export { Badge }

38
components/ui/button.tsx Executable file
View File

@@ -0,0 +1,38 @@
import * as React from "react"
import { cn } from "../../lib/utils"
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
size?: "default" | "sm" | "lg" | "icon"
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "default", size = "default", ...props }, ref) => {
return (
<button
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
"bg-primary text-primary-foreground hover:bg-primary/90": variant === "default",
"bg-destructive text-destructive-foreground hover:bg-destructive/90": variant === "destructive",
"border border-input bg-background hover:bg-accent hover:text-accent-foreground": variant === "outline",
"bg-secondary text-secondary-foreground hover:bg-secondary/80": variant === "secondary",
"hover:bg-accent hover:text-accent-foreground": variant === "ghost",
"text-primary underline-offset-4 hover:underline": variant === "link",
"h-10 px-4 py-2": size === "default",
"h-9 rounded-md px-3": size === "sm",
"h-11 rounded-md px-8": size === "lg",
"h-10 w-10": size === "icon",
},
className
)}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button }

78
components/ui/card.tsx Executable file
View File

@@ -0,0 +1,78 @@
import * as React from "react"
import { cn } from "../../lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

10
components/ui/collapsible.tsx Executable file
View File

@@ -0,0 +1,10 @@
import * as React from "react"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

198
components/ui/context-menu.tsx Executable file
View File

@@ -0,0 +1,198 @@
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "../../lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-[100000] min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-[9999] min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

120
components/ui/dialog.tsx Executable file
View File

@@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "../../lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

24
components/ui/input.tsx Executable file
View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "../../lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

19
components/ui/label.tsx Executable file
View File

@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "../../lib/utils"
const Label = React.forwardRef<
HTMLLabelElement,
React.LabelHTMLAttributes<HTMLLabelElement>
>(({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...props}
/>
))
Label.displayName = "Label"
export { Label }

29
components/ui/popover.tsx Executable file
View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "../../lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

46
components/ui/scroll-area.tsx Executable file
View File

@@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "../../lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

158
components/ui/select.tsx Executable file
View File

@@ -0,0 +1,158 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "../../lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

53
components/ui/tabs.tsx Executable file
View File

@@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "../../lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

23
components/ui/textarea.tsx Executable file
View File

@@ -0,0 +1,23 @@
import * as React from "react"
import { cn } from "../../lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

12
electron/launch.cjs Normal file
View File

@@ -0,0 +1,12 @@
const { spawn } = require("node:child_process");
const electronPath = require("electron"); // returns binary path
const env = { ...process.env };
delete env.ELECTRON_RUN_AS_NODE;
const child = spawn(electronPath, ["."], {
stdio: "inherit",
env,
});
child.on("exit", (code) => process.exit(code ?? 0));

359
electron/main.cjs Normal file
View File

@@ -0,0 +1,359 @@
// Make sure the helper processes do not get forced into Node-only mode.
// Presence of ELECTRON_RUN_AS_NODE (even "0") makes helpers parse Chromium
// switches as Node flags, leading to "bad option: --type=renderer".
if (process.env.ELECTRON_RUN_AS_NODE) {
delete process.env.ELECTRON_RUN_AS_NODE;
}
let electronModule;
try {
electronModule = require("node:electron");
} catch {
electronModule = require("electron");
}
console.log("electron module raw:", electronModule);
console.log("process.versions:", process.versions);
console.log("env ELECTRON_RUN_AS_NODE:", process.env.ELECTRON_RUN_AS_NODE);
const { app, BrowserWindow } = electronModule || {};
if (!app || !BrowserWindow) {
throw new Error("Failed to load Electron runtime. Ensure the app is launched with the Electron binary.");
}
const path = require("node:path");
const os = require("node:os");
const fs = require("node:fs");
const pty = require("node-pty");
const SftpClient = require("ssh2-sftp-client");
const { Client: SSHClient } = require("ssh2");
// GPU: keep hardware acceleration enabled for smoother rendering
// (If you hit GPU issues, you can restore these switches.)
// app.commandLine.appendSwitch("disable-gpu");
// app.commandLine.appendSwitch("disable-software-rasterizer");
app.commandLine.appendSwitch("no-sandbox");
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
const isDev = !!devServerUrl;
const preload = path.join(__dirname, "preload.cjs");
const isMac = process.platform === "darwin";
const appIcon = path.join(__dirname, "../public/icon.png");
const sessions = new Map();
const sftpClients = new Map();
const keyRoot = path.join(os.homedir(), ".nebula-ssh", "keys");
const ensureKeyDir = () => {
try {
fs.mkdirSync(keyRoot, { recursive: true, mode: 0o700 });
} catch (err) {
console.warn("Unable to ensure key cache dir", err);
}
};
const writeKeyToDisk = (keyId, privateKey) => {
if (!privateKey) return null;
ensureKeyDir();
const filename = `${keyId || "temp"}.pem`;
const target = path.join(keyRoot, filename);
const normalized = privateKey.endsWith("\n") ? privateKey : `${privateKey}\n`;
try {
fs.writeFileSync(target, normalized, { mode: 0o600 });
return target;
} catch (err) {
console.error("Failed to persist private key", err);
return null;
}
};
const registerSSHBridge = (win) => {
if (registerSSHBridge._registered) return;
registerSSHBridge._registered = true;
const start = (event, options) => {
const sessionId =
options.sessionId ||
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
const sshArgs = [];
if (options.port) sshArgs.push("-p", String(options.port));
sshArgs.push("-o", "StrictHostKeyChecking=accept-new");
sshArgs.push(
"-o",
`UserKnownHostsFile=${path.join(os.homedir(), ".ssh", "known_hosts")}`
);
if (options.agentForwarding) sshArgs.push("-A");
const keyPath = options.privateKey
? writeKeyToDisk(options.keyId || sessionId, options.privateKey)
: null;
if (keyPath) {
sshArgs.push("-i", keyPath);
}
if (Array.isArray(options.extraArgs)) {
sshArgs.push(...options.extraArgs);
}
sshArgs.push(`${options.username}@${options.hostname}`);
const env = {
...process.env,
LANG: options.charset || process.env.LANG || "en_US.UTF-8",
TERM: "xterm-256color",
};
const proc = pty.spawn("ssh", sshArgs, {
cols: options.cols || 80,
rows: options.rows || 24,
env,
});
const session = {
proc,
webContentsId: event.sender.id,
password: options.password,
sentPassword: false,
};
sessions.set(sessionId, session);
proc.onData((data) => {
if (session.password && !session.sentPassword && /password:/i.test(data)) {
proc.write(`${session.password}\r`);
session.sentPassword = true;
}
const contents = BrowserWindow.fromWebContents(event.sender)?.webContents;
contents?.send("nebula:data", { sessionId, data });
});
proc.onExit((evt) => {
const contents = BrowserWindow.fromWebContents(event.sender)?.webContents;
contents?.send("nebula:exit", { sessionId, ...evt });
sessions.delete(sessionId);
});
return { sessionId };
};
const write = (_event, payload) => {
const session = sessions.get(payload.sessionId);
session?.proc.write(payload.data);
};
const resize = (_event, payload) => {
const session = sessions.get(payload.sessionId);
if (!session) return;
try {
session.proc.resize(payload.cols, payload.rows);
} catch (err) {
console.warn("Resize failed", err);
}
};
const close = (_event, payload) => {
const session = sessions.get(payload.sessionId);
if (!session) return;
try {
session.proc.kill();
} catch (err) {
console.warn("Kill failed", err);
}
sessions.delete(payload.sessionId);
};
electronModule.ipcMain.handle("nebula:start", start);
electronModule.ipcMain.on("nebula:write", write);
electronModule.ipcMain.on("nebula:resize", resize);
electronModule.ipcMain.on("nebula:close", close);
// One-off hidden exec (for probes like distro detection)
const execOnce = async (_event, payload) => {
return new Promise((resolve, reject) => {
const conn = new SSHClient();
let stdout = "";
let stderr = "";
let settled = false;
const timeoutMs = payload.timeout || 10000;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
conn.end();
reject(new Error("SSH exec timeout"));
}, timeoutMs);
conn
.on("ready", () => {
conn.exec(payload.command, (err, stream) => {
if (err) {
clearTimeout(timer);
settled = true;
conn.end();
return reject(err);
}
stream
.on("data", (data) => {
stdout += data.toString();
})
.stderr.on("data", (data) => {
stderr += data.toString();
})
.on("close", (code) => {
if (settled) return;
clearTimeout(timer);
settled = true;
conn.end();
resolve({ stdout, stderr, code });
});
});
})
.on("error", (err) => {
if (settled) return;
clearTimeout(timer);
settled = true;
reject(err);
})
.on("end", () => {
if (settled) return;
clearTimeout(timer);
settled = true;
resolve({ stdout, stderr, code: null });
});
conn.connect({
host: payload.hostname,
port: payload.port || 22,
username: payload.username,
password: payload.password,
privateKey: payload.privateKey,
readyTimeout: timeoutMs,
keepaliveInterval: 0,
});
});
};
electronModule.ipcMain.handle("nebula:ssh:exec", execOnce);
// SFTP handlers
const openSftp = async (_event, options) => {
const client = new SftpClient();
const connId = options.sessionId || `${Date.now()}-sftp-${Math.random().toString(16).slice(2)}`;
const connectOpts = {
host: options.hostname,
port: options.port || 22,
username: options.username || "root",
};
if (options.privateKey) {
connectOpts.privateKey = options.privateKey;
} else if (options.password) {
connectOpts.password = options.password;
}
await client.connect(connectOpts);
sftpClients.set(connId, client);
return { sftpId: connId };
};
const listSftp = async (_event, payload) => {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
const list = await client.list(payload.path || ".");
return list.map((item) => ({
name: item.name,
type: item.type === "d" ? "directory" : "file",
size: `${item.size} bytes`,
lastModified: new Date(item.modifyTime || Date.now()).toISOString(),
}));
};
const readSftp = async (_event, payload) => {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
const buffer = await client.get(payload.path);
return buffer.toString();
};
const writeSftp = async (_event, payload) => {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await client.put(Buffer.from(payload.content, "utf-8"), payload.path);
return true;
};
const closeSftp = async (_event, payload) => {
const client = sftpClients.get(payload.sftpId);
if (!client) return;
try {
await client.end();
} catch (err) {
console.warn("SFTP close failed", err);
}
sftpClients.delete(payload.sftpId);
};
const mkdirSftp = async (_event, payload) => {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await client.mkdir(payload.path, true);
return true;
};
electronModule.ipcMain.handle("nebula:sftp:open", openSftp);
electronModule.ipcMain.handle("nebula:sftp:list", listSftp);
electronModule.ipcMain.handle("nebula:sftp:read", readSftp);
electronModule.ipcMain.handle("nebula:sftp:write", writeSftp);
electronModule.ipcMain.handle("nebula:sftp:close", closeSftp);
electronModule.ipcMain.handle("nebula:sftp:mkdir", mkdirSftp);
};
async function createWindow() {
const win = new BrowserWindow({
width: 1400,
height: 900,
backgroundColor: "#0b1220",
icon: appIcon,
titleBarStyle: isMac ? "hiddenInset" : "hidden",
titleBarOverlay: {
color: isMac ? "#0b1220" : "#0b1220",
symbolColor: "#ffffff",
height: 44,
},
trafficLightPosition: isMac ? { x: 12, y: 12 } : undefined,
webPreferences: {
preload,
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
});
if (isDev) {
try {
await win.loadURL(devServerUrl);
win.webContents.openDevTools({ mode: "detach" });
registerSSHBridge(win);
return;
} catch (e) {
console.warn("Dev server not reachable, falling back to bundled dist.", e);
}
}
const indexPath = path.join(__dirname, "../dist/index.html");
await win.loadFile(indexPath);
registerSSHBridge(win);
}
app.whenReady().then(() => {
if (isMac && appIcon && app.dock?.setIcon) {
try {
app.dock.setIcon(appIcon);
} catch (err) {
console.warn("Failed to set dock icon", err);
}
}
createWindow();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});

83
electron/preload.cjs Normal file
View File

@@ -0,0 +1,83 @@
const { ipcRenderer, contextBridge } = require("electron");
const dataListeners = new Map();
const exitListeners = new Map();
ipcRenderer.on("nebula:data", (_event, payload) => {
const set = dataListeners.get(payload.sessionId);
if (!set) return;
set.forEach((cb) => {
try {
cb(payload.data);
} catch (err) {
console.error("Data callback failed", err);
}
});
});
ipcRenderer.on("nebula:exit", (_event, payload) => {
const set = exitListeners.get(payload.sessionId);
if (set) {
set.forEach((cb) => {
try {
cb(payload);
} catch (err) {
console.error("Exit callback failed", err);
}
});
}
dataListeners.delete(payload.sessionId);
exitListeners.delete(payload.sessionId);
});
const api = {
startSSHSession: async (options) => {
const result = await ipcRenderer.invoke("nebula:start", options);
return result.sessionId;
},
writeToSession: (sessionId, data) => {
ipcRenderer.send("nebula:write", { sessionId, data });
},
execCommand: async (options) => {
return ipcRenderer.invoke("nebula:ssh:exec", options);
},
resizeSession: (sessionId, cols, rows) => {
ipcRenderer.send("nebula:resize", { sessionId, cols, rows });
},
closeSession: (sessionId) => {
ipcRenderer.send("nebula:close", { sessionId });
},
onSessionData: (sessionId, cb) => {
if (!dataListeners.has(sessionId)) dataListeners.set(sessionId, new Set());
dataListeners.get(sessionId).add(cb);
return () => dataListeners.get(sessionId)?.delete(cb);
},
onSessionExit: (sessionId, cb) => {
if (!exitListeners.has(sessionId)) exitListeners.set(sessionId, new Set());
exitListeners.get(sessionId).add(cb);
return () => exitListeners.get(sessionId)?.delete(cb);
},
openSftp: async (options) => {
const result = await ipcRenderer.invoke("nebula:sftp:open", options);
return result.sftpId;
},
listSftp: async (sftpId, path) => {
return ipcRenderer.invoke("nebula:sftp:list", { sftpId, path });
},
readSftp: async (sftpId, path) => {
return ipcRenderer.invoke("nebula:sftp:read", { sftpId, path });
},
writeSftp: async (sftpId, path, content) => {
return ipcRenderer.invoke("nebula:sftp:write", { sftpId, path, content });
},
closeSftp: async (sftpId) => {
return ipcRenderer.invoke("nebula:sftp:close", { sftpId });
},
mkdirSftp: async (sftpId, path) => {
return ipcRenderer.invoke("nebula:sftp:mkdir", { sftpId, path });
},
};
// Merge with existing nebula (if any) to avoid stale objects on hot reload
const existing = (typeof window !== "undefined" && window.nebula) ? window.nebula : {};
contextBridge.exposeInMainWorld("nebula", { ...existing, ...api });

51
global.d.ts vendored Normal file
View File

@@ -0,0 +1,51 @@
import type { TerminalSession, RemoteFile } from "./types";
interface NebulaSSHOptions {
sessionId?: string;
hostname: string;
username: string;
port?: number;
password?: string;
privateKey?: string;
keyId?: string;
agentForwarding?: boolean;
cols?: number;
rows?: number;
charset?: string;
extraArgs?: string[];
}
interface NebulaBridge {
startSSHSession(options: NebulaSSHOptions): Promise<string>;
execCommand(options: {
hostname: string;
username: string;
port?: number;
password?: string;
privateKey?: string;
command: string;
timeout?: number;
}): Promise<{ stdout: string; stderr: string; code: number | null }>;
writeToSession(sessionId: string, data: string): void;
resizeSession(sessionId: string, cols: number, rows: number): void;
closeSession(sessionId: string): void;
onSessionData(sessionId: string, cb: (data: string) => void): () => void;
onSessionExit(
sessionId: string,
cb: (evt: { exitCode?: number; signal?: number }) => void
): () => void;
openSftp(options: NebulaSSHOptions): Promise<string>;
listSftp(sftpId: string, path: string): Promise<RemoteFile[]>;
readSftp(sftpId: string, path: string): Promise<string>;
writeSftp(sftpId: string, path: string, content: string): Promise<void>;
closeSftp(sftpId: string): Promise<void>;
mkdirSftp(sftpId: string, path: string): Promise<void>;
}
declare global {
interface Window {
nebula?: NebulaBridge;
}
}
export {};

88
index.css Normal file
View File

@@ -0,0 +1,88 @@
:root {
color-scheme: light;
}
body {
min-height: 100vh;
background:
radial-gradient(900px circle at 15% 0%, hsl(var(--primary) / 0.10), transparent 38%),
radial-gradient(1200px circle at 85% 10%, hsl(var(--accent) / 0.16), transparent 40%),
hsl(var(--background));
color: hsl(var(--foreground));
}
.dark {
color-scheme: dark;
}
.dark body {
background:
radial-gradient(1200px circle at 10% 0%, hsl(var(--primary) / 0.08), transparent 32%),
radial-gradient(900px circle at 85% 10%, hsl(var(--accent) / 0.12), transparent 36%),
hsl(var(--background));
color: hsl(var(--foreground));
}
.nebula-shell {
position: relative;
background: linear-gradient(180deg, hsl(var(--background)) 0%, hsl(var(--background)) 60%, hsl(var(--background) / 0.9) 100%);
}
@keyframes shimmer {
0% { background-position: -120px 0; }
100% { background-position: 120px 0; }
}
.glass-panel {
background: hsl(var(--secondary) / 0.95);
border: 1px solid hsl(var(--border) / 0.8);
backdrop-filter: blur(12px);
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.24);
}
.soft-card {
background: hsl(var(--card));
border: 1px solid hsl(var(--border) / 0.8);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02), 0 12px 36px rgba(0, 0, 0, 0.22);
}
.card-highlight {
position: relative;
overflow: hidden;
}
.card-highlight::before {
content: "";
position: absolute;
inset: -40%;
background: radial-gradient(500px circle at 20% 20%, hsl(var(--primary) / 0.18), transparent 55%);
opacity: 0;
transition: opacity 180ms ease;
}
.card-highlight:hover::before {
opacity: 1;
}
.elevate {
transition: transform 140ms ease, box-shadow 140ms ease, border-color 140ms ease;
}
.elevate:hover {
transform: translateY(-2px);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.28);
}
::selection {
background: hsl(var(--primary) / 0.22);
color: hsl(var(--foreground));
}
/* Custom titlebar drag regions for Electron */
.app-drag {
-webkit-app-region: drag;
}
.app-no-drag {
-webkit-app-region: no-drag;
}

215
index.html Executable file
View File

@@ -0,0 +1,215 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>netcatty SSH</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
<style>
:root {
--background: 216 33% 96%;
--foreground: 222 47% 12%;
--card: 0 0% 100%;
--card-foreground: 222 47% 12%;
--popover: 0 0% 100%;
--popover-foreground: 222 47% 12%;
--primary: 208 100% 50%;
--primary-foreground: 0 0% 100%;
--secondary: 220 16% 90%;
--secondary-foreground: 222 47% 12%;
--muted: 220 16% 90%;
--muted-foreground: 220 10% 45%;
--accent: 200 70% 90%;
--accent-foreground: 222 47% 12%;
--destructive: 0 70% 50%;
--destructive-foreground: 0 0% 100%;
--border: 220 16% 82%;
--input: 220 16% 82%;
--ring: 208 100% 50%;
--radius: 0.65rem;
}
.dark {
--background: 220 28% 8%;
--foreground: 210 40% 95%;
--card: 220 22% 12%;
--card-foreground: 210 40% 95%;
--popover: 220 22% 12%;
--popover-foreground: 210 40% 95%;
--primary: 200 100% 61%;
--primary-foreground: 222 47% 12%;
--secondary: 220 16% 16%;
--secondary-foreground: 210 40% 90%;
--muted: 220 16% 16%;
--muted-foreground: 220 10% 70%;
--accent: 163 90% 45%;
--accent-foreground: 220 40% 96%;
--destructive: 0 70% 50%;
--destructive-foreground: 210 40% 96%;
--border: 220 22% 18%;
--input: 220 22% 18%;
--ring: 200 100% 61%;
--radius: 0.65rem;
}
body {
font-family: 'Space Grotesk', 'Inter', sans-serif;
}
/* Global Modern Scrollbar */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.3);
border-radius: 99px;
border: 3px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
background-clip: content-box;
}
::-webkit-scrollbar-corner {
background: transparent;
}
/* Utility to hide scrollbar */
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>
<script>
// Polyfill process for environment variables if missing
if (typeof process === 'undefined') {
window.process = { env: {} };
}
tailwind.config = {
darkMode: ["class"],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
fontFamily: {
sans: ['"Space Grotesk"', 'Inter', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-collapsible-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-collapsible-content-height)" },
to: { height: "0" },
},
"fade-in": {
from: { opacity: "0" },
to: { opacity: "1" },
},
"zoom-in-95": {
from: { transform: "scale(0.95)" },
to: { transform: "scale(1)" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"fade-in": "fade-in 0.2s ease-out",
},
},
},
}
</script>
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@19.2.1",
"react/jsx-runtime": "https://esm.sh/react@19.2.1/jsx-runtime",
"react-dom/client": "https://esm.sh/react-dom@19.2.1/client",
"react-dom": "https://esm.sh/react-dom@19.2.1",
"@google/genai": "https://esm.sh/@google/genai@1.31.0",
"lucide-react": "https://esm.sh/lucide-react@0.556.0?external=react,react-dom",
"clsx": "https://esm.sh/clsx@2.1.0",
"tailwind-merge": "https://esm.sh/tailwind-merge@2.2.0",
"xterm": "https://esm.sh/xterm@5.3.0",
"xterm-addon-fit": "https://esm.sh/xterm-addon-fit@0.8.0?external=xterm",
"@radix-ui/react-dialog": "https://esm.sh/@radix-ui/react-dialog@1.1.15?external=react,react-dom",
"@radix-ui/react-tabs": "https://esm.sh/@radix-ui/react-tabs@1.1.13?external=react,react-dom",
"@radix-ui/react-select": "https://esm.sh/@radix-ui/react-select@2.2.6?external=react,react-dom",
"@radix-ui/react-popover": "https://esm.sh/@radix-ui/react-popover@1.1.15?external=react,react-dom",
"@radix-ui/react-context-menu": "https://esm.sh/@radix-ui/react-context-menu@2.2.16?external=react,react-dom",
"@radix-ui/react-collapsible": "https://esm.sh/@radix-ui/react-collapsible@1.1.12?external=react,react-dom",
"@radix-ui/react-scroll-area": "https://esm.sh/@radix-ui/react-scroll-area@1.2.10?external=react,react-dom",
"@radix-ui/react-slot": "https://esm.sh/@radix-ui/react-slot@1.2.4?external=react,react-dom",
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.1/",
"react/": "https://aistudiocdn.com/react@^19.2.1/"
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body class="bg-background text-foreground overflow-hidden antialiased h-screen w-screen">
<div id="root" class="h-full w-full"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

15
index.tsx Executable file
View File

@@ -0,0 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

220
lib/terminalThemes.ts Executable file
View File

@@ -0,0 +1,220 @@
import { TerminalTheme } from '../types';
export const TERMINAL_THEMES: TerminalTheme[] = [
{
id: 'termius-dark',
name: 'Termius Dark',
type: 'dark',
colors: {
background: '#141729',
foreground: '#aeb3cb',
cursor: '#00af8d',
selection: '#1e2d42',
black: '#141729',
red: '#eb4129',
green: '#00af8d',
yellow: '#f6c744',
blue: '#42a5f5',
magenta: '#a062d3',
cyan: '#00bcd4',
white: '#ffffff',
brightBlack: '#475266',
brightRed: '#ff6e5e',
brightGreen: '#56d364',
brightYellow: '#ffeb3b',
brightBlue: '#90caf9',
brightMagenta: '#ce93d8',
brightCyan: '#80deea',
brightWhite: '#ffffff'
}
},
{
id: 'termius-light',
name: 'Termius Light',
type: 'light',
colors: {
background: '#ffffff',
foreground: '#333333',
cursor: '#00af8d',
selection: '#e0e0e0',
black: '#000000',
red: '#d32f2f',
green: '#388e3c',
yellow: '#fbc02d',
blue: '#1976d2',
magenta: '#7b1fa2',
cyan: '#0097a7',
white: '#eeeeee',
brightBlack: '#9e9e9e',
brightRed: '#f44336',
brightGreen: '#4caf50',
brightYellow: '#ffeb3b',
brightBlue: '#2196f3',
brightMagenta: '#9c27b0',
brightCyan: '#00bcd4',
brightWhite: '#ffffff'
}
},
{
id: 'flexoki-dark',
name: 'Flexoki Dark',
type: 'dark',
colors: {
background: '#100F0F',
foreground: '#CECDC3',
cursor: '#CECDC3',
selection: '#282726',
black: '#100F0F',
red: '#AF3029',
green: '#66800B',
yellow: '#AD8301',
blue: '#205EA6',
magenta: '#5E409D',
cyan: '#24837B',
white: '#CECDC3',
brightBlack: '#282726',
brightRed: '#D14D41',
brightGreen: '#879A39',
brightYellow: '#D0A215',
brightBlue: '#4385BE',
brightMagenta: '#8B7EC8',
brightCyan: '#3AA99F',
brightWhite: '#FFFCF0'
}
},
{
id: 'kanagawa-wave',
name: 'Kanagawa Wave',
type: 'dark',
colors: {
background: '#1F1F28',
foreground: '#DCD7BA',
cursor: '#C8C093',
selection: '#2D4F67',
black: '#090618',
red: '#C34043',
green: '#76946A',
yellow: '#C0A36E',
blue: '#7E9CD8',
magenta: '#957FB8',
cyan: '#6A9589',
white: '#C8C093',
brightBlack: '#727169',
brightRed: '#E82424',
brightGreen: '#98BB6C',
brightYellow: '#E6C384',
brightBlue: '#7FB4CA',
brightMagenta: '#938AA9',
brightCyan: '#7AA89F',
brightWhite: '#DCD7BA'
}
},
{
id: 'kanagawa-dragon',
name: 'Kanagawa Dragon',
type: 'dark',
colors: {
background: '#181616',
foreground: '#c5c9c5',
cursor: '#c8c093',
selection: '#2d4f67',
black: '#0d0c0c',
red: '#c4746e',
green: '#8a9a7b',
yellow: '#c4b28a',
blue: '#8ba4b0',
magenta: '#a292a3',
cyan: '#8ea4a2',
white: '#c5c9c5',
brightBlack: '#a6a69c',
brightRed: '#e46876',
brightGreen: '#87a987',
brightYellow: '#e6c384',
brightBlue: '#7fb4ca',
brightMagenta: '#938aa9',
brightCyan: '#7aa89f',
brightWhite: '#c5c9c5'
}
},
{
id: 'hacker-green',
name: 'Hacker Green',
type: 'dark',
colors: {
background: '#0d0208',
foreground: '#00ff41',
cursor: '#00ff41',
selection: '#003b00',
black: '#000000',
red: '#ff0000',
green: '#00ff41',
yellow: '#008F11',
blue: '#005F00',
magenta: '#00ff41',
cyan: '#00ff41',
white: '#00ff41',
brightBlack: '#001100',
brightRed: '#ff0000',
brightGreen: '#00ff41',
brightYellow: '#00ff41',
brightBlue: '#00ff41',
brightMagenta: '#00ff41',
brightCyan: '#00ff41',
brightWhite: '#ccffcc'
}
},
{
id: 'hacker-blue',
name: 'Hacker Blue',
type: 'dark',
colors: {
background: '#050a14',
foreground: '#00aaff',
cursor: '#00aaff',
selection: '#002a4d',
black: '#000000',
red: '#ff3333',
green: '#33ff33',
yellow: '#ffff33',
blue: '#00aaff',
magenta: '#ff33ff',
cyan: '#33ffff',
white: '#ffffff',
brightBlack: '#333333',
brightRed: '#ff6666',
brightGreen: '#66ff66',
brightYellow: '#ffff66',
brightBlue: '#66ccff',
brightMagenta: '#ff66ff',
brightCyan: '#66ffff',
brightWhite: '#ffffff'
}
},
{
id: 'night-owl',
name: 'Night Owl',
type: 'dark',
colors: {
background: '#011627',
foreground: '#d6deeb',
cursor: '#80a4c2',
selection: '#1d3b53',
black: '#011627',
red: '#ef5350',
green: '#22da6e',
yellow: '#addb67',
blue: '#82aaff',
magenta: '#c792ea',
cyan: '#21c7a8',
white: '#ffffff',
brightBlack: '#575656',
brightRed: '#ef5350',
brightGreen: '#22da6e',
brightYellow: '#ffeb95',
brightBlue: '#82aaff',
brightMagenta: '#c792ea',
brightCyan: '#7fdbca',
brightWhite: '#ffffff'
}
}
];

6
lib/utils.ts Executable file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

5
metadata.json Executable file
View File

@@ -0,0 +1,5 @@
{
"name": "netcatty SSH",
"description": "A modern, AI-powered SSH server manager and terminal simulator. Manage your hosts and practice in a virtual Linux environment powered by Gemini.",
"requestFramePermissions": []
}

5444
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
package.json Executable file
View File

@@ -0,0 +1,45 @@
{
"name": "nebula-ssh",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "electron/main.cjs",
"scripts": {
"dev": "concurrently -k \"vite\" \"npm:dev:electron\"",
"dev:electron": "wait-on http://localhost:5173 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 node electron/launch.cjs",
"build": "vite build",
"preview": "vite preview",
"start": "node electron/launch.cjs"
},
"dependencies": {
"@google/genai": "1.31.0",
"@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-context-menu": "2.2.16",
"@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-scroll-area": "1.2.10",
"@radix-ui/react-select": "2.2.6",
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-tabs": "1.1.13",
"clsx": "2.1.0",
"ghostty-web": "^0.3.0",
"lucide-react": "0.556.0",
"node-pty": "^1.0.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"ssh2-sftp-client": "^9.1.0",
"tailwind-merge": "2.2.0",
"xterm": "5.3.0",
"xterm-addon-fit": "0.8.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"concurrently": "^9.1.0",
"cross-env": "^7.0.3",
"electron": "^31.7.4",
"typescript": "~5.8.2",
"vite": "^6.2.0",
"wait-on": "^7.2.0"
}
}

1
public/distro/alpine.svg Normal file
View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Alpine Linux</title><path d="M5.998 1.607L0 12l5.998 10.393h12.004L24 12 18.002 1.607H5.998zM9.965 7.12L12.66 9.9l1.598 1.595.002-.002 2.41 2.363c-.2.14-.386.252-.563.344a3.756 3.756 0 01-.496.217 2.702 2.702 0 01-.425.111c-.131.023-.25.034-.358.034-.13 0-.242-.014-.338-.034a1.317 1.317 0 01-.24-.072.95.95 0 01-.2-.113l-1.062-1.092-3.039-3.041-1.1 1.053-3.07 3.072a.974.974 0 01-.2.111 1.274 1.274 0 01-.237.073c-.096.02-.209.033-.338.033-.108 0-.227-.009-.358-.031a2.7 2.7 0 01-.425-.114 3.748 3.748 0 01-.496-.217 5.228 5.228 0 01-.563-.343l6.803-6.727zm4.72.785l4.579 4.598 1.382 1.353a5.24 5.24 0 01-.564.344 3.73 3.73 0 01-.494.217 2.697 2.697 0 01-.426.111c-.13.023-.251.034-.36.034-.129 0-.241-.014-.337-.034a1.285 1.285 0 01-.385-.146c-.033-.02-.05-.036-.053-.04l-1.232-1.218-2.111-2.111-.334.334L12.79 9.8l1.896-1.897zm-5.966 4.12v2.529a2.128 2.128 0 01-.356-.035 2.765 2.765 0 01-.422-.116 3.708 3.708 0 01-.488-.214 5.217 5.217 0 01-.555-.34l1.82-1.825Z"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

7
public/distro/amazon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.0 KiB

1
public/distro/arch.svg Normal file
View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Arch Linux</title><path d="M11.39.605C10.376 3.092 9.764 4.72 8.635 7.132c.693.734 1.543 1.589 2.923 2.554-1.484-.61-2.496-1.224-3.252-1.86C6.86 10.842 4.596 15.138 0 23.395c3.612-2.085 6.412-3.37 9.021-3.862a6.61 6.61 0 01-.171-1.547l.003-.115c.058-2.315 1.261-4.095 2.687-3.973 1.426.12 2.534 2.096 2.478 4.409a6.52 6.52 0 01-.146 1.243c2.58.505 5.352 1.787 8.914 3.844-.702-1.293-1.33-2.459-1.929-3.57-.943-.73-1.926-1.682-3.933-2.713 1.38.359 2.367.772 3.137 1.234-6.09-11.334-6.582-12.84-8.67-17.74zM22.898 21.36v-.623h-.234v-.084h.562v.084h-.234v.623h.331v-.707h.142l.167.5.034.107a2.26 2.26 0 01.038-.114l.17-.493H24v.707h-.091v-.593l-.206.593h-.084l-.205-.602v.602h-.091"/></svg>

After

Width:  |  Height:  |  Size: 765 B

1
public/distro/centos.svg Normal file
View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>CentOS</title><path d="M12.076.066L8.883 3.28H3.348v5.434L0 12.01l3.349 3.298v5.39h5.374l3.285 3.236 3.285-3.236h5.43v-5.374L24 12.026l-3.232-3.252V3.321H15.31zm0 .749l2.49 2.506h-1.69v6.441l-.8.805-.81-.815V3.28H9.627zm-8.2 2.991h4.483L6.485 5.692l4.253 4.279v.654H9.94L5.674 6.423l-1.798 1.77zm5.227 0h1.635v5.415l-3.509-3.53zm4.302.043h1.687l1.83 1.842-3.517 3.539zm2.431 0h4.404v4.394l-1.83-1.842-4.241 4.267h-.764v-.69l4.261-4.287zm2.574 3.3l1.83 1.843v1.676h-5.327zm-12.735.013l3.515 3.462H3.876v-1.69zM3.348 9.454v1.697h6.377l.871.858-.782.77H3.35v1.786L.753 12.01zm17.42.068l2.488 2.503-2.533 2.55v-1.796h-6.41l-.75-.754.825-.83h6.38zm-9.502.978l.81.815.186-.188.614-.618v.686h.768l-.825.83.75.754h-.719v.808l-.842-.83-.741.73v-.707h-.7l.781-.77-.188-.186-.682-.672h.788zm-7.39 2.807h5.402l-3.603 3.55-1.798-1.772zm6.154 0h.708v.7l-4.404 4.338 1.852 1.824h-4.31v-4.342l1.798 1.77zm3.348 0h.715l4.317 4.343.186-.187 1.599-1.61v4.316h-4.366l1.853-1.825-.188-.185-4.116-4.054zm1.46 0h5.357v1.798l-1.785 1.796zm-2.83.191l.842.829v6.37h1.691l-2.532 2.495-2.533-2.495h1.79V14.23zm-1.27 1.251v5.42H8.939l-1.852-1.823zm2.64.097l3.552 3.499-1.853 1.825h-1.7z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

1
public/distro/debian.svg Normal file
View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Debian</title><path d="M13.88 12.685c-.4 0 .08.2.601.28.14-.1.27-.22.39-.33a3.001 3.001 0 01-.99.05m2.14-.53c.23-.33.4-.69.47-1.06-.06.27-.2.5-.33.73-.75.47-.07-.27 0-.56-.8 1.01-.11.6-.14.89m.781-2.05c.05-.721-.14-.501-.2-.221.07.04.13.5.2.22M12.38.31c.2.04.45.07.42.12.23-.05.28-.1-.43-.12m.43.12l-.15.03.14-.01V.43m6.633 9.944c.02.64-.2.95-.38 1.5l-.35.181c-.28.54.03.35-.17.78-.44.39-1.34 1.22-1.62 1.301-.201 0 .14-.25.19-.34-.591.4-.481.6-1.371.85l-.03-.06c-2.221 1.04-5.303-1.02-5.253-3.842-.03.17-.07.13-.12.2a3.551 3.552 0 012.001-3.501 3.361 3.362 0 013.732.48 3.341 3.342 0 00-2.721-1.3c-1.18.01-2.281.76-2.651 1.57-.6.38-.67 1.47-.93 1.661-.361 2.601.66 3.722 2.38 5.042.27.19.08.21.12.35a4.702 4.702 0 01-1.53-1.16c.23.33.47.66.8.91-.55-.18-1.27-1.3-1.48-1.35.93 1.66 3.78 2.921 5.261 2.3a6.203 6.203 0 01-2.33-.28c-.33-.16-.77-.51-.7-.57a5.802 5.803 0 005.902-.84c.44-.35.93-.94 1.07-.95-.2.32.04.16-.12.44.44-.72-.2-.3.46-1.24l.24.33c-.09-.6.74-1.321.66-2.262.19-.3.2.3 0 .97.29-.74.08-.85.15-1.46.08.2.18.42.23.63-.18-.7.2-1.2.28-1.6-.09-.05-.28.3-.32-.53 0-.37.1-.2.14-.28-.08-.05-.26-.32-.38-.861.08-.13.22.33.34.34-.08-.42-.2-.75-.2-1.08-.34-.68-.12.1-.4-.3-.34-1.091.3-.25.34-.74.54.77.84 1.96.981 2.46-.1-.6-.28-1.2-.49-1.76.16.07-.26-1.241.21-.37A7.823 7.824 0 0017.702 1.6c.18.17.42.39.33.42-.75-.45-.62-.48-.73-.67-.61-.25-.65.02-1.06 0C15.082.73 14.862.8 13.8.4l.05.23c-.77-.25-.9.1-1.73 0-.05-.04.27-.14.53-.18-.741.1-.701-.14-1.431.03.17-.13.36-.21.55-.32-.6.04-1.44.35-1.18.07C9.6.68 7.847 1.3 6.867 2.22L6.838 2c-.45.54-1.96 1.611-2.08 2.311l-.131.03c-.23.4-.38.85-.57 1.261-.3.52-.45.2-.4.28-.6 1.22-.9 2.251-1.16 3.102.18.27 0 1.65.07 2.76-.3 5.463 3.84 10.776 8.363 12.006.67.23 1.65.23 2.49.25-.99-.28-1.12-.15-2.08-.49-.7-.32-.85-.7-1.34-1.13l.2.35c-.971-.34-.57-.42-1.361-.67l.21-.27c-.31-.03-.83-.53-.97-.81l-.34.01c-.41-.501-.63-.871-.61-1.161l-.111.2c-.13-.21-1.52-1.901-.8-1.511-.13-.12-.31-.2-.5-.55l.14-.17c-.35-.44-.64-1.02-.62-1.2.2.24.32.3.45.33-.88-2.172-.93-.12-1.601-2.202l.15-.02c-.1-.16-.18-.34-.26-.51l.06-.6c-.63-.74-.18-3.102-.09-4.402.07-.54.53-1.1.88-1.981l-.21-.04c.4-.71 2.341-2.872 3.241-2.761.43-.55-.09 0-.18-.14.96-.991 1.26-.7 1.901-.88.7-.401-.6.16-.27-.151 1.2-.3.85-.7 2.421-.85.16.1-.39.14-.52.26 1-.49 3.151-.37 4.562.27 1.63.77 3.461 3.011 3.531 5.132l.08.02c-.04.85.13 1.821-.17 2.711l.2-.42M9.54 13.236l-.05.28c.26.35.47.73.8 1.01-.24-.47-.42-.66-.75-1.3m.62-.02c-.14-.15-.22-.34-.31-.52.08.32.26.6.43.88l-.12-.36m10.945-2.382l-.07.15c-.1.76-.34 1.511-.69 2.212.4-.73.65-1.541.75-2.362M12.45.12c.27-.1.66-.05.95-.12-.37.03-.74.05-1.1.1l.15.02M3.006 5.142c.07.57-.43.8.11.42.3-.66-.11-.18-.1-.42m-.64 2.661c.12-.39.15-.62.2-.84-.35.44-.17.53-.2.83"/></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

1
public/distro/fedora.svg Normal file
View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Fedora</title><path d="M12.001 0C5.376 0 .008 5.369.004 11.992H.002v9.287h.002A2.726 2.726 0 0 0 2.73 24h9.275c6.626-.004 11.993-5.372 11.993-11.997C23.998 5.375 18.628 0 12 0zm2.431 4.94c2.015 0 3.917 1.543 3.917 3.671 0 .197.001.395-.03.619a1.002 1.002 0 0 1-1.137.893 1.002 1.002 0 0 1-.842-1.175 2.61 2.61 0 0 0 .013-.337c0-1.207-.987-1.672-1.92-1.672-.934 0-1.775.784-1.777 1.672.016 1.027 0 2.046 0 3.07l1.732-.012c1.352-.028 1.368 2.009.016 1.998l-1.748.013c-.004.826.006.677.002 1.093 0 0 .015 1.01-.016 1.776-.209 2.25-2.124 4.046-4.424 4.046-2.438 0-4.448-1.993-4.448-4.437.073-2.515 2.078-4.492 4.603-4.469l1.409-.01v1.996l-1.409.013h-.007c-1.388.04-2.577.984-2.6 2.47a2.438 2.438 0 0 0 2.452 2.439c1.356 0 2.441-.987 2.441-2.437l-.001-7.557c0-.14.005-.252.02-.407.23-1.848 1.883-3.256 3.754-3.256z"/></svg>

After

Width:  |  Height:  |  Size: 896 B

1
public/distro/kali.svg Normal file
View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Kali Linux</title><path d="M12.778 5.943s-1.97-.13-5.327.92c-3.42 1.07-5.36 2.587-5.36 2.587s5.098-2.847 10.852-3.008zm7.351 3.095l.257-.017s-1.468-1.78-4.278-2.648c1.58.642 2.954 1.493 4.021 2.665zm.42.74c.039-.068.166.217.263.337.004.024.01.039-.045.027-.005-.025-.013-.032-.013-.032s-.135-.08-.177-.137c-.041-.057-.049-.157-.028-.195zm3.448 8.479s.312-3.578-5.31-4.403a18.277 18.277 0 0 0-2.524-.187c-4.506.06-4.67-5.197-1.275-5.462 1.407-.116 3.087.643 4.73 1.408-.007.204.002.385.136.552.134.168.648.35.813.445.164.094.691.43 1.014.85.07-.131.654-.512.654-.512s-.14.003-.465-.119c-.326-.122-.713-.49-.722-.511-.01-.022-.015-.055.06-.07.059-.049-.072-.207-.13-.265-.058-.058-.445-.716-.454-.73-.009-.016-.012-.031-.04-.05-.085-.027-.46.04-.46.04s-.575-.283-.774-.893c.003.107-.099.224 0 .469-.3-.127-.558-.344-.762-.88-.12.305 0 .499 0 .499s-.707-.198-.82-.85c-.124.293 0 .469 0 .469s-1.153-.602-3.069-.61c-1.283-.118-1.55-2.374-1.43-2.754 0 0-1.85-.975-5.493-1.406-3.642-.43-6.628-.065-6.628-.065s6.45-.31 11.617 1.783c.176.785.704 2.094.989 2.723-.815.563-1.733 1.092-1.876 2.97-.143 1.878 1.472 3.53 3.474 3.58 1.9.102 3.214.116 4.806.942 1.52.84 2.766 3.4 2.89 5.703.132-1.709-.509-5.383-3.5-6.498 4.181.732 4.549 3.832 4.549 3.832zM12.68 5.663l-.15-.485s-2.484-.441-5.822-.204C3.37 5.211 0 6.38 0 6.38s6.896-1.735 12.68-.717Z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>openSUSE</title><path d="M10.724 0a12 12 0 0 0-9.448 4.623c1.464.391 2.5.727 2.81.832.005-.19.037-1.893.037-1.893s.004-.04.025-.06c.026-.026.065-.018.065-.018.385.056 8.602 1.274 12.066 3.292.427.25.638.517.902.786.958.99 2.223 5.108 2.359 5.957.005.033-.036.07-.054.083a5.177 5.177 0 0 1-.313.228c-.82.55-2.708 1.872-5.13 1.656-2.176-.193-5.018-1.44-8.445-3.699.336.79.668 1.58 1 2.371.497.258 5.287 2.7 7.651 2.651 1.904-.04 3.941-.968 4.756-1.458 0 0 .179-.108.257-.048.085.066.061.167.041.27-.05.234-.164.66-.242.863l-.065.165c-.093.25-.183.482-.356.625-.48.436-1.246.784-2.446 1.305-1.855.812-4.865 1.328-7.66 1.31-1.001-.022-1.968-.133-2.817-.232-1.743-.197-3.161-.357-4.026.269A12 12 0 0 0 10.724 24a12 12 0 0 0 12-12 12 12 0 0 0-12-12zM13.4 6.963a3.503 3.503 0 0 0-2.521.942 3.498 3.498 0 0 0-1.114 2.449 3.528 3.528 0 0 0 3.39 3.64 3.48 3.48 0 0 0 2.524-.946 3.504 3.504 0 0 0 1.114-2.446 3.527 3.527 0 0 0-3.393-3.64zm-.03 1.035a2.458 2.458 0 0 1 2.368 2.539 2.43 2.43 0 0 1-.774 1.706 2.456 2.456 0 0 1-1.762.659 2.461 2.461 0 0 1-2.364-2.542c.02-.655.3-1.26.777-1.707a2.419 2.419 0 0 1 1.756-.655zm.402 1.23c-.602 0-1.087.325-1.087.727 0 .4.485.725 1.087.725.6 0 1.088-.326 1.088-.725 0-.402-.487-.726-1.088-.726Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

6
public/distro/oracle.svg Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="512px" height="67px" viewBox="0 0 512 67" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
<g>
<path d="M221.034224,43.3028585 L254.866139,43.3028585 L236.977029,14.5219565 L204.144344,66.5590894 L189.201618,66.5590894 L229.137107,4.05139996 C230.873472,1.52582675 233.767268,0 236.977029,0 C240.081163,0 242.975008,1.47323786 244.658785,3.94617225 L284.75209,66.5590894 L269.809323,66.5590894 L262.758717,54.9309781 L228.505591,54.9309781 L221.034224,43.3028585 L221.034224,43.3028585 Z M376.251178,54.9309781 L376.251178,0.631391222 L363.570757,0.631391222 L363.570757,60.2452021 C363.570757,61.8762982 364.202256,63.4547554 365.412325,64.6649074 C366.62231,65.8751009 368.25349,66.5590894 370.042461,66.5590894 L427.867434,66.5590894 L435.338818,54.9309781 L376.251178,54.9309781 L376.251178,54.9309781 Z M166.471559,45.1970405 C178.783867,45.1970405 188.780891,35.2526558 188.780891,22.9405145 C188.780891,10.6284147 178.783867,0.631391222 166.471559,0.631391222 L110.99843,0.631391222 L110.99843,66.5590894 L123.673825,66.5590894 L123.673825,12.2595108 L165.629671,12.2595108 C171.522872,12.2595108 176.258211,17.0475214 176.258211,22.9405145 C176.258211,28.8334992 171.522872,33.6215597 165.629671,33.6215597 L129.882576,33.5689209 L167.734175,66.5590894 L186.150048,66.5590894 L160.683969,45.1970405 L166.471559,45.1970405 L166.471559,45.1970405 Z M32.9694209,66.5590894 C14.7693798,66.5590894 9.23705556e-14,51.8266442 9.23705556e-14,33.6215597 C9.23705556e-14,15.4164336 14.7693798,0.631391222 32.9694209,0.631391222 L71.2892929,0.631391222 C89.4943774,0.631391222 104.253246,15.4164336 104.253246,33.6215597 C104.253246,51.8266442 89.4943774,66.5590894 71.2892929,66.5590894 L32.9694209,66.5590894 L32.9694209,66.5590894 Z M70.4368936,54.9309781 C82.2283308,54.9309781 91.7781116,45.4074876 91.7781116,33.6215597 C91.7781116,21.8355819 82.2283308,12.2595108 70.4368936,12.2595108 L33.8163525,12.2595108 C22.030383,12.2595108 12.4751261,21.8355819 12.4751261,33.6215597 C12.4751261,45.4074876 22.030383,54.9309781 33.8163525,54.9309781 L70.4368936,54.9309781 L70.4368936,54.9309781 Z M311.217808,66.5590894 C293.012682,66.5590894 278.227789,51.8266442 278.227789,33.6215597 C278.227789,15.4164336 293.012682,0.631391222 311.217808,0.631391222 L356.73054,0.631391222 L349.311753,12.2595108 L312.059696,12.2595108 C300.273676,12.2595108 290.697822,21.8355819 290.697822,33.6215597 C290.697822,45.4074876 300.273676,54.9309781 312.059696,54.9309781 L357.782733,54.9309781 L350.311433,66.5590894 L311.217808,66.5590894 L311.217808,66.5590894 Z M466.276962,54.9309781 C456.5429,54.9309781 448.282225,48.4065938 445.75656,39.4092751 L499.951078,39.4092751 L507.422461,27.7811639 L445.75656,27.7811639 C448.282225,18.8364757 456.5429,12.2595108 466.276962,12.2595108 L503.476422,12.2595108 L511.000403,0.631391222 L465.435074,0.631391222 C447.229948,0.631391222 432.445056,15.4164336 432.445056,33.6215597 C432.445056,51.8266442 447.229948,66.5590894 465.435074,66.5590894 L504.528699,66.5590894 L512,54.9309781 L466.276962,54.9309781 L466.276962,54.9309781 Z" fill="#EA1B22"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

1
public/distro/redhat.svg Normal file
View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Red Hat</title><path d="M16.009 13.386c1.577 0 3.86-.326 3.86-2.202a1.765 1.765 0 0 0-.04-.431l-.94-4.08c-.216-.898-.406-1.305-1.982-2.093-1.223-.625-3.888-1.658-4.676-1.658-.733 0-.947.946-1.822.946-.842 0-1.467-.706-2.255-.706-.757 0-1.25.515-1.63 1.576 0 0-1.06 2.99-1.197 3.424a.81.81 0 0 0-.028.245c0 1.162 4.577 4.974 10.71 4.974m4.101-1.435c.218 1.032.218 1.14.218 1.277 0 1.765-1.984 2.745-4.593 2.745-5.895.004-11.06-3.451-11.06-5.734a2.326 2.326 0 0 1 .19-.925C2.746 9.415 0 9.794 0 12.217c0 3.969 9.405 8.861 16.851 8.861 5.71 0 7.149-2.582 7.149-4.62 0-1.605-1.387-3.425-3.887-4.512"/></svg>

After

Width:  |  Height:  |  Size: 681 B

1
public/distro/rocky.svg Normal file
View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Rocky Linux</title><path d="M23.332 15.957c.433-1.239.668-2.57.668-3.957 0-6.627-5.373-12-12-12S0 5.373 0 12c0 3.28 1.315 6.251 3.447 8.417L15.62 8.245l3.005 3.005zm-2.192 3.819l-5.52-5.52L6.975 22.9c1.528.706 3.23 1.1 5.025 1.1 3.661 0 6.94-1.64 9.14-4.224z"/></svg>

After

Width:  |  Height:  |  Size: 345 B

1
public/distro/ubuntu.svg Normal file
View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Ubuntu</title><path d="M17.61.455a3.41 3.41 0 0 0-3.41 3.41 3.41 3.41 0 0 0 3.41 3.41 3.41 3.41 0 0 0 3.41-3.41 3.41 3.41 0 0 0-3.41-3.41zM12.92.8C8.923.777 5.137 2.941 3.148 6.451a4.5 4.5 0 0 1 .26-.007 4.92 4.92 0 0 1 2.585.737A8.316 8.316 0 0 1 12.688 3.6 4.944 4.944 0 0 1 13.723.834 11.008 11.008 0 0 0 12.92.8zm9.226 4.994a4.915 4.915 0 0 1-1.918 2.246 8.36 8.36 0 0 1-.273 8.303 4.89 4.89 0 0 1 1.632 2.54 11.156 11.156 0 0 0 .559-13.089zM3.41 7.932A3.41 3.41 0 0 0 0 11.342a3.41 3.41 0 0 0 3.41 3.409 3.41 3.41 0 0 0 3.41-3.41 3.41 3.41 0 0 0-3.41-3.41zm2.027 7.866a4.908 4.908 0 0 1-2.915.358 11.1 11.1 0 0 0 7.991 6.698 11.234 11.234 0 0 0 2.422.249 4.879 4.879 0 0 1-.999-2.85 8.484 8.484 0 0 1-.836-.136 8.304 8.304 0 0 1-5.663-4.32zm11.405.928a3.41 3.41 0 0 0-3.41 3.41 3.41 3.41 0 0 0 3.41 3.41 3.41 3.41 0 0 0 3.41-3.41 3.41 3.41 0 0 0-3.41-3.41z"/></svg>

After

Width:  |  Height:  |  Size: 948 B

BIN
public/ghostty-vt.wasm Executable file

Binary file not shown.

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

1
public/logo.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><rect x='4' y='4' width='56' height='56' rx='12' fill='#2463EB'/><rect x='14' y='17' width='36' height='24' rx='4' fill='white'/><rect x='14' y='17' width='36' height='5' rx='4' fill='#E5ECFF'/><circle cx='18' cy='19.5' r='1' fill='#2463EB'/><circle cx='22' cy='19.5' r='1' fill='#2463EB' opacity='0.7'/><circle cx='26' cy='19.5' r='1' fill='#2463EB' opacity='0.5'/><path d='M20 32 L24 30 L20 28' stroke='#2463EB' fill='none' stroke-width='1.6'/><path d='M28 34 H34' stroke='#2463EB' stroke-width='1.6'/><path d='M24 17 L26 12 L28 17Z' fill='white'/><path d='M36 17 L38 12 L40 17Z' fill='white'/><path d='M40 37 C44 40,46 42,46 46 C46 49,44 51,41 51' stroke='white' fill='none' stroke-width='3.2'/><rect x='38' y='48' width='6' height='5' rx='1' fill='white' stroke='#2463EB'/></svg>

After

Width:  |  Height:  |  Size: 843 B

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

111
services/geminiService.ts Executable file
View File

@@ -0,0 +1,111 @@
import { GoogleGenAI, Chat } from "@google/genai";
import { RemoteFile } from "../types";
const getClient = () => {
const apiKey = process.env.API_KEY;
if (!apiKey) {
throw new Error("API Key not found");
}
return new GoogleGenAI({ apiKey });
};
// --- Terminal Simulator ---
export const createTerminalSession = () => {
const ai = getClient();
return ai.chats.create({
model: 'gemini-2.5-flash',
config: {
systemInstruction: `You are a simulated Linux Ubuntu 22.04 LTS terminal.
- The user will type commands, and you must reply ONLY with the standard stdout or stderr of that command.
- Maintain a persistent state for the current directory (start at /home/user), created files, and environment variables throughout the session conversation.
- If the user runs a command that produces no output (like 'cd' or 'mkdir'), return an empty string or a new line.
- Do NOT use markdown code blocks (\`\`\`) in your response unless the command itself (like 'cat file.md') would output markdown. Just raw text.
- If the command is invalid, simulate the exact bash error message.
- Assume the user has sudo privileges (password is 'password').
- Be fast and concise.`,
temperature: 0.1, // Low temperature for deterministic terminal behavior
},
});
};
export const sendTerminalCommand = async (chat: Chat, command: string): Promise<string> => {
try {
const result = await chat.sendMessage({ message: command });
return result.text || "";
} catch (error) {
console.error("Terminal Error:", error);
return `bash: simulated_connection_error: ${error instanceof Error ? error.message : 'Unknown error'}`;
}
};
// --- SFTP Simulation ---
export const sftpListFiles = async (chat: Chat, path: string): Promise<RemoteFile[]> => {
try {
// We ask the AI to act as a tool and return JSON instead of raw ls output for better UI parsing
const result = await chat.sendMessage({
message: `(System Command) List the files in directory "${path}" formatted strictly as a JSON array of objects.
Each object must have: "name" (string), "type" ("file" or "directory"), "size" (human readable string), "lastModified" (string).
Do not include '.' or '..'. If directory doesn't exist, return empty array. Only return JSON.`
});
const text = result.text.replace(/```json|```/g, '').trim();
return JSON.parse(text);
} catch (e) {
console.error("SFTP List Error", e);
return [];
}
};
export const sftpReadFile = async (chat: Chat, path: string): Promise<string> => {
try {
const result = await chat.sendMessage({
message: `(System Command) Output the raw text content of file "${path}". Do not use markdown blocks. If binary, say "BINARY_FILE".`
});
return result.text;
} catch (e) {
return "";
}
};
export const sftpWriteFile = async (chat: Chat, path: string, content: string): Promise<boolean> => {
try {
await chat.sendMessage({
message: `(System Command) Create/Overwrite a file at "${path}" with the following content:\n${content}\n\nConfirm with "OK".`
});
return true;
} catch (e) {
return false;
}
};
// --- AI Assistant ---
export const generateSSHCommand = async (prompt: string): Promise<string> => {
const ai = getClient();
try {
const result = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: `Generate a bash/shell command for the following task: "${prompt}".
Respond ONLY with the code snippet, no markdown, no explanation.`,
});
return result.text.trim();
} catch (error) {
return `Error generating command: ${error instanceof Error ? error.message : 'Unknown error'}`;
}
};
export const explainLog = async (log: string): Promise<string> => {
const ai = getClient();
try {
const result = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: `Explain this server log or error message briefly and suggest a fix:\n\n${log}`,
});
return result.text;
} catch (error) {
return "Could not analyze logs.";
}
};

67
services/syncService.ts Executable file
View File

@@ -0,0 +1,67 @@
import { Host, SSHKey, Snippet } from '../types';
interface BackupData {
hosts: Host[];
keys: SSHKey[];
snippets: Snippet[];
customGroups: string[];
timestamp: number;
version: number;
}
export const syncToGist = async (token: string, gistId: string | undefined, data: Omit<BackupData, 'timestamp' | 'version'>): Promise<string> => {
const payload = {
description: "netcatty SSH Config Backup",
public: false,
files: {
"nebula-config.json": {
content: JSON.stringify({ ...data, timestamp: Date.now(), version: 1 }, null, 2)
}
}
};
const url = gistId
? `https://api.github.com/gists/${gistId}`
: `https://api.github.com/gists`;
const method = gistId ? 'PATCH' : 'POST';
const response = await fetch(url, {
method,
headers: {
'Authorization': `token ${token}`,
'Accept': 'application/vnd.github.v3+json'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`Failed to sync: ${response.statusText}`);
}
const result = await response.json();
return result.id;
};
export const loadFromGist = async (token: string, gistId: string): Promise<BackupData> => {
const response = await fetch(`https://api.github.com/gists/${gistId}`, {
headers: {
'Authorization': `token ${token}`,
'Accept': 'application/vnd.github.v3+json'
}
});
if (!response.ok) {
throw new Error(`Failed to load: ${response.statusText}`);
}
const result = await response.json();
const file = result.files["nebula-config.json"];
if (!file || !file.content) {
throw new Error("Invalid Gist format: nebula-config.json not found");
}
return JSON.parse(file.content);
};

30
tsconfig.json Executable file
View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node",
"vite/client"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

109
types.ts Executable file
View File

@@ -0,0 +1,109 @@
export interface Host {
id: string;
label: string;
hostname: string;
port: number;
username: string;
group?: string;
tags: string[];
os: 'linux' | 'windows' | 'macos';
identityFileId?: string; // Reference to SSHKey
protocol?: 'ssh' | 'telnet';
password?: string;
authMethod?: 'password' | 'key' | 'certificate' | 'fido2';
agentForwarding?: boolean;
startupCommand?: string;
hostChaining?: string;
proxy?: string;
envVars?: string;
charset?: string;
moshEnabled?: boolean;
theme?: string;
distro?: string; // detected distro id (e.g., ubuntu, debian)
}
export interface SSHKey {
id: string;
label: string;
type: 'RSA' | 'ECDSA' | 'ED25519';
privateKey: string;
publicKey?: string;
created: number;
}
export interface Snippet {
id: string;
label: string;
command: string; // Multi-line script
tags?: string[];
}
export interface TerminalLine {
type: 'input' | 'output' | 'error' | 'system';
content: string;
directory?: string;
timestamp: number;
}
export interface ChatMessage {
role: 'user' | 'model';
text: string;
}
export interface GroupNode {
name: string;
path: string;
children: Record<string, GroupNode>;
hosts: Host[];
}
export interface SyncConfig {
gistId: string;
githubToken: string;
lastSync?: number;
}
export interface TerminalTheme {
id: string;
name: string;
type: 'dark' | 'light';
colors: {
background: string;
foreground: string;
cursor: string;
selection: string;
black: string;
red: string;
green: string;
yellow: string;
blue: string;
magenta: string;
cyan: string;
white: string;
brightBlack: string;
brightRed: string;
brightGreen: string;
brightYellow: string;
brightBlue: string;
brightMagenta: string;
brightCyan: string;
brightWhite: string;
}
}
export interface TerminalSession {
id: string;
hostId: string;
hostLabel: string;
username: string;
hostname: string;
status: 'connecting' | 'connected' | 'disconnected';
}
export interface RemoteFile {
name: string;
type: 'file' | 'directory';
size: string;
lastModified: string;
}

27
vite.config.ts Executable file
View File

@@ -0,0 +1,27 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
base: "./",
server: {
port: 5173,
host: '0.0.0.0',
},
build: {
chunkSizeWarningLimit: 1500,
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});