diff --git a/src/main/index.ts b/src/main/index.ts index 9bfededf..da97bd7b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -23,7 +23,11 @@ import { createLogger } from '@shared/utils/logger'; import { app, BrowserWindow } from 'electron'; import { join } from 'path'; -import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers'; +import { + initializeIpcHandlers, + reinitializeServiceHandlers, + removeIpcHandlers, +} from './ipc/handlers'; // Icon path - works for both dev and production const getIconPath = (): string => { @@ -108,6 +112,15 @@ function initializeServices(): void { sessionParser = new SessionParser(projectScanner); subagentResolver = new SubagentResolver(projectScanner); + // Re-initialize IPC handler service references so subsequent calls use new instances + reinitializeServiceHandlers( + projectScanner, + sessionParser, + subagentResolver, + chunkBuilder, + dataCache + ); + // Update file watcher provider fileWatcher.setFileSystemProvider(provider); diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 9807d7ce..51ccb5b3 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -95,6 +95,25 @@ export function initializeIpcHandlers( logger.info('All handlers registered'); } +/** + * Re-initializes service-dependent IPC handlers after a mode switch (local ↔ SSH). + * This updates the module-level service references held by each domain handler module, + * ensuring IPC calls after the switch use the new service instances. + */ +export function reinitializeServiceHandlers( + scanner: ProjectScanner, + parser: SessionParser, + resolver: SubagentResolver, + builder: ChunkBuilder, + cache: DataCache +): void { + initializeProjectHandlers(scanner); + initializeSessionHandlers(scanner, parser, resolver, builder, cache); + initializeSearchHandlers(scanner); + initializeSubagentHandlers(builder, cache, parser, resolver); + logger.info('Service handlers re-initialized after mode switch'); +} + /** * Removes all IPC handlers. * Should be called when shutting down. diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index d0b3d6b4..8949fbb3 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -12,17 +12,22 @@ import { SSH_CONNECT, SSH_DISCONNECT, SSH_GET_CONFIG_HOSTS, + SSH_GET_LAST_CONNECTION, SSH_GET_STATE, SSH_RESOLVE_HOST, + SSH_SAVE_LAST_CONNECTION, SSH_TEST, } from '@preload/constants/ipcChannels'; import { createLogger } from '@shared/utils/logger'; +import { configManager } from '../services'; + import type { SshConnectionConfig, SshConnectionManager, SshConnectionStatus, } from '../services/infrastructure/SshConnectionManager'; +import type { SshLastConnection } from '@shared/types'; import type { IpcMain } from 'electron'; const logger = createLogger('IPC:ssh'); @@ -120,6 +125,36 @@ export function registerSshHandlers(ipcMain: IpcMain): void { } }); + ipcMain.handle(SSH_SAVE_LAST_CONNECTION, async (_event, config: SshLastConnection) => { + try { + configManager.updateConfig('ssh', { + lastConnection: { + host: config.host, + port: config.port, + username: config.username, + authMethod: config.authMethod, + privateKeyPath: config.privateKeyPath, + }, + }); + return { success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error('Failed to save SSH connection:', message); + return { success: false, error: message }; + } + }); + + ipcMain.handle(SSH_GET_LAST_CONNECTION, async () => { + try { + const config = configManager.getConfig(); + return { success: true, data: config.ssh.lastConnection }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error('Failed to get last SSH connection:', message); + return { success: true, data: null }; + } + }); + logger.info('SSH handlers registered'); } @@ -130,4 +165,6 @@ export function removeSshHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(SSH_TEST); ipcMain.removeHandler(SSH_GET_CONFIG_HOSTS); ipcMain.removeHandler(SSH_RESOLVE_HOST); + ipcMain.removeHandler(SSH_SAVE_LAST_CONNECTION); + ipcMain.removeHandler(SSH_GET_LAST_CONNECTION); } diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index c2ff0e19..cf64aa0a 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -190,11 +190,23 @@ export interface SessionsConfig { pinnedSessions: Record; } +export interface SshPersistConfig { + lastConnection: { + host: string; + port: number; + username: string; + authMethod: 'password' | 'privateKey' | 'agent' | 'auto'; + privateKeyPath?: string; + } | null; + autoReconnect: boolean; +} + export interface AppConfig { notifications: NotificationConfig; general: GeneralConfig; display: DisplayConfig; sessions: SessionsConfig; + ssh: SshPersistConfig; } // Config section keys for type-safe updates @@ -232,6 +244,10 @@ const DEFAULT_CONFIG: AppConfig = { sessions: { pinnedSessions: {}, }, + ssh: { + lastConnection: null, + autoReconnect: false, + }, }; // =========================================================================== @@ -352,6 +368,10 @@ export class ConfigManager { ...DEFAULT_CONFIG.sessions, ...(loaded.sessions ?? {}), }, + ssh: { + ...DEFAULT_CONFIG.ssh, + ...(loaded.ssh ?? {}), + }, }; } diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index e1655264..a3983326 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -81,6 +81,12 @@ export const SSH_GET_CONFIG_HOSTS = 'ssh:getConfigHosts'; /** Resolve a single SSH config host alias */ export const SSH_RESOLVE_HOST = 'ssh:resolveHost'; +/** Save last SSH connection config */ +export const SSH_SAVE_LAST_CONNECTION = 'ssh:saveLastConnection'; + +/** Get last saved SSH connection config */ +export const SSH_GET_LAST_CONNECTION = 'ssh:getLastConnection'; + /** SSH status event channel (main -> renderer) */ export const SSH_STATUS = 'ssh:status'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 696eb8e1..56935da6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5,8 +5,10 @@ import { SSH_CONNECT, SSH_DISCONNECT, SSH_GET_CONFIG_HOSTS, + SSH_GET_LAST_CONNECTION, SSH_GET_STATE, SSH_RESOLVE_HOST, + SSH_SAVE_LAST_CONNECTION, SSH_STATUS, SSH_TEST, UPDATER_CHECK, @@ -42,6 +44,7 @@ import type { SshConfigHostEntry, SshConnectionConfig, SshConnectionStatus, + SshLastConnection, TriggerTestResult, } from '@shared/types'; @@ -341,6 +344,12 @@ const electronAPI: ElectronAPI = { resolveHost: async (alias: string): Promise => { return invokeIpcWithResult(SSH_RESOLVE_HOST, alias); }, + saveLastConnection: async (config: SshLastConnection): Promise => { + return invokeIpcWithResult(SSH_SAVE_LAST_CONNECTION, config); + }, + getLastConnection: async (): Promise => { + return invokeIpcWithResult(SSH_GET_LAST_CONNECTION); + }, onStatus: (callback: (event: unknown, status: SshConnectionStatus) => void): (() => void) => { ipcRenderer.on( SSH_STATUS, diff --git a/src/renderer/components/settings/sections/ConnectionSection.tsx b/src/renderer/components/settings/sections/ConnectionSection.tsx index 0580d641..7a77c837 100644 --- a/src/renderer/components/settings/sections/ConnectionSection.tsx +++ b/src/renderer/components/settings/sections/ConnectionSection.tsx @@ -27,6 +27,8 @@ export const ConnectionSection = (): React.JSX.Element => { const testConnection = useStore((s) => s.testConnection); const sshConfigHosts = useStore((s) => s.sshConfigHosts); const fetchSshConfigHosts = useStore((s) => s.fetchSshConfigHosts); + const lastSshConfig = useStore((s) => s.lastSshConfig); + const loadLastConnection = useStore((s) => s.loadLastConnection); // Form state const [host, setHost] = useState(''); @@ -43,10 +45,29 @@ export const ConnectionSection = (): React.JSX.Element => { const hostInputRef = useRef(null); const dropdownRef = useRef(null); - // Fetch SSH config hosts on mount + // Fetch SSH config hosts and load last connection on mount useEffect(() => { void fetchSshConfigHosts(); - }, [fetchSshConfigHosts]); + void loadLastConnection(); + }, [fetchSshConfigHosts, loadLastConnection]); + + // 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 + // use it as useState initializers. + const prefilled = useRef(false); + useEffect(() => { + if (lastSshConfig && connectionState !== 'connected' && !prefilled.current) { + prefilled.current = true; + setHost(lastSshConfig.host); + setPort(String(lastSshConfig.port)); + setUsername(lastSshConfig.username); + setAuthMethod(lastSshConfig.authMethod); + if (lastSshConfig.privateKeyPath) { + setPrivateKeyPath(lastSshConfig.privateKeyPath); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- one-time prefill when async data arrives + }, [lastSshConfig]); // Close dropdown on outside click useEffect(() => { diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 557a4ff5..bb38330c 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -288,9 +288,11 @@ export function initializeNotificationListeners(): () => void { s.error ); - // Re-fetch data when connection state changes to connected or disconnected + // Re-fetch all data when connection state changes to connected or disconnected if (s.state === 'connected' || s.state === 'disconnected') { - void useStore.getState().fetchProjects(); + const store = useStore.getState(); + void store.fetchProjects(); + void store.fetchRepositoryGroups(); } }); if (typeof cleanup === 'function') { diff --git a/src/renderer/store/slices/connectionSlice.ts b/src/renderer/store/slices/connectionSlice.ts index 0fc477bb..a14c317a 100644 --- a/src/renderer/store/slices/connectionSlice.ts +++ b/src/renderer/store/slices/connectionSlice.ts @@ -5,8 +5,15 @@ * and provides actions for connecting/disconnecting. */ +import { getFullResetState } from '../utils/stateResetHelpers'; + import type { AppState } from '../types'; -import type { SshConfigHostEntry, SshConnectionConfig, SshConnectionState } from '@shared/types'; +import type { + SshConfigHostEntry, + SshConnectionConfig, + SshConnectionState, + SshLastConnection, +} from '@shared/types'; import type { StateCreator } from 'zustand'; // ============================================================================= @@ -20,6 +27,7 @@ export interface ConnectionSlice { connectedHost: string | null; connectionError: string | null; sshConfigHosts: SshConfigHostEntry[]; + lastSshConfig: SshLastConnection | null; // Actions connectSsh: (config: SshConnectionConfig) => Promise; @@ -32,6 +40,7 @@ export interface ConnectionSlice { ) => void; fetchSshConfigHosts: () => Promise; resolveConfigHost: (alias: string) => Promise; + loadLastConnection: () => Promise; } // ============================================================================= @@ -48,6 +57,7 @@ export const createConnectionSlice: StateCreator => { @@ -64,12 +74,26 @@ export const createConnectionSlice: StateCreator => { + try { + const saved = await window.electronAPI.ssh.getLastConnection(); + set({ lastSshConfig: saved }); + } catch { + // Gracefully ignore - no saved connection + } + }, }); diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 71b71126..855e73a5 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -207,6 +207,17 @@ export interface SshConnectionStatus { /** * SSH API exposed via preload. */ +/** + * Saved SSH connection config (no password). + */ +export interface SshLastConnection { + host: string; + port: number; + username: string; + authMethod: SshAuthMethod; + privateKeyPath?: string; +} + export interface SshAPI { connect: (config: SshConnectionConfig) => Promise; disconnect: () => Promise; @@ -214,6 +225,8 @@ export interface SshAPI { test: (config: SshConnectionConfig) => Promise<{ success: boolean; error?: string }>; getConfigHosts: () => Promise; resolveHost: (alias: string) => Promise; + saveLastConnection: (config: SshLastConnection) => Promise; + getLastConnection: () => Promise; onStatus: (callback: (event: unknown, status: SshConnectionStatus) => void) => () => void; }