Add: dotenv visual editor (#6964)

This commit is contained in:
naman-bruno
2026-02-02 19:43:54 +05:30
committed by GitHub
parent c9059c9905
commit 700e25a1d5
33 changed files with 2913 additions and 507 deletions

View File

@@ -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;

View File

@@ -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 (
<StyledWrapper className={expanded ? 'expanded' : 'collapsed'}>
<div className="section-header" onClick={onToggle}>
<div className="section-title-wrapper">
<IconChevronRight
size={14}
strokeWidth={2}
className={`section-icon ${expanded ? 'expanded' : ''}`}
/>
<span className="section-title">{title}</span>
{badge !== undefined && badge !== null && (
<span className="section-badge">{badge}</span>
)}
</div>
{actions && (
<div className="section-actions" onClick={(e) => e.stopPropagation()}>
{actions}
</div>
)}
</div>
<div className="section-content">
{children}
</div>
</StyledWrapper>
);
};
export default CollapsibleSection;

View File

@@ -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;

View File

@@ -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 (
<StyledWrapper>
<div className="header">
<h3 className="title">{title}</h3>
<div className="actions">
{dotEnvExists && (
<>
<div className="view-toggle" role="group" aria-label="View mode">
<button
type="button"
className={`toggle-btn ${viewMode === 'table' ? 'active' : ''}`}
onClick={() => onViewModeChange?.('table')}
aria-pressed={viewMode === 'table'}
>
Table
</button>
<button
type="button"
className={`toggle-btn ${viewMode === 'raw' ? 'active' : ''}`}
onClick={() => onViewModeChange?.('raw')}
aria-pressed={viewMode === 'raw'}
>
Raw
</button>
</div>
<button type="button" onClick={handleDeleteClick} title="Delete .env file" className="action-btn delete-btn">
<IconTrash size={15} strokeWidth={1.5} />
</button>
</>
)}
</div>
</div>
{showDeleteModal && (
<DeleteDotEnvFile
onClose={() => setShowDeleteModal(false)}
onConfirm={handleConfirmDelete}
filename={title}
/>
)}
<div className="content">
{children}
</div>
</StyledWrapper>
);
};
export default DotEnvFileDetails;

View File

@@ -0,0 +1,16 @@
import React from 'react';
import { IconFileOff } from '@tabler/icons';
const DotEnvEmptyState = () => {
return (
<div className="empty-state">
<IconFileOff size={48} strokeWidth={1.5} />
<div className="title">No .env File</div>
<div className="description">
Add a variable below to create a .env file in this location.
</div>
</div>
);
};
export default DotEnvEmptyState;

View File

@@ -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 (
<span>
<IconAlertCircle id={id} className="text-red-600 cursor-pointer" size={20} />
<Tooltip className="tooltip-mod" anchorId={id} html={meta.error || ''} />
</span>
);
});
export default DotEnvErrorMessage;

View File

@@ -0,0 +1,43 @@
import React from 'react';
import CodeEditor from 'components/CodeEditor';
const DotEnvRawView = ({
collection,
item,
theme,
value,
onChange,
onSave,
onReset,
isSaving
}) => {
return (
<>
<div className="raw-editor-container">
<CodeEditor
collection={collection}
item={item}
theme={theme}
value={value}
onEdit={onChange}
onSave={onSave}
mode="text/plain"
enableVariableHighlighting={false}
enableBrunoVarInfo={false}
/>
</div>
<div className="button-container">
<div className="flex items-center">
<button type="button" className="submit" onClick={onSave} disabled={isSaving} data-testid="save-dotenv-raw">
{isSaving ? 'Saving...' : 'Save'}
</button>
<button type="button" className="submit reset ml-2" onClick={onReset} disabled={isSaving} data-testid="reset-dotenv-raw">
Reset
</button>
</div>
</div>
</>
);
};
export default DotEnvRawView;

View File

@@ -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 }) => (
<tr key={item.uid} data-testid={`dotenv-var-row-${item.name}`}>{children}</tr>
), (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 (
<>
<td>
<div className="flex items-center">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${index}.name`}
name={`${index}.name`}
value={variable.name}
placeholder={isLastEmptyRow ? 'Name' : ''}
onChange={(e) => onNameChange(index, e)}
onBlur={() => onNameBlur(index)}
onKeyDown={(e) => onNameKeyDown(index, e)}
/>
<DotEnvErrorMessage formik={currentFormik} name={`${index}.name`} index={index} />
</div>
</td>
{showValueColumn && (
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative">
<MultiLineEditor
theme={theme}
name={`${index}.value`}
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
onChange={(newValue) => currentFormik.setFieldValue(`${index}.value`, newValue, true)}
onSave={onSave}
/>
</div>
</td>
)}
<td className="delete-col">
{!isLastEmptyRow && (
<button
type="button"
aria-label="Delete variable"
onClick={() => onRemoveVar(variable.uid)}
>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
</>
);
};
return (
<>
<TableVirtuoso
className="table-container"
style={{ height: tableHeight || MIN_TABLE_HEIGHT }}
components={{ TableRow }}
data={formik.values}
totalListHeightChanged={handleTotalHeightChanged}
fixedHeaderContent={() => (
<tr>
<td>Name</td>
{showValueColumn && <td>Value</td>}
<td className="delete-col"></td>
</tr>
)}
fixedItemHeight={35}
computeItemKey={(index, variable) => variable.uid}
itemContent={itemContent}
/>
<div className="button-container">
<div className="flex items-center">
<button type="button" className="submit" onClick={onSave} disabled={isSaving} data-testid="save-dotenv">
{isSaving ? 'Saving...' : 'Save'}
</button>
<button type="button" className="submit reset ml-2" onClick={onReset} disabled={isSaving} data-testid="reset-dotenv">
Reset
</button>
</div>
</div>
</>
);
};
export default DotEnvTableView;

View File

@@ -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;

View File

@@ -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 (
<StyledWrapper>
<DotEnvRawView
collection={collection}
item={item}
theme={displayedTheme}
value={rawValue}
onChange={handleRawChange}
onSave={handleSaveRaw}
onReset={handleReset}
isSaving={isSaving}
/>
</StyledWrapper>
);
}
// Empty state (no .env file exists yet)
const showEmptyState = !dotEnvExists && (!variables || variables.length === 0);
return (
<StyledWrapper>
{showEmptyState && <DotEnvEmptyState />}
<DotEnvTableView
formik={formik}
theme={displayedTheme}
showValueColumn={!showEmptyState}
tableHeight={showEmptyState ? MIN_TABLE_HEIGHT : tableHeight}
onHeightChange={setTableHeight}
onNameChange={handleNameChange}
onNameBlur={handleNameBlur}
onNameKeyDown={handleNameKeyDown}
onRemoveVar={handleRemoveVar}
onSave={handleSave}
onReset={handleReset}
isSaving={isSaving}
/>
</StyledWrapper>
);
};
export default DotEnvFileEditor;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 (
<Portal>
<StyledWrapper>
<Modal
size="sm"
title={`Delete ${filename} File`}
confirmText="Delete"
handleConfirm={handleConfirm}
handleCancel={onClose}
confirmButtonColor="danger"
>
Are you sure you want to delete <span className="font-medium">{filename}</span> file?
</Modal>
</StyledWrapper>
</Portal>
);
};
export default DeleteDotEnvFile;

View File

@@ -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;

View File

@@ -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 (
<DotEnvFileDetails
title={selectedDotEnvFile}
onDelete={() => handleDeleteDotEnvFile(selectedDotEnvFile)}
dotEnvExists={selectedDotEnvData?.exists}
viewMode={dotEnvViewMode}
onViewModeChange={handleDotEnvViewModeChange}
>
<DotEnvFileEditor
variables={selectedDotEnvData?.variables || []}
onSave={handleSaveDotEnv}
onSaveRaw={handleSaveDotEnvRaw}
isModified={isDotEnvModified}
setIsModified={setIsDotEnvModified}
dotEnvExists={selectedDotEnvData?.exists}
viewMode={dotEnvViewMode}
collection={collection}
/>
</DotEnvFileDetails>
);
}
if (selectedEnvironment) {
return (
<EnvironmentDetails
environment={selectedEnvironment}
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
collection={collection}
/>
);
}
return (
<div className="empty-state">
<IconFileAlert size={48} strokeWidth={1.5} />
<div className="title">No Environments</div>
<div className="actions">
<Button size="sm" color="secondary" onClick={() => handleCreateEnvClick()}>
Create Environment
</Button>
<Button size="sm" color="secondary" onClick={() => handleImportClick()}>
Import Environment
</Button>
</div>
</div>
);
};
return (
<StyledWrapper>
{openCreateModal && <CreateEnvironment collection={collection} onClose={() => setOpenCreateModal(false)} />}
{openImportModal && (
<ImportEnvironmentModal type="collection" collection={collection} onClose={() => setOpenImportModal(false)} />
)}
@@ -299,42 +518,111 @@ const EnvironmentList = ({
<div className="sidebar">
<div className="sidebar-header">
<h2 className="title">Environments</h2>
<div className="flex items-center gap-2">
<button className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
<IconPlus size={16} strokeWidth={1.5} />
</button>
<button className="btn-action" onClick={() => handleImportClick()} title="Import environment">
<IconDownload size={16} strokeWidth={1.5} />
</button>
<button className="btn-action" onClick={() => handleExportClick()} title="Export environment">
<IconUpload size={16} strokeWidth={1.5} />
</button>
</div>
<h2 className="title">Variables</h2>
</div>
<div className="search-container">
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
<input
type="text"
placeholder="Search environments..."
placeholder="Search..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="search-input"
/>
</div>
<div className="environments-list">
{filteredEnvironments.map((env) => (
<div
key={env.uid}
id={env.uid}
className={`environment-item ${selectedEnvironment.uid === env.uid ? 'active' : ''} ${renamingEnvUid === env.uid ? 'renaming' : ''} ${activeEnvironmentUid === env.uid ? 'activated' : ''}`}
onClick={() => renamingEnvUid !== env.uid && handleEnvironmentClick(env)}
onDoubleClick={() => handleEnvironmentDoubleClick(env)}
>
{renamingEnvUid === env.uid ? (
<div className="rename-container" ref={renameContainerRef}>
<div className="sections-container">
<CollapsibleSection
title="Environments"
expanded={environmentsExpanded}
onToggle={() => setEnvironmentsExpanded(!environmentsExpanded)}
actions={(
<>
<button type="button" className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
<IconPlus size={14} strokeWidth={1.5} />
</button>
<button type="button" className="btn-action" onClick={() => handleImportClick()} title="Import environment">
<IconDownload size={14} strokeWidth={1.5} />
</button>
<button type="button" className="btn-action" onClick={() => handleExportClick()} title="Export environment">
<IconUpload size={14} strokeWidth={1.5} />
</button>
</>
)}
>
<div className="environments-list">
{filteredEnvironments.map((env) => (
<div
key={env.uid}
id={env.uid}
className={classnames('environment-item', {
active: activeView === 'environment' && selectedEnvironment?.uid === env.uid,
renaming: renamingEnvUid === env.uid,
activated: activeEnvironmentUid === env.uid
})}
onClick={() => renamingEnvUid !== env.uid && handleEnvironmentClick(env)}
onDoubleClick={() => handleEnvironmentDoubleClick(env)}
>
{renamingEnvUid === env.uid ? (
<div className="rename-container" ref={renameContainerRef}>
<input
ref={inputRef}
type="text"
className="environment-name-input"
value={newEnvName}
onChange={handleEnvNameChange}
onKeyDown={handleEnvNameKeyDown}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveRename}
onMouseDown={(e) => e.preventDefault()}
title="Save"
>
<IconCheck size={14} strokeWidth={2} />
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancelRename}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
>
<IconX size={14} strokeWidth={2} />
</button>
</div>
</div>
) : (
<>
<ColorBadge color={env.color} size={8} />
<span className="environment-name">{env.name}</span>
<div className="environment-actions">
{activeEnvironmentUid === env.uid ? (
<div className="activated-checkmark" title="Active environment">
<IconCheck size={16} strokeWidth={2} />
</div>
) : (
<button
className="activate-btn"
onClick={(e) => handleActivateEnvironment(e, env)}
title="Activate environment"
>
<IconCheck size={16} strokeWidth={2} />
</button>
)}
</div>
</>
)}
</div>
))}
{isCreatingInline && (
<div className="environment-item creating" ref={createContainerRef}>
<input
ref={inputRef}
type="text"
@@ -342,6 +630,7 @@ const EnvironmentList = ({
value={newEnvName}
onChange={handleEnvNameChange}
onKeyDown={handleEnvNameKeyDown}
placeholder="Environment name..."
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
@@ -350,7 +639,7 @@ const EnvironmentList = ({
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveRename}
onClick={handleSaveNewEnv}
onMouseDown={(e) => e.preventDefault()}
title="Save"
>
@@ -358,7 +647,7 @@ const EnvironmentList = ({
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancelRename}
onClick={handleCancelCreate}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
>
@@ -366,76 +655,94 @@ const EnvironmentList = ({
</button>
</div>
</div>
) : (
<>
<ColorBadge color={env.color} size={8} />
<span className="environment-name">{env.name}</span>
<div className="environment-actions">
{activeEnvironmentUid === env.uid ? (
<div className="activated-checkmark" title="Active environment">
<IconCheck size={16} strokeWidth={2} />
</div>
) : (
<button
className="activate-btn"
onClick={(e) => handleActivateEnvironment(e, env)}
title="Activate environment"
>
<IconCheck size={16} strokeWidth={2} />
</button>
)}
</div>
</>
)}
{envNameError && (isCreatingInline || renamingEnvUid) && <div className="env-error">{envNameError}</div>}
{filteredEnvironments.length === 0 && !isCreatingInline && (
<div className="no-env-file">
<span>No environments</span>
</div>
)}
</div>
))}
</CollapsibleSection>
{isCreatingInline && (
<div className="environment-item creating" ref={createContainerRef}>
<input
ref={inputRef}
type="text"
className="environment-name-input"
value={newEnvName}
onChange={handleEnvNameChange}
onKeyDown={handleEnvNameKeyDown}
placeholder="Environment name..."
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveNewEnv}
onMouseDown={(e) => e.preventDefault()}
title="Save"
<CollapsibleSection
title=".env Files"
expanded={dotEnvExpanded}
onToggle={() => setDotEnvExpanded(!dotEnvExpanded)}
badge={dotEnvFiles.length}
actions={(
<button
className="btn-action"
onClick={handleCreateDotEnvInlineClick}
title="Create .env file"
>
<IconPlus size={14} strokeWidth={1.5} />
</button>
)}
>
<div className="environments-list">
{dotEnvFiles.map((file) => (
<div
key={file.filename}
className={classnames('environment-item', {
active: activeView === 'dotenv' && selectedDotEnvFile === file.filename
})}
onClick={() => handleDotEnvClick(file.filename)}
>
<IconCheck size={14} strokeWidth={2} />
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancelCreate}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
>
<IconX size={14} strokeWidth={2} />
</button>
</div>
<span className="environment-name">{file.filename}</span>
</div>
))}
{isCreatingDotEnvInline && (
<div className="environment-item creating" ref={dotEnvCreateContainerRef}>
<input
ref={dotEnvInputRef}
type="text"
className="environment-name-input"
value={newDotEnvName}
onChange={handleDotEnvNameChange}
onKeyDown={handleDotEnvNameKeyDown}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveNewDotEnv}
onMouseDown={(e) => e.preventDefault()}
title="Create"
>
<IconCheck size={14} strokeWidth={2} />
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancelDotEnvCreate}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
>
<IconX size={14} strokeWidth={2} />
</button>
</div>
</div>
)}
{dotEnvNameError && isCreatingDotEnvInline && <div className="env-error">{dotEnvNameError}</div>}
{dotEnvFiles.length === 0 && !isCreatingDotEnvInline && (
<div className="no-env-file">
<span>No .env files</span>
</div>
)}
</div>
)}
{envNameError && (isCreatingInline || renamingEnvUid) && <div className="env-error">{envNameError}</div>}
</CollapsibleSection>
</div>
</div>
<EnvironmentDetails
environment={selectedEnvironment}
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
collection={collection}
/>
{renderContent()}
</div>
</StyledWrapper>
);

View File

@@ -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 }) => (
<div className="empty-state">
<IconFileAlert size={48} strokeWidth={1.5} />
<div className="title">No Environments</div>
<div className="actions">
<Button size="sm" color="secondary" onClick={() => setTab('create')}>
Create Environment
</Button>
<Button size="sm" color="secondary" onClick={() => setTab('import')}>
Import Environment
</Button>
</div>
</div>
);
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 (
<StyledWrapper>
{tab === 'create' ? (
<CreateEnvironment collection={collection} onClose={() => setTab('default')} />
) : tab === 'import' ? (
<ImportEnvironmentModal type="collection" collection={collection} onClose={() => setTab('default')} />
) : (
<DefaultTab setTab={setTab} />
)}
</StyledWrapper>
);
}
return (
<StyledWrapper>
<EnvironmentList

View File

@@ -1,8 +1,14 @@
import React from 'react';
import { useSelector } from 'react-redux';
import WorkspaceEnvironments from 'components/WorkspaceHome/WorkspaceEnvironments';
const GlobalEnvironmentSettings = () => {
return <WorkspaceEnvironments />;
const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid);
const workspace = useSelector((state) =>
state.workspaces.workspaces.find((w) => w.uid === activeWorkspaceUid)
);
return <WorkspaceEnvironments workspace={workspace} />;
};
export default GlobalEnvironmentSettings;

View File

@@ -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;

View File

@@ -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 (
<DotEnvFileDetails
title={selectedDotEnvFile}
onDelete={() => handleDeleteDotEnvFile(selectedDotEnvFile)}
dotEnvExists={selectedDotEnvData?.exists}
viewMode={dotEnvViewMode}
onViewModeChange={handleDotEnvViewModeChange}
>
<DotEnvFileEditor
variables={selectedDotEnvData?.variables || []}
onSave={handleSaveDotEnv}
onSaveRaw={handleSaveDotEnvRaw}
isModified={isDotEnvModified}
setIsModified={setIsDotEnvModified}
dotEnvExists={selectedDotEnvData?.exists}
viewMode={dotEnvViewMode}
/>
</DotEnvFileDetails>
);
}
if (selectedEnvironment) {
return (
<EnvironmentDetails
environment={selectedEnvironment}
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
collection={collection}
/>
);
}
return (
<div className="empty-state">
<IconFileAlert size={48} strokeWidth={1.5} />
<div className="title">No Environments</div>
<div className="actions">
<Button size="sm" color="secondary" onClick={() => handleCreateEnvClick()}>
Create Environment
</Button>
<Button size="sm" color="secondary" onClick={() => handleImportClick()}>
Import Environment
</Button>
</div>
</div>
);
};
return (
<StyledWrapper>
{openCreateModal && <CreateEnvironment onClose={() => setOpenCreateModal(false)} />}
{openImportModal && <ImportEnvironmentModal type="global" onClose={() => setOpenImportModal(false)} />}
<div className="environments-container">
@@ -286,45 +511,113 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
</div>
)}
{/* Left Sidebar */}
<div className="sidebar">
<div className="sidebar-header">
<h2 className="title">Environments</h2>
<div className="flex items-center gap-2">
<button className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
<IconPlus size={16} strokeWidth={1.5} />
</button>
<button className="btn-action" onClick={() => handleImportClick()} title="Import environment">
<IconDownload size={16} strokeWidth={1.5} />
</button>
<button className="btn-action" onClick={() => handleExportClick()} title="Export environment">
<IconUpload size={16} strokeWidth={1.5} />
</button>
</div>
<h2 className="title">Variables</h2>
</div>
<div className="search-container">
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
<input
type="text"
placeholder="Search environments..."
placeholder="Search..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="search-input"
/>
</div>
<div className="environments-list">
{filteredEnvironments.map((env) => (
<div
key={env.uid}
id={env.uid}
className={`environment-item ${selectedEnvironment.uid === env.uid ? 'active' : ''} ${renamingEnvUid === env.uid ? 'renaming' : ''} ${activeEnvironmentUid === env.uid ? 'activated' : ''}`}
onClick={() => renamingEnvUid !== env.uid && handleEnvironmentClick(env)}
onDoubleClick={() => handleEnvironmentDoubleClick(env)}
>
{renamingEnvUid === env.uid ? (
<div className="rename-container" ref={renameContainerRef}>
<div className="sections-container">
<CollapsibleSection
title="Environments"
expanded={environmentsExpanded}
onToggle={() => setEnvironmentsExpanded(!environmentsExpanded)}
actions={(
<>
<button type="button" className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
<IconPlus size={14} strokeWidth={1.5} />
</button>
<button type="button" className="btn-action" onClick={() => handleImportClick()} title="Import environment">
<IconDownload size={14} strokeWidth={1.5} />
</button>
<button type="button" className="btn-action" onClick={() => handleExportClick()} title="Export environment">
<IconUpload size={14} strokeWidth={1.5} />
</button>
</>
)}
>
<div className="environments-list">
{filteredEnvironments.map((env) => (
<div
key={env.uid}
id={env.uid}
className={classnames('environment-item', {
active: activeView === 'environment' && selectedEnvironment?.uid === env.uid,
renaming: renamingEnvUid === env.uid,
activated: activeEnvironmentUid === env.uid
})}
onClick={() => renamingEnvUid !== env.uid && handleEnvironmentClick(env)}
onDoubleClick={() => handleEnvironmentDoubleClick(env)}
>
{renamingEnvUid === env.uid ? (
<div className="rename-container" ref={renameContainerRef}>
<input
ref={inputRef}
type="text"
className="environment-name-input"
value={newEnvName}
onChange={handleEnvNameChange}
onKeyDown={handleEnvNameKeyDown}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveRename}
onMouseDown={(e) => e.preventDefault()}
title="Save"
>
<IconCheck size={14} strokeWidth={2} />
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancelRename}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
>
<IconX size={14} strokeWidth={2} />
</button>
</div>
</div>
) : (
<>
<ColorBadge color={env.color} size={8} />
<span className="environment-name">{env.name}</span>
<div className="environment-actions">
{activeEnvironmentUid === env.uid ? (
<div className="activated-checkmark" title="Active environment">
<IconCheck size={16} strokeWidth={2} />
</div>
) : (
<button
className="activate-btn"
onClick={(e) => handleActivateEnvironment(e, env)}
title="Activate environment"
>
<IconCheck size={16} strokeWidth={2} />
</button>
)}
</div>
</>
)}
</div>
))}
{isCreatingInline && (
<div className="environment-item creating" ref={createContainerRef}>
<input
ref={inputRef}
type="text"
@@ -332,6 +625,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
value={newEnvName}
onChange={handleEnvNameChange}
onKeyDown={handleEnvNameKeyDown}
placeholder="Environment name..."
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
@@ -340,7 +634,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveRename}
onClick={handleSaveNewEnv}
onMouseDown={(e) => e.preventDefault()}
title="Save"
>
@@ -348,7 +642,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancelRename}
onClick={handleCancelCreate}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
>
@@ -356,79 +650,94 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
</button>
</div>
</div>
) : (
<>
<ColorBadge color={env.color} size={8} />
<span className="environment-name">{env.name}</span>
<div className="environment-actions">
{activeEnvironmentUid === env.uid ? (
<div className="activated-checkmark" title="Active environment">
<IconCheck size={16} strokeWidth={2} />
</div>
) : (
<button
className="activate-btn"
onClick={(e) => handleActivateEnvironment(e, env)}
title="Activate environment"
>
<IconCheck size={16} strokeWidth={2} />
</button>
)}
</div>
</>
)}
{envNameError && (isCreatingInline || renamingEnvUid) && <div className="env-error">{envNameError}</div>}
{filteredEnvironments.length === 0 && !isCreatingInline && (
<div className="no-env-file">
<span>No environments</span>
</div>
)}
</div>
))}
</CollapsibleSection>
{isCreatingInline && (
<div className="environment-item creating" ref={createContainerRef}>
<input
ref={inputRef}
type="text"
className="environment-name-input"
value={newEnvName}
onChange={handleEnvNameChange}
onKeyDown={handleEnvNameKeyDown}
placeholder="Environment name..."
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveNewEnv}
onMouseDown={(e) => e.preventDefault()}
title="Save"
<CollapsibleSection
title=".env Files"
expanded={dotEnvExpanded}
onToggle={() => setDotEnvExpanded(!dotEnvExpanded)}
badge={dotEnvFiles.length}
actions={(
<button
className="btn-action"
onClick={handleCreateDotEnvInlineClick}
title="Create .env file"
>
<IconPlus size={14} strokeWidth={1.5} />
</button>
)}
>
<div className="environments-list">
{dotEnvFiles.map((file) => (
<div
key={file.filename}
className={classnames('environment-item', {
active: activeView === 'dotenv' && selectedDotEnvFile === file.filename
})}
onClick={() => handleDotEnvClick(file.filename)}
>
<IconCheck size={14} strokeWidth={2} />
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancelCreate}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
>
<IconX size={14} strokeWidth={2} />
</button>
</div>
<span className="environment-name">{file.filename}</span>
</div>
))}
{isCreatingDotEnvInline && (
<div className="environment-item creating" ref={dotEnvCreateContainerRef}>
<input
ref={dotEnvInputRef}
type="text"
className="environment-name-input"
value={newDotEnvName}
onChange={handleDotEnvNameChange}
onKeyDown={handleDotEnvNameKeyDown}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveNewDotEnv}
onMouseDown={(e) => e.preventDefault()}
title="Create"
>
<IconCheck size={14} strokeWidth={2} />
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancelDotEnvCreate}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
>
<IconX size={14} strokeWidth={2} />
</button>
</div>
</div>
)}
{dotEnvNameError && isCreatingDotEnvInline && <div className="env-error">{dotEnvNameError}</div>}
{dotEnvFiles.length === 0 && !isCreatingDotEnvInline && (
<div className="no-env-file">
<span>No .env files</span>
</div>
)}
</div>
)}
{envNameError && (isCreatingInline || renamingEnvUid) && (
<div className="env-error">{envNameError}</div>
)}
</CollapsibleSection>
</div>
</div>
{/* Right Content */}
<EnvironmentDetails
environment={selectedEnvironment}
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
collection={collection}
/>
{renderContent()}
</div>
</StyledWrapper>
);

View File

@@ -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 }) => (
<div className="empty-state">
<IconFileAlert size={48} strokeWidth={1.5} />
<div className="title">No Environments</div>
<div className="actions">
<Button size="sm" color="secondary" onClick={() => setTab('create')}>
Create Environment
</Button>
<Button size="sm" color="secondary" onClick={() => setTab('import')}>
Import Environment
</Button>
</div>
</div>
);
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 (
<StyledWrapper>
{tab === 'create' ? (
<CreateEnvironment onClose={() => setTab('default')} />
) : tab === 'import' ? (
<ImportEnvironmentModal type="global" onClose={() => setTab('default')} />
) : (
<DefaultTab setTab={setTab} />
)}
</StyledWrapper>
);
}
return (
<StyledWrapper>
<EnvironmentList
environments={globalEnvironments}
environments={globalEnvironments || []}
activeEnvironmentUid={activeGlobalEnvironmentUid}
selectedEnvironment={selectedEnvironment}
setSelectedEnvironment={setSelectedEnvironment}
isModified={isModified}
setIsModified={setIsModified}
collection={null}
workspace={workspace}
setShowExportModal={setShowExportModal}
/>
{showExportModal && (
<ExportEnvironmentModal
onClose={() => setShowExportModal(false)}
environments={globalEnvironments}
environments={globalEnvironments || []}
environmentType="global"
/>
)}

View File

@@ -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]
);
};

View File

@@ -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();

View File

@@ -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);
});
};

View File

@@ -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,

View File

@@ -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);
});
};

View File

@@ -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;

View File

@@ -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();

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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