diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index cdd889ee..7ca5277f 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -10,6 +10,7 @@ import type { HttpServerConfig, NotificationConfig, NotificationTrigger, + SshPersistConfig, } from '../services'; type ConfigSection = keyof AppConfig; @@ -30,6 +31,7 @@ export type ConfigUpdateValidationResult = | ValidationSuccess<'general'> | ValidationSuccess<'display'> | ValidationSuccess<'httpServer'> + | ValidationSuccess<'ssh'> | ValidationFailure; const VALID_SECTIONS = new Set([ @@ -37,6 +39,7 @@ const VALID_SECTIONS = new Set([ 'general', 'display', 'httpServer', + 'ssh', ]); const MAX_SNOOZE_MINUTES = 24 * 60; @@ -321,6 +324,70 @@ function validateHttpServerSection( }; } +function isValidSshProfile(profile: unknown): boolean { + if (!isPlainObject(profile)) return false; + if (typeof profile.id !== 'string' || profile.id.trim().length === 0) return false; + if (typeof profile.name !== 'string') return false; + if (typeof profile.host !== 'string') return false; + if (typeof profile.port !== 'number') return false; + if (typeof profile.username !== 'string') return false; + const validMethods = ['password', 'privateKey', 'agent', 'auto']; + if (!validMethods.includes(profile.authMethod as string)) return false; + return true; +} + +function validateSshSection(data: unknown): ValidationSuccess<'ssh'> | ValidationFailure { + if (!isPlainObject(data)) { + return { valid: false, error: 'ssh update must be an object' }; + } + + const allowedKeys: (keyof SshPersistConfig)[] = [ + 'lastConnection', + 'autoReconnect', + 'profiles', + 'lastActiveContextId', + ]; + + const result: Partial = {}; + + for (const [key, value] of Object.entries(data)) { + if (!allowedKeys.includes(key as keyof SshPersistConfig)) { + return { valid: false, error: `ssh.${key} is not a valid setting` }; + } + + switch (key as keyof SshPersistConfig) { + case 'autoReconnect': + if (typeof value !== 'boolean') { + return { valid: false, error: 'ssh.autoReconnect must be a boolean' }; + } + result.autoReconnect = value; + break; + case 'lastActiveContextId': + if (typeof value !== 'string') { + return { valid: false, error: 'ssh.lastActiveContextId must be a string' }; + } + result.lastActiveContextId = value; + break; + case 'lastConnection': + if (value !== null && !isPlainObject(value)) { + return { valid: false, error: 'ssh.lastConnection must be an object or null' }; + } + result.lastConnection = value as SshPersistConfig['lastConnection']; + break; + case 'profiles': + if (!Array.isArray(value) || !value.every(isValidSshProfile)) { + return { valid: false, error: 'ssh.profiles must be a valid profile array' }; + } + result.profiles = value as SshPersistConfig['profiles']; + break; + default: + return { valid: false, error: `Unsupported ssh key: ${key}` }; + } + } + + return { valid: true, section: 'ssh', data: result }; +} + export function validateConfigUpdatePayload( section: unknown, data: unknown @@ -328,7 +395,7 @@ export function validateConfigUpdatePayload( if (typeof section !== 'string' || !VALID_SECTIONS.has(section as ConfigSection)) { return { valid: false, - error: 'Section must be one of: notifications, general, display, httpServer', + error: 'Section must be one of: notifications, general, display, httpServer, ssh', }; } @@ -341,6 +408,8 @@ export function validateConfigUpdatePayload( return validateDisplaySection(data); case 'httpServer': return validateHttpServerSection(data); + case 'ssh': + return validateSshSection(data); default: return { valid: false, error: 'Invalid section' }; } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 25089e2b..aaa32127 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -4,6 +4,7 @@ import { ContextSwitchOverlay } from './components/common/ContextSwitchOverlay'; import { ErrorBoundary } from './components/common/ErrorBoundary'; import { TabbedLayout } from './components/layout/TabbedLayout'; import { useTheme } from './hooks/useTheme'; +import { api } from './api'; import { initializeNotificationListeners, useStore } from './store'; export const App = (): React.JSX.Element => { @@ -26,8 +27,8 @@ export const App = (): React.JSX.Element => { // Refresh available contexts when SSH connection state changes useEffect(() => { - if (!window.electronAPI.ssh?.onStatus) return; - const cleanup = window.electronAPI.ssh.onStatus(() => { + if (!api.ssh?.onStatus) return; + const cleanup = api.ssh.onStatus(() => { void useStore.getState().fetchAvailableContexts(); }); return cleanup; diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 07544248..2adf1a09 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -102,6 +102,10 @@ export class HttpAPIClient implements ElectronAPI { private async parseJson(res: Response): Promise { const text = await res.text(); + if (!res.ok) { + const parsed = JSON.parse(text) as { error?: string }; + throw new Error(parsed.error ?? `HTTP ${res.status}`); + } return JSON.parse(text, HttpAPIClient.reviveDates) as T; } diff --git a/src/renderer/components/settings/components/SettingsSelect.tsx b/src/renderer/components/settings/components/SettingsSelect.tsx index f8b53df0..009ec40e 100644 --- a/src/renderer/components/settings/components/SettingsSelect.tsx +++ b/src/renderer/components/settings/components/SettingsSelect.tsx @@ -13,6 +13,8 @@ interface SettingsSelectProps { readonly onChange: (value: T) => void; readonly disabled?: boolean; readonly dropUp?: boolean; + /** When true, trigger spans full width and dropdown aligns left */ + readonly fullWidth?: boolean; } export const SettingsSelect = ({ @@ -21,6 +23,7 @@ export const SettingsSelect = ({ onChange, disabled = false, dropUp = false, + fullWidth = false, }: SettingsSelectProps): React.JSX.Element => { const [isOpen, setIsOpen] = useState(false); const containerRef = useRef(null); @@ -54,7 +57,7 @@ export const SettingsSelect = ({ type="button" onClick={() => !disabled && setIsOpen(!isOpen)} disabled={disabled} - className={`flex h-8 min-w-[140px] items-center justify-between gap-2 rounded-md border bg-transparent px-2 text-sm transition-all duration-150 focus:outline-none focus:ring-1 focus:ring-zinc-700 ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'} ${isOpen ? 'ring-1 ring-zinc-700' : ''} `} + className={`flex h-8 items-center justify-between gap-2 rounded-md border bg-transparent px-2 text-sm transition-all duration-150 focus:outline-none focus:ring-1 focus:ring-zinc-700 ${fullWidth ? 'w-full' : 'min-w-[140px]'} ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'} ${isOpen ? 'ring-1 ring-zinc-700' : ''} `} style={{ color: 'var(--color-text-secondary)', borderColor: isOpen ? 'var(--color-border)' : 'var(--color-border-subtle)', @@ -70,7 +73,7 @@ export const SettingsSelect = ({ {/* Dropdown Menu */} {isOpen && (
{ const connectionState = useStore((s) => s.connectionState); @@ -45,11 +59,25 @@ export const ConnectionSection = (): React.JSX.Element => { const hostInputRef = useRef(null); const dropdownRef = useRef(null); - // Fetch SSH config hosts and load last connection on mount + // Saved profiles + const [savedProfiles, setSavedProfiles] = useState([]); + + const loadProfiles = useCallback(async () => { + try { + const config = await api.config.get(); + const loaded = config.ssh; + setSavedProfiles(loaded?.profiles ?? []); + } catch { + // ignore + } + }, []); + + // Fetch SSH config hosts, saved profiles, and load last connection on mount useEffect(() => { void fetchSshConfigHosts(); void loadLastConnection(); - }, [fetchSshConfigHosts, loadLastConnection]); + void loadProfiles(); + }, [fetchSshConfigHosts, loadLastConnection, loadProfiles]); // Pre-fill form from saved connection config when it arrives (one-time on mount). // setState in effect is intentional: lastSshConfig loads async from IPC, so we can't @@ -104,6 +132,16 @@ export const ConnectionSection = (): React.JSX.Element => { setTestResult(null); }; + const handleSelectProfile = (profile: SshConnectionProfile): void => { + setHost(profile.host); + setPort(String(profile.port)); + setUsername(profile.username); + setAuthMethod(profile.authMethod); + if (profile.privateKeyPath) setPrivateKeyPath(profile.privateKeyPath); + setPassword(''); + setTestResult(null); + }; + const buildConfig = (): SshConnectionConfig => ({ host, port: parseInt(port, 10) || 22, @@ -196,6 +234,35 @@ export const ConnectionSection = (): React.JSX.Element => { )} + {/* Saved Profiles */} + {!isConnected && savedProfiles.length > 0 && ( +
+

+ Saved Profiles +

+
+ {savedProfiles.map((profile) => ( + + ))} +
+
+ )} + {/* SSH Connection Form */} {!isConnected && (
@@ -292,17 +359,12 @@ export const ConnectionSection = (): React.JSX.Element => { - + options={authMethodOptions} + onChange={setAuthMethod} + fullWidth + />
{authMethod === 'privateKey' && ( diff --git a/src/renderer/components/settings/sections/WorkspaceSection.tsx b/src/renderer/components/settings/sections/WorkspaceSection.tsx index e17c0b37..9ba78ad8 100644 --- a/src/renderer/components/settings/sections/WorkspaceSection.tsx +++ b/src/renderer/components/settings/sections/WorkspaceSection.tsx @@ -10,12 +10,14 @@ * Profile changes persist via ConfigManager and trigger context list refresh. */ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; -import { ChevronDown, Edit2, Loader2, Plus, Save, Server, Trash2, X } from 'lucide-react'; +import { Edit2, Loader2, Plus, Save, Server, Trash2, X } from 'lucide-react'; import { SettingsSectionHeader } from '../components/SettingsSectionHeader'; +import { SettingsSelect } from '../components/SettingsSelect'; import type { SshAuthMethod, SshConnectionProfile } from '@shared/types'; @@ -26,7 +28,7 @@ const inputStyle = { color: 'var(--color-text)', }; -const authMethodOptions: { value: SshAuthMethod; label: string }[] = [ +const authMethodOptions: readonly { value: SshAuthMethod; label: string }[] = [ { value: 'auto', label: 'Auto (from SSH Config)' }, { value: 'agent', label: 'SSH Agent' }, { value: 'privateKey', label: 'Private Key' }, @@ -42,101 +44,6 @@ const defaultForm = { privateKeyPath: '', }; -const AuthMethodSelect = ({ - value, - onChange, -}: { - value: SshAuthMethod; - onChange: (v: SshAuthMethod) => void; -}): React.JSX.Element => { - const [isOpen, setIsOpen] = useState(false); - const containerRef = useRef(null); - - useEffect(() => { - if (!isOpen) return; - const handleClickOutside = (e: MouseEvent): void => { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - setIsOpen(false); - } - }; - const handleEscape = (e: KeyboardEvent): void => { - if (e.key === 'Escape') setIsOpen(false); - }; - document.addEventListener('mousedown', handleClickOutside); - document.addEventListener('keydown', handleEscape); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - document.removeEventListener('keydown', handleEscape); - }; - }, [isOpen]); - - const selectedLabel = authMethodOptions.find((o) => o.value === value)?.label ?? value; - - return ( -
- -
- - {isOpen && ( -
- {authMethodOptions.map((opt) => ( - - ))} -
- )} -
-
- ); -}; - export const WorkspaceSection = (): React.JSX.Element => { const [profiles, setProfiles] = useState([]); const [loading, setLoading] = useState(true); @@ -162,9 +69,9 @@ export const WorkspaceSection = (): React.JSX.Element => { const loadProfiles = useCallback(async () => { try { - const config = await window.electronAPI.config.get(); + const config = await api.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; + const loaded = config.ssh; setProfiles(loaded?.profiles ?? []); } catch (error) { console.error('[WorkspaceSection] Failed to load profiles:', error); @@ -203,7 +110,7 @@ export const WorkspaceSection = (): React.JSX.Element => { privateKeyPath: formAuthMethod === 'privateKey' ? formPrivateKeyPath.trim() : undefined, }; - await window.electronAPI.config.update('ssh', { profiles: [...profiles, newProfile] }); + await api.config.update('ssh', { profiles: [...profiles, newProfile] }); await loadProfiles(); resetForm(); setShowAddForm(false); @@ -225,7 +132,7 @@ export const WorkspaceSection = (): React.JSX.Element => { : p ); - await window.electronAPI.config.update('ssh', { profiles: updatedProfiles }); + await api.config.update('ssh', { profiles: updatedProfiles }); await loadProfiles(); setEditingId(null); resetForm(); @@ -240,7 +147,7 @@ export const WorkspaceSection = (): React.JSX.Element => { if (!confirmed) return; const filtered = profiles.filter((p) => p.id !== id); - await window.electronAPI.config.update('ssh', { profiles: filtered }); + await api.config.update('ssh', { profiles: filtered }); await loadProfiles(); void useStore.getState().fetchAvailableContexts(); }; @@ -314,7 +221,17 @@ export const WorkspaceSection = (): React.JSX.Element => {
- +
+ + +
{formAuthMethod === 'privateKey' && (
diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index fcfdb79c..798c7491 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -304,8 +304,8 @@ export function initializeNotificationListeners(): () => void { } // Listen for context changes from main process (e.g., SSH disconnect) - if (window.electronAPI.context?.onChanged) { - const cleanup = window.electronAPI.context.onChanged((_event: unknown, data: unknown) => { + if (api.context?.onChanged) { + const cleanup = api.context.onChanged((_event: unknown, data: unknown) => { const { id } = data as { id: string }; const currentContextId = useStore.getState().activeContextId; if (id !== currentContextId) { diff --git a/src/renderer/store/slices/contextSlice.ts b/src/renderer/store/slices/contextSlice.ts index 2e6e8ba0..6f62ceab 100644 --- a/src/renderer/store/slices/contextSlice.ts +++ b/src/renderer/store/slices/contextSlice.ts @@ -5,6 +5,7 @@ * between local and SSH contexts, with IndexedDB persistence and TTL. */ +import { api } from '@renderer/api'; import { contextStorage } from '@renderer/services/contextStorage'; import { getFullResetState } from '../utils/stateResetHelpers'; @@ -243,7 +244,7 @@ export const createContextSlice: StateCreator = } // Fetch active context from main process - const activeContextId = await window.electronAPI.context.getActive(); + const activeContextId = await api.context.getActive(); set({ contextSnapshotsReady: true, @@ -261,7 +262,7 @@ export const createContextSlice: StateCreator = // Fetch list of available contexts (local + SSH) fetchAvailableContexts: async () => { try { - const result = await window.electronAPI.context.list(); + const result = await api.context.list(); set({ availableContexts: result }); } catch (error) { console.error('[contextSlice] Failed to fetch available contexts:', error); @@ -290,12 +291,12 @@ export const createContextSlice: StateCreator = await contextStorage.saveSnapshot(state.activeContextId, currentSnapshot); // Step 2: Switch main process context - await window.electronAPI.context.switch(targetContextId); + await api.context.switch(targetContextId); // Step 3: Fetch fresh data from target context const [freshProjects, freshRepoGroups] = await Promise.all([ - window.electronAPI.getProjects(), - window.electronAPI.getRepositoryGroups(), + api.getProjects(), + api.getRepositoryGroups(), ]); // Step 4: Attempt to restore snapshot for target context diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts index 970948c3..7428c2b6 100644 --- a/src/shared/types/notifications.ts +++ b/src/shared/types/notifications.ts @@ -275,6 +275,31 @@ export interface AppConfig { /** Pinned sessions per project. Key is projectId, value is array of pinned sessions */ pinnedSessions: Record; }; + /** SSH connection settings */ + ssh?: { + /** Last used connection details */ + lastConnection: { + host: string; + port: number; + username: string; + authMethod: 'password' | 'privateKey' | 'agent' | 'auto'; + privateKeyPath?: string; + } | null; + /** Whether to auto-reconnect on launch */ + autoReconnect: boolean; + /** Saved SSH connection profiles */ + profiles: { + id: string; + name: string; + host: string; + port: number; + username: string; + authMethod: 'password' | 'privateKey' | 'agent' | 'auto'; + privateKeyPath?: string; + }[]; + /** Last active context ID */ + lastActiveContextId: string; + }; /** HTTP sidecar server settings for iframe embedding */ httpServer?: { /** Whether the HTTP server is enabled */