diff --git a/packages/bruno-app/src/components/CreateTransientRequest/index.js b/packages/bruno-app/src/components/CreateTransientRequest/index.js
new file mode 100644
index 000000000..9a98985e0
--- /dev/null
+++ b/packages/bruno-app/src/components/CreateTransientRequest/index.js
@@ -0,0 +1,245 @@
+import React, { useState, useRef, useCallback, useMemo } from 'react';
+import { IconPlus, IconApi, IconBrandGraphql, IconPlugConnected, IconCode } from '@tabler/icons';
+import ActionIcon from 'ui/ActionIcon/index';
+import Dropdown from 'components/Dropdown';
+import { newHttpRequest, newGrpcRequest, newWsRequest } from 'providers/ReduxStore/slices/collections/actions';
+import { sanitizeName } from 'utils/common/regex';
+import toast from 'react-hot-toast';
+import { useDispatch, useSelector } from 'react-redux';
+import { flattenItems, isItemARequest, isItemTransientRequest } from 'utils/collections';
+import filter from 'lodash/filter';
+import { get } from 'lodash';
+
+const REQUEST_TYPE = {
+ HTTP: 'http',
+ GRAPHQL: 'graphql',
+ GRPC: 'grpc',
+ WEBSOCKET: 'websocket'
+};
+
+/**
+ * Generate a request name for transient requests in the pattern "Untitled {Count}"
+ * @param {Object} collection - The collection object
+ * @returns {string} A request name like "Untitled 1", "Untitled 2", etc.
+ */
+const generateTransientRequestName = (collection) => {
+ if (!collection || !collection.items) {
+ return 'Untitled 1';
+ }
+ const allItems = flattenItems(collection.items);
+ const transientRequests = filter(allItems, (item) => {
+ return isItemTransientRequest(item);
+ });
+
+ // Find the highest "Untitled X" number among transient requests
+ let maxNumber = 0;
+ transientRequests.forEach((item) => {
+ const match = item.name?.match(/^Untitled (\d+)$/);
+ if (match) {
+ const number = parseInt(match[1], 10);
+ if (number > maxNumber) {
+ maxNumber = number;
+ }
+ }
+ });
+
+ // Increment from the highest number found, or start at 1 if none found
+ const count = maxNumber + 1;
+
+ return `Untitled ${count}`;
+};
+
+const CreateTransientRequest = ({ collectionUid }) => {
+ const [dropdownVisible, setDropdownVisible] = useState(false);
+ const dropdownTippyRef = useRef();
+ const dispatch = useDispatch();
+ const collections = useSelector((state) => state.collections.collections);
+
+ const collection = useMemo(() => {
+ return collections?.find((c) => c.uid === collectionUid);
+ }, [collections]);
+
+ const collectionPresets = useMemo(() => {
+ return get(collection, collection?.draft?.brunoConfig ? 'draft.brunoConfig.presets' : 'brunoConfig.presets', {
+ requestType: 'http',
+ requestUrl: ''
+ });
+ }, [collection]);
+
+ const onDropdownCreate = (ref) => {
+ dropdownTippyRef.current = ref;
+ if (ref) {
+ ref.setProps({
+ onHide: () => {
+ setDropdownVisible(false);
+ }
+ });
+ }
+ };
+
+ const handleLeftClick = () => {
+ handleItemClick(collectionPresets.requestType);
+ };
+
+ const handleRightClick = (e) => {
+ e.preventDefault();
+ setDropdownVisible(true);
+ };
+
+ const handleCreateHttpRequest = useCallback(() => {
+ if (!collection) return;
+
+ const uniqueName = generateTransientRequestName(collection);
+ const filename = sanitizeName(uniqueName);
+
+ dispatch(
+ newHttpRequest({
+ requestName: uniqueName,
+ filename: filename,
+ requestType: 'http-request',
+ requestUrl: collectionPresets.requestUrl,
+ requestMethod: 'GET',
+ collectionUid: collection.uid,
+ itemUid: null,
+ isTransient: true
+ })
+ ).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
+ }, [dispatch, collection, collectionPresets.requestUrl]);
+
+ const handleCreateGraphQLRequest = useCallback(() => {
+ if (!collection) return;
+
+ const uniqueName = generateTransientRequestName(collection);
+ const filename = sanitizeName(uniqueName);
+
+ dispatch(
+ newHttpRequest({
+ requestName: uniqueName,
+ filename: filename,
+ requestType: 'graphql-request',
+ requestUrl: collectionPresets.requestUrl,
+ requestMethod: 'POST',
+ collectionUid: collection.uid,
+ itemUid: null,
+ isTransient: true,
+ body: {
+ mode: 'graphql',
+ graphql: {
+ query: '',
+ variables: ''
+ }
+ }
+ })
+ ).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
+ }, [dispatch, collection, collectionPresets.requestUrl]);
+
+ const handleCreateWebSocketRequest = useCallback(() => {
+ if (!collection) return;
+
+ const uniqueName = generateTransientRequestName(collection);
+ const filename = sanitizeName(uniqueName);
+
+ dispatch(
+ newWsRequest({
+ requestName: uniqueName,
+ filename: filename,
+ requestUrl: collectionPresets.requestUrl,
+ requestMethod: 'ws',
+ collectionUid: collection.uid,
+ itemUid: null,
+ isTransient: true
+ })
+ ).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
+ }, [dispatch, collection, collectionPresets.requestUrl]);
+
+ const handleCreateGrpcRequest = useCallback(() => {
+ if (!collection) return;
+
+ const uniqueName = generateTransientRequestName(collection);
+ const filename = sanitizeName(uniqueName);
+
+ dispatch(
+ newGrpcRequest({
+ requestName: uniqueName,
+ filename: filename,
+ requestUrl: collectionPresets.requestUrl,
+ collectionUid: collection.uid,
+ itemUid: null,
+ isTransient: true
+ })
+ ).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
+ }, [dispatch, collection, collectionPresets.requestUrl]);
+
+ const handleItemClick = (type) => {
+ if (dropdownTippyRef.current) {
+ dropdownTippyRef.current.hide();
+ }
+ switch (type) {
+ case REQUEST_TYPE.HTTP:
+ handleCreateHttpRequest();
+ break;
+ case REQUEST_TYPE.GRAPHQL:
+ handleCreateGraphQLRequest();
+ break;
+ case REQUEST_TYPE.GRPC:
+ handleCreateGrpcRequest();
+ break;
+ case REQUEST_TYPE.WEBSOCKET:
+ handleCreateWebSocketRequest();
+ break;
+ }
+ };
+
+ if (!collection) {
+ return null;
+ }
+
+ const IconButton = (
+
+
+
+ );
+
+ return (
+ setDropdownVisible(false)}
+ placement="bottom-end"
+ >
+ handleItemClick(REQUEST_TYPE.HTTP)}>
+
+
+
+
HTTP
+
+ handleItemClick(REQUEST_TYPE.GRAPHQL)}>
+
+
+
+
GraphQL
+
+ handleItemClick(REQUEST_TYPE.GRPC)}>
+
+
+
+
gRPC
+
+ handleItemClick(REQUEST_TYPE.WEBSOCKET)}>
+
+
+
+
WebSocket
+
+
+ );
+};
+
+export default CreateTransientRequest;
diff --git a/packages/bruno-app/src/components/RequestTabs/index.js b/packages/bruno-app/src/components/RequestTabs/index.js
index 15fb43a0e..b10552008 100644
--- a/packages/bruno-app/src/components/RequestTabs/index.js
+++ b/packages/bruno-app/src/components/RequestTabs/index.js
@@ -10,8 +10,7 @@ import CollectionToolBar from './CollectionToolBar';
import RequestTab from './RequestTab';
import StyledWrapper from './StyledWrapper';
import DraggableTab from './DraggableTab';
-import CreateUntitledRequest from 'components/CreateUntitledRequest';
-import { IconPlus } from '@tabler/icons';
+import CreateTransientRequest from 'components/CreateTransientRequest';
import ActionIcon from 'ui/ActionIcon/index';
const RequestTabs = () => {
@@ -161,12 +160,7 @@ const RequestTabs = () => {
{activeCollection && (
- setNewRequestModalOpen(true)} aria-label="New Request" size="lg" style={{ marginBottom: '3px' }}>
-
-
+
)}
diff --git a/packages/bruno-app/src/components/SaveTransientRequest/Container.js b/packages/bruno-app/src/components/SaveTransientRequest/Container.js
new file mode 100644
index 000000000..6fce90ed8
--- /dev/null
+++ b/packages/bruno-app/src/components/SaveTransientRequest/Container.js
@@ -0,0 +1,123 @@
+import React, { useState, useEffect } from 'react';
+import { useSelector, useDispatch } from 'react-redux';
+import { pluralizeWord } from 'utils/common';
+import { IconAlertTriangle, IconDeviceFloppy } from '@tabler/icons';
+import { clearAllSaveTransientRequestModals } from 'providers/ReduxStore/slices/collections';
+import { closeTabs } from 'providers/ReduxStore/slices/tabs';
+import toast from 'react-hot-toast';
+import Modal from 'components/Modal';
+import Button from 'ui/Button';
+import SaveTransientRequest from './index';
+
+const SaveTransientRequestContainer = () => {
+ const dispatch = useDispatch();
+ const modals = useSelector((state) => state.collections.saveTransientRequestModals);
+ const [openItemUid, setOpenItemUid] = useState(null);
+
+ // Reset openItemUid if the modal no longer exists in the array
+ useEffect(() => {
+ if (openItemUid && !modals.find((modal) => modal.item.uid === openItemUid)) {
+ setOpenItemUid(null);
+ }
+ }, [modals, openItemUid]);
+
+ const handleDiscardAll = () => {
+ // Close all tabs for the transient requests (this will also delete the transient files)
+ const tabUids = modals.map((modal) => modal.item.uid);
+ dispatch(closeTabs({ tabUids }));
+
+ // Clear all modals
+ dispatch(clearAllSaveTransientRequestModals());
+
+ // Show success message
+ toast.success(`Discarded ${modals.length} ${pluralizeWord('request', modals.length)}`);
+ };
+
+ const handleCancel = () => {
+ // Clear all modals on close
+ dispatch(clearAllSaveTransientRequestModals());
+ };
+
+ const handleOpenSpecificModal = (itemUid) => {
+ setOpenItemUid(itemUid);
+ };
+
+ // If a specific modal is open, show it
+ if (openItemUid) {
+ const modalToOpen = modals.find((modal) => modal.item.uid === openItemUid);
+ if (modalToOpen) {
+ return (
+
+ );
+ }
+ }
+
+ // Show list of multiple modals
+ return (
+
+
+
+
You have unsaved transient requests
+
+
+ You have {modals.length} {' '}
+ {pluralizeWord('request', modals.length)} that need to be saved.
+
+
+
+
+ Transient {pluralizeWord('Request', modals.length)} ({modals.length})
+
+
+ These requests need to be saved before you can proceed.
+
+
+ {modals.map((modal) => {
+ const { item, collection } = modal;
+ return (
+
+
+ {item.name}
+
+ {collection.name}
+
+
+
handleOpenSpecificModal(item.uid)}
+ icon={ }
+ >
+ Save
+
+
+ );
+ })}
+
+
+
+
+
+ Discard All
+
+
+
+ );
+};
+
+export default SaveTransientRequestContainer;
diff --git a/packages/bruno-app/src/components/SaveTransientRequest/StyledWrapper.js b/packages/bruno-app/src/components/SaveTransientRequest/StyledWrapper.js
new file mode 100644
index 000000000..1c0fa096b
--- /dev/null
+++ b/packages/bruno-app/src/components/SaveTransientRequest/StyledWrapper.js
@@ -0,0 +1,287 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ .save-request-form {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ }
+
+ .form-section {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .form-label {
+ display: block;
+ font-weight: 500;
+ margin-bottom: 8px;
+ color: ${(props) => props.theme.text};
+ }
+
+ .form-input {
+ display: block;
+ width: 100%;
+ line-height: 1.42857143;
+ padding: 0.45rem;
+ border-radius: ${(props) => props.theme.border.radius.sm};
+ background-color: ${(props) => props.theme.input.bg};
+ border: 1px solid ${(props) => props.theme.input.border};
+ color: ${(props) => props.theme.text};
+ transition: border-color ease-in-out 0.1s;
+
+ &:focus {
+ border: solid 1px ${(props) => props.theme.input.focusBorder} !important;
+ outline: none !important;
+ }
+ }
+
+ .collections-section {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .collections-label {
+ display: block;
+ font-weight: 500;
+ margin-bottom: 8px;
+ color: ${(props) => props.theme.text};
+ }
+
+ .collection-name {
+ display: flex;
+ align-items: center;
+ font-size: 14px;
+ margin-bottom: 12px;
+ color: ${(props) => props.theme.colors.text.muted};
+ }
+
+ .collection-name-clickable {
+ cursor: pointer;
+ }
+
+ .collection-name-breadcrumb {
+ cursor: pointer;
+ }
+
+ .collection-name-chevron {
+ margin: 0 4px;
+ }
+
+ .search-container {
+ margin-bottom: 12px;
+ }
+
+ .folder-list {
+ border: 1px solid ${(props) => props.theme.border.border1};
+ border-radius: ${(props) => props.theme.border.radius.sm};
+ max-height: 256px;
+ overflow-y: auto;
+ background-color: ${(props) => props.theme.modal.body.bg};
+ padding: 8px 8px;
+ }
+
+ .folder-list-items {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ border-radius: ${(props) => props.theme.border.radius.sm};
+ }
+
+ .folder-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 12px;
+ cursor: pointer;
+ transition: background-color 0.15s ease;
+ color: ${(props) => props.theme.text};
+ border-radius: ${(props) => props.theme.border.radius.sm};
+ user-select: none;
+ &:hover {
+ background-color: ${(props) => props.theme.plainGrid.hoverBg};
+ }
+
+ &.selected {
+ background-color: ${(props) => props.theme.plainGrid.hoverBg};
+ }
+ }
+
+ .folder-item-content {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .folder-item-name {
+ color: ${(props) => props.theme.text};
+ }
+
+ .folder-empty-state {
+ padding: 16px 12px;
+ text-align: center;
+ font-size: 14px;
+ color: ${(props) => props.theme.colors.text.muted};
+ }
+
+ .custom-modal-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px 0px;
+ background-color: ${(props) => props.theme.modal.body.bg};
+ border-top: 1px solid ${(props) => props.theme.border.border0};
+ border-bottom-left-radius: ${(props) => props.theme.border.radius.base};
+ border-bottom-right-radius: ${(props) => props.theme.border.radius.base};
+ }
+
+ .footer-left {
+ display: flex;
+ align-items: center;
+ }
+
+ .footer-right {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .text-muted {
+ color: ${(props) => props.theme.colors.text.muted};
+ }
+
+ .new-folder-item {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 10px 12px;
+ border-top: 1px solid ${(props) => props.theme.border.border1};
+ margin-top: 4px;
+ padding-top: 12px;
+ }
+
+ .new-folder-content {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ }
+
+ .new-folder-inputs {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ flex: 1;
+ }
+
+ .new-folder-name-input-wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ flex: 1;
+ }
+
+ .new-folder-name-label {
+ font-size: 12px;
+ font-weight: 500;
+ color: ${(props) => props.theme.colors.text.muted};
+ }
+
+ .new-folder-input-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .new-folder-input {
+ flex: 1;
+ padding: 6px 8px;
+ border-radius: ${(props) => props.theme.border.radius.sm};
+ background-color: ${(props) => props.theme.input.bg};
+ border: 1px solid ${(props) => props.theme.input.border};
+ color: ${(props) => props.theme.text};
+ font-size: 14px;
+ transition: border-color ease-in-out 0.1s;
+
+ &:focus {
+ border: solid 1px ${(props) => props.theme.input.focusBorder} !important;
+ outline: none !important;
+ }
+
+ &::placeholder {
+ color: ${(props) => props.theme.colors.text.muted};
+ }
+ }
+
+ .new-folder-actions {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ }
+
+ .new-folder-action-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 4px;
+ border: none;
+ background: transparent;
+ color: ${(props) => props.theme.colors.text.muted};
+ cursor: pointer;
+ border-radius: ${(props) => props.theme.border.radius.sm};
+ transition: all 0.15s ease;
+
+ &:hover {
+ background-color: ${(props) => props.theme.plainGrid.hoverBg};
+ color: ${(props) => props.theme.text};
+ }
+
+ &:active {
+ opacity: 0.7;
+ }
+ }
+
+ .new-folder-filesystem-wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-top: 4px;
+ }
+
+ .new-folder-filesystem-label {
+ font-size: 12px;
+ font-weight: 500;
+ color: ${(props) => props.theme.colors.text.muted};
+ }
+
+ .new-folder-toggle-filesystem-btn {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 8px;
+ margin-top: 4px;
+ border: none;
+ background: transparent;
+ color: ${(props) => props.theme.colors.text.muted};
+ cursor: pointer;
+ border-radius: ${(props) => props.theme.border.radius.sm};
+ font-size: 12px;
+ transition: all 0.15s ease;
+ align-self: flex-start;
+
+ &:hover {
+ background-color: ${(props) => props.theme.plainGrid.hoverBg};
+ color: ${(props) => props.theme.text};
+ }
+ }
+
+ .new-folder-error {
+ color: ${(props) => props.theme.colors.danger};
+ font-size: 12px;
+ margin-top: 4px;
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/SaveTransientRequest/index.js b/packages/bruno-app/src/components/SaveTransientRequest/index.js
new file mode 100644
index 000000000..3522beca2
--- /dev/null
+++ b/packages/bruno-app/src/components/SaveTransientRequest/index.js
@@ -0,0 +1,412 @@
+import React, { useState, useMemo, useEffect, useRef } from 'react';
+import { useSelector, useDispatch } from 'react-redux';
+import Modal from 'components/Modal';
+import SearchInput from 'components/SearchInput';
+import Button from 'ui/Button';
+import { IconFolder, IconChevronRight, IconCheck, IconX, IconEye, IconEyeOff } from '@tabler/icons';
+import filter from 'lodash/filter';
+import toast from 'react-hot-toast';
+import StyledWrapper from './StyledWrapper';
+import useCollectionFolderTree from 'hooks/useCollectionFolderTree';
+import { removeSaveTransientRequestModal, deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
+import { newFolder } from 'providers/ReduxStore/slices/collections/actions';
+import { closeTabs } from 'providers/ReduxStore/slices/tabs';
+import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
+import { resolveRequestFilename } from 'utils/common/platform';
+import { transformRequestToSaveToFilesystem, findCollectionByUid, findItemInCollection } from 'utils/collections';
+import { itemSchema } from '@usebruno/schema';
+
+const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOpen = false, onClose }) => {
+ const dispatch = useDispatch();
+
+ const latestCollection = useSelector((state) =>
+ collectionProp ? findCollectionByUid(state.collections.collections, collectionProp.uid) : null
+ );
+ const latestItem = latestCollection && itemProp ? findItemInCollection(latestCollection, itemProp.uid) : itemProp;
+
+ const item = itemProp;
+ const collection = collectionProp;
+
+ const handleClose = () => {
+ if (onClose) {
+ onClose();
+ return;
+ }
+ // Remove from Redux array
+ dispatch(removeSaveTransientRequestModal({ itemUid: item.uid }));
+ };
+ const [requestName, setRequestName] = useState(item?.name || '');
+ const [searchText, setSearchText] = useState('');
+ const [showNewFolderInput, setShowNewFolderInput] = useState(false);
+ const [newFolderName, setNewFolderName] = useState('');
+ const [newFolderDirectoryName, setNewFolderDirectoryName] = useState('');
+ const [showFilesystemName, setShowFilesystemName] = useState(false);
+ const newFolderInputRef = useRef(null);
+
+ const {
+ currentFolders,
+ breadcrumbs,
+ selectedFolderUid,
+ navigateIntoFolder,
+ navigateToRoot,
+ navigateToBreadcrumb,
+ getCurrentParentFolder,
+ getCurrentSelectedFolder,
+ reset,
+ isAtRoot
+ } = useCollectionFolderTree(collection?.uid);
+
+ const resetForm = () => {
+ setRequestName(item.name || '');
+ setSearchText('');
+ reset();
+ setShowNewFolderInput(false);
+ setNewFolderName('');
+ setNewFolderDirectoryName('');
+ setShowFilesystemName(false);
+ };
+
+ useEffect(() => {
+ isOpen && item && resetForm();
+ }, [isOpen, item]);
+
+ useEffect(() => {
+ if (showNewFolderInput && newFolderInputRef.current) {
+ newFolderInputRef.current.focus();
+ }
+ }, [showNewFolderInput]);
+
+ const filteredFolders = useMemo(() => {
+ if (!searchText.trim()) {
+ return currentFolders;
+ }
+ const searchLower = searchText.toLowerCase();
+ return filter(currentFolders, (folder) => folder.name.toLowerCase().includes(searchLower));
+ }, [currentFolders, searchText]);
+
+ const handleCancel = () => {
+ resetForm();
+ handleClose();
+ };
+
+ const handleConfirm = async () => {
+ if (!item || !collection || !latestItem) {
+ return;
+ }
+
+ try {
+ const { ipcRenderer } = window;
+
+ const selectedFolder = getCurrentSelectedFolder();
+ const targetDirname = selectedFolder ? selectedFolder.pathname : collection.pathname;
+
+ const trimmedName = requestName.trim();
+ if (!trimmedName || trimmedName.length === 0) {
+ toast.error('Request name is required');
+ return;
+ }
+
+ const sanitizedFilename = sanitizeName(trimmedName);
+
+ const itemToSave = latestItem.draft ? { ...latestItem, ...latestItem.draft } : { ...latestItem };
+ itemToSave.name = sanitizedFilename;
+ delete itemToSave.draft;
+
+ const transformedItem = transformRequestToSaveToFilesystem(itemToSave);
+ await itemSchema.validate(transformedItem);
+
+ const format = collection.format || 'bru';
+ const targetFilename = resolveRequestFilename(sanitizedFilename, format);
+
+ await ipcRenderer.invoke('renderer:save-transient-request', {
+ sourcePathname: item.pathname,
+ targetDirname,
+ targetFilename,
+ request: transformedItem,
+ format
+ });
+
+ dispatch(
+ closeTabs({
+ tabUids: [item.uid]
+ })
+ );
+
+ dispatch({
+ type: 'collections/deleteItem',
+ payload: {
+ itemUid: item.uid,
+ collectionUid: collection.uid
+ }
+ });
+
+ toast.success('Request saved successfully');
+ handleClose();
+ } catch (err) {
+ toast.error(err?.message || 'Failed to save request');
+ console.error('Error saving request:', err);
+ }
+ };
+
+ const handleShowNewFolder = () => {
+ setShowNewFolderInput(true);
+ setNewFolderName('');
+ setNewFolderDirectoryName('');
+ setShowFilesystemName(false);
+ };
+
+ const handleCancelNewFolder = () => {
+ setShowNewFolderInput(false);
+ setNewFolderName('');
+ setNewFolderDirectoryName('');
+ setShowFilesystemName(false);
+ };
+
+ const handleNewFolderNameChange = (value) => {
+ setNewFolderName(value);
+ if (!showFilesystemName) {
+ setNewFolderDirectoryName(sanitizeName(value));
+ }
+ };
+
+ const handleDirectoryNameChange = (value) => {
+ setNewFolderDirectoryName(value);
+ };
+
+ const handleCreateNewFolder = async () => {
+ const directoryName = newFolderDirectoryName.trim() || sanitizeName(newFolderName.trim());
+ const parentFolder = getCurrentParentFolder();
+
+ try {
+ await dispatch(newFolder(newFolderName.trim(), directoryName, collection?.uid, parentFolder?.uid));
+ toast.success('New folder created!');
+ handleCancelNewFolder();
+ } catch (err) {
+ const errorMessage = err?.message || 'An error occurred while adding the folder';
+ toast.error(errorMessage);
+ }
+ };
+
+ const handleFolderClick = (folderUid) => {
+ navigateIntoFolder(folderUid);
+ setSearchText('');
+ };
+
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ Request name
+
+ setRequestName(e.target.value)}
+ autoFocus={true}
+ onFocus={(e) => e.target.select()}
+ />
+
+
+
+
Save to Collections
+ {collection && (
+
+ {collection.name}
+ {breadcrumbs.length > 0 && (
+ <>
+ {breadcrumbs.map((breadcrumb, index) => (
+
+
+ {
+ e.stopPropagation();
+ navigateToBreadcrumb(index);
+ setSearchText('');
+ }}
+ >
+ {breadcrumb.name}
+
+
+ ))}
+ >
+ )}
+ {isAtRoot && }
+
+ )}
+
+
+
+
+
+
+ {filteredFolders.length > 0 || showNewFolderInput ? (
+
+ {filteredFolders.map((folder) => (
+ handleFolderClick(folder.uid)}
+ >
+
+
+ {folder.name}
+
+
+
+ ))}
+ {showNewFolderInput && (
+
+
+
+
+
+ {showFilesystemName && (
+
New Folder name (in bruno)
+ )}
+
+
handleNewFolderNameChange(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ handleCreateNewFolder();
+ } else if (e.key === 'Escape') {
+ handleCancelNewFolder();
+ }
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ {showFilesystemName && (
+
+ Name on filesystem
+ handleDirectoryNameChange(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ handleCreateNewFolder();
+ }
+ }}
+ />
+
+ )}
+
+
+ {
+ setShowFilesystemName(!showFilesystemName);
+ setNewFolderDirectoryName(sanitizeName(newFolderName));
+ }}
+ >
+ {showFilesystemName ? (
+ <>
+
+ Hide filesystem name
+ >
+ ) : (
+ <>
+
+ Show filesystem name
+ >
+ )}
+
+
+ )}
+
+ ) : (
+
+ {searchText.trim() ? 'No folders found' : 'No folders available'}
+
+ )}
+
+
+
+
+
+
+ {!showNewFolderInput && (
+ }
+ onClick={handleShowNewFolder}
+ >
+ New Folder
+
+ )}
+
+
+
+ Cancel
+
+
+ Save
+
+
+
+
+
+ );
+};
+
+export default SaveTransientRequest;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js
index 91f54d50a..918c6accd 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js
@@ -502,8 +502,8 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
setCreateExampleModalOpen(false);
};
- const folderItems = sortByNameThenSequence(filter(item.items, (i) => isItemAFolder(i)));
- const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i)));
+ const folderItems = sortByNameThenSequence(filter(item.items, (i) => isItemAFolder(i) && !i.isTransient));
+ const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i) && !i.isTransient));
const handleGenerateCode = () => {
if (
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/ConfirmCollectionCloseDrafts.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/ConfirmCollectionCloseDrafts.js
index 13a24db9d..b31dc655a 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/ConfirmCollectionCloseDrafts.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/ConfirmCollectionCloseDrafts.js
@@ -1,49 +1,89 @@
-import React from 'react';
+import React, { useMemo } from 'react';
import filter from 'lodash/filter';
-import { useDispatch } from 'react-redux';
-import { flattenItems, isItemARequest, hasRequestChanges } from 'utils/collections';
+import { useDispatch, useSelector } from 'react-redux';
+import { flattenItems, isItemARequest, hasRequestChanges, findCollectionByUid } from 'utils/collections';
import { pluralizeWord } from 'utils/common';
-import { saveMultipleRequests } from 'providers/ReduxStore/slices/collections/actions';
+import { saveRequest, saveMultipleRequests } from 'providers/ReduxStore/slices/collections/actions';
import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
import { removeCollection } from 'providers/ReduxStore/slices/collections/actions';
-import { IconAlertTriangle } from '@tabler/icons';
+import { IconAlertTriangle, IconDeviceFloppy } from '@tabler/icons';
import Modal from 'components/Modal';
import toast from 'react-hot-toast';
import Button from 'ui/Button';
+const MAX_UNSAVED_REQUESTS_TO_SHOW = 5;
+
const ConfirmCollectionCloseDrafts = ({ onClose, collection, collectionUid }) => {
- const MAX_UNSAVED_REQUESTS_TO_SHOW = 5;
const dispatch = useDispatch();
- // Get all draft items in the collection
- const currentDrafts = React.useMemo(() => {
- if (!collection) return [];
- const items = flattenItems(collection.items);
- const collectionDrafts = filter(items, (item) => isItemARequest(item) && hasRequestChanges(item));
- return collectionDrafts.map((draft) => ({
- ...draft,
- collectionUid: collectionUid
- }));
- }, [collection, collectionUid]);
+ const latestCollection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid));
+
+ const activeCollection = latestCollection || collection;
+
+ const currentDrafts = useMemo(() => {
+ if (!activeCollection) return [];
+ const items = flattenItems(activeCollection.items);
+ return items
+ ?.filter((item) => isItemARequest(item) && hasRequestChanges(item) && !item.isTransient)
+ .map((item) => {
+ return {
+ ...item,
+ collectionUid: collectionUid
+ };
+ });
+ }, [activeCollection, collectionUid]);
+
+ const currentTransientDrafts = useMemo(() => {
+ if (!activeCollection) return [];
+ const items = flattenItems(activeCollection.items);
+ return items
+ ?.filter((item) => isItemARequest(item) && hasRequestChanges(item) && item.isTransient)
+ .map((item) => {
+ return {
+ ...item,
+ collectionUid: collectionUid
+ };
+ });
+ }, [activeCollection, collectionUid]);
+
+ const allDrafts = useMemo(() => {
+ return [...currentDrafts, ...currentTransientDrafts];
+ }, [currentDrafts, currentTransientDrafts]);
const handleSaveAll = () => {
- dispatch(saveMultipleRequests(currentDrafts))
- .then(() => {
- dispatch(removeCollection(collectionUid))
- .then(() => {
- toast.success('Collection removed from workspace');
- onClose();
- })
- .catch(() => toast.error('An error occurred while removing the collection'));
- })
- .catch(() => {
- toast.error('Failed to save requests!');
- });
+ // If there are transient drafts, we can't proceed with batch save
+ if (currentTransientDrafts.length > 0) {
+ toast.error('Please save or discard transient requests first');
+ return;
+ }
+ // Save only non-transient drafts
+ if (currentDrafts.length > 0) {
+ dispatch(saveMultipleRequests(currentDrafts))
+ .then(() => {
+ dispatch(removeCollection(collectionUid))
+ .then(() => {
+ toast.success('Collection removed from workspace');
+ onClose();
+ })
+ .catch(() => toast.error('An error occurred while removing the collection'));
+ })
+ .catch(() => {
+ toast.error('Failed to save requests!');
+ });
+ } else {
+ // No non-transient drafts, just remove the collection
+ dispatch(removeCollection(collectionUid))
+ .then(() => {
+ toast.success('Collection removed from workspace');
+ onClose();
+ })
+ .catch(() => toast.error('An error occurred while removing the collection'));
+ }
};
const handleDiscardAll = () => {
- // Discard all drafts
- currentDrafts.forEach((draft) => {
+ // Discard all drafts (both regular and transient)
+ allDrafts.forEach((draft) => {
dispatch(deleteRequestDraft({
collectionUid: collectionUid,
itemUid: draft.uid
@@ -59,7 +99,11 @@ const ConfirmCollectionCloseDrafts = ({ onClose, collection, collectionUid }) =>
.catch(() => toast.error('An error occurred while removing the collection'));
};
- if (!currentDrafts.length) {
+ const handleSaveTransient = (draft) => {
+ dispatch(saveRequest(draft.uid, collectionUid));
+ };
+
+ if (!currentDrafts.length && !currentTransientDrafts.length) {
return null;
}
@@ -80,38 +124,82 @@ const ConfirmCollectionCloseDrafts = ({ onClose, collection, collectionUid }) =>
Hold on..
- Do you want to save the changes you made to the following{' '}
- {currentDrafts.length} {pluralizeWord('request', currentDrafts.length)}?
+ You have unsaved changes in {allDrafts.length} {' '}
+ {pluralizeWord('request', allDrafts.length)}.
-
- {currentDrafts.slice(0, MAX_UNSAVED_REQUESTS_TO_SHOW).map((item) => {
- return (
-
- {item.filename}
-
- );
- })}
-
+ {/* Regular (saved) requests with changes */}
+ {currentDrafts.length > 0 && (
+
+
+ Saved {pluralizeWord('Request', currentDrafts.length)} ({currentDrafts.length})
+
+
+ {currentDrafts.slice(0, MAX_UNSAVED_REQUESTS_TO_SHOW).map((item) => {
+ return (
+
+ • {item.filename || item.name}
+
+ );
+ })}
+
+ {currentDrafts.length > MAX_UNSAVED_REQUESTS_TO_SHOW && (
+
+ ...{currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW} additional{' '}
+ {pluralizeWord('request', currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW)} not shown
+
+ )}
+
+ )}
- {currentDrafts.length > MAX_UNSAVED_REQUESTS_TO_SHOW && (
-
- ...{currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW} additional{' '}
- {pluralizeWord('request', currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW)} not shown
-
+ {/* Transient (unsaved) requests */}
+ {currentTransientDrafts.length > 0 && (
+
+
+ Transient {pluralizeWord('Request', currentTransientDrafts.length)} ({currentTransientDrafts.length})
+
+
+ These requests need to be saved individually before closing the collection.
+
+
+ {currentTransientDrafts.map((item) => {
+ return (
+
+ {item.name}
+ handleSaveTransient(item)}
+ icon={ }
+ >
+ Save
+
+
+ );
+ })}
+
+
)}
- Discard and Remove
+ Discard All and Remove
Cancel
-
+ 0}
+ title={currentTransientDrafts.length > 0 ? 'Please save or discard transient requests first' : ''}
+ >
{currentDrafts.length > 1 ? 'Save All and Remove' : 'Save and Remove'}
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js
index 049de9eca..90b830137 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js
@@ -276,8 +276,8 @@ const Collection = ({ collection, searchText }) => {
return items.sort((a, b) => a.seq - b.seq);
};
- const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i)));
- const folderItems = sortByNameThenSequence(filter(collection.items, (i) => isItemAFolder(i)));
+ const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i) && !i.isTransient));
+ const folderItems = sortByNameThenSequence(filter(collection.items, (i) => isItemAFolder(i) && !i.isTransient));
const menuItems = [
{
diff --git a/packages/bruno-app/src/hooks/useCollectionFolderTree/index.js b/packages/bruno-app/src/hooks/useCollectionFolderTree/index.js
new file mode 100644
index 000000000..f7663777d
--- /dev/null
+++ b/packages/bruno-app/src/hooks/useCollectionFolderTree/index.js
@@ -0,0 +1,162 @@
+import { useState, useMemo, useCallback } from 'react';
+import { isItemAFolder } from 'utils/collections';
+import { sortByNameThenSequence } from 'utils/common/index';
+import filter from 'lodash/filter';
+import { useSelector } from 'react-redux';
+import { findCollectionByUid } from 'utils/collections';
+
+const buildTree = (items) => {
+ const tree = {};
+
+ if (!items || items.length === 0) {
+ return tree;
+ }
+
+ const folders = filter(items, (i) => isItemAFolder(i) && !i.isTransient);
+ const sortedFolders = sortByNameThenSequence(folders);
+
+ for (const folder of sortedFolders) {
+ tree[folder.name] = {
+ uid: folder.uid,
+ name: folder.name,
+ item: folder,
+ children: folder.items && folder.items.length > 0 ? buildTree(folder.items) : {}
+ };
+ }
+
+ return tree;
+};
+
+const findFolderByUidInTree = (tree, uid) => {
+ for (const folderName in tree) {
+ const folder = tree[folderName];
+ if (folder.uid === uid) {
+ return folder;
+ }
+ if (folder.children && Object.keys(folder.children).length > 0) {
+ const found = findFolderByUidInTree(folder.children, uid);
+ if (found) return found;
+ }
+ }
+ return null;
+};
+
+const getFoldersAtPath = (tree, path) => {
+ if (path.length === 0) {
+ return Object.values(tree).map((folder) => folder.item);
+ }
+
+ let currentTree = tree;
+ for (const folderUid of path) {
+ const folder = findFolderByUidInTree(currentTree, folderUid);
+ if (folder && folder.children) {
+ currentTree = folder.children;
+ } else {
+ return [];
+ }
+ }
+
+ return Object.values(currentTree).map((folder) => folder.item);
+};
+
+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 {};
+ }
+ return buildTree(collection.items);
+ }, [collection]);
+
+ const currentFolders = useMemo(() => {
+ return getFoldersAtPath(tree, currentFolderPath);
+ }, [tree, currentFolderPath]);
+
+ const breadcrumbs = useMemo(() => {
+ if (currentFolderPath.length === 0) {
+ return [];
+ }
+
+ const breadcrumbParts = [];
+ let currentTree = tree;
+
+ for (const folderUid of currentFolderPath) {
+ const folder = findFolderByUidInTree(currentTree, folderUid);
+ if (folder) {
+ breadcrumbParts.push({
+ uid: folder.uid,
+ name: folder.name
+ });
+ currentTree = folder.children;
+ }
+ }
+
+ return breadcrumbParts;
+ }, [tree, currentFolderPath]);
+
+ const navigateIntoFolder = useCallback((folderUid) => {
+ setCurrentFolderPath((prev) => [...prev, folderUid]);
+ setSelectedFolderUid(folderUid);
+ }, []);
+
+ const goBack = useCallback(() => {
+ setCurrentFolderPath((prev) => {
+ if (prev.length > 0) {
+ return prev.slice(0, -1);
+ }
+ return prev;
+ });
+ setSelectedFolderUid(null);
+ }, []);
+
+ const navigateToRoot = useCallback(() => {
+ setCurrentFolderPath([]);
+ setSelectedFolderUid(null);
+ }, []);
+
+ const navigateToBreadcrumb = useCallback((index) => {
+ setCurrentFolderPath((prev) => prev.slice(0, index + 1));
+ setSelectedFolderUid(null);
+ }, []);
+
+ const getCurrentParentFolder = useCallback(() => {
+ if (currentFolderPath.length === 0) {
+ return null;
+ }
+ const lastFolderUid = currentFolderPath[currentFolderPath.length - 1];
+ const folder = findFolderByUidInTree(tree, lastFolderUid);
+ return folder ? folder.item : null;
+ }, [tree, currentFolderPath]);
+
+ const getCurrentSelectedFolder = useCallback(() => {
+ if (selectedFolderUid) {
+ const folder = findFolderByUidInTree(tree, selectedFolderUid);
+ return folder ? folder.item : null;
+ }
+ return null;
+ }, [tree, selectedFolderUid]);
+
+ const reset = useCallback(() => {
+ setCurrentFolderPath([]);
+ setSelectedFolderUid(null);
+ }, []);
+
+ return {
+ currentFolders,
+ breadcrumbs,
+ selectedFolderUid,
+ setSelectedFolderUid,
+ navigateIntoFolder,
+ goBack,
+ navigateToRoot,
+ navigateToBreadcrumb,
+ getCurrentParentFolder,
+ getCurrentSelectedFolder,
+ reset,
+ isAtRoot: currentFolderPath.length === 0
+ };
+};
+
+export default useCollectionFolderTree;
diff --git a/packages/bruno-app/src/pages/Bruno/index.js b/packages/bruno-app/src/pages/Bruno/index.js
index 9536df398..91a5b80a8 100644
--- a/packages/bruno-app/src/pages/Bruno/index.js
+++ b/packages/bruno-app/src/pages/Bruno/index.js
@@ -20,6 +20,8 @@ import Devtools from 'components/Devtools';
import useGrpcEventListeners from 'utils/network/grpc-event-listeners';
import useWsEventListeners from 'utils/network/ws-event-listeners';
import Portal from 'components/Portal';
+import SaveTransientRequestContainer from 'components/SaveTransientRequest/Container';
+import SaveTransientRequest from 'components/SaveTransientRequest';
require('codemirror/mode/javascript/javascript');
require('codemirror/mode/xml/xml');
@@ -53,6 +55,24 @@ require('utils/codemirror/brunoVarInfo');
require('utils/codemirror/javascript-lint');
require('utils/codemirror/autocomplete');
+const TransientRequestModalsRenderer = ({ modals }) => {
+ if (modals.length === 0) {
+ return null;
+ }
+
+ if (modals.length === 1) {
+ return (
+
+ );
+ }
+
+ return
;
+};
+
export default function Main() {
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const activeApiSpecUid = useSelector((state) => state.apiSpec.activeApiSpecUid);
@@ -61,6 +81,7 @@ export default function Main() {
const showApiSpecPage = useSelector((state) => state.app.showApiSpecPage);
const showManageWorkspacePage = useSelector((state) => state.app.showManageWorkspacePage);
const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen);
+ const saveTransientRequestModals = useSelector((state) => state.collections.saveTransientRequestModals);
const mainSectionRef = useRef(null);
const [showRosettaBanner, setShowRosettaBanner] = useState(false);
@@ -137,6 +158,7 @@ export default function Main() {
+
//
);
diff --git a/packages/bruno-app/src/providers/ReduxStore/middlewares/autosave/middleware.js b/packages/bruno-app/src/providers/ReduxStore/middlewares/autosave/middleware.js
index c77fe1ce9..3810e3673 100644
--- a/packages/bruno-app/src/providers/ReduxStore/middlewares/autosave/middleware.js
+++ b/packages/bruno-app/src/providers/ReduxStore/middlewares/autosave/middleware.js
@@ -1,6 +1,6 @@
import { saveRequest, saveCollectionSettings, saveFolderRoot, saveEnvironment } from '../../slices/collections/actions';
import { saveGlobalEnvironment } from '../../slices/global-environments';
-import { flattenItems, isItemARequest, isItemAFolder } from 'utils/collections';
+import { flattenItems, isItemARequest, isItemAFolder, findItemInCollection, findCollectionByUid, isItemTransientRequest } from 'utils/collections';
const actionsToIntercept = [
// Request-level actions
@@ -134,6 +134,10 @@ const saveExistingDrafts = (dispatch, getState, interval) => {
allItems.forEach((item) => {
if (item.draft) {
if (isItemARequest(item)) {
+ // Skip auto-save for transient requests
+ if (isItemTransientRequest(item)) {
+ return;
+ }
const key = `request-${item.uid}`;
scheduleAutoSave(key, () => dispatch(saveRequest(item.uid, collection.uid, true)), interval);
} else if (isItemAFolder(item)) {
@@ -199,6 +203,16 @@ const determineSaveHandler = (actionType, payload, dispatch, getState) => {
// Handle request actions
if (itemUid) {
+ // Check if this is a transient request and skip auto-save
+ const state = getState();
+ const collection = findCollectionByUid(state.collections.collections, collectionUid);
+ if (collection) {
+ const item = findItemInCollection(collection, itemUid);
+ if (item && isItemTransientRequest(item)) {
+ return null; // Skip auto-save for transient requests
+ }
+ }
+
return {
key: `request-${itemUid}`,
save: () => dispatch(saveRequest(itemUid, collectionUid, true))
diff --git a/packages/bruno-app/src/providers/ReduxStore/middlewares/tasks/middleware.js b/packages/bruno-app/src/providers/ReduxStore/middlewares/tasks/middleware.js
index e9830b589..540981b29 100644
--- a/packages/bruno-app/src/providers/ReduxStore/middlewares/tasks/middleware.js
+++ b/packages/bruno-app/src/providers/ReduxStore/middlewares/tasks/middleware.js
@@ -3,9 +3,9 @@ import each from 'lodash/each';
import filter from 'lodash/filter';
import { createListenerMiddleware } from '@reduxjs/toolkit';
import { removeTaskFromQueue } from 'providers/ReduxStore/slices/app';
-import { addTab } from 'providers/ReduxStore/slices/tabs';
+import { addTab, closeTabs, closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs';
import { collectionAddFileEvent, collectionChangeFileEvent } from 'providers/ReduxStore/slices/collections';
-import { findCollectionByUid, findItemInCollectionByPathname, getDefaultRequestPaneTab, findItemInCollectionByItemUid } from 'utils/collections/index';
+import { findCollectionByUid, findItemInCollectionByPathname, getDefaultRequestPaneTab, findItemInCollectionByItemUid, findItemInCollection, flattenItems } from 'utils/collections/index';
import { taskTypes } from './utils';
const taskMiddleware = createListenerMiddleware();
@@ -29,12 +29,14 @@ taskMiddleware.startListening({
const collection = findCollectionByUid(state.collections.collections, collectionUid);
if (collection && collection.mountStatus === 'mounted' && !collection.isLoading) {
const item = findItemInCollectionByPathname(collection, task.itemPathname);
+ const isTransient = item?.isTransient ?? false;
if (item) {
listenerApi.dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
- requestPaneTab: getDefaultRequestPaneTab(item)
+ requestPaneTab: getDefaultRequestPaneTab(item),
+ preview: !isTransient
})
);
}
@@ -91,4 +93,39 @@ taskMiddleware.startListening({
}
});
+/*
+ * When tabs are closed, check if any of them are transient requests.
+ * If so, delete the temporary files from the filesystem.
+ * Note: If a transient request was saved (moved to permanent location),
+ * the file will already be deleted, which is expected behavior.
+ */
+taskMiddleware.startListening({
+ actionCreator: closeTabs,
+ effect: (action, listenerApi) => {
+ const state = listenerApi.getState();
+ const tabUids = action.payload.tabUids || [];
+ const { ipcRenderer } = window;
+
+ each(tabUids, (tabUid) => {
+ const collections = state.collections.collections;
+
+ for (const collection of collections) {
+ const item = findItemInCollection(collection, tabUid);
+ const isTransient = item?.isTransient ?? false;
+ if (item && isTransient) {
+ ipcRenderer
+ .invoke('renderer:delete-item', item.pathname, item.type, collection.pathname)
+ .then(() => {})
+ .catch((err) => {
+ if (err.message && !err.message.includes('does not exist')) {
+ console.error(`Failed to delete transient request file: ${item.pathname}`, err);
+ }
+ });
+
+ break;
+ }
+ }
+ });
+ }
+});
export default taskMiddleware;
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 bd1a45014..aea5c0352 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
@@ -20,7 +20,8 @@ import {
isItemARequest,
getAllVariables,
transformRequestToSaveToFilesystem,
- transformCollectionRootToSave
+ transformCollectionRootToSave,
+ flattenItems
} from 'utils/collections';
import { uuid, waitForNextTick } from 'utils/common';
import { cancelNetworkRequest, connectWS, sendGrpcRequest, sendNetworkRequest, sendWsRequest } from 'utils/network/index';
@@ -56,6 +57,8 @@ import {
updateFolderVar,
addCollectionVar,
updateCollectionVar,
+ addTransientDirectory,
+ addSaveTransientRequestModal,
updatePathParam
} from './index';
@@ -137,7 +140,7 @@ export const renameCollection = (newName, collectionUid) => (dispatch, getState)
export const saveRequest = (itemUid, collectionUid, silent = false) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
-
+ const tempDirectory = state.collections.tempDirectories?.[collectionUid];
return new Promise((resolve, reject) => {
if (!collection) {
return reject(new Error('Collection not found'));
@@ -149,6 +152,12 @@ export const saveRequest = (itemUid, collectionUid, silent = false) => (dispatch
return reject(new Error('Not able to locate item'));
}
+ const isTransient = tempDirectory && item.pathname.startsWith(tempDirectory);
+ if (isTransient) {
+ dispatch(addSaveTransientRequestModal({ item, collection }));
+ return reject();
+ }
+
const itemToSave = transformRequestToSaveToFilesystem(item);
const { ipcRenderer } = window;
@@ -1235,7 +1244,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
headers,
body,
auth,
- settings
+ settings,
+ isTransient = false
} = params;
return new Promise((resolve, reject) => {
@@ -1245,6 +1255,9 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
return reject(new Error('Collection not found'));
}
+ // Get temp directory if isTransient is true
+ const tempDirectory = isTransient ? state.collections.tempDirectories?.[collectionUid] : null;
+
const parts = splitOnFirst(requestUrl, '?');
const queryParams = parseQueryParams(parts[1]);
each(queryParams, (urlParam) => {
@@ -1265,6 +1278,7 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
type: requestType,
name: requestName,
filename,
+ isTransient: isTransient,
request: {
method: requestMethod,
url: requestUrl,
@@ -1295,8 +1309,45 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
};
// itemUid is null when we are creating a new request at the root level
+ // For transient requests, itemUid is always null
const resolvedFilename = resolveRequestFilename(filename, collection.format);
- if (!itemUid) {
+
+ if (isTransient) {
+ // Transient requests are always created in temp directory
+ // Check for duplicates only among other transient requests
+ const allItems = flattenItems(collection.items);
+ const transientRequests = filter(
+ allItems,
+ (i) => isItemARequest(i) && i.pathname && i.pathname.startsWith(tempDirectory)
+ );
+ const reqWithSameNameExists = find(transientRequests, (i) => trim(i.filename) === trim(resolvedFilename));
+ const items = filter(collection.items, (i) => isItemAFolder(i) || isItemARequest(i));
+ item.seq = items.length + 1;
+
+ if (!reqWithSameNameExists) {
+ const fullName = path.join(tempDirectory, resolvedFilename);
+ const { ipcRenderer } = window;
+
+ ipcRenderer
+ .invoke('renderer:new-request', fullName, item)
+ .then(() => {
+ // task middleware will track this and open the new request in a new tab once request is created
+ dispatch(
+ insertTaskIntoQueue({
+ uid: uuid(),
+ type: 'OPEN_REQUEST',
+ collectionUid,
+ itemPathname: fullName
+ })
+ );
+ resolve();
+ })
+ .catch(reject);
+ } else {
+ return reject(new Error('Duplicate request names are not allowed under the same folder'));
+ }
+ } else if (!itemUid) {
+ // Regular request at root level
const reqWithSameNameExists = find(
collection.items,
(i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)
@@ -1362,7 +1413,7 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
};
export const newGrpcRequest = (params) => (dispatch, getState) => {
- const { requestName, filename, requestUrl, collectionUid, body, auth, headers, itemUid } = params;
+ const { requestName, filename, requestUrl, collectionUid, body, auth, headers, itemUid, isTransient = false } = params;
return new Promise((resolve, reject) => {
const state = getState();
@@ -1370,6 +1421,10 @@ export const newGrpcRequest = (params) => (dispatch, getState) => {
if (!collection) {
return reject(new Error('Collection not found'));
}
+
+ // Get temp directory if isTransient is true
+ const tempDirectory = isTransient ? state.collections.tempDirectories?.[collectionUid] : null;
+
// do we need to handle query, path params for grpc requests?
// skipping for now
@@ -1378,6 +1433,7 @@ export const newGrpcRequest = (params) => (dispatch, getState) => {
name: requestName,
filename,
type: 'grpc-request',
+ isTransient: isTransient,
headers: headers ?? [],
request: {
url: requestUrl,
@@ -1407,42 +1463,84 @@ export const newGrpcRequest = (params) => (dispatch, getState) => {
};
// itemUid is null when we are creating a new request at the root level
- const parentItem = itemUid ? findItemInCollection(collection, itemUid) : collection;
+ // For transient requests, itemUid is always null
const resolvedFilename = resolveRequestFilename(filename, collection.format);
- if (!parentItem) {
- return reject(new Error('Parent item not found'));
+ if (isTransient) {
+ // Transient requests are always created in temp directory
+ // Check for duplicates only among other transient requests
+ const allItems = flattenItems(collection.items);
+ const transientRequests = filter(
+ allItems,
+ (i) => isItemARequest(i) && i.pathname && i.pathname.startsWith(tempDirectory)
+ );
+ const reqWithSameNameExists = find(transientRequests, (i) => trim(i.filename) === trim(resolvedFilename));
+
+ if (reqWithSameNameExists) {
+ return reject(new Error('Duplicate request names are not allowed under the same folder'));
+ }
+
+ const items = filter(collection.items, (i) => isItemAFolder(i) || isItemARequest(i));
+ item.seq = items.length + 1;
+ const fullName = path.join(tempDirectory, resolvedFilename);
+ const { ipcRenderer } = window;
+ ipcRenderer
+ .invoke('renderer:new-request', fullName, item)
+ .then(() => {
+ // task middleware will track this and open the new request in a new tab once request is created
+ dispatch(
+ insertTaskIntoQueue({
+ uid: uuid(),
+ type: 'OPEN_REQUEST',
+ collectionUid,
+ itemPathname: fullName
+ })
+ );
+ resolve();
+ })
+ .catch(reject);
+ } else {
+ // Regular request (can be at root or in a folder)
+ const parentItem = itemUid ? findItemInCollection(collection, itemUid) : collection;
+
+ if (!parentItem) {
+ return reject(new Error('Parent item not found'));
+ }
+
+ const reqWithSameNameExists = find(
+ parentItem.items,
+ (i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)
+ );
+
+ if (reqWithSameNameExists) {
+ return reject(new Error('Duplicate request names are not allowed under the same folder'));
+ }
+
+ const items = filter(parentItem.items, (i) => isItemAFolder(i) || isItemARequest(i));
+ item.seq = items.length + 1;
+ const fullName = path.join(parentItem.pathname, resolvedFilename);
+ const { ipcRenderer } = window;
+ ipcRenderer
+ .invoke('renderer:new-request', fullName, item)
+ .then(() => {
+ // task middleware will track this and open the new request in a new tab once request is created
+ dispatch(
+ insertTaskIntoQueue({
+ uid: uuid(),
+ type: 'OPEN_REQUEST',
+ collectionUid,
+ itemPathname: fullName
+ })
+ );
+ resolve();
+ })
+ .catch(reject);
}
-
- const reqWithSameNameExists = find(parentItem.items,
- (i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename));
-
- if (reqWithSameNameExists) {
- return reject(new Error('Duplicate request names are not allowed under the same folder'));
- }
-
- const items = filter(parentItem.items, (i) => isItemAFolder(i) || isItemARequest(i));
- item.seq = items.length + 1;
- const fullName = path.join(parentItem.pathname, resolvedFilename);
- const { ipcRenderer } = window;
- ipcRenderer
- .invoke('renderer:new-request', fullName, item)
- .then(() => {
- // task middleware will track this and open the new request in a new tab once request is created
- dispatch(insertTaskIntoQueue({
- uid: uuid(),
- type: 'OPEN_REQUEST',
- collectionUid,
- itemPathname: fullName
- }));
- resolve();
- })
- .catch(reject);
});
};
export const newWsRequest = (params) => (dispatch, getState) => {
- const { requestName, requestMethod, filename, requestUrl, collectionUid, body, auth, headers, itemUid } = params;
+ const { requestName, requestMethod, filename, requestUrl, collectionUid, body, auth, headers, itemUid, isTransient = false } = params;
return new Promise((resolve, reject) => {
const state = getState();
@@ -1451,11 +1549,15 @@ export const newWsRequest = (params) => (dispatch, getState) => {
return reject(new Error('Collection not found'));
}
+ // Get temp directory if isTransient is true
+ const tempDirectory = isTransient ? state.collections.tempDirectories?.[collectionUid] : null;
+
const item = {
uid: uuid(),
name: requestName,
filename,
type: 'ws-request',
+ isTransient: isTransient,
headers: headers ?? [],
request: {
url: requestUrl,
@@ -1488,37 +1590,79 @@ export const newWsRequest = (params) => (dispatch, getState) => {
};
// itemUid is null when we are creating a new request at the root level
- const parentItem = itemUid ? findItemInCollection(collection, itemUid) : collection;
+ // For transient requests, itemUid is always null
const resolvedFilename = resolveRequestFilename(filename, collection.format);
- if (!parentItem) {
- return reject(new Error('Parent item not found'));
+ if (isTransient) {
+ // Transient requests are always created in temp directory
+ // Check for duplicates only among other transient requests
+ const allItems = flattenItems(collection.items);
+ const transientRequests = filter(
+ allItems,
+ (i) => isItemARequest(i) && i.pathname && i.pathname.startsWith(tempDirectory)
+ );
+ const reqWithSameNameExists = find(transientRequests, (i) => trim(i.filename) === trim(resolvedFilename));
+
+ if (reqWithSameNameExists) {
+ return reject(new Error('Duplicate request names are not allowed under the same folder'));
+ }
+
+ const items = filter(collection.items, (i) => isItemAFolder(i) || isItemARequest(i));
+ item.seq = items.length + 1;
+ const fullName = path.join(tempDirectory, resolvedFilename);
+ const { ipcRenderer } = window;
+ ipcRenderer
+ .invoke('renderer:new-request', fullName, item)
+ .then(() => {
+ // task middleware will track this and open the new request in a new tab once request is created
+ dispatch(
+ insertTaskIntoQueue({
+ uid: uuid(),
+ type: 'OPEN_REQUEST',
+ collectionUid,
+ itemPathname: fullName
+ })
+ );
+ resolve();
+ })
+ .catch(reject);
+ } else {
+ // Regular request (can be at root or in a folder)
+ const parentItem = itemUid ? findItemInCollection(collection, itemUid) : collection;
+
+ if (!parentItem) {
+ return reject(new Error('Parent item not found'));
+ }
+
+ const reqWithSameNameExists = find(
+ parentItem.items,
+ (i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)
+ );
+
+ if (reqWithSameNameExists) {
+ return reject(new Error('Duplicate request names are not allowed under the same folder'));
+ }
+
+ const items = filter(parentItem.items, (i) => isItemAFolder(i) || isItemARequest(i));
+ item.seq = items.length + 1;
+ const fullName = path.join(parentItem.pathname, resolvedFilename);
+ const { ipcRenderer } = window;
+ ipcRenderer
+ .invoke('renderer:new-request', fullName, item)
+ .then(() => {
+ // task middleware will track this and open the new request in a new tab once request is created
+ dispatch(
+ insertTaskIntoQueue({
+ uid: uuid(),
+ type: 'OPEN_REQUEST',
+ collectionUid,
+ itemPathname: fullName
+ })
+ );
+ resolve();
+ })
+ .catch(reject);
}
-
- const reqWithSameNameExists = find(parentItem.items,
- (i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename));
-
- if (reqWithSameNameExists) {
- return reject(new Error('Duplicate request names are not allowed under the same folder'));
- }
-
- const items = filter(parentItem.items, (i) => isItemAFolder(i) || isItemARequest(i));
- item.seq = items.length + 1;
- const fullName = path.join(parentItem.pathname, resolvedFilename);
- const { ipcRenderer } = window;
- ipcRenderer
- .invoke('renderer:new-request', fullName, item)
- .then(() => {
- // task middleware will track this and open the new request in a new tab once request is created
- dispatch(insertTaskIntoQueue({
- uid: uuid(),
- type: 'OPEN_REQUEST',
- collectionUid,
- itemPathname: fullName
- }));
- resolve();
- })
- .catch(reject);
});
};
@@ -2666,7 +2810,10 @@ export const mountCollection
dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'mounting' }));
return new Promise(async (resolve, reject) => {
callIpc('renderer:mount-collection', { collectionUid, collectionPathname, brunoConfig })
- .then(() => dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'mounted' })))
+ .then((transientDirPath) => {
+ dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'mounted' }));
+ dispatch(addTransientDirectory({ collectionUid, pathname: transientDirPath }));
+ })
.then(resolve)
.catch(() => {
dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'unmounted' }));
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
index 7fe15aa08..753d50124 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
@@ -69,7 +69,9 @@ const wsStatusCodes = {
const initialState = {
collections: [],
collectionSortOrder: 'default',
- activeConnections: []
+ activeConnections: [],
+ tempDirectories: {},
+ saveTransientRequestModals: []
};
const initiatedGrpcResponse = {
@@ -775,6 +777,7 @@ export const collectionsSlice = createSlice({
uid: action.payload.uid,
name: action.payload.requestName,
type: action.payload.requestType,
+ isTransient: false,
request: {
url: action.payload.requestUrl,
method: action.payload.requestMethod,
@@ -2568,6 +2571,10 @@ export const collectionsSlice = createSlice({
if (collection) {
const dirname = path.dirname(file.meta.pathname);
+
+ const tempDirectory = state.tempDirectories?.[file.meta.collectionUid];
+ const isTransientFile = tempDirectory && file.meta.pathname.startsWith(tempDirectory);
+
const subDirectories = getSubdirectoriesFromRoot(collection.pathname, dirname);
let currentPath = collection.pathname;
let currentSubItems = collection.items;
@@ -2581,9 +2588,13 @@ export const collectionsSlice = createSlice({
name: directoryName,
collapsed: true,
type: 'folder',
+ isTransient: isTransientFile,
items: []
};
currentSubItems.push(childItem);
+ } else if (isTransientFile && !childItem.isTransient) {
+ // Update existing folder to be transient if the file is transient
+ childItem.isTransient = true;
}
currentSubItems = childItem.items;
}
@@ -2608,6 +2619,7 @@ export const collectionsSlice = createSlice({
currentItem.loading = file.loading;
currentItem.size = file.size;
currentItem.error = file.error;
+ currentItem.isTransient = isTransientFile;
} else {
currentSubItems.push({
uid: file.data.uid,
@@ -2624,7 +2636,8 @@ export const collectionsSlice = createSlice({
partial: file.partial,
loading: file.loading,
size: file.size,
- error: file.error
+ error: file.error,
+ isTransient: isTransientFile
});
}
}
@@ -2636,6 +2649,10 @@ export const collectionsSlice = createSlice({
const collection = findCollectionByUid(state.collections, dir.meta.collectionUid);
if (collection) {
+ // Check if this directory is in a temp directory (transient request)
+ const tempDirectory = state.tempDirectories?.[dir.meta.collectionUid];
+ const isTransientDir = tempDirectory && dir.meta.pathname.startsWith(tempDirectory);
+
const subDirectories = getSubdirectoriesFromRoot(collection.pathname, dir.meta.pathname);
let currentPath = collection.pathname;
let currentSubItems = collection.items;
@@ -2651,9 +2668,13 @@ export const collectionsSlice = createSlice({
filename: directoryName,
collapsed: true,
type: 'folder',
+ isTransient: isTransientDir,
items: []
};
currentSubItems.push(childItem);
+ } else if (isTransientDir && !childItem.isTransient) {
+ // Update existing folder to be transient if the directory is transient
+ childItem.isTransient = true;
}
currentSubItems = childItem.items;
}
@@ -3363,6 +3384,26 @@ export const collectionsSlice = createSlice({
}
},
+ addTransientDirectory: (state, action) => {
+ state.tempDirectories[action.payload.collectionUid] = action.payload.pathname;
+ },
+ addSaveTransientRequestModal: (state, action) => {
+ const { item, collection } = action.payload;
+ // Avoid duplicates - check if this item is already in the array
+ const exists = state.saveTransientRequestModals.some((modal) => modal.item.uid === item.uid);
+ if (!exists) {
+ state.saveTransientRequestModals.push({ item, collection });
+ }
+ },
+ removeSaveTransientRequestModal: (state, action) => {
+ const { itemUid } = action.payload;
+ state.saveTransientRequestModals = state.saveTransientRequestModals.filter(
+ (modal) => modal.item.uid !== itemUid
+ );
+ },
+ clearAllSaveTransientRequestModals: (state) => {
+ state.saveTransientRequestModals = [];
+ },
/* Response Example Actions */
addResponseExample: exampleReducers.addResponseExample,
cloneResponseExample: exampleReducers.cloneResponseExample,
@@ -3588,8 +3629,12 @@ export const {
deleteResponseExampleRequestHeader,
moveResponseExampleRequestHeader,
setResponseExampleRequestHeaders,
- setResponseExampleParams
+ setResponseExampleParams,
/* Response Example Actions - End */
+ addTransientDirectory,
+ addSaveTransientRequestModal,
+ removeSaveTransientRequestModal,
+ clearAllSaveTransientRequestModals
} = collectionsSlice.actions;
export default collectionsSlice.reducer;
diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js
index 2ef277128..1eb231285 100644
--- a/packages/bruno-app/src/utils/collections/index.js
+++ b/packages/bruno-app/src/utils/collections/index.js
@@ -1704,3 +1704,7 @@ export const generateUniqueRequestName = async (collection, baseName = 'Untitled
return `${baseName}${nextNumber}`;
};
+
+export const isItemTransientRequest = (item) => {
+ return isItemARequest(item) && item?.isTransient;
+};
diff --git a/packages/bruno-app/src/utils/collections/search.js b/packages/bruno-app/src/utils/collections/search.js
index 9c2f187e5..2919216e0 100644
--- a/packages/bruno-app/src/utils/collections/search.js
+++ b/packages/bruno-app/src/utils/collections/search.js
@@ -8,14 +8,14 @@ export const doesRequestMatchSearchText = (request, searchText = '') => {
export const doesFolderHaveItemsMatchSearchText = (item, searchText = '') => {
let flattenedItems = flattenItems(item.items);
- let requestItems = filter(flattenedItems, (item) => isItemARequest(item));
+ let requestItems = filter(flattenedItems, (item) => isItemARequest(item) && !item.isTransient);
return find(requestItems, (request) => doesRequestMatchSearchText(request, searchText));
};
export const doesCollectionHaveItemsMatchingSearchText = (collection, searchText = '') => {
let flattenedItems = flattenItems(collection.items);
- let requestItems = filter(flattenedItems, (item) => isItemARequest(item));
+ let requestItems = filter(flattenedItems, (item) => isItemARequest(item) && !item.isTransient);
return find(requestItems, (request) => doesRequestMatchSearchText(request, searchText));
};
diff --git a/packages/bruno-electron/src/app/collection-watcher.js b/packages/bruno-electron/src/app/collection-watcher.js
index ec2aae053..7d03ba77a 100644
--- a/packages/bruno-electron/src/app/collection-watcher.js
+++ b/packages/bruno-electron/src/app/collection-watcher.js
@@ -666,6 +666,7 @@ class CollectionWatcher {
constructor() {
this.watchers = {};
this.loadingStates = {};
+ this.tempDirectoryMap = {};
}
// Initialize loading state tracking for a collection
@@ -823,6 +824,13 @@ class CollectionWatcher {
this.watchers[watchPath] = null;
}
+ const tempDirectoryPath = this.tempDirectoryMap[watchPath];
+ if (tempDirectoryPath && this.watchers[tempDirectoryPath]) {
+ this.watchers[tempDirectoryPath].close();
+ delete this.watchers[tempDirectoryPath];
+ delete this.tempDirectoryMap[watchPath];
+ }
+
if (collectionUid) {
this.cleanupLoadingState(collectionUid);
}
@@ -855,6 +863,106 @@ class CollectionWatcher {
}
}
+ // Helper function to get collection path from temp directory metadata
+ getCollectionPathFromTempDirectory(tempDirectoryPath) {
+ const metadataPath = path.join(tempDirectoryPath, 'metadata.json');
+ try {
+ const metadataContent = fs.readFileSync(metadataPath, 'utf8');
+ const metadata = JSON.parse(metadataContent);
+ return metadata.collectionPath;
+ } catch (error) {
+ console.error(`Error reading metadata from temp directory ${tempDirectoryPath}:`, error);
+ return null;
+ }
+ }
+
+ // Add watcher for transient directory
+ // The tempDirectoryPath is stored in this.tempDirectoryMap[collectionPath] so removeWatcher can clean it up
+ addTempDirectoryWatcher(win, tempDirectoryPath, collectionUid, collectionPath) {
+ if (this.watchers[tempDirectoryPath]) {
+ this.watchers[tempDirectoryPath].close();
+ }
+
+ // Store the mapping from collectionPath to tempDirectoryPath for cleanup in removeWatcher
+ this.tempDirectoryMap[collectionPath] = tempDirectoryPath;
+
+ // Ignore metadata.json file
+ const ignored = (filepath) => {
+ const basename = path.basename(filepath);
+ return basename === 'metadata.json';
+ };
+
+ const watcher = chokidar.watch(tempDirectoryPath, {
+ ignoreInitial: true, // Don't process existing files
+ usePolling: isWSLPath(tempDirectoryPath) ? true : false,
+ ignored,
+ persistent: true,
+ ignorePermissionErrors: true,
+ awaitWriteFinish: {
+ stabilityThreshold: 80,
+ pollInterval: 10
+ },
+ depth: 1, // Only watch the temp directory itself, not subdirectories
+ disableGlobbing: true
+ });
+
+ // Wrapper function to handle temp directory files
+ const addTempFile = async (pathname) => {
+ // Skip metadata.json
+ if (path.basename(pathname) === 'metadata.json') {
+ return;
+ }
+
+ // Get the actual collection path from metadata
+ const actualCollectionPath = this.getCollectionPathFromTempDirectory(tempDirectoryPath);
+ if (!actualCollectionPath) {
+ console.error(`Could not determine collection path for temp directory: ${tempDirectoryPath}`);
+ return;
+ }
+
+ // Use the collection format from the actual collection
+ const format = getCollectionFormat(actualCollectionPath);
+
+ // Only process request files
+ if (hasRequestExtension(pathname, format)) {
+ // Call the regular add function with the actual collection path
+ // This will hydrate and send the file to the renderer
+ await add(win, pathname, collectionUid, actualCollectionPath, false, this);
+ }
+ };
+ const unlinkTempFile = async (pathname) => {
+ // Skip metadata.json
+ if (path.basename(pathname) === 'metadata.json') {
+ return;
+ }
+
+ // Get the actual collection path from metadata
+ const actualCollectionPath = this.getCollectionPathFromTempDirectory(tempDirectoryPath);
+ if (!actualCollectionPath) {
+ console.error(`Could not determine collection path for temp directory: ${tempDirectoryPath}`);
+ return;
+ }
+
+ // Use the collection format from the actual collection
+ const format = getCollectionFormat(actualCollectionPath);
+
+ // Only process request files
+ if (hasRequestExtension(pathname, format)) {
+ // Call the regular unlink function with the actual collection path
+ await unlink(win, pathname, collectionUid, actualCollectionPath);
+ }
+ };
+
+ watcher
+ .on('add', (pathname) => addTempFile(pathname))
+ .on('unlink', (pathname) => unlinkTempFile(pathname))
+ .on('error', (error) => {
+ console.error(`An error occurred in the temp directory watcher for: ${tempDirectoryPath}`, error);
+ });
+
+ this.watchers[tempDirectoryPath] = watcher;
+ }
+
getAllWatcherPaths() {
return Object.entries(this.watchers)
.filter(([path, watcher]) => !!watcher)
diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js
index 3667562a8..eb465d8a1 100644
--- a/packages/bruno-electron/src/ipc/collection.js
+++ b/packages/bruno-electron/src/ipc/collection.js
@@ -84,6 +84,25 @@ const envHasSecrets = (environment = {}) => {
};
const findCollectionPathByItemPath = (filePath) => {
+ const tmpDir = os.tmpdir();
+ const parts = filePath.split(path.sep);
+ const index = parts.findIndex((part) => part.startsWith('bruno-'));
+
+ if (filePath.startsWith(tmpDir) && index !== -1) {
+ const transientDirPath = parts.slice(0, index + 1).join(path.sep);
+ const metadataPath = path.join(transientDirPath, 'metadata.json');
+ try {
+ const metadataContent = fs.readFileSync(metadataPath, 'utf8');
+ const metadata = JSON.parse(metadataContent);
+ if (metadata.collectionPath) {
+ return metadata.collectionPath;
+ }
+ } catch (error) {
+ return null;
+ }
+ return null;
+ }
+
const allCollectionPaths = collectionWatcher.getAllWatcherPaths();
// Find the collection path that contains this file
@@ -363,6 +382,52 @@ 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 }) => {
+ 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);
+
+ // 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`);
+ }
+
+ // Step 1: Save the updated content to the transient file
+ syncExampleUidsCache(sourcePathname, request.examples);
+ const content = await stringifyRequestViaWorker(request, { format });
+ await writeFile(sourcePathname, content);
+
+ // Step 2: Read the file content from temp (this is the actual file content)
+ const fileContent = await fs.promises.readFile(sourcePathname, 'utf8');
+
+ // Step 3: Create new file at target location with the content
+ await writeFile(targetPathname, fileContent);
+
+ // Step 4: Delete the old temp file
+ await removePath(sourcePathname);
+
+ // Return the new pathname (file watcher will handle adding to Redux)
+ return { newPathname: targetPathname };
+ } catch (error) {
+ return Promise.reject(error);
+ }
+ });
+
// save multiple requests
ipcMain.handle('renderer:save-multiple-requests', async (event, requestsToSave) => {
try {
@@ -1607,6 +1672,16 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
});
ipcMain.handle('renderer:mount-collection', async (event, { collectionUid, collectionPathname, brunoConfig }) => {
+ let tempDirectoryPath = null;
+ try {
+ tempDirectoryPath = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-'));
+ const metadata = {
+ collectionPath: collectionPathname
+ };
+ fs.writeFileSync(path.join(tempDirectoryPath, 'metadata.json'), JSON.stringify(metadata));
+ } catch (error) {
+ throw error;
+ }
const {
size,
filesCount,
@@ -1619,6 +1694,11 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
|| (maxFileSize > MAX_SINGLE_FILE_SIZE_IN_COLLECTION_IN_MB);
watcher.addWatcher(mainWindow, collectionPathname, collectionUid, brunoConfig, false, shouldLoadCollectionAsync);
+
+ // Add watcher for transient directory
+ watcher.addTempDirectoryWatcher(mainWindow, tempDirectoryPath, collectionUid, collectionPathname);
+
+ return tempDirectoryPath;
});
ipcMain.handle('renderer:show-in-folder', async (event, filePath) => {
diff --git a/packages/bruno-electron/src/utils/collection.js b/packages/bruno-electron/src/utils/collection.js
index 016786d29..e0db7bca0 100644
--- a/packages/bruno-electron/src/utils/collection.js
+++ b/packages/bruno-electron/src/utils/collection.js
@@ -4,6 +4,7 @@ const { getRequestUid, getExampleUid } = require('../cache/requestUids');
const { uuid } = require('./common');
const os = require('os');
const { preferencesUtil } = require('../store/preferences');
+const path = require('path');
const mergeHeaders = (collection, request, requestTreePath) => {
let headers = new Map();
@@ -402,6 +403,8 @@ const parseFileMeta = (data, format = 'bru') => {
const hydrateRequestWithUuid = (request, pathname) => {
request.uid = getRequestUid(pathname);
+ const prefix = path.join(os.tmpdir(), 'bruno-');
+ request.isTransient = pathname.startsWith(prefix);
const params = get(request, 'request.params', []);
const headers = get(request, 'request.headers', []);
diff --git a/tests/onboarding/sample-collection.spec.ts b/tests/onboarding/sample-collection.spec.ts
index a0259f8c9..d520d1944 100644
--- a/tests/onboarding/sample-collection.spec.ts
+++ b/tests/onboarding/sample-collection.spec.ts
@@ -92,9 +92,20 @@ test.describe('Onboarding', () => {
await expect(removeOption).toBeVisible();
await removeOption.click();
- // Confirm removal in the modal
- await page.locator('[data-testid="close-collection-modal-title"]', { hasText: 'Remove Collection' }).waitFor({ state: 'visible' });
- await page.locator('.bruno-modal-footer .submit').click();
+ // Wait for modal to appear - could be either regular remove or drafts confirmation
+ const removeModal = page.locator('.bruno-modal').filter({ hasText: 'Remove Collection' });
+ await removeModal.waitFor({ state: 'visible', timeout: 5000 });
+
+ // Check if it's the drafts confirmation modal (has "Discard All and Remove" button)
+ const hasDiscardButton = await page.getByRole('button', { name: 'Discard All and Remove' }).isVisible().catch(() => false);
+
+ if (hasDiscardButton) {
+ // Drafts modal - click "Discard All and Remove"
+ await page.getByRole('button', { name: 'Discard All and Remove' }).click();
+ } else {
+ // Regular modal - click the submit button
+ await page.locator('.bruno-modal-footer .submit').click();
+ }
// Verify collection is closed (no longer visible in sidebar)
await expect(sampleCollection).not.toBeVisible();
diff --git a/tests/transient-requests/transient-requests.spec.ts b/tests/transient-requests/transient-requests.spec.ts
new file mode 100644
index 000000000..cb18fd644
--- /dev/null
+++ b/tests/transient-requests/transient-requests.spec.ts
@@ -0,0 +1,225 @@
+import { test, expect } from '../../playwright';
+import { createTransientRequest, fillRequestUrl, closeAllCollections, createCollection, sendRequest, clickResponseAction, selectRequestPaneTab } from '../utils/page';
+import { buildCommonLocators, buildWebsocketCommonLocators } from '../utils/page/locators';
+
+test.describe.serial('Transient Requests', () => {
+ let locators: ReturnType;
+
+ test.beforeAll(async ({ page, createTmpDir }) => {
+ locators = buildCommonLocators(page);
+
+ // Create a temporary collection
+ const collectionPath = await createTmpDir('transient-collection');
+ await createCollection(page, 'transient-requests-test', collectionPath);
+
+ // Verify the collection is loaded
+ await test.step('Verify test collection is loaded', async () => {
+ await expect(locators.sidebar.collection('transient-requests-test')).toBeVisible();
+ await locators.sidebar.collection('transient-requests-test').click();
+ });
+ });
+
+ test.afterAll(async ({ page }) => {
+ // Clean up all collections
+ await closeAllCollections(page);
+ });
+
+ test('Create transient HTTP request - should not appear in sidebar', async ({ page }) => {
+ await test.step('Create transient HTTP request', async () => {
+ await createTransientRequest(page, {
+ requestType: '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 request is NOT in sidebar', async () => {
+ // Click on the collection to ensure it's expanded
+ await locators.sidebar.collection('transient-requests-test').click();
+ await page.waitForTimeout(300);
+
+ // Check that there are no requests in the collection
+ // Transient requests should not appear in the sidebar
+ const collectionItems = page.locator('.collection-item-name');
+ await expect(collectionItems).toHaveCount(0);
+ });
+ });
+
+ test('Create transient GraphQL request - should not appear in sidebar', async ({ page }) => {
+ await test.step('Create transient GraphQL request', async () => {
+ await createTransientRequest(page, {
+ requestType: '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');
+ });
+
+ await test.step('Verify request is NOT in sidebar', async () => {
+ // Check that there are still no requests in the collection
+ const collectionItems = page.locator('.collection-item-name');
+ await expect(collectionItems).toHaveCount(0);
+ });
+ });
+
+ test('Create transient gRPC request - should not appear in sidebar', async ({ page }) => {
+ await test.step('Create transient gRPC request', async () => {
+ await createTransientRequest(page, {
+ requestType: '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');
+ });
+
+ await test.step('Verify request is NOT in sidebar', async () => {
+ // Check that there are still no requests in the collection
+ const collectionItems = page.locator('.collection-item-name');
+ await expect(collectionItems).toHaveCount(0);
+ });
+ });
+
+ test('Create transient WebSocket request - should not appear in sidebar', async ({ page }) => {
+ await test.step('Create transient WebSocket request', async () => {
+ await createTransientRequest(page, {
+ requestType: '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');
+ });
+
+ await test.step('Verify request is NOT in sidebar', async () => {
+ // Check that there are still no requests in the collection
+ const collectionItems = page.locator('.collection-item-name');
+ await expect(collectionItems).toHaveCount(0);
+ });
+ });
+
+ test('Save transient HTTP request - should appear in sidebar after save', async ({ page }) => {
+ await test.step('Create transient HTTP request', async () => {
+ await createTransientRequest(page, {
+ requestType: 'HTTP'
+ });
+ await fillRequestUrl(page, 'http://localhost:8081/echo');
+ });
+
+ await test.step('Trigger save action using keyboard shortcut', async () => {
+ // Try to save using Cmd+S (Mac) or Ctrl+S (other platforms)
+ await page.keyboard.press('Meta+s');
+ await page.waitForTimeout(500);
+ });
+
+ await test.step('Fill in save dialog', async () => {
+ // Wait for save modal to appear
+ const saveModal = page.locator('.bruno-modal-card').filter({ hasText: 'Save Request' });
+ await expect(saveModal).toBeVisible({ timeout: 5000 });
+
+ // Fill in request name
+ const requestNameInput = saveModal.locator('#request-name');
+ await requestNameInput.clear();
+ await requestNameInput.fill('Saved HTTP Request');
+
+ // Click Save button
+ await saveModal.getByRole('button', { name: 'Save' }).click();
+
+ // Wait for success toast
+ await page.waitForTimeout(1000);
+ });
+
+ await test.step('Verify saved request appears in sidebar', async () => {
+ // Check collection is expanded
+ await locators.sidebar.collection('transient-requests-test').click();
+
+ // Look for the saved request in sidebar
+ const savedRequest = locators.sidebar.request('Saved HTTP Request');
+ await expect(savedRequest).toBeVisible();
+ });
+
+ await test.step('Cleanup: Delete the saved request', async () => {
+ await locators.sidebar.request('Saved HTTP Request').hover();
+ await locators.actions.collectionItemActions('Saved HTTP Request').click();
+ await locators.dropdown.item('Delete').click();
+ await locators.modal.button('Delete').click();
+ await expect(locators.sidebar.request('Saved HTTP Request')).not.toBeVisible();
+ });
+ });
+
+ test('Save transient GraphQL request - should appear in sidebar after save', async ({ page }) => {
+ await test.step('Create transient GraphQL request', async () => {
+ await createTransientRequest(page, {
+ requestType: 'GraphQL'
+ });
+ await fillRequestUrl(page, 'https://api.example.com/graphql');
+ });
+
+ await test.step('Trigger save action using keyboard shortcut', async () => {
+ await page.keyboard.press('Meta+s');
+ await page.waitForTimeout(500);
+ });
+
+ await test.step('Fill in save dialog', async () => {
+ const saveModal = page.locator('.bruno-modal-card').filter({ hasText: 'Save Request' });
+ await expect(saveModal).toBeVisible({ timeout: 5000 });
+
+ const requestNameInput = saveModal.locator('#request-name');
+ await requestNameInput.clear();
+ await requestNameInput.fill('Saved GraphQL Request');
+
+ await saveModal.getByRole('button', { name: 'Save' }).click();
+ await page.waitForTimeout(1000);
+ });
+
+ await test.step('Verify saved request appears in sidebar', async () => {
+ await locators.sidebar.collection('transient-requests-test').click();
+ const savedRequest = locators.sidebar.request('Saved GraphQL Request');
+ await expect(savedRequest).toBeVisible();
+ });
+
+ await test.step('Cleanup: Delete the saved request', async () => {
+ await locators.sidebar.request('Saved GraphQL Request').hover();
+ await locators.actions.collectionItemActions('Saved GraphQL Request').click();
+ await locators.dropdown.item('Delete').click();
+ await locators.modal.button('Delete').click();
+ await expect(locators.sidebar.request('Saved GraphQL Request')).not.toBeVisible();
+ });
+ });
+
+ test('Send transient HTTP request - verify response', async ({ page }) => {
+ await test.step('Create transient HTTP request', async () => {
+ await createTransientRequest(page, {
+ requestType: 'HTTP'
+ });
+ await fillRequestUrl(page, 'http://localhost:8081/ping');
+ });
+
+ await test.step('Send request and verify response', async () => {
+ // Send request using the helper function
+ 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');
+ });
+ });
+});
diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts
index d76297322..71d73160d 100644
--- a/tests/utils/page/actions.ts
+++ b/tests/utils/page/actions.ts
@@ -17,11 +17,24 @@ const closeAllCollections = async (page) => {
await firstCollection.hover();
await firstCollection.locator('.collection-actions .icon').click();
await page.locator('.dropdown-item').getByText('Remove').click();
- // Wait for the remove collection modal to be visible
- await page.getByTestId('close-collection-modal-title').filter({ hasText: 'Remove Collection' }).waitFor({ state: 'visible' });
- await page.locator('.bruno-modal-footer .submit').click();
- // Wait for the remove collection modal to be hidden
- await page.getByTestId('close-collection-modal-title').filter({ hasText: 'Remove Collection' }).waitFor({ state: 'hidden' });
+
+ // Wait for modal to appear - could be either regular remove or drafts confirmation
+ const removeModal = page.locator('.bruno-modal').filter({ hasText: 'Remove Collection' });
+ await removeModal.waitFor({ state: 'visible', timeout: 5000 });
+
+ // Check if it's the drafts confirmation modal (has "Discard All and Remove" button)
+ const hasDiscardButton = await page.getByRole('button', { name: 'Discard All and Remove' }).isVisible().catch(() => false);
+
+ if (hasDiscardButton) {
+ // Drafts modal - click "Discard All and Remove"
+ await page.getByRole('button', { name: 'Discard All and Remove' }).click();
+ } else {
+ // Regular modal - click the submit button
+ await page.locator('.bruno-modal-footer .submit').click();
+ }
+
+ // Wait for modal to close
+ await removeModal.waitFor({ state: 'hidden', timeout: 5000 });
}
// Wait until no collections are left open (check sidebar only)
@@ -143,6 +156,77 @@ const createUntitledRequest = async (
});
};
+type CreateTransientRequestOptions = {
+ requestType?: 'HTTP' | 'GraphQL' | 'gRPC' | 'WebSocket';
+};
+
+/**
+ * Create a transient request using the + icon button in the tabs area
+ * Based on the CreateTransientRequest component behavior
+ * @param page - The page object
+ * @param options - Optional settings (requestType)
+ * @returns void
+ */
+const createTransientRequest = async (
+ page: Page,
+ options: CreateTransientRequestOptions = {}
+) => {
+ const { requestType = 'HTTP' } = options;
+
+ await test.step(`Create transient ${requestType} request`, async () => {
+ // Find the + icon button (ActionIcon with aria-label="New Transient Request")
+ const createButton = page.getByRole('button', { name: 'New Transient Request' });
+ await createButton.waitFor({ state: 'visible', timeout: 5000 });
+
+ // Click the + icon to open the dropdown
+ 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
+ // The dropdown items have both icon and label, we match by the label text
+ await page.locator('.dropdown-item').filter({ hasText: requestType }).click();
+
+ // Wait for the request tab to be active (transient requests show as "Untitled X")
+ await page.locator('.request-tab.active').waitFor({ state: 'visible' });
+ await expect(page.locator('.request-tab.active')).toContainText('Untitled');
+ await page.waitForTimeout(300);
+ });
+};
+
+/**
+ * Fill the URL field in the currently active request
+ * Works with HTTP, GraphQL, gRPC, and WebSocket requests
+ * @param page - The page object
+ * @param url - The URL to fill
+ * @returns void
+ */
+const fillRequestUrl = async (page: Page, url: string) => {
+ await test.step(`Fill request URL: ${url}`, async () => {
+ // HTTP/GraphQL requests use #request-url
+ // gRPC/WebSocket don't have a specific ID, so we need to find the CodeMirror in the active request pane
+ const httpGraphqlUrl = page.locator('#request-url .CodeMirror');
+ const grpcWsUrl = page.locator('.input-container .CodeMirror').first();
+
+ // Try HTTP/GraphQL selector first
+ const isHttpOrGraphql = await httpGraphqlUrl.isVisible().catch(() => false);
+
+ if (isHttpOrGraphql) {
+ await httpGraphqlUrl.click();
+ await page.locator('#request-url textarea').fill(url);
+ } else {
+ // Fall back to generic selector for gRPC/WebSocket
+ await grpcWsUrl.click();
+ await page.locator('.input-container textarea').first().fill(url);
+ }
+
+ await page.waitForTimeout(200);
+ });
+};
+
/**
* Create a request in a collection or folder
* @param page - The page object
@@ -293,10 +377,23 @@ const removeCollection = async (page: Page, collectionName: string) => {
await collectionRow.locator('.collection-actions .icon').click();
await locators.dropdown.item('Remove').click();
- // Wait for and confirm modal
- await page.getByTestId('close-collection-modal-title').filter({ hasText: 'Remove Collection' }).waitFor({ state: 'visible' });
- await locators.modal.button('Remove').click();
- await page.getByTestId('close-collection-modal-title').filter({ hasText: 'Remove Collection' }).waitFor({ state: 'hidden' });
+ // Wait for modal to appear - could be either regular remove or drafts confirmation
+ const removeModal = page.locator('.bruno-modal').filter({ hasText: 'Remove Collection' });
+ await removeModal.waitFor({ state: 'visible', timeout: 5000 });
+
+ // Check if it's the drafts confirmation modal (has "Discard All and Remove" button)
+ const hasDiscardButton = await page.getByRole('button', { name: 'Discard All and Remove' }).isVisible().catch(() => false);
+
+ if (hasDiscardButton) {
+ // Drafts modal - click "Discard All and Remove"
+ await page.getByRole('button', { name: 'Discard All and Remove' }).click();
+ } else {
+ // Regular modal - click Remove button
+ await locators.modal.button('Remove').click();
+ }
+
+ // Wait for modal to close
+ await removeModal.waitFor({ state: 'hidden', timeout: 5000 });
// Verify collection is removed
await expect(
@@ -864,6 +961,8 @@ export {
createCollection,
createRequest,
createUntitledRequest,
+ createTransientRequest,
+ fillRequestUrl,
deleteRequest,
importCollection,
removeCollection,
@@ -892,4 +991,4 @@ export {
saveRequest
};
-export type { SandboxMode, EnvironmentType, EnvironmentVariable, ImportCollectionOptions, CreateRequestOptions, CreateUntitledRequestOptions, AssertionInput };
+export type { SandboxMode, EnvironmentType, EnvironmentVariable, ImportCollectionOptions, CreateRequestOptions, CreateUntitledRequestOptions, CreateTransientRequestOptions, AssertionInput };