mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-26 14:15:52 +00:00
Add: dotenv visual editor (#6964)
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
214
packages/bruno-electron/src/app/dotenv-watcher.js
Normal file
214
packages/bruno-electron/src/app/dotenv-watcher.js
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user