mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-15 11:51:30 +00:00
feat: add copy and paste functionality for requests (#5907)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
27
packages/bruno-app/src/utils/bruno-clipboard.js
Normal file
27
packages/bruno-app/src/utils/bruno-clipboard.js
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
66
tests/request/copy-request/copy-request.spec.ts
Normal file
66
tests/request/copy-request/copy-request.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user