feat: Moved Workspace Selector to the Titlebar of the window. (#6319)

* refactor: update sidebar components and styles, replace TitleBar with SidebarHeader, and enhance collections search functionality

* refactor: improve event listener management in AppTitleBar and clean up SidebarHeader styles

* fix: ensure safe access to layout preferences in AppTitleBar and set default order in SidebarHeader

* refactor: centralize toTitleCase utility and remove redundant implementations in AppTitleBar and WorkspaceSelector

* feat: enhance accessibility and testing for sidebar and devtools toggle buttons in AppTitleBar

* chore: quick fix on a flaky test

---------

Co-authored-by: Bijin A B <bijin@usebruno.com>
This commit is contained in:
Abhishek S Lal
2025-12-06 02:07:05 +05:30
committed by GitHub
parent f5211f6a08
commit 4a8d787f31
30 changed files with 1158 additions and 527 deletions

View File

@@ -0,0 +1,248 @@
import styled from 'styled-components';
const Wrapper = styled.div`
height: 36px;
display: flex;
align-items: center;
background: ${(props) => props.theme.sidebar.bg};
border-bottom: 1px solid ${(props) => props.theme.sidebar.collection.item.hoverBg};
-webkit-app-region: drag;
user-select: none;
.titlebar-content {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 100%;
padding: 0 12px;
padding-left: 70px; /* Space for macOS window controls */
transition: padding-left 0.15s ease;
}
/* When in full screen, no traffic lights so reduce padding */
&.fullscreen .titlebar-content {
padding-left: 4px;
}
/* Remove drag region from interactive elements */
.workspace-name-container,
.dropdown-item,
.home-button,
.env-selector-trigger,
.dropdown,
button {
-webkit-app-region: no-drag;
}
/* Left section */
.titlebar-left {
display: flex;
align-items: center;
flex-shrink: 0;
margin-left: 10px;
-webkit-app-region: no-drag;
}
/* When in full screen, no traffic lights so remove margin-left */
&.fullscreen .titlebar-left {
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;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
.workspace-name {
font-size: 13px;
font-weight: 500;
color: ${(props) => props.theme.sidebar.color};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 180px;
}
.chevron-icon {
flex-shrink: 0;
color: ${(props) => props.theme.sidebar.muted};
transition: transform 0.2s ease;
}
}
/* Center section - Bruno branding */
.titlebar-center {
position: absolute;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 6px;
pointer-events: none;
.bruno-text {
font-size: 13px;
font-weight: 600;
color: ${(props) => props.theme.sidebar.muted};
letter-spacing: 0.5px;
}
}
/* Right section */
.titlebar-right {
display: flex;
align-items: center;
justify-content: flex-end;
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;
align-items: center;
justify-content: space-between;
padding: 4px 10px !important;
margin: 0 !important;
&.active {
.check-icon {
opacity: 1;
}
}
&:hover {
.pin-btn:not(.pinned) {
opacity: 1;
}
}
.workspace-name {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 400;
color: ${(props) => props.theme.dropdown.color};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.workspace-actions {
display: flex;
align-items: center;
gap: 4px;
margin-left: 8px;
flex-shrink: 0;
pointer-events: none;
> * {
pointer-events: auto;
}
}
.check-icon {
color: ${(props) => props.theme.workspace?.accent || props.theme.colors?.text?.yellow};
flex-shrink: 0;
}
.pin-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
color: ${(props) => props.theme.dropdown.mutedText};
transition: background 0.15s ease, color 0.15s ease, opacity 0.15s ease;
opacity: 0;
&.pinned {
opacity: 1;
}
&:hover {
background: ${(props) => props.theme.dropdown.hoverBg};
color: ${(props) => props.theme.dropdown.mutedText};
}
}
}
/* Adjust for non-macOS platforms */
body:not(.os-mac) & {
.titlebar-content {
padding-left: 12px;
}
}
/* Leave room for Windows caption buttons when the overlay is enabled */
body.os-windows & {
.titlebar-content {
padding-right: 120px;
}
}
`;
export default Wrapper;

View File

@@ -0,0 +1,244 @@
import { IconCheck, IconChevronDown, IconFolder, IconHome, IconLayoutColumns, IconLayoutRows, IconPin, IconPinned, IconPlus } from '@tabler/icons';
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import toast from 'react-hot-toast';
import { useDispatch, useSelector } from 'react-redux';
import { savePreferences, showHomePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { closeConsole, openConsole } from 'providers/ReduxStore/slices/logs';
import { openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import { sortWorkspaces, toggleWorkspacePin } from 'utils/workspaces';
import Bruno from 'components/Bruno';
import Dropdown from 'components/Dropdown';
import IconSidebarToggle from 'components/Icons/IconSidebarToggle';
import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace';
import IconBottombarToggle from 'components/Icons/IconBottombarToggle/index';
import StyledWrapper from './StyledWrapper';
import { toTitleCase } from 'utils/common/index';
const AppTitleBar = () => {
const dispatch = useDispatch();
const [isFullScreen, setIsFullScreen] = useState(false);
// Listen for fullscreen changes
useEffect(() => {
const { ipcRenderer } = window;
if (!ipcRenderer) return;
const removeEnterFullScreenListener = ipcRenderer.on('main:enter-full-screen', () => {
setIsFullScreen(true);
});
const removeLeaveFullScreenListener = ipcRenderer.on('main:leave-full-screen', () => {
setIsFullScreen(false);
});
return () => {
removeEnterFullScreenListener();
removeLeaveFullScreenListener();
};
}, []);
// Get workspace info
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const preferences = useSelector((state) => state.app.preferences);
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
// Sort workspaces according to preferences
const sortedWorkspaces = useMemo(() => {
return sortWorkspaces(workspaces, preferences);
}, [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)}>
<span className="workspace-name">{toTitleCase(activeWorkspace?.name) || 'Default Workspace'}</span>
<IconChevronDown size={14} stroke={1.5} className="chevron-icon" />
</div>
);
});
const handleHomeClick = () => {
dispatch(showHomePage());
};
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');
} catch (error) {
toast.error(error.message || 'Failed to open workspace');
}
};
const handleCreateWorkspace = () => {
setShowWorkspaceDropdown(false);
setCreateWorkspaceModalOpen(true);
};
const handlePinWorkspace = useCallback((workspaceUid, e) => {
e.preventDefault();
e.stopPropagation();
const newPreferences = toggleWorkspacePin(workspaceUid, preferences);
dispatch(savePreferences(newPreferences));
}, [dispatch, preferences]);
const orientation = preferences?.layout?.responsePaneOrientation || 'horizontal';
const handleToggleSidebar = () => {
dispatch(toggleSidebarCollapse());
};
const handleToggleDevtools = () => {
if (isConsoleOpen) {
dispatch(closeConsole());
} else {
dispatch(openConsole());
}
};
const handleToggleVerticalLayout = () => {
const newOrientation = orientation === 'horizontal' ? 'vertical' : 'horizontal';
const updatedPreferences = {
...preferences,
layout: {
...preferences?.layout || {},
responsePaneOrientation: newOrientation
}
};
dispatch(savePreferences(updatedPreferences));
};
return (
<StyledWrapper className={`app-titlebar ${isFullScreen ? 'fullscreen' : ''}`}>
{createWorkspaceModalOpen && (
<CreateWorkspace onClose={() => setCreateWorkspaceModalOpen(false)} />
)}
<div className="titlebar-content">
{/* Left section: Home + Workspace */}
<div className="titlebar-left">
<button className="home-button" onClick={handleHomeClick} title="Home">
<IconHome size={16} stroke={1.5} />
</button>
{/* Workspace Dropdown */}
<Dropdown
onCreate={onWorkspaceDropdownCreate}
icon={<WorkspaceName />}
placement="bottom-start"
style="new"
visible={showWorkspaceDropdown}
onClickOutside={() => setShowWorkspaceDropdown(false)}
>
{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>
</div>
{/* Center section: Bruno logo + text */}
<div className="titlebar-center">
<Bruno width={18} />
<span className="bruno-text">Bruno</span>
</div>
{/* Right section: Action buttons */}
<div className="titlebar-right">
{/* Toggle sidebar */}
<button
className="titlebar-action-button"
onClick={handleToggleSidebar}
title={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}
aria-label="Toggle Sidebar"
data-testid="toggle-sidebar-button"
>
<IconSidebarToggle collapsed={sidebarCollapsed} size={16} strokeWidth={1.5} />
</button>
{/* Toggle devtools */}
<button
className="titlebar-action-button"
onClick={handleToggleDevtools}
title={isConsoleOpen ? 'Hide devtools' : 'Show devtools'}
aria-label="Toggle Devtools"
data-testid="toggle-devtools-button"
>
<IconBottombarToggle collapsed={!isConsoleOpen} size={16} strokeWidth={1.5} />
</button>
{/* Toggle vertical layout */}
<button
className="titlebar-action-button"
onClick={handleToggleVerticalLayout}
title={orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout'}
aria-label="Toggle Vertical Layout"
data-testid="toggle-vertical-layout-button"
>
{orientation === 'horizontal' ? (
<IconLayoutColumns size={16} stroke={1.5} />
) : (
<IconLayoutRows size={16} stroke={1.5} />
)}
</button>
</div>
</div>
</StyledWrapper>
);
};
export default AppTitleBar;

View File

@@ -373,7 +373,7 @@ const ClientCertSettings = ({ collection }) => {
) : null}
</div>
<div className="mt-6 flex flex-row gap-2 items-center">
<button type="submit" className="submit btn btn-sm btn-secondary">
<button type="submit" className="submit btn btn-sm btn-secondary" data-testid="add-client-cert">
Add
</button>
<div className="h-4 border-l border-gray-600"></div>

View File

@@ -0,0 +1,16 @@
import React from 'react';
const IconBottombarToggle = ({ collapsed = false, size = 16, strokeWidth = 1.5, className = '', ...rest }) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={`icon icon-tabler icons-tabler-outline icon-tabler-layout-bottombar ${className}`} {...rest}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z" />
<path d="M4 15l16 0" />
{!collapsed && (
<rect x="4.6" y="15.6" width="14.8" height="2.8" rx="0.8" fill="currentColor" />
)}
</svg>
);
};
export default IconBottombarToggle;

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { uuid } from 'utils/common';
import { IconFiles, IconRun, IconEye, IconSettings } from '@tabler/icons';
import { IconBox, IconRun, IconEye, IconSettings } from '@tabler/icons';
import EnvironmentSelector from 'components/Environments/EnvironmentSelector';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { useDispatch } from 'react-redux';
@@ -45,7 +45,7 @@ const CollectionToolBar = ({ collection }) => {
<StyledWrapper>
<div className="flex items-center py-2 px-4">
<div className="flex flex-1 items-center cursor-pointer hover:underline" onClick={viewCollectionSettings}>
<IconFiles size={18} strokeWidth={1.5} />
<IconBox size={18} strokeWidth={1.5} />
<span className="ml-2 mr-4 font-medium">{collection?.name}</span>
</div>
<div className="flex flex-3 items-center justify-end">

View File

@@ -5,7 +5,7 @@ const Wrapper = styled.div`
height: 1.75rem;
cursor: pointer;
user-select: none;
padding-left: 8px;
padding-left: 4px;
border: ${(props) => props.theme.dragAndDrop.borderStyle} transparent;
.rotate-90 {

View File

@@ -0,0 +1,65 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
margin: 4px 10px 8px 10px;
position: relative;
.search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: ${(props) => props.theme.sidebar.muted};
pointer-events: none;
}
input {
width: 100%;
height: 32px;
padding: 0 32px 0 32px;
font-size: 12px;
color: ${(props) => props.theme.sidebar.color};
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
border: 1px solid transparent;
border-radius: 6px;
outline: none;
transition: all 0.15s ease;
&::placeholder {
color: ${(props) => props.theme.sidebar.muted};
}
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
border-color: ${(props) => props.theme.sidebar.muted}40;
}
&:focus {
background: ${(props) => props.theme.sidebar.bg};
border-color: ${(props) => props.theme.sidebar.muted}80;
}
}
.clear-icon {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 4px;
color: ${(props) => props.theme.sidebar.muted};
cursor: pointer;
transition: all 0.15s ease;
&:hover {
color: ${(props) => props.theme.sidebar.color};
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
}
`;
export default StyledWrapper;

View File

@@ -1,39 +1,28 @@
import { IconSearch, IconX } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const CollectionSearch = ({ searchText, setSearchText }) => {
return (
<div className="relative collection-filter px-2">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<span className="text-gray-500 sm:text-sm">
<IconSearch size={16} strokeWidth={1.5} />
</span>
</div>
<StyledWrapper>
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
<input
type="text"
name="search"
placeholder="Search requests"
placeholder="Search requests..."
id="search"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="block w-full pl-7 pr-8 py-1 sm:text-sm"
value={searchText}
onChange={(e) => setSearchText(e.target.value.toLowerCase())}
/>
{searchText !== '' && (
<div className="absolute inset-y-0 right-0 pr-4 flex items-center">
<span
className="close-icon"
onClick={() => {
setSearchText('');
}}
>
<IconX size={16} strokeWidth={1.5} className="cursor-pointer" />
</span>
<div className="clear-icon" onClick={() => setSearchText('')}>
<IconX size={14} strokeWidth={1.5} />
</div>
)}
</div>
</StyledWrapper>
);
};

View File

@@ -1,24 +0,0 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.collections-badge {
margin-inline: 0.5rem;
background-color: ${(props) => props.theme.sidebar.badge.bg};
border-radius: 5px;
.caret {
margin-left: 0.25rem;
color: rgb(140, 140, 140);
fill: rgb(140, 140, 140);
}
.collections-header-actions {
.collection-action-button {
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
}
}
`;
export default Wrapper;

View File

@@ -1,86 +0,0 @@
import { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { IconArrowsSort, IconFolders, IconSortAscendingLetters, IconSortDescendingLetters } from '@tabler/icons';
import CloseAllIcon from 'components/Icons/CloseAll';
import { sortCollections } from 'providers/ReduxStore/slices/collections/index';
import RemoveCollectionsModal from '../RemoveCollectionsModal';
import StyledWrapper from './StyledWrapper';
const CollectionsHeader = () => {
const dispatch = useDispatch();
const { collections } = useSelector((state) => state.collections);
const { collectionSortOrder } = useSelector((state) => state.collections);
const [collectionsToClose, setCollectionsToClose] = useState([]);
const sortCollectionOrder = () => {
let order;
switch (collectionSortOrder) {
case 'default':
order = 'alphabetical';
break;
case 'alphabetical':
order = 'reverseAlphabetical';
break;
case 'reverseAlphabetical':
order = 'default';
break;
}
dispatch(sortCollections({ order }));
};
let sortIcon;
if (collectionSortOrder === 'default') {
sortIcon = <IconArrowsSort size={18} strokeWidth={1.5} />;
} else if (collectionSortOrder === 'alphabetical') {
sortIcon = <IconSortAscendingLetters size={18} strokeWidth={1.5} />;
} else {
sortIcon = <IconSortDescendingLetters size={18} strokeWidth={1.5} />;
}
const selectAllCollectionsToClose = () => {
setCollectionsToClose(collections.map((c) => c.uid));
};
const clearCollectionsToClose = () => {
setCollectionsToClose([]);
};
return (
<StyledWrapper>
<div className="collections-badge flex items-center justify-between px-2 mt-2 relative">
<div className="flex items-center py-1 select-none">
<span className="mr-2">
<IconFolders size={18} strokeWidth={1.5} />
</span>
<span>Collections</span>
</div>
{collections.length >= 1 && (
<div className="flex items-center collections-header-actions">
<button
className="mr-1 collection-action-button"
onClick={selectAllCollectionsToClose}
aria-label="Close all collections"
title="Close all collections"
data-testid="close-all-collections-button"
>
<CloseAllIcon size={18} strokeWidth={1.5} className="cursor-pointer" />
</button>
<button
className="collection-action-button"
onClick={() => sortCollectionOrder()}
aria-label="Sort collections"
title="Sort collections"
>
{sortIcon}
</button>
{collectionsToClose.length > 0 && (
<RemoveCollectionsModal collectionUids={collectionsToClose} onClose={clearCollectionsToClose} />
)}
</div>
)}
</div>
</StyledWrapper>
);
};
export default CollectionsHeader;

View File

@@ -1,12 +1,35 @@
import styled from 'styled-components';
const Wrapper = styled.div`
span.close-icon {
color: ${(props) => props.theme.colors.text.muted};
}
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
padding-top: 4px;
&:hover .collections-badge .collections-header-actions .collection-action-button {
opacity: 1;
.collections-list {
min-height: 0;
padding: 0 4px;
padding-top: 4px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: ${(props) => props.theme.scrollbar.color};
border-radius: 3px;
}
&::-webkit-scrollbar-thumb:hover {
background: ${(props) => props.theme.scrollbar.color};
}
}
`;

View File

@@ -5,6 +5,7 @@ import CreateCollection from '../CreateCollection';
import StyledWrapper from './StyledWrapper';
import CreateOrOpenCollection from './CreateOrOpenCollection';
import CollectionSearch from './CollectionSearch/index';
import { useMemo } from 'react';
const Collections = ({ showSearch }) => {
const [searchText, setSearchText] = useState('');
@@ -14,13 +15,12 @@ const Collections = ({ showSearch }) => {
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid) || workspaces.find((w) => w.type === 'default');
let workspaceCollections = [];
if (activeWorkspace?.collections?.length) {
workspaceCollections = activeWorkspace.collections.map((wc) => {
return collections.find((c) => c.pathname === wc.path);
}).filter(Boolean);
}
const workspaceCollections = useMemo(() => {
if (!activeWorkspace) return [];
return collections.filter((c) =>
activeWorkspace.collections?.some((wc) => wc.path === c.pathname)
);
}, [activeWorkspace, collections]);
if (!workspaceCollections || !workspaceCollections.length) {
return (
@@ -42,7 +42,7 @@ const Collections = ({ showSearch }) => {
<CollectionSearch searchText={searchText} setSearchText={setSearchText} />
)}
<div className={`mt-4 flex flex-col overflow-hidden hover:overflow-y-auto absolute ${showSearch ? 'top-16' : 'top-8'} bottom-0 left-0 right-0`}>
<div className="collections-list flex flex-col flex-1 overflow-hidden hover:overflow-y-auto">
{workspaceCollections && workspaceCollections.length
? workspaceCollections.map((c) => {
return (

View File

@@ -0,0 +1,110 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
padding: 8px 4px 6px 10px;
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
/* Section Title (single view mode) - with separator */
&.single-view {
border-bottom: 1px solid ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
/* Section Title (single view mode) */
.section-title {
display: flex;
align-items: center;
gap: 6px;
color: ${(props) => props.theme.sidebar.color};
font-size: 12px;
font-weight: 600;
padding: 2px 0;
svg {
color: ${(props) => props.theme.sidebar.muted};
}
}
/* View Tabs (multi-view mode) */
.view-tabs {
display: flex;
align-items: center;
gap: 2px;
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
border-radius: 6px;
padding: 2px;
}
.view-tab {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
color: ${(props) => props.theme.sidebar.muted};
font-size: 11px;
font-weight: 500;
transition: all 0.15s ease;
white-space: nowrap;
&:hover {
color: ${(props) => props.theme.sidebar.color};
}
&.active {
background: ${(props) => props.theme.sidebar.bg};
color: ${(props) => props.theme.sidebar.color};
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
svg {
flex-shrink: 0;
}
span {
display: none;
}
@media (min-width: 280px) {
span {
display: inline;
}
}
}
/* Header Actions */
.header-actions {
display: flex;
align-items: center;
gap: 1px;
}
.action-button {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
color: ${(props) => props.theme.sidebar.muted};
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.dropdown?.hoverBg || props.theme.sidebar?.collection?.item?.hoverBg};
color: ${(props) => props.theme.dropdown?.mutedText || props.theme.text?.muted || '#888'};
}
}
`;
export default StyledWrapper;

View File

@@ -9,6 +9,7 @@ import { sortWorkspaces, toggleWorkspacePin } from 'utils/workspaces';
import Dropdown from 'components/Dropdown';
import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace';
import { toTitleCase } from 'utils/common/index';
const WorkspaceSelector = () => {
const dispatch = useDispatch();
@@ -27,15 +28,6 @@ const WorkspaceSelector = () => {
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const toTitleCase = (str) => {
if (!str) return '';
if (str === 'default') return 'Default';
return str
.split(/[\s-_]+/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
};
const WorkspaceName = forwardRef((props, ref) => {
return (
<div ref={ref} className="workspace-name-container" onClick={() => setShowDropdown(!showDropdown)}>

View File

@@ -0,0 +1,337 @@
import {
IconArrowsSort,
IconBox,
IconDeviceDesktop,
IconDotsVertical,
IconDownload,
IconFolder,
IconPlus,
IconSearch,
IconSortAscendingLetters,
IconSortDescendingLetters,
IconSquareX,
IconTrash
} from '@tabler/icons';
import { useRef, useState } from 'react';
import toast from 'react-hot-toast';
import { useDispatch, useSelector } from 'react-redux';
import { importCollection, openCollection } from 'providers/ReduxStore/slices/collections/actions';
import { sortCollections } from 'providers/ReduxStore/slices/collections/index';
import { importCollectionInWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import Dropdown from 'components/Dropdown';
import ImportCollection from 'components/Sidebar/ImportCollection';
import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation';
import RemoveCollectionsModal from '../Collections/RemoveCollectionsModal/index';
import CreateCollection from '../CreateCollection';
import StyledWrapper from './StyledWrapper';
const VIEW_TABS = [
{ id: 'collections', label: 'Collections', icon: IconBox }
];
const SidebarHeader = ({ setShowSearch, activeView = 'collections', onViewChange }) => {
const dispatch = useDispatch();
const { ipcRenderer } = window;
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
// Get collection sort order
const { collections } = useSelector((state) => state.collections);
const { collectionSortOrder } = useSelector((state) => state.collections);
const [collectionsToClose, setCollectionsToClose] = useState([]);
const [importData, setImportData] = useState(null);
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
const handleImportCollection = ({ rawData, type }) => {
setImportCollectionModalOpen(false);
if (activeWorkspace && activeWorkspace.type !== 'default') {
dispatch(importCollectionInWorkspace(rawData, activeWorkspace.uid, undefined, type))
.catch((err) => {
toast.error('An error occurred while importing the collection');
});
} else {
setImportData({ rawData, type });
setImportCollectionLocationModalOpen(true);
}
};
const handleImportCollectionLocation = (convertedCollection, collectionLocation) => {
dispatch(importCollection(convertedCollection, collectionLocation))
.then(() => {
setImportCollectionLocationModalOpen(false);
setImportData(null);
toast.success('Collection imported successfully');
})
.catch((err) => {
console.error(err);
toast.error('An error occurred while importing the collection');
});
};
const addDropdownTippyRef = useRef();
const onAddDropdownCreate = (ref) => (addDropdownTippyRef.current = ref);
const actionsDropdownTippyRef = useRef();
const onActionsDropdownCreate = (ref) => (actionsDropdownTippyRef.current = ref);
const handleToggleSearch = () => {
if (setShowSearch) {
setShowSearch((prev) => !prev);
}
};
const handleSortCollections = () => {
let order;
switch (collectionSortOrder) {
case 'default':
order = 'alphabetical';
break;
case 'alphabetical':
order = 'reverseAlphabetical';
break;
case 'reverseAlphabetical':
order = 'default';
break;
default:
order = 'default';
break;
}
dispatch(sortCollections({ order }));
};
const getSortIcon = () => {
switch (collectionSortOrder) {
case 'alphabetical':
return IconSortDescendingLetters;
case 'reverseAlphabetical':
return IconArrowsSort;
default:
return IconSortAscendingLetters;
}
};
const getSortLabel = () => {
switch (collectionSortOrder) {
case 'alphabetical':
return 'Sort Z-A';
case 'reverseAlphabetical':
return 'Clear sort';
default:
return 'Sort A-Z';
}
};
const selectAllCollectionsToClose = () => {
setCollectionsToClose(collections.map((c) => c.uid));
};
const clearCollectionsToClose = () => {
setCollectionsToClose([]);
};
const handleOpenCollection = () => {
const options = {};
if (activeWorkspace?.pathname) {
options.workspaceId = activeWorkspace.pathname;
}
dispatch(openCollection(options)).catch((err) => {
toast.error('An error occurred while opening the collection');
});
};
const renderModals = () => (
<>
{createCollectionModalOpen && (
<CreateCollection
onClose={() => setCreateCollectionModalOpen(false)}
/>
)}
{importCollectionModalOpen && (
<ImportCollection
onClose={() => setImportCollectionModalOpen(false)}
handleSubmit={handleImportCollection}
/>
)}
{importCollectionLocationModalOpen && importData && (
<ImportCollectionLocation
rawData={importData.rawData}
format={importData.type}
onClose={() => setImportCollectionLocationModalOpen(false)}
handleSubmit={handleImportCollectionLocation}
/>
)}
</>
);
const isSingleView = VIEW_TABS.length === 1;
// Render Collections-specific actions
const renderCollectionsActions = () => (
<>
<button
className="action-button"
onClick={handleToggleSearch}
title="Search requests"
>
<IconSearch size={14} stroke={1.5} />
</button>
{/* Add/Create dropdown */}
<Dropdown
onCreate={onAddDropdownCreate}
icon={(
<button className="action-button plus-icon-button" title="Add new">
<IconPlus size={14} stroke={1.5} />
</button>
)}
placement="bottom-end"
style="new"
>
<div className="label-item">Collections</div>
<div
className="dropdown-item"
onClick={() => {
setCreateCollectionModalOpen(true);
addDropdownTippyRef.current?.hide();
}}
>
<IconPlus size={16} stroke={1.5} className="icon" />
Create collection
</div>
<div
className="dropdown-item"
onClick={() => {
addDropdownTippyRef.current?.hide();
setImportCollectionModalOpen(true);
}}
>
<IconDownload size={16} stroke={1.5} className="icon" />
Import collection
</div>
<div
className="dropdown-item"
onClick={() => {
handleOpenCollection();
addDropdownTippyRef.current?.hide();
}}
>
<IconFolder size={16} stroke={1.5} className="icon" />
Open collection
</div>
</Dropdown>
{/* Actions dropdown (sort, close all, etc.) */}
<Dropdown
onCreate={onActionsDropdownCreate}
icon={(
<button className="action-button" title="More actions">
<IconDotsVertical size={14} stroke={1.5} />
</button>
)}
placement="bottom-end"
style="new"
>
<div
className="dropdown-item"
onClick={() => {
handleSortCollections();
actionsDropdownTippyRef.current?.hide();
}}
aria-label="Sort collections"
title="Sort collections"
data-testid="sort-collections-button"
>
{(() => {
const SortIcon = getSortIcon();
return <SortIcon size={16} stroke={1.5} className="icon" />;
})()}
{getSortLabel()}
</div>
<div
className="dropdown-item"
onClick={() => {
selectAllCollectionsToClose();
actionsDropdownTippyRef.current?.hide();
}}
aria-label="Close all collections"
title="Close all collections"
data-testid="close-all-collections-button"
>
<IconSquareX size={16} stroke={1.5} className="icon" />
Close all
</div>
</Dropdown>
{collectionsToClose.length > 0 && (
<RemoveCollectionsModal collectionUids={collectionsToClose} onClose={clearCollectionsToClose} />
)}
</>
);
// Render Second Tab-specific actions
const renderSecondTabActions = () => (
<>
{/* Add second tab actions here */}
</>
);
// Render the view switcher - either tabs or single title
const renderViewSwitcher = () => {
if (isSingleView) {
// Single view - just show the title
const tab = VIEW_TABS[0];
const TabIcon = tab.icon;
return (
<div className="section-title">
<TabIcon size={14} stroke={1.5} />
<span>{tab.label}</span>
</div>
);
}
// Multiple views - show segmented tabs
return (
<div className="view-tabs">
{VIEW_TABS.map((tab) => {
const TabIcon = tab.icon;
return (
<button
key={tab.id}
className={`view-tab ${activeView === tab.id ? 'active' : ''}`}
onClick={() => onViewChange?.(tab.id)}
title={tab.label}
>
<TabIcon size={14} stroke={1.5} />
<span>{tab.label}</span>
</button>
);
})}
</div>
);
};
return (
<StyledWrapper className={isSingleView ? 'single-view' : ''}>
{renderModals()}
<div className="sidebar-header">
{renderViewSwitcher()}
{/* Action Buttons - Context Sensitive */}
<div className="header-actions">
{activeView === 'collections' ? renderCollectionsActions() : renderSecondTabActions()}
</div>
</div>
</StyledWrapper>
);
};
export default SidebarHeader;

View File

@@ -28,18 +28,6 @@ const Wrapper = styled.div`
top: -0.625rem;
}
}
.collection-filter {
input {
border: ${(props) => props.theme.sidebar.search.border};
border-radius: 2px;
background-color: ${(props) => props.theme.sidebar.search.bg};
&:focus {
outline: none;
}
}
}
}
div.sidebar-drag-handle {
@@ -65,6 +53,15 @@ const Wrapper = styled.div`
border-left: solid 1px ${(props) => props.theme.sidebar.dragbar.activeBorder};
}
}
.second-tab-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
color: ${(props) => props.theme.sidebar.muted};
}
`;
export default Wrapper;

View File

@@ -1,154 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.titlebar-container {
display: flex;
align-items: center;
}
.workspace-name-container {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
margin-left: 0px;
border-radius: ${(props) => props.theme.border.radius.base};
cursor: pointer;
transition: all 0.2s ease;
min-width: 0;
flex: 1;
max-width: 120px;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
.workspace-name {
font-size: ${(props) => props.theme.font.size.base};
font-weight: 600;
color: ${(props) => props.theme.sidebar.color};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chevron-icon {
flex-shrink: 0;
color: ${(props) => props.theme.sidebar.muted};
transition: transform 0.2s ease;
}
}
/* Actions Button */
.actions-container {
margin-left: auto;
display: flex;
align-items: center;
}
.home-icon-button,
.search-icon-button,
.plus-icon-button {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s ease;
color: ${(props) => props.theme.text};
}
.workspace-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 10px !important;
margin: 0 !important;
&.active {
.check-icon {
opacity: 1;
}
}
&:hover {
.pin-btn:not(.pinned) {
opacity: 1;
}
}
.workspace-name {
flex: 1;
min-width: 0;
font-size: ${(props) => props.theme.font.size.base};
font-weight: 400;
color: ${(props) => props.theme.dropdown.color};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.workspace-actions {
display: flex;
align-items: center;
gap: 4px;
margin-left: 8px;
flex-shrink: 0;
pointer-events: none;
> * {
pointer-events: auto;
}
}
.check-icon {
color: ${(props) => props.theme.workspace?.accent || props.theme.colors?.text?.yellow || '#f0c674'};
flex-shrink: 0;
}
.pin-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
color: ${(props) => props.theme.dropdown?.mutedText || props.theme.text?.muted || '#888'};
transition: background 0.15s ease, color 0.15s ease, opacity 0.15s ease;
opacity: 0;
&.pinned {
opacity: 1;
}
&:hover {
background: ${(props) => props.theme.dropdown?.hoverBg || props.theme.sidebar?.collection?.item?.hoverBg};
color: ${(props) => props.theme.dropdown?.mutedText || props.theme.text?.muted || '#888'};
}
}
}
.collection-dropdown {
color: ${(props) => props.theme.sidebar.dropdownIcon.color};
&:hover {
color: inherit;
}
.tippy-box {
top: -0.5rem;
position: relative;
user-select: none;
}
}
`;
export default StyledWrapper;

View File

@@ -1,181 +0,0 @@
import { useState, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import toast from 'react-hot-toast';
import { IconPlus, IconFolder, IconDownload, IconHome, IconSearch, IconDeviceDesktop } from '@tabler/icons';
import { showHomePage } from 'providers/ReduxStore/slices/app';
import { openCollection, importCollection } from 'providers/ReduxStore/slices/collections/actions';
import { importCollectionInWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import Dropdown from 'components/Dropdown';
import ImportCollection from 'components/Sidebar/ImportCollection';
import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation';
import CreateCollection from '../CreateCollection';
import WorkspaceSelector from './WorkspaceSelector';
import StyledWrapper from './StyledWrapper';
const TitleBar = ({ showSearch, setShowSearch }) => {
const dispatch = useDispatch();
const { ipcRenderer } = window;
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
const [importData, setImportData] = useState(null);
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
const actionsDropdownTippyRef = useRef();
const onActionsDropdownCreate = (ref) => (actionsDropdownTippyRef.current = ref);
const handleImportCollection = ({ rawData, type }) => {
setImportCollectionModalOpen(false);
if (activeWorkspace && activeWorkspace.type !== 'default') {
dispatch(importCollectionInWorkspace(rawData, activeWorkspace.uid, undefined, type))
.catch((err) => {
toast.error('An error occurred while importing the collection');
});
} else {
setImportData({ rawData, type });
setImportCollectionLocationModalOpen(true);
}
};
const handleImportCollectionLocation = (convertedCollection, collectionLocation) => {
dispatch(importCollection(convertedCollection, collectionLocation))
.then(() => {
setImportCollectionLocationModalOpen(false);
setImportData(null);
toast.success('Collection imported successfully');
})
.catch((err) => {
console.error(err);
toast.error('An error occurred while importing the collection');
});
};
const handleToggleSearch = () => {
if (setShowSearch) {
setShowSearch((prev) => !prev);
}
};
const handleOpenCollection = () => {
const options = {};
if (activeWorkspace?.pathname) {
options.workspaceId = activeWorkspace.pathname;
}
dispatch(openCollection(options)).catch((err) => {
toast.error('An error occurred while opening the collection');
});
};
const openDevTools = () => {
ipcRenderer.invoke('renderer:open-devtools');
};
const renderModals = () => (
<>
{createCollectionModalOpen && (
<CreateCollection
onClose={() => setCreateCollectionModalOpen(false)}
/>
)}
{importCollectionModalOpen && (
<ImportCollection
onClose={() => setImportCollectionModalOpen(false)}
handleSubmit={handleImportCollection}
/>
)}
{importCollectionLocationModalOpen && importData && (
<ImportCollectionLocation
rawData={importData.rawData}
format={importData.type}
onClose={() => setImportCollectionLocationModalOpen(false)}
handleSubmit={handleImportCollectionLocation}
/>
)}
</>
);
return (
<StyledWrapper className="px-2 py-2">
{renderModals()}
<div className="titlebar-container">
<WorkspaceSelector />
<div className="actions-container">
<button className="home-icon-button" onClick={() => dispatch(showHomePage())} title="Home">
<IconHome size={16} stroke={1.5} />
</button>
{setShowSearch && (
<button className="search-icon-button" onClick={handleToggleSearch} title="Toggle search">
<IconSearch size={16} stroke={1.5} />
</button>
)}
<Dropdown
onCreate={onActionsDropdownCreate}
icon={(
<button className="plus-icon-button">
<IconPlus size={16} stroke={1.5} />
</button>
)}
placement="bottom-end"
style="new"
>
<div className="label-item">Collections</div>
<div
className="dropdown-item"
onClick={(e) => {
setCreateCollectionModalOpen(true);
actionsDropdownTippyRef.current?.hide();
}}
>
<IconPlus size={16} stroke={1.5} className="icon" />
Create collection
</div>
<div
className="dropdown-item"
onClick={(e) => {
actionsDropdownTippyRef.current?.hide();
setImportCollectionModalOpen(true);
}}
>
<IconDownload size={16} stroke={1.5} className="icon" />
Import collection
</div>
<div
className="dropdown-item"
onClick={(e) => {
handleOpenCollection();
actionsDropdownTippyRef.current?.hide();
}}
>
<IconFolder size={16} stroke={1.5} className="icon" />
Open collection
</div>
<div className="dropdown-separator"></div>
<div
className="dropdown-item"
onClick={(e) => {
actionsDropdownTippyRef.current?.hide();
openDevTools();
}}
>
<IconDeviceDesktop size={16} stroke={1.5} className="icon" />
Devtools
</div>
</Dropdown>
</div>
</div>
</StyledWrapper>
);
};
export default TitleBar;

View File

@@ -1,4 +1,4 @@
import TitleBar from './TitleBar';
import SidebarHeader from './SidebarHeader';
import Collections from './Collections';
import StyledWrapper from './StyledWrapper';
@@ -6,7 +6,7 @@ import { useState, useEffect, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { updateLeftSidebarWidth, updateIsDragging } from 'providers/ReduxStore/slices/app';
const MIN_LEFT_SIDEBAR_WIDTH = 221;
const MIN_LEFT_SIDEBAR_WIDTH = 220;
const MAX_LEFT_SIDEBAR_WIDTH = 600;
const Sidebar = () => {
@@ -14,6 +14,8 @@ const Sidebar = () => {
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
const [asideWidth, setAsideWidth] = useState(leftSidebarWidth);
const lastWidthRef = useRef(leftSidebarWidth);
const [showSearch, setShowSearch] = useState(false);
const [activeView, setActiveView] = useState('collections'); // 'collections' or any other future tab
const dispatch = useDispatch();
const [dragging, setDragging] = useState(false);
@@ -80,9 +82,21 @@ const Sidebar = () => {
<aside className="sidebar" style={{ width: currentWidth, transition: dragging ? 'none' : 'width 0.2s ease-in-out' }}>
<div className="flex flex-row h-full w-full">
<div className="flex flex-col w-full" style={{ width: asideWidth }}>
<div className="flex flex-col flex-grow">
<TitleBar />
<Collections />
<div className="flex flex-col flex-grow" style={{ minHeight: 0, overflow: 'hidden' }}>
<SidebarHeader
setShowSearch={setShowSearch}
activeView={activeView}
onViewChange={setActiveView}
/>
{activeView === 'collections' ? (
<Collections showSearch={showSearch} />
) : (
<div className="second-tab-placeholder">
<p className="text-center text-muted py-8 px-4 text-sm opacity-60">
Second tab content will appear here
</p>
</div>
)}
</div>
</div>
</div>

View File

@@ -9,7 +9,7 @@ import IconSidebarToggle from 'components/Icons/IconSidebarToggle';
import Cookies from 'components/Cookies';
import Notifications from 'components/Notifications';
import Portal from 'components/Portal';
import { showPreferences, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { showPreferences } from 'providers/ReduxStore/slices/app';
import { openConsole } from 'providers/ReduxStore/slices/logs';
import { useApp } from 'providers/App';
import StyledWrapper from './StyledWrapper';
@@ -18,7 +18,6 @@ const StatusBar = () => {
const dispatch = useDispatch();
const preferencesOpen = useSelector((state) => state.app.showPreferences);
const logs = useSelector((state) => state.logs.logs);
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
const [cookiesOpen, setCookiesOpen] = useState(false);
const { version } = useApp();
@@ -70,16 +69,6 @@ const StatusBar = () => {
<div className="status-bar">
<div className="status-bar-section">
<div className="status-bar-group">
<ToolHint text="Toggle Sidebar" toolhintId="Toggle Sidebar" place="top-start" offset={10}>
<button
className="status-bar-button"
aria-label="Toggle Sidebar"
onClick={() => dispatch(toggleSidebarCollapse())}
>
<IconSidebarToggle collapsed={sidebarCollapsed} size={16} strokeWidth={1.5} aria-hidden="true" />
</button>
</ToolHint>
<ToolHint text="Preferences" toolhintId="Preferences" place="top-start" offset={10}>
<button
className="status-bar-button preferences-button"

View File

@@ -6,7 +6,7 @@ import { showInFolder, openCollection } from 'providers/ReduxStore/slices/collec
import toast from 'react-hot-toast';
import CreateCollection from 'components/Sidebar/CreateCollection';
import ImportCollection from 'components/Sidebar/ImportCollection';
import CloseWorkspace from 'components/Sidebar/TitleBar/CloseWorkspace';
import CloseWorkspace from 'components/Sidebar/SidebarHeader/CloseWorkspace';
import WorkspaceCollections from './WorkspaceCollections';
import WorkspaceDocs from './WorkspaceDocs';
import WorkspaceEnvironments from './WorkspaceEnvironments';

View File

@@ -5,6 +5,7 @@ import RequestTabs from 'components/RequestTabs';
import RequestTabPanel from 'components/RequestTabPanel';
import Sidebar from 'components/Sidebar';
import StatusBar from 'components/StatusBar';
import AppTitleBar from 'components/AppTitleBar';
// import ErrorCapture from 'components/ErrorCapture';
import { useSelector } from 'react-redux';
import { isElectron } from 'utils/common/platform';
@@ -87,6 +88,7 @@ export default function Main() {
return (
// <ErrorCapture>
<div id="main-container" className="flex flex-col h-screen max-h-screen overflow-hidden">
<AppTitleBar />
{showRosettaBanner ? (
<Portal>
<div className="fixed bottom-0 left-0 right-0 z-10 bg-amber-100 border border-amber-400 text-amber-700 px-4 py-3" role="alert">
@@ -105,7 +107,7 @@ export default function Main() {
className="flex-1 min-h-0 flex"
data-app-state="loading"
style={{
height: isConsoleOpen ? `calc(100vh - 22px - ${isConsoleOpen ? '300px' : '0px'})` : 'calc(100vh - 22px)'
height: isConsoleOpen ? `calc(100vh - 60px - ${isConsoleOpen ? '300px' : '0px'})` : 'calc(100vh - 60px)'
}}
>
<StyledWrapper className={className} style={{ height: '100%', zIndex: 1 }}>

View File

@@ -20,9 +20,24 @@ export const AppProvider = (props) => {
}, []);
useEffect(() => {
const platform = get(navigator, 'platform', '');
if (platform && platform.toLowerCase().indexOf('mac') > -1) {
const platform = get(navigator, 'platform', '').toLowerCase();
if (!platform) {
return;
}
if (platform.includes('mac')) {
document.body.classList.add('os-mac');
return;
}
if (platform.includes('win')) {
document.body.classList.add('os-windows');
return;
}
if (platform.includes('linux')) {
document.body.classList.add('os-linux');
}
}, []);

View File

@@ -5,7 +5,7 @@ import brunoClipboard from 'utils/bruno-clipboard';
const initialState = {
isDragging: false,
idbConnectionReady: false,
leftSidebarWidth: 222,
leftSidebarWidth: 250,
sidebarCollapsed: false,
screenWidth: 500,
showHomePage: false,

View File

@@ -338,3 +338,23 @@ export const prettifyJsonString = (jsonDataString) => {
}
return jsonDataString;
};
/**
* Returns the given string value converted to title case.
* - If the value is falsy, returns an empty string.
* - Special-case: if the value is 'default', returns 'Default'.
* - Otherwise, splits the string on whitespace, hyphens, or underscores,
* uppercases the first letter of each word, and lowercases the rest.
*
* @param {string} str - The input string to convert.
* @returns {string} - The converted title-case string.
*/
export const toTitleCase = (str) => {
if (!str) return '';
if (str === 'default') return 'Default';
return str
.split(/[\s-_]+/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
};

View File

@@ -78,6 +78,8 @@ const contentSecurityPolicy = [
setContentSecurityPolicy(contentSecurityPolicy.join(';') + ';');
const menu = Menu.buildFromTemplate(menuTemplate);
const isMac = process.platform === 'darwin';
const isWindows = process.platform === 'win32';
let mainWindow;
@@ -118,7 +120,11 @@ app.on('ready', async () => {
webviewTag: true
},
title: 'Bruno',
icon: path.join(__dirname, 'about/256x256.png')
icon: path.join(__dirname, 'about/256x256.png'),
// Custom title bar ensure React titlebar occupies the window chrome on all OSes
titleBarStyle: isMac ? 'hiddenInset' : isWindows ? 'hidden' : 'default',
titleBarOverlay: isWindows ? { height: 36 } : undefined,
trafficLightPosition: isMac ? { x: 12, y: 10 } : undefined
// we will bring this back
// see https://github.com/usebruno/bruno/issues/440
// autoHideMenuBar: true
@@ -166,6 +172,15 @@ app.on('ready', async () => {
mainWindow.on('maximize', () => saveMaximized(true));
mainWindow.on('unmaximize', () => saveMaximized(false));
// Full screen events for title bar padding adjustment
mainWindow.on('enter-full-screen', () => {
mainWindow.webContents.send('main:enter-full-screen');
});
mainWindow.on('leave-full-screen', () => {
mainWindow.webContents.send('main:leave-full-screen');
});
mainWindow.on('close', (e) => {
e.preventDefault();
terminalManager.cleanup(mainWindow.webContents);

View File

@@ -146,7 +146,7 @@ test.describe('Draft indicator in collection and folder settings', () => {
await keyFileChooser.setFiles('./tests/collection/draft/fixtures/grpcbin.proto');
// Click Add button
await page.getByRole('button', { name: 'Add' }).click();
await page.getByTestId('add-client-cert').click();
// Verify draft indicator appears
await expect(collectionTab.locator('.has-changes-icon')).toBeVisible();

View File

@@ -4,7 +4,7 @@ test.describe('Sidebar Toggle', () => {
test('should toggle sidebar visibility when clicking the toggle button', async ({ page }) => {
// Get the sidebar and toggle button elements
const sidebar = page.locator('aside.sidebar');
const toggleButton = page.getByLabel('Toggle Sidebar');
const toggleButton = page.getByTestId('toggle-sidebar-button');
const dragHandle = page.locator('.sidebar-drag-handle');
// Initial state - sidebar and drag handle should be visible

View File

@@ -23,7 +23,7 @@ test.describe('Response Pane Actions', () => {
await page.getByPlaceholder('Request Name').fill('copy-test');
await page.locator('#new-request-url .CodeMirror').click();
// Using httpbin.org for a simple JSON response
await page.locator('textarea').fill('https://httpbin.org/json');
await page.locator('textarea').fill('https://www.httpfaker.org/api/random/json?size=1kb');
await page.getByRole('button', { name: 'Create' }).click();
});