Feature/transient request (#6878)

* feat: add functionality to retrieve collection path from transient file requests in IPC module

* feat: implement transient directory handling in collection mounting process

* add action to store transient directory paths in Redux state
* update IPC handler to create and return a temporary directory for collections
* modify collection mount action to dispatch transient directory addition

* feat: add CreateTransientRequest component for managing transient requests

* implement CreateTransientRequest component to facilitate the creation of HTTP, GraphQL, gRPC, and WebSocket transient requests
* integrate the component into RequestTabs for user interaction
* update collection and request handling to differentiate between transient and non-transient requests
* enhance Redux actions to support transient request creation and management

* feat: enhance transient request handling and add temp directory watcher

* refactor Redux actions for HTTP, gRPC, and WebSocket requests to utilize a unified task queue for transient requests
* implement a new helper function to retrieve collection paths from temporary directory metadata
* add functionality to watch transient directories for file changes, excluding metadata.json
* integrate transient directory watcher into the collection mounting process

* feat: enhance transient request management with temp directory integration

* update generateTransientRequestName function to accept tempDirectory parameter for improved request naming
* modify CreateTransientRequest component to utilize tempDirectory for transient request creation
* adjust middleware to check for transient status based on tempDirectory
* implement transient file and directory identification in collections slice for better state management

* feat: add SaveTransientRequest component for managing transient requests

* implement SaveTransientRequest component to facilitate saving transient requests to selected folders
* create StyledWrapper for component styling
* introduce useCollectionFolderTree hook for managing folder navigation and state
* update Redux actions to handle saving requests from transient state

* feat: implement SaveTransientRequestContainer and enhance modal management

* add SaveTransientRequestContainer to manage multiple transient request modals
* refactor SaveTransientRequest component to utilize Redux for modal state management
* implement open and close actions for transient request modals in Redux slice
* update Bruno page to include SaveTransientRequestContainer for improved UI integration

* feat: enhance SaveTransientRequest component with new folder creation functionality

* add input for creating new folders within the SaveTransientRequest component
* implement validation for new folder names and filesystem names
* integrate folder creation logic with Redux actions for better state management
* update styling for new folder input elements in StyledWrapper
* improve modal behavior to reset state when opened

* feat: update CreateTransientRequest to utilize collection presets for request URLs

* Refactored CreateTransientRequest component to retrieve request URLs from collection presets.
* Enhanced request handling by dynamically setting request URLs based on the selected collection's configuration.

* refactor: clean up unused imports and adjust request handling in collections actions

* Removed unused imports from actions.js to streamline the code.
* Updated the saveRequest function to reject the modal instead of resolving it when handling transient requests.
* Cleaned up comments in index.js for better clarity.

* refactor: streamline transient request handling and improve save functionality

* Removed success toast notifications from CreateTransientRequest component to simplify user feedback.
* Enhanced SaveTransientRequest component to handle transient requests more effectively, including improved filename resolution and validation.
* Added IPC handler for saving transient requests, ensuring proper file management and error handling.
* Updated Redux actions to check for duplicate transient request names within the temporary directory.

* feat: enhance request handling in ConfirmCollectionCloseDrafts component

* Added logic to differentiate between transient and non-transient drafts, ensuring transient requests are saved individually before closing the collection.
* Improved user feedback by displaying unsaved changes for both regular and transient requests.
* Updated save and discard functionality to handle all drafts appropriately, enhancing overall user experience.

* fix:fixed useCallback dependency array

* fix:added request name checks before save

* fix: added isTransient to files

* fix: added watcher cleanup for temp directory

* refactor: enhance transient request handling and optimize component logic

* Updated CreateTransientRequest to utilize useMemo for improved performance and prevent unnecessary re-renders.
* Refactored generateTransientRequestName to focus solely on transient requests, removing tempDirectory dependency.
* Streamlined SaveTransientRequest by consolidating form reset logic and removing unused state variables.
* Improved ConfirmCollectionCloseDrafts to differentiate between transient and non-transient drafts more effectively.
* Cleaned up imports and optimized Redux actions for better maintainability.

* feat: implement transient request file deletion on tab close

* Added middleware to handle the deletion of transient request files when tabs are closed.
* Enhanced collection-watcher to unlink temporary files, ensuring metadata.json is skipped and only request files are processed.
* Improved error handling for file deletion operations.

* feat: enhance autosave middleware to skip transient requests

* Updated autosave middleware to check for transient requests and skip auto-save operations accordingly.

* fix: update ConfirmCollectionCloseDrafts to display all transient drafts

* Modified the ConfirmCollectionCloseDrafts component to show all transient drafts without limiting the display to a maximum number.
* Removed the conditional message for additional drafts not shown, enhancing the user experience by providing complete visibility of transient requests.

* feat: enhance SaveTransientRequest component for better modal management

* Refactored SaveTransientRequest and its container to improve modal handling for unsaved transient requests.
* Introduced state management for opening specific modals and added functionality to discard all unsaved requests.
* Updated Redux actions to manage transient request modals more effectively, ensuring no duplicates are added.
* Enhanced user interface to display a list of unsaved requests with options to save or discard them.

* feat: improve modal management in SaveTransientRequestContainer

* Added useEffect to reset openItemUid when the corresponding modal is no longer present.
* Implemented functionality to close all tabs associated with transient requests and show a success message upon discarding them.
* Removed unnecessary modal close handler and streamlined modal opening logic for better clarity and performance.

* refactor: streamline code formatting and improve readability in collection actions

* Consolidated multiple lines of code into single lines for better readability in ConfirmCollectionCloseDrafts and actions.js.
* Enhanced consistency in the formatting of function parameters and return statements across the collections slice.
* Removed unnecessary line breaks and improved the structure of the code for easier maintenance.

* refactor: improve code readability and structure in middleware and actions

* Consolidated multiple lines of code into single lines for better readability in middleware.js and actions.js.
* Enhanced consistency in formatting function parameters and return statements across the collections slice.
* Removed unnecessary line breaks and improved the structure of the code for easier maintenance.
* Streamlined dispatch calls for better clarity and performance.

* refactor: enhance code readability and consistency in middleware and actions

* Improved formatting and structure in middleware.js for dispatch calls.
* Streamlined comments and indentation in actions.js for better clarity.
* Consolidated multiple lines into single lines where appropriate to enhance readability.

* refactor: enhance transient request handling and modal interactions

* Improved the modal handling logic for removing collections to differentiate between regular and drafts confirmation modals.
* Added new tests for creating and saving transient requests (HTTP, GraphQL, gRPC, WebSocket) ensuring they do not appear in the sidebar until saved.
* Introduced utility functions for creating transient requests and filling request URLs, improving code reusability and clarity.

* refactor: simplify transient request modal rendering and improve collection watcher logic

* Introduced a new TransientRequestModalsRenderer component to streamline modal rendering based on the number of transient requests.
* Refactored the collection watcher logic to enhance readability by removing unnecessary setTimeout and consolidating file handling functions.
* Improved error handling and logging for the temp directory watcher.

* fix: correct spelling of 'WebSocket' in transient request components and tests

* Updated the spelling of 'Websocket' to 'WebSocket' in CreateTransientRequest component, transient requests test, and action type definitions for consistency and accuracy.
This commit is contained in:
Chirag Chandrashekhar
2026-01-29 18:38:42 +05:30
committed by GitHub
parent 4b724ebd85
commit ca4d0dd40b
22 changed files with 2252 additions and 146 deletions

View File

@@ -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 = (
<ActionIcon
onClick={handleLeftClick}
onContextMenu={handleRightClick}
aria-label="New Transient Request"
size="lg"
style={{ marginBottom: '3px' }}
>
<IconPlus size={18} strokeWidth={1.5} />
</ActionIcon>
);
return (
<Dropdown
icon={IconButton}
visible={dropdownVisible}
onCreate={onDropdownCreate}
onClickOutside={() => setDropdownVisible(false)}
placement="bottom-end"
>
<div className="dropdown-item" onClick={() => handleItemClick(REQUEST_TYPE.HTTP)}>
<div className="dropdown-icon">
<IconApi size={16} strokeWidth={2} />
</div>
<div className="dropdown-label">HTTP</div>
</div>
<div className="dropdown-item" onClick={() => handleItemClick(REQUEST_TYPE.GRAPHQL)}>
<div className="dropdown-icon">
<IconBrandGraphql size={16} strokeWidth={2} />
</div>
<div className="dropdown-label">GraphQL</div>
</div>
<div className="dropdown-item" onClick={() => handleItemClick(REQUEST_TYPE.GRPC)}>
<div className="dropdown-icon">
<IconCode size={16} strokeWidth={2} />
</div>
<div className="dropdown-label">gRPC</div>
</div>
<div className="dropdown-item" onClick={() => handleItemClick(REQUEST_TYPE.WEBSOCKET)}>
<div className="dropdown-icon">
<IconPlugConnected size={16} strokeWidth={2} />
</div>
<div className="dropdown-label">WebSocket</div>
</div>
</Dropdown>
);
};
export default CreateTransientRequest;

View File

@@ -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 = () => {
</div>
{activeCollection && (
<ActionIcon onClick={() => setNewRequestModalOpen(true)} aria-label="New Request" size="lg" style={{ marginBottom: '3px' }}>
<IconPlus
size={18}
strokeWidth={1.5}
/>
</ActionIcon>
<CreateTransientRequest collectionUid={activeCollection.uid} />
)}
<div className={classnames('scroll-chevrons', { hidden: !showChevrons })}>

View File

@@ -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 (
<SaveTransientRequest
item={modalToOpen.item}
collection={modalToOpen.collection}
isOpen={true}
/>
);
}
}
// Show list of multiple modals
return (
<Modal
size="md"
title="Unsaved Transient Requests"
hideFooter={true}
disableEscapeKey={true}
disableCloseOnOutsideClick={true}
handleCancel={handleCancel}
>
<div className="flex items-center">
<IconAlertTriangle size={32} strokeWidth={1.5} className="text-yellow-600" />
<h1 className="ml-2 text-lg font-medium">You have unsaved transient requests</h1>
</div>
<p className="mt-4">
You have <span className="font-medium">{modals.length}</span>{' '}
{pluralizeWord('request', modals.length)} that need to be saved.
</p>
<div className="mt-4">
<p className="text-sm font-medium mb-2">
Transient {pluralizeWord('Request', modals.length)} ({modals.length})
</p>
<p className="text-xs text-orange-600 mb-3">
These requests need to be saved before you can proceed.
</p>
<div className="space-y-2 max-h-96 overflow-y-auto pr-1">
{modals.map((modal) => {
const { item, collection } = modal;
return (
<div
key={item.uid}
className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded border border-gray-200"
>
<div className="flex flex-col flex-1 min-w-0 mr-3">
<span className="text-sm text-gray-700 truncate">{item.name}</span>
<span className="text-xs text-gray-500 truncate">
{collection.name}
</span>
</div>
<Button
color="primary"
variant="ghost"
size="sm"
onClick={() => handleOpenSpecificModal(item.uid)}
icon={<IconDeviceFloppy size={14} strokeWidth={1.5} />}
>
Save
</Button>
</div>
);
})}
</div>
</div>
<div className="flex justify-end mt-6 pt-4 border-t">
<Button color="danger" onClick={handleDiscardAll}>
Discard All
</Button>
</div>
</Modal>
);
};
export default SaveTransientRequestContainer;

View File

@@ -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;

View File

@@ -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 (
<StyledWrapper>
<Modal
size="md"
title="Save Request"
handleCancel={handleCancel}
handleConfirm={handleConfirm}
confirmText="Save"
cancelText="Cancel"
hideFooter={true}
>
<div className="save-request-form">
<div className="form-section">
<label htmlFor="request-name" className="form-label">
Request name
</label>
<input
id="request-name"
type="text"
className="form-input textbox"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={requestName}
onChange={(e) => setRequestName(e.target.value)}
autoFocus={true}
onFocus={(e) => e.target.select()}
/>
</div>
<div className="collections-section">
<div className="collections-label">Save to Collections</div>
{collection && (
<div
className={`collection-name ${!isAtRoot ? 'collection-name-clickable' : ''}`}
onClick={!isAtRoot ? navigateToRoot : undefined}
>
<span>{collection.name}</span>
{breadcrumbs.length > 0 && (
<>
{breadcrumbs.map((breadcrumb, index) => (
<React.Fragment key={breadcrumb.uid}>
<IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />
<span
className="collection-name-breadcrumb"
onClick={(e) => {
e.stopPropagation();
navigateToBreadcrumb(index);
setSearchText('');
}}
>
{breadcrumb.name}
</span>
</React.Fragment>
))}
</>
)}
{isAtRoot && <IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />}
</div>
)}
<div className="search-container">
<SearchInput
searchText={searchText}
setSearchText={setSearchText}
placeholder="Search for folder"
autoFocus={false}
/>
</div>
<div className="folder-list">
{filteredFolders.length > 0 || showNewFolderInput ? (
<ul className="folder-list-items">
{filteredFolders.map((folder) => (
<li
key={folder.uid}
className={`folder-item ${selectedFolderUid === folder.uid ? 'selected' : ''}`}
onClick={() => handleFolderClick(folder.uid)}
>
<div className="folder-item-content">
<IconFolder size={16} strokeWidth={1.5} />
<span className="folder-item-name">{folder.name}</span>
</div>
<IconChevronRight size={16} strokeWidth={1.5} />
</li>
))}
{showNewFolderInput && (
<li className="new-folder-item">
<div className="new-folder-content">
<IconFolder size={16} strokeWidth={1.5} />
<div className="new-folder-inputs">
<div className="new-folder-name-input-wrapper">
{showFilesystemName && (
<label className="new-folder-name-label">New Folder name (in bruno)</label>
)}
<div className="new-folder-input-row">
<input
ref={newFolderInputRef}
type="text"
className="new-folder-input"
placeholder="Untitled new folder"
value={newFolderName}
onChange={(e) => handleNewFolderNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleCreateNewFolder();
} else if (e.key === 'Escape') {
handleCancelNewFolder();
}
}}
/>
<div className="new-folder-actions">
<button
type="button"
className="new-folder-action-btn"
onClick={handleCancelNewFolder}
title="Cancel"
>
<IconX size={16} strokeWidth={1.5} />
</button>
<button
type="button"
className="new-folder-action-btn"
onClick={handleCreateNewFolder}
title="Create folder"
>
<IconCheck size={16} strokeWidth={1.5} />
</button>
</div>
</div>
</div>
{showFilesystemName && (
<div className="new-folder-filesystem-wrapper">
<label className="new-folder-filesystem-label">Name on filesystem</label>
<input
type="text"
className="new-folder-input"
value={newFolderDirectoryName}
onChange={(e) => handleDirectoryNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleCreateNewFolder();
}
}}
/>
</div>
)}
</div>
</div>
<button
type="button"
className="new-folder-toggle-filesystem-btn"
onClick={() => {
setShowFilesystemName(!showFilesystemName);
setNewFolderDirectoryName(sanitizeName(newFolderName));
}}
>
{showFilesystemName ? (
<>
<IconEyeOff size={16} strokeWidth={1.5} />
<span>Hide filesystem name</span>
</>
) : (
<>
<IconEye size={16} strokeWidth={1.5} />
<span>Show filesystem name</span>
</>
)}
</button>
</li>
)}
</ul>
) : (
<div className="folder-empty-state">
{searchText.trim() ? 'No folders found' : 'No folders available'}
</div>
)}
</div>
</div>
</div>
<div className="custom-modal-footer">
<div className="footer-left">
{!showNewFolderInput && (
<Button
type="button"
color="primary"
variant="ghost"
icon={<IconFolder size={16} strokeWidth={1.5} />}
onClick={handleShowNewFolder}
>
New Folder
</Button>
)}
</div>
<div className="footer-right">
<Button type="button" color="secondary" variant="ghost" onClick={handleCancel}>
Cancel
</Button>
<Button type="button" color="primary" onClick={handleConfirm}>
Save
</Button>
</div>
</div>
</Modal>
</StyledWrapper>
);
};
export default SaveTransientRequest;

View File

@@ -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 (

View File

@@ -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 }) =>
<h1 className="ml-2 text-lg font-medium">Hold on..</h1>
</div>
<p className="mt-4">
Do you want to save the changes you made to the following{' '}
<span className="font-medium">{currentDrafts.length}</span> {pluralizeWord('request', currentDrafts.length)}?
You have unsaved changes in <span className="font-medium">{allDrafts.length}</span>{' '}
{pluralizeWord('request', allDrafts.length)}.
</p>
<ul className="mt-4">
{currentDrafts.slice(0, MAX_UNSAVED_REQUESTS_TO_SHOW).map((item) => {
return (
<li key={item.uid} className="mt-1 text-xs">
{item.filename}
</li>
);
})}
</ul>
{/* Regular (saved) requests with changes */}
{currentDrafts.length > 0 && (
<div className="mt-4">
<p className="text-sm font-medium mb-2">
Saved {pluralizeWord('Request', currentDrafts.length)} ({currentDrafts.length})
</p>
<ul className="ml-2">
{currentDrafts.slice(0, MAX_UNSAVED_REQUESTS_TO_SHOW).map((item) => {
return (
<li key={item.uid} className="mt-1 text-xs text-gray-600">
{item.filename || item.name}
</li>
);
})}
</ul>
{currentDrafts.length > MAX_UNSAVED_REQUESTS_TO_SHOW && (
<p className="ml-2 mt-1 text-xs text-gray-500">
...{currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW} additional{' '}
{pluralizeWord('request', currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW)} not shown
</p>
)}
</div>
)}
{currentDrafts.length > MAX_UNSAVED_REQUESTS_TO_SHOW && (
<p className="mt-1 text-xs">
...{currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW} additional{' '}
{pluralizeWord('request', currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW)} not shown
</p>
{/* Transient (unsaved) requests */}
{currentTransientDrafts.length > 0 && (
<div className="mt-4">
<p className="text-sm font-medium mb-2">
Transient {pluralizeWord('Request', currentTransientDrafts.length)} ({currentTransientDrafts.length})
</p>
<p className="text-xs text-orange-600 mb-3">
These requests need to be saved individually before closing the collection.
</p>
<div className="space-y-2 max-h-64 overflow-y-auto pr-1">
{currentTransientDrafts.map((item) => {
return (
<div
key={item.uid}
className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded border border-gray-200"
>
<span className="text-sm text-gray-700 truncate mr-3">{item.name}</span>
<Button
color="primary"
variant="ghost"
size="sm"
onClick={() => handleSaveTransient(item)}
icon={<IconDeviceFloppy size={14} strokeWidth={1.5} />}
>
Save
</Button>
</div>
);
})}
</div>
</div>
)}
<div className="flex justify-between mt-6">
<div>
<Button color="danger" onClick={handleDiscardAll}>
Discard and Remove
Discard All and Remove
</Button>
</div>
<div>
<Button className="mr-2" color="secondary" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSaveAll}>
<Button
onClick={handleSaveAll}
disabled={currentTransientDrafts.length > 0}
title={currentTransientDrafts.length > 0 ? 'Please save or discard transient requests first' : ''}
>
{currentDrafts.length > 1 ? 'Save All and Remove' : 'Save and Remove'}
</Button>
</div>

View File

@@ -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 = [
{

View File

@@ -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;

View File

@@ -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 (
<SaveTransientRequest
item={modals[0].item}
collection={modals[0].collection}
isOpen={true}
/>
);
}
return <SaveTransientRequestContainer />;
};
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() {
<Devtools mainSectionRef={mainSectionRef} />
<StatusBar />
<TransientRequestModalsRenderer modals={saveTransientRequestModals} />
</div>
// </ErrorCapture>
);

View File

@@ -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))

View File

@@ -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;

View File

@@ -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' }));

View File

@@ -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;

View File

@@ -1704,3 +1704,7 @@ export const generateUniqueRequestName = async (collection, baseName = 'Untitled
return `${baseName}${nextNumber}`;
};
export const isItemTransientRequest = (item) => {
return isItemARequest(item) && item?.isTransient;
};

View File

@@ -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));
};

View File

@@ -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)

View File

@@ -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) => {

View File

@@ -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', []);

View File

@@ -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();

View File

@@ -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<typeof buildCommonLocators>;
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');
});
});
});

View File

@@ -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 };