From cc7f1ea58f75affe9a08c65feeaf83afba38ae5b Mon Sep 17 00:00:00 2001 From: Pooja Date: Wed, 29 Oct 2025 17:24:09 +0530 Subject: [PATCH] feat: add copy and paste functionality for requests (#5907) --- .../Collection/CollectionItem/index.js | 37 +++++++- .../Sidebar/Collections/Collection/index.js | 24 +++++- .../src/components/Sidebar/NewFolder/index.js | 2 +- .../src/providers/ReduxStore/slices/app.js | 19 ++++- .../ReduxStore/slices/collections/actions.js | 84 +++++++++++++++++++ .../bruno-app/src/utils/bruno-clipboard.js | 27 ++++++ .../cross-collection-drag-drop-folder.spec.ts | 12 +-- .../moving-requests/tag-persistence.spec.ts | 4 +- .../collection/moving-tabs/move-tabs.spec.ts | 8 +- .../request/copy-request/copy-request.spec.ts | 66 +++++++++++++++ tests/utils/page/actions.ts | 14 +++- 11 files changed, 278 insertions(+), 19 deletions(-) create mode 100644 packages/bruno-app/src/utils/bruno-clipboard.js create mode 100644 tests/request/copy-request/copy-request.spec.ts 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 1b09cb7e3..b6602aa7f 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 @@ -7,8 +7,9 @@ import { useDrag, useDrop } from 'react-dnd'; import { IconChevronRight, IconDots } from '@tabler/icons'; import { useSelector, useDispatch } from 'react-redux'; import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs'; -import { handleCollectionItemDrop, sendRequest, showInFolder } from 'providers/ReduxStore/slices/collections/actions'; +import { handleCollectionItemDrop, sendRequest, showInFolder, pasteItem } from 'providers/ReduxStore/slices/collections/actions'; import { toggleCollectionItem } from 'providers/ReduxStore/slices/collections'; +import { copyRequest } from 'providers/ReduxStore/slices/app'; import Dropdown from 'components/Dropdown'; import NewRequest from 'components/Sidebar/NewRequest'; import NewFolder from 'components/Sidebar/NewFolder'; @@ -40,6 +41,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) const isTabForItemPresent = useSelector(_isTabForItemPresentSelector, isEqual); const isSidebarDragging = useSelector((state) => state.app.isDragging); + const { hasCopiedItems } = useSelector((state) => state.app.clipboard); const dispatch = useDispatch(); // We use a single ref for drag and drop. @@ -306,6 +308,23 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) } }; + const handleCopyRequest = () => { + dropdownTippyRef.current.hide(); + dispatch(copyRequest(item)); + toast.success('Request copied to clipboard'); + }; + + const handlePasteRequest = () => { + dropdownTippyRef.current.hide(); + dispatch(pasteItem(collectionUid, item.uid)) + .then(() => { + toast.success('Request pasted successfully'); + }) + .catch((err) => { + toast.error(err ? err.message : 'An error occurred while pasting the request'); + }); + }; + return ( {renameItemModalOpen && ( @@ -431,6 +450,22 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) > Clone + {!isFolder && ( +
+ Copy +
+ )} + {isFolder && hasCopiedItems && ( +
+ Paste +
+ )} {!isFolder && (
{ const collectionRef = useRef(null); const isCollectionFocused = useSelector(isTabForItemActive({ itemUid: collection.uid })); - + const { hasCopiedItems } = useSelector((state) => state.app.clipboard); const menuDropdownTippyRef = useRef(); const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref); const MenuIcon = forwardRef((_props, ref) => { @@ -146,6 +147,17 @@ const Collection = ({ collection, searchText }) => { ); }; + const handlePasteRequest = () => { + menuDropdownTippyRef.current.hide(); + dispatch(pasteItem(collection.uid, null)) + .then(() => { + toast.success('Request pasted successfully'); + }) + .catch((err) => { + toast.error(err ? err.message : 'An error occurred while pasting the request'); + }); + }; + const isCollectionItem = (itemType) => { return itemType === 'collection-item'; }; @@ -286,6 +298,14 @@ const Collection = ({ collection, searchText }) => { > Clone
+ {hasCopiedItems && ( +
+ Paste +
+ )}
{ diff --git a/packages/bruno-app/src/components/Sidebar/NewFolder/index.js b/packages/bruno-app/src/components/Sidebar/NewFolder/index.js index 83c243653..d7a7e6998 100644 --- a/packages/bruno-app/src/components/Sidebar/NewFolder/index.js +++ b/packages/bruno-app/src/components/Sidebar/NewFolder/index.js @@ -90,7 +90,7 @@ const NewFolder = ({ collectionUid, item, onClose }) => { Folder Name { state.sidebarCollapsed = !state.sidebarCollapsed; + }, + setClipboard: (state, action) => { + // Update clipboard UI state + state.clipboard.hasCopiedItems = action.payload.hasCopiedItems; } } }); @@ -113,7 +121,8 @@ export const { removeAllTasksFromQueue, updateSystemProxyEnvVariables, updateGenerateCode, - toggleSidebarCollapse + toggleSidebarCollapse, + setClipboard } = appSlice.actions; export const savePreferences = (preferences) => (dispatch, getState) => { @@ -179,4 +188,10 @@ export const completeQuitFlow = () => (dispatch, getState) => { return ipcRenderer.invoke('main:complete-quit-flow'); }; +export const copyRequest = (item) => (dispatch, getState) => { + brunoClipboard.write(item); + dispatch(setClipboard({ hasCopiedItems: true })); + return Promise.resolve(); +}; + export default appSlice.reducer; 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 e8e29f7fc..cee1fb201 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -22,6 +22,7 @@ import { import { uuid, waitForNextTick } from 'utils/common'; import { cancelNetworkRequest, connectWS, sendGrpcRequest, sendNetworkRequest, sendWsRequest } from 'utils/network/index'; import { callIpc } from 'utils/common/ipc'; +import brunoClipboard from 'utils/bruno-clipboard'; import { collectionAddEnvFileEvent as _collectionAddEnvFileEvent, @@ -723,6 +724,89 @@ export const cloneItem = (newName, newFilename, itemUid, collectionUid) => (disp }); }; +export const pasteItem = (targetCollectionUid, targetItemUid = null) => (dispatch, getState) => { + const state = getState(); + + const clipboardResult = brunoClipboard.read(); + + if (!clipboardResult.hasData) { + return Promise.reject(new Error('No item in clipboard')); + } + + const targetCollection = findCollectionByUid(state.collections.collections, targetCollectionUid); + + if (!targetCollection) { + return Promise.reject(new Error('Target collection not found')); + } + + return new Promise(async (resolve, reject) => { + try { + for (const clipboardItem of clipboardResult.items) { + const copiedItem = cloneDeep(clipboardItem); + + // Only allow pasting requests (not folders) + if (isItemAFolder(copiedItem)) { + return reject(new Error('Pasting folders is not supported')); + } + + const targetCollectionCopy = cloneDeep(targetCollection); + let targetItem = null; + let targetParentPathname = targetCollection.pathname; + + // If targetItemUid is provided, we're pasting into a folder + if (targetItemUid) { + targetItem = findItemInCollection(targetCollectionCopy, targetItemUid); + if (!targetItem) { + return reject(new Error('Target folder not found')); + } + if (!isItemAFolder(targetItem)) { + return reject(new Error('Target must be a folder or collection')); + } + targetParentPathname = targetItem.pathname; + } + + // Generate a unique filename for the pasted item + let newName = copiedItem.name; + let newFilename = sanitizeName(copiedItem.name); + let counter = 1; + + const existingItems = targetItem ? targetItem.items : targetCollection.items; + + // Check for duplicate names and append counter if needed + while (find(existingItems, (i) => i.type !== 'folder' && trim(i.filename) === trim(resolveRequestFilename(newFilename)))) { + newName = `${copiedItem.name} (${counter})`; + newFilename = `${sanitizeName(copiedItem.name)} (${counter})`; + counter++; + } + + const filename = resolveRequestFilename(newFilename); + const itemToSave = refreshUidsInItem(transformRequestToSaveToFilesystem(copiedItem)); + set(itemToSave, 'name', trim(newName)); + set(itemToSave, 'filename', trim(filename)); + + const fullPathname = path.join(targetParentPathname, filename); + const { ipcRenderer } = window; + const requestItems = filter(existingItems, (i) => i.type !== 'folder'); + itemToSave.seq = requestItems ? requestItems.length + 1 : 1; + + await itemSchema.validate(itemToSave); + await ipcRenderer.invoke('renderer:new-request', fullPathname, itemToSave); + + dispatch(insertTaskIntoQueue({ + uid: uuid(), + type: 'OPEN_REQUEST', + collectionUid: targetCollectionUid, + itemPathname: fullPathname + })); + } + + resolve(); + } catch (error) { + reject(error); + } + }); +}; + export const deleteItem = (itemUid, collectionUid) => (dispatch, getState) => { const state = getState(); const collection = findCollectionByUid(state.collections.collections, collectionUid); diff --git a/packages/bruno-app/src/utils/bruno-clipboard.js b/packages/bruno-app/src/utils/bruno-clipboard.js new file mode 100644 index 000000000..973d8842a --- /dev/null +++ b/packages/bruno-app/src/utils/bruno-clipboard.js @@ -0,0 +1,27 @@ +class BrunoClipboard { + constructor() { + this.items = []; + } + + /** + * @param {Object} item - Item to copy + */ + write(item) { + // Limit to one item for now + this.items = [item]; + } + + /** + * @returns {Object} Result with items array + */ + read() { + return { + items: this.items, + hasData: this.items.length > 0 + }; + } +} + +const brunoClipboard = new BrunoClipboard(); + +export default brunoClipboard; diff --git a/tests/collection/moving-requests/cross-collection-drag-drop-folder.spec.ts b/tests/collection/moving-requests/cross-collection-drag-drop-folder.spec.ts index 9295ee474..3e52a4cfa 100644 --- a/tests/collection/moving-requests/cross-collection-drag-drop-folder.spec.ts +++ b/tests/collection/moving-requests/cross-collection-drag-drop-folder.spec.ts @@ -29,8 +29,8 @@ test.describe('Cross-Collection Drag and Drop for folder', () => { await page.locator('.dropdown-item').filter({ hasText: 'New Folder' }).click(); // Fill folder name in the modal - await expect(page.locator('#collection-name')).toBeVisible(); - await page.locator('#collection-name').fill('test-folder'); + await expect(page.locator('#folder-name')).toBeVisible(); + await page.locator('#folder-name').fill('test-folder'); await page.getByRole('button', { name: 'Create' }).click(); // Wait for the folder to be created and appear in the sidebar @@ -151,8 +151,8 @@ test.describe('Cross-Collection Drag and Drop for folder', () => { .locator('.collection-actions .icon') .click(); await page.locator('.dropdown-item').filter({ hasText: 'New Folder' }).click(); - await expect(page.locator('#collection-name')).toBeVisible(); - await page.locator('#collection-name').fill('folder-1'); + await expect(page.locator('#folder-name')).toBeVisible(); + await page.locator('#folder-name').fill('folder-1'); await page.getByRole('button', { name: 'Create' }).click(); await expect(page.locator('.collection-item-name').filter({ hasText: 'folder-1' })).toBeVisible(); @@ -195,8 +195,8 @@ test.describe('Cross-Collection Drag and Drop for folder', () => { .locator('.collection-actions .icon') .click(); await page.locator('.dropdown-item').filter({ hasText: 'New Folder' }).click(); - await expect(page.locator('#collection-name')).toBeVisible(); - await page.locator('#collection-name').fill('folder-1'); + await expect(page.locator('#folder-name')).toBeVisible(); + await page.locator('#folder-name').fill('folder-1'); await page.getByRole('button', { name: 'Create' }).click(); // Go back to source collection to drag the folder diff --git a/tests/collection/moving-requests/tag-persistence.spec.ts b/tests/collection/moving-requests/tag-persistence.spec.ts index 56cc17c8e..49f1e7b18 100644 --- a/tests/collection/moving-requests/tag-persistence.spec.ts +++ b/tests/collection/moving-requests/tag-persistence.spec.ts @@ -88,7 +88,7 @@ test.describe('Tag persistence', () => { }); await page.waitForTimeout(200); await page.getByText('New Folder').click(); - await page.locator('#collection-name').fill('f1'); + await page.locator('#folder-name').fill('f1'); await page.getByRole('button', { name: 'Create' }).click(); await page.waitForTimeout(200); @@ -126,7 +126,7 @@ test.describe('Tag persistence', () => { button: 'right' }); await page.locator('.dropdown-item').getByText('New Folder').click(); - await page.locator('#collection-name').fill('f2'); + await page.locator('#folder-name').fill('f2'); await page.getByRole('button', { name: 'Create' }).click(); // open f2 folder diff --git a/tests/collection/moving-tabs/move-tabs.spec.ts b/tests/collection/moving-tabs/move-tabs.spec.ts index 68e221daf..52a3eccae 100644 --- a/tests/collection/moving-tabs/move-tabs.spec.ts +++ b/tests/collection/moving-tabs/move-tabs.spec.ts @@ -28,8 +28,8 @@ test.describe('Move tabs', () => { await page.locator('.dropdown-item').filter({ hasText: 'New Folder' }).click(); // Fill folder name in the modal - await expect(page.locator('#collection-name')).toBeVisible(); - await page.locator('#collection-name').fill('test-folder'); + await expect(page.locator('#folder-name')).toBeVisible(); + await page.locator('#folder-name').fill('test-folder'); await page.getByRole('button', { name: 'Create' }).click(); // Wait for the folder to be created and appear in the sidebar @@ -116,8 +116,8 @@ test.describe('Move tabs', () => { await page.locator('.dropdown-item').filter({ hasText: 'New Folder' }).click(); // Fill folder name in the modal - await expect(page.locator('#collection-name')).toBeVisible(); - await page.locator('#collection-name').fill('test-folder'); + await expect(page.locator('#folder-name')).toBeVisible(); + await page.locator('#folder-name').fill('test-folder'); await page.getByRole('button', { name: 'Create' }).click(); // Wait for the folder to be created and appear in the sidebar diff --git a/tests/request/copy-request/copy-request.spec.ts b/tests/request/copy-request/copy-request.spec.ts new file mode 100644 index 000000000..b980c2997 --- /dev/null +++ b/tests/request/copy-request/copy-request.spec.ts @@ -0,0 +1,66 @@ +import { test, expect } from '../../../playwright'; +import { closeAllCollections, createCollection } from '../../utils/page'; + +test.describe('Copy and Paste Requests', () => { + test.afterAll(async ({ page }) => { + await closeAllCollections(page); + }); + + test('should copy and paste a request within the same collection', async ({ page, createTmpDir }) => { + await createCollection(page, 'test-collection', createTmpDir); + + // Create a new request + const collection = page.locator('.collection-name').filter({ hasText: 'test-collection' }); + await collection.locator('.collection-actions').hover(); + await collection.locator('.collection-actions .icon').click(); + await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).click(); + await page.getByPlaceholder('Request Name').fill('original-request'); + await page.locator('#new-request-url .CodeMirror').click(); + await page.locator('textarea').fill('https://httpbin.org/get'); + await page.getByRole('button', { name: 'Create' }).click(); + + await expect(page.locator('.collection-item-name').filter({ hasText: 'original-request' })).toBeVisible(); + + // Copy the request + const requestItem = page.locator('.collection-item-name').filter({ hasText: 'original-request' }); + await requestItem.locator('.menu-icon').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Copy' }).click(); + + // Paste into the collection root + await collection.click({ button: 'right' }); + await page.locator('.dropdown-item').filter({ hasText: 'Paste' }).click(); + + // Verify the pasted request appears with the same name + await expect(page.locator('.collection-item-name').filter({ hasText: 'original-request' })).toHaveCount(2); + }); + + test('should paste request into a folder', async ({ page, createTmpDir }) => { + const collection = page.locator('.collection-name').filter({ hasText: 'test-collection' }); + await collection.locator('.collection-actions').hover(); + await collection.locator('.collection-actions .icon').click(); + await page.locator('.dropdown-item').filter({ hasText: 'New Folder' }).click(); + await page.locator('#folder-name').fill('test-folder'); + await page.getByRole('button', { name: 'Create' }).click(); + + // Paste into the folder + const folder = page.locator('.collection-item-name').filter({ hasText: 'test-folder' }); + await folder.click(); + await folder.click({ button: 'right' }); + await page.locator('.dropdown-item').filter({ hasText: 'Paste' }).click(); + + await page.waitForTimeout(2000); + await expect(page.locator('.collection-item-name').filter({ hasText: 'original-request' })).toHaveCount(3); + }); + + test('should copy and paste a request into a different collection', async ({ page, createTmpDir }) => { + await createCollection(page, 'test-collection-2', createTmpDir); + const collection = page.locator('.collection-name').filter({ hasText: 'test-collection-2' }); + + // Paste into the collection root + await collection.click({ button: 'right' }); + await page.locator('.dropdown-item').filter({ hasText: 'Paste' }).click(); + + // Verify the pasted request appears with the same name + await expect(page.locator('.collection-item-name').filter({ hasText: 'original-request' })).toHaveCount(4); + }); +}); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index 50086a721..493e8629e 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -45,4 +45,16 @@ const openCollectionAndAcceptSandbox = async (page, collectionName: string, sand }); }; -export { closeAllCollections, openCollectionAndAcceptSandbox }; +const createCollection = async (page, collectionName: string, createDir: (tag?: string | undefined) => Promise) => { + await page.locator('.dropdown-icon').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Create Collection' }).click(); + await page.getByLabel('Name').fill(collectionName); + await page.getByLabel('Location').fill(await createDir(collectionName)); + await page.getByRole('button', { name: 'Create', exact: true }).click(); + await expect(page.locator('#sidebar-collection-name').filter({ hasText: collectionName })).toBeVisible(); + await page.locator('#sidebar-collection-name').filter({ hasText: collectionName }).click(); + await page.getByLabel('Safe Mode').check(); + await page.getByRole('button', { name: 'Save' }).click(); +}; + +export { closeAllCollections, openCollectionAndAcceptSandbox, createCollection };