Implement SSH last connection management and enhance IPC handlers

- Added functionality to save and retrieve the last SSH connection configuration, allowing for auto-fill on subsequent launches.
- Introduced new IPC channels for saving and getting the last SSH connection details.
- Updated the connection state management to persist the last connection information upon successful connection.
- Enhanced the ConnectionSection component to load the last connection details on mount and pre-fill the form fields.
- Refactored relevant types and state management to accommodate the new SSH last connection features.
This commit is contained in:
matt 2026-02-12 00:42:21 +09:00
parent 8a671bc53f
commit ad4e75b8e5
10 changed files with 183 additions and 7 deletions

View file

@ -23,7 +23,11 @@ import { createLogger } from '@shared/utils/logger';
import { app, BrowserWindow } from 'electron'; import { app, BrowserWindow } from 'electron';
import { join } from 'path'; 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 // Icon path - works for both dev and production
const getIconPath = (): string => { const getIconPath = (): string => {
@ -108,6 +112,15 @@ function initializeServices(): void {
sessionParser = new SessionParser(projectScanner); sessionParser = new SessionParser(projectScanner);
subagentResolver = new SubagentResolver(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 // Update file watcher provider
fileWatcher.setFileSystemProvider(provider); fileWatcher.setFileSystemProvider(provider);

View file

@ -95,6 +95,25 @@ export function initializeIpcHandlers(
logger.info('All handlers registered'); 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. * Removes all IPC handlers.
* Should be called when shutting down. * Should be called when shutting down.

View file

@ -12,17 +12,22 @@ import {
SSH_CONNECT, SSH_CONNECT,
SSH_DISCONNECT, SSH_DISCONNECT,
SSH_GET_CONFIG_HOSTS, SSH_GET_CONFIG_HOSTS,
SSH_GET_LAST_CONNECTION,
SSH_GET_STATE, SSH_GET_STATE,
SSH_RESOLVE_HOST, SSH_RESOLVE_HOST,
SSH_SAVE_LAST_CONNECTION,
SSH_TEST, SSH_TEST,
} from '@preload/constants/ipcChannels'; } from '@preload/constants/ipcChannels';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { configManager } from '../services';
import type { import type {
SshConnectionConfig, SshConnectionConfig,
SshConnectionManager, SshConnectionManager,
SshConnectionStatus, SshConnectionStatus,
} from '../services/infrastructure/SshConnectionManager'; } from '../services/infrastructure/SshConnectionManager';
import type { SshLastConnection } from '@shared/types';
import type { IpcMain } from 'electron'; import type { IpcMain } from 'electron';
const logger = createLogger('IPC:ssh'); 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'); logger.info('SSH handlers registered');
} }
@ -130,4 +165,6 @@ export function removeSshHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(SSH_TEST); ipcMain.removeHandler(SSH_TEST);
ipcMain.removeHandler(SSH_GET_CONFIG_HOSTS); ipcMain.removeHandler(SSH_GET_CONFIG_HOSTS);
ipcMain.removeHandler(SSH_RESOLVE_HOST); ipcMain.removeHandler(SSH_RESOLVE_HOST);
ipcMain.removeHandler(SSH_SAVE_LAST_CONNECTION);
ipcMain.removeHandler(SSH_GET_LAST_CONNECTION);
} }

View file

@ -190,11 +190,23 @@ export interface SessionsConfig {
pinnedSessions: Record<string, { sessionId: string; pinnedAt: number }[]>; pinnedSessions: Record<string, { sessionId: string; pinnedAt: number }[]>;
} }
export interface SshPersistConfig {
lastConnection: {
host: string;
port: number;
username: string;
authMethod: 'password' | 'privateKey' | 'agent' | 'auto';
privateKeyPath?: string;
} | null;
autoReconnect: boolean;
}
export interface AppConfig { export interface AppConfig {
notifications: NotificationConfig; notifications: NotificationConfig;
general: GeneralConfig; general: GeneralConfig;
display: DisplayConfig; display: DisplayConfig;
sessions: SessionsConfig; sessions: SessionsConfig;
ssh: SshPersistConfig;
} }
// Config section keys for type-safe updates // Config section keys for type-safe updates
@ -232,6 +244,10 @@ const DEFAULT_CONFIG: AppConfig = {
sessions: { sessions: {
pinnedSessions: {}, pinnedSessions: {},
}, },
ssh: {
lastConnection: null,
autoReconnect: false,
},
}; };
// =========================================================================== // ===========================================================================
@ -352,6 +368,10 @@ export class ConfigManager {
...DEFAULT_CONFIG.sessions, ...DEFAULT_CONFIG.sessions,
...(loaded.sessions ?? {}), ...(loaded.sessions ?? {}),
}, },
ssh: {
...DEFAULT_CONFIG.ssh,
...(loaded.ssh ?? {}),
},
}; };
} }

View file

@ -81,6 +81,12 @@ export const SSH_GET_CONFIG_HOSTS = 'ssh:getConfigHosts';
/** Resolve a single SSH config host alias */ /** Resolve a single SSH config host alias */
export const SSH_RESOLVE_HOST = 'ssh:resolveHost'; 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) */ /** SSH status event channel (main -> renderer) */
export const SSH_STATUS = 'ssh:status'; export const SSH_STATUS = 'ssh:status';

View file

@ -5,8 +5,10 @@ import {
SSH_CONNECT, SSH_CONNECT,
SSH_DISCONNECT, SSH_DISCONNECT,
SSH_GET_CONFIG_HOSTS, SSH_GET_CONFIG_HOSTS,
SSH_GET_LAST_CONNECTION,
SSH_GET_STATE, SSH_GET_STATE,
SSH_RESOLVE_HOST, SSH_RESOLVE_HOST,
SSH_SAVE_LAST_CONNECTION,
SSH_STATUS, SSH_STATUS,
SSH_TEST, SSH_TEST,
UPDATER_CHECK, UPDATER_CHECK,
@ -42,6 +44,7 @@ import type {
SshConfigHostEntry, SshConfigHostEntry,
SshConnectionConfig, SshConnectionConfig,
SshConnectionStatus, SshConnectionStatus,
SshLastConnection,
TriggerTestResult, TriggerTestResult,
} from '@shared/types'; } from '@shared/types';
@ -341,6 +344,12 @@ const electronAPI: ElectronAPI = {
resolveHost: async (alias: string): Promise<SshConfigHostEntry | null> => { resolveHost: async (alias: string): Promise<SshConfigHostEntry | null> => {
return invokeIpcWithResult<SshConfigHostEntry | null>(SSH_RESOLVE_HOST, alias); return invokeIpcWithResult<SshConfigHostEntry | null>(SSH_RESOLVE_HOST, alias);
}, },
saveLastConnection: async (config: SshLastConnection): Promise<void> => {
return invokeIpcWithResult<void>(SSH_SAVE_LAST_CONNECTION, config);
},
getLastConnection: async (): Promise<SshLastConnection | null> => {
return invokeIpcWithResult<SshLastConnection | null>(SSH_GET_LAST_CONNECTION);
},
onStatus: (callback: (event: unknown, status: SshConnectionStatus) => void): (() => void) => { onStatus: (callback: (event: unknown, status: SshConnectionStatus) => void): (() => void) => {
ipcRenderer.on( ipcRenderer.on(
SSH_STATUS, SSH_STATUS,

View file

@ -27,6 +27,8 @@ export const ConnectionSection = (): React.JSX.Element => {
const testConnection = useStore((s) => s.testConnection); const testConnection = useStore((s) => s.testConnection);
const sshConfigHosts = useStore((s) => s.sshConfigHosts); const sshConfigHosts = useStore((s) => s.sshConfigHosts);
const fetchSshConfigHosts = useStore((s) => s.fetchSshConfigHosts); const fetchSshConfigHosts = useStore((s) => s.fetchSshConfigHosts);
const lastSshConfig = useStore((s) => s.lastSshConfig);
const loadLastConnection = useStore((s) => s.loadLastConnection);
// Form state // Form state
const [host, setHost] = useState(''); const [host, setHost] = useState('');
@ -43,10 +45,29 @@ 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 on mount // Fetch SSH config hosts and load last connection on mount
useEffect(() => { useEffect(() => {
void fetchSshConfigHosts(); 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 // Close dropdown on outside click
useEffect(() => { useEffect(() => {

View file

@ -288,9 +288,11 @@ export function initializeNotificationListeners(): () => void {
s.error 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') { 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') { if (typeof cleanup === 'function') {

View file

@ -5,8 +5,15 @@
* and provides actions for connecting/disconnecting. * and provides actions for connecting/disconnecting.
*/ */
import { getFullResetState } from '../utils/stateResetHelpers';
import type { AppState } from '../types'; 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'; import type { StateCreator } from 'zustand';
// ============================================================================= // =============================================================================
@ -20,6 +27,7 @@ export interface ConnectionSlice {
connectedHost: string | null; connectedHost: string | null;
connectionError: string | null; connectionError: string | null;
sshConfigHosts: SshConfigHostEntry[]; sshConfigHosts: SshConfigHostEntry[];
lastSshConfig: SshLastConnection | null;
// Actions // Actions
connectSsh: (config: SshConnectionConfig) => Promise<void>; connectSsh: (config: SshConnectionConfig) => Promise<void>;
@ -32,6 +40,7 @@ export interface ConnectionSlice {
) => void; ) => void;
fetchSshConfigHosts: () => Promise<void>; fetchSshConfigHosts: () => Promise<void>;
resolveConfigHost: (alias: string) => Promise<SshConfigHostEntry | null>; resolveConfigHost: (alias: string) => Promise<SshConfigHostEntry | null>;
loadLastConnection: () => Promise<void>;
} }
// ============================================================================= // =============================================================================
@ -48,6 +57,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
connectedHost: null, connectedHost: null,
connectionError: null, connectionError: null,
sshConfigHosts: [], sshConfigHosts: [],
lastSshConfig: null,
// Actions // Actions
connectSsh: async (config: SshConnectionConfig): Promise<void> => { connectSsh: async (config: SshConnectionConfig): Promise<void> => {
@ -64,12 +74,26 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
connectionState: status.state, connectionState: status.state,
connectedHost: status.host, connectedHost: status.host,
connectionError: status.error, connectionError: status.error,
// Clear stale local selections so dashboard shows fresh remote data
...(status.state === 'connected' ? getFullResetState() : {}),
}); });
// Re-fetch all data when connected // Re-fetch all data and persist config when connected
if (status.state === 'connected') { if (status.state === 'connected') {
const state = get(); const state = get();
void state.fetchProjects(); void state.fetchProjects();
void state.fetchRepositoryGroups();
// Save connection config (without password) for form pre-fill on next launch
const saved: SshLastConnection = {
host: config.host,
port: config.port,
username: config.username,
authMethod: config.authMethod,
privateKeyPath: config.privateKeyPath,
};
set({ lastSshConfig: saved });
void window.electronAPI.ssh.saveLastConnection(saved);
} }
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
@ -88,11 +112,14 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
connectionState: status.state, connectionState: status.state,
connectedHost: null, connectedHost: null,
connectionError: null, connectionError: null,
// Clear stale remote selections so dashboard shows fresh local data
...getFullResetState(),
}); });
// Re-fetch local data // Re-fetch local data
const state = get(); const state = get();
void state.fetchProjects(); void state.fetchProjects();
void state.fetchRepositoryGroups();
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
set({ connectionError: message }); set({ connectionError: message });
@ -140,4 +167,13 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
return null; return null;
} }
}, },
loadLastConnection: async (): Promise<void> => {
try {
const saved = await window.electronAPI.ssh.getLastConnection();
set({ lastSshConfig: saved });
} catch {
// Gracefully ignore - no saved connection
}
},
}); });

View file

@ -207,6 +207,17 @@ export interface SshConnectionStatus {
/** /**
* SSH API exposed via preload. * 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 { export interface SshAPI {
connect: (config: SshConnectionConfig) => Promise<SshConnectionStatus>; connect: (config: SshConnectionConfig) => Promise<SshConnectionStatus>;
disconnect: () => Promise<SshConnectionStatus>; disconnect: () => Promise<SshConnectionStatus>;
@ -214,6 +225,8 @@ export interface SshAPI {
test: (config: SshConnectionConfig) => Promise<{ success: boolean; error?: string }>; test: (config: SshConnectionConfig) => Promise<{ success: boolean; error?: string }>;
getConfigHosts: () => Promise<SshConfigHostEntry[]>; getConfigHosts: () => Promise<SshConfigHostEntry[]>;
resolveHost: (alias: string) => Promise<SshConfigHostEntry | null>; resolveHost: (alias: string) => Promise<SshConfigHostEntry | null>;
saveLastConnection: (config: SshLastConnection) => Promise<void>;
getLastConnection: () => Promise<SshLastConnection | null>;
onStatus: (callback: (event: unknown, status: SshConnectionStatus) => void) => () => void; onStatus: (callback: (event: unknown, status: SshConnectionStatus) => void) => () => void;
} }