From ca4d0dd40beac801ad08d5e950afe40e9a490b7f Mon Sep 17 00:00:00 2001 From: Chirag Chandrashekhar Date: Thu, 29 Jan 2026 18:38:42 +0530 Subject: [PATCH] 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. --- .../CreateTransientRequest/index.js | 245 +++++++++++ .../src/components/RequestTabs/index.js | 10 +- .../SaveTransientRequest/Container.js | 123 ++++++ .../SaveTransientRequest/StyledWrapper.js | 287 ++++++++++++ .../components/SaveTransientRequest/index.js | 412 ++++++++++++++++++ .../Collection/CollectionItem/index.js | 4 +- .../ConfirmCollectionCloseDrafts.js | 186 +++++--- .../Sidebar/Collections/Collection/index.js | 4 +- .../hooks/useCollectionFolderTree/index.js | 162 +++++++ packages/bruno-app/src/pages/Bruno/index.js | 22 + .../middlewares/autosave/middleware.js | 16 +- .../middlewares/tasks/middleware.js | 43 +- .../ReduxStore/slices/collections/actions.js | 273 +++++++++--- .../ReduxStore/slices/collections/index.js | 51 ++- .../bruno-app/src/utils/collections/index.js | 4 + .../bruno-app/src/utils/collections/search.js | 4 +- .../src/app/collection-watcher.js | 108 +++++ packages/bruno-electron/src/ipc/collection.js | 80 ++++ .../bruno-electron/src/utils/collection.js | 3 + tests/onboarding/sample-collection.spec.ts | 17 +- .../transient-requests.spec.ts | 225 ++++++++++ tests/utils/page/actions.ts | 119 ++++- 22 files changed, 2252 insertions(+), 146 deletions(-) create mode 100644 packages/bruno-app/src/components/CreateTransientRequest/index.js create mode 100644 packages/bruno-app/src/components/SaveTransientRequest/Container.js create mode 100644 packages/bruno-app/src/components/SaveTransientRequest/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/SaveTransientRequest/index.js create mode 100644 packages/bruno-app/src/hooks/useCollectionFolderTree/index.js create mode 100644 tests/transient-requests/transient-requests.spec.ts diff --git a/packages/bruno-app/src/components/CreateTransientRequest/index.js b/packages/bruno-app/src/components/CreateTransientRequest/index.js new file mode 100644 index 000000000..9a98985e0 --- /dev/null +++ b/packages/bruno-app/src/components/CreateTransientRequest/index.js @@ -0,0 +1,245 @@ +import React, { useState, useRef, useCallback, useMemo } from 'react'; +import { IconPlus, IconApi, IconBrandGraphql, IconPlugConnected, IconCode } from '@tabler/icons'; +import ActionIcon from 'ui/ActionIcon/index'; +import Dropdown from 'components/Dropdown'; +import { newHttpRequest, newGrpcRequest, newWsRequest } from 'providers/ReduxStore/slices/collections/actions'; +import { sanitizeName } from 'utils/common/regex'; +import toast from 'react-hot-toast'; +import { useDispatch, useSelector } from 'react-redux'; +import { flattenItems, isItemARequest, isItemTransientRequest } from 'utils/collections'; +import filter from 'lodash/filter'; +import { get } from 'lodash'; + +const REQUEST_TYPE = { + HTTP: 'http', + GRAPHQL: 'graphql', + GRPC: 'grpc', + WEBSOCKET: 'websocket' +}; + +/** + * Generate a request name for transient requests in the pattern "Untitled {Count}" + * @param {Object} collection - The collection object + * @returns {string} A request name like "Untitled 1", "Untitled 2", etc. + */ +const generateTransientRequestName = (collection) => { + if (!collection || !collection.items) { + return 'Untitled 1'; + } + const allItems = flattenItems(collection.items); + const transientRequests = filter(allItems, (item) => { + return isItemTransientRequest(item); + }); + + // Find the highest "Untitled X" number among transient requests + let maxNumber = 0; + transientRequests.forEach((item) => { + const match = item.name?.match(/^Untitled (\d+)$/); + if (match) { + const number = parseInt(match[1], 10); + if (number > maxNumber) { + maxNumber = number; + } + } + }); + + // Increment from the highest number found, or start at 1 if none found + const count = maxNumber + 1; + + return `Untitled ${count}`; +}; + +const CreateTransientRequest = ({ collectionUid }) => { + const [dropdownVisible, setDropdownVisible] = useState(false); + const dropdownTippyRef = useRef(); + const dispatch = useDispatch(); + const collections = useSelector((state) => state.collections.collections); + + const collection = useMemo(() => { + return collections?.find((c) => c.uid === collectionUid); + }, [collections]); + + const collectionPresets = useMemo(() => { + return get(collection, collection?.draft?.brunoConfig ? 'draft.brunoConfig.presets' : 'brunoConfig.presets', { + requestType: 'http', + requestUrl: '' + }); + }, [collection]); + + const onDropdownCreate = (ref) => { + dropdownTippyRef.current = ref; + if (ref) { + ref.setProps({ + onHide: () => { + setDropdownVisible(false); + } + }); + } + }; + + const handleLeftClick = () => { + handleItemClick(collectionPresets.requestType); + }; + + const handleRightClick = (e) => { + e.preventDefault(); + setDropdownVisible(true); + }; + + const handleCreateHttpRequest = useCallback(() => { + if (!collection) return; + + const uniqueName = generateTransientRequestName(collection); + const filename = sanitizeName(uniqueName); + + dispatch( + newHttpRequest({ + requestName: uniqueName, + filename: filename, + requestType: 'http-request', + requestUrl: collectionPresets.requestUrl, + requestMethod: 'GET', + collectionUid: collection.uid, + itemUid: null, + isTransient: true + }) + ).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request')); + }, [dispatch, collection, collectionPresets.requestUrl]); + + const handleCreateGraphQLRequest = useCallback(() => { + if (!collection) return; + + const uniqueName = generateTransientRequestName(collection); + const filename = sanitizeName(uniqueName); + + dispatch( + newHttpRequest({ + requestName: uniqueName, + filename: filename, + requestType: 'graphql-request', + requestUrl: collectionPresets.requestUrl, + requestMethod: 'POST', + collectionUid: collection.uid, + itemUid: null, + isTransient: true, + body: { + mode: 'graphql', + graphql: { + query: '', + variables: '' + } + } + }) + ).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request')); + }, [dispatch, collection, collectionPresets.requestUrl]); + + const handleCreateWebSocketRequest = useCallback(() => { + if (!collection) return; + + const uniqueName = generateTransientRequestName(collection); + const filename = sanitizeName(uniqueName); + + dispatch( + newWsRequest({ + requestName: uniqueName, + filename: filename, + requestUrl: collectionPresets.requestUrl, + requestMethod: 'ws', + collectionUid: collection.uid, + itemUid: null, + isTransient: true + }) + ).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request')); + }, [dispatch, collection, collectionPresets.requestUrl]); + + const handleCreateGrpcRequest = useCallback(() => { + if (!collection) return; + + const uniqueName = generateTransientRequestName(collection); + const filename = sanitizeName(uniqueName); + + dispatch( + newGrpcRequest({ + requestName: uniqueName, + filename: filename, + requestUrl: collectionPresets.requestUrl, + collectionUid: collection.uid, + itemUid: null, + isTransient: true + }) + ).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request')); + }, [dispatch, collection, collectionPresets.requestUrl]); + + const handleItemClick = (type) => { + if (dropdownTippyRef.current) { + dropdownTippyRef.current.hide(); + } + switch (type) { + case REQUEST_TYPE.HTTP: + handleCreateHttpRequest(); + break; + case REQUEST_TYPE.GRAPHQL: + handleCreateGraphQLRequest(); + break; + case REQUEST_TYPE.GRPC: + handleCreateGrpcRequest(); + break; + case REQUEST_TYPE.WEBSOCKET: + handleCreateWebSocketRequest(); + break; + } + }; + + if (!collection) { + return null; + } + + const IconButton = ( + + + + ); + + return ( + setDropdownVisible(false)} + placement="bottom-end" + > +
handleItemClick(REQUEST_TYPE.HTTP)}> +
+ +
+
HTTP
+
+
handleItemClick(REQUEST_TYPE.GRAPHQL)}> +
+ +
+
GraphQL
+
+
handleItemClick(REQUEST_TYPE.GRPC)}> +
+ +
+
gRPC
+
+
handleItemClick(REQUEST_TYPE.WEBSOCKET)}> +
+ +
+
WebSocket
+
+
+ ); +}; + +export default CreateTransientRequest; diff --git a/packages/bruno-app/src/components/RequestTabs/index.js b/packages/bruno-app/src/components/RequestTabs/index.js index 15fb43a0e..b10552008 100644 --- a/packages/bruno-app/src/components/RequestTabs/index.js +++ b/packages/bruno-app/src/components/RequestTabs/index.js @@ -10,8 +10,7 @@ import CollectionToolBar from './CollectionToolBar'; import RequestTab from './RequestTab'; import StyledWrapper from './StyledWrapper'; import DraggableTab from './DraggableTab'; -import CreateUntitledRequest from 'components/CreateUntitledRequest'; -import { IconPlus } from '@tabler/icons'; +import CreateTransientRequest from 'components/CreateTransientRequest'; import ActionIcon from 'ui/ActionIcon/index'; const RequestTabs = () => { @@ -161,12 +160,7 @@ const RequestTabs = () => { {activeCollection && ( - setNewRequestModalOpen(true)} aria-label="New Request" size="lg" style={{ marginBottom: '3px' }}> - - + )}
diff --git a/packages/bruno-app/src/components/SaveTransientRequest/Container.js b/packages/bruno-app/src/components/SaveTransientRequest/Container.js new file mode 100644 index 000000000..6fce90ed8 --- /dev/null +++ b/packages/bruno-app/src/components/SaveTransientRequest/Container.js @@ -0,0 +1,123 @@ +import React, { useState, useEffect } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { pluralizeWord } from 'utils/common'; +import { IconAlertTriangle, IconDeviceFloppy } from '@tabler/icons'; +import { clearAllSaveTransientRequestModals } from 'providers/ReduxStore/slices/collections'; +import { closeTabs } from 'providers/ReduxStore/slices/tabs'; +import toast from 'react-hot-toast'; +import Modal from 'components/Modal'; +import Button from 'ui/Button'; +import SaveTransientRequest from './index'; + +const SaveTransientRequestContainer = () => { + const dispatch = useDispatch(); + const modals = useSelector((state) => state.collections.saveTransientRequestModals); + const [openItemUid, setOpenItemUid] = useState(null); + + // Reset openItemUid if the modal no longer exists in the array + useEffect(() => { + if (openItemUid && !modals.find((modal) => modal.item.uid === openItemUid)) { + setOpenItemUid(null); + } + }, [modals, openItemUid]); + + const handleDiscardAll = () => { + // Close all tabs for the transient requests (this will also delete the transient files) + const tabUids = modals.map((modal) => modal.item.uid); + dispatch(closeTabs({ tabUids })); + + // Clear all modals + dispatch(clearAllSaveTransientRequestModals()); + + // Show success message + toast.success(`Discarded ${modals.length} ${pluralizeWord('request', modals.length)}`); + }; + + const handleCancel = () => { + // Clear all modals on close + dispatch(clearAllSaveTransientRequestModals()); + }; + + const handleOpenSpecificModal = (itemUid) => { + setOpenItemUid(itemUid); + }; + + // If a specific modal is open, show it + if (openItemUid) { + const modalToOpen = modals.find((modal) => modal.item.uid === openItemUid); + if (modalToOpen) { + return ( + + ); + } + } + + // Show list of multiple modals + return ( + +
+ +

You have unsaved transient requests

+
+

+ You have {modals.length}{' '} + {pluralizeWord('request', modals.length)} that need to be saved. +

+ +
+

+ Transient {pluralizeWord('Request', modals.length)} ({modals.length}) +

+

+ These requests need to be saved before you can proceed. +

+
+ {modals.map((modal) => { + const { item, collection } = modal; + return ( +
+
+ {item.name} + + {collection.name} + +
+ +
+ ); + })} +
+
+ +
+ +
+
+ ); +}; + +export default SaveTransientRequestContainer; diff --git a/packages/bruno-app/src/components/SaveTransientRequest/StyledWrapper.js b/packages/bruno-app/src/components/SaveTransientRequest/StyledWrapper.js new file mode 100644 index 000000000..1c0fa096b --- /dev/null +++ b/packages/bruno-app/src/components/SaveTransientRequest/StyledWrapper.js @@ -0,0 +1,287 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .save-request-form { + display: flex; + flex-direction: column; + gap: 24px; + } + + .form-section { + display: flex; + flex-direction: column; + } + + .form-label { + display: block; + font-weight: 500; + margin-bottom: 8px; + color: ${(props) => props.theme.text}; + } + + .form-input { + display: block; + width: 100%; + line-height: 1.42857143; + padding: 0.45rem; + border-radius: ${(props) => props.theme.border.radius.sm}; + background-color: ${(props) => props.theme.input.bg}; + border: 1px solid ${(props) => props.theme.input.border}; + color: ${(props) => props.theme.text}; + transition: border-color ease-in-out 0.1s; + + &:focus { + border: solid 1px ${(props) => props.theme.input.focusBorder} !important; + outline: none !important; + } + } + + .collections-section { + display: flex; + flex-direction: column; + } + + .collections-label { + display: block; + font-weight: 500; + margin-bottom: 8px; + color: ${(props) => props.theme.text}; + } + + .collection-name { + display: flex; + align-items: center; + font-size: 14px; + margin-bottom: 12px; + color: ${(props) => props.theme.colors.text.muted}; + } + + .collection-name-clickable { + cursor: pointer; + } + + .collection-name-breadcrumb { + cursor: pointer; + } + + .collection-name-chevron { + margin: 0 4px; + } + + .search-container { + margin-bottom: 12px; + } + + .folder-list { + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: ${(props) => props.theme.border.radius.sm}; + max-height: 256px; + overflow-y: auto; + background-color: ${(props) => props.theme.modal.body.bg}; + padding: 8px 8px; + } + + .folder-list-items { + display: flex; + flex-direction: column; + gap: 4px; + list-style: none; + padding: 0; + margin: 0; + border-radius: ${(props) => props.theme.border.radius.sm}; + } + + .folder-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + cursor: pointer; + transition: background-color 0.15s ease; + color: ${(props) => props.theme.text}; + border-radius: ${(props) => props.theme.border.radius.sm}; + user-select: none; + &:hover { + background-color: ${(props) => props.theme.plainGrid.hoverBg}; + } + + &.selected { + background-color: ${(props) => props.theme.plainGrid.hoverBg}; + } + } + + .folder-item-content { + display: flex; + align-items: center; + gap: 8px; + } + + .folder-item-name { + color: ${(props) => props.theme.text}; + } + + .folder-empty-state { + padding: 16px 12px; + text-align: center; + font-size: 14px; + color: ${(props) => props.theme.colors.text.muted}; + } + + .custom-modal-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 0px; + background-color: ${(props) => props.theme.modal.body.bg}; + border-top: 1px solid ${(props) => props.theme.border.border0}; + border-bottom-left-radius: ${(props) => props.theme.border.radius.base}; + border-bottom-right-radius: ${(props) => props.theme.border.radius.base}; + } + + .footer-left { + display: flex; + align-items: center; + } + + .footer-right { + display: flex; + align-items: center; + gap: 8px; + } + + .text-muted { + color: ${(props) => props.theme.colors.text.muted}; + } + + .new-folder-item { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + border-top: 1px solid ${(props) => props.theme.border.border1}; + margin-top: 4px; + padding-top: 12px; + } + + .new-folder-content { + display: flex; + align-items: flex-start; + gap: 8px; + } + + .new-folder-inputs { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + } + + .new-folder-name-input-wrapper { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; + } + + .new-folder-name-label { + font-size: 12px; + font-weight: 500; + color: ${(props) => props.theme.colors.text.muted}; + } + + .new-folder-input-row { + display: flex; + align-items: center; + gap: 8px; + } + + .new-folder-input { + flex: 1; + padding: 6px 8px; + border-radius: ${(props) => props.theme.border.radius.sm}; + background-color: ${(props) => props.theme.input.bg}; + border: 1px solid ${(props) => props.theme.input.border}; + color: ${(props) => props.theme.text}; + font-size: 14px; + transition: border-color ease-in-out 0.1s; + + &:focus { + border: solid 1px ${(props) => props.theme.input.focusBorder} !important; + outline: none !important; + } + + &::placeholder { + color: ${(props) => props.theme.colors.text.muted}; + } + } + + .new-folder-actions { + display: flex; + align-items: center; + gap: 4px; + } + + .new-folder-action-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + border: none; + background: transparent; + color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + border-radius: ${(props) => props.theme.border.radius.sm}; + transition: all 0.15s ease; + + &:hover { + background-color: ${(props) => props.theme.plainGrid.hoverBg}; + color: ${(props) => props.theme.text}; + } + + &:active { + opacity: 0.7; + } + } + + .new-folder-filesystem-wrapper { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 4px; + } + + .new-folder-filesystem-label { + font-size: 12px; + font-weight: 500; + color: ${(props) => props.theme.colors.text.muted}; + } + + .new-folder-toggle-filesystem-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + margin-top: 4px; + border: none; + background: transparent; + color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + border-radius: ${(props) => props.theme.border.radius.sm}; + font-size: 12px; + transition: all 0.15s ease; + align-self: flex-start; + + &:hover { + background-color: ${(props) => props.theme.plainGrid.hoverBg}; + color: ${(props) => props.theme.text}; + } + } + + .new-folder-error { + color: ${(props) => props.theme.colors.danger}; + font-size: 12px; + margin-top: 4px; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/SaveTransientRequest/index.js b/packages/bruno-app/src/components/SaveTransientRequest/index.js new file mode 100644 index 000000000..3522beca2 --- /dev/null +++ b/packages/bruno-app/src/components/SaveTransientRequest/index.js @@ -0,0 +1,412 @@ +import React, { useState, useMemo, useEffect, useRef } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import Modal from 'components/Modal'; +import SearchInput from 'components/SearchInput'; +import Button from 'ui/Button'; +import { IconFolder, IconChevronRight, IconCheck, IconX, IconEye, IconEyeOff } from '@tabler/icons'; +import filter from 'lodash/filter'; +import toast from 'react-hot-toast'; +import StyledWrapper from './StyledWrapper'; +import useCollectionFolderTree from 'hooks/useCollectionFolderTree'; +import { removeSaveTransientRequestModal, deleteRequestDraft } from 'providers/ReduxStore/slices/collections'; +import { newFolder } from 'providers/ReduxStore/slices/collections/actions'; +import { closeTabs } from 'providers/ReduxStore/slices/tabs'; +import { sanitizeName, validateName, validateNameError } from 'utils/common/regex'; +import { resolveRequestFilename } from 'utils/common/platform'; +import { transformRequestToSaveToFilesystem, findCollectionByUid, findItemInCollection } from 'utils/collections'; +import { itemSchema } from '@usebruno/schema'; + +const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOpen = false, onClose }) => { + const dispatch = useDispatch(); + + const latestCollection = useSelector((state) => + collectionProp ? findCollectionByUid(state.collections.collections, collectionProp.uid) : null + ); + const latestItem = latestCollection && itemProp ? findItemInCollection(latestCollection, itemProp.uid) : itemProp; + + const item = itemProp; + const collection = collectionProp; + + const handleClose = () => { + if (onClose) { + onClose(); + return; + } + // Remove from Redux array + dispatch(removeSaveTransientRequestModal({ itemUid: item.uid })); + }; + const [requestName, setRequestName] = useState(item?.name || ''); + const [searchText, setSearchText] = useState(''); + const [showNewFolderInput, setShowNewFolderInput] = useState(false); + const [newFolderName, setNewFolderName] = useState(''); + const [newFolderDirectoryName, setNewFolderDirectoryName] = useState(''); + const [showFilesystemName, setShowFilesystemName] = useState(false); + const newFolderInputRef = useRef(null); + + const { + currentFolders, + breadcrumbs, + selectedFolderUid, + navigateIntoFolder, + navigateToRoot, + navigateToBreadcrumb, + getCurrentParentFolder, + getCurrentSelectedFolder, + reset, + isAtRoot + } = useCollectionFolderTree(collection?.uid); + + const resetForm = () => { + setRequestName(item.name || ''); + setSearchText(''); + reset(); + setShowNewFolderInput(false); + setNewFolderName(''); + setNewFolderDirectoryName(''); + setShowFilesystemName(false); + }; + + useEffect(() => { + isOpen && item && resetForm(); + }, [isOpen, item]); + + useEffect(() => { + if (showNewFolderInput && newFolderInputRef.current) { + newFolderInputRef.current.focus(); + } + }, [showNewFolderInput]); + + const filteredFolders = useMemo(() => { + if (!searchText.trim()) { + return currentFolders; + } + const searchLower = searchText.toLowerCase(); + return filter(currentFolders, (folder) => folder.name.toLowerCase().includes(searchLower)); + }, [currentFolders, searchText]); + + const handleCancel = () => { + resetForm(); + handleClose(); + }; + + const handleConfirm = async () => { + if (!item || !collection || !latestItem) { + return; + } + + try { + const { ipcRenderer } = window; + + const selectedFolder = getCurrentSelectedFolder(); + const targetDirname = selectedFolder ? selectedFolder.pathname : collection.pathname; + + const trimmedName = requestName.trim(); + if (!trimmedName || trimmedName.length === 0) { + toast.error('Request name is required'); + return; + } + + const sanitizedFilename = sanitizeName(trimmedName); + + const itemToSave = latestItem.draft ? { ...latestItem, ...latestItem.draft } : { ...latestItem }; + itemToSave.name = sanitizedFilename; + delete itemToSave.draft; + + const transformedItem = transformRequestToSaveToFilesystem(itemToSave); + await itemSchema.validate(transformedItem); + + const format = collection.format || 'bru'; + const targetFilename = resolveRequestFilename(sanitizedFilename, format); + + await ipcRenderer.invoke('renderer:save-transient-request', { + sourcePathname: item.pathname, + targetDirname, + targetFilename, + request: transformedItem, + format + }); + + dispatch( + closeTabs({ + tabUids: [item.uid] + }) + ); + + dispatch({ + type: 'collections/deleteItem', + payload: { + itemUid: item.uid, + collectionUid: collection.uid + } + }); + + toast.success('Request saved successfully'); + handleClose(); + } catch (err) { + toast.error(err?.message || 'Failed to save request'); + console.error('Error saving request:', err); + } + }; + + const handleShowNewFolder = () => { + setShowNewFolderInput(true); + setNewFolderName(''); + setNewFolderDirectoryName(''); + setShowFilesystemName(false); + }; + + const handleCancelNewFolder = () => { + setShowNewFolderInput(false); + setNewFolderName(''); + setNewFolderDirectoryName(''); + setShowFilesystemName(false); + }; + + const handleNewFolderNameChange = (value) => { + setNewFolderName(value); + if (!showFilesystemName) { + setNewFolderDirectoryName(sanitizeName(value)); + } + }; + + const handleDirectoryNameChange = (value) => { + setNewFolderDirectoryName(value); + }; + + const handleCreateNewFolder = async () => { + const directoryName = newFolderDirectoryName.trim() || sanitizeName(newFolderName.trim()); + const parentFolder = getCurrentParentFolder(); + + try { + await dispatch(newFolder(newFolderName.trim(), directoryName, collection?.uid, parentFolder?.uid)); + toast.success('New folder created!'); + handleCancelNewFolder(); + } catch (err) { + const errorMessage = err?.message || 'An error occurred while adding the folder'; + toast.error(errorMessage); + } + }; + + const handleFolderClick = (folderUid) => { + navigateIntoFolder(folderUid); + setSearchText(''); + }; + + if (!isOpen) { + return null; + } + + return ( + + +
+
+ + setRequestName(e.target.value)} + autoFocus={true} + onFocus={(e) => e.target.select()} + /> +
+ +
+
Save to Collections
+ {collection && ( +
+ {collection.name} + {breadcrumbs.length > 0 && ( + <> + {breadcrumbs.map((breadcrumb, index) => ( + + + { + e.stopPropagation(); + navigateToBreadcrumb(index); + setSearchText(''); + }} + > + {breadcrumb.name} + + + ))} + + )} + {isAtRoot && } +
+ )} + +
+ +
+ +
+ {filteredFolders.length > 0 || showNewFolderInput ? ( +
    + {filteredFolders.map((folder) => ( +
  • handleFolderClick(folder.uid)} + > +
    + + {folder.name} +
    + +
  • + ))} + {showNewFolderInput && ( +
  • +
    + +
    +
    + {showFilesystemName && ( + + )} +
    + handleNewFolderNameChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleCreateNewFolder(); + } else if (e.key === 'Escape') { + handleCancelNewFolder(); + } + }} + /> +
    + + +
    +
    +
    + + {showFilesystemName && ( +
    + + handleDirectoryNameChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleCreateNewFolder(); + } + }} + /> +
    + )} +
    +
    + +
  • + )} +
+ ) : ( +
+ {searchText.trim() ? 'No folders found' : 'No folders available'} +
+ )} +
+
+
+ +
+
+ {!showNewFolderInput && ( + + )} +
+
+ + +
+
+
+
+ ); +}; + +export default SaveTransientRequest; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js index 91f54d50a..918c6accd 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js @@ -502,8 +502,8 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) setCreateExampleModalOpen(false); }; - const folderItems = sortByNameThenSequence(filter(item.items, (i) => isItemAFolder(i))); - const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i))); + const folderItems = sortByNameThenSequence(filter(item.items, (i) => isItemAFolder(i) && !i.isTransient)); + const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i) && !i.isTransient)); const handleGenerateCode = () => { if ( diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/ConfirmCollectionCloseDrafts.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/ConfirmCollectionCloseDrafts.js index 13a24db9d..b31dc655a 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/ConfirmCollectionCloseDrafts.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/ConfirmCollectionCloseDrafts.js @@ -1,49 +1,89 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import filter from 'lodash/filter'; -import { useDispatch } from 'react-redux'; -import { flattenItems, isItemARequest, hasRequestChanges } from 'utils/collections'; +import { useDispatch, useSelector } from 'react-redux'; +import { flattenItems, isItemARequest, hasRequestChanges, findCollectionByUid } from 'utils/collections'; import { pluralizeWord } from 'utils/common'; -import { saveMultipleRequests } from 'providers/ReduxStore/slices/collections/actions'; +import { saveRequest, saveMultipleRequests } from 'providers/ReduxStore/slices/collections/actions'; import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections'; import { removeCollection } from 'providers/ReduxStore/slices/collections/actions'; -import { IconAlertTriangle } from '@tabler/icons'; +import { IconAlertTriangle, IconDeviceFloppy } from '@tabler/icons'; import Modal from 'components/Modal'; import toast from 'react-hot-toast'; import Button from 'ui/Button'; +const MAX_UNSAVED_REQUESTS_TO_SHOW = 5; + const ConfirmCollectionCloseDrafts = ({ onClose, collection, collectionUid }) => { - const MAX_UNSAVED_REQUESTS_TO_SHOW = 5; const dispatch = useDispatch(); - // Get all draft items in the collection - const currentDrafts = React.useMemo(() => { - if (!collection) return []; - const items = flattenItems(collection.items); - const collectionDrafts = filter(items, (item) => isItemARequest(item) && hasRequestChanges(item)); - return collectionDrafts.map((draft) => ({ - ...draft, - collectionUid: collectionUid - })); - }, [collection, collectionUid]); + const latestCollection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid)); + + const activeCollection = latestCollection || collection; + + const currentDrafts = useMemo(() => { + if (!activeCollection) return []; + const items = flattenItems(activeCollection.items); + return items + ?.filter((item) => isItemARequest(item) && hasRequestChanges(item) && !item.isTransient) + .map((item) => { + return { + ...item, + collectionUid: collectionUid + }; + }); + }, [activeCollection, collectionUid]); + + const currentTransientDrafts = useMemo(() => { + if (!activeCollection) return []; + const items = flattenItems(activeCollection.items); + return items + ?.filter((item) => isItemARequest(item) && hasRequestChanges(item) && item.isTransient) + .map((item) => { + return { + ...item, + collectionUid: collectionUid + }; + }); + }, [activeCollection, collectionUid]); + + const allDrafts = useMemo(() => { + return [...currentDrafts, ...currentTransientDrafts]; + }, [currentDrafts, currentTransientDrafts]); const handleSaveAll = () => { - dispatch(saveMultipleRequests(currentDrafts)) - .then(() => { - dispatch(removeCollection(collectionUid)) - .then(() => { - toast.success('Collection removed from workspace'); - onClose(); - }) - .catch(() => toast.error('An error occurred while removing the collection')); - }) - .catch(() => { - toast.error('Failed to save requests!'); - }); + // If there are transient drafts, we can't proceed with batch save + if (currentTransientDrafts.length > 0) { + toast.error('Please save or discard transient requests first'); + return; + } + // Save only non-transient drafts + if (currentDrafts.length > 0) { + dispatch(saveMultipleRequests(currentDrafts)) + .then(() => { + dispatch(removeCollection(collectionUid)) + .then(() => { + toast.success('Collection removed from workspace'); + onClose(); + }) + .catch(() => toast.error('An error occurred while removing the collection')); + }) + .catch(() => { + toast.error('Failed to save requests!'); + }); + } else { + // No non-transient drafts, just remove the collection + dispatch(removeCollection(collectionUid)) + .then(() => { + toast.success('Collection removed from workspace'); + onClose(); + }) + .catch(() => toast.error('An error occurred while removing the collection')); + } }; const handleDiscardAll = () => { - // Discard all drafts - currentDrafts.forEach((draft) => { + // Discard all drafts (both regular and transient) + allDrafts.forEach((draft) => { dispatch(deleteRequestDraft({ collectionUid: collectionUid, itemUid: draft.uid @@ -59,7 +99,11 @@ const ConfirmCollectionCloseDrafts = ({ onClose, collection, collectionUid }) => .catch(() => toast.error('An error occurred while removing the collection')); }; - if (!currentDrafts.length) { + const handleSaveTransient = (draft) => { + dispatch(saveRequest(draft.uid, collectionUid)); + }; + + if (!currentDrafts.length && !currentTransientDrafts.length) { return null; } @@ -80,38 +124,82 @@ const ConfirmCollectionCloseDrafts = ({ onClose, collection, collectionUid }) =>

Hold on..

- Do you want to save the changes you made to the following{' '} - {currentDrafts.length} {pluralizeWord('request', currentDrafts.length)}? + You have unsaved changes in {allDrafts.length}{' '} + {pluralizeWord('request', allDrafts.length)}.

- + {/* Regular (saved) requests with changes */} + {currentDrafts.length > 0 && ( +
+

+ Saved {pluralizeWord('Request', currentDrafts.length)} ({currentDrafts.length}) +

+
    + {currentDrafts.slice(0, MAX_UNSAVED_REQUESTS_TO_SHOW).map((item) => { + return ( +
  • + • {item.filename || item.name} +
  • + ); + })} +
+ {currentDrafts.length > MAX_UNSAVED_REQUESTS_TO_SHOW && ( +

+ ...{currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW} additional{' '} + {pluralizeWord('request', currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW)} not shown +

+ )} +
+ )} - {currentDrafts.length > MAX_UNSAVED_REQUESTS_TO_SHOW && ( -

- ...{currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW} additional{' '} - {pluralizeWord('request', currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW)} not shown -

+ {/* Transient (unsaved) requests */} + {currentTransientDrafts.length > 0 && ( +
+

+ Transient {pluralizeWord('Request', currentTransientDrafts.length)} ({currentTransientDrafts.length}) +

+

+ These requests need to be saved individually before closing the collection. +

+
+ {currentTransientDrafts.map((item) => { + return ( +
+ {item.name} + +
+ ); + })} +
+
)}
-
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js index 049de9eca..90b830137 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js @@ -276,8 +276,8 @@ const Collection = ({ collection, searchText }) => { return items.sort((a, b) => a.seq - b.seq); }; - const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i))); - const folderItems = sortByNameThenSequence(filter(collection.items, (i) => isItemAFolder(i))); + const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i) && !i.isTransient)); + const folderItems = sortByNameThenSequence(filter(collection.items, (i) => isItemAFolder(i) && !i.isTransient)); const menuItems = [ { diff --git a/packages/bruno-app/src/hooks/useCollectionFolderTree/index.js b/packages/bruno-app/src/hooks/useCollectionFolderTree/index.js new file mode 100644 index 000000000..f7663777d --- /dev/null +++ b/packages/bruno-app/src/hooks/useCollectionFolderTree/index.js @@ -0,0 +1,162 @@ +import { useState, useMemo, useCallback } from 'react'; +import { isItemAFolder } from 'utils/collections'; +import { sortByNameThenSequence } from 'utils/common/index'; +import filter from 'lodash/filter'; +import { useSelector } from 'react-redux'; +import { findCollectionByUid } from 'utils/collections'; + +const buildTree = (items) => { + const tree = {}; + + if (!items || items.length === 0) { + return tree; + } + + const folders = filter(items, (i) => isItemAFolder(i) && !i.isTransient); + const sortedFolders = sortByNameThenSequence(folders); + + for (const folder of sortedFolders) { + tree[folder.name] = { + uid: folder.uid, + name: folder.name, + item: folder, + children: folder.items && folder.items.length > 0 ? buildTree(folder.items) : {} + }; + } + + return tree; +}; + +const findFolderByUidInTree = (tree, uid) => { + for (const folderName in tree) { + const folder = tree[folderName]; + if (folder.uid === uid) { + return folder; + } + if (folder.children && Object.keys(folder.children).length > 0) { + const found = findFolderByUidInTree(folder.children, uid); + if (found) return found; + } + } + return null; +}; + +const getFoldersAtPath = (tree, path) => { + if (path.length === 0) { + return Object.values(tree).map((folder) => folder.item); + } + + let currentTree = tree; + for (const folderUid of path) { + const folder = findFolderByUidInTree(currentTree, folderUid); + if (folder && folder.children) { + currentTree = folder.children; + } else { + return []; + } + } + + return Object.values(currentTree).map((folder) => folder.item); +}; + +const useCollectionFolderTree = (collectionUid) => { + const collection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid)); + const [currentFolderPath, setCurrentFolderPath] = useState([]); + const [selectedFolderUid, setSelectedFolderUid] = useState(null); + const tree = useMemo(() => { + if (!collection || !collection.items) { + return {}; + } + return buildTree(collection.items); + }, [collection]); + + const currentFolders = useMemo(() => { + return getFoldersAtPath(tree, currentFolderPath); + }, [tree, currentFolderPath]); + + const breadcrumbs = useMemo(() => { + if (currentFolderPath.length === 0) { + return []; + } + + const breadcrumbParts = []; + let currentTree = tree; + + for (const folderUid of currentFolderPath) { + const folder = findFolderByUidInTree(currentTree, folderUid); + if (folder) { + breadcrumbParts.push({ + uid: folder.uid, + name: folder.name + }); + currentTree = folder.children; + } + } + + return breadcrumbParts; + }, [tree, currentFolderPath]); + + const navigateIntoFolder = useCallback((folderUid) => { + setCurrentFolderPath((prev) => [...prev, folderUid]); + setSelectedFolderUid(folderUid); + }, []); + + const goBack = useCallback(() => { + setCurrentFolderPath((prev) => { + if (prev.length > 0) { + return prev.slice(0, -1); + } + return prev; + }); + setSelectedFolderUid(null); + }, []); + + const navigateToRoot = useCallback(() => { + setCurrentFolderPath([]); + setSelectedFolderUid(null); + }, []); + + const navigateToBreadcrumb = useCallback((index) => { + setCurrentFolderPath((prev) => prev.slice(0, index + 1)); + setSelectedFolderUid(null); + }, []); + + const getCurrentParentFolder = useCallback(() => { + if (currentFolderPath.length === 0) { + return null; + } + const lastFolderUid = currentFolderPath[currentFolderPath.length - 1]; + const folder = findFolderByUidInTree(tree, lastFolderUid); + return folder ? folder.item : null; + }, [tree, currentFolderPath]); + + const getCurrentSelectedFolder = useCallback(() => { + if (selectedFolderUid) { + const folder = findFolderByUidInTree(tree, selectedFolderUid); + return folder ? folder.item : null; + } + return null; + }, [tree, selectedFolderUid]); + + const reset = useCallback(() => { + setCurrentFolderPath([]); + setSelectedFolderUid(null); + }, []); + + return { + currentFolders, + breadcrumbs, + selectedFolderUid, + setSelectedFolderUid, + navigateIntoFolder, + goBack, + navigateToRoot, + navigateToBreadcrumb, + getCurrentParentFolder, + getCurrentSelectedFolder, + reset, + isAtRoot: currentFolderPath.length === 0 + }; +}; + +export default useCollectionFolderTree; diff --git a/packages/bruno-app/src/pages/Bruno/index.js b/packages/bruno-app/src/pages/Bruno/index.js index 9536df398..91a5b80a8 100644 --- a/packages/bruno-app/src/pages/Bruno/index.js +++ b/packages/bruno-app/src/pages/Bruno/index.js @@ -20,6 +20,8 @@ import Devtools from 'components/Devtools'; import useGrpcEventListeners from 'utils/network/grpc-event-listeners'; import useWsEventListeners from 'utils/network/ws-event-listeners'; import Portal from 'components/Portal'; +import SaveTransientRequestContainer from 'components/SaveTransientRequest/Container'; +import SaveTransientRequest from 'components/SaveTransientRequest'; require('codemirror/mode/javascript/javascript'); require('codemirror/mode/xml/xml'); @@ -53,6 +55,24 @@ require('utils/codemirror/brunoVarInfo'); require('utils/codemirror/javascript-lint'); require('utils/codemirror/autocomplete'); +const TransientRequestModalsRenderer = ({ modals }) => { + if (modals.length === 0) { + return null; + } + + if (modals.length === 1) { + return ( + + ); + } + + return ; +}; + export default function Main() { const activeTabUid = useSelector((state) => state.tabs.activeTabUid); const activeApiSpecUid = useSelector((state) => state.apiSpec.activeApiSpecUid); @@ -61,6 +81,7 @@ export default function Main() { const showApiSpecPage = useSelector((state) => state.app.showApiSpecPage); const showManageWorkspacePage = useSelector((state) => state.app.showManageWorkspacePage); const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen); + const saveTransientRequestModals = useSelector((state) => state.collections.saveTransientRequestModals); const mainSectionRef = useRef(null); const [showRosettaBanner, setShowRosettaBanner] = useState(false); @@ -137,6 +158,7 @@ export default function Main() { +
// ); diff --git a/packages/bruno-app/src/providers/ReduxStore/middlewares/autosave/middleware.js b/packages/bruno-app/src/providers/ReduxStore/middlewares/autosave/middleware.js index c77fe1ce9..3810e3673 100644 --- a/packages/bruno-app/src/providers/ReduxStore/middlewares/autosave/middleware.js +++ b/packages/bruno-app/src/providers/ReduxStore/middlewares/autosave/middleware.js @@ -1,6 +1,6 @@ import { saveRequest, saveCollectionSettings, saveFolderRoot, saveEnvironment } from '../../slices/collections/actions'; import { saveGlobalEnvironment } from '../../slices/global-environments'; -import { flattenItems, isItemARequest, isItemAFolder } from 'utils/collections'; +import { flattenItems, isItemARequest, isItemAFolder, findItemInCollection, findCollectionByUid, isItemTransientRequest } from 'utils/collections'; const actionsToIntercept = [ // Request-level actions @@ -134,6 +134,10 @@ const saveExistingDrafts = (dispatch, getState, interval) => { allItems.forEach((item) => { if (item.draft) { if (isItemARequest(item)) { + // Skip auto-save for transient requests + if (isItemTransientRequest(item)) { + return; + } const key = `request-${item.uid}`; scheduleAutoSave(key, () => dispatch(saveRequest(item.uid, collection.uid, true)), interval); } else if (isItemAFolder(item)) { @@ -199,6 +203,16 @@ const determineSaveHandler = (actionType, payload, dispatch, getState) => { // Handle request actions if (itemUid) { + // Check if this is a transient request and skip auto-save + const state = getState(); + const collection = findCollectionByUid(state.collections.collections, collectionUid); + if (collection) { + const item = findItemInCollection(collection, itemUid); + if (item && isItemTransientRequest(item)) { + return null; // Skip auto-save for transient requests + } + } + return { key: `request-${itemUid}`, save: () => dispatch(saveRequest(itemUid, collectionUid, true)) diff --git a/packages/bruno-app/src/providers/ReduxStore/middlewares/tasks/middleware.js b/packages/bruno-app/src/providers/ReduxStore/middlewares/tasks/middleware.js index e9830b589..540981b29 100644 --- a/packages/bruno-app/src/providers/ReduxStore/middlewares/tasks/middleware.js +++ b/packages/bruno-app/src/providers/ReduxStore/middlewares/tasks/middleware.js @@ -3,9 +3,9 @@ import each from 'lodash/each'; import filter from 'lodash/filter'; import { createListenerMiddleware } from '@reduxjs/toolkit'; import { removeTaskFromQueue } from 'providers/ReduxStore/slices/app'; -import { addTab } from 'providers/ReduxStore/slices/tabs'; +import { addTab, closeTabs, closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs'; import { collectionAddFileEvent, collectionChangeFileEvent } from 'providers/ReduxStore/slices/collections'; -import { findCollectionByUid, findItemInCollectionByPathname, getDefaultRequestPaneTab, findItemInCollectionByItemUid } from 'utils/collections/index'; +import { findCollectionByUid, findItemInCollectionByPathname, getDefaultRequestPaneTab, findItemInCollectionByItemUid, findItemInCollection, flattenItems } from 'utils/collections/index'; import { taskTypes } from './utils'; const taskMiddleware = createListenerMiddleware(); @@ -29,12 +29,14 @@ taskMiddleware.startListening({ const collection = findCollectionByUid(state.collections.collections, collectionUid); if (collection && collection.mountStatus === 'mounted' && !collection.isLoading) { const item = findItemInCollectionByPathname(collection, task.itemPathname); + const isTransient = item?.isTransient ?? false; if (item) { listenerApi.dispatch( addTab({ uid: item.uid, collectionUid: collection.uid, - requestPaneTab: getDefaultRequestPaneTab(item) + requestPaneTab: getDefaultRequestPaneTab(item), + preview: !isTransient }) ); } @@ -91,4 +93,39 @@ taskMiddleware.startListening({ } }); +/* + * When tabs are closed, check if any of them are transient requests. + * If so, delete the temporary files from the filesystem. + * Note: If a transient request was saved (moved to permanent location), + * the file will already be deleted, which is expected behavior. + */ +taskMiddleware.startListening({ + actionCreator: closeTabs, + effect: (action, listenerApi) => { + const state = listenerApi.getState(); + const tabUids = action.payload.tabUids || []; + const { ipcRenderer } = window; + + each(tabUids, (tabUid) => { + const collections = state.collections.collections; + + for (const collection of collections) { + const item = findItemInCollection(collection, tabUid); + const isTransient = item?.isTransient ?? false; + if (item && isTransient) { + ipcRenderer + .invoke('renderer:delete-item', item.pathname, item.type, collection.pathname) + .then(() => {}) + .catch((err) => { + if (err.message && !err.message.includes('does not exist')) { + console.error(`Failed to delete transient request file: ${item.pathname}`, err); + } + }); + + break; + } + } + }); + } +}); export default taskMiddleware; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index bd1a45014..aea5c0352 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -20,7 +20,8 @@ import { isItemARequest, getAllVariables, transformRequestToSaveToFilesystem, - transformCollectionRootToSave + transformCollectionRootToSave, + flattenItems } from 'utils/collections'; import { uuid, waitForNextTick } from 'utils/common'; import { cancelNetworkRequest, connectWS, sendGrpcRequest, sendNetworkRequest, sendWsRequest } from 'utils/network/index'; @@ -56,6 +57,8 @@ import { updateFolderVar, addCollectionVar, updateCollectionVar, + addTransientDirectory, + addSaveTransientRequestModal, updatePathParam } from './index'; @@ -137,7 +140,7 @@ export const renameCollection = (newName, collectionUid) => (dispatch, getState) export const saveRequest = (itemUid, collectionUid, silent = false) => (dispatch, getState) => { const state = getState(); const collection = findCollectionByUid(state.collections.collections, collectionUid); - + const tempDirectory = state.collections.tempDirectories?.[collectionUid]; return new Promise((resolve, reject) => { if (!collection) { return reject(new Error('Collection not found')); @@ -149,6 +152,12 @@ export const saveRequest = (itemUid, collectionUid, silent = false) => (dispatch return reject(new Error('Not able to locate item')); } + const isTransient = tempDirectory && item.pathname.startsWith(tempDirectory); + if (isTransient) { + dispatch(addSaveTransientRequestModal({ item, collection })); + return reject(); + } + const itemToSave = transformRequestToSaveToFilesystem(item); const { ipcRenderer } = window; @@ -1235,7 +1244,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => { headers, body, auth, - settings + settings, + isTransient = false } = params; return new Promise((resolve, reject) => { @@ -1245,6 +1255,9 @@ export const newHttpRequest = (params) => (dispatch, getState) => { return reject(new Error('Collection not found')); } + // Get temp directory if isTransient is true + const tempDirectory = isTransient ? state.collections.tempDirectories?.[collectionUid] : null; + const parts = splitOnFirst(requestUrl, '?'); const queryParams = parseQueryParams(parts[1]); each(queryParams, (urlParam) => { @@ -1265,6 +1278,7 @@ export const newHttpRequest = (params) => (dispatch, getState) => { type: requestType, name: requestName, filename, + isTransient: isTransient, request: { method: requestMethod, url: requestUrl, @@ -1295,8 +1309,45 @@ export const newHttpRequest = (params) => (dispatch, getState) => { }; // itemUid is null when we are creating a new request at the root level + // For transient requests, itemUid is always null const resolvedFilename = resolveRequestFilename(filename, collection.format); - if (!itemUid) { + + if (isTransient) { + // Transient requests are always created in temp directory + // Check for duplicates only among other transient requests + const allItems = flattenItems(collection.items); + const transientRequests = filter( + allItems, + (i) => isItemARequest(i) && i.pathname && i.pathname.startsWith(tempDirectory) + ); + const reqWithSameNameExists = find(transientRequests, (i) => trim(i.filename) === trim(resolvedFilename)); + const items = filter(collection.items, (i) => isItemAFolder(i) || isItemARequest(i)); + item.seq = items.length + 1; + + if (!reqWithSameNameExists) { + const fullName = path.join(tempDirectory, resolvedFilename); + const { ipcRenderer } = window; + + ipcRenderer + .invoke('renderer:new-request', fullName, item) + .then(() => { + // task middleware will track this and open the new request in a new tab once request is created + dispatch( + insertTaskIntoQueue({ + uid: uuid(), + type: 'OPEN_REQUEST', + collectionUid, + itemPathname: fullName + }) + ); + resolve(); + }) + .catch(reject); + } else { + return reject(new Error('Duplicate request names are not allowed under the same folder')); + } + } else if (!itemUid) { + // Regular request at root level const reqWithSameNameExists = find( collection.items, (i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename) @@ -1362,7 +1413,7 @@ export const newHttpRequest = (params) => (dispatch, getState) => { }; export const newGrpcRequest = (params) => (dispatch, getState) => { - const { requestName, filename, requestUrl, collectionUid, body, auth, headers, itemUid } = params; + const { requestName, filename, requestUrl, collectionUid, body, auth, headers, itemUid, isTransient = false } = params; return new Promise((resolve, reject) => { const state = getState(); @@ -1370,6 +1421,10 @@ export const newGrpcRequest = (params) => (dispatch, getState) => { if (!collection) { return reject(new Error('Collection not found')); } + + // Get temp directory if isTransient is true + const tempDirectory = isTransient ? state.collections.tempDirectories?.[collectionUid] : null; + // do we need to handle query, path params for grpc requests? // skipping for now @@ -1378,6 +1433,7 @@ export const newGrpcRequest = (params) => (dispatch, getState) => { name: requestName, filename, type: 'grpc-request', + isTransient: isTransient, headers: headers ?? [], request: { url: requestUrl, @@ -1407,42 +1463,84 @@ export const newGrpcRequest = (params) => (dispatch, getState) => { }; // itemUid is null when we are creating a new request at the root level - const parentItem = itemUid ? findItemInCollection(collection, itemUid) : collection; + // For transient requests, itemUid is always null const resolvedFilename = resolveRequestFilename(filename, collection.format); - if (!parentItem) { - return reject(new Error('Parent item not found')); + if (isTransient) { + // Transient requests are always created in temp directory + // Check for duplicates only among other transient requests + const allItems = flattenItems(collection.items); + const transientRequests = filter( + allItems, + (i) => isItemARequest(i) && i.pathname && i.pathname.startsWith(tempDirectory) + ); + const reqWithSameNameExists = find(transientRequests, (i) => trim(i.filename) === trim(resolvedFilename)); + + if (reqWithSameNameExists) { + return reject(new Error('Duplicate request names are not allowed under the same folder')); + } + + const items = filter(collection.items, (i) => isItemAFolder(i) || isItemARequest(i)); + item.seq = items.length + 1; + const fullName = path.join(tempDirectory, resolvedFilename); + const { ipcRenderer } = window; + ipcRenderer + .invoke('renderer:new-request', fullName, item) + .then(() => { + // task middleware will track this and open the new request in a new tab once request is created + dispatch( + insertTaskIntoQueue({ + uid: uuid(), + type: 'OPEN_REQUEST', + collectionUid, + itemPathname: fullName + }) + ); + resolve(); + }) + .catch(reject); + } else { + // Regular request (can be at root or in a folder) + const parentItem = itemUid ? findItemInCollection(collection, itemUid) : collection; + + if (!parentItem) { + return reject(new Error('Parent item not found')); + } + + const reqWithSameNameExists = find( + parentItem.items, + (i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename) + ); + + if (reqWithSameNameExists) { + return reject(new Error('Duplicate request names are not allowed under the same folder')); + } + + const items = filter(parentItem.items, (i) => isItemAFolder(i) || isItemARequest(i)); + item.seq = items.length + 1; + const fullName = path.join(parentItem.pathname, resolvedFilename); + const { ipcRenderer } = window; + ipcRenderer + .invoke('renderer:new-request', fullName, item) + .then(() => { + // task middleware will track this and open the new request in a new tab once request is created + dispatch( + insertTaskIntoQueue({ + uid: uuid(), + type: 'OPEN_REQUEST', + collectionUid, + itemPathname: fullName + }) + ); + resolve(); + }) + .catch(reject); } - - const reqWithSameNameExists = find(parentItem.items, - (i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)); - - if (reqWithSameNameExists) { - return reject(new Error('Duplicate request names are not allowed under the same folder')); - } - - const items = filter(parentItem.items, (i) => isItemAFolder(i) || isItemARequest(i)); - item.seq = items.length + 1; - const fullName = path.join(parentItem.pathname, resolvedFilename); - const { ipcRenderer } = window; - ipcRenderer - .invoke('renderer:new-request', fullName, item) - .then(() => { - // task middleware will track this and open the new request in a new tab once request is created - dispatch(insertTaskIntoQueue({ - uid: uuid(), - type: 'OPEN_REQUEST', - collectionUid, - itemPathname: fullName - })); - resolve(); - }) - .catch(reject); }); }; export const newWsRequest = (params) => (dispatch, getState) => { - const { requestName, requestMethod, filename, requestUrl, collectionUid, body, auth, headers, itemUid } = params; + const { requestName, requestMethod, filename, requestUrl, collectionUid, body, auth, headers, itemUid, isTransient = false } = params; return new Promise((resolve, reject) => { const state = getState(); @@ -1451,11 +1549,15 @@ export const newWsRequest = (params) => (dispatch, getState) => { return reject(new Error('Collection not found')); } + // Get temp directory if isTransient is true + const tempDirectory = isTransient ? state.collections.tempDirectories?.[collectionUid] : null; + const item = { uid: uuid(), name: requestName, filename, type: 'ws-request', + isTransient: isTransient, headers: headers ?? [], request: { url: requestUrl, @@ -1488,37 +1590,79 @@ export const newWsRequest = (params) => (dispatch, getState) => { }; // itemUid is null when we are creating a new request at the root level - const parentItem = itemUid ? findItemInCollection(collection, itemUid) : collection; + // For transient requests, itemUid is always null const resolvedFilename = resolveRequestFilename(filename, collection.format); - if (!parentItem) { - return reject(new Error('Parent item not found')); + if (isTransient) { + // Transient requests are always created in temp directory + // Check for duplicates only among other transient requests + const allItems = flattenItems(collection.items); + const transientRequests = filter( + allItems, + (i) => isItemARequest(i) && i.pathname && i.pathname.startsWith(tempDirectory) + ); + const reqWithSameNameExists = find(transientRequests, (i) => trim(i.filename) === trim(resolvedFilename)); + + if (reqWithSameNameExists) { + return reject(new Error('Duplicate request names are not allowed under the same folder')); + } + + const items = filter(collection.items, (i) => isItemAFolder(i) || isItemARequest(i)); + item.seq = items.length + 1; + const fullName = path.join(tempDirectory, resolvedFilename); + const { ipcRenderer } = window; + ipcRenderer + .invoke('renderer:new-request', fullName, item) + .then(() => { + // task middleware will track this and open the new request in a new tab once request is created + dispatch( + insertTaskIntoQueue({ + uid: uuid(), + type: 'OPEN_REQUEST', + collectionUid, + itemPathname: fullName + }) + ); + resolve(); + }) + .catch(reject); + } else { + // Regular request (can be at root or in a folder) + const parentItem = itemUid ? findItemInCollection(collection, itemUid) : collection; + + if (!parentItem) { + return reject(new Error('Parent item not found')); + } + + const reqWithSameNameExists = find( + parentItem.items, + (i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename) + ); + + if (reqWithSameNameExists) { + return reject(new Error('Duplicate request names are not allowed under the same folder')); + } + + const items = filter(parentItem.items, (i) => isItemAFolder(i) || isItemARequest(i)); + item.seq = items.length + 1; + const fullName = path.join(parentItem.pathname, resolvedFilename); + const { ipcRenderer } = window; + ipcRenderer + .invoke('renderer:new-request', fullName, item) + .then(() => { + // task middleware will track this and open the new request in a new tab once request is created + dispatch( + insertTaskIntoQueue({ + uid: uuid(), + type: 'OPEN_REQUEST', + collectionUid, + itemPathname: fullName + }) + ); + resolve(); + }) + .catch(reject); } - - const reqWithSameNameExists = find(parentItem.items, - (i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)); - - if (reqWithSameNameExists) { - return reject(new Error('Duplicate request names are not allowed under the same folder')); - } - - const items = filter(parentItem.items, (i) => isItemAFolder(i) || isItemARequest(i)); - item.seq = items.length + 1; - const fullName = path.join(parentItem.pathname, resolvedFilename); - const { ipcRenderer } = window; - ipcRenderer - .invoke('renderer:new-request', fullName, item) - .then(() => { - // task middleware will track this and open the new request in a new tab once request is created - dispatch(insertTaskIntoQueue({ - uid: uuid(), - type: 'OPEN_REQUEST', - collectionUid, - itemPathname: fullName - })); - resolve(); - }) - .catch(reject); }); }; @@ -2666,7 +2810,10 @@ export const mountCollection dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'mounting' })); return new Promise(async (resolve, reject) => { callIpc('renderer:mount-collection', { collectionUid, collectionPathname, brunoConfig }) - .then(() => dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'mounted' }))) + .then((transientDirPath) => { + dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'mounted' })); + dispatch(addTransientDirectory({ collectionUid, pathname: transientDirPath })); + }) .then(resolve) .catch(() => { dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'unmounted' })); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index 7fe15aa08..753d50124 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -69,7 +69,9 @@ const wsStatusCodes = { const initialState = { collections: [], collectionSortOrder: 'default', - activeConnections: [] + activeConnections: [], + tempDirectories: {}, + saveTransientRequestModals: [] }; const initiatedGrpcResponse = { @@ -775,6 +777,7 @@ export const collectionsSlice = createSlice({ uid: action.payload.uid, name: action.payload.requestName, type: action.payload.requestType, + isTransient: false, request: { url: action.payload.requestUrl, method: action.payload.requestMethod, @@ -2568,6 +2571,10 @@ export const collectionsSlice = createSlice({ if (collection) { const dirname = path.dirname(file.meta.pathname); + + const tempDirectory = state.tempDirectories?.[file.meta.collectionUid]; + const isTransientFile = tempDirectory && file.meta.pathname.startsWith(tempDirectory); + const subDirectories = getSubdirectoriesFromRoot(collection.pathname, dirname); let currentPath = collection.pathname; let currentSubItems = collection.items; @@ -2581,9 +2588,13 @@ export const collectionsSlice = createSlice({ name: directoryName, collapsed: true, type: 'folder', + isTransient: isTransientFile, items: [] }; currentSubItems.push(childItem); + } else if (isTransientFile && !childItem.isTransient) { + // Update existing folder to be transient if the file is transient + childItem.isTransient = true; } currentSubItems = childItem.items; } @@ -2608,6 +2619,7 @@ export const collectionsSlice = createSlice({ currentItem.loading = file.loading; currentItem.size = file.size; currentItem.error = file.error; + currentItem.isTransient = isTransientFile; } else { currentSubItems.push({ uid: file.data.uid, @@ -2624,7 +2636,8 @@ export const collectionsSlice = createSlice({ partial: file.partial, loading: file.loading, size: file.size, - error: file.error + error: file.error, + isTransient: isTransientFile }); } } @@ -2636,6 +2649,10 @@ export const collectionsSlice = createSlice({ const collection = findCollectionByUid(state.collections, dir.meta.collectionUid); if (collection) { + // Check if this directory is in a temp directory (transient request) + const tempDirectory = state.tempDirectories?.[dir.meta.collectionUid]; + const isTransientDir = tempDirectory && dir.meta.pathname.startsWith(tempDirectory); + const subDirectories = getSubdirectoriesFromRoot(collection.pathname, dir.meta.pathname); let currentPath = collection.pathname; let currentSubItems = collection.items; @@ -2651,9 +2668,13 @@ export const collectionsSlice = createSlice({ filename: directoryName, collapsed: true, type: 'folder', + isTransient: isTransientDir, items: [] }; currentSubItems.push(childItem); + } else if (isTransientDir && !childItem.isTransient) { + // Update existing folder to be transient if the directory is transient + childItem.isTransient = true; } currentSubItems = childItem.items; } @@ -3363,6 +3384,26 @@ export const collectionsSlice = createSlice({ } }, + addTransientDirectory: (state, action) => { + state.tempDirectories[action.payload.collectionUid] = action.payload.pathname; + }, + addSaveTransientRequestModal: (state, action) => { + const { item, collection } = action.payload; + // Avoid duplicates - check if this item is already in the array + const exists = state.saveTransientRequestModals.some((modal) => modal.item.uid === item.uid); + if (!exists) { + state.saveTransientRequestModals.push({ item, collection }); + } + }, + removeSaveTransientRequestModal: (state, action) => { + const { itemUid } = action.payload; + state.saveTransientRequestModals = state.saveTransientRequestModals.filter( + (modal) => modal.item.uid !== itemUid + ); + }, + clearAllSaveTransientRequestModals: (state) => { + state.saveTransientRequestModals = []; + }, /* Response Example Actions */ addResponseExample: exampleReducers.addResponseExample, cloneResponseExample: exampleReducers.cloneResponseExample, @@ -3588,8 +3629,12 @@ export const { deleteResponseExampleRequestHeader, moveResponseExampleRequestHeader, setResponseExampleRequestHeaders, - setResponseExampleParams + setResponseExampleParams, /* Response Example Actions - End */ + addTransientDirectory, + addSaveTransientRequestModal, + removeSaveTransientRequestModal, + clearAllSaveTransientRequestModals } = collectionsSlice.actions; export default collectionsSlice.reducer; diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index 2ef277128..1eb231285 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -1704,3 +1704,7 @@ export const generateUniqueRequestName = async (collection, baseName = 'Untitled return `${baseName}${nextNumber}`; }; + +export const isItemTransientRequest = (item) => { + return isItemARequest(item) && item?.isTransient; +}; diff --git a/packages/bruno-app/src/utils/collections/search.js b/packages/bruno-app/src/utils/collections/search.js index 9c2f187e5..2919216e0 100644 --- a/packages/bruno-app/src/utils/collections/search.js +++ b/packages/bruno-app/src/utils/collections/search.js @@ -8,14 +8,14 @@ export const doesRequestMatchSearchText = (request, searchText = '') => { export const doesFolderHaveItemsMatchSearchText = (item, searchText = '') => { let flattenedItems = flattenItems(item.items); - let requestItems = filter(flattenedItems, (item) => isItemARequest(item)); + let requestItems = filter(flattenedItems, (item) => isItemARequest(item) && !item.isTransient); return find(requestItems, (request) => doesRequestMatchSearchText(request, searchText)); }; export const doesCollectionHaveItemsMatchingSearchText = (collection, searchText = '') => { let flattenedItems = flattenItems(collection.items); - let requestItems = filter(flattenedItems, (item) => isItemARequest(item)); + let requestItems = filter(flattenedItems, (item) => isItemARequest(item) && !item.isTransient); return find(requestItems, (request) => doesRequestMatchSearchText(request, searchText)); }; diff --git a/packages/bruno-electron/src/app/collection-watcher.js b/packages/bruno-electron/src/app/collection-watcher.js index ec2aae053..7d03ba77a 100644 --- a/packages/bruno-electron/src/app/collection-watcher.js +++ b/packages/bruno-electron/src/app/collection-watcher.js @@ -666,6 +666,7 @@ class CollectionWatcher { constructor() { this.watchers = {}; this.loadingStates = {}; + this.tempDirectoryMap = {}; } // Initialize loading state tracking for a collection @@ -823,6 +824,13 @@ class CollectionWatcher { this.watchers[watchPath] = null; } + const tempDirectoryPath = this.tempDirectoryMap[watchPath]; + if (tempDirectoryPath && this.watchers[tempDirectoryPath]) { + this.watchers[tempDirectoryPath].close(); + delete this.watchers[tempDirectoryPath]; + delete this.tempDirectoryMap[watchPath]; + } + if (collectionUid) { this.cleanupLoadingState(collectionUid); } @@ -855,6 +863,106 @@ class CollectionWatcher { } } + // Helper function to get collection path from temp directory metadata + getCollectionPathFromTempDirectory(tempDirectoryPath) { + const metadataPath = path.join(tempDirectoryPath, 'metadata.json'); + try { + const metadataContent = fs.readFileSync(metadataPath, 'utf8'); + const metadata = JSON.parse(metadataContent); + return metadata.collectionPath; + } catch (error) { + console.error(`Error reading metadata from temp directory ${tempDirectoryPath}:`, error); + return null; + } + } + + // Add watcher for transient directory + // The tempDirectoryPath is stored in this.tempDirectoryMap[collectionPath] so removeWatcher can clean it up + addTempDirectoryWatcher(win, tempDirectoryPath, collectionUid, collectionPath) { + if (this.watchers[tempDirectoryPath]) { + this.watchers[tempDirectoryPath].close(); + } + + // Store the mapping from collectionPath to tempDirectoryPath for cleanup in removeWatcher + this.tempDirectoryMap[collectionPath] = tempDirectoryPath; + + // Ignore metadata.json file + const ignored = (filepath) => { + const basename = path.basename(filepath); + return basename === 'metadata.json'; + }; + + const watcher = chokidar.watch(tempDirectoryPath, { + ignoreInitial: true, // Don't process existing files + usePolling: isWSLPath(tempDirectoryPath) ? true : false, + ignored, + persistent: true, + ignorePermissionErrors: true, + awaitWriteFinish: { + stabilityThreshold: 80, + pollInterval: 10 + }, + depth: 1, // Only watch the temp directory itself, not subdirectories + disableGlobbing: true + }); + + // Wrapper function to handle temp directory files + const addTempFile = async (pathname) => { + // Skip metadata.json + if (path.basename(pathname) === 'metadata.json') { + return; + } + + // Get the actual collection path from metadata + const actualCollectionPath = this.getCollectionPathFromTempDirectory(tempDirectoryPath); + if (!actualCollectionPath) { + console.error(`Could not determine collection path for temp directory: ${tempDirectoryPath}`); + return; + } + + // Use the collection format from the actual collection + const format = getCollectionFormat(actualCollectionPath); + + // Only process request files + if (hasRequestExtension(pathname, format)) { + // Call the regular add function with the actual collection path + // This will hydrate and send the file to the renderer + await add(win, pathname, collectionUid, actualCollectionPath, false, this); + } + }; + const unlinkTempFile = async (pathname) => { + // Skip metadata.json + if (path.basename(pathname) === 'metadata.json') { + return; + } + + // Get the actual collection path from metadata + const actualCollectionPath = this.getCollectionPathFromTempDirectory(tempDirectoryPath); + if (!actualCollectionPath) { + console.error(`Could not determine collection path for temp directory: ${tempDirectoryPath}`); + return; + } + + // Use the collection format from the actual collection + const format = getCollectionFormat(actualCollectionPath); + + // Only process request files + if (hasRequestExtension(pathname, format)) { + // Call the regular unlink function with the actual collection path + await unlink(win, pathname, collectionUid, actualCollectionPath); + } + }; + + watcher + .on('add', (pathname) => addTempFile(pathname)) + .on('unlink', (pathname) => unlinkTempFile(pathname)) + .on('error', (error) => { + console.error(`An error occurred in the temp directory watcher for: ${tempDirectoryPath}`, error); + }); + + this.watchers[tempDirectoryPath] = watcher; + } + getAllWatcherPaths() { return Object.entries(this.watchers) .filter(([path, watcher]) => !!watcher) diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 3667562a8..eb465d8a1 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -84,6 +84,25 @@ const envHasSecrets = (environment = {}) => { }; const findCollectionPathByItemPath = (filePath) => { + const tmpDir = os.tmpdir(); + const parts = filePath.split(path.sep); + const index = parts.findIndex((part) => part.startsWith('bruno-')); + + if (filePath.startsWith(tmpDir) && index !== -1) { + const transientDirPath = parts.slice(0, index + 1).join(path.sep); + const metadataPath = path.join(transientDirPath, 'metadata.json'); + try { + const metadataContent = fs.readFileSync(metadataPath, 'utf8'); + const metadata = JSON.parse(metadataContent); + if (metadata.collectionPath) { + return metadata.collectionPath; + } + } catch (error) { + return null; + } + return null; + } + const allCollectionPaths = collectionWatcher.getAllWatcherPaths(); // Find the collection path that contains this file @@ -363,6 +382,52 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { } }); + // save transient request (handles move from temp to permanent location) + ipcMain.handle('renderer:save-transient-request', async (event, { sourcePathname, targetDirname, targetFilename, request, format }) => { + try { + // Validate source exists + if (!fs.existsSync(sourcePathname)) { + throw new Error(`Source path: ${sourcePathname} does not exist`); + } + + // Validate target directory exists + if (!fs.existsSync(targetDirname)) { + throw new Error(`Target directory: ${targetDirname} does not exist`); + } + + // Check if the target directory is inside a collection + validatePathIsInsideCollection(targetDirname); + + // Use provided target filename or fall back to source filename + const filename = targetFilename || path.basename(sourcePathname); + const targetPathname = path.join(targetDirname, filename); + + // Check for filename conflicts and throw error if exists + if (fs.existsSync(targetPathname)) { + throw new Error(`A file with the name "${filename}" already exists in the target location`); + } + + // Step 1: Save the updated content to the transient file + syncExampleUidsCache(sourcePathname, request.examples); + const content = await stringifyRequestViaWorker(request, { format }); + await writeFile(sourcePathname, content); + + // Step 2: Read the file content from temp (this is the actual file content) + const fileContent = await fs.promises.readFile(sourcePathname, 'utf8'); + + // Step 3: Create new file at target location with the content + await writeFile(targetPathname, fileContent); + + // Step 4: Delete the old temp file + await removePath(sourcePathname); + + // Return the new pathname (file watcher will handle adding to Redux) + return { newPathname: targetPathname }; + } catch (error) { + return Promise.reject(error); + } + }); + // save multiple requests ipcMain.handle('renderer:save-multiple-requests', async (event, requestsToSave) => { try { @@ -1607,6 +1672,16 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { }); ipcMain.handle('renderer:mount-collection', async (event, { collectionUid, collectionPathname, brunoConfig }) => { + let tempDirectoryPath = null; + try { + tempDirectoryPath = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-')); + const metadata = { + collectionPath: collectionPathname + }; + fs.writeFileSync(path.join(tempDirectoryPath, 'metadata.json'), JSON.stringify(metadata)); + } catch (error) { + throw error; + } const { size, filesCount, @@ -1619,6 +1694,11 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { || (maxFileSize > MAX_SINGLE_FILE_SIZE_IN_COLLECTION_IN_MB); watcher.addWatcher(mainWindow, collectionPathname, collectionUid, brunoConfig, false, shouldLoadCollectionAsync); + + // Add watcher for transient directory + watcher.addTempDirectoryWatcher(mainWindow, tempDirectoryPath, collectionUid, collectionPathname); + + return tempDirectoryPath; }); ipcMain.handle('renderer:show-in-folder', async (event, filePath) => { diff --git a/packages/bruno-electron/src/utils/collection.js b/packages/bruno-electron/src/utils/collection.js index 016786d29..e0db7bca0 100644 --- a/packages/bruno-electron/src/utils/collection.js +++ b/packages/bruno-electron/src/utils/collection.js @@ -4,6 +4,7 @@ const { getRequestUid, getExampleUid } = require('../cache/requestUids'); const { uuid } = require('./common'); const os = require('os'); const { preferencesUtil } = require('../store/preferences'); +const path = require('path'); const mergeHeaders = (collection, request, requestTreePath) => { let headers = new Map(); @@ -402,6 +403,8 @@ const parseFileMeta = (data, format = 'bru') => { const hydrateRequestWithUuid = (request, pathname) => { request.uid = getRequestUid(pathname); + const prefix = path.join(os.tmpdir(), 'bruno-'); + request.isTransient = pathname.startsWith(prefix); const params = get(request, 'request.params', []); const headers = get(request, 'request.headers', []); diff --git a/tests/onboarding/sample-collection.spec.ts b/tests/onboarding/sample-collection.spec.ts index a0259f8c9..d520d1944 100644 --- a/tests/onboarding/sample-collection.spec.ts +++ b/tests/onboarding/sample-collection.spec.ts @@ -92,9 +92,20 @@ test.describe('Onboarding', () => { await expect(removeOption).toBeVisible(); await removeOption.click(); - // Confirm removal in the modal - await page.locator('[data-testid="close-collection-modal-title"]', { hasText: 'Remove Collection' }).waitFor({ state: 'visible' }); - await page.locator('.bruno-modal-footer .submit').click(); + // Wait for modal to appear - could be either regular remove or drafts confirmation + const removeModal = page.locator('.bruno-modal').filter({ hasText: 'Remove Collection' }); + await removeModal.waitFor({ state: 'visible', timeout: 5000 }); + + // Check if it's the drafts confirmation modal (has "Discard All and Remove" button) + const hasDiscardButton = await page.getByRole('button', { name: 'Discard All and Remove' }).isVisible().catch(() => false); + + if (hasDiscardButton) { + // Drafts modal - click "Discard All and Remove" + await page.getByRole('button', { name: 'Discard All and Remove' }).click(); + } else { + // Regular modal - click the submit button + await page.locator('.bruno-modal-footer .submit').click(); + } // Verify collection is closed (no longer visible in sidebar) await expect(sampleCollection).not.toBeVisible(); diff --git a/tests/transient-requests/transient-requests.spec.ts b/tests/transient-requests/transient-requests.spec.ts new file mode 100644 index 000000000..cb18fd644 --- /dev/null +++ b/tests/transient-requests/transient-requests.spec.ts @@ -0,0 +1,225 @@ +import { test, expect } from '../../playwright'; +import { createTransientRequest, fillRequestUrl, closeAllCollections, createCollection, sendRequest, clickResponseAction, selectRequestPaneTab } from '../utils/page'; +import { buildCommonLocators, buildWebsocketCommonLocators } from '../utils/page/locators'; + +test.describe.serial('Transient Requests', () => { + let locators: ReturnType; + + test.beforeAll(async ({ page, createTmpDir }) => { + locators = buildCommonLocators(page); + + // Create a temporary collection + const collectionPath = await createTmpDir('transient-collection'); + await createCollection(page, 'transient-requests-test', collectionPath); + + // Verify the collection is loaded + await test.step('Verify test collection is loaded', async () => { + await expect(locators.sidebar.collection('transient-requests-test')).toBeVisible(); + await locators.sidebar.collection('transient-requests-test').click(); + }); + }); + + test.afterAll(async ({ page }) => { + // Clean up all collections + await closeAllCollections(page); + }); + + test('Create transient HTTP request - should not appear in sidebar', async ({ page }) => { + await test.step('Create transient HTTP request', async () => { + await createTransientRequest(page, { + requestType: 'HTTP' + }); + await fillRequestUrl(page, 'http://localhost:8081/ping'); + }); + + await test.step('Verify HTTP request tab is open', async () => { + const activeTab = page.locator('.request-tab.active'); + await expect(activeTab).toBeVisible(); + await expect(activeTab).toContainText('Untitled'); + }); + + await test.step('Verify request is NOT in sidebar', async () => { + // Click on the collection to ensure it's expanded + await locators.sidebar.collection('transient-requests-test').click(); + await page.waitForTimeout(300); + + // Check that there are no requests in the collection + // Transient requests should not appear in the sidebar + const collectionItems = page.locator('.collection-item-name'); + await expect(collectionItems).toHaveCount(0); + }); + }); + + test('Create transient GraphQL request - should not appear in sidebar', async ({ page }) => { + await test.step('Create transient GraphQL request', async () => { + await createTransientRequest(page, { + requestType: 'GraphQL' + }); + await fillRequestUrl(page, 'https://api.example.com/graphql'); + }); + + await test.step('Verify GraphQL request tab is open', async () => { + const activeTab = page.locator('.request-tab.active'); + await expect(activeTab).toBeVisible(); + await expect(activeTab).toContainText('Untitled'); + }); + + await test.step('Verify request is NOT in sidebar', async () => { + // Check that there are still no requests in the collection + const collectionItems = page.locator('.collection-item-name'); + await expect(collectionItems).toHaveCount(0); + }); + }); + + test('Create transient gRPC request - should not appear in sidebar', async ({ page }) => { + await test.step('Create transient gRPC request', async () => { + await createTransientRequest(page, { + requestType: 'gRPC' + }); + await fillRequestUrl(page, 'grpc://localhost:50051'); + }); + + await test.step('Verify gRPC request tab is open', async () => { + const activeTab = page.locator('.request-tab.active'); + await expect(activeTab).toBeVisible(); + await expect(activeTab).toContainText('Untitled'); + }); + + await test.step('Verify request is NOT in sidebar', async () => { + // Check that there are still no requests in the collection + const collectionItems = page.locator('.collection-item-name'); + await expect(collectionItems).toHaveCount(0); + }); + }); + + test('Create transient WebSocket request - should not appear in sidebar', async ({ page }) => { + await test.step('Create transient WebSocket request', async () => { + await createTransientRequest(page, { + requestType: 'WebSocket' + }); + await fillRequestUrl(page, 'ws://localhost:8082'); + }); + + await test.step('Verify WebSocket request tab is open', async () => { + const activeTab = page.locator('.request-tab.active'); + await expect(activeTab).toBeVisible(); + await expect(activeTab).toContainText('Untitled'); + }); + + await test.step('Verify request is NOT in sidebar', async () => { + // Check that there are still no requests in the collection + const collectionItems = page.locator('.collection-item-name'); + await expect(collectionItems).toHaveCount(0); + }); + }); + + test('Save transient HTTP request - should appear in sidebar after save', async ({ page }) => { + await test.step('Create transient HTTP request', async () => { + await createTransientRequest(page, { + requestType: 'HTTP' + }); + await fillRequestUrl(page, 'http://localhost:8081/echo'); + }); + + await test.step('Trigger save action using keyboard shortcut', async () => { + // Try to save using Cmd+S (Mac) or Ctrl+S (other platforms) + await page.keyboard.press('Meta+s'); + await page.waitForTimeout(500); + }); + + await test.step('Fill in save dialog', async () => { + // Wait for save modal to appear + const saveModal = page.locator('.bruno-modal-card').filter({ hasText: 'Save Request' }); + await expect(saveModal).toBeVisible({ timeout: 5000 }); + + // Fill in request name + const requestNameInput = saveModal.locator('#request-name'); + await requestNameInput.clear(); + await requestNameInput.fill('Saved HTTP Request'); + + // Click Save button + await saveModal.getByRole('button', { name: 'Save' }).click(); + + // Wait for success toast + await page.waitForTimeout(1000); + }); + + await test.step('Verify saved request appears in sidebar', async () => { + // Check collection is expanded + await locators.sidebar.collection('transient-requests-test').click(); + + // Look for the saved request in sidebar + const savedRequest = locators.sidebar.request('Saved HTTP Request'); + await expect(savedRequest).toBeVisible(); + }); + + await test.step('Cleanup: Delete the saved request', async () => { + await locators.sidebar.request('Saved HTTP Request').hover(); + await locators.actions.collectionItemActions('Saved HTTP Request').click(); + await locators.dropdown.item('Delete').click(); + await locators.modal.button('Delete').click(); + await expect(locators.sidebar.request('Saved HTTP Request')).not.toBeVisible(); + }); + }); + + test('Save transient GraphQL request - should appear in sidebar after save', async ({ page }) => { + await test.step('Create transient GraphQL request', async () => { + await createTransientRequest(page, { + requestType: 'GraphQL' + }); + await fillRequestUrl(page, 'https://api.example.com/graphql'); + }); + + await test.step('Trigger save action using keyboard shortcut', async () => { + await page.keyboard.press('Meta+s'); + await page.waitForTimeout(500); + }); + + await test.step('Fill in save dialog', async () => { + const saveModal = page.locator('.bruno-modal-card').filter({ hasText: 'Save Request' }); + await expect(saveModal).toBeVisible({ timeout: 5000 }); + + const requestNameInput = saveModal.locator('#request-name'); + await requestNameInput.clear(); + await requestNameInput.fill('Saved GraphQL Request'); + + await saveModal.getByRole('button', { name: 'Save' }).click(); + await page.waitForTimeout(1000); + }); + + await test.step('Verify saved request appears in sidebar', async () => { + await locators.sidebar.collection('transient-requests-test').click(); + const savedRequest = locators.sidebar.request('Saved GraphQL Request'); + await expect(savedRequest).toBeVisible(); + }); + + await test.step('Cleanup: Delete the saved request', async () => { + await locators.sidebar.request('Saved GraphQL Request').hover(); + await locators.actions.collectionItemActions('Saved GraphQL Request').click(); + await locators.dropdown.item('Delete').click(); + await locators.modal.button('Delete').click(); + await expect(locators.sidebar.request('Saved GraphQL Request')).not.toBeVisible(); + }); + }); + + test('Send transient HTTP request - verify response', async ({ page }) => { + await test.step('Create transient HTTP request', async () => { + await createTransientRequest(page, { + requestType: 'HTTP' + }); + await fillRequestUrl(page, 'http://localhost:8081/ping'); + }); + + await test.step('Send request and verify response', async () => { + // Send request using the helper function + await sendRequest(page, 200); + + // Copy response to clipboard and verify + await clickResponseAction(page, 'response-copy-btn'); + await expect(page.getByText('Response copied to clipboard')).toBeVisible(); + + const clipboardText = await page.evaluate(() => navigator.clipboard.readText()); + expect(clipboardText).toBe('pong'); + }); + }); +}); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index d76297322..71d73160d 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -17,11 +17,24 @@ const closeAllCollections = async (page) => { await firstCollection.hover(); await firstCollection.locator('.collection-actions .icon').click(); await page.locator('.dropdown-item').getByText('Remove').click(); - // Wait for the remove collection modal to be visible - await page.getByTestId('close-collection-modal-title').filter({ hasText: 'Remove Collection' }).waitFor({ state: 'visible' }); - await page.locator('.bruno-modal-footer .submit').click(); - // Wait for the remove collection modal to be hidden - await page.getByTestId('close-collection-modal-title').filter({ hasText: 'Remove Collection' }).waitFor({ state: 'hidden' }); + + // Wait for modal to appear - could be either regular remove or drafts confirmation + const removeModal = page.locator('.bruno-modal').filter({ hasText: 'Remove Collection' }); + await removeModal.waitFor({ state: 'visible', timeout: 5000 }); + + // Check if it's the drafts confirmation modal (has "Discard All and Remove" button) + const hasDiscardButton = await page.getByRole('button', { name: 'Discard All and Remove' }).isVisible().catch(() => false); + + if (hasDiscardButton) { + // Drafts modal - click "Discard All and Remove" + await page.getByRole('button', { name: 'Discard All and Remove' }).click(); + } else { + // Regular modal - click the submit button + await page.locator('.bruno-modal-footer .submit').click(); + } + + // Wait for modal to close + await removeModal.waitFor({ state: 'hidden', timeout: 5000 }); } // Wait until no collections are left open (check sidebar only) @@ -143,6 +156,77 @@ const createUntitledRequest = async ( }); }; +type CreateTransientRequestOptions = { + requestType?: 'HTTP' | 'GraphQL' | 'gRPC' | 'WebSocket'; +}; + +/** + * Create a transient request using the + icon button in the tabs area + * Based on the CreateTransientRequest component behavior + * @param page - The page object + * @param options - Optional settings (requestType) + * @returns void + */ +const createTransientRequest = async ( + page: Page, + options: CreateTransientRequestOptions = {} +) => { + const { requestType = 'HTTP' } = options; + + await test.step(`Create transient ${requestType} request`, async () => { + // Find the + icon button (ActionIcon with aria-label="New Transient Request") + const createButton = page.getByRole('button', { name: 'New Transient Request' }); + await createButton.waitFor({ state: 'visible', timeout: 5000 }); + + // Click the + icon to open the dropdown + await createButton.click({ + button: 'right' + }); + + // Wait for dropdown to be visible + await page.locator('.dropdown-item').first().waitFor({ state: 'visible' }); + + // Select the request type from dropdown + // The dropdown items have both icon and label, we match by the label text + await page.locator('.dropdown-item').filter({ hasText: requestType }).click(); + + // Wait for the request tab to be active (transient requests show as "Untitled X") + await page.locator('.request-tab.active').waitFor({ state: 'visible' }); + await expect(page.locator('.request-tab.active')).toContainText('Untitled'); + await page.waitForTimeout(300); + }); +}; + +/** + * Fill the URL field in the currently active request + * Works with HTTP, GraphQL, gRPC, and WebSocket requests + * @param page - The page object + * @param url - The URL to fill + * @returns void + */ +const fillRequestUrl = async (page: Page, url: string) => { + await test.step(`Fill request URL: ${url}`, async () => { + // HTTP/GraphQL requests use #request-url + // gRPC/WebSocket don't have a specific ID, so we need to find the CodeMirror in the active request pane + const httpGraphqlUrl = page.locator('#request-url .CodeMirror'); + const grpcWsUrl = page.locator('.input-container .CodeMirror').first(); + + // Try HTTP/GraphQL selector first + const isHttpOrGraphql = await httpGraphqlUrl.isVisible().catch(() => false); + + if (isHttpOrGraphql) { + await httpGraphqlUrl.click(); + await page.locator('#request-url textarea').fill(url); + } else { + // Fall back to generic selector for gRPC/WebSocket + await grpcWsUrl.click(); + await page.locator('.input-container textarea').first().fill(url); + } + + await page.waitForTimeout(200); + }); +}; + /** * Create a request in a collection or folder * @param page - The page object @@ -293,10 +377,23 @@ const removeCollection = async (page: Page, collectionName: string) => { await collectionRow.locator('.collection-actions .icon').click(); await locators.dropdown.item('Remove').click(); - // Wait for and confirm modal - await page.getByTestId('close-collection-modal-title').filter({ hasText: 'Remove Collection' }).waitFor({ state: 'visible' }); - await locators.modal.button('Remove').click(); - await page.getByTestId('close-collection-modal-title').filter({ hasText: 'Remove Collection' }).waitFor({ state: 'hidden' }); + // Wait for modal to appear - could be either regular remove or drafts confirmation + const removeModal = page.locator('.bruno-modal').filter({ hasText: 'Remove Collection' }); + await removeModal.waitFor({ state: 'visible', timeout: 5000 }); + + // Check if it's the drafts confirmation modal (has "Discard All and Remove" button) + const hasDiscardButton = await page.getByRole('button', { name: 'Discard All and Remove' }).isVisible().catch(() => false); + + if (hasDiscardButton) { + // Drafts modal - click "Discard All and Remove" + await page.getByRole('button', { name: 'Discard All and Remove' }).click(); + } else { + // Regular modal - click Remove button + await locators.modal.button('Remove').click(); + } + + // Wait for modal to close + await removeModal.waitFor({ state: 'hidden', timeout: 5000 }); // Verify collection is removed await expect( @@ -864,6 +961,8 @@ export { createCollection, createRequest, createUntitledRequest, + createTransientRequest, + fillRequestUrl, deleteRequest, importCollection, removeCollection, @@ -892,4 +991,4 @@ export { saveRequest }; -export type { SandboxMode, EnvironmentType, EnvironmentVariable, ImportCollectionOptions, CreateRequestOptions, CreateUntitledRequestOptions, AssertionInput }; +export type { SandboxMode, EnvironmentType, EnvironmentVariable, ImportCollectionOptions, CreateRequestOptions, CreateUntitledRequestOptions, CreateTransientRequestOptions, AssertionInput };