diff --git a/packages/bruno-app/src/components/AppTitleBar/AppMenu/StyledWrapper.js b/packages/bruno-app/src/components/AppTitleBar/AppMenu/StyledWrapper.js
new file mode 100644
index 000000000..8b86125d3
--- /dev/null
+++ b/packages/bruno-app/src/components/AppTitleBar/AppMenu/StyledWrapper.js
@@ -0,0 +1,15 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ display: flex;
+ align-items: center;
+ height: 100%;
+ -webkit-app-region: no-drag;
+
+ .shortcut {
+ font-size: 11px;
+ color: ${(props) => props.theme.dropdown.mutedText};
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/AppTitleBar/AppMenu/index.js b/packages/bruno-app/src/components/AppTitleBar/AppMenu/index.js
new file mode 100644
index 000000000..b499b7b68
--- /dev/null
+++ b/packages/bruno-app/src/components/AppTitleBar/AppMenu/index.js
@@ -0,0 +1,154 @@
+import React, { useState } from 'react';
+import { IconMenu2 } from '@tabler/icons';
+import MenuDropdown from 'ui/MenuDropdown';
+import ActionIcon from 'ui/ActionIcon';
+import StyledWrapper from './StyledWrapper';
+
+const AppMenu = () => {
+ const [isOpen, setIsOpen] = useState(false);
+ const { ipcRenderer } = window;
+
+ const menuItems = [
+ {
+ id: 'file',
+ label: 'File',
+ submenu: [
+ {
+ id: 'open-collection',
+ label: 'Open Collection',
+ onClick: () => ipcRenderer?.invoke('renderer:open-collection')
+ },
+ { type: 'divider', id: 'file-div-1' },
+ {
+ id: 'preferences',
+ label: 'Preferences',
+ rightSection: Ctrl+,,
+ onClick: () => ipcRenderer?.invoke('renderer:open-preferences')
+ },
+ { type: 'divider', id: 'file-div-2' },
+ {
+ id: 'quit',
+ label: 'Quit',
+ rightSection: Alt+F4,
+ onClick: () => ipcRenderer?.send('renderer:window-close')
+ }
+ ]
+ },
+ {
+ id: 'edit',
+ label: 'Edit',
+ submenu: [
+ {
+ id: 'undo',
+ label: 'Undo',
+ rightSection: Ctrl+Z,
+ onClick: () => document.execCommand('undo')
+ },
+ {
+ id: 'redo',
+ label: 'Redo',
+ rightSection: Ctrl+Y,
+ onClick: () => document.execCommand('redo')
+ },
+ { type: 'divider', id: 'edit-div-1' },
+ {
+ id: 'cut',
+ label: 'Cut',
+ rightSection: Ctrl+X,
+ onClick: () => document.execCommand('cut')
+ },
+ {
+ id: 'copy',
+ label: 'Copy',
+ rightSection: Ctrl+C,
+ onClick: () => document.execCommand('copy')
+ },
+ {
+ id: 'paste',
+ label: 'Paste',
+ rightSection: Ctrl+V,
+ onClick: () => document.execCommand('paste')
+ },
+ { type: 'divider', id: 'edit-div-2' },
+ {
+ id: 'select-all',
+ label: 'Select All',
+ rightSection: Ctrl+A,
+ onClick: () => document.execCommand('selectAll')
+ }
+ ]
+ },
+ {
+ id: 'view',
+ label: 'View',
+ submenu: [
+ {
+ id: 'toggle-devtools',
+ label: 'Developer Tools',
+ rightSection: Ctrl+Shift+I,
+ onClick: () => ipcRenderer?.invoke('renderer:toggle-devtools')
+ },
+ { type: 'divider', id: 'view-div-1' },
+ {
+ id: 'reset-zoom',
+ label: 'Reset Zoom',
+ rightSection: Ctrl+0,
+ onClick: () => ipcRenderer?.invoke('renderer:reset-zoom')
+ },
+ {
+ id: 'zoom-in',
+ label: 'Zoom In',
+ rightSection: Ctrl++,
+ onClick: () => ipcRenderer?.invoke('renderer:zoom-in')
+ },
+ {
+ id: 'zoom-out',
+ label: 'Zoom Out',
+ rightSection: Ctrl+-,
+ onClick: () => ipcRenderer?.invoke('renderer:zoom-out')
+ },
+ { type: 'divider', id: 'view-div-2' },
+ {
+ id: 'toggle-fullscreen',
+ label: 'Full Screen',
+ rightSection: F11,
+ onClick: () => ipcRenderer?.invoke('renderer:toggle-fullscreen')
+ }
+ ]
+ },
+ {
+ id: 'help',
+ label: 'Help',
+ submenu: [
+ {
+ id: 'about',
+ label: 'About Bruno',
+ onClick: () => ipcRenderer?.invoke('renderer:open-about')
+ },
+ {
+ id: 'documentation',
+ label: 'Documentation',
+ onClick: () => ipcRenderer?.invoke('renderer:open-docs')
+ }
+ ]
+ }
+ ];
+
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+export default AppMenu;
diff --git a/packages/bruno-app/src/components/AppTitleBar/StyledWrapper.js b/packages/bruno-app/src/components/AppTitleBar/StyledWrapper.js
index 028a116e3..e397f6f99 100644
--- a/packages/bruno-app/src/components/AppTitleBar/StyledWrapper.js
+++ b/packages/bruno-app/src/components/AppTitleBar/StyledWrapper.js
@@ -210,6 +210,10 @@ const Wrapper = styled.div`
margin-left: 6px;
}
+ .app-menu {
+ margin-left: 8px;
+ }
+
/* Custom window control buttons for Windows - always interactive, above modal overlay */
.window-controls {
display: flex;
diff --git a/packages/bruno-app/src/components/AppTitleBar/index.js b/packages/bruno-app/src/components/AppTitleBar/index.js
index 0e40c9ea9..d9e7134ac 100644
--- a/packages/bruno-app/src/components/AppTitleBar/index.js
+++ b/packages/bruno-app/src/components/AppTitleBar/index.js
@@ -17,6 +17,7 @@ import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace';
import ImportWorkspace from 'components/WorkspaceSidebar/ImportWorkspace';
import IconBottombarToggle from 'components/Icons/IconBottombarToggle/index';
+import AppMenu from './AppMenu';
import StyledWrapper from './StyledWrapper';
import ResponseLayoutToggle from 'components/ResponsePane/ResponseLayoutToggle';
import { isMacOS, isWindowsOS, isLinuxOS } from 'utils/common/platform';
@@ -247,8 +248,9 @@ const AppTitleBar = () => {
)}
- {/* Left section: Home + Workspace */}
+ {showWindowControls &&
}
+
diff --git a/packages/bruno-app/src/components/Dropdown/StyledWrapper.js b/packages/bruno-app/src/components/Dropdown/StyledWrapper.js
index b41ca2b7c..723d5a750 100644
--- a/packages/bruno-app/src/components/Dropdown/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Dropdown/StyledWrapper.js
@@ -173,6 +173,18 @@ const Wrapper = styled.div`
background-color: ${(props) => props.theme.dropdown.separator};
margin: 0.25rem 0;
}
+
+ .submenu-trigger {
+ position: relative;
+ }
+
+ .submenu-arrow {
+ color: ${(props) => props.theme.dropdown.mutedText};
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ margin-left: auto;
+ }
`;
export default Wrapper;
diff --git a/packages/bruno-app/src/components/Dropdown/index.js b/packages/bruno-app/src/components/Dropdown/index.js
index 5f1771a23..e9f96c417 100644
--- a/packages/bruno-app/src/components/Dropdown/index.js
+++ b/packages/bruno-app/src/components/Dropdown/index.js
@@ -2,7 +2,7 @@ import React from 'react';
import Tippy from '@tippyjs/react';
import StyledWrapper from './StyledWrapper';
-const Dropdown = ({ icon, children, onCreate, placement, transparent, visible, appendTo, ...props }) => {
+const Dropdown = ({ icon, children, onCreate, placement, transparent, visible, appendTo, onMouseEnter, onMouseLeave, ...props }) => {
// When in controlled mode (visible prop is provided), don't use trigger prop
const tippyProps = visible !== undefined
? { ...props, visible, interactive: true, appendTo: appendTo || 'parent' }
@@ -11,7 +11,14 @@ const Dropdown = ({ icon, children, onCreate, placement, transparent, visible, a
return (
(
-
+
{children}
)}
diff --git a/packages/bruno-app/src/ui/MenuDropdown/SubMenuItem/index.js b/packages/bruno-app/src/ui/MenuDropdown/SubMenuItem/index.js
new file mode 100644
index 000000000..f2b5bf476
--- /dev/null
+++ b/packages/bruno-app/src/ui/MenuDropdown/SubMenuItem/index.js
@@ -0,0 +1,65 @@
+import React, { useState } from 'react';
+import { IconChevronRight, IconChevronLeft } from '@tabler/icons';
+
+const SubMenuItem = ({
+ item,
+ onRootClose,
+ submenuPlacement,
+ getMenuItemProps,
+ renderMenuItemContent,
+ MenuDropdownComponent
+}) => {
+ const [submenuOpen, setSubmenuOpen] = useState(false);
+ const isLeftPlacement = submenuPlacement === 'left';
+ const submenuTippyPlacement = isLeftPlacement ? 'left-start' : 'right-start';
+ const ArrowIcon = isLeftPlacement ? IconChevronLeft : IconChevronRight;
+
+ const submenuItemsWithClose = item.submenu.map((subItem) => {
+ if (subItem.type === 'divider') return subItem;
+ return {
+ ...subItem,
+ onClick: () => {
+ subItem.onClick?.();
+ onRootClose();
+ }
+ };
+ });
+
+ const itemProps = getMenuItemProps(item, {
+ 'className': 'has-submenu',
+ 'aria-haspopup': 'true',
+ 'aria-expanded': submenuOpen,
+ 'aria-current': undefined // submenu triggers don't need aria-current
+ });
+
+ const arrowElement = (
+
+
+
+ );
+
+ return (
+ setSubmenuOpen(true)}
+ onMouseLeave={() => setSubmenuOpen(false)}
+ >
+
document.body}
+ offset={[0, 0]}
+ >
+
+ {renderMenuItemContent(item, arrowElement)}
+
+
+
+ );
+};
+
+export default SubMenuItem;
diff --git a/packages/bruno-app/src/ui/MenuDropdown/index.js b/packages/bruno-app/src/ui/MenuDropdown/index.js
index d96bec5e2..92865d61b 100644
--- a/packages/bruno-app/src/ui/MenuDropdown/index.js
+++ b/packages/bruno-app/src/ui/MenuDropdown/index.js
@@ -1,5 +1,6 @@
import React, { forwardRef, useRef, useCallback, useState, useImperativeHandle, useEffect, useMemo } from 'react';
import Dropdown from 'components/Dropdown';
+import SubMenuItem from './SubMenuItem';
// Constants
const NAVIGATION_KEYS = ['ArrowDown', 'ArrowUp', 'Home', 'End', 'Escape'];
@@ -31,6 +32,7 @@ const getNextIndex = (currentIndex, total, key, noFocus) => {
* - testId: string (optional, for testing, for items only)
* - disabled: boolean (optional, for items only)
* - className: string (optional, additional CSS classes for the item)
+ * - submenu: Array (optional, array of menu items for nested submenu, opens on hover)
*
* Grouped format: [{name: string, options: [{id, label, ...}]}, ...]
* Flat format: [{id, label, ...}, ...]
@@ -46,6 +48,7 @@ const getNextIndex = (currentIndex, total, key, noFocus) => {
* @param {boolean} props.showGroupDividers - Optional flag to show dividers between groups in grouped format (default: true)
* @param {string} props.groupStyle - Style for grouped items: 'action' (default, normal case) or 'select' (uppercase labels, indented items)
* @param {boolean} props.autoFocusFirstOption - Optional flag to auto-focus first option when dropdown opens (default: false)
+ * @param {string} props.submenuPlacement - Placement of submenus: 'right' (default) or 'left'. Controls both position and arrow direction.
* @param {Object} props.dropdownProps - Other props passed to underlying Dropdown component
* @param {React.Ref} ref - Optional ref to expose open/close methods
*/
@@ -63,6 +66,7 @@ const MenuDropdown = forwardRef(({
showGroupDividers = true,
groupStyle = 'action',
autoFocusFirstOption = false,
+ submenuPlacement = 'right',
'data-testid': testId = 'menu-dropdown',
...dropdownProps
}, ref) => {
@@ -264,7 +268,11 @@ const MenuDropdown = forwardRef(({
}, [isOpen, updateOpenState]);
// Close dropdown when clicking outside
- const handleClickOutside = useCallback(() => {
+ const handleClickOutside = useCallback((instance, event) => {
+ // Don't close if clicking inside a submenu (another tippy popper)
+ if (event?.target?.closest?.('[data-tippy-root]')) {
+ return;
+ }
updateOpenState(false);
}, [updateOpenState]);
@@ -346,39 +354,75 @@ const MenuDropdown = forwardRef(({
return section;
};
- // Render menu item
- const renderMenuItem = (item) => {
+ // Get common props for menu items (shared between regular items and submenu triggers)
+ const getMenuItemProps = (item, extraProps = {}) => {
const selectIndentClass = item.groupStyle === 'select' ? 'dropdown-item-select' : '';
const isActive = item.id === selectedItemId;
const activeClass = isActive ? 'dropdown-item-active' : '';
+ // Destructure className from extraProps to avoid it being overwritten by spread
+ const { className: extraClassName, ...restExtraProps } = extraProps;
+
+ return {
+ 'className': `dropdown-item ${item.disabled ? 'disabled' : ''} ${selectIndentClass} ${activeClass} ${extraClassName || ''} ${item.className || ''}`.trim(),
+ 'role': 'menuitem',
+ 'data-item-id': item.id,
+ 'tabIndex': item.disabled ? -1 : 0,
+ 'aria-label': item.ariaLabel,
+ 'aria-disabled': item.disabled,
+ 'aria-current': isActive ? 'true' : undefined,
+ 'title': item.title,
+ 'data-testid': `${testId}-${String(item.id).toLowerCase()}`,
+ ...restExtraProps
+ };
+ };
+
+ // Render the content inside a menu item (leftSection, label, and rightSection/arrow)
+ const renderMenuItemContent = (item, rightContent = null) => (
+ <>
+ {renderSection(item.leftSection)}
+ {item.label}
+ {rightContent}
+ >
+ );
+
+ // Render menu item
+ const renderMenuItem = (item) => {
+ if (item.submenu) {
+ return (
+ updateOpenState(false)}
+ submenuPlacement={submenuPlacement}
+ getMenuItemProps={getMenuItemProps}
+ renderMenuItemContent={renderMenuItemContent}
+ MenuDropdownComponent={MenuDropdown}
+ />
+ );
+ }
+
+ const itemProps = getMenuItemProps(item);
+
+ const rightContent = item.rightSection ? (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ >
+ {renderSection(item.rightSection)}
+
+ ) : null;
+
return (
!item.disabled && handleItemClick(item)}
- tabIndex={item.disabled ? -1 : 0}
- aria-label={item.ariaLabel}
- aria-disabled={item.disabled}
- aria-current={isActive ? 'true' : undefined}
- title={item.title}
- data-testid={`${testId}-${String(item.id).toLowerCase()}`}
>
- {renderSection(item.leftSection)}
-
{item.label}
- {item.rightSection && (
-
{
- e.preventDefault();
- e.stopPropagation();
- }}
- >
- {renderSection(item.rightSection)}
-
- )}
+ {renderMenuItemContent(item, rightContent)}
);
};
diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js
index 6e5f05e3e..6cc1979b8 100644
--- a/packages/bruno-electron/src/index.js
+++ b/packages/bruno-electron/src/index.js
@@ -232,6 +232,50 @@ app.on('ready', async () => {
return mainWindow.isMaximized();
});
+ ipcMain.handle('renderer:open-preferences', () => {
+ ipcMain.emit('main:open-preferences');
+ });
+
+ ipcMain.handle('renderer:toggle-devtools', () => {
+ mainWindow.webContents.toggleDevTools();
+ });
+
+ ipcMain.handle('renderer:reset-zoom', () => {
+ mainWindow.webContents.setZoomLevel(0);
+ });
+
+ ipcMain.handle('renderer:zoom-in', () => {
+ const currentZoom = mainWindow.webContents.getZoomLevel();
+ mainWindow.webContents.setZoomLevel(currentZoom + 0.5);
+ });
+
+ ipcMain.handle('renderer:zoom-out', () => {
+ const currentZoom = mainWindow.webContents.getZoomLevel();
+ mainWindow.webContents.setZoomLevel(currentZoom - 0.5);
+ });
+
+ ipcMain.handle('renderer:toggle-fullscreen', () => {
+ mainWindow.setFullScreen(!mainWindow.isFullScreen());
+ });
+
+ ipcMain.handle('renderer:open-docs', () => {
+ ipcMain.emit('main:open-docs');
+ });
+
+ ipcMain.handle('renderer:open-about', () => {
+ const { version } = require('../package.json');
+ const aboutBruno = require('./app/about-bruno');
+ const aboutWindow = new BrowserWindow({
+ width: 350,
+ height: 250,
+ webPreferences: {
+ nodeIntegration: true
+ }
+ });
+ aboutWindow.removeMenu();
+ aboutWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(aboutBruno({ version }))}`);
+ });
+
mainWindow.once('ready-to-show', () => {
mainWindow.show();
});