feat: add copy and paste functionality for requests (#5907)

This commit is contained in:
Pooja
2025-10-29 17:24:09 +05:30
committed by GitHub
parent 6e8cd55b76
commit cc7f1ea58f
11 changed files with 278 additions and 19 deletions

View File

@@ -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 (
<StyledWrapper className={className}>
{renameItemModalOpen && (
@@ -431,6 +450,22 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
>
Clone
</div>
{!isFolder && (
<div
className="dropdown-item"
onClick={handleCopyRequest}
>
Copy
</div>
)}
{isFolder && hasCopiedItems && (
<div
className="dropdown-item"
onClick={handlePasteRequest}
>
Paste
</div>
)}
{!isFolder && (
<div
className="dropdown-item"

View File

@@ -7,10 +7,11 @@ import { useDrop, useDrag } from 'react-dnd';
import { IconChevronRight, IconDots, IconLoader2 } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { toggleCollection, collapseFullCollection } from 'providers/ReduxStore/slices/collections';
import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop } from 'providers/ReduxStore/slices/collections/actions';
import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop, pasteItem } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch, useSelector } from 'react-redux';
import { hideHomePage } from 'providers/ReduxStore/slices/app';
import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import toast from 'react-hot-toast';
import NewRequest from 'components/Sidebar/NewRequest';
import NewFolder from 'components/Sidebar/NewFolder';
import CollectionItem from './CollectionItem';
@@ -41,7 +42,7 @@ const Collection = ({ collection, searchText }) => {
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
</div>
{hasCopiedItems && (
<div
className="dropdown-item"
onClick={handlePasteRequest}
>
Paste
</div>
)}
<div
className="dropdown-item"
onClick={(_e) => {

View File

@@ -90,7 +90,7 @@ const NewFolder = ({ collectionUid, item, onClose }) => {
Folder Name
</label>
<input
id="collection-name"
id="folder-name"
type="text"
name="folderName"
ref={inputRef}

View File

@@ -1,5 +1,6 @@
import { createSlice } from '@reduxjs/toolkit';
import filter from 'lodash/filter';
import brunoClipboard from 'utils/bruno-clipboard';
const initialState = {
isDragging: false,
@@ -36,7 +37,10 @@ const initialState = {
},
cookies: [],
taskQueue: [],
systemProxyEnvVariables: {}
systemProxyEnvVariables: {},
clipboard: {
hasCopiedItems: false // Whether clipboard has Bruno data (for UI)
}
};
export const appSlice = createSlice({
@@ -93,6 +97,10 @@ export const appSlice = createSlice({
},
toggleSidebarCollapse: (state) => {
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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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