feat(ssh): enhance SSH configuration management and validation
- Added SSH section validation to the configuration update process, including validation for SSH profiles. - Implemented functions to validate SSH profiles and the overall SSH section structure. - Integrated SSH profile management into the ConnectionSection, allowing users to select saved profiles for quick connection setup. - Updated the WorkspaceSection to manage SSH profiles with full CRUD functionality, ensuring persistence through the ConfigManager. - Refactored API calls to use a unified `api` module for better maintainability. This commit significantly improves the SSH management experience by providing robust validation and user-friendly profile handling.
This commit is contained in:
parent
b200561ac1
commit
575ced5d99
9 changed files with 213 additions and 131 deletions
|
|
@ -10,6 +10,7 @@ import type {
|
||||||
HttpServerConfig,
|
HttpServerConfig,
|
||||||
NotificationConfig,
|
NotificationConfig,
|
||||||
NotificationTrigger,
|
NotificationTrigger,
|
||||||
|
SshPersistConfig,
|
||||||
} from '../services';
|
} from '../services';
|
||||||
|
|
||||||
type ConfigSection = keyof AppConfig;
|
type ConfigSection = keyof AppConfig;
|
||||||
|
|
@ -30,6 +31,7 @@ export type ConfigUpdateValidationResult =
|
||||||
| ValidationSuccess<'general'>
|
| ValidationSuccess<'general'>
|
||||||
| ValidationSuccess<'display'>
|
| ValidationSuccess<'display'>
|
||||||
| ValidationSuccess<'httpServer'>
|
| ValidationSuccess<'httpServer'>
|
||||||
|
| ValidationSuccess<'ssh'>
|
||||||
| ValidationFailure;
|
| ValidationFailure;
|
||||||
|
|
||||||
const VALID_SECTIONS = new Set<ConfigSection>([
|
const VALID_SECTIONS = new Set<ConfigSection>([
|
||||||
|
|
@ -37,6 +39,7 @@ const VALID_SECTIONS = new Set<ConfigSection>([
|
||||||
'general',
|
'general',
|
||||||
'display',
|
'display',
|
||||||
'httpServer',
|
'httpServer',
|
||||||
|
'ssh',
|
||||||
]);
|
]);
|
||||||
const MAX_SNOOZE_MINUTES = 24 * 60;
|
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<SshPersistConfig> = {};
|
||||||
|
|
||||||
|
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(
|
export function validateConfigUpdatePayload(
|
||||||
section: unknown,
|
section: unknown,
|
||||||
data: unknown
|
data: unknown
|
||||||
|
|
@ -328,7 +395,7 @@ export function validateConfigUpdatePayload(
|
||||||
if (typeof section !== 'string' || !VALID_SECTIONS.has(section as ConfigSection)) {
|
if (typeof section !== 'string' || !VALID_SECTIONS.has(section as ConfigSection)) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
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);
|
return validateDisplaySection(data);
|
||||||
case 'httpServer':
|
case 'httpServer':
|
||||||
return validateHttpServerSection(data);
|
return validateHttpServerSection(data);
|
||||||
|
case 'ssh':
|
||||||
|
return validateSshSection(data);
|
||||||
default:
|
default:
|
||||||
return { valid: false, error: 'Invalid section' };
|
return { valid: false, error: 'Invalid section' };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { ContextSwitchOverlay } from './components/common/ContextSwitchOverlay';
|
||||||
import { ErrorBoundary } from './components/common/ErrorBoundary';
|
import { ErrorBoundary } from './components/common/ErrorBoundary';
|
||||||
import { TabbedLayout } from './components/layout/TabbedLayout';
|
import { TabbedLayout } from './components/layout/TabbedLayout';
|
||||||
import { useTheme } from './hooks/useTheme';
|
import { useTheme } from './hooks/useTheme';
|
||||||
|
import { api } from './api';
|
||||||
import { initializeNotificationListeners, useStore } from './store';
|
import { initializeNotificationListeners, useStore } from './store';
|
||||||
|
|
||||||
export const App = (): React.JSX.Element => {
|
export const App = (): React.JSX.Element => {
|
||||||
|
|
@ -26,8 +27,8 @@ export const App = (): React.JSX.Element => {
|
||||||
|
|
||||||
// Refresh available contexts when SSH connection state changes
|
// Refresh available contexts when SSH connection state changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.electronAPI.ssh?.onStatus) return;
|
if (!api.ssh?.onStatus) return;
|
||||||
const cleanup = window.electronAPI.ssh.onStatus(() => {
|
const cleanup = api.ssh.onStatus(() => {
|
||||||
void useStore.getState().fetchAvailableContexts();
|
void useStore.getState().fetchAvailableContexts();
|
||||||
});
|
});
|
||||||
return cleanup;
|
return cleanup;
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,10 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
|
|
||||||
private async parseJson<T>(res: Response): Promise<T> {
|
private async parseJson<T>(res: Response): Promise<T> {
|
||||||
const text = await res.text();
|
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;
|
return JSON.parse(text, HttpAPIClient.reviveDates) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ interface SettingsSelectProps<T extends string | number> {
|
||||||
readonly onChange: (value: T) => void;
|
readonly onChange: (value: T) => void;
|
||||||
readonly disabled?: boolean;
|
readonly disabled?: boolean;
|
||||||
readonly dropUp?: boolean;
|
readonly dropUp?: boolean;
|
||||||
|
/** When true, trigger spans full width and dropdown aligns left */
|
||||||
|
readonly fullWidth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SettingsSelect = <T extends string | number>({
|
export const SettingsSelect = <T extends string | number>({
|
||||||
|
|
@ -21,6 +23,7 @@ export const SettingsSelect = <T extends string | number>({
|
||||||
onChange,
|
onChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
dropUp = false,
|
dropUp = false,
|
||||||
|
fullWidth = false,
|
||||||
}: SettingsSelectProps<T>): React.JSX.Element => {
|
}: SettingsSelectProps<T>): React.JSX.Element => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -54,7 +57,7 @@ export const SettingsSelect = <T extends string | number>({
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||||
disabled={disabled}
|
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={{
|
style={{
|
||||||
color: 'var(--color-text-secondary)',
|
color: 'var(--color-text-secondary)',
|
||||||
borderColor: isOpen ? 'var(--color-border)' : 'var(--color-border-subtle)',
|
borderColor: isOpen ? 'var(--color-border)' : 'var(--color-border-subtle)',
|
||||||
|
|
@ -70,7 +73,7 @@ export const SettingsSelect = <T extends string | number>({
|
||||||
{/* Dropdown Menu */}
|
{/* Dropdown Menu */}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div
|
<div
|
||||||
className={`absolute right-0 z-50 min-w-max overflow-hidden rounded-md border py-1 shadow-xl shadow-black/20 ${dropUp ? 'bottom-full mb-1' : 'top-full mt-1'}`}
|
className={`absolute z-50 min-w-max overflow-hidden rounded-md border py-1 shadow-xl shadow-black/20 ${fullWidth ? 'inset-x-0' : 'right-0'} ${dropUp ? 'bottom-full mb-1' : 'top-full mt-1'}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--color-surface-raised)',
|
backgroundColor: 'var(--color-surface-raised)',
|
||||||
borderColor: 'var(--color-border-subtle)',
|
borderColor: 'var(--color-border-subtle)',
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,29 @@
|
||||||
* - Testing and connecting to remote hosts
|
* - Testing and connecting to remote hosts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { Loader2, Monitor, Wifi, WifiOff } from 'lucide-react';
|
import { Loader2, Monitor, Server, Wifi, WifiOff } from 'lucide-react';
|
||||||
|
|
||||||
import { SettingRow } from '../components/SettingRow';
|
import { SettingRow } from '../components/SettingRow';
|
||||||
import { SettingsSectionHeader } from '../components/SettingsSectionHeader';
|
import { SettingsSectionHeader } from '../components/SettingsSectionHeader';
|
||||||
|
import { SettingsSelect } from '../components/SettingsSelect';
|
||||||
|
|
||||||
import type { SshAuthMethod, SshConfigHostEntry, SshConnectionConfig } from '@shared/types';
|
import type {
|
||||||
|
SshAuthMethod,
|
||||||
|
SshConfigHostEntry,
|
||||||
|
SshConnectionConfig,
|
||||||
|
SshConnectionProfile,
|
||||||
|
} from '@shared/types';
|
||||||
|
|
||||||
|
const authMethodOptions: readonly { value: SshAuthMethod; label: string }[] = [
|
||||||
|
{ value: 'auto', label: 'Auto (from SSH Config)' },
|
||||||
|
{ value: 'agent', label: 'SSH Agent' },
|
||||||
|
{ value: 'privateKey', label: 'Private Key' },
|
||||||
|
{ value: 'password', label: 'Password' },
|
||||||
|
];
|
||||||
|
|
||||||
export const ConnectionSection = (): React.JSX.Element => {
|
export const ConnectionSection = (): React.JSX.Element => {
|
||||||
const connectionState = useStore((s) => s.connectionState);
|
const connectionState = useStore((s) => s.connectionState);
|
||||||
|
|
@ -45,11 +59,25 @@ export const ConnectionSection = (): React.JSX.Element => {
|
||||||
const hostInputRef = useRef<HTMLInputElement>(null);
|
const hostInputRef = useRef<HTMLInputElement>(null);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Fetch SSH config hosts and load last connection on mount
|
// Saved profiles
|
||||||
|
const [savedProfiles, setSavedProfiles] = useState<SshConnectionProfile[]>([]);
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
void fetchSshConfigHosts();
|
void fetchSshConfigHosts();
|
||||||
void loadLastConnection();
|
void loadLastConnection();
|
||||||
}, [fetchSshConfigHosts, loadLastConnection]);
|
void loadProfiles();
|
||||||
|
}, [fetchSshConfigHosts, loadLastConnection, loadProfiles]);
|
||||||
|
|
||||||
// Pre-fill form from saved connection config when it arrives (one-time on mount).
|
// 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
|
// 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);
|
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 => ({
|
const buildConfig = (): SshConnectionConfig => ({
|
||||||
host,
|
host,
|
||||||
port: parseInt(port, 10) || 22,
|
port: parseInt(port, 10) || 22,
|
||||||
|
|
@ -196,6 +234,35 @@ export const ConnectionSection = (): React.JSX.Element => {
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Saved Profiles */}
|
||||||
|
{!isConnected && savedProfiles.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-medium" style={{ color: 'var(--color-text-secondary)' }}>
|
||||||
|
Saved Profiles
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{savedProfiles.map((profile) => (
|
||||||
|
<button
|
||||||
|
key={profile.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectProfile(profile)}
|
||||||
|
className="flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-surface-raised"
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--color-border)',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Server className="size-3.5" style={{ color: 'var(--color-text-muted)' }} />
|
||||||
|
<span>{profile.name}</span>
|
||||||
|
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
|
{profile.username}@{profile.host}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* SSH Connection Form */}
|
{/* SSH Connection Form */}
|
||||||
{!isConnected && (
|
{!isConnected && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -292,17 +359,12 @@ export const ConnectionSection = (): React.JSX.Element => {
|
||||||
<label className="mb-1 block text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
<label className="mb-1 block text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
Authentication
|
Authentication
|
||||||
</label>
|
</label>
|
||||||
<select
|
<SettingsSelect
|
||||||
value={authMethod}
|
value={authMethod}
|
||||||
onChange={(e) => setAuthMethod(e.target.value as SshAuthMethod)}
|
options={authMethodOptions}
|
||||||
className="w-full rounded-md border px-3 py-1.5 text-sm"
|
onChange={setAuthMethod}
|
||||||
style={inputStyle}
|
fullWidth
|
||||||
>
|
/>
|
||||||
<option value="auto">Auto (from SSH Config)</option>
|
|
||||||
<option value="agent">SSH Agent</option>
|
|
||||||
<option value="privateKey">Private Key</option>
|
|
||||||
<option value="password">Password</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{authMethod === 'privateKey' && (
|
{authMethod === 'privateKey' && (
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,14 @@
|
||||||
* Profile changes persist via ConfigManager and trigger context list refresh.
|
* 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 { 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 { SettingsSectionHeader } from '../components/SettingsSectionHeader';
|
||||||
|
import { SettingsSelect } from '../components/SettingsSelect';
|
||||||
|
|
||||||
import type { SshAuthMethod, SshConnectionProfile } from '@shared/types';
|
import type { SshAuthMethod, SshConnectionProfile } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -26,7 +28,7 @@ const inputStyle = {
|
||||||
color: 'var(--color-text)',
|
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: 'auto', label: 'Auto (from SSH Config)' },
|
||||||
{ value: 'agent', label: 'SSH Agent' },
|
{ value: 'agent', label: 'SSH Agent' },
|
||||||
{ value: 'privateKey', label: 'Private Key' },
|
{ value: 'privateKey', label: 'Private Key' },
|
||||||
|
|
@ -42,101 +44,6 @@ const defaultForm = {
|
||||||
privateKeyPath: '',
|
privateKeyPath: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const AuthMethodSelect = ({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
value: SshAuthMethod;
|
|
||||||
onChange: (v: SshAuthMethod) => void;
|
|
||||||
}): React.JSX.Element => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(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 (
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
|
||||||
Authentication
|
|
||||||
</label>
|
|
||||||
<div ref={containerRef} className="relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
className={inputClass}
|
|
||||||
style={{
|
|
||||||
...inputStyle,
|
|
||||||
textAlign: 'left',
|
|
||||||
cursor: 'pointer',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>{selectedLabel}</span>
|
|
||||||
<ChevronDown
|
|
||||||
className={`size-3.5 transition-transform duration-150 ${isOpen ? 'rotate-180' : ''}`}
|
|
||||||
style={{ color: 'var(--color-text-muted)' }}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
{isOpen && (
|
|
||||||
<div
|
|
||||||
className="absolute inset-x-0 top-full z-50 mt-1 overflow-hidden rounded-md border shadow-lg"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--color-surface-overlay)',
|
|
||||||
borderColor: 'var(--color-border-emphasis)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{authMethodOptions.map((opt) => (
|
|
||||||
<button
|
|
||||||
key={opt.value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onChange(opt.value);
|
|
||||||
setIsOpen(false);
|
|
||||||
}}
|
|
||||||
className="flex w-full items-center px-3 py-2 text-left text-sm transition-colors"
|
|
||||||
style={{
|
|
||||||
color: opt.value === value ? 'var(--color-text)' : 'var(--color-text-secondary)',
|
|
||||||
backgroundColor:
|
|
||||||
opt.value === value ? 'var(--color-surface-raised)' : 'transparent',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (opt.value !== value)
|
|
||||||
e.currentTarget.style.backgroundColor = 'var(--color-surface-raised)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (opt.value !== value) e.currentTarget.style.backgroundColor = 'transparent';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WorkspaceSection = (): React.JSX.Element => {
|
export const WorkspaceSection = (): React.JSX.Element => {
|
||||||
const [profiles, setProfiles] = useState<SshConnectionProfile[]>([]);
|
const [profiles, setProfiles] = useState<SshConnectionProfile[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -162,9 +69,9 @@ export const WorkspaceSection = (): React.JSX.Element => {
|
||||||
|
|
||||||
const loadProfiles = useCallback(async () => {
|
const loadProfiles = useCallback(async () => {
|
||||||
try {
|
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
|
// 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 ?? []);
|
setProfiles(loaded?.profiles ?? []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[WorkspaceSection] Failed to load profiles:', 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,
|
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();
|
await loadProfiles();
|
||||||
resetForm();
|
resetForm();
|
||||||
setShowAddForm(false);
|
setShowAddForm(false);
|
||||||
|
|
@ -225,7 +132,7 @@ export const WorkspaceSection = (): React.JSX.Element => {
|
||||||
: p
|
: p
|
||||||
);
|
);
|
||||||
|
|
||||||
await window.electronAPI.config.update('ssh', { profiles: updatedProfiles });
|
await api.config.update('ssh', { profiles: updatedProfiles });
|
||||||
await loadProfiles();
|
await loadProfiles();
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
resetForm();
|
resetForm();
|
||||||
|
|
@ -240,7 +147,7 @@ export const WorkspaceSection = (): React.JSX.Element => {
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
const filtered = profiles.filter((p) => p.id !== id);
|
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();
|
await loadProfiles();
|
||||||
void useStore.getState().fetchAvailableContexts();
|
void useStore.getState().fetchAvailableContexts();
|
||||||
};
|
};
|
||||||
|
|
@ -314,7 +221,17 @@ export const WorkspaceSection = (): React.JSX.Element => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AuthMethodSelect value={formAuthMethod} onChange={setFormAuthMethod} />
|
<div>
|
||||||
|
<label className="mb-1 block text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
|
Authentication
|
||||||
|
</label>
|
||||||
|
<SettingsSelect
|
||||||
|
value={formAuthMethod}
|
||||||
|
options={authMethodOptions}
|
||||||
|
onChange={setFormAuthMethod}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{formAuthMethod === 'privateKey' && (
|
{formAuthMethod === 'privateKey' && (
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -304,8 +304,8 @@ export function initializeNotificationListeners(): () => void {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for context changes from main process (e.g., SSH disconnect)
|
// Listen for context changes from main process (e.g., SSH disconnect)
|
||||||
if (window.electronAPI.context?.onChanged) {
|
if (api.context?.onChanged) {
|
||||||
const cleanup = window.electronAPI.context.onChanged((_event: unknown, data: unknown) => {
|
const cleanup = api.context.onChanged((_event: unknown, data: unknown) => {
|
||||||
const { id } = data as { id: string };
|
const { id } = data as { id: string };
|
||||||
const currentContextId = useStore.getState().activeContextId;
|
const currentContextId = useStore.getState().activeContextId;
|
||||||
if (id !== currentContextId) {
|
if (id !== currentContextId) {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
* between local and SSH contexts, with IndexedDB persistence and TTL.
|
* between local and SSH contexts, with IndexedDB persistence and TTL.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
import { contextStorage } from '@renderer/services/contextStorage';
|
import { contextStorage } from '@renderer/services/contextStorage';
|
||||||
|
|
||||||
import { getFullResetState } from '../utils/stateResetHelpers';
|
import { getFullResetState } from '../utils/stateResetHelpers';
|
||||||
|
|
@ -243,7 +244,7 @@ export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> =
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch active context from main process
|
// Fetch active context from main process
|
||||||
const activeContextId = await window.electronAPI.context.getActive();
|
const activeContextId = await api.context.getActive();
|
||||||
|
|
||||||
set({
|
set({
|
||||||
contextSnapshotsReady: true,
|
contextSnapshotsReady: true,
|
||||||
|
|
@ -261,7 +262,7 @@ export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> =
|
||||||
// Fetch list of available contexts (local + SSH)
|
// Fetch list of available contexts (local + SSH)
|
||||||
fetchAvailableContexts: async () => {
|
fetchAvailableContexts: async () => {
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.context.list();
|
const result = await api.context.list();
|
||||||
set({ availableContexts: result });
|
set({ availableContexts: result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[contextSlice] Failed to fetch available contexts:', error);
|
console.error('[contextSlice] Failed to fetch available contexts:', error);
|
||||||
|
|
@ -290,12 +291,12 @@ export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> =
|
||||||
await contextStorage.saveSnapshot(state.activeContextId, currentSnapshot);
|
await contextStorage.saveSnapshot(state.activeContextId, currentSnapshot);
|
||||||
|
|
||||||
// Step 2: Switch main process context
|
// 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
|
// Step 3: Fetch fresh data from target context
|
||||||
const [freshProjects, freshRepoGroups] = await Promise.all([
|
const [freshProjects, freshRepoGroups] = await Promise.all([
|
||||||
window.electronAPI.getProjects(),
|
api.getProjects(),
|
||||||
window.electronAPI.getRepositoryGroups(),
|
api.getRepositoryGroups(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Step 4: Attempt to restore snapshot for target context
|
// Step 4: Attempt to restore snapshot for target context
|
||||||
|
|
|
||||||
|
|
@ -275,6 +275,31 @@ export interface AppConfig {
|
||||||
/** Pinned sessions per project. Key is projectId, value is array of pinned sessions */
|
/** Pinned sessions per project. Key is projectId, value is array of pinned sessions */
|
||||||
pinnedSessions: Record<string, { sessionId: string; pinnedAt: number }[]>;
|
pinnedSessions: Record<string, { sessionId: string; pinnedAt: number }[]>;
|
||||||
};
|
};
|
||||||
|
/** 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 */
|
/** HTTP sidecar server settings for iframe embedding */
|
||||||
httpServer?: {
|
httpServer?: {
|
||||||
/** Whether the HTTP server is enabled */
|
/** Whether the HTTP server is enabled */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue