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 && (
-
-
-
-
- )}
-
- {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
-
- setTab('create')}>
- Create Environment
-
- setTab('import')}>
- Import Environment
-
-
-
-);
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
+
+ handleCreateEnvClick()}>
+ Create Environment
+
+ handleImportClick()}>
+ Import Environment
+
+
+
+ );
+ };
return (
- {openCreateModal && setOpenCreateModal(false)} />}
{openImportModal && setOpenImportModal(false)} />}
@@ -286,45 +511,113 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
)}
- {/* Left Sidebar */}
-
Environments
-
- handleCreateEnvClick()} title="Create environment">
-
-
- handleImportClick()} title="Import environment">
-
-
- handleExportClick()} title="Export environment">
-
-
-
+
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={(
+ <>
+ handleCreateEnvClick()} title="Create environment">
+
+
+ handleImportClick()} title="Import environment">
+
+
+ handleExportClick()} title="Export environment">
+
+
+ >
+ )}
+ >
+
+ {filteredEnvironments.map((env) => (
+
renamingEnvUid !== env.uid && handleEnvironmentClick(env)}
+ onDoubleClick={() => handleEnvironmentDoubleClick(env)}
+ >
+ {renamingEnvUid === env.uid ? (
+
+
+
+ e.preventDefault()}
+ title="Save"
+ >
+
+
+ e.preventDefault()}
+ title="Cancel"
+ >
+
+
+
+
+ ) : (
+ <>
+
+
{env.name}
+
+ {activeEnvironmentUid === env.uid ? (
+
+
+
+ ) : (
+
handleActivateEnvironment(e, env)}
+ title="Activate environment"
+ >
+
+
+ )}
+
+ >
+ )}
+
+ ))}
+
+ {isCreatingInline && (
+
e.preventDefault()}
title="Save"
>
@@ -348,7 +642,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
e.preventDefault()}
title="Cancel"
>
@@ -356,79 +650,94 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
- ) : (
- <>
-
- {env.name}
-
- {activeEnvironmentUid === env.uid ? (
-
-
-
- ) : (
-
handleActivateEnvironment(e, env)}
- title="Activate environment"
- >
-
-
- )}
-
- >
+ )}
+
+ {envNameError && (isCreatingInline || renamingEnvUid) && {envNameError}
}
+
+ {filteredEnvironments.length === 0 && !isCreatingInline && (
+
+ No environments
+
)}
- ))}
+
- {isCreatingInline && (
-
-
-
-
e.preventDefault()}
- title="Save"
+ setDotEnvExpanded(!dotEnvExpanded)}
+ badge={dotEnvFiles.length}
+ actions={(
+
+
+
+ )}
+ >
+
+ {dotEnvFiles.map((file) => (
+
handleDotEnvClick(file.filename)}
>
-
-
- e.preventDefault()}
- title="Cancel"
- >
-
-
-
+
{file.filename}
+
+ ))}
+
+ {isCreatingDotEnvInline && (
+
+
+
+ e.preventDefault()}
+ title="Create"
+ >
+
+
+ e.preventDefault()}
+ title="Cancel"
+ >
+
+
+
+
+ )}
+
+ {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
-
- setTab('create')}>
- Create Environment
-
- setTab('import')}>
- Import Environment
-
-
-
-);
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