feat(#304) Environments color 🎨 (#1053)

* associate environment to a color

Signed-off-by: mathieu <mathieu.dreano@gmail.com>

use StyledWrapper

Signed-off-by: mathieu <mathieu.dreano@gmail.com>

don't save anything for color if it is not set

Signed-off-by: mathieu <mathieu.dreano@gmail.com>

use redux store instead of local state

remove logs

fix selectedEnvironment

cleanup

add bottom border on active tab

* associate environment to a color

Signed-off-by: mathieu <mathieu.dreano@gmail.com>

* move dependency to appropriate package.json

Signed-off-by: mathieu <mathieu.dreano@gmail.com>

* use border instead of background color

Signed-off-by: mathieu <mathieu.dreano@gmail.com>

* simplify onColorChange

Signed-off-by: mathieu <mathieu.dreano@gmail.com>

* add black, keep backgound on unselected color

Signed-off-by: mathieu <mathieu.dreano@gmail.com>

* fix conflicts

Signed-off-by: mathieu <mathieu.dreano@gmail.com>

* associate environment to a color

Signed-off-by: mathieu <mathieu.dreano@gmail.com>

use StyledWrapper

Signed-off-by: mathieu <mathieu.dreano@gmail.com>

don't save anything for color if it is not set

Signed-off-by: mathieu <mathieu.dreano@gmail.com>

use redux store instead of local state

remove logs

fix selectedEnvironment

cleanup

add bottom border on active tab

# Conflicts:
#	packages/bruno-app/src/components/Environments/EnvironmentSelector/StyledWrapper.js
#	packages/bruno-app/src/components/Environments/EnvironmentSelector/index.js
#	packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/index.js
#	packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js
#	packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js
#	packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js

* Update packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentColor/index.js

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* unused selectedEnvironment prop in EnvironmentList

Signed-off-by: Mathieu D <mathieu.dreano@decathlon.com>

* RequestTab, avoid unnecessary call if undefined activeCollection

Signed-off-by: Mathieu D <mathieu.dreano@decathlon.com>

* use @uiw/reac-color instead of react-color

Signed-off-by: Mathieu D <mathieu.dreano@decathlon.com>

---------

Signed-off-by: mathieu <mathieu.dreano@gmail.com>
Signed-off-by: Mathieu D <mathieu.dreano@decathlon.com>
Co-authored-by: Mathieu D <mathieu.dreano@decathlon.com>
Co-authored-by: Anoop M D <anoop@usebruno.com>
Co-authored-by: Mathieu DREANO <122891400+mdreano@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Mathieu DREANO
2026-01-29 11:51:39 +01:00
committed by Sid
parent 214e1434e5
commit 5a6714f085
15 changed files with 612 additions and 24 deletions

View File

@@ -18,6 +18,7 @@
"@tabler/icons": "^1.46.0",
"@testing-library/user-event": "^14.6.1",
"@tippyjs/react": "^4.2.6",
"@uiw/react-color": "^2.9.2",
"@usebruno/common": "0.1.0",
"@usebruno/graphql-docs": "0.1.0",
"@usebruno/schema": "0.7.0",

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { IconPlus, IconDownload, IconSettings } from '@tabler/icons';
import { IconPlus, IconDownload, IconSettings, IconDatabase } from '@tabler/icons';
import ToolHint from 'components/ToolHint';
const EnvironmentListContent = ({
@@ -38,6 +38,7 @@ const EnvironmentListContent = ({
data-tooltip-content={env.name}
data-tooltip-hidden={env.name?.length < 90}
>
<IconDatabase size={16} strokeWidth={1.5} color={env.color} />
<span className="max-w-100% truncate no-wrap">{env.name}</span>
</div>
))}

View File

@@ -5,8 +5,8 @@ const Wrapper = styled.div`
border-radius: ${(props) => props.theme.border.radius.base};
padding: 0.25rem 0.3rem 0.25rem 0.5rem;
user-select: none;
background-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.bg};
border: 1px solid ${(props) => props.theme.app.collection.toolbar.environmentSelector.border};
background-color: ${(props) => props.color ? undefined : 'transparent'};
border: 2px solid ${(props) => props.color ?? props.theme.dropdown.selectedColor};
line-height: 1rem;
transition: all 0.15s ease;
@@ -15,6 +15,11 @@ const Wrapper = styled.div`
background-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.hoverBg};
}
.active-env-toolhint {
display: flex;
flex-direction: row;
}
.caret {
margin-left: 0.25rem;
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.caret};
@@ -24,11 +29,12 @@ const Wrapper = styled.div`
.env-icon {
margin-right: 0.25rem;
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.icon};
color: ${(props) => props.color ?? props.theme.dropdown.selectedColor};
}
.env-text {
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.text};
color: ${(props) => props.color ?? props.theme.dropdown.selectedColor};
font-size: ${(props) => props.theme.font.size.base};
display: block;
}
@@ -65,6 +71,37 @@ const Wrapper = styled.div`
overflow: hidden;
}
.tippy-box .tippy-content {
padding: 0;
display: flex;
flex-direction: column;
height: 100%;
.dropdown-item {
display: flex;
flex-direction: row;
align-items: center;
column-gap: 0.35em;
padding: 0.35rem 0.6rem;
cursor: pointer;
font-size: ${(props) => props.theme.font.size.base};
color: ${(props) => props.theme.dropdown.primaryText};
&:hover:not(:disabled) {
background-color: ${(props) => props.theme.dropdown.hoverBg};
}
&.active {
background-color: ${(props) => props.theme.dropdown.selectedBg};
color: ${(props) => props.color ?? props.theme.dropdown.selectedColor} !important;
}
&.no-environment {
color: ${(props) => props.theme.dropdown.mutedText};
}
}
}
.configure-button {
position: absolute;
bottom: 0;

View File

@@ -134,14 +134,15 @@ const EnvironmentSelector = ({ collection }) => {
{activeCollectionEnvironment && (
<>
<div className="flex items-center">
<IconDatabase size={14} strokeWidth={1.5} className="env-icon" />
<ToolHint
className="active-env-toolhint"
text={activeCollectionEnvironment.name}
toolhintId={`collection-env-${activeCollectionEnvironment.uid}`}
place="bottom-start"
delayShow={1000}
hidden={activeCollectionEnvironment.name?.length < 7}
>
<IconDatabase size={14} strokeWidth={1.5} className="env-icon" />
<span className="env-text max-w-24 truncate overflow-hidden">{activeCollectionEnvironment.name}</span>
</ToolHint>
</div>
@@ -182,7 +183,7 @@ const EnvironmentSelector = ({ collection }) => {
});
return (
<StyledWrapper width={dropdownWidth}>
<StyledWrapper color={activeCollectionEnvironment?.color} width={dropdownWidth}>
<div className="environment-selector flex align-center cursor-pointer">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
{/* Tab Headers */}

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { useCallback } from 'react';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import { saveEnvironmentColor } from 'providers/ReduxStore/slices/collections/actions';
import { Circle } from '@uiw/react-color';
const EnvironmentColor = ({ environment, collectionUid }) => {
const dispatch = useDispatch();
const onColorChange = useCallback(
(color) => {
if (color == environment.color) return;
dispatch(saveEnvironmentColor(color, environment.uid, collectionUid))
.then(() => toast.success('Environment color changed successfully'))
.catch(() => toast.error('An error occurred while changing the environment color'));
},
[dispatch, environment.uid, environment.color, collectionUid]
);
return (
<Circle
id="environment-color"
style={{ gap: 3 }}
pointProps={{ style: { width: 14, height: 14, borderRadius: 10 } }}
colors={['#000000','#9c27b0','#3f51b5','#03a9f4','#009688','#8bc34a','#ffeb3b','#ff9800','#ff5722','#795548','#607d8b']}
color={environment.color}
onChange={(color) => onColorChange(color.hex)}
/>
);
};
export default EnvironmentColor;

View File

@@ -7,6 +7,8 @@ import toast from 'react-hot-toast';
import CopyEnvironment from 'components/Environments/EnvironmentSettings/CopyEnvironment';
import DeleteEnvironment from 'components/Environments/EnvironmentSettings/DeleteEnvironment';
import EnvironmentVariables from './EnvironmentVariables';
import EnvironmentColor from '../EnvironmentDetails/EnvironmentColor';
import ToolHint from 'components/ToolHint/index';
import StyledWrapper from './StyledWrapper';
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
@@ -119,7 +121,6 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
{openCopyModal && (
<CopyEnvironment onClose={() => setOpenCopyModal(false)} environment={environment} collection={collection} />
)}
<div className="header">
<div className={`title-container ${isRenaming ? 'renaming' : ''}`}>
{isRenaming ? (
@@ -174,9 +175,8 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
</div>
</div>
<div className="content">
<EnvironmentVariables environment={environment} setIsModified={setIsModified} collection={collection} />
</div>
<EnvironmentColor environment={environment} collectionUid={collection.uid} />
<EnvironmentVariables environment={environment} collection={collection} setIsModified={setIsModified} onClose={onClose} />
</StyledWrapper>
);
};

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState, useRef } from 'react';
import { findEnvironmentInCollection, findItem } from 'utils/collections';
import usePrevious from 'hooks/usePrevious';
import EnvironmentDetails from './EnvironmentDetails';
import CreateEnvironment from 'components/Environments/EnvironmentSettings/CreateEnvironment';
@@ -24,6 +25,10 @@ const EnvironmentList = ({
}) => {
const dispatch = useDispatch();
const EnvironmentList = ({ collection, isModified, setIsModified, onClose, setShowExportModal }) => {
const { environments, activeEnvironmentUid } = collection;
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
const [openCreateModal, setOpenCreateModal] = useState(false);
const [openImportModal, setOpenImportModal] = useState(false);
const [searchText, setSearchText] = useState('');
@@ -38,7 +43,7 @@ const EnvironmentList = ({
const [switchEnvConfirmClose, setSwitchEnvConfirmClose] = useState(false);
const [originalEnvironmentVariables, setOriginalEnvironmentVariables] = useState([]);
const envUids = environments ? environments.map((env) => env.uid) : [];
const envUids = environments?.map((env) => env.uid) ?? [];
const prevEnvUids = usePrevious(envUids);
useEffect(() => {
@@ -63,10 +68,12 @@ const EnvironmentList = ({
if (hasSelectedEnvironmentChanged || selectedEnvironment.uid !== _selectedEnvironment?.uid) {
setSelectedEnvironment(_selectedEnvironment);
}
setOriginalEnvironmentVariables(_selectedEnvironment?.variables || []);
setOriginalEnvironmentVariables(selectedEnvironment?.variables||[]);
setSelectedEnvironment(findItem(environments, selectedEnvironment.uid));
return;
}
const environment = environments?.find((env) => env.uid === activeEnvironmentUid) || environments?.[0];
setSelectedEnvironment(environment);
@@ -74,15 +81,21 @@ const EnvironmentList = ({
}, [environments, activeEnvironmentUid, selectedEnvironment]);
useEffect(() => {
if (prevEnvUids && prevEnvUids.length && envUids.length > prevEnvUids.length) {
if (selectedEnvironment) {
setSelectedEnvironment(findEnvironmentInCollection(collection, selectedEnvironment.uid));
}
}, [environments]);
useEffect(() => {
if (prevEnvUids?.length && envUids.length > prevEnvUids.length) {
const newEnv = environments.find((env) => !prevEnvUids.includes(env.uid));
if (newEnv) {
setSelectedEnvironment(newEnv);
}
}
if (prevEnvUids && prevEnvUids.length && envUids.length < prevEnvUids.length) {
setSelectedEnvironment(environments && environments.length ? environments[0] : null);
if (prevEnvUids?.length && envUids.length < prevEnvUids.length) {
setSelectedEnvironment(environments?.length ? environments[0] : null);
}
}, [envUids, environments, prevEnvUids]);

View File

@@ -71,9 +71,8 @@ const Wrapper = styled.div`
&:not(.active) {
background: ${(props) => props.theme.requestTabs.bg};
border-color: transparent;
border-bottom: 3px solid ${(props) => props.color ?? "transparent"};
border-radius: ${(props) => props.theme.border.radius.base};
}
&:nth-last-child(1) {
@@ -113,7 +112,7 @@ const Wrapper = styled.div`
&.active {
background: ${(props) => props.theme.bg || '#ffffff'};
border: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
border-bottom-color: ${(props) => props.theme.bg || '#ffffff'};
border-bottom-color: ${(props) => props.color ?? props.theme.bg ?? '#ffffff'};
border-radius: 8px 8px 0 0;
z-index: 1;
margin-bottom: -2px;

View File

@@ -6,6 +6,7 @@ import { IconChevronRight, IconChevronLeft } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
import { focusTab, reorderTabs } from 'providers/ReduxStore/slices/tabs';
import NewRequest from 'components/Sidebar/NewRequest';
import { findEnvironmentInCollection } from 'utils/collections';
import CollectionToolBar from './CollectionToolBar';
import RequestTab from './RequestTab';
import StyledWrapper from './StyledWrapper';
@@ -85,6 +86,17 @@ const RequestTabs = () => {
return null;
}
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (!activeTab) {
return <StyledWrapper>Something went wrong!</StyledWrapper>;
}
const activeCollection = find(collections, (c) => c.uid === activeTab.collectionUid);
const activeEnvironment = activeCollection
? findEnvironmentInCollection(activeCollection, activeCollection.activeEnvironmentUid)
: null;
const collectionRequestTabs = filter(tabs, (t) => t.collectionUid === activeTab.collectionUid);
const effectiveSidebarWidth = sidebarCollapsed ? 0 : leftSidebarWidth;
const maxTablistWidth = screenWidth - effectiveSidebarWidth - 150;
@@ -102,9 +114,14 @@ const RequestTabs = () => {
});
};
const getRootClassname = () => {
return classnames({
'has-chevrons': showChevrons
});
};
// Todo: Must support ephemeral requests
return (
<StyledWrapper>
<StyledWrapper color={activeEnvironment?.color} className={getRootClassname()}>
{newRequestModalOpen && (
<NewRequest collectionUid={activeCollection?.uid} onClose={() => setNewRequestModalOpen(false)} />
)}

View File

@@ -36,6 +36,7 @@ import {
sortCollections as _sortCollections,
updateCollectionMountStatus,
moveCollection,
saveEnvironmentColor as _saveEnvironmentColor,
workspaceEnvUpdateEvent,
requestCancelled,
resetRunResults,
@@ -2252,6 +2253,27 @@ export const mergeAndPersistEnvironment
});
};
export const saveEnvironmentColor = (color, environmentUid, collectionUid) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const collection =
findCollectionByUid(getState().collections.collections, collectionUid) ??
reject(new Error('Collection not found'));
const environment =
findEnvironmentInCollection(collection, environmentUid) ?? reject(new Error('Environment not found'));
const updatedEnvironment = { ...environment, color: color };
const { ipcRenderer } = window;
environmentSchema
.validate(updatedEnvironment)
// save to file
.then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, updatedEnvironment))
// update store
.then(() => dispatch(_saveEnvironmentColor({ color, environmentUid, collectionUid })))
.then(resolve)
.catch(reject);
});
};
export const selectEnvironment = (environmentUid, collectionUid) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const state = getState();

View File

@@ -258,6 +258,17 @@ export const collectionsSlice = createSlice({
if (environment) {
environment.variables = variables;
environment.color = color;
}
}
},
saveEnvironmentColor: (state, action) => {
const { color, environmentUid, collectionUid } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
const environment = findEnvironmentInCollection(collection, environmentUid);
if (environment) {
environment.color = color;
}
}
},
@@ -3464,6 +3475,7 @@ export const {
updatedFolderSettingsSelectedTab,
collectionUnlinkEnvFileEvent,
saveEnvironment,
saveEnvironmentColor,
selectEnvironment,
newItem,
deleteItem,

View File

@@ -12,7 +12,7 @@ const _ = require('lodash');
// }
const indentLevel = 4;
const grammar = ohm.grammar(`Bru {
BruEnvFile = (vars | secretvars)*
BruEnvFile = (vars | secretvars | color)*
nl = "\\r"? "\\n"
st = " " | "\\t"
@@ -43,6 +43,7 @@ const grammar = ohm.grammar(`Bru {
secretvars = "vars:secret" array
vars = "vars" dictionary
color = "color:" any*
}`);
const mapPairListToKeyValPairs = (pairList = []) => {
@@ -190,6 +191,11 @@ const sem = grammar.createSemantics().addAttribute('ast', {
return {
variables: vars
};
},
color: (_1, anystring) => {
return {
color: anystring.sourceString.trim()
};
}
});

View File

@@ -20,13 +20,16 @@ const envToJson = (json) => {
return indentString(`${prefix}${name}`);
});
const color = _.get(json, 'color', undefined);
let output = '';
if (!variables || !variables.length) {
return `vars {
output += `vars {
}
`;
}
let output = '';
if (vars.length) {
output += `vars {
${vars.join('\n')}
@@ -38,6 +41,10 @@ ${vars.join('\n')}
output += `vars:secret [
${secretVars.join(',\n')}
]
`;
}
if (color) {
output += `color: ${color}
`;
}

View File

@@ -16,7 +16,8 @@ const environmentVariablesSchema = Yup.object({
const environmentSchema = Yup.object({
uid: uidSchema,
name: Yup.string().min(1).required('name is required'),
variables: Yup.array().of(environmentVariablesSchema).required('variables are required')
variables: Yup.array().of(environmentVariablesSchema).required('variables are required'),
color: Yup.string().optional()
})
.noUnknown(true)
.strict();