feat: add custom AppMenu component for windows & linux (#6934)

* feat: add custom AppMenu component for windows & linux

* fixes

* fixes

* fixes

* fixes
This commit is contained in:
naman-bruno
2026-01-30 22:58:36 +05:30
committed by GitHub
parent 3112380289
commit ba166561cc
9 changed files with 375 additions and 28 deletions

View File

@@ -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;

View File

@@ -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: <span className="shortcut">Ctrl+,</span>,
onClick: () => ipcRenderer?.invoke('renderer:open-preferences')
},
{ type: 'divider', id: 'file-div-2' },
{
id: 'quit',
label: 'Quit',
rightSection: <span className="shortcut">Alt+F4</span>,
onClick: () => ipcRenderer?.send('renderer:window-close')
}
]
},
{
id: 'edit',
label: 'Edit',
submenu: [
{
id: 'undo',
label: 'Undo',
rightSection: <span className="shortcut">Ctrl+Z</span>,
onClick: () => document.execCommand('undo')
},
{
id: 'redo',
label: 'Redo',
rightSection: <span className="shortcut">Ctrl+Y</span>,
onClick: () => document.execCommand('redo')
},
{ type: 'divider', id: 'edit-div-1' },
{
id: 'cut',
label: 'Cut',
rightSection: <span className="shortcut">Ctrl+X</span>,
onClick: () => document.execCommand('cut')
},
{
id: 'copy',
label: 'Copy',
rightSection: <span className="shortcut">Ctrl+C</span>,
onClick: () => document.execCommand('copy')
},
{
id: 'paste',
label: 'Paste',
rightSection: <span className="shortcut">Ctrl+V</span>,
onClick: () => document.execCommand('paste')
},
{ type: 'divider', id: 'edit-div-2' },
{
id: 'select-all',
label: 'Select All',
rightSection: <span className="shortcut">Ctrl+A</span>,
onClick: () => document.execCommand('selectAll')
}
]
},
{
id: 'view',
label: 'View',
submenu: [
{
id: 'toggle-devtools',
label: 'Developer Tools',
rightSection: <span className="shortcut">Ctrl+Shift+I</span>,
onClick: () => ipcRenderer?.invoke('renderer:toggle-devtools')
},
{ type: 'divider', id: 'view-div-1' },
{
id: 'reset-zoom',
label: 'Reset Zoom',
rightSection: <span className="shortcut">Ctrl+0</span>,
onClick: () => ipcRenderer?.invoke('renderer:reset-zoom')
},
{
id: 'zoom-in',
label: 'Zoom In',
rightSection: <span className="shortcut">Ctrl++</span>,
onClick: () => ipcRenderer?.invoke('renderer:zoom-in')
},
{
id: 'zoom-out',
label: 'Zoom Out',
rightSection: <span className="shortcut">Ctrl+-</span>,
onClick: () => ipcRenderer?.invoke('renderer:zoom-out')
},
{ type: 'divider', id: 'view-div-2' },
{
id: 'toggle-fullscreen',
label: 'Full Screen',
rightSection: <span className="shortcut">F11</span>,
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 (
<StyledWrapper>
<MenuDropdown
opened={isOpen}
onChange={setIsOpen}
placement="bottom-start"
showTickMark={false}
items={menuItems}
>
<ActionIcon label="Menu" size="lg">
<IconMenu2 size={16} stroke={1.5} />
</ActionIcon>
</MenuDropdown>
</StyledWrapper>
);
};
export default AppMenu;

View File

@@ -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;

View File

@@ -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 = () => {
)}
<div className="titlebar-content">
{/* Left section: Home + Workspace */}
<div className="titlebar-left">
{showWindowControls && <AppMenu />}
<ActionIcon onClick={handleHomeClick} label="Home" size="lg" className="home-button">
<IconHome size={16} stroke={1.5} />
</ActionIcon>

View File

@@ -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;

View File

@@ -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 (
<Tippy
render={(attrs) => (
<StyledWrapper className="tippy-box dropdown" transparent={transparent} tabIndex={-1} {...attrs}>
<StyledWrapper
className="tippy-box dropdown"
transparent={transparent}
tabIndex={-1}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
{...attrs}
>
{children}
</StyledWrapper>
)}

View File

@@ -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 = (
<span className="submenu-arrow">
<ArrowIcon size={14} />
</span>
);
return (
<div
className="submenu-trigger"
onMouseEnter={() => setSubmenuOpen(true)}
onMouseLeave={() => setSubmenuOpen(false)}
>
<MenuDropdownComponent
items={submenuItemsWithClose}
placement={submenuTippyPlacement}
opened={submenuOpen}
onChange={setSubmenuOpen}
showTickMark={false}
submenuPlacement={submenuPlacement}
appendTo={() => document.body}
offset={[0, 0]}
>
<div {...itemProps}>
{renderMenuItemContent(item, arrowElement)}
</div>
</MenuDropdownComponent>
</div>
);
};
export default SubMenuItem;

View File

@@ -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)}
<span className="dropdown-label">{item.label}</span>
{rightContent}
</>
);
// Render menu item
const renderMenuItem = (item) => {
if (item.submenu) {
return (
<SubMenuItem
key={item.id}
item={item}
onRootClose={() => updateOpenState(false)}
submenuPlacement={submenuPlacement}
getMenuItemProps={getMenuItemProps}
renderMenuItemContent={renderMenuItemContent}
MenuDropdownComponent={MenuDropdown}
/>
);
}
const itemProps = getMenuItemProps(item);
const rightContent = item.rightSection ? (
<div
className="dropdown-right-section"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{renderSection(item.rightSection)}
</div>
) : null;
return (
<div
key={item.id}
className={`dropdown-item ${item.disabled ? 'disabled' : ''} ${selectIndentClass} ${activeClass} ${item.className || ''}`.trim()}
role="menuitem"
data-item-id={item.id}
{...itemProps}
onClick={() => !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)}
<span className="dropdown-label">{item.label}</span>
{item.rightSection && (
<div
className="dropdown-right-section"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{renderSection(item.rightSection)}
</div>
)}
{renderMenuItemContent(item, rightContent)}
</div>
);
};

View File

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