diff --git a/src/renderer/hooks/use-menu-action.ts b/src/renderer/hooks/use-menu-action.ts new file mode 100644 index 0000000..bb19c12 --- /dev/null +++ b/src/renderer/hooks/use-menu-action.ts @@ -0,0 +1,29 @@ +import { useEffect } from 'react'; +import { ipc } from '@/lib/ipc'; +import { useCommandStore } from '@/stores/command-store'; +import type { CommandId } from '@/stores/command-store'; + +type Transform = (...payload: unknown[]) => Args; + +/** + * Bridge a native-menu IPC channel to a command-store dispatch. + * + * When the main process sends `channel` (e.g. `'file-save'`), the optional + * `transform` converts the payload to the command's args, then the command + * `commandId` is dispatched through the command store. + * + * The hook cleans up its IPC subscription on unmount. + */ +export function useMenuAction( + channel: string, + commandId: CommandId, + transform?: Transform +): void { + useEffect(() => { + const unsubscribe = ipc.menu.on(channel, (...payload: unknown[]) => { + const args = transform ? transform(...payload) : (payload[0] as Args); + useCommandStore.getState().dispatch(commandId, args); + }); + return unsubscribe; + }, [channel, commandId, transform]); +} diff --git a/src/renderer/lib/ipc.ts b/src/renderer/lib/ipc.ts index 340ed3c..5281919 100644 --- a/src/renderer/lib/ipc.ts +++ b/src/renderer/lib/ipc.ts @@ -95,4 +95,16 @@ export const ipc = { showItemInFolder: (path: string): Promise> => safeCall('app', 'showItemInFolder', path), }, + menu: { + /** + * Subscribe to a native-menu IPC channel. The callback receives the + * raw payload(s) from the main process. Returns an unsubscribe fn. + */ + on: (channel: string, callback: (...args: unknown[]) => void): (() => void) => { + if (typeof window === 'undefined' || !window.electronAPI?.on) { + return () => {}; + } + return window.electronAPI.on(channel, callback as (...a: any[]) => void); + }, + }, }; \ No newline at end of file diff --git a/tests/unit/hooks/use-menu-action.test.ts b/tests/unit/hooks/use-menu-action.test.ts new file mode 100644 index 0000000..531c785 --- /dev/null +++ b/tests/unit/hooks/use-menu-action.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useMenuAction } from '@/hooks/use-menu-action'; +import { useCommandStore } from '@/stores/command-store'; + +type Cleanup = () => void; + +const menuListeners = new Map void>(); +const subscribeToMenu = vi.fn( + (channel: string, callback: (...args: unknown[]) => void): Cleanup => { + menuListeners.set(channel, callback); + return () => { + if (menuListeners.get(channel) === callback) menuListeners.delete(channel); + }; + } +); + +vi.mock('@/lib/ipc', () => ({ + ipc: { + menu: { + on: (channel: string, callback: (...args: unknown[]) => void) => + subscribeToMenu(channel, callback), + }, + }, +})); + +function fireMenu(channel: string, ...args: unknown[]): void { + const listener = menuListeners.get(channel); + if (!listener) throw new Error(`No listener registered for ${channel}`); + listener(...args); +} + +describe('useMenuAction', () => { + beforeEach(() => { + menuListeners.clear(); + subscribeToMenu.mockClear(); + useCommandStore.setState({ handlers: {} }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('subscribes to the given IPC channel and dispatches the matching command on fire', () => { + const dispatched: unknown[] = []; + useCommandStore.getState().register('file.save', (args) => dispatched.push(args)); + + renderHook(() => useMenuAction('file-save', 'file.save')); + + expect(subscribeToMenu).toHaveBeenCalledWith('file-save', expect.any(Function)); + fireMenu('file-save'); + expect(dispatched).toEqual([undefined]); + }); + + it('forwards the IPC payload to the command as args', () => { + const dispatched: unknown[] = []; + useCommandStore.getState().register('template.load', (args) => dispatched.push(args)); + + renderHook(() => useMenuAction('load-template-menu', 'template.load', (name) => ({ name }))); + + fireMenu('load-template-menu', 'blog-post.md'); + expect(dispatched).toEqual([{ name: 'blog-post.md' }]); + }); + + it('default transform: args is the first IPC payload as-is', () => { + const dispatched: unknown[] = []; + useCommandStore.getState().register('file.opened', (args) => dispatched.push(args)); + + renderHook(() => useMenuAction('file-opened', 'file.opened')); + + fireMenu('file-opened', { path: '/a.md', content: '# hi' }); + expect(dispatched).toEqual([{ path: '/a.md', content: '# hi' }]); + }); + + it('if no command is registered, fire is a no-op (does not throw)', () => { + renderHook(() => useMenuAction('file-save', 'file.save')); + expect(() => fireMenu('file-save')).not.toThrow(); + }); + + it('unsubscribes on unmount', () => { + renderHook(() => useMenuAction('file-save', 'file.save')); + expect(menuListeners.has('file-save')).toBe(true); + }); +});