-
-
- {isEditingFolderFilename ? (
-
setIsEditingFolderFilename(false)}
- />
- ) : (
- setIsEditingFolderFilename(true)}
- />
- )}
+
+
+
+
+
+ {filteredFolders.length > 0 || showNewFolderInput ? (
+
+ {filteredFolders.map((folder) => (
+ - handleFolderClick(folder.uid)}
+ >
+
+
+ {folder.name}
- {isEditingFolderFilename ? (
-
-
setNewFolderDirectoryName(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- e.stopPropagation();
- handleCreateNewFolder();
- } else if (e.key === 'Escape') {
- e.stopPropagation();
- handleCancelNewFolder();
- }
- }}
- />
+
+
+ ))}
+ {showNewFolderInput && (
+
-
+
+
+
+
+
+
handleNewFolderNameChange(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ e.stopPropagation();
+ handleCreateNewFolder();
+ } else if (e.key === 'Escape') {
+ e.stopPropagation();
+ handleCancelNewFolder();
+ }
+ }}
+ />
+
+
+
- ) : (
-
+
+ {showFilesystemName && (
+
+
+
+ {isEditingFolderFilename ? (
+
setIsEditingFolderFilename(false)}
+ />
+ ) : (
+ setIsEditingFolderFilename(true)}
+ />
+ )}
+
+ {isEditingFolderFilename ? (
+
+ setNewFolderDirectoryName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ e.stopPropagation();
+ handleCreateNewFolder();
+ } else if (e.key === 'Escape') {
+ e.stopPropagation();
+ handleCancelNewFolder();
+ }
+ }}
+ />
+
+ ) : (
+
+ )}
)}
-
- )}
-
-
+
+
+ )}
+
+ ) : (
+
+ {searchText.trim() ? 'No folders found' : 'No folders available'}
+
)}
-
- ) : (
-
- {searchText.trim() ? 'No folders found' : 'No folders available'}
- )}
-
+ >
+ )}
- {!showNewFolderInput && (
+ {!showNewFolderInput && !isSelectingCollection && (
-
+ {!isSelectingCollection && (
+
+ )}
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/index.js b/packages/bruno-app/src/components/Sidebar/Collections/index.js
index 60ac62a6a..08b94f5e8 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/index.js
@@ -1,12 +1,12 @@
-import React, { useState } from 'react';
+import React, { useState, useMemo } from 'react';
import { useSelector } from 'react-redux';
import Collection from './Collection';
import CreateCollection from '../CreateCollection';
import StyledWrapper from './StyledWrapper';
import CreateOrOpenCollection from './CreateOrOpenCollection';
import CollectionSearch from './CollectionSearch/index';
-import { useMemo } from 'react';
import { normalizePath } from 'utils/common/path';
+import { isScratchCollection } from 'utils/collections';
const Collections = ({ showSearch }) => {
const [searchText, setSearchText] = useState('');
@@ -18,10 +18,14 @@ const Collections = ({ showSearch }) => {
const workspaceCollections = useMemo(() => {
if (!activeWorkspace) return [];
- return collections.filter((c) =>
- activeWorkspace.collections?.some((wc) => normalizePath(wc.path) === normalizePath(c.pathname))
- );
- }, [activeWorkspace, collections]);
+
+ return collections.filter((c) => {
+ if (isScratchCollection(c, workspaces)) {
+ return false;
+ }
+ return activeWorkspace.collections?.some((wc) => normalizePath(wc.path) === normalizePath(c.pathname));
+ });
+ }, [activeWorkspace, collections, workspaces]);
if (!workspaceCollections || !workspaceCollections.length) {
return (
diff --git a/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js b/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js
index d3fec1217..30b7a3ca9 100644
--- a/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js
@@ -18,6 +18,7 @@ import {
import { importCollection, openCollection, importCollectionFromZip } from 'providers/ReduxStore/slices/collections/actions';
import { sortCollections } from 'providers/ReduxStore/slices/collections/index';
import { normalizePath } from 'utils/common/path';
+import { isScratchCollection } from 'utils/collections';
import MenuDropdown from 'ui/MenuDropdown';
import ActionIcon from 'ui/ActionIcon';
@@ -47,10 +48,14 @@ const CollectionsSection = () => {
const workspaceCollections = useMemo(() => {
if (!activeWorkspace) return [];
- return collections.filter((c) =>
- activeWorkspace.collections?.some((wc) => normalizePath(wc.path) === normalizePath(c.pathname))
- );
- }, [activeWorkspace, collections]);
+
+ return collections.filter((c) => {
+ if (isScratchCollection(c, workspaces)) {
+ return false;
+ }
+ return activeWorkspace.collections?.some((wc) => normalizePath(wc.path) === normalizePath(c.pathname));
+ });
+ }, [activeWorkspace, collections, workspaces]);
const handleImportCollection = ({ rawData, type, ...rest }) => {
setImportCollectionModalOpen(false);
diff --git a/packages/bruno-app/src/components/StatusBar/index.js b/packages/bruno-app/src/components/StatusBar/index.js
index 636c71ffc..7a44ef1db 100644
--- a/packages/bruno-app/src/components/StatusBar/index.js
+++ b/packages/bruno-app/src/components/StatusBar/index.js
@@ -10,7 +10,6 @@ import Notifications from 'components/Notifications';
import Portal from 'components/Portal';
import ThemeDropdown from './ThemeDropdown';
import { openConsole } from 'providers/ReduxStore/slices/logs';
-import { setActiveWorkspaceTab } from 'providers/ReduxStore/slices/workspaceTabs';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { useApp } from 'providers/App';
import StyledWrapper from './StyledWrapper';
@@ -18,6 +17,7 @@ import StyledWrapper from './StyledWrapper';
const StatusBar = () => {
const dispatch = useDispatch();
const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid);
+ const workspaces = useSelector((state) => state.workspaces.workspaces);
const showHomePage = useSelector((state) => state.app.showHomePage);
const showManageWorkspacePage = useSelector((state) => state.app.showManageWorkspacePage);
const showApiSpecPage = useSelector((state) => state.app.showApiSpecPage);
@@ -28,6 +28,8 @@ const StatusBar = () => {
const [cookiesOpen, setCookiesOpen] = useState(false);
const { version } = useApp();
+ const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
+
const errorCount = logs.filter((log) => log.type === 'error').length;
const handleConsoleClick = () => {
@@ -35,19 +37,15 @@ const StatusBar = () => {
};
const handlePreferencesClick = () => {
- if (showHomePage || showManageWorkspacePage || showApiSpecPage || !activeTabUid) {
- if (activeWorkspaceUid) {
- dispatch(setActiveWorkspaceTab({ workspaceUid: activeWorkspaceUid, type: 'preferences' }));
- }
- } else {
- dispatch(
- addTab({
- type: 'preferences',
- uid: activeTab?.collectionUid ? `${activeTab.collectionUid}-preferences` : 'preferences',
- collectionUid: activeTab?.collectionUid
- })
- );
- }
+ const collectionUid = activeTab?.collectionUid || activeWorkspace?.scratchCollectionUid;
+
+ dispatch(
+ addTab({
+ type: 'preferences',
+ uid: collectionUid ? `${collectionUid}-preferences` : 'preferences',
+ collectionUid: collectionUid
+ })
+ );
};
const openGlobalSearch = () => {
diff --git a/packages/bruno-app/src/components/WorkspaceHome/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceHome/StyledWrapper.js
deleted file mode 100644
index 7c098b114..000000000
--- a/packages/bruno-app/src/components/WorkspaceHome/StyledWrapper.js
+++ /dev/null
@@ -1,110 +0,0 @@
-import styled from 'styled-components';
-import { rgba } from 'polished';
-
-const StyledWrapper = styled.div`
- .main-content {
- flex: 1;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- }
-
- .workspace-header {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 12px 16px;
- position: relative;
- }
-
- .workspace-title {
- display: flex;
- align-items: center;
- gap: 8px;
- height: 24px;
- font-size: 15px;
- font-weight: 600;
- color: ${(props) => props.theme.text};
- }
-
- .workspace-rename-container {
- height: 24px;
- display: flex;
- align-items: center;
- background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
- gap: 6px;
- border-radius: 4px;
- }
-
- .workspace-name-input {
- padding: 0 8px;
- font-size: 14px;
- font-weight: 600;
- border-radius: 4px;
- background: transparent;
- color: ${(props) => props.theme.text};
- outline: none;
- min-width: 180px;
-
- &:focus {
- outline: none;
- }
- }
-
- .inline-actions {
- display: flex;
- gap: 2px;
- }
-
- .inline-action-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 4px;
- border-radius: 4px;
- cursor: pointer;
- transition: all 0.15s;
-
- &.save {
- color: ${(props) => props.theme.colors.text.green};
-
- &:hover {
- background-color: ${(props) => rgba(props.theme.colors.text.green, 0.1)};
- }
- }
-
- &.cancel {
- color: ${(props) => props.theme.colors.text.danger};
-
- &:hover {
- background-color: ${(props) => rgba(props.theme.colors.text.danger, 0.1)};
- }
- }
- }
-
- .workspace-error {
- position: absolute;
- top: 80%;
- left: 40px;
- z-index: 10;
- margin-top: 4px;
- padding: 4px 8px;
- font-size: 11px;
- color: ${(props) => props.theme.colors.text.danger};
- background: ${(props) => props.theme.bg};
- border: 1px solid ${(props) => props.theme.colors.text.danger};
- border-radius: 4px;
- white-space: nowrap;
- }
-
- .workspace-menu-dropdown {
- min-width: 140px;
- }
-
- .tab-content {
- flex: 1;
- overflow: hidden;
- }
-`;
-
-export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js
index 421eb5803..fc5eb2351 100644
--- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js
+++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js
@@ -28,7 +28,14 @@ const CollectionsList = ({ workspace }) => {
return [];
}
- return workspace.collections.map((wc) => {
+ const filteredCollections = workspace.collections.filter((wc) => {
+ if (workspace.scratchTempDirectory) {
+ return normalizePath(wc.path) !== normalizePath(workspace.scratchTempDirectory);
+ }
+ return true;
+ });
+
+ return filteredCollections.map((wc) => {
const loadedCollection = collections.find(
(c) => normalizePath(c.pathname) === normalizePath(wc.path)
);
@@ -64,7 +71,7 @@ const CollectionsList = ({ workspace }) => {
}
};
});
- }, [workspace.collections, collections]);
+ }, [workspace.collections, workspace.scratchTempDirectory, collections]);
const handleOpenCollectionClick = (collection, event) => {
if (event.target.closest('.collection-menu')) {
diff --git a/packages/bruno-app/src/components/WorkspaceHome/index.js b/packages/bruno-app/src/components/WorkspaceHome/index.js
deleted file mode 100644
index ff6220cbf..000000000
--- a/packages/bruno-app/src/components/WorkspaceHome/index.js
+++ /dev/null
@@ -1,262 +0,0 @@
-import React, { useEffect, useState, useRef } from 'react';
-import { useSelector, useDispatch } from 'react-redux';
-import { IconCategory, IconDots, IconEdit, IconX, IconCheck, IconFolder, IconUpload } from '@tabler/icons';
-import { renameWorkspaceAction, exportWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
-import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
-import toast from 'react-hot-toast';
-import CloseWorkspace from 'components/Sidebar/CloseWorkspace';
-import WorkspaceOverview from './WorkspaceOverview';
-import WorkspaceEnvironments from './WorkspaceEnvironments';
-import Preferences from 'components/Preferences';
-import WorkspaceTabs from 'components/WorkspaceTabs';
-import StyledWrapper from './StyledWrapper';
-import Dropdown from 'components/Dropdown';
-import { getRevealInFolderLabel } from 'utils/common/platform';
-import { getWorkspaceDisplayName } from 'components/AppTitleBar';
-import classNames from 'classnames';
-
-const WorkspaceHome = () => {
- const dispatch = useDispatch();
- const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
- const workspaceTabs = useSelector((state) => state.workspaceTabs);
- const activeTabUid = workspaceTabs.activeTabUid;
- const activeTab = workspaceTabs.tabs.find((t) => t.uid === activeTabUid);
-
- 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 handleRenameWorkspaceClick = () => {
- dropdownTippyRef.current?.hide();
- 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) => {
- toast.error('Error opening the folder');
- });
- }
- };
-
- const handleExportWorkspace = () => {
- dropdownTippyRef.current?.hide();
- dispatch(exportWorkspaceAction(activeWorkspace.uid))
- .then((result) => {
- if (!result.canceled) {
- toast.success('Workspace exported successfully');
- }
- })
- .catch((error) => {
- toast.error(error?.message || 'Error exporting workspace');
- });
- };
-
- 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) => {
- setWorkspaceNameInput(e.target.value);
- if (workspaceNameError) {
- setWorkspaceNameError('');
- }
- };
-
- const handleWorkspaceNameKeyDown = (e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- handleSaveWorkspaceRename();
- } else if (e.key === 'Escape') {
- e.preventDefault();
- handleCancelWorkspaceRename();
- }
- };
-
- const renderTabContent = () => {
- if (!activeTab) return null;
-
- switch (activeTab.type) {
- case 'overview':
- return
;
- case 'environments':
- return
;
- case 'preferences':
- return
;
- default:
- return null;
- }
- };
-
- return (
-
-
- {closeWorkspaceModalOpen && (
-
setCloseWorkspaceModalOpen(false)}
- />
- )}
-
-
-
-
-
- {isRenamingWorkspace ? (
-
-
-
-
-
-
-
- ) : (
-
{getWorkspaceDisplayName(activeWorkspace.name)}
- )}
-
-
- {!isRenamingWorkspace && activeWorkspace.type !== 'default' && (
-
}
- >
-
-
-
- Rename
-
-
-
- {getRevealInFolderLabel()}
-
-
-
- Export
-
-
-
- Close
-
-
-
- )}
-
- {workspaceNameError && isRenamingWorkspace && (
-
{workspaceNameError}
- )}
-
-
-
-
-
{renderTabContent()}
-
-
-
- );
-};
-
-export default WorkspaceHome;
diff --git a/packages/bruno-app/src/components/WorkspaceTabs/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceTabs/StyledWrapper.js
deleted file mode 100644
index 8f394f0c8..000000000
--- a/packages/bruno-app/src/components/WorkspaceTabs/StyledWrapper.js
+++ /dev/null
@@ -1,197 +0,0 @@
-import styled from 'styled-components';
-
-const Wrapper = styled.div`
- position: relative;
-
- &::after {
- content: '';
- position: absolute;
- bottom: 0;
- left: 0;
- right: 0;
- height: 1px;
- background: ${(props) => props.theme.requestTabs.bottomBorder};
- z-index: 0;
- }
-
- .tabs-scroll-container {
- overflow-x: auto;
- overflow-y: clip;
- padding-bottom: 10px;
- margin-bottom: -10px;
-
- &::-webkit-scrollbar {
- display: none;
- }
-
- scrollbar-width: none;
-
- ul {
- margin-bottom: 0;
- overflow: visible;
- }
- }
-
- ul {
- padding: 0 3px;
- margin: 0;
- display: flex;
- align-items: flex-end;
- position: relative;
-
- &::-webkit-scrollbar {
- display: none;
- }
-
- scrollbar-width: none;
-
- li {
- display: inline-flex;
- max-width: 180px;
- min-width: 80px;
- list-style: none;
- cursor: pointer;
- font-size: 0.8125rem;
- position: relative;
- margin-right: 3px;
- color: ${(props) => props.theme.requestTabs.color};
- background: transparent;
- border: 1px solid transparent;
- padding: 6px 0;
- flex-shrink: 0;
- margin-bottom: 3px;
-
- .tab-container {
- width: 100%;
- position: relative;
- overflow: hidden;
- }
-
- &:not(.active) {
- background: ${(props) => props.theme.requestTabs.bg};
- border-color: transparent;
- border-radius: ${(props) => props.theme.border.radius.base};
- }
-
- &:nth-last-child(1) {
- margin-right: 4px;
- }
-
- &.has-overflow:not(:hover) .tab-name {
- mask-image: linear-gradient(
- to right,
- ${(props) => props.theme.requestTabs.color} 0%,
- ${(props) => props.theme.requestTabs.color} calc(100% - 12px),
- transparent 100%
- );
- -webkit-mask-image: linear-gradient(
- to right,
- ${(props) => props.theme.requestTabs.color} 0%,
- ${(props) => props.theme.requestTabs.color} calc(100% - 12px),
- transparent 100%
- );
- }
-
- &.has-overflow:hover .tab-name {
- mask-image: linear-gradient(
- to right,
- ${(props) => props.theme.requestTabs.color} 0%,
- ${(props) => props.theme.requestTabs.color} calc(100% - 8px),
- transparent 100%
- );
- -webkit-mask-image: linear-gradient(
- to right,
- ${(props) => props.theme.requestTabs.color} 0%,
- ${(props) => props.theme.requestTabs.color} calc(100% - 8px),
- transparent 100%
- );
- }
-
- &.active {
- background: ${(props) => props.theme.bg || '#ffffff'};
- border: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
- border-bottom-color: ${(props) => props.theme.bg || '#ffffff'};
- border-radius: 8px 8px 0 0;
- z-index: 2;
- margin-bottom: -2px;
- padding-bottom: 12px;
-
- &::before {
- content: '';
- position: absolute;
- bottom: 1px;
- left: -8px;
- width: 8px;
- height: 8px;
- background: transparent;
- border-bottom-right-radius: 6px;
- box-shadow: 3px 3px 0 0 ${(props) => props.theme.bg || '#ffffff'};
- border-right: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
- border-bottom: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
- }
-
- &::after {
- content: '';
- position: absolute;
- bottom: 1px;
- right: -8px;
- width: 8px;
- height: 8px;
- background: transparent;
- border-bottom-left-radius: 6px;
- box-shadow: -3px 3px 0 0 ${(props) => props.theme.bg || '#ffffff'};
- border-left: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
- border-bottom: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
- }
- }
-
- &.permanent-tab {
- .close-icon {
- display: none;
- }
- }
-
- &.short-tab {
- width: 32px;
- min-width: 32px;
- max-width: 32px;
- padding: 5px 0;
- display: inline-flex;
- justify-content: center;
- align-items: center;
- color: ${(props) => props.theme.text};
- background-color: transparent;
- border: 1px solid transparent;
- border-radius: ${(props) => props.theme.border.radius.base};
- flex-shrink: 0;
-
- > div {
- padding: 3px;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: ${(props) => props.theme.border.radius.sm};
- transition: background-color 0.12s ease, color 0.12s ease;
- }
-
- svg {
- height: 20px;
- width: 20px;
- }
-
- &:hover {
- > div {
- background-color: ${(props) => props.theme.background.surface0};
- color: ${(props) => props.theme.text};
- }
- }
- }
- }
- }
-
- &.has-chevrons ul {
- padding-left: 0;
- }
-`;
-
-export default Wrapper;
diff --git a/packages/bruno-app/src/components/WorkspaceTabs/WorkspaceTab/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceTabs/WorkspaceTab/StyledWrapper.js
deleted file mode 100644
index cadfbb82a..000000000
--- a/packages/bruno-app/src/components/WorkspaceTabs/WorkspaceTab/StyledWrapper.js
+++ /dev/null
@@ -1,61 +0,0 @@
-import styled from 'styled-components';
-
-const StyledWrapper = styled.div`
- position: relative;
- width: 100%;
- height: 100%;
-
- .tab-label {
- overflow: hidden;
- align-items: center;
- position: relative;
- flex: 1;
- min-width: 0;
- }
-
- .tab-icon {
- flex-shrink: 0;
- display: flex;
- align-items: center;
- margin-right: 6px;
- color: ${(props) => props.theme.requestTabs.color};
- }
-
- .tab-name {
- position: relative;
- overflow: hidden;
- white-space: nowrap;
- font-size: 0.8125rem;
- padding-right: 2px;
- }
-
- .close-icon {
- margin-left: 6px;
- padding: 2px;
- border-radius: 3px;
- display: flex;
- align-items: center;
- justify-content: center;
- opacity: 0;
- transition: opacity 0.15s, background-color 0.15s;
-
- &:hover {
- background-color: ${(props) => props.theme.requestTabs.closeIconHoverBg || 'rgba(0, 0, 0, 0.1)'};
- }
-
- svg {
- width: 14px;
- height: 14px;
- }
- }
-
- &:hover .close-icon {
- opacity: 1;
- }
-
- &.permanent .close-icon {
- display: none;
- }
-`;
-
-export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/WorkspaceTabs/WorkspaceTab/index.js b/packages/bruno-app/src/components/WorkspaceTabs/WorkspaceTab/index.js
deleted file mode 100644
index 0d4d256ba..000000000
--- a/packages/bruno-app/src/components/WorkspaceTabs/WorkspaceTab/index.js
+++ /dev/null
@@ -1,45 +0,0 @@
-import React from 'react';
-import { IconX, IconHome, IconWorld, IconSettings } from '@tabler/icons';
-import { useDispatch } from 'react-redux';
-import { closeWorkspaceTab } from 'providers/ReduxStore/slices/workspaceTabs';
-import StyledWrapper from './StyledWrapper';
-
-const TAB_ICONS = {
- overview: IconHome,
- environments: IconWorld,
- preferences: IconSettings
-};
-
-const WorkspaceTab = ({ tab, isActive }) => {
- const dispatch = useDispatch();
-
- const handleCloseClick = (event) => {
- event.stopPropagation();
- event.preventDefault();
- dispatch(closeWorkspaceTab({ uid: tab.uid }));
- };
-
- const TabIcon = TAB_ICONS[tab.type];
-
- return (
-
-
- {TabIcon && (
-
-
-
- )}
-
- {tab.label}
-
-
- {!tab.permanent && (
-
-
-
- )}
-
- );
-};
-
-export default WorkspaceTab;
diff --git a/packages/bruno-app/src/components/WorkspaceTabs/index.js b/packages/bruno-app/src/components/WorkspaceTabs/index.js
deleted file mode 100644
index e579f572d..000000000
--- a/packages/bruno-app/src/components/WorkspaceTabs/index.js
+++ /dev/null
@@ -1,158 +0,0 @@
-import React, { useState, useRef, useEffect, useCallback } from 'react';
-import filter from 'lodash/filter';
-import classnames from 'classnames';
-import { IconChevronRight, IconChevronLeft } from '@tabler/icons';
-import { useSelector, useDispatch } from 'react-redux';
-import { focusWorkspaceTab, initializeWorkspaceTabs } from 'providers/ReduxStore/slices/workspaceTabs';
-import WorkspaceTab from './WorkspaceTab';
-import StyledWrapper from './StyledWrapper';
-
-const PERMANENT_TABS = [
- { type: 'overview', label: 'Overview' },
- { type: 'environments', label: 'Global Environments' }
-];
-
-const WorkspaceTabs = ({ workspaceUid }) => {
- const dispatch = useDispatch();
- const tabsRef = useRef();
- const scrollContainerRef = useRef();
- const [tabOverflowStates, setTabOverflowStates] = useState({});
- const [showChevrons, setShowChevrons] = useState(false);
-
- const tabs = useSelector((state) => state.workspaceTabs.tabs);
- const activeTabUid = useSelector((state) => state.workspaceTabs.activeTabUid);
- const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth);
- const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
- const screenWidth = useSelector((state) => state.app.screenWidth);
-
- // Initialize permanent tabs for this workspace
- useEffect(() => {
- if (workspaceUid) {
- dispatch(initializeWorkspaceTabs({
- workspaceUid,
- permanentTabs: PERMANENT_TABS
- }));
- }
- }, [workspaceUid, dispatch]);
-
- const createSetHasOverflow = useCallback((tabUid) => {
- return (hasOverflow) => {
- setTabOverflowStates((prev) => {
- if (prev[tabUid] === hasOverflow) {
- return prev;
- }
- return {
- ...prev,
- [tabUid]: hasOverflow
- };
- });
- };
- }, []);
-
- // Filter tabs for this workspace
- const workspaceTabs = filter(tabs, (t) => t.workspaceUid === workspaceUid);
-
- useEffect(() => {
- if (!activeTabUid) return;
-
- const checkOverflow = () => {
- if (tabsRef.current && scrollContainerRef.current) {
- const hasOverflow = tabsRef.current.scrollWidth > scrollContainerRef.current.clientWidth + 1;
- setShowChevrons(hasOverflow);
- }
- };
-
- checkOverflow();
- const resizeObserver = new ResizeObserver(checkOverflow);
- if (scrollContainerRef.current) {
- resizeObserver.observe(scrollContainerRef.current);
- }
-
- return () => resizeObserver.disconnect();
- }, [activeTabUid, workspaceTabs.length, screenWidth, leftSidebarWidth, sidebarCollapsed]);
-
- const getTabClassname = (tab, index) => {
- return classnames('request-tab select-none', {
- 'active': tab.uid === activeTabUid,
- 'permanent-tab': tab.permanent,
- 'last-tab': workspaceTabs && workspaceTabs.length && index === workspaceTabs.length - 1,
- 'has-overflow': tabOverflowStates[tab.uid]
- });
- };
-
- const handleClick = (tab) => {
- dispatch(focusWorkspaceTab({ uid: tab.uid }));
- };
-
- if (!workspaceUid || workspaceTabs.length === 0) {
- return null;
- }
-
- const effectiveSidebarWidth = sidebarCollapsed ? 0 : leftSidebarWidth;
- const maxTablistWidth = screenWidth - effectiveSidebarWidth - 150;
-
- const leftSlide = () => {
- scrollContainerRef.current?.scrollBy({
- left: -120,
- behavior: 'smooth'
- });
- };
-
- const rightSlide = () => {
- scrollContainerRef.current?.scrollBy({
- left: 120,
- behavior: 'smooth'
- });
- };
-
- const getRootClassname = () => {
- return classnames({
- 'has-chevrons': showChevrons
- });
- };
-
- return (
-
-
-
- {showChevrons ? (
- -
-
-
-
-
- ) : null}
-
-
-
- {workspaceTabs.map((tab, index) => (
- - handleClick(tab)}
- >
-
-
- ))}
-
-
-
- {showChevrons ? (
- -
-
-
-
-
- ) : null}
-
-
-
- );
-};
-
-export default WorkspaceTabs;
diff --git a/packages/bruno-app/src/hooks/useCollectionFolderTree/index.js b/packages/bruno-app/src/hooks/useCollectionFolderTree/index.js
index f7663777d..e2f2e234c 100644
--- a/packages/bruno-app/src/hooks/useCollectionFolderTree/index.js
+++ b/packages/bruno-app/src/hooks/useCollectionFolderTree/index.js
@@ -1,4 +1,4 @@
-import { useState, useMemo, useCallback } from 'react';
+import { useState, useMemo, useCallback, useEffect } from 'react';
import { isItemAFolder } from 'utils/collections';
import { sortByNameThenSequence } from 'utils/common/index';
import filter from 'lodash/filter';
@@ -63,6 +63,7 @@ const useCollectionFolderTree = (collectionUid) => {
const collection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid));
const [currentFolderPath, setCurrentFolderPath] = useState([]);
const [selectedFolderUid, setSelectedFolderUid] = useState(null);
+
const tree = useMemo(() => {
if (!collection || !collection.items) {
return {};
@@ -143,6 +144,10 @@ const useCollectionFolderTree = (collectionUid) => {
setSelectedFolderUid(null);
}, []);
+ useEffect(() => {
+ reset();
+ }, [collectionUid, reset]);
+
return {
currentFolders,
breadcrumbs,
diff --git a/packages/bruno-app/src/pages/Bruno/index.js b/packages/bruno-app/src/pages/Bruno/index.js
index 91a5b80a8..35275bb29 100644
--- a/packages/bruno-app/src/pages/Bruno/index.js
+++ b/packages/bruno-app/src/pages/Bruno/index.js
@@ -1,6 +1,5 @@
import React, { useState, useRef, useEffect } from 'react';
import classnames from 'classnames';
-import WorkspaceHome from 'components/WorkspaceHome';
import ManageWorkspace from 'components/ManageWorkspace';
import RequestTabs from 'components/RequestTabs';
import RequestTabPanel from 'components/RequestTabPanel';
@@ -77,7 +76,6 @@ export default function Main() {
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const activeApiSpecUid = useSelector((state) => state.apiSpec.activeApiSpecUid);
const isDragging = useSelector((state) => state.app.isDragging);
- const showHomePage = useSelector((state) => state.app.showHomePage);
const showApiSpecPage = useSelector((state) => state.app.showApiSpecPage);
const showManageWorkspacePage = useSelector((state) => state.app.showManageWorkspacePage);
const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen);
@@ -144,8 +142,6 @@ export default function Main() {
) : showManageWorkspacePage ? (
- ) : showHomePage || !activeTabUid ? (
-
) : (
<>
diff --git a/packages/bruno-app/src/providers/App/useIpcEvents.js b/packages/bruno-app/src/providers/App/useIpcEvents.js
index 77aa687a1..b03fb8e51 100644
--- a/packages/bruno-app/src/providers/App/useIpcEvents.js
+++ b/packages/bruno-app/src/providers/App/useIpcEvents.js
@@ -6,9 +6,6 @@ import {
import {
addTab
} from 'providers/ReduxStore/slices/tabs';
-import {
- setActiveWorkspaceTab
-} from 'providers/ReduxStore/slices/workspaceTabs';
import {
brunoConfigUpdateEvent,
collectionAddDirectoryEvent,
@@ -28,7 +25,10 @@ import {
setDotEnvVariables
} from 'providers/ReduxStore/slices/collections';
import { collectionAddEnvFileEvent, openCollectionEvent, hydrateCollectionWithUiStateSnapshot, mergeAndPersistEnvironment } from 'providers/ReduxStore/slices/collections/actions';
-import { workspaceOpenedEvent, workspaceConfigUpdatedEvent } from 'providers/ReduxStore/slices/workspaces/actions';
+import {
+ workspaceOpenedEvent,
+ workspaceConfigUpdatedEvent
+} from 'providers/ReduxStore/slices/workspaces/actions';
import { workspaceDotEnvUpdateEvent, setWorkspaceDotEnvVariables } from 'providers/ReduxStore/slices/workspaces';
import toast from 'react-hot-toast';
import { useDispatch, useStore } from 'react-redux';
@@ -274,24 +274,21 @@ const useIpcEvents = () => {
const removeShowPreferencesListener = ipcRenderer.on('main:open-preferences', () => {
const state = store.getState();
const activeWorkspaceUid = state.workspaces?.activeWorkspaceUid;
- const { showHomePage, showManageWorkspacePage, showApiSpecPage } = state.app;
+ const workspaces = state.workspaces?.workspaces;
const tabs = state.tabs?.tabs;
const activeTabUid = state.tabs?.activeTabUid;
const activeTab = tabs?.find((t) => t.uid === activeTabUid);
- if (showHomePage || showManageWorkspacePage || showApiSpecPage || !activeTabUid) {
- if (activeWorkspaceUid) {
- dispatch(setActiveWorkspaceTab({ workspaceUid: activeWorkspaceUid, type: 'preferences' }));
- }
- } else {
- dispatch(
- addTab({
- type: 'preferences',
- uid: activeTab?.collectionUid ? `${activeTab.collectionUid}-preferences` : 'preferences',
- collectionUid: activeTab?.collectionUid
- })
- );
- }
+ const activeWorkspace = workspaces?.find((w) => w.uid === activeWorkspaceUid);
+ const collectionUid = activeTab?.collectionUid || activeWorkspace?.scratchCollectionUid;
+
+ dispatch(
+ addTab({
+ type: 'preferences',
+ uid: collectionUid ? `${collectionUid}-preferences` : 'preferences',
+ collectionUid
+ })
+ );
});
const removePreferencesUpdatesListener = ipcRenderer.on('main:load-preferences', (val) => {
diff --git a/packages/bruno-app/src/providers/Hotkeys/index.js b/packages/bruno-app/src/providers/Hotkeys/index.js
index 4f46872a1..a87b67e29 100644
--- a/packages/bruno-app/src/providers/Hotkeys/index.js
+++ b/packages/bruno-app/src/providers/Hotkeys/index.js
@@ -16,7 +16,6 @@ import {
} from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid, findItemInCollection } from 'utils/collections';
import { addTab, reorderTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
-import { closeWorkspaceTab } from 'providers/ReduxStore/slices/workspaceTabs';
import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { getKeyBindingsForActionAllOS } from './keyMappings';
@@ -27,8 +26,6 @@ export const HotkeysProvider = (props) => {
const tabs = useSelector((state) => state.tabs.tabs);
const collections = useSelector((state) => state.collections.collections);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
- const showHomePage = useSelector((state) => state.app.showHomePage);
- const activeWorkspaceTabUid = useSelector((state) => state.workspaceTabs.activeTabUid);
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
const [showGlobalSearchModal, setShowGlobalSearchModal] = useState(false);
@@ -175,9 +172,7 @@ export const HotkeysProvider = (props) => {
// close tab hotkey
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('closeTab')], (e) => {
- if (showHomePage && activeWorkspaceTabUid) {
- dispatch(closeWorkspaceTab({ uid: activeWorkspaceTabUid }));
- } else if (activeTabUid) {
+ if (activeTabUid) {
dispatch(
closeTabs({
tabUids: [activeTabUid]
@@ -191,7 +186,7 @@ export const HotkeysProvider = (props) => {
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('closeTab')]);
};
- }, [activeTabUid, showHomePage, activeWorkspaceTabUid]);
+ }, [activeTabUid]);
// Switch to the previous tab
useEffect(() => {
diff --git a/packages/bruno-app/src/providers/ReduxStore/index.js b/packages/bruno-app/src/providers/ReduxStore/index.js
index e448c55d7..3e17f6ad7 100644
--- a/packages/bruno-app/src/providers/ReduxStore/index.js
+++ b/packages/bruno-app/src/providers/ReduxStore/index.js
@@ -4,7 +4,6 @@ import debugMiddleware from './middlewares/debug/middleware';
import appReducer from './slices/app';
import collectionsReducer from './slices/collections';
import tabsReducer from './slices/tabs';
-import workspaceTabsReducer from './slices/workspaceTabs';
import notificationsReducer from './slices/notifications';
import globalEnvironmentsReducer from './slices/global-environments';
import logsReducer from './slices/logs';
@@ -28,7 +27,6 @@ export const store = configureStore({
app: appReducer,
collections: collectionsReducer,
tabs: tabsReducer,
- workspaceTabs: workspaceTabsReducer,
notifications: notificationsReducer,
globalEnvironments: globalEnvironmentsReducer,
logs: logsReducer,
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 f59af7ddc..29ebb38db 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
@@ -2439,6 +2439,53 @@ export const updateBrunoConfig = (brunoConfig, collectionUid) => (dispatch, getS
});
};
+/**
+ * Opens a scratch collection and creates it in Redux state.
+ * This is a simplified version of openCollectionEvent for scratch collections,
+ * without workspace management, toasts, or sidebar toggles.
+ *
+ * @param {string} uid - The unique identifier for the scratch collection
+ * @param {string} pathname - The filesystem path to the scratch collection
+ * @param {Object} brunoConfig - The Bruno configuration object for the collection
+ * @returns {Promise} Resolves when the collection is created, rejects on error
+ */
+export const openScratchCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, getState) => {
+ const { ipcRenderer } = window;
+
+ return new Promise((resolve, reject) => {
+ const state = getState();
+ const existingCollection = state.collections.collections.find(
+ (c) => normalizePath(c.pathname) === normalizePath(pathname)
+ );
+
+ if (existingCollection) {
+ resolve();
+ return;
+ }
+
+ const collection = {
+ version: '1',
+ uid,
+ name: brunoConfig.name,
+ pathname,
+ items: [],
+ runtimeVariables: {},
+ brunoConfig
+ };
+
+ ipcRenderer
+ .invoke('renderer:get-collection-security-config', pathname)
+ .then((securityConfig) => {
+ collectionSchema
+ .validate(collection)
+ .then(() => dispatch(_createCollection({ ...collection, securityConfig })))
+ .then(resolve)
+ .catch(reject);
+ })
+ .catch(reject);
+ });
+};
+
export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, getState) => {
const { ipcRenderer } = window;
@@ -2447,24 +2494,20 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid);
const workspaceProcessEnvVariables = activeWorkspace?.processEnvVariables || {};
- // Check if collection already exists in Redux state
const existingCollection = state.collections.collections.find(
(c) => normalizePath(c.pathname) === normalizePath(pathname)
);
- // Check if collection is already in the current workspace
const isAlreadyInWorkspace = activeWorkspace?.collections?.some(
(c) => normalizePath(c.path) === normalizePath(pathname)
);
- // If collection already exists in Redux AND in current workspace, show toast and return
if (existingCollection && isAlreadyInWorkspace) {
toast.success('Collection is already opened');
resolve();
return;
}
- // If collection exists in Redux but not in workspace, add to workspace
if (existingCollection) {
if (state.app.sidebarCollapsed) {
dispatch(toggleSidebarCollapse());
@@ -2493,7 +2536,6 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
return;
}
- // Collection doesn't exist - create it
const collection = {
version: '1',
uid: uid,
@@ -2520,7 +2562,6 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
);
if (currentWorkspace) {
- // Set collection-workspace mapping for workspace env vars
ipcRenderer.invoke('renderer:set-collection-workspace', uid, currentWorkspace.pathname);
const alreadyInWorkspace = currentWorkspace.collections?.some(
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js
index 0e4ea28dc..5d5932285 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js
@@ -26,7 +26,9 @@ export const tabsSlice = createSlice({
'collection-runner',
'environment-settings',
'global-environment-settings',
- 'preferences'
+ 'preferences',
+ 'workspaceOverview',
+ 'workspaceEnvironments'
];
const existingTab = find(state.tabs, (tab) => tab.uid === uid);
@@ -173,8 +175,10 @@ export const tabsSlice = createSlice({
const activeTab = find(state.tabs, (t) => t.uid === state.activeTabUid);
const tabUids = action.payload.tabUids || [];
- // remove the tabs from the state
- state.tabs = filter(state.tabs, (t) => !tabUids.includes(t.uid));
+ const nonClosableTypes = ['workspaceOverview', 'workspaceEnvironments'];
+ state.tabs = filter(state.tabs, (t) =>
+ !tabUids.includes(t.uid) || nonClosableTypes.includes(t.type)
+ );
if (activeTab && state.tabs.length) {
const { collectionUid } = activeTab;
@@ -201,9 +205,14 @@ export const tabsSlice = createSlice({
}
},
closeAllCollectionTabs: (state, action) => {
- const collectionUid = action.payload.collectionUid;
+ const { collectionUid } = action.payload;
+ const prevActiveTabUid = state.activeTabUid;
state.tabs = filter(state.tabs, (t) => t.collectionUid !== collectionUid);
- state.activeTabUid = null;
+
+ const activeTabStillExists = state.tabs.some((t) => t.uid === prevActiveTabUid);
+ if (!activeTabStillExists) {
+ state.activeTabUid = state.tabs.length > 0 ? last(state.tabs).uid : null;
+ }
},
makeTabPermanent: (state, action) => {
const { uid } = action.payload;
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/workspaceTabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/workspaceTabs.js
deleted file mode 100644
index d4d9e4e6d..000000000
--- a/packages/bruno-app/src/providers/ReduxStore/slices/workspaceTabs.js
+++ /dev/null
@@ -1,199 +0,0 @@
-import { createSlice } from '@reduxjs/toolkit';
-import filter from 'lodash/filter';
-import find from 'lodash/find';
-import last from 'lodash/last';
-
-const initialState = {
- tabs: [],
- activeTabUid: null
-};
-
-export const workspaceTabsSlice = createSlice({
- name: 'workspaceTabs',
- initialState,
- reducers: {
- addWorkspaceTab: (state, action) => {
- const { uid, workspaceUid, type, label, permanent = false } = action.payload;
-
- const existingTab = find(state.tabs, (tab) => tab.uid === uid);
- if (existingTab) {
- state.activeTabUid = existingTab.uid;
- return;
- }
-
- // Check if a tab of the same type already exists for this workspace
- const existingTypeTab = find(
- state.tabs,
- (tab) => tab.workspaceUid === workspaceUid && tab.type === type
- );
- if (existingTypeTab) {
- state.activeTabUid = existingTypeTab.uid;
- return;
- }
-
- state.tabs.push({
- uid,
- workspaceUid,
- type,
- label,
- permanent
- });
- state.activeTabUid = uid;
- },
- focusWorkspaceTab: (state, action) => {
- state.activeTabUid = action.payload.uid;
- },
- closeWorkspaceTab: (state, action) => {
- const tabUid = action.payload.uid;
- const tab = find(state.tabs, (t) => t.uid === tabUid);
-
- // Don't allow closing permanent tabs
- if (tab?.permanent) {
- return;
- }
-
- state.tabs = filter(state.tabs, (t) => t.uid !== tabUid);
-
- // If we closed the active tab, activate another one
- if (state.activeTabUid === tabUid && state.tabs.length > 0) {
- state.activeTabUid = last(state.tabs).uid;
- } else if (state.tabs.length === 0) {
- state.activeTabUid = null;
- }
- },
- closeWorkspaceTabs: (state, action) => {
- const tabUids = action.payload.tabUids || [];
-
- // Filter out permanent tabs from the close request
- const tabsToClose = tabUids.filter((uid) => {
- const tab = find(state.tabs, (t) => t.uid === uid);
- return tab && !tab.permanent;
- });
-
- state.tabs = filter(state.tabs, (t) => !tabsToClose.includes(t.uid));
-
- // If active tab was closed, activate another one
- if (tabsToClose.includes(state.activeTabUid)) {
- if (state.tabs.length > 0) {
- state.activeTabUid = last(state.tabs).uid;
- } else {
- state.activeTabUid = null;
- }
- }
- },
- closeAllWorkspaceTabs: (state, action) => {
- const workspaceUid = action.payload?.workspaceUid;
-
- if (workspaceUid) {
- // Close non-permanent tabs for specific workspace
- state.tabs = filter(
- state.tabs,
- (t) => t.workspaceUid !== workspaceUid || t.permanent
- );
- } else {
- // Close all non-permanent tabs
- state.tabs = filter(state.tabs, (t) => t.permanent);
- }
-
- // If active tab was closed, activate another one
- const activeTabExists = find(state.tabs, (t) => t.uid === state.activeTabUid);
- if (!activeTabExists) {
- state.activeTabUid = state.tabs.length > 0 ? last(state.tabs).uid : null;
- }
- },
- reorderWorkspaceTabs: (state, action) => {
- const { sourceUid, targetUid } = action.payload;
- const tabs = state.tabs;
-
- const sourceIdx = tabs.findIndex((t) => t.uid === sourceUid);
- const targetIdx = tabs.findIndex((t) => t.uid === targetUid);
-
- // Don't reorder permanent tabs
- const sourceTab = tabs[sourceIdx];
- if (sourceTab?.permanent) {
- return;
- }
-
- if (sourceIdx < 0 || targetIdx < 0 || sourceIdx === targetIdx) {
- return;
- }
-
- const [moved] = tabs.splice(sourceIdx, 1);
- tabs.splice(targetIdx, 0, moved);
-
- state.tabs = tabs;
- },
- initializeWorkspaceTabs: (state, action) => {
- const { workspaceUid, permanentTabs } = action.payload;
-
- // Check if permanent tabs already exist for this workspace
- const existingPermanentTabs = state.tabs.filter(
- (t) => t.workspaceUid === workspaceUid && t.permanent
- );
-
- if (existingPermanentTabs.length === 0) {
- // Add permanent tabs
- permanentTabs.forEach((tab) => {
- state.tabs.push({
- uid: `${workspaceUid}-${tab.type}`,
- workspaceUid,
- type: tab.type,
- label: tab.label,
- permanent: true
- });
- });
- }
-
- const workspaceActiveTab = state.tabs.find(
- (t) => t.uid === state.activeTabUid && t.workspaceUid === workspaceUid
- );
-
- if (!workspaceActiveTab) {
- const workspaceTabs = state.tabs.filter((t) => t.workspaceUid === workspaceUid);
- if (workspaceTabs.length > 0) {
- state.activeTabUid = workspaceTabs[0].uid;
- }
- }
- },
- setActiveWorkspaceTab: (state, action) => {
- const { workspaceUid, type } = action.payload;
- let tab = find(
- state.tabs,
- (t) => t.workspaceUid === workspaceUid && t.type === type
- );
-
- if (!tab) {
- const newTabUid = `${workspaceUid}-${type}`;
- const labels = {
- overview: 'Overview',
- environments: 'Global Environments',
- preferences: 'Preferences'
- };
- const newTab = {
- uid: newTabUid,
- workspaceUid,
- type,
- label: labels[type] || type,
- permanent: false
- };
- state.tabs.push(newTab);
- tab = newTab;
- }
-
- state.activeTabUid = tab.uid;
- }
- }
-});
-
-export const {
- addWorkspaceTab,
- focusWorkspaceTab,
- closeWorkspaceTab,
- closeWorkspaceTabs,
- closeAllWorkspaceTabs,
- reorderWorkspaceTabs,
- initializeWorkspaceTabs,
- setActiveWorkspaceTab
-} = workspaceTabsSlice.actions;
-
-export default workspaceTabsSlice.reducer;
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js
index 08a255d78..33651d320 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js
@@ -6,13 +6,14 @@ import {
updateWorkspace,
addCollectionToWorkspace,
removeCollectionFromWorkspace,
- updateWorkspaceLoadingState
+ updateWorkspaceLoadingState,
+ setWorkspaceScratchCollection
} from '../workspaces';
import { showHomePage } from '../app';
-import { createCollection, openCollection, openMultipleCollections } from '../collections/actions';
-import { removeCollection } from '../collections';
+import { createCollection, openCollection, openMultipleCollections, openScratchCollectionEvent } from '../collections/actions';
+import { removeCollection, addTransientDirectory, updateCollectionMountStatus } from '../collections';
import { updateGlobalEnvironments } from '../global-environments';
-import { initializeWorkspaceTabs, setActiveWorkspaceTab } from '../workspaceTabs';
+import { addTab, focusTab } from '../tabs';
import { normalizePath } from 'utils/common/path';
import toast from 'react-hot-toast';
@@ -262,15 +263,29 @@ export const switchWorkspace = (workspaceUid) => {
dispatch(updateGlobalEnvironments({ globalEnvironments: [], activeGlobalEnvironmentUid: null }));
}
+ const scratchCollection = await dispatch(mountScratchCollection(workspaceUid));
await loadWorkspaceCollectionsForSwitch(dispatch, workspace);
- dispatch(showHomePage());
- const permanentTabs = [
- { type: 'overview', label: 'Overview' },
- { type: 'environments', label: 'Global Environments' }
- ];
- dispatch(initializeWorkspaceTabs({ workspaceUid, permanentTabs }));
- dispatch(setActiveWorkspaceTab({ workspaceUid, type: 'overview' }));
+ if (scratchCollection?.uid) {
+ const overviewTabUid = `${scratchCollection.uid}-overview`;
+ const environmentsTabUid = `${scratchCollection.uid}-environments`;
+
+ dispatch(addTab({
+ uid: overviewTabUid,
+ collectionUid: scratchCollection.uid,
+ type: 'workspaceOverview'
+ }));
+
+ dispatch(addTab({
+ uid: environmentsTabUid,
+ collectionUid: scratchCollection.uid,
+ type: 'workspaceEnvironments'
+ }));
+
+ dispatch(focusTab({
+ uid: overviewTabUid
+ }));
+ }
};
};
@@ -840,3 +855,88 @@ export const deleteWorkspaceDotEnvFile = (workspaceUid, filename = '.env') => (d
.catch(reject);
});
};
+
+// Scratch Collection Actions
+
+/**
+ * Get the scratch collection for a workspace
+ */
+export const getScratchCollection = (workspaceUid) => {
+ return (dispatch, getState) => {
+ const state = getState();
+ const workspace = state.workspaces.workspaces.find((w) => w.uid === workspaceUid);
+ if (!workspace?.scratchCollectionUid) {
+ return null;
+ }
+ return state.collections.collections.find((c) => c.uid === workspace.scratchCollectionUid);
+ };
+};
+
+/**
+ * Mount scratch collection for a workspace
+ */
+export const mountScratchCollection = (workspaceUid) => {
+ return async (dispatch, getState) => {
+ const state = getState();
+ const workspace = state.workspaces.workspaces.find((w) => w.uid === workspaceUid);
+
+ if (!workspace) {
+ return null;
+ }
+
+ if (workspace.scratchCollectionUid) {
+ const existingCollection = state.collections.collections.find(
+ (c) => c.uid === workspace.scratchCollectionUid
+ );
+ if (existingCollection) {
+ return existingCollection;
+ }
+ }
+
+ try {
+ const tempDirectoryPath = await ipcRenderer.invoke('renderer:mount-workspace-scratch', {
+ workspaceUid,
+ workspacePath: workspace.pathname || 'default'
+ });
+
+ const { generateUidBasedOnHash } = await import('utils/common');
+ const scratchCollectionUid = generateUidBasedOnHash(tempDirectoryPath);
+
+ const brunoConfig = {
+ opencollection: '1.0.0',
+ name: 'Scratch',
+ type: 'collection',
+ ignore: ['node_modules', '.git']
+ };
+
+ await ipcRenderer.invoke('renderer:add-collection-watcher', {
+ collectionPath: tempDirectoryPath,
+ collectionUid: scratchCollectionUid,
+ brunoConfig
+ });
+
+ await dispatch(openScratchCollectionEvent(scratchCollectionUid, tempDirectoryPath, brunoConfig));
+
+ dispatch(setWorkspaceScratchCollection({
+ workspaceUid,
+ scratchCollectionUid,
+ scratchTempDirectory: tempDirectoryPath
+ }));
+
+ dispatch(addTransientDirectory({
+ collectionUid: scratchCollectionUid,
+ pathname: tempDirectoryPath
+ }));
+
+ dispatch(updateCollectionMountStatus({ collectionUid: scratchCollectionUid, mountStatus: 'mounted' }));
+
+ return { uid: scratchCollectionUid, pathname: tempDirectoryPath };
+ } catch (error) {
+ console.error('Error mounting scratch collection:', error);
+ if (workspace.scratchCollectionUid) {
+ dispatch(updateCollectionMountStatus({ collectionUid: workspace.scratchCollectionUid, mountStatus: 'unmounted' }));
+ }
+ return null;
+ }
+ };
+};
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/index.js
index ec62e47c7..fb26be4b2 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/index.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/index.js
@@ -116,6 +116,16 @@ export const workspacesSlice = createSlice({
workspace.dotEnvVariables = mainEnvFile?.variables || [];
workspace.dotEnvExists = mainEnvFile?.exists || false;
}
+ },
+
+ // Set scratch collection info on workspace
+ setWorkspaceScratchCollection: (state, action) => {
+ const { workspaceUid, scratchCollectionUid, scratchTempDirectory } = action.payload;
+ const workspace = state.workspaces.find((w) => w.uid === workspaceUid);
+ if (workspace) {
+ workspace.scratchCollectionUid = scratchCollectionUid;
+ workspace.scratchTempDirectory = scratchTempDirectory;
+ }
}
}
});
@@ -129,7 +139,8 @@ export const {
removeCollectionFromWorkspace,
updateWorkspaceLoadingState,
workspaceDotEnvUpdateEvent,
- setWorkspaceDotEnvVariables
+ setWorkspaceDotEnvVariables,
+ setWorkspaceScratchCollection
} = workspacesSlice.actions;
export default workspacesSlice.reducer;
diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js
index 7b11d31b7..d3eacb93a 100644
--- a/packages/bruno-app/src/utils/collections/index.js
+++ b/packages/bruno-app/src/utils/collections/index.js
@@ -1737,3 +1737,14 @@ export const filterTransientItems = (items) => {
return item;
});
};
+
+/**
+ * Checks if a collection is a scratch collection for any workspace
+ * @param {Object} collection - The collection to check
+ * @param {Array} workspaces - Array of workspace objects
+ * @returns {boolean} True if the collection is a scratch collection
+ */
+export const isScratchCollection = (collection, workspaces) => {
+ if (!collection || !workspaces) return false;
+ return workspaces.some((w) => w.scratchCollectionUid === collection.uid);
+};
diff --git a/packages/bruno-electron/src/app/collections.js b/packages/bruno-electron/src/app/collections.js
index e82bef6a8..63d35486b 100644
--- a/packages/bruno-electron/src/app/collections.js
+++ b/packages/bruno-electron/src/app/collections.js
@@ -7,6 +7,14 @@ const { generateUidBasedOnHash } = require('../utils/common');
const { transformBrunoConfigAfterRead } = require('../utils/transformBrunoConfig');
const { parseCollection } = require('@usebruno/filestore');
+// Track scratch collection paths (temp directories for workspace scratch requests)
+const scratchCollectionPaths = new Set();
+
+// Register a scratch collection path
+const registerScratchCollectionPath = (scratchPath) => {
+ scratchCollectionPaths.add(path.normalize(scratchPath));
+};
+
// todo: bruno.json config schema validation errors must be propagated to the UI
const configSchema = Yup.object({
name: Yup.string().max(256, 'name must be 256 characters or less').required('name is required'),
@@ -170,5 +178,6 @@ const openCollectionsByPathname = async (win, watcher, collectionPaths, options
module.exports = {
openCollection,
openCollectionDialog,
- openCollectionsByPathname
+ openCollectionsByPathname,
+ registerScratchCollectionPath
};
diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js
index 1901e6762..132148cbe 100644
--- a/packages/bruno-electron/src/ipc/collection.js
+++ b/packages/bruno-electron/src/ipc/collection.js
@@ -55,7 +55,7 @@ const {
isBruEnvironmentConfig,
isCollectionRootBruFile
} = require('../utils/filesystem');
-const { openCollectionDialog, openCollectionsByPathname } = require('../app/collections');
+const { openCollectionDialog, openCollectionsByPathname, registerScratchCollectionPath } = require('../app/collections');
const { generateUidBasedOnHash, stringifyJson, safeStringifyJSON, safeParseJSON } = require('../utils/common');
const { moveRequestUid, deleteRequestUid, syncExampleUidsCache } = require('../cache/requestUids');
const { deleteCookiesForDomain, getDomainsWithCookies, addCookieForDomain, modifyCookieForDomain, parseCookieString, createCookieString, deleteCookie } = require('../utils/cookies');
@@ -99,6 +99,11 @@ const findCollectionPathByItemPath = (filePath) => {
try {
const metadataContent = fs.readFileSync(metadataPath, 'utf8');
const metadata = JSON.parse(metadataContent);
+
+ if (metadata.type === 'scratch') {
+ return transientDirPath;
+ }
+
if (metadata.collectionPath) {
return metadata.collectionPath;
}
@@ -387,43 +392,50 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
}
});
- // save transient request (handles move from temp to permanent location)
- ipcMain.handle('renderer:save-transient-request', async (event, { sourcePathname, targetDirname, targetFilename, request, format }) => {
+ ipcMain.handle('renderer:save-transient-request', async (event, { sourcePathname, targetDirname, targetFilename, request, format, sourceFormat }) => {
try {
- // Validate source exists
if (!fs.existsSync(sourcePathname)) {
throw new Error(`Source path: ${sourcePathname} does not exist`);
}
- // Validate target directory exists
if (!fs.existsSync(targetDirname)) {
throw new Error(`Target directory: ${targetDirname} does not exist`);
}
- // Check if the target directory is inside a collection
validatePathIsInsideCollection(targetDirname);
- // Use provided target filename or fall back to source filename
- const filename = targetFilename || path.basename(sourcePathname);
- const targetPathname = path.join(targetDirname, filename);
+ const collectionPath = findCollectionPathByItemPath(targetDirname);
+ if (!collectionPath) {
+ throw new Error('Could not determine collection for target directory');
+ }
+ const targetFormat = getCollectionFormat(collectionPath);
+
+ const filename = targetFilename || path.basename(sourcePathname);
+ const filenameWithoutExt = filename.replace(/\.(bru|yml)$/, '');
+ const finalFilename = `${filenameWithoutExt}.${targetFormat}`;
+ const targetPathname = path.join(targetDirname, finalFilename);
- // Check for filename conflicts and throw error if exists
if (fs.existsSync(targetPathname)) {
- throw new Error(`A file with the name "${filename}" already exists in the target location`);
+ throw new Error(`A file with the name "${finalFilename}" already exists in the target location`);
}
- // Step 1: Save the updated content to the transient file
- syncExampleUidsCache(sourcePathname, request.examples);
- const content = await stringifyRequestViaWorker(request, { format });
- await writeFile(sourcePathname, content);
+ const actualSourceFormat = sourceFormat || 'yml';
+ const needsConversion = actualSourceFormat !== targetFormat;
- // Step 2: Read the file content from temp (this is the actual file content)
- const fileContent = await fs.promises.readFile(sourcePathname, 'utf8');
+ let finalContent;
+ if (needsConversion) {
+ const { parseRequest, stringifyRequest } = require('@usebruno/filestore');
+ const sourceContent = await fs.promises.readFile(sourcePathname, 'utf8');
+ const parsedRequest = parseRequest(sourceContent, { format: actualSourceFormat });
+ const mergedRequest = { ...parsedRequest, ...request };
+ syncExampleUidsCache(sourcePathname, mergedRequest.examples);
+ finalContent = stringifyRequest(mergedRequest, { format: targetFormat });
+ } else {
+ syncExampleUidsCache(sourcePathname, request.examples);
+ finalContent = await stringifyRequestViaWorker(request, { format: targetFormat });
+ }
- // Step 3: Create new file at target location with the content
- await writeFile(targetPathname, fileContent);
-
- // Return the new pathname (file watcher will handle adding to Redux)
+ await writeFile(targetPathname, finalContent);
return { newPathname: targetPathname };
} catch (error) {
return Promise.reject(error);
@@ -1860,6 +1872,105 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
return tempDirectoryPath;
});
+ ipcMain.handle('renderer:mount-workspace-scratch', async (event, { workspaceUid, workspacePath }) => {
+ try {
+ const tempDirectoryPath = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-scratch-'));
+ registerScratchCollectionPath(tempDirectoryPath);
+
+ const collectionRoot = {
+ meta: {
+ name: 'Scratch'
+ }
+ };
+
+ const brunoConfig = {
+ opencollection: '1.0.0',
+ name: 'Scratch',
+ type: 'collection',
+ ignore: ['node_modules', '.git']
+ };
+
+ const content = stringifyCollection(collectionRoot, brunoConfig, { format: 'yml' });
+ await writeFile(path.join(tempDirectoryPath, 'opencollection.yml'), content);
+
+ const metadata = {
+ workspaceUid,
+ workspacePath,
+ type: 'scratch'
+ };
+ fs.writeFileSync(path.join(tempDirectoryPath, 'metadata.json'), JSON.stringify(metadata));
+
+ return tempDirectoryPath;
+ } catch (error) {
+ console.error('Error mounting workspace scratch collection:', error);
+ throw error;
+ }
+ });
+
+ ipcMain.handle('renderer:add-collection-watcher', async (event, { collectionPath, collectionUid, brunoConfig }) => {
+ if (!watcher || !mainWindow) {
+ throw new Error('Watcher or mainWindow not available');
+ }
+
+ try {
+ const { size, filesCount, maxFileSize } = await getCollectionStats(collectionPath);
+
+ const shouldLoadCollectionAsync
+ = (size > MAX_COLLECTION_SIZE_IN_MB)
+ || (filesCount > MAX_COLLECTION_FILES_COUNT)
+ || (maxFileSize > MAX_SINGLE_FILE_SIZE_IN_COLLECTION_IN_MB);
+
+ watcher.addWatcher(mainWindow, collectionPath, collectionUid, brunoConfig, false, shouldLoadCollectionAsync);
+
+ return { success: true };
+ } catch (error) {
+ console.error('Error adding collection watcher:', error);
+ throw error;
+ }
+ });
+
+ ipcMain.handle('renderer:save-scratch-request', async (event, { sourcePathname, targetDirname, targetFilename, request }) => {
+ try {
+ if (!fs.existsSync(sourcePathname)) {
+ throw new Error(`Source path: ${sourcePathname} does not exist`);
+ }
+
+ if (!fs.existsSync(targetDirname)) {
+ throw new Error(`Target directory: ${targetDirname} does not exist`);
+ }
+
+ validatePathIsInsideCollection(targetDirname);
+
+ const collectionPath = findCollectionPathByItemPath(targetDirname);
+ if (!collectionPath) {
+ throw new Error('Could not determine collection for target directory');
+ }
+ const format = getCollectionFormat(collectionPath);
+
+ const filename = targetFilename || path.basename(sourcePathname);
+ const filenameWithoutExt = filename.replace(/\.(bru|yml)$/, '');
+ const finalFilename = `${filenameWithoutExt}.${format}`;
+ const targetPathname = path.join(targetDirname, finalFilename);
+
+ if (fs.existsSync(targetPathname)) {
+ throw new Error(`A file with the name "${finalFilename}" already exists in the target location`);
+ }
+
+ const content = await stringifyRequestViaWorker(request, { format });
+
+ await writeFile(targetPathname, content);
+
+ if (request.examples) {
+ syncExampleUidsCache(collectionPath, request.examples);
+ }
+
+ return { newPathname: targetPathname };
+ } catch (error) {
+ console.error('Error saving scratch request:', error);
+ return Promise.reject(error);
+ }
+ });
+
ipcMain.handle('renderer:show-in-folder', async (event, filePath) => {
try {
if (!filePath) {
diff --git a/tests/scratch-requests/scratch-requests.spec.ts b/tests/scratch-requests/scratch-requests.spec.ts
new file mode 100644
index 000000000..232a573ca
--- /dev/null
+++ b/tests/scratch-requests/scratch-requests.spec.ts
@@ -0,0 +1,238 @@
+import { test, expect, Page } from '../../playwright';
+import { fillRequestUrl, sendRequest, clickResponseAction, createCollection, closeAllCollections, closeAllTabs } from '../utils/page';
+import { buildCommonLocators } from '../utils/page/locators';
+
+test.describe.serial('Scratch Requests', () => {
+ let locators: ReturnType
;
+
+ test.beforeAll(async ({ page }) => {
+ locators = buildCommonLocators(page);
+
+ // Wait for the app to fully load
+ await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
+ });
+
+ test.afterAll(async ({ page }) => {
+ // Close all tabs (including scratch requests) to avoid "unsaved changes" modal
+ await closeAllTabs(page);
+
+ // Clean up any regular collections
+ await closeAllCollections(page);
+ });
+
+ /**
+ * Helper to create a scratch request when on workspace overview
+ */
+ const createScratchRequest = async (page: Page, requestType: 'HTTP' | 'GraphQL' | 'gRPC' | 'WebSocket' = 'HTTP') => {
+ await test.step(`Create scratch ${requestType} request`, async () => {
+ // Click the + button to create a new request (this is on the workspace overview)
+ const createButton = page.getByRole('button', { name: 'New Transient Request' });
+ await createButton.waitFor({ state: 'visible', timeout: 5000 });
+
+ // Right-click to open the dropdown menu
+ await createButton.click({ button: 'right' });
+
+ // Wait for dropdown to be visible
+ await page.locator('.dropdown-item').first().waitFor({ state: 'visible' });
+
+ // Select the request type from dropdown
+ await page.locator('.dropdown-item').filter({ hasText: requestType }).click();
+
+ // Wait for the request tab to be active
+ await page.locator('.request-tab.active').waitFor({ state: 'visible' });
+ await expect(page.locator('.request-tab.active')).toContainText('Untitled');
+ await page.waitForTimeout(300);
+ });
+ };
+
+ /**
+ * Helper to navigate to workspace overview (home)
+ */
+ const goToWorkspaceOverview = async (page: Page) => {
+ await test.step('Navigate to workspace overview', async () => {
+ // Click the home icon in the title bar to go to workspace overview
+ const homeButton = page.locator('.titlebar-left .home-button');
+ await homeButton.click();
+ await page.waitForTimeout(300);
+ });
+ };
+
+ test('Create scratch HTTP request - should open in workspace tabs', async ({ page }) => {
+ await test.step('Navigate to workspace overview', async () => {
+ await goToWorkspaceOverview(page);
+ });
+
+ await test.step('Create scratch HTTP request', async () => {
+ await createScratchRequest(page, 'HTTP');
+ await fillRequestUrl(page, 'http://localhost:8081/ping');
+ });
+
+ await test.step('Verify HTTP request tab is open', async () => {
+ const activeTab = page.locator('.request-tab.active');
+ await expect(activeTab).toBeVisible();
+ await expect(activeTab).toContainText('Untitled');
+ });
+
+ await test.step('Verify collection header shows for scratch collection', async () => {
+ // Scratch requests should show the collection header with workspace name in the switcher
+ const collectionSwitcher = page.locator('.collection-switcher');
+ await expect(collectionSwitcher).toBeVisible();
+
+ // The switcher should display the workspace name (e.g., "My Workspace")
+ const switcherName = page.locator('.switcher-name');
+ await expect(switcherName).toBeVisible();
+ });
+ });
+
+ test('Create scratch GraphQL request', async ({ page }) => {
+ await test.step('Navigate to workspace overview', async () => {
+ await goToWorkspaceOverview(page);
+ });
+
+ await test.step('Create scratch GraphQL request', async () => {
+ await createScratchRequest(page, 'GraphQL');
+ await fillRequestUrl(page, 'https://api.example.com/graphql');
+ });
+
+ await test.step('Verify GraphQL request tab is open', async () => {
+ const activeTab = page.locator('.request-tab.active');
+ await expect(activeTab).toBeVisible();
+ await expect(activeTab).toContainText('Untitled');
+ });
+ });
+
+ test('Create scratch gRPC request', async ({ page }) => {
+ await test.step('Navigate to workspace overview', async () => {
+ await goToWorkspaceOverview(page);
+ });
+
+ await test.step('Create scratch gRPC request', async () => {
+ await createScratchRequest(page, 'gRPC');
+ await fillRequestUrl(page, 'grpc://localhost:50051');
+ });
+
+ await test.step('Verify gRPC request tab is open', async () => {
+ const activeTab = page.locator('.request-tab.active');
+ await expect(activeTab).toBeVisible();
+ await expect(activeTab).toContainText('Untitled');
+ });
+ });
+
+ test('Create scratch WebSocket request', async ({ page }) => {
+ await test.step('Navigate to workspace overview', async () => {
+ await goToWorkspaceOverview(page);
+ });
+
+ await test.step('Create scratch WebSocket request', async () => {
+ await createScratchRequest(page, 'WebSocket');
+ await fillRequestUrl(page, 'ws://localhost:8082');
+ });
+
+ await test.step('Verify WebSocket request tab is open', async () => {
+ const activeTab = page.locator('.request-tab.active');
+ await expect(activeTab).toBeVisible();
+ await expect(activeTab).toContainText('Untitled');
+ });
+ });
+
+ test('Send scratch HTTP request - verify response', async ({ page }) => {
+ await test.step('Navigate to workspace overview', async () => {
+ await goToWorkspaceOverview(page);
+ });
+
+ await test.step('Create scratch HTTP request', async () => {
+ await createScratchRequest(page, 'HTTP');
+ await fillRequestUrl(page, 'http://localhost:8081/ping');
+ });
+
+ await test.step('Send request and verify response', async () => {
+ await sendRequest(page, 200);
+
+ // Copy response to clipboard and verify
+ await clickResponseAction(page, 'response-copy-btn');
+ await expect(page.getByText('Response copied to clipboard')).toBeVisible();
+
+ const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
+ expect(clipboardText).toBe('pong');
+ });
+ });
+
+ test('Save scratch request to a collection', async ({ page, createTmpDir }) => {
+ // Create a collection to save the scratch request to
+ const collectionPath = await createTmpDir('scratch-save-target');
+ await createCollection(page, 'scratch-save-test', collectionPath);
+
+ await test.step('Navigate to workspace overview', async () => {
+ await goToWorkspaceOverview(page);
+ });
+
+ await test.step('Create scratch HTTP request', async () => {
+ await createScratchRequest(page, 'HTTP');
+ await fillRequestUrl(page, 'http://localhost:8081/echo');
+ });
+
+ await test.step('Trigger save action using keyboard shortcut', async () => {
+ const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s';
+ await page.keyboard.press(saveShortcut);
+ });
+
+ await test.step('Fill in save dialog', async () => {
+ // Wait for save modal to appear - scratch requests show "Select Collection" first
+ const saveModal = page.locator('.bruno-modal-card');
+ await expect(saveModal).toBeVisible({ timeout: 5000 });
+
+ // Fill in request name
+ const requestNameInput = saveModal.locator('#request-name');
+ await requestNameInput.clear();
+ await requestNameInput.fill('Saved Scratch Request');
+
+ // Select the target collection from the list (this transitions from "Select Collection" to "Save Request")
+ const collectionSelector = saveModal.locator('.collection-item').filter({ hasText: 'scratch-save-test' });
+ await collectionSelector.click();
+
+ // Wait for the modal to transition to "Save Request" state (Save button becomes visible)
+ const saveButton = saveModal.getByRole('button', { name: 'Save' });
+ await expect(saveButton).toBeVisible({ timeout: 5000 });
+
+ // Click Save button
+ await saveButton.click();
+
+ // Wait for success toast
+ await expect(page.getByText('Request saved')).toBeVisible({ timeout: 5000 });
+ });
+
+ await test.step('Verify saved request appears in collection sidebar', async () => {
+ // Click on the collection to ensure it's expanded
+ await locators.sidebar.collection('scratch-save-test').click();
+
+ // Look for the saved request in sidebar
+ const savedRequest = locators.sidebar.request('Saved Scratch Request');
+ await expect(savedRequest).toBeVisible();
+ });
+ });
+
+ test('Multiple scratch requests maintain separate tabs', async ({ page }) => {
+ await test.step('Navigate to workspace overview', async () => {
+ await goToWorkspaceOverview(page);
+ });
+
+ await test.step('Create first scratch HTTP request', async () => {
+ await createScratchRequest(page, 'HTTP');
+ await fillRequestUrl(page, 'http://localhost:8081/ping');
+ });
+
+ await test.step('Create second scratch HTTP request', async () => {
+ await createScratchRequest(page, 'HTTP');
+ await fillRequestUrl(page, 'http://localhost:8081/echo');
+ });
+
+ await test.step('Verify both tabs exist', async () => {
+ const tabs = page.locator('.request-tab');
+ const tabCount = await tabs.count();
+ expect(tabCount).toBeGreaterThanOrEqual(2);
+
+ // Both should contain "Untitled" with different numbers
+ await expect(tabs.filter({ hasText: 'Untitled' }).first()).toBeVisible();
+ });
+ });
+});
diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts
index 36798ccf8..b7b261f4a 100644
--- a/tests/utils/page/actions.ts
+++ b/tests/utils/page/actions.ts
@@ -958,6 +958,37 @@ const saveRequest = async (page: Page) => {
});
};
+/**
+ * Close all open request tabs using the right-click context menu
+ * @param page - The page object
+ * @returns void
+ */
+const closeAllTabs = async (page: Page) => {
+ await test.step('Close all tabs', async () => {
+ // Find actual request tabs (those with .tab-method, not Overview/Environments)
+ const requestTabLabel = page.locator('.request-tab').filter({ has: page.locator('.tab-method') }).locator('.tab-label').first();
+ if (!(await requestTabLabel.isVisible().catch(() => false))) {
+ return; // No request tabs to close
+ }
+
+ // Right-click on the tab label to open context menu
+ await requestTabLabel.click({ button: 'right' });
+
+ // Wait for the dropdown menu to appear
+ const dropdown = page.locator('.tippy-box.dropdown');
+ await dropdown.waitFor({ state: 'visible', timeout: 5000 });
+
+ // Click "Close All" menu item
+ await dropdown.locator('[role="menuitem"][data-item-id="close-all"]').click();
+
+ // Handle "Unsaved Transient Requests" modal if it appears
+ const discardAllButton = page.getByRole('button', { name: 'Discard All' });
+ if (await discardAllButton.isVisible({ timeout: 1000 }).catch(() => false)) {
+ await discardAllButton.click();
+ }
+ });
+};
+
export {
closeAllCollections,
openCollection,
@@ -991,7 +1022,8 @@ export {
addAssertion,
editAssertion,
deleteAssertion,
- saveRequest
+ saveRequest,
+ closeAllTabs
};
export type { SandboxMode, EnvironmentType, EnvironmentVariable, ImportCollectionOptions, CreateRequestOptions, CreateUntitledRequestOptions, CreateTransientRequestOptions, AssertionInput };