Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions main/src/daemon/commandRegistry.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, (_event: unknown, ...args: unknown[]) => 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 });
});
});
65 changes: 65 additions & 0 deletions main/src/daemon/commandRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { isDaemonOwnedChannel } from '../../../shared/types/daemon';

export type PaneCommandHandler<TArgs extends unknown[] = unknown[], TResult = unknown> = (
...args: TArgs
) => Promise<TResult> | TResult;

interface IpcMainHandleLike {
handle(channel: string, listener: (_event: unknown, ...args: unknown[]) => unknown): void;
}

export class PaneCommandRegistry {
private readonly handlers = new Map<string, PaneCommandHandler>();
private readonly boundChannels = new Set<string>();

register<TArgs extends unknown[], TResult>(
channel: string,
handler: PaneCommandHandler<TArgs, TResult>,
): 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<unknown> {
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);
}
}
}
90 changes: 48 additions & 42 deletions main/src/ipc/folders.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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) {
Expand All @@ -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);
Expand All @@ -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) {
Expand All @@ -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 };
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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 };
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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' };
}
});
}

commandRegistry.bindChannels(ipcMain, DAEMON_FOLDER_CHANNELS);
}
6 changes: 2 additions & 4 deletions main/src/ipc/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
Expand Down
15 changes: 10 additions & 5 deletions main/src/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
}

return commandRegistry;
}
Loading