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