diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js index 21aeaf46ca..08da94a74d 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js @@ -1,14 +1,14 @@ import React, { useCallback, useState, useRef, Fragment, useMemo, useEffect } from 'react'; import get from 'lodash/get'; import { makeTabPermanent, syncTabUid } from 'providers/ReduxStore/slices/tabs'; -import { saveRequest, saveCollectionRoot, saveFolderRoot, saveEnvironment, saveCollectionSettings, closeTabs } from 'providers/ReduxStore/slices/collections/actions'; +import { saveRequest, saveCollectionRoot, saveFolderRoot, saveEnvironment, saveCollectionSettings, closeTabs, cloneItemAsTransient } from 'providers/ReduxStore/slices/collections/actions'; import useKeybinding from 'hooks/useKeybinding'; import { deleteRequestDraft, deleteCollectionDraft, deleteFolderDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections'; import { clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-environments'; import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; import { useTheme } from 'providers/Theme'; import { useDispatch, useSelector } from 'react-redux'; -import { findItemInCollection, findItemInCollectionByPathname, hasRequestChanges, areItemsLoading } from 'utils/collections'; +import { findItemInCollection, findItemInCollectionByPathname, hasRequestChanges, areItemsLoading, isItemARequest } from 'utils/collections'; import ConfirmRequestClose from './ConfirmRequestClose'; import ConfirmCollectionClose from './ConfirmCollectionClose'; import ConfirmFolderClose from './ConfirmFolderClose'; @@ -706,6 +706,17 @@ function RequestTabMenu({ menuDropdownRef, tabLabelRef, collectionRequestTabs, t await handleCloseMultipleTabs(collectionRequestTabs); } + const isRequestTab = currentTabItem && isItemARequest(currentTabItem); + + const handleDuplicateAsTab = () => { + if (!currentTabItem?.uid || !collection?.uid) { + return; + } + dispatch(cloneItemAsTransient(currentTabItem.uid, collection.uid)).catch((err) => { + toast.error(err?.message || 'An error occurred while duplicating the request'); + }); + }; + const menuItems = useMemo(() => [ { id: 'new-request', @@ -717,6 +728,12 @@ function RequestTabMenu({ menuDropdownRef, tabLabelRef, collectionRequestTabs, t label: 'Clone Request', onClick: () => setShowCloneRequestModal(true) }, + { + id: 'duplicate-as-tab', + label: 'Duplicate as Tab', + onClick: handleDuplicateAsTab, + disabled: !isRequestTab + }, { id: 'revert-changes', label: 'Revert Changes', @@ -756,7 +773,7 @@ function RequestTabMenu({ menuDropdownRef, tabLabelRef, collectionRequestTabs, t label: 'Close All', onClick: handleCloseAllTabs } - ], [currentTabUid, currentTabItem, hasOtherTabs, hasLeftTabs, hasRightTabs, collection, collectionRequestTabs, tabIndex, dispatch]); + ], [currentTabUid, currentTabItem, isRequestTab, hasOtherTabs, hasLeftTabs, hasRightTabs, collection, collectionRequestTabs, tabIndex, dispatch]); const menuDropdown = ( (disp }); }; +// Duplicates a request into the collection's temp directory as a transient (unsaved) request, +// mirroring Postman's "Duplicate Tab" behavior. Folders are not supported — they require a +// committed name on disk to be meaningful. +export const cloneItemAsTransient = (itemUid, collectionUid) => (dispatch, getState) => { + const state = getState(); + const collection = findCollectionByUid(state.collections.collections, collectionUid); + + return new Promise((resolve, reject) => { + if (!collection) { + return reject(new Error('Collection not found')); + } + + const tempDirectory = state.collections.tempDirectories?.[collectionUid]; + if (!tempDirectory) { + return reject(new Error('Temporary directory is not initialized for this collection')); + } + + const item = findItemInCollection(cloneDeep(collection), itemUid); + if (!item) { + return reject(new Error('Unable to locate item')); + } + if (isItemAFolder(item)) { + return reject(new Error('Folders cannot be duplicated as a tab')); + } + + const transientRequests = filter( + flattenItems(collection.items), + (i) => isItemARequest(i) && i.pathname && i.pathname.startsWith(tempDirectory) + ); + const { newName, newFilename } = generateUniqueName(`${item.name} copy`, transientRequests, false); + const filename = resolveRequestFilename(newFilename, collection.format); + + // The `isTransient` flag is inferred at load time from the file's path + // (it's never persisted to disk), so we don't set it here — the schema is strict. + const itemToSave = refreshUidsInItem(transformRequestToSaveToFilesystem(item)); + set(itemToSave, 'name', trim(newName)); + set(itemToSave, 'filename', trim(filename)); + const items = filter(collection.items, (i) => isItemAFolder(i) || isItemARequest(i)); + itemToSave.seq = items.length + 1; + + const fullPathname = path.join(tempDirectory, filename); + const { ipcRenderer } = window; + + itemSchema + .validate(itemToSave) + .then(() => ipcRenderer.invoke('renderer:new-request', fullPathname, itemToSave)) + .then(() => { + dispatch( + insertTaskIntoQueue({ + uid: uuid(), + type: 'OPEN_REQUEST', + collectionUid, + itemPathname: fullPathname, + preview: false + }) + ); + resolve(); + }) + .catch(reject); + }); +}; + export const pasteItem = (targetCollectionUid, targetItemUid = null) => (dispatch, getState) => { const state = getState();