diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index b50e190d9..c8667eb69 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -7,7 +7,6 @@ import HttpRequestPane from 'components/RequestPane/HttpRequestPane'; import GrpcRequestPane from 'components/RequestPane/GrpcRequestPane/index'; import ResponsePane from 'components/ResponsePane'; import GrpcResponsePane from 'components/ResponsePane/GrpcResponsePane'; -import Welcome from 'components/Welcome'; import { findItemInCollection } from 'utils/collections'; import { cancelRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions'; import RequestNotFound from './RequestNotFound'; @@ -34,6 +33,7 @@ import WSRequestPane from 'components/RequestPane/WSRequestPane'; import WSResponsePane from 'components/ResponsePane/WsResponsePane'; import { useTabPaneBoundaries } from 'hooks/useTabPaneBoundaries/index'; import ResponseExample from 'components/ResponseExample'; +import WorkspaceOverview from 'components/WorkspaceOverview/index'; const MIN_LEFT_PANE_WIDTH = 300; const MIN_RIGHT_PANE_WIDTH = 350; @@ -137,7 +137,7 @@ const RequestTabPanel = () => { }, [dragging]); if (!activeTabUid) { - return ; + return ; } if (!focusedTab || !focusedTab.uid || !focusedTab.collectionUid) { diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/ConfirmCollectionCloseDrafts.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/ConfirmCollectionCloseDrafts.js index ef107d493..bb67370b8 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/ConfirmCollectionCloseDrafts.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/ConfirmCollectionCloseDrafts.js @@ -30,10 +30,10 @@ const ConfirmCollectionCloseDrafts = ({ onClose, collection, collectionUid }) => .then(() => { dispatch(removeCollection(collectionUid)) .then(() => { - toast.success('Collection closed'); + toast.success('Collection removed from workspace'); onClose(); }) - .catch(() => toast.error('An error occurred while closing the collection')); + .catch(() => toast.error('An error occurred while removing the collection')); }) .catch(() => { toast.error('Failed to save requests!'); @@ -49,13 +49,13 @@ const ConfirmCollectionCloseDrafts = ({ onClose, collection, collectionUid }) => })); }); - // Then close the collection + // Then remove the collection dispatch(removeCollection(collectionUid)) .then(() => { - toast.success('Collection closed'); + toast.success('Collection removed from workspace'); onClose(); }) - .catch(() => toast.error('An error occurred while closing the collection')); + .catch(() => toast.error('An error occurred while removing the collection')); }; if (!currentDrafts.length) { @@ -65,9 +65,9 @@ const ConfirmCollectionCloseDrafts = ({ onClose, collection, collectionUid }) => return (
@@ -111,7 +111,7 @@ const ConfirmCollectionCloseDrafts = ({ onClose, collection, collectionUid }) => Cancel
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/index.js index cd95d8cfc..5ce0ff3ad 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/index.js @@ -20,12 +20,17 @@ const RemoveCollection = ({ onClose, collectionUid }) => { }, [collection]); const onConfirm = () => { + if (!collection) { + toast.error('Collection not found'); + onClose(); + return; + } dispatch(removeCollection(collection.uid)) .then(() => { - toast.success('Collection closed'); + toast.success('Collection removed from workspace'); onClose(); }) - .catch(() => toast.error('An error occurred while closing the collection')); + .catch(() => toast.error('An error occurred while removing the collection')); }; // If there are drafts, show the draft confirmation modal @@ -33,19 +38,19 @@ const RemoveCollection = ({ onClose, collectionUid }) => { return ; } - // Otherwise, show the standard close confirmation modal + // Otherwise, show the standard remove confirmation modal return ( - +
{collection.name}
{collection.pathname}
- Are you sure you want to close collection {collection.name} in Bruno? + Are you sure you want to remove collection {collection.name} from this workspace?
-
- It will still be available in the file system at the above location and can be re-opened later. +
+ The collection files will remain on disk and can be re-added to this or another workspace later.
); diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js index 7c7a26f58..424a004e8 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js @@ -405,7 +405,7 @@ const Collection = ({ collection, searchText }) => { - Close + Remove
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/CreateOrOpenCollection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/CreateOrOpenCollection/index.js index 1de9084ab..e3cd694fd 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/CreateOrOpenCollection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/CreateOrOpenCollection/index.js @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useTheme } from '../../../../providers/Theme'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { openCollection } from 'providers/ReduxStore/slices/collections/actions'; import toast from 'react-hot-toast'; @@ -17,6 +17,9 @@ const CreateOrOpenCollection = () => { const dispatch = useDispatch(); const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false); + const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces); + const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid); + const handleOpenCollection = () => { dispatch(openCollection()).catch( (err) => { @@ -42,7 +45,13 @@ const CreateOrOpenCollection = () => { return ( - {createCollectionModalOpen ? setCreateCollectionModalOpen(false)} /> : null} + {createCollectionModalOpen ? ( + setCreateCollectionModalOpen(false)} + workspaceUid={activeWorkspaceUid} + hideLocationInput={activeWorkspace && activeWorkspace.type !== 'default'} + /> + ) : null}
No collections found.
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/index.js b/packages/bruno-app/src/components/Sidebar/Collections/index.js index 3eed809f8..767de168a 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/index.js @@ -1,71 +1,96 @@ -import { - IconSearch, - IconX -} from '@tabler/icons'; import React, { useState } from 'react'; import { useSelector } from 'react-redux'; +import { IconSearch, IconX } from '@tabler/icons'; +import Collection from '../Collections/Collection'; import CreateCollection from '../CreateCollection'; -import Collection from './Collection'; -import CollectionsHeader from './CollectionsHeader'; -import CreateOrOpenCollection from './CreateOrOpenCollection'; import StyledWrapper from './StyledWrapper'; +import CreateOrOpenCollection from './CreateOrOpenCollection'; -const Collections = () => { +const Collections = ({ showSearch }) => { const [searchText, setSearchText] = useState(''); const { collections } = useSelector((state) => state.collections); + const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces); const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false); - if (!collections || !collections.length) { + const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid) || workspaces.find((w) => w.type === 'default'); + + let allCollections = []; + + if (!activeWorkspace || activeWorkspace.type === 'default') { + if (activeWorkspace && activeWorkspace.collections && activeWorkspace.collections.length > 0) { + allCollections = activeWorkspace.collections.map((wc) => { + const loadedCollection = collections.find((c) => c.pathname === wc.path); + return loadedCollection; + }).filter(Boolean); + } else { + allCollections = []; + } + } else { + if (activeWorkspace.collections && activeWorkspace.collections.length > 0) { + allCollections = activeWorkspace.collections.map((wc) => { + const loadedCollection = collections.find((c) => c.pathname === wc.path); + return loadedCollection; + }).filter(Boolean); + } + } + + if (!allCollections || !allCollections.length) { return ( - - + ); } return ( - - {createCollectionModalOpen ? setCreateCollectionModalOpen(false)} /> : null} - - - -
-
- - - -
- setSearchText(e.target.value.toLowerCase())} + + {createCollectionModalOpen ? ( + setCreateCollectionModalOpen(false)} + workspaceUid={activeWorkspace?.uid} + defaultLocation={activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : ''} + hideLocationInput={!!activeWorkspace?.pathname} /> - {searchText !== '' && ( -
- { - setSearchText(''); - }} - > - + ) : null} + + {showSearch && ( +
+
+ +
- )} -
+ setSearchText(e.target.value.toLowerCase())} + /> + {searchText !== '' && ( +
+ { + setSearchText(''); + }} + > + + +
+ )} +
+ )} -
- {collections && collections.length - ? collections.map((c) => { +
+ {allCollections && allCollections.length + ? allCollections.map((c) => { return ( ); diff --git a/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js b/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js index 216261fc5..d7ae6bdc9 100644 --- a/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js +++ b/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js @@ -2,8 +2,9 @@ import React, { useRef, useEffect, forwardRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useFormik } from 'formik'; import * as Yup from 'yup'; -import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions'; -import { createCollection } from 'providers/ReduxStore/slices/collections/actions'; +import path from 'path'; +import { browseDirectory, createCollection } from 'providers/ReduxStore/slices/collections/actions'; +import { loadWorkspaceCollections } from 'providers/ReduxStore/slices/workspaces/actions'; import toast from 'react-hot-toast'; import Portal from 'components/Portal'; import Modal from 'components/Modal'; @@ -19,22 +20,30 @@ import Dropdown from 'components/Dropdown'; import StyledWrapper from './StyledWrapper'; import get from 'lodash/get'; -const CreateCollection = ({ onClose }) => { +const CreateCollection = ({ onClose, workspaceUid, defaultLocation: propDefaultLocation, hideLocationInput = false }) => { const inputRef = useRef(); const dispatch = useDispatch(); + const workspaces = useSelector((state) => state.workspaces?.workspaces || []); const [isEditing, toggleEditing] = useState(false); const preferences = useSelector((state) => state.app.preferences); - const defaultLocation = get(preferences, 'general.defaultCollectionLocation', ''); + const [showExternalLocation, setShowExternalLocation] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false); + const defaultLocation = propDefaultLocation || get(preferences, 'general.defaultCollectionLocation', ''); const dropdownTippyRef = useRef(); const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref); + const activeWorkspace = workspaces.find((w) => w.uid === workspaceUid); + const isDefaultWorkspace = activeWorkspace?.type === 'default'; + + const shouldShowAccordion = workspaceUid && hideLocationInput && !isDefaultWorkspace; + const actuallyHideLocationInput = hideLocationInput && !showExternalLocation && !isDefaultWorkspace; + const formik = useFormik({ enableReinitialize: true, initialValues: { collectionName: '', collectionFolderName: '', - collectionLocation: defaultLocation, + collectionLocation: isDefaultWorkspace ? '' : (defaultLocation || ''), format: 'yml' }, validationSchema: Yup.object({ @@ -50,31 +59,54 @@ const CreateCollection = ({ onClose }) => { return isValid ? true : this.createError({ message: validateNameError(value) }); }) .required('folder name is required'), - collectionLocation: Yup.string().min(1, 'location is required').required('location is required'), + collectionLocation: actuallyHideLocationInput + ? Yup.string() // Optional for workspaces when not using external location + : Yup.string().min(1, 'location is required').required('location is required'), format: Yup.string().oneOf(['bru', 'yml'], 'invalid format').required('format is required') }), - onSubmit: (values) => { - dispatch(createCollection(values.collectionName, values.collectionFolderName, values.collectionLocation, values.format)) - .then(() => { - toast.success('Collection created!'); - dispatch(toggleSidebarCollapse()); - onClose(); - }) - .catch((e) => toast.error(multiLineMsg('An error occurred while creating the collection', formatIpcError(e)))); + onSubmit: async (values) => { + try { + const currentWorkspace = workspaces.find((w) => w.uid === workspaceUid); + const useExternalLocation = workspaceUid && showExternalLocation && values.collectionLocation; + + let collectionLocation = values.collectionLocation; + if (workspaceUid && !useExternalLocation && currentWorkspace && currentWorkspace.type !== 'default') { + collectionLocation = path.join(currentWorkspace.pathname, 'collections'); + } + + await dispatch(createCollection(values.collectionName, + values.collectionFolderName, + collectionLocation, + { format: values.format })); + + if (useExternalLocation && currentWorkspace) { + const { ipcRenderer } = window; + const collectionPath = path.join(values.collectionLocation, values.collectionFolderName); + const workspaceCollection = { + name: values.collectionName, + path: collectionPath + }; + await ipcRenderer.invoke('renderer:add-collection-to-workspace', currentWorkspace.pathname, workspaceCollection); + await dispatch(loadWorkspaceCollections(workspaceUid)); + } + + toast.success('Collection created!'); + onClose(); + } catch (e) { + toast.error(multiLineMsg('An error occurred while creating the collection', formatIpcError(e))); + } } }); const browse = () => { dispatch(browseDirectory()) .then((dirPath) => { - // When the user closes the dialog without selecting anything dirPath will be false if (typeof dirPath === 'string') { formik.setFieldValue('collectionLocation', dirPath); } }) - .catch((error) => { + .catch(() => { formik.setFieldValue('collectionLocation', ''); - console.error(error); }); }; @@ -84,8 +116,6 @@ const CreateCollection = ({ onClose }) => { } }, [inputRef]); - const onSubmit = () => formik.handleSubmit(); - const AdvancedOptions = forwardRef((props, ref) => { return (
@@ -129,43 +159,47 @@ const CreateCollection = ({ onClose }) => {
{formik.errors.collectionName}
) : null} - - { - formik.setFieldValue('collectionLocation', e.target.value); - }} - /> - {formik.touched.collectionLocation && formik.errors.collectionLocation ? ( -
{formik.errors.collectionLocation}
- ) : null} -
- - Browse - -
+ {!actuallyHideLocationInput && ( + <> + + { + formik.setFieldValue('collectionLocation', e.target.value); + }} + /> + {formik.touched.collectionLocation && formik.errors.collectionLocation ? ( +
{formik.errors.collectionLocation}
+ ) : null} +
+ + Browse + +
+ + )} {formik.values.collectionName?.trim()?.length > 0 && (
@@ -257,6 +291,18 @@ const CreateCollection = ({ onClose }) => {
} placement="bottom-start"> + {shouldShowAccordion && ( +
{ + dropdownTippyRef.current.hide(); + setShowExternalLocation(!showExternalLocation); + }} + > + {showExternalLocation ? 'Use Default Location' : 'Create in External Location'} +
+ )}
{ + const dispatch = useDispatch(); + const { workspaces } = useSelector((state) => state.workspaces); + const workspace = workspaces.find((w) => w.uid === workspaceUid); + + const onConfirm = async () => { + try { + if (!workspace) { + toast.error('Workspace not found'); + onClose(); + return; + } + if (workspace.type === 'default') { + toast.error('Cannot close the default workspace'); + onClose(); + return; + } + + await dispatch(closeWorkspaceAction(workspace.uid)); + toast.success('Workspace closed'); + onClose(); + } catch (error) { + console.error('Error closing workspace:', error); + toast.error('An error occurred while closing the workspace'); + } + }; + + return ( + +
+ + {workspace?.name} +
+ {workspace?.pathname && ( +
{workspace.pathname}
+ )} +
+ Are you sure you want to close workspace {workspace?.name}? +
+
+ It will still be available in the file system at the above location and can be re-opened later. +
+
+ ); +}; + +export default CloseWorkspace; diff --git a/packages/bruno-app/src/components/Sidebar/TitleBar/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/TitleBar/StyledWrapper.js index f312e534b..191261b89 100644 --- a/packages/bruno-app/src/components/Sidebar/TitleBar/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/TitleBar/StyledWrapper.js @@ -1,6 +1,160 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` + .titlebar-container { + display: flex; + align-items: center; + } + + .workspace-icon-container { + display: flex; + align-items: center; + justify-content: center; + } + + .workspace-letter-logo { + width: 20px; + height: 20px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 500; + background: white; + color: #5d5d5d; + } + + .workspace-name-container { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 10px; + margin-left: 0px; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + min-width: 0; + flex: 1; + max-width: 120px; + + &:hover { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + } + + .workspace-name { + font-size: 13px; + font-weight: 600; + color: ${(props) => props.theme.sidebar.color}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .chevron-icon { + flex-shrink: 0; + color: ${(props) => props.theme.sidebar.muted}; + transition: transform 0.2s ease; + } + } + + /* Actions Button */ + .actions-container { + margin-left: auto; + display: flex; + align-items: center; + } + + .home-icon-button, + .search-icon-button, + .plus-icon-button { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border: none; + background: transparent; + border-radius: 4px; + cursor: pointer; + transition: background 0.15s ease; + color: ${(props) => props.theme.text}; + } + + .workspace-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 10px !important; + margin: 0 !important; + + &.active { + .check-icon { + opacity: 1; + } + } + + &:hover { + .pin-btn:not(.pinned) { + opacity: 1; + } + } + + .workspace-name { + flex: 1; + min-width: 0; + font-size: 13px; + font-weight: 400; + color: ${(props) => props.theme.dropdown.color}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .workspace-actions { + display: flex; + align-items: center; + gap: 4px; + margin-left: 8px; + flex-shrink: 0; + pointer-events: none; + + > * { + pointer-events: auto; + } + } + + .check-icon { + color: ${(props) => props.theme.workspace?.accent || props.theme.colors?.text?.yellow || '#f0c674'}; + flex-shrink: 0; + } + + .pin-btn { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: none; + background: transparent; + border-radius: 4px; + cursor: pointer; + color: ${(props) => props.theme.dropdown?.mutedText || props.theme.text?.muted || '#888'}; + transition: background 0.15s ease, color 0.15s ease, opacity 0.15s ease; + opacity: 0; + + &.pinned { + opacity: 1; + } + + &:hover { + background: ${(props) => props.theme.dropdown?.hoverBg || props.theme.sidebar?.collection?.item?.hoverBg}; + color: ${(props) => props.theme.dropdown?.mutedText || props.theme.text?.muted || '#888'}; + } + } + } + .collection-dropdown { color: ${(props) => props.theme.sidebar.dropdownIcon.color}; diff --git a/packages/bruno-app/src/components/Sidebar/TitleBar/index.js b/packages/bruno-app/src/components/Sidebar/TitleBar/index.js index f9ac95535..baf26bba7 100644 --- a/packages/bruno-app/src/components/Sidebar/TitleBar/index.js +++ b/packages/bruno-app/src/components/Sidebar/TitleBar/index.js @@ -1,150 +1,310 @@ +import { useState, forwardRef, useRef, useMemo, useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import toast from 'react-hot-toast'; -import Bruno from 'components/Bruno'; +import { IconPlus, IconChevronDown, IconCheck, IconFolder, IconDownload, IconPin, IconPinned, IconHome, IconSearch, IconDeviceDesktop } from '@tabler/icons'; + +import { showHomePage, savePreferences } from 'providers/ReduxStore/slices/app'; +import { openCollection, importCollection } from 'providers/ReduxStore/slices/collections/actions'; +import { switchWorkspace, openWorkspaceDialog, importCollectionInWorkspace } from 'providers/ReduxStore/slices/workspaces/actions'; +import { sortWorkspaces, toggleWorkspacePin } from 'utils/workspaces'; + import Dropdown from 'components/Dropdown'; -import CreateCollection from '../CreateCollection'; import ImportCollection from 'components/Sidebar/ImportCollection'; import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation'; +import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace'; -import { IconDots, IconPlus, IconFolder, IconDownload, IconDeviceDesktop } from '@tabler/icons'; -import { useState, forwardRef, useRef } from 'react'; -import { useDispatch } from 'react-redux'; -import { showHomePage } from 'providers/ReduxStore/slices/app'; -import { openCollection, importCollection } from 'providers/ReduxStore/slices/collections/actions'; +import CreateCollection from '../CreateCollection'; import StyledWrapper from './StyledWrapper'; -import { multiLineMsg } from "utils/common"; -import { formatIpcError } from "utils/common/error"; -const TitleBar = () => { - const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false); - const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false); - const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false); - const [importData, setImportData] = useState(null); + +const TitleBar = ({ showSearch, setShowSearch }) => { const dispatch = useDispatch(); const { ipcRenderer } = window; - const handleImportCollection = ({ rawData, type }) => { - setImportData({ rawData, type }); + const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces); + const preferences = useSelector((state) => state.app.preferences); + const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid); + + // Sort workspaces according to preferences + const sortedWorkspaces = useMemo(() => { + return sortWorkspaces(workspaces, preferences); + }, [workspaces, preferences]); + + const [importedCollection, setImportedCollection] = useState(null); + const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false); + const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false); + const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false); + const [importType, setImportType] = useState(null); + const [createWorkspaceModalOpen, setCreateWorkspaceModalOpen] = useState(false); + + const toTitleCase = (str) => { + if (!str) return ''; + if (str === 'default') return 'Default'; + return str + .split(/[\s-_]+/) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); + }; + + const handleImportCollection = ({ rawData, type, environment }) => { + setImportedCollection(rawData); + setImportType(type); setImportCollectionModalOpen(false); - setImportCollectionLocationModalOpen(true); + + if (activeWorkspace) { + dispatch(importCollectionInWorkspace(rawData, activeWorkspace.uid, undefined, type)) + .catch((err) => { + toast.error('An error occurred while importing the collection'); + }); + } else { + setImportCollectionLocationModalOpen(true); + } }; - const handleImportCollectionLocation = (convertedCollection, collectionLocation) => { - dispatch(importCollection(convertedCollection, collectionLocation)) - .then(() => { + const handleImportCollectionLocation = (collectionLocation, selectedCollections) => { + const isMultipleImport = importType && (importType === 'multiple' || importType === 'bulk'); + const collectionsToImport = !isMultipleImport ? importedCollection : importedCollection.filter((collection) => + selectedCollections.includes(collection.uid)); + + const collectionsArray = Array.isArray(collectionsToImport) ? collectionsToImport : [collectionsToImport]; + if (collectionsArray.length === 0 || (collectionsArray.length === 1 && !collectionsArray[0])) { + toast.error('Please select at least one collection to import.'); + return; + } + setImportCollectionLocationModalOpen(false); - setImportData(null); - toast.success('Collection imported successfully'); - }) - .catch((err) => { - console.error(err); - toast.error(multiLineMsg('An error occurred while importing the collection.', formatIpcError(err))); - }); + + if (activeWorkspace) { + dispatch(importCollectionInWorkspace(collectionsToImport, activeWorkspace.uid, collectionLocation)) + .catch((err) => { + console.error(err); + toast.error('An error occurred while importing the collection'); + }); + } else { + dispatch(importCollection(collectionsToImport, collectionLocation)); + } }; - const menuDropdownTippyRef = useRef(); - const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref); - const MenuIcon = forwardRef((props, ref) => { + const [showWorkspaceDropdown, setShowWorkspaceDropdown] = useState(false); + const workspaceDropdownTippyRef = useRef(); + const onWorkspaceDropdownCreate = (ref) => (workspaceDropdownTippyRef.current = ref); + + const actionsDropdownTippyRef = useRef(); + const onActionsDropdownCreate = (ref) => (actionsDropdownTippyRef.current = ref); + + const WorkspaceName = forwardRef((props, ref) => { return ( -
- +
setShowWorkspaceDropdown(!showWorkspaceDropdown)}> + {toTitleCase(activeWorkspace?.name) || 'Default Workspace'} +
); }); - const handleTitleClick = () => dispatch(showHomePage()); + const handleToggleSearch = () => { + if (setShowSearch) { + setShowSearch((prev) => !prev); + } + }; + + const handleWorkspaceSwitch = (workspaceUid) => { + dispatch(switchWorkspace(workspaceUid)); + setShowWorkspaceDropdown(false); + toast.success(`Switched to ${workspaces.find((w) => w.uid === workspaceUid)?.name}`); + }; + + const handleOpenWorkspace = async () => { + setShowWorkspaceDropdown(false); + try { + await dispatch(openWorkspaceDialog()); + toast.success('Workspace opened successfully'); + } catch (error) { + toast.error(error.message || 'Failed to open workspace'); + } + }; + + const handleCreateWorkspace = () => { + setShowWorkspaceDropdown(false); + setCreateWorkspaceModalOpen(true); + }; + + const handlePinWorkspace = useCallback((workspaceUid, e) => { + e.preventDefault(); + e.stopPropagation(); + const newPreferences = toggleWorkspacePin(workspaceUid, preferences); + dispatch(savePreferences(newPreferences)); + }, [dispatch, preferences]); const handleOpenCollection = () => { - dispatch(openCollection()).catch( - (err) => { - console.log(err); - toast.error('An error occurred while opening the collection'); - } - ); + const options = {}; + if (activeWorkspace?.pathname) { + options.workspaceId = activeWorkspace.pathname; + } + + dispatch(openCollection(options)).catch((err) => { + toast.error('An error occurred while opening the collection'); + }); }; const openDevTools = () => { ipcRenderer.invoke('renderer:open-devtools'); }; - return ( - - {createCollectionModalOpen ? setCreateCollectionModalOpen(false)} /> : null} - {importCollectionModalOpen ? ( - setImportCollectionModalOpen(false)} handleSubmit={handleImportCollection} /> - ) : null} - {importCollectionLocationModalOpen && importData ? ( + const renderModals = () => ( + <> + {createCollectionModalOpen && ( + setCreateCollectionModalOpen(false)} + workspaceUid={activeWorkspace?.uid} + defaultLocation={activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : ''} + hideLocationInput={!!activeWorkspace?.pathname} + /> + )} + {importCollectionModalOpen && ( + setImportCollectionModalOpen(false)} + handleSubmit={handleImportCollection} + /> + )} + {importCollectionLocationModalOpen && ( setImportCollectionLocationModalOpen(false)} handleSubmit={handleImportCollectionLocation} /> - ) : null} + )} + {createWorkspaceModalOpen && ( + setCreateWorkspaceModalOpen(false)} /> + )} + + ); -
- -
- } placement="bottom-start"> + return ( + + {renderModals()} +
+ + {/* Workspace Dropdown */} + } + placement="bottom-start" + style="new" + visible={showWorkspaceDropdown} + onClickOutside={() => setShowWorkspaceDropdown(false)} + > + {sortedWorkspaces.map((workspace) => { + const isActive = workspace.uid === activeWorkspaceUid; + const isPinned = preferences?.workspaces?.pinnedWorkspaceUids?.includes(workspace.uid); + + return ( +
handleWorkspaceSwitch(workspace.uid)} + > + {toTitleCase(workspace.name)} +
+ {workspace.type !== 'default' && ( + + )} + {isActive && } +
+
+ ); + })} + +
Workspaces
+ +
+ + Create workspace +
+
+ + Open workspace +
+
+ + {/* Search and Actions */} +
+ + + {setShowSearch && ( + + )} + + + + + )} + placement="bottom-end" + style="new" + >
Collections
{ setCreateCollectionModalOpen(true); - menuDropdownTippyRef.current.hide(); + actionsDropdownTippyRef.current?.hide(); }} > - - - - Create Collection + + Create collection +
+
{ + actionsDropdownTippyRef.current?.hide(); + setImportCollectionModalOpen(true); + }} + > + + Import collection
{ handleOpenCollection(); - menuDropdownTippyRef.current.hide(); + actionsDropdownTippyRef.current?.hide(); }} > - - - - Open -
-
{ - menuDropdownTippyRef.current.hide(); - setImportCollectionModalOpen(true); - }} - > - - - - Import + + Open collection
{ - menuDropdownTippyRef.current.hide(); + actionsDropdownTippyRef.current?.hide(); openDevTools(); }} > - - - + Devtools
- ); + ); }; export default TitleBar; diff --git a/packages/bruno-app/src/components/Welcome/StyledWrapper.js b/packages/bruno-app/src/components/Welcome/StyledWrapper.js deleted file mode 100644 index d586f7782..000000000 --- a/packages/bruno-app/src/components/Welcome/StyledWrapper.js +++ /dev/null @@ -1,45 +0,0 @@ -import styled from 'styled-components'; - -const StyledWrapper = styled.div` - .heading { - color: ${(props) => props.theme.welcome.heading}; - font-size: ${(props) => props.theme.font.size.base}; - } - - .muted { - color: ${(props) => props.theme.welcome.muted}; - } - - .collection-options { - cursor: pointer; - - svg { - position: relative; - top: -1px; - } - - .label { - &:hover { - text-decoration: underline; - } - } - } - - .keycap { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 1px 6px; - border: 1px solid ${(props) => props.theme.modal.input.border}; - border-radius: 4px; - background: ${(props) => - props.theme.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'}; - font-size: ${(props) => props.theme.font.size.base}; - font-weight: 500; - font-family: inherit; - line-height: 1; - color: ${(props) => props.theme.text}; - } -`; - -export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Welcome/index.js b/packages/bruno-app/src/components/Welcome/index.js deleted file mode 100644 index 24982aae8..000000000 --- a/packages/bruno-app/src/components/Welcome/index.js +++ /dev/null @@ -1,153 +0,0 @@ -import { useState } from 'react'; -import toast from 'react-hot-toast'; -import { useDispatch, useSelector } from 'react-redux'; -import { useTranslation } from 'react-i18next'; -import { openCollection, importCollection } from 'providers/ReduxStore/slices/collections/actions'; - -import { IconBrandGithub, IconPlus, IconDownload, IconFolders, IconSpeakerphone, IconBook } from '@tabler/icons'; - -import Bruno from 'components/Bruno'; -import CreateCollection from 'components/Sidebar/CreateCollection'; -import ImportCollection from 'components/Sidebar/ImportCollection'; -import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation'; -import StyledWrapper from './StyledWrapper'; - -const Welcome = () => { - const dispatch = useDispatch(); - const { t } = useTranslation(); - const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed); - const collections = useSelector((state) => state.collections.collections); - const [importData, setImportData] = useState(null); - const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false); - const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false); - const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false); - - const handleOpenCollection = () => { - dispatch(openCollection()) - .catch((err) => { - console.error(err); - toast.error(t('WELCOME.COLLECTION_OPEN_ERROR')); - }); - }; - - const handleImportCollection = ({ rawData, type }) => { - setImportData({ rawData, type }); - setImportCollectionModalOpen(false); - setImportCollectionLocationModalOpen(true); - }; - - const handleImportCollectionLocation = (convertedCollection, collectionLocation) => { - dispatch(importCollection(convertedCollection, collectionLocation)) - .then(() => { - setImportCollectionLocationModalOpen(false); - setImportData(null); - toast.success(t('WELCOME.COLLECTION_IMPORT_SUCCESS')); - }) - .catch((err) => { - setImportCollectionLocationModalOpen(false); - console.error(err); - toast.error(t('WELCOME.COLLECTION_IMPORT_ERROR')); - }); - }; - - return ( - - {createCollectionModalOpen ? setCreateCollectionModalOpen(false)} /> : null} - {importCollectionModalOpen ? ( - setImportCollectionModalOpen(false)} handleSubmit={handleImportCollection} /> - ) : null} - {importCollectionLocationModalOpen && importData ? ( - setImportCollectionLocationModalOpen(false)} - handleSubmit={handleImportCollectionLocation} - /> - ) : null} - -
- -
-
bruno
-
{t('WELCOME.ABOUT_BRUNO')}
- -
{t('COMMON.COLLECTIONS')}
-
- - - - - -
- -
{t('WELCOME.LINKS')}
-
- - - - -
- {t('WELCOME.GLOBAL_SEARCH_TIP_PART1')} {' '}K{' '} - {t('WELCOME.GLOBAL_SEARCH_TIP_PART2')} Ctrl{' '}K{' '} - {t('WELCOME.GLOBAL_SEARCH_TIP_PART3')} -
-
-
- ); -}; - -export default Welcome; diff --git a/packages/bruno-app/src/components/WorkspaceOverview/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceOverview/StyledWrapper.js new file mode 100644 index 000000000..7e800b754 --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceOverview/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceCollections/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceCollections/StyledWrapper.js new file mode 100644 index 000000000..53d63bc5d --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceCollections/StyledWrapper.js @@ -0,0 +1,271 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .collections-table { + display: flex; + flex-direction: column; + height: 100%; + font-size: 13px; + } + + .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: 11px; + 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; + + &:has(.cell-git) { + grid-template-columns: 1fr 3fr 1fr 1.5fr; + } + + &:not(:has(.cell-git)) { + 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: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .collection-subtitle { + font-size: 11px; + color: ${(props) => props.theme.text.muted}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 2px; + } + } + + .cell-type { + .type-icon { + flex-shrink: 0; + + &.remote { + color: #3B82F6; + } + + &.local { + color: ${(props) => props.theme.workspace.accent}; + } + } + + .type-label { + font-size: 12px; + font-weight: 500; + + &.remote { + color: #3B82F6; + } + + &.local { + color: ${(props) => props.theme.text.primary}; + } + } + } + + .cell-location { + .location-text { + font-size: 12px; + color: ${(props) => props.theme.text.muted}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; + } + } + + .cell-git { + .git-badge { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; + width: fit-content; + + &.git-enabled { + background-color: #10B98120; + color: #10B981; + } + + &.git-disabled { + background-color: ${(props) => props.theme.workspace.border}40; + color: ${(props) => props.theme.text.muted}; + } + } + } + + .cell-requests { + .request-count { + display: flex; + align-items: baseline; + gap: 4px; + } + + .count-number { + font-weight: 600; + font-size: 14px; + color: ${(props) => props.theme.text.primary}; + } + + .count-label { + font-size: 11px; + color: ${(props) => props.theme.text.muted}; + } + } + + .cell-status { + .status-badge { + display: flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; + + &.status-ready { + background-color: ${(props) => props.theme.workspace.accent}20; + color: ${(props) => props.theme.workspace.accent}; + + .status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background-color: ${(props) => props.theme.workspace.accent}; + } + } + + &.status-not-loaded { + background-color: ${(props) => props.theme.workspace.border}40; + color: ${(props) => props.theme.text.muted}; + } + } + } + + .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-git { + color: #F97316; + } + + &.action-run { + color: #10B981; + } + + &.action-edit { + color: ${(props) => props.theme.text}; + } + + &.action-share { + color: #3B82F6; + } + + &.action-settings { + color: ${(props) => props.theme.text}; + } + + &.action-delete { + color: #EF4444; + } + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceCollections/index.js b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceCollections/index.js new file mode 100644 index 000000000..52b04ce8e --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceCollections/index.js @@ -0,0 +1,353 @@ +import React, { useState, useEffect } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { IconBox, IconTrash, IconEdit, IconShare } from '@tabler/icons'; +import { loadWorkspaceCollections, removeCollectionFromWorkspaceAction, importCollectionInWorkspace } from 'providers/ReduxStore/slices/workspaces/actions'; +import { addTab } from 'providers/ReduxStore/slices/tabs'; +import { hideHomePage } from 'providers/ReduxStore/slices/app'; +import { uuid } from 'utils/common'; +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); + + useEffect(() => { + if (workspace && workspace.uid) { + dispatch(loadWorkspaceCollections(workspace.uid)); + } + }, [workspace?.uid, collections.length, dispatch]); + + 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 ( + +
+ + {createCollectionModalOpen && ( + setCreateCollectionModalOpen(false)} + workspaceUid={workspace.uid} + defaultLocation={`${workspace.pathname}/collections`} + hideLocationInput={true} + /> + )} + + {importCollectionModalOpen && ( + setImportCollectionModalOpen(false)} + handleSubmit={handleImportCollection} + /> + )} + + {renameCollectionModalOpen && selectedCollectionUid && ( + { + setRenameCollectionModalOpen(false); + setSelectedCollectionUid(null); + }} + /> + )} + + {shareCollectionModalOpen && selectedCollectionUid && ( + { + setShareCollectionModalOpen(false); + setSelectedCollectionUid(null); + }} + /> + )} + + {collectionToRemove && ( + setCollectionToRemove(null)} + handleConfirm={confirmRemoveCollection} + confirmText="Delete Collection" + cancelText="Cancel" + style="new" + > +

+ Are you sure you want to delete "{collectionToRemove.name}"? +

+

+ {(() => { + 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.'; + } + })()} +

+
+ )} + +
+ {workspaceCollections.length === 0 ? ( +
+
+ +
+

No collections yet

+

+ Create your first collection or open an existing one to get started. +

+
+ ) : ( +
+
+
Collection
+
Location
+
Actions
+
+ +
+ {workspaceCollections.map((collection, index) => { + return ( +
handleOpenCollectionClick(collection, e)} + > +
+
+ +
+
{collection.name}
+ {collection.brunoConfig?.name && collection.brunoConfig.name !== collection.name && ( +
{collection.brunoConfig.name}
+ )} +
+
+
+ +
+
+ {collection.pathname} +
+
+ +
+
+ + + +
+
+
+ ); + })} +
+
+ )} +
+
+
+ ); +}; + +export default WorkspaceCollections; diff --git a/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceDocs/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceDocs/StyledWrapper.js new file mode 100644 index 000000000..af80d4c08 --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceDocs/StyledWrapper.js @@ -0,0 +1,9 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .editing-mode { + cursor: pointer; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceDocs/index.js b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceDocs/index.js new file mode 100644 index 000000000..627e7b04e --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceDocs/index.js @@ -0,0 +1,142 @@ +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 () => { + 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 ( + +
+
+ + Workspace Documentation +
+
+ {isEditing ? ( + <> +
+ +
+ + + ) : ( +
+ +
+ )} +
+
+ {isEditing ? ( + + ) : ( +
+
+ { + localDocs?.length > 0 + ? + : + } +
+
+ )} +
+ ); +}; + +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! +`; diff --git a/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/CopyEnvironment/index.js b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/CopyEnvironment/index.js new file mode 100644 index 000000000..cb967a34e --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/CopyEnvironment/index.js @@ -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 ( + + +
e.preventDefault()}> +
+ + + {formik.touched.name && formik.errors.name ? ( +
{formik.errors.name}
+ ) : null} +
+
+
+
+ ); +}; + +export default CopyEnvironment; diff --git a/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/CreateEnvironment/index.js b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/CreateEnvironment/index.js new file mode 100644 index 000000000..7899c807a --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/CreateEnvironment/index.js @@ -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 ( + + +
e.preventDefault()}> +
+ +
+ +
+ {formik.touched.name && formik.errors.name ? ( +
{formik.errors.name}
+ ) : null} +
+
+
+
+ ); +}; + +export default CreateEnvironment; diff --git a/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/DeleteEnvironment/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/DeleteEnvironment/StyledWrapper.js new file mode 100644 index 000000000..48b874214 --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/DeleteEnvironment/StyledWrapper.js @@ -0,0 +1,15 @@ +import styled from 'styled-components'; + +const Wrapper = styled.div` + button.submit { + color: white; + background-color: var(--color-background-danger) !important; + border: inherit !important; + + &:hover { + border: inherit !important; + } + } +`; + +export default Wrapper; diff --git a/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/DeleteEnvironment/index.js b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/DeleteEnvironment/index.js new file mode 100644 index 000000000..766215d66 --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/DeleteEnvironment/index.js @@ -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 ( + + + + Are you sure you want to delete {environment.name} ? + + + + ); +}; + +export default DeleteEnvironment; diff --git a/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/EnvironmentList/ConfirmSwitchEnv.js b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/EnvironmentList/ConfirmSwitchEnv.js new file mode 100644 index 000000000..5dff60948 --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/EnvironmentList/ConfirmSwitchEnv.js @@ -0,0 +1,39 @@ +import React from 'react'; +import { IconAlertTriangle } from '@tabler/icons'; +import Modal from 'components/Modal'; +import { createPortal } from 'react-dom'; + +const ConfirmSwitchEnv = ({ onCancel }) => { + return createPortal( + { + e.stopPropagation(); + e.preventDefault(); + }} + hideFooter={true} + > +
+ +

Hold on..

+
+
You have unsaved changes in this environment.
+ +
+
+ +
+
+
+
, + document.body); +}; + +export default ConfirmSwitchEnv; diff --git a/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js new file mode 100644 index 000000000..88d57a40f --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js new file mode 100644 index 000000000..e173b765c --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js @@ -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 ( + + + + + ); + }; + + 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 ( + +
+ + + + + + + + + + + + {formik.values.map((variable, index) => { + const isLastRow = index === formik.values.length - 1; + const isEmptyRow = !variable.name || variable.name.trim() === ''; + const isLastEmptyRow = isLastRow && isEmptyRow; + + return ( + + + + + + + + ); + })} + +
EnabledNameValueSecret
+ {!isLastEmptyRow && ( + + )} + +
+ handleNameChange(index, e)} + /> + +
+
+
+ formik.setFieldValue(`${index}.value`, newValue, true)} + /> +
+ {typeof variable.value !== 'string' && ( + + + + + )} +
+ {!isLastEmptyRow && ( + + )} + + {!isLastEmptyRow && ( + + )} +
+
+ +
+
+ + +
+
+
+ ); +}; +export default EnvironmentVariables; diff --git a/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/index.js b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/index.js new file mode 100644 index 000000000..230457af6 --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/index.js @@ -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 ( + + {openDeleteModal && ( + setOpenDeleteModal(false)} + environment={environment} + /> + )} + {openCopyModal && ( + setOpenCopyModal(false)} environment={environment} /> + )} + +
+
+ {isRenaming ? ( + <> + +
+ + +
+ + ) : ( +

{environment.name}

+ )} +
+ {nameError && isRenaming &&
{nameError}
} +
+ + + +
+
+ +
+ +
+
+ ); +}; + +export default EnvironmentDetails; diff --git a/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/EnvironmentList/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/EnvironmentList/StyledWrapper.js new file mode 100644 index 000000000..0f60f349a --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/EnvironmentList/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/EnvironmentList/index.js b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/EnvironmentList/index.js new file mode 100644 index 000000000..6c7197e1a --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/EnvironmentList/index.js @@ -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 ( + + {openCreateModal && setOpenCreateModal(false)} />} + {openImportModal && setOpenImportModal(false)} />} + +
+ {switchEnvConfirmClose && ( +
+ handleConfirmSwitch(false)} /> +
+ )} + + {/* Left Sidebar */} +
+
+

Environments

+
+ + +
+
+ +
+ + setSearchText(e.target.value)} + className="search-input" + /> +
+ +
+ {filteredEnvironments.map((env) => ( +
renamingEnvUid !== env.uid && handleEnvironmentClick(env)} + onDoubleClick={() => handleEnvironmentDoubleClick(env)} + > + {renamingEnvUid === env.uid ? ( +
+ +
+ + +
+
+ ) : ( + <> + {env.name} +
+ {activeEnvironmentUid === env.uid ? ( +
+ +
+ ) : ( + + )} +
+ + )} +
+ ))} + + {isCreatingInline && ( +
+ +
+ + +
+
+ )} + + {envNameError && (isCreatingInline || renamingEnvUid) && ( +
{envNameError}
+ )} +
+
+ + {/* Right Content */} + +
+
+ ); +}; + +export default EnvironmentList; diff --git a/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/ImportEnvironment/index.js b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/ImportEnvironment/index.js new file mode 100644 index 000000000..3e9690fe6 --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/ImportEnvironment/index.js @@ -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) => { + 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); + }); + }); + }) + .then(() => { + onClose(); + // Call the callback if provided + if (onEnvironmentCreated) { + onEnvironmentCreated(); + } + }) + .catch((err) => toastError(err, 'Postman Import environment failed')); + }; + + return ( + + + + + + ); +}; + +export default ImportEnvironment; diff --git a/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/RenameEnvironment/index.js b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/RenameEnvironment/index.js new file mode 100644 index 000000000..7d3ae3efe --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/RenameEnvironment/index.js @@ -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 ( + + +
e.preventDefault()}> +
+ + + {formik.touched.name && formik.errors.name ? ( +
{formik.errors.name}
+ ) : null} +
+
+
+
+ ); +}; + +export default RenameEnvironment; diff --git a/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/StyledWrapper.js new file mode 100644 index 000000000..7931f4e94 --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/index.js b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/index.js new file mode 100644 index 000000000..fa409e839 --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceOverview/WorkspaceEnvironments/index.js @@ -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 ( + + ); +}; + +const DefaultTab = ({ setTab }) => { + return ( +
+ +
No Environments
+
+ + +
+
+ ); +}; + +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 ( + + {tab === 'create' ? ( + setTab('default')} /> + ) : tab === 'import' ? ( + setTab('default')} /> + ) : ( + + )} + + ); + } + + return ( + + + + ); +}; + +export default WorkspaceEnvironments; diff --git a/packages/bruno-app/src/components/WorkspaceOverview/index.js b/packages/bruno-app/src/components/WorkspaceOverview/index.js new file mode 100644 index 000000000..894939e96 --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceOverview/index.js @@ -0,0 +1,360 @@ +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 { loadWorkspaceCollections, 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 WorkspaceOverview = () => { + 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 (activeWorkspaceUid && activeWorkspace) { + dispatch(loadWorkspaceCollections(activeWorkspaceUid)); + } + }, [activeWorkspaceUid, dispatch, activeWorkspace]); + + 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: ( + + ) + }, + { + id: 'environments', + label: 'Environments', + component: + }, + { + id: 'documentation', + label: 'Documentation', + component: + } + ]; + + return ( + +
+ {createCollectionModalOpen && ( + setCreateCollectionModalOpen(false)} + workspaceUid={activeWorkspace.uid} + defaultLocation={`${activeWorkspace.pathname}/collections`} + hideLocationInput={true} + /> + )} + + {importCollectionModalOpen && ( + setImportCollectionModalOpen(false)} + handleSubmit={handleImportCollectionSubmit} + /> + )} + +
+
+ + {isRenamingWorkspace ? ( +
+ +
+ + +
+
+ ) : ( + {activeWorkspace.name} + )} +
+ + {!isRenamingWorkspace && activeWorkspace.type !== 'default' && ( + } + > +
+
+ + Rename +
+
+ + Show in Folder +
+
+ + Close +
+
+
+ )} + + {workspaceNameError && isRenamingWorkspace && ( +
{workspaceNameError}
+ )} +
+ + {closeWorkspaceModalOpen && ( + setCloseWorkspaceModalOpen(false)} + /> + )} + +
+
+ {tabs.map((tab) => { + return ( + + ); + })} +
+ + {activeTab === 'collections' && ( +
+ + + +
+ )} +
+ +
+ {tabs.find((tab) => tab.id === activeTab)?.component} +
+
+
+ ); +}; + +export default WorkspaceOverview; diff --git a/packages/bruno-app/src/components/WorkspaceSidebar/CreateWorkspace/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceSidebar/CreateWorkspace/StyledWrapper.js new file mode 100644 index 000000000..e49220854 --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceSidebar/CreateWorkspace/StyledWrapper.js @@ -0,0 +1,5 @@ +import styled from 'styled-components'; + +const Wrapper = styled.div``; + +export default Wrapper; diff --git a/packages/bruno-app/src/components/WorkspaceSidebar/CreateWorkspace/index.js b/packages/bruno-app/src/components/WorkspaceSidebar/CreateWorkspace/index.js new file mode 100644 index 000000000..e328233e0 --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceSidebar/CreateWorkspace/index.js @@ -0,0 +1,147 @@ +import React, { useRef, useEffect, useState } from 'react'; +import { useFormik } from 'formik'; +import { useDispatch, useSelector } from 'react-redux'; +import * as Yup from 'yup'; +import toast from 'react-hot-toast'; +import Modal from 'components/Modal'; +import { createWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions'; +import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions'; +import { multiLineMsg } from 'utils/common/index'; +import { formatIpcError } from 'utils/common/error'; +import StyledWrapper from './StyledWrapper'; + +const CreateWorkspace = ({ onClose }) => { + const inputRef = useRef(); + const dispatch = useDispatch(); + const workspaces = useSelector((state) => state.workspaces.workspaces); + const [isSubmitting, setIsSubmitting] = useState(false); + + const formik = useFormik({ + enableReinitialize: true, + initialValues: { + workspaceName: '', + workspaceLocation: '' + }, + validationSchema: Yup.object({ + workspaceName: Yup.string() + .min(1, 'must be at least 1 character') + .max(255, 'must be 255 characters or less') + .required('workspace name is required') + .test('unique-name', 'A workspace with this name already exists', function (value) { + if (!value) return true; + + return !workspaces.some((w) => + w.name.toLowerCase() === value.toLowerCase()); + }), + workspaceLocation: Yup.string().min(1, 'location is required').required('location is required') + }), + onSubmit: async (values) => { + if (isSubmitting) return; + + try { + setIsSubmitting(true); + + await dispatch(createWorkspaceAction(values.workspaceName, values.workspaceName, values.workspaceLocation)); + toast.success('Workspace created!'); + onClose(); + } catch (error) { + toast.error(multiLineMsg('An error occurred while creating the workspace', formatIpcError(error))); + } finally { + setIsSubmitting(false); + } + } + }); + + const browse = () => { + dispatch(browseDirectory()) + .then((dirPath) => { + if (typeof dirPath === 'string') { + formik.setFieldValue('workspaceLocation', dirPath); + } + }) + .catch((error) => { + formik.setFieldValue('workspaceLocation', ''); + console.error(error); + }); + }; + + useEffect(() => { + if (inputRef && inputRef.current) { + inputRef.current.focus(); + } + }, [inputRef]); + + return ( + + +
+
+
+ + + {formik.touched.workspaceName && formik.errors.workspaceName ? ( +
{formik.errors.workspaceName}
+ ) : null} +
+ +
+ +
+ + +
+ {formik.touched.workspaceLocation && formik.errors.workspaceLocation ? ( +
{formik.errors.workspaceLocation}
+ ) : null} +
+
+
+
+
+ ); +}; + +export default CreateWorkspace; diff --git a/packages/bruno-app/src/pages/Bruno/index.js b/packages/bruno-app/src/pages/Bruno/index.js index 0a4002640..6885d8741 100644 --- a/packages/bruno-app/src/pages/Bruno/index.js +++ b/packages/bruno-app/src/pages/Bruno/index.js @@ -1,6 +1,6 @@ -import React, { useState, useCallback, useRef, useEffect } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import classnames from 'classnames'; -import Welcome from 'components/Welcome'; +import WorkspaceOverview from 'components/WorkspaceOverview'; import RequestTabs from 'components/RequestTabs'; import RequestTabPanel from 'components/RequestTabPanel'; import Sidebar from 'components/Sidebar'; @@ -112,7 +112,7 @@ export default function Main() {
{showHomePage ? ( - + ) : ( <> diff --git a/packages/bruno-app/src/providers/App/useIpcEvents.js b/packages/bruno-app/src/providers/App/useIpcEvents.js index b405269fa..8dbb96f27 100644 --- a/packages/bruno-app/src/providers/App/useIpcEvents.js +++ b/packages/bruno-app/src/providers/App/useIpcEvents.js @@ -22,6 +22,8 @@ import { streamDataReceived } from 'providers/ReduxStore/slices/collections'; import { collectionAddEnvFileEvent, openCollectionEvent, hydrateCollectionWithUiStateSnapshot, mergeAndPersistEnvironment } from 'providers/ReduxStore/slices/collections/actions'; +import { workspaceOpenedEvent, workspaceConfigUpdatedEvent, loadLastOpenedWorkspaces, switchWorkspace, loadWorkspaceCollections } from 'providers/ReduxStore/slices/workspaces/actions'; +import { createWorkspace } from 'providers/ReduxStore/slices/workspaces'; import toast from 'react-hot-toast'; import { useDispatch } from 'react-redux'; import { isElectron } from 'utils/common/platform'; @@ -90,6 +92,33 @@ const useIpcEvents = () => { } }; + const initializeDefaultWorkspace = async () => { + try { + const defaultWorkspace = await ipcRenderer.invoke('renderer:get-default-workspace'); + + if (defaultWorkspace) { + const { workspaceConfig, workspaceUid, workspacePath } = defaultWorkspace; + + dispatch(createWorkspace({ + uid: workspaceUid, + name: workspaceConfig.name || 'Default', + type: 'default', + pathname: workspacePath, + collections: [], + docs: workspaceConfig.docs || '' + })); + + await dispatch(loadWorkspaceCollections(workspaceUid)); + dispatch(switchWorkspace(workspaceUid)); + } + } catch (error) { + console.error('Error loading default workspace:', error); + } + }; + + initializeDefaultWorkspace(); + dispatch(loadLastOpenedWorkspaces()); + ipcRenderer.invoke('renderer:ready'); const removeCollectionTreeUpdateListener = ipcRenderer.on('main:collection-tree-updated', _collectionTreeUpdated); @@ -98,6 +127,68 @@ const useIpcEvents = () => { dispatch(openCollectionEvent(uid, pathname, brunoConfig)); }); + const removeOpenWorkspaceListener = ipcRenderer.on('main:workspace-opened', (workspacePath, workspaceUid, workspaceConfig) => { + dispatch(workspaceOpenedEvent(workspacePath, workspaceUid, workspaceConfig)); + }); + + const removeWorkspaceConfigUpdatedListener = ipcRenderer.on('main:workspace-config-updated', (workspacePath, workspaceUid, workspaceConfig) => { + dispatch(workspaceConfigUpdatedEvent(workspacePath, workspaceUid, workspaceConfig)); + }); + + const removeWorkspaceEnvironmentAddedListener = ipcRenderer.on('main:workspace-environment-added', (workspaceUid, file) => { + const state = window.__store__.getState(); + const activeWorkspaceUid = state.workspaces?.activeWorkspaceUid; + if (activeWorkspaceUid === workspaceUid) { + const workspace = state.workspaces?.workspaces?.find((w) => w.uid === workspaceUid); + if (workspace) { + ipcRenderer.invoke('renderer:get-global-environments', { + workspaceUid, + workspacePath: workspace.pathname + }).then((result) => { + dispatch(updateGlobalEnvironments(result)); + }).catch((error) => { + console.error('Error refreshing global environments:', error); + }); + } + } + }); + + const removeWorkspaceEnvironmentChangedListener = ipcRenderer.on('main:workspace-environment-changed', (workspaceUid, file) => { + const state = window.__store__.getState(); + const activeWorkspaceUid = state.workspaces?.activeWorkspaceUid; + if (activeWorkspaceUid === workspaceUid) { + const workspace = state.workspaces?.workspaces?.find((w) => w.uid === workspaceUid); + if (workspace) { + ipcRenderer.invoke('renderer:get-global-environments', { + workspaceUid, + workspacePath: workspace.pathname + }).then((result) => { + dispatch(updateGlobalEnvironments(result)); + }).catch((error) => { + console.error('Error refreshing global environments:', error); + }); + } + } + }); + + const removeWorkspaceEnvironmentDeletedListener = ipcRenderer.on('main:workspace-environment-deleted', (workspaceUid, environmentUid) => { + const state = window.__store__.getState(); + const activeWorkspaceUid = state.workspaces?.activeWorkspaceUid; + if (activeWorkspaceUid === workspaceUid) { + const workspace = state.workspaces?.workspaces?.find((w) => w.uid === workspaceUid); + if (workspace) { + ipcRenderer.invoke('renderer:get-global-environments', { + workspaceUid, + workspacePath: workspace.pathname + }).then((result) => { + dispatch(updateGlobalEnvironments(result)); + }).catch((error) => { + console.error('Error refreshing global environments:', error); + }); + } + } + }); + const removeCollectionAlreadyOpenedListener = ipcRenderer.on('main:collection-already-opened', (pathname) => { toast.success('Collection is already opened'); }); @@ -205,6 +296,11 @@ const useIpcEvents = () => { return () => { removeCollectionTreeUpdateListener(); removeOpenCollectionListener(); + removeOpenWorkspaceListener(); + removeWorkspaceConfigUpdatedListener(); + removeWorkspaceEnvironmentAddedListener(); + removeWorkspaceEnvironmentChangedListener(); + removeWorkspaceEnvironmentDeletedListener(); removeCollectionAlreadyOpenedListener(); removeDisplayErrorListener(); removeScriptEnvUpdateListener(); diff --git a/packages/bruno-app/src/providers/ReduxStore/index.js b/packages/bruno-app/src/providers/ReduxStore/index.js index 14dcdbe04..97def82f7 100644 --- a/packages/bruno-app/src/providers/ReduxStore/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/index.js @@ -8,6 +8,7 @@ import notificationsReducer from './slices/notifications'; import globalEnvironmentsReducer from './slices/global-environments'; import logsReducer from './slices/logs'; import performanceReducer from './slices/performance'; +import workspacesReducer from './slices/workspaces'; import { draftDetectMiddleware } from './middlewares/draft/middleware'; import { autosaveMiddleware } from './middlewares/autosave/middleware'; @@ -28,7 +29,8 @@ export const store = configureStore({ notifications: notificationsReducer, globalEnvironments: globalEnvironmentsReducer, logs: logsReducer, - performance: performanceReducer + performance: performanceReducer, + workspaces: workspacesReducer }, middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(middleware) }); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index 64df682cd..66aad3e70 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -58,6 +58,8 @@ import { import { each } from 'lodash'; import { closeAllCollectionTabs, updateResponsePaneScrollPosition } from 'providers/ReduxStore/slices/tabs'; +import { addCollectionToWorkspace, removeCollectionFromWorkspace } from 'providers/ReduxStore/slices/workspaces'; +import { loadWorkspaceCollections } from 'providers/ReduxStore/slices/workspaces/actions'; import { resolveRequestFilename } from 'utils/common/platform'; import { interpolateUrl, parsePathParams, splitOnFirst } from 'utils/url/index'; import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index'; @@ -758,66 +760,66 @@ export const newFolder = (folderName, directoryName, collectionUid, itemUid) => export const renameItem = ({ newName, newFilename, itemUid, collectionUid }) => - (dispatch, getState) => { - const state = getState(); - const collection = findCollectionByUid(state.collections.collections, collectionUid); + (dispatch, getState) => { + const state = getState(); + const collection = findCollectionByUid(state.collections.collections, collectionUid); - return new Promise((resolve, reject) => { - if (!collection) { - return reject(new Error('Collection not found')); - } - - const collectionCopy = cloneDeep(collection); - const item = findItemInCollection(collectionCopy, itemUid); - if (!item) { - return reject(new Error('Unable to locate item')); - } - - const { ipcRenderer } = window; - - const renameName = async () => { - return ipcRenderer.invoke('renderer:rename-item-name', { itemPath: item.pathname, newName, collectionPathname: collection.pathname }).catch((err) => { - toast.error('Failed to rename the item name'); - console.error(err); - throw new Error('Failed to rename the item name'); - }); - }; - - const renameFile = async () => { - const dirname = path.dirname(item.pathname); - let newPath = ''; - if (item.type === 'folder') { - newPath = path.join(dirname, trim(newFilename)); - } else { - const filename = resolveRequestFilename(newFilename, collection.format); - newPath = path.join(dirname, filename); + return new Promise((resolve, reject) => { + if (!collection) { + return reject(new Error('Collection not found')); } - return ipcRenderer - .invoke('renderer:rename-item-filename', { oldPath: item.pathname, newPath, newName, newFilename, collectionPathname: collection.pathname }) - .catch((err) => { - toast.error('Failed to rename the file'); + const collectionCopy = cloneDeep(collection); + const item = findItemInCollection(collectionCopy, itemUid); + if (!item) { + return reject(new Error('Unable to locate item')); + } + + const { ipcRenderer } = window; + + const renameName = async () => { + return ipcRenderer.invoke('renderer:rename-item-name', { itemPath: item.pathname, newName, collectionPathname: collection.pathname }).catch((err) => { + toast.error('Failed to rename the item name'); console.error(err); - throw new Error('Failed to rename the file'); + throw new Error('Failed to rename the item name'); }); - }; + }; - let renameOperation = null; - if (newName) renameOperation = renameName; - if (newFilename) renameOperation = renameFile; + const renameFile = async () => { + const dirname = path.dirname(item.pathname); + let newPath = ''; + if (item.type === 'folder') { + newPath = path.join(dirname, trim(newFilename)); + } else { + const filename = resolveRequestFilename(newFilename, collection.format); + newPath = path.join(dirname, filename); + } - if (!renameOperation) { - resolve(); - } + return ipcRenderer + .invoke('renderer:rename-item-filename', { oldPath: item.pathname, newPath, newName, newFilename, collectionPathname: collection.pathname }) + .catch((err) => { + toast.error('Failed to rename the file'); + console.error(err); + throw new Error('Failed to rename the file'); + }); + }; - renameOperation() - .then(() => { - toast.success('Item renamed successfully'); + let renameOperation = null; + if (newName) renameOperation = renameName; + if (newFilename) renameOperation = renameFile; + + if (!renameOperation) { resolve(); - }) - .catch((err) => reject(err)); - }); - }; + } + + renameOperation() + .then(() => { + toast.success('Item renamed successfully'); + resolve(); + }) + .catch((err) => reject(err)); + }); + }; export const cloneItem = (newName, newFilename, itemUid, collectionUid) => (dispatch, getState) => { const state = getState(); @@ -1048,138 +1050,134 @@ export const sortCollections = (payload) => (dispatch) => { export const moveItem = ({ targetDirname, sourcePathname }) => - (dispatch, getState) => { - return new Promise((resolve, reject) => { - const { ipcRenderer } = window; + (dispatch, getState) => { + return new Promise((resolve, reject) => { + const { ipcRenderer } = window; - ipcRenderer.invoke('renderer:move-item', { targetDirname, sourcePathname }).then(resolve).catch(reject); - }); - }; + ipcRenderer.invoke('renderer:move-item', { targetDirname, sourcePathname }).then(resolve).catch(reject); + }); + }; export const handleCollectionItemDrop = ({ targetItem, draggedItem, dropType, collectionUid }) => - (dispatch, getState) => { - const state = getState(); - const collection = findCollectionByUid(state.collections.collections, collectionUid); - // if its withincollection set the source to current collection, - // if its cross collection set the source to the source collection - const sourceCollectionUid = draggedItem.sourceCollectionUid - const isCrossCollectionMove = sourceCollectionUid && collectionUid !== sourceCollectionUid; - const sourceCollection = isCrossCollectionMove ? findCollectionByUid(state.collections.collections, sourceCollectionUid) : collection; - const { uid: draggedItemUid, pathname: draggedItemPathname } = draggedItem; - const { uid: targetItemUid, pathname: targetItemPathname } = targetItem; - const targetItemDirectory = findParentItemInCollection(collection, targetItemUid) || collection; - const targetItemDirectoryItems = cloneDeep(targetItemDirectory.items); - const draggedItemDirectory = findParentItemInCollection(sourceCollection, draggedItemUid) || sourceCollection; - const draggedItemDirectoryItems = cloneDeep(draggedItemDirectory.items); + (dispatch, getState) => { + const state = getState(); + const collection = findCollectionByUid(state.collections.collections, collectionUid); + // if its withincollection set the source to current collection, + // if its cross collection set the source to the source collection + const sourceCollectionUid = draggedItem.sourceCollectionUid; + const isCrossCollectionMove = sourceCollectionUid && collectionUid !== sourceCollectionUid; + const sourceCollection = isCrossCollectionMove ? findCollectionByUid(state.collections.collections, sourceCollectionUid) : collection; + const { uid: draggedItemUid, pathname: draggedItemPathname } = draggedItem; + const { uid: targetItemUid, pathname: targetItemPathname } = targetItem; + const targetItemDirectory = findParentItemInCollection(collection, targetItemUid) || collection; + const targetItemDirectoryItems = cloneDeep(targetItemDirectory.items); + const draggedItemDirectory = findParentItemInCollection(sourceCollection, draggedItemUid) || sourceCollection; + const draggedItemDirectoryItems = cloneDeep(draggedItemDirectory.items); - const handleMoveToNewLocation = async ({ - draggedItem, - draggedItemDirectoryItems, - targetItem, - targetItemDirectoryItems, - newPathname, - dropType - }) => { - const { uid: targetItemUid } = targetItem; - const { pathname: draggedItemPathname, uid: draggedItemUid } = draggedItem; + const handleMoveToNewLocation = async ({ + draggedItem, + draggedItemDirectoryItems, + targetItem, + targetItemDirectoryItems, + newPathname, + dropType + }) => { + const { uid: targetItemUid } = targetItem; + const { pathname: draggedItemPathname, uid: draggedItemUid } = draggedItem; - const newDirname = path.dirname(newPathname); - await dispatch( - moveItem({ + const newDirname = path.dirname(newPathname); + await dispatch(moveItem({ targetDirname: newDirname, sourcePathname: draggedItemPathname - }) - ); + })); - // Update sequences in the source directory - if (draggedItemDirectoryItems?.length) { - // reorder items in the source directory - const draggedItemDirectoryItemsWithoutDraggedItem = draggedItemDirectoryItems.filter( - (i) => i.uid !== draggedItemUid - ); - const reorderedSourceItems = getReorderedItemsInSourceDirectory({ - items: draggedItemDirectoryItemsWithoutDraggedItem - }); - if (reorderedSourceItems?.length) { + // Update sequences in the source directory + if (draggedItemDirectoryItems?.length) { + // reorder items in the source directory + const draggedItemDirectoryItemsWithoutDraggedItem = draggedItemDirectoryItems.filter((i) => i.uid !== draggedItemUid); + const reorderedSourceItems = getReorderedItemsInSourceDirectory({ + items: draggedItemDirectoryItemsWithoutDraggedItem + }); + if (reorderedSourceItems?.length) { await dispatch(updateItemsSequences({ itemsToResequence: reorderedSourceItems, collectionUid: sourceCollectionUid || collectionUid })); + } } - } - // Update sequences in the target directory (if dropping adjacent) - if (dropType === 'adjacent') { - const targetItemSequence = targetItemDirectoryItems.findIndex((i) => i.uid === targetItemUid)?.seq; + // Update sequences in the target directory (if dropping adjacent) + if (dropType === 'adjacent') { + const targetItemSequence = targetItemDirectoryItems.findIndex((i) => i.uid === targetItemUid)?.seq; - const draggedItemWithNewPathAndSequence = { - ...draggedItem, - pathname: newPathname, - seq: targetItemSequence - }; + const draggedItemWithNewPathAndSequence = { + ...draggedItem, + pathname: newPathname, + seq: targetItemSequence + }; - // draggedItem is added to the targetItem's directory - const reorderedTargetItems = getReorderedItemsInTargetDirectory({ - items: [...targetItemDirectoryItems, draggedItemWithNewPathAndSequence], + // draggedItem is added to the targetItem's directory + const reorderedTargetItems = getReorderedItemsInTargetDirectory({ + items: [...targetItemDirectoryItems, draggedItemWithNewPathAndSequence], + targetItemUid, + draggedItemUid + }); + + if (reorderedTargetItems?.length) { + await dispatch(updateItemsSequences({ itemsToResequence: reorderedTargetItems, collectionUid })); + } + } + }; + + const handleReorderInSameLocation = async ({ draggedItem, targetItem, targetItemDirectoryItems }) => { + const { uid: targetItemUid } = targetItem; + const { uid: draggedItemUid } = draggedItem; + + // reorder items in the targetItem's directory + const reorderedItems = getReorderedItemsInTargetDirectory({ + items: targetItemDirectoryItems, targetItemUid, draggedItemUid }); - if (reorderedTargetItems?.length) { - await dispatch(updateItemsSequences({ itemsToResequence: reorderedTargetItems, collectionUid })); - } - } - }; - - const handleReorderInSameLocation = async ({ draggedItem, targetItem, targetItemDirectoryItems }) => { - const { uid: targetItemUid } = targetItem; - const { uid: draggedItemUid } = draggedItem; - - // reorder items in the targetItem's directory - const reorderedItems = getReorderedItemsInTargetDirectory({ - items: targetItemDirectoryItems, - targetItemUid, - draggedItemUid - }); - - if (reorderedItems?.length) { + if (reorderedItems?.length) { await dispatch(updateItemsSequences({ itemsToResequence: reorderedItems, collectionUid })); - } + } + }; + + return new Promise(async (resolve, reject) => { + try { + const newPathname = calculateDraggedItemNewPathname({ + draggedItem, + targetItem, + dropType, + collectionPathname: collection.pathname + }); + if (!newPathname) return; + if (targetItemPathname?.startsWith(draggedItemPathname)) return; + if (newPathname !== draggedItemPathname) { + await handleMoveToNewLocation({ + targetItem, + targetItemDirectoryItems, + draggedItem, + draggedItemDirectoryItems, + newPathname, + dropType + }); + } else { + await handleReorderInSameLocation({ draggedItem, targetItemDirectoryItems, targetItem }); + } + resolve(); + } catch (error) { + console.error(error); + toast.error(error?.message); + reject(error); + } + }); }; - return new Promise(async (resolve, reject) => { - try { - const newPathname = calculateDraggedItemNewPathname({ - draggedItem, - targetItem, - dropType, - collectionPathname: collection.pathname - }); - if (!newPathname) return; - if (targetItemPathname?.startsWith(draggedItemPathname)) return; - if (newPathname !== draggedItemPathname) { - await handleMoveToNewLocation({ - targetItem, - targetItemDirectoryItems, - draggedItem, - draggedItemDirectoryItems, - newPathname, - dropType - }); - } else { - await handleReorderInSameLocation({ draggedItem, targetItemDirectoryItems, targetItem }); - } - resolve(); - } catch (error) { - console.error(error); - toast.error(error?.message); - reject(error); - } - }); - }; - -export const updateItemsSequences = - ({ itemsToResequence, collectionUid }) => - (dispatch, getState) => { - return new Promise((resolve, reject) => { +export const updateItemsSequences + = ({ itemsToResequence, collectionUid }) => + (dispatch, getState) => { + return new Promise((resolve, reject) => { const state = getState(); const collection = findCollectionByUid(state.collections.collections, collectionUid); @@ -1187,11 +1185,11 @@ export const updateItemsSequences = return reject(new Error('Collection not found')); } - const { ipcRenderer } = window; + const { ipcRenderer } = window; ipcRenderer.invoke('renderer:resequence-items', itemsToResequence, collection.pathname).then(resolve).catch(reject); - }); - }; + }); + }; export const newHttpRequest = (params) => (dispatch, getState) => { const { @@ -1995,52 +1993,52 @@ export const mergeAndPersistEnvironment const state = getState(); const collection = findCollectionByUid(state.collections.collections, collectionUid); - if (!collection) { - return reject(new Error('Collection not found')); - } - - const environmentUid = collection.activeEnvironmentUid; - if (!environmentUid) { - return reject(new Error('No active environment found')); - } - - const collectionCopy = cloneDeep(collection); - const environment = findEnvironmentInCollection(collectionCopy, environmentUid); - if (!environment) { - return reject(new Error('Environment not found')); - } - - // Only proceed if there are persistent variables to save - if (!persistentEnvVariables || Object.keys(persistentEnvVariables).length === 0) { - return resolve(); - } - - let existingVars = environment.variables || []; - - let normalizedNewVars = Object.entries(persistentEnvVariables).map(([name, value]) => ({ - uid: uuid(), - name, - value, - type: 'text', - enabled: true, - secret: false - })); - - const merged = existingVars.map((v) => { - const found = normalizedNewVars.find((nv) => nv.name === v.name); - if (found) { - return { ...v, value: found.value }; + if (!collection) { + return reject(new Error('Collection not found')); } - return v; - }); - normalizedNewVars.forEach((nv) => { - if (!merged.some((v) => v.name === nv.name)) { - merged.push(nv); + + const environmentUid = collection.activeEnvironmentUid; + if (!environmentUid) { + return reject(new Error('No active environment found')); } - }); + + const collectionCopy = cloneDeep(collection); + const environment = findEnvironmentInCollection(collectionCopy, environmentUid); + if (!environment) { + return reject(new Error('Environment not found')); + } + + // Only proceed if there are persistent variables to save + if (!persistentEnvVariables || Object.keys(persistentEnvVariables).length === 0) { + return resolve(); + } + + let existingVars = environment.variables || []; + + let normalizedNewVars = Object.entries(persistentEnvVariables).map(([name, value]) => ({ + uid: uuid(), + name, + value, + type: 'text', + enabled: true, + secret: false + })); + + const merged = existingVars.map((v) => { + const found = normalizedNewVars.find((nv) => nv.name === v.name); + if (found) { + return { ...v, value: found.value }; + } + return v; + }); + normalizedNewVars.forEach((nv) => { + if (!merged.some((v) => v.name === nv.name)) { + merged.push(nv); + } + }); // Save all non-ephemeral vars and all variables that were previously persisted - const persistedNames = new Set(Object.keys(persistentEnvVariables)); + const persistedNames = new Set(Object.keys(persistentEnvVariables)); // Add all existing non-ephemeral variables to persistedNames so they are preserved existingVars.forEach((v) => { @@ -2049,17 +2047,17 @@ export const mergeAndPersistEnvironment } }); - const environmentToSave = cloneDeep(environment); - environmentToSave.variables = buildPersistedEnvVariables(merged, { mode: 'merge', persistedNames }); + const environmentToSave = cloneDeep(environment); + environmentToSave.variables = buildPersistedEnvVariables(merged, { mode: 'merge', persistedNames }); - const { ipcRenderer } = window; - environmentSchema - .validate(environmentToSave) - .then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, environmentToSave)) - .then(resolve) - .catch(reject); - }); - }; + const { ipcRenderer } = window; + environmentSchema + .validate(environmentToSave) + .then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, environmentToSave)) + .then(resolve) + .catch(reject); + }); + }; export const selectEnvironment = (environmentUid, collectionUid) => (dispatch, getState) => { return new Promise((resolve, reject) => { @@ -2096,18 +2094,48 @@ export const removeCollection = (collectionUid) => (dispatch, getState) => { return reject(new Error('Collection not found')); } const { ipcRenderer } = window; + + // Get active workspace to determine which workspace we're removing from + const { workspaces } = state; + const activeWorkspace = workspaces.workspaces.find((w) => w.uid === workspaces.activeWorkspaceUid); + + let workspaceId = 'default'; + if (activeWorkspace) { + if (activeWorkspace.pathname) { + workspaceId = activeWorkspace.pathname; + } else { + workspaceId = activeWorkspace.uid; + } + } + ipcRenderer - .invoke('renderer:remove-collection', collection.pathname, collectionUid) + .invoke('renderer:remove-collection', collection.pathname, collectionUid, workspaceId) .then(() => { - dispatch(closeAllCollectionTabs({ collectionUid })); + // Check if the collection still exists in other workspaces + return ipcRenderer.invoke('renderer:get-collection-workspaces', collection.pathname); }) - .then(waitForNextTick) - .then(() => { - dispatch( - _removeCollection({ - collectionUid: collectionUid - }) - ); + .then((remainingWorkspaces) => { + // Close tabs for this collection + dispatch(closeAllCollectionTabs({ collectionUid })); + + // Remove collection from workspace in Redux state + if (activeWorkspace) { + dispatch(removeCollectionFromWorkspace({ + workspaceUid: activeWorkspace.uid, + collectionLocation: collection.pathname + })); + } + + // Only remove from Redux if no workspaces remain + if (!remainingWorkspaces || remainingWorkspaces.length === 0) { + return waitForNextTick().then(() => { + dispatch(_removeCollection({ + collectionUid: collectionUid + })); + }); + } else { + // Collection still exists in other workspaces + } }) .then(resolve) .catch(reject); @@ -2213,6 +2241,35 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge if (state.app.sidebarCollapsed) { dispatch(toggleSidebarCollapse()); } + + const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid); + if (activeWorkspace) { + const isAlreadyInWorkspace = activeWorkspace.collections?.some((c) => c.path === pathname); + + if (!isAlreadyInWorkspace) { + const workspaceCollection = { + name: brunoConfig.name, + path: pathname + }; + + ipcRenderer + .invoke('renderer:add-collection-to-workspace', activeWorkspace.pathname, workspaceCollection) + .then(() => { + dispatch(addCollectionToWorkspace({ + workspaceUid: activeWorkspace.uid, + collection: workspaceCollection + })); + dispatch(loadWorkspaceCollections(activeWorkspace.uid, true)); + }) + .catch((err) => { + console.error('Failed to add collection to workspace', err); + toast.error('Failed to add collection to workspace'); + }); + } else { + dispatch(loadWorkspaceCollections(activeWorkspace.uid, true)); + } + } + resolve(); }) .catch(reject); @@ -2220,12 +2277,23 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge }); }; -export const createCollection = (collectionName, collectionFolderName, collectionLocation, format = 'bru') => () => { +export const createCollection = (collectionName, collectionFolderName, collectionLocation, options = {}) => (dispatch, getState) => { const { ipcRenderer } = window; + if (!options.workspaceId) { + const { workspaces } = getState(); + const activeWorkspace = workspaces.workspaces.find((w) => w.uid === workspaces.activeWorkspaceUid); + + if (activeWorkspace && activeWorkspace.pathname) { + options.workspaceId = activeWorkspace.pathname; + } else { + options.workspaceId = 'default'; + } + } + return new Promise((resolve, reject) => { ipcRenderer - .invoke('renderer:create-collection', collectionName, collectionFolderName, collectionLocation, format) + .invoke('renderer:create-collection', collectionName, collectionFolderName, collectionLocation, options) .then(resolve) .catch(reject); }); @@ -2241,11 +2309,34 @@ export const cloneCollection = (collectionName, collectionFolderName, collection previousPath ); }; -export const openCollection = () => () => { +export const openCollection = (options = {}) => (dispatch, getState) => { return new Promise((resolve, reject) => { const { ipcRenderer } = window; - ipcRenderer.invoke('renderer:open-collection').then(resolve).catch(reject); + const state = getState(); + const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid); + + if (!options.workspaceId) { + options.workspaceId = activeWorkspace?.pathname || 'default'; + } + + ipcRenderer.invoke('renderer:open-collection', options) + .then((result) => { + resolve(result); + }) + .catch(reject); + }); +}; + +export const openMultipleCollections = (collectionPaths, options = {}) => () => { + return new Promise((resolve, reject) => { + const { ipcRenderer } = window; + + ipcRenderer.invoke('renderer:open-multiple-collections', collectionPaths, options) + .then(resolve) + .catch((err) => { + reject(); + }); }); }; @@ -2274,28 +2365,43 @@ export const collectionAddEnvFileEvent = (payload) => (dispatch, getState) => { }); }; -export const importCollection = (collection, collectionLocation) => (dispatch, getState) => { - return new Promise((resolve, reject) => { +export const importCollection = (collection, collectionLocation, options = {}) => (dispatch, getState) => { + return new Promise(async (resolve, reject) => { const { ipcRenderer } = window; - ipcRenderer.invoke('renderer:import-collection', collection, collectionLocation).then(resolve).catch(reject); + try { + const state = getState(); + const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid); + + const collectionPath = await ipcRenderer.invoke('renderer:import-collection', collection, collectionLocation); + + if (activeWorkspace && activeWorkspace.pathname && activeWorkspace.type !== 'default') { + const workspaceCollection = { + name: collection.name, + path: collectionPath + }; + + await ipcRenderer.invoke('renderer:add-collection-to-workspace', activeWorkspace.pathname, workspaceCollection); + + dispatch(addCollectionToWorkspace({ + workspaceUid: activeWorkspace.uid, + collection: workspaceCollection + })); + } + + resolve(collectionPath); + } catch (error) { + reject(error); + } }); }; -export const moveCollectionAndPersist = - ({ draggedItem, targetItem }) => - (dispatch, getState) => { - dispatch(moveCollection({ draggedItem, targetItem })); - - return new Promise((resolve, reject) => { - const { ipcRenderer } = window; - const state = getState(); - - const collectionPaths = state.collections.collections.map((collection) => collection.pathname); - - ipcRenderer.invoke('renderer:update-collection-paths', collectionPaths).then(resolve).catch(reject); - }); - }; +export const moveCollectionAndPersist + = ({ draggedItem, targetItem }) => + (dispatch, getState) => { + dispatch(moveCollection({ draggedItem, targetItem })); + return Promise.resolve(); + }; export const saveCollectionSecurityConfig = (collectionUid, securityConfig) => (dispatch, getState) => { return new Promise((resolve, reject) => { @@ -2416,46 +2522,46 @@ export const clearOauth2Cache = (payload) => async (dispatch, getState) => { // todo: could be removed export const loadRequestViaWorker = ({ collectionUid, pathname }) => - (dispatch, getState) => { - return new Promise(async (resolve, reject) => { - const { ipcRenderer } = window; - ipcRenderer.invoke('renderer:load-request-via-worker', { collectionUid, pathname }).then(resolve).catch(reject); - }); - }; + (dispatch, getState) => { + return new Promise(async (resolve, reject) => { + const { ipcRenderer } = window; + ipcRenderer.invoke('renderer:load-request-via-worker', { collectionUid, pathname }).then(resolve).catch(reject); + }); + }; // todo: could be removed -export const loadRequest = - ({ collectionUid, pathname }) => - (dispatch, getState) => { - return new Promise(async (resolve, reject) => { - const { ipcRenderer } = window; - ipcRenderer.invoke('renderer:load-request', { collectionUid, pathname }).then(resolve).catch(reject); - }); - }; +export const loadRequest + = ({ collectionUid, pathname }) => + (dispatch, getState) => { + return new Promise(async (resolve, reject) => { + const { ipcRenderer } = window; + ipcRenderer.invoke('renderer:load-request', { collectionUid, pathname }).then(resolve).catch(reject); + }); + }; -export const loadLargeRequest = - ({ collectionUid, pathname }) => - (dispatch, getState) => { - return new Promise(async (resolve, reject) => { - const { ipcRenderer } = window; - ipcRenderer.invoke('renderer:load-large-request', { collectionUid, pathname }).then(resolve).catch(reject); - }); - }; +export const loadLargeRequest + = ({ collectionUid, pathname }) => + (dispatch, getState) => { + return new Promise(async (resolve, reject) => { + const { ipcRenderer } = window; + ipcRenderer.invoke('renderer:load-large-request', { collectionUid, pathname }).then(resolve).catch(reject); + }); + }; -export const mountCollection = - ({ collectionUid, collectionPathname, brunoConfig }) => - (dispatch, getState) => { - dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'mounting' })); - return new Promise(async (resolve, reject) => { - callIpc('renderer:mount-collection', { collectionUid, collectionPathname, brunoConfig }) - .then(() => dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'mounted' }))) - .then(resolve) - .catch(() => { - dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'unmounted' })); - reject(); - }); - }); - }; +export const mountCollection + = ({ collectionUid, collectionPathname, brunoConfig }) => + (dispatch, getState) => { + dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'mounting' })); + return new Promise(async (resolve, reject) => { + callIpc('renderer:mount-collection', { collectionUid, collectionPathname, brunoConfig }) + .then(() => dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'mounted' }))) + .then(resolve) + .catch(() => { + dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'unmounted' })); + reject(); + }); + }); + }; export const showInFolder = (collectionPath) => () => { return new Promise((resolve, reject) => { @@ -2482,30 +2588,26 @@ export const updateActiveConnectionsInStore = (activeConnectionIds) => (dispatch export const openCollectionSettings = (collectionUid, tabName = 'overview') => - (dispatch, getState) => { - const state = getState(); - const collection = findCollectionByUid(state.collections.collections, collectionUid); + (dispatch, getState) => { + const state = getState(); + const collection = findCollectionByUid(state.collections.collections, collectionUid); - return new Promise((resolve, reject) => { - if (!collection) { - return reject(new Error('Collection not found')); - } + return new Promise((resolve, reject) => { + if (!collection) { + return reject(new Error('Collection not found')); + } - dispatch( - updateSettingsSelectedTab({ + dispatch(updateSettingsSelectedTab({ collectionUid: collection.uid, tab: tabName - }) - ); + })); - dispatch( - addTab({ + dispatch(addTab({ uid: collection.uid, collectionUid: collection.uid, type: 'collection-settings' - }) - ); + })); - resolve(); - }); - }; + resolve(); + }); + }; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/global-environments.js b/packages/bruno-app/src/providers/ReduxStore/slices/global-environments.js index 8e8effd1e..6c033e805 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/global-environments.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/global-environments.js @@ -87,19 +87,31 @@ export const { _deleteGlobalEnvironment } = globalEnvironmentsSlice.actions; -export const addGlobalEnvironment = ({ name, variables = [] }) => (dispatch) => { +const getWorkspaceContext = (state) => { + const workspaceUid = state.workspaces?.activeWorkspaceUid; + const workspace = state.workspaces?.workspaces?.find((w) => w.uid === workspaceUid); + return { workspaceUid, workspacePath: workspace?.pathname }; +}; + +export const addGlobalEnvironment = ({ name, variables = [] }) => (dispatch, getState) => { return new Promise((resolve, reject) => { const uid = uuid(); - let environment = { name, uid, variables }; + const environment = { name, uid, variables }; const { ipcRenderer } = window; + const state = getState(); + const { workspaceUid, workspacePath } = getWorkspaceContext(state); + environmentSchema .validate(environment) - .then(() => ipcRenderer.invoke('renderer:create-global-environment', { name, uid, variables })) + .then(() => ipcRenderer.invoke('renderer:create-global-environment', { name, uid, variables, workspaceUid, workspacePath })) .then((result) => { + const finalUid = result?.uid || uid; const finalName = result?.name || name; - dispatch(_addGlobalEnvironment({ name: finalName, uid, variables })); + const finalVariables = result?.variables || variables; + dispatch(_addGlobalEnvironment({ name: finalName, uid: finalUid, variables: finalVariables })); + return finalUid; }) - .then(() => dispatch(selectGlobalEnvironment({ environmentUid: uid }))) + .then((finalUid) => dispatch(selectGlobalEnvironment({ environmentUid: finalUid }))) .then(resolve) .catch(reject); }); @@ -108,17 +120,21 @@ export const addGlobalEnvironment = ({ name, variables = [] }) => (dispatch) => export const copyGlobalEnvironment = ({ name, environmentUid: baseEnvUid }) => (dispatch, getState) => { return new Promise((resolve, reject) => { const state = getState(); + const { workspaceUid, workspacePath } = getWorkspaceContext(state); const globalEnvironments = state.globalEnvironments.globalEnvironments; - const baseEnv = globalEnvironments?.find(env => env?.uid == baseEnvUid) + const baseEnv = globalEnvironments?.find((env) => env?.uid == baseEnvUid); const uid = uuid(); - let environment = { uid, name, variables: baseEnv.variables }; + const environment = { uid, name, variables: baseEnv.variables }; const { ipcRenderer } = window; + environmentSchema .validate(environment) - .then(() => ipcRenderer.invoke('renderer:create-global-environment', { uid, name, variables: baseEnv.variables })) + .then(() => ipcRenderer.invoke('renderer:create-global-environment', { uid, name, variables: baseEnv.variables, workspaceUid, workspacePath })) .then((result) => { + const finalUid = result?.uid || uid; const finalName = result?.name || name; - dispatch(_copyGlobalEnvironment({ name: finalName, uid, variables: baseEnv.variables })); + const finalVariables = result?.variables || baseEnv.variables; + dispatch(_copyGlobalEnvironment({ name: finalName, uid: finalUid, variables: finalVariables })); }) .then(resolve) .catch(reject); @@ -129,15 +145,27 @@ export const renameGlobalEnvironment = ({ name: newName, environmentUid }) => (d return new Promise((resolve, reject) => { const { ipcRenderer } = window; const state = getState(); + const { workspaceUid, workspacePath } = getWorkspaceContext(state); const globalEnvironments = state.globalEnvironments.globalEnvironments; - const environment = globalEnvironments?.find(env => env?.uid == environmentUid) + const environment = globalEnvironments?.find((env) => env?.uid == environmentUid); + if (!environment) { return reject(new Error('Environment not found')); } environmentSchema .validate(environment) - .then(() => ipcRenderer.invoke('renderer:rename-global-environment', { name: newName, environmentUid })) - .then(() => dispatch(_renameGlobalEnvironment({ name: newName, environmentUid }))) + .then(() => ipcRenderer.invoke('renderer:rename-global-environment', { name: newName, environmentUid, workspaceUid, workspacePath })) + .then((result) => { + const resolvedUid = result?.uid || environmentUid; + dispatch(_renameGlobalEnvironment({ name: newName, environmentUid: resolvedUid })); + return ipcRenderer + .invoke('renderer:get-global-environments', { workspaceUid, workspacePath }) + .then((data) => { + dispatch(updateGlobalEnvironments(data)); + return resolvedUid; + }); + }) + .then((resolvedUid) => dispatch(_selectGlobalEnvironment({ environmentUid: resolvedUid }))) .then(resolve) .catch(reject); }); @@ -146,35 +174,47 @@ export const renameGlobalEnvironment = ({ name: newName, environmentUid }) => (d export const saveGlobalEnvironment = ({ variables, environmentUid }) => (dispatch, getState) => { return new Promise((resolve, reject) => { const state = getState(); + const { workspaceUid, workspacePath } = getWorkspaceContext(state); const globalEnvironments = state.globalEnvironments.globalEnvironments; - const environment = globalEnvironments?.find(env => env?.uid == environmentUid); + let environment = globalEnvironments?.find((env) => env?.uid == environmentUid); + if (!environment) { + const activeUid = state.globalEnvironments?.activeGlobalEnvironmentUid; + const activeEnv = globalEnvironments?.find((env) => env?.uid == activeUid); + if (activeEnv) { + environment = activeEnv; + environmentUid = activeEnv.uid; + } + } if (!environment) { return reject(new Error('Environment not found')); } - let environmentToSave = { ...environment, variables }; - + const environmentToSave = { ...environment, variables }; const { ipcRenderer } = window; + environmentSchema .validate(environmentToSave) .then(() => ipcRenderer.invoke('renderer:save-global-environment', { environmentUid, - variables + variables, + workspaceUid, + workspacePath })) .then(() => dispatch(_saveGlobalEnvironment({ environmentUid, variables }))) .then(resolve) - .catch((error) => { - reject(error); - }); + .catch(reject); }); }; export const selectGlobalEnvironment = ({ environmentUid }) => (dispatch, getState) => { return new Promise((resolve, reject) => { const { ipcRenderer } = window; + const state = getState(); + const { workspaceUid, workspacePath } = getWorkspaceContext(state); + ipcRenderer - .invoke('renderer:select-global-environment', { environmentUid }) + .invoke('renderer:select-global-environment', { environmentUid, workspaceUid, workspacePath }) .then(() => dispatch(_selectGlobalEnvironment({ environmentUid }))) .then(resolve) .catch(reject); @@ -184,8 +224,11 @@ export const selectGlobalEnvironment = ({ environmentUid }) => (dispatch, getSta export const deleteGlobalEnvironment = ({ environmentUid }) => (dispatch, getState) => { return new Promise((resolve, reject) => { const { ipcRenderer } = window; + const state = getState(); + const { workspaceUid, workspacePath } = getWorkspaceContext(state); + ipcRenderer - .invoke('renderer:delete-global-environment', { environmentUid }) + .invoke('renderer:delete-global-environment', { environmentUid, workspaceUid, workspacePath }) .then(() => dispatch(_deleteGlobalEnvironment({ environmentUid }))) .then(resolve) .catch(reject); @@ -198,6 +241,7 @@ export const globalEnvironmentsUpdateEvent = ({ globalEnvironmentVariables }) => if (!globalEnvironmentVariables) resolve(); const state = getState(); + const { workspaceUid, workspacePath } = getWorkspaceContext(state); const globalEnvironments = state?.globalEnvironments?.globalEnvironments || []; const environmentUid = state?.globalEnvironments?.activeGlobalEnvironmentUid; const environment = globalEnvironments?.find(env => env?.uid == environmentUid); @@ -208,8 +252,6 @@ export const globalEnvironmentsUpdateEvent = ({ globalEnvironmentVariables }) => let variables = cloneDeep(environment?.variables); - // "globalEnvironmentVariables" will include only the enabled variables and newly added variables created using the script. - // Update the value of each variable if it's present in "globalEnvironmentVariables", otherwise keep the existing value. variables = variables?.map?.(variable => ({ ...variable, value: has(globalEnvironmentVariables, variable?.name) @@ -217,9 +259,8 @@ export const globalEnvironmentsUpdateEvent = ({ globalEnvironmentVariables }) => : variable?.value })); - // add new env values Object.entries(globalEnvironmentVariables)?.forEach?.(([key, value]) => { - let isAnExistingVariable = variables?.find(v => v?.name == key) + const isAnExistingVariable = variables?.find((v) => v?.name == key); if (!isAnExistingVariable) { variables.push({ uid: uuid(), @@ -232,21 +273,21 @@ export const globalEnvironmentsUpdateEvent = ({ globalEnvironmentVariables }) => } }); - let environmentToSave = { ...environment, variables }; + const environmentToSave = { ...environment, variables }; environmentSchema .validate(environmentToSave) .then(() => ipcRenderer.invoke('renderer:save-global-environment', { environmentUid, - variables + variables, + workspaceUid, + workspacePath })) .then(() => dispatch(_saveGlobalEnvironment({ environmentUid, variables }))) .then(resolve) - .catch((error) => { - reject(error); - }); + .catch(reject); }); -} +}; -export default globalEnvironmentsSlice.reducer; \ No newline at end of file +export default globalEnvironmentsSlice.reducer; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js new file mode 100644 index 000000000..121a07a8f --- /dev/null +++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js @@ -0,0 +1,646 @@ +import path from 'path'; +import { + createWorkspace, + removeWorkspace, + setActiveWorkspace, + updateWorkspace, + addCollectionToWorkspace, + removeCollectionFromWorkspace, + updateWorkspaceLoadingState +} from '../workspaces'; +import { showHomePage } from '../app'; +import { createCollection, openCollection, openMultipleCollections } from '../collections/actions'; +import { removeCollection } from '../collections'; +import { updateGlobalEnvironments } from '../global-environments'; +import toast from 'react-hot-toast'; + +const { ipcRenderer } = window; + +const transformCollection = async (collection, type) => { + switch (type) { + case 'bruno': { + const { processBrunoCollection } = await import('utils/importers/bruno-collection'); + return processBrunoCollection(collection); + } + case 'postman': { + const { postmanToBruno } = await import('utils/importers/postman-collection'); + return postmanToBruno(collection); + } + case 'insomnia': { + const { insomniaToBruno } = await import('utils/importers/insomnia-collection'); + return insomniaToBruno(collection); + } + case 'openapi': { + const { openapiToBruno } = await import('utils/importers/openapi-collection'); + return openapiToBruno(collection); + } + case 'wsdl': { + const { wsdlToBruno } = await import('utils/importers/wsdl-collection'); + return wsdlToBruno(collection); + } + default: + throw new Error(`Unsupported collection type: ${type}`); + } +}; + +export const createWorkspaceAction = (workspaceName, workspaceFolderName, workspaceLocation) => { + return async (dispatch) => { + try { + const result = await ipcRenderer.invoke('renderer:create-workspace', + workspaceName, + workspaceFolderName, + workspaceLocation); + + const { workspaceConfig, workspaceUid, workspacePath } = result; + + dispatch(createWorkspace({ + uid: workspaceUid, + name: workspaceName, + pathname: workspacePath, + ...workspaceConfig + })); + + await dispatch(switchWorkspace(workspaceUid)); + + return result; + } catch (error) { + throw error; + } + }; +}; + +export const openWorkspace = () => { + return async (dispatch) => { + try { + const workspacePath = await ipcRenderer.invoke('renderer:browse-directory'); + if (workspacePath) { + const result = await ipcRenderer.invoke('renderer:open-workspace', workspacePath); + const { workspaceConfig, workspaceUid } = result; + + dispatch(createWorkspace({ + uid: workspaceUid, + pathname: workspacePath, + ...workspaceConfig + })); + + await dispatch(switchWorkspace(workspaceUid)); + + return result; + } + } catch (error) { + throw error; + } + }; +}; + +export const openWorkspaceDialog = () => { + return async (dispatch) => { + try { + const result = await ipcRenderer.invoke('renderer:open-workspace-dialog'); + if (result) { + const { workspaceConfig, workspaceUid } = result; + + dispatch(createWorkspace({ + uid: workspaceUid, + pathname: result.workspacePath, + ...workspaceConfig + })); + + await dispatch(switchWorkspace(workspaceUid)); + + return result; + } + } catch (error) { + throw error; + } + }; +}; + +export const removeCollectionFromWorkspaceAction = (workspaceUid, collectionPath) => { + return async (dispatch, getState) => { + try { + const workspacesState = getState().workspaces; + const collectionsState = getState().collections; + const workspace = workspacesState.workspaces.find((w) => w.uid === workspaceUid); + + if (!workspace) { + throw new Error('Workspace not found'); + } + + const collection = collectionsState.collections.find((c) => c.pathname === collectionPath); + + await ipcRenderer.invoke('renderer:remove-collection-from-workspace', + workspaceUid, + workspace.pathname, + collectionPath); + + if (collection) { + const workspaceCollection = workspace.collections?.find((wc) => + wc.path === collectionPath); + + if (workspaceCollection) { + dispatch(removeCollection({ collectionUid: collection.uid })); + } + } + + dispatch(removeCollectionFromWorkspace({ + workspaceUid, + collectionLocation: collectionPath + })); + + return true; + } catch (error) { + throw error; + } + }; +}; + +const loadWorkspaceCollectionsForSwitch = async (dispatch, workspace) => { + const openCollectionsFunction = (collectionPaths, workspaceId) => { + return dispatch(openMultipleCollections(collectionPaths, { workspaceId })); + }; + + try { + const workspaceCollections = await dispatch(loadWorkspaceCollections(workspace.uid)); + const updatedWorkspace = await dispatch((_, getState) => getState().workspaces.workspaces.find((w) => w.uid === workspace.uid)); + + if (updatedWorkspace?.collections?.length > 0) { + const alreadyOpenCollections = await dispatch((_, getState) => getState().collections.collections.map((c) => c.pathname)); + + const collectionPaths = updatedWorkspace.collections + .map((wc) => wc.path) + .filter((p) => p && !alreadyOpenCollections.includes(p)); + + if (collectionPaths.length > 0) { + await openCollectionsFunction(collectionPaths, updatedWorkspace.pathname); + } + } + } catch (error) { + console.error('Failed to load workspace collections:', error); + } +}; + +export const switchWorkspace = (workspaceUid) => { + return async (dispatch, getState) => { + dispatch(setActiveWorkspace(workspaceUid)); + + const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid); + + if (!workspace) { + return; + } + + try { + const { ipcRenderer } = window; + + const result = await ipcRenderer.invoke('renderer:get-global-environments', + { + workspaceUid, + workspacePath: workspace.pathname + }); + + const globalEnvironments = result?.globalEnvironments || []; + const activeGlobalEnvironmentUid = result?.activeGlobalEnvironmentUid || null; + + dispatch(updateGlobalEnvironments({ globalEnvironments, activeGlobalEnvironmentUid })); + } catch (error) { + dispatch(updateGlobalEnvironments({ globalEnvironments: [], activeGlobalEnvironmentUid: null })); + } + + await loadWorkspaceCollectionsForSwitch(dispatch, workspace); + dispatch(showHomePage()); + }; +}; + +export const loadWorkspaceCollections = (workspaceUid, force = false) => { + return async (dispatch, getState) => { + try { + const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid); + if (!workspace) { + throw new Error('Workspace not found'); + } + + const hasProcessedCollections = workspace.collections + && workspace.collections.length > 0 + && workspace.collections.some((c) => c.path && c.path.startsWith('/')); + + if (!force && hasProcessedCollections) { + return workspace.collections; + } + + dispatch(updateWorkspaceLoadingState({ workspaceUid, loadingState: 'loading' })); + + let collections = []; + + if (!workspace.pathname) { + collections = []; + } else { + const rawCollections = await ipcRenderer.invoke('renderer:load-workspace-collections', workspace.pathname); + + collections = rawCollections.map((collection) => { + return { + ...collection + }; + }); + } + + dispatch(updateWorkspace({ + uid: workspaceUid, + collections + })); + + dispatch(updateWorkspaceLoadingState({ workspaceUid, loadingState: 'loaded' })); + + return collections; + } catch (error) { + dispatch(updateWorkspaceLoadingState({ workspaceUid, loadingState: 'error' })); + throw error; + } + }; +}; + +export const removeWorkspaceAction = (workspaceUid) => { + return (dispatch) => { + dispatch(removeWorkspace(workspaceUid)); + }; +}; + +export const loadLastOpenedWorkspaces = () => { + return async (dispatch, getState) => { + try { + const workspaces = await ipcRenderer.invoke('renderer:get-last-opened-workspaces'); + const currentWorkspaces = getState().workspaces.workspaces; + const validWorkspaceUids = new Set(workspaces.map((w) => w.uid)); + + for (const currentWorkspace of currentWorkspaces) { + if (currentWorkspace.type !== 'default' && !validWorkspaceUids.has(currentWorkspace.uid)) { + dispatch(removeWorkspace(currentWorkspace.uid)); + } + } + + for (const workspace of workspaces) { + const existingWorkspace = currentWorkspaces.find((w) => w.uid === workspace.uid); + + if (!existingWorkspace) { + dispatch(createWorkspace(workspace)); + + if (workspace.pathname) { + try { + await ipcRenderer.invoke('renderer:start-workspace-watcher', workspace.pathname); + } catch (error) { + } + } + } + } + + return workspaces; + } catch (error) { + throw error; + } + }; +}; + +export const workspaceOpenedEvent = (workspacePath, workspaceUid, workspaceConfig) => { + return async (dispatch) => { + dispatch(createWorkspace({ + uid: workspaceUid, + pathname: workspacePath, + ...workspaceConfig + })); + + try { + await dispatch(loadWorkspaceCollections(workspaceUid)); + } catch (error) { + } + }; +}; + +export const workspaceConfigUpdatedEvent = (workspacePath, workspaceUid, workspaceConfig) => { + return async (dispatch, getState) => { + if (!workspaceConfig) { + return; + } + + const { collections, ...configWithoutCollections } = workspaceConfig; + + dispatch(updateWorkspace({ + uid: workspaceUid, + ...configWithoutCollections + })); + + const activeWorkspaceUid = getState().workspaces.activeWorkspaceUid; + if (activeWorkspaceUid === workspaceUid) { + try { + await dispatch(loadWorkspaceCollections(workspaceUid, true)); + + const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid); + const openCollections = getState().collections.collections.map((c) => c.pathname); + + if (workspace?.collections?.length > 0) { + const newCollectionPaths = workspace.collections + .map((workspaceCollection) => workspaceCollection.path) + .filter((collectionPath) => collectionPath && !openCollections.includes(collectionPath)); + + if (newCollectionPaths.length > 0) { + try { + await dispatch(openMultipleCollections(newCollectionPaths, { workspaceId: workspace.pathname })); + } catch (error) { + } + } + } + } catch (error) { + } + } + }; +}; + +export const saveWorkspaceDocs = (workspaceUid, docs) => { + return async (dispatch, getState) => { + try { + const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid); + if (!workspace) { + throw new Error('Workspace not found'); + } + + if (workspace.type === 'default' || !workspace.pathname) { + await ipcRenderer.invoke('renderer:save-preferences', { + defaultWorkspaceDocs: docs || '' + }); + } else { + await ipcRenderer.invoke('renderer:save-workspace-docs', workspace.pathname, docs || ''); + } + + dispatch(updateWorkspace({ + uid: workspaceUid, + docs: docs + })); + + return docs; + } catch (error) { + throw error; + } + }; +}; + +export const createCollectionInWorkspace = (collectionName, collectionFolderName, collectionLocation, workspaceUid) => { + return async (dispatch, getState) => { + const currentWorkspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid); + if (!currentWorkspace) { + throw new Error('Workspace not found'); + } + + const projectCollectionLocation = `${currentWorkspace.pathname}/collections`; + + return await dispatch(createCollection(collectionName, collectionFolderName, projectCollectionLocation, { + workspaceId: currentWorkspace.pathname + })); + }; +}; + +export const openCollectionInWorkspace = () => { + return (dispatch) => dispatch(openCollection()); +}; + +const handleWorkspaceAction = async (action, workspaceUid, ...args) => { + try { + await action(workspaceUid, ...args); + return true; + } catch (error) { + const actionName = action.name.replace('renderer:', '').replace('-', ' '); + toast.error(error.message || `Failed to ${actionName} workspace`); + throw error; + } +}; + +export const renameWorkspaceAction = (workspaceUid, newName) => { + return async (dispatch, getState) => { + try { + const { workspaces } = getState().workspaces; + const workspace = workspaces.find((w) => w.uid === workspaceUid); + + if (!workspace) { + throw new Error('Workspace not found'); + } + + await handleWorkspaceAction((...args) => ipcRenderer.invoke('renderer:rename-workspace', ...args), + workspace.pathname, + newName); + + dispatch(updateWorkspace({ + uid: workspaceUid, + name: newName + })); + + toast.success('Workspace renamed successfully'); + } catch (error) { + throw error; + } + }; +}; + +export const closeWorkspaceAction = (workspaceUid) => { + return async (dispatch, getState) => { + try { + const { workspaces } = getState().workspaces; + const workspace = workspaces.find((w) => w.uid === workspaceUid); + + if (!workspace) { + throw new Error('Workspace not found'); + } + + await ipcRenderer.invoke('renderer:close-workspace', workspace.pathname); + dispatch(removeWorkspace(workspaceUid)); + + toast.success('Workspace closed successfully'); + } catch (error) { + toast.error(error.message || 'Failed to close workspace'); + throw error; + } + }; +}; + +export const importCollectionInWorkspace = (collection, workspaceUid, collectionLocation, type) => { + return async (dispatch, getState) => { + const currentWorkspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid); + + if (!currentWorkspace) { + throw new Error('Workspace not found'); + } + + const location = collectionLocation || path.join(currentWorkspace.pathname, 'collections'); + const transformedCollection = await transformCollection(collection, type); + const collectionPath = await ipcRenderer.invoke('renderer:import-collection', transformedCollection, location); + + const workspaceCollection = { + name: transformedCollection.name, + path: collectionPath + }; + + await ipcRenderer.invoke('renderer:add-collection-to-workspace', currentWorkspace.pathname, workspaceCollection); + + dispatch(addCollectionToWorkspace({ + workspaceUid, + collection: workspaceCollection + })); + + return collectionPath; + }; +}; + +export const loadWorkspaceEnvironments = (workspaceUid) => { + return async (dispatch, getState) => { + try { + const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid); + if (!workspace) { + throw new Error('Workspace not found'); + } + + const environments = await ipcRenderer.invoke('renderer:load-workspace-environments', workspace.pathname); + + dispatch(updateWorkspace({ + uid: workspaceUid, + environments: environments + })); + + return environments; + } catch (error) { + throw error; + } + }; +}; + +export const createWorkspaceEnvironment = (workspaceUid, environmentName) => { + return async (dispatch, getState) => { + try { + const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid); + if (!workspace) { + throw new Error('Workspace not found'); + } + + const environment = await ipcRenderer.invoke('renderer:create-workspace-environment', workspace.pathname, environmentName); + + await dispatch(loadWorkspaceEnvironments(workspaceUid)); + + return environment; + } catch (error) { + throw error; + } + }; +}; + +export const deleteWorkspaceEnvironment = (workspaceUid, environmentUid) => { + return async (dispatch, getState) => { + try { + const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid); + if (!workspace) { + throw new Error('Workspace not found'); + } + + await ipcRenderer.invoke('renderer:delete-workspace-environment', workspace.pathname, environmentUid); + + await dispatch(loadWorkspaceEnvironments(workspaceUid)); + + return true; + } catch (error) { + throw error; + } + }; +}; + +export const selectWorkspaceEnvironment = (workspaceUid, environmentUid) => { + return async (dispatch, getState) => { + try { + const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid); + if (!workspace) { + throw new Error('Workspace not found'); + } + + await ipcRenderer.invoke('renderer:select-workspace-environment', workspace.pathname, environmentUid); + + dispatch(updateWorkspace({ + uid: workspaceUid, + activeEnvironmentUid: environmentUid + })); + + return true; + } catch (error) { + throw error; + } + }; +}; + +export const importWorkspaceEnvironment = (workspaceUid, environmentData) => { + return async (dispatch, getState) => { + try { + const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid); + if (!workspace) { + throw new Error('Workspace not found'); + } + + const environment = await ipcRenderer.invoke('renderer:import-workspace-environment', workspace.pathname, environmentData); + + await dispatch(loadWorkspaceEnvironments(workspaceUid)); + + return environment; + } catch (error) { + throw error; + } + }; +}; + +export const updateWorkspaceEnvironment = (workspaceUid, environmentUid, environmentData) => { + return async (dispatch, getState) => { + try { + const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid); + if (!workspace) { + throw new Error('Workspace not found'); + } + + await ipcRenderer.invoke('renderer:update-workspace-environment', workspace.pathname, environmentUid, environmentData); + + await dispatch(loadWorkspaceEnvironments(workspaceUid)); + + return true; + } catch (error) { + throw error; + } + }; +}; + +export const renameWorkspaceEnvironment = (workspaceUid, environmentUid, newName) => { + return async (dispatch, getState) => { + try { + const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid); + if (!workspace) { + throw new Error('Workspace not found'); + } + + await ipcRenderer.invoke('renderer:rename-workspace-environment', workspace.pathname, environmentUid, newName); + + await dispatch(loadWorkspaceEnvironments(workspaceUid)); + + return true; + } catch (error) { + throw error; + } + }; +}; + +export const copyWorkspaceEnvironment = (workspaceUid, environmentUid, newName) => { + return async (dispatch, getState) => { + try { + const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid); + if (!workspace) { + throw new Error('Workspace not found'); + } + + const newEnvironment = await ipcRenderer.invoke('renderer:copy-workspace-environment', workspace.pathname, environmentUid, newName); + + await dispatch(loadWorkspaceEnvironments(workspaceUid)); + + return newEnvironment; + } catch (error) { + throw error; + } + }; +}; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/index.js new file mode 100644 index 000000000..340776f6b --- /dev/null +++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/index.js @@ -0,0 +1,90 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const DEFAULT_WORKSPACE_UID = 'default'; + +const initialState = { + workspaces: [], + activeWorkspaceUid: DEFAULT_WORKSPACE_UID +}; + +export const workspacesSlice = createSlice({ + name: 'workspaces', + initialState, + reducers: { + setActiveWorkspace: (state, action) => { + state.activeWorkspaceUid = action.payload; + }, + + createWorkspace: (state, action) => { + const workspace = action.payload; + workspace.collections = workspace.collections || []; + + const existingWorkspace = state.workspaces.find((w) => w.uid === workspace.uid); + if (!existingWorkspace) { + state.workspaces.push(workspace); + } else { + Object.assign(existingWorkspace, workspace); + } + }, + + removeWorkspace: (state, action) => { + const workspaceUid = action.payload; + state.workspaces = state.workspaces.filter((w) => w.uid !== workspaceUid); + + if (state.activeWorkspaceUid === workspaceUid) { + state.activeWorkspaceUid = DEFAULT_WORKSPACE_UID; + } + }, + + updateWorkspace: (state, action) => { + const { uid, ...updates } = action.payload; + const workspace = state.workspaces.find((w) => w.uid === uid); + if (workspace) { + Object.assign(workspace, updates); + } + }, + + addCollectionToWorkspace: (state, action) => { + const { workspaceUid, collection } = action.payload; + const workspace = state.workspaces.find((w) => w.uid === workspaceUid); + if (workspace) { + workspace.collections = workspace.collections || []; + const existingCollection = workspace.collections.find((c) => + c.uid === collection.uid || c.path === collection.path); + if (!existingCollection) { + workspace.collections.push(collection); + } + } + }, + + removeCollectionFromWorkspace: (state, action) => { + const { workspaceUid, collectionLocation } = action.payload; + const workspace = state.workspaces.find((w) => w.uid === workspaceUid); + if (workspace?.collections) { + // Filter by both path and location since path could be relative or absolute + workspace.collections = workspace.collections.filter((c) => + c.path !== collectionLocation && c.location !== collectionLocation); + } + }, + + updateWorkspaceLoadingState: (state, action) => { + const { workspaceUid, loadingState } = action.payload; + const workspace = state.workspaces.find((w) => w.uid === workspaceUid); + if (workspace) { + workspace.loadingState = loadingState; + } + } + } +}); + +export const { + setActiveWorkspace, + createWorkspace, + removeWorkspace, + updateWorkspace, + addCollectionToWorkspace, + removeCollectionFromWorkspace, + updateWorkspaceLoadingState +} = workspacesSlice.actions; + +export default workspacesSlice.reducer; diff --git a/packages/bruno-app/src/themes/dark.js b/packages/bruno-app/src/themes/dark.js index a1a4dbfe7..19f35d2b2 100644 --- a/packages/bruno-app/src/themes/dark.js +++ b/packages/bruno-app/src/themes/dark.js @@ -148,6 +148,39 @@ const darkTheme = { headingText: '#FFFFFF' }, + listItem: { + hoverBg: '#2A2D2F', + activeBg: '#3D3D3D' + }, + + workspace: { + accent: '#F59E0B', + border: '#444', + borderMuted: '#585858', + card: { + bg: '#2A2D2F' + }, + button: { + bg: '#242424' + }, + collection: { + header: { + indentBorder: 'solid 1px #444444' + }, + item: { + indentBorder: 'solid 1px #313131' + } + }, + environments: { + bg: '#212121', + indentBorder: 'solid 1px #313131', + activeBg: '#37373c', + search: { + bg: '#3D3D3D' + } + } + }, + request: { methods: { get: '#8cd656', diff --git a/packages/bruno-app/src/themes/light.js b/packages/bruno-app/src/themes/light.js index a34e85d98..a70455899 100644 --- a/packages/bruno-app/src/themes/light.js +++ b/packages/bruno-app/src/themes/light.js @@ -151,6 +151,39 @@ const lightTheme = { headingText: '#343434' }, + listItem: { + hoverBg: '#e7e7e7', + activeBg: '#dcdcdc' + }, + + workspace: { + accent: '#D97706', + border: '#e7e7e7', + borderMuted: '#f3f3f3', + card: { + bg: '#fff' + }, + button: { + bg: '#f3f3f3' + }, + collection: { + header: { + indentBorder: 'solid 1px #efefef' + }, + item: { + indentBorder: 'solid 1px #f9f9f9' + } + }, + environments: { + bg: '#fbfbfb', + indentBorder: 'solid 1px #efefef', + activeBg: '#eeeeee', + search: { + bg: '#fff' + } + } + }, + request: { methods: { get: 'rgb(5, 150, 105)', diff --git a/packages/bruno-app/src/utils/workspaces/index.js b/packages/bruno-app/src/utils/workspaces/index.js new file mode 100644 index 000000000..545e12a88 --- /dev/null +++ b/packages/bruno-app/src/utils/workspaces/index.js @@ -0,0 +1,119 @@ +// Utility functions for workspace pinning and reordering + +export const sortWorkspaces = (workspaces, preferences) => { + const pinnedUids = preferences?.workspaces?.pinnedWorkspaceUids || []; + const pinnedOrder = preferences?.workspaces?.pinnedOrder || []; + const unpinnedOrder = preferences?.workspaces?.unpinnedOrder || []; + + const defaultWs = workspaces.find((w) => w.type === 'default'); + const pinnedWs = workspaces.filter((w) => w.type !== 'default' && pinnedUids.includes(w.uid)); + const unpinnedWs = workspaces.filter((w) => w.type !== 'default' && !pinnedUids.includes(w.uid)); + + const sortedPinned = [...pinnedWs].sort((a, b) => { + const aIndex = pinnedOrder.indexOf(a.uid); + const bIndex = pinnedOrder.indexOf(b.uid); + + if (aIndex !== -1 && bIndex !== -1) { + return aIndex - bIndex; + } + if (aIndex !== -1) return -1; + if (bIndex !== -1) return 1; + + return (a.name || '').localeCompare(b.name || ''); + }); + + const sortedUnpinned = [...unpinnedWs].sort((a, b) => { + const aIndex = unpinnedOrder.indexOf(a.uid); + const bIndex = unpinnedOrder.indexOf(b.uid); + + if (aIndex !== -1 && bIndex !== -1) { + return aIndex - bIndex; + } + if (aIndex !== -1) return -1; + if (bIndex !== -1) return 1; + + return (a.name || '').localeCompare(b.name || ''); + }); + + // Combine: default -> pinned -> unpinned + return [ + ...(defaultWs ? [defaultWs] : []), + ...sortedPinned, + ...sortedUnpinned + ]; +}; + +export const toggleWorkspacePin = (workspaceUid, preferences) => { + const pinnedUids = preferences?.workspaces?.pinnedWorkspaceUids || []; + const pinnedOrder = preferences?.workspaces?.pinnedOrder || []; + const unpinnedOrder = preferences?.workspaces?.unpinnedOrder || []; + + const isPinned = pinnedUids.includes(workspaceUid); + + if (isPinned) { + return { + ...preferences, + workspaces: { + ...preferences.workspaces, + pinnedWorkspaceUids: pinnedUids.filter((uid) => uid !== workspaceUid), + pinnedOrder: pinnedOrder.filter((uid) => uid !== workspaceUid), + unpinnedOrder: [...unpinnedOrder, workspaceUid] + } + }; + } else { + return { + ...preferences, + workspaces: { + ...(preferences?.workspaces || {}), + pinnedWorkspaceUids: [...pinnedUids, workspaceUid], + pinnedOrder: [...pinnedOrder, workspaceUid], + unpinnedOrder: unpinnedOrder.filter((uid) => uid !== workspaceUid) + } + }; + } +}; + +export const reorderWorkspaces = (draggedUid, targetUid, dropPosition, preferences) => { + const pinnedUids = preferences?.workspaces?.pinnedWorkspaceUids || []; + const pinnedOrder = preferences?.workspaces?.pinnedOrder || []; + const unpinnedOrder = preferences?.workspaces?.unpinnedOrder || []; + + const isDraggedPinned = pinnedUids.includes(draggedUid); + const isTargetPinned = pinnedUids.includes(targetUid); + + if (isDraggedPinned !== isTargetPinned) { + return preferences; + } + + const orderArray = isDraggedPinned ? [...pinnedOrder] : [...unpinnedOrder]; + + const filteredOrder = orderArray.filter((uid) => uid !== draggedUid); + + let targetIndex = filteredOrder.indexOf(targetUid); + + if (targetIndex === -1) { + filteredOrder.push(targetUid); + targetIndex = filteredOrder.length - 1; + } + + const insertIndex = dropPosition === 'after' ? targetIndex + 1 : targetIndex; + filteredOrder.splice(insertIndex, 0, draggedUid); + + if (isDraggedPinned) { + return { + ...preferences, + workspaces: { + ...(preferences?.workspaces || {}), + pinnedOrder: filteredOrder + } + }; + } else { + return { + ...preferences, + workspaces: { + ...(preferences?.workspaces || {}), + unpinnedOrder: filteredOrder + } + }; + } +}; diff --git a/packages/bruno-electron/src/app/collections.js b/packages/bruno-electron/src/app/collections.js index 1fdbbed16..f86d2cece 100644 --- a/packages/bruno-electron/src/app/collections.js +++ b/packages/bruno-electron/src/app/collections.js @@ -2,7 +2,7 @@ const fs = require('fs'); const path = require('path'); const { dialog, ipcMain } = require('electron'); const Yup = require('yup'); -const { isDirectory, getCollectionStats } = require('../utils/filesystem'); +const { isDirectory, getCollectionStats, normalizeAndResolvePath } = require('../utils/filesystem'); const { generateUidBasedOnHash } = require('../utils/common'); const { transformBrunoConfigAfterRead } = require('../utils/transfomBrunoConfig'); const { parseCollection } = require('@usebruno/filestore'); @@ -132,7 +132,21 @@ const openCollection = async (win, watcher, collectionPath, options = {}) => { } }; +const openCollectionsByPathname = async (win, watcher, collectionPaths, options = {}) => { + for (const collectionPath of collectionPaths) { + const resolvedPath = path.isAbsolute(collectionPath) + ? collectionPath + : normalizeAndResolvePath(collectionPath); + if (isDirectory(resolvedPath)) { + await openCollection(win, watcher, resolvedPath, options); + } else { + console.error(`Cannot open unknown folder: "${resolvedPath}"`); + } + } +}; + module.exports = { openCollection, - openCollectionDialog + openCollectionDialog, + openCollectionsByPathname }; diff --git a/packages/bruno-electron/src/app/onboarding.js b/packages/bruno-electron/src/app/onboarding.js index de7b575e2..b2de55e77 100644 --- a/packages/bruno-electron/src/app/onboarding.js +++ b/packages/bruno-electron/src/app/onboarding.js @@ -27,7 +27,7 @@ function getDefaultCollectionLocation() { /** * Import sample collection for new users */ -async function importSampleCollection(collectionLocation, mainWindow, lastOpenedCollections) { +async function importSampleCollection(collectionLocation, mainWindow) { // Handle both development and production paths const sampleCollectionPath = app.isPackaged ? path.join(process.resourcesPath, 'data', 'sample-collection.json') @@ -56,7 +56,6 @@ async function importSampleCollection(collectionLocation, mainWindow, lastOpened collectionToImport, collectionLocation, mainWindow, - lastOpenedCollections, collectionName ); @@ -80,14 +79,15 @@ async function onboardUser(mainWindow, lastOpenedCollections) { // Check if user already has collections (indicates they're an existing user) // Onboarding was added in a later version, so for existing users we should skip it // to avoid creating sample collections - const collections = await lastOpenedCollections.getAll(); + // lastOpenedCollections is still used here to check for existing collections during migration + const collections = lastOpenedCollections ? lastOpenedCollections.getAll() : []; if (collections.length > 0) { await preferencesUtil.markAsLaunched(); return; } const collectionLocation = getDefaultCollectionLocation(); - await importSampleCollection(collectionLocation, mainWindow, lastOpenedCollections); + await importSampleCollection(collectionLocation, mainWindow); } await preferencesUtil.markAsLaunched(); diff --git a/packages/bruno-electron/src/app/workspace-watcher.js b/packages/bruno-electron/src/app/workspace-watcher.js new file mode 100644 index 000000000..de42adec7 --- /dev/null +++ b/packages/bruno-electron/src/app/workspace-watcher.js @@ -0,0 +1,235 @@ +const _ = require('lodash'); +const fs = require('fs'); +const path = require('path'); +const chokidar = require('chokidar'); +const yaml = require('js-yaml'); +const { generateUidBasedOnHash, uuid } = require('../utils/common'); +const { parseEnvironment } = require('@usebruno/filestore'); +const EnvironmentSecretsStore = require('../store/env-secrets'); +const { decryptStringSafe } = require('../utils/encryption'); + +const environmentSecretsStore = new EnvironmentSecretsStore(); + +/** + * Check if environment has secret variables + */ +const envHasSecrets = (environment) => { + const secrets = _.filter(environment.variables, (v) => v.secret === true); + return secrets && secrets.length > 0; +}; + +/** + * Handle workspace.yml file changes + */ +const handleWorkspaceFileChange = (win, workspacePath) => { + try { + const workspaceFilePath = path.join(workspacePath, 'workspace.yml'); + + if (!fs.existsSync(workspaceFilePath)) { + return; + } + + const yamlContent = fs.readFileSync(workspaceFilePath, 'utf8'); + const workspaceConfig = yaml.load(yamlContent); + + if (workspaceConfig.type !== 'workspace') { + return; + } + + const workspaceUid = generateUidBasedOnHash(workspacePath); + + win.webContents.send('main:workspace-config-updated', workspacePath, workspaceUid, workspaceConfig); + } catch (error) { + console.error('Error handling workspace file change:', error); + } +}; + +/** + * Parse global environment file and handle secrets + */ +const parseGlobalEnvironmentFile = async (pathname, workspacePath, workspaceUid) => { + const basename = path.basename(pathname); + const environmentName = basename.slice(0, -'.yml'.length); + + const file = { + meta: { + workspaceUid, + pathname, + name: basename + } + }; + + const content = fs.readFileSync(pathname, 'utf8'); + file.data = await parseEnvironment(content, { format: 'yml' }); + file.data.name = environmentName; + file.data.uid = generateUidBasedOnHash(pathname); + + // Ensure all variables have UIDs + _.each(_.get(file, 'data.variables', []), (variable) => { + if (!variable.uid) { + variable.uid = uuid(); + } + }); + + // Decrypt secrets if present + if (envHasSecrets(file.data)) { + const envSecrets = environmentSecretsStore.getEnvSecrets(workspacePath, file.data); + _.each(envSecrets, (secret) => { + const variable = _.find(file.data.variables, (v) => v.name === secret.name); + if (variable && secret.value) { + const decryptionResult = decryptStringSafe(secret.value); + variable.value = decryptionResult.value; + } + }); + } + + return file; +}; + +/** + * Handle global environment file add + */ +const handleGlobalEnvironmentFileAdd = async (win, pathname, workspacePath, workspaceUid) => { + try { + const file = await parseGlobalEnvironmentFile(pathname, workspacePath, workspaceUid); + win.webContents.send('main:global-environment-added', workspaceUid, file); + } catch (error) { + console.error('Error handling global environment file add:', error); + } +}; + +/** + * Handle global environment file change + */ +const handleGlobalEnvironmentFileChange = async (win, pathname, workspacePath, workspaceUid) => { + try { + const file = await parseGlobalEnvironmentFile(pathname, workspacePath, workspaceUid); + win.webContents.send('main:global-environment-changed', workspaceUid, file); + } catch (error) { + console.error('Error handling global environment file change:', error); + } +}; + +/** + * Handle global environment file unlink + */ +const handleGlobalEnvironmentFileUnlink = async (win, pathname, workspaceUid) => { + try { + const environmentUid = generateUidBasedOnHash(pathname); + win.webContents.send('main:global-environment-deleted', workspaceUid, environmentUid); + } catch (error) { + console.error('Error handling global environment file unlink:', error); + } +}; + +/** + * Workspace Watcher + * Watches workspace files for changes and notifies the renderer + */ +class WorkspaceWatcher { + constructor() { + this.watchers = {}; + this.environmentWatchers = {}; + } + + addWatcher(win, workspacePath) { + const workspaceFilePath = path.join(workspacePath, 'workspace.yml'); + const environmentsDir = path.join(workspacePath, 'environments'); + const workspaceUid = generateUidBasedOnHash(workspacePath); + + // Close existing watchers if any + if (this.watchers[workspacePath]) { + this.watchers[workspacePath].close(); + } + if (this.environmentWatchers[workspacePath]) { + this.environmentWatchers[workspacePath].close(); + } + + const self = this; + setTimeout(() => { + // Guard against window being destroyed during delay + if (win.isDestroyed()) { + return; + } + + // Watch workspace.yml file + const watcher = chokidar.watch(workspaceFilePath, { + ignoreInitial: false, + persistent: true, + ignorePermissionErrors: true, + awaitWriteFinish: { + stabilityThreshold: 80, + pollInterval: 10 + } + }); + + watcher.on('change', () => handleWorkspaceFileChange(win, workspacePath)); + watcher.on('add', () => handleWorkspaceFileChange(win, workspacePath)); + + self.watchers[workspacePath] = watcher; + + // Watch global environment files (.yml) + if (fs.existsSync(environmentsDir)) { + const envWatcher = chokidar.watch(path.join(environmentsDir, `*.yml`), { + ignoreInitial: true, + persistent: true, + ignorePermissionErrors: true, + awaitWriteFinish: { + stabilityThreshold: 100, + pollInterval: 10 + } + }); + + envWatcher.on('add', (pathname) => { + handleGlobalEnvironmentFileAdd(win, pathname, workspacePath, workspaceUid); + }); + + envWatcher.on('change', (pathname) => { + handleGlobalEnvironmentFileChange(win, pathname, workspacePath, workspaceUid); + }); + + envWatcher.on('unlink', (pathname) => { + handleGlobalEnvironmentFileUnlink(win, pathname, workspaceUid); + }); + + self.environmentWatchers[workspacePath] = envWatcher; + } else { + // Watch for environments directory creation + const dirWatcher = chokidar.watch(environmentsDir, { + ignoreInitial: false, + persistent: true, + ignorePermissionErrors: true, + depth: 0 + }); + + dirWatcher.on('addDir', () => { + dirWatcher.close(); + self.addWatcher(win, workspacePath); + }); + + self.environmentWatchers[workspacePath] = dirWatcher; + } + }, 100); + } + + removeWatcher(workspacePath) { + try { + if (this.watchers[workspacePath]) { + this.watchers[workspacePath].close(); + delete this.watchers[workspacePath]; + } + if (this.environmentWatchers[workspacePath]) { + this.environmentWatchers[workspacePath].close(); + delete this.environmentWatchers[workspacePath]; + } + } catch (error) { + console.error('Error removing workspace watcher:', error); + } + } + + hasWatcher(workspacePath) { + return Boolean(this.watchers[workspacePath]); + } +} + +module.exports = WorkspaceWatcher; diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js index ea20963eb..08995d726 100644 --- a/packages/bruno-electron/src/index.js +++ b/packages/bruno-electron/src/index.js @@ -38,8 +38,11 @@ const registerCollectionsIpc = require('./ipc/collection'); const registerFilesystemIpc = require('./ipc/filesystem'); const registerPreferencesIpc = require('./ipc/preferences'); const registerSystemMonitorIpc = require('./ipc/system-monitor'); +const registerWorkspaceIpc = require('./ipc/workspace'); const collectionWatcher = require('./app/collection-watcher'); +const WorkspaceWatcher = require('./app/workspace-watcher'); const { loadWindowState, saveBounds, saveMaximized } = require('./utils/window'); +const { globalEnvironmentsManager } = require('./store/workspace-environments'); const registerNotificationsIpc = require('./ipc/notifications'); const registerGlobalEnvironmentsIpc = require('./ipc/global-environments'); const { safeParseJSON, safeStringifyJSON } = require('./utils/common'); @@ -52,6 +55,8 @@ const { getIsRunningInRosetta } = require('./utils/arch'); const lastOpenedCollections = new LastOpenedCollections(); const systemMonitor = new SystemMonitor(); +const workspaceWatcher = new WorkspaceWatcher(); + // Reference: https://content-security-policy.com/ const contentSecurityPolicy = [ "default-src 'self'", @@ -212,9 +217,10 @@ app.on('ready', async () => { // register all ipc handlers registerNetworkIpc(mainWindow); - registerGlobalEnvironmentsIpc(mainWindow); - registerCollectionsIpc(mainWindow, collectionWatcher, lastOpenedCollections); - registerPreferencesIpc(mainWindow, collectionWatcher, lastOpenedCollections); + registerGlobalEnvironmentsIpc(mainWindow, globalEnvironmentsManager); + registerCollectionsIpc(mainWindow, collectionWatcher); + registerPreferencesIpc(mainWindow, collectionWatcher); + registerWorkspaceIpc(mainWindow, workspaceWatcher); registerNotificationsIpc(mainWindow, collectionWatcher); registerFilesystemIpc(mainWindow); registerSystemMonitorIpc(mainWindow, systemMonitor); diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 1d1ba13e7..b14017bcf 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -29,17 +29,11 @@ const { sanitizeName, isWSLPath, safeToRename, - getSubDirectories, isWindowsOS, - readDir, hasRequestExtension, getCollectionFormat, searchForRequestFiles, - normalizeAndResolvePath, validateName, - chooseFileToSave, - exists, - isFile, getCollectionStats, sizeInMB, safeWriteFileSync, @@ -48,7 +42,7 @@ const { getPaths, generateUniqueName } = require('../utils/filesystem'); -const { openCollectionDialog } = require('../app/collections'); +const { openCollectionDialog, openCollectionsByPathname } = require('../app/collections'); const { generateUidBasedOnHash, stringifyJson, safeStringifyJSON, safeParseJSON } = require('../utils/common'); const { moveRequestUid, deleteRequestUid } = require('../cache/requestUids'); const { deleteCookiesForDomain, getDomainsWithCookies, addCookieForDomain, modifyCookieForDomain, parseCookieString, createCookieString, deleteCookie } = require('../utils/cookies'); @@ -78,12 +72,8 @@ const envHasSecrets = (environment = {}) => { return secrets && secrets.length > 0; }; -const findCollectionPathByItemPath = (filePath, lastOpenedCollections) => { - const openCollectionPaths = collectionWatcher.getAllWatcherPaths(); - const lastOpenedPaths = lastOpenedCollections ? lastOpenedCollections.getAll() : []; - - // Combine both currently watched collections and last opened collections - const allCollectionPaths = [...new Set([...openCollectionPaths, ...lastOpenedPaths])]; +const findCollectionPathByItemPath = (filePath) => { + const allCollectionPaths = collectionWatcher.getAllWatcherPaths(); // Find the collection path that contains this file // Sort by length descending to find the most specific (deepest) match first @@ -98,20 +88,21 @@ const findCollectionPathByItemPath = (filePath, lastOpenedCollections) => { return null; }; -const validatePathIsInsideCollection = (filePath, lastOpenedCollections) => { - const collectionPath = findCollectionPathByItemPath(filePath, lastOpenedCollections); +const validatePathIsInsideCollection = (filePath) => { + const collectionPath = findCollectionPathByItemPath(filePath); if (!collectionPath) { throw new Error(`Path: ${filePath} should be inside a collection`); } } -const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollections) => { +const registerRendererEventHandlers = (mainWindow, watcher) => { // create collection ipcMain.handle( 'renderer:create-collection', - async (event, collectionName, collectionFolderName, collectionLocation, format = 'bru') => { + async (event, collectionName, collectionFolderName, collectionLocation, options = {}) => { try { + const format = options.format || 'bru'; collectionFolderName = sanitizeName(collectionFolderName); const dirPath = path.join(collectionLocation, collectionFolderName); if (fs.existsSync(dirPath)) { @@ -313,7 +304,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection throw new Error(`path: ${pathname} already exists`); } - const collectionPath = findCollectionPathByItemPath(pathname, lastOpenedCollections); + const collectionPath = findCollectionPathByItemPath(pathname); if (!collectionPath) { throw new Error('Collection not found for the given pathname'); } @@ -324,7 +315,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection if (!validateName(baseFilename)) { throw new Error(`${request.filename} is not a valid filename`); } - validatePathIsInsideCollection(pathname, lastOpenedCollections); + validatePathIsInsideCollection(pathname); const content = await stringifyRequestViaWorker(request, { format }); await writeFile(pathname, content); @@ -688,7 +679,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection const folderFileContent = await stringifyFolder(folderFileJsonContent, { format }); await writeFile(folderFilePath, folderFileContent); - + const requestFilesAtSource = await searchForRequestFiles(oldPath, collectionPathname); for (let requestFile of requestFilesAtSource) { @@ -803,31 +794,38 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection } }); - ipcMain.handle('renderer:open-collection', () => { + ipcMain.handle('renderer:open-collection', async () => { if (watcher && mainWindow) { - openCollectionDialog(mainWindow, watcher); + await openCollectionDialog(mainWindow, watcher); } }); - ipcMain.handle('renderer:remove-collection', async (event, collectionPath, collectionUid) => { + ipcMain.handle('renderer:open-multiple-collections', async (e, collectionPaths) => { if (watcher && mainWindow) { - console.log(`watcher stopWatching: ${collectionPath}`); - watcher.removeWatcher(collectionPath, mainWindow, collectionUid); - lastOpenedCollections.remove(collectionPath); + await openCollectionsByPathname(mainWindow, watcher, collectionPaths); + } + }); + + ipcMain.handle('renderer:remove-collection', async (event, collectionPath, collectionUid, workspacePath) => { + if (watcher && mainWindow) { + watcher.removeWatcher(collectionPath, mainWindow, collectionUid); - // If wsclient was initialised for any collections that are opened - // then close for the current collection if (wsClient) { wsClient.closeForCollection(collectionUid); } } + + if (workspacePath && workspacePath !== 'default') { + try { + const { removeCollectionFromWorkspace } = require('../utils/workspace-config'); + await removeCollectionFromWorkspace(workspacePath, collectionPath); + } catch (error) { + console.error('Error removing collection from workspace.yml:', error); + } + } }); - ipcMain.handle('renderer:update-collection-paths', async (_, collectionPaths) => { - lastOpenedCollections.update(collectionPaths); - }) - - ipcMain.handle('renderer:import-collection', async (event, collection, collectionLocation, format = 'bru') => { + ipcMain.handle('renderer:import-collection', async (_, collection, collectionLocation, format = 'bru') => { try { let collectionName = sanitizeName(collection.name); let collectionPath = path.join(collectionLocation, collectionName); @@ -837,8 +835,8 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection } // Recursive function to parse the collection items and create files/folders - const parseCollectionItems = (items = [], currentPath) => { - items.forEach(async (item) => { + const parseCollectionItems = async (items = [], currentPath) => { + await Promise.all(items.map(async (item) => { if (['http-request', 'graphql-request', 'grpc-request', 'ws-request'].includes(item.type)) { let sanitizedFilename = sanitizeName(item?.filename || `${item.name}.${format}`); const content = await stringifyRequestViaWorker(item, { format }); @@ -858,7 +856,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection } if (item.items && item.items.length) { - parseCollectionItems(item.items, folderPath); + await parseCollectionItems(item.items, folderPath); } } // Handle items of type 'js' @@ -867,21 +865,21 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection const filePath = path.join(currentPath, sanitizedFilename); safeWriteFileSync(filePath, item.fileContent); } - }); + })); }; - const parseEnvironments = (environments = [], collectionPath) => { + const parseEnvironments = async (environments = [], collectionPath) => { const envDirPath = path.join(collectionPath, 'environments'); if (!fs.existsSync(envDirPath)) { fs.mkdirSync(envDirPath); } - environments.forEach(async (env) => { + await Promise.all(environments.map(async (env) => { const content = await stringifyEnvironment(env, { format }); let sanitizedEnvFilename = sanitizeName(`${env.name}.${format}`); const filePath = path.join(envDirPath, sanitizedEnvFilename); safeWriteFileSync(filePath, content); - }); + })); }; const getBrunoJsonConfig = (collection) => { @@ -924,11 +922,11 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection mainWindow.webContents.send('main:collection-opened', collectionPath, uid, brunoConfig); ipcMain.emit('main:collection-opened', mainWindow, collectionPath, uid, brunoConfig); - lastOpenedCollections.add(collectionPath); - // create folder and files based on collection await parseCollectionItems(collection.items, collectionPath); await parseEnvironments(collection.environments, collectionPath); + + return collectionPath; } catch (error) { return Promise.reject(error); } @@ -1529,7 +1527,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection }); }; -const registerMainEventHandlers = (mainWindow, watcher, lastOpenedCollections) => { +const registerMainEventHandlers = (mainWindow, watcher) => { ipcMain.on('main:open-collection', () => { if (watcher && mainWindow) { openCollectionDialog(mainWindow, watcher); @@ -1542,7 +1540,6 @@ const registerMainEventHandlers = (mainWindow, watcher, lastOpenedCollections) = }); ipcMain.on('main:collection-opened', async (win, pathname, uid, brunoConfig) => { - lastOpenedCollections.add(pathname); app.addRecentDocument(pathname); }); @@ -1560,9 +1557,9 @@ const registerMainEventHandlers = (mainWindow, watcher, lastOpenedCollections) = }); }; -const registerCollectionsIpc = (mainWindow, watcher, lastOpenedCollections) => { - registerRendererEventHandlers(mainWindow, watcher, lastOpenedCollections); - registerMainEventHandlers(mainWindow, watcher, lastOpenedCollections); +const registerCollectionsIpc = (mainWindow, watcher) => { + registerRendererEventHandlers(mainWindow, watcher); + registerMainEventHandlers(mainWindow, watcher); }; module.exports = registerCollectionsIpc; diff --git a/packages/bruno-electron/src/ipc/global-environments.js b/packages/bruno-electron/src/ipc/global-environments.js index 40e6ccb7e..2432e283e 100644 --- a/packages/bruno-electron/src/ipc/global-environments.js +++ b/packages/bruno-electron/src/ipc/global-environments.js @@ -3,60 +3,102 @@ const { ipcMain } = require('electron'); const { globalEnvironmentsStore } = require('../store/global-environments'); const { generateUniqueName, sanitizeName } = require('../utils/filesystem'); -const registerGlobalEnvironmentsIpc = (mainWindow) => { - - // GLOBAL ENVIRONMENTS - - ipcMain.handle('renderer:create-global-environment', async (event, { uid, name, variables }) => { +const registerGlobalEnvironmentsIpc = (mainWindow, workspaceEnvironmentsManager) => { + ipcMain.handle('renderer:create-global-environment', async (event, { uid, name, variables, workspaceUid, workspacePath }) => { try { - // Get existing global environment names to generate unique name + // If workspace path provided, use workspace environments manager + if (workspacePath && workspaceEnvironmentsManager) { + const { globalEnvironments } = await workspaceEnvironmentsManager.getGlobalEnvironmentsByPath(workspacePath); + const existingNames = globalEnvironments?.map((env) => env.name) || []; + + const sanitizedName = sanitizeName(name); + const uniqueName = generateUniqueName(sanitizedName, (name) => existingNames.includes(name)); + + return await workspaceEnvironmentsManager.addGlobalEnvironmentByPath(workspacePath, { uid, name: uniqueName, variables }); + } + const existingGlobalEnvironments = globalEnvironmentsStore.getGlobalEnvironments(); const existingNames = existingGlobalEnvironments?.map((env) => env.name) || []; - // Generate unique name based on existing global environment names const sanitizedName = sanitizeName(name); const uniqueName = generateUniqueName(sanitizedName, (name) => existingNames.includes(name)); globalEnvironmentsStore.addGlobalEnvironment({ uid, name: uniqueName, variables }); - // Return the unique name that was actually used return { name: uniqueName }; } catch (error) { + console.error('Error in renderer:create-global-environment:', error); return Promise.reject(error); } }); - ipcMain.handle('renderer:save-global-environment', async (event, { environmentUid, variables }) => { + ipcMain.handle('renderer:save-global-environment', async (event, { environmentUid, variables, workspaceUid, workspacePath }) => { try { - globalEnvironmentsStore.saveGlobalEnvironment({ environmentUid, variables }) + if (workspacePath && workspaceEnvironmentsManager) { + return await workspaceEnvironmentsManager.saveGlobalEnvironmentByPath(workspacePath, { environmentUid, variables }); + } + + globalEnvironmentsStore.saveGlobalEnvironment({ environmentUid, variables }); } catch (error) { + console.error('Error in renderer:save-global-environment:', error); return Promise.reject(error); } }); - ipcMain.handle('renderer:rename-global-environment', async (event, { environmentUid, name }) => { + ipcMain.handle('renderer:rename-global-environment', async (event, { environmentUid, name, workspaceUid, workspacePath }) => { try { + if (workspacePath && workspaceEnvironmentsManager) { + return await workspaceEnvironmentsManager.renameGlobalEnvironmentByPath(workspacePath, { environmentUid, name }); + } + globalEnvironmentsStore.renameGlobalEnvironment({ environmentUid, name }); } catch (error) { + console.error('Error in renderer:rename-global-environment:', error); return Promise.reject(error); } }); - ipcMain.handle('renderer:delete-global-environment', async (event, { environmentUid }) => { + ipcMain.handle('renderer:delete-global-environment', async (event, { environmentUid, workspaceUid, workspacePath }) => { try { + if (workspacePath && workspaceEnvironmentsManager) { + return await workspaceEnvironmentsManager.deleteGlobalEnvironmentByPath(workspacePath, { environmentUid }); + } + globalEnvironmentsStore.deleteGlobalEnvironment({ environmentUid }); } catch (error) { + console.error('Error in renderer:delete-global-environment:', error); return Promise.reject(error); } }); - ipcMain.handle('renderer:select-global-environment', async (event, { environmentUid }) => { + ipcMain.handle('renderer:select-global-environment', async (event, { environmentUid, workspaceUid, workspacePath }) => { try { + if (workspacePath && workspaceEnvironmentsManager) { + return await workspaceEnvironmentsManager.selectGlobalEnvironmentByPath(workspacePath, { environmentUid }); + } + globalEnvironmentsStore.selectGlobalEnvironment({ environmentUid }); } catch (error) { + console.error('Error in renderer:select-global-environment:', error); + return Promise.reject(error); + } + }); + + ipcMain.handle('renderer:get-global-environments', async (event, { workspaceUid, workspacePath }) => { + try { + if (workspacePath && workspaceEnvironmentsManager) { + return await workspaceEnvironmentsManager.getGlobalEnvironmentsByPath(workspacePath); + } + + return { + globalEnvironments: globalEnvironmentsStore.getGlobalEnvironments() || [], + activeGlobalEnvironmentUid: globalEnvironmentsStore.getActiveGlobalEnvironmentUid() + }; + } catch (error) { + console.error('Error in renderer:get-global-environments:', error); return Promise.reject(error); } }); }; -module.exports = registerGlobalEnvironmentsIpc; \ No newline at end of file +module.exports = registerGlobalEnvironmentsIpc; diff --git a/packages/bruno-electron/src/ipc/preferences.js b/packages/bruno-electron/src/ipc/preferences.js index 3cf95c6f4..fc389bf7a 100644 --- a/packages/bruno-electron/src/ipc/preferences.js +++ b/packages/bruno-electron/src/ipc/preferences.js @@ -1,10 +1,8 @@ const { ipcMain } = require('electron'); const { getPreferences, savePreferences, preferencesUtil } = require('../store/preferences'); -const { isDirectory } = require('../utils/filesystem'); -const { openCollection } = require('../app/collections'); const { globalEnvironmentsStore } = require('../store/global-environments'); -``; -const registerPreferencesIpc = (mainWindow, watcher, lastOpenedCollections) => { + +const registerPreferencesIpc = (mainWindow, watcher) => { ipcMain.handle('renderer:ready', async (event) => { // load preferences const preferences = getPreferences(); @@ -26,19 +24,6 @@ const registerPreferencesIpc = (mainWindow, watcher, lastOpenedCollections) => { console.error("Error occured while fetching global environements!"); console.error(error); } - - // reload last opened collections - const lastOpened = lastOpenedCollections.getAll(); - - if (lastOpened && lastOpened.length) { - for (let collectionPath of lastOpened) { - if (isDirectory(collectionPath)) { - await openCollection(mainWindow, watcher, collectionPath, { - dontSendDisplayErrors: true - }); - } - } - } }); ipcMain.on('main:open-preferences', () => { diff --git a/packages/bruno-electron/src/ipc/workspace.js b/packages/bruno-electron/src/ipc/workspace.js new file mode 100644 index 000000000..3f37142ef --- /dev/null +++ b/packages/bruno-electron/src/ipc/workspace.js @@ -0,0 +1,434 @@ +const fs = require('fs'); +const path = require('path'); +const { ipcMain, dialog } = require('electron'); +const { createDirectory, sanitizeName } = require('../utils/filesystem'); +const { generateUidBasedOnHash } = require('../utils/common'); +const yaml = require('js-yaml'); +const LastOpenedWorkspaces = require('../store/last-opened-workspaces'); +const { defaultWorkspaceManager } = require('../store/default-workspace'); +const { globalEnvironmentsManager } = require('../store/workspace-environments'); + +// Workspace configuration module (includes path and validation utilities) +const { + createWorkspaceConfig, + readWorkspaceConfig, + writeWorkspaceConfig, + validateWorkspaceConfig, + updateWorkspaceName, + updateWorkspaceDocs, + addCollectionToWorkspace, + removeCollectionFromWorkspace, + getWorkspaceCollections, + normalizeCollectionEntry, + validateWorkspacePath, + validateWorkspaceDirectory +} = require('../utils/workspace-config'); + +const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => { + const lastOpenedWorkspaces = new LastOpenedWorkspaces(); + + ipcMain.handle('renderer:create-workspace', + async (event, workspaceName, workspaceFolderName, workspaceLocation) => { + try { + workspaceFolderName = sanitizeName(workspaceFolderName); + const dirPath = path.join(workspaceLocation, workspaceFolderName); + + if (fs.existsSync(dirPath)) { + const files = fs.readdirSync(dirPath); + if (files.length > 0) { + throw new Error(`workspace: ${dirPath} already exists and is not empty`); + } + } + + validateWorkspaceDirectory(dirPath); + + if (!fs.existsSync(dirPath)) { + await createDirectory(dirPath); + } + + await createDirectory(path.join(dirPath, 'collections')); + + const workspaceUid = generateUidBasedOnHash(dirPath); + const workspaceConfig = createWorkspaceConfig(workspaceName); + + await writeWorkspaceConfig(dirPath, workspaceConfig); + + lastOpenedWorkspaces.add(dirPath, workspaceConfig); + + mainWindow.webContents.send('main:workspace-opened', dirPath, workspaceUid, workspaceConfig); + + if (workspaceWatcher) { + workspaceWatcher.addWatcher(mainWindow, dirPath); + } + + return { + workspaceConfig, + workspaceUid, + workspacePath: dirPath + }; + } catch (error) { + throw error; + } + }); + + ipcMain.handle('renderer:open-workspace', async (event, workspacePath) => { + try { + validateWorkspacePath(workspacePath); + + const workspaceConfig = readWorkspaceConfig(workspacePath); + validateWorkspaceConfig(workspaceConfig); + + const workspaceUid = generateUidBasedOnHash(workspacePath); + + lastOpenedWorkspaces.add(workspacePath, workspaceConfig); + + mainWindow.webContents.send('main:workspace-opened', workspacePath, workspaceUid, workspaceConfig); + + if (workspaceWatcher) { + workspaceWatcher.addWatcher(mainWindow, workspacePath); + } + + return { + workspaceConfig, + workspaceUid, + workspacePath + }; + } catch (error) { + throw error; + } + }); + + ipcMain.handle('renderer:open-workspace-dialog', async (event) => { + try { + const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, { + properties: ['openDirectory'], + title: 'Open Workspace', + buttonLabel: 'Open Workspace' + }); + + if (canceled || filePaths.length === 0) { + return null; + } + + const workspacePath = filePaths[0]; + validateWorkspacePath(workspacePath); + + const workspaceConfig = readWorkspaceConfig(workspacePath); + const workspaceUid = generateUidBasedOnHash(workspacePath); + + lastOpenedWorkspaces.add(workspacePath, workspaceConfig); + + mainWindow.webContents.send('main:workspace-opened', workspacePath, workspaceUid, workspaceConfig); + + if (workspaceWatcher) { + workspaceWatcher.addWatcher(mainWindow, workspacePath); + } + + return { + workspaceConfig, + workspaceUid, + workspacePath + }; + } catch (error) { + throw error; + } + }); + + ipcMain.handle('renderer:load-workspace-collections', async (event, workspacePath) => { + try { + if (!workspacePath) { + throw new Error('Workspace path is undefined'); + } + + validateWorkspacePath(workspacePath); + return getWorkspaceCollections(workspacePath); + } catch (error) { + throw error; + } + }); + + ipcMain.handle('renderer:get-last-opened-workspaces', async () => { + try { + const workspaces = lastOpenedWorkspaces.getAll(); + const validWorkspaces = []; + const invalidWorkspaceUids = []; + + // Check each workspace to see if workspace.yml still exists + for (const workspace of workspaces) { + if (workspace.pathname) { + const workspaceYmlPath = path.join(workspace.pathname, 'workspace.yml'); + + if (fs.existsSync(workspaceYmlPath)) { + validWorkspaces.push(workspace); + } else { + invalidWorkspaceUids.push(workspace.uid); + } + } else { + invalidWorkspaceUids.push(workspace.uid); + } + } + + // Remove invalid workspaces from preferences + if (invalidWorkspaceUids.length > 0) { + for (const uid of invalidWorkspaceUids) { + lastOpenedWorkspaces.remove(uid); + } + } + + return validWorkspaces; + } catch (error) { + throw error; + } + }); + + ipcMain.handle('renderer:rename-workspace', async (event, workspacePath, newName) => { + try { + await updateWorkspaceName(workspacePath, newName); + + // Update in last opened workspaces + const workspaces = lastOpenedWorkspaces.getAll(); + const workspaceIndex = workspaces.findIndex((w) => w.pathname === workspacePath); + if (workspaceIndex !== -1) { + workspaces[workspaceIndex].name = newName; + lastOpenedWorkspaces.store.set('workspaces.lastOpenedWorkspaces', workspaces); + } + + return { success: true }; + } catch (error) { + throw error; + } + }); + + ipcMain.handle('renderer:close-workspace', async (event, workspacePath) => { + try { + const workspaces = lastOpenedWorkspaces.getAll(); + const filteredWorkspaces = workspaces.filter((w) => w.pathname !== workspacePath); + + lastOpenedWorkspaces.store.set('workspaces.lastOpenedWorkspaces', filteredWorkspaces); + + if (workspaceWatcher) { + workspaceWatcher.removeWatcher(workspacePath); + } + + return { success: true }; + } catch (error) { + throw error; + } + }); + + ipcMain.handle('renderer:save-workspace-docs', async (event, workspacePath, docs) => { + try { + return await updateWorkspaceDocs(workspacePath, docs); + } catch (error) { + throw error; + } + }); + + ipcMain.handle('renderer:load-workspace-environments', async (event, workspacePath) => { + try { + const result = await globalEnvironmentsManager.getGlobalEnvironments(workspacePath); + return result.globalEnvironments; + } catch (error) { + throw error; + } + }); + + ipcMain.handle('renderer:create-workspace-environment', async (event, workspacePath, environmentName) => { + try { + return await globalEnvironmentsManager.createGlobalEnvironment(workspacePath, { + name: environmentName, + variables: [] + }); + } catch (error) { + throw error; + } + }); + + ipcMain.handle('renderer:delete-workspace-environment', async (event, workspacePath, environmentUid) => { + try { + return await globalEnvironmentsManager.deleteGlobalEnvironment(workspacePath, { environmentUid }); + } catch (error) { + throw error; + } + }); + + ipcMain.handle('renderer:select-workspace-environment', async (event, workspacePath, environmentUid) => { + try { + return await globalEnvironmentsManager.selectGlobalEnvironment(workspacePath, { environmentUid }); + } catch (error) { + throw error; + } + }); + + ipcMain.handle('renderer:import-workspace-environment', async (event, workspacePath, environmentData) => { + try { + return await globalEnvironmentsManager.createGlobalEnvironment(workspacePath, { + name: environmentData.name || 'Imported Environment', + variables: environmentData.variables || [] + }); + } catch (error) { + throw error; + } + }); + + ipcMain.handle('renderer:update-workspace-environment', async (event, workspacePath, environmentUid, environmentData) => { + try { + return await globalEnvironmentsManager.saveGlobalEnvironment(workspacePath, { + environmentUid, + variables: environmentData.variables || [] + }); + } catch (error) { + throw error; + } + }); + + ipcMain.handle('renderer:rename-workspace-environment', async (event, workspacePath, environmentUid, newName) => { + try { + return await globalEnvironmentsManager.renameGlobalEnvironment(workspacePath, { + environmentUid, + name: newName + }); + } catch (error) { + throw error; + } + }); + + ipcMain.handle('renderer:copy-workspace-environment', async (event, workspacePath, environmentUid, newName) => { + try { + const result = await globalEnvironmentsManager.getGlobalEnvironments(workspacePath); + const sourceEnv = result.globalEnvironments.find((env) => env.uid === environmentUid); + + if (!sourceEnv) { + throw new Error('Source environment not found'); + } + + // Create new environment with copied variables + return await globalEnvironmentsManager.createGlobalEnvironment(workspacePath, { + name: newName, + variables: sourceEnv.variables || [] + }); + } catch (error) { + throw error; + } + }); + + ipcMain.handle('renderer:add-collection-to-workspace', async (event, workspacePath, collection) => { + try { + const normalizedCollection = normalizeCollectionEntry(workspacePath, collection); + return await addCollectionToWorkspace(workspacePath, normalizedCollection); + } catch (error) { + throw error; + } + }); + + ipcMain.handle('renderer:ensure-collections-folder', async (event, workspacePath) => { + try { + const collectionsPath = path.join(workspacePath, 'collections'); + if (!fs.existsSync(collectionsPath)) { + await createDirectory(collectionsPath); + } + return collectionsPath; + } catch (error) { + throw error; + } + }); + + ipcMain.handle('renderer:start-workspace-watcher', async (event, workspacePath) => { + try { + if (workspaceWatcher) { + workspaceWatcher.addWatcher(mainWindow, workspacePath); + } + return true; + } catch (error) { + throw error; + } + }); + + ipcMain.handle('renderer:remove-collection-from-workspace', async (event, workspaceUid, workspacePath, collectionPath) => { + try { + const result = await removeCollectionFromWorkspace(workspacePath, collectionPath); + + // Delete collection files if it's a workspace collection + if (result.shouldDeleteFiles && result.removedCollection && fs.existsSync(collectionPath)) { + const fsExtra = require('fs-extra'); + await fsExtra.remove(collectionPath); + } + + const correctWorkspaceUid = generateUidBasedOnHash(workspacePath); + mainWindow.webContents.send('main:workspace-config-updated', workspacePath, correctWorkspaceUid, result.updatedConfig); + + return true; + } catch (error) { + throw error; + } + }); + + ipcMain.handle('renderer:get-collection-workspaces', async (event, collectionPath) => { + try { + const workspaces = lastOpenedWorkspaces.getAll(); + const workspacesWithCollection = []; + + for (const workspace of workspaces) { + if (workspace.pathname) { + try { + const workspaceYmlPath = path.join(workspace.pathname, 'workspace.yml'); + if (fs.existsSync(workspaceYmlPath)) { + const workspaceConfig = yaml.load(fs.readFileSync(workspaceYmlPath, 'utf8')) || {}; + const collections = workspaceConfig.collections || []; + + const hasCollection = collections.some((c) => { + const resolvedPath = path.isAbsolute(c.path) + ? c.path + : path.resolve(workspace.pathname, c.path); + return resolvedPath === collectionPath; + }); + + if (hasCollection) { + workspacesWithCollection.push(workspace); + } + } + } catch (error) { + console.warn('Failed to check workspace collection:', error.message); + } + } + } + + return workspacesWithCollection; + } catch (error) { + return []; + } + }); + + ipcMain.handle('renderer:get-default-workspace', async (event) => { + try { + const result = await defaultWorkspaceManager.ensureDefaultWorkspaceExists(); + + if (!result) { + return null; + } + + const { workspacePath, workspaceUid } = result; + + const workspaceFilePath = path.join(workspacePath, 'workspace.yml'); + if (!fs.existsSync(workspaceFilePath)) { + return null; + } + + const yamlContent = fs.readFileSync(workspaceFilePath, 'utf8'); + const workspaceConfig = yaml.load(yamlContent); + + return { + workspaceConfig: { + ...workspaceConfig, + type: 'default' + }, + workspaceUid, + workspacePath + }; + } catch (error) { + return null; + } + }); +}; + +module.exports = registerWorkspaceIpc; diff --git a/packages/bruno-electron/src/store/default-workspace.js b/packages/bruno-electron/src/store/default-workspace.js new file mode 100644 index 000000000..3ab4b3f7d --- /dev/null +++ b/packages/bruno-electron/src/store/default-workspace.js @@ -0,0 +1,217 @@ +const fs = require('fs'); +const path = require('path'); +const { app } = require('electron'); +const yaml = require('js-yaml'); +const { generateUidBasedOnHash } = require('../utils/common'); +const { writeFile, createDirectory } = require('../utils/filesystem'); +const { getPreferences, savePreferences } = require('./preferences'); +const { globalEnvironmentsStore } = require('./global-environments'); + +class DefaultWorkspaceManager { + constructor() { + this.defaultWorkspacePath = null; + this.defaultWorkspaceUid = null; + this.initializationPromise = null; + } + + getDefaultWorkspacePath() { + if (this.defaultWorkspacePath) { + return this.defaultWorkspacePath; + } + + const preferences = getPreferences(); + this.defaultWorkspacePath = preferences?.general?.defaultWorkspacePath; + return this.defaultWorkspacePath; + } + + getDefaultWorkspaceUid() { + const workspacePath = this.getDefaultWorkspacePath(); + if (!workspacePath) { + return null; + } + + if (!this.defaultWorkspaceUid) { + this.defaultWorkspaceUid = generateUidBasedOnHash(workspacePath); + } + + return this.defaultWorkspaceUid; + } + + async setDefaultWorkspacePath(workspacePath) { + const preferences = getPreferences(); + if (!preferences.general) { + preferences.general = {}; + } + preferences.general.defaultWorkspacePath = workspacePath; + await savePreferences(preferences); + + this.defaultWorkspacePath = workspacePath; + this.defaultWorkspaceUid = generateUidBasedOnHash(workspacePath); + + return workspacePath; + } + + async ensureDefaultWorkspaceExists() { + if (this.initializationPromise) { + return this.initializationPromise; + } + + const existingPath = this.getDefaultWorkspacePath(); + + if (existingPath && fs.existsSync(existingPath)) { + return { + workspacePath: existingPath, + workspaceUid: this.getDefaultWorkspaceUid() + }; + } + + this.initializationPromise = (async () => { + try { + const shouldMigrate = this.needsMigration(); + const newWorkspacePath = await this.initializeDefaultWorkspace(null, { migrateFromPreferences: shouldMigrate }); + const workspaceYmlPath = path.join(existingPath, 'workspace.yml'); + if (!fs.existsSync(workspaceYmlPath)) { + this.defaultWorkspacePath = null; + } else { + return { + workspacePath: existingPath, + workspaceUid: this.getDefaultWorkspaceUid() + }; + } + } finally { + this.initializationPromise = null; + } + })(); + + return this.initializationPromise; + } + + async initializeDefaultWorkspace(workspacePath = null, options = {}) { + const { migrateFromPreferences = true } = options; + + if (!workspacePath) { + const configDir = app.getPath('userData'); + const baseWorkspacePath = path.join(configDir, 'default-workspace'); + + let finalPath = baseWorkspacePath; + let counter = 1; + while (fs.existsSync(finalPath)) { + finalPath = `${baseWorkspacePath}-${counter}`; + counter++; + } + + workspacePath = finalPath; + } + + if (!fs.existsSync(workspacePath)) { + await createDirectory(workspacePath); + } + + await createDirectory(path.join(workspacePath, 'collections')); + await createDirectory(path.join(workspacePath, 'environments')); + + const workspaceConfig = { + name: 'My Workspace', + type: 'default', + version: '1.0.0', + docs: '', + collections: [] + }; + + if (migrateFromPreferences) { + await this.migrateFromPreferences(workspacePath, workspaceConfig); + } + + const yamlContent = yaml.dump(workspaceConfig, { + indent: 2, + lineWidth: -1, + noRefs: true + }); + await writeFile(path.join(workspacePath, 'workspace.yml'), yamlContent); + + await this.setDefaultWorkspacePath(workspacePath); + + return workspacePath; + } + + async migrateFromPreferences(workspacePath, workspaceConfig) { + try { + const Store = require('electron-store'); + const preferencesStore = new Store({ name: 'preferences' }); + + const lastOpenedCollections = preferencesStore.get('lastOpenedCollections', []); + + if (lastOpenedCollections && lastOpenedCollections.length > 0) { + const collections = lastOpenedCollections.map((collectionPath) => { + const absolutePath = path.resolve(collectionPath); + const collectionName = path.basename(absolutePath); + + return { + type: 'preference', + path: absolutePath, + name: collectionName + }; + }); + + workspaceConfig.collections = collections; + } + + const globalEnvironments = globalEnvironmentsStore.getGlobalEnvironments(); + const activeGlobalEnvironmentUid = globalEnvironmentsStore.getActiveGlobalEnvironmentUid(); + + if (globalEnvironments && globalEnvironments.length > 0) { + const { stringifyEnvironment } = require('@usebruno/filestore'); + const environmentsDir = path.join(workspacePath, 'environments'); + + for (const env of globalEnvironments) { + const envFilePath = path.join(environmentsDir, `${env.name}.yml`); + + const environment = { + name: env.name, + variables: env.variables || [] + }; + + const content = stringifyEnvironment(environment, { format: 'yml' }); + await writeFile(envFilePath, content); + + if (env.uid === activeGlobalEnvironmentUid) { + const newUid = generateUidBasedOnHash(envFilePath); + workspaceConfig.activeEnvironmentUid = newUid; + } + } + + const globalEnvStore = new Store({ name: 'global-environments' }); + globalEnvStore.clear(); + } + + const defaultWorkspaceDocs = preferencesStore.get('preferences.defaultWorkspaceDocs', ''); + if (defaultWorkspaceDocs) { + workspaceConfig.docs = defaultWorkspaceDocs; + preferencesStore.delete('preferences.defaultWorkspaceDocs'); + } + } catch (error) { + console.error('Failed to migrate from preferences:', error); + } + } + + needsMigration() { + const workspacePath = this.getDefaultWorkspacePath(); + if (workspacePath && fs.existsSync(workspacePath)) { + return false; + } + + const Store = require('electron-store'); + const preferencesStore = new Store({ name: 'preferences' }); + const lastOpenedCollections = preferencesStore.get('lastOpenedCollections', []); + const globalEnvironments = globalEnvironmentsStore.getGlobalEnvironments(); + + return lastOpenedCollections.length > 0 || globalEnvironments.length > 0; + } +} + +const defaultWorkspaceManager = new DefaultWorkspaceManager(); + +module.exports = { + defaultWorkspaceManager, + DefaultWorkspaceManager +}; diff --git a/packages/bruno-electron/src/store/last-opened-workspaces.js b/packages/bruno-electron/src/store/last-opened-workspaces.js new file mode 100644 index 000000000..4242cda97 --- /dev/null +++ b/packages/bruno-electron/src/store/last-opened-workspaces.js @@ -0,0 +1,49 @@ +const Store = require('electron-store'); +const { generateUidBasedOnHash } = require('../utils/common'); + +const MAX_WORKSPACES = 10; + +class LastOpenedWorkspaces { + constructor() { + this.store = new Store({ + name: 'preferences', + defaults: {} + }); + } + + getAll() { + return this.store.get('workspaces.lastOpenedWorkspaces', []); + } + + add(workspacePath, workspaceConfig) { + const workspaces = this.getAll(); + + const workspaceUid = generateUidBasedOnHash(workspacePath); + + const filteredWorkspaces = workspaces.filter((w) => w.uid !== workspaceUid); + + const workspaceEntry = { + ...workspaceConfig, + uid: workspaceUid, + name: workspaceConfig.name, + lastOpened: new Date().toISOString(), + pathname: workspacePath + }; + + filteredWorkspaces.unshift(workspaceEntry); + + const limitedWorkspaces = filteredWorkspaces.slice(0, MAX_WORKSPACES); + + this.store.set('workspaces.lastOpenedWorkspaces', limitedWorkspaces); + return limitedWorkspaces; + } + + remove(workspaceUid) { + const workspaces = this.getAll(); + const filteredWorkspaces = workspaces.filter((w) => w.uid !== workspaceUid); + this.store.set('workspaces.lastOpenedWorkspaces', filteredWorkspaces); + return filteredWorkspaces; + } +} + +module.exports = LastOpenedWorkspaces; diff --git a/packages/bruno-electron/src/store/workspace-environments.js b/packages/bruno-electron/src/store/workspace-environments.js new file mode 100644 index 000000000..0d5c7b74a --- /dev/null +++ b/packages/bruno-electron/src/store/workspace-environments.js @@ -0,0 +1,363 @@ +const fs = require('fs'); +const path = require('path'); +const _ = require('lodash'); +const yaml = require('js-yaml'); +const { parseEnvironment, stringifyEnvironment } = require('@usebruno/filestore'); +const { writeFile, createDirectory } = require('../utils/filesystem'); +const { generateUidBasedOnHash, uuid } = require('../utils/common'); +const { decryptStringSafe } = require('../utils/encryption'); +const EnvironmentSecretsStore = require('./env-secrets'); + +const environmentSecretsStore = new EnvironmentSecretsStore(); + +const ENV_FILE_EXTENSION = '.yml'; + +class GlobalEnvironmentsManager { + constructor() {} + + envHasSecrets(environment) { + const secrets = _.filter(environment.variables, (v) => v.secret === true); + return secrets && secrets.length > 0; + } + + getEnvironmentsDir(workspacePath) { + return path.join(workspacePath, 'environments'); + } + + getEnvironmentFilePath(workspacePath, environmentName) { + return path.join(this.getEnvironmentsDir(workspacePath), `${environmentName}${ENV_FILE_EXTENSION}`); + } + + findEnvironmentFileByUid(workspacePath, environmentUid) { + const environmentsDir = this.getEnvironmentsDir(workspacePath); + + if (!fs.existsSync(environmentsDir)) { + return null; + } + + const files = fs.readdirSync(environmentsDir); + + for (const file of files) { + if (file.endsWith(ENV_FILE_EXTENSION)) { + const filePath = path.join(environmentsDir, file); + const fileUid = generateUidBasedOnHash(filePath); + if (fileUid === environmentUid) { + return { + filePath, + fileName: file, + name: file.slice(0, -ENV_FILE_EXTENSION.length) + }; + } + } + } + + return null; + } + + async parseEnvironmentFile(filePath, workspacePath) { + const content = fs.readFileSync(filePath, 'utf8'); + const environment = await parseEnvironment(content, { format: 'yml' }); + + const fileName = path.basename(filePath); + environment.name = fileName.slice(0, -ENV_FILE_EXTENSION.length); + environment.uid = generateUidBasedOnHash(filePath); + + _.each(environment.variables, (variable) => { + if (!variable.uid) { + variable.uid = uuid(); + } + }); + + if (this.envHasSecrets(environment)) { + const envSecrets = environmentSecretsStore.getEnvSecrets(workspacePath, environment); + _.each(envSecrets, (secret) => { + const variable = _.find(environment.variables, (v) => v.name === secret.name); + if (variable && secret.value) { + const decryptionResult = decryptStringSafe(secret.value); + variable.value = decryptionResult.value; + } + }); + } + + return environment; + } + + async getGlobalEnvironments(workspacePath) { + try { + if (!workspacePath) { + throw new Error('Workspace path is required'); + } + + const environmentsDir = this.getEnvironmentsDir(workspacePath); + + if (!fs.existsSync(environmentsDir)) { + return { + globalEnvironments: [], + activeGlobalEnvironmentUid: null + }; + } + + const files = fs.readdirSync(environmentsDir); + const environments = []; + + for (const file of files) { + if (file.endsWith(ENV_FILE_EXTENSION)) { + const filePath = path.join(environmentsDir, file); + + try { + const environment = await this.parseEnvironmentFile(filePath, workspacePath); + environments.push(environment); + } catch (parseError) { + console.error(`Failed to parse environment file ${file}:`, parseError); + } + } + } + + const activeGlobalEnvironmentUid = await this.getActiveGlobalEnvironmentUid(workspacePath); + + return { + globalEnvironments: environments, + activeGlobalEnvironmentUid + }; + } catch (error) { + throw error; + } + } + + async getActiveGlobalEnvironmentUid(workspacePath) { + try { + if (!workspacePath) { + return null; + } + + const workspaceFilePath = path.join(workspacePath, 'workspace.yml'); + + if (!fs.existsSync(workspaceFilePath)) { + return null; + } + + const yamlContent = fs.readFileSync(workspaceFilePath, 'utf8'); + const workspaceConfig = yaml.load(yamlContent); + + return workspaceConfig.activeEnvironmentUid || null; + } catch (error) { + return null; + } + } + + async setActiveGlobalEnvironmentUid(workspacePath, environmentUid) { + try { + if (!workspacePath) { + throw new Error('Workspace path is required'); + } + + const workspaceFilePath = path.join(workspacePath, 'workspace.yml'); + + if (!fs.existsSync(workspaceFilePath)) { + throw new Error('Invalid workspace: workspace.yml not found'); + } + + const yamlContent = fs.readFileSync(workspaceFilePath, 'utf8'); + const workspaceConfig = yaml.load(yamlContent); + + workspaceConfig.activeEnvironmentUid = environmentUid; + + const yamlOutput = yaml.dump(workspaceConfig, { + indent: 2, + lineWidth: -1, + noRefs: true + }); + + await writeFile(workspaceFilePath, yamlOutput); + return true; + } catch (error) { + throw error; + } + } + + async createGlobalEnvironment(workspacePath, { uid, name, variables }) { + try { + if (!workspacePath) { + throw new Error('Workspace path is required'); + } + + const environmentsDir = this.getEnvironmentsDir(workspacePath); + + if (!fs.existsSync(environmentsDir)) { + await createDirectory(environmentsDir); + } + + const environmentFilePath = this.getEnvironmentFilePath(workspacePath, name); + + if (fs.existsSync(environmentFilePath)) { + throw new Error(`Environment "${name}" already exists`); + } + + const environment = { + name: name, + variables: variables || [] + }; + + if (this.envHasSecrets(environment)) { + environmentSecretsStore.storeEnvSecrets(workspacePath, environment); + } + + const content = await stringifyEnvironment(environment, { format: 'yml' }); + await writeFile(environmentFilePath, content); + + return { + uid: generateUidBasedOnHash(environmentFilePath), + name, + variables + }; + } catch (error) { + throw error; + } + } + + async saveGlobalEnvironment(workspacePath, { environmentUid, variables }) { + try { + if (!workspacePath) { + throw new Error('Workspace path is required'); + } + + const envFile = this.findEnvironmentFileByUid(workspacePath, environmentUid); + + if (!envFile) { + throw new Error(`Environment file not found for uid: ${environmentUid}`); + } + + const environment = { + name: envFile.name, + variables: variables + }; + + if (this.envHasSecrets(environment)) { + environmentSecretsStore.storeEnvSecrets(workspacePath, environment); + } + + const content = await stringifyEnvironment(environment, { format: 'yml' }); + await writeFile(envFile.filePath, content); + + return true; + } catch (error) { + throw error; + } + } + + async renameGlobalEnvironment(workspacePath, { environmentUid, name: newName }) { + try { + if (!workspacePath) { + throw new Error('Workspace path is required'); + } + + const envFile = this.findEnvironmentFileByUid(workspacePath, environmentUid); + + if (!envFile) { + throw new Error(`Environment file not found for uid: ${environmentUid}`); + } + + const newFilePath = this.getEnvironmentFilePath(workspacePath, newName); + + if (fs.existsSync(newFilePath) && newFilePath !== envFile.filePath) { + throw new Error(`Environment "${newName}" already exists`); + } + + const environment = await this.parseEnvironmentFile(envFile.filePath, workspacePath); + const oldName = environment.name; + environment.name = newName; + + const content = await stringifyEnvironment(environment, { format: 'yml' }); + await writeFile(newFilePath, content); + + if (this.envHasSecrets(environment)) { + const oldEnv = { name: oldName }; + const secrets = environmentSecretsStore.getEnvSecrets(workspacePath, oldEnv); + + if (secrets && secrets.length > 0) { + const newEnv = { name: newName, variables: environment.variables }; + environmentSecretsStore.storeEnvSecrets(workspacePath, newEnv); + } + } + + if (envFile.filePath !== newFilePath) { + fs.unlinkSync(envFile.filePath); + } + + const newUid = generateUidBasedOnHash(newFilePath); + return { uid: newUid, name: newName }; + } catch (error) { + throw error; + } + } + + async deleteGlobalEnvironment(workspacePath, { environmentUid }) { + try { + if (!workspacePath) { + throw new Error('Workspace path is required'); + } + + const envFile = this.findEnvironmentFileByUid(workspacePath, environmentUid); + + if (!envFile) { + throw new Error(`Environment file not found for uid: ${environmentUid}`); + } + + fs.unlinkSync(envFile.filePath); + + const activeGlobalEnvironmentUid = await this.getActiveGlobalEnvironmentUid(workspacePath); + if (activeGlobalEnvironmentUid === environmentUid) { + await this.setActiveGlobalEnvironmentUid(workspacePath, null); + } + + return true; + } catch (error) { + throw error; + } + } + + async selectGlobalEnvironment(workspacePath, { environmentUid }) { + try { + if (!workspacePath) { + throw new Error('Workspace path is required'); + } + + await this.setActiveGlobalEnvironmentUid(workspacePath, environmentUid); + return true; + } catch (error) { + throw error; + } + } + + async getGlobalEnvironmentsByPath(workspacePath) { + return this.getGlobalEnvironments(workspacePath); + } + + async addGlobalEnvironmentByPath(workspacePath, params) { + return this.createGlobalEnvironment(workspacePath, params); + } + + async saveGlobalEnvironmentByPath(workspacePath, params) { + return this.saveGlobalEnvironment(workspacePath, params); + } + + async renameGlobalEnvironmentByPath(workspacePath, params) { + return this.renameGlobalEnvironment(workspacePath, params); + } + + async deleteGlobalEnvironmentByPath(workspacePath, params) { + return this.deleteGlobalEnvironment(workspacePath, params); + } + + async selectGlobalEnvironmentByPath(workspacePath, params) { + return this.selectGlobalEnvironment(workspacePath, params); + } +} + +const globalEnvironmentsManager = new GlobalEnvironmentsManager(); + +module.exports = { + globalEnvironmentsManager, + GlobalEnvironmentsManager, + ENV_FILE_EXTENSION +}; diff --git a/packages/bruno-electron/src/utils/collection-import.js b/packages/bruno-electron/src/utils/collection-import.js index 069085452..5dfd35e36 100644 --- a/packages/bruno-electron/src/utils/collection-import.js +++ b/packages/bruno-electron/src/utils/collection-import.js @@ -22,7 +22,7 @@ async function findUniqueFolderName(baseName, collectionLocation, counter = 0) { /** * Import a collection - shared logic used by both IPC handler and onboarding service */ -async function importCollection(collection, collectionLocation, mainWindow, lastOpenedCollections, uniqueFolderName = null, format = 'bru') { +async function importCollection(collection, collectionLocation, mainWindow, uniqueFolderName = null, format = 'bru') { // Use provided unique folder name or use collection name let folderName = uniqueFolderName ? sanitizeName(uniqueFolderName) : sanitizeName(collection.name); let collectionPath = path.join(collectionLocation, folderName); @@ -100,13 +100,13 @@ async function importCollection(collection, collectionLocation, mainWindow, last let brunoConfig = getBrunoJsonConfig(collection); if (format === 'yml') { - const collectionContent = await stringifyCollection(collection.root, brunoConfig, { format }); + const collectionContent = await stringifyCollection(collection.root, { format }); await writeFile(path.join(collectionPath, 'opencollection.yml'), collectionContent); } else if (format === 'bru') { const stringifiedBrunoConfig = await stringifyJson(brunoConfig); await writeFile(path.join(collectionPath, 'bruno.json'), stringifiedBrunoConfig); - const collectionContent = await stringifyCollection(collection.root, brunoConfig, { format }); + const collectionContent = await stringifyCollection(collection.root, { format }); await writeFile(path.join(collectionPath, 'collection.bru'), collectionContent); } else { throw new Error(`Invalid format: ${format}`); @@ -119,8 +119,6 @@ async function importCollection(collection, collectionLocation, mainWindow, last mainWindow.webContents.send('main:collection-opened', collectionPath, uid, brunoConfig); ipcMain.emit('main:collection-opened', mainWindow, collectionPath, uid, brunoConfig); - lastOpenedCollections.add(collectionPath); - // create folder and files based on collection await parseCollectionItems(collection.items, collectionPath); await parseEnvironments(collection.environments, collectionPath); diff --git a/packages/bruno-electron/src/utils/workspace-config.js b/packages/bruno-electron/src/utils/workspace-config.js new file mode 100644 index 000000000..8e575dc05 --- /dev/null +++ b/packages/bruno-electron/src/utils/workspace-config.js @@ -0,0 +1,225 @@ +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); +const { writeFile, validateName } = require('./filesystem'); + +const WORKSPACE_TYPE = 'workspace'; + +const makeRelativePath = (workspacePath, absolutePath) => { + if (!path.isAbsolute(absolutePath)) { + return absolutePath; + } + + try { + return path.relative(workspacePath, absolutePath); + } catch (error) { + return absolutePath; + } +}; + +const normalizeCollectionEntry = (workspacePath, collection) => { + const relativePath = makeRelativePath(workspacePath, collection.path); + + const normalizedCollection = { + name: collection.name, + path: relativePath + }; + + if (collection.remote) { + normalizedCollection.remote = collection.remote; + } + + return normalizedCollection; +}; + +const validateWorkspacePath = (workspacePath) => { + if (!workspacePath) { + throw new Error('Workspace path is required'); + } + + if (!fs.existsSync(workspacePath)) { + throw new Error(`Workspace path does not exist: ${workspacePath}`); + } + + const workspaceFilePath = path.join(workspacePath, 'workspace.yml'); + if (!fs.existsSync(workspaceFilePath)) { + throw new Error('Invalid workspace: workspace.yml not found'); + } + + return true; +}; + +const validateWorkspaceDirectory = (dirPath) => { + if (!validateName(path.basename(dirPath))) { + throw new Error(`Invalid workspace directory name: ${dirPath}`); + } + return true; +}; + +const createWorkspaceConfig = (workspaceName) => ({ + name: workspaceName, + type: WORKSPACE_TYPE, + version: '1.0.0', + docs: '', + collections: [] +}); + +const readWorkspaceConfig = (workspacePath) => { + const workspaceFilePath = path.join(workspacePath, 'workspace.yml'); + + if (!fs.existsSync(workspaceFilePath)) { + throw new Error('Invalid workspace: workspace.yml not found'); + } + + const yamlContent = fs.readFileSync(workspaceFilePath, 'utf8'); + const workspaceConfig = yaml.load(yamlContent); + + if (!workspaceConfig || typeof workspaceConfig !== 'object') { + throw new Error('Invalid workspace: workspace.yml is malformed'); + } + + return workspaceConfig; +}; + +const writeWorkspaceConfig = async (workspacePath, config) => { + const yamlContent = yaml.dump(config, { + indent: 2, + lineWidth: -1, + noRefs: true + }); + await writeFile(path.join(workspacePath, 'workspace.yml'), yamlContent); +}; + +const validateWorkspaceConfig = (config) => { + if (!config || typeof config !== 'object') { + throw new Error('Workspace configuration must be an object'); + } + + if (config.type !== WORKSPACE_TYPE) { + throw new Error('Invalid workspace: not a bruno workspace'); + } + + if (!config.name || typeof config.name !== 'string') { + throw new Error('Workspace must have a valid name'); + } + + return true; +}; + +const updateWorkspaceName = async (workspacePath, newName) => { + const config = readWorkspaceConfig(workspacePath); + config.name = newName; + await writeWorkspaceConfig(workspacePath, config); + return config; +}; + +const updateWorkspaceDocs = async (workspacePath, docs) => { + const config = readWorkspaceConfig(workspacePath); + config.docs = docs; + await writeWorkspaceConfig(workspacePath, config); + return docs; +}; + +const addCollectionToWorkspace = async (workspacePath, collection) => { + const config = readWorkspaceConfig(workspacePath); + + if (!config.collections) { + config.collections = []; + } + + // Normalize collection entry + const normalizedCollection = { + name: collection.name, + path: collection.path + }; + + if (collection.remote) { + normalizedCollection.remote = collection.remote; + } + + // Check if collection already exists + const existingIndex = config.collections.findIndex((c) => c.name === normalizedCollection.name || c.path === normalizedCollection.path); + + if (existingIndex >= 0) { + config.collections[existingIndex] = normalizedCollection; + } else { + config.collections.push(normalizedCollection); + } + + await writeWorkspaceConfig(workspacePath, config); + return config.collections; +}; + +const removeCollectionFromWorkspace = async (workspacePath, collectionPath) => { + const config = readWorkspaceConfig(workspacePath); + + let removedCollection = null; + let shouldDeleteFiles = false; + + config.collections = (config.collections || []).filter((c) => { + const collectionPathFromYml = c.path; + + if (!collectionPathFromYml) { + return true; + } + + // Convert to absolute path for comparison + const absoluteCollectionPath = path.isAbsolute(collectionPathFromYml) + ? collectionPathFromYml + : path.resolve(workspacePath, collectionPathFromYml); + + if (path.normalize(absoluteCollectionPath) === path.normalize(collectionPath)) { + removedCollection = c; + + // Delete files only for workspace collections (not remote, not external absolute paths) + const hasRemote = c.remote; + const isExternalPath = path.isAbsolute(collectionPathFromYml); + + shouldDeleteFiles = !hasRemote && !isExternalPath; + + return false; // Remove from array + } + + return true; // Keep in array + }); + + await writeWorkspaceConfig(workspacePath, config); + + return { + removedCollection, + shouldDeleteFiles, + updatedConfig: config + }; +}; + +const getWorkspaceCollections = (workspacePath) => { + const config = readWorkspaceConfig(workspacePath); + const collections = config.collections || []; + + // Resolve relative paths to absolute + return collections.map((collection) => { + if (collection.path && !path.isAbsolute(collection.path)) { + return { + ...collection, + path: path.join(workspacePath, collection.path) + }; + } + return collection; + }); +}; + +module.exports = { + makeRelativePath, + normalizeCollectionEntry, + validateWorkspacePath, + validateWorkspaceDirectory, + createWorkspaceConfig, + readWorkspaceConfig, + writeWorkspaceConfig, + validateWorkspaceConfig, + updateWorkspaceName, + updateWorkspaceDocs, + addCollectionToWorkspace, + removeCollectionFromWorkspace, + getWorkspaceCollections +};