# Hostkeeper V2 — React Component Tree > **Status**: V2 Planning Complete > **Last Updated**: 2026-06-29 > **Framework**: React 19, TypeScript, Zustand, TailwindCSS --- ## 1. App Component Tree ``` App ├── # React Query (optional) │ ├── # CSS variables theme provider │ │ ├── # React Router │ │ │ ├── # Route: / (if locked) │ │ │ └── # Route: /* │ │ │ ├── │ │ │ │ ├── │ │ │ │ ├── │ │ │ │ │ ├── │ │ │ │ │ │ └── │ │ │ │ │ └── (recursive) │ │ │ │ └── │ │ │ ├── │ │ │ │ ├── │ │ │ │ └── │ │ │ ├── │ │ │ │ ├── │ │ │ │ ├── │ │ │ │ ├── │ │ │ │ ├── │ │ │ │ ├── │ │ │ │ ├── │ │ │ │ ├── │ │ │ │ ├── │ │ │ │ └── │ │ │ └── │ │ └── │ │ ├── │ │ ├── │ │ ├── │ │ ├── │ │ └── │ └── # Sonner toast notifications ``` --- ## 2. Component Details ### 2.1 App Root ```typescript // app/frontend/src/App.tsx function App() { const { isLocked } = useVaultStore(); if (isLocked) { return ; } return ( ); } ``` **State**: `useVaultStore()` — `isLocked: boolean` --- ### 2.2 VaultScreen Password prompt shown on app start (when vault is locked). ```typescript interface VaultScreenProps { onUnlock: (password: string) => Promise; } function VaultScreen({ onUnlock }: VaultScreenProps) { const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); // UI: centered card with password input, unlock button // Enter key submits, Esc clears } ``` **Layout**: Centered card on dark background. App logo + name above. Password input + Unlock button. Error message below input. --- ### 2.3 MainLayout The main application shell after vault unlock. ```typescript function MainLayout() { const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarWidth, setSidebarWidth] = useState(240); const tabs = useTabStore(state => state.tabs); return (
); } ``` **Layout**: Horizontal split — sidebar (left) + main area (right). Main area splits vertically — tab bar (top) + content (middle) + status bar (bottom). --- ### 2.4 Sidebar Left navigation panel with host tree and quick actions. ```typescript interface SidebarProps { width: number; onToggle: (open: boolean) => void; } function Sidebar({ width, onToggle }: SidebarProps) { const [searchQuery, setSearchQuery] = useState(''); const groups = useHostStore(state => state.groups); const hosts = useHostStore(state => state.hosts); return ( ); } ``` **Width**: 240px default, resizable via drag handle (min 180px, max 400px). --- ### 2.5 SearchBar ```typescript interface SearchBarProps { value: string; onChange: (value: string) => void; placeholder?: string; } function SearchBar({ value, onChange, placeholder = "Search hosts..." }: SearchBarProps) { return (
onChange(e.target.value)} placeholder={placeholder} className="w-full pl-8 pr-3 py-1.5 rounded-md bg-surface text-sm" /> {value && ( )}
); } ``` --- ### 2.6 GroupTree Recursive tree component for host groups. ```typescript interface GroupTreeProps { groups: HostGroup[]; hosts: Host[]; filter: string; parent_id?: string | null; depth?: number; } function GroupTree({ groups, hosts, filter, parent_id = null, depth = 0 }: GroupTreeProps) { const childGroups = groups.filter(g => g.parent_id === parent_id); const ungroupedHosts = hosts.filter(h => !h.group_id && !parent_id); return (
{childGroups.map(group => ( ))} {depth === 0 && }
); } interface GroupItemProps { group: HostGroup; groups: HostGroup[]; hosts: Host[]; filter: string; depth: number; } function GroupItem({ group, groups, hosts, filter, depth }: GroupItemProps) { const [expanded, setExpanded] = useState(true); const groupHosts = hosts.filter(h => h.group_id === group.id); const color = group.color || '#888'; return (
setExpanded(!expanded)} > {expanded ? : }
{group.name} {groupHosts.length}
{expanded && ( )}
); } ``` **Features**: - Collapsible groups with chevron icon - Color dot for group color - Host count badge - Drag-and-drop: drag host onto group to reassign - Right-click: context menu (rename, delete, new sub-group) --- ### 2.7 HostItem ```typescript interface HostItemProps { host: Host; isActive?: boolean; onClick?: () => void; } function HostItem({ host, isActive, onClick }: HostItemProps) { const status = useTerminalStore(state => state.getSessionStatus(host.id)); return (
{host.name} {host.is_favorite && ( )}
); } function StatusDot({ status }: { status: string }) { const color = { connected: 'bg-green-500', connecting: 'bg-yellow-500', disconnected: 'bg-gray-500', error: 'bg-red-500' }[status] || 'bg-gray-500'; return
; } ``` --- ### 2.8 TabBar Horizontal tab bar for open terminals/SFTP sessions. ```typescript function TabBar() { const { tabs, activeTab, addTab, closeTab, setActiveTab } = useTabStore(); return (
{tabs.map(tab => ( setActiveTab(tab.id)} onClose={() => closeTab(tab.id)} /> ))} addTab()} />
); } interface TabProps { tab: Tab; isActive: boolean; onClick: () => void; onClose: () => void; } function Tab({ tab, isActive, onClick, onClose }: TabProps) { const status = useTerminalStore(state => state.getSessionStatus(tab.host_id)); return (
{tab.title}
); } ``` **Features**: - Status dot (green=connected, red=error, gray=disconnected) - Tab title (host name) - Close button (X) - Drag to reorder - Middle-click to close - Max 20 tabs --- ### 2.9 MainContent Routes to the appropriate screen based on active tab type. ```typescript function MainContent() { const activeTab = useTabStore(state => state.getActiveTab()); if (!activeTab) { return ; } switch (activeTab.type) { case 'terminal': return ; case 'sftp': return ; default: return ; } } ``` --- ### 2.10 HostListScreen Default screen when no tab is active. ```typescript function HostListScreen() { const [viewMode, setViewMode] = useState<'grid' | 'list'>('list'); const hosts = useHostStore(state => state.hosts); const selectedHost = useHostStore(state => state.selectedHost); if (hosts.length === 0) { return ; } return (

Hosts

{viewMode === 'list' ? ( ) : ( )}
); } function EmptyState() { return (

No hosts yet

Add your first SSH host to get started

); } ``` --- ### 2.11 HostForm (Add/Edit) ```typescript interface HostFormProps { host?: Host; // undefined = add mode, defined = edit mode onSave: (host: Host) => void; onCancel: () => void; } function HostForm({ host, onSave, onCancel }: HostFormProps) { const [formData, setFormData] = useState({ name: host?.name || '', hostname: host?.hostname || '', port: host?.port || 22, username: host?.username || '', auth_type: host?.auth?.type || 'key', key_id: host?.auth?.key_id || '', password: '', group_id: host?.group_id || null, tags: host?.tags || [], is_favorite: host?.is_favorite || false, notes: host?.notes || '', }); const [errors, setErrors] = useState>({}); return (

{host ? 'Edit Host' : 'New Host'}

{/* Basic Info */} setFormData({ ...formData, name: v })} error={errors.name} placeholder="My Server" /> setFormData({ ...formData, hostname: v })} error={errors.hostname} placeholder="192.168.1.100 or example.com" />
setFormData({ ...formData, port: parseInt(v) })} error={errors.port} /> setFormData({ ...formData, username: v })} error={errors.username} placeholder="root" />
{/* Auth */} setFormData({ ...formData, auth_type: v })} onKeyChange={v => setFormData({ ...formData, key_id: v })} onPasswordChange={v => setFormData({ ...formData, password: v })} /> {/* Tags */} setFormData({ ...formData, tags })} /> {/* Group */} setFormData({ ...formData, group_id: id })} /> {/* Notes */}