+
dispatch(showHomePage())} title="Home">
+
+
+
+ {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
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')}
-
- setCreateCollectionModalOpen(true)}
- aria-label={t('WELCOME.CREATE_COLLECTION')}
- >
-
-
- {t('WELCOME.CREATE_COLLECTION')}
-
-
-
-
-
- {t('WELCOME.OPEN_COLLECTION')}
-
-
- setImportCollectionModalOpen(true)}
- aria-label={t('WELCOME.IMPORT_COLLECTION')}
- >
-
-
- {t('WELCOME.IMPORT_COLLECTION')}
-
-
-
-
- {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/WorkspaceHome/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceHome/StyledWrapper.js
new file mode 100644
index 000000000..7e800b754
--- /dev/null
+++ b/packages/bruno-app/src/components/WorkspaceHome/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/WorkspaceHome/WorkspaceCollections/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceCollections/StyledWrapper.js
new file mode 100644
index 000000000..1c464a0d6
--- /dev/null
+++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceCollections/StyledWrapper.js
@@ -0,0 +1,154 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ .collections-table {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ font-size: ${(props) => props.theme.font.size.base};
+ }
+
+ .collections-header {
+ display: grid;
+ gap: 16px;
+ padding: 10px 16px;
+ border-bottom: ${(props) => props.theme.workspace.collection.header.indentBorder};
+ position: sticky;
+ top: 0;
+ z-index: 10;
+
+ &:has(.header-git) {
+ grid-template-columns: 1fr 3fr 1fr 1.5fr;
+ }
+
+ &:not(:has(.header-git)) {
+ grid-template-columns: 1fr 3fr 1.5fr;
+ }
+ }
+
+ .header-cell {
+ font-weight: 600;
+ font-size: ${(props) => props.theme.font.size.xs};
+ color: ${(props) => props.theme.text.muted};
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ display: flex;
+ align-items: center;
+ }
+
+ .collections-body {
+ flex: 1;
+ overflow-y: auto;
+ }
+
+ .collection-row {
+ display: grid;
+ gap: 16px;
+ padding: 8px 16px;
+ border-bottom: ${(props) => props.theme.workspace.collection.item.indentBorder};
+ transition: background-color 0.15s ease;
+ cursor: pointer;
+ grid-template-columns: 1fr 3fr 1.5fr;
+
+ &:hover {
+ background-color: ${(props) => props.theme.sidebar.bg};
+ }
+
+ &:last-child {
+ border-bottom: none;
+ }
+ }
+
+ .row-cell {
+ display: flex;
+ align-items: center;
+ overflow: hidden;
+ }
+
+ .cell-name {
+ .collection-icon {
+ color: ${(props) => props.theme.workspace.accent};
+ flex-shrink: 0;
+ }
+
+ .collection-info {
+ min-width: 0;
+ flex: 1;
+ }
+
+ .collection-name {
+ font-weight: 400;
+ color: ${(props) => props.theme.text};
+ font-size: ${(props) => props.theme.font.size.base};
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .collection-subtitle {
+ font-size: ${(props) => props.theme.font.size.xs};
+ color: ${(props) => props.theme.text.muted};
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-top: 2px;
+ }
+ }
+
+ .cell-location {
+ .location-text {
+ font-size: ${(props) => props.theme.font.size.sm};
+ color: ${(props) => props.theme.text.muted};
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ width: 100%;
+ }
+ }
+
+ .cell-actions {
+ justify-content: flex-end;
+
+ .action-buttons {
+ display: flex;
+ gap: 4px;
+ }
+ }
+
+ .action-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 30px;
+ height: 30px;
+ border: none;
+ background: transparent;
+ border-radius: 5px;
+ cursor: pointer;
+ transition: background 0.15s ease, color 0.15s ease;
+ color: ${(props) => props.theme.text.muted};
+
+ &:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ }
+
+ &:hover:not(:disabled) {
+ background-color: ${(props) => props.theme.listItem.hoverBg};
+
+ &.action-edit {
+ color: ${(props) => props.theme.text};
+ }
+
+ &.action-share {
+ color: #3B82F6;
+ }
+
+ &.action-delete {
+ color: #EF4444;
+ }
+ }
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceCollections/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceCollections/index.js
new file mode 100644
index 000000000..89b1349c9
--- /dev/null
+++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceCollections/index.js
@@ -0,0 +1,343 @@
+import React, { useState } from 'react';
+import { useSelector, useDispatch } from 'react-redux';
+import { IconBox, IconTrash, IconEdit, IconShare } from '@tabler/icons';
+import { removeCollectionFromWorkspaceAction, importCollectionInWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
+import { addTab } from 'providers/ReduxStore/slices/tabs';
+import { hideHomePage } from 'providers/ReduxStore/slices/app';
+import toast from 'react-hot-toast';
+import Modal from 'components/Modal';
+import CreateCollection from 'components/Sidebar/CreateCollection';
+import ImportCollection from 'components/Sidebar/ImportCollection';
+import RenameCollection from 'components/Sidebar/Collections/Collection/RenameCollection';
+import ShareCollection from 'components/ShareCollection';
+import StyledWrapper from './StyledWrapper';
+import { mountCollection } from 'providers/ReduxStore/slices/collections/actions';
+
+const WorkspaceCollections = ({ workspace, onImportCollection }) => {
+ const dispatch = useDispatch();
+ const { collections } = useSelector((state) => state.collections);
+ const [collectionToRemove, setCollectionToRemove] = useState(null);
+ const [renameCollectionModalOpen, setRenameCollectionModalOpen] = useState(false);
+ const [shareCollectionModalOpen, setShareCollectionModalOpen] = useState(false);
+ const [selectedCollectionUid, setSelectedCollectionUid] = useState(null);
+
+ const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
+ const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
+
+ const handleImportCollection = ({ rawData, type }) => {
+ if (onImportCollection) {
+ onImportCollection();
+ return;
+ }
+
+ setImportCollectionModalOpen(false);
+ dispatch(importCollectionInWorkspace(rawData, workspace.uid, undefined, type))
+ .catch((err) => {
+ console.error(err);
+ toast.error('An error occurred while importing the collection');
+ });
+ };
+
+ const workspaceCollections = React.useMemo(() => {
+ if (!workspace.collections || workspace.collections.length === 0) {
+ return [];
+ }
+
+ const result = [];
+
+ workspace.collections.forEach((wc) => {
+ const loadedCollection = collections.find((c) => c.pathname === wc.path);
+
+ if (loadedCollection) {
+ result.push({
+ ...loadedCollection,
+ isGitBacked: !!wc.remote,
+ gitRemoteUrl: wc.remote
+ });
+ } else {
+ result.push({
+ uid: `unloaded-${wc.path}`,
+ name: wc.name,
+ pathname: wc.path,
+ items: [],
+ environments: [],
+ isGitBacked: !!wc.remote,
+ isLoaded: false,
+ gitRemoteUrl: wc.remote,
+ git: { gitRootPath: null },
+ brunoConfig: {},
+ root: {
+ request: {
+ headers: [],
+ auth: { mode: 'none' },
+ vars: { req: [], res: [] },
+ script: { req: '', res: '' },
+ tests: ''
+ },
+ docs: ''
+ }
+ });
+ }
+ });
+
+ return result;
+ }, [workspace.collections, collections, workspace.pathname]);
+
+ const handleOpenCollectionClick = (collection, event) => {
+ if (event.target.closest('.action-buttons')) {
+ return;
+ }
+
+ if (collection.isLoaded === false) {
+ if (collection.isGitBacked) {
+ toast.error(`Collection "${collection.name}" needs to be cloned first`);
+ } else {
+ toast.error(`Collection "${collection.name}" does not exist on disk`);
+ }
+ return;
+ }
+
+ dispatch(mountCollection({
+ collectionUid: collection.uid,
+ collectionPathname: collection.pathname,
+ brunoConfig: collection.brunoConfig
+ }));
+
+ dispatch(hideHomePage());
+
+ dispatch(addTab({
+ uid: collection.uid,
+ collectionUid: collection.uid,
+ type: 'collection-settings'
+ }));
+ };
+
+ const handleRenameCollection = (collection) => {
+ if (collection.isLoaded === false) {
+ toast.error('Cannot rename collections that are not cloned yet');
+ return;
+ }
+
+ setSelectedCollectionUid(collection.uid);
+ setRenameCollectionModalOpen(true);
+ };
+
+ const handleShareCollection = (collection) => {
+ if (collection.isLoaded === false) {
+ toast.error('Please clone this collection first before sharing it');
+ return;
+ }
+
+ dispatch(mountCollection({
+ collectionUid: collection.uid,
+ collectionPathname: collection.pathname,
+ brunoConfig: collection.brunoConfig
+ }));
+
+ setSelectedCollectionUid(collection.uid);
+ setShareCollectionModalOpen(true);
+ };
+
+ const handleRemoveCollection = (collection) => {
+ setCollectionToRemove(collection);
+ };
+
+ const confirmRemoveCollection = async () => {
+ if (!collectionToRemove) return;
+
+ try {
+ await dispatch(removeCollectionFromWorkspaceAction(workspace.uid, collectionToRemove.pathname));
+
+ const collectionInfo = getCollectionWorkspaceInfo(collectionToRemove);
+
+ if (collectionInfo.isLoaded && !collectionInfo.isGitBacked) {
+ toast.success(`Deleted "${collectionToRemove.name}" collection`);
+ } else if (collectionInfo.isGitBacked) {
+ toast.success(`Removed git-backed collection "${collectionToRemove.name}" from workspace`);
+ } else {
+ toast.success(`Removed "${collectionToRemove.name}" from workspace`);
+ }
+
+ setCollectionToRemove(null);
+ } catch (error) {
+ console.error('Error removing collection:', error);
+ toast.error(error.message || 'Failed to remove collection from workspace');
+ }
+ };
+
+ const getCollectionWorkspaceInfo = (collection) => {
+ if (collection.hasOwnProperty('isGitBacked')) {
+ return {
+ isGitBacked: collection.isGitBacked,
+ gitRemoteUrl: collection.gitRemoteUrl,
+ isLoaded: collection.isLoaded !== false
+ };
+ }
+
+ const workspaceCollection = workspace.collections?.find((wc) => {
+ return collection.pathname === wc.path;
+ });
+
+ return {
+ isGitBacked: !!workspaceCollection?.remote,
+ gitRemoteUrl: workspaceCollection?.remote,
+ isLoaded: true
+ };
+ };
+
+ return (
+
+
+
+ {createCollectionModalOpen && (
+
setCreateCollectionModalOpen(false)}
+ />
+ )}
+
+ {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}
+
+
+
+
+
+ {
+ e.stopPropagation();
+ handleRenameCollection(collection);
+ }}
+ className="action-btn action-edit"
+ title="Rename collection"
+ >
+
+
+ {
+ e.stopPropagation();
+ handleShareCollection(collection);
+ }}
+ className="action-btn action-share"
+ title="Share collection"
+ >
+
+
+ {
+ e.stopPropagation();
+ handleRemoveCollection(collection);
+ }}
+ className="action-btn action-delete"
+ title="Remove from workspace"
+ >
+
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+
+
+ );
+};
+
+export default WorkspaceCollections;
diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceDocs/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceDocs/StyledWrapper.js
new file mode 100644
index 000000000..af80d4c08
--- /dev/null
+++ b/packages/bruno-app/src/components/WorkspaceHome/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/WorkspaceHome/WorkspaceDocs/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceDocs/index.js
new file mode 100644
index 000000000..f676070ca
--- /dev/null
+++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceDocs/index.js
@@ -0,0 +1,147 @@
+import 'github-markdown-css/github-markdown.css';
+import get from 'lodash/get';
+import { useTheme } from 'providers/Theme';
+import { useState, useEffect } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { saveWorkspaceDocs } from 'providers/ReduxStore/slices/workspaces/actions';
+import Markdown from 'components/MarkDown';
+import CodeEditor from 'components/CodeEditor';
+import StyledWrapper from './StyledWrapper';
+import { IconEdit, IconX, IconFileText } from '@tabler/icons';
+import toast from 'react-hot-toast';
+
+const WorkspaceDocs = ({ workspace }) => {
+ const dispatch = useDispatch();
+ const { displayedTheme } = useTheme();
+ const [isEditing, setIsEditing] = useState(false);
+ const [localDocs, setLocalDocs] = useState(workspace?.docs || '');
+ const preferences = useSelector((state) => state.app.preferences);
+
+ useEffect(() => {
+ setLocalDocs(workspace?.docs || '');
+ setIsEditing(false);
+ }, [workspace?.uid, workspace?.docs]);
+
+ const toggleViewMode = () => {
+ setIsEditing((prev) => !prev);
+ };
+
+ const onEdit = (value) => {
+ setLocalDocs(value);
+ };
+
+ const handleDiscardChanges = () => {
+ setLocalDocs(workspace?.docs || '');
+ toggleViewMode();
+ };
+
+ const onSave = async () => {
+ if (!workspace) {
+ toast.error('Workspace not found');
+ return;
+ }
+
+ try {
+ await dispatch(saveWorkspaceDocs(workspace.uid, localDocs));
+ toast.success('Documentation saved successfully');
+ toggleViewMode();
+ } catch (error) {
+ console.error('Error saving workspace docs:', error);
+ toast.error('Failed to save documentation');
+ }
+ };
+
+ return (
+
+
+
+
+ Workspace Documentation
+
+
+ {isEditing ? (
+ <>
+
+
+
+
+ Save
+
+ >
+ ) : (
+
+
+
+ )}
+
+
+ {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/WorkspaceHome/WorkspaceEnvironments/CopyEnvironment/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/CopyEnvironment/index.js
new file mode 100644
index 000000000..cb967a34e
--- /dev/null
+++ b/packages/bruno-app/src/components/WorkspaceHome/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 (
+
+
+
+
+
+ );
+};
+
+export default CopyEnvironment;
diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/CreateEnvironment/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/CreateEnvironment/index.js
new file mode 100644
index 000000000..7899c807a
--- /dev/null
+++ b/packages/bruno-app/src/components/WorkspaceHome/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 (
+
+
+
+
+
+ );
+};
+
+export default CreateEnvironment;
diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/DeleteEnvironment/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/DeleteEnvironment/StyledWrapper.js
new file mode 100644
index 000000000..48b874214
--- /dev/null
+++ b/packages/bruno-app/src/components/WorkspaceHome/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/WorkspaceHome/WorkspaceEnvironments/DeleteEnvironment/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/DeleteEnvironment/index.js
new file mode 100644
index 000000000..766215d66
--- /dev/null
+++ b/packages/bruno-app/src/components/WorkspaceHome/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/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/ConfirmSwitchEnv.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/ConfirmSwitchEnv.js
new file mode 100644
index 000000000..82bc985bf
--- /dev/null
+++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/ConfirmSwitchEnv.js
@@ -0,0 +1,41 @@
+import React from 'react';
+import { IconAlertTriangle } from '@tabler/icons';
+import Modal from 'components/Modal';
+import { createPortal } from 'react-dom';
+
+const ConfirmSwitchEnv = ({ onCancel }) => {
+ const modalContent = (
+
{
+ e.stopPropagation();
+ e.preventDefault();
+ }}
+ hideFooter={true}
+ >
+
+
+
Hold on..
+
+ You have unsaved changes in this environment.
+
+
+
+ );
+
+ return createPortal(modalContent, document.body);
+};
+
+export default ConfirmSwitchEnv;
diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js
new file mode 100644
index 000000000..88d57a40f
--- /dev/null
+++ b/packages/bruno-app/src/components/WorkspaceHome/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/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js
new file mode 100644
index 000000000..e173b765c
--- /dev/null
+++ b/packages/bruno-app/src/components/WorkspaceHome/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 (
+
+
+
+
+
+
+ Save
+
+
+ Reset
+
+
+
+
+ );
+};
+export default EnvironmentVariables;
diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/index.js
new file mode 100644
index 000000000..230457af6
--- /dev/null
+++ b/packages/bruno-app/src/components/WorkspaceHome/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 ? (
+ <>
+
+
+ e.preventDefault()}
+ title="Save"
+ >
+
+
+ e.preventDefault()}
+ title="Cancel"
+ >
+
+
+
+ >
+ ) : (
+
{environment.name}
+ )}
+
+ {nameError && isRenaming &&
{nameError}
}
+
+
+
+
+ setOpenCopyModal(true)} title="Copy">
+
+
+ setOpenDeleteModal(true)} title="Delete">
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default EnvironmentDetails;
diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/StyledWrapper.js
new file mode 100644
index 000000000..0f60f349a
--- /dev/null
+++ b/packages/bruno-app/src/components/WorkspaceHome/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/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/index.js
new file mode 100644
index 000000000..6c7197e1a
--- /dev/null
+++ b/packages/bruno-app/src/components/WorkspaceHome/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
+
+ handleCreateEnvClick()} title="Create environment">
+
+
+ handleImportClick()} title="Import environment">
+
+
+
+
+
+
+
+ setSearchText(e.target.value)}
+ className="search-input"
+ />
+
+
+
+ {filteredEnvironments.map((env) => (
+
renamingEnvUid !== env.uid && handleEnvironmentClick(env)}
+ onDoubleClick={() => handleEnvironmentDoubleClick(env)}
+ >
+ {renamingEnvUid === env.uid ? (
+
+
+
+ e.preventDefault()}
+ title="Save"
+ >
+
+
+ e.preventDefault()}
+ title="Cancel"
+ >
+
+
+
+
+ ) : (
+ <>
+
{env.name}
+
+ {activeEnvironmentUid === env.uid ? (
+
+
+
+ ) : (
+
handleActivateEnvironment(e, env)}
+ title="Activate environment"
+ >
+
+
+ )}
+
+ >
+ )}
+
+ ))}
+
+ {isCreatingInline && (
+
+
+
+ e.preventDefault()}
+ title="Save"
+ >
+
+
+ e.preventDefault()}
+ title="Cancel"
+ >
+
+
+
+
+ )}
+
+ {envNameError && (isCreatingInline || renamingEnvUid) && (
+
{envNameError}
+ )}
+
+
+
+ {/* Right Content */}
+
+
+
+ );
+};
+
+export default EnvironmentList;
diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/ImportEnvironment/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/ImportEnvironment/index.js
new file mode 100644
index 000000000..e4f6544d9
--- /dev/null
+++ b/packages/bruno-app/src/components/WorkspaceHome/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) => {
+ const importPromises = environments
+ .filter((env) =>
+ env.name && env.name !== 'undefined')
+ .map((environment) =>
+ dispatch(addGlobalEnvironment({ name: environment.name, variables: environment.variables }))
+ .then(() => {
+ toast.success('Environment imported successfully');
+ })
+ .catch((error) => {
+ toast.error('An error occurred while importing the environment');
+ console.error(error);
+ }));
+ return Promise.all(importPromises);
+ })
+ .then(() => {
+ onClose();
+ // Call the callback if provided
+ if (onEnvironmentCreated) {
+ onEnvironmentCreated();
+ }
+ })
+ .catch((err) => toastError(err, 'Postman Import environment failed'));
+ };
+
+ return (
+
+
+
+
+ Import your Postman environments
+
+
+
+ );
+};
+
+export default ImportEnvironment;
diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/RenameEnvironment/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/RenameEnvironment/index.js
new file mode 100644
index 000000000..7d3ae3efe
--- /dev/null
+++ b/packages/bruno-app/src/components/WorkspaceHome/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 (
+
+
+
+
+
+ );
+};
+
+export default RenameEnvironment;
diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/StyledWrapper.js
new file mode 100644
index 000000000..7931f4e94
--- /dev/null
+++ b/packages/bruno-app/src/components/WorkspaceHome/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/WorkspaceHome/WorkspaceEnvironments/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/index.js
new file mode 100644
index 000000000..fa409e839
--- /dev/null
+++ b/packages/bruno-app/src/components/WorkspaceHome/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 (
+
+ {children}
+
+ );
+};
+
+const DefaultTab = ({ setTab }) => {
+ return (
+
+
+
No Environments
+
+ setTab('create')}>
+ Create Environment
+
+ setTab('import')}>
+ Import Environment
+
+
+
+ );
+};
+
+const WorkspaceEnvironments = ({ workspace }) => {
+ const [isModified, setIsModified] = useState(false);
+ const [selectedEnvironment, setSelectedEnvironment] = useState(null);
+ const [tab, setTab] = useState('default');
+
+ const 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/WorkspaceHome/index.js b/packages/bruno-app/src/components/WorkspaceHome/index.js
new file mode 100644
index 000000000..7f0c331c7
--- /dev/null
+++ b/packages/bruno-app/src/components/WorkspaceHome/index.js
@@ -0,0 +1,351 @@
+import React, { useEffect, useState, useRef } from 'react';
+import { useSelector, useDispatch } from 'react-redux';
+import { IconCategory, IconPlus, IconFolders, IconFileImport, IconDots, IconEdit, IconX, IconCheck, IconFolder } from '@tabler/icons';
+import { importCollectionInWorkspace, renameWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
+import { showInFolder, openCollection } from 'providers/ReduxStore/slices/collections/actions';
+import toast from 'react-hot-toast';
+import CreateCollection from 'components/Sidebar/CreateCollection';
+import ImportCollection from 'components/Sidebar/ImportCollection';
+import CloseWorkspace from 'components/Sidebar/TitleBar/CloseWorkspace';
+import WorkspaceCollections from './WorkspaceCollections';
+import WorkspaceDocs from './WorkspaceDocs';
+import WorkspaceEnvironments from './WorkspaceEnvironments';
+import StyledWrapper from './StyledWrapper';
+import Dropdown from 'components/Dropdown';
+
+const WorkspaceHome = () => {
+ const dispatch = useDispatch();
+ const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
+ const [activeTab, setActiveTab] = useState('collections');
+
+ const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
+ const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
+
+ const [isRenamingWorkspace, setIsRenamingWorkspace] = useState(false);
+ const [workspaceNameInput, setWorkspaceNameInput] = useState('');
+ const [workspaceNameError, setWorkspaceNameError] = useState('');
+ const [closeWorkspaceModalOpen, setCloseWorkspaceModalOpen] = useState(false);
+ const workspaceNameInputRef = useRef(null);
+ const workspaceRenameContainerRef = useRef(null);
+ const dropdownTippyRef = useRef();
+ const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
+
+ const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
+
+ useEffect(() => {
+ if (!isRenamingWorkspace) return;
+
+ const handleClickOutside = (event) => {
+ if (workspaceRenameContainerRef.current && !workspaceRenameContainerRef.current.contains(event.target)) {
+ handleCancelWorkspaceRename();
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [isRenamingWorkspace]);
+
+ if (!activeWorkspace) {
+ return null;
+ }
+
+ const handleCreateCollection = async () => {
+ try {
+ const { ipcRenderer } = window;
+ await ipcRenderer.invoke('renderer:ensure-collections-folder', activeWorkspace.pathname);
+ setCreateCollectionModalOpen(true);
+ } catch (error) {
+ console.error('Error ensuring collections folder exists:', error);
+ toast.error('Error preparing workspace for collection creation');
+ }
+ };
+
+ const handleOpenCollection = () => {
+ dispatch(openCollection())
+ .catch((err) => {
+ console.error(err);
+ toast.error('An error occurred while opening the collection');
+ });
+ };
+
+ const handleImportCollection = () => {
+ setImportCollectionModalOpen(true);
+ };
+
+ const handleImportCollectionSubmit = ({ rawData, type, environment, repositoryUrl }) => {
+ setImportCollectionModalOpen(false);
+ dispatch(importCollectionInWorkspace(rawData, activeWorkspace.uid, undefined, type))
+ .catch((err) => {
+ console.error(err);
+ toast.error('An error occurred while importing the collection');
+ });
+ };
+
+ // Workspace menu handlers
+ const handleRenameWorkspaceClick = () => {
+ setIsRenamingWorkspace(true);
+ setWorkspaceNameInput(activeWorkspace.name);
+ setWorkspaceNameError('');
+ setTimeout(() => {
+ workspaceNameInputRef.current?.focus();
+ workspaceNameInputRef.current?.select();
+ }, 50);
+ };
+
+ const handleCloseWorkspaceClick = () => {
+ dropdownTippyRef.current?.hide();
+ if (activeWorkspace.type === 'default') {
+ toast.error('Cannot close the default workspace');
+ return;
+ }
+ setCloseWorkspaceModalOpen(true);
+ };
+
+ const handleShowInFolder = () => {
+ dropdownTippyRef.current?.hide();
+ if (activeWorkspace.pathname) {
+ dispatch(showInFolder(activeWorkspace.pathname))
+ .catch((error) => {
+ console.error('Error opening the folder', error);
+ toast.error('Error opening the folder');
+ });
+ }
+ };
+
+ const validateWorkspaceName = (name) => {
+ if (!name || name.trim() === '') {
+ return 'Name is required';
+ }
+
+ if (name.length < 1) {
+ return 'Must be at least 1 character';
+ }
+
+ if (name.length > 255) {
+ return 'Must be 255 characters or less';
+ }
+
+ return null;
+ };
+
+ const handleSaveWorkspaceRename = () => {
+ const error = validateWorkspaceName(workspaceNameInput);
+ if (error) {
+ setWorkspaceNameError(error);
+ return;
+ }
+
+ dispatch(renameWorkspaceAction(activeWorkspace.uid, workspaceNameInput))
+ .then(() => {
+ toast.success('Workspace renamed!');
+ setIsRenamingWorkspace(false);
+ setWorkspaceNameInput('');
+ setWorkspaceNameError('');
+ })
+ .catch((err) => {
+ toast.error(err?.message || 'An error occurred while renaming the workspace');
+ setWorkspaceNameError(err?.message || 'Failed to rename workspace');
+ });
+ };
+
+ const handleCancelWorkspaceRename = () => {
+ setIsRenamingWorkspace(false);
+ setWorkspaceNameInput('');
+ setWorkspaceNameError('');
+ };
+
+ const handleWorkspaceNameChange = (e) => {
+ const value = e.target.value;
+ setWorkspaceNameInput(value);
+
+ if (workspaceNameError) {
+ setWorkspaceNameError('');
+ }
+ };
+
+ const handleWorkspaceNameKeyDown = (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ handleSaveWorkspaceRename();
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ handleCancelWorkspaceRename();
+ }
+ };
+
+ if (!activeWorkspace) {
+ return null;
+ }
+
+ const tabs = [
+ {
+ id: 'collections',
+ label: 'Collections',
+ component: (
+
+ )
+ },
+ {
+ id: 'environments',
+ label: 'Environments',
+ component:
+ },
+ {
+ id: 'documentation',
+ label: 'Documentation',
+ component:
+ }
+ ];
+
+ return (
+
+
+ {createCollectionModalOpen && (
+
setCreateCollectionModalOpen(false)}
+ />
+ )}
+
+ {importCollectionModalOpen && (
+ setImportCollectionModalOpen(false)}
+ handleSubmit={handleImportCollectionSubmit}
+ />
+ )}
+
+
+
+
+ {isRenamingWorkspace ? (
+
+
+
+ e.preventDefault()}
+ title="Save"
+ >
+
+
+ e.preventDefault()}
+ title="Cancel"
+ >
+
+
+
+
+ ) : (
+
{activeWorkspace.name}
+ )}
+
+
+ {!isRenamingWorkspace && activeWorkspace.type !== 'default' && (
+
}
+ >
+
+
+
+ Rename
+
+
+
+ Show in Folder
+
+
+
+ Close
+
+
+
+ )}
+
+ {workspaceNameError && isRenamingWorkspace && (
+
{workspaceNameError}
+ )}
+
+
+ {closeWorkspaceModalOpen && (
+ setCloseWorkspaceModalOpen(false)}
+ />
+ )}
+
+
+
+ {tabs.map((tab) => {
+ return (
+ setActiveTab(tab.id)}
+ className={`flex items-center gap-2 py-2 text-sm border-b-2 transition-colors tab-item ${activeTab === tab.id ? 'active' : ''}`}
+ >
+ {tab.label}
+
+ );
+ })}
+
+
+ {activeTab === 'collections' && (
+
+
+
+ Create
+
+
+
+ Add
+
+
+
+ Import
+
+
+ )}
+
+
+
+ {tabs.find((tab) => tab.id === activeTab)?.component}
+
+
+
+ );
+};
+
+export default WorkspaceHome;
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..1d24ac168
--- /dev/null
+++ b/packages/bruno-app/src/components/WorkspaceSidebar/CreateWorkspace/index.js
@@ -0,0 +1,144 @@
+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';
+
+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 (
+
+
+
+ );
+};
+
+export default CreateWorkspace;
diff --git a/packages/bruno-app/src/pages/Bruno/index.js b/packages/bruno-app/src/pages/Bruno/index.js
index 8e5f8b944..de4e387c8 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 WorkspaceHome from 'components/WorkspaceHome';
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..a0ca0523d 100644
--- a/packages/bruno-app/src/providers/App/useIpcEvents.js
+++ b/packages/bruno-app/src/providers/App/useIpcEvents.js
@@ -22,6 +22,7 @@ import {
streamDataReceived
} from 'providers/ReduxStore/slices/collections';
import { collectionAddEnvFileEvent, openCollectionEvent, hydrateCollectionWithUiStateSnapshot, mergeAndPersistEnvironment } from 'providers/ReduxStore/slices/collections/actions';
+import { workspaceOpenedEvent, workspaceConfigUpdatedEvent } from 'providers/ReduxStore/slices/workspaces/actions';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import { isElectron } from 'utils/common/platform';
@@ -98,6 +99,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 +268,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 2228c5ec0..d828797e3 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
@@ -59,6 +59,7 @@ import {
import { each } from 'lodash';
import { closeAllCollectionTabs, updateResponsePaneScrollPosition } from 'providers/ReduxStore/slices/tabs';
+import { removeCollectionFromWorkspace } from 'providers/ReduxStore/slices/workspaces';
import { resolveRequestFilename } from 'utils/common/platform';
import { interpolateUrl, parsePathParams, splitOnFirst } from 'utils/url/index';
import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index';
@@ -1127,19 +1128,15 @@ export const handleCollectionItemDrop
const { pathname: draggedItemPathname, uid: draggedItemUid } = draggedItem;
const newDirname = path.dirname(newPathname);
- await dispatch(
- moveItem({
- targetDirname: newDirname,
- sourcePathname: draggedItemPathname
- })
- );
+ 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
- );
+ // reorder items in the source directory
+ const draggedItemDirectoryItemsWithoutDraggedItem = draggedItemDirectoryItems.filter((i) => i.uid !== draggedItemUid);
const reorderedSourceItems = getReorderedItemsInSourceDirectory({
items: draggedItemDirectoryItemsWithoutDraggedItem
});
@@ -2139,18 +2136,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);
@@ -2256,6 +2283,28 @@ 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
+ };
+
+ // The electron handler will automatically trigger workspace config update
+ // which will cause the app to react and reload collections
+ ipcRenderer
+ .invoke('renderer:add-collection-to-workspace', activeWorkspace.pathname, workspaceCollection)
+ .catch((err) => {
+ console.error('Failed to add collection to workspace', err);
+ toast.error('Failed to add collection to workspace');
+ });
+ }
+ }
+
resolve();
})
.catch(reject);
@@ -2263,12 +2312,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);
});
@@ -2284,11 +2344,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();
+ });
});
};
@@ -2317,11 +2400,29 @@ 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);
+ }
+
+ resolve(collectionPath);
+ } catch (error) {
+ reject(error);
+ }
});
};
@@ -2329,15 +2430,7 @@ 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);
- });
+ return Promise.resolve();
};
export const saveCollectionSecurityConfig = (collectionUid, securityConfig) => (dispatch, getState) => {
@@ -2534,20 +2627,16 @@ export const openCollectionSettings
return reject(new Error('Collection not found'));
}
- dispatch(
- updateSettingsSelectedTab({
- collectionUid: collection.uid,
- tab: tabName
- })
- );
+ dispatch(updateSettingsSelectedTab({
+ collectionUid: collection.uid,
+ tab: tabName
+ }));
- dispatch(
- addTab({
- uid: collection.uid,
- collectionUid: collection.uid,
- type: 'collection-settings'
- })
- );
+ dispatch(addTab({
+ uid: collection.uid,
+ collectionUid: collection.uid,
+ type: 'collection-settings'
+ }));
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 3df3db9e8..0e5503593 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,24 @@ 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);
+ if (!baseEnv) {
+ return reject(new Error('Base environment not found'));
+ }
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,6 +148,7 @@ 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);
if (!environment) {
@@ -136,8 +156,18 @@ export const renameGlobalEnvironment = ({ name: newName, environmentUid }) => (d
}
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 +176,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 +226,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 +243,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);
@@ -217,9 +263,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,19 +277,19 @@ 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);
});
};
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..9e34212d0
--- /dev/null
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js
@@ -0,0 +1,649 @@
+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 { convertInsomniaToBruno } = await import('utils/importers/insomnia-collection');
+ return convertInsomniaToBruno(collection);
+ }
+ case 'openapi': {
+ const { convertOpenapiToBruno } = await import('utils/importers/openapi-collection');
+ return convertOpenapiToBruno(collection);
+ }
+ case 'wsdl': {
+ const { wsdlToBruno } = await import('@usebruno/converters');
+ 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, getState) => {
+ dispatch(createWorkspace({
+ uid: workspaceUid,
+ pathname: workspacePath,
+ ...workspaceConfig
+ }));
+
+ try {
+ await dispatch(loadWorkspaceCollections(workspaceUid));
+ } catch (error) {
+ }
+
+ // If this is the default workspace or no workspace is active yet, switch to it
+ const state = getState();
+ const activeWorkspaceUid = state.workspaces.activeWorkspaceUid;
+
+ if (!activeWorkspaceUid || workspaceConfig.type === 'default') {
+ dispatch(switchWorkspace(workspaceUid));
+ }
+ };
+};
+
+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);
+
+ 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 1c72e0b0f..42d56cbf4 100644
--- a/packages/bruno-app/src/themes/dark.js
+++ b/packages/bruno-app/src/themes/dark.js
@@ -149,6 +149,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 230a6ed17..f085ea807 100644
--- a/packages/bruno-app/src/themes/light.js
+++ b/packages/bruno-app/src/themes/light.js
@@ -152,6 +152,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 fe7ebebe2..8288edf9b 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 TerminalManager = require('./ipc/terminal');
@@ -54,6 +57,8 @@ const lastOpenedCollections = new LastOpenedCollections();
const systemMonitor = new SystemMonitor();
const terminalManager = new TerminalManager();
+const workspaceWatcher = new WorkspaceWatcher();
+
// Reference: https://content-security-policy.com/
const contentSecurityPolicy = [
'default-src \'self\'',
@@ -214,9 +219,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 cf64e682e..b6605ca9f 100644
--- a/packages/bruno-electron/src/ipc/collection.js
+++ b/packages/bruno-electron/src/ipc/collection.js
@@ -30,17 +30,11 @@ const {
sanitizeName,
isWSLPath,
safeToRename,
- getSubDirectories,
isWindowsOS,
- readDir,
hasRequestExtension,
getCollectionFormat,
searchForRequestFiles,
- normalizeAndResolvePath,
validateName,
- chooseFileToSave,
- exists,
- isFile,
getCollectionStats,
sizeInMB,
safeWriteFileSync,
@@ -49,7 +43,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');
@@ -80,12 +74,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
@@ -100,20 +90,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)) {
@@ -317,7 +308,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');
}
@@ -328,7 +319,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);
@@ -806,31 +797,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);
@@ -840,8 +838,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 });
@@ -861,7 +859,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'
@@ -870,21 +868,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) => {
@@ -927,11 +925,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);
}
@@ -1537,7 +1535,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);
@@ -1550,7 +1548,6 @@ const registerMainEventHandlers = (mainWindow, watcher, lastOpenedCollections) =
});
ipcMain.on('main:collection-opened', async (win, pathname, uid, brunoConfig) => {
- lastOpenedCollections.add(pathname);
app.addRecentDocument(pathname);
});
@@ -1568,9 +1565,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 30c988895..2432e283e 100644
--- a/packages/bruno-electron/src/ipc/global-environments.js
+++ b/packages/bruno-electron/src/ipc/global-environments.js
@@ -3,56 +3,99 @@ 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 {
+ 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);
}
});
diff --git a/packages/bruno-electron/src/ipc/preferences.js b/packages/bruno-electron/src/ipc/preferences.js
index 79bffbc36..69106c84a 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,18 +24,7 @@ const registerPreferencesIpc = (mainWindow, watcher, lastOpenedCollections) => {
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.emit('main:renderer-ready', mainWindow);
});
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..4610516ad
--- /dev/null
+++ b/packages/bruno-electron/src/ipc/workspace.js
@@ -0,0 +1,502 @@
+const fs = require('fs');
+const path = require('path');
+const fsExtra = require('fs-extra');
+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);
+ 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: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);
+ const updatedCollections = await addCollectionToWorkspace(workspacePath, normalizedCollection);
+
+ const workspaceConfig = readWorkspaceConfig(workspacePath);
+ const workspaceUid = generateUidBasedOnHash(workspacePath);
+ mainWindow.webContents.send('main:workspace-config-updated', workspacePath, workspaceUid, workspaceConfig);
+
+ return updatedCollections;
+ } 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)) {
+ 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;
+ }
+ });
+
+ ipcMain.on('main:renderer-ready', async (win) => {
+ try {
+ const defaultResult = await defaultWorkspaceManager.ensureDefaultWorkspaceExists();
+ if (defaultResult) {
+ const { workspacePath, workspaceUid } = defaultResult;
+ const workspaceFilePath = path.join(workspacePath, 'workspace.yml');
+
+ if (fs.existsSync(workspaceFilePath)) {
+ const yamlContent = fs.readFileSync(workspaceFilePath, 'utf8');
+ const workspaceConfig = yaml.load(yamlContent);
+
+ win.webContents.send('main:workspace-opened', workspacePath, workspaceUid, {
+ ...workspaceConfig,
+ type: 'default'
+ });
+
+ if (workspaceWatcher) {
+ workspaceWatcher.addWatcher(win, workspacePath);
+ }
+ }
+ }
+
+ const workspaces = lastOpenedWorkspaces.getAll();
+ const invalidWorkspaceUids = [];
+
+ for (const workspace of workspaces) {
+ if (workspace.pathname) {
+ const workspaceYmlPath = path.join(workspace.pathname, 'workspace.yml');
+
+ if (fs.existsSync(workspaceYmlPath)) {
+ try {
+ const workspaceConfig = readWorkspaceConfig(workspace.pathname);
+ validateWorkspaceConfig(workspaceConfig);
+ const workspaceUid = generateUidBasedOnHash(workspace.pathname);
+
+ win.webContents.send('main:workspace-opened', workspace.pathname, workspaceUid, workspaceConfig);
+
+ if (workspaceWatcher) {
+ workspaceWatcher.addWatcher(win, workspace.pathname);
+ }
+ } catch (error) {
+ console.error(`Error loading workspace ${workspace.pathname}:`, error);
+ invalidWorkspaceUids.push(workspace.uid);
+ }
+ } else {
+ invalidWorkspaceUids.push(workspace.uid);
+ }
+ } else {
+ invalidWorkspaceUids.push(workspace.uid);
+ }
+ }
+
+ for (const uid of invalidWorkspaceUids) {
+ lastOpenedWorkspaces.remove(uid);
+ }
+ } catch (error) {
+ console.error('Error initializing workspaces:', error);
+ }
+ });
+};
+
+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..8f259a9b9
--- /dev/null
+++ b/packages/bruno-electron/src/store/default-workspace.js
@@ -0,0 +1,218 @@
+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(newWorkspacePath, 'workspace.yml');
+ if (!fs.existsSync(workspaceYmlPath)) {
+ this.defaultWorkspacePath = null;
+ return null;
+ } else {
+ return {
+ workspacePath: newWorkspacePath,
+ 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 4880b0884..9f3b5f11e 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
+};
diff --git a/tests/collection/close-all-collections/close-all-collections.spec.ts b/tests/collection/close-all-collections/close-all-collections.spec.ts
index 2f458de8b..c43d06d6d 100644
--- a/tests/collection/close-all-collections/close-all-collections.spec.ts
+++ b/tests/collection/close-all-collections/close-all-collections.spec.ts
@@ -16,7 +16,10 @@ const restartAppAndGetLocators = async (restartApp: (options?: { initUserDataPat
return { app, page, locators };
};
-test.describe('Close All Collections', () => {
+// TODO: These tests need to be updated for the new workspace UI
+// The CollectionsHeader component (with close-all-collections-button) is not rendered in workspace mode
+// The "Remove from workspace" flow is different from the old "Close collection" flow
+test.describe.skip('Close All Collections', () => {
test.afterAll(async () => {
// Reset the request file to the original state after saving changes
execSync(`git checkout -- "${path.join(__dirname, 'fixtures', 'collections', 'collection 1', 'test-request.bru')}"`);
diff --git a/tests/collection/create/create-collection.spec.ts b/tests/collection/create/create-collection.spec.ts
index 30557e4a0..147983a72 100644
--- a/tests/collection/create/create-collection.spec.ts
+++ b/tests/collection/create/create-collection.spec.ts
@@ -8,14 +8,17 @@ test.describe('Create collection', () => {
});
test('Create collection and add a simple HTTP request', async ({ page, createTmpDir }) => {
- // Create a new collection
- await page.getByLabel('Create Collection').click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }).click();
await page.getByLabel('Name').click();
await page.getByLabel('Name').fill('test-collection');
await page.getByLabel('Name').press('Tab');
- await page.getByLabel('Location').fill(await createTmpDir('test-collection'));
- await page.getByRole('button', { name: 'Create', exact: true }).click();
- await page.getByText('test-collection').click();
+ const locationInput = page.locator('.bruno-modal').getByLabel('Location');
+ if (await locationInput.isVisible()) {
+ await locationInput.fill(await createTmpDir('test-collection'));
+ }
+ await page.locator('.bruno-modal').getByRole('button', { name: 'Create', exact: true }).click();
+ await page.locator('#sidebar-collection-name').filter({ hasText: 'test-collection' }).click();
// Select safe mode
await page.getByLabel('Safe Mode').check();
diff --git a/tests/collection/moving-requests/tag-persistence.spec.ts b/tests/collection/moving-requests/tag-persistence.spec.ts
index 6785791d9..7ae0020ba 100644
--- a/tests/collection/moving-requests/tag-persistence.spec.ts
+++ b/tests/collection/moving-requests/tag-persistence.spec.ts
@@ -8,12 +8,16 @@ test.describe('Tag persistence', () => {
});
test('Verify tag persistence while moving requests within a collection', async ({ page, createTmpDir }) => {
- // Create first collection - click dropdown menu first
- await page.getByLabel('Create Collection').click();
+ // Create first collection - click plus icon button to open dropdown
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }).click();
await page.getByLabel('Name').fill('test-collection');
- await page.getByLabel('Location').fill(await createTmpDir('test-collection'));
- await page.getByRole('button', { name: 'Create', exact: true }).click();
- await page.getByText('test-collection').click();
+ const locationInput = page.locator('.bruno-modal').getByLabel('Location');
+ if (await locationInput.isVisible()) {
+ await locationInput.fill(await createTmpDir('test-collection'));
+ }
+ await page.locator('.bruno-modal').getByRole('button', { name: 'Create', exact: true }).click();
+ await page.locator('#sidebar-collection-name').filter({ hasText: 'test-collection' }).click();
await page.getByLabel('Safe Mode').check();
await page.getByRole('button', { name: 'Save' }).click();
@@ -74,12 +78,16 @@ test.describe('Tag persistence', () => {
});
test('verify tag persistence while moving requests between folders', async ({ page, createTmpDir }) => {
- // Create first collection - click dropdown menu first
- await page.getByLabel('Create Collection').click();
+ // Create first collection - click plus icon button to open dropdown
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }).click();
await page.getByLabel('Name').fill('test-collection');
- await page.getByLabel('Location').fill(await createTmpDir('test-collection'));
- await page.getByRole('button', { name: 'Create', exact: true }).click();
- await page.getByText('test-collection').click();
+ const locationInput = page.locator('.bruno-modal').getByLabel('Location');
+ if (await locationInput.isVisible()) {
+ await locationInput.fill(await createTmpDir('test-collection'));
+ }
+ await page.locator('.bruno-modal').getByRole('button', { name: 'Create', exact: true }).click();
+ await page.locator('#sidebar-collection-name').filter({ hasText: 'test-collection' }).click();
await page.getByLabel('Safe Mode').check();
await page.getByRole('button', { name: 'Save' }).click();
diff --git a/tests/collection/moving-tabs/move-tabs.spec.ts b/tests/collection/moving-tabs/move-tabs.spec.ts
index 8a590e0c6..6700ff05b 100644
--- a/tests/collection/moving-tabs/move-tabs.spec.ts
+++ b/tests/collection/moving-tabs/move-tabs.spec.ts
@@ -9,11 +9,14 @@ test.describe('Move tabs', () => {
test('Verify tab move by drag and drop', async ({ page, createTmpDir }) => {
// Create a collection
- await page.locator('.dropdown-icon').click();
- await page.locator('.dropdown-item').filter({ hasText: 'Create Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }).click();
await page.getByLabel('Name').fill('source-collection-drag-drop');
- await page.getByLabel('Location').fill(await createTmpDir('source-collection-drag-drop'));
- await page.getByRole('button', { name: 'Create', exact: true }).click();
+ const locationInput = page.locator('.bruno-modal').getByLabel('Location');
+ if (await locationInput.isVisible()) {
+ await locationInput.fill(await createTmpDir('source-collection-drag-drop'));
+ }
+ await page.locator('.bruno-modal').getByRole('button', { name: 'Create', exact: true }).click();
// Wait for collection to appear and click on it
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'source-collection-drag-drop' })).toBeVisible();
@@ -97,11 +100,14 @@ test.describe('Move tabs', () => {
test('Verify tab move by keyboard shortcut', async ({ page, createTmpDir }) => {
// Create a collection
- await page.locator('.dropdown-icon').click();
- await page.locator('.dropdown-item').filter({ hasText: 'Create Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }).click();
await page.getByLabel('Name').fill('source-collection-keyboard-shortcut');
- await page.getByLabel('Location').fill(await createTmpDir('source-collection-keyboard-shortcut'));
- await page.getByRole('button', { name: 'Create', exact: true }).click();
+ const locationInput2 = page.locator('.bruno-modal').getByLabel('Location');
+ if (await locationInput2.isVisible()) {
+ await locationInput2.fill(await createTmpDir('source-collection-keyboard-shortcut'));
+ }
+ await page.locator('.bruno-modal').getByRole('button', { name: 'Create', exact: true }).click();
// Wait for collection to appear and click on it
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'source-collection-keyboard-shortcut' })).toBeVisible();
diff --git a/tests/collection/open/open-multiple-collections.spec.ts b/tests/collection/open/open-multiple-collections.spec.ts
index 3d12b3b81..835ffbe5f 100644
--- a/tests/collection/open/open-multiple-collections.spec.ts
+++ b/tests/collection/open/open-multiple-collections.spec.ts
@@ -1,7 +1,6 @@
import { test, expect } from '../../../playwright';
import * as path from 'path';
import * as fs from 'fs';
-
import { closeAllCollections } from '../../utils/page';
test.describe('Open Multiple Collections', () => {
@@ -57,8 +56,9 @@ test.describe('Open Multiple Collections', () => {
await expect(page.locator('#sidebar-collection-name').getByText('Test Collection 1')).not.toBeVisible();
- // Click on Open Collection button
- await page.locator('button').filter({ hasText: 'Open Collection' }).click();
+ // Click on plus icon button and then "Open collection" in the dropdown
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Open collection' }).click();
// Wait for both collections to appear in the sidebar
const collection1Element = page.locator('#sidebar-collection-name').getByText('Test Collection 1');
@@ -80,6 +80,9 @@ test.describe('Open Multiple Collections', () => {
const collection1Dir = await createTmpDir('collection-1');
const collection2Dir = 'invalid-collection-path';
+ // Count collections before attempting to open invalid ones
+ const collectionCountBefore = await page.locator('#sidebar-collection-name').count();
+
// Mock the electron dialog to return multiple folder selections
await electronApp.evaluate(({ dialog }, { collection1Dir, collection2Dir }) => {
dialog.showOpenDialog = async () => ({
@@ -89,20 +92,17 @@ test.describe('Open Multiple Collections', () => {
},
{ collection1Dir, collection2Dir });
- await expect(page.locator('#sidebar-collection-name').getByText('Test Collection 1')).not.toBeVisible();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Open collection' }).click();
- // Click on Open Collection button
- await page.getByRole('button', { name: 'Open Collection' }).click();
+ // Wait for error toasts to appear
+ await page.waitForTimeout(1000);
// Verify no collections were opened
- await expect(page.locator('#sidebar-collection-name')).toHaveCount(0);
+ await expect(page.locator('#sidebar-collection-name')).toHaveCount(collectionCountBefore);
// Verify invalid collection error
const invalidCollectionError = page.getByText('The collection is not valid').first();
await expect(invalidCollectionError).toBeVisible();
-
- // Verify invalid path error
- const invalidPathError = page.getByText('Some selected folders could not be opened').getByText('invalid-collection-path').first();
- await expect(invalidPathError).toBeVisible();
});
});
diff --git a/tests/environments/create-environment/collection-env-create.spec.ts b/tests/environments/create-environment/collection-env-create.spec.ts
index 1a34e2fc8..f44439f2d 100644
--- a/tests/environments/create-environment/collection-env-create.spec.ts
+++ b/tests/environments/create-environment/collection-env-create.spec.ts
@@ -9,7 +9,8 @@ test.describe('Collection Environment Create Tests', () => {
const openApiFile = path.join(__dirname, 'fixtures', 'bruno-collection.json');
// Import test collection
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
const importModal = page.locator('[data-testid="import-collection-modal"]');
await importModal.waitFor({ state: 'visible' });
@@ -21,7 +22,7 @@ test.describe('Collection Environment Create Tests', () => {
await expect(locationModal.getByText('test_collection')).toBeVisible();
await page.locator('#collection-location').fill(await createTmpDir('env-test'));
- await page.getByRole('button', { name: 'Import', exact: true }).click();
+ await locationModal.getByRole('button', { name: 'Import' }).click();
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'test_collection' })).toBeVisible();
@@ -113,18 +114,13 @@ test.describe('Collection Environment Create Tests', () => {
await expect(responsePane).toContainText('"body": "This is a test post body with environment variables"');
await expect(responsePane).toContainText('"apiToken": "super-secret-token-12345"');
- // Cleanup
- await page
- .locator('.collection-name')
- .filter({ has: page.locator('#sidebar-collection-name:has-text("test_collection")') })
- .locator('.collection-actions')
- .click();
- await page.locator('.dropdown-item').filter({ hasText: 'Close' }).click();
- // Scope the Close button to the confirmation modal to avoid matching the dropdown close button
- // Wait for the confirmation modal with "Close Collection" title to appear
- const closeModal = page.getByRole('dialog').filter({ has: page.getByText('Close Collection') });
- await closeModal.getByRole('button', { name: 'Close' }).click();
+ // Cleanup - use new "Remove" action in workspace UI
+ const collectionRow = page.locator('.collection-name').filter({ has: page.locator('#sidebar-collection-name:has-text("test_collection")') });
+ await collectionRow.hover();
+ await collectionRow.locator('.collection-actions .icon').click();
+ await page.locator('.dropdown-item').filter({ hasText: 'Remove' }).click();
- await page.locator('.bruno-logo').click();
+ const closeModal = page.getByRole('dialog').filter({ has: page.getByText('Remove Collection') });
+ await closeModal.getByRole('button', { name: 'Remove' }).click();
});
});
diff --git a/tests/environments/create-environment/global-env-create.spec.ts b/tests/environments/create-environment/global-env-create.spec.ts
index 5ca860fce..32ffa6641 100644
--- a/tests/environments/create-environment/global-env-create.spec.ts
+++ b/tests/environments/create-environment/global-env-create.spec.ts
@@ -1,5 +1,6 @@
import { test, expect } from '../../../playwright';
import path from 'path';
+import { closeAllCollections } from '../../utils/page';
test.describe('Global Environment Create Tests', () => {
test('should import collection and create global environment for request usage', async ({
@@ -9,20 +10,22 @@ test.describe('Global Environment Create Tests', () => {
const openApiFile = path.join(__dirname, 'fixtures', 'bruno-collection.json');
// Import test collection
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
const importModal = page.locator('[data-testid="import-collection-modal"]');
await importModal.waitFor({ state: 'visible' });
await page.setInputFiles('input[type="file"]', openApiFile);
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
+ // Wait for location modal to appear after file processing
const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
+ await locationModal.waitFor({ state: 'visible', timeout: 10000 });
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
await expect(locationModal.getByText('test_collection')).toBeVisible();
await page.locator('#collection-location').fill(await createTmpDir('global-env-test'));
- await page.getByRole('button', { name: 'Import', exact: true }).click();
+ await locationModal.getByRole('button', { name: 'Import' }).click();
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'test_collection' })).toBeVisible();
@@ -116,18 +119,7 @@ test.describe('Global Environment Create Tests', () => {
await expect(responsePane).toContainText('"body": "This is a global test post body with environment variables"');
await expect(responsePane).toContainText('"apiToken": "global-secret-token-12345"');
- // Cleanup
- await page
- .locator('.collection-name')
- .filter({ has: page.locator('#sidebar-collection-name:has-text("test_collection")') })
- .locator('.collection-actions')
- .click();
- await page.locator('.dropdown-item').filter({ hasText: 'Close' }).click();
- // Scope the Close button to the confirmation modal to avoid matching the dropdown close button
- // Wait for the confirmation modal with "Close Collection" title to appear
- const closeModal = page.getByRole('dialog').filter({ has: page.getByText('Close Collection') });
- await closeModal.getByRole('button', { name: 'Close' }).click();
-
- await page.locator('.bruno-logo').click();
+ // cleanup: close all collections
+ await closeAllCollections(page);
});
});
diff --git a/tests/environments/import-environment/collection-env-import.spec.ts b/tests/environments/import-environment/collection-env-import.spec.ts
index 0e5097f54..fa6ac1c8c 100644
--- a/tests/environments/import-environment/collection-env-import.spec.ts
+++ b/tests/environments/import-environment/collection-env-import.spec.ts
@@ -12,24 +12,24 @@ test.describe('Collection Environment Import Tests', () => {
const envFile = path.join(__dirname, 'fixtures', 'collection-env.json');
// Import test collection
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
const importModal = page.locator('[data-testid="import-collection-modal"]');
await importModal.waitFor({ state: 'visible' });
await page.setInputFiles('input[type="file"]', openApiFile);
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
await expect(locationModal.getByText('Environment Test Collection')).toBeVisible();
+ // Select a location and import
await page.locator('#collection-location').fill(await createTmpDir('collection-env-import-test'));
- await page.getByRole('button', { name: 'Import', exact: true }).click();
+ await locationModal.getByRole('button', { name: 'Import' }).click();
await expect(
- page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Test Collection' })
- ).toBeVisible();
+ page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Test Collection' })).toBeVisible({ timeout: 10000 });
// Configure collection
await page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Test Collection' }).click();
diff --git a/tests/environments/import-environment/global-env-import.spec.ts b/tests/environments/import-environment/global-env-import.spec.ts
index 8e5205296..917675ef9 100644
--- a/tests/environments/import-environment/global-env-import.spec.ts
+++ b/tests/environments/import-environment/global-env-import.spec.ts
@@ -1,5 +1,6 @@
import { test, expect } from '../../../playwright';
import path from 'path';
+import { closeAllCollections } from '../../utils/page';
test.describe('Global Environment Import Tests', () => {
test('should import global environment from file', async ({ newPage: page, createTmpDir }) => {
@@ -7,24 +8,23 @@ test.describe('Global Environment Import Tests', () => {
const globalEnvFile = path.join(__dirname, 'fixtures', 'global-env.json');
// Import test collection
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
const importModal = page.locator('[data-testid="import-collection-modal"]');
await importModal.waitFor({ state: 'visible' });
await page.setInputFiles('input[type="file"]', openApiFile);
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
await expect(locationModal.getByText('Environment Test Collection')).toBeVisible();
await page.locator('#collection-location').fill(await createTmpDir('global-env-import-test'));
- await page.getByRole('button', { name: 'Import', exact: true }).click();
+ await locationModal.getByRole('button', { name: 'Import' }).click();
await expect(
- page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Test Collection' })
- ).toBeVisible();
+ page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Test Collection' })).toBeVisible({ timeout: 10000 });
// Configure collection
await page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Test Collection' }).click();
@@ -79,16 +79,7 @@ test.describe('Global Environment Import Tests', () => {
await page.locator('[data-testid="response-status-code"]').waitFor({ state: 'visible' });
await expect(page.locator('[data-testid="response-status-code"]')).toContainText('201');
- // Cleanup
- await page.locator('#sidebar-collection-name').filter({ hasText: 'Environment Test Collection' }).click();
- await page
- .locator('.collection-name')
- .filter({ has: page.locator('#sidebar-collection-name:has-text("Environment Test Collection")') })
- .locator('.collection-actions')
- .click();
- await page.locator('.dropdown-item').filter({ hasText: 'Close' }).click();
- await page.locator('.dropdown-item').filter({ hasText: 'Close' }).waitFor({ state: 'detached' });
- const closeModal = page.getByRole('dialog').filter({ has: page.getByText('Close Collection') });
- await closeModal.getByRole('button', { name: 'Close' }).click();
+ // cleanup: close all collections
+ await closeAllCollections(page);
});
});
diff --git a/tests/environments/multiline-variables/read-multiline-environment.spec.ts b/tests/environments/multiline-variables/read-multiline-environment.spec.ts
index fdf2fefc7..d9ec80636 100644
--- a/tests/environments/multiline-variables/read-multiline-environment.spec.ts
+++ b/tests/environments/multiline-variables/read-multiline-environment.spec.ts
@@ -5,8 +5,9 @@ test.describe('Multiline Variables - Read Environment Test', () => {
test.setTimeout(30 * 1000);
// open the collection
- await expect(page.getByTitle('multiline-variables')).toBeVisible();
- await page.getByTitle('multiline-variables').click();
+ const collection = page.getByTestId('collections').locator('#sidebar-collection-name').filter({ hasText: 'multiline-variables' });
+ await expect(collection).toBeVisible();
+ await collection.click();
// open request
await expect(page.getByTitle('request', { exact: true })).toBeVisible();
diff --git a/tests/environments/multiline-variables/write-multiline-variable.spec.ts b/tests/environments/multiline-variables/write-multiline-variable.spec.ts
index 6d4f63a18..d774d88a3 100644
--- a/tests/environments/multiline-variables/write-multiline-variable.spec.ts
+++ b/tests/environments/multiline-variables/write-multiline-variable.spec.ts
@@ -5,8 +5,9 @@ test.describe('Multiline Variables - Write Test', () => {
test.setTimeout(60 * 1000);
// open the collection
- await expect(page.getByTitle('multiline-variables')).toBeVisible();
- await page.getByTitle('multiline-variables').click();
+ const collection = page.getByTestId('collections').locator('#sidebar-collection-name').filter({ hasText: 'multiline-variables' });
+ await expect(collection).toBeVisible();
+ await collection.click();
// open request
await expect(page.getByTitle('multiline-test', { exact: true })).toBeVisible();
@@ -88,8 +89,4 @@ test.describe('Multiline Variables - Write Test', () => {
fs.writeFileSync(testBruPath, content);
});
-
- test.afterAll(async ({ page }) => {
- await page.locator('.bruno-logo').click();
- });
});
diff --git a/tests/import/bruno/import-bruno-corrupted-fails.spec.ts b/tests/import/bruno/import-bruno-corrupted-fails.spec.ts
index 6fe2a0799..76da05539 100644
--- a/tests/import/bruno/import-bruno-corrupted-fails.spec.ts
+++ b/tests/import/bruno/import-bruno-corrupted-fails.spec.ts
@@ -5,7 +5,8 @@ test.describe('Import Corrupted Bruno Collection - Should Fail', () => {
test('Import Bruno collection with invalid JSON structure should fail', async ({ page }) => {
const brunoFile = path.resolve(__dirname, 'fixtures', 'bruno-malformed.json');
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByRole('dialog');
@@ -14,16 +15,13 @@ test.describe('Import Corrupted Bruno Collection - Should Fail', () => {
await page.setInputFiles('input[type="file"]', brunoFile);
- // Wait for the loader to disappear
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
-
// Check for JSON parsing error
- const hasImportError = await page.getByText('Failed to parse the file – ensure it is valid JSON or YAML').first().isVisible();
+ const hasImportError = await page.getByText('Failed to parse the file – ensure it is valid JSON or YAML').first().isVisible({ timeout: 5000 });
// Either parsing error or import error should be shown
expect(hasImportError).toBe(true);
// Cleanup: close any open modals
- await page.locator('[data-test-id="modal-close-button"]').click();
+ await page.getByTestId('modal-close-button').click();
});
});
diff --git a/tests/import/bruno/import-bruno-missing-required-schema.spec.ts b/tests/import/bruno/import-bruno-missing-required-schema.spec.ts
index 53dcd08bc..f9a131bf2 100644
--- a/tests/import/bruno/import-bruno-missing-required-schema.spec.ts
+++ b/tests/import/bruno/import-bruno-missing-required-schema.spec.ts
@@ -5,7 +5,8 @@ test.describe('Import Bruno Collection - Missing Required Schema Fields', () =>
test('Import Bruno collection missing required version field should fail', async ({ page }) => {
const brunoFile = path.resolve(__dirname, 'fixtures', 'bruno-missing-required-fields.json');
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByRole('dialog');
@@ -14,15 +15,12 @@ test.describe('Import Bruno Collection - Missing Required Schema Fields', () =>
await page.setInputFiles('input[type="file"]', brunoFile);
- // Wait for the loader to disappear
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
-
// Check for schema validation error messages
- const hasImportError = await page.getByText('Unsupported collection format').first().isVisible();
+ const hasImportError = await page.getByText('Unsupported collection format').first().isVisible({ timeout: 5000 });
expect(hasImportError).toBe(true);
// Cleanup: close any open modals
- await page.locator('[data-test-id="modal-close-button"]').click();
+ await page.getByTestId('modal-close-button').click();
});
});
diff --git a/tests/import/bruno/import-bruno-testbench.spec.ts b/tests/import/bruno/import-bruno-testbench.spec.ts
index a2590fd60..3ddf3d484 100644
--- a/tests/import/bruno/import-bruno-testbench.spec.ts
+++ b/tests/import/bruno/import-bruno-testbench.spec.ts
@@ -3,20 +3,15 @@ import * as path from 'path';
import { closeAllCollections } from '../../utils/page';
test.describe('Import Bruno Testbench Collection', () => {
- test.beforeAll(async ({ page }) => {
- // Navigate back to homescreen after all tests
- await page.locator('.bruno-logo').click();
- });
-
- test.afterEach(async ({ page }) => {
- // cleanup: close all collections
+ test.afterAll(async ({ page }) => {
await closeAllCollections(page);
});
test('Import Bruno Testbench collection successfully', async ({ page, createTmpDir }) => {
const brunoFile = path.resolve(__dirname, 'fixtures', 'bruno-testbench.json');
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByRole('dialog');
@@ -25,18 +20,16 @@ test.describe('Import Bruno Testbench Collection', () => {
await page.setInputFiles('input[type="file"]', brunoFile);
- // Wait for the loader to disappear
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
-
- // Verify that the Import Collection modal is displayed (for location selection)
- const locationModal = page.getByRole('dialog');
+ // Wait for location modal to appear after file processing
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
+ await locationModal.waitFor({ state: 'visible', timeout: 10000 });
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
// Wait for collection to appear in the location modal
await expect(locationModal.getByText('bruno-testbench')).toBeVisible();
await page.locator('#collection-location').fill(await createTmpDir('bruno-testbench-test'));
- await page.getByRole('button', { name: 'Import', exact: true }).click();
+ await locationModal.getByRole('button', { name: 'Import' }).click();
await expect(page.locator('#sidebar-collection-name').getByText('bruno-testbench')).toBeVisible();
});
diff --git a/tests/import/bruno/import-bruno-with-examples.spec.ts b/tests/import/bruno/import-bruno-with-examples.spec.ts
index 7f188a3ec..71078e8c5 100644
--- a/tests/import/bruno/import-bruno-with-examples.spec.ts
+++ b/tests/import/bruno/import-bruno-with-examples.spec.ts
@@ -11,7 +11,8 @@ test.describe('Import Bruno Collection with Examples', () => {
const brunoFile = path.resolve(__dirname, 'fixtures', 'bruno-with-examples.json');
await test.step('Open import collection modal', async () => {
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
});
await test.step('Wait for import modal and verify title', async () => {
@@ -24,10 +25,6 @@ test.describe('Import Bruno Collection with Examples', () => {
await page.setInputFiles('input[type="file"]', brunoFile);
});
- await test.step('Wait for file processing to complete', async () => {
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
- });
-
await test.step('Verify no parsing errors occurred', async () => {
const hasError = await page.getByText('Failed to parse the file').isVisible().catch(() => false);
if (hasError) {
@@ -36,12 +33,12 @@ test.describe('Import Bruno Collection with Examples', () => {
});
await test.step('Verify location selection modal appears', async () => {
- const locationModal = page.getByRole('dialog');
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
});
await test.step('Verify collection name appears in location modal', async () => {
- const locationModal = page.getByRole('dialog');
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
await expect(locationModal.getByText('bruno-with-examples')).toBeVisible();
await page.getByTestId('modal-close-button').click();
});
diff --git a/tests/import/file-types/file-input-acceptance.spec.ts b/tests/import/file-types/file-input-acceptance.spec.ts
index 3718ffd13..481440ede 100644
--- a/tests/import/file-types/file-input-acceptance.spec.ts
+++ b/tests/import/file-types/file-input-acceptance.spec.ts
@@ -2,7 +2,8 @@ import { test, expect } from '../../../playwright';
test.describe('File Input Acceptance', () => {
test('File input accepts expected file types', async ({ page }) => {
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Check that file input exists (even if hidden)
const fileInput = page.locator('input[type="file"]');
diff --git a/tests/import/file-types/invalid-file-handling.spec.ts b/tests/import/file-types/invalid-file-handling.spec.ts
index e8439fa51..bccf0c81f 100644
--- a/tests/import/file-types/invalid-file-handling.spec.ts
+++ b/tests/import/file-types/invalid-file-handling.spec.ts
@@ -5,7 +5,8 @@ test.describe('Invalid File Handling', () => {
test('Handle invalid file without crashing', async ({ page }) => {
const invalidFile = path.resolve(__dirname, 'fixtures', 'invalid.txt');
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByRole('dialog');
diff --git a/tests/import/insomnia/import-insomnia-v4-environments.spec.ts b/tests/import/insomnia/import-insomnia-v4-environments.spec.ts
index fa1e0d8dc..ac6df41d7 100644
--- a/tests/import/insomnia/import-insomnia-v4-environments.spec.ts
+++ b/tests/import/insomnia/import-insomnia-v4-environments.spec.ts
@@ -23,7 +23,8 @@ test.describe('Import Insomnia v4 Collection - Environment Import', () => {
const insomniaFile = path.resolve(__dirname, 'fixtures', 'insomnia-v4-with-envs.json');
await test.step('Import Insomnia v4 collection with environments', async () => {
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
const importModal = page.getByTestId('import-collection-modal');
await importModal.waitFor({ state: 'visible' });
@@ -31,13 +32,13 @@ test.describe('Import Insomnia v4 Collection - Environment Import', () => {
await page.setInputFiles('input[type="file"]', insomniaFile);
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
+ await locationModal.waitFor({ state: 'visible', timeout: 10000 });
- const locationModal = page.getByTestId('import-collection-location-modal');
await expect(locationModal.getByText('Test API Collection v4 with Environments')).toBeVisible();
await page.locator('#collection-location').fill(await createTmpDir('insomnia-v4-env-test'));
- await page.getByRole('button', { name: 'Import', exact: true }).click();
+ await locationModal.getByRole('button', { name: 'Import' }).click();
await expect(page.locator('#sidebar-collection-name').getByText('Test API Collection v4 with Environments')).toBeVisible();
diff --git a/tests/import/insomnia/import-insomnia-v4.spec.ts b/tests/import/insomnia/import-insomnia-v4.spec.ts
index 042ad948b..1cb0a55ec 100644
--- a/tests/import/insomnia/import-insomnia-v4.spec.ts
+++ b/tests/import/insomnia/import-insomnia-v4.spec.ts
@@ -11,7 +11,8 @@ test.describe('Import Insomnia Collection v4', () => {
test('Import Insomnia Collection v4 successfully', async ({ page, createTmpDir }) => {
const insomniaFile = path.resolve(__dirname, 'fixtures', 'insomnia-v4.json');
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByRole('dialog');
@@ -20,15 +21,13 @@ test.describe('Import Insomnia Collection v4', () => {
await page.setInputFiles('input[type="file"]', insomniaFile);
- // Wait for the loader to disappear
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
-
- // Verify that the Import Collection modal is displayed (for location selection)
- const locationModal = page.getByRole('dialog');
+ // Wait for location modal to appear after file processing
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
+ await locationModal.waitFor({ state: 'visible', timeout: 10000 });
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
await page.locator('#collection-location').fill(await createTmpDir('insomnia-v4-test'));
- await page.getByRole('button', { name: 'Import', exact: true }).click();
+ await locationModal.getByRole('button', { name: 'Import' }).click();
await expect(page.locator('#sidebar-collection-name').getByText('Test API Collection v4')).toBeVisible();
});
diff --git a/tests/import/insomnia/import-insomnia-v5-environments.spec.ts b/tests/import/insomnia/import-insomnia-v5-environments.spec.ts
index 178621667..745ab753a 100644
--- a/tests/import/insomnia/import-insomnia-v5-environments.spec.ts
+++ b/tests/import/insomnia/import-insomnia-v5-environments.spec.ts
@@ -23,7 +23,8 @@ test.describe('Import Insomnia v5 Collection - Environment Import', () => {
const insomniaFile = path.resolve(__dirname, 'fixtures', 'insomnia-v5-with-envs.yaml');
await test.step('Import Insomnia v5 collection with environments', async () => {
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
const importModal = page.getByTestId('import-collection-modal');
await importModal.waitFor({ state: 'visible' });
@@ -31,15 +32,12 @@ test.describe('Import Insomnia v5 Collection - Environment Import', () => {
await page.setInputFiles('input[type="file"]', insomniaFile);
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
-
- const locationModal = page.getByTestId('import-collection-location-modal');
- await expect(locationModal.getByText('Test API Collection v5 with Environments')).toBeVisible();
+ // Wait for location modal to appear after file processing
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
+ await locationModal.waitFor({ state: 'visible', timeout: 10000 });
await page.locator('#collection-location').fill(await createTmpDir('insomnia-v5-env-test'));
- await page.getByRole('button', { name: 'Import', exact: true }).click();
-
- await expect(page.getByText('Test API Collection v5 with Environments')).toBeVisible();
+ await locationModal.getByRole('button', { name: 'Import' }).click();
await openCollectionAndAcceptSandbox(page, 'Test API Collection v5 with Environments', 'safe');
});
diff --git a/tests/import/insomnia/import-insomnia-v5.spec.ts b/tests/import/insomnia/import-insomnia-v5.spec.ts
index 8c66ed582..7a00b6ebf 100644
--- a/tests/import/insomnia/import-insomnia-v5.spec.ts
+++ b/tests/import/insomnia/import-insomnia-v5.spec.ts
@@ -11,7 +11,8 @@ test.describe('Import Insomnia Collection v5', () => {
test('Import Insomnia Collection v5 successfully', async ({ page, createTmpDir }) => {
const insomniaFile = path.resolve(__dirname, 'fixtures', 'insomnia-v5.yaml');
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByRole('dialog');
@@ -20,15 +21,13 @@ test.describe('Import Insomnia Collection v5', () => {
await page.setInputFiles('input[type="file"]', insomniaFile);
- // Wait for the loader to disappear
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
-
- // Verify that the Import Collection modal is displayed (for location selection)
- const locationModal = page.getByRole('dialog');
+ // Wait for location modal to appear after file processing
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
+ await locationModal.waitFor({ state: 'visible', timeout: 10000 });
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
await page.locator('#collection-location').fill(await createTmpDir('insomnia-v5-test'));
- await page.getByRole('button', { name: 'Import', exact: true }).click();
+ await locationModal.getByRole('button', { name: 'Import' }).click();
await expect(page.locator('#sidebar-collection-name').getByText('Test API Collection v5')).toBeVisible();
});
diff --git a/tests/import/insomnia/invalid-missing-collection.spec.ts b/tests/import/insomnia/invalid-missing-collection.spec.ts
index 2882b4a61..db1f2cfea 100644
--- a/tests/import/insomnia/invalid-missing-collection.spec.ts
+++ b/tests/import/insomnia/invalid-missing-collection.spec.ts
@@ -5,7 +5,8 @@ test.describe('Invalid Insomnia Collection - Missing Collection Array', () => {
test('Handle Insomnia v5 collection missing collection array', async ({ page }) => {
const insomniaFile = path.resolve(__dirname, 'fixtures', 'insomnia-v5-invalid-missing-collection.yaml');
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByRole('dialog');
@@ -14,9 +15,6 @@ test.describe('Invalid Insomnia Collection - Missing Collection Array', () => {
await page.setInputFiles('input[type="file"]', insomniaFile);
- // Wait for the loader to disappear
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
-
// Check for error message
const hasError = await page.getByText('Unsupported collection format').first().isVisible();
expect(hasError).toBe(true);
diff --git a/tests/import/insomnia/malformed-structure.spec.ts b/tests/import/insomnia/malformed-structure.spec.ts
index 05e9be062..08286a403 100644
--- a/tests/import/insomnia/malformed-structure.spec.ts
+++ b/tests/import/insomnia/malformed-structure.spec.ts
@@ -5,7 +5,8 @@ test.describe('Invalid Insomnia Collection - Malformed Structure', () => {
test('Handle malformed Insomnia collection structure', async ({ page }) => {
const insomniaFile = path.resolve(__dirname, 'fixtures', 'insomnia-malformed.json');
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByRole('dialog');
@@ -14,9 +15,6 @@ test.describe('Invalid Insomnia Collection - Malformed Structure', () => {
await page.setInputFiles('input[type="file"]', insomniaFile);
- // Wait for the loader to disappear
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
-
// Check for error message - this should fail during JSON parsing
const hasError = await page.getByText('Failed to parse the file').first().isVisible();
expect(hasError).toBe(true);
diff --git a/tests/import/openapi/duplicate-operation-names-fix.spec.ts b/tests/import/openapi/duplicate-operation-names-fix.spec.ts
index 6f2570508..64f2e8d84 100644
--- a/tests/import/openapi/duplicate-operation-names-fix.spec.ts
+++ b/tests/import/openapi/duplicate-operation-names-fix.spec.ts
@@ -12,7 +12,8 @@ test.describe('OpenAPI Duplicate Names Handling', () => {
const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-duplicate-operation-name.yaml');
// start the import process
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// wait for the import collection modal to appear
const importModal = page.getByTestId('import-collection-modal');
@@ -21,12 +22,15 @@ test.describe('OpenAPI Duplicate Names Handling', () => {
// upload the OpenAPI file with duplicate operation names
await page.setInputFiles('input[type="file"]', openApiFile);
+ // Wait for location modal to appear after file processing
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
+ await locationModal.waitFor({ state: 'visible', timeout: 10000 });
+
// wait for the file processing to complete
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
// select a location
await page.locator('#collection-location').fill(await createTmpDir('duplicate-test'));
- await page.getByRole('button', { name: 'Import', exact: true }).click();
+ await locationModal.getByRole('button', { name: 'Import' }).click();
// verify the collection was imported successfully
await expect(page.locator('#sidebar-collection-name').getByText('Duplicate Test Collection')).toBeVisible();
diff --git a/tests/import/openapi/import-openapi-json.spec.ts b/tests/import/openapi/import-openapi-json.spec.ts
index 7923303ab..77f455939 100644
--- a/tests/import/openapi/import-openapi-json.spec.ts
+++ b/tests/import/openapi/import-openapi-json.spec.ts
@@ -11,7 +11,8 @@ test.describe('Import OpenAPI v3 JSON Collection', () => {
test('Import simple OpenAPI v3 JSON successfully', async ({ page, createTmpDir }) => {
const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-simple.json');
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByRole('dialog');
@@ -20,11 +21,9 @@ test.describe('Import OpenAPI v3 JSON Collection', () => {
await page.setInputFiles('input[type="file"]', openApiFile);
- // Wait for the loader to disappear
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
-
- // Verify that the Import Collection modal is displayed (for location selection)
- const locationModal = page.getByRole('dialog');
+ // Wait for location modal to appear after file processing
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
+ await locationModal.waitFor({ state: 'visible', timeout: 10000 });
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
// Wait for collection to appear in the location modal
@@ -32,7 +31,7 @@ test.describe('Import OpenAPI v3 JSON Collection', () => {
// Select a location and import
await page.locator('#collection-location').fill(await createTmpDir('simple-test'));
- await page.getByRole('button', { name: 'Import', exact: true }).click();
+ await locationModal.getByRole('button', { name: 'Import' }).click();
// Verify the collection was imported successfully
await expect(page.locator('#sidebar-collection-name').getByText('Simple Test API')).toBeVisible();
diff --git a/tests/import/openapi/import-openapi-with-examples.spec.ts b/tests/import/openapi/import-openapi-with-examples.spec.ts
index a644ce0eb..ad64b371c 100644
--- a/tests/import/openapi/import-openapi-with-examples.spec.ts
+++ b/tests/import/openapi/import-openapi-with-examples.spec.ts
@@ -35,7 +35,8 @@ test.describe('Import OpenAPI Collection with Examples', () => {
}, { importDir });
await test.step('Open import collection modal', async () => {
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
});
await test.step('Wait for import modal and verify title', async () => {
@@ -47,10 +48,10 @@ test.describe('Import OpenAPI Collection with Examples', () => {
await test.step('Upload OpenAPI collection file using hidden file input', async () => {
// The "choose a file" button triggers a hidden file input, so we can directly set files on it
await page.setInputFiles('input[type="file"]', openApiFile);
- });
- await test.step('Wait for file processing to complete', async () => {
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
+ // Wait for location modal to appear after file processing
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
+ await locationModal.waitFor({ state: 'visible', timeout: 10000 });
});
await test.step('Verify no parsing errors occurred', async () => {
@@ -61,18 +62,18 @@ test.describe('Import OpenAPI Collection with Examples', () => {
});
await test.step('Verify Import Collection location modal appears', async () => {
- const locationModal = page.getByRole('dialog');
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
await expect(locationModal.getByText('API with Examples')).toBeVisible();
});
await test.step('Click Browse link to select collection folder', async () => {
- const locationModal = page.getByRole('dialog');
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
await locationModal.getByText('Browse').click();
});
await test.step('Complete import by clicking import button', async () => {
- const locationModal = page.getByRole('dialog');
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
await locationModal.getByRole('button', { name: 'Import' }).click();
});
@@ -151,7 +152,8 @@ test.describe('Import OpenAPI Collection with Examples', () => {
}, { importDir });
await test.step('Open import collection modal', async () => {
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
});
await test.step('Wait for import modal and verify title', async () => {
@@ -162,10 +164,10 @@ test.describe('Import OpenAPI Collection with Examples', () => {
await test.step('Upload OpenAPI collection file using hidden file input', async () => {
await page.setInputFiles('input[type="file"]', openApiFile);
- });
- await test.step('Wait for file processing to complete', async () => {
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
+ // Wait for location modal to appear after file processing
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
+ await locationModal.waitFor({ state: 'visible', timeout: 10000 });
});
await test.step('Verify no parsing errors occurred', async () => {
@@ -176,13 +178,13 @@ test.describe('Import OpenAPI Collection with Examples', () => {
});
await test.step('Verify Import Collection location modal appears', async () => {
- const locationModal = page.getByRole('dialog');
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
await expect(locationModal.getByText('API with Examples')).toBeVisible();
});
await test.step('Select path-based grouping option from dropdown', async () => {
- const locationModal = page.getByRole('dialog');
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
// Click on the grouping dropdown to open it
const groupingDropdown = locationModal.getByTestId('grouping-dropdown');
@@ -194,12 +196,12 @@ test.describe('Import OpenAPI Collection with Examples', () => {
});
await test.step('Click Browse link to select collection folder', async () => {
- const locationModal = page.getByRole('dialog');
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
await locationModal.getByText('Browse').click();
});
await test.step('Complete import by clicking import button', async () => {
- const locationModal = page.getByRole('dialog');
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
await locationModal.getByRole('button', { name: 'Import' }).click();
});
diff --git a/tests/import/openapi/import-openapi-yaml.spec.ts b/tests/import/openapi/import-openapi-yaml.spec.ts
index e7110bf93..b6948a18c 100644
--- a/tests/import/openapi/import-openapi-yaml.spec.ts
+++ b/tests/import/openapi/import-openapi-yaml.spec.ts
@@ -11,7 +11,8 @@ test.describe('Import OpenAPI v3 YAML Collection', () => {
test('Import comprehensive OpenAPI v3 YAML successfully', async ({ page, createTmpDir }) => {
const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-comprehensive.yaml');
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByRole('dialog');
@@ -20,11 +21,9 @@ test.describe('Import OpenAPI v3 YAML Collection', () => {
await page.setInputFiles('input[type="file"]', openApiFile);
- // Wait for the loader to disappear
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
-
- // Verify that the Import Collection modal is displayed (for location selection)
- const locationModal = page.getByRole('dialog');
+ // Wait for location modal to appear after file processing
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
+ await locationModal.waitFor({ state: 'visible', timeout: 10000 });
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
// Wait for collection to appear in the location modal
@@ -32,7 +31,7 @@ test.describe('Import OpenAPI v3 YAML Collection', () => {
// Select a location and import
await page.locator('#collection-location').fill(await createTmpDir('comprehensive-test'));
- await page.getByRole('button', { name: 'Import', exact: true }).click();
+ await locationModal.getByRole('button', { name: 'Import' }).click();
// Verify the collection was imported successfully
await expect(page.locator('#sidebar-collection-name').getByText('Comprehensive API Test Collection')).toBeVisible();
diff --git a/tests/import/openapi/malformed-yaml.spec.ts b/tests/import/openapi/malformed-yaml.spec.ts
index ec596d18a..5e0a1f5a8 100644
--- a/tests/import/openapi/malformed-yaml.spec.ts
+++ b/tests/import/openapi/malformed-yaml.spec.ts
@@ -5,7 +5,8 @@ test.describe('Invalid OpenAPI - Malformed YAML', () => {
test('Handle malformed OpenAPI YAML structure', async ({ page }) => {
const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-malformed.yaml');
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByRole('dialog');
@@ -14,9 +15,6 @@ test.describe('Invalid OpenAPI - Malformed YAML', () => {
await page.setInputFiles('input[type="file"]', openApiFile);
- // Wait for the loader to disappear
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
-
// Check for error message - this should fail during YAML parsing
const hasParseError = await page.getByText('Failed to parse the file').isVisible();
const hasImportError = await page.getByText('Import collection failed').isVisible();
diff --git a/tests/import/openapi/missing-info.spec.ts b/tests/import/openapi/missing-info.spec.ts
index dc5d230fc..e58607426 100644
--- a/tests/import/openapi/missing-info.spec.ts
+++ b/tests/import/openapi/missing-info.spec.ts
@@ -5,7 +5,8 @@ test.describe('Invalid OpenAPI - Missing Info Section', () => {
test('Handle OpenAPI specification missing required info section', async ({ page }) => {
const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-missing-info.yaml');
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByRole('dialog');
@@ -14,9 +15,6 @@ test.describe('Invalid OpenAPI - Missing Info Section', () => {
await page.setInputFiles('input[type="file"]', openApiFile);
- // Wait for the loader to disappear
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
-
// The OpenAPI parser might handle missing info gracefully with defaults
const hasError = await page.getByText('Unsupported collection format').first().isVisible();
diff --git a/tests/import/openapi/operation-name-with-newlines-fix.spec.ts b/tests/import/openapi/operation-name-with-newlines-fix.spec.ts
index 6ca401c26..72d621be9 100644
--- a/tests/import/openapi/operation-name-with-newlines-fix.spec.ts
+++ b/tests/import/openapi/operation-name-with-newlines-fix.spec.ts
@@ -12,7 +12,8 @@ test.describe('OpenAPI Newline Handling', () => {
const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-newline-in-operation-name.yaml');
// start the import process
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// wait for the import collection modal to appear
const importModal = page.getByTestId('import-collection-modal');
@@ -21,13 +22,14 @@ test.describe('OpenAPI Newline Handling', () => {
// upload the OpenAPI file with problematic operation names
await page.setInputFiles('input[type="file"]', openApiFile);
- // verify that the collection location modal appears (OpenAPI files go directly to location modal)
- const locationModal = page.getByTestId('import-collection-location-modal');
+ // Wait for location modal to appear after file processing
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
+ await locationModal.waitFor({ state: 'visible', timeout: 10000 });
await expect(locationModal.getByText('Newline Test Collection')).toBeVisible();
// select a location
await page.locator('#collection-location').fill(await createTmpDir('newline-test'));
- await page.getByRole('button', { name: 'Import', exact: true }).click();
+ await locationModal.getByRole('button', { name: 'Import' }).click();
// verify the collection was imported successfully
await expect(page.locator('#sidebar-collection-name').getByText('Newline Test Collection')).toBeVisible();
diff --git a/tests/import/openapi/path-based-grouping.spec.ts b/tests/import/openapi/path-based-grouping.spec.ts
index 9f98939bc..9a2494d63 100644
--- a/tests/import/openapi/path-based-grouping.spec.ts
+++ b/tests/import/openapi/path-based-grouping.spec.ts
@@ -12,7 +12,8 @@ test.describe('OpenAPI Path-Based Grouping', () => {
const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-path-grouping.json');
// Start the import process
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByTestId('import-collection-modal');
@@ -21,11 +22,9 @@ test.describe('OpenAPI Path-Based Grouping', () => {
// Upload the OpenAPI file
await page.setInputFiles('input[type="file"]', openApiFile);
- // Wait for the loader to disappear
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
-
- // Verify that the collection location modal appears
- const locationModal = page.getByTestId('import-collection-location-modal');
+ // Wait for location modal to appear after file processing
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
+ await locationModal.waitFor({ state: 'visible', timeout: 10000 });
await expect(locationModal.getByText('Path Grouping Test API')).toBeVisible();
// Select path-based grouping from dropdown
@@ -34,7 +33,7 @@ test.describe('OpenAPI Path-Based Grouping', () => {
// Select a location and import
await page.locator('#collection-location').fill(await createTmpDir('path-grouping-test'));
- await page.getByRole('button', { name: 'Import', exact: true }).click();
+ await locationModal.getByRole('button', { name: 'Import' }).click();
// Verify the collection was imported successfully
await expect(page.locator('#sidebar-collection-name').getByText('Path Grouping Test API')).toBeVisible();
diff --git a/tests/import/postman/import-postman-v20.spec.ts b/tests/import/postman/import-postman-v20.spec.ts
index d107d47de..4611405a1 100644
--- a/tests/import/postman/import-postman-v20.spec.ts
+++ b/tests/import/postman/import-postman-v20.spec.ts
@@ -11,7 +11,8 @@ test.describe('Import Postman Collection v2.0', () => {
test('Import Postman Collection v2.0 successfully', async ({ page, createTmpDir }) => {
const postmanFile = path.resolve(__dirname, 'fixtures', 'postman-v20.json');
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByRole('dialog');
@@ -20,11 +21,9 @@ test.describe('Import Postman Collection v2.0', () => {
await page.setInputFiles('input[type="file"]', postmanFile);
- // Wait for the loader to disappear
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
-
- // Verify that the Import Collection modal is displayed (for location selection)
- const locationModal = page.getByRole('dialog');
+ // Wait for location modal to appear after file processing
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
+ await locationModal.waitFor({ state: 'visible', timeout: 10000 });
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
// Wait for collection to appear in the location modal
@@ -32,7 +31,7 @@ test.describe('Import Postman Collection v2.0', () => {
// Select a location and import
await page.locator('#collection-location').fill(await createTmpDir('postman-v20-test'));
- await page.getByRole('button', { name: 'Import', exact: true }).click();
+ await locationModal.getByRole('button', { name: 'Import' }).click();
// Verify the collection was imported successfully
await expect(page.locator('#sidebar-collection-name').getByText('Postman v2.0 Collection')).toBeVisible();
diff --git a/tests/import/postman/import-postman-v21.spec.ts b/tests/import/postman/import-postman-v21.spec.ts
index cc4312446..be2c3f986 100644
--- a/tests/import/postman/import-postman-v21.spec.ts
+++ b/tests/import/postman/import-postman-v21.spec.ts
@@ -11,7 +11,8 @@ test.describe('Import Postman Collection v2.1', () => {
test('Import Postman Collection v2.1 successfully', async ({ page, createTmpDir }) => {
const postmanFile = path.resolve(__dirname, 'fixtures', 'postman-v21.json');
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByRole('dialog');
@@ -20,11 +21,9 @@ test.describe('Import Postman Collection v2.1', () => {
await page.setInputFiles('input[type="file"]', postmanFile);
- // Wait for the loader to disappear
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
-
- // Verify that the Import Collection modal is displayed (for location selection)
- const locationModal = page.getByRole('dialog');
+ // Wait for location modal to appear after file processing
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
+ await locationModal.waitFor({ state: 'visible', timeout: 10000 });
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
// Wait for collection to appear in the location modal
@@ -32,7 +31,7 @@ test.describe('Import Postman Collection v2.1', () => {
// Select a location and import
await page.locator('#collection-location').fill(await createTmpDir('postman-v21-test'));
- await page.getByRole('button', { name: 'Import', exact: true }).click();
+ await locationModal.getByRole('button', { name: 'Import' }).click();
// Verify the collection was imported successfully
await expect(page.locator('#sidebar-collection-name').getByText('Postman v2.1 Collection')).toBeVisible();
diff --git a/tests/import/postman/import-postman-with-examples.spec.ts b/tests/import/postman/import-postman-with-examples.spec.ts
index 7fca83f0e..8181c58ea 100644
--- a/tests/import/postman/import-postman-with-examples.spec.ts
+++ b/tests/import/postman/import-postman-with-examples.spec.ts
@@ -35,7 +35,8 @@ test.describe('Import Postman Collection with Examples', () => {
}, { importDir });
await test.step('Open import collection modal', async () => {
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
});
await test.step('Wait for import modal and verify title', async () => {
@@ -47,10 +48,10 @@ test.describe('Import Postman Collection with Examples', () => {
await test.step('Upload Postman collection file using hidden file input', async () => {
// The "choose a file" button triggers a hidden file input, so we can directly set files on it
await page.setInputFiles('input[type="file"]', postmanFile);
- });
- await test.step('Wait for file processing to complete', async () => {
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
+ // Wait for location modal to appear after file processing
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
+ await locationModal.waitFor({ state: 'visible', timeout: 10000 });
});
await test.step('Verify no parsing errors occurred', async () => {
@@ -61,22 +62,22 @@ test.describe('Import Postman Collection with Examples', () => {
});
await test.step('Verify location selection modal appears', async () => {
- const locationModal = page.getByRole('dialog');
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
});
await test.step('Verify collection name appears in location modal', async () => {
- const locationModal = page.getByRole('dialog');
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
await expect(locationModal.getByText('collection with examples')).toBeVisible();
});
await test.step('Click Browse link to select collection folder', async () => {
- const locationModal = page.getByRole('dialog');
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
await locationModal.getByText('Browse').click();
});
await test.step('Complete import by clicking import button', async () => {
- const locationModal = page.getByRole('dialog');
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
await locationModal.getByRole('button', { name: 'Import' }).click();
});
diff --git a/tests/import/postman/invalid-json.spec.ts b/tests/import/postman/invalid-json.spec.ts
index 5263dec9e..f47d1d2f1 100644
--- a/tests/import/postman/invalid-json.spec.ts
+++ b/tests/import/postman/invalid-json.spec.ts
@@ -5,7 +5,8 @@ test.describe('Invalid Postman Collection - Invalid JSON', () => {
test('Handle invalid JSON syntax', async ({ page }) => {
const postmanFile = path.resolve(__dirname, 'fixtures', 'postman-invalid-schema.json');
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByRole('dialog');
@@ -14,9 +15,6 @@ test.describe('Invalid Postman Collection - Invalid JSON', () => {
await page.setInputFiles('input[type="file"]', postmanFile);
- // Wait for the loader to disappear
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
-
// Check for error message
const hasError = await page.getByText('Unsupported collection format').first().isVisible();
diff --git a/tests/import/postman/invalid-missing-info.spec.ts b/tests/import/postman/invalid-missing-info.spec.ts
index d1f571382..a71ac4388 100644
--- a/tests/import/postman/invalid-missing-info.spec.ts
+++ b/tests/import/postman/invalid-missing-info.spec.ts
@@ -5,7 +5,8 @@ test.describe('Invalid Postman Collection - Missing Info', () => {
test('Handle Postman collection missing required info field', async ({ page }) => {
const postmanFile = path.resolve(__dirname, 'fixtures', 'postman-invalid-missing-info.json');
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByRole('dialog');
@@ -14,9 +15,6 @@ test.describe('Invalid Postman Collection - Missing Info', () => {
await page.setInputFiles('input[type="file"]', postmanFile);
- // Wait for the loader to disappear
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
-
// Check for error message
const hasError = await page.getByText('Unsupported collection format').first().isVisible();
expect(hasError).toBe(true);
diff --git a/tests/import/postman/invalid-schema.spec.ts b/tests/import/postman/invalid-schema.spec.ts
index ac549b0e0..bd8d26eb1 100644
--- a/tests/import/postman/invalid-schema.spec.ts
+++ b/tests/import/postman/invalid-schema.spec.ts
@@ -5,7 +5,8 @@ test.describe('Invalid Postman Collection - Invalid Schema', () => {
test('Handle Postman collection with invalid schema version', async ({ page }) => {
const postmanFile = path.resolve(__dirname, 'fixtures', 'postman-invalid-schema.json');
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByRole('dialog');
@@ -14,9 +15,6 @@ test.describe('Invalid Postman Collection - Invalid Schema', () => {
await page.setInputFiles('input[type="file"]', postmanFile);
- // Wait for the loader to disappear
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
-
// Check for error message
const hasError = await page.getByText('Unsupported collection format').first().isVisible();
expect(hasError).toBe(true);
diff --git a/tests/import/postman/malformed-structure.spec.ts b/tests/import/postman/malformed-structure.spec.ts
index 9facbac62..30cf4c540 100644
--- a/tests/import/postman/malformed-structure.spec.ts
+++ b/tests/import/postman/malformed-structure.spec.ts
@@ -5,7 +5,8 @@ test.describe('Invalid Postman Collection - Malformed Structure', () => {
test('Handle malformed Postman collection structure', async ({ page }) => {
const postmanFile = path.resolve(__dirname, 'fixtures', 'postman-malformed.json');
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByRole('dialog');
@@ -14,9 +15,6 @@ test.describe('Invalid Postman Collection - Malformed Structure', () => {
await page.setInputFiles('input[type="file"]', postmanFile);
- // Wait for the loader to disappear
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
-
// Check for error message
const hasError = await page.getByText('Unsupported collection format').first().isVisible();
expect(hasError).toBe(true);
diff --git a/tests/import/wsdl/import-wsdl.spec.ts b/tests/import/wsdl/import-wsdl.spec.ts
index 23bdd4ab9..71dd220fb 100644
--- a/tests/import/wsdl/import-wsdl.spec.ts
+++ b/tests/import/wsdl/import-wsdl.spec.ts
@@ -13,7 +13,8 @@ test.describe('Import WSDL Collection', () => {
const wsdlFile = path.join(testDataDir, 'wsdl.xml');
await test.step('Open import collection modal', async () => {
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByRole('dialog');
@@ -23,18 +24,19 @@ test.describe('Import WSDL Collection', () => {
await test.step('Choose WSDL XML file', async () => {
await page.setInputFiles('input[type="file"]', wsdlFile);
- // Wait for the loader to disappear
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
+ // Wait for location modal to appear after file processing
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
+ await locationModal.waitFor({ state: 'visible', timeout: 10000 });
});
await test.step('Select the location for the collection and submit to import', async () => {
// Verify that the location selection modal is displayed to import the collection
- const locationModal = page.getByRole('dialog');
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
// select a location
await page.locator('#collection-location').fill(await createTmpDir('wsdl-xml-test'));
- await page.getByRole('button', { name: 'Import', exact: true }).click();
+ await locationModal.getByRole('button', { name: 'Import' }).click();
await expect(page.locator('#sidebar-collection-name').getByText('TestWSDLServiceXML')).toBeVisible();
});
@@ -69,7 +71,8 @@ test.describe('Import WSDL Collection', () => {
const wsdlFile = path.join(testDataDir, 'wsdl-bruno.json');
await test.step('Open import collection modal', async () => {
- await page.getByRole('button', { name: 'Import Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByRole('dialog');
@@ -79,13 +82,14 @@ test.describe('Import WSDL Collection', () => {
await test.step('Choose WSDL JSON file', async () => {
await page.setInputFiles('input[type="file"]', wsdlFile);
- // Wait for the loader to disappear
- await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
+ // Wait for location modal to appear after file processing
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
+ await locationModal.waitFor({ state: 'visible', timeout: 10000 });
});
await test.step('Select the location for the collection and submit to import', async () => {
// Verify that the location selection modal is displayed to import the collection
- const locationModal = page.getByRole('dialog');
+ const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
// Wait for collection to appear in the location modal
@@ -93,7 +97,7 @@ test.describe('Import WSDL Collection', () => {
// select a location
await page.locator('#collection-location').fill(await createTmpDir('wsdl-json-test'));
- await page.getByRole('button', { name: 'Import', exact: true }).click();
+ await locationModal.getByRole('button', { name: 'Import' }).click();
});
await test.step('Verify that the collection was imported successfully', async () => {
diff --git a/tests/onboarding/sample-collection.spec.ts b/tests/onboarding/sample-collection.spec.ts
index c709b8034..3ca2ea70b 100644
--- a/tests/onboarding/sample-collection.spec.ts
+++ b/tests/onboarding/sample-collection.spec.ts
@@ -86,22 +86,21 @@ test.describe('Onboarding', () => {
const page = await app.firstWindow();
// First launch - sample collection should be created
- const sampleCollection = page.locator('.collection-name').filter({ hasText: 'Sample API Collection' });
+ const sampleCollection = page.getByTestId('collections').locator('.collection-name').filter({ hasText: 'Sample API Collection' });
await expect(sampleCollection).toBeVisible();
- // User closes the sample collection (hover on the collection and open context menu)
+ // User removes the sample collection from workspace (hover on the collection and open context menu)
await sampleCollection.hover();
await sampleCollection.locator('.collection-actions .icon').click();
- // Close the sample collection
- const closeOption = page.locator('.dropdown-item').getByText('Close');
- await expect(closeOption).toBeVisible();
- await closeOption.click();
+ // Remove the sample collection
+ const removeOption = page.locator('.dropdown-item').getByText('Remove');
+ await expect(removeOption).toBeVisible();
+ await removeOption.click();
- // Handle the confirmation dialog - click the 'Close' button to confirm
- const confirmCloseButton = page.locator('.bruno-modal').getByRole('button', { name: 'Close' });
- await expect(confirmCloseButton).toBeVisible();
- await confirmCloseButton.click();
+ // Confirm removal in the modal
+ const removeModal = page.getByRole('dialog').filter({ has: page.getByText('Remove Collection') });
+ await removeModal.getByRole('button', { name: 'Remove' }).click();
// Verify collection is closed (no longer visible in sidebar)
await expect(sampleCollection).not.toBeVisible();
diff --git a/tests/preferences/default-collection-location/default-collection-location.spec.js b/tests/preferences/default-collection-location/default-collection-location.spec.js
index 3eeda754f..edb751bd9 100644
--- a/tests/preferences/default-collection-location/default-collection-location.spec.js
+++ b/tests/preferences/default-collection-location/default-collection-location.spec.js
@@ -56,14 +56,17 @@ test.describe('Default Collection Location Feature', () => {
test('Should use default location in Create Collection modal', async ({ pageWithUserData: page }) => {
// test Create Collection modal
- await page.locator('[data-testid="create-collection"]').click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }).click();
- // verify the default location is pre-filled
+ // verify the default location is pre-filled (if location input is visible)
const collectionLocationInput = page.getByLabel('Location');
- await expect(collectionLocationInput).toHaveValue('/tmp/bruno-collections');
+ if (await collectionLocationInput.isVisible()) {
+ await expect(collectionLocationInput).toHaveValue('/tmp/bruno-collections');
+ }
// cancel the collection creation
- await page.getByRole('button', { name: 'Cancel' }).click();
+ await page.locator('.bruno-modal').getByRole('button', { name: 'Cancel' }).click();
// wait for 2 seconds
await page.waitForTimeout(2000);
@@ -71,12 +74,19 @@ test.describe('Default Collection Location Feature', () => {
test('Should use default location in Clone Collection modal', async ({ pageWithUserData: page }) => {
// open the clone collection modal
- await page.locator('[data-testid="collection-actions"]').click();
- await page.getByTestId('clone-collection').click();
+ const collection = page.locator('.collection-name').first();
+ await collection.hover();
+ await collection.locator('.collection-actions .icon').click();
+ await page.locator('.dropdown-item').filter({ hasText: 'Clone' }).click();
// verify the default location is pre-filled
const cloneLocationInput = page.getByLabel('Location');
- await expect(cloneLocationInput).toHaveValue('/tmp/bruno-collections');
+ if (await cloneLocationInput.isVisible()) {
+ await expect(cloneLocationInput).toHaveValue('/tmp/bruno-collections');
+ }
+
+ // cancel the clone operation
+ await page.locator('.bruno-modal').getByRole('button', { name: 'Cancel' }).click();
// wait for 2 seconds
await page.waitForTimeout(2000);
diff --git a/tests/request/encoding/curl-encoding.spec.ts b/tests/request/encoding/curl-encoding.spec.ts
index 255d22a55..c6a7b7f78 100644
--- a/tests/request/encoding/curl-encoding.spec.ts
+++ b/tests/request/encoding/curl-encoding.spec.ts
@@ -18,11 +18,15 @@ test.describe('Code Generation URL Encoding', () => {
page,
createTmpDir
}) => {
- await page.locator('.dropdown-icon').click();
- await page.locator('.dropdown-item').filter({ hasText: 'Create Collection' }).click();
+ // Use plus icon button in new workspace UI
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }).click();
await page.getByLabel('Name').fill('unencoded-test-collection');
- await page.getByLabel('Location').fill(await createTmpDir('unencoded-test-collection'));
- await page.getByRole('button', { name: 'Create', exact: true }).click();
+ const locationInput = page.getByLabel('Location');
+ if (await locationInput.isVisible()) {
+ await locationInput.fill(await createTmpDir('unencoded-test-collection'));
+ }
+ await page.locator('.bruno-modal').getByRole('button', { name: 'Create', exact: true }).click();
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'unencoded-test-collection' })).toBeVisible();
await page.locator('#sidebar-collection-name').filter({ hasText: 'unencoded-test-collection' }).click();
@@ -60,11 +64,15 @@ test.describe('Code Generation URL Encoding', () => {
page,
createTmpDir
}) => {
- await page.locator('.dropdown-icon').click();
- await page.locator('.dropdown-item').filter({ hasText: 'Create Collection' }).click();
+ // Use plus icon button in new workspace UI
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }).click();
await page.getByLabel('Name').fill('encoded-test-collection');
- await page.getByLabel('Location').fill(await createTmpDir('encoded-test-collection'));
- await page.getByRole('button', { name: 'Create', exact: true }).click();
+ const locationInput = page.getByLabel('Location');
+ if (await locationInput.isVisible()) {
+ await locationInput.fill(await createTmpDir('encoded-test-collection'));
+ }
+ await page.locator('.bruno-modal').getByRole('button', { name: 'Create', exact: true }).click();
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'encoded-test-collection' })).toBeVisible();
await page.locator('#sidebar-collection-name').filter({ hasText: 'encoded-test-collection' }).click();
diff --git a/tests/request/newlines/newlines-persistence.spec.ts b/tests/request/newlines/newlines-persistence.spec.ts
index 22ce33783..249ee7ab5 100644
--- a/tests/request/newlines/newlines-persistence.spec.ts
+++ b/tests/request/newlines/newlines-persistence.spec.ts
@@ -10,20 +10,20 @@ test('should persist request with newlines across app restarts', async ({ create
const app1 = await launchElectronApp({ userDataPath });
const page = await app1.firstWindow();
- await page.locator('.dropdown-icon').click();
- await page.locator('.dropdown-item').filter({ hasText: 'Create Collection' }).click();
- await page.getByLabel('Name').fill('newlines-persistence');
- await page.getByLabel('Location').fill(collectionPath);
- await page.getByRole('button', { name: 'Create', exact: true }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }).click();
+ await page.locator('.bruno-modal').getByLabel('Name').fill('newlines-persistence');
+ await page.locator('.bruno-modal').getByLabel('Location').fill(collectionPath);
+ await page.locator('.bruno-modal').getByRole('button', { name: 'Create' }).click();
- const collection = page.locator('.collection-name').filter({ hasText: 'newlines-persistence' });
+ const collection = page.getByTestId('collections').locator('.collection-name').filter({ hasText: 'newlines-persistence' });
await collection.locator('.collection-actions').hover();
await collection.locator('.collection-actions .icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).click();
await page.getByPlaceholder('Request Name').fill('persistence-test');
await page.locator('#new-request-url').locator('.CodeMirror').click();
await page.locator('#new-request-url').locator('textarea').fill('https://httpbin.org/get');
- await page.getByRole('button', { name: 'Create', exact: true }).click();
+ await page.locator('.bruno-modal').getByRole('button', { name: 'Create', exact: true }).click();
await openCollectionAndAcceptSandbox(page, 'newlines-persistence', 'safe');
await page.locator('.collection-item-name').filter({ hasText: 'persistence-test' }).dblclick();
@@ -60,7 +60,7 @@ test('should persist request with newlines across app restarts', async ({ create
const app2 = await launchElectronApp({ userDataPath });
const page2 = await app2.firstWindow();
- await page2.locator('.collection-name').filter({ hasText: 'newlines-persistence' }).click();
+ await page2.getByTestId('collections').locator('.collection-name').filter({ hasText: 'newlines-persistence' }).click();
await page2.locator('.collection-item-name').filter({ hasText: 'persistence-test' }).dblclick();
// Verify params persisted
diff --git a/tests/request/save/save.spec.ts b/tests/request/save/save.spec.ts
index 3cd18df30..a40d3161f 100644
--- a/tests/request/save/save.spec.ts
+++ b/tests/request/save/save.spec.ts
@@ -9,11 +9,14 @@ const isRequestSaved = async (saveButton: Locator) => {
};
const setup = async (page: Page, createTmpDir: (tag?: string | undefined) => Promise) => {
- await page.locator('.dropdown-icon').click();
- await page.locator('.dropdown-item').filter({ hasText: 'Create Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }).click();
await page.getByLabel('Name').fill('source-collection');
- await page.getByLabel('Location').fill(await createTmpDir('source-collection'));
- await page.getByRole('button', { name: 'Create', exact: true }).click();
+ const locationInput = page.getByLabel('Location');
+ if (await locationInput.isVisible()) {
+ await locationInput.fill(await createTmpDir('source-collection'));
+ }
+ await page.locator('.bruno-modal').getByRole('button', { name: 'Create', exact: true }).click();
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'source-collection' })).toBeVisible();
await page.locator('#sidebar-collection-name').filter({ hasText: 'source-collection' }).click();
await page.getByLabel('Safe Mode').check();
diff --git a/tests/request/tests/custom-search/custom-search.spec.ts b/tests/request/tests/custom-search/custom-search.spec.ts
index b6c620224..9806782b4 100644
--- a/tests/request/tests/custom-search/custom-search.spec.ts
+++ b/tests/request/tests/custom-search/custom-search.spec.ts
@@ -2,7 +2,7 @@ import { test, expect } from '../../../../playwright';
test.describe('Custom Search Functionality in Scripts Tab', () => {
test('should open search box when Cmd+F or Ctrl+F is pressed in scripts tab', async ({ pageWithUserData: page }) => {
- await page.getByTitle('custom-search').click();
+ await page.getByTestId('collections').locator('#sidebar-collection-name').filter({ hasText: 'custom-search' }).click();
await page.getByText('search-test-request').click();
@@ -62,7 +62,7 @@ test.describe('Custom Search Functionality in Scripts Tab', () => {
});
test('should handle search in different script editors independently', async ({ pageWithUserData: page }) => {
- await page.getByTitle('custom-search').click();
+ await page.getByTestId('collections').locator('#sidebar-collection-name').filter({ hasText: 'custom-search' }).click();
await page.getByText('search-test-request').click();
@@ -96,7 +96,7 @@ test.describe('Custom Search Functionality in Scripts Tab', () => {
});
test('should maintain search state when switching between tabs', async ({ pageWithUserData: page }) => {
- await page.getByTitle('custom-search').click();
+ await page.getByTestId('collections').locator('#sidebar-collection-name').filter({ hasText: 'custom-search' }).click();
await page.getByText('search-test-request').click();
diff --git a/tests/runner/collection-run.ts b/tests/runner/collection-run.ts
index 1f7a01db5..8d89b8618 100644
--- a/tests/runner/collection-run.ts
+++ b/tests/runner/collection-run.ts
@@ -1,4 +1,4 @@
-import { test } from '../../playwright';
+import { test, expect } from '../../playwright';
import { setSandboxMode, runCollection, validateRunnerResults } from '../utils/page/index';
test.describe.parallel('Collection Run', () => {
@@ -29,7 +29,7 @@ test.describe.parallel('Collection Run', () => {
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('.environment-selector').nth(1).click();
await page.locator('.dropdown-item').getByText('Prod').click();
- const collectionContainer = page.locator('.collection-name').filter({ hasText: 'bruno-testbench' });
+ const collectionContainer = page.getByTestId('collections').locator('.collection-name').filter({ hasText: 'bruno-testbench' });
await collectionContainer.locator('.collection-actions').hover();
await collectionContainer.locator('.collection-actions .icon').waitFor({ state: 'visible' });
await collectionContainer.locator('.collection-actions .icon').click();
diff --git a/tests/start/app-open.spec.ts b/tests/start/app-open.spec.ts
index 326ff895c..d2df08383 100644
--- a/tests/start/app-open.spec.ts
+++ b/tests/start/app-open.spec.ts
@@ -1,5 +1,9 @@
import { test, expect } from '../../playwright';
-test('Check if the logo on top left is visible', async ({ page }) => {
- await expect(page.getByRole('button', { name: 'bruno' })).toBeVisible();
+test('Check if the workspace name is visible in the sidebar', async ({ page }) => {
+ // Wait for the app to be loaded
+ await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
+
+ // Wait for the workspace name container to be visible (contains workspace name like "My Workspace" or "Default Workspace")
+ await expect(page.locator('.workspace-name-container')).toBeVisible();
});
diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts
index a0589e149..fd41eab73 100644
--- a/tests/utils/page/actions.ts
+++ b/tests/utils/page/actions.ts
@@ -8,20 +8,20 @@ import { buildCommonLocators } from './locators';
*/
const closeAllCollections = async (page) => {
await test.step('Close all collections', async () => {
- const numberOfCollections = await page.locator('.collection-name').count();
+ const numberOfCollections = await page.locator('[data-testid="collections"] .collection-name').count();
for (let i = 0; i < numberOfCollections; i++) {
- await page.locator('.collection-name').first().locator('.collection-actions').click();
- await page.locator('.dropdown-item').getByText('Close').click();
- // Wait for the close collection modal to be visible
- await page.locator('.bruno-modal-header-title', { hasText: 'Close Collection' }).waitFor({ state: 'visible' });
+ await page.locator('[data-testid="collections"] .collection-name').first().locator('.collection-actions').click();
+ await page.locator('.dropdown-item').getByText('Remove').click();
+ // Wait for the remove collection modal to be visible
+ await page.locator('.bruno-modal-header-title', { hasText: 'Remove Collection' }).waitFor({ state: 'visible' });
await page.locator('.bruno-modal-footer .submit').click();
- // Wait for the close collection modal to be hidden
- await page.locator('.bruno-modal-header-title', { hasText: 'Close Collection' }).waitFor({ state: 'hidden' });
+ // Wait for the remove collection modal to be hidden
+ await page.locator('.bruno-modal-header-title', { hasText: 'Remove Collection' }).waitFor({ state: 'hidden' });
}
- // Wait until no collections are left open
- await expect(page.locator('.collection-name')).toHaveCount(0);
+ // Wait until no collections are left open (check sidebar only)
+ await expect(page.getByTestId('collections').locator('.collection-name')).toHaveCount(0);
});
};
@@ -62,13 +62,16 @@ type CreateCollectionOptions = {
*/
const createCollection = async (page, collectionName: string, collectionLocation: string, options: CreateCollectionOptions = {}) => {
await test.step(`Create collection "${collectionName}"`, async () => {
- await page.locator('.collection-dropdown .dropdown-icon').click();
- await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create Collection' }).click();
+ await page.locator('.plus-icon-button').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }).click();
const createCollectionModal = page.locator('.bruno-modal-card').filter({ hasText: 'Create Collection' });
await createCollectionModal.getByLabel('Name').fill(collectionName);
- await createCollectionModal.getByLabel('Location').fill(collectionLocation);
+ const locationInput = createCollectionModal.getByLabel('Location');
+ if (await locationInput.isVisible()) {
+ await locationInput.fill(collectionLocation);
+ }
await createCollectionModal.getByRole('button', { name: 'Create', exact: true }).click();
await createCollectionModal.waitFor({ state: 'detached' });
@@ -115,8 +118,8 @@ const deleteRequest = async (page, requestName: string, collectionName: string)
await locators.sidebar.collection(collectionName).click();
// Find the request within the collection's context
- // Use the collection container (.collection-name) to scope the search
- const collectionContainer = page.locator('.collection-name').filter({ hasText: collectionName });
+ // Use the collection container (.collection-name) scoped to sidebar to scope the search
+ const collectionContainer = page.getByTestId('collections').locator('.collection-name').filter({ hasText: collectionName });
const collectionWrapper = collectionContainer.locator('..');
const request = collectionWrapper.locator('.collection-item-name').filter({ hasText: requestName });
diff --git a/tests/utils/page/locators.ts b/tests/utils/page/locators.ts
index 7ee6e660a..833913ef3 100644
--- a/tests/utils/page/locators.ts
+++ b/tests/utils/page/locators.ts
@@ -21,7 +21,7 @@ export const buildCommonLocators = (page: Page) => ({
},
actions: {
collectionActions: (collectionName: string) =>
- page.locator('.collection-name')
+ page.getByTestId('collections').locator('.collection-name')
.filter({ hasText: collectionName })
.locator('.collection-actions .icon'),
collectionItemActions: (itemName: string) =>
@@ -43,7 +43,7 @@ export const buildCommonLocators = (page: Page) => ({
modal: {
title: (title: string) => page.locator('.bruno-modal-header-title').filter({ hasText: title }),
byTitle: (title: string) => page.locator('.bruno-modal').filter({ has: page.locator('.bruno-modal-header-title').filter({ hasText: title }) }),
- button: (name: string) => page.getByRole('button', { name: name, exact: true }),
+ button: (name: string) => page.locator('.bruno-modal').getByRole('button', { name: name, exact: true }),
closeButton: () => page.locator('.bruno-modal').getByTestId('modal-close-button')
},
environment: {
diff --git a/tests/utils/page/runner.ts b/tests/utils/page/runner.ts
index d6454b4ed..a3aa1ae23 100644
--- a/tests/utils/page/runner.ts
+++ b/tests/utils/page/runner.ts
@@ -27,8 +27,8 @@ export const getRunnerResultCounts = async (page: Page) => {
* @returns void
*/
export const runCollection = async (page: Page, collectionName: string) => {
- // Ensure collection is visible and loaded
- const collectionContainer = page.locator('.collection-name').filter({ hasText: collectionName });
+ // Ensure collection is visible and loaded (scope to sidebar)
+ const collectionContainer = page.getByTestId('collections').locator('.collection-name').filter({ hasText: collectionName });
await collectionContainer.waitFor({ state: 'visible' });
// Wait a bit for the UI to stabilize
await page.waitForTimeout(300);
@@ -71,24 +71,14 @@ export const runCollection = async (page: Page, collectionName: string) => {
* @returns void
*/
export const setSandboxMode = async (page: Page, collectionName: string, mode: 'developer' | 'safe') => {
- // Click on the collection name - try sidebar first, then fall back to collection tab/name
- // First try sidebar collection name (more reliable)
- const sidebarCollection = page.locator('#sidebar-collection-name').filter({ hasText: collectionName });
- const sidebarExists = await sidebarCollection.count().then((count) => count > 0).catch(() => false);
+ // Click on the collection name in the sidebar
+ // Use the collections testid to scope to the sidebar, then find the specific collection
+ const sidebarCollection = page.getByTestId('collections').locator('#sidebar-collection-name').filter({ hasText: collectionName }).first();
- if (sidebarExists) {
- await sidebarCollection.click();
- } else {
- // Fall back to collection by title or text
- const collectionByTitle = page.getByTitle(collectionName);
- const collectionByText = page.getByText(collectionName);
- const titleExists = await collectionByTitle.count().then((count) => count > 0).catch(() => false);
- if (titleExists) {
- await collectionByTitle.click();
- } else {
- await collectionByText.click();
- }
- }
+ // Wait for the sidebar to be loaded
+ await page.waitForTimeout(500);
+
+ await sidebarCollection.click();
// Wait a moment for the UI to load
await page.waitForTimeout(300);