first commit
33
.gitignore
vendored
Executable 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
@@ -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
@@ -0,0 +1,43 @@
|
|||||||
|
# Netcatty — SSH workspace, SFTP, and terminals in one
|
||||||
|
|
||||||
|
[](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
@@ -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;
|
||||||
271
components/HostDetailsPanel.tsx
Normal 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 & 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
@@ -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
@@ -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;
|
||||||
178
components/PortForwarding.tsx
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
45
package.json
Executable 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
@@ -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
|
After Width: | Height: | Size: 8.0 KiB |
1
public/distro/arch.svg
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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 |
1
public/distro/opensuse.svg
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
BIN
public/icon.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
1
public/logo.svg
Normal 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
|
After Width: | Height: | Size: 210 KiB |
111
services/geminiService.ts
Executable 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
@@ -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
@@ -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
@@ -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
@@ -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, '.'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||