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(); });