diff --git a/src/renderer/components/settings/sections/WorkspaceSection.tsx b/src/renderer/components/settings/sections/WorkspaceSection.tsx new file mode 100644 index 00000000..078621e1 --- /dev/null +++ b/src/renderer/components/settings/sections/WorkspaceSection.tsx @@ -0,0 +1,393 @@ +/** + * WorkspaceSection - Settings section for managing saved SSH connection profiles. + * + * Provides CRUD UI for: + * - Listing saved SSH profiles + * - Adding new profiles (name, host, port, username, auth method) + * - Inline editing existing profile fields + * - Deleting profiles with confirmation + * + * Profile changes persist via ConfigManager and trigger context list refresh. + */ + +import { useCallback, useEffect, useState } from 'react'; + +import { useStore } from '@renderer/store'; +import { Edit2, Loader2, Plus, Save, Server, Trash2, X } from 'lucide-react'; + +import { SettingsSectionHeader } from '../components/SettingsSectionHeader'; + +import type { SshAuthMethod, SshConnectionProfile } from '@shared/types'; + +const inputClass = 'w-full rounded-md border px-3 py-1.5 text-sm focus:outline-none focus:ring-1'; +const inputStyle = { + backgroundColor: 'var(--color-surface-raised)', + borderColor: 'var(--color-border)', + color: 'var(--color-text)', +}; + +const defaultForm = { + name: '', + host: '', + port: '22', + username: '', + authMethod: 'auto' as SshAuthMethod, + privateKeyPath: '', +}; + +export const WorkspaceSection = (): React.JSX.Element => { + const [profiles, setProfiles] = useState([]); + const [loading, setLoading] = useState(true); + const [editingId, setEditingId] = useState(null); + const [showAddForm, setShowAddForm] = useState(false); + + // Form state + const [formName, setFormName] = useState(defaultForm.name); + const [formHost, setFormHost] = useState(defaultForm.host); + const [formPort, setFormPort] = useState(defaultForm.port); + const [formUsername, setFormUsername] = useState(defaultForm.username); + const [formAuthMethod, setFormAuthMethod] = useState(defaultForm.authMethod); + const [formPrivateKeyPath, setFormPrivateKeyPath] = useState(defaultForm.privateKeyPath); + + const resetForm = useCallback(() => { + setFormName(defaultForm.name); + setFormHost(defaultForm.host); + setFormPort(defaultForm.port); + setFormUsername(defaultForm.username); + setFormAuthMethod(defaultForm.authMethod); + setFormPrivateKeyPath(defaultForm.privateKeyPath); + }, []); + + const loadProfiles = useCallback(async () => { + try { + const config = await window.electronAPI.config.get(); + // AppConfig type doesn't include ssh field, but ConfigManager returns it at runtime + const loaded = (config as unknown as { ssh?: { profiles?: SshConnectionProfile[] } }).ssh; + setProfiles(loaded?.profiles ?? []); + } catch (error) { + console.error('[WorkspaceSection] Failed to load profiles:', error); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void loadProfiles(); + }, [loadProfiles]); + + // Populate form when editing starts + useEffect(() => { + if (editingId) { + const profile = profiles.find((p) => p.id === editingId); + if (profile) { + setFormName(profile.name); + setFormHost(profile.host); + setFormPort(String(profile.port)); + setFormUsername(profile.username); + setFormAuthMethod(profile.authMethod); + setFormPrivateKeyPath(profile.privateKeyPath ?? ''); + } + } + }, [editingId, profiles]); + + const handleAdd = async (): Promise => { + const newProfile: SshConnectionProfile = { + id: crypto.randomUUID(), + name: formName.trim(), + host: formHost.trim(), + port: parseInt(formPort, 10) || 22, + username: formUsername.trim(), + authMethod: formAuthMethod, + privateKeyPath: formAuthMethod === 'privateKey' ? formPrivateKeyPath.trim() : undefined, + }; + + await window.electronAPI.config.update('ssh', { profiles: [...profiles, newProfile] }); + await loadProfiles(); + resetForm(); + setShowAddForm(false); + void useStore.getState().fetchAvailableContexts(); + }; + + const handleEdit = async (): Promise => { + const updatedProfiles = profiles.map((p) => + p.id === editingId + ? { + ...p, + name: formName.trim(), + host: formHost.trim(), + port: parseInt(formPort, 10) || 22, + username: formUsername.trim(), + authMethod: formAuthMethod, + privateKeyPath: formAuthMethod === 'privateKey' ? formPrivateKeyPath.trim() : undefined, + } + : p + ); + + await window.electronAPI.config.update('ssh', { profiles: updatedProfiles }); + await loadProfiles(); + setEditingId(null); + resetForm(); + void useStore.getState().fetchAvailableContexts(); + }; + + const handleDelete = async (id: string): Promise => { + const profile = profiles.find((p) => p.id === id); + if (!profile) return; + + const confirmed = window.confirm(`Delete profile "${profile.name}"?`); + if (!confirmed) return; + + const filtered = profiles.filter((p) => p.id !== id); + await window.electronAPI.config.update('ssh', { profiles: filtered }); + await loadProfiles(); + void useStore.getState().fetchAvailableContexts(); + }; + + const isFormValid = + formName.trim() !== '' && formHost.trim() !== '' && formUsername.trim() !== ''; + + const renderForm = (onSave: () => Promise, onCancel: () => void): React.JSX.Element => ( +
+
+
+ + setFormName(e.target.value)} + placeholder="My Server" + className={inputClass} + style={inputStyle} + /> +
+
+ + setFormHost(e.target.value)} + placeholder="hostname or IP" + className={inputClass} + style={inputStyle} + /> +
+
+ +
+
+ + setFormPort(e.target.value)} + placeholder="22" + className={inputClass} + style={inputStyle} + /> +
+
+ + setFormUsername(e.target.value)} + placeholder="user" + className={inputClass} + style={inputStyle} + /> +
+
+ +
+ + +
+ + {formAuthMethod === 'privateKey' && ( +
+ + setFormPrivateKeyPath(e.target.value)} + placeholder="~/.ssh/id_rsa" + className={inputClass} + style={inputStyle} + /> +
+ )} + +
+ + +
+
+ ); + + return ( +
+ +

+ Save SSH connection profiles for quick reconnection +

+ + {loading && ( +
+ + Loading profiles... +
+ )} + + {!loading && profiles.length === 0 && !showAddForm && ( +
+ +

No saved profiles

+

Add an SSH profile to connect quickly

+
+ )} + + {!loading && ( +
+ {profiles.map((profile) => + editingId === profile.id ? ( +
+ {renderForm(handleEdit, () => { + setEditingId(null); + resetForm(); + })} +
+ ) : ( +
+ +
+

+ {profile.name} +

+

+ {profile.username}@{profile.host}:{profile.port} +

+
+ + {profile.authMethod} + + + +
+ ) + )} +
+ )} + + {!loading && ( +
+ {showAddForm ? ( + renderForm(handleAdd, () => { + setShowAddForm(false); + resetForm(); + }) + ) : ( + + )} +
+ )} +
+ ); +};