1143 lines
33 KiB
Markdown
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 |
|