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.
This commit is contained in:
parent
0dfe445b0e
commit
971fc142b2
7 changed files with 172 additions and 72 deletions
|
|
@ -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<void> {
|
|||
}
|
||||
|
||||
/**
|
||||
* 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 () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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() };
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -76,8 +76,30 @@ 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() : {}),
|
||||
// On connect: sync context ID + clear all stale local data including tabs
|
||||
...(status.state === 'connected'
|
||||
? {
|
||||
activeContextId: `ssh-${config.host}`,
|
||||
projects: [],
|
||||
repositoryGroups: [],
|
||||
openTabs: [],
|
||||
activeTabId: null,
|
||||
selectedTabIds: [],
|
||||
paneLayout: {
|
||||
panes: [
|
||||
{
|
||||
id: 'pane-default',
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
selectedTabIds: [],
|
||||
widthFraction: 1,
|
||||
},
|
||||
],
|
||||
focusedPaneId: 'pane-default',
|
||||
},
|
||||
...getFullResetState(),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
// Re-fetch all data and persist config when connected
|
||||
|
|
@ -114,7 +136,25 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
|||
connectionState: status.state,
|
||||
connectedHost: null,
|
||||
connectionError: null,
|
||||
// Clear stale remote selections so dashboard shows fresh local data
|
||||
activeContextId: 'local',
|
||||
// Clear all stale SSH data including tabs so dashboard shows fresh local data
|
||||
projects: [],
|
||||
repositoryGroups: [],
|
||||
openTabs: [],
|
||||
activeTabId: null,
|
||||
selectedTabIds: [],
|
||||
paneLayout: {
|
||||
panes: [
|
||||
{
|
||||
id: 'pane-default',
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
selectedTabIds: [],
|
||||
widthFraction: 1,
|
||||
},
|
||||
],
|
||||
focusedPaneId: 'pane-default',
|
||||
},
|
||||
...getFullResetState(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -12,9 +12,8 @@ import { getFullResetState } from '../utils/stateResetHelpers';
|
|||
|
||||
import type { AppState } from '../types';
|
||||
import type { ContextSnapshot } from '@renderer/services/contextStorage';
|
||||
import type { DetectedError, Project, RepositoryGroup } from '@renderer/types/data';
|
||||
import type { Project, RepositoryGroup } from '@renderer/types/data';
|
||||
import type { Pane } from '@renderer/types/panes';
|
||||
import type { Tab } from '@renderer/types/tabs';
|
||||
import type { ContextInfo } from '@shared/types/api';
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
|
|
@ -280,51 +279,106 @@ export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> =
|
|||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue