mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-15 11:51:30 +00:00
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:
@@ -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;
|
||||
154
packages/bruno-app/src/components/AppTitleBar/AppMenu/index.js
Normal file
154
packages/bruno-app/src/components/AppTitleBar/AppMenu/index.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
65
packages/bruno-app/src/ui/MenuDropdown/SubMenuItem/index.js
Normal file
65
packages/bruno-app/src/ui/MenuDropdown/SubMenuItem/index.js
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user