From 700e25a1d5aa3e77b4c9233db9a83587cabd729b Mon Sep 17 00:00:00 2001 From: naman-bruno Date: Mon, 2 Feb 2026 19:43:54 +0530 Subject: [PATCH] Add: dotenv visual editor (#6964) --- .../CollapsibleSection/StyledWrapper.js | 105 +++ .../Environments/CollapsibleSection/index.js | 40 ++ .../DotEnvFileDetails/StyledWrapper.js | 93 +++ .../Environments/DotEnvFileDetails/index.js | 74 +++ .../DotEnvFileEditor/DotEnvEmptyState.js | 16 + .../DotEnvFileEditor/DotEnvErrorMessage.js | 25 + .../DotEnvFileEditor/DotEnvRawView.js | 43 ++ .../DotEnvFileEditor/DotEnvTableView.js | 130 ++++ .../DotEnvFileEditor/StyledWrapper.js | 185 ++++++ .../Environments/DotEnvFileEditor/index.js | 340 ++++++++++ .../Environments/DotEnvFileEditor/utils.js | 59 ++ .../DeleteDotEnvFile/StyledWrapper.js | 15 + .../DeleteDotEnvFile/index.js | 30 + .../EnvironmentList/StyledWrapper.js | 64 +- .../EnvironmentList/index.js | 581 +++++++++++++---- .../Environments/EnvironmentSettings/index.js | 34 - .../GlobalEnvironmentSettings/index.js | 8 +- .../EnvironmentList/StyledWrapper.js | 122 +++- .../EnvironmentList/index.js | 597 +++++++++++++----- .../WorkspaceEnvironments/index.js | 39 +- .../src/hooks/useOnClickOutside/index.js | 6 +- .../src/providers/App/useIpcEvents.js | 33 +- .../ReduxStore/slices/collections/actions.js | 68 ++ .../ReduxStore/slices/collections/index.js | 32 + .../ReduxStore/slices/workspaces/actions.js | 80 +++ .../ReduxStore/slices/workspaces/index.js | 35 +- .../src/app/collection-watcher.js | 58 +- .../bruno-electron/src/app/dotenv-watcher.js | 214 +++++++ .../src/app/workspace-watcher.js | 73 +-- packages/bruno-electron/src/ipc/collection.js | 94 +++ .../src/ipc/global-environments.js | 114 +++- .../bruno-electron/src/store/process-env.js | 5 + .../bruno-electron/src/utils/filesystem.js | 8 + 33 files changed, 2913 insertions(+), 507 deletions(-) create mode 100644 packages/bruno-app/src/components/Environments/CollapsibleSection/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/Environments/CollapsibleSection/index.js create mode 100644 packages/bruno-app/src/components/Environments/DotEnvFileDetails/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/Environments/DotEnvFileDetails/index.js create mode 100644 packages/bruno-app/src/components/Environments/DotEnvFileEditor/DotEnvEmptyState.js create mode 100644 packages/bruno-app/src/components/Environments/DotEnvFileEditor/DotEnvErrorMessage.js create mode 100644 packages/bruno-app/src/components/Environments/DotEnvFileEditor/DotEnvRawView.js create mode 100644 packages/bruno-app/src/components/Environments/DotEnvFileEditor/DotEnvTableView.js create mode 100644 packages/bruno-app/src/components/Environments/DotEnvFileEditor/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/Environments/DotEnvFileEditor/index.js create mode 100644 packages/bruno-app/src/components/Environments/DotEnvFileEditor/utils.js create mode 100644 packages/bruno-app/src/components/Environments/EnvironmentSettings/DeleteDotEnvFile/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/Environments/EnvironmentSettings/DeleteDotEnvFile/index.js create mode 100644 packages/bruno-electron/src/app/dotenv-watcher.js diff --git a/packages/bruno-app/src/components/Environments/CollapsibleSection/StyledWrapper.js b/packages/bruno-app/src/components/Environments/CollapsibleSection/StyledWrapper.js new file mode 100644 index 000000000..a16375965 --- /dev/null +++ b/packages/bruno-app/src/components/Environments/CollapsibleSection/StyledWrapper.js @@ -0,0 +1,105 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + display: flex; + flex-direction: column; + + &.collapsed { + flex-shrink: 0; + + .section-content { + display: none; + } + } + + &.expanded { + flex: 1; + min-height: 0; + overflow: hidden; + + .section-content { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + } + } + + .section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + cursor: pointer; + user-select: none; + border-radius: 4px; + transition: background 0.15s ease; + flex-shrink: 0; + + &:hover { + background: ${(props) => props.theme.workspace.button.bg}; + } + + .section-title-wrapper { + display: flex; + align-items: center; + gap: 6px; + } + + .section-icon { + color: ${(props) => props.theme.colors.text.muted}; + transition: transform 0.2s ease; + + &.expanded { + transform: rotate(90deg); + } + } + + .section-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: ${(props) => props.theme.sidebar.color}; + } + + .section-badge { + font-size: 10px; + padding: 1px 6px; + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + border-radius: 10px; + color: ${(props) => props.theme.colors.text.muted}; + } + + .section-actions { + display: flex; + align-items: center; + gap: 2px; + + .btn-action { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + background: transparent; + border: none; + border-radius: 4px; + color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + color: ${(props) => props.theme.text}; + } + } + } + } + + .section-content { + padding: 4px 0; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Environments/CollapsibleSection/index.js b/packages/bruno-app/src/components/Environments/CollapsibleSection/index.js new file mode 100644 index 000000000..169720b48 --- /dev/null +++ b/packages/bruno-app/src/components/Environments/CollapsibleSection/index.js @@ -0,0 +1,40 @@ +import React from 'react'; +import { IconChevronRight } from '@tabler/icons'; +import StyledWrapper from './StyledWrapper'; + +const CollapsibleSection = ({ + title, + expanded, + onToggle, + badge, + actions, + children +}) => { + return ( + +
+
+ + {title} + {badge !== undefined && badge !== null && ( + {badge} + )} +
+ {actions && ( +
e.stopPropagation()}> + {actions} +
+ )} +
+
+ {children} +
+
+ ); +}; + +export default CollapsibleSection; diff --git a/packages/bruno-app/src/components/Environments/DotEnvFileDetails/StyledWrapper.js b/packages/bruno-app/src/components/Environments/DotEnvFileDetails/StyledWrapper.js new file mode 100644 index 000000000..3fe8e25e2 --- /dev/null +++ b/packages/bruno-app/src/components/Environments/DotEnvFileDetails/StyledWrapper.js @@ -0,0 +1,93 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + background: ${(props) => props.theme.bg}; + + .header { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px 8px 20px; + flex-shrink: 0; + + .title { + font-size: ${(props) => props.theme.font.size.base}; + font-weight: 500; + color: ${(props) => props.theme.text}; + margin: 0; + } + + .actions { + display: flex; + align-items: center; + gap: 12px; + + .view-toggle { + display: flex; + border: 1px solid ${(props) => props.theme.border.border0}; + border-radius: 4px; + overflow: hidden; + + .toggle-btn { + padding: 4px 12px; + font-size: 12px; + border: none; + background: transparent; + color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + transition: all 0.15s ease; + + &:first-child { + border-right: 1px solid ${(props) => props.theme.border.border0}; + } + + &:hover { + background: ${(props) => props.theme.sidebar.bg}; + } + + &.active { + background: ${(props) => props.theme.brand}; + color: ${(props) => props.theme.bg}; + } + } + } + + .action-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 6px; + border: none; + background: transparent; + color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + border-radius: 4px; + transition: all 0.15s ease; + + &:hover { + background: ${(props) => props.theme.sidebar.bg}; + color: ${(props) => props.theme.text}; + } + + &.delete-btn:hover { + color: ${(props) => props.theme.colors.text.danger}; + } + } + } + } + + .content { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; + padding: 0 20px 20px 20px; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Environments/DotEnvFileDetails/index.js b/packages/bruno-app/src/components/Environments/DotEnvFileDetails/index.js new file mode 100644 index 000000000..fa6333125 --- /dev/null +++ b/packages/bruno-app/src/components/Environments/DotEnvFileDetails/index.js @@ -0,0 +1,74 @@ +import React, { useState } from 'react'; +import { IconTrash } from '@tabler/icons'; +import DeleteDotEnvFile from 'components/Environments/EnvironmentSettings/DeleteDotEnvFile'; +import StyledWrapper from './StyledWrapper'; + +const DotEnvFileDetails = ({ + title, + children, + onDelete, + dotEnvExists, + viewMode, + onViewModeChange +}) => { + const [showDeleteModal, setShowDeleteModal] = useState(false); + + const handleDeleteClick = () => { + setShowDeleteModal(true); + }; + + const handleConfirmDelete = () => { + if (onDelete) { + onDelete(); + } + }; + + return ( + +
+

{title}

+
+ {dotEnvExists && ( + <> +
+ + +
+ + + )} +
+
+ + {showDeleteModal && ( + setShowDeleteModal(false)} + onConfirm={handleConfirmDelete} + filename={title} + /> + )} + +
+ {children} +
+
+ ); +}; + +export default DotEnvFileDetails; diff --git a/packages/bruno-app/src/components/Environments/DotEnvFileEditor/DotEnvEmptyState.js b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/DotEnvEmptyState.js new file mode 100644 index 000000000..6723ef18a --- /dev/null +++ b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/DotEnvEmptyState.js @@ -0,0 +1,16 @@ +import React from 'react'; +import { IconFileOff } from '@tabler/icons'; + +const DotEnvEmptyState = () => { + return ( +
+ +
No .env File
+
+ Add a variable below to create a .env file in this location. +
+
+ ); +}; + +export default DotEnvEmptyState; diff --git a/packages/bruno-app/src/components/Environments/DotEnvFileEditor/DotEnvErrorMessage.js b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/DotEnvErrorMessage.js new file mode 100644 index 000000000..a25b84dbc --- /dev/null +++ b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/DotEnvErrorMessage.js @@ -0,0 +1,25 @@ +import React from 'react'; +import { IconAlertCircle } from '@tabler/icons'; +import { Tooltip } from 'react-tooltip'; + +const DotEnvErrorMessage = React.memo(({ formik, name, index }) => { + const meta = formik.getFieldMeta(name); + const id = `error-${name}-${index}`; + + const isLastRow = index === formik.values.length - 1; + const variable = formik.values[index]; + const isEmptyRow = !variable?.name || variable.name.trim() === ''; + + if ((isLastRow && isEmptyRow) || !meta.error || !meta.touched) { + return null; + } + + return ( + + + + + ); +}); + +export default DotEnvErrorMessage; diff --git a/packages/bruno-app/src/components/Environments/DotEnvFileEditor/DotEnvRawView.js b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/DotEnvRawView.js new file mode 100644 index 000000000..2570c7378 --- /dev/null +++ b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/DotEnvRawView.js @@ -0,0 +1,43 @@ +import React from 'react'; +import CodeEditor from 'components/CodeEditor'; + +const DotEnvRawView = ({ + collection, + item, + theme, + value, + onChange, + onSave, + onReset, + isSaving +}) => { + return ( + <> +
+ +
+
+
+ + +
+
+ + ); +}; + +export default DotEnvRawView; diff --git a/packages/bruno-app/src/components/Environments/DotEnvFileEditor/DotEnvTableView.js b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/DotEnvTableView.js new file mode 100644 index 000000000..01f969215 --- /dev/null +++ b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/DotEnvTableView.js @@ -0,0 +1,130 @@ +import React, { useCallback, useRef } from 'react'; +import { TableVirtuoso } from 'react-virtuoso'; +import { IconTrash } from '@tabler/icons'; +import MultiLineEditor from 'components/MultiLineEditor/index'; +import DotEnvErrorMessage from './DotEnvErrorMessage'; +import { MIN_TABLE_HEIGHT } from './utils'; + +const TableRow = React.memo(({ children, item }) => ( + {children} +), (prevProps, nextProps) => { + const prevUid = prevProps?.item?.uid; + const nextUid = nextProps?.item?.uid; + return prevUid === nextUid && prevProps.children === nextProps.children; +}); + +const DotEnvTableView = ({ + formik, + theme, + showValueColumn, + tableHeight, + onHeightChange, + onNameChange, + onNameBlur, + onNameKeyDown, + onRemoveVar, + onSave, + onReset, + isSaving +}) => { + const handleTotalHeightChanged = useCallback((h) => { + onHeightChange(h); + }, [onHeightChange]); + + // Use refs for stable access to formik values in callbacks + const formikRef = useRef(formik); + formikRef.current = formik; + + // Don't memoize itemContent - TableVirtuoso handles this internally + // and we need fresh access to formik values + const itemContent = (index, variable) => { + const currentFormik = formikRef.current; + const isLastRow = index === currentFormik.values.length - 1; + const isEmptyRow = !variable.name || variable.name.trim() === ''; + const isLastEmptyRow = isLastRow && isEmptyRow; + + return ( + <> + +
+ onNameChange(index, e)} + onBlur={() => onNameBlur(index)} + onKeyDown={(e) => onNameKeyDown(index, e)} + /> + +
+ + {showValueColumn && ( + +
+ currentFormik.setFieldValue(`${index}.value`, newValue, true)} + onSave={onSave} + /> +
+ + )} + + {!isLastEmptyRow && ( + + )} + + + ); + }; + + return ( + <> + ( + + Name + {showValueColumn && Value} + + + )} + fixedItemHeight={35} + computeItemKey={(index, variable) => variable.uid} + itemContent={itemContent} + /> +
+
+ + +
+
+ + ); +}; + +export default DotEnvTableView; diff --git a/packages/bruno-app/src/components/Environments/DotEnvFileEditor/StyledWrapper.js b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/StyledWrapper.js new file mode 100644 index 000000000..d2cc48c7b --- /dev/null +++ b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/StyledWrapper.js @@ -0,0 +1,185 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + + .raw-editor-container { + flex: 1; + overflow: hidden; + border-radius: 8px; + border: solid 1px ${(props) => props.theme.border.border0}; + + .CodeMirror { + font-size: ${(props) => props.theme.font.size.base}; + } + } + + .table-container { + overflow-y: auto; + border-radius: 8px; + border: solid 1px ${(props) => props.theme.border.border0}; + } + + table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + font-size: 12px; + + td { + vertical-align: middle; + padding: 2px 10px; + + &:first-child { + width: 35%; + } + + &.delete-col { + width: 40px; + text-align: center; + padding: 2px 4px; + } + } + + thead { + color: ${(props) => props.theme.table.thead.color} !important; + background: ${(props) => props.theme.sidebar.bg}; + font-size: ${(props) => props.theme.font.size.base}; + user-select: none; + + td { + padding: 5px 10px !important; + border-bottom: solid 1px ${(props) => props.theme.border.border0}; + border-right: solid 1px ${(props) => props.theme.border.border0}; + + &:last-child { + border-right: none; + } + } + } + + tbody { + tr { + transition: background 0.1s ease; + + &:last-child td { + border-bottom: none; + } + + td { + border-bottom: solid 1px ${(props) => props.theme.border.border0}; + border-right: solid 1px ${(props) => props.theme.border.border0}; + + &:last-child { + border-right: none; + } + } + } + } + } + + .tooltip-mod { + max-width: 200px !important; + } + + input[type='text'] { + width: 100%; + border: 1px solid transparent; + outline: none !important; + background-color: transparent; + color: ${(props) => props.theme.text}; + padding: 0; + border-radius: 4px; + transition: all 0.15s ease; + + &:focus { + outline: none !important; + } + } + + input[type='checkbox'] { + cursor: pointer; + width: 14px; + height: 14px; + accent-color: ${(props) => props.theme.colors.accent}; + vertical-align: middle; + margin: 0; + } + + button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px; + color: ${(props) => props.theme.colors.text.muted}; + background: transparent; + border: none; + cursor: pointer; + border-radius: 4px; + transition: color 0.15s ease, background 0.15s ease; + } + + .button-container { + padding: 12px 2px; + background: ${(props) => props.theme.bg}; + flex-shrink: 0; + display: flex; + gap: 8px; + } + + .submit { + padding: 6px 16px; + font-size: ${(props) => props.theme.font.size.sm}; + border-radius: ${(props) => props.theme.border.radius.base}; + border: none; + background: ${(props) => props.theme.brand}; + color: ${(props) => props.theme.bg}; + cursor: pointer; + transition: opacity 0.15s ease; + + &:hover { + opacity: 0.9; + } + } + + .reset { + background: transparent; + padding: 6px 16px; + color: ${(props) => props.theme.brand}; + &:hover { + opacity: 0.9; + } + } + + .empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + color: ${(props) => props.theme.colors.text.muted}; + + svg { + opacity: 0.4; + margin-bottom: 12px; + } + + .title { + font-size: 13px; + font-weight: 500; + margin-bottom: 8px; + } + + .description { + font-size: 12px; + text-align: center; + max-width: 300px; + line-height: 1.5; + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Environments/DotEnvFileEditor/index.js b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/index.js new file mode 100644 index 000000000..c1a821dd9 --- /dev/null +++ b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/index.js @@ -0,0 +1,340 @@ +import React, { useCallback, useRef, useMemo, useEffect, useState } from 'react'; +import { useTheme } from 'providers/Theme'; +import { uuid } from 'utils/common'; +import { useFormik } from 'formik'; +import { variableNameRegex } from 'utils/common/regex'; +import toast from 'react-hot-toast'; + +import StyledWrapper from './StyledWrapper'; +import DotEnvTableView from './DotEnvTableView'; +import DotEnvRawView from './DotEnvRawView'; +import DotEnvEmptyState from './DotEnvEmptyState'; +import { variablesToRaw, rawToVariables, MIN_TABLE_HEIGHT } from './utils'; + +const DotEnvFileEditor = ({ + variables, + onSave, + onSaveRaw, + isModified, + setIsModified, + dotEnvExists, + rawContent, + viewMode = 'table', + collection, + item +}) => { + const { displayedTheme } = useTheme(); + const [tableHeight, setTableHeight] = useState(MIN_TABLE_HEIGHT); + // Derive a single baseline raw value for consistent dirty-tracking + const baselineRaw = rawContent ?? variablesToRaw(variables || []); + const initialRawValue = baselineRaw; + const [rawValue, setRawValue] = useState(initialRawValue); + const [prevViewMode, setPrevViewMode] = useState(viewMode); + const [isSaving, setIsSaving] = useState(false); + + const formikRef = useRef(null); + + const initialValues = useMemo(() => { + const vars = (variables || []).map((v) => ({ + ...v, + uid: v.uid || uuid() + })); + return [ + ...vars, + { + uid: uuid(), + name: '', + value: '' + } + ]; + }, [variables]); + + const formik = useFormik({ + enableReinitialize: true, + initialValues: initialValues, + validate: (values) => { + const errors = {}; + values.forEach((variable, index) => { + const isLastRow = index === values.length - 1; + const isEmptyRow = !variable.name || variable.name.trim() === ''; + + if (isLastRow && isEmptyRow) { + return; + } + + if (!variable.name || variable.name.trim() === '') { + if (!errors[index]) errors[index] = {}; + errors[index].name = 'Name cannot be empty'; + } else if (!variableNameRegex.test(variable.name)) { + if (!errors[index]) errors[index] = {}; + errors[index].name + = 'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.'; + } + }); + return Object.keys(errors).length > 0 ? errors : {}; + }, + onSubmit: () => {} + }); + + formikRef.current = formik; + + // Sync raw value with external changes + useEffect(() => { + setRawValue(baselineRaw); + }, [baselineRaw]); + + // Handle view mode switching + useEffect(() => { + if (viewMode !== prevViewMode) { + if (viewMode === 'raw' && prevViewMode === 'table') { + const currentVars = formikRef.current.values.filter((v) => v.name && v.name.trim() !== ''); + const newRawValue = variablesToRaw(currentVars); + setRawValue(newRawValue); + } else if (viewMode === 'table' && prevViewMode === 'raw') { + const parsedVars = rawToVariables(rawValue); + const newValues = [ + ...parsedVars, + { uid: uuid(), name: '', value: '' } + ]; + formikRef.current.setValues(newValues); + } + setPrevViewMode(viewMode); + } + }, [viewMode, prevViewMode, rawValue]); + + const normalizeForComparison = (vars) => { + return vars + .filter((v) => v.name && v.name.trim() !== '') + .map(({ name, value }) => ({ name, value: value || '' })); + }; + + const savedValuesJson = useMemo(() => { + return JSON.stringify(normalizeForComparison(variables || [])); + }, [variables]); + + useEffect(() => { + if (viewMode === 'raw') { + const hasRawChanges = rawValue !== baselineRaw; + setIsModified(hasRawChanges); + } else { + const currentValuesJson = JSON.stringify(normalizeForComparison(formik.values)); + const hasActualChanges = currentValuesJson !== savedValuesJson; + setIsModified(hasActualChanges); + } + }, [formik.values, savedValuesJson, setIsModified, viewMode, rawValue, baselineRaw]); + + // Ref for stable formik.values access + const valuesRef = useRef(formik.values); + valuesRef.current = formik.values; + + const handleRemoveVar = useCallback((id) => { + const currentValues = valuesRef.current; + + if (!currentValues || currentValues.length === 0) { + return; + } + + const lastRow = currentValues[currentValues.length - 1]; + const isLastEmptyRow = lastRow?.uid === id && (!lastRow.name || lastRow.name.trim() === ''); + + if (isLastEmptyRow) { + return; + } + + const filteredValues = currentValues.filter((variable) => variable.uid !== id); + + const hasEmptyLastRow + = filteredValues.length > 0 + && (!filteredValues[filteredValues.length - 1].name + || filteredValues[filteredValues.length - 1].name.trim() === ''); + + const newValues = hasEmptyLastRow + ? filteredValues + : [ + ...filteredValues, + { uid: uuid(), name: '', value: '' } + ]; + + formikRef.current.setValues(newValues); + }, []); + + const handleNameChange = useCallback((index, e) => { + formik.handleChange(e); + const isLastRow = index === valuesRef.current.length - 1; + + if (isLastRow) { + const newVariable = { uid: uuid(), name: '', value: '' }; + setTimeout(() => { + formik.setValues((prev) => { + const lastRow = prev[prev.length - 1]; + if (lastRow?.name?.trim()) { + return [...prev, newVariable]; + } + return prev; + }); + }, 0); + } + }, []); + + const handleNameBlur = useCallback((index) => { + formik.setFieldTouched(`${index}.name`, true, true); + }, []); + + const handleNameKeyDown = useCallback((index, e) => { + if (e.key === 'Enter') { + e.preventDefault(); + formik.setFieldTouched(`${index}.name`, true, true); + } + }, []); + + const handleSave = useCallback(() => { + if (isSaving) return; + + const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== ''); + + const hasValidationErrors = variablesToSave.some((variable) => { + if (!variable.name || variable.name.trim() === '') { + return true; + } + if (!variableNameRegex.test(variable.name)) { + return true; + } + return false; + }); + + if (hasValidationErrors) { + toast.error('Please fix validation errors before saving'); + return; + } + + setIsSaving(true); + onSave(variablesToSave) + .then(() => { + toast.success('Changes saved successfully'); + const newValues = [ + ...variablesToSave, + { uid: uuid(), name: '', value: '' } + ]; + formik.resetForm({ values: newValues }); + setIsModified(false); + }) + .catch((error) => { + console.error(error); + toast.error('An error occurred while saving the changes'); + }) + .finally(() => { + setIsSaving(false); + }); + }, [isSaving, formik.values, onSave, setIsModified]); + + const handleSaveRaw = useCallback(() => { + if (isSaving) return; + + if (!onSaveRaw) { + toast.error('Raw save is not supported'); + return; + } + + setIsSaving(true); + onSaveRaw(rawValue) + .then(() => { + toast.success('Changes saved successfully'); + setIsModified(false); + }) + .catch((error) => { + console.error(error); + toast.error('An error occurred while saving the changes'); + }) + .finally(() => { + setIsSaving(false); + }); + }, [isSaving, rawValue, onSaveRaw, setIsModified]); + + const handleReset = useCallback(() => { + if (viewMode === 'raw') { + setRawValue(baselineRaw); + setIsModified(false); + } else { + const originalVars = (variables || []).map((v) => ({ + ...v, + uid: v.uid || uuid() + })); + const resetValues = [ + ...originalVars, + { uid: uuid(), name: '', value: '' } + ]; + formik.resetForm({ values: resetValues }); + setIsModified(false); + } + }, [viewMode, baselineRaw, variables, setIsModified]); + + const handleRawChange = useCallback((newValue) => { + setRawValue(newValue); + }, []); + + // Global save event listener + const handleSaveRef = useRef(handleSave); + handleSaveRef.current = handleSave; + + const handleSaveRawRef = useRef(handleSaveRaw); + handleSaveRawRef.current = handleSaveRaw; + + useEffect(() => { + const handleSaveEvent = () => { + if (viewMode === 'raw') { + handleSaveRawRef.current(); + } else { + handleSaveRef.current(); + } + }; + + window.addEventListener('dotenv-save', handleSaveEvent); + + return () => { + window.removeEventListener('dotenv-save', handleSaveEvent); + }; + }, [viewMode]); + + // Raw view mode + if (viewMode === 'raw') { + return ( + + + + ); + } + + // Empty state (no .env file exists yet) + const showEmptyState = !dotEnvExists && (!variables || variables.length === 0); + + return ( + + {showEmptyState && } + + + ); +}; + +export default DotEnvFileEditor; diff --git a/packages/bruno-app/src/components/Environments/DotEnvFileEditor/utils.js b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/utils.js new file mode 100644 index 000000000..422adbcf3 --- /dev/null +++ b/packages/bruno-app/src/components/Environments/DotEnvFileEditor/utils.js @@ -0,0 +1,59 @@ +import { uuid } from 'utils/common'; + +export const variablesToRaw = (variables) => { + return variables + .filter((v) => v.name && v.name.trim() !== '') + .map((v) => { + const value = v.value || ''; + if (value.includes('\n') || value.includes('"') || value.includes('\'')) { + const escapedValue = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); + return `${v.name}="${escapedValue}"`; + } + return `${v.name}=${value}`; + }) + .join('\n'); +}; + +export const rawToVariables = (rawContent) => { + if (!rawContent || rawContent.trim() === '') { + return []; + } + + const variables = []; + const lines = rawContent.split('\n'); + + for (const line of lines) { + const trimmedLine = line.trim(); + + if (!trimmedLine || trimmedLine.startsWith('#')) { + continue; + } + + const equalIndex = trimmedLine.indexOf('='); + if (equalIndex === -1) { + continue; + } + + const name = trimmedLine.substring(0, equalIndex).trim(); + let value = trimmedLine.substring(equalIndex + 1); + + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) { + value = value.slice(1, -1); + value = value.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\'); + } + + if (name) { + variables.push({ + uid: uuid(), + name, + value, + enabled: true, + secret: false + }); + } + } + + return variables; +}; + +export const MIN_TABLE_HEIGHT = 35 * 2; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/DeleteDotEnvFile/StyledWrapper.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/DeleteDotEnvFile/StyledWrapper.js new file mode 100644 index 000000000..48b874214 --- /dev/null +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/DeleteDotEnvFile/StyledWrapper.js @@ -0,0 +1,15 @@ +import styled from 'styled-components'; + +const Wrapper = styled.div` + button.submit { + color: white; + background-color: var(--color-background-danger) !important; + border: inherit !important; + + &:hover { + border: inherit !important; + } + } +`; + +export default Wrapper; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/DeleteDotEnvFile/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/DeleteDotEnvFile/index.js new file mode 100644 index 000000000..b15f56154 --- /dev/null +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/DeleteDotEnvFile/index.js @@ -0,0 +1,30 @@ +import React from 'react'; +import Portal from 'components/Portal/index'; +import Modal from 'components/Modal/index'; +import StyledWrapper from './StyledWrapper'; + +const DeleteDotEnvFile = ({ onClose, onConfirm, filename = '.env' }) => { + const handleConfirm = () => { + onConfirm(); + onClose(); + }; + + return ( + + + + Are you sure you want to delete {filename} file? + + + + ); +}; + +export default DeleteDotEnvFile; 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 542b93f5f..4dc305394 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js @@ -99,12 +99,39 @@ const StyledWrapper = styled.div` } } - .environments-list { + .sections-container { flex: 1; - overflow-y: auto; + display: flex; + flex-direction: column; + overflow: hidden; padding: 0 8px; } + .environments-list { + overflow-y: auto; + padding: 0 4px; + } + + .btn-action { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + background: transparent; + border: none; + border-radius: 4px; + color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + color: ${(props) => props.theme.text}; + } + } + .environment-item { position: relative; display: flex; @@ -281,6 +308,39 @@ const StyledWrapper = styled.div` background: ${(props) => `${props.theme.colors.text.danger}15`}; border-radius: 4px; } + + .no-env-file { + padding: 8px 12px; + font-size: 12px; + color: ${(props) => props.theme.colors.text.muted}; + font-style: italic; + } + + .empty-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding-top: 10%; + color: ${(props) => props.theme.colors.text.muted}; + + svg { + opacity: 0.3; + margin-bottom: 8px; + } + + .title { + font-size: 13px; + font-weight: 500; + margin-bottom: 12px; + color: ${(props) => props.theme.colors.text.muted}; + } + + .actions { + display: flex; + gap: 8px; + } + } `; export default StyledWrapper; 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 c7f40a13f..856aaf9ba 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js @@ -1,17 +1,32 @@ -import React, { useEffect, useState, useRef } from 'react'; +import React, { useEffect, useState, useRef, useCallback } from 'react'; import usePrevious from 'hooks/usePrevious'; +import useOnClickOutside from 'hooks/useOnClickOutside'; import EnvironmentDetails from './EnvironmentDetails'; -import CreateEnvironment from 'components/Environments/EnvironmentSettings/CreateEnvironment'; -import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX } from '@tabler/icons'; +import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX, IconFileAlert } from '@tabler/icons'; +import Button from 'ui/Button'; import StyledWrapper from './StyledWrapper'; import ConfirmSwitchEnv from 'components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/ConfirmSwitchEnv'; import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal'; +import CollapsibleSection from 'components/Environments/CollapsibleSection'; +import DotEnvFileEditor from 'components/Environments/DotEnvFileEditor'; +import DotEnvFileDetails from 'components/Environments/DotEnvFileDetails'; import ColorBadge from 'components/ColorBadge'; import { isEqual } from 'lodash'; -import { useDispatch } from 'react-redux'; -import { addEnvironment, renameEnvironment, selectEnvironment } from 'providers/ReduxStore/slices/collections/actions'; +import { useDispatch, useSelector } from 'react-redux'; +import { + addEnvironment, + renameEnvironment, + selectEnvironment, + saveDotEnvVariables, + saveDotEnvRaw, + createDotEnvFile, + deleteDotEnvFile +} from 'providers/ReduxStore/slices/collections/actions'; import { validateName, validateNameError } from 'utils/common/regex'; import toast from 'react-hot-toast'; +import classnames from 'classnames'; + +const EMPTY_ARRAY = []; const EnvironmentList = ({ environments, @@ -25,7 +40,6 @@ const EnvironmentList = ({ }) => { const dispatch = useDispatch(); - const [openCreateModal, setOpenCreateModal] = useState(false); const [openImportModal, setOpenImportModal] = useState(false); const [searchText, setSearchText] = useState(''); const [isCreatingInline, setIsCreatingInline] = useState(false); @@ -38,10 +52,40 @@ const EnvironmentList = ({ const [switchEnvConfirmClose, setSwitchEnvConfirmClose] = useState(false); const [originalEnvironmentVariables, setOriginalEnvironmentVariables] = useState([]); + const [environmentsExpanded, setEnvironmentsExpanded] = useState(true); + const [dotEnvExpanded, setDotEnvExpanded] = useState(false); + const [activeView, setActiveView] = useState('environment'); + const [isDotEnvModified, setIsDotEnvModified] = useState(false); + const [dotEnvViewMode, setDotEnvViewMode] = useState('table'); + const [selectedDotEnvFile, setSelectedDotEnvFile] = useState(null); + const [isCreatingDotEnvInline, setIsCreatingDotEnvInline] = useState(false); + const [newDotEnvName, setNewDotEnvName] = useState('.env'); + const [dotEnvNameError, setDotEnvNameError] = useState(''); + const dotEnvInputRef = useRef(null); + const dotEnvCreateContainerRef = useRef(null); + + const dotEnvFiles = useSelector((state) => { + const coll = state.collections.collections.find((c) => c.uid === collection?.uid); + return coll?.dotEnvFiles || EMPTY_ARRAY; + }); const envUids = environments ? environments.map((env) => env.uid) : []; const prevEnvUids = usePrevious(envUids); + useEffect(() => { + if (dotEnvFiles.length === 0) { + setSelectedDotEnvFile(null); + setActiveView('environment'); + setIsDotEnvModified(false); + return; + } + + const fileExists = dotEnvFiles.some((f) => f.filename === selectedDotEnvFile); + if (!selectedDotEnvFile || !fileExists) { + setSelectedDotEnvFile(dotEnvFiles[0].filename); + } + }, [dotEnvFiles]); + useEffect(() => { if (!environments?.length) { setSelectedEnvironment(null); @@ -87,44 +131,34 @@ const EnvironmentList = ({ } }, [envUids, environments, prevEnvUids]); - useEffect(() => { - if (!renamingEnvUid) return; - - const handleClickOutside = (event) => { - if (renameContainerRef.current && !renameContainerRef.current.contains(event.target)) { - handleCancelRename(); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [renamingEnvUid]); - - useEffect(() => { - if (!isCreatingInline) return; - - const handleClickOutside = (event) => { - if (createContainerRef.current && !createContainerRef.current.contains(event.target)) { - handleCancelCreate(); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [isCreatingInline]); - const handleEnvironmentClick = (env) => { + if (activeView === 'dotenv' && isDotEnvModified) { + setSwitchEnvConfirmClose(true); + return; + } if (!isModified) { setSelectedEnvironment(env); + setActiveView('environment'); + setEnvironmentsExpanded(true); } else { setSwitchEnvConfirmClose(true); } }; + const handleDotEnvClick = (filename) => { + if (isModified) { + setSwitchEnvConfirmClose(true); + return; + } + if (activeView === 'dotenv' && isDotEnvModified && selectedDotEnvFile !== filename) { + setSwitchEnvConfirmClose(true); + return; + } + setSelectedDotEnvFile(filename); + setActiveView('dotenv'); + setDotEnvExpanded(true); + }; + const handleEnvironmentDoubleClick = (env) => { setRenamingEnvUid(env.uid); setNewEnvName(env.name); @@ -135,7 +169,7 @@ const EnvironmentList = ({ }, 50); }; - const handleActivateEnvironment = (e, env) => { + const handleActivateEnvironment = useCallback((e, env) => { e.stopPropagation(); dispatch(selectEnvironment(env.uid, collection.uid)) .then(() => { @@ -144,11 +178,7 @@ const EnvironmentList = ({ .catch(() => { toast.error('Failed to activate environment'); }); - }; - - if (!selectedEnvironment) { - return null; - } + }, [dispatch, collection.uid]); const validateEnvironmentName = (name, excludeUid = null) => { if (!name || name.trim() === '') { @@ -171,7 +201,7 @@ const EnvironmentList = ({ }; const handleCreateEnvClick = () => { - if (!isModified) { + if (!isModified && !isDotEnvModified) { setIsCreatingInline(true); setNewEnvName(''); setEnvNameError(''); @@ -183,11 +213,13 @@ const EnvironmentList = ({ } }; - const handleCancelCreate = () => { + const handleCancelCreate = useCallback(() => { setIsCreatingInline(false); setNewEnvName(''); setEnvNameError(''); - }; + }, []); + + useOnClickOutside(createContainerRef, handleCancelCreate, isCreatingInline); const handleSaveNewEnv = () => { const error = validateEnvironmentName(newEnvName); @@ -254,14 +286,16 @@ const EnvironmentList = ({ }); }; - const handleCancelRename = () => { + const handleCancelRename = useCallback(() => { setRenamingEnvUid(null); setNewEnvName(''); setEnvNameError(''); - }; + }, []); + + useOnClickOutside(renameContainerRef, handleCancelRename, !!renamingEnvUid); const handleImportClick = () => { - if (!isModified) { + if (!isModified && !isDotEnvModified) { setOpenImportModal(true); } else { setSwitchEnvConfirmClose(true); @@ -280,12 +314,197 @@ const EnvironmentList = ({ } }; + const handleSaveDotEnv = (variables) => { + if (!selectedDotEnvFile) return Promise.reject(new Error('No file selected')); + return dispatch(saveDotEnvVariables(collection.uid, variables, selectedDotEnvFile)); + }; + + const handleSaveDotEnvRaw = (content) => { + if (!selectedDotEnvFile) return Promise.reject(new Error('No file selected')); + return dispatch(saveDotEnvRaw(collection.uid, content, selectedDotEnvFile)); + }; + + const handleCreateDotEnvInlineClick = () => { + if (isModified || isDotEnvModified) { + setSwitchEnvConfirmClose(true); + return; + } + setIsCreatingDotEnvInline(true); + setNewDotEnvName('.env'); + setDotEnvNameError(''); + setTimeout(() => { + dotEnvInputRef.current?.focus(); + const input = dotEnvInputRef.current; + if (input) { + input.setSelectionRange(input.value.length, input.value.length); + } + }, 50); + }; + + const handleCancelDotEnvCreate = useCallback(() => { + setIsCreatingDotEnvInline(false); + setNewDotEnvName('.env'); + setDotEnvNameError(''); + }, []); + + useOnClickOutside(dotEnvCreateContainerRef, handleCancelDotEnvCreate, isCreatingDotEnvInline); + + const validateDotEnvName = (name) => { + if (!name || name.trim() === '') { + return 'Name is required'; + } + + if (!name.startsWith('.env')) { + return 'File name must start with .env'; + } + + const validPattern = /^\.env[a-zA-Z0-9._-]*$/; + if (!validPattern.test(name)) { + return 'Invalid file name'; + } + + const exists = dotEnvFiles.some((f) => f.filename === name); + if (exists) { + return 'File already exists'; + } + + return null; + }; + + const handleSaveNewDotEnv = () => { + const error = validateDotEnvName(newDotEnvName); + if (error) { + setDotEnvNameError(error); + return; + } + + dispatch(createDotEnvFile(collection.uid, newDotEnvName)) + .then(() => { + toast.success(`${newDotEnvName} file created!`); + setIsCreatingDotEnvInline(false); + setNewDotEnvName('.env'); + setDotEnvNameError(''); + setSelectedDotEnvFile(newDotEnvName); + setActiveView('dotenv'); + setDotEnvExpanded(true); + }) + .catch((error) => { + toast.error(error.message || 'Failed to create .env file'); + }); + }; + + const handleDotEnvNameChange = (e) => { + const value = e.target.value; + if (!value.startsWith('.env')) { + setNewDotEnvName('.env'); + } else { + setNewDotEnvName(value); + } + if (dotEnvNameError) { + setDotEnvNameError(''); + } + }; + + const handleDotEnvNameKeyDown = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSaveNewDotEnv(); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleCancelDotEnvCreate(); + } else if (e.key === 'Backspace') { + const input = e.target; + if (input.selectionStart <= 4 && input.selectionEnd <= 4) { + e.preventDefault(); + } + } + }; + + const handleDeleteDotEnvFile = (filename) => { + dispatch(deleteDotEnvFile(collection.uid, filename)) + .then(() => { + toast.success(`${filename} file deleted!`); + setIsDotEnvModified(false); + if (selectedDotEnvFile === filename) { + const remainingFiles = dotEnvFiles.filter((f) => f.filename !== filename); + if (remainingFiles.length > 0) { + setSelectedDotEnvFile(remainingFiles[0].filename); + } else { + setActiveView('environment'); + if (environments?.length) { + const env = environments.find((e) => e.uid === activeEnvironmentUid) || environments[0]; + setSelectedEnvironment(env); + } + } + } + }) + .catch((error) => { + toast.error(error.message || 'Failed to delete .env file'); + }); + }; + + const handleDotEnvViewModeChange = (mode) => { + setDotEnvViewMode(mode); + }; + const filteredEnvironments = environments?.filter((env) => env.name.toLowerCase().includes(searchText.toLowerCase())) || []; + const selectedDotEnvData = dotEnvFiles.find((f) => f.filename === selectedDotEnvFile); + + const renderContent = () => { + if (activeView === 'dotenv' && selectedDotEnvFile && selectedDotEnvData) { + return ( + handleDeleteDotEnvFile(selectedDotEnvFile)} + dotEnvExists={selectedDotEnvData?.exists} + viewMode={dotEnvViewMode} + onViewModeChange={handleDotEnvViewModeChange} + > + + + ); + } + + if (selectedEnvironment) { + return ( + + ); + } + + return ( +
+ +
No Environments
+
+ + +
+
+ ); + }; + return ( - {openCreateModal && setOpenCreateModal(false)} />} {openImportModal && ( setOpenImportModal(false)} /> )} @@ -299,42 +518,111 @@ const EnvironmentList = ({
-

Environments

-
- - - -
+

Variables

setSearchText(e.target.value)} className="search-input" />
-
- {filteredEnvironments.map((env) => ( -
renamingEnvUid !== env.uid && handleEnvironmentClick(env)} - onDoubleClick={() => handleEnvironmentDoubleClick(env)} - > - {renamingEnvUid === env.uid ? ( -
+
+ setEnvironmentsExpanded(!environmentsExpanded)} + actions={( + <> + + + + + )} + > +
+ {filteredEnvironments.map((env) => ( +
renamingEnvUid !== env.uid && handleEnvironmentClick(env)} + onDoubleClick={() => handleEnvironmentDoubleClick(env)} + > + {renamingEnvUid === env.uid ? ( +
+ +
+ + +
+
+ ) : ( + <> + + {env.name} +
+ {activeEnvironmentUid === env.uid ? ( +
+ +
+ ) : ( + + )} +
+ + )} +
+ ))} + + {isCreatingInline && ( +
- ) : ( - <> - - {env.name} -
- {activeEnvironmentUid === env.uid ? ( -
- -
- ) : ( - - )} -
- + )} + + {envNameError && (isCreatingInline || renamingEnvUid) &&
{envNameError}
} + + {filteredEnvironments.length === 0 && !isCreatingInline && ( +
+ No environments +
)}
- ))} + - {isCreatingInline && ( -
- -
- + )} + > +
+ {dotEnvFiles.map((file) => ( +
handleDotEnvClick(file.filename)} > - - - -
+ {file.filename} +
+ ))} + + {isCreatingDotEnvInline && ( +
+ +
+ + +
+
+ )} + + {dotEnvNameError && isCreatingDotEnvInline &&
{dotEnvNameError}
} + + {dotEnvFiles.length === 0 && !isCreatingDotEnvInline && ( +
+ No .env files +
+ )}
- )} - - {envNameError && (isCreatingInline || renamingEnvUid) &&
{envNameError}
} +
- + {renderContent()}
); diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js index f58b644a2..8a7e43ca1 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js @@ -1,26 +1,7 @@ import React, { useState } from 'react'; -import CreateEnvironment from 'components/Environments/EnvironmentSettings/CreateEnvironment'; import EnvironmentList from './EnvironmentList'; import StyledWrapper from './StyledWrapper'; -import { IconFileAlert } from '@tabler/icons'; -import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal'; import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal'; -import Button from 'ui/Button'; - -const DefaultTab = ({ setTab }) => ( -
- -
No Environments
-
- - -
-
-); const EnvironmentSettings = ({ collection }) => { const [isModified, setIsModified] = useState(false); @@ -30,23 +11,8 @@ const EnvironmentSettings = ({ collection }) => { if (!environments.length) return null; return environments.find((env) => env.uid === collection?.activeEnvironmentUid) || environments[0]; }); - const [tab, setTab] = useState('default'); const [showExportModal, setShowExportModal] = useState(false); - if (!environments || !environments.length) { - return ( - - {tab === 'create' ? ( - setTab('default')} /> - ) : tab === 'import' ? ( - setTab('default')} /> - ) : ( - - )} - - ); - } - return ( { - return ; + const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid); + const workspace = useSelector((state) => + state.workspaces.workspaces.find((w) => w.uid === activeWorkspaceUid) + ); + + return ; }; export default GlobalEnvironmentSettings; 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 cbf76f605..c36d77868 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/StyledWrapper.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/StyledWrapper.js @@ -99,12 +99,39 @@ const StyledWrapper = styled.div` } } - .environments-list { + .sections-container { flex: 1; - overflow-y: auto; + display: flex; + flex-direction: column; + overflow: hidden; padding: 0 8px; } + .environments-list { + overflow-y: auto; + padding: 0 4px; + } + + .btn-action { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + background: transparent; + border: none; + border-radius: 4px; + color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + color: ${(props) => props.theme.text}; + } + } + .environment-item { position: relative; display: flex; @@ -228,46 +255,46 @@ const StyledWrapper = styled.div` color: ${(props) => props.theme.text}; font-size: 13px; padding: 2px 4px; - + &::placeholder { color: ${(props) => props.theme.colors.text.muted}; } } - + .inline-actions { display: flex; gap: 2px; margin-left: 4px; flex-shrink: 0; } - - .inline-action-btn { - display: flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - padding: 0; - background: transparent; - border: none; - border-radius: 4px; - cursor: pointer; - transition: all 0.15s ease; - - &.save { - color: ${(props) => props.theme.colors.text.green}; - - &:hover { - background: ${(props) => rgba(props.theme.colors.text.green, 0.1)}; - } + } + + .inline-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + transition: all 0.15s ease; + + &.save { + color: ${(props) => props.theme.colors.text.green}; + + &:hover { + background: ${(props) => rgba(props.theme.colors.text.green, 0.1)}; } - - &.cancel { - color: ${(props) => props.theme.colors.text.danger}; - - &:hover { - background: ${(props) => rgba(props.theme.colors.text.danger, 0.1)}; - } + } + + &.cancel { + color: ${(props) => props.theme.colors.text.danger}; + + &:hover { + background: ${(props) => rgba(props.theme.colors.text.danger, 0.1)}; } } } @@ -281,6 +308,39 @@ const StyledWrapper = styled.div` background: ${(props) => `${props.theme.colors.text.danger}15`}; border-radius: 4px; } + + .no-env-file { + padding: 8px 12px; + font-size: 12px; + color: ${(props) => props.theme.colors.text.muted}; + font-style: italic; + } + + .empty-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding-top: 10%; + color: ${(props) => props.theme.colors.text.muted}; + + svg { + opacity: 0.3; + margin-bottom: 8px; + } + + .title { + font-size: 13px; + font-weight: 500; + margin-bottom: 12px; + color: ${(props) => props.theme.colors.text.muted}; + } + + .actions { + display: flex; + gap: 8px; + } + } `; export default StyledWrapper; 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 ed42867c7..862f71ad8 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/index.js @@ -1,23 +1,45 @@ -import React, { useEffect, useState, useRef } from 'react'; +import React, { useEffect, useState, useRef, useCallback } from 'react'; import usePrevious from 'hooks/usePrevious'; +import useOnClickOutside from 'hooks/useOnClickOutside'; import EnvironmentDetails from './EnvironmentDetails'; -import CreateEnvironment from '../CreateEnvironment'; -import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX } from '@tabler/icons'; +import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX, IconFileAlert } from '@tabler/icons'; +import Button from 'ui/Button'; import StyledWrapper from './StyledWrapper'; import ConfirmSwitchEnv from './ConfirmSwitchEnv'; import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal'; +import CollapsibleSection from 'components/Environments/CollapsibleSection'; +import DotEnvFileEditor from 'components/Environments/DotEnvFileEditor'; +import DotEnvFileDetails from 'components/Environments/DotEnvFileDetails'; 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'; +import { + saveWorkspaceDotEnvVariables, + saveWorkspaceDotEnvRaw, + createWorkspaceDotEnvFile, + deleteWorkspaceDotEnvFile +} from 'providers/ReduxStore/slices/workspaces/actions'; import { validateName, validateNameError } from 'utils/common/regex'; import toast from 'react-hot-toast'; +import classnames from 'classnames'; -const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified, collection, setShowExportModal }) => { +const EMPTY_ARRAY = []; + +const EnvironmentList = ({ + environments, + activeEnvironmentUid, + selectedEnvironment, + setSelectedEnvironment, + isModified, + setIsModified, + collection, + workspace, + setShowExportModal +}) => { const dispatch = useDispatch(); const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments); - const [openCreateModal, setOpenCreateModal] = useState(false); const [openImportModal, setOpenImportModal] = useState(false); const [searchText, setSearchText] = useState(''); const [isCreatingInline, setIsCreatingInline] = useState(false); @@ -30,10 +52,38 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme const [switchEnvConfirmClose, setSwitchEnvConfirmClose] = useState(false); const [originalEnvironmentVariables, setOriginalEnvironmentVariables] = useState([]); + const [environmentsExpanded, setEnvironmentsExpanded] = useState(true); + const [dotEnvExpanded, setDotEnvExpanded] = useState(false); + const [activeView, setActiveView] = useState('environment'); + const [isDotEnvModified, setIsDotEnvModified] = useState(false); + const [dotEnvViewMode, setDotEnvViewMode] = useState('table'); + const [selectedDotEnvFile, setSelectedDotEnvFile] = useState(null); + const [isCreatingDotEnvInline, setIsCreatingDotEnvInline] = useState(false); + const [newDotEnvName, setNewDotEnvName] = useState('.env'); + const [dotEnvNameError, setDotEnvNameError] = useState(''); + const dotEnvInputRef = useRef(null); + const dotEnvCreateContainerRef = useRef(null); + + const dotEnvFiles = useSelector((state) => { + const ws = state.workspaces.workspaces.find((w) => w.uid === workspace?.uid); + return ws?.dotEnvFiles || EMPTY_ARRAY; + }); const envUids = environments ? environments.map((env) => env.uid) : []; const prevEnvUids = usePrevious(envUids); + useEffect(() => { + if (dotEnvFiles.length === 0) { + setSelectedDotEnvFile(null); + return; + } + + const fileExists = dotEnvFiles.some((f) => f.filename === selectedDotEnvFile); + if (!selectedDotEnvFile || !fileExists) { + setSelectedDotEnvFile(dotEnvFiles[0].filename); + } + }, [dotEnvFiles]); + useEffect(() => { if (!environments?.length) { setSelectedEnvironment(null); @@ -79,44 +129,34 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme } }, [envUids, environments, prevEnvUids]); - useEffect(() => { - if (!renamingEnvUid) return; - - const handleClickOutside = (event) => { - if (renameContainerRef.current && !renameContainerRef.current.contains(event.target)) { - handleCancelRename(); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [renamingEnvUid]); - - useEffect(() => { - if (!isCreatingInline) return; - - const handleClickOutside = (event) => { - if (createContainerRef.current && !createContainerRef.current.contains(event.target)) { - handleCancelCreate(); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [isCreatingInline]); - const handleEnvironmentClick = (env) => { + if (activeView === 'dotenv' && isDotEnvModified) { + setSwitchEnvConfirmClose(true); + return; + } if (!isModified) { setSelectedEnvironment(env); + setActiveView('environment'); + setEnvironmentsExpanded(true); } else { setSwitchEnvConfirmClose(true); } }; + const handleDotEnvClick = (filename) => { + if (isModified) { + setSwitchEnvConfirmClose(true); + return; + } + if (activeView === 'dotenv' && isDotEnvModified && selectedDotEnvFile !== filename) { + setSwitchEnvConfirmClose(true); + return; + } + setSelectedDotEnvFile(filename); + setActiveView('dotenv'); + setDotEnvExpanded(true); + }; + const handleEnvironmentDoubleClick = (env) => { setRenamingEnvUid(env.uid); setNewEnvName(env.name); @@ -127,7 +167,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme }, 50); }; - const handleActivateEnvironment = (e, env) => { + const handleActivateEnvironment = useCallback((e, env) => { e.stopPropagation(); dispatch(selectGlobalEnvironment({ environmentUid: env.uid })) .then(() => { @@ -136,11 +176,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme .catch(() => { toast.error('Failed to activate environment'); }); - }; - - if (!selectedEnvironment) { - return null; - } + }, [dispatch]); const validateEnvironmentName = (name, excludeUid = null) => { if (!name || name.trim() === '') { @@ -152,8 +188,9 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme } const trimmedName = name.toLowerCase().trim(); - const isDuplicate = globalEnvs.some((env) => - env?.uid !== excludeUid && env?.name?.toLowerCase().trim() === trimmedName); + const isDuplicate = globalEnvs?.some( + (env) => env?.uid !== excludeUid && env?.name?.toLowerCase().trim() === trimmedName + ); if (isDuplicate) { return 'Environment already exists'; } @@ -162,7 +199,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme }; const handleCreateEnvClick = () => { - if (!isModified) { + if (!isModified && !isDotEnvModified) { setIsCreatingInline(true); setNewEnvName(''); setEnvNameError(''); @@ -174,11 +211,13 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme } }; - const handleCancelCreate = () => { + const handleCancelCreate = useCallback(() => { setIsCreatingInline(false); setNewEnvName(''); setEnvNameError(''); - }; + }, []); + + useOnClickOutside(createContainerRef, handleCancelCreate, isCreatingInline); const handleSaveNewEnv = () => { const error = validateEnvironmentName(newEnvName); @@ -245,14 +284,16 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme }); }; - const handleCancelRename = () => { + const handleCancelRename = useCallback(() => { setRenamingEnvUid(null); setNewEnvName(''); setEnvNameError(''); - }; + }, []); + + useOnClickOutside(renameContainerRef, handleCancelRename, !!renamingEnvUid); const handleImportClick = () => { - if (!isModified) { + if (!isModified && !isDotEnvModified) { setOpenImportModal(true); } else { setSwitchEnvConfirmClose(true); @@ -271,12 +312,196 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme } }; - const filteredEnvironments = environments?.filter((env) => - env.name.toLowerCase().includes(searchText.toLowerCase())) || []; + const handleSaveDotEnv = (variables) => { + if (!selectedDotEnvFile) return Promise.reject(new Error('No file selected')); + return dispatch(saveWorkspaceDotEnvVariables(workspace.uid, variables, selectedDotEnvFile)); + }; + + const handleSaveDotEnvRaw = (content) => { + if (!selectedDotEnvFile) return Promise.reject(new Error('No file selected')); + return dispatch(saveWorkspaceDotEnvRaw(workspace.uid, content, selectedDotEnvFile)); + }; + + const handleCreateDotEnvInlineClick = () => { + if (isModified || isDotEnvModified) { + setSwitchEnvConfirmClose(true); + return; + } + setIsCreatingDotEnvInline(true); + setNewDotEnvName('.env'); + setDotEnvNameError(''); + setTimeout(() => { + dotEnvInputRef.current?.focus(); + const input = dotEnvInputRef.current; + if (input) { + input.setSelectionRange(input.value.length, input.value.length); + } + }, 50); + }; + + const handleCancelDotEnvCreate = useCallback(() => { + setIsCreatingDotEnvInline(false); + setNewDotEnvName('.env'); + setDotEnvNameError(''); + }, []); + + useOnClickOutside(dotEnvCreateContainerRef, handleCancelDotEnvCreate, isCreatingDotEnvInline); + + const validateDotEnvName = (name) => { + if (!name || name.trim() === '') { + return 'Name is required'; + } + + if (!name.startsWith('.env')) { + return 'File name must start with .env'; + } + + const validPattern = /^\.env[a-zA-Z0-9._-]*$/; + if (!validPattern.test(name)) { + return 'Invalid file name'; + } + + const exists = dotEnvFiles.some((f) => f.filename === name); + if (exists) { + return 'File already exists'; + } + + return null; + }; + + const handleSaveNewDotEnv = () => { + const error = validateDotEnvName(newDotEnvName); + if (error) { + setDotEnvNameError(error); + return; + } + + dispatch(createWorkspaceDotEnvFile(workspace.uid, newDotEnvName)) + .then(() => { + toast.success(`${newDotEnvName} file created!`); + setIsCreatingDotEnvInline(false); + setNewDotEnvName('.env'); + setDotEnvNameError(''); + setSelectedDotEnvFile(newDotEnvName); + setActiveView('dotenv'); + setDotEnvExpanded(true); + }) + .catch((error) => { + toast.error(error.message || 'Failed to create .env file'); + }); + }; + + const handleDotEnvNameChange = (e) => { + const value = e.target.value; + if (!value.startsWith('.env')) { + setNewDotEnvName('.env'); + } else { + setNewDotEnvName(value); + } + if (dotEnvNameError) { + setDotEnvNameError(''); + } + }; + + const handleDotEnvNameKeyDown = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSaveNewDotEnv(); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleCancelDotEnvCreate(); + } else if (e.key === 'Backspace') { + const input = e.target; + if (input.selectionStart <= 4 && input.selectionEnd <= 4) { + e.preventDefault(); + } + } + }; + + const handleDeleteDotEnvFile = (filename) => { + dispatch(deleteWorkspaceDotEnvFile(workspace.uid, filename)) + .then(() => { + toast.success(`${filename} file deleted!`); + setIsDotEnvModified(false); + if (selectedDotEnvFile === filename) { + const remainingFiles = dotEnvFiles.filter((f) => f.filename !== filename); + if (remainingFiles.length > 0) { + setSelectedDotEnvFile(remainingFiles[0].filename); + } else { + setActiveView('environment'); + if (environments?.length) { + const env = environments.find((e) => e.uid === activeEnvironmentUid) || environments[0]; + setSelectedEnvironment(env); + } + } + } + }) + .catch((error) => { + toast.error(error.message || 'Failed to delete .env file'); + }); + }; + + const handleDotEnvViewModeChange = (mode) => { + setDotEnvViewMode(mode); + }; + + const filteredEnvironments + = environments?.filter((env) => env.name.toLowerCase().includes(searchText.toLowerCase())) || []; + + const selectedDotEnvData = dotEnvFiles.find((f) => f.filename === selectedDotEnvFile); + + const renderContent = () => { + if (activeView === 'dotenv' && selectedDotEnvFile && selectedDotEnvData) { + return ( + handleDeleteDotEnvFile(selectedDotEnvFile)} + dotEnvExists={selectedDotEnvData?.exists} + viewMode={dotEnvViewMode} + onViewModeChange={handleDotEnvViewModeChange} + > + + + ); + } + + if (selectedEnvironment) { + return ( + + ); + } + + return ( +
+ +
No Environments
+
+ + +
+
+ ); + }; return ( - {openCreateModal && setOpenCreateModal(false)} />} {openImportModal && setOpenImportModal(false)} />}
@@ -286,45 +511,113 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
)} - {/* Left Sidebar */}
-

Environments

-
- - - -
+

Variables

setSearchText(e.target.value)} className="search-input" />
-
- {filteredEnvironments.map((env) => ( -
renamingEnvUid !== env.uid && handleEnvironmentClick(env)} - onDoubleClick={() => handleEnvironmentDoubleClick(env)} - > - {renamingEnvUid === env.uid ? ( -
+
+ setEnvironmentsExpanded(!environmentsExpanded)} + actions={( + <> + + + + + )} + > +
+ {filteredEnvironments.map((env) => ( +
renamingEnvUid !== env.uid && handleEnvironmentClick(env)} + onDoubleClick={() => handleEnvironmentDoubleClick(env)} + > + {renamingEnvUid === env.uid ? ( +
+ +
+ + +
+
+ ) : ( + <> + + {env.name} +
+ {activeEnvironmentUid === env.uid ? ( +
+ +
+ ) : ( + + )} +
+ + )} +
+ ))} + + {isCreatingInline && ( +
- ) : ( - <> - - {env.name} -
- {activeEnvironmentUid === env.uid ? ( -
- -
- ) : ( - - )} -
- + )} + + {envNameError && (isCreatingInline || renamingEnvUid) &&
{envNameError}
} + + {filteredEnvironments.length === 0 && !isCreatingInline && ( +
+ No environments +
)}
- ))} + - {isCreatingInline && ( -
- -
- + )} + > +
+ {dotEnvFiles.map((file) => ( +
handleDotEnvClick(file.filename)} > - - - -
+ {file.filename} +
+ ))} + + {isCreatingDotEnvInline && ( +
+ +
+ + +
+
+ )} + + {dotEnvNameError && isCreatingDotEnvInline &&
{dotEnvNameError}
} + + {dotEnvFiles.length === 0 && !isCreatingDotEnvInline && ( +
+ No .env files +
+ )}
- )} - - {envNameError && (isCreatingInline || renamingEnvUid) && ( -
{envNameError}
- )} +
- {/* Right Content */} - + {renderContent()}
); diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/index.js index 96914db8b..08a3244ee 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/index.js @@ -1,67 +1,34 @@ import React, { useState } from 'react'; import { useSelector } from 'react-redux'; -import CreateEnvironment from './CreateEnvironment'; import EnvironmentList from './EnvironmentList'; import StyledWrapper from './StyledWrapper'; -import { IconFileAlert } from '@tabler/icons'; -import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal'; import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal'; -import Button from 'ui/Button'; - -const DefaultTab = ({ setTab }) => ( -
- -
No Environments
-
- - -
-
-); const WorkspaceEnvironments = ({ workspace }) => { const [isModified, setIsModified] = useState(false); const [selectedEnvironment, setSelectedEnvironment] = useState(null); - const [tab, setTab] = useState('default'); const [showExportModal, setShowExportModal] = useState(false); const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments); const activeGlobalEnvironmentUid = useSelector((state) => state.globalEnvironments.activeGlobalEnvironmentUid); - if (!globalEnvironments || !globalEnvironments.length) { - return ( - - {tab === 'create' ? ( - setTab('default')} /> - ) : tab === 'import' ? ( - setTab('default')} /> - ) : ( - - )} - - ); - } - return ( {showExportModal && ( setShowExportModal(false)} - environments={globalEnvironments} + environments={globalEnvironments || []} environmentType="global" /> )} diff --git a/packages/bruno-app/src/hooks/useOnClickOutside/index.js b/packages/bruno-app/src/hooks/useOnClickOutside/index.js index 2dbef75f2..d86fcb303 100644 --- a/packages/bruno-app/src/hooks/useOnClickOutside/index.js +++ b/packages/bruno-app/src/hooks/useOnClickOutside/index.js @@ -1,9 +1,11 @@ // See https://usehooks.com/useOnClickOutside/ import { useEffect } from 'react'; -const useOnClickOutside = (ref, handler) => { +const useOnClickOutside = (ref, handler, enabled = true) => { useEffect( () => { + if (!enabled) return; + const listener = (event) => { // Do nothing if clicking ref's element or descendant elements if (!ref.current || ref.current.contains(event.target)) { @@ -27,7 +29,7 @@ const useOnClickOutside = (ref, handler) => { // ... callback/cleanup to run every render. It's not a big deal ... // ... but to optimize you can wrap handler in useCallback before ... // ... passing it into this hook. - [ref, handler] + [ref, handler, enabled] ); }; diff --git a/packages/bruno-app/src/providers/App/useIpcEvents.js b/packages/bruno-app/src/providers/App/useIpcEvents.js index 4b256555a..77aa687a1 100644 --- a/packages/bruno-app/src/providers/App/useIpcEvents.js +++ b/packages/bruno-app/src/providers/App/useIpcEvents.js @@ -24,11 +24,12 @@ import { runFolderEvent, runRequestEvent, scriptEnvironmentUpdateEvent, - streamDataReceived + streamDataReceived, + setDotEnvVariables } from 'providers/ReduxStore/slices/collections'; import { collectionAddEnvFileEvent, openCollectionEvent, hydrateCollectionWithUiStateSnapshot, mergeAndPersistEnvironment } from 'providers/ReduxStore/slices/collections/actions'; import { workspaceOpenedEvent, workspaceConfigUpdatedEvent } from 'providers/ReduxStore/slices/workspaces/actions'; -import { workspaceDotEnvUpdateEvent } from 'providers/ReduxStore/slices/workspaces'; +import { workspaceDotEnvUpdateEvent, setWorkspaceDotEnvVariables } from 'providers/ReduxStore/slices/workspaces'; import toast from 'react-hot-toast'; import { useDispatch, useStore } from 'react-redux'; import { isElectron } from 'utils/common/platform'; @@ -226,6 +227,33 @@ const useIpcEvents = () => { dispatch(workspaceEnvUpdateEvent({ processEnvVariables: val.processEnvVariables })); }); + const removeDotEnvFileUpdateListener = ipcRenderer.on('main:dotenv-file-update', (val) => { + const { type, collectionUid, workspaceUid, filename, variables, exists, processEnvVariables } = val; + + if (type === 'collection' && collectionUid) { + dispatch(setDotEnvVariables({ + collectionUid, + variables, + exists, + filename + })); + if (filename === '.env') { + dispatch(processEnvUpdateEvent({ collectionUid, processEnvVariables })); + } + } else if (type === 'workspace' && workspaceUid) { + dispatch(setWorkspaceDotEnvVariables({ + workspaceUid, + variables, + exists, + filename + })); + if (filename === '.env') { + dispatch(workspaceDotEnvUpdateEvent(val)); + dispatch(workspaceEnvUpdateEvent({ processEnvVariables })); + } + } + }); + const removeConsoleLogListener = ipcRenderer.on('main:console-log', (val) => { console[val.type](...val.args); dispatch(addLog({ @@ -321,6 +349,7 @@ const useIpcEvents = () => { removeRunRequestEventListener(); removeProcessEnvUpdatesListener(); removeWorkspaceDotEnvUpdatesListener(); + removeDotEnvFileUpdateListener(); removeConsoleLogListener(); removeConfigUpdatesListener(); removeShowPreferencesListener(); 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 84884aae3..043529020 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -2896,3 +2896,71 @@ export const openCollectionSettings resolve(); }); }; + +export const saveDotEnvVariables = (collectionUid, variables, filename = '.env') => (dispatch, getState) => { + const { ipcRenderer } = window; + return new Promise((resolve, reject) => { + const state = getState(); + const collection = findCollectionByUid(state.collections.collections, collectionUid); + + if (!collection) { + return reject(new Error('Collection not found')); + } + + ipcRenderer + .invoke('renderer:save-dotenv-variables', collection.pathname, variables, filename) + .then(resolve) + .catch(reject); + }); +}; + +export const saveDotEnvRaw = (collectionUid, content, filename = '.env') => (dispatch, getState) => { + const { ipcRenderer } = window; + return new Promise((resolve, reject) => { + const state = getState(); + const collection = findCollectionByUid(state.collections.collections, collectionUid); + + if (!collection) { + return reject(new Error('Collection not found')); + } + + ipcRenderer + .invoke('renderer:save-dotenv-raw', collection.pathname, content, filename) + .then(resolve) + .catch(reject); + }); +}; + +export const createDotEnvFile = (collectionUid, filename = '.env') => (dispatch, getState) => { + const { ipcRenderer } = window; + return new Promise((resolve, reject) => { + const state = getState(); + const collection = findCollectionByUid(state.collections.collections, collectionUid); + + if (!collection) { + return reject(new Error('Collection not found')); + } + + ipcRenderer + .invoke('renderer:create-dotenv-file', collection.pathname, filename) + .then(resolve) + .catch(reject); + }); +}; + +export const deleteDotEnvFile = (collectionUid, filename = '.env') => (dispatch, getState) => { + const { ipcRenderer } = window; + return new Promise((resolve, reject) => { + const state = getState(); + const collection = findCollectionByUid(state.collections.collections, collectionUid); + + if (!collection) { + return reject(new Error('Collection not found')); + } + + ipcRenderer + .invoke('renderer:delete-dotenv-file', collection.pathname, filename) + .then(resolve) + .catch(reject); + }); +}; 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 a31536246..9972b7c7d 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -470,6 +470,37 @@ export const collectionsSlice = createSlice({ collection.workspaceProcessEnvVariables = processEnvVariables; }); }, + setDotEnvVariables: (state, action) => { + const { collectionUid, variables, exists, filename = '.env' } = action.payload; + const collection = findCollectionByUid(state.collections, collectionUid); + + if (collection) { + if (!collection.dotEnvFiles) { + collection.dotEnvFiles = []; + } + + const existingIndex = collection.dotEnvFiles.findIndex((f) => f.filename === filename); + if (existingIndex >= 0) { + if (exists) { + collection.dotEnvFiles[existingIndex] = { filename, variables, exists }; + } else { + collection.dotEnvFiles.splice(existingIndex, 1); + } + } else if (exists) { + collection.dotEnvFiles.push({ filename, variables, exists }); + } + + collection.dotEnvFiles.sort((a, b) => { + if (a.filename === '.env') return -1; + if (b.filename === '.env') return 1; + return a.filename.localeCompare(b.filename); + }); + + const mainEnvFile = collection.dotEnvFiles.find((f) => f.filename === '.env'); + collection.dotEnvVariables = mainEnvFile?.variables || []; + collection.dotEnvExists = mainEnvFile?.exists || false; + } + }, requestCancelled: (state, action) => { const { itemUid, collectionUid, seq, timestamp } = action.payload; const collection = findCollectionByUid(state.collections, collectionUid); @@ -3530,6 +3561,7 @@ export const { scriptEnvironmentUpdateEvent, processEnvUpdateEvent, workspaceEnvUpdateEvent, + setDotEnvVariables, requestCancelled, responseReceived, runGrpcRequestEvent, diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js index 1f439acbd..08a255d78 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js @@ -760,3 +760,83 @@ export const importWorkspaceAction = (zipFilePath, extractLocation) => { } }; }; + +export const saveWorkspaceDotEnvVariables = (workspaceUid, variables, filename = '.env') => (dispatch, getState) => { + return new Promise((resolve, reject) => { + const state = getState(); + const workspace = state.workspaces.workspaces.find((w) => w.uid === workspaceUid); + + if (!workspace) { + return reject(new Error('Workspace not found')); + } + + if (!workspace.pathname) { + return reject(new Error('Workspace path not found')); + } + + ipcRenderer + .invoke('renderer:save-workspace-dotenv-variables', { workspacePath: workspace.pathname, variables, filename }) + .then(resolve) + .catch(reject); + }); +}; + +export const saveWorkspaceDotEnvRaw = (workspaceUid, content, filename = '.env') => (dispatch, getState) => { + return new Promise((resolve, reject) => { + const state = getState(); + const workspace = state.workspaces.workspaces.find((w) => w.uid === workspaceUid); + + if (!workspace) { + return reject(new Error('Workspace not found')); + } + + if (!workspace.pathname) { + return reject(new Error('Workspace path not found')); + } + + ipcRenderer + .invoke('renderer:save-workspace-dotenv-raw', { workspacePath: workspace.pathname, content, filename }) + .then(resolve) + .catch(reject); + }); +}; + +export const createWorkspaceDotEnvFile = (workspaceUid, filename = '.env') => (dispatch, getState) => { + return new Promise((resolve, reject) => { + const state = getState(); + const workspace = state.workspaces.workspaces.find((w) => w.uid === workspaceUid); + + if (!workspace) { + return reject(new Error('Workspace not found')); + } + + if (!workspace.pathname) { + return reject(new Error('Workspace path not found')); + } + + ipcRenderer + .invoke('renderer:create-workspace-dotenv-file', { workspacePath: workspace.pathname, filename }) + .then(resolve) + .catch(reject); + }); +}; + +export const deleteWorkspaceDotEnvFile = (workspaceUid, filename = '.env') => (dispatch, getState) => { + return new Promise((resolve, reject) => { + const state = getState(); + const workspace = state.workspaces.workspaces.find((w) => w.uid === workspaceUid); + + if (!workspace) { + return reject(new Error('Workspace not found')); + } + + if (!workspace.pathname) { + return reject(new Error('Workspace path not found')); + } + + ipcRenderer + .invoke('renderer:delete-workspace-dotenv-file', { workspacePath: workspace.pathname, filename }) + .then(resolve) + .catch(reject); + }); +}; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/index.js index d8bb63f1f..ec62e47c7 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/index.js @@ -84,6 +84,38 @@ export const workspacesSlice = createSlice({ if (workspace) { workspace.processEnvVariables = processEnvVariables; } + }, + + setWorkspaceDotEnvVariables: (state, action) => { + const { workspaceUid, variables, exists, filename = '.env' } = action.payload; + const workspace = state.workspaces.find((w) => w.uid === workspaceUid); + + if (workspace) { + if (!workspace.dotEnvFiles) { + workspace.dotEnvFiles = []; + } + + const existingIndex = workspace.dotEnvFiles.findIndex((f) => f.filename === filename); + if (existingIndex >= 0) { + if (exists) { + workspace.dotEnvFiles[existingIndex] = { filename, variables, exists }; + } else { + workspace.dotEnvFiles.splice(existingIndex, 1); + } + } else if (exists) { + workspace.dotEnvFiles.push({ filename, variables, exists }); + } + + workspace.dotEnvFiles.sort((a, b) => { + if (a.filename === '.env') return -1; + if (b.filename === '.env') return 1; + return a.filename.localeCompare(b.filename); + }); + + const mainEnvFile = workspace.dotEnvFiles.find((f) => f.filename === '.env'); + workspace.dotEnvVariables = mainEnvFile?.variables || []; + workspace.dotEnvExists = mainEnvFile?.exists || false; + } } } }); @@ -96,7 +128,8 @@ export const { addCollectionToWorkspace, removeCollectionFromWorkspace, updateWorkspaceLoadingState, - workspaceDotEnvUpdateEvent + workspaceDotEnvUpdateEvent, + setWorkspaceDotEnvVariables } = workspacesSlice.actions; export default workspacesSlice.reducer; diff --git a/packages/bruno-electron/src/app/collection-watcher.js b/packages/bruno-electron/src/app/collection-watcher.js index 7d03ba77a..038ba0195 100644 --- a/packages/bruno-electron/src/app/collection-watcher.js +++ b/packages/bruno-electron/src/app/collection-watcher.js @@ -16,30 +16,22 @@ const { parseCollection, parseFolder } = require('@usebruno/filestore'); -const { parseDotEnv } = require('@usebruno/filestore'); const { uuid } = require('../utils/common'); const { getRequestUid } = require('../cache/requestUids'); const { decryptStringSafe } = require('../utils/encryption'); -const { setDotEnvVars } = require('../store/process-env'); const { setBrunoConfig } = require('../store/bruno-config'); const EnvironmentSecretsStore = require('../store/env-secrets'); const UiStateSnapshot = require('../store/ui-state-snapshot'); const { parseFileMeta, hydrateRequestWithUuid } = require('../utils/collection'); const { parseLargeRequestWithRedaction } = require('../utils/parse'); const { transformBrunoConfigAfterRead } = require('../utils/transformBrunoConfig'); +const dotEnvWatcher = require('./dotenv-watcher'); const MAX_FILE_SIZE = 2.5 * 1024 * 1024; const environmentSecretsStore = new EnvironmentSecretsStore(); -const isDotEnvFile = (pathname, collectionPath) => { - const dirname = path.dirname(pathname); - const basename = path.basename(pathname); - - return dirname === collectionPath && basename === '.env'; -}; - const isBrunoConfigFile = (pathname, collectionPath) => { const dirname = path.dirname(pathname); const basename = path.basename(pathname); @@ -227,24 +219,6 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread } } - if (isDotEnvFile(pathname, collectionPath)) { - try { - const content = fs.readFileSync(pathname, 'utf8'); - const jsonData = parseDotEnv(content); - - setDotEnvVars(collectionUid, jsonData); - const payload = { - collectionUid, - processEnvVariables: { - ...jsonData - } - }; - win.webContents.send('main:process-env-update', payload); - } catch (err) { - console.error(err); - } - } - if (isEnvironmentsFolder(pathname, collectionPath)) { return addEnvironmentFile(win, pathname, collectionUid, collectionPath); } @@ -470,26 +444,6 @@ const change = async (win, pathname, collectionUid, collectionPath) => { return; } - if (isDotEnvFile(pathname, collectionPath)) { - try { - const content = fs.readFileSync(pathname, 'utf8'); - const jsonData = parseDotEnv(content); - - setDotEnvVars(collectionUid, jsonData); - const payload = { - collectionUid, - processEnvVariables: { - ...jsonData - } - }; - win.webContents.send('main:process-env-update', payload); - } catch (err) { - console.error(err); - } - - return; - } - if (isEnvironmentsFolder(pathname, collectionPath)) { return changeEnvironmentFile(win, pathname, collectionUid, collectionPath); } @@ -759,6 +713,12 @@ class CollectionWatcher { ignored: (filepath) => { const normalizedPath = normalizeAndResolvePath(filepath); const relativePath = path.relative(watchPath, normalizedPath); + const basename = path.basename(filepath); + + // Ignore .env files - handled by dotenv-watcher + if (basename === '.env' || basename.startsWith('.env.')) { + return true; + } // Check if any path segment matches a default ignore pattern (handles symlinks) const pathSegments = relativePath.split(path.sep); @@ -811,6 +771,8 @@ class CollectionWatcher { }); this.watchers[watchPath] = watcher; + + dotEnvWatcher.addCollectionWatcher(win, watchPath, collectionUid); }, 100); } @@ -824,6 +786,8 @@ class CollectionWatcher { this.watchers[watchPath] = null; } + dotEnvWatcher.removeCollectionWatcher(watchPath); + const tempDirectoryPath = this.tempDirectoryMap[watchPath]; if (tempDirectoryPath && this.watchers[tempDirectoryPath]) { this.watchers[tempDirectoryPath].close(); diff --git a/packages/bruno-electron/src/app/dotenv-watcher.js b/packages/bruno-electron/src/app/dotenv-watcher.js new file mode 100644 index 000000000..aef09e6a7 --- /dev/null +++ b/packages/bruno-electron/src/app/dotenv-watcher.js @@ -0,0 +1,214 @@ +const fs = require('fs'); +const path = require('path'); +const chokidar = require('chokidar'); +const { parseDotEnv } = require('@usebruno/filestore'); +const { setDotEnvVars, clearDotEnvVars, setWorkspaceDotEnvVars, clearWorkspaceDotEnvVars } = require('../store/process-env'); + +const isDotEnvFile = (filename) => { + return filename === '.env' || filename.startsWith('.env.'); +}; + +const parseVariablesToArray = (envObject) => { + return Object.entries(envObject).map(([name, value]) => ({ + name, + value, + enabled: true, + secret: false + })); +}; + +const DEFAULT_WATCHER_OPTIONS = { + ignoreInitial: false, + persistent: true, + ignorePermissionErrors: true, + depth: 0 +}; + +const createFileHandler = (win, options) => (pathname) => { + const { type, uid, uidKey, pathKey, basePath, setEnvVars } = options; + const filename = path.basename(pathname); + + if (!isDotEnvFile(filename)) { + return; + } + + try { + const content = fs.readFileSync(pathname, 'utf8'); + const jsonData = parseDotEnv(content); + + if (filename === '.env') { + setEnvVars(jsonData); + } + + const variables = parseVariablesToArray(jsonData); + + if (!win.isDestroyed()) { + const payload = { + type, + [uidKey]: uid, + filename, + variables, + exists: true, + processEnvVariables: { ...jsonData } + }; + if (pathKey) { + payload[pathKey] = basePath; + } + win.webContents.send('main:dotenv-file-update', payload); + } + } catch (err) { + console.error(`Error processing dotenv file ${pathname}:`, err); + } +}; + +const createUnlinkHandler = (win, options) => (pathname) => { + const { type, uid, uidKey, pathKey, basePath, clearEnvVars } = options; + const filename = path.basename(pathname); + + if (!isDotEnvFile(filename)) { + return; + } + + if (filename === '.env') { + clearEnvVars(); + } + + if (!win.isDestroyed()) { + const payload = { + type, + [uidKey]: uid, + filename, + variables: [], + exists: false, + processEnvVariables: {} + }; + if (pathKey) { + payload[pathKey] = basePath; + } + win.webContents.send('main:dotenv-file-update', payload); + } +}; + +class DotEnvWatcher { + constructor() { + this.collectionWatchers = new Map(); + this.workspaceWatchers = new Map(); + } + + addCollectionWatcher(win, collectionPath, collectionUid) { + if (this.collectionWatchers.has(collectionPath)) { + this.collectionWatchers.get(collectionPath).close(); + } + + const dotEnvPattern = path.join(collectionPath, '.env*'); + + const watcher = chokidar.watch(dotEnvPattern, { + ...DEFAULT_WATCHER_OPTIONS, + awaitWriteFinish: { + stabilityThreshold: 80, + pollInterval: 100 + } + }); + + const handlerOptions = { + type: 'collection', + uid: collectionUid, + uidKey: 'collectionUid', + basePath: collectionPath, + setEnvVars: (data) => setDotEnvVars(collectionUid, data), + clearEnvVars: () => clearDotEnvVars(collectionUid) + }; + + const handleFile = createFileHandler(win, handlerOptions); + const handleUnlink = createUnlinkHandler(win, handlerOptions); + + watcher.on('add', handleFile); + watcher.on('change', handleFile); + watcher.on('unlink', handleUnlink); + watcher.on('error', (err) => { + console.error(`Collection watcher error for ${collectionPath}:`, err); + }); + + this.collectionWatchers.set(collectionPath, watcher); + } + + removeCollectionWatcher(collectionPath, collectionUid) { + if (this.collectionWatchers.has(collectionPath)) { + this.collectionWatchers.get(collectionPath).close(); + this.collectionWatchers.delete(collectionPath); + } + if (collectionUid) { + clearDotEnvVars(collectionUid); + } + } + + hasCollectionWatcher(collectionPath) { + return this.collectionWatchers.has(collectionPath); + } + + addWorkspaceWatcher(win, workspacePath, workspaceUid) { + if (this.workspaceWatchers.has(workspacePath)) { + this.workspaceWatchers.get(workspacePath).close(); + } + + const dotEnvPattern = path.join(workspacePath, '.env*'); + + const watcher = chokidar.watch(dotEnvPattern, { + ...DEFAULT_WATCHER_OPTIONS, + awaitWriteFinish: { + stabilityThreshold: 80, + pollInterval: 250 + } + }); + + const handlerOptions = { + type: 'workspace', + uid: workspaceUid, + uidKey: 'workspaceUid', + pathKey: 'workspacePath', + basePath: workspacePath, + setEnvVars: (data) => setWorkspaceDotEnvVars(workspacePath, data), + clearEnvVars: () => clearWorkspaceDotEnvVars(workspacePath) + }; + + const handleFile = createFileHandler(win, handlerOptions); + const handleUnlink = createUnlinkHandler(win, handlerOptions); + + watcher.on('add', handleFile); + watcher.on('change', handleFile); + watcher.on('unlink', handleUnlink); + watcher.on('error', (err) => { + console.error(`Workspace watcher error for ${workspacePath}:`, err); + }); + + this.workspaceWatchers.set(workspacePath, watcher); + } + + removeWorkspaceWatcher(workspacePath) { + if (this.workspaceWatchers.has(workspacePath)) { + this.workspaceWatchers.get(workspacePath).close(); + this.workspaceWatchers.delete(workspacePath); + } + clearWorkspaceDotEnvVars(workspacePath); + } + + hasWorkspaceWatcher(workspacePath) { + return this.workspaceWatchers.has(workspacePath); + } + + closeAll() { + for (const [path, watcher] of this.collectionWatchers) { + watcher.close(); + } + this.collectionWatchers.clear(); + + for (const [path, watcher] of this.workspaceWatchers) { + watcher.close(); + } + this.workspaceWatchers.clear(); + } +} + +const dotEnvWatcher = new DotEnvWatcher(); + +module.exports = dotEnvWatcher; diff --git a/packages/bruno-electron/src/app/workspace-watcher.js b/packages/bruno-electron/src/app/workspace-watcher.js index aecaa27d2..1cc38c87c 100644 --- a/packages/bruno-electron/src/app/workspace-watcher.js +++ b/packages/bruno-electron/src/app/workspace-watcher.js @@ -5,10 +5,10 @@ const chokidar = require('chokidar'); const yaml = require('js-yaml'); const { generateUidBasedOnHash, uuid } = require('../utils/common'); const { getWorkspaceUid } = require('../utils/workspace-config'); -const { parseEnvironment, parseDotEnv } = require('@usebruno/filestore'); +const { parseEnvironment } = require('@usebruno/filestore'); const EnvironmentSecretsStore = require('../store/env-secrets'); const { decryptStringSafe } = require('../utils/encryption'); -const { setWorkspaceDotEnvVars, clearWorkspaceDotEnvVars } = require('../store/process-env'); +const dotEnvWatcher = require('./dotenv-watcher'); const environmentSecretsStore = new EnvironmentSecretsStore(); @@ -123,51 +123,15 @@ const handleGlobalEnvironmentFileUnlink = async (win, pathname, workspaceUid) => } }; -const handleWorkspaceDotEnvFile = (win, workspacePath, workspaceUid) => { - try { - const dotEnvPath = path.join(workspacePath, '.env'); - if (!fs.existsSync(dotEnvPath)) { - return; - } - - const content = fs.readFileSync(dotEnvPath, 'utf8'); - const jsonData = parseDotEnv(content); - - setWorkspaceDotEnvVars(workspacePath, jsonData); - win.webContents.send('main:workspace-dotenv-update', { - workspaceUid, - workspacePath, - processEnvVariables: { ...jsonData } - }); - } catch (error) { - console.error('Error handling workspace .env file:', error); - } -}; - -const handleWorkspaceDotEnvUnlink = (win, workspacePath, workspaceUid) => { - try { - clearWorkspaceDotEnvVars(workspacePath); - win.webContents.send('main:workspace-dotenv-update', { - workspaceUid, - workspacePath, - processEnvVariables: {} - }); - } catch (error) { - console.error('Error handling workspace .env file unlink:', error); - } -}; - class WorkspaceWatcher { constructor() { this.watchers = {}; this.environmentWatchers = {}; - this.dotEnvWatchers = {}; } addWatcher(win, workspacePath) { const workspaceFilePath = path.join(workspacePath, 'workspace.yml'); const environmentsDir = path.join(workspacePath, 'environments'); - const dotEnvFilePath = path.join(workspacePath, '.env'); const workspaceUid = getWorkspaceUid(workspacePath); if (this.watchers[workspacePath]) { @@ -176,9 +140,6 @@ class WorkspaceWatcher { if (this.environmentWatchers[workspacePath]) { this.environmentWatchers[workspacePath].close(); } - if (this.dotEnvWatchers[workspacePath]) { - this.dotEnvWatchers[workspacePath].close(); - } const self = this; setTimeout(() => { @@ -186,9 +147,6 @@ class WorkspaceWatcher { return; } - // Load initial .env file if exists - handleWorkspaceDotEnvFile(win, workspacePath, workspaceUid); - const watcher = chokidar.watch(workspaceFilePath, { ignoreInitial: true, persistent: true, @@ -199,29 +157,11 @@ class WorkspaceWatcher { } }); - // Only listen for 'change' events - 'add' event is not needed because: - // 1. The workspace is already loaded when the watcher is started - // 2. ignoreInitial: true prevents firing for existing files - // 3. If workspace.yml is deleted and recreated, 'change' will catch it watcher.on('change', () => handleWorkspaceFileChange(win, workspacePath)); self.watchers[workspacePath] = watcher; - const dotEnvWatcher = chokidar.watch(dotEnvFilePath, { - ignoreInitial: true, - persistent: true, - ignorePermissionErrors: true, - awaitWriteFinish: { - stabilityThreshold: 80, - pollInterval: 250 - } - }); - - dotEnvWatcher.on('add', () => handleWorkspaceDotEnvFile(win, workspacePath, workspaceUid)); - dotEnvWatcher.on('change', () => handleWorkspaceDotEnvFile(win, workspacePath, workspaceUid)); - dotEnvWatcher.on('unlink', () => handleWorkspaceDotEnvUnlink(win, workspacePath, workspaceUid)); - - self.dotEnvWatchers[workspacePath] = dotEnvWatcher; + dotEnvWatcher.addWorkspaceWatcher(win, workspacePath, workspaceUid); if (fs.existsSync(environmentsDir)) { const envWatcher = chokidar.watch(path.join(environmentsDir, `*.yml`), { @@ -275,12 +215,7 @@ class WorkspaceWatcher { this.environmentWatchers[workspacePath].close(); delete this.environmentWatchers[workspacePath]; } - if (this.dotEnvWatchers[workspacePath]) { - this.dotEnvWatchers[workspacePath].close(); - delete this.dotEnvWatchers[workspacePath]; - } - // Clear workspace env vars when watcher is removed - clearWorkspaceDotEnvVars(workspacePath); + dotEnvWatcher.removeWorkspaceWatcher(workspacePath); } catch (error) { console.error('Error removing workspace watcher:', error); } diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index e72e41195..68ce1bbe0 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -46,6 +46,7 @@ const { getPaths, generateUniqueName, isDotEnvFile, + isValidDotEnvFilename, isBrunoConfigFile, isBruEnvironmentConfig, isCollectionRootBruFile @@ -620,6 +621,99 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { } }); + // Save .env file variables for collection + ipcMain.handle('renderer:save-dotenv-variables', async (event, collectionPathname, variables, filename = '.env') => { + try { + if (!isValidDotEnvFilename(filename)) { + throw new Error('Invalid .env filename'); + } + + const dotEnvPath = path.join(collectionPathname, filename); + + // Convert variables array to .env format + const content = variables + .filter((v) => v.name && v.name.trim() !== '') + .map((v) => { + const value = v.value || ''; + // If value contains newlines or special characters, wrap in quotes + if (value.includes('\n') || value.includes('"') || value.includes('\'') || value.includes('\\')) { + // Escape backslashes first, then double quotes + const escapedValue = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return `${v.name}="${escapedValue}"`; + } + return `${v.name}=${value}`; + }) + .join('\n'); + + await writeFile(dotEnvPath, content); + + return { success: true }; + } catch (error) { + console.error('Error saving .env file:', error); + return Promise.reject(error); + } + }); + + // Save .env file raw content for collection + ipcMain.handle('renderer:save-dotenv-raw', async (event, collectionPathname, content, filename = '.env') => { + try { + if (!isValidDotEnvFilename(filename)) { + throw new Error('Invalid .env filename'); + } + + const dotEnvPath = path.join(collectionPathname, filename); + await writeFile(dotEnvPath, content); + return { success: true }; + } catch (error) { + console.error('Error saving .env file:', error); + return Promise.reject(error); + } + }); + + // Create .env file for collection + ipcMain.handle('renderer:create-dotenv-file', async (event, collectionPathname, filename = '.env') => { + try { + if (!isValidDotEnvFilename(filename)) { + throw new Error('Invalid .env filename'); + } + + const dotEnvPath = path.join(collectionPathname, filename); + + if (fs.existsSync(dotEnvPath)) { + throw new Error(`${filename} file already exists`); + } + + await writeFile(dotEnvPath, ''); + + return { success: true, filename }; + } catch (error) { + console.error('Error creating .env file:', error); + return Promise.reject(error); + } + }); + + // Delete .env file for collection + ipcMain.handle('renderer:delete-dotenv-file', async (event, collectionPathname, filename = '.env') => { + try { + if (!isValidDotEnvFilename(filename)) { + throw new Error('Invalid .env filename'); + } + + const dotEnvPath = path.join(collectionPathname, filename); + + if (!fs.existsSync(dotEnvPath)) { + throw new Error(`${filename} file does not exist`); + } + + fs.unlinkSync(dotEnvPath); + + return { success: true }; + } catch (error) { + console.error('Error deleting .env file:', error); + return Promise.reject(error); + } + }); + // update environment color ipcMain.handle('renderer:update-environment-color', async (event, collectionPathname, environmentName, color) => { try { diff --git a/packages/bruno-electron/src/ipc/global-environments.js b/packages/bruno-electron/src/ipc/global-environments.js index 432fd7330..7821e233e 100644 --- a/packages/bruno-electron/src/ipc/global-environments.js +++ b/packages/bruno-electron/src/ipc/global-environments.js @@ -1,7 +1,9 @@ require('dotenv').config(); +const fs = require('fs'); +const path = require('path'); const { ipcMain } = require('electron'); const { globalEnvironmentsStore } = require('../store/global-environments'); -const { generateUniqueName, sanitizeName } = require('../utils/filesystem'); +const { generateUniqueName, sanitizeName, writeFile, isValidDotEnvFilename } = require('../utils/filesystem'); const registerGlobalEnvironmentsIpc = (mainWindow, workspaceEnvironmentsManager) => { ipcMain.handle('renderer:create-global-environment', async (event, { uid, name, variables, workspaceUid, workspacePath }) => { @@ -100,6 +102,116 @@ const registerGlobalEnvironmentsIpc = (mainWindow, workspaceEnvironmentsManager) } }); + // Save workspace .env file variables + ipcMain.handle('renderer:save-workspace-dotenv-variables', async (event, { workspacePath, variables, filename = '.env' }) => { + try { + if (!workspacePath) { + throw new Error('Workspace path is required'); + } + + if (!isValidDotEnvFilename(filename)) { + throw new Error('Invalid .env filename'); + } + + const dotEnvPath = path.join(workspacePath, filename); + + // Convert variables array to .env format + const content = variables + .filter((v) => v.name && v.name.trim() !== '') + .map((v) => { + const value = v.value || ''; + // If value contains newlines or special characters, wrap in quotes + if (value.includes('\n') || value.includes('"') || value.includes('\'') || value.includes('\\')) { + // Escape backslashes first, then double quotes + const escapedValue = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return `${v.name}="${escapedValue}"`; + } + return `${v.name}=${value}`; + }) + .join('\n'); + + await writeFile(dotEnvPath, content); + + return { success: true }; + } catch (error) { + console.error('Error saving workspace .env file:', error); + return Promise.reject(error); + } + }); + + // Save workspace .env file raw content + ipcMain.handle('renderer:save-workspace-dotenv-raw', async (event, { workspacePath, content, filename = '.env' }) => { + try { + if (!workspacePath) { + throw new Error('Workspace path is required'); + } + + if (!isValidDotEnvFilename(filename)) { + throw new Error('Invalid .env filename'); + } + + const dotEnvPath = path.join(workspacePath, filename); + await writeFile(dotEnvPath, content); + + return { success: true }; + } catch (error) { + console.error('Error saving workspace .env file:', error); + return Promise.reject(error); + } + }); + + // Create workspace .env file + ipcMain.handle('renderer:create-workspace-dotenv-file', async (event, { workspacePath, filename = '.env' }) => { + try { + if (!workspacePath) { + throw new Error('Workspace path is required'); + } + + if (!isValidDotEnvFilename(filename)) { + throw new Error('Invalid .env filename'); + } + + const dotEnvPath = path.join(workspacePath, filename); + + if (fs.existsSync(dotEnvPath)) { + throw new Error(`${filename} file already exists`); + } + + await writeFile(dotEnvPath, ''); + + return { success: true }; + } catch (error) { + console.error('Error creating workspace .env file:', error); + return Promise.reject(error); + } + }); + + // Delete workspace .env file + ipcMain.handle('renderer:delete-workspace-dotenv-file', async (event, { workspacePath, filename = '.env' }) => { + try { + if (!workspacePath) { + throw new Error('Workspace path is required'); + } + + if (!isValidDotEnvFilename(filename)) { + throw new Error('Invalid .env filename'); + } + + const dotEnvPath = path.join(workspacePath, filename); + + if (!fs.existsSync(dotEnvPath)) { + throw new Error(`${filename} file does not exist`); + } + + fs.unlinkSync(dotEnvPath); + + return { success: true }; + } catch (error) { + console.error('Error deleting workspace .env file:', error); + return Promise.reject(error); + } + }); + ipcMain.handle('renderer:update-global-environment-color', async (event, { environmentUid, color, workspacePath }) => { try { if (workspacePath && workspaceEnvironmentsManager) { diff --git a/packages/bruno-electron/src/store/process-env.js b/packages/bruno-electron/src/store/process-env.js index 3dbdd087b..d68258fe7 100644 --- a/packages/bruno-electron/src/store/process-env.js +++ b/packages/bruno-electron/src/store/process-env.js @@ -32,6 +32,10 @@ const setDotEnvVars = (collectionUid, envVars) => { dotEnvVars[collectionUid] = envVars; }; +const clearDotEnvVars = (collectionUid) => { + delete dotEnvVars[collectionUid]; +}; + const setWorkspaceDotEnvVars = (workspacePath, envVars) => { workspaceDotEnvVars[workspacePath] = envVars; }; @@ -51,6 +55,7 @@ const clearCollectionWorkspace = (collectionUid) => { module.exports = { getProcessEnvVars, setDotEnvVars, + clearDotEnvVars, setWorkspaceDotEnvVars, clearWorkspaceDotEnvVars, setCollectionWorkspace, diff --git a/packages/bruno-electron/src/utils/filesystem.js b/packages/bruno-electron/src/utils/filesystem.js index 0d405419f..7e88f0c95 100644 --- a/packages/bruno-electron/src/utils/filesystem.js +++ b/packages/bruno-electron/src/utils/filesystem.js @@ -445,6 +445,13 @@ const isDotEnvFile = (pathname, collectionPath) => { return dirname === collectionPath && basename === '.env'; }; +const isValidDotEnvFilename = (filename) => { + if (!filename || typeof filename !== 'string') return false; + const basename = path.basename(filename); + if (basename !== filename) return false; + return basename === '.env' || (basename.startsWith('.env.') && /^\.env\.[a-zA-Z0-9._-]+$/.test(basename)); +}; + const isBrunoConfigFile = (pathname, collectionPath) => { const dirname = path.dirname(pathname); const basename = path.basename(pathname); @@ -504,6 +511,7 @@ module.exports = { generateUniqueName, getCollectionFormat, isDotEnvFile, + isValidDotEnvFilename, isBrunoConfigFile, isBruEnvironmentConfig, isCollectionRootBruFile