From a95c2a4bec38f2c7df7593425882e2ce39d99b85 Mon Sep 17 00:00:00 2001 From: Anna Henningsen Date: Wed, 22 Oct 2025 21:29:01 +0200 Subject: [PATCH 1/7] chore: refactor and centralize app menu access Instead of having the logic for accessing the Electron application menu from the collection tabs spread out through the codebase, bundle it in a single interface that gives all Compass plugins access to the menu as needed. --- .../src/components/collection-tab.tsx | 82 ++++++++- packages/compass-workspaces/package.json | 6 +- .../src/application-menu.tsx | 141 +++++++++++++++ packages/compass-workspaces/src/index.ts | 42 ----- packages/compass/src/app/application.tsx | 113 ++++++++---- packages/compass/src/app/components/home.tsx | 31 ++-- packages/compass/src/main/menu.spec.ts | 137 +++++++------- packages/compass/src/main/menu.ts | 167 ++++++++++-------- 8 files changed, 471 insertions(+), 248 deletions(-) create mode 100644 packages/compass-workspaces/src/application-menu.tsx diff --git a/packages/compass-collection/src/components/collection-tab.tsx b/packages/compass-collection/src/components/collection-tab.tsx index 7d0756d113f..eee21d0328b 100644 --- a/packages/compass-collection/src/components/collection-tab.tsx +++ b/packages/compass-collection/src/components/collection-tab.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { connect } from 'react-redux'; import { type CollectionState, selectTab } from '../modules/collection-tab'; import { css, ErrorBoundary, TabNavBar } from '@mongodb-js/compass-components'; @@ -19,6 +19,11 @@ import { useConnectionSupports, } from '@mongodb-js/compass-connections/provider'; import { usePreference } from 'compass-preferences-model/provider'; +import { useApplicationMenu } from '@mongodb-js/compass-workspaces/application-menu'; +import { + useGlobalAppRegistry, + useLocalAppRegistry, +} from '@mongodb-js/compass-app-registry'; type CollectionSubtabTrackingId = Lowercase extends infer U ? U extends string @@ -228,6 +233,80 @@ const CollectionTabWithMetadata: React.FunctionComponent< ); }; +// Setup the Electron application menu for the collection tab +function useCollectionTabApplicationMenu( + collectionMetadata: CollectionMetadata | null +) { + const localAppRegistry = useLocalAppRegistry(); + const globalAppRegistry = useGlobalAppRegistry(); + const connectionInfoRef = useConnectionInfoRef(); + const preferencesReadOnly = usePreference('readOnly'); + + const shareSchemaClick = useCallback(() => { + localAppRegistry.emit('menu-share-schema-json'); + }, [localAppRegistry]); + + const importClick = useCallback(() => { + if (!collectionMetadata) return; + globalAppRegistry.emit( + 'open-import', + { + namespace: collectionMetadata.namespace, + origin: 'menu', + }, + { + connectionId: connectionInfoRef.current.id, + }, + {} + ); + }, [collectionMetadata, globalAppRegistry, connectionInfoRef]); + + const exportClick = useCallback(() => { + if (!collectionMetadata) return; + globalAppRegistry.emit( + 'open-export', + { + exportFullCollection: true, + namespace: collectionMetadata.namespace, + origin: 'menu', + }, + { + connectionId: connectionInfoRef.current.id, + } + ); + }, [collectionMetadata, globalAppRegistry, connectionInfoRef]); + + useApplicationMenu({ + menu: collectionMetadata + ? { + label: '&Collection', + submenu: [ + { + label: '&Share Schema as JSON (Legacy)', + accelerator: 'Alt+CmdOrCtrl+S', + click: shareSchemaClick, + }, + { + type: 'separator', + }, + ...(preferencesReadOnly || collectionMetadata?.isReadonly + ? [] + : [ + { + label: '&Import Data', + click: importClick, + }, + ]), + { + label: '&Export Collection', + click: exportClick, + }, + ], + } + : undefined, + }); +} + const CollectionTab = ({ collectionMetadata, ...props @@ -235,6 +314,7 @@ const CollectionTab = ({ collectionMetadata: CollectionMetadata | null; }) => { const QueryBarPlugin = useCollectionQueryBar(); + useCollectionTabApplicationMenu(collectionMetadata); if (!collectionMetadata) { return null; diff --git a/packages/compass-workspaces/package.json b/packages/compass-workspaces/package.json index 1c332c48bc2..b0958ed190d 100644 --- a/packages/compass-workspaces/package.json +++ b/packages/compass-workspaces/package.json @@ -24,11 +24,13 @@ "compass:main": "src/index.ts", "exports": { ".": "./dist/index.js", - "./provider": "./dist/provider.js" + "./provider": "./dist/provider.js", + "./application-menu": "./dist/application-menu.js" }, "compass:exports": { ".": "./src/index.ts", - "./provider": "./src/provider.tsx" + "./provider": "./src/provider.tsx", + "./application-menu": "./src/application-menu.tsx" }, "types": "./dist/index.d.ts", "scripts": { diff --git a/packages/compass-workspaces/src/application-menu.tsx b/packages/compass-workspaces/src/application-menu.tsx new file mode 100644 index 00000000000..62db60b89c5 --- /dev/null +++ b/packages/compass-workspaces/src/application-menu.tsx @@ -0,0 +1,141 @@ +import React, { useContext, useEffect } from 'react'; +import { createServiceLocator } from '@mongodb-js/compass-app-registry'; + +// Type-only import in a separate entry point, so this is fine +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import type { MenuItemConstructorOptions } from 'electron'; + +export type CompassAppMenu void> = Omit< + MenuItemConstructorOptions, + 'click' | 'submenu' +> & { click?: ClickHandlerType; submenu?: CompassAppMenu[] }; + +export interface ApplicationMenuProvider { + // These functions return 'unsubscribe'-style listeners to remove + // the handlers again + showApplicationMenu(this: void, menu: CompassAppMenu): () => void; + handleMenuRole( + this: void, + role: MenuItemConstructorOptions['role'], + handler: () => void + ): () => void; +} + +const ApplicationMenuContext = React.createContext({ + showApplicationMenu: () => () => {}, + handleMenuRole: () => () => {}, +}); + +export function ApplicationMenuContextProvider({ + provider, + children, +}: { + provider: ApplicationMenuProvider; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +function useApplicationMenuService(): ApplicationMenuProvider { + return useContext(ApplicationMenuContext); +} + +export const applicationMenuServiceLocator = createServiceLocator( + useApplicationMenuService, + 'applicationMenuServiceLocator' +); + +// Shared helper that is useful in a few places since we need to +// translate between 'real function' click handlers and +// string identifiers for those click handlers in a few places. +export function transformAppMenu( + menu: CompassAppMenu, + transform: ( + cb: Omit, 'submenu'> + ) => Omit, 'submenu'> +): CompassAppMenu { + return { + ...transform({ ...menu }), + submenu: menu.submenu + ? menu.submenu.map((sub) => transformAppMenu(sub, transform)) + : undefined, + }; +} + +const objectIds = new WeakMap(); +let objectIdCounter = 0; + +function getObjectId(obj: object): number { + let id = objectIds.get(obj); + if (id === undefined) { + id = ++objectIdCounter; + objectIds.set(obj, id); + } + return id; +} + +// Hook to set up an additional application menu, as well as +// override handlers for pre-defined Electron menu roles. +// +// Example usage: +// +// useApplicationMenu({ +// menu: { +// label: '&MyMenu', +// submenu: [ +// { +// label: 'Do Something', +// click: () => { ... } +// } +// ] +// }, +// roles: { +// undo: () => { ... }, +// redo: () => { ... } +// } +// }); +// +// You will typically want to memoize the callbacks used in these objects +// since they end up as part of the dependency array for this hook. +export function useApplicationMenu({ + menu, + roles, +}: { + menu?: CompassAppMenu; + roles?: Partial< + Record, () => void> + >; +}): void { + const { showApplicationMenu, handleMenuRole } = useApplicationMenuService(); + + useEffect(() => { + const hideMenu = menu && showApplicationMenu(menu); + const subscriptions = Object.entries(roles ?? {}).map(([role, handler]) => + handleMenuRole(role as MenuItemConstructorOptions['role'], handler) + ); + + return () => { + hideMenu?.(); + for (const unsubscribe of subscriptions) unsubscribe(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + showApplicationMenu, + handleMenuRole, + // eslint-disable-next-line react-hooks/exhaustive-deps + menu + ? JSON.stringify( + transformAppMenu(menu, (item) => ({ + ...item, + click: item.click && getObjectId(item.click), + })) + ) + : undefined, + // eslint-disable-next-line react-hooks/exhaustive-deps + roles ? JSON.stringify(Object.values(roles).map(getObjectId)) : undefined, + ]); +} diff --git a/packages/compass-workspaces/src/index.ts b/packages/compass-workspaces/src/index.ts index 6a3dccbd716..6afe1ecf02c 100644 --- a/packages/compass-workspaces/src/index.ts +++ b/packages/compass-workspaces/src/index.ts @@ -9,9 +9,7 @@ import workspacesReducer, { collectionRemoved, collectionRenamed, databaseRemoved, - getActiveTab, getInitialTabState, - getLocalAppRegistryForTab, cleanupLocalAppRegistries, connectionDisconnected, updateDatabaseInfo, @@ -168,46 +166,6 @@ export function activateWorkspacePlugin( } ); - on(globalAppRegistry, 'menu-share-schema-json', () => { - const activeTab = getActiveTab(store.getState()); - if (activeTab?.type === 'Collection') { - getLocalAppRegistryForTab(activeTab.id).emit('menu-share-schema-json'); - } - }); - - on(globalAppRegistry, 'open-active-namespace-export', function () { - const activeTab = getActiveTab(store.getState()); - if (activeTab?.type === 'Collection') { - globalAppRegistry.emit( - 'open-export', - { - exportFullCollection: true, - namespace: activeTab.namespace, - origin: 'menu', - }, - { - connectionId: activeTab.connectionId, - } - ); - } - }); - - on(globalAppRegistry, 'open-active-namespace-import', function () { - const activeTab = getActiveTab(store.getState()); - if (activeTab?.type === 'Collection') { - globalAppRegistry.emit( - 'open-import', - { - namespace: activeTab.namespace, - origin: 'menu', - }, - { - connectionId: activeTab.connectionId, - } - ); - } - }); - onBeforeUnloadCallbackRequest?.(() => { return store.dispatch(beforeUnloading()); }); diff --git a/packages/compass/src/app/application.tsx b/packages/compass/src/app/application.tsx index cdda3a3f485..e27adacce14 100644 --- a/packages/compass/src/app/application.tsx +++ b/packages/compass/src/app/application.tsx @@ -1,6 +1,6 @@ -import { ipcRenderer } from 'hadron-ipc'; +import { ipcRenderer, type HadronIpcRenderer } from 'hadron-ipc'; import * as remote from '@electron/remote'; -import { webUtils, webFrame } from 'electron'; +import { webUtils, webFrame, type MenuItemConstructorOptions } from 'electron'; import { globalAppRegistry } from '@mongodb-js/compass-app-registry'; import { defaultPreferencesInstance } from 'compass-preferences-model'; import semver from 'semver'; @@ -27,23 +27,99 @@ import { onAutoupdateStarted, onAutoupdateSuccess, } from './components/update-toasts'; +import { UUID } from 'bson'; import { createElectronFileInputBackend } from '@mongodb-js/compass-components'; import { CompassRendererConnectionStorage } from '@mongodb-js/connection-storage/renderer'; import type { SettingsTabId } from '@mongodb-js/compass-settings'; import type { AutoConnectPreferences } from '../main/auto-connect'; -const { log, mongoLogId } = createLogger('COMPASS-APP'); +const { log, mongoLogId, debug } = createLogger('COMPASS-APP'); const track = createIpcTrack(); import './index.less'; import 'source-code-pro/source-code-pro.css'; +import { + transformAppMenu, + type ApplicationMenuProvider, + type CompassAppMenu, +} from '@mongodb-js/compass-workspaces/application-menu'; const DEFAULT_APP_VERSION = '0.0.0'; +function translateCallsToHandlerIds( + menu: CompassAppMenu +): [CompassAppMenu, Map void>] { + const handlerIds = new Map void>(); + const transformedMenu = transformAppMenu(menu, (item) => { + if (!item.click) return { ...item, click: undefined }; + const id = new UUID().toString(); + handlerIds.set(id, item.click); + return { ...item, click: id }; + }); + return [transformedMenu, handlerIds]; +} + +class ApplicationMenu implements ApplicationMenuProvider { + handlers = new Map void>(); + ipcRenderer: HadronIpcRenderer | undefined; + + constructor(ipcRenderer: HadronIpcRenderer | undefined) { + this.ipcRenderer = ipcRenderer; + this.ipcRenderer?.on( + 'application-menu:invoke-handler', + (event, { id }: { id: string }) => { + const handler = this.handlers.get(id); + if (!handler) debug('No handler found for menu item id', id); + handler?.(); + } + ); + } + + showApplicationMenu = (menu: CompassAppMenu): (() => void) => { + const id = new UUID().toString(); + const [translatedMenu, handlers] = translateCallsToHandlerIds(menu); + for (const [handlerId, handler] of handlers.entries()) { + this.handlers.set(handlerId, handler); + } + void this.ipcRenderer?.call('application-menu:modify-application-menu', { + id, + menu: translatedMenu, + }); + return () => { + void this.ipcRenderer?.call('application-menu:modify-application-menu', { + id, + menu: undefined, + }); + for (const handlerId of handlers.keys()) { + this.handlers.delete(handlerId); + } + }; + }; + + handleMenuRole = ( + role: MenuItemConstructorOptions['role'], + handler: () => void + ): (() => void) => { + const id = new UUID().toString(); + this.handlers.set(id, handler); + void this.ipcRenderer?.call('application-menu:modify-application-menu', { + id, + role, + }); + return () => { + void this.ipcRenderer?.call('application-menu:modify-application-menu', { + id, + }); + this.handlers.delete(id); + }; + }; +} + class Application { private static instance: Application | null = null; + private menuProvider: ApplicationMenuProvider; version: string; previousVersion: string; highestInstalledVersion: string; @@ -52,6 +128,7 @@ class Application { this.version = remote.app.getVersion() || ''; this.previousVersion = DEFAULT_APP_VERSION; this.highestInstalledVersion = this.version; + this.menuProvider = new ApplicationMenu(ipcRenderer); } public static getInstance(): Application { @@ -180,8 +257,7 @@ class Application { remote, webUtils )} - showCollectionSubMenu={this.showCollectionSubMenu.bind(this)} - hideCollectionSubMenu={this.hideCollectionSubMenu.bind(this)} + applicationMenuProvider={this.menuProvider} showSettings={this.showSettingsModal.bind(this)} connectionStorage={connectionStorage} onAutoconnectInfoRequest={ @@ -216,21 +292,6 @@ class Application { ); } - private setupSchemaSharingListener() { - ipcRenderer?.on('window:menu-share-schema-json', () => { - globalAppRegistry.emit('menu-share-schema-json'); - }); - } - - private setupImportExportListeners() { - ipcRenderer?.on('compass:open-export', () => { - globalAppRegistry.emit('open-active-namespace-export'); - }); - ipcRenderer?.on('compass:open-import', () => { - globalAppRegistry.emit('open-active-namespace-import'); - }); - } - private setupDownloadStatusListeners() { const fileDownloadCompleteToastId = 'file-download-complete'; ipcRenderer?.on('download-finished', (event, { path }) => { @@ -263,8 +324,6 @@ class Application { private setupIpcListeners() { this.setupDataRefreshListener(); - this.setupSchemaSharingListener(); - this.setupImportExportListeners(); this.setupDownloadStatusListeners(); } @@ -457,16 +516,6 @@ class Application { document.addEventListener('drop', (evt) => evt.preventDefault()); } - private showCollectionSubMenu({ isReadOnly }: { isReadOnly: boolean }) { - void ipcRenderer?.call('window:show-collection-submenu', { - isReadOnly, - }); - } - - private hideCollectionSubMenu() { - void ipcRenderer?.call('window:hide-collection-submenu'); - } - private showSettingsModal(tab?: SettingsTabId) { globalAppRegistry?.emit('open-compass-settings', tab); } diff --git a/packages/compass/src/app/components/home.tsx b/packages/compass/src/app/components/home.tsx index 798bf5ca975..0539e1dbb8b 100644 --- a/packages/compass/src/app/components/home.tsx +++ b/packages/compass/src/app/components/home.tsx @@ -29,13 +29,14 @@ import { CompassInstanceStorePlugin } from '@mongodb-js/compass-app-stores'; import FieldStorePlugin from '@mongodb-js/compass-field-store'; import { AtlasAuthPlugin } from '@mongodb-js/atlas-service/renderer'; import { CompassGenerativeAIPlugin } from '@mongodb-js/compass-generative-ai'; -import type { WorkspaceTab } from '@mongodb-js/compass-workspaces'; import { ConnectionStorageProvider } from '@mongodb-js/connection-storage/provider'; import { ConnectionImportExportProvider } from '@mongodb-js/compass-connection-import-export'; import { useTelemetry } from '@mongodb-js/compass-telemetry/provider'; import { usePreference } from 'compass-preferences-model/provider'; import { CompassAssistantProvider } from '@mongodb-js/compass-assistant'; import { APP_NAMES_FOR_PROMPT } from '@mongodb-js/compass-assistant'; +import type { ApplicationMenuProvider } from '@mongodb-js/compass-workspaces/application-menu'; +import { ApplicationMenuContextProvider } from '@mongodb-js/compass-workspaces/application-menu'; resetGlobalCSS(); @@ -62,8 +63,6 @@ const globalDarkThemeStyles = css({ export type HomeProps = { appName: string; showWelcomeModal?: boolean; - showCollectionSubMenu: (args: { isReadOnly: boolean }) => void; - hideCollectionSubMenu: () => void; showSettings: (tab?: SettingsTabId) => void; }; @@ -76,24 +75,13 @@ const verticalSplitStyles = css({ overflow: 'hidden', }); +function noop() {} + function Home({ appName, showWelcomeModal = false, - showCollectionSubMenu, - hideCollectionSubMenu, showSettings, }: HomeProps): React.ReactElement | null { - const onWorkspaceChange = useCallback( - (ws: WorkspaceTab | null, collectionInfo) => { - if (ws?.type === 'Collection') { - showCollectionSubMenu({ isReadOnly: !!collectionInfo?.isReadonly }); - } else { - hideCollectionSubMenu(); - } - }, - [showCollectionSubMenu, hideCollectionSubMenu] - ); - const [isWelcomeOpen, setIsWelcomeOpen] = useState(showWelcomeModal); const closeWelcomeModal = useCallback( @@ -112,10 +100,7 @@ function Home({
- +
@@ -139,12 +124,14 @@ type HomeWithConnectionsProps = HomeProps & > & { connectionStorage: ConnectionStorage; createFileInputBackend: () => FileInputBackend; + applicationMenuProvider: ApplicationMenuProvider; }; function HomeWithConnections({ onAutoconnectInfoRequest, connectionStorage, createFileInputBackend, + applicationMenuProvider, ...props }: HomeWithConnectionsProps) { return ( @@ -167,7 +154,9 @@ function HomeWithConnections({ }); }} > - + + + diff --git a/packages/compass/src/main/menu.spec.ts b/packages/compass/src/main/menu.spec.ts index c514e8a1f63..975e7fcceca 100644 --- a/packages/compass/src/main/menu.spec.ts +++ b/packages/compass/src/main/menu.spec.ts @@ -47,8 +47,8 @@ describe('CompassMenu', function () { App.emit('new-window', bw); expect(CompassMenu['windowState']).to.have.property('size', 1); expect(CompassMenu['windowState'].get(bw.id)).to.deep.eq({ - showCollection: false, - isReadOnly: false, + additionalMenus: [], + roleListeners: [], updateManagerState: 'idle', }); }); @@ -75,49 +75,18 @@ describe('CompassMenu', function () { expect(CompassMenu).to.have.property('currentWindowMenuLoaded', bw1.id); }); - it('should change window state when window emits show-collection-submenu event', function () { - const bw = new BrowserWindow({ show: false }); - App.emit('new-window', bw); - ipcMain.emit( - 'window:show-collection-submenu', - { sender: bw.webContents }, - { isReadOnly: false } - ); - expect(CompassMenu['windowState'].get(bw.id)).to.deep.eq({ - showCollection: true, - isReadOnly: false, - updateManagerState: 'idle', - }); - ipcMain.emit('window:hide-collection-submenu', { sender: bw.webContents }); - expect(CompassMenu['windowState'].get(bw.id)).to.deep.eq({ - showCollection: false, - isReadOnly: false, - updateManagerState: 'idle', - }); - ipcMain.emit( - 'window:show-collection-submenu', - { sender: bw.webContents }, - { isReadOnly: true } - ); - expect(CompassMenu['windowState'].get(bw.id)).to.deep.eq({ - showCollection: true, - isReadOnly: true, - updateManagerState: 'idle', - }); - }); - it('should change window state when window emits update-manager:new-state event', function () { const bw = new BrowserWindow({ show: false }); App.emit('new-window', bw); expect(CompassMenu['windowState'].get(bw.id)).to.deep.eq({ - showCollection: false, - isReadOnly: false, + additionalMenus: [], + roleListeners: [], updateManagerState: 'idle', }); App.emit('auto-updater:new-state', AutoUpdateManagerState.PromptForRestart); expect(CompassMenu['windowState'].get(bw.id)).to.deep.eq({ - showCollection: false, - isReadOnly: false, + additionalMenus: [], + roleListeners: [], updateManagerState: 'ready to restart', }); App.emit( @@ -126,8 +95,8 @@ describe('CompassMenu', function () { ); expect(CompassMenu['windowState'].get(bw.id)).to.deep.eq({ - showCollection: false, - isReadOnly: false, + additionalMenus: [], + roleListeners: [], updateManagerState: 'installing updates', }); }); @@ -388,72 +357,84 @@ describe('CompassMenu', function () { } }); - it('should generate a menu template without collection submenu if `showCollection` is `false`', function () { - expect( - CompassMenu.getTemplate(0).find((item) => item.label === '&Collection') - ).to.be.undefined; - }); - - it('should generate a menu template with collection submenu if `showCollection` is `true`', function () { + it('should add menus requested from browser windows', function () { CompassMenu['windowState'].set(0, { - showCollection: true, - isReadOnly: false, + additionalMenus: [ + { + id: 'menu0', + menu: { + label: '&MyMenu', + submenu: [{ label: 'Do Something', click: 'id2' }], + }, + }, + ], + roleListeners: [ + ['undo', 'id0'], + ['redo', 'id1'], + ], updateManagerState: 'idle', }); expect( // Contains functions, so we can't easily deep equal it without // converting to serializable format serializable( - CompassMenu.getTemplate(0).find( - (item) => item.label === '&Collection' - ) + CompassMenu.getTemplate(0).find((item) => item.label === '&MyMenu') ) ).to.deep.eq({ - label: '&Collection', + label: '&MyMenu', submenu: [ { - accelerator: 'Alt+CmdOrCtrl+S', - label: '&Share Schema as JSON (Legacy)', - }, - { - type: 'separator', - }, - { - label: '&Import Data', - }, - { - label: '&Export Collection', + label: 'Do Something', }, ], }); - }); - - it('should generate a menu template with import collection action hidden if `isReadOnly` is `true`', function () { - CompassMenu['windowState'].set(0, { - showCollection: true, - isReadOnly: true, - updateManagerState: 'idle', - }); expect( // Contains functions, so we can't easily deep equal it without // converting to serializable format serializable( - CompassMenu.getTemplate(0).find( - (item) => item.label === '&Collection' - ) + CompassMenu.getTemplate(0).find((item) => item.label === 'Edit') ) ).to.deep.eq({ - label: '&Collection', + label: 'Edit', submenu: [ { - accelerator: 'Alt+CmdOrCtrl+S', - label: '&Share Schema as JSON (Legacy)', + // note the missing 'role' property here + accelerator: 'Command+Z', + label: 'Undo', + }, + { + accelerator: 'Shift+Command+Z', + label: 'Redo', + }, + { + type: 'separator', + }, + { + accelerator: 'Command+X', + label: 'Cut', + role: 'cut', + }, + { + accelerator: 'Command+C', + label: 'Copy', + role: 'copy', + }, + { + accelerator: 'Command+V', + label: 'Paste', + role: 'paste', + }, + { + accelerator: 'Command+A', + label: 'Select All', + role: 'selectAll', }, { type: 'separator', }, { - label: '&Export Collection', + accelerator: 'CmdOrCtrl+F', + label: 'Find', }, ], }); diff --git a/packages/compass/src/main/menu.ts b/packages/compass/src/main/menu.ts index e3e3662b284..83217133803 100644 --- a/packages/compass/src/main/menu.ts +++ b/packages/compass/src/main/menu.ts @@ -1,4 +1,7 @@ -import type { MenuItemConstructorOptions } from 'electron'; +import { + transformAppMenu, + type CompassAppMenu, +} from '@mongodb-js/compass-workspaces/application-menu'; import { BrowserWindow, Menu, @@ -19,12 +22,48 @@ import { createIpcTrack } from '@mongodb-js/compass-telemetry'; const track = createIpcTrack(); -type MenuTemplate = MenuItemConstructorOptions | MenuItemConstructorOptions[]; +type MenuItemConstructorOptions = CompassAppMenu; // Alias to reduce diff complexity +type MenuTemplate = CompassAppMenu | CompassAppMenu[]; const debug = createDebug('mongodb-compass:menu'); const COMPASS_HELP = 'https://docs.mongodb.com/compass/'; +function translateHandlerIdsToCalls( + menu: CompassAppMenu +): CompassAppMenu { + return transformAppMenu(menu, (item) => { + const id = item.click; + if (!id) return { ...item, click: undefined }; + return { + ...item, + click: () => + ipcMain?.broadcastFocused('application-menu:invoke-handler', { id }), + }; + }); +} + +function translateRoles( + menu: CompassAppMenu, + state: WindowMenuState +): CompassAppMenu { + return transformAppMenu(menu, (item) => { + if (!item.role) return item; + + const listener = state.roleListeners.find(([role]) => role === item.role); + if (!listener) return item; + const id = listener[1]; + return { + ...item, + role: undefined, + click: () => + ipcMain?.broadcastFocused('application-menu:invoke-handler', { + id, + }), + }; + }); +} + function separator(): MenuItemConstructorOptions { return { type: 'separator' as const, @@ -321,39 +360,6 @@ function helpSubMenu( }; } -function collectionSubMenu( - menuReadOnly: boolean, - app: typeof CompassApplication -): MenuItemConstructorOptions { - const subMenu = []; - subMenu.push({ - label: '&Share Schema as JSON (Legacy)', - accelerator: 'Alt+CmdOrCtrl+S', - click() { - ipcMain?.broadcastFocused('window:menu-share-schema-json'); - }, - }); - subMenu.push(separator()); - if (!app.preferences.getPreferences().readOnly && !menuReadOnly) { - subMenu.push({ - label: '&Import Data', - click() { - ipcMain?.broadcastFocused('compass:open-import'); - }, - }); - } - subMenu.push({ - label: '&Export Collection', - click() { - ipcMain?.broadcastFocused('compass:open-export'); - }, - }); - return { - label: '&Collection', - submenu: subMenu, - }; -} - function viewSubMenu( app: typeof CompassApplication ): MenuItemConstructorOptions { @@ -452,42 +458,39 @@ function darwinMenu( menuState: WindowMenuState, app: typeof CompassApplication ): MenuItemConstructorOptions[] { - const menu: MenuTemplate = [darwinCompassSubMenu(menuState, app)]; - - menu.push(connectSubMenu(false, app)); - menu.push(editSubMenu()); - menu.push(viewSubMenu(app)); - - if (menuState.showCollection) { - menu.push(collectionSubMenu(menuState.isReadOnly, app)); - } - - menu.push(windowSubMenu(app)); - menu.push(helpSubMenu(menuState, app)); - - return menu; + return [ + darwinCompassSubMenu(menuState, app), + connectSubMenu(false, app), + editSubMenu(), + viewSubMenu(app), + ...menuState.additionalMenus.map(({ menu }) => + translateHandlerIdsToCalls(menu) + ), + windowSubMenu(app), + helpSubMenu(menuState, app), + ].map((menu) => translateRoles(menu, menuState)); } function nonDarwinMenu( menuState: WindowMenuState, app: typeof CompassApplication ): MenuItemConstructorOptions[] { - const menu = [connectSubMenu(true, app), editSubMenu(), viewSubMenu(app)]; - - if (menuState.showCollection) { - menu.push(collectionSubMenu(menuState.isReadOnly, app)); - } - - menu.push(helpSubMenu(menuState, app)); - - return menu; + return [ + connectSubMenu(true, app), + editSubMenu(), + viewSubMenu(app), + ...menuState.additionalMenus.map(({ menu }) => + translateHandlerIdsToCalls(menu) + ), + helpSubMenu(menuState, app), + ].map((menu) => translateRoles(menu, menuState)); } type UpdateManagerState = 'idle' | 'installing updates' | 'ready to restart'; class WindowMenuState { - showCollection = false; - isReadOnly = false; + roleListeners: [MenuItemConstructorOptions['role'], string][] = []; + additionalMenus: { id: string; menu: CompassAppMenu }[] = []; updateManagerState: UpdateManagerState = 'idle'; } @@ -527,12 +530,12 @@ class CompassMenu { return 'idle'; } })(); - this.updateMenu({ updateManagerState }); + this.updateMenu(() => ({ updateManagerState })); }); ipcMain?.respondTo({ - 'window:show-collection-submenu': this.showCollection.bind(this), - 'window:hide-collection-submenu': this.hideCollection.bind(this), + 'application-menu:modify-application-menu': + this.modifyApplicationMenuHandler.bind(this), }); preferences.onPreferenceValueChanged('theme', (newTheme: THEMES) => { @@ -674,19 +677,39 @@ class CompassMenu { Menu.setApplicationMenu(menu); }; - private static showCollection( + private static modifyApplicationMenuHandler( evt: unknown, - { isReadOnly }: { isReadOnly: boolean } + { + id, + menu, + role, + }: { + id: string; + menu?: CompassAppMenu; + role?: MenuItemConstructorOptions['role']; + } ) { - this.updateMenu({ showCollection: true, isReadOnly }); - } - - private static hideCollection() { - this.updateMenu({ showCollection: false }); + this.updateMenu(({ ...state }) => { + if (menu) { + state.additionalMenus.push({ id, menu }); + } else { + state.additionalMenus = state.additionalMenus.filter( + (m) => m.id !== id + ); + } + if (role) { + state.roleListeners.push([role, id]); + } else { + state.roleListeners = state.roleListeners.filter( + ([, listenerId]) => listenerId !== id + ); + } + return state; + }); } private static updateMenu( - newValues: Partial, + newValues: (state: WindowMenuState) => Partial, bw: BrowserWindow | null = this.lastFocusedWindow ) { debug(`updateMenu() set menu state to ${JSON.stringify(newValues)}`); @@ -699,7 +722,7 @@ class CompassMenu { const menuState = this.windowState.get(bw.id); if (menuState) { - Object.assign(menuState, newValues); + Object.assign(menuState, newValues(menuState)); this.windowState.set(bw.id, menuState); this.setTemplate(bw.id); } From 79cc9be55ff0316fcd6c4713bff4874ae4a9cc0a Mon Sep 17 00:00:00 2001 From: Anna Henningsen Date: Thu, 23 Oct 2025 16:45:09 +0200 Subject: [PATCH 2/7] fixup: check + test --- package-lock.json | 2 ++ packages/compass-workspaces/package.json | 1 + .../compass-workspaces/src/application-menu.tsx | 1 + packages/compass/src/main/menu.spec.ts | 13 +++++++++++++ scripts/check-peer-deps.js | 8 ++++++++ 5 files changed, 25 insertions(+) diff --git a/package-lock.json b/package-lock.json index dd4ff8f0f64..6024871b12a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53292,6 +53292,7 @@ "@types/sinon-chai": "^3.2.5", "chai": "^4.3.6", "depcheck": "^1.4.1", + "electron": "^37.6.1", "electron-mocha": "^12.2.0", "mocha": "^10.2.0", "nyc": "^15.1.0", @@ -66770,6 +66771,7 @@ "chai": "^4.3.6", "compass-preferences-model": "^2.62.0", "depcheck": "^1.4.1", + "electron": "^37.6.1", "electron-mocha": "^12.2.0", "lodash": "^4.17.21", "mocha": "^10.2.0", diff --git a/packages/compass-workspaces/package.json b/packages/compass-workspaces/package.json index b0958ed190d..cdaeee3d486 100644 --- a/packages/compass-workspaces/package.json +++ b/packages/compass-workspaces/package.json @@ -83,6 +83,7 @@ "@types/sinon-chai": "^3.2.5", "chai": "^4.3.6", "depcheck": "^1.4.1", + "electron": "^37.6.1", "electron-mocha": "^12.2.0", "mocha": "^10.2.0", "nyc": "^15.1.0", diff --git a/packages/compass-workspaces/src/application-menu.tsx b/packages/compass-workspaces/src/application-menu.tsx index 62db60b89c5..5a4357bf3bf 100644 --- a/packages/compass-workspaces/src/application-menu.tsx +++ b/packages/compass-workspaces/src/application-menu.tsx @@ -2,6 +2,7 @@ import React, { useContext, useEffect } from 'react'; import { createServiceLocator } from '@mongodb-js/compass-app-registry'; // Type-only import in a separate entry point, so this is fine +// compass-peer-deps-ignore // eslint-disable-next-line @typescript-eslint/no-restricted-imports import type { MenuItemConstructorOptions } from 'electron'; diff --git a/packages/compass/src/main/menu.spec.ts b/packages/compass/src/main/menu.spec.ts index 975e7fcceca..af31263b2bc 100644 --- a/packages/compass/src/main/menu.spec.ts +++ b/packages/compass/src/main/menu.spec.ts @@ -436,6 +436,19 @@ describe('CompassMenu', function () { accelerator: 'CmdOrCtrl+F', label: 'Find', }, + [ + ...(process.platform === 'darwin' + ? [] + : [ + { + type: 'separator', + }, + { + accelerator: 'CmdOrCtrl+,', + label: '&Settings', + }, + ]), + ], ], }); }); diff --git a/scripts/check-peer-deps.js b/scripts/check-peer-deps.js index b2764f4a427..b6300ba7600 100644 --- a/scripts/check-peer-deps.js +++ b/scripts/check-peer-deps.js @@ -77,19 +77,27 @@ async function collectAllAbsoluteImports(entryPoints = []) { ], ], }); + const shouldSkip = (path) => + path.node.leadingComments?.some((comment) => + comment.value?.includes('compass-peer-deps-ignore') + ); traverse(program, { ImportDeclaration(path) { + if (shouldSkip(path)) return; queueImport(path.node.source.value); }, ExportNamedDeclaration(path) { + if (shouldSkip(path)) return; if (path.node.source) { queueImport(path.node.source.value); } }, ExportAllDeclaration(path) { + if (shouldSkip(path)) return; queueImport(path.node.source.value); }, CallExpression(path) { + if (shouldSkip(path)) return; if ( path.node.callee.type === 'Identifier' && (path.node.callee.name === 'require' || From e98afa3aa9710db22ab7eaac6fc3d3d1b7a26e51 Mon Sep 17 00:00:00 2001 From: Anna Henningsen Date: Thu, 23 Oct 2025 23:17:19 +0200 Subject: [PATCH 3/7] fixup: separate package --- package-lock.json | 235 +++++++++++++++++- packages/compass-collection/package.json | 1 + .../src/components/collection-tab.tsx | 2 +- packages/compass-data-modeling/package.json | 1 + packages/compass-electron-menu/.depcheckrc | 11 + packages/compass-electron-menu/.eslintignore | 2 + packages/compass-electron-menu/.eslintrc.js | 8 + packages/compass-electron-menu/.mocharc.js | 1 + packages/compass-electron-menu/package.json | 81 ++++++ .../src/application-menu.spec.tsx | 180 ++++++++++++++ .../src/application-menu.tsx | 61 +---- packages/compass-electron-menu/src/index.ts | 6 + .../src/integration.spec.tsx | 176 +++++++++++++ .../src/ipc-provider-main.ts | 91 +++++++ .../src/ipc-provider-renderer.ts | 89 +++++++ .../compass-electron-menu/src/types.spec.ts | 28 +++ packages/compass-electron-menu/src/types.ts | 41 +++ .../compass-electron-menu/src/util.spec.ts | 31 +++ packages/compass-electron-menu/src/util.ts | 18 ++ .../compass-electron-menu/tsconfig-build.json | 5 + packages/compass-electron-menu/tsconfig.json | 8 + packages/compass-workspaces/package.json | 7 +- packages/compass/package.json | 1 + packages/compass/src/app/application.tsx | 83 +------ .../compass/src/app/components/home.spec.tsx | 4 + packages/compass/src/app/components/home.tsx | 4 +- packages/compass/src/main/menu.spec.ts | 75 +++--- packages/compass/src/main/menu.ts | 109 ++------ 28 files changed, 1098 insertions(+), 261 deletions(-) create mode 100644 packages/compass-electron-menu/.depcheckrc create mode 100644 packages/compass-electron-menu/.eslintignore create mode 100644 packages/compass-electron-menu/.eslintrc.js create mode 100644 packages/compass-electron-menu/.mocharc.js create mode 100644 packages/compass-electron-menu/package.json create mode 100644 packages/compass-electron-menu/src/application-menu.spec.tsx rename packages/{compass-workspaces => compass-electron-menu}/src/application-menu.tsx (57%) create mode 100644 packages/compass-electron-menu/src/index.ts create mode 100644 packages/compass-electron-menu/src/integration.spec.tsx create mode 100644 packages/compass-electron-menu/src/ipc-provider-main.ts create mode 100644 packages/compass-electron-menu/src/ipc-provider-renderer.ts create mode 100644 packages/compass-electron-menu/src/types.spec.ts create mode 100644 packages/compass-electron-menu/src/types.ts create mode 100644 packages/compass-electron-menu/src/util.spec.ts create mode 100644 packages/compass-electron-menu/src/util.ts create mode 100644 packages/compass-electron-menu/tsconfig-build.json create mode 100644 packages/compass-electron-menu/tsconfig.json diff --git a/package-lock.json b/package-lock.json index 6024871b12a..93f0d31f61d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10477,6 +10477,10 @@ "resolved": "packages/compass-editor", "link": true }, + "node_modules/@mongodb-js/compass-electron-menu": { + "resolved": "packages/compass-electron-menu", + "link": true + }, "node_modules/@mongodb-js/compass-explain-plan": { "resolved": "packages/compass-explain-plan", "link": true @@ -47942,6 +47946,7 @@ "hasInstallScript": true, "license": "SSPL", "dependencies": { + "@mongodb-js/compass-electron-menu": "^0.1.0", "@mongodb-js/device-id": "^0.2.0", "@mongosh/node-runtime-worker-thread": "^3.3.26", "clipboard": "^2.0.6", @@ -48587,6 +48592,7 @@ "@mongodb-js/compass-components": "^1.56.0", "@mongodb-js/compass-connections": "^1.83.0", "@mongodb-js/compass-editor": "^0.58.0", + "@mongodb-js/compass-electron-menu": "^0.1.0", "@mongodb-js/compass-generative-ai": "^0.62.0", "@mongodb-js/compass-logging": "^1.7.22", "@mongodb-js/compass-telemetry": "^1.18.0", @@ -49841,6 +49847,7 @@ "@mongodb-js/compass-app-stores": "^7.69.0", "@mongodb-js/compass-components": "^1.56.0", "@mongodb-js/compass-connections": "^1.83.0", + "@mongodb-js/compass-electron-menu": "^0.1.0", "@mongodb-js/compass-logging": "^1.7.22", "@mongodb-js/compass-telemetry": "^1.18.0", "@mongodb-js/compass-user-data": "^0.10.5", @@ -50589,6 +50596,125 @@ "node": ">=0.3.1" } }, + "packages/compass-electron-menu": { + "name": "@mongodb-js/compass-electron-menu", + "version": "0.1.0", + "license": "SSPL", + "dependencies": { + "bson": "^6.10.4", + "debug": "^4.3.4", + "react": "^17.0.2" + }, + "devDependencies": { + "@mongodb-js/eslint-config-compass": "^1.4.12", + "@mongodb-js/mocha-config-compass": "^1.7.2", + "@mongodb-js/prettier-config-compass": "^1.2.9", + "@mongodb-js/testing-library-compass": "^1.3.17", + "@mongodb-js/tsconfig-compass": "^1.2.12", + "@types/chai": "^4.2.21", + "@types/chai-dom": "^0.0.10", + "@types/mocha": "^9.0.0", + "@types/react": "^17.0.5", + "@types/react-dom": "^17.0.10", + "@types/sinon-chai": "^3.2.5", + "chai": "^4.3.6", + "depcheck": "^1.4.1", + "electron": "^37.6.1", + "gen-esm-wrapper": "^1.1.0", + "mocha": "^10.2.0", + "nyc": "^15.1.0", + "sinon": "^17.0.1", + "typescript": "^5.9.3" + } + }, + "packages/compass-electron-menu/node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "packages/compass-electron-menu/node_modules/@sinonjs/fake-timers": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.3.1.tgz", + "integrity": "sha512-EVJO7nW5M/F5Tur0Rf2z/QoMo+1Ia963RiMtapiQrEWvY0iBUvADo8Beegwjpnle5BHkyHuoxSTW3jF43H1XRA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "packages/compass-electron-menu/node_modules/@sinonjs/samsam": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz", + "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "type-detect": "^4.1.0" + } + }, + "packages/compass-electron-menu/node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "packages/compass-electron-menu/node_modules/just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true, + "license": "MIT" + }, + "packages/compass-electron-menu/node_modules/nise": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.9.tgz", + "integrity": "sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/text-encoding": "^0.7.2", + "just-extend": "^6.2.0", + "path-to-regexp": "^6.2.1" + } + }, + "packages/compass-electron-menu/node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "packages/compass-electron-menu/node_modules/sinon": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz", + "integrity": "sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.1.0", + "nise": "^5.1.5", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, "packages/compass-explain-plan": { "name": "@mongodb-js/compass-explain-plan", "version": "6.83.0", @@ -53292,7 +53418,6 @@ "@types/sinon-chai": "^3.2.5", "chai": "^4.3.6", "depcheck": "^1.4.1", - "electron": "^37.6.1", "electron-mocha": "^12.2.0", "mocha": "^10.2.0", "nyc": "^15.1.0", @@ -63105,6 +63230,7 @@ "@mongodb-js/compass-components": "^1.56.0", "@mongodb-js/compass-connections": "^1.83.0", "@mongodb-js/compass-editor": "^0.58.0", + "@mongodb-js/compass-electron-menu": "^0.1.0", "@mongodb-js/compass-generative-ai": "^0.62.0", "@mongodb-js/compass-logging": "^1.7.22", "@mongodb-js/compass-telemetry": "^1.18.0", @@ -64186,6 +64312,7 @@ "@mongodb-js/compass-app-stores": "^7.69.0", "@mongodb-js/compass-components": "^1.56.0", "@mongodb-js/compass-connections": "^1.83.0", + "@mongodb-js/compass-electron-menu": "^0.1.0", "@mongodb-js/compass-logging": "^1.7.22", "@mongodb-js/compass-telemetry": "^1.18.0", "@mongodb-js/compass-user-data": "^0.10.5", @@ -64475,6 +64602,110 @@ } } }, + "@mongodb-js/compass-electron-menu": { + "version": "file:packages/compass-electron-menu", + "requires": { + "@mongodb-js/eslint-config-compass": "^1.4.12", + "@mongodb-js/mocha-config-compass": "^1.7.2", + "@mongodb-js/prettier-config-compass": "^1.2.9", + "@mongodb-js/testing-library-compass": "^1.3.17", + "@mongodb-js/tsconfig-compass": "^1.2.12", + "@types/chai": "^4.2.21", + "@types/chai-dom": "^0.0.10", + "@types/mocha": "^9.0.0", + "@types/react": "^17.0.5", + "@types/react-dom": "^17.0.10", + "@types/sinon-chai": "^3.2.5", + "bson": "^6.10.4", + "chai": "^4.3.6", + "debug": "^4.3.4", + "depcheck": "^1.4.1", + "electron": "^37.6.1", + "gen-esm-wrapper": "^1.1.0", + "mocha": "^10.2.0", + "nyc": "^15.1.0", + "react": "^17.0.2", + "sinon": "^17.0.1", + "typescript": "^5.9.3" + }, + "dependencies": { + "@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.3.1.tgz", + "integrity": "sha512-EVJO7nW5M/F5Tur0Rf2z/QoMo+1Ia963RiMtapiQrEWvY0iBUvADo8Beegwjpnle5BHkyHuoxSTW3jF43H1XRA==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.1" + } + }, + "@sinonjs/samsam": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz", + "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.1", + "type-detect": "^4.1.0" + }, + "dependencies": { + "type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true + } + } + }, + "just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true + }, + "nise": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.9.tgz", + "integrity": "sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/text-encoding": "^0.7.2", + "just-extend": "^6.2.0", + "path-to-regexp": "^6.2.1" + } + }, + "path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true + }, + "sinon": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz", + "integrity": "sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.1.0", + "nise": "^5.1.5", + "supports-color": "^7.2.0" + } + } + } + }, "@mongodb-js/compass-explain-plan": { "version": "file:packages/compass-explain-plan", "requires": { @@ -66771,7 +67002,6 @@ "chai": "^4.3.6", "compass-preferences-model": "^2.62.0", "depcheck": "^1.4.1", - "electron": "^37.6.1", "electron-mocha": "^12.2.0", "lodash": "^4.17.21", "mocha": "^10.2.0", @@ -87766,6 +87996,7 @@ "@mongodb-js/compass-crud": "^13.83.0", "@mongodb-js/compass-data-modeling": "^1.34.0", "@mongodb-js/compass-databases-collections": "^1.82.0", + "@mongodb-js/compass-electron-menu": "^0.1.0", "@mongodb-js/compass-explain-plan": "^6.83.0", "@mongodb-js/compass-export-to-language": "^9.59.0", "@mongodb-js/compass-field-store": "^9.58.0", diff --git a/packages/compass-collection/package.json b/packages/compass-collection/package.json index c780bfc6c88..a5765cdc575 100644 --- a/packages/compass-collection/package.json +++ b/packages/compass-collection/package.json @@ -54,6 +54,7 @@ "@mongodb-js/compass-components": "^1.56.0", "@mongodb-js/compass-connections": "^1.83.0", "@mongodb-js/compass-editor": "^0.58.0", + "@mongodb-js/compass-electron-menu": "^0.1.0", "@mongodb-js/compass-generative-ai": "^0.62.0", "@mongodb-js/compass-logging": "^1.7.22", "@mongodb-js/compass-telemetry": "^1.18.0", diff --git a/packages/compass-collection/src/components/collection-tab.tsx b/packages/compass-collection/src/components/collection-tab.tsx index eee21d0328b..59c99f893e0 100644 --- a/packages/compass-collection/src/components/collection-tab.tsx +++ b/packages/compass-collection/src/components/collection-tab.tsx @@ -19,7 +19,7 @@ import { useConnectionSupports, } from '@mongodb-js/compass-connections/provider'; import { usePreference } from 'compass-preferences-model/provider'; -import { useApplicationMenu } from '@mongodb-js/compass-workspaces/application-menu'; +import { useApplicationMenu } from '@mongodb-js/compass-electron-menu'; import { useGlobalAppRegistry, useLocalAppRegistry, diff --git a/packages/compass-data-modeling/package.json b/packages/compass-data-modeling/package.json index 0332b880071..38f7f275898 100644 --- a/packages/compass-data-modeling/package.json +++ b/packages/compass-data-modeling/package.json @@ -59,6 +59,7 @@ "@mongodb-js/compass-app-stores": "^7.69.0", "@mongodb-js/compass-components": "^1.56.0", "@mongodb-js/compass-connections": "^1.83.0", + "@mongodb-js/compass-electron-menu": "^0.1.0", "@mongodb-js/compass-logging": "^1.7.22", "@mongodb-js/compass-telemetry": "^1.18.0", "@mongodb-js/compass-user-data": "^0.10.5", diff --git a/packages/compass-electron-menu/.depcheckrc b/packages/compass-electron-menu/.depcheckrc new file mode 100644 index 00000000000..ae7c8273e41 --- /dev/null +++ b/packages/compass-electron-menu/.depcheckrc @@ -0,0 +1,11 @@ +ignores: + - '@mongodb-js/prettier-config-compass' + - '@mongodb-js/tsconfig-compass' + - '@types/chai' + - '@types/sinon-chai' + - 'sinon' + - '@types/chai-dom' + - '@types/react' + - '@types/react-dom' +ignore-patterns: + - 'dist' diff --git a/packages/compass-electron-menu/.eslintignore b/packages/compass-electron-menu/.eslintignore new file mode 100644 index 00000000000..85a8a75e68c --- /dev/null +++ b/packages/compass-electron-menu/.eslintignore @@ -0,0 +1,2 @@ +.nyc-output +dist diff --git a/packages/compass-electron-menu/.eslintrc.js b/packages/compass-electron-menu/.eslintrc.js new file mode 100644 index 00000000000..a812ac46f5d --- /dev/null +++ b/packages/compass-electron-menu/.eslintrc.js @@ -0,0 +1,8 @@ +module.exports = { + root: true, + extends: ['@mongodb-js/eslint-config-compass'], + parserOptions: { + tsconfigRootDir: __dirname, + project: ['./tsconfig.json'], + }, +}; diff --git a/packages/compass-electron-menu/.mocharc.js b/packages/compass-electron-menu/.mocharc.js new file mode 100644 index 00000000000..30aecfb78c3 --- /dev/null +++ b/packages/compass-electron-menu/.mocharc.js @@ -0,0 +1 @@ +module.exports = require('@mongodb-js/mocha-config-compass/react'); diff --git a/packages/compass-electron-menu/package.json b/packages/compass-electron-menu/package.json new file mode 100644 index 00000000000..b55f094a2cb --- /dev/null +++ b/packages/compass-electron-menu/package.json @@ -0,0 +1,81 @@ +{ + "name": "@mongodb-js/compass-electron-menu", + "description": "Provide access to the Electron application menu", + "author": { + "name": "MongoDB Inc", + "email": "compass@mongodb.com" + }, + "publishConfig": { + "access": "public" + }, + "bugs": { + "url": "https://jira.mongodb.org/projects/COMPASS/issues", + "email": "compass@mongodb.com" + }, + "homepage": "https://github.com/mongodb-js/compass", + "version": "0.1.0", + "repository": { + "type": "git", + "url": "https://github.com/mongodb-js/compass.git" + }, + "files": [ + "dist" + ], + "license": "SSPL", + "main": "dist/index.js", + "compass:main": "src/index.ts", + "exports": { + ".": "./dist/index.js", + "./ipc-provider-main": "./dist/ipc-provider-main.js", + "./ipc-provider-renderer": "./dist/ipc-provider-renderer.js" + }, + "compass:exports": { + ".": "./src/index.ts", + "./ipc-provider-main": "./src/ipc-provider-main.ts", + "./ipc-provider-renderer": "./src/ipc-provider-renderer.ts" + }, + "types": "./dist/index.d.ts", + "scripts": { + "bootstrap": "npm run compile", + "prepublishOnly": "npm run compile && compass-scripts check-exports-exist", + "compile": "tsc -p tsconfig-build.json && gen-esm-wrapper . ./dist/.esm-wrapper.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit", + "eslint": "eslint-compass", + "prettier": "prettier-compass", + "lint": "npm run eslint . && npm run prettier -- --check .", + "depcheck": "compass-scripts check-peer-deps && depcheck", + "check": "npm run typecheck && npm run lint && npm run depcheck", + "check-ci": "npm run check", + "test": "mocha", + "test-cov": "nyc --compact=false --produce-source-map=false -x \"**/*.spec.*\" --reporter=lcov --reporter=text --reporter=html npm run test", + "test-watch": "npm run test -- --watch", + "test-ci": "npm run test-cov", + "reformat": "npm run eslint . -- --fix && npm run prettier -- --write ." + }, + "dependencies": { + "bson": "^6.10.4", + "debug": "^4.3.4", + "react": "^17.0.2" + }, + "devDependencies": { + "@mongodb-js/eslint-config-compass": "^1.4.12", + "@mongodb-js/mocha-config-compass": "^1.7.2", + "@mongodb-js/prettier-config-compass": "^1.2.9", + "@mongodb-js/testing-library-compass": "^1.3.17", + "@mongodb-js/tsconfig-compass": "^1.2.12", + "@types/chai": "^4.2.21", + "@types/chai-dom": "^0.0.10", + "@types/mocha": "^9.0.0", + "@types/react": "^17.0.5", + "@types/react-dom": "^17.0.10", + "@types/sinon-chai": "^3.2.5", + "chai": "^4.3.6", + "electron": "^37.6.1", + "depcheck": "^1.4.1", + "gen-esm-wrapper": "^1.1.0", + "mocha": "^10.2.0", + "nyc": "^15.1.0", + "sinon": "^17.0.1", + "typescript": "^5.9.3" + } +} diff --git a/packages/compass-electron-menu/src/application-menu.spec.tsx b/packages/compass-electron-menu/src/application-menu.spec.tsx new file mode 100644 index 00000000000..2c7a2e9763c --- /dev/null +++ b/packages/compass-electron-menu/src/application-menu.spec.tsx @@ -0,0 +1,180 @@ +import React from 'react'; +import { render, cleanup } from '@mongodb-js/testing-library-compass'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { + ApplicationMenuContextProvider, + useApplicationMenu, +} from './application-menu'; +import type { CompassAppMenu } from './types'; + +describe('application-menu / useApplicationMenu', function () { + afterEach(() => { + cleanup(); + sinon.restore(); + }); + + function createMockProvider() { + const showUnsubscribes: sinon.SinonSpy[] = []; + const roleUnsubscribes: sinon.SinonSpy[] = []; + const showApplicationMenu = sinon.stub().callsFake(() => { + const unsub = sinon.spy(); + showUnsubscribes.push(unsub); + return unsub; + }); + const handleMenuRole = sinon.stub().callsFake(() => { + const unsub = sinon.spy(); + roleUnsubscribes.push(unsub); + return unsub; + }); + return { + provider: { showApplicationMenu, handleMenuRole }, + showUnsubscribes, + roleUnsubscribes, + }; + } + + const TestComponent: React.FC<{ + menu?: CompassAppMenu; + roles?: Record void>; + }> = ({ menu, roles }) => { + useApplicationMenu({ menu, roles }); + return null; + }; + + it('subscribes to menu and roles and unsubscribes on unmount', function () { + const { provider, showUnsubscribes, roleUnsubscribes } = + createMockProvider(); + const menu: CompassAppMenu = { + label: '&File', + submenu: [{ label: 'Item', click: () => {} }], + }; + const roles = { + undo: () => {}, + redo: () => {}, + }; + + const { unmount } = render( + + + + ); + + expect(provider.showApplicationMenu.calledOnce).to.equal(true); + expect(provider.handleMenuRole.callCount).to.equal(2); + expect(showUnsubscribes).to.have.length(1); + expect(roleUnsubscribes).to.have.length(2); + for (const u of showUnsubscribes) expect(u.called).to.equal(false); + for (const u of roleUnsubscribes) expect(u.called).to.equal(false); + + unmount(); + + for (const u of showUnsubscribes) expect(u.calledOnce).to.equal(true); + for (const u of roleUnsubscribes) expect(u.calledOnce).to.equal(true); + }); + + it('does not subscribe when neither menu nor roles provided', function () { + const { provider } = createMockProvider(); + const { unmount } = render( + + + + ); + expect(provider.showApplicationMenu.called).to.equal(false); + expect(provider.handleMenuRole.called).to.equal(false); + unmount(); + // No unsubscribes expected + expect(provider.showApplicationMenu.called).to.equal(false); + }); + + it('re-subscribes when a menu handler identity changes', function () { + const { provider, showUnsubscribes } = createMockProvider(); + + const clickA = () => {}; + const menuA: CompassAppMenu = { + label: '&Edit', + submenu: [{ label: 'Action', click: clickA }], + }; + + const { rerender } = render( + + + + ); + + expect(provider.showApplicationMenu.callCount).to.equal(1); + expect(showUnsubscribes[0].called).to.equal(false); + + // Rerender with same function identity: should NOT resubscribe + rerender( + + + + ); + expect(provider.showApplicationMenu.callCount).to.equal(1); + + // Change click handler identity: should resubscribe + const menuB: CompassAppMenu = { + ...menuA, + submenu: [{ label: 'Action', click: () => {} }], + }; + rerender( + + + + ); + + expect(provider.showApplicationMenu.callCount).to.equal(2); + // First unsubscribe should have been called during effect cleanup + expect(showUnsubscribes[0].calledOnce).to.equal(true); + expect(showUnsubscribes[1].called).to.equal(false); + }); + + it('re-subscribes to roles when role handler identities change', function () { + const { provider, roleUnsubscribes } = createMockProvider(); + + const handlerUndoA = () => {}; + const handlerRedoA = () => {}; + let roles: Record void> = { + undo: handlerUndoA, + redo: handlerRedoA, + }; + + const { rerender } = render( + + + + ); + + expect(provider.handleMenuRole.callCount).to.equal(2); + expect(roleUnsubscribes[0].called).to.equal(false); + expect(roleUnsubscribes[1].called).to.equal(false); + + // Rerender with same handler identities: no resubscribe + rerender( + + + + ); + expect(provider.handleMenuRole.callCount).to.equal(2); + + // Change one handler identity + roles = { + undo: () => {}, // new identity + redo: handlerRedoA, // same identity + }; + rerender( + + + + ); + // Both roles re-subscribed because dependency array uses all handlers serialized + expect(provider.handleMenuRole.callCount).to.equal(4); + // First two unsubscribes called + expect(roleUnsubscribes[0].calledOnce).to.equal(true); + expect(roleUnsubscribes[1].calledOnce).to.equal(true); + // New unsubscribes not yet called + expect(roleUnsubscribes[2].called).to.equal(false); + expect(roleUnsubscribes[3].called).to.equal(false); + }); +}); diff --git a/packages/compass-workspaces/src/application-menu.tsx b/packages/compass-electron-menu/src/application-menu.tsx similarity index 57% rename from packages/compass-workspaces/src/application-menu.tsx rename to packages/compass-electron-menu/src/application-menu.tsx index 5a4357bf3bf..8f202d8a575 100644 --- a/packages/compass-workspaces/src/application-menu.tsx +++ b/packages/compass-electron-menu/src/application-menu.tsx @@ -1,15 +1,7 @@ import React, { useContext, useEffect } from 'react'; -import { createServiceLocator } from '@mongodb-js/compass-app-registry'; - -// Type-only import in a separate entry point, so this is fine -// compass-peer-deps-ignore -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -import type { MenuItemConstructorOptions } from 'electron'; - -export type CompassAppMenu void> = Omit< - MenuItemConstructorOptions, - 'click' | 'submenu' -> & { click?: ClickHandlerType; submenu?: CompassAppMenu[] }; +import type { CompassAppMenu, MenuItemConstructorOptions } from './types'; +import { transformAppMenu } from './types'; +import { getObjectId } from './util'; export interface ApplicationMenuProvider { // These functions return 'unsubscribe'-style listeners to remove @@ -45,40 +37,6 @@ function useApplicationMenuService(): ApplicationMenuProvider { return useContext(ApplicationMenuContext); } -export const applicationMenuServiceLocator = createServiceLocator( - useApplicationMenuService, - 'applicationMenuServiceLocator' -); - -// Shared helper that is useful in a few places since we need to -// translate between 'real function' click handlers and -// string identifiers for those click handlers in a few places. -export function transformAppMenu( - menu: CompassAppMenu, - transform: ( - cb: Omit, 'submenu'> - ) => Omit, 'submenu'> -): CompassAppMenu { - return { - ...transform({ ...menu }), - submenu: menu.submenu - ? menu.submenu.map((sub) => transformAppMenu(sub, transform)) - : undefined, - }; -} - -const objectIds = new WeakMap(); -let objectIdCounter = 0; - -function getObjectId(obj: object): number { - let id = objectIds.get(obj); - if (id === undefined) { - id = ++objectIdCounter; - objectIds.set(obj, id); - } - return id; -} - // Hook to set up an additional application menu, as well as // override handlers for pre-defined Electron menu roles. // @@ -114,14 +72,15 @@ export function useApplicationMenu({ const { showApplicationMenu, handleMenuRole } = useApplicationMenuService(); useEffect(() => { - const hideMenu = menu && showApplicationMenu(menu); - const subscriptions = Object.entries(roles ?? {}).map(([role, handler]) => - handleMenuRole(role as MenuItemConstructorOptions['role'], handler) - ); + const subscriptions = [ + menu && showApplicationMenu(menu), + ...Object.entries(roles ?? {}).map(([role, handler]) => + handleMenuRole(role as MenuItemConstructorOptions['role'], handler) + ), + ]; return () => { - hideMenu?.(); - for (const unsubscribe of subscriptions) unsubscribe(); + for (const unsubscribe of subscriptions) unsubscribe?.(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ diff --git a/packages/compass-electron-menu/src/index.ts b/packages/compass-electron-menu/src/index.ts new file mode 100644 index 00000000000..a02ab22751b --- /dev/null +++ b/packages/compass-electron-menu/src/index.ts @@ -0,0 +1,6 @@ +export { + type ApplicationMenuProvider, + ApplicationMenuContextProvider, + useApplicationMenu, +} from './application-menu'; +export type { CompassAppMenu } from './types'; diff --git a/packages/compass-electron-menu/src/integration.spec.tsx b/packages/compass-electron-menu/src/integration.spec.tsx new file mode 100644 index 00000000000..54e17300019 --- /dev/null +++ b/packages/compass-electron-menu/src/integration.spec.tsx @@ -0,0 +1,176 @@ +import React from 'react'; +import { render } from '@mongodb-js/testing-library-compass'; +import { expect } from 'chai'; +import { + ApplicationMenuContextProvider, + useApplicationMenu, +} from './application-menu'; +import type { CompassAppMenu, ModifyApplicationMenuParams } from './types'; +import type { HadronIpcRenderer } from './ipc-provider-renderer'; +import { ApplicationMenu } from './ipc-provider-renderer'; +import type { HadronIpcMain } from './ipc-provider-main'; +import { RendererDefinedMenuState } from './ipc-provider-main'; +import { EventEmitter } from 'events'; + +function serializable(obj: T): T { + try { + return JSON.parse( + JSON.stringify(obj, (_, value) => { + if (typeof value === 'function') { + return '[Function]'; + } + return value; + }) + ); + } catch { + return obj; + } +} + +const tick = () => new Promise((resolve) => setTimeout(resolve)); + +const TestComponent: React.FC<{ + menu?: CompassAppMenu; + roles?: Record void>; +}> = ({ menu, roles }) => { + useApplicationMenu({ menu, roles }); + return null; +}; + +describe('application menu integration test', function () { + let ipcRenderer: HadronIpcRenderer & EventEmitter; + let ipcMain: HadronIpcMain & EventEmitter; + + beforeEach(function () { + ipcRenderer = new (class extends EventEmitter implements HadronIpcRenderer { + // eslint-disable-next-line @typescript-eslint/require-await + async call(event: string, payload: unknown) { + queueMicrotask(() => ipcMain.emit(event, null, payload)); + } + })(); + ipcMain = new (class extends EventEmitter implements HadronIpcMain { + broadcastFocused(event: string, payload: unknown): void { + queueMicrotask(() => ipcRenderer.emit(event, null, payload)); + } + })(); + }); + + it('lets the react hook establish an application menu', async function () { + const provider = new ApplicationMenu(ipcRenderer); + const state = new RendererDefinedMenuState(ipcMain); + + ipcMain.on( + RendererDefinedMenuState.modifyApplicationMenuIpcEvent, + (_, event: ModifyApplicationMenuParams) => + state.modifyApplicationMenuHandler(event) + ); + + const clicks = { a: 0, b: 0, c: 0 }; + const clickA = () => clicks.a++; + const clickB = () => clicks.b++; + const clickC = () => clicks.c++; + const menuA: CompassAppMenu = { + label: '&Actions', + submenu: [{ label: 'Action', click: clickA }], + }; + const menuB: CompassAppMenu = { + label: 'Edit', + submenu: [ + { + label: 'Undo', + role: 'undo', + }, + { + label: 'Redo', + role: 'redo', + }, + ], + }; + + const { rerender } = render( + + {null} + + ); + + const getMenuForProps = async ( + props?: React.ComponentProps + ) => { + rerender( + + + + ); + + await tick(); + return state.translateRoles([menuB, ...state.menus()]); + }; + + let menu: CompassAppMenu[] = await getMenuForProps(); + expect(serializable(menu)).to.deep.equal([ + { + label: 'Edit', + submenu: [ + { label: 'Undo', role: 'undo' }, + { label: 'Redo', role: 'redo' }, + ], + }, + ]); + + menu = await getMenuForProps({ menu: menuA }); + expect(serializable(menu)).to.deep.equal([ + { + label: 'Edit', + submenu: [ + { label: 'Undo', role: 'undo' }, + { label: 'Redo', role: 'redo' }, + ], + }, + { + label: '&Actions', + submenu: [{ label: 'Action', click: '[Function]' }], + }, + ]); + + expect(clicks).to.deep.equal({ a: 0, b: 0, c: 0 }); + menu.find((m) => m.label === '&Actions')?.submenu?.[0].click?.(); + await tick(); + expect(clicks).to.deep.equal({ a: 1, b: 0, c: 0 }); + + menu = await getMenuForProps({ roles: { undo: clickB, redo: clickC } }); + expect(serializable(menu)).to.deep.equal([ + { + label: 'Edit', + submenu: [ + { label: 'Undo', click: '[Function]' }, + { label: 'Redo', click: '[Function]' }, + ], + }, + ]); + + expect(clicks).to.deep.equal({ a: 1, b: 0, c: 0 }); + menu + .find((m) => m.label === 'Edit') + ?.submenu?.find((i) => i.label === 'Undo') + ?.click?.(); + await tick(); + expect(clicks).to.deep.equal({ a: 1, b: 1, c: 0 }); + menu + .find((m) => m.label === 'Edit') + ?.submenu?.find((i) => i.label === 'Redo') + ?.click?.(); + await tick(); + expect(clicks).to.deep.equal({ a: 1, b: 1, c: 1 }); + + menu = await getMenuForProps(); + expect(serializable(menu)).to.deep.equal([ + { + label: 'Edit', + submenu: [ + { label: 'Undo', role: 'undo' }, + { label: 'Redo', role: 'redo' }, + ], + }, + ]); + }); +}); diff --git a/packages/compass-electron-menu/src/ipc-provider-main.ts b/packages/compass-electron-menu/src/ipc-provider-main.ts new file mode 100644 index 00000000000..23e7f979a5c --- /dev/null +++ b/packages/compass-electron-menu/src/ipc-provider-main.ts @@ -0,0 +1,91 @@ +import type { IpcEvents, ModifyApplicationMenuParams } from './types'; +import { + transformAppMenu, + type CompassAppMenu, + type MenuItemConstructorOptions, + type UUIDString, +} from './types'; + +export interface HadronIpcMain { + broadcastFocused( + channel: K, + payload: IpcEvents[K] + ): void; +} + +export class RendererDefinedMenuState { + private roleListeners: [MenuItemConstructorOptions['role'], string][] = []; + private additionalMenus: { id: string; menu: CompassAppMenu }[] = + []; + + private ipcMain: HadronIpcMain; + + constructor(ipcMain: HadronIpcMain | undefined) { + if (!ipcMain) { + throw new Error('ipcMain is required for RendererDefinedMenuState'); + } + this.ipcMain = ipcMain; + } + + menus(): CompassAppMenu[] { + return this.additionalMenus.map( + ({ menu }): CompassAppMenu => + transformAppMenu(menu, (item) => { + const id = item.click; + if (!id) return { ...item, click: undefined }; + return { + ...item, + click: () => + this.ipcMain.broadcastFocused('application-menu:invoke-handler', { + id, + }), + }; + }) + ); + } + + translateRoles(menus: CompassAppMenu[]): CompassAppMenu[] { + return menus.map((menu): CompassAppMenu => { + return transformAppMenu(menu, (item) => { + if (!item.role) return item; + + const listener = this.roleListeners.find( + ([role]) => role === item.role + ); + if (!listener) return item; + const id = listener[1]; + return { + ...item, + role: undefined, + click: () => + this.ipcMain.broadcastFocused('application-menu:invoke-handler', { + id, + }), + }; + }); + }); + } + + modifyApplicationMenuHandler = ({ + id, + menu, + role, + }: ModifyApplicationMenuParams): this => { + if (menu) { + this.additionalMenus.push({ id, menu }); + } else { + this.additionalMenus = this.additionalMenus.filter((m) => m.id !== id); + } + if (role) { + this.roleListeners.push([role, id]); + } else { + this.roleListeners = this.roleListeners.filter( + ([, listenerId]) => listenerId !== id + ); + } + return this; + }; + + static readonly modifyApplicationMenuIpcEvent = + 'application-menu:modify-application-menu' as const satisfies keyof IpcEvents; +} diff --git a/packages/compass-electron-menu/src/ipc-provider-renderer.ts b/packages/compass-electron-menu/src/ipc-provider-renderer.ts new file mode 100644 index 00000000000..e0ca65768d9 --- /dev/null +++ b/packages/compass-electron-menu/src/ipc-provider-renderer.ts @@ -0,0 +1,89 @@ +import type { ApplicationMenuProvider, CompassAppMenu } from './'; +import type { + IpcEvents, + MenuItemConstructorOptions, + UUIDString, +} from './types'; +import { transformAppMenu } from './types'; +import { uuid } from './util'; +import createDebug from 'debug'; +const debug = createDebug('compass-electron-menu:ipc-provider-renderer'); +export interface HadronIpcRenderer { + on( + event: K, + listener: (event: unknown, payload: IpcEvents[K]) => void + ): void; + call( + event: K, + payload: IpcEvents[K] + ): Promise; +} + +function translateCallsToHandlerIds( + menu: CompassAppMenu +): [CompassAppMenu, Map void>] { + const handlerIds = new Map void>(); + const transformedMenu = transformAppMenu(menu, (item) => { + if (!item.click) return { ...item, click: undefined }; + const id = uuid(); + handlerIds.set(id, item.click); + return { ...item, click: id }; + }); + return [transformedMenu, handlerIds]; +} + +export class ApplicationMenu implements ApplicationMenuProvider { + handlers = new Map void>(); + ipcRenderer: HadronIpcRenderer | undefined; + + constructor(ipcRenderer: HadronIpcRenderer | undefined) { + this.ipcRenderer = ipcRenderer; + this.ipcRenderer?.on( + 'application-menu:invoke-handler', + (event, { id }: { id: string }) => { + const handler = this.handlers.get(id); + if (!handler) debug('No handler found for menu item id', id); + handler?.(); + } + ); + } + + showApplicationMenu = (menu: CompassAppMenu): (() => void) => { + const id = uuid(); + const [translatedMenu, handlers] = translateCallsToHandlerIds(menu); + for (const [handlerId, handler] of handlers.entries()) { + this.handlers.set(handlerId, handler); + } + void this.ipcRenderer?.call('application-menu:modify-application-menu', { + id, + menu: translatedMenu, + }); + return () => { + void this.ipcRenderer?.call('application-menu:modify-application-menu', { + id, + menu: undefined, + }); + for (const handlerId of handlers.keys()) { + this.handlers.delete(handlerId); + } + }; + }; + + handleMenuRole = ( + role: MenuItemConstructorOptions['role'], + handler: () => void + ): (() => void) => { + const id = uuid(); + this.handlers.set(id, handler); + void this.ipcRenderer?.call('application-menu:modify-application-menu', { + id, + role, + }); + return () => { + void this.ipcRenderer?.call('application-menu:modify-application-menu', { + id, + }); + this.handlers.delete(id); + }; + }; +} diff --git a/packages/compass-electron-menu/src/types.spec.ts b/packages/compass-electron-menu/src/types.spec.ts new file mode 100644 index 00000000000..7230ff35796 --- /dev/null +++ b/packages/compass-electron-menu/src/types.spec.ts @@ -0,0 +1,28 @@ +import { expect } from 'chai'; +import { transformAppMenu } from './types'; + +describe('transformAppMenu', function () { + it('transforms menu items using the callback', function () { + expect( + transformAppMenu( + { + label: '42', + click: 'quux', + submenu: [ + { label: 'Item 1', click: 'foo' }, + { label: 'Item 2', click: 'bar' }, + ], + }, + (item) => ({ ...item, click: item.click?.length, extra: 'abc' }) + ) + ).to.deep.equal({ + label: '42', + click: 4, + submenu: [ + { label: 'Item 1', click: 3, extra: 'abc' }, + { label: 'Item 2', click: 3, extra: 'abc' }, + ], + extra: 'abc', + }); + }); +}); diff --git a/packages/compass-electron-menu/src/types.ts b/packages/compass-electron-menu/src/types.ts new file mode 100644 index 00000000000..c7133ac503a --- /dev/null +++ b/packages/compass-electron-menu/src/types.ts @@ -0,0 +1,41 @@ +// Type-only import in a separate entry point, so this is fine +// compass-peer-deps-ignore +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import type { MenuItemConstructorOptions } from 'electron'; + +export type { MenuItemConstructorOptions }; +export type CompassAppMenu void> = Omit< + MenuItemConstructorOptions, + 'click' | 'submenu' +> & { click?: ClickHandlerType; submenu?: CompassAppMenu[] }; +export type UUIDString = string; + +// Shared helper that is useful in a few places since we need to +// translate between 'real function' click handlers and +// string identifiers for those click handlers in a few places. +export function transformAppMenu( + menu: CompassAppMenu, + transform: ( + cb: Omit, 'submenu'> + ) => Omit, 'submenu'> +): CompassAppMenu { + return { + ...transform({ ...menu }), + ...(menu.submenu + ? { + submenu: menu.submenu.map((sub) => transformAppMenu(sub, transform)), + } + : undefined), + }; +} + +export interface ModifyApplicationMenuParams { + id: string; + menu?: CompassAppMenu; + role?: MenuItemConstructorOptions['role']; +} + +export interface IpcEvents { + 'application-menu:modify-application-menu': ModifyApplicationMenuParams; + 'application-menu:invoke-handler': { id: string }; +} diff --git a/packages/compass-electron-menu/src/util.spec.ts b/packages/compass-electron-menu/src/util.spec.ts new file mode 100644 index 00000000000..947e2fa1878 --- /dev/null +++ b/packages/compass-electron-menu/src/util.spec.ts @@ -0,0 +1,31 @@ +import { expect } from 'chai'; +import { uuid, getObjectId } from './util'; + +describe('util.ts', function () { + describe('uuid()', function () { + it('returns a string matching UUID v4 format', function () { + const value = uuid(); + expect(value).to.be.a('string'); + expect(value).to.match( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + ); + }); + }); + + describe('getObjectId()', function () { + it('returns stable id for the same object', function () { + const obj = {}; + const first = getObjectId(obj); + const second = getObjectId(obj); + expect(first).to.equal(second); + }); + + it('returns incremental ids for different objects', function () { + const obj1 = {}; + const obj2 = {}; + const id1 = getObjectId(obj1); + const id2 = getObjectId(obj2); + expect(id2).to.equal(id1 + 1); + }); + }); +}); diff --git a/packages/compass-electron-menu/src/util.ts b/packages/compass-electron-menu/src/util.ts new file mode 100644 index 00000000000..7da8fd19035 --- /dev/null +++ b/packages/compass-electron-menu/src/util.ts @@ -0,0 +1,18 @@ +import { UUID } from 'bson'; +import type { UUIDString } from './types'; + +export function uuid(): UUIDString { + return new UUID().toString(); +} + +const objectIds = new WeakMap(); +let objectIdCounter = 0; + +export function getObjectId(obj: object): number { + let id = objectIds.get(obj); + if (id === undefined) { + id = ++objectIdCounter; + objectIds.set(obj, id); + } + return id; +} diff --git a/packages/compass-electron-menu/tsconfig-build.json b/packages/compass-electron-menu/tsconfig-build.json new file mode 100644 index 00000000000..737091e2e1c --- /dev/null +++ b/packages/compass-electron-menu/tsconfig-build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "exclude": ["./src/**/*.spec.*"] +} diff --git a/packages/compass-electron-menu/tsconfig.json b/packages/compass-electron-menu/tsconfig.json new file mode 100644 index 00000000000..3495f3190e9 --- /dev/null +++ b/packages/compass-electron-menu/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@mongodb-js/tsconfig-compass/tsconfig.react.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/compass-workspaces/package.json b/packages/compass-workspaces/package.json index cdaeee3d486..1c332c48bc2 100644 --- a/packages/compass-workspaces/package.json +++ b/packages/compass-workspaces/package.json @@ -24,13 +24,11 @@ "compass:main": "src/index.ts", "exports": { ".": "./dist/index.js", - "./provider": "./dist/provider.js", - "./application-menu": "./dist/application-menu.js" + "./provider": "./dist/provider.js" }, "compass:exports": { ".": "./src/index.ts", - "./provider": "./src/provider.tsx", - "./application-menu": "./src/application-menu.tsx" + "./provider": "./src/provider.tsx" }, "types": "./dist/index.d.ts", "scripts": { @@ -83,7 +81,6 @@ "@types/sinon-chai": "^3.2.5", "chai": "^4.3.6", "depcheck": "^1.4.1", - "electron": "^37.6.1", "electron-mocha": "^12.2.0", "mocha": "^10.2.0", "nyc": "^15.1.0", diff --git a/packages/compass/package.json b/packages/compass/package.json index 22cf5172578..51a419fa687 100644 --- a/packages/compass/package.json +++ b/packages/compass/package.json @@ -181,6 +181,7 @@ "email": "compass@mongodb.com" }, "dependencies": { + "@mongodb-js/compass-electron-menu": "^0.1.0", "@mongodb-js/device-id": "^0.2.0", "@mongosh/node-runtime-worker-thread": "^3.3.26", "clipboard": "^2.0.6", diff --git a/packages/compass/src/app/application.tsx b/packages/compass/src/app/application.tsx index e27adacce14..e80021cbece 100644 --- a/packages/compass/src/app/application.tsx +++ b/packages/compass/src/app/application.tsx @@ -1,6 +1,6 @@ -import { ipcRenderer, type HadronIpcRenderer } from 'hadron-ipc'; +import { ipcRenderer } from 'hadron-ipc'; import * as remote from '@electron/remote'; -import { webUtils, webFrame, type MenuItemConstructorOptions } from 'electron'; +import { webUtils, webFrame } from 'electron'; import { globalAppRegistry } from '@mongodb-js/compass-app-registry'; import { defaultPreferencesInstance } from 'compass-preferences-model'; import semver from 'semver'; @@ -27,95 +27,22 @@ import { onAutoupdateStarted, onAutoupdateSuccess, } from './components/update-toasts'; -import { UUID } from 'bson'; import { createElectronFileInputBackend } from '@mongodb-js/compass-components'; import { CompassRendererConnectionStorage } from '@mongodb-js/connection-storage/renderer'; import type { SettingsTabId } from '@mongodb-js/compass-settings'; import type { AutoConnectPreferences } from '../main/auto-connect'; -const { log, mongoLogId, debug } = createLogger('COMPASS-APP'); +const { log, mongoLogId } = createLogger('COMPASS-APP'); const track = createIpcTrack(); import './index.less'; import 'source-code-pro/source-code-pro.css'; -import { - transformAppMenu, - type ApplicationMenuProvider, - type CompassAppMenu, -} from '@mongodb-js/compass-workspaces/application-menu'; +import type { ApplicationMenuProvider } from '@mongodb-js/compass-electron-menu'; +import { ApplicationMenu } from '@mongodb-js/compass-electron-menu/ipc-provider-renderer'; const DEFAULT_APP_VERSION = '0.0.0'; -function translateCallsToHandlerIds( - menu: CompassAppMenu -): [CompassAppMenu, Map void>] { - const handlerIds = new Map void>(); - const transformedMenu = transformAppMenu(menu, (item) => { - if (!item.click) return { ...item, click: undefined }; - const id = new UUID().toString(); - handlerIds.set(id, item.click); - return { ...item, click: id }; - }); - return [transformedMenu, handlerIds]; -} - -class ApplicationMenu implements ApplicationMenuProvider { - handlers = new Map void>(); - ipcRenderer: HadronIpcRenderer | undefined; - - constructor(ipcRenderer: HadronIpcRenderer | undefined) { - this.ipcRenderer = ipcRenderer; - this.ipcRenderer?.on( - 'application-menu:invoke-handler', - (event, { id }: { id: string }) => { - const handler = this.handlers.get(id); - if (!handler) debug('No handler found for menu item id', id); - handler?.(); - } - ); - } - - showApplicationMenu = (menu: CompassAppMenu): (() => void) => { - const id = new UUID().toString(); - const [translatedMenu, handlers] = translateCallsToHandlerIds(menu); - for (const [handlerId, handler] of handlers.entries()) { - this.handlers.set(handlerId, handler); - } - void this.ipcRenderer?.call('application-menu:modify-application-menu', { - id, - menu: translatedMenu, - }); - return () => { - void this.ipcRenderer?.call('application-menu:modify-application-menu', { - id, - menu: undefined, - }); - for (const handlerId of handlers.keys()) { - this.handlers.delete(handlerId); - } - }; - }; - - handleMenuRole = ( - role: MenuItemConstructorOptions['role'], - handler: () => void - ): (() => void) => { - const id = new UUID().toString(); - this.handlers.set(id, handler); - void this.ipcRenderer?.call('application-menu:modify-application-menu', { - id, - role, - }); - return () => { - void this.ipcRenderer?.call('application-menu:modify-application-menu', { - id, - }); - this.handlers.delete(id); - }; - }; -} - class Application { private static instance: Application | null = null; diff --git a/packages/compass/src/app/components/home.spec.tsx b/packages/compass/src/app/components/home.spec.tsx index 683afe968a8..a84e82bb2e6 100644 --- a/packages/compass/src/app/components/home.spec.tsx +++ b/packages/compass/src/app/components/home.spec.tsx @@ -49,6 +49,10 @@ const HOME_PROPS = { getAutoConnectInfo: () => Promise.resolve(undefined), showWelcomeModal: false, connectionStorage: new InMemoryConnectionStorage(), + applicationMenuProvider: { + showApplicationMenu: () => () => {}, + handleMenuRole: () => () => {}, + }, } as const; describe('Home [Component]', function () { diff --git a/packages/compass/src/app/components/home.tsx b/packages/compass/src/app/components/home.tsx index 0539e1dbb8b..6eb16bc54a1 100644 --- a/packages/compass/src/app/components/home.tsx +++ b/packages/compass/src/app/components/home.tsx @@ -35,8 +35,8 @@ import { useTelemetry } from '@mongodb-js/compass-telemetry/provider'; import { usePreference } from 'compass-preferences-model/provider'; import { CompassAssistantProvider } from '@mongodb-js/compass-assistant'; import { APP_NAMES_FOR_PROMPT } from '@mongodb-js/compass-assistant'; -import type { ApplicationMenuProvider } from '@mongodb-js/compass-workspaces/application-menu'; -import { ApplicationMenuContextProvider } from '@mongodb-js/compass-workspaces/application-menu'; +import type { ApplicationMenuProvider } from '@mongodb-js/compass-electron-menu'; +import { ApplicationMenuContextProvider } from '@mongodb-js/compass-electron-menu'; resetGlobalCSS(); diff --git a/packages/compass/src/main/menu.spec.ts b/packages/compass/src/main/menu.spec.ts index af31263b2bc..8f3813c7d05 100644 --- a/packages/compass/src/main/menu.spec.ts +++ b/packages/compass/src/main/menu.spec.ts @@ -9,6 +9,7 @@ import type { CompassApplication } from './application'; import type { CompassMenu as _CompassMenu } from './menu'; import { quitItem } from './menu'; import { AutoUpdateManagerState } from './auto-update-manager'; +import { RendererDefinedMenuState } from '@mongodb-js/compass-electron-menu/ipc-provider-main'; function serializable(obj: T): T { try { @@ -46,11 +47,12 @@ describe('CompassMenu', function () { const bw = new BrowserWindow({ show: false }); App.emit('new-window', bw); expect(CompassMenu['windowState']).to.have.property('size', 1); - expect(CompassMenu['windowState'].get(bw.id)).to.deep.eq({ - additionalMenus: [], - roleListeners: [], - updateManagerState: 'idle', - }); + expect(serializable(CompassMenu['windowState'].get(bw.id))).to.deep.eq( + serializable({ + rendererState: new RendererDefinedMenuState(ipcMain as any), + updateManagerState: 'idle', + }) + ); }); it('should remove window from state when window is closed', function () { @@ -78,27 +80,30 @@ describe('CompassMenu', function () { it('should change window state when window emits update-manager:new-state event', function () { const bw = new BrowserWindow({ show: false }); App.emit('new-window', bw); - expect(CompassMenu['windowState'].get(bw.id)).to.deep.eq({ - additionalMenus: [], - roleListeners: [], - updateManagerState: 'idle', - }); + expect(serializable(CompassMenu['windowState'].get(bw.id))).to.deep.eq( + serializable({ + rendererState: new RendererDefinedMenuState(ipcMain as any), + updateManagerState: 'idle', + }) + ); App.emit('auto-updater:new-state', AutoUpdateManagerState.PromptForRestart); - expect(CompassMenu['windowState'].get(bw.id)).to.deep.eq({ - additionalMenus: [], - roleListeners: [], - updateManagerState: 'ready to restart', - }); + expect(serializable(CompassMenu['windowState'].get(bw.id))).to.deep.eq( + serializable({ + rendererState: new RendererDefinedMenuState(ipcMain as any), + updateManagerState: 'ready to restart', + }) + ); App.emit( 'auto-updater:new-state', AutoUpdateManagerState.DownloadingUpdate ); - expect(CompassMenu['windowState'].get(bw.id)).to.deep.eq({ - additionalMenus: [], - roleListeners: [], - updateManagerState: 'installing updates', - }); + expect(serializable(CompassMenu['windowState'].get(bw.id))).to.deep.eq( + serializable({ + rendererState: new RendererDefinedMenuState(ipcMain as any), + updateManagerState: 'installing updates', + }) + ); }); describe('getTemplate', function () { @@ -358,7 +363,8 @@ describe('CompassMenu', function () { }); it('should add menus requested from browser windows', function () { - CompassMenu['windowState'].set(0, { + const rendererState = new RendererDefinedMenuState({} as any); + Object.assign(rendererState, { additionalMenus: [ { id: 'menu0', @@ -372,6 +378,9 @@ describe('CompassMenu', function () { ['undo', 'id0'], ['redo', 'id1'], ], + }); + CompassMenu['windowState'].set(0, { + rendererState, updateManagerState: 'idle', }); expect( @@ -436,19 +445,17 @@ describe('CompassMenu', function () { accelerator: 'CmdOrCtrl+F', label: 'Find', }, - [ - ...(process.platform === 'darwin' - ? [] - : [ - { - type: 'separator', - }, - { - accelerator: 'CmdOrCtrl+,', - label: '&Settings', - }, - ]), - ], + ...(process.platform === 'darwin' + ? [] + : [ + { + type: 'separator', + }, + { + accelerator: 'CmdOrCtrl+,', + label: '&Settings', + }, + ]), ], }); }); diff --git a/packages/compass/src/main/menu.ts b/packages/compass/src/main/menu.ts index 83217133803..0df2f637c98 100644 --- a/packages/compass/src/main/menu.ts +++ b/packages/compass/src/main/menu.ts @@ -1,7 +1,5 @@ -import { - transformAppMenu, - type CompassAppMenu, -} from '@mongodb-js/compass-workspaces/application-menu'; +import { RendererDefinedMenuState } from '@mongodb-js/compass-electron-menu/ipc-provider-main'; +import { type CompassAppMenu } from '@mongodb-js/compass-electron-menu'; import { BrowserWindow, Menu, @@ -29,41 +27,6 @@ const debug = createDebug('mongodb-compass:menu'); const COMPASS_HELP = 'https://docs.mongodb.com/compass/'; -function translateHandlerIdsToCalls( - menu: CompassAppMenu -): CompassAppMenu { - return transformAppMenu(menu, (item) => { - const id = item.click; - if (!id) return { ...item, click: undefined }; - return { - ...item, - click: () => - ipcMain?.broadcastFocused('application-menu:invoke-handler', { id }), - }; - }); -} - -function translateRoles( - menu: CompassAppMenu, - state: WindowMenuState -): CompassAppMenu { - return transformAppMenu(menu, (item) => { - if (!item.role) return item; - - const listener = state.roleListeners.find(([role]) => role === item.role); - if (!listener) return item; - const id = listener[1]; - return { - ...item, - role: undefined, - click: () => - ipcMain?.broadcastFocused('application-menu:invoke-handler', { - id, - }), - }; - }); -} - function separator(): MenuItemConstructorOptions { return { type: 'separator' as const, @@ -458,39 +421,36 @@ function darwinMenu( menuState: WindowMenuState, app: typeof CompassApplication ): MenuItemConstructorOptions[] { - return [ + return menuState.rendererState.translateRoles([ darwinCompassSubMenu(menuState, app), connectSubMenu(false, app), editSubMenu(), viewSubMenu(app), - ...menuState.additionalMenus.map(({ menu }) => - translateHandlerIdsToCalls(menu) - ), + ...menuState.rendererState.menus(), windowSubMenu(app), helpSubMenu(menuState, app), - ].map((menu) => translateRoles(menu, menuState)); + ]); } function nonDarwinMenu( menuState: WindowMenuState, app: typeof CompassApplication ): MenuItemConstructorOptions[] { - return [ + return menuState.rendererState.translateRoles([ connectSubMenu(true, app), editSubMenu(), viewSubMenu(app), - ...menuState.additionalMenus.map(({ menu }) => - translateHandlerIdsToCalls(menu) - ), + ...menuState.rendererState.menus(), helpSubMenu(menuState, app), - ].map((menu) => translateRoles(menu, menuState)); + ]); } type UpdateManagerState = 'idle' | 'installing updates' | 'ready to restart'; class WindowMenuState { - roleListeners: [MenuItemConstructorOptions['role'], string][] = []; - additionalMenus: { id: string; menu: CompassAppMenu }[] = []; + rendererState: RendererDefinedMenuState = new RendererDefinedMenuState( + ipcMain + ); updateManagerState: UpdateManagerState = 'idle'; } @@ -534,8 +494,11 @@ class CompassMenu { }); ipcMain?.respondTo({ - 'application-menu:modify-application-menu': - this.modifyApplicationMenuHandler.bind(this), + [RendererDefinedMenuState.modifyApplicationMenuIpcEvent]: (ev, params) => + this.updateMenu((state) => ({ + rendererState: + state.rendererState.modifyApplicationMenuHandler(params), + })), }); preferences.onPreferenceValueChanged('theme', (newTheme: THEMES) => { @@ -655,10 +618,11 @@ class CompassMenu { menuState = new WindowMenuState(); } - if (process.platform === 'darwin') { - return darwinMenu(menuState, this.app); - } - return nonDarwinMenu(menuState, this.app); + const menu = + process.platform === 'darwin' + ? darwinMenu(menuState, this.app) + : nonDarwinMenu(menuState, this.app); + return menuState.rendererState.translateRoles(menu); } private static refreshMenu = () => { @@ -677,37 +641,6 @@ class CompassMenu { Menu.setApplicationMenu(menu); }; - private static modifyApplicationMenuHandler( - evt: unknown, - { - id, - menu, - role, - }: { - id: string; - menu?: CompassAppMenu; - role?: MenuItemConstructorOptions['role']; - } - ) { - this.updateMenu(({ ...state }) => { - if (menu) { - state.additionalMenus.push({ id, menu }); - } else { - state.additionalMenus = state.additionalMenus.filter( - (m) => m.id !== id - ); - } - if (role) { - state.roleListeners.push([role, id]); - } else { - state.roleListeners = state.roleListeners.filter( - ([, listenerId]) => listenerId !== id - ); - } - return state; - }); - } - private static updateMenu( newValues: (state: WindowMenuState) => Partial, bw: BrowserWindow | null = this.lastFocusedWindow From 500892aceb5602cfcf73f3f68ecf7d0f51b53aa5 Mon Sep 17 00:00:00 2001 From: Anna Henningsen Date: Thu, 23 Oct 2025 23:20:35 +0200 Subject: [PATCH 4/7] fixup: compass-data-modeling does not yet depend on compass-electron-menu --- package-lock.json | 2 -- packages/compass-data-modeling/package.json | 1 - 2 files changed, 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 93f0d31f61d..705679c0406 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49847,7 +49847,6 @@ "@mongodb-js/compass-app-stores": "^7.69.0", "@mongodb-js/compass-components": "^1.56.0", "@mongodb-js/compass-connections": "^1.83.0", - "@mongodb-js/compass-electron-menu": "^0.1.0", "@mongodb-js/compass-logging": "^1.7.22", "@mongodb-js/compass-telemetry": "^1.18.0", "@mongodb-js/compass-user-data": "^0.10.5", @@ -64312,7 +64311,6 @@ "@mongodb-js/compass-app-stores": "^7.69.0", "@mongodb-js/compass-components": "^1.56.0", "@mongodb-js/compass-connections": "^1.83.0", - "@mongodb-js/compass-electron-menu": "^0.1.0", "@mongodb-js/compass-logging": "^1.7.22", "@mongodb-js/compass-telemetry": "^1.18.0", "@mongodb-js/compass-user-data": "^0.10.5", diff --git a/packages/compass-data-modeling/package.json b/packages/compass-data-modeling/package.json index 38f7f275898..0332b880071 100644 --- a/packages/compass-data-modeling/package.json +++ b/packages/compass-data-modeling/package.json @@ -59,7 +59,6 @@ "@mongodb-js/compass-app-stores": "^7.69.0", "@mongodb-js/compass-components": "^1.56.0", "@mongodb-js/compass-connections": "^1.83.0", - "@mongodb-js/compass-electron-menu": "^0.1.0", "@mongodb-js/compass-logging": "^1.7.22", "@mongodb-js/compass-telemetry": "^1.18.0", "@mongodb-js/compass-user-data": "^0.10.5", From 07da41fb5d8e0c22a2344ef17ecdd04e2eb412cb Mon Sep 17 00:00:00 2001 From: Anna Henningsen Date: Fri, 24 Oct 2025 11:17:36 +0200 Subject: [PATCH 5/7] fixup: remove peer dep check workaround --- packages/compass-electron-menu/package.json | 2 +- packages/compass-electron-menu/src/types.ts | 7 ++++--- scripts/check-peer-deps.js | 8 -------- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/compass-electron-menu/package.json b/packages/compass-electron-menu/package.json index b55f094a2cb..d1e5d4f2e1a 100644 --- a/packages/compass-electron-menu/package.json +++ b/packages/compass-electron-menu/package.json @@ -55,6 +55,7 @@ "dependencies": { "bson": "^6.10.4", "debug": "^4.3.4", + "electron": "^37.6.1", "react": "^17.0.2" }, "devDependencies": { @@ -70,7 +71,6 @@ "@types/react-dom": "^17.0.10", "@types/sinon-chai": "^3.2.5", "chai": "^4.3.6", - "electron": "^37.6.1", "depcheck": "^1.4.1", "gen-esm-wrapper": "^1.1.0", "mocha": "^10.2.0", diff --git a/packages/compass-electron-menu/src/types.ts b/packages/compass-electron-menu/src/types.ts index c7133ac503a..56039092aeb 100644 --- a/packages/compass-electron-menu/src/types.ts +++ b/packages/compass-electron-menu/src/types.ts @@ -1,6 +1,7 @@ -// Type-only import in a separate entry point, so this is fine -// compass-peer-deps-ignore -// eslint-disable-next-line @typescript-eslint/no-restricted-imports +// NB: We add `electron` as a production dependency because +// of this type import. That's fine because we expect this +// package to only be used in Compass, where we know elecron +// is a dependency anyway. import type { MenuItemConstructorOptions } from 'electron'; export type { MenuItemConstructorOptions }; diff --git a/scripts/check-peer-deps.js b/scripts/check-peer-deps.js index b6300ba7600..b2764f4a427 100644 --- a/scripts/check-peer-deps.js +++ b/scripts/check-peer-deps.js @@ -77,27 +77,19 @@ async function collectAllAbsoluteImports(entryPoints = []) { ], ], }); - const shouldSkip = (path) => - path.node.leadingComments?.some((comment) => - comment.value?.includes('compass-peer-deps-ignore') - ); traverse(program, { ImportDeclaration(path) { - if (shouldSkip(path)) return; queueImport(path.node.source.value); }, ExportNamedDeclaration(path) { - if (shouldSkip(path)) return; if (path.node.source) { queueImport(path.node.source.value); } }, ExportAllDeclaration(path) { - if (shouldSkip(path)) return; queueImport(path.node.source.value); }, CallExpression(path) { - if (shouldSkip(path)) return; if ( path.node.callee.type === 'Identifier' && (path.node.callee.name === 'require' || From 020d08e3da2fb8f37cf6957885666cce5da09ce5 Mon Sep 17 00:00:00 2001 From: Anna Henningsen Date: Fri, 24 Oct 2025 11:29:22 +0200 Subject: [PATCH 6/7] fixup: cr --- package-lock.json | 4 ++-- .../compass-electron-menu/src/application-menu.spec.tsx | 7 +------ packages/compass/package.json | 2 +- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 705679c0406..5f531e10b77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47946,7 +47946,6 @@ "hasInstallScript": true, "license": "SSPL", "dependencies": { - "@mongodb-js/compass-electron-menu": "^0.1.0", "@mongodb-js/device-id": "^0.2.0", "@mongosh/node-runtime-worker-thread": "^3.3.26", "clipboard": "^2.0.6", @@ -47972,6 +47971,7 @@ "@mongodb-js/compass-crud": "^13.83.0", "@mongodb-js/compass-data-modeling": "^1.34.0", "@mongodb-js/compass-databases-collections": "^1.82.0", + "@mongodb-js/compass-electron-menu": "^0.1.0", "@mongodb-js/compass-explain-plan": "^6.83.0", "@mongodb-js/compass-export-to-language": "^9.59.0", "@mongodb-js/compass-field-store": "^9.58.0", @@ -50602,6 +50602,7 @@ "dependencies": { "bson": "^6.10.4", "debug": "^4.3.4", + "electron": "^37.6.1", "react": "^17.0.2" }, "devDependencies": { @@ -50618,7 +50619,6 @@ "@types/sinon-chai": "^3.2.5", "chai": "^4.3.6", "depcheck": "^1.4.1", - "electron": "^37.6.1", "gen-esm-wrapper": "^1.1.0", "mocha": "^10.2.0", "nyc": "^15.1.0", diff --git a/packages/compass-electron-menu/src/application-menu.spec.tsx b/packages/compass-electron-menu/src/application-menu.spec.tsx index 2c7a2e9763c..8b44c86a71a 100644 --- a/packages/compass-electron-menu/src/application-menu.spec.tsx +++ b/packages/compass-electron-menu/src/application-menu.spec.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, cleanup } from '@mongodb-js/testing-library-compass'; +import { render } from '@mongodb-js/testing-library-compass'; import { expect } from 'chai'; import sinon from 'sinon'; import { @@ -9,11 +9,6 @@ import { import type { CompassAppMenu } from './types'; describe('application-menu / useApplicationMenu', function () { - afterEach(() => { - cleanup(); - sinon.restore(); - }); - function createMockProvider() { const showUnsubscribes: sinon.SinonSpy[] = []; const roleUnsubscribes: sinon.SinonSpy[] = []; diff --git a/packages/compass/package.json b/packages/compass/package.json index 51a419fa687..6b77dcd32e6 100644 --- a/packages/compass/package.json +++ b/packages/compass/package.json @@ -181,7 +181,6 @@ "email": "compass@mongodb.com" }, "dependencies": { - "@mongodb-js/compass-electron-menu": "^0.1.0", "@mongodb-js/device-id": "^0.2.0", "@mongosh/node-runtime-worker-thread": "^3.3.26", "clipboard": "^2.0.6", @@ -207,6 +206,7 @@ "@mongodb-js/compass-assistant": "^1.14.0", "@mongodb-js/compass-data-modeling": "^1.34.0", "@mongodb-js/compass-databases-collections": "^1.82.0", + "@mongodb-js/compass-electron-menu": "^0.1.0", "@mongodb-js/compass-explain-plan": "^6.83.0", "@mongodb-js/compass-export-to-language": "^9.59.0", "@mongodb-js/compass-field-store": "^9.58.0", From 299d0265ddc1adb82de133060cd3df4e06f4d712 Mon Sep 17 00:00:00 2001 From: Anna Henningsen Date: Fri, 24 Oct 2025 13:15:41 +0200 Subject: [PATCH 7/7] fixup: cr --- packages/compass/src/app/application.tsx | 46 ++++++++++--------- .../compass/src/app/components/home.spec.tsx | 4 -- packages/compass/src/app/components/home.tsx | 8 +--- 3 files changed, 26 insertions(+), 32 deletions(-) diff --git a/packages/compass/src/app/application.tsx b/packages/compass/src/app/application.tsx index e80021cbece..f6f75efdad2 100644 --- a/packages/compass/src/app/application.tsx +++ b/packages/compass/src/app/application.tsx @@ -38,7 +38,10 @@ const track = createIpcTrack(); import './index.less'; import 'source-code-pro/source-code-pro.css'; -import type { ApplicationMenuProvider } from '@mongodb-js/compass-electron-menu'; +import { + ApplicationMenuContextProvider, + type ApplicationMenuProvider, +} from '@mongodb-js/compass-electron-menu'; import { ApplicationMenu } from '@mongodb-js/compass-electron-menu/ipc-provider-renderer'; const DEFAULT_APP_VERSION = '0.0.0'; @@ -177,26 +180,27 @@ class Application { ReactDOM.render( - { - return connectionStorage.getAutoConnectInfo( - initialAutoConnectPreferences - ); - } - : undefined - } - /> + + { + return connectionStorage.getAutoConnectInfo( + initialAutoConnectPreferences + ); + } + : undefined + } + /> + , elem.querySelector('[data-hook="layout-container"]') ); diff --git a/packages/compass/src/app/components/home.spec.tsx b/packages/compass/src/app/components/home.spec.tsx index a84e82bb2e6..683afe968a8 100644 --- a/packages/compass/src/app/components/home.spec.tsx +++ b/packages/compass/src/app/components/home.spec.tsx @@ -49,10 +49,6 @@ const HOME_PROPS = { getAutoConnectInfo: () => Promise.resolve(undefined), showWelcomeModal: false, connectionStorage: new InMemoryConnectionStorage(), - applicationMenuProvider: { - showApplicationMenu: () => () => {}, - handleMenuRole: () => () => {}, - }, } as const; describe('Home [Component]', function () { diff --git a/packages/compass/src/app/components/home.tsx b/packages/compass/src/app/components/home.tsx index 6eb16bc54a1..db15bedbbb5 100644 --- a/packages/compass/src/app/components/home.tsx +++ b/packages/compass/src/app/components/home.tsx @@ -35,8 +35,6 @@ import { useTelemetry } from '@mongodb-js/compass-telemetry/provider'; import { usePreference } from 'compass-preferences-model/provider'; import { CompassAssistantProvider } from '@mongodb-js/compass-assistant'; import { APP_NAMES_FOR_PROMPT } from '@mongodb-js/compass-assistant'; -import type { ApplicationMenuProvider } from '@mongodb-js/compass-electron-menu'; -import { ApplicationMenuContextProvider } from '@mongodb-js/compass-electron-menu'; resetGlobalCSS(); @@ -124,14 +122,12 @@ type HomeWithConnectionsProps = HomeProps & > & { connectionStorage: ConnectionStorage; createFileInputBackend: () => FileInputBackend; - applicationMenuProvider: ApplicationMenuProvider; }; function HomeWithConnections({ onAutoconnectInfoRequest, connectionStorage, createFileInputBackend, - applicationMenuProvider, ...props }: HomeWithConnectionsProps) { return ( @@ -154,9 +150,7 @@ function HomeWithConnections({ }); }} > - - - +