From 2a6846467613899e9428c99f70cf07337c88f184 Mon Sep 17 00:00:00 2001 From: Bruno Moreira Date: Thu, 7 May 2026 10:22:52 -0300 Subject: [PATCH 1/3] feat: add "Duplicate as Tab" action to request tab menu Mirrors Postman's "Duplicate Tab": creates an unsaved transient copy of the current request in a new tab, pre-seeded with the full request state (including unsaved draft changes). Avoids the friction of the existing Clone modal, which requires a name and immediately persists to disk. Reuses the existing transient-request pipeline (temp directory + SaveTransientRequest flow); no new IPC, no schema changes. Related: #184 --- .../RequestTabs/RequestTab/index.js | 18 +++++- .../ReduxStore/slices/collections/actions.js | 62 +++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js index 21aeaf46ca..2e22860f27 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js @@ -1,7 +1,7 @@ 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'; @@ -706,6 +706,14 @@ function RequestTabMenu({ menuDropdownRef, tabLabelRef, collectionRequestTabs, t await handleCloseMultipleTabs(collectionRequestTabs); } + const isRequestTab = currentTabItem && (currentTabItem.type === 'http-request' || currentTabItem.type === 'graphql-request' || currentTabItem.type === 'grpc-request' || currentTabItem.type === 'ws-request'); + + const handleDuplicateAsTab = () => { + 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 +725,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 +770,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(); From 9cfb4539c04ac7b6a229cff40b162d7c9e9809c4 Mon Sep 17 00:00:00 2001 From: Bruno Moreira Date: Thu, 7 May 2026 10:29:43 -0300 Subject: [PATCH 2/3] fix: guard handleDuplicateAsTab against missing currentTabItem The menu item is already disabled when currentTabItem/isRequestTab is falsy, but mirrors the defensive pattern used elsewhere in this file (e.g. the currentTabUid guard) and prevents a theoretical NPE if state desyncs between render and click. Per CodeRabbit review on #7945. --- .../bruno-app/src/components/RequestTabs/RequestTab/index.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js index 2e22860f27..2778bd536d 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js @@ -709,6 +709,9 @@ function RequestTabMenu({ menuDropdownRef, tabLabelRef, collectionRequestTabs, t const isRequestTab = currentTabItem && (currentTabItem.type === 'http-request' || currentTabItem.type === 'graphql-request' || currentTabItem.type === 'grpc-request' || currentTabItem.type === 'ws-request'); 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'); }); From 90b5d61f6569d3a0bb359e5968732678b1e98da5 Mon Sep 17 00:00:00 2001 From: Bruno Moreira Date: Thu, 7 May 2026 10:32:25 -0300 Subject: [PATCH 3/3] refactor: use isItemARequest helper for request-type check Replaces an inline list of request types with the canonical isItemARequest helper. Avoids drift if new request types are added and matches how the rest of the codebase classifies items. Per Copilot review on #7945. --- .../bruno-app/src/components/RequestTabs/RequestTab/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js index 2778bd536d..08da94a74d 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js @@ -8,7 +8,7 @@ import { clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global- 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,7 +706,7 @@ function RequestTabMenu({ menuDropdownRef, tabLabelRef, collectionRequestTabs, t await handleCloseMultipleTabs(collectionRequestTabs); } - const isRequestTab = currentTabItem && (currentTabItem.type === 'http-request' || currentTabItem.type === 'graphql-request' || currentTabItem.type === 'grpc-request' || currentTabItem.type === 'ws-request'); + const isRequestTab = currentTabItem && isItemARequest(currentTabItem); const handleDuplicateAsTab = () => { if (!currentTabItem?.uid || !collection?.uid) {