From 971fc142b23e6fd8180a82ac4669bff5cc99e218 Mon Sep 17 00:00:00 2001 From: matt Date: Thu, 12 Feb 2026 17:08:05 +0900 Subject: [PATCH] feat(ipc): refactor context switching and event handling - Introduced a new `CONTEXT_CHANGED` IPC channel for notifying renderer of context changes. - Refactored context switching logic to separate re-wiring of file watcher events from renderer notifications. - Updated IPC handler initialization to support both rewire-only and full context switch callbacks. - Enhanced SSH handlers to utilize the new context rewire mechanism, improving clarity and maintainability. - Adjusted renderer store to handle context change notifications more effectively. This commit improves the structure and clarity of context management within the application, facilitating better handling of context switches and notifications. --- src/main/index.ts | 25 ++-- src/main/ipc/context.ts | 19 ++- src/main/ipc/handlers.ts | 9 +- src/main/ipc/ssh.ts | 16 +-- src/renderer/store/index.ts | 11 +- src/renderer/store/slices/connectionSlice.ts | 46 +++++++- src/renderer/store/slices/contextSlice.ts | 118 ++++++++++++++----- 7 files changed, 172 insertions(+), 72 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 57a87b5d..16949776 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -34,6 +34,7 @@ const getIconPath = (): string => { const logger = createLogger('App'); // IPC channel constants (duplicated from @preload to avoid boundary violation) const SSH_STATUS = 'ssh:status'; +const CONTEXT_CHANGED = 'context:changed'; const HTTP_SERVER_START = 'httpServer:start'; const HTTP_SERVER_STOP = 'httpServer:stop'; const HTTP_SERVER_GET_STATUS = 'httpServer:getStatus'; @@ -118,18 +119,25 @@ async function handleModeSwitch(mode: 'local' | 'ssh'): Promise { } /** - * Callback invoked when context switches (called by SSH IPC handler). - * Re-wires file watcher events and notifies renderer. + * Re-wires file watcher events only. No renderer notification. + * Used for renderer-initiated switches where the renderer already handles state. */ -export function onContextSwitched(context: ServiceContext): void { - // Re-wire file watcher events to new context +export function rewireContextEvents(context: ServiceContext): void { wireFileWatcherEvents(context); +} + +/** + * Full callback: re-wire + notify renderer. + * Used for external/unexpected switches (e.g., HTTP server mode switch). + */ +function onContextSwitched(context: ServiceContext): void { + rewireContextEvents(context); // Notify renderer of context change if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(SSH_STATUS, sshConnectionManager.getStatus()); - mainWindow.webContents.send('context-changed', { - contextId: context.id, + mainWindow.webContents.send(CONTEXT_CHANGED, { + id: context.id, type: context.type, }); } @@ -174,7 +182,10 @@ function initializeServices(): void { httpServer = new HttpServer(); // Initialize IPC handlers with registry - initializeIpcHandlers(contextRegistry, updaterService, sshConnectionManager, onContextSwitched); + initializeIpcHandlers(contextRegistry, updaterService, sshConnectionManager, { + rewire: rewireContextEvents, + full: onContextSwitched, + }); // HTTP Server control IPC handlers ipcMain.handle(HTTP_SERVER_START, async () => { diff --git a/src/main/ipc/context.ts b/src/main/ipc/context.ts index c2894432..03131239 100644 --- a/src/main/ipc/context.ts +++ b/src/main/ipc/context.ts @@ -7,12 +7,7 @@ * - context:switch - Switch to a different context */ -import { - CONTEXT_CHANGED, - CONTEXT_GET_ACTIVE, - CONTEXT_LIST, - CONTEXT_SWITCH, -} from '@preload/constants/ipcChannels'; +import { CONTEXT_GET_ACTIVE, CONTEXT_LIST, CONTEXT_SWITCH } from '@preload/constants/ipcChannels'; import { createLogger } from '@shared/utils/logger'; import type { ServiceContext, ServiceContextRegistry } from '../services'; @@ -25,7 +20,7 @@ const logger = createLogger('IPC:context'); // ============================================================================= let registry: ServiceContextRegistry; -let onContextSwitched: (context: ServiceContext) => void; +let onContextRewire: (context: ServiceContext) => void; // ============================================================================= // Initialization @@ -34,14 +29,14 @@ let onContextSwitched: (context: ServiceContext) => void; /** * Initialize context handlers with required services. * @param contextRegistry - The service context registry - * @param onSwitched - Callback to invoke after successful context switch + * @param onRewire - Rewire-only callback (no renderer notification) for renderer-initiated switches */ export function initializeContextHandlers( contextRegistry: ServiceContextRegistry, - onSwitched: (context: ServiceContext) => void + onRewire: (context: ServiceContext) => void ): void { registry = contextRegistry; - onContextSwitched = onSwitched; + onContextRewire = onRewire; } // ============================================================================= @@ -76,8 +71,8 @@ export function registerContextHandlers(ipcMain: IpcMain): void { // Switch to the new context const { current } = registry.switch(contextId); - // Invoke the context switched callback (re-wires file watcher events) - onContextSwitched(current); + // Re-wire file watcher events only (no renderer notification — renderer initiated this switch) + onContextRewire(current); return { success: true, data: { contextId } }; } catch (err) { diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 1ba70ad9..5e486e92 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -64,7 +64,10 @@ export function initializeIpcHandlers( registry: ServiceContextRegistry, updater: UpdaterService, sshManager: SshConnectionManager, - onContextSwitched: (context: ServiceContext) => void + contextCallbacks: { + rewire: (context: ServiceContext) => void; + full: (context: ServiceContext) => void; + } ): void { // Initialize domain handlers with registry initializeProjectHandlers(registry); @@ -72,8 +75,8 @@ export function initializeIpcHandlers( initializeSearchHandlers(registry); initializeSubagentHandlers(registry); initializeUpdaterHandlers(updater); - initializeSshHandlers(sshManager, registry); - initializeContextHandlers(registry, onContextSwitched); + initializeSshHandlers(sshManager, registry, contextCallbacks.rewire); + initializeContextHandlers(registry, contextCallbacks.rewire); // Register all handlers registerProjectHandlers(ipcMain); diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 807030eb..570204f1 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -39,6 +39,7 @@ const logger = createLogger('IPC:ssh'); let connectionManager: SshConnectionManager; let registry: ServiceContextRegistry; +let onContextRewire: (context: ServiceContext) => void; // ============================================================================= // Initialization @@ -48,13 +49,16 @@ let registry: ServiceContextRegistry; * Initialize SSH handlers with required services. * @param manager - The SSH connection manager instance * @param contextRegistry - The service context registry + * @param onRewire - Rewire-only callback (no renderer notification) for renderer-initiated switches */ export function initializeSshHandlers( manager: SshConnectionManager, - contextRegistry: ServiceContextRegistry + contextRegistry: ServiceContextRegistry, + onRewire: (context: ServiceContext) => void ): void { connectionManager = manager; registry = contextRegistry; + onContextRewire = onRewire; } // ============================================================================= @@ -95,9 +99,8 @@ export function registerSshHandlers(ipcMain: IpcMain): void { // Switch to SSH context registry.switch(contextId); - // Import and call context switch callback from index.ts - const { onContextSwitched } = await import('../index'); - onContextSwitched(sshContext); + // Re-wire file watcher events only (renderer's connectSsh() handles state) + onContextRewire(sshContext); return { success: true, data: connectionManager.getStatus() }; } catch (err) { @@ -124,10 +127,9 @@ export function registerSshHandlers(ipcMain: IpcMain): void { // Destroy the SSH context registry.destroy(currentContextId); - // Call context switch callback to rewire events + // Re-wire file watcher events only (renderer's disconnectSsh() handles state) const localContext = registry.getActive(); - const { onContextSwitched } = await import('../index'); - onContextSwitched(localContext); + onContextRewire(localContext); } return { success: true, data: connectionManager.getStatus() }; diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 798c7491..14b1dd00 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -280,6 +280,8 @@ export function initializeNotificationListeners(): () => void { } // Listen for SSH connection status changes from main process + // NOTE: Only syncs connection status here. Data fetching is handled by + // connectionSlice.connectSsh/disconnectSsh and contextSlice.switchContext. if (api.ssh?.onStatus) { const cleanup = api.ssh.onStatus((_event: unknown, status: unknown) => { const s = status as { state: string; host: string | null; error: string | null }; @@ -290,13 +292,6 @@ export function initializeNotificationListeners(): () => void { s.host, s.error ); - - // Re-fetch all data when connection state changes to connected or disconnected - if (s.state === 'connected' || s.state === 'disconnected') { - const store = useStore.getState(); - void store.fetchProjects(); - void store.fetchRepositoryGroups(); - } }); if (typeof cleanup === 'function') { cleanupFns.push(cleanup); @@ -306,7 +301,7 @@ export function initializeNotificationListeners(): () => void { // Listen for context changes from main process (e.g., SSH disconnect) if (api.context?.onChanged) { const cleanup = api.context.onChanged((_event: unknown, data: unknown) => { - const { id } = data as { id: string }; + const { id } = data as { id: string; type: string }; const currentContextId = useStore.getState().activeContextId; if (id !== currentContextId) { // Main process switched context externally (e.g., SSH disconnect) diff --git a/src/renderer/store/slices/connectionSlice.ts b/src/renderer/store/slices/connectionSlice.ts index 5847ceb8..a16e3f6c 100644 --- a/src/renderer/store/slices/connectionSlice.ts +++ b/src/renderer/store/slices/connectionSlice.ts @@ -76,8 +76,30 @@ export const createConnectionSlice: StateCreator = return; } + // Re-entrancy guard: prevent concurrent switch races from overlapping events + if (state.isContextSwitching) { + return; + } + set({ isContextSwitching: true, targetContextId, }); try { - // Step 1: Capture current context's snapshot + // Step 1: Save current snapshot + load target snapshot + switch main process + // These are independent — run in parallel for speed const currentSnapshot = captureSnapshot(state, state.activeContextId); - await contextStorage.saveSnapshot(state.activeContextId, currentSnapshot); - - // Step 2: Switch main process context - await api.context.switch(targetContextId); - - // Step 3: Fetch fresh data from target context - const [freshProjects, freshRepoGroups] = await Promise.all([ - api.getProjects(), - api.getRepositoryGroups(), + const [, targetSnapshot] = await Promise.all([ + contextStorage.saveSnapshot(state.activeContextId, currentSnapshot), + contextStorage.loadSnapshot(targetContextId), + api.context.switch(targetContextId), ]); - // Step 4: Attempt to restore snapshot for target context - const targetSnapshot = await contextStorage.loadSnapshot(targetContextId); - + // Step 2: Apply cached snapshot immediately for instant visual feedback if (targetSnapshot) { - // Validate and restore snapshot - const validatedState = validateSnapshot(targetSnapshot, freshProjects, freshRepoGroups); - set(validatedState); - } else { - // No snapshot (new context or expired) - apply empty state - const emptyState = getEmptyContextState(); set({ - ...emptyState, - projects: freshProjects, - repositoryGroups: freshRepoGroups, + projects: targetSnapshot.projects, + repositoryGroups: targetSnapshot.repositoryGroups, + selectedProjectId: targetSnapshot.selectedProjectId, + selectedRepositoryId: targetSnapshot.selectedRepositoryId, + selectedWorktreeId: targetSnapshot.selectedWorktreeId, + viewMode: targetSnapshot.viewMode, + sessions: targetSnapshot.sessions, + selectedSessionId: targetSnapshot.selectedSessionId, + sessionsCursor: targetSnapshot.sessionsCursor, + sessionsHasMore: targetSnapshot.sessionsHasMore, + sessionsTotalCount: targetSnapshot.sessionsTotalCount, + pinnedSessionIds: targetSnapshot.pinnedSessionIds, + notifications: targetSnapshot.notifications, + unreadCount: targetSnapshot.unreadCount, + openTabs: targetSnapshot.openTabs, + activeTabId: targetSnapshot.activeTabId, + selectedTabIds: targetSnapshot.selectedTabIds, + activeProjectId: targetSnapshot.activeProjectId, + paneLayout: targetSnapshot.paneLayout, + sidebarCollapsed: targetSnapshot.sidebarCollapsed, + // Finalize switch — overlay disappears, user sees cached data instantly + activeContextId: targetContextId, + isContextSwitching: false, + targetContextId: null, }); } - // Step 5: Fetch notifications in background - void get().fetchNotifications(); + // Step 3: Fetch fresh data in background (slow over SSH) + // Wrapped in try/catch so fetch failures don't wipe valid snapshot data. + // IPC handlers return [] on SSH scan failure — we must guard against that. + try { + const [freshProjects, freshRepoGroups] = await Promise.all([ + api.getProjects(), + api.getRepositoryGroups(), + ]); - // Step 6: Finalize switch - set({ - isContextSwitching: false, - targetContextId: null, - activeContextId: targetContextId, - }); + if (targetSnapshot) { + // Guard: don't overwrite snapshot data if fetch returned empty + // (likely transient SSH scan failure, not genuinely empty workspace) + const snapshotHadData = + targetSnapshot.projects.length > 0 || targetSnapshot.repositoryGroups.length > 0; + const freshIsEmpty = freshProjects.length === 0 && freshRepoGroups.length === 0; + + if (snapshotHadData && freshIsEmpty) { + console.warn( + '[contextSlice] Background fetch returned empty but snapshot had data — keeping snapshot' + ); + } else { + set(validateSnapshot(targetSnapshot, freshProjects, freshRepoGroups)); + } + } else { + // No cache (first visit) — apply empty state with fresh data + set({ + ...getEmptyContextState(), + projects: freshProjects, + repositoryGroups: freshRepoGroups, + activeContextId: targetContextId, + isContextSwitching: false, + targetContextId: null, + }); + } + } catch (fetchError) { + console.error('[contextSlice] Background data refresh failed:', fetchError); + // Keep snapshot data as fallback — don't wipe user's view + if (!targetSnapshot) { + // No snapshot and fetch failed — finalize switch with empty state + set({ + ...getEmptyContextState(), + activeContextId: targetContextId, + isContextSwitching: false, + targetContextId: null, + }); + } + } + + // Step 4: Fetch notifications in background + void get().fetchNotifications(); } catch (error) { console.error('[contextSlice] Failed to switch context:', error); // Do NOT leave in broken state