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:
matt 2026-02-12 17:08:05 +09:00
parent 0dfe445b0e
commit 971fc142b2
7 changed files with 172 additions and 72 deletions

View file

@ -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 () => {

View file

@ -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) {

View file

@ -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);

View file

@ -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() };

View file

@ -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)

View file

@ -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(),
});

View file

@ -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