init: workspaces (#6264)

* init: workspaces
This commit is contained in:
naman-bruno
2025-12-04 04:56:43 +05:30
committed by GitHub
parent 6786f19d04
commit ebe0203415
108 changed files with 7315 additions and 908 deletions

View File

@@ -0,0 +1,134 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.workspace-header {
position: relative;
}
.workspace-rename-container {
height: 28px;
display: flex;
align-items: center;
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
gap: 8px;
border-radius: 4px;
}
.workspace-name-input {
padding: 0 8px;
font-size: 18px;
font-weight: 600;
border-radius: 4px;
background: transparent;
color: ${(props) => props.theme.text.primary};
outline: none;
min-width: 200px;
&:focus {
outline: none;
}
}
.inline-actions {
display: flex;
gap: 4px;
}
.inline-action-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
&.save {
color: ${(props) => props.theme.colors.text.green};
&:hover {
background: ${(props) => props.theme.colors.bg.green};
}
}
&.cancel {
color: ${(props) => props.theme.colors.text.red};
&:hover {
background: ${(props) => props.theme.colors.bg.red};
}
}
}
.workspace-error {
position: absolute;
top: 100%;
left: 16px;
margin-top: 4px;
font-size: 12px;
color: ${(props) => props.theme.colors.text.red};
}
.workspace-menu-dropdown {
min-width: 150px;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
transition: background 0.2s;
color: ${(props) => props.theme.text.primary};
&.disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.tabs-container {
border-bottom: 1px solid ${(props) => props.theme.workspace.border};
background: ${(props) => props.theme.bg.primary};
}
.tab-item {
position: relative;
cursor: pointer;
color: var(--color-tab-inactive);
border-bottom: 2px solid transparent;
transition: all 0.15s ease;
&:hover {
color: ${(props) => props.theme.text.primary};
border-bottom-color: ${(props) => props.theme.colors.border};
}
&.active {
border-bottom-color: ${(props) => props.theme.colors.text.yellow};
color: ${(props) => props.theme.tabs.active.color};
}
}
.workspace-action-buttons {
gap: 4px;
}
.workspace-button {
display: flex;
align-items: center;
gap: 5px;
padding: 4px 8px;
font-size: 12px;
border-radius: 8px;
color: ${(props) => props.theme.text.primary};
cursor: pointer;
&:hover {
background-color: ${(props) => props.theme.workspace.button.bg};
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,154 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.collections-table {
display: flex;
flex-direction: column;
height: 100%;
font-size: ${(props) => props.theme.font.size.base};
}
.collections-header {
display: grid;
gap: 16px;
padding: 10px 16px;
border-bottom: ${(props) => props.theme.workspace.collection.header.indentBorder};
position: sticky;
top: 0;
z-index: 10;
&:has(.header-git) {
grid-template-columns: 1fr 3fr 1fr 1.5fr;
}
&:not(:has(.header-git)) {
grid-template-columns: 1fr 3fr 1.5fr;
}
}
.header-cell {
font-weight: 600;
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.text.muted};
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex;
align-items: center;
}
.collections-body {
flex: 1;
overflow-y: auto;
}
.collection-row {
display: grid;
gap: 16px;
padding: 8px 16px;
border-bottom: ${(props) => props.theme.workspace.collection.item.indentBorder};
transition: background-color 0.15s ease;
cursor: pointer;
grid-template-columns: 1fr 3fr 1.5fr;
&:hover {
background-color: ${(props) => props.theme.sidebar.bg};
}
&:last-child {
border-bottom: none;
}
}
.row-cell {
display: flex;
align-items: center;
overflow: hidden;
}
.cell-name {
.collection-icon {
color: ${(props) => props.theme.workspace.accent};
flex-shrink: 0;
}
.collection-info {
min-width: 0;
flex: 1;
}
.collection-name {
font-weight: 400;
color: ${(props) => props.theme.text};
font-size: ${(props) => props.theme.font.size.base};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.collection-subtitle {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.text.muted};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
}
.cell-location {
.location-text {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.text.muted};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
}
.cell-actions {
justify-content: flex-end;
.action-buttons {
display: flex;
gap: 4px;
}
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: none;
background: transparent;
border-radius: 5px;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
color: ${(props) => props.theme.text.muted};
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
&:hover:not(:disabled) {
background-color: ${(props) => props.theme.listItem.hoverBg};
&.action-edit {
color: ${(props) => props.theme.text};
}
&.action-share {
color: #3B82F6;
}
&.action-delete {
color: #EF4444;
}
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,343 @@
import React, { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { IconBox, IconTrash, IconEdit, IconShare } from '@tabler/icons';
import { removeCollectionFromWorkspaceAction, importCollectionInWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { hideHomePage } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
import Modal from 'components/Modal';
import CreateCollection from 'components/Sidebar/CreateCollection';
import ImportCollection from 'components/Sidebar/ImportCollection';
import RenameCollection from 'components/Sidebar/Collections/Collection/RenameCollection';
import ShareCollection from 'components/ShareCollection';
import StyledWrapper from './StyledWrapper';
import { mountCollection } from 'providers/ReduxStore/slices/collections/actions';
const WorkspaceCollections = ({ workspace, onImportCollection }) => {
const dispatch = useDispatch();
const { collections } = useSelector((state) => state.collections);
const [collectionToRemove, setCollectionToRemove] = useState(null);
const [renameCollectionModalOpen, setRenameCollectionModalOpen] = useState(false);
const [shareCollectionModalOpen, setShareCollectionModalOpen] = useState(false);
const [selectedCollectionUid, setSelectedCollectionUid] = useState(null);
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
const handleImportCollection = ({ rawData, type }) => {
if (onImportCollection) {
onImportCollection();
return;
}
setImportCollectionModalOpen(false);
dispatch(importCollectionInWorkspace(rawData, workspace.uid, undefined, type))
.catch((err) => {
console.error(err);
toast.error('An error occurred while importing the collection');
});
};
const workspaceCollections = React.useMemo(() => {
if (!workspace.collections || workspace.collections.length === 0) {
return [];
}
const result = [];
workspace.collections.forEach((wc) => {
const loadedCollection = collections.find((c) => c.pathname === wc.path);
if (loadedCollection) {
result.push({
...loadedCollection,
isGitBacked: !!wc.remote,
gitRemoteUrl: wc.remote
});
} else {
result.push({
uid: `unloaded-${wc.path}`,
name: wc.name,
pathname: wc.path,
items: [],
environments: [],
isGitBacked: !!wc.remote,
isLoaded: false,
gitRemoteUrl: wc.remote,
git: { gitRootPath: null },
brunoConfig: {},
root: {
request: {
headers: [],
auth: { mode: 'none' },
vars: { req: [], res: [] },
script: { req: '', res: '' },
tests: ''
},
docs: ''
}
});
}
});
return result;
}, [workspace.collections, collections, workspace.pathname]);
const handleOpenCollectionClick = (collection, event) => {
if (event.target.closest('.action-buttons')) {
return;
}
if (collection.isLoaded === false) {
if (collection.isGitBacked) {
toast.error(`Collection "${collection.name}" needs to be cloned first`);
} else {
toast.error(`Collection "${collection.name}" does not exist on disk`);
}
return;
}
dispatch(mountCollection({
collectionUid: collection.uid,
collectionPathname: collection.pathname,
brunoConfig: collection.brunoConfig
}));
dispatch(hideHomePage());
dispatch(addTab({
uid: collection.uid,
collectionUid: collection.uid,
type: 'collection-settings'
}));
};
const handleRenameCollection = (collection) => {
if (collection.isLoaded === false) {
toast.error('Cannot rename collections that are not cloned yet');
return;
}
setSelectedCollectionUid(collection.uid);
setRenameCollectionModalOpen(true);
};
const handleShareCollection = (collection) => {
if (collection.isLoaded === false) {
toast.error('Please clone this collection first before sharing it');
return;
}
dispatch(mountCollection({
collectionUid: collection.uid,
collectionPathname: collection.pathname,
brunoConfig: collection.brunoConfig
}));
setSelectedCollectionUid(collection.uid);
setShareCollectionModalOpen(true);
};
const handleRemoveCollection = (collection) => {
setCollectionToRemove(collection);
};
const confirmRemoveCollection = async () => {
if (!collectionToRemove) return;
try {
await dispatch(removeCollectionFromWorkspaceAction(workspace.uid, collectionToRemove.pathname));
const collectionInfo = getCollectionWorkspaceInfo(collectionToRemove);
if (collectionInfo.isLoaded && !collectionInfo.isGitBacked) {
toast.success(`Deleted "${collectionToRemove.name}" collection`);
} else if (collectionInfo.isGitBacked) {
toast.success(`Removed git-backed collection "${collectionToRemove.name}" from workspace`);
} else {
toast.success(`Removed "${collectionToRemove.name}" from workspace`);
}
setCollectionToRemove(null);
} catch (error) {
console.error('Error removing collection:', error);
toast.error(error.message || 'Failed to remove collection from workspace');
}
};
const getCollectionWorkspaceInfo = (collection) => {
if (collection.hasOwnProperty('isGitBacked')) {
return {
isGitBacked: collection.isGitBacked,
gitRemoteUrl: collection.gitRemoteUrl,
isLoaded: collection.isLoaded !== false
};
}
const workspaceCollection = workspace.collections?.find((wc) => {
return collection.pathname === wc.path;
});
return {
isGitBacked: !!workspaceCollection?.remote,
gitRemoteUrl: workspaceCollection?.remote,
isLoaded: true
};
};
return (
<StyledWrapper className="h-full">
<div className="w-full h-full">
{createCollectionModalOpen && (
<CreateCollection
onClose={() => setCreateCollectionModalOpen(false)}
/>
)}
{importCollectionModalOpen && (
<ImportCollection
onClose={() => setImportCollectionModalOpen(false)}
handleSubmit={handleImportCollection}
/>
)}
{renameCollectionModalOpen && selectedCollectionUid && (
<RenameCollection
collectionUid={selectedCollectionUid}
onClose={() => {
setRenameCollectionModalOpen(false);
setSelectedCollectionUid(null);
}}
/>
)}
{shareCollectionModalOpen && selectedCollectionUid && (
<ShareCollection
collectionUid={selectedCollectionUid}
onClose={() => {
setShareCollectionModalOpen(false);
setSelectedCollectionUid(null);
}}
/>
)}
{collectionToRemove && (
<Modal
size="sm"
title="Delete Collection"
handleCancel={() => setCollectionToRemove(null)}
handleConfirm={confirmRemoveCollection}
confirmText="Delete Collection"
cancelText="Cancel"
style="new"
>
<p className="text-gray-600 dark:text-gray-300">
Are you sure you want to delete <strong>"{collectionToRemove.name}"</strong>?
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-3">
{(() => {
const collectionInfo = getCollectionWorkspaceInfo(collectionToRemove);
if (collectionInfo.isGitBacked) {
return 'This will remove the git-backed collection reference from workspace.yml. Local files (if any) will not be deleted.';
} else {
return 'This will permanently delete the collection files from the workspace collections folder.';
}
})()}
</p>
</Modal>
)}
<div className="h-full overflow-auto">
{workspaceCollections.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-8">
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-full mb-4">
<IconBox size={32} stroke={1.5} className="text-gray-400" />
</div>
<h3 className="text-lg font-medium mb-2">No collections yet</h3>
<p className="text-muted mb-4">
Create your first collection or open an existing one to get started.
</p>
</div>
) : (
<div className="collections-table">
<div className="collections-header">
<div className="header-cell header-name">Collection</div>
<div className="header-cell header-location">Location</div>
<div className="header-cell flex justify-end">Actions</div>
</div>
<div className="collections-body">
{workspaceCollections.map((collection, index) => {
return (
<div
key={collection.uid || index}
className="collection-row"
onClick={(e) => handleOpenCollectionClick(collection, e)}
>
<div className="row-cell cell-name">
<div className="flex items-center gap-2">
<IconBox size={16} stroke={1.5} className="collection-icon" />
<div className="collection-info">
<div className="collection-name">{collection.name}</div>
{collection.brunoConfig?.name && collection.brunoConfig.name !== collection.name && (
<div className="collection-subtitle">{collection.brunoConfig.name}</div>
)}
</div>
</div>
</div>
<div className="row-cell cell-location">
<div className="location-text" title={collection.pathname}>
{collection.pathname}
</div>
</div>
<div className="row-cell cell-actions">
<div className="action-buttons">
<button
onClick={(e) => {
e.stopPropagation();
handleRenameCollection(collection);
}}
className="action-btn action-edit"
title="Rename collection"
>
<IconEdit size={16} stroke={1.5} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleShareCollection(collection);
}}
className="action-btn action-share"
title="Share collection"
>
<IconShare size={16} stroke={1.5} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleRemoveCollection(collection);
}}
className="action-btn action-delete"
title="Remove from workspace"
>
<IconTrash size={16} stroke={1.5} />
</button>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
</StyledWrapper>
);
};
export default WorkspaceCollections;

View File

@@ -0,0 +1,9 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.editing-mode {
cursor: pointer;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,147 @@
import 'github-markdown-css/github-markdown.css';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { saveWorkspaceDocs } from 'providers/ReduxStore/slices/workspaces/actions';
import Markdown from 'components/MarkDown';
import CodeEditor from 'components/CodeEditor';
import StyledWrapper from './StyledWrapper';
import { IconEdit, IconX, IconFileText } from '@tabler/icons';
import toast from 'react-hot-toast';
const WorkspaceDocs = ({ workspace }) => {
const dispatch = useDispatch();
const { displayedTheme } = useTheme();
const [isEditing, setIsEditing] = useState(false);
const [localDocs, setLocalDocs] = useState(workspace?.docs || '');
const preferences = useSelector((state) => state.app.preferences);
useEffect(() => {
setLocalDocs(workspace?.docs || '');
setIsEditing(false);
}, [workspace?.uid, workspace?.docs]);
const toggleViewMode = () => {
setIsEditing((prev) => !prev);
};
const onEdit = (value) => {
setLocalDocs(value);
};
const handleDiscardChanges = () => {
setLocalDocs(workspace?.docs || '');
toggleViewMode();
};
const onSave = async () => {
if (!workspace) {
toast.error('Workspace not found');
return;
}
try {
await dispatch(saveWorkspaceDocs(workspace.uid, localDocs));
toast.success('Documentation saved successfully');
toggleViewMode();
} catch (error) {
console.error('Error saving workspace docs:', error);
toast.error('Failed to save documentation');
}
};
return (
<StyledWrapper className="h-full w-full relative flex flex-col p-4">
<div className="flex flex-row w-full justify-between items-center mb-4">
<div className="text-lg font-medium flex items-center gap-2">
<IconFileText size={20} strokeWidth={1.5} />
Workspace Documentation
</div>
<div className="flex flex-row gap-2 items-center justify-center">
{isEditing ? (
<>
<div className="editing-mode" role="tab" onClick={handleDiscardChanges}>
<IconX className="cursor-pointer" size={20} strokeWidth={1.5} />
</div>
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={onSave}>
Save
</button>
</>
) : (
<div className="editing-mode" role="tab" onClick={toggleViewMode}>
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} />
</div>
)}
</div>
</div>
{isEditing ? (
<CodeEditor
theme={displayedTheme}
value={localDocs}
onEdit={onEdit}
onSave={onSave}
mode="markdown"
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
/>
) : (
<div className="h-full overflow-auto pl-1">
<div className="h-[1px] min-h-[500px]">
{
localDocs?.length > 0
? <Markdown onDoubleClick={toggleViewMode} content={localDocs} />
: <Markdown onDoubleClick={toggleViewMode} content={workspaceDocumentationPlaceholder} />
}
</div>
</div>
)}
</StyledWrapper>
);
};
export default WorkspaceDocs;
const workspaceDocumentationPlaceholder = `
# Welcome to your Workspace Documentation
This is your workspace documentation area where you can document your entire project, team guidelines, and shared resources.
## What to Document Here
### Project Overview
- Project goals and objectives
- Architecture overview
- Key stakeholders and team members
- Project timeline and milestones
### Development Guidelines
- Coding standards and conventions
- Git workflow and branching strategy
- Code review process
- Testing guidelines
### API Documentation
- Authentication methods
- Base URLs and environments
- Common headers and parameters
- Error handling standards
### Team Resources
- Useful links and references
- Development environment setup
- Deployment procedures
- Troubleshooting guides
## Markdown Support
This documentation supports full Markdown formatting:
- **Bold** and *italic* text
- \`inline code\` and code blocks
- Lists and tables
- [Links](https://usebruno.com) and images
- Headers and sections
**Tip:** Double-click anywhere in this area to start editing!
`;

View File

@@ -0,0 +1,78 @@
import Modal from 'components/Modal/index';
import Portal from 'components/Portal/index';
import { useFormik } from 'formik';
import { copyGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { useEffect, useRef } from 'react';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import * as Yup from 'yup';
const CopyEnvironment = ({ environment, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
const formik = useFormik({
enableReinitialize: true,
initialValues: {
name: environment.name + ' - Copy'
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less')
.required('name is required')
}),
onSubmit: (values) => {
dispatch(copyGlobalEnvironment({ name: values.name, environmentUid: environment.uid }))
.then(() => {
toast.success('Environment created!');
onClose();
})
.catch((error) => {
toast.error('An error occurred while creating the environment');
console.error(error);
});
}
});
useEffect(() => {
if (inputRef && inputRef.current) {
inputRef.current.focus();
}
}, [inputRef]);
const onSubmit = () => {
formik.handleSubmit();
};
return (
<Portal>
<Modal size="sm" title="Copy Environment" confirmText="Copy" handleConfirm={onSubmit} handleCancel={onClose}>
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
<div>
<label htmlFor="environment-name" className="block font-semibold">
New Environment Name
</label>
<input
id="environment-name"
type="text"
name="name"
ref={inputRef}
className="block textbox mt-2 w-full"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.name || ''}
/>
{formik.touched.name && formik.errors.name ? (
<div className="text-red-500">{formik.errors.name}</div>
) : null}
</div>
</form>
</Modal>
</Portal>
);
};
export default CopyEnvironment;

View File

@@ -0,0 +1,100 @@
import React, { useEffect, useRef } from 'react';
import toast from 'react-hot-toast';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { useDispatch, useSelector } from 'react-redux';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { validateName, validateNameError } from 'utils/common/regex';
const CreateEnvironment = ({ onClose, onEnvironmentCreated }) => {
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
const validateEnvironmentName = (name) => {
const trimmedName = name?.toLowerCase().trim();
return (globalEnvs || []).every((env) => env?.name?.toLowerCase().trim() !== trimmedName);
};
const dispatch = useDispatch();
const inputRef = useRef();
const formik = useFormik({
enableReinitialize: true,
initialValues: {
name: ''
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'Must be at least 1 character')
.max(255, 'Must be 255 characters or less')
.test('is-valid-filename', function (value) {
const isValid = validateName(value);
return isValid ? true : this.createError({ message: validateNameError(value) });
})
.required('Name is required')
.test('duplicate-name', 'Environment already exists', validateEnvironmentName)
}),
onSubmit: (values) => {
dispatch(addGlobalEnvironment({ name: values.name }))
.then(() => {
toast.success('Environment created!');
onClose();
// Call the callback if provided
if (onEnvironmentCreated) {
onEnvironmentCreated();
}
})
.catch(() => toast.error('An error occurred while creating the environment'));
}
});
useEffect(() => {
if (inputRef && inputRef.current) {
inputRef.current.focus();
}
}, [inputRef]);
const onSubmit = () => {
formik.handleSubmit();
};
return (
<Portal>
<Modal
size="sm"
title="Create Environment"
confirmText="Create"
handleConfirm={onSubmit}
handleCancel={onClose}
>
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
<div>
<label htmlFor="name" className="block font-semibold">
Environment Name
</label>
<div className="flex items-center mt-2">
<input
id="environment-name"
type="text"
name="name"
ref={inputRef}
className="block textbox w-full"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.name || ''}
/>
</div>
{formik.touched.name && formik.errors.name ? (
<div className="text-red-500">{formik.errors.name}</div>
) : null}
</div>
</form>
</Modal>
</Portal>
);
};
export default CreateEnvironment;

View File

@@ -0,0 +1,15 @@
import styled from 'styled-components';
const Wrapper = styled.div`
button.submit {
color: white;
background-color: var(--color-background-danger) !important;
border: inherit !important;
&:hover {
border: inherit !important;
}
}
`;
export default Wrapper;

View File

@@ -0,0 +1,37 @@
import React from 'react';
import Portal from 'components/Portal/index';
import toast from 'react-hot-toast';
import Modal from 'components/Modal/index';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import { deleteGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
const DeleteEnvironment = ({ onClose, environment }) => {
const dispatch = useDispatch();
const onConfirm = () => {
dispatch(deleteGlobalEnvironment({ environmentUid: environment.uid }))
.then(() => {
toast.success('Environment deleted successfully');
onClose();
})
.catch(() => toast.error('An error occurred while deleting the environment'));
};
return (
<Portal>
<StyledWrapper>
<Modal
size="sm"
title="Delete Environment"
confirmText="Delete"
handleConfirm={onConfirm}
handleCancel={onClose}
>
Are you sure you want to delete <span className="font-semibold">{environment.name}</span> ?
</Modal>
</StyledWrapper>
</Portal>
);
};
export default DeleteEnvironment;

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { IconAlertTriangle } from '@tabler/icons';
import Modal from 'components/Modal';
import { createPortal } from 'react-dom';
const ConfirmSwitchEnv = ({ onCancel }) => {
const modalContent = (
<Modal
size="md"
title="Unsaved changes"
disableEscapeKey={true}
disableCloseOnOutsideClick={true}
closeModalFadeTimeout={150}
handleCancel={onCancel}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
hideFooter={true}
>
<div className="flex items-center font-normal">
<IconAlertTriangle size={32} strokeWidth={1.5} className="text-yellow-600" />
<h1 className="ml-2 text-lg font-semibold">Hold on..</h1>
</div>
<div className="font-normal mt-4">You have unsaved changes in this environment.</div>
<div className="flex justify-between mt-6">
<div>
<button className="btn btn-sm btn-danger" onClick={onCancel}>
Close
</button>
</div>
<div></div>
</div>
</Modal>
);
return createPortal(modalContent, document.body);
};
export default ConfirmSwitchEnv;

View File

@@ -0,0 +1,195 @@
import styled from 'styled-components';
const Wrapper = styled.div`
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
.table-container {
overflow-y: auto;
border-radius: 8px;
border: ${(props) => props.theme.workspace.environments.indentBorder};
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 12px;
thead,
td {
padding: 4px 12px;
&:nth-child(1),
&:nth-child(4) {
width: 80px;
}
&:nth-child(5) {
width: 60px;
}
&:nth-child(2) {
width: 30%;
}
}
thead {
color: ${(props) => props.theme.colors.text.muted};
background: ${(props) => props.theme.sidebar.bg};
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
user-select: none;
td {
padding: 8px 10px;
border-bottom: ${(props) => props.theme.workspace.environments.indentBorder};
border-right: ${(props) => props.theme.workspace.environments.indentBorder};
font-weight: 600;
&:last-child {
border-right: none;
}
}
}
tbody {
tr {
transition: background 0.1s ease;
&:hover {
background: ${(props) => props.theme.sidebar.bg};
}
&:last-child td {
border-bottom: none;
}
td {
border-bottom: ${(props) => props.theme.workspace.environments.indentBorder};
border-right: ${(props) => props.theme.workspace.environments.indentBorder};
&:last-child {
border-right: none;
}
}
}
}
}
.btn-add-param {
font-size: 12px;
color: ${(props) => props.theme.textLink};
font-weight: 500;
padding: 7px 14px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 6px;
border: ${(props) => props.theme.sidebar.collection.item.indentBorder};
background: transparent;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.listItem.hoverBg};
border-color: ${(props) => props.theme.textLink};
}
}
.tooltip-mod {
font-size: 11px !important;
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: 5px 8px;
font-size: 12px;
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.workspace.accent};
}
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 0;
background: ${(props) => props.theme.bg};
flex-shrink: 0;
display: flex;
gap: 8px;
}
.submit {
padding: 7px 16px;
font-size: 12px;
font-weight: 500;
border-radius: 6px;
border: none;
background: ${(props) => props.theme.workspace.accent};
color: ${(props) => props.theme.bg};
cursor: pointer;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.9;
}
}
.reset {
background: transparent;
padding: 6px 16px;
border: 1px solid ${(props) => props.theme.workspace.accent};
color: ${(props) => props.theme.workspace.accent};
&:hover {
opacity: 0.9;
}
}
.discard {
padding: 7px 16px;
font-size: 12px;
font-weight: 500;
border-radius: 6px;
background: transparent;
color: ${(props) => props.theme.text};
border: ${(props) => props.theme.sidebar.collection.item.indentBorder};
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
}
`;
export default Wrapper;

View File

@@ -0,0 +1,344 @@
import React from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import MultiLineEditor from 'components/MultiLineEditor/index';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { variableNameRegex } from 'utils/common/regex';
import toast from 'react-hot-toast';
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables } from 'utils/collections';
const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentVariables, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
let _collection = collection ? cloneDeep(collection) : {};
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
if (_collection) {
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
}
const initialValues = React.useMemo(() => {
const vars = environment.variables || [];
return [
...vars,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
];
}, [environment.uid, environment.variables]);
const formik = useFormik({
enableReinitialize: true,
initialValues: initialValues,
validationSchema: Yup.array().of(Yup.object({
enabled: Yup.boolean(),
name: Yup.string()
.when('$isLastRow', {
is: true,
then: (schema) => schema.optional(),
otherwise: (schema) => schema
.required('Name cannot be empty')
.matches(variableNameRegex,
'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.')
.trim()
}),
secret: Yup.boolean(),
type: Yup.string(),
uid: Yup.string(),
value: Yup.mixed().nullable()
})),
validate: (values) => {
const errors = {};
values.forEach((variable, index) => {
const isLastRow = index === values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
// Skip validation for the last empty row
if (isLastRow && isEmptyRow) {
return;
}
// Validate name for non-empty rows
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: () => {}
});
React.useEffect(() => {
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const savedValues = environment.variables || [];
const hasActualChanges = JSON.stringify(currentValues) !== JSON.stringify(savedValues);
setIsModified(hasActualChanges);
}, [formik.values, environment.variables, setIsModified]);
const ErrorMessage = ({ name, index }) => {
const meta = formik.getFieldMeta(name);
const id = `error-${name}-${index}`;
// Don't show error for the last empty row
const isLastRow = index === formik.values.length - 1;
const variable = formik.values[index];
const isEmptyRow = !variable?.name || variable.name.trim() === '';
if (isLastRow && isEmptyRow) {
return null;
}
if (!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>
);
};
const handleRemoveVar = (id) => {
const filteredValues = formik.values.filter((variable) => variable.uid !== id);
const lastRow = formik.values[formik.values.length - 1];
const isLastEmptyRow = lastRow.uid === id && (!lastRow.name || lastRow.name.trim() === '');
if (isLastEmptyRow) {
return;
}
const hasEmptyLastRow = filteredValues.length > 0
&& (!filteredValues[filteredValues.length - 1].name
|| filteredValues[filteredValues.length - 1].name.trim() === '');
if (!hasEmptyLastRow) {
filteredValues.push({
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
});
}
formik.setValues(filteredValues);
};
const handleNameChange = (index, e) => {
formik.handleChange(e);
const isLastRow = index === formik.values.length - 1;
// If typing in the last row, add a new empty row
if (isLastRow) {
const newVariable = {
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
};
// Use setTimeout to ensure the change is processed first
setTimeout(() => {
formik.setFieldValue(formik.values.length, newVariable, false);
}, 0);
}
};
const handleSave = () => {
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;
}
dispatch(saveGlobalEnvironment({ environmentUid: environment.uid, variables: cloneDeep(variablesToSave) }))
.then(() => {
toast.success('Changes saved successfully');
const newValues = [
...variablesToSave,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
];
formik.resetForm({ values: newValues });
setIsModified(false);
})
.catch((error) => {
console.error(error);
toast.error('An error occurred while saving the changes');
});
};
const handleReset = () => {
const originalVars = environment.variables || [];
const resetValues = [
...originalVars,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
];
formik.resetForm({ values: resetValues });
};
return (
<StyledWrapper>
<div className="table-container">
<table>
<thead>
<tr>
<td className="text-center">Enabled</td>
<td>Name</td>
<td>Value</td>
<td className="text-center">Secret</td>
<td></td>
</tr>
</thead>
<tbody>
{formik.values.map((variable, index) => {
const isLastRow = index === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
return (
<tr key={variable.uid}>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${index}.enabled`}
checked={variable.enabled}
onChange={formik.handleChange}
/>
)}
</td>
<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) => handleNameChange(index, e)}
/>
<ErrorMessage name={`${index}.name`} index={index} />
</div>
</td>
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative">
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${index}.value`}
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle
id={`${variable.uid}-disabled-info-icon`}
className="text-muted"
size={16}
/>
<Tooltip
anchorId={`${variable.uid}-disabled-info-icon`}
content="Non-string values set via scripts are read-only and can only be updated through scripts."
place="top"
/>
</span>
)}
</td>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${index}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={() => handleRemoveVar(variable.uid)}>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="button-container">
<div className="flex items-center">
<button type="button" className="submit" onClick={handleSave} data-testid="save-env">
Save
</button>
<button type="button" className="submit reset ml-2" onClick={handleReset} data-testid="reset-env">
Reset
</button>
</div>
</div>
</StyledWrapper>
);
};
export default EnvironmentVariables;

View File

@@ -0,0 +1,316 @@
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX } from '@tabler/icons';
import { useState, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { renameGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
import styled from 'styled-components';
import CopyEnvironment from '../../CopyEnvironment';
import DeleteEnvironment from '../../DeleteEnvironment';
import EnvironmentVariables from './EnvironmentVariables';
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 12px 20px;
flex-shrink: 0;
.title {
font-size: 15px;
font-weight: 600;
color: ${(props) => props.theme.text};
margin: 0;
}
.title-container {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
&.renaming {
.title-input {
flex: 1;
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
outline: none;
color: ${(props) => props.theme.text};
font-size: 15px;
font-weight: 600;
padding: 4px 8px;
border-radius: 5px;
}
.inline-actions {
display: flex;
gap: 2px;
}
.inline-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
&.save {
color: ${(props) => props.theme.textLink};
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
}
&.cancel {
color: ${(props) => props.theme.colors.text.muted};
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
color: ${(props) => props.theme.text};
}
}
}
}
}
.title-error {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
padding: 4px 8px;
font-size: 11px;
color: ${(props) => props.theme.colors.text.danger};
background: ${(props) => `${props.theme.colors.text.danger}15`};
border-radius: 4px;
white-space: nowrap;
}
.actions {
display: flex;
gap: 2px;
button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: none;
border-radius: 5px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
color: ${(props) => props.theme.text};
}
&:last-child:hover {
color: ${(props) => props.theme.colors.text.danger};
}
}
}
}
.content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 0 20px 20px 20px;
}
`;
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
const dispatch = useDispatch();
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
const [openDeleteModal, setOpenDeleteModal] = useState(false);
const [openCopyModal, setOpenCopyModal] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [newName, setNewName] = useState('');
const [nameError, setNameError] = useState('');
const inputRef = useRef(null);
const validateEnvironmentName = (name) => {
if (!name || name.trim() === '') {
return 'Name is required';
}
if (name.length < 1) {
return 'Must be at least 1 character';
}
if (name.length > 255) {
return 'Must be 255 characters or less';
}
if (!validateName(name)) {
return validateNameError(name);
}
const trimmedName = name.toLowerCase().trim();
const isDuplicate = (globalEnvs || []).some((env) =>
env?.uid !== environment.uid && env?.name?.toLowerCase().trim() === trimmedName);
if (isDuplicate) {
return 'Environment already exists';
}
return null;
};
const handleRenameClick = () => {
setIsRenaming(true);
setNewName(environment.name);
setNameError('');
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 50);
};
const handleSaveRename = () => {
const error = validateEnvironmentName(newName);
if (error) {
setNameError(error);
return;
}
dispatch(renameGlobalEnvironment({ name: newName, environmentUid: environment.uid }))
.then(() => {
toast.success('Environment renamed!');
setIsRenaming(false);
setNewName('');
setNameError('');
})
.catch(() => {
toast.error('An error occurred while renaming the environment');
});
};
const handleCancelRename = () => {
setIsRenaming(false);
setNewName('');
setNameError('');
};
const handleNameChange = (e) => {
setNewName(e.target.value);
if (nameError) {
setNameError('');
}
};
const handleNameBlur = () => {
if (newName.trim() === '') {
handleCancelRename();
} else {
const error = validateEnvironmentName(newName);
if (error) {
setNameError(error);
}
}
};
const handleNameKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveRename();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancelRename();
}
};
return (
<StyledWrapper>
{openDeleteModal && (
<DeleteEnvironment
onClose={() => setOpenDeleteModal(false)}
environment={environment}
/>
)}
{openCopyModal && (
<CopyEnvironment onClose={() => setOpenCopyModal(false)} environment={environment} />
)}
<div className="header">
<div className={`title-container ${isRenaming ? 'renaming' : ''}`}>
{isRenaming ? (
<>
<input
ref={inputRef}
type="text"
className="title-input"
value={newName}
onChange={handleNameChange}
onBlur={handleNameBlur}
onKeyDown={handleNameKeyDown}
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>
</>
) : (
<h2 className="title">{environment.name}</h2>
)}
</div>
{nameError && isRenaming && <div className="title-error">{nameError}</div>}
<div className="actions">
<button onClick={handleRenameClick} title="Rename">
<IconEdit size={15} strokeWidth={1.5} />
</button>
<button onClick={() => setOpenCopyModal(true)} title="Copy">
<IconCopy size={15} strokeWidth={1.5} />
</button>
<button onClick={() => setOpenDeleteModal(true)} title="Delete">
<IconTrash size={15} strokeWidth={1.5} />
</button>
</div>
</div>
<div className="content">
<EnvironmentVariables environment={environment} setIsModified={setIsModified} collection={collection} />
</div>
</StyledWrapper>
);
};
export default EnvironmentDetails;

View File

@@ -0,0 +1,280 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
height: 100%;
background-color: ${(props) => props.theme.bg};
position: relative;
.environments-container {
display: flex;
height: 100%;
width: 100%;
}
.confirm-switch-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
background: ${(props) => props.theme.bg};
padding: 12px;
border-bottom: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
}
/* Left Sidebar */
.sidebar {
width: 240px;
min-width: 240px;
border-right: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
display: flex;
flex-direction: column;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 16px 12px 16px;
.title {
font-size: 13px;
font-weight: 600;
color: ${(props) => props.theme.text};
margin: 0;
}
.btn-action {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
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};
}
}
}
.search-container {
position: relative;
padding: 0 12px 12px 12px;
.search-icon {
position: absolute;
left: 20px;
top: 50%;
transform: translateY(-100%);
color: ${(props) => props.theme.colors.text.muted};
pointer-events: none;
}
.search-input {
width: 100%;
padding: 6px 8px 6px 28px;
font-size: 12px;
background: transparent;
border: ${(props) => props.theme.sidebar.collection.item.indentBorder};
border-radius: 5px;
color: ${(props) => props.theme.text};
transition: all 0.15s ease;
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
&:focus {
outline: none;
}
}
}
.environments-list {
flex: 1;
overflow-y: auto;
padding: 0 8px;
}
.environment-item {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
margin-bottom: 1px;
font-size: 13px;
color: ${(props) => props.theme.text};
cursor: pointer;
border-radius: 5px;
transition: background 0.15s ease;
.environment-name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.environment-actions {
display: flex;
align-items: center;
opacity: 0;
transition: opacity 0.15s ease;
.activate-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border: none;
background: transparent;
cursor: pointer;
color: ${(props) => props.theme.text.muted};
border-radius: 3px;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.workspace.button.bg};
color: ${(props) => props.theme.colors.text.green};
}
}
.activated-checkmark {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
color: ${(props) => props.theme.colors.text.green};
opacity: 1;
}
}
&:hover .environment-actions {
opacity: 1;
}
&.activated .environment-actions {
opacity: 1;
}
&:hover {
background: ${(props) => props.theme.workspace.button.bg};
}
&.active {
background: ${(props) => props.theme.workspace.environments.activeBg};
color: ${(props) => props.theme.text};
}
&.renaming,
&.creating {
cursor: default;
padding: 4px 4px 4px 8px;
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
&:hover {
background: ${(props) => props.theme.workspace.button.bg};
}
}
.rename-container {
display: flex;
align-items: center;
flex: 1;
.environment-name-input {
flex: 1;
background: transparent;
border: none;
outline: none;
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;
}
}
&.creating {
.environment-name-input {
flex: 1;
background: transparent;
border: none;
outline: none;
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;
}
.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.textLink};
&:hover {
background: ${(props) => props.theme.listItem.hoverBg};
}
}
&.cancel {
color: ${(props) => props.theme.colors.text.muted};
&:hover {
background: ${(props) => props.theme.listItem.hoverBg};
color: ${(props) => props.theme.text};
}
}
}
}
}
.env-error {
padding: 4px 12px;
margin-top: 4px;
font-size: 11px;
color: ${(props) => props.theme.colors.text.danger};
background: ${(props) => `${props.theme.colors.text.danger}15`};
border-radius: 4px;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,426 @@
import React, { useEffect, useState, useRef } from 'react';
import usePrevious from 'hooks/usePrevious';
import EnvironmentDetails from './EnvironmentDetails';
import CreateEnvironment from '../CreateEnvironment';
import { IconDownload, IconSearch, IconPlus, IconCheck, IconX } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import ConfirmSwitchEnv from './ConfirmSwitchEnv';
import ImportEnvironment from '../ImportEnvironment';
import { isEqual } from 'lodash';
import { useDispatch, useSelector } from 'react-redux';
import { addGlobalEnvironment, renameGlobalEnvironment, selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified, collection }) => {
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);
const [renamingEnvUid, setRenamingEnvUid] = useState(null);
const [newEnvName, setNewEnvName] = useState('');
const [envNameError, setEnvNameError] = useState('');
const inputRef = useRef(null);
const renameContainerRef = useRef(null);
const createContainerRef = useRef(null);
const [switchEnvConfirmClose, setSwitchEnvConfirmClose] = useState(false);
const [originalEnvironmentVariables, setOriginalEnvironmentVariables] = useState([]);
const envUids = environments ? environments.map((env) => env.uid) : [];
const prevEnvUids = usePrevious(envUids);
useEffect(() => {
if (!environments?.length) {
setSelectedEnvironment(null);
setOriginalEnvironmentVariables([]);
return;
}
if (selectedEnvironment) {
let _selectedEnvironment = environments?.find((env) => env?.uid === selectedEnvironment?.uid);
if (!_selectedEnvironment) {
_selectedEnvironment = environments?.find((env) => env?.name === selectedEnvironment?.name);
}
if (!_selectedEnvironment) {
_selectedEnvironment = environments?.find((env) => env.uid === activeEnvironmentUid) || environments?.[0];
}
const hasSelectedEnvironmentChanged = !isEqual(selectedEnvironment, _selectedEnvironment);
if (hasSelectedEnvironmentChanged || selectedEnvironment.uid !== _selectedEnvironment?.uid) {
setSelectedEnvironment(_selectedEnvironment);
}
setOriginalEnvironmentVariables(_selectedEnvironment?.variables || []);
return;
}
const environment = environments?.find((env) => env.uid === activeEnvironmentUid) || environments?.[0];
setSelectedEnvironment(environment);
setOriginalEnvironmentVariables(environment?.variables || []);
}, [environments, activeEnvironmentUid, selectedEnvironment]);
useEffect(() => {
if (prevEnvUids && prevEnvUids.length && envUids.length > prevEnvUids.length) {
const newEnv = environments.find((env) => !prevEnvUids.includes(env.uid));
if (newEnv) {
setSelectedEnvironment(newEnv);
}
}
if (prevEnvUids && prevEnvUids.length && envUids.length < prevEnvUids.length) {
setSelectedEnvironment(environments && environments.length ? environments[0] : null);
}
}, [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 (!isModified) {
setSelectedEnvironment(env);
} else {
setSwitchEnvConfirmClose(true);
}
};
const handleEnvironmentDoubleClick = (env) => {
setRenamingEnvUid(env.uid);
setNewEnvName(env.name);
setEnvNameError('');
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 50);
};
const handleActivateEnvironment = (e, env) => {
e.stopPropagation();
dispatch(selectGlobalEnvironment({ environmentUid: env.uid }))
.then(() => {
toast.success(`Environment "${env.name}" activated`);
})
.catch(() => {
toast.error('Failed to activate environment');
});
};
if (!selectedEnvironment) {
return null;
}
const validateEnvironmentName = (name, excludeUid = null) => {
if (!name || name.trim() === '') {
return 'Name is required';
}
if (!validateName(name)) {
return validateNameError(name);
}
const trimmedName = name.toLowerCase().trim();
const isDuplicate = globalEnvs.some((env) =>
env?.uid !== excludeUid && env?.name?.toLowerCase().trim() === trimmedName);
if (isDuplicate) {
return 'Environment already exists';
}
return null;
};
const handleCreateEnvClick = () => {
if (!isModified) {
setIsCreatingInline(true);
setNewEnvName('');
setEnvNameError('');
setTimeout(() => {
inputRef.current?.focus();
}, 50);
} else {
setSwitchEnvConfirmClose(true);
}
};
const handleCancelCreate = () => {
setIsCreatingInline(false);
setNewEnvName('');
setEnvNameError('');
};
const handleSaveNewEnv = () => {
const error = validateEnvironmentName(newEnvName);
if (error) {
setEnvNameError(error);
return;
}
dispatch(addGlobalEnvironment({ name: newEnvName }))
.then(() => {
toast.success('Environment created!');
setIsCreatingInline(false);
setNewEnvName('');
setEnvNameError('');
})
.catch(() => {
toast.error('An error occurred while creating the environment');
});
};
const handleEnvNameChange = (e) => {
const value = e.target.value;
setNewEnvName(value);
if (envNameError) {
setEnvNameError('');
}
};
const handleEnvNameKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (renamingEnvUid) {
handleSaveRename();
} else {
handleSaveNewEnv();
}
} else if (e.key === 'Escape') {
e.preventDefault();
if (renamingEnvUid) {
handleCancelRename();
} else {
handleCancelCreate();
}
}
};
const handleSaveRename = () => {
const error = validateEnvironmentName(newEnvName, renamingEnvUid);
if (error) {
setEnvNameError(error);
return;
}
dispatch(renameGlobalEnvironment({ name: newEnvName, environmentUid: renamingEnvUid }))
.then(() => {
toast.success('Environment renamed!');
setRenamingEnvUid(null);
setNewEnvName('');
setEnvNameError('');
})
.catch(() => {
toast.error('An error occurred while renaming the environment');
});
};
const handleCancelRename = () => {
setRenamingEnvUid(null);
setNewEnvName('');
setEnvNameError('');
};
const handleImportClick = () => {
if (!isModified) {
setOpenImportModal(true);
} else {
setSwitchEnvConfirmClose(true);
}
};
const handleConfirmSwitch = (saveChanges) => {
if (!saveChanges) {
setSwitchEnvConfirmClose(false);
}
};
const filteredEnvironments = environments?.filter((env) =>
env.name.toLowerCase().includes(searchText.toLowerCase())) || [];
return (
<StyledWrapper>
{openCreateModal && <CreateEnvironment onClose={() => setOpenCreateModal(false)} />}
{openImportModal && <ImportEnvironment onClose={() => setOpenImportModal(false)} />}
<div className="environments-container">
{switchEnvConfirmClose && (
<div className="confirm-switch-overlay">
<ConfirmSwitchEnv onCancel={() => handleConfirmSwitch(false)} />
</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>
</div>
</div>
<div className="search-container">
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
<input
type="text"
placeholder="Search environments..."
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}>
<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>
) : (
<>
<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"
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"
>
<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>
</div>
)}
{envNameError && (isCreatingInline || renamingEnvUid) && (
<div className="env-error">{envNameError}</div>
)}
</div>
</div>
{/* Right Content */}
<EnvironmentDetails
environment={selectedEnvironment}
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
collection={collection}
/>
</div>
</StyledWrapper>
);
};
export default EnvironmentList;

View File

@@ -0,0 +1,58 @@
import React from 'react';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import importPostmanEnvironment from 'utils/importers/postman-environment';
import { toastError } from 'utils/common/error';
import { IconDatabaseImport } from '@tabler/icons';
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
const ImportEnvironment = ({ onClose, onEnvironmentCreated }) => {
const dispatch = useDispatch();
const handleImportPostmanEnvironment = () => {
importPostmanEnvironment()
.then((environments) => {
const importPromises = environments
.filter((env) =>
env.name && env.name !== 'undefined')
.map((environment) =>
dispatch(addGlobalEnvironment({ name: environment.name, variables: environment.variables }))
.then(() => {
toast.success('Environment imported successfully');
})
.catch((error) => {
toast.error('An error occurred while importing the environment');
console.error(error);
}));
return Promise.all(importPromises);
})
.then(() => {
onClose();
// Call the callback if provided
if (onEnvironmentCreated) {
onEnvironmentCreated();
}
})
.catch((err) => toastError(err, 'Postman Import environment failed'));
};
return (
<Portal>
<Modal size="sm" title="Import Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose} dataTestId="import-environment-modal">
<button
type="button"
onClick={handleImportPostmanEnvironment}
className="flex justify-center flex-col items-center w-full dark:bg-zinc-700 rounded-lg border-2 border-dashed border-zinc-300 dark:border-zinc-400 p-12 text-center hover:border-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2"
data-testid="import-postman-environment"
>
<IconDatabaseImport size={64} />
<span className="mt-2 block text-sm font-semibold">Import your Postman environments</span>
</button>
</Modal>
</Portal>
);
};
export default ImportEnvironment;

View File

@@ -0,0 +1,102 @@
import React, { useEffect, useRef } from 'react';
import Portal from 'components/Portal/index';
import Modal from 'components/Modal/index';
import toast from 'react-hot-toast';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { useDispatch } from 'react-redux';
import { renameGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { validateName, validateNameError } from 'utils/common/regex';
import { useSelector } from 'react-redux';
const RenameEnvironment = ({ onClose, environment }) => {
const dispatch = useDispatch();
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
const inputRef = useRef();
const validateEnvironmentName = (name) => {
const trimmedName = name?.toLowerCase().trim();
return (globalEnvs || []).every((env) =>
env.uid === environment.uid || env?.name?.toLowerCase().trim() !== trimmedName);
};
const formik = useFormik({
enableReinitialize: true,
initialValues: {
name: environment.name
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be at least 1 character')
.max(255, 'Must be 255 characters or less')
.test('is-valid-filename', function (value) {
const isValid = validateName(value);
return isValid ? true : this.createError({ message: validateNameError(value) });
})
.required('name is required')
.test('duplicate-name', 'Environment already exists', validateEnvironmentName)
}),
onSubmit: (values) => {
if (values.name === environment.name) {
return;
}
dispatch(renameGlobalEnvironment({ name: values.name, environmentUid: environment.uid }))
.then(() => {
toast.success('Environment renamed successfully');
onClose();
})
.catch((error) => {
toast.error('An error occurred while renaming the environment');
console.error(error);
});
}
});
useEffect(() => {
if (inputRef && inputRef.current) {
inputRef.current.focus();
}
}, [inputRef]);
const onSubmit = () => {
formik.handleSubmit();
};
return (
<Portal>
<Modal
size="sm"
title="Rename Environment"
confirmText="Rename"
handleConfirm={onSubmit}
handleCancel={onClose}
>
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
<div>
<label htmlFor="name" className="block font-semibold">
Environment Name
</label>
<input
id="environment-name"
type="text"
name="name"
ref={inputRef}
className="block textbox mt-2 w-full"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.name || ''}
/>
{formik.touched.name && formik.errors.name ? (
<div className="text-red-500">{formik.errors.name}</div>
) : null}
</div>
</form>
</Modal>
</Portal>
);
};
export default RenameEnvironment;

View File

@@ -0,0 +1,52 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 100%;
display: flex;
flex-direction: column;
background-color: ${(props) => props.theme.bg};
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
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;
}
}
.shared-button {
padding: 5px 10px;
font-size: 12px;
border-radius: 5px;
border: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
background: ${(props) => props.theme.sidebar.bg};
color: ${(props) => props.theme.text};
cursor: pointer;
transition: all 0.1s ease;
&:hover {
background: ${(props) => props.theme.listItem.hoverBg};
border-color: ${(props) => props.theme.textLink};
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,76 @@
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 ImportEnvironment from './ImportEnvironment';
export const SharedButton = ({ children, className, onClick }) => {
return (
<button
type="button"
onClick={onClick}
className={`rounded bg-transparent px-2.5 py-2 w-fit text-xs font-semibold text-zinc-900 dark:text-zinc-50 shadow-sm ring-1 ring-inset ring-zinc-300 dark:ring-zinc-500 hover:bg-gray-50 dark:hover:bg-zinc-700
${className}`}
>
{children}
</button>
);
};
const DefaultTab = ({ setTab }) => {
return (
<div className="empty-state">
<IconFileAlert size={48} strokeWidth={1.5} />
<div className="title">No Environments</div>
<div className="actions">
<button className="shared-button" onClick={() => setTab('create')}>
Create Environment
</button>
<button className="shared-button" 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 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' ? (
<ImportEnvironment onClose={() => setTab('default')} />
) : (
<DefaultTab setTab={setTab} />
)}
</StyledWrapper>
);
}
return (
<StyledWrapper>
<EnvironmentList
environments={globalEnvironments}
activeEnvironmentUid={activeGlobalEnvironmentUid}
selectedEnvironment={selectedEnvironment}
setSelectedEnvironment={setSelectedEnvironment}
isModified={isModified}
setIsModified={setIsModified}
collection={null}
/>
</StyledWrapper>
);
};
export default WorkspaceEnvironments;

View File

@@ -0,0 +1,351 @@
import React, { useEffect, useState, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { IconCategory, IconPlus, IconFolders, IconFileImport, IconDots, IconEdit, IconX, IconCheck, IconFolder } from '@tabler/icons';
import { importCollectionInWorkspace, renameWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
import { showInFolder, openCollection } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import CreateCollection from 'components/Sidebar/CreateCollection';
import ImportCollection from 'components/Sidebar/ImportCollection';
import CloseWorkspace from 'components/Sidebar/TitleBar/CloseWorkspace';
import WorkspaceCollections from './WorkspaceCollections';
import WorkspaceDocs from './WorkspaceDocs';
import WorkspaceEnvironments from './WorkspaceEnvironments';
import StyledWrapper from './StyledWrapper';
import Dropdown from 'components/Dropdown';
const WorkspaceHome = () => {
const dispatch = useDispatch();
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const [activeTab, setActiveTab] = useState('collections');
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
const [isRenamingWorkspace, setIsRenamingWorkspace] = useState(false);
const [workspaceNameInput, setWorkspaceNameInput] = useState('');
const [workspaceNameError, setWorkspaceNameError] = useState('');
const [closeWorkspaceModalOpen, setCloseWorkspaceModalOpen] = useState(false);
const workspaceNameInputRef = useRef(null);
const workspaceRenameContainerRef = useRef(null);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
useEffect(() => {
if (!isRenamingWorkspace) return;
const handleClickOutside = (event) => {
if (workspaceRenameContainerRef.current && !workspaceRenameContainerRef.current.contains(event.target)) {
handleCancelWorkspaceRename();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isRenamingWorkspace]);
if (!activeWorkspace) {
return null;
}
const handleCreateCollection = async () => {
try {
const { ipcRenderer } = window;
await ipcRenderer.invoke('renderer:ensure-collections-folder', activeWorkspace.pathname);
setCreateCollectionModalOpen(true);
} catch (error) {
console.error('Error ensuring collections folder exists:', error);
toast.error('Error preparing workspace for collection creation');
}
};
const handleOpenCollection = () => {
dispatch(openCollection())
.catch((err) => {
console.error(err);
toast.error('An error occurred while opening the collection');
});
};
const handleImportCollection = () => {
setImportCollectionModalOpen(true);
};
const handleImportCollectionSubmit = ({ rawData, type, environment, repositoryUrl }) => {
setImportCollectionModalOpen(false);
dispatch(importCollectionInWorkspace(rawData, activeWorkspace.uid, undefined, type))
.catch((err) => {
console.error(err);
toast.error('An error occurred while importing the collection');
});
};
// Workspace menu handlers
const handleRenameWorkspaceClick = () => {
setIsRenamingWorkspace(true);
setWorkspaceNameInput(activeWorkspace.name);
setWorkspaceNameError('');
setTimeout(() => {
workspaceNameInputRef.current?.focus();
workspaceNameInputRef.current?.select();
}, 50);
};
const handleCloseWorkspaceClick = () => {
dropdownTippyRef.current?.hide();
if (activeWorkspace.type === 'default') {
toast.error('Cannot close the default workspace');
return;
}
setCloseWorkspaceModalOpen(true);
};
const handleShowInFolder = () => {
dropdownTippyRef.current?.hide();
if (activeWorkspace.pathname) {
dispatch(showInFolder(activeWorkspace.pathname))
.catch((error) => {
console.error('Error opening the folder', error);
toast.error('Error opening the folder');
});
}
};
const validateWorkspaceName = (name) => {
if (!name || name.trim() === '') {
return 'Name is required';
}
if (name.length < 1) {
return 'Must be at least 1 character';
}
if (name.length > 255) {
return 'Must be 255 characters or less';
}
return null;
};
const handleSaveWorkspaceRename = () => {
const error = validateWorkspaceName(workspaceNameInput);
if (error) {
setWorkspaceNameError(error);
return;
}
dispatch(renameWorkspaceAction(activeWorkspace.uid, workspaceNameInput))
.then(() => {
toast.success('Workspace renamed!');
setIsRenamingWorkspace(false);
setWorkspaceNameInput('');
setWorkspaceNameError('');
})
.catch((err) => {
toast.error(err?.message || 'An error occurred while renaming the workspace');
setWorkspaceNameError(err?.message || 'Failed to rename workspace');
});
};
const handleCancelWorkspaceRename = () => {
setIsRenamingWorkspace(false);
setWorkspaceNameInput('');
setWorkspaceNameError('');
};
const handleWorkspaceNameChange = (e) => {
const value = e.target.value;
setWorkspaceNameInput(value);
if (workspaceNameError) {
setWorkspaceNameError('');
}
};
const handleWorkspaceNameKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveWorkspaceRename();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancelWorkspaceRename();
}
};
if (!activeWorkspace) {
return null;
}
const tabs = [
{
id: 'collections',
label: 'Collections',
component: (
<WorkspaceCollections
workspace={activeWorkspace}
onImportCollection={handleImportCollection}
/>
)
},
{
id: 'environments',
label: 'Environments',
component: <WorkspaceEnvironments workspace={activeWorkspace} />
},
{
id: 'documentation',
label: 'Documentation',
component: <WorkspaceDocs workspace={activeWorkspace} />
}
];
return (
<StyledWrapper className="h-full">
<div className="h-full flex flex-col">
{createCollectionModalOpen && (
<CreateCollection
onClose={() => setCreateCollectionModalOpen(false)}
/>
)}
{importCollectionModalOpen && (
<ImportCollection
onClose={() => setImportCollectionModalOpen(false)}
handleSubmit={handleImportCollectionSubmit}
/>
)}
<div className="flex items-center gap-5 p-4 pb-2 workspace-header">
<div className="text-xl font-semibold flex items-center gap-2">
<IconCategory size={24} stroke={2} />
{isRenamingWorkspace ? (
<div className="workspace-rename-container" ref={workspaceRenameContainerRef}>
<input
ref={workspaceNameInputRef}
type="text"
className="workspace-name-input"
value={workspaceNameInput}
onChange={handleWorkspaceNameChange}
onKeyDown={handleWorkspaceNameKeyDown}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveWorkspaceRename}
onMouseDown={(e) => e.preventDefault()}
title="Save"
>
<IconCheck size={14} strokeWidth={2} />
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancelWorkspaceRename}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
>
<IconX size={14} strokeWidth={2} />
</button>
</div>
</div>
) : (
<span>{activeWorkspace.name}</span>
)}
</div>
{!isRenamingWorkspace && activeWorkspace.type !== 'default' && (
<Dropdown
style="new"
placement="bottom-end"
onCreate={onDropdownCreate}
icon={<IconDots size={20} strokeWidth={1.5} className="cursor-pointer" />}
>
<div className="workspace-menu-dropdown">
<div className="dropdown-item" onClick={handleRenameWorkspaceClick}>
<IconEdit size={16} strokeWidth={1.5} />
<span>Rename</span>
</div>
<div className="dropdown-item" onClick={handleShowInFolder}>
<IconFolder size={16} strokeWidth={1.5} />
<span>Show in Folder</span>
</div>
<div className="dropdown-item" onClick={handleCloseWorkspaceClick}>
<IconX size={16} strokeWidth={1.5} />
<span>Close</span>
</div>
</div>
</Dropdown>
)}
{workspaceNameError && isRenamingWorkspace && (
<div className="workspace-error">{workspaceNameError}</div>
)}
</div>
{closeWorkspaceModalOpen && (
<CloseWorkspace
workspaceUid={activeWorkspace.uid}
onClose={() => setCloseWorkspaceModalOpen(false)}
/>
)}
<div className="flex items-center justify-between px-4 tabs-container">
<div className="flex gap-5">
{tabs.map((tab) => {
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 py-2 text-sm border-b-2 transition-colors tab-item ${activeTab === tab.id ? 'active' : ''}`}
>
{tab.label}
</button>
);
})}
</div>
{activeTab === 'collections' && (
<div className="flex items-center gap-1 workspace-action-buttons">
<button
onClick={handleCreateCollection}
className="workspace-button"
title="Create Collection"
>
<IconPlus size={16} stroke={1.5} />
Create
</button>
<button
onClick={handleOpenCollection}
className="workspace-button"
title="Add Collection"
>
<IconFolders size={16} stroke={1.5} />
Add
</button>
<button
onClick={handleImportCollection}
className="workspace-button"
title="Import Collection"
>
<IconFileImport size={16} stroke={1.5} />
Import
</button>
</div>
)}
</div>
<div className="flex-1 overflow-hidden">
{tabs.find((tab) => tab.id === activeTab)?.component}
</div>
</div>
</StyledWrapper>
);
};
export default WorkspaceHome;