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,
|
||||
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<ConfigSection>([
|
||||
|
|
@ -37,6 +39,7 @@ const VALID_SECTIONS = new Set<ConfigSection>([
|
|||
'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<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(
|
||||
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' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -102,6 +102,10 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
|
||||
private async parseJson<T>(res: Response): Promise<T> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ interface SettingsSelectProps<T extends string | number> {
|
|||
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 = <T extends string | number>({
|
||||
|
|
@ -21,6 +23,7 @@ export const SettingsSelect = <T extends string | number>({
|
|||
onChange,
|
||||
disabled = false,
|
||||
dropUp = false,
|
||||
fullWidth = false,
|
||||
}: SettingsSelectProps<T>): React.JSX.Element => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -54,7 +57,7 @@ export const SettingsSelect = <T extends string | number>({
|
|||
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 = <T extends string | number>({
|
|||
{/* Dropdown Menu */}
|
||||
{isOpen && (
|
||||
<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={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
|
|
|
|||
|
|
@ -8,15 +8,29 @@
|
|||
* - 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 { Loader2, Monitor, Wifi, WifiOff } from 'lucide-react';
|
||||
import { Loader2, Monitor, Server, Wifi, WifiOff } from 'lucide-react';
|
||||
|
||||
import { SettingRow } from '../components/SettingRow';
|
||||
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 => {
|
||||
const connectionState = useStore((s) => s.connectionState);
|
||||
|
|
@ -45,11 +59,25 @@ export const ConnectionSection = (): React.JSX.Element => {
|
|||
const hostInputRef = useRef<HTMLInputElement>(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(() => {
|
||||
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 => {
|
|||
</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 */}
|
||||
{!isConnected && (
|
||||
<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)' }}>
|
||||
Authentication
|
||||
</label>
|
||||
<select
|
||||
<SettingsSelect
|
||||
value={authMethod}
|
||||
onChange={(e) => setAuthMethod(e.target.value as SshAuthMethod)}
|
||||
className="w-full rounded-md border px-3 py-1.5 text-sm"
|
||||
style={inputStyle}
|
||||
>
|
||||
<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>
|
||||
options={authMethodOptions}
|
||||
onChange={setAuthMethod}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
|
||||
{authMethod === 'privateKey' && (
|
||||
|
|
|
|||
|
|
@ -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<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 => {
|
||||
const [profiles, setProfiles] = useState<SshConnectionProfile[]>([]);
|
||||
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 => {
|
|||
</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' && (
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<AppState, [], [], ContextSlice> =
|
|||
}
|
||||
|
||||
// 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<AppState, [], [], ContextSlice> =
|
|||
// 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<AppState, [], [], ContextSlice> =
|
|||
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
|
||||
|
|
|
|||
|
|
@ -275,6 +275,31 @@ export interface AppConfig {
|
|||
/** Pinned sessions per project. Key is projectId, value is array of pinned sessions */
|
||||
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 */
|
||||
httpServer?: {
|
||||
/** Whether the HTTP server is enabled */
|
||||
|
|
|
|||
Loading…
Reference in a new issue