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:
parent
8a671bc53f
commit
ad4e75b8e5
10 changed files with 183 additions and 7 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -190,11 +190,23 @@ export interface SessionsConfig {
|
|||
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 {
|
||||
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 ?? {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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<SshConfigHostEntry | null> => {
|
||||
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) => {
|
||||
ipcRenderer.on(
|
||||
SSH_STATUS,
|
||||
|
|
|
|||
|
|
@ -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<HTMLInputElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(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(() => {
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
|
|
@ -32,6 +40,7 @@ export interface ConnectionSlice {
|
|||
) => void;
|
||||
fetchSshConfigHosts: () => Promise<void>;
|
||||
resolveConfigHost: (alias: string) => Promise<SshConfigHostEntry | null>;
|
||||
loadLastConnection: () => Promise<void>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -48,6 +57,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
|||
connectedHost: null,
|
||||
connectionError: null,
|
||||
sshConfigHosts: [],
|
||||
lastSshConfig: null,
|
||||
|
||||
// Actions
|
||||
connectSsh: async (config: SshConnectionConfig): Promise<void> => {
|
||||
|
|
@ -64,12 +74,26 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
|||
connectionState: status.state,
|
||||
connectedHost: status.host,
|
||||
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') {
|
||||
const state = get();
|
||||
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) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
|
@ -88,11 +112,14 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
|||
connectionState: status.state,
|
||||
connectedHost: null,
|
||||
connectionError: null,
|
||||
// Clear stale remote selections so dashboard shows fresh local data
|
||||
...getFullResetState(),
|
||||
});
|
||||
|
||||
// Re-fetch local data
|
||||
const state = get();
|
||||
void state.fetchProjects();
|
||||
void state.fetchRepositoryGroups();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
set({ connectionError: message });
|
||||
|
|
@ -140,4 +167,13 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
|||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
loadLastConnection: async (): Promise<void> => {
|
||||
try {
|
||||
const saved = await window.electronAPI.ssh.getLastConnection();
|
||||
set({ lastSshConfig: saved });
|
||||
} catch {
|
||||
// Gracefully ignore - no saved connection
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<SshConnectionStatus>;
|
||||
disconnect: () => Promise<SshConnectionStatus>;
|
||||
|
|
@ -214,6 +225,8 @@ export interface SshAPI {
|
|||
test: (config: SshConnectionConfig) => Promise<{ success: boolean; error?: string }>;
|
||||
getConfigHosts: () => Promise<SshConfigHostEntry[]>;
|
||||
resolveHost: (alias: string) => Promise<SshConfigHostEntry | null>;
|
||||
saveLastConnection: (config: SshLastConnection) => Promise<void>;
|
||||
getLastConnection: () => Promise<SshLastConnection | null>;
|
||||
onStatus: (callback: (event: unknown, status: SshConnectionStatus) => void) => () => void;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue