Files
HostKeeper/docs/v2/UI_COMPONENTS.md
T
2026-07-07 11:53:16 +07:00

33 KiB

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
├── <QueryClientProvider>                    # React Query (optional)
│   ├── <ThemeProvider>                      # CSS variables theme provider
│   │   ├── <RouterProvider>                 # React Router
│   │   │   ├── <VaultScreen>               # Route: / (if locked)
│   │   │   └── <MainLayout>                # Route: /*
│   │   │       ├── <Sidebar>
│   │   │       │   ├── <SearchBar>
│   │   │       │   ├── <GroupTree>
│   │   │       │   │   ├── <GroupItem>
│   │   │       │   │   │   └── <HostItem>
│   │   │       │   │   └── (recursive)
│   │   │       │   └── <QuickActions>
│   │   │       ├── <TabBar>
│   │   │       │   ├── <Tab>
│   │   │       │   └── <NewTabButton>
│   │   │       ├── <MainContent>
│   │   │       │   ├── <HostListScreen>
│   │   │       │   ├── <HostDetailScreen>
│   │   │       │   ├── <TerminalScreen>
│   │   │       │   ├── <SFTPScreen>
│   │   │       │   ├── <KeychainScreen>
│   │   │       │   ├── <SnippetsScreen>
│   │   │       │   ├── <PortForwardScreen>
│   │   │       │   ├── <WorkspaceScreen>
│   │   │       │   └── <SettingsScreen>
│   │   │       └── <StatusBar>
│   │   └── <Modals>
│   │       ├── <ConfirmDialog>
│   │       ├── <KnownHostDialog>
│   │       ├── <PasswordPrompt>
│   │       ├── <KeyPassphrasePrompt>
│   │       └── <ErrorDialog>
│   └── <Toasts />                           # Sonner toast notifications

2. Component Details

2.1 App Root

// app/frontend/src/App.tsx
function App() {
    const { isLocked } = useVaultStore();
    
    if (isLocked) {
        return <VaultScreen onUnlock={handleUnlock} />;
    }
    
    return (
        <ThemeProvider>
            <RouterProvider router={router} />
            <Toasts position="bottom-right" />
        </ThemeProvider>
    );
}

State: useVaultStore()isLocked: boolean


2.2 VaultScreen

Password prompt shown on app start (when vault is locked).

interface VaultScreenProps {
    onUnlock: (password: string) => Promise<void>;
}

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.

function MainLayout() {
    const [sidebarOpen, setSidebarOpen] = useState(true);
    const [sidebarWidth, setSidebarWidth] = useState(240);
    const tabs = useTabStore(state => state.tabs);
    
    return (
        <div className="flex h-screen">
            <Sidebar width={sidebarWidth} onToggle={setSidebarOpen} />
            <div className="flex-1 flex flex-col">
                <TabBar tabs={tabs} />
                <MainContent />
                <StatusBar />
            </div>
        </div>
    );
}

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.

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 (
        <aside style={{ width }}>
            <div className="flex items-center justify-between p-3">
                <h2 className="font-semibold">Hosts</h2>
                <button onClick={() => onToggle(false)}>
                    <PanelLeftClose />
                </button>
            </div>
            <SearchBar value={searchQuery} onChange={setSearchQuery} />
            <GroupTree groups={groups} hosts={hosts} filter={searchQuery} />
            <QuickActions />
        </aside>
    );
}

Width: 240px default, resizable via drag handle (min 180px, max 400px).


interface SearchBarProps {
    value: string;
    onChange: (value: string) => void;
    placeholder?: string;
}

function SearchBar({ value, onChange, placeholder = "Search hosts..." }: SearchBarProps) {
    return (
        <div className="relative px-3">
            <Search className="absolute left-5 top-2.5 h-4 w-4 text-muted" />
            <input
                type="text"
                value={value}
                onChange={e => onChange(e.target.value)}
                placeholder={placeholder}
                className="w-full pl-8 pr-3 py-1.5 rounded-md bg-surface text-sm"
            />
            {value && (
                <button onClick={() => onChange('')}>
                    <X className="h-4 w-4" />
                </button>
            )}
        </div>
    );
}

2.6 GroupTree

Recursive tree component for host groups.

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 (
        <div>
            {childGroups.map(group => (
                <GroupItem
                    key={group.id}
                    group={group}
                    groups={groups}
                    hosts={hosts}
                    filter={filter}
                    depth={depth}
                />
            ))}
            {depth === 0 && <HostItem host={null} label="Ungrouped" />}
        </div>
    );
}

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 (
        <div>
            <div
                className="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-surface-hover"
                style={{ paddingLeft: `${12 + depth * 16}px` }}
                onClick={() => setExpanded(!expanded)}
            >
                {expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
                <div
                    className="w-2 h-2 rounded-full"
                    style={{ backgroundColor: color }}
                />
                <span className="text-sm font-medium">{group.name}</span>
                <span className="text-xs text-muted ml-auto">
                    {groupHosts.length}
                </span>
            </div>
            {expanded && (
                <GroupTree
                    groups={groups}
                    hosts={hosts}
                    filter={filter}
                    parent_id={group.id}
                    depth={depth + 1}
                />
            )}
        </div>
    );
}

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

interface HostItemProps {
    host: Host;
    isActive?: boolean;
    onClick?: () => void;
}

function HostItem({ host, isActive, onClick }: HostItemProps) {
    const status = useTerminalStore(state => state.getSessionStatus(host.id));
    
    return (
        <div
            className={cn(
                "flex items-center gap-2 px-3 py-1.5 cursor-pointer",
                "hover:bg-surface-hover",
                isActive && "bg-surface-active"
            )}
            onClick={onClick}
        >
            <StatusDot status={status} />
            <Server className="h-4 w-4 text-muted" />
            <span className="text-sm truncate flex-1">{host.name}</span>
            {host.is_favorite && (
                <Star className="h-3 w-3 text-yellow-500 fill-yellow-500" />
            )}
        </div>
    );
}

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 <div className={cn("w-2 h-2 rounded-full", color)} />;
}

2.8 TabBar

Horizontal tab bar for open terminals/SFTP sessions.

function TabBar() {
    const { tabs, activeTab, addTab, closeTab, setActiveTab } = useTabStore();
    
    return (
        <div className="flex items-center h-9 border-b bg-surface">
            {tabs.map(tab => (
                <Tab
                    key={tab.id}
                    tab={tab}
                    isActive={tab.id === activeTab}
                    onClick={() => setActiveTab(tab.id)}
                    onClose={() => closeTab(tab.id)}
                />
            ))}
            <NewTabButton onClick={() => addTab()} />
        </div>
    );
}

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 (
        <div
            className={cn(
                "flex items-center gap-2 px-3 h-full border-r cursor-pointer",
                isActive ? "bg-background" : "bg-surface hover:bg-surface-hover"
            )}
            onClick={onClick}
        >
            <StatusDot status={status} />
            <span className="text-sm truncate max-w-[120px]">{tab.title}</span>
            <button
                className="ml-1 hover:bg-surface-hover rounded p-0.5"
                onClick={e => { e.stopPropagation(); onClose(); }}
            >
                <X className="h-3 w-3" />
            </button>
        </div>
    );
}

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.

function MainContent() {
    const activeTab = useTabStore(state => state.getActiveTab());
    
    if (!activeTab) {
        return <HostListScreen />;
    }
    
    switch (activeTab.type) {
        case 'terminal':
            return <TerminalScreen tabId={activeTab.id} hostId={activeTab.host_id} />;
        case 'sftp':
            return <SFTPScreen tabId={activeTab.id} hostId={activeTab.host_id} />;
        default:
            return <HostListScreen />;
    }
}

2.10 HostListScreen

Default screen when no tab is active.

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 <EmptyState />;
    }
    
    return (
        <div className="flex-1 p-4">
            <div className="flex items-center justify-between mb-4">
                <h1 className="text-xl font-semibold">Hosts</h1>
                <div className="flex items-center gap-2">
                    <TagFilterBar />
                    <ViewToggle mode={viewMode} onChange={setViewMode} />
                    <Button onClick={() => openHostForm()}>
                        <Plus className="h-4 w-4 mr-1" /> New Host
                    </Button>
                </div>
            </div>
            {viewMode === 'list' ? (
                <HostList hosts={hosts} />
            ) : (
                <HostGrid hosts={hosts} />
            )}
        </div>
    );
}

function EmptyState() {
    return (
        <div className="flex flex-col items-center justify-center h-full text-muted">
            <Server className="h-16 w-16 mb-4 opacity-50" />
            <h2 className="text-lg font-medium mb-2">No hosts yet</h2>
            <p className="text-sm mb-4">Add your first SSH host to get started</p>
            <Button onClick={() => openHostForm()}>
                <Plus className="h-4 w-4 mr-1" /> Add Host
            </Button>
        </div>
    );
}

2.11 HostForm (Add/Edit)

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<HostForm>({
        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<Record<string, string>>({});
    
    return (
        <div className="max-w-lg mx-auto p-6">
            <h2 className="text-lg font-semibold mb-4">
                {host ? 'Edit Host' : 'New Host'}
            </h2>
            
            <div className="space-y-4">
                {/* Basic Info */}
                <Input
                    label="Name"
                    value={formData.name}
                    onChange={v => setFormData({ ...formData, name: v })}
                    error={errors.name}
                    placeholder="My Server"
                />
                <Input
                    label="Hostname"
                    value={formData.hostname}
                    onChange={v => setFormData({ ...formData, hostname: v })}
                    error={errors.hostname}
                    placeholder="192.168.1.100 or example.com"
                />
                <div className="flex gap-4">
                    <Input
                        label="Port"
                        type="number"
                        value={formData.port}
                        onChange={v => setFormData({ ...formData, port: parseInt(v) })}
                        error={errors.port}
                    />
                    <Input
                        label="Username"
                        value={formData.username}
                        onChange={v => setFormData({ ...formData, username: v })}
                        error={errors.username}
                        placeholder="root"
                    />
                </div>
                
                {/* Auth */}
                <AuthSection
                    type={formData.auth_type}
                    keyId={formData.key_id}
                    onTypeChange={v => setFormData({ ...formData, auth_type: v })}
                    onKeyChange={v => setFormData({ ...formData, key_id: v })}
                    onPasswordChange={v => setFormData({ ...formData, password: v })}
                />
                
                {/* Tags */}
                <TagsInput
                    tags={formData.tags}
                    onChange={tags => setFormData({ ...formData, tags })}
                />
                
                {/* Group */}
                <GroupPicker
                    value={formData.group_id}
                    onChange={id => setFormData({ ...formData, group_id: id })}
                />
                
                {/* Notes */}
                <Textarea
                    label="Notes"
                    value={formData.notes}
                    onChange={v => setFormData({ ...formData, notes: v })}
                    placeholder="Optional notes..."
                />
            </div>
            
            {/* Actions */}
            <div className="flex justify-end gap-2 mt-6">
                <Button variant="ghost" onClick={onCancel}>Cancel</Button>
                <Button onClick={() => handleSave()}>Save</Button>
            </div>
        </div>
    );
}

2.12 TerminalScreen

interface TerminalScreenProps {
    tabId: string;
    hostId: string;
}

function TerminalScreen({ tabId, hostId }: TerminalScreenProps) {
    const host = useHostStore(state => state.getHost(hostId));
    const [showSnippets, setShowSnippets] = useState(false);
    
    return (
        <div className="flex flex-col h-full">
            <TerminalToolbar
                host={host}
                onToggleSnippets={() => setShowSnippets(!showSnippets)}
                onReconnect={() => reconnect(tabId)}
            />
            <div className="flex-1 relative">
                <XTermWrapper tabId={tabId} hostId={hostId} />
            </div>
            <ConnectionStatusBar tabId={tabId} />
            {showSnippets && (
                <SnippetPanel
                    hostId={hostId}
                    onInsert={cmd => injectCommand(tabId, cmd)}
                    onClose={() => setShowSnippets(false)}
                />
            )}
        </div>
    );
}

2.13 XTermWrapper (Most Critical)

interface XTermWrapperProps {
    tabId: string;
    hostId: string;
}

function XTermWrapper({ tabId, hostId }: XTermWrapperProps) {
    const terminalRef = useRef<HTMLDivElement>(null);
    const termRef = useRef<Terminal | null>(null);
    const wsRef = useRef<WebSocket | null>(null);
    const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>('connecting');
    
    // Initialize terminal
    useEffect(() => {
        if (!terminalRef.current) return;
        
        const term = new Terminal({
            fontFamily: "'JetBrains Mono', monospace",
            fontSize: 14,
            theme: {
                background: '#1a1b26',
                foreground: '#c0caf5',
            },
            cursorBlink: true,
            scrollback: 10000,
        });
        
        const fitAddon = new FitAddon();
        const webLinksAddon = new WebLinksAddon();
        const searchAddon = new SearchAddon();
        
        term.loadAddon(fitAddon);
        term.loadAddon(webLinksAddon);
        term.loadAddon(searchAddon);
        
        term.open(terminalRef.current);
        fitAddon.fit();
        
        termRef.current = term;
        
        // Connect WebSocket
        connectWebSocket(term);
        
        return () => {
            wsRef.current?.close();
            term.dispose();
        };
    }, [hostId]);
    
    // Handle resize
    useEffect(() => {
        const term = termRef.current;
        if (!term) return;
        
        const resizeObserver = new ResizeObserver(() => {
            const fitAddon = term.getAddon FitAddon) // ... need to store ref
            // fitAddon.fit();
            // Send resize to server
            wsRef.current?.send(JSON.stringify({
                type: 'resize',
                cols: term.cols,
                rows: term.rows,
            }));
        });
        
        resizeObserver.observe(terminalRef.current!);
        return () => resizeObserver.disconnect();
    }, []);
    
    function connectWebSocket(term: Terminal) {
        const ws = new WebSocket(
            `ws://localhost:${PORT}/api/terminal/connect?host_id=${hostId}&cols=${term.cols}&rows=${term.rows}`
        );
        
        ws.binaryType = 'arraybuffer';
        
        ws.onopen = () => {
            setStatus('connected');
        };
        
        ws.onmessage = (event) => {
            if (event.data instanceof ArrayBuffer) {
                term.write(new Uint8Array(event.data));
            } else {
                // JSON control message
                const msg = JSON.parse(event.data);
                if (msg.type === 'error') {
                    setStatus('error');
                    term.write(`\r\n\x1b[31mError: ${msg.message}\x1b[0m\r\n`);
                }
            }
        };
        
        ws.onclose = () => {
            setStatus('disconnected');
        };
        
        ws.onerror = () => {
            setStatus('error');
        };
        
        // Terminal input -> WebSocket
        term.onData(data => {
            if (ws.readyState === WebSocket.OPEN) {
                ws.send(new TextEncoder().encode(data));
            }
        });
        
        wsRef.current = ws;
    }
    
    return (
        <div className="h-full relative">
            <div ref={terminalRef} className="h-full" />
            {status !== 'connected' && (
                <ConnectionOverlay status={status} onReconnect={() => reconnect()} />
            )}
        </div>
    );
}

Addons used:

  • @xterm/addon-fit — Auto-fit terminal to container
  • @xterm/addon-web-links — Clickable URLs
  • @xterm/addon-search — Ctrl+F search in terminal
  • @xterm/addon-webgl — WebGL renderer (performance)

2.14 SFTPScreen

interface SFTPScreenProps {
    tabId: string;
    hostId: string;
}

function SFTPScreen({ tabId, hostId }: SFTPScreenProps) {
    const [localPath, setLocalPath] = useState('~');
    const [remotePath, setRemotePath] = useState('/');
    const [localFiles, setLocalFiles] = useState<FileItem[]>([]);
    const [remoteFiles, setRemoteFiles] = useState<FileItem[]>([]);
    const [transfers, setTransfers] = useState<Transfer[]>([]);
    
    return (
        <div className="flex flex-col h-full">
            <SFTPToolbar
                hostId={hostId}
                onUpload={handleUpload}
                onRefresh={handleRefresh}
            />
            <div className="flex-1 flex">
                <LocalPane
                    path={localPath}
                    files={localFiles}
                    onNavigate={setLocalPath}
                    onSelect={handleLocalSelect}
                />
                <div className="w-px bg-border" />
                <RemotePane
                    path={remotePath}
                    files={remoteFiles}
                    onNavigate={setRemotePath}
                    onSelect={handleRemoteSelect}
                />
            </div>
            {transfers.length > 0 && (
                <TransferQueue transfers={transfers} />
            )}
        </div>
    );
}

Layout: Two side-by-side panes. Left = local filesystem. Right = remote filesystem.


2.15 KeychainScreen

function KeychainScreen() {
    const keys = useKeyStore(state => state.keys);
    const [showForm, setShowForm] = useState(false);
    const [selectedKey, setSelectedKey] = useState<KeyPair | null>(null);
    
    return (
        <div className="p-4">
            <div className="flex items-center justify-between mb-4">
                <h1 className="text-xl font-semibold">SSH Keys</h1>
                <div className="flex gap-2">
                    <Button variant="outline" onClick={() => handleImport()}>
                        Import
                    </Button>
                    <Button onClick={() => setShowForm(true)}>
                        <Plus className="h-4 w-4 mr-1" /> Generate
                    </Button>
                </div>
            </div>
            <KeyList keys={keys} onSelect={setSelectedKey} />
            {showForm && <KeyForm onSave={handleSave} onCancel={() => setShowForm(false)} />}
            {selectedKey && <KeyDetail key={selectedKey} onClose={() => setSelectedKey(null)} />}
        </div>
    );
}

2.16 SnippetsScreen

function SnippetsScreen() {
    const snippets = useSnippetStore(state => state.snippets);
    const [searchQuery, setSearchQuery] = useState('');
    const [selectedTag, setSelectedTag] = useState<string | null>(null);
    
    const filtered = snippets.filter(s => {
        if (searchQuery && !s.name.includes(searchQuery) && !s.command.includes(searchQuery)) {
            return false;
        }
        if (selectedTag && !s.tags.includes(selectedTag)) {
            return false;
        }
        return true;
    });
    
    return (
        <div className="p-4">
            <div className="flex items-center justify-between mb-4">
                <h1 className="text-xl font-semibold">Snippets</h1>
                <Button onClick={() => openSnippetForm()}>
                    <Plus className="h-4 w-4 mr-1" /> New Snippet
                </Button>
            </div>
            <SearchBar value={searchQuery} onChange={setSearchQuery} />
            <SnippetTagBar
                tags={getAllTags(snippets)}
                selected={selectedTag}
                onSelect={setSelectedTag}
            />
            <SnippetList snippets={filtered} />
        </div>
    );
}

2.17 PortForwardScreen

function PortForwardScreen() {
    const forwards = useForwardStore(state => state.forwards);
    
    return (
        <div className="p-4">
            <div className="flex items-center justify-between mb-4">
                <h1 className="text-xl font-semibold">Port Forwarding</h1>
                <Button onClick={() => openForwardForm()}>
                    <Plus className="h-4 w-4 mr-1" /> New Forward
                </Button>
            </div>
            <ForwardList forwards={forwards} />
        </div>
    );
}

2.18 SettingsScreen

function SettingsScreen() {
    const [activeTab, setActiveTab] = useState('appearance');
    
    return (
        <div className="flex h-full">
            <nav className="w-48 border-r p-4">
                {['appearance', 'terminal', 'connection', 'vault', 'general'].map(tab => (
                    <button
                        key={tab}
                        className={cn(
                            "block w-full text-left px-3 py-2 rounded-md text-sm",
                            activeTab === tab ? "bg-surface-active" : "hover:bg-surface"
                        )}
                        onClick={() => setActiveTab(tab)}
                    >
                        {tab.charAt(0).toUpperCase() + tab.slice(1)}
                    </button>
                ))}
            </nav>
            <div className="flex-1 p-6">
                {activeTab === 'appearance' && <AppearanceSettings />}
                {activeTab === 'terminal' && <TerminalSettings />}
                {activeTab === 'connection' && <ConnectionSettings />}
                {activeTab === 'vault' && <VaultSettings />}
                {activeTab === 'general' && <GeneralSettings />}
            </div>
        </div>
    );
}

3. State Management (Zustand Stores)

3.1 HostStore

interface HostStore {
    hosts: Host[];
    groups: HostGroup[];
    selectedHost: string | null;
    
    // Actions
    setHosts: (hosts: Host[]) => void;
    addHost: (host: Host) => void;
    updateHost: (id: string, updates: Partial<Host>) => void;
    deleteHost: (id: string) => void;
    setSelectedHost: (id: string | null) => void;
    
    // Computed
    getHost: (id: string) => Host | undefined;
    getHostsByGroup: (groupId: string) => Host[];
    getFavorites: () => Host[];
    searchHosts: (query: string) => Host[];
    
    // Groups
    setGroups: (groups: HostGroup[]) => void;
    addGroup: (group: HostGroup) => void;
    updateGroup: (id: string, updates: Partial<HostGroup>) => void;
    deleteGroup: (id: string) => void;
}

3.2 TabStore

interface TabStore {
    tabs: Tab[];
    activeTab: string | null;
    
    // Actions
    addTab: (tab: Tab) => void;
    closeTab: (id: string) => void;
    setActiveTab: (id: string) => void;
    updateTab: (id: string, updates: Partial<Tab>) => void;
    
    // Computed
    getActiveTab: () => Tab | undefined;
    getTabCount: () => number;
}

3.3 TerminalStore

interface TerminalStore {
    sessions: Map<string, Session>;
    
    // Actions
    addSession: (tabId: string, session: Session) => void;
    removeSession: (tabId: string) => void;
    updateSession: (tabId: string, updates: Partial<Session>) => void;
    
    // Computed
    getSessionStatus: (hostId: string) => string;
}

3.4 VaultStore

interface VaultStore {
    isLocked: boolean;
    encryptionEnabled: boolean;
    
    // Actions
    unlock: (password: string) => Promise<void>;
    lock: () => void;
    setEncryption: (enabled: boolean) => void;
}

3.5 UIStore

interface UIStore {
    sidebarOpen: boolean;
    sidebarWidth: number;
    theme: 'dark' | 'light' | 'high-contrast';
    
    // Actions
    toggleSidebar: () => void;
    setSidebarWidth: (width: number) => void;
    setTheme: (theme: string) => void;
}

4. Custom Hooks

useTerminal

function useTerminal(tabId: string) {
    const terminal = useRef<Terminal | null>(null);
    const ws = useRef<WebSocket | null>(null);
    const [status, setStatus] = useState<string>('disconnected');
    
    const connect = useCallback((hostId: string) => {
        // Create terminal, connect WebSocket
    }, []);
    
    const disconnect = useCallback(() => {
        // Close WebSocket, dispose terminal
    }, []);
    
    const sendInput = useCallback((data: string) => {
        // Send to WebSocket
    }, []);
    
    const resize = useCallback((cols: number, rows: number) => {
        // Send resize to WebSocket
    }, []);
    
    return { terminal, status, connect, disconnect, sendInput, resize };
}

useSSH

function useSSH(hostId: string) {
    const [connected, setConnected] = useState(false);
    const [error, setError] = useState<string | null>(null);
    
    const connect = useCallback(async () => {
        // REST API: GET /api/hosts/:id
        // WebSocket: ws://localhost:PORT/api/terminal/connect?host_id=xxx
    }, [hostId]);
    
    const execute = useCallback(async (cmd: string) => {
        // Send command via WebSocket, collect output
    }, []);
    
    return { connected, error, connect, execute };
}

useSFTP

function useSFTP(hostId: string) {
    const [files, setFiles] = useState<FileItem[]>([]);
    const [loading, setLoading] = useState(false);
    
    const list = useCallback(async (path: string) => {
        setLoading(true);
        const res = await fetch(`/api/sftp/ls?host_id=${hostId}&path=${path}`);
        const data = await res.json();
        setFiles(data.items);
        setLoading(false);
    }, [hostId]);
    
    const upload = useCallback(async (file: File, path: string) => {
        // POST /api/sftp/upload
    }, [hostId]);
    
    const download = useCallback(async (path: string) => {
        // GET /api/sftp/download?host_id=xxx&path=xxx
    }, [hostId]);
    
    return { files, loading, list, upload, download };
}

5. Routing

// app/frontend/src/router.tsx
import { createBrowserRouter } from 'react-router-dom';

export const router = createBrowserRouter([
    {
        path: '/',
        element: <MainLayout />,
        children: [
            { index: true, element: <HostListScreen /> },
            { path: 'hosts', element: <HostListScreen /> },
            { path: 'hosts/new', element: <HostForm /> },
            { path: 'hosts/:id/edit', element: <HostForm /> },
            { path: 'terminal/:tabId', element: <TerminalScreen /> },
            { path: 'sftp/:tabId', element: <SFTPScreen /> },
            { path: 'keys', element: <KeychainScreen /> },
            { path: 'snippets', element: <SnippetsScreen /> },
            { path: 'forwards', element: <PortForwardScreen /> },
            { path: 'workspaces', element: <WorkspaceScreen /> },
            { path: 'settings', element: <SettingsScreen /> },
        ],
    },
]);

6. Keyboard Shortcuts

Shortcut Action Context
Cmd/Ctrl + N New host Global
Cmd/Ctrl + K Focus search Global
Cmd/Ctrl + D Toggle dark mode Global
Cmd/Ctrl + S Save Host form
Esc Cancel / Close modal Global
Cmd/Ctrl + 1-9 Switch to tab 1-9 Global
Cmd/Ctrl + W Close current tab Global
Cmd/Ctrl + T New terminal tab Global
Ctrl + Shift + F Search in terminal Terminal
Ctrl + Shift + C Copy selection Terminal
Ctrl + Shift + V Paste Terminal
Delete Delete selected Host list, SFTP
F2 Rename selected SFTP
Enter Connect to host Host list
Ctrl + E Edit host Host list