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

1143 lines
33 KiB
Markdown

# 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
```typescript
// 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).
```typescript
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.
```typescript
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.
```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 (
<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).
---
### 2.5 SearchBar
```typescript
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.
```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 (
<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
```typescript
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.
```typescript
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.
```typescript
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.
```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 <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)
```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<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
```typescript
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)
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
interface VaultStore {
isLocked: boolean;
encryptionEnabled: boolean;
// Actions
unlock: (password: string) => Promise<void>;
lock: () => void;
setEncryption: (enabled: boolean) => void;
}
```
### 3.5 UIStore
```typescript
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
```typescript
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
```typescript
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
```typescript
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
```typescript
// 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 |