Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 1 addition & 51 deletions packages/bruno-app/src/components/AppTitleBar/StyledWrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ const Wrapper = styled.div`
.workspace-name-container,
.dropdown-item,
.home-button,
.env-selector-trigger,
.dropdown,
button {
-webkit-app-region: no-drag;
Expand All @@ -49,25 +48,6 @@ const Wrapper = styled.div`
margin-left: 0px;
}

/* Home button */
.home-button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
color: ${(props) => props.theme.sidebar.color};
transition: background 0.15s ease;

&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
}

/* Workspace Name Dropdown Trigger */
.workspace-name-container {
display: flex;
Expand Down Expand Up @@ -112,7 +92,7 @@ const Wrapper = styled.div`
.bruno-text {
font-size: 13px;
font-weight: 600;
color: ${(props) => props.theme.sidebar.muted};
color: ${(props) => props.theme.text};
letter-spacing: 0.5px;
}
}
Expand All @@ -125,36 +105,6 @@ const Wrapper = styled.div`
flex-shrink: 0;
}

/* Action buttons in right section */
.titlebar-action-button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
color: ${(props) => props.theme.sidebar.color};
transition: background 0.15s ease;

&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}

svg {
color: ${(props) => props.theme.sidebar.color};
}
}

/* Draggable region */
.drag-region {
flex: 1;
height: 100%;
-webkit-app-region: drag;
}

/* Workspace Dropdown Styles */
.workspace-item {
display: flex;
Expand Down
158 changes: 83 additions & 75 deletions packages/bruno-app/src/components/AppTitleBar/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import { IconCheck, IconChevronDown, IconFolder, IconHome, IconLayoutColumns, IconLayoutRows, IconPin, IconPinned, IconPlus } from '@tabler/icons';
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import { useDispatch, useSelector } from 'react-redux';

Expand All @@ -9,7 +10,8 @@ import { openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slice
import { sortWorkspaces, toggleWorkspacePin } from 'utils/workspaces';

import Bruno from 'components/Bruno';
import Dropdown from 'components/Dropdown';
import MenuDropdown from 'ui/MenuDropdown';
import ActionIcon from 'ui/ActionIcon';
import IconSidebarToggle from 'components/Icons/IconSidebarToggle';
import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace';

Expand Down Expand Up @@ -53,13 +55,10 @@ const AppTitleBar = () => {
}, [workspaces, preferences]);

const [createWorkspaceModalOpen, setCreateWorkspaceModalOpen] = useState(false);
const [showWorkspaceDropdown, setShowWorkspaceDropdown] = useState(false);
const workspaceDropdownTippyRef = useRef();
const onWorkspaceDropdownCreate = (ref) => (workspaceDropdownTippyRef.current = ref);

const WorkspaceName = forwardRef((props, ref) => {
return (
<div ref={ref} className="workspace-name-container" onClick={() => setShowWorkspaceDropdown(!showWorkspaceDropdown)}>
<div ref={ref} className="workspace-name-container" {...props}>
<span className="workspace-name">{toTitleCase(activeWorkspace?.name) || 'Default Workspace'}</span>
<IconChevronDown size={14} stroke={1.5} className="chevron-icon" />
</div>
Expand All @@ -72,12 +71,10 @@ const AppTitleBar = () => {

const handleWorkspaceSwitch = (workspaceUid) => {
dispatch(switchWorkspace(workspaceUid));
setShowWorkspaceDropdown(false);
toast.success(`Switched to ${workspaces.find((w) => w.uid === workspaceUid)?.name}`);
};

const handleOpenWorkspace = async () => {
setShowWorkspaceDropdown(false);
try {
await dispatch(openWorkspaceDialog());
toast.success('Workspace opened successfully');
Expand All @@ -87,7 +84,6 @@ const AppTitleBar = () => {
};

const handleCreateWorkspace = () => {
setShowWorkspaceDropdown(false);
setCreateWorkspaceModalOpen(true);
};

Expand Down Expand Up @@ -124,6 +120,59 @@ const AppTitleBar = () => {
dispatch(savePreferences(updatedPreferences));
};

// Build workspace menu items
const workspaceMenuItems = useMemo(() => {
const items = sortedWorkspaces.map((workspace) => {
const isActive = workspace.uid === activeWorkspaceUid;
const isPinned = preferences?.workspaces?.pinnedWorkspaceUids?.includes(workspace.uid);

return {
id: workspace.uid,
label: toTitleCase(workspace.name),
onClick: () => handleWorkspaceSwitch(workspace.uid),
className: `workspace-item ${isActive ? 'active' : ''}`,
rightSection: (
<div className="workspace-actions">
{workspace.type !== 'default' && (
<ActionIcon
className={`pin-btn ${isPinned ? 'pinned' : ''}`}
onClick={(e) => handlePinWorkspace(workspace.uid, e)}
label={isPinned ? 'Unpin workspace' : 'Pin workspace'}
size="sm"
>
{isPinned ? (
<IconPinned size={14} stroke={1.5} />
) : (
<IconPin size={14} stroke={1.5} />
)}
</ActionIcon>
)}
{isActive && <IconCheck size={16} stroke={1.5} className="check-icon" />}
</div>
)
};
});

// Add label and action items
items.push(
{ type: 'label', label: 'Workspaces' },
{
id: 'create-workspace',
leftSection: IconPlus,
label: 'Create workspace',
onClick: handleCreateWorkspace
},
{
id: 'open-workspace',
leftSection: IconFolder,
label: 'Open workspace',
onClick: handleOpenWorkspace
}
);

return items;
}, [sortedWorkspaces, activeWorkspaceUid, preferences, handlePinWorkspace]);
Comment on lines +123 to +174
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing dependencies in useMemo.

The workspaceMenuItems memo references handleWorkspaceSwitch, handleCreateWorkspace, and handleOpenWorkspace in onClick handlers, but these aren't in the dependency array. Since these functions aren't wrapped in useCallback, they're recreated each render, yet the memoized items won't update with them.

Either add them to dependencies or wrap those handlers in useCallback:

-  const workspaceMenuItems = useMemo(() => {
+  const handleWorkspaceSwitchCb = useCallback((workspaceUid) => {
+    dispatch(switchWorkspace(workspaceUid));
+    toast.success(`Switched to ${workspaces.find((w) => w.uid === workspaceUid)?.name}`);
+  }, [dispatch, workspaces]);

+  const handleCreateWorkspaceCb = useCallback(() => {
+    setCreateWorkspaceModalOpen(true);
+  }, []);

+  const handleOpenWorkspaceCb = useCallback(async () => {
+    try {
+      await dispatch(openWorkspaceDialog());
+      toast.success('Workspace opened successfully');
+    } catch (error) {
+      toast.error(error.message || 'Failed to open workspace');
+    }
+  }, [dispatch]);

   const workspaceMenuItems = useMemo(() => {
     // ... use the callback versions
-  }, [sortedWorkspaces, activeWorkspaceUid, preferences, handlePinWorkspace]);
+  }, [sortedWorkspaces, activeWorkspaceUid, preferences, handlePinWorkspace, handleWorkspaceSwitchCb, handleCreateWorkspaceCb, handleOpenWorkspaceCb]);

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In packages/bruno-app/src/components/AppTitleBar/index.js around lines 123 to
174, the useMemo that builds workspaceMenuItems references
handleWorkspaceSwitch, handleCreateWorkspace, and handleOpenWorkspace inside
onClick handlers but does not include them in the dependency array; either add
these functions to the useMemo dependency array or wrap each of them in
useCallback so their identities are stable and then include them in the
dependency list (update the final dependency array to [sortedWorkspaces,
activeWorkspaceUid, preferences, handlePinWorkspace, handleWorkspaceSwitch,
handleCreateWorkspace, handleOpenWorkspace] if you choose to add them).


return (
<StyledWrapper className={`app-titlebar ${isFullScreen ? 'fullscreen' : ''}`}>
{createWorkspaceModalOpen && (
Expand All @@ -133,61 +182,24 @@ const AppTitleBar = () => {
<div className="titlebar-content">
{/* Left section: Home + Workspace */}
<div className="titlebar-left">
<button className="home-button" onClick={handleHomeClick} title="Home">
<ActionIcon
onClick={handleHomeClick}
label="Home"
size="lg"
className="home-button"
>
<IconHome size={16} stroke={1.5} />
</button>
</ActionIcon>

{/* Workspace Dropdown */}
<Dropdown
onCreate={onWorkspaceDropdownCreate}
icon={<WorkspaceName />}
<MenuDropdown
data-testid="workspace-menu"
items={workspaceMenuItems}
placement="bottom-start"
style="new"
visible={showWorkspaceDropdown}
onClickOutside={() => setShowWorkspaceDropdown(false)}
selectedItemId={activeWorkspaceUid}
>
{sortedWorkspaces.map((workspace) => {
const isActive = workspace.uid === activeWorkspaceUid;
const isPinned = preferences?.workspaces?.pinnedWorkspaceUids?.includes(workspace.uid);

return (
<div
key={workspace.uid}
className={`dropdown-item workspace-item ${isActive ? 'active' : ''}`}
onClick={() => handleWorkspaceSwitch(workspace.uid)}
>
<span className="workspace-name">{toTitleCase(workspace.name)}</span>
<div className="workspace-actions">
{workspace.type !== 'default' && (
<button
className={`pin-btn ${isPinned ? 'pinned' : ''}`}
onClick={(e) => handlePinWorkspace(workspace.uid, e)}
title={isPinned ? 'Unpin workspace' : 'Pin workspace'}
>
{isPinned ? (
<IconPinned size={14} stroke={1.5} />
) : (
<IconPin size={14} stroke={1.5} />
)}
</button>
)}
{isActive && <IconCheck size={16} stroke={1.5} className="check-icon" />}
</div>
</div>
);
})}

<div className="label-item border-top">Workspaces</div>

<div className="dropdown-item" onClick={handleCreateWorkspace}>
<IconPlus size={16} stroke={1.5} className="icon" />
Create workspace
</div>
<div className="dropdown-item" onClick={handleOpenWorkspace}>
<IconFolder size={16} stroke={1.5} className="icon" />
Open workspace
</div>
</Dropdown>
<WorkspaceName />
</MenuDropdown>
</div>

{/* Center section: Bruno logo + text */}
Expand All @@ -199,42 +211,38 @@ const AppTitleBar = () => {
{/* Right section: Action buttons */}
<div className="titlebar-right">
{/* Toggle sidebar */}
<button
className="titlebar-action-button"
<ActionIcon
onClick={handleToggleSidebar}
title={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}
aria-label="Toggle Sidebar"
label={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}
size="lg"
data-testid="toggle-sidebar-button"
>
<IconSidebarToggle collapsed={sidebarCollapsed} size={16} strokeWidth={1.5} />
</button>
</ActionIcon>

{/* Toggle devtools */}
<button
className="titlebar-action-button"
<ActionIcon
onClick={handleToggleDevtools}
title={isConsoleOpen ? 'Hide devtools' : 'Show devtools'}
aria-label="Toggle Devtools"
label={isConsoleOpen ? 'Hide devtools' : 'Show devtools'}
size="lg"
data-testid="toggle-devtools-button"
>
<IconBottombarToggle collapsed={!isConsoleOpen} size={16} strokeWidth={1.5} />
</button>
</ActionIcon>

{/* Toggle vertical layout */}
<button
className="titlebar-action-button"
<ActionIcon
onClick={handleToggleVerticalLayout}
title={orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout'}
aria-label="Toggle Vertical Layout"
label={orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout'}
size="lg"
data-testid="toggle-vertical-layout-button"
>
{orientation === 'horizontal' ? (
<IconLayoutColumns size={16} stroke={1.5} />
) : (
<IconLayoutRows size={16} stroke={1.5} />
)}
</button>

</ActionIcon>
</div>
</div>
</StyledWrapper>
Expand Down
35 changes: 35 additions & 0 deletions packages/bruno-app/src/components/Dropdown/StyledWrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ const Wrapper = styled.div`
padding-top: 0;
padding-bottom: 0;

[role="menu"] {
outline: none;
&:focus {
outline: none;
}
&:focus-visible {
outline: none;
}
}
Comment on lines +28 to +36
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Disabled styling doesn’t apply to aria-disabled menu items

MenuDropdown renders items as <div role="menuitem" aria-disabled="true">, so the &:disabled selector here never matches and disabled items won’t get the not-allowed cursor / reduced opacity. Also, outlines are fully suppressed on the menu container and focused items, so the hover background becomes the only focus affordance.

Consider extending selectors so both semantics and visuals line up, for example:

-      .dropdown-item {
+      .dropdown-item {
         ...
-        &:hover:not(:disabled) {
+        &:hover:not(:disabled):not([aria-disabled="true"]) {
           background-color: ${(props) => props.theme.dropdown.hoverBg};
         }
         ...
-        &:disabled {
-          cursor: not-allowed;
-          opacity: 0.5;
-        }
+        &:disabled,
+        &[aria-disabled="true"],
+        &.disabled {
+          cursor: not-allowed;
+          opacity: 0.5;
+        }
       }

This keeps keyboard focus visible via background while correctly styling disabled items coming from MenuDropdown.

Also applies to: 95-115

🤖 Prompt for AI Agents
In packages/bruno-app/src/components/Dropdown/StyledWrapper.js around lines
28-36 (and similarly update lines 95-115), the stylesheet uses &:disabled which
won't match elements using aria-disabled, and outlines are completely suppressed
causing no visible keyboard focus; update the selectors to target both the
disabled pseudo-class and the aria attribute (e.g., &[aria-disabled="true"],
&:disabled) and apply the disabled styles (cursor: not-allowed, reduced opacity)
to those selectors, and adjust focus rules to keep a visible focus affordance
(remove blanket outline: none on the container and use :focus-visible to show a
subtle background or box-shadow while still preventing outline suppression from
hiding keyboard focus).


.label-item {
display: flex;
align-items: center;
Expand Down Expand Up @@ -59,6 +69,10 @@ const Wrapper = styled.div`
}
}

.dropdown-label {
flex: 1;
}

.dropdown-icon {
flex-shrink: 0;
width: 16px;
Expand All @@ -70,10 +84,31 @@ const Wrapper = styled.div`
opacity: 0.8;
}

.dropdown-right-section {
margin-left: auto;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}

&:hover:not(:disabled) {
background-color: ${(props) => props.theme.dropdown.hoverBg};
}

&.selected-focused:not(:disabled) {
background-color: ${(props) => props.theme.dropdown.hoverBg};
}

&:focus-visible:not(:disabled) {
outline: none;
background-color: ${(props) => props.theme.dropdown.hoverBg};
}

&:focus:not(:focus-visible) {
outline: none;
}

&:disabled {
cursor: not-allowed;
opacity: 0.5;
Expand Down
Loading
Loading