diff --git a/main/src/daemon/commandRegistry.test.ts b/main/src/daemon/commandRegistry.test.ts new file mode 100644 index 0000000..d7ef141 --- /dev/null +++ b/main/src/daemon/commandRegistry.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { PaneCommandRegistry } from './commandRegistry'; + +describe('PaneCommandRegistry', () => { + it('registers and invokes daemon-owned commands', async () => { + const registry = new PaneCommandRegistry(); + registry.register('folders:get-by-project', async (projectId: number) => ({ projectId })); + + await expect(registry.invoke('folders:get-by-project', [42])).resolves.toEqual({ projectId: 42 }); + }); + + it('rejects non-daemon-owned channels', () => { + const registry = new PaneCommandRegistry(); + + expect(() => registry.register('openExternal', () => true)).toThrow( + 'Cannot register non-daemon-owned channel "openExternal" in PaneCommandRegistry', + ); + }); + + it('rejects duplicate registrations', () => { + const registry = new PaneCommandRegistry(); + registry.register('logs:get-by-project', () => []); + + expect(() => registry.register('logs:get-by-project', () => [])).toThrow( + 'Pane daemon command "logs:get-by-project" is already registered', + ); + }); + + it('throws when invoking an unregistered command', async () => { + const registry = new PaneCommandRegistry(); + + await expect(registry.invoke('folders:get-by-project', [1])).rejects.toThrow( + 'No Pane daemon command registered for channel "folders:get-by-project"', + ); + }); + + it('binds registered commands back to IPC handles', async () => { + const registry = new PaneCommandRegistry(); + const bound = new Map unknown>(); + const ipcMain = { + handle(channel: string, listener: (_event: unknown, ...args: unknown[]) => unknown) { + bound.set(channel, listener); + }, + }; + + registry.register('resource-monitor:get-snapshot', async () => ({ success: true })); + registry.bindChannels(ipcMain, ['resource-monitor:get-snapshot']); + + const listener = bound.get('resource-monitor:get-snapshot'); + expect(listener).toBeTruthy(); + if (!listener) { + throw new Error('Expected IPC listener to be bound'); + } + await expect(listener({})).resolves.toEqual({ success: true }); + }); +}); diff --git a/main/src/daemon/commandRegistry.ts b/main/src/daemon/commandRegistry.ts new file mode 100644 index 0000000..2de857f --- /dev/null +++ b/main/src/daemon/commandRegistry.ts @@ -0,0 +1,65 @@ +import { isDaemonOwnedChannel } from '../../../shared/types/daemon'; + +export type PaneCommandHandler = ( + ...args: TArgs +) => Promise | TResult; + +interface IpcMainHandleLike { + handle(channel: string, listener: (_event: unknown, ...args: unknown[]) => unknown): void; +} + +export class PaneCommandRegistry { + private readonly handlers = new Map(); + private readonly boundChannels = new Set(); + + register( + channel: string, + handler: PaneCommandHandler, + ): void { + if (!isDaemonOwnedChannel(channel)) { + throw new Error(`Cannot register non-daemon-owned channel "${channel}" in PaneCommandRegistry`); + } + + if (this.handlers.has(channel)) { + throw new Error(`Pane daemon command "${channel}" is already registered`); + } + + this.handlers.set(channel, handler as PaneCommandHandler); + } + + has(channel: string): boolean { + return this.handlers.has(channel); + } + + listChannels(): string[] { + return [...this.handlers.keys()].sort(); + } + + async invoke(channel: string, args: readonly unknown[] = []): Promise { + const handler = this.handlers.get(channel); + if (!handler) { + throw new Error(`No Pane daemon command registered for channel "${channel}"`); + } + + return handler(...args); + } + + bindChannel(ipcMain: IpcMainHandleLike, channel: string): void { + if (!this.handlers.has(channel)) { + throw new Error(`Cannot bind unregistered Pane daemon command "${channel}"`); + } + + if (this.boundChannels.has(channel)) { + throw new Error(`Pane daemon command "${channel}" is already bound to IPC`); + } + + ipcMain.handle(channel, (_event, ...args) => this.invoke(channel, args)); + this.boundChannels.add(channel); + } + + bindChannels(ipcMain: IpcMainHandleLike, channels: readonly string[]): void { + for (const channel of channels) { + this.bindChannel(ipcMain, channel); + } + } +} diff --git a/main/src/ipc/folders.ts b/main/src/ipc/folders.ts index aa02d8b..7810307 100644 --- a/main/src/ipc/folders.ts +++ b/main/src/ipc/folders.ts @@ -1,28 +1,31 @@ -import { IpcMain } from 'electron'; -import type { Folder } from '../database/models'; +import type { IpcMain } from 'electron'; +import { PaneCommandRegistry } from '../daemon/commandRegistry'; import type { AppServices } from './types'; - -// Convert database folder (snake_case) to frontend folder (camelCase) -export function convertDbFolderToFolder(dbFolder: Folder) { - return { - id: dbFolder.id, - name: dbFolder.name, - projectId: dbFolder.project_id, - parentFolderId: dbFolder.parent_folder_id, - displayOrder: dbFolder.display_order, - createdAt: dbFolder.created_at, - updatedAt: dbFolder.updated_at - }; -} - -export function registerFolderHandlers(ipcMain: IpcMain, services: AppServices) { - const { databaseService, getMainWindow, analyticsManager } = services; +import { + convertDbFolderToRendererFolder, + emitFolderCreatedEvent, + emitFolderDeletedEvent, + emitFolderUpdatedEvent, +} from '../services/folderEvents'; + +const DAEMON_FOLDER_CHANNELS = [ + 'folders:get-by-project', + 'folders:create', + 'folders:update', + 'folders:delete', + 'folders:reorder', + 'folders:move-session', + 'folders:move', +] as const; + +export function registerFolderHandlers(ipcMain: IpcMain, services: AppServices, commandRegistry: PaneCommandRegistry) { + const { databaseService, analyticsManager } = services; // Get all folders for a project - ipcMain.handle('folders:get-by-project', async (_, projectId: number) => { + commandRegistry.register('folders:get-by-project', async (projectId: number) => { try { const folders = databaseService.getFoldersForProject(projectId); - const convertedFolders = folders.map(convertDbFolderToFolder); + const convertedFolders = folders.map(convertDbFolderToRendererFolder); return { success: true, data: convertedFolders }; } catch (error: unknown) { console.error('[IPC] Failed to get folders:', error); @@ -31,10 +34,10 @@ export function registerFolderHandlers(ipcMain: IpcMain, services: AppServices) }); // Create a new folder - ipcMain.handle('folders:create', async (_, name: string, projectId: number, parentFolderId?: string | null) => { + commandRegistry.register('folders:create', async (name: string, projectId: number, parentFolderId?: string | null) => { try { const folder = databaseService.createFolder(name, projectId, parentFolderId); - const convertedFolder = convertDbFolderToFolder(folder); + const convertedFolder = convertDbFolderToRendererFolder(folder); // Track folder creation if (analyticsManager) { @@ -44,6 +47,7 @@ export function registerFolderHandlers(ipcMain: IpcMain, services: AppServices) }); } + emitFolderCreatedEvent(folder); return { success: true, data: convertedFolder }; } catch (error: unknown) { console.error('[IPC] Failed to create folder:', error); @@ -52,7 +56,10 @@ export function registerFolderHandlers(ipcMain: IpcMain, services: AppServices) }); // Update a folder - ipcMain.handle('folders:update', async (_, folderId: string, updates: { name?: string; display_order?: number; parent_folder_id?: string | null }) => { + commandRegistry.register('folders:update', async ( + folderId: string, + updates: { name?: string; display_order?: number; parent_folder_id?: string | null }, + ) => { try { // Track folder rename if name is being updated if (analyticsManager && updates.name !== undefined) { @@ -64,14 +71,7 @@ export function registerFolderHandlers(ipcMain: IpcMain, services: AppServices) // Get the updated folder to emit the event const updatedFolder = databaseService.getFolder(folderId); if (updatedFolder) { - - // Emit the folder:updated event to notify the frontend - const mainWindow = getMainWindow(); - if (mainWindow && !mainWindow.isDestroyed()) { - console.log(`[IPC] Emitting folder:updated event for folder ${folderId}`); - const convertedFolder = convertDbFolderToFolder(updatedFolder); - mainWindow.webContents.send('folder:updated', convertedFolder); - } + emitFolderUpdatedEvent(updatedFolder); } return { success: true }; @@ -82,7 +82,7 @@ export function registerFolderHandlers(ipcMain: IpcMain, services: AppServices) }); // Delete a folder - ipcMain.handle('folders:delete', async (_, folderId: string) => { + commandRegistry.register('folders:delete', async (folderId: string) => { try { // Count sessions in the folder before deletion for analytics if (analyticsManager) { @@ -100,12 +100,7 @@ export function registerFolderHandlers(ipcMain: IpcMain, services: AppServices) databaseService.deleteFolder(folderId); - // Emit the folder:deleted event to notify the frontend - const mainWindow = getMainWindow(); - if (mainWindow && !mainWindow.isDestroyed()) { - console.log(`[IPC] Emitting folder:deleted event for folder ${folderId}`); - mainWindow.webContents.send('folder:deleted', folderId); - } + emitFolderDeletedEvent(folderId); return { success: true }; } catch (error: unknown) { @@ -115,7 +110,10 @@ export function registerFolderHandlers(ipcMain: IpcMain, services: AppServices) }); // Reorder folders within a project - ipcMain.handle('folders:reorder', async (_, projectId: number, folderOrders: Array<{ id: string; displayOrder: number }>) => { + commandRegistry.register('folders:reorder', async ( + projectId: number, + folderOrders: Array<{ id: string; displayOrder: number }>, + ) => { try { databaseService.reorderFolders(projectId, folderOrders); return { success: true }; @@ -126,7 +124,7 @@ export function registerFolderHandlers(ipcMain: IpcMain, services: AppServices) }); // Move session to folder - ipcMain.handle('folders:move-session', async (_, sessionId: string, folderId: string | null) => { + commandRegistry.register('folders:move-session', async (sessionId: string, folderId: string | null) => { try { // Get the session to verify it exists const session = databaseService.getSession(sessionId); @@ -155,7 +153,7 @@ export function registerFolderHandlers(ipcMain: IpcMain, services: AppServices) }); // Move folder to another folder (for nesting) - ipcMain.handle('folders:move', async (_, folderId: string, parentFolderId: string | null) => { + commandRegistry.register('folders:move', async (folderId: string, parentFolderId: string | null) => { try { // Get the folder to verify it exists const folder = databaseService.getFolder(folderId); @@ -187,10 +185,18 @@ export function registerFolderHandlers(ipcMain: IpcMain, services: AppServices) // Update the folder databaseService.updateFolder(folderId, { parent_folder_id: parentFolderId }); + + const updatedFolder = databaseService.getFolder(folderId); + if (updatedFolder) { + emitFolderUpdatedEvent(updatedFolder); + } + return { success: true }; } catch (error: unknown) { console.error('[IPC] Failed to move folder:', error); return { success: false, error: error instanceof Error ? error.message : 'Failed to move folder' }; } }); -} \ No newline at end of file + + commandRegistry.bindChannels(ipcMain, DAEMON_FOLDER_CHANNELS); +} diff --git a/main/src/ipc/git.ts b/main/src/ipc/git.ts index 1f517cc..d023d62 100644 --- a/main/src/ipc/git.ts +++ b/main/src/ipc/git.ts @@ -3,7 +3,7 @@ import { existsSync } from 'fs'; import { join } from 'path'; import type { AppServices } from './types'; import { buildGitCommitCommand } from '../utils/shellEscape'; -import { mainWindow } from '../index'; +import { getPaneEventSink } from '../core/runtime'; import { panelEventBus } from '../services/panelEventBus'; import { PanelEventType, ToolPanelType, PanelEvent } from '../../../shared/types/panels'; import type { Session } from '../types/session'; @@ -100,9 +100,7 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo // Also forward to renderer so UI components listening for window 'panel:event' receive it try { - if (mainWindow) { - mainWindow.webContents.send('panel:event', event); - } + getPaneEventSink().send('panel:event', event); } catch (ipcError) { console.error('[Git] Failed to forward git operation event to renderer:', ipcError); } diff --git a/main/src/ipc/index.ts b/main/src/ipc/index.ts index 7910031..87188df 100644 --- a/main/src/ipc/index.ts +++ b/main/src/ipc/index.ts @@ -22,9 +22,12 @@ import { registerCloudHandlers } from './cloud'; import { registerClipboardHandlers } from './clipboard'; import { registerResourceMonitorHandlers } from './resourceMonitor'; import { registerOnboardingHandlers } from './onboarding'; +import { PaneCommandRegistry } from '../daemon/commandRegistry'; -export function registerIpcHandlers(services: AppServices): void { +export function registerIpcHandlers(services: AppServices): PaneCommandRegistry { + const commandRegistry = new PaneCommandRegistry(); + registerAppHandlers(ipcMain, services); registerUpdaterHandlers(ipcMain, services); registerSessionHandlers(ipcMain, services); @@ -35,16 +38,18 @@ export function registerIpcHandlers(services: AppServices): void { registerScriptHandlers(ipcMain, services); registerPromptHandlers(ipcMain, services); registerFileHandlers(ipcMain, services); - registerFolderHandlers(ipcMain, services); + registerFolderHandlers(ipcMain, services, commandRegistry); registerUIStateHandlers(services); registerDashboardHandlers(ipcMain, services); - setupLogHandlers(services.sessionManager); + setupLogHandlers(ipcMain, services.sessionManager, commandRegistry); registerPanelHandlers(ipcMain, services); registerEditorPanelHandlers(ipcMain, services); registerNimbalystHandlers(ipcMain, services); registerSpotlightHandlers(ipcMain, services); registerCloudHandlers(ipcMain, services); registerClipboardHandlers(ipcMain, services); - registerResourceMonitorHandlers(ipcMain, services); + registerResourceMonitorHandlers(ipcMain, services, commandRegistry); registerOnboardingHandlers(ipcMain, services); -} \ No newline at end of file + + return commandRegistry; +} diff --git a/main/src/ipc/logs.ts b/main/src/ipc/logs.ts index e9b9400..083ae63 100644 --- a/main/src/ipc/logs.ts +++ b/main/src/ipc/logs.ts @@ -1,6 +1,7 @@ -import { ipcMain } from 'electron'; +import type { IpcMain } from 'electron'; +import { PaneCommandRegistry } from '../daemon/commandRegistry'; +import { getPaneEventSink } from '../core/runtime'; import { SessionManager } from '../services/sessionManager'; -import { mainWindow } from '../index'; interface LogEntry { timestamp: string; @@ -12,9 +13,30 @@ interface LogEntry { // Store logs per session in memory const sessionLogs = new Map(); -export function setupLogHandlers(sessionManager: SessionManager) { +const DAEMON_LOG_CHANNELS = [ + 'sessions:get-logs', + 'sessions:clear-logs', + 'sessions:add-log', +] as const; + +function sendSessionLogEvent(sessionId: string, entry: LogEntry): void { + getPaneEventSink().send('session-log', { + sessionId, + entry, + }); +} + +function sendSessionLogsClearedEvent(sessionId: string): void { + getPaneEventSink().send('session-logs-cleared', { sessionId }); +} + +export function setupLogHandlers( + ipcMain: IpcMain, + _sessionManager: SessionManager, + commandRegistry: PaneCommandRegistry, +) { // Get logs for a session - ipcMain.handle('sessions:get-logs', async (_event, sessionId: string) => { + commandRegistry.register('sessions:get-logs', async (sessionId: string) => { try { const logs = sessionLogs.get(sessionId) || []; return { success: true, data: logs }; @@ -28,9 +50,10 @@ export function setupLogHandlers(sessionManager: SessionManager) { }); // Clear logs for a session - ipcMain.handle('sessions:clear-logs', async (_event, sessionId: string) => { + commandRegistry.register('sessions:clear-logs', async (sessionId: string) => { try { sessionLogs.set(sessionId, []); + sendSessionLogsClearedEvent(sessionId); return { success: true }; } catch (error) { console.error('Failed to clear logs:', error); @@ -42,20 +65,13 @@ export function setupLogHandlers(sessionManager: SessionManager) { }); // Add a log entry - ipcMain.handle('sessions:add-log', async (_event, sessionId: string, entry: LogEntry) => { + commandRegistry.register('sessions:add-log', async (sessionId: string, entry: LogEntry) => { try { const logs = sessionLogs.get(sessionId) || []; logs.push(entry); sessionLogs.set(sessionId, logs); - - // Send the log entry to the renderer - if (mainWindow) { - mainWindow.webContents.send('session-log', { - sessionId, - entry - }); - } - + + sendSessionLogEvent(sessionId, entry); return { success: true }; } catch (error) { console.error('Failed to add log:', error); @@ -65,6 +81,8 @@ export function setupLogHandlers(sessionManager: SessionManager) { }; } }); + + commandRegistry.bindChannels(ipcMain, DAEMON_LOG_CHANNELS); } // Helper function to add a log from internal sources @@ -79,22 +97,13 @@ export function addSessionLog(sessionId: string, level: LogEntry['level'], messa const logs = sessionLogs.get(sessionId) || []; logs.push(entry); sessionLogs.set(sessionId, logs); - - // Send the log entry to the renderer - if (mainWindow) { - mainWindow.webContents.send('session-log', { - sessionId, - entry - }); - } + + sendSessionLogEvent(sessionId, entry); } // Helper to clean up logs when a session is deleted or when starting a new run export function cleanupSessionLogs(sessionId: string) { sessionLogs.delete(sessionId); - - // Notify the frontend to clear logs - if (mainWindow) { - mainWindow.webContents.send('session-logs-cleared', { sessionId }); - } -} \ No newline at end of file + + sendSessionLogsClearedEvent(sessionId); +} diff --git a/main/src/ipc/resourceMonitor.ts b/main/src/ipc/resourceMonitor.ts index 551c680..ad6dcc8 100644 --- a/main/src/ipc/resourceMonitor.ts +++ b/main/src/ipc/resourceMonitor.ts @@ -1,9 +1,20 @@ import type { IpcMain } from 'electron'; +import { PaneCommandRegistry } from '../daemon/commandRegistry'; import type { AppServices } from './types'; import { resourceMonitorService } from '../services/resourceMonitorService'; -export function registerResourceMonitorHandlers(ipcMain: IpcMain, _services: AppServices): void { - ipcMain.handle('resource-monitor:get-snapshot', async () => { +const DAEMON_RESOURCE_MONITOR_CHANNELS = [ + 'resource-monitor:get-snapshot', + 'resource-monitor:start-active', + 'resource-monitor:stop-active', +] as const; + +export function registerResourceMonitorHandlers( + ipcMain: IpcMain, + _services: AppServices, + commandRegistry: PaneCommandRegistry, +): void { + commandRegistry.register('resource-monitor:get-snapshot', async () => { try { const snapshot = await resourceMonitorService.getSnapshot(); return { success: true, data: snapshot }; @@ -13,7 +24,7 @@ export function registerResourceMonitorHandlers(ipcMain: IpcMain, _services: App } }); - ipcMain.handle('resource-monitor:start-active', async () => { + commandRegistry.register('resource-monitor:start-active', async () => { try { resourceMonitorService.startActivePolling(); return { success: true }; @@ -22,7 +33,7 @@ export function registerResourceMonitorHandlers(ipcMain: IpcMain, _services: App } }); - ipcMain.handle('resource-monitor:stop-active', async () => { + commandRegistry.register('resource-monitor:stop-active', async () => { try { resourceMonitorService.stopActivePolling(); return { success: true }; @@ -30,4 +41,6 @@ export function registerResourceMonitorHandlers(ipcMain: IpcMain, _services: App return { success: false, error: (error instanceof Error) ? error.message : String(error) }; } }); + + commandRegistry.bindChannels(ipcMain, DAEMON_RESOURCE_MONITOR_CHANNELS); } diff --git a/main/src/ipc/session.ts b/main/src/ipc/session.ts index 4eb3f68..a6be5f5 100644 --- a/main/src/ipc/session.ts +++ b/main/src/ipc/session.ts @@ -11,7 +11,7 @@ import { existsSync } from 'fs'; import type { AppServices } from './types'; import type { CreateSessionRequest } from '../types/session'; import { getAppSubdirectory } from '../utils/appDirectory'; -import { convertDbFolderToFolder } from './folders'; +import { convertDbFolderToRendererFolder } from '../services/folderEvents'; import { sessionImageCounters } from './panels'; import { panelManager } from '../services/panelManager'; import { terminalPanelManager } from '../services/terminalPanelManager'; @@ -89,7 +89,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) const projectsWithSessions = allProjects.map(project => { const sessions = sessionManager.getSessionsForProject(project.id); const folders = databaseService.getFoldersForProject(project.id); - const convertedFolders = folders.map(convertDbFolderToFolder); + const convertedFolders = folders.map(convertDbFolderToRendererFolder); return { ...project, sessions, diff --git a/main/src/services/folderEvents.ts b/main/src/services/folderEvents.ts new file mode 100644 index 0000000..453af1e --- /dev/null +++ b/main/src/services/folderEvents.ts @@ -0,0 +1,26 @@ +import { getPaneEventSink } from '../core/runtime'; +import type { Folder as DatabaseFolder } from '../database/models'; + +export function convertDbFolderToRendererFolder(dbFolder: DatabaseFolder) { + return { + id: dbFolder.id, + name: dbFolder.name, + projectId: dbFolder.project_id, + parentFolderId: dbFolder.parent_folder_id, + displayOrder: dbFolder.display_order, + createdAt: dbFolder.created_at, + updatedAt: dbFolder.updated_at, + }; +} + +export function emitFolderCreatedEvent(folder: DatabaseFolder): void { + getPaneEventSink().send('folder:created', convertDbFolderToRendererFolder(folder)); +} + +export function emitFolderUpdatedEvent(folder: DatabaseFolder): void { + getPaneEventSink().send('folder:updated', convertDbFolderToRendererFolder(folder)); +} + +export function emitFolderDeletedEvent(folderId: string): void { + getPaneEventSink().send('folder:deleted', folderId); +} diff --git a/main/src/services/taskQueue.ts b/main/src/services/taskQueue.ts index f59a10e..1a4a1c4 100644 --- a/main/src/services/taskQueue.ts +++ b/main/src/services/taskQueue.ts @@ -17,6 +17,7 @@ import type { Project } from '../database/models'; import { worktreeFileSyncService } from './worktreeFileSyncService'; import { terminalPanelManager } from './terminalPanelManager'; import { detectProjectConfig } from './projectConfigDetector'; +import { emitFolderCreatedEvent } from './folderEvents'; interface TaskQueueOptions { sessionManager: SessionManager; @@ -503,12 +504,10 @@ export class TaskQueue { folderId = folder.id; // Emit folder created event immediately - const getMainWindow = this.options.getMainWindow; - const mainWindow = getMainWindow(); - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('folder:created', folder); - } else { - console.warn(`[TaskQueue] Could not emit folder:created event - main window not available`); + try { + emitFolderCreatedEvent(folder); + } catch (error) { + console.error('[TaskQueue] Failed to emit folder:created event:', error); } } catch (error) { console.error('[TaskQueue] Failed to create folder for multi-session prompt:', error);