Files
Netcatty/components/VaultView.tsx
bincxz 885d837fda feat: Implement agents architecture and core functionality
- Added agents overview documentation outlining the project structure and roles.
- Implemented `useSessionState` for managing terminal sessions and workspaces.
- Developed `useSettingsState` for handling user settings and theme management.
- Created `useVaultState` for managing hosts, SSH keys, snippets, and custom groups.
- Introduced domain logic for host normalization and sanitization.
- Defined models for Host, SSHKey, Snippet, TerminalSession, and Workspace.
- Implemented workspace management functions including creation, insertion, and pruning.
- Established local storage adapter for persistent data management.
- Integrated Gemini AI service for terminal simulation and command generation.
- Developed sync service for backing up and restoring configuration to/from GitHub Gists.
2025-12-08 01:29:49 +08:00

468 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useMemo, useState } from 'react';
import {
Activity,
BookMarked,
ChevronRight,
Command,
FolderPlus,
Edit2,
FileCode,
Grid,
Heart,
Key,
LayoutGrid,
Plug,
Plus,
Search,
Settings,
Star,
Trash2,
TerminalSquare,
} from 'lucide-react';
import { Host, SSHKey, Snippet, GroupNode, TerminalSession } from '../types';
import { DistroAvatar } from './DistroAvatar';
import SnippetsManager from './SnippetsManager';
import KeyManager from './KeyManager';
import PortForwarding from './PortForwarding';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
import { cn } from '../lib/utils';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
import { Label } from './ui/label';
import { sanitizeHost } from '../domain/host';
type VaultSection = 'hosts' | 'keys' | 'snippets' | 'port';
interface VaultViewProps {
isActive: boolean;
hosts: Host[];
keys: SSHKey[];
snippets: Snippet[];
snippetPackages: string[];
customGroups: string[];
sessions: TerminalSession[];
showAssistant: boolean;
onToggleAssistant: () => void;
onOpenSettings: () => void;
onOpenQuickSwitcher: () => void;
onNewHost: () => void;
onCreateLocalTerminal: () => void;
onEditHost: (host: Host) => void;
onDeleteHost: (id: string) => void;
onConnect: (host: Host) => void;
onUpdateHosts: (hosts: Host[]) => void;
onUpdateKeys: (keys: SSHKey[]) => void;
onUpdateSnippets: (snippets: Snippet[]) => void;
onUpdateSnippetPackages: (pkgs: string[]) => void;
onUpdateCustomGroups: (groups: string[]) => void;
}
export const VaultView: React.FC<VaultViewProps> = ({
isActive,
hosts,
keys,
snippets,
snippetPackages,
customGroups,
sessions,
showAssistant,
onToggleAssistant,
onOpenSettings,
onOpenQuickSwitcher,
onNewHost,
onCreateLocalTerminal,
onEditHost,
onDeleteHost,
onConnect,
onUpdateHosts,
onUpdateKeys,
onUpdateSnippets,
onUpdateSnippetPackages,
onUpdateCustomGroups,
}) => {
const [currentSection, setCurrentSection] = useState<VaultSection>('hosts');
const [search, setSearch] = useState('');
const [selectedGroupPath, setSelectedGroupPath] = useState<string | null>(null);
const [isNewFolderOpen, setIsNewFolderOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [targetParentPath, setTargetParentPath] = useState<string | null>(null);
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 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();
onUpdateCustomGroups(Array.from(new Set([...customGroups, fullPath])));
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;
});
onUpdateCustomGroups(keepGroups);
onUpdateHosts(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;
});
onUpdateCustomGroups(Array.from(new Set(updatedGroups)));
onUpdateHosts(updatedHosts);
if (selectedGroupPath && (selectedGroupPath === sourcePath || selectedGroupPath.startsWith(sourcePath + '/'))) {
setSelectedGroupPath(newPath);
}
};
const moveHostToGroup = (hostId: string, groupPath: string | null) => {
onUpdateHosts(hosts.map(h => h.id === hostId ? { ...h, group: groupPath || '' } : h));
};
return (
<div
className="absolute inset-0 min-h-0 flex z-20"
style={{ display: isActive ? 'flex' : 'none' }}
>
{/* 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); }}>
<LayoutGrid 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={onToggleAssistant}>
<Command size={16} /> AI Assistant
</Button>
<Button variant="ghost" className="w-full justify-start gap-3" onClick={onOpenSettings}>
<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={onOpenQuickSwitcher}>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={onNewHost}>
<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>
<Button size="sm" variant="secondary" className="h-11 px-3" onClick={onCreateLocalTerminal}>
<TerminalSquare size={14} className="mr-2" /> Terminal
</Button>
</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) => {
const safeHost = sanitizeHost(host);
const distroBadge = { 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={() => onConnect(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={() => onConnect(host)}>
<Plug className="mr-2 h-4 w-4" /> Connect
</ContextMenuItem>
<ContextMenuItem onClick={() => onEditHost(host)}>
<Edit2 className="mr-2 h-4 w-4" /> Edit
</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={() => onDeleteHost(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={onNewHost}>
<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 => onUpdateKeys([...keys, k])} onDelete={id => onUpdateKeys(keys.filter(k => k.id !== id))} />
)}
{currentSection === 'snippets' && (
<SnippetsManager
snippets={snippets}
packages={snippetPackages}
hosts={hosts}
onPackagesChange={onUpdateSnippetPackages}
onSave={s => onUpdateSnippets(snippets.find(ex => ex.id === s.id) ? snippets.map(ex => ex.id === s.id ? s : ex) : [...snippets, s])}
onDelete={id => onUpdateSnippets(snippets.filter(s => s.id !== id))}
/>
)}
{currentSection === 'port' && <PortForwarding />}
</div>
</div>
<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>
);
};