diff --git a/packages/bruno-app/src/components/ColorBadge/index.js b/packages/bruno-app/src/components/ColorBadge/index.js new file mode 100644 index 000000000..dc372bbee --- /dev/null +++ b/packages/bruno-app/src/components/ColorBadge/index.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { useTheme } from 'providers/Theme'; + +const ColorBadge = ({ color, size = 10, showEmptyBorder = true }) => { + const sizeValue = typeof size === 'string' ? size : `${size}px`; + const { theme } = useTheme(); + + const showBorder = !color && showEmptyBorder; + + return ( +
+ ); +}; + +export default ColorBadge; diff --git a/packages/bruno-app/src/components/ColorPicker/StyledWrapper.js b/packages/bruno-app/src/components/ColorPicker/StyledWrapper.js new file mode 100644 index 000000000..3f1fc4bc0 --- /dev/null +++ b/packages/bruno-app/src/components/ColorPicker/StyledWrapper.js @@ -0,0 +1,7 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ColorPicker/index.js b/packages/bruno-app/src/components/ColorPicker/index.js new file mode 100644 index 000000000..b2cd69198 --- /dev/null +++ b/packages/bruno-app/src/components/ColorPicker/index.js @@ -0,0 +1,164 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { IconBan, IconBrush } from '@tabler/icons'; +import Dropdown from 'components/Dropdown'; +import ColorBadge from 'components/ColorBadge'; +import StyledWrapper from './StyledWrapper'; +import { parseToRgb, toColorString } from 'polished'; +import ColorRangePicker from 'components/ColorRange/index'; + +const PRESET_COLORS = [ + '#CE4F3B', + '#2E8A54', + '#346AB2', + '#C77A0F', + '#B83D7F', + '#8D44B2' +]; + +const COLOR_RANGE_SEQUENCE = ['#D85D43', '#F4BB74', '#61DCB1', '#7EBDF2', '#D48ADE', '#B491E5']; + +/** + * @param {string} hex + * @returns {red:string,green:string,blue:string} + */ +const hexToRgb = (hex) => { + try { + return parseToRgb(hex); + } catch (err) { + return { red: 0, green: 0, blue: 0 }; + } +}; + +const rgbToHex = (r, g, b) => { + return toColorString({ red: Math.round(r), green: Math.round(g), blue: Math.round(b) }); +}; + +const interpolateColor = (position) => { + const numColors = COLOR_RANGE_SEQUENCE.length; + const scaledPos = (position / 100) * (numColors - 1); + const index = Math.floor(scaledPos); + const fraction = scaledPos - index; + + if (index >= numColors - 1) { + return COLOR_RANGE_SEQUENCE[numColors - 1]; + } + + const color1 = hexToRgb(COLOR_RANGE_SEQUENCE[index]); + const color2 = hexToRgb(COLOR_RANGE_SEQUENCE[index + 1]); + + const r = color1.red + (color2.red - color1.red) * fraction; + const g = color1.green + (color2.green - color1.green) * fraction; + const b = color1.blue + (color2.blue - color1.blue) * fraction; + + return rgbToHex(r, g, b); +}; + +const findClosestPosition = (hex) => { + if (!hex) return 0; + const target = hexToRgb(hex); + let closestPos = 0; + let minDistance = Infinity; + + for (let pos = 0; pos <= 100; pos++) { + const color = hexToRgb(interpolateColor(pos)); + const distance = Math.sqrt( + Math.pow(target.red - color.red, 2) + Math.pow(target.green - color.green, 2) + Math.pow(target.blue - color.blue, 2) + ); + if (distance < minDistance) { + minDistance = distance; + closestPos = pos; + } + } + return closestPos; +}; + +const ColorPickerIcon = ({ color }) => { + if (color) { + return ; + } + return ; +}; + +const ColorPicker = ({ color, onChange, icon }) => { + const [sliderPosition, setSliderPosition] = useState(() => + color && !PRESET_COLORS.includes(color) ? findClosestPosition(color) : 0 + ); + const [customColor, setCustomColor] = useState(() => + color && !PRESET_COLORS.includes(color) ? color : COLOR_RANGE_SEQUENCE[0] + ); + const pendingColorRef = useRef(customColor); + + const handleColorSelect = (selectedColor) => { + onChange(selectedColor); + }; + + const handleSliderChange = (e) => { + const newPosition = parseInt(e.target.value, 10); + setSliderPosition(newPosition); + const newColor = interpolateColor(newPosition); + setCustomColor(newColor); + pendingColorRef.current = newColor; + }; + + const handleSliderEnd = () => { + onChange(pendingColorRef.current); + }; + + const defaultIcon = ( +
+ +
+ ); + + const colorPickerContent = ( + +
+
+
handleColorSelect(null)} + title="No color" + > + +
+ {PRESET_COLORS.map((presetColor, index) => ( +
handleColorSelect(presetColor)} + title={presetColor} + /> + ))} +
+ +
+
handleColorSelect(customColor)} + title="Custom color" + /> + +
+
+ + ); + + return ( + + {colorPickerContent} + + ); +}; + +export default ColorPicker; diff --git a/packages/bruno-app/src/components/ColorRange/StyledWrapper.js b/packages/bruno-app/src/components/ColorRange/StyledWrapper.js new file mode 100644 index 000000000..7d5addb9a --- /dev/null +++ b/packages/bruno-app/src/components/ColorRange/StyledWrapper.js @@ -0,0 +1,45 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .hue-slider { + -webkit-appearance: none; + appearance: none; + height: 4px; + border-radius: 2px; + outline: none; + } + + .hue-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: ${(props) => props.color ?? props.theme.bg}; + border: none; + cursor: pointer; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + transition: transform 0.1s ease; + } + + .hue-slider::-webkit-slider-thumb:hover { + transform: scale(1.1); + } + + .hue-slider::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: ${(props) => props.color ?? props.theme.bg}; + border: none; + cursor: pointer; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + transition: transform 0.1s ease; + } + + .hue-slider::-moz-range-thumb:hover { + transform: scale(1.1); + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ColorRange/index.js b/packages/bruno-app/src/components/ColorRange/index.js new file mode 100644 index 000000000..09d14f50c --- /dev/null +++ b/packages/bruno-app/src/components/ColorRange/index.js @@ -0,0 +1,23 @@ +import StyledWrapper from './StyledWrapper'; + +const ColorRangePicker = ({ selectedColor, className, value, onChange, colorRange, ...props }) => { + return ( + + + + ); +}; + +export default ColorRangePicker; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSelector/EnvironmentListContent/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSelector/EnvironmentListContent/index.js index 0c74c6d9b..5685ddee8 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSelector/EnvironmentListContent/index.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSelector/EnvironmentListContent/index.js @@ -1,6 +1,7 @@ import React from 'react'; import { IconPlus, IconDownload, IconSettings } from '@tabler/icons'; import ToolHint from 'components/ToolHint'; +import ColorBadge from 'components/ColorBadge'; const EnvironmentListContent = ({ environments, @@ -38,6 +39,7 @@ const EnvironmentListContent = ({ data-tooltip-content={env.name} data-tooltip-hidden={env.name?.length < 90} > + {env.name}
))} diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSelector/StyledWrapper.js b/packages/bruno-app/src/components/Environments/EnvironmentSelector/StyledWrapper.js index d36b5a8ee..082c7a50f 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSelector/StyledWrapper.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSelector/StyledWrapper.js @@ -33,8 +33,7 @@ const Wrapper = styled.div` } .env-separator { - color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.separator}; - margin: 0 0.35rem; + background-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.separator}; } .env-text-inactive { diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSelector/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSelector/index.js index 8b454afb5..3fc299f72 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSelector/index.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSelector/index.js @@ -13,6 +13,166 @@ import ImportEnvironmentModal from 'components/Environments/Common/ImportEnviron import CreateGlobalEnvironment from 'components/WorkspaceHome/WorkspaceEnvironments/CreateEnvironment'; import ToolHint from 'components/ToolHint'; import StyledWrapper from './StyledWrapper'; +import { transparentize, toColorString, parseToRgb } from 'polished'; + +const TABS = [ + { id: 'collection', label: 'Collection', icon: }, + { id: 'global', label: 'Global', icon: } +]; + +const EMPTY_STATE_DESCRIPTIONS = { + collection: 'Create your first environment to begin working with your collection.', + global: 'Create your first global environment to begin working across collections.' +}; + +/** + * Generates background color with transparency for environment badges + */ +const getEnvBackgroundColor = (color) => (color ? transparentize(1 - 0.12, color) : 'transparent'); + +/** + * Calculates the style for an environment badge section + */ +const getEnvBadgeStyle = (environment, position, hasOtherEnv) => { + const color = environment?.color; + const isLeft = position === 'left'; + + // Determine border radius based on position and whether other env exists + let borderRadius = '0.3rem'; + if (hasOtherEnv) { + borderRadius = isLeft ? '0.3rem 0 0 0.3rem' : '0 0.3rem 0.3rem 0'; + } + + // Determine padding based on position + const padding = isLeft + ? hasOtherEnv + ? '0.25rem 0.5rem 0.25rem 0.5rem' + : '0.25rem 0.3rem 0.25rem 0.5rem' + : '0.25rem 0.3rem 0.25rem 0.5rem'; + + return { + backgroundColor: getEnvBackgroundColor(color), + padding, + borderRadius + }; +}; + +/** + * Calculates dropdown width based on longest environment name + */ +const calculateDropdownWidth = (environments, globalEnvironments) => { + const allEnvironments = [...environments, ...globalEnvironments]; + if (allEnvironments.length === 0) return 0; + + const maxCharLength = Math.max(...allEnvironments.map((env) => env.name?.length || 0)); + // 8 pixels per character (rough estimate for average character width) + return maxCharLength * 8; +}; + +/** + * Displays a single environment with icon, name, and optional color styling + */ +const EnvironmentBadge = ({ environment, icon: Icon }) => { + if (!environment) return null; + + const colorStyle = environment.color ? { color: environment.color } : {}; + + return ( + <> + + + + ); +}; + +/** + * Dropdown trigger component showing active environments + */ +const DropdownTrigger = forwardRef(({ collectionEnv, globalEnv }, ref) => { + const hasAnyEnv = collectionEnv || globalEnv; + + // Empty state - no environments selected + if (!hasAnyEnv) { + return ( +
+ No Environment + +
+ ); + } + + // Only collection env selected - caret goes with collection env + if (collectionEnv && !globalEnv) { + return ( +
+
+ + +
+
+ ); + } + + // Only global env selected - caret goes with global env + if (!collectionEnv && globalEnv) { + return ( +
+
+ + +
+
+ ); + } + + // Both environments selected + return ( +
+ {/* Collection Environment Section */} +
+ +
+ + {/* Separator */} +
+ + {/* Global Environment Section + Caret */} +
+ + +
+
+ ); +}); const EnvironmentSelector = ({ collection }) => { const dispatch = useDispatch(); @@ -35,159 +195,82 @@ const EnvironmentSelector = ({ collection }) => { ? find(environments, (e) => e.uid === activeEnvironmentUid) : null; - const tabs = [ - { id: 'collection', label: 'Collection', icon: }, - { id: 'global', label: 'Global', icon: } - ]; + const dropdownWidth = useMemo( + () => calculateDropdownWidth(environments, globalEnvironments), + [environments, globalEnvironments] + ); - const onDropdownCreate = (ref) => { - dropdownTippyRef.current = ref; - }; + const description = EMPTY_STATE_DESCRIPTIONS[activeTab]; - // Get description based on active tab - const description - = activeTab === 'collection' - ? 'Create your first environment to begin working with your collection.' - : 'Create your first global environment to begin working across collections.'; + const hideDropdown = () => dropdownTippyRef.current?.hide(); - // Environment selection handler const handleEnvironmentSelect = (environment) => { const action = activeTab === 'collection' - ? selectEnvironment(environment ? environment.uid : null, collection.uid) - : selectGlobalEnvironment({ environmentUid: environment ? environment.uid : null }); + ? selectEnvironment(environment?.uid || null, collection.uid) + : selectGlobalEnvironment({ environmentUid: environment?.uid || null }); dispatch(action) .then(() => { - if (environment) { - toast.success(`Environment changed to ${environment.name}`); - } else { - toast.success('No Environments are active now'); - } - dropdownTippyRef.current.hide(); + toast.success(environment ? `Environment changed to ${environment.name}` : 'No Environments are active now'); + hideDropdown(); }) - .catch((err) => { + .catch(() => { toast.error('An error occurred while selecting the environment'); }); }; - // Settings handler - opens environment settings tab const handleSettingsClick = () => { - if (activeTab === 'collection') { - dispatch( - addTab({ - uid: `${collection.uid}-environment-settings`, - collectionUid: collection.uid, - type: 'environment-settings' - }) - ); - } else { - dispatch( - addTab({ - uid: `${collection.uid}-global-environment-settings`, - collectionUid: collection.uid, - type: 'global-environment-settings' - }) - ); - } - dropdownTippyRef.current.hide(); + const isCollection = activeTab === 'collection'; + dispatch( + addTab({ + uid: `${collection.uid}-${isCollection ? 'environment' : 'global-environment'}-settings`, + collectionUid: collection.uid, + type: isCollection ? 'environment-settings' : 'global-environment-settings' + }) + ); + hideDropdown(); }; - // Create handler const handleCreateClick = () => { if (activeTab === 'collection') { setShowCreateCollectionModal(true); } else { setShowCreateGlobalModal(true); } - dropdownTippyRef.current.hide(); + hideDropdown(); }; - // Import handler const handleImportClick = () => { if (activeTab === 'collection') { setShowImportCollectionModal(true); } else { setShowImportGlobalModal(true); } - dropdownTippyRef.current.hide(); + hideDropdown(); }; - // Calculate dropdown width based on the longest environment name. - // To prevent resizing while switching between collection and global environments. - const dropdownWidth = useMemo(() => { - const allEnvironments = [...environments, ...globalEnvironments]; - if (allEnvironments.length === 0) return 0; - - const maxCharLength = Math.max(...allEnvironments.map((env) => env.name?.length || 0)); - // 8 pixels per character: This is a rough estimate for the average character width in most fonts - // (monospace fonts are typically 8-10px, proportional fonts vary but 8px is a safe average) - return maxCharLength * 8; - }, [environments, globalEnvironments]); - - // Create icon component for dropdown trigger - const Icon = forwardRef((props, ref) => { - const hasAnyEnv = activeGlobalEnvironment || activeCollectionEnvironment; - - const displayContent = hasAnyEnv ? ( - <> - {activeCollectionEnvironment && ( - <> -
- - -
- {activeGlobalEnvironment && |} - - )} - {activeGlobalEnvironment && ( -
- - -
- )} - - ) : ( - No Environment + const openEnvironmentSettingsTab = (type) => { + dispatch( + addTab({ + uid: `${collection.uid}-${type}-settings`, + collectionUid: collection.uid, + type: `${type}-settings` + }) ); - - return ( -
- {displayContent} - -
- ); - }); + }; return (
- } placement="bottom-end"> + (dropdownTippyRef.current = ref)} + icon={} + placement="bottom-end" + > {/* Tab Headers */}
- {tabs.map((tab) => ( + {TABS.map((tab) => (
) : ( -

{environment.name}

+
+

{environment.name}

+ +
)}
{nameError && isRenaming &&
{nameError}
} diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js index 920386fa1..542b93f5f 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js @@ -110,6 +110,7 @@ const StyledWrapper = styled.div` display: flex; align-items: center; justify-content: space-between; + gap: 8px; padding: 4px 8px; margin-bottom: 1px; font-size: 13px; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js index e7d154022..c7f40a13f 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js @@ -6,6 +6,7 @@ import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX } from import StyledWrapper from './StyledWrapper'; import ConfirmSwitchEnv from 'components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/ConfirmSwitchEnv'; import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal'; +import ColorBadge from 'components/ColorBadge'; import { isEqual } from 'lodash'; import { useDispatch } from 'react-redux'; import { addEnvironment, renameEnvironment, selectEnvironment } from 'providers/ReduxStore/slices/collections/actions'; @@ -367,6 +368,7 @@ const EnvironmentList = ({
) : ( <> + {env.name}
{activeEnvironmentUid === env.uid ? ( diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/index.js index 362e33ac6..9f7d2705c 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/index.js @@ -1,12 +1,13 @@ import { IconCopy, IconEdit, IconTrash, IconCheck, IconX } from '@tabler/icons'; import { useState, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { renameGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; +import { renameGlobalEnvironment, updateGlobalEnvironmentColor } from 'providers/ReduxStore/slices/global-environments'; import { validateName, validateNameError } from 'utils/common/regex'; import toast from 'react-hot-toast'; import CopyEnvironment from '../../CopyEnvironment'; import DeleteEnvironment from '../../DeleteEnvironment'; import EnvironmentVariables from './EnvironmentVariables'; +import ColorPicker from 'components/ColorPicker'; import StyledWrapper from './StyledWrapper'; const EnvironmentDetails = ({ environment, setIsModified, collection }) => { @@ -110,6 +111,16 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => { } }; + const handleColorChange = (color) => { + dispatch(updateGlobalEnvironmentColor(environment.uid, color)) + .then(() => { + toast.success('Environment color updated!'); + }) + .catch(() => { + toast.error('An error occurred while updating the environment color'); + }); + }; + return ( {openDeleteModal && ( @@ -159,7 +170,10 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
) : ( -

{environment.name}

+
+

{environment.name}

+ +
)}
{nameError && isRenaming &&
{nameError}
} diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/StyledWrapper.js index ead76685a..cbf76f605 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/StyledWrapper.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/StyledWrapper.js @@ -110,6 +110,7 @@ const StyledWrapper = styled.div` display: flex; align-items: center; justify-content: space-between; + gap: 8px; padding: 4px 8px; margin-bottom: 1px; font-size: 13px; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/index.js index 7ce4c66ef..ed42867c7 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/index.js @@ -6,6 +6,7 @@ import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX } from import StyledWrapper from './StyledWrapper'; import ConfirmSwitchEnv from './ConfirmSwitchEnv'; import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal'; +import ColorBadge from 'components/ColorBadge'; import { isEqual } from 'lodash'; import { useDispatch, useSelector } from 'react-redux'; import { addGlobalEnvironment, renameGlobalEnvironment, selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; @@ -357,6 +358,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
) : ( <> + {env.name}
{activeEnvironmentUid === env.uid ? ( diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index aea5c0352..f3f3cfef0 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -49,6 +49,7 @@ import { updateActiveConnections, saveRequest as _saveRequest, saveEnvironment as _saveEnvironment, + updateEnvironmentColor as _updateEnvironmentColor, saveCollectionDraft, saveFolderDraft, addVar, @@ -1930,6 +1931,31 @@ export const saveEnvironment = (variables, environmentUid, collectionUid) => (di }); }; +export const updateEnvironmentColor = (environmentUid, color, collectionUid) => (dispatch, getState) => { + return new Promise((resolve, reject) => { + const state = getState(); + const collection = findCollectionByUid(state.collections.collections, collectionUid); + if (!collection) { + return reject(new Error('Collection not found')); + } + + const collectionCopy = cloneDeep(collection); + const environment = findEnvironmentInCollection(collectionCopy, environmentUid); + if (!environment) { + return reject(new Error('Environment not found')); + } + + environment.color = color; + const { ipcRenderer } = window; + ipcRenderer.invoke('renderer:update-environment-color', collection.pathname, environment.name, color) + .then(() => { + dispatch(_updateEnvironmentColor({ environmentUid, color, collectionUid })); + resolve(); + }) + .catch(reject); + }); +}; + /** * Update a variable value directly in the file without affecting draft state * @param {string} pathname - File path diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index 753d50124..efec9f44a 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -277,6 +277,18 @@ export const collectionsSlice = createSlice({ } } }, + updateEnvironmentColor: (state, action) => { + const { environmentUid, color, collectionUid } = action.payload; + const collection = findCollectionByUid(state.collections, collectionUid); + + if (collection) { + const environment = findEnvironmentInCollection(collection, environmentUid); + + if (environment) { + environment.color = color; + } + } + }, newItem: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); @@ -3465,6 +3477,7 @@ export const { collectionUnlinkEnvFileEvent, saveEnvironment, selectEnvironment, + updateEnvironmentColor, newItem, deleteItem, renameItem, diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/global-environments.js b/packages/bruno-app/src/providers/ReduxStore/slices/global-environments.js index 028c0db29..5eb823ada 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/global-environments.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/global-environments.js @@ -81,6 +81,12 @@ export const globalEnvironmentsSlice = createSlice({ }, clearGlobalEnvironmentDraft: (state) => { state.globalEnvironmentDraft = null; + }, + _updateGlobalEnvironmentColor: (state, action) => { + const { environmentUid, color } = action.payload; + if (environmentUid) { + state.globalEnvironments = state.globalEnvironments.map((env) => env?.uid == environmentUid ? { ...env, color } : env); + } } } }); @@ -93,6 +99,7 @@ export const { _copyGlobalEnvironment, _selectGlobalEnvironment, _deleteGlobalEnvironment, + _updateGlobalEnvironmentColor, setGlobalEnvironmentDraft, clearGlobalEnvironmentDraft } = globalEnvironmentsSlice.actions; @@ -303,4 +310,16 @@ export const globalEnvironmentsUpdateEvent = ({ globalEnvironmentVariables }) => }); }; +export const updateGlobalEnvironmentColor = (environmentUid, color) => (dispatch, getState) => { + return new Promise((resolve, reject) => { + const { ipcRenderer } = window; + const state = getState(); + const { workspaceUid, workspacePath } = getWorkspaceContext(state); + ipcRenderer.invoke('renderer:update-global-environment-color', { environmentUid, color, workspaceUid, workspacePath }) + .then(() => dispatch(_updateGlobalEnvironmentColor({ environmentUid, color }))) + .then(resolve) + .catch(reject); + }); +}; + export default globalEnvironmentsSlice.reducer; diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index eb465d8a1..e72e41195 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -620,6 +620,28 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { } }); + // update environment color + ipcMain.handle('renderer:update-environment-color', async (event, collectionPathname, environmentName, color) => { + try { + const format = getCollectionFormat(collectionPathname); + const envDirPath = path.join(collectionPathname, 'environments'); + const envFilePath = path.join(envDirPath, `${environmentName}.${format}`); + + if (!fs.existsSync(envFilePath)) { + throw new Error(`environment: ${envFilePath} does not exist`); + } + + // Read, update color, and write back to file + const fileContent = fs.readFileSync(envFilePath, 'utf8'); + const environment = parseEnvironment(fileContent, { format }); + environment.color = color; + const updatedContent = stringifyEnvironment(environment, { format }); + fs.writeFileSync(envFilePath, updatedContent, 'utf8'); + } catch (error) { + return Promise.reject(error); + } + }); + // Generic environment export handler ipcMain.handle('renderer:export-environment', async (event, { environments, environmentType, filePath, exportFormat = 'folder' }) => { try { diff --git a/packages/bruno-electron/src/ipc/global-environments.js b/packages/bruno-electron/src/ipc/global-environments.js index 2432e283e..432fd7330 100644 --- a/packages/bruno-electron/src/ipc/global-environments.js +++ b/packages/bruno-electron/src/ipc/global-environments.js @@ -99,6 +99,19 @@ const registerGlobalEnvironmentsIpc = (mainWindow, workspaceEnvironmentsManager) return Promise.reject(error); } }); + + ipcMain.handle('renderer:update-global-environment-color', async (event, { environmentUid, color, workspacePath }) => { + try { + if (workspacePath && workspaceEnvironmentsManager) { + return await workspaceEnvironmentsManager.updateGlobalEnvironmentColorByPath(workspacePath, { environmentUid, color }); + } + + globalEnvironmentsStore.updateGlobalEnvironmentColor({ environmentUid, color }); + } catch (error) { + console.error('Error in renderer:update-global-environment-color:', error); + return Promise.reject(error); + } + }); }; module.exports = registerGlobalEnvironmentsIpc; diff --git a/packages/bruno-electron/src/store/global-environments.js b/packages/bruno-electron/src/store/global-environments.js index 0bdeb6957..084803af3 100644 --- a/packages/bruno-electron/src/store/global-environments.js +++ b/packages/bruno-electron/src/store/global-environments.js @@ -162,6 +162,15 @@ class GlobalEnvironmentsStore { } this.setGlobalEnvironments(globalEnvironments); } + + updateGlobalEnvironmentColor({ environmentUid, color }) { + let globalEnvironments = this.getGlobalEnvironments(); + const environment = globalEnvironments.find((env) => env?.uid == environmentUid); + if (environment) { + environment.color = color; + } + this.setGlobalEnvironments(globalEnvironments); + } } const globalEnvironmentsStore = new GlobalEnvironmentsStore(); diff --git a/packages/bruno-electron/src/store/workspace-environments.js b/packages/bruno-electron/src/store/workspace-environments.js index b27ad67b4..3098f2b35 100644 --- a/packages/bruno-electron/src/store/workspace-environments.js +++ b/packages/bruno-electron/src/store/workspace-environments.js @@ -325,6 +325,30 @@ class GlobalEnvironmentsManager { } } + async updateGlobalEnvironmentColor(workspacePath, environmentUid, color) { + try { + if (!workspacePath) { + throw new Error('Workspace path is required'); + } + + const envFile = this.findEnvironmentFileByUid(workspacePath, environmentUid); + + if (!envFile) { + throw new Error(`Environment file not found for uid: ${environmentUid}`); + } + + const environment = await this.parseEnvironmentFile(envFile.filePath, workspacePath); + environment.color = color; + + const content = stringifyEnvironment(environment, { format: 'yml' }); + await writeFile(envFile.filePath, content); + + return true; + } catch (error) { + throw error; + } + } + async getGlobalEnvironmentsByPath(workspacePath) { return this.getGlobalEnvironments(workspacePath); } @@ -348,6 +372,10 @@ class GlobalEnvironmentsManager { async selectGlobalEnvironmentByPath(workspacePath, params) { return this.selectGlobalEnvironment(workspacePath, params); } + + async updateGlobalEnvironmentColorByPath(workspacePath, { environmentUid, color }) { + return this.updateGlobalEnvironmentColor(workspacePath, environmentUid, color); + } } const globalEnvironmentsManager = new GlobalEnvironmentsManager(); diff --git a/packages/bruno-filestore/src/formats/yml/parseEnvironment.ts b/packages/bruno-filestore/src/formats/yml/parseEnvironment.ts index 5eaba6baf..92b4016b8 100644 --- a/packages/bruno-filestore/src/formats/yml/parseEnvironment.ts +++ b/packages/bruno-filestore/src/formats/yml/parseEnvironment.ts @@ -43,7 +43,8 @@ const parseEnvironment = (ymlString: string): BrunoEnvironment => { const brunoEnvironment: BrunoEnvironment = { uid: uuid(), name: ensureString(ocEnvironment.name, 'Untitled Environment'), - variables: toBrunoEnvironmentVariables(ocEnvironment.variables) + variables: toBrunoEnvironmentVariables(ocEnvironment.variables), + color: ocEnvironment.color || null }; return brunoEnvironment; diff --git a/packages/bruno-filestore/src/formats/yml/stringifyEnvironment.ts b/packages/bruno-filestore/src/formats/yml/stringifyEnvironment.ts index 640cef6b2..2f57f4aad 100644 --- a/packages/bruno-filestore/src/formats/yml/stringifyEnvironment.ts +++ b/packages/bruno-filestore/src/formats/yml/stringifyEnvironment.ts @@ -47,7 +47,10 @@ const stringifyEnvironment = (environment: BrunoEnvironment): string => { name: environment.name }; - // Convert variables if they exist + if (environment.color) { + ocEnvironment.color = environment.color; + } + if (environment.variables?.length) { const ocVariables = toOpenCollectionEnvironmentVariables(environment.variables); if (ocVariables) { diff --git a/packages/bruno-filestore/test-results/.last-run.json b/packages/bruno-filestore/test-results/.last-run.json new file mode 100644 index 000000000..5fca3f84b --- /dev/null +++ b/packages/bruno-filestore/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "failed", + "failedTests": [] +} \ No newline at end of file diff --git a/packages/bruno-lang/v2/src/envToJson.js b/packages/bruno-lang/v2/src/envToJson.js index 124d22318..a4e97a941 100644 --- a/packages/bruno-lang/v2/src/envToJson.js +++ b/packages/bruno-lang/v2/src/envToJson.js @@ -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() + }; } }); diff --git a/packages/bruno-lang/v2/src/jsonToEnv.js b/packages/bruno-lang/v2/src/jsonToEnv.js index 185dc46bb..09811734b 100644 --- a/packages/bruno-lang/v2/src/jsonToEnv.js +++ b/packages/bruno-lang/v2/src/jsonToEnv.js @@ -3,6 +3,8 @@ const { getValueString, indentString } = require('./utils'); const envToJson = (json) => { const variables = _.get(json, 'variables', []); + const color = _.get(json, 'color', null); + const vars = variables .filter((variable) => !variable.secret) .map((variable) => { @@ -20,13 +22,14 @@ const envToJson = (json) => { return indentString(`${prefix}${name}`); }); + 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} `; } diff --git a/packages/bruno-schema-types/src/collection/environment.ts b/packages/bruno-schema-types/src/collection/environment.ts index 90fdbfaf3..ecc14b7d1 100644 --- a/packages/bruno-schema-types/src/collection/environment.ts +++ b/packages/bruno-schema-types/src/collection/environment.ts @@ -13,6 +13,7 @@ export interface Environment { uid: UID; name: string; variables: EnvironmentVariable[]; + color?: string | null; } export type Environments = Environment[]; diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js index 09b40e1ed..d17fa9f6c 100644 --- a/packages/bruno-schema/src/collections/index.js +++ b/packages/bruno-schema/src/collections/index.js @@ -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().nullable().optional() }) .noUnknown(true) .strict(); diff --git a/tests/environments/color-picker/collection/bruno.json b/tests/environments/color-picker/collection/bruno.json new file mode 100644 index 000000000..a2b575fc0 --- /dev/null +++ b/tests/environments/color-picker/collection/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "global-env-config-selection", + "type": "collection" +} diff --git a/tests/environments/color-picker/collection/test-request.bru b/tests/environments/color-picker/collection/test-request.bru new file mode 100644 index 000000000..ea3cdd03f --- /dev/null +++ b/tests/environments/color-picker/collection/test-request.bru @@ -0,0 +1,17 @@ +meta { + name: test-request + type: http + seq: 1 +} + +get { + url: {{host}}/api/echo + body: none + auth: none +} + +tests { + test("should get 200 response", function() { + expect(res.getStatus()).to.equal(200); + }); +} diff --git a/tests/environments/color-picker/color-picker.spec.ts b/tests/environments/color-picker/color-picker.spec.ts new file mode 100644 index 000000000..b74b105b3 --- /dev/null +++ b/tests/environments/color-picker/color-picker.spec.ts @@ -0,0 +1,156 @@ +import { test, expect } from '../../../playwright'; +import { closeAllCollections } from '../../utils/page/actions'; + +const PRESET_COLORS = [ + '#CE4F3B', + '#2E8A54', + '#346AB2', + '#C77A0F', + '#B83D7F', + '#8D44B2' +]; + +// Convert hex color to RGB format used by CSS +const hexToRgb = (hex: string): string => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + if (!result) return ''; + const r = parseInt(result[1], 16); + const g = parseInt(result[2], 16); + const b = parseInt(result[3], 16); + return `rgb(${r}, ${g}, ${b})`; +}; + +test.describe('Color Picker Tests', () => { + test.afterAll(async ({ pageWithUserData: page }) => { + await closeAllCollections(page); + }); + + test('should select a preset color for global environment', async ({ pageWithUserData: page }) => { + // Open the collection from sidebar + await page.locator('#sidebar-collection-name').filter({ hasText: 'global-env-config-selection' }).click(); + + // Open global environment configuration + await page.getByTestId('environment-selector-trigger').click(); + await page.getByTestId('env-tab-global').click(); + await page.getByText('Configure', { exact: true }).click(); + + // Wait for the environments tab to be visible + const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' }); + await expect(envTab).toBeVisible(); + + // Click on the color picker icon (brush icon) next to the environment name + const colorPickerTrigger = page.locator('[title="Change color"]').first(); + await colorPickerTrigger.click(); + + // Wait for the color picker dropdown to appear + const colorPickerDropdown = page.locator('.tippy-box'); + await expect(colorPickerDropdown).toBeVisible(); + + // Select the first preset color (red) using title attribute + const presetColor = PRESET_COLORS[0]; + const colorOption = colorPickerDropdown.locator(`[title="${presetColor}"]`); + await colorOption.click(); + + // Verify the color badge in the environment list shows the selected color + const activeEnvItem = page.locator('.environment-item.active'); + const colorBadge = activeEnvItem.locator('.rounded-full').first(); + await expect(colorBadge).toHaveCSS('background-color', hexToRgb(presetColor)); + }); + + test('should remove color from environment', async ({ pageWithUserData: page }) => { + // Open global environment configuration + await page.getByTestId('environment-selector-trigger').click(); + await page.getByTestId('env-tab-global').click(); + await page.getByText('Configure', { exact: true }).click(); + + // Wait for the environments tab to be visible + const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' }); + await expect(envTab).toBeVisible(); + + // Click on the color picker icon + const colorPickerTrigger = page.locator('[title="Change color"]').first(); + await colorPickerTrigger.click(); + + // Wait for the color picker dropdown to appear + const colorPickerDropdown = page.locator('.tippy-box'); + await expect(colorPickerDropdown).toBeVisible(); + + // Click the "No color" option (ban icon) + const noColorOption = colorPickerDropdown.locator('[title="No color"]'); + await noColorOption.click(); + + // Verify the color badge becomes transparent (no color) + const activeEnvItem = page.locator('.environment-item.active'); + const colorBadge = activeEnvItem.locator('.rounded-full').first(); + await expect(colorBadge).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); + }); + + test('should select custom color using slider', async ({ pageWithUserData: page }) => { + // Open global environment configuration + await page.getByTestId('environment-selector-trigger').click(); + await page.getByTestId('env-tab-global').click(); + await page.getByText('Configure', { exact: true }).click(); + + // Wait for the environments tab to be visible + const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' }); + await expect(envTab).toBeVisible(); + + // Click on the color picker icon + const colorPickerTrigger = page.locator('[title="Change color"]').first(); + await colorPickerTrigger.click(); + + // Wait for the color picker dropdown to appear + const colorPickerDropdown = page.locator('.tippy-box'); + await expect(colorPickerDropdown).toBeVisible(); + + // Find the slider and change its value + const slider = colorPickerDropdown.locator('input[type="range"]'); + await expect(slider).toBeVisible(); + + // Move slider to middle position (50%) + await slider.fill('50'); + + // Click the custom color preview to apply it + const customColorPreview = colorPickerDropdown.locator('[title="Custom color"]'); + await customColorPreview.click(); + + // Verify the color badge has a color applied (not transparent) + const activeEnvItem = page.locator('.environment-item.active'); + const colorBadge = activeEnvItem.locator('.rounded-full').first(); + const bgColor = await colorBadge.evaluate((el) => getComputedStyle(el).backgroundColor); + expect(bgColor).not.toBe('rgba(0, 0, 0, 0)'); + expect(bgColor).toMatch(/^rgb\(\d+, \d+, \d+\)$/); + }); + + test('should display color badge in environment list after selecting color', async ({ pageWithUserData: page }) => { + // Open global environment configuration + await page.getByTestId('environment-selector-trigger').click(); + await page.getByTestId('env-tab-global').click(); + await page.getByText('Configure', { exact: true }).click(); + + // Wait for the environments tab to be visible + const envTab = page.locator('.request-tab').filter({ hasText: 'Global Environments' }); + await expect(envTab).toBeVisible(); + + // Get the currently selected environment name + const activeEnvItem = page.locator('.environment-item.active'); + const envName = await activeEnvItem.locator('.environment-name').textContent(); + + // Click on the color picker icon + const colorPickerTrigger = page.locator('[title="Change color"]').first(); + await colorPickerTrigger.click(); + + // Wait for the color picker dropdown to appear and select a color + const colorPickerDropdown = page.locator('.tippy-box'); + await expect(colorPickerDropdown).toBeVisible(); + + const presetColor = PRESET_COLORS[1]; // green + const colorOption = colorPickerDropdown.locator(`[title="${presetColor}"]`); + await colorOption.click(); + + // Verify the color badge in the environment list shows the selected color + const envListItem = page.locator('.environment-item').filter({ hasText: envName as string }); + const colorBadge = envListItem.locator('.rounded-full').first(); + await expect(colorBadge).toHaveCSS('background-color', hexToRgb(presetColor)); + }); +}); diff --git a/tests/environments/color-picker/init-user-data/collection-security.json b/tests/environments/color-picker/init-user-data/collection-security.json new file mode 100644 index 000000000..bd8bdb1d0 --- /dev/null +++ b/tests/environments/color-picker/init-user-data/collection-security.json @@ -0,0 +1,10 @@ +{ + "collections": [ + { + "path": "{{projectRoot}}/tests/environments/global-env-config-selection/collection", + "securityConfig": { + "jsSandboxMode": "safe" + } + } + ] +} diff --git a/tests/environments/color-picker/init-user-data/global-environments.json b/tests/environments/color-picker/init-user-data/global-environments.json new file mode 100644 index 000000000..45c301067 --- /dev/null +++ b/tests/environments/color-picker/init-user-data/global-environments.json @@ -0,0 +1,47 @@ +{ + "environments": [ + { + "uid": "FlaexlO7lcH7UtEpWsVyz", + "name": "Development Environment", + "variables": [ + { + "uid": "lflBDSYBdHkUedYhBF4Ty", + "name": "env_type", + "value": "development", + "type": "text", + "secret": false, + "enabled": true + } + ] + }, + { + "uid": "MsHcnAIonZ3455OfvpTUT", + "name": "Production Environment", + "variables": [ + { + "uid": "TZljXLErzW1nUWoozntZE", + "name": "env_type", + "value": "production", + "type": "text", + "secret": false, + "enabled": true + } + ] + }, + { + "uid": "VdUAdMPcfapMCqjKAeUiI", + "name": "Staging Environment", + "variables": [ + { + "uid": "FwoWhHvu9eLhA8H4brG6f", + "name": "env_type", + "value": "staging", + "type": "text", + "secret": false, + "enabled": true + } + ] + } + ], + "activeGlobalEnvironmentUid": "MsHcnAIonZ3455OfvpTUT" +} \ No newline at end of file diff --git a/tests/environments/color-picker/init-user-data/preferences.json b/tests/environments/color-picker/init-user-data/preferences.json new file mode 100644 index 000000000..3fa671a0b --- /dev/null +++ b/tests/environments/color-picker/init-user-data/preferences.json @@ -0,0 +1,6 @@ +{ + "maximized": true, + "lastOpenedCollections": [ + "{{projectRoot}}/tests/environments/global-env-config-selection/collection" + ] +}