From 8cee7bad392f1342103ab58ea63cb644e4ae640a Mon Sep 17 00:00:00 2001 From: Shashank Shekhar <48152748+sha5git@users.noreply.github.com> Date: Tue, 2 Dec 2025 14:53:51 +0530 Subject: [PATCH 01/16] added copy button to copy response (#5409) Co-authored-by: Shashank Shekhar <48152748+sha5-git@users.noreply.github.com> --- .../ResponseCopy/StyledWrapper.js | 8 +++++ .../ResponsePane/ResponseCopy/index.js | 33 +++++++++++++++++++ .../src/components/ResponsePane/index.js | 4 +++ 3 files changed, 45 insertions(+) create mode 100644 packages/bruno-app/src/components/ResponsePane/ResponseCopy/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/ResponsePane/ResponseCopy/index.js diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseCopy/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/ResponseCopy/StyledWrapper.js new file mode 100644 index 000000000..b2acb52b8 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/ResponseCopy/StyledWrapper.js @@ -0,0 +1,8 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + font-size: 0.8125rem; + color: ${(props) => props.theme.requestTabPanel.responseStatus}; +`; + +export default StyledWrapper; \ No newline at end of file diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseCopy/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseCopy/index.js new file mode 100644 index 000000000..80763f131 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/ResponseCopy/index.js @@ -0,0 +1,33 @@ +import React from 'react'; +import StyledWrapper from './StyledWrapper'; +import toast from 'react-hot-toast'; +import { IconCopy } from '@tabler/icons'; + +const ResponseCopy = ({ item }) => { + const response = item.response || {}; + + const copyResponse = () => { + try { + const textToCopy = typeof response.data === 'string' + ? response.data + : JSON.stringify(response.data, null, 2); + + navigator.clipboard.writeText(textToCopy).then(() => { + toast.success('Response copied to clipboard'); + }).catch(() => { + toast.error('Failed to copy response'); + }); + } catch (error) { + toast.error('Failed to copy response'); + } + }; + + return ( + + + + ); +}; +export default ResponseCopy; \ No newline at end of file diff --git a/packages/bruno-app/src/components/ResponsePane/index.js b/packages/bruno-app/src/components/ResponsePane/index.js index b098570e2..859196182 100644 --- a/packages/bruno-app/src/components/ResponsePane/index.js +++ b/packages/bruno-app/src/components/ResponsePane/index.js @@ -18,6 +18,7 @@ import ScriptErrorIcon from './ScriptErrorIcon'; import StyledWrapper from './StyledWrapper'; import ResponseSave from 'src/components/ResponsePane/ResponseSave'; import ResponseClear from 'src/components/ResponsePane/ResponseClear'; +import ResponseCopy from 'src/components/ResponsePane/ResponseCopy'; import ResponseBookmark from 'src/components/ResponsePane/ResponseBookmark'; import SkippedRequest from './SkippedRequest'; import ClearTimeline from './ClearTimeline/index'; @@ -189,6 +190,9 @@ const ResponsePane = ({ item, collection }) => { <> + + + {item.response?.stream?.running From 06a024a1d91ccb3483cdbf3f46f4056e2cb1edaf Mon Sep 17 00:00:00 2001 From: Pooja Date: Tue, 2 Dec 2025 15:29:32 +0530 Subject: [PATCH 02/16] feat: add copy paste feature for folder (#6097) * feat: add copy paste feature for folder * add: playwright test * fix * fix * add: keyboardFocusBg in light mode * fix: copy paste in yaml collection * improvement --- .../CollectionItem/StyledWrapper.js | 9 ++ .../Collection/CollectionItem/index.js | 73 ++++++++--- .../Collections/Collection/StyledWrapper.js | 9 ++ .../Sidebar/Collections/Collection/index.js | 41 +++++-- .../ReduxStore/slices/collections/actions.js | 113 ++++++++++++------ packages/bruno-app/src/themes/dark.js | 1 + packages/bruno-app/src/themes/light.js | 1 + packages/bruno-electron/src/ipc/collection.js | 11 +- .../src/formats/yml/common/variables.ts | 7 +- .../request/copy-request/copy-folder.spec.ts | 92 ++++++++++++++ .../request/copy-request/copy-request.spec.ts | 1 - .../copy-request/keyboard-shortcuts.spec.ts | 93 ++++++++++++++ 12 files changed, 383 insertions(+), 68 deletions(-) create mode 100644 tests/request/copy-request/copy-folder.spec.ts create mode 100644 tests/request/copy-request/keyboard-shortcuts.spec.ts diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js index 69f9777c9..960520b0f 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js @@ -131,6 +131,15 @@ const Wrapper = styled.div` } } + &.item-keyboard-focused { + background: ${(props) => props.theme.sidebar.collection.item.keyboardFocusBg}; + outline: none; + + &:hover { + background: ${(props) => props.theme.sidebar.collection.item.keyboardFocusBg} !important; + } + } + div.tippy-box { position: relative; top: -0.625rem; 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 24ca66e9f..28132f8eb 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 @@ -77,6 +77,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) const [runCollectionModalOpen, setRunCollectionModalOpen] = useState(false); const [itemInfoModalOpen, setItemInfoModalOpen] = useState(false); const [examplesExpanded, setExamplesExpanded] = useState(false); + const [isKeyboardFocused, setIsKeyboardFocused] = useState(false); const hasSearchText = searchText && searchText?.trim()?.length; const itemIsCollapsed = hasSearchText ? false : item.collapsed; const isFolder = isItemAFolder(item); @@ -186,10 +187,11 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) }); const itemRowClassName = classnames('flex collection-item-name relative items-center', { - 'item-focused-in-tab': isTabForItemActive, + 'item-focused-in-tab': isTabForItemActive && !isKeyboardFocused, 'item-hovered': isOver && canDrop, 'drop-target': isOver && dropType === 'inside', - 'drop-target-above': isOver && dropType === 'adjacent' + 'drop-target-above': isOver && dropType === 'adjacent', + 'item-keyboard-focused': isKeyboardFocused }); const handleRun = async () => { @@ -393,23 +395,56 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) } }; - const handleCopyRequest = () => { + const handleCopyItem = () => { dropdownTippyRef.current.hide(); dispatch(copyRequest(item)); - toast.success('Request copied to clipboard'); + const itemType = isFolder ? 'Folder' : 'Request'; + toast.success(`${itemType} copied to clipboard`); }; - const handlePasteRequest = () => { + const handlePasteItem = () => { dropdownTippyRef.current.hide(); + + // Only allow paste into folders + if (!isFolder) { + toast.error('Paste is only available for folders'); + return; + } + dispatch(pasteItem(collectionUid, item.uid)) .then(() => { - toast.success('Request pasted successfully'); + toast.success('Item pasted successfully'); }) .catch((err) => { - toast.error(err ? err.message : 'An error occurred while pasting the request'); + toast.error(err ? err.message : 'An error occurred while pasting the item'); }); }; + // Keyboard shortcuts handler + const handleKeyDown = (e) => { + // Detect Mac by checking both metaKey and platform + const isMac = navigator.userAgent?.includes('Mac') || navigator.platform?.startsWith('Mac'); + const isModifierPressed = isMac ? e.metaKey : e.ctrlKey; + + if (isModifierPressed && e.key.toLowerCase() === 'c') { + e.preventDefault(); + e.stopPropagation(); + handleCopyItem(); + } else if (isModifierPressed && e.key.toLowerCase() === 'v') { + e.preventDefault(); + e.stopPropagation(); + handlePasteItem(); + } + }; + + const handleFocus = () => { + setIsKeyboardFocused(true); + }; + + const handleBlur = () => { + setIsKeyboardFocused(false); + }; + return ( {renameItemModalOpen && ( @@ -449,6 +484,10 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) ref.current = node; drag(drop(node)); }} + tabIndex={0} + onKeyDown={handleKeyDown} + onFocus={handleFocus} + onBlur={handleBlur} >
{indents && indents.length @@ -568,21 +607,19 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) Clone
- {!isFolder && ( -
- +
+ - - Copy -
- )} +
+ Copy +
{isFolder && hasCopiedItems && (
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js index 3598f3744..bcce56e5e 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js @@ -94,6 +94,15 @@ const Wrapper = styled.div` background: ${(props) => props.theme.sidebar.collection.item.bg} !important; } } + + &.collection-keyboard-focused { + background: ${(props) => props.theme.sidebar.collection.item.keyboardFocusBg}; + outline: none; + + &:hover { + background: ${(props) => props.theme.sidebar.collection.item.keyboardFocusBg} !important; + } + } } #sidebar-collection-name { diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js index 7c7a26f58..3c8e07987 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js @@ -51,6 +51,7 @@ const Collection = ({ collection, searchText }) => { const [showShareCollectionModal, setShowShareCollectionModal] = useState(false); const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false); const [dropType, setDropType] = useState(null); + const [isKeyboardFocused, setIsKeyboardFocused] = useState(false); const dispatch = useDispatch(); const isLoading = areItemsLoading(collection); const collectionRef = useRef(null); @@ -161,17 +162,38 @@ const Collection = ({ collection, searchText }) => { ); }; - const handlePasteRequest = () => { + const handlePasteItem = () => { menuDropdownTippyRef.current.hide(); dispatch(pasteItem(collection.uid, null)) .then(() => { - toast.success('Request pasted successfully'); + toast.success('Item pasted successfully'); }) .catch((err) => { - toast.error(err ? err.message : 'An error occurred while pasting the request'); + toast.error(err ? err.message : 'An error occurred while pasting the item'); }); }; + // Keyboard shortcuts handler for collection + const handleKeyDown = (e) => { + // Detect Mac by checking both metaKey and platform + const isMac = navigator.userAgent?.includes('Mac') || navigator.platform?.startsWith('Mac'); + const isModifierPressed = isMac ? e.metaKey : e.ctrlKey; + + if (isModifierPressed && e.key.toLowerCase() === 'v') { + e.preventDefault(); + e.stopPropagation(); + handlePasteItem(); + } + }; + + const handleFocus = () => { + setIsKeyboardFocused(true); + }; + + const handleBlur = () => { + setIsKeyboardFocused(false); + }; + const isCollectionItem = (itemType) => { return itemType === 'collection-item'; }; @@ -227,9 +249,10 @@ const Collection = ({ collection, searchText }) => { } const collectionRowClassName = classnames('flex py-1 collection-name items-center', { - 'item-hovered': isOver && dropType === 'adjacent', // For collection-to-collection moves (show line) - 'drop-target': isOver && dropType === 'inside', // For collection-item drops (highlight full area) - 'collection-focused-in-tab': isCollectionFocused + 'item-hovered': isOver && dropType === 'adjacent', // For collection-to-collection moves (show line) + 'drop-target': isOver && dropType === 'inside', // For collection-item drops (highlight full area) + 'collection-focused-in-tab': isCollectionFocused && !isKeyboardFocused, + 'collection-keyboard-focused': isKeyboardFocused }); // we need to sort request items by seq property @@ -262,6 +285,10 @@ const Collection = ({ collection, searchText }) => { collectionRef.current = node; drag(drop(node)); }} + tabIndex={0} + onKeyDown={handleKeyDown} + onFocus={handleFocus} + onBlur={handleBlur} >
{ {hasCopiedItems && (
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 64df682cd..41ba37cdf 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -81,6 +81,42 @@ import { addTab } from 'providers/ReduxStore/slices/tabs'; import { updateSettingsSelectedTab } from './index'; import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; +// generate a unique names +const generateUniqueName = (originalName, existingItems, isFolder) => { + // Extract base name by removing any existing " (number)" suffix + const baseName = originalName.replace(/\s*\(\d+\)$/, ''); + const baseFilename = sanitizeName(baseName); + + // Get normalized filenames for items of the same type + const existingFilenames = existingItems + .filter((item) => isFolder ? item.type === 'folder' : item.type !== 'folder') + .map((item) => { + let filename = trim(item.filename); + // For requests, remove file extension (.bru, .yml, .yaml) + return isFolder ? filename : filename.replace(/\.(bru|yml|yaml)$/, ''); + }); + + // Check if base name conflicts with existing items + if (!existingFilenames.includes(baseFilename)) { + return { newName: baseName, newFilename: baseFilename }; + } + + // Find highest counter among conflicting names + const counters = existingFilenames + .filter((filename) => filename === baseFilename || filename.startsWith(`${baseFilename} (`)) + .map((filename) => { + if (filename === baseFilename) return 0; + const match = filename.match(/\((\d+)\)$/); + return match ? parseInt(match[1], 10) : 0; + }); + + const nextCounter = Math.max(0, ...counters) + 1; + return { + newName: `${baseName} (${nextCounter})`, + newFilename: `${baseFilename} (${nextCounter})` + }; +}; + export const renameCollection = (newName, collectionUid) => (dispatch, getState) => { const state = getState(); const collection = findCollectionByUid(state.collections.collections, collectionUid); @@ -853,7 +889,7 @@ export const cloneItem = (newName, newFilename, itemUid, collectionUid) => (disp const collectionPath = path.join(parentFolder.pathname, newFilename); const { ipcRenderer } = window; - ipcRenderer.invoke('renderer:clone-folder', item, collectionPath).then(resolve).catch(reject); + ipcRenderer.invoke('renderer:clone-folder', item, collectionPath, collection.pathname).then(resolve).catch(reject); return; } @@ -943,11 +979,6 @@ export const pasteItem = (targetCollectionUid, targetItemUid = null) => (dispatc 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; @@ -964,39 +995,47 @@ export const pasteItem = (targetCollectionUid, targetItemUid = null) => (dispatc 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, targetCollection.format)))) { - newName = `${copiedItem.name} (${counter})`; - newFilename = `${sanitizeName(copiedItem.name)} (${counter})`; - counter++; + // Handle folder pasting + if (isItemAFolder(copiedItem)) { + // Generate unique name for folder + const { newName, newFilename } = generateUniqueName(copiedItem.name, existingItems, true); + + set(copiedItem, 'name', newName); + set(copiedItem, 'filename', newFilename); + set(copiedItem, 'root.meta.name', newName); + set(copiedItem, 'root.meta.seq', (existingItems?.length ?? 0) + 1); + + const fullPathname = path.join(targetParentPathname, newFilename); + const { ipcRenderer } = window; + + await ipcRenderer.invoke('renderer:clone-folder', copiedItem, fullPathname, targetCollection.pathname); + } else { + // Handle request pasting + // Generate unique name for request + const { newName, newFilename } = generateUniqueName(copiedItem.name, existingItems, false); + + const filename = resolveRequestFilename(newFilename, targetCollection.format); + 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, targetCollection.format); + + dispatch(insertTaskIntoQueue({ + uid: uuid(), + type: 'OPEN_REQUEST', + collectionUid: targetCollectionUid, + itemPathname: fullPathname + })); } - - const filename = resolveRequestFilename(newFilename, targetCollection.format); - 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, targetCollection.format); - - dispatch(insertTaskIntoQueue({ - uid: uuid(), - type: 'OPEN_REQUEST', - collectionUid: targetCollectionUid, - itemPathname: fullPathname - })); } resolve(); @@ -1021,7 +1060,7 @@ export const deleteItem = (itemUid, collectionUid) => (dispatch, getState) => { const { ipcRenderer } = window; ipcRenderer - .invoke('renderer:delete-item', item.pathname, item.type) + .invoke('renderer:delete-item', item.pathname, item.type, collection.pathname) .then(async () => { // Reorder items in parent directory after deletion if (parentDirectoryItem.items) { diff --git a/packages/bruno-app/src/themes/dark.js b/packages/bruno-app/src/themes/dark.js index a1a4dbfe7..1c72e0b0f 100644 --- a/packages/bruno-app/src/themes/dark.js +++ b/packages/bruno-app/src/themes/dark.js @@ -115,6 +115,7 @@ const darkTheme = { item: { bg: '#37373D', hoverBg: '#2A2D2F', + keyboardFocusBg: 'rgba(10, 132, 255, 0.2)', indentBorder: 'solid 1px #585858', active: { indentBorder: 'solid 1px #4c4c4c' diff --git a/packages/bruno-app/src/themes/light.js b/packages/bruno-app/src/themes/light.js index a34e85d98..230a6ed17 100644 --- a/packages/bruno-app/src/themes/light.js +++ b/packages/bruno-app/src/themes/light.js @@ -118,6 +118,7 @@ const lightTheme = { item: { bg: colors.GRAY_2, hoverBg: colors.GRAY_2, + keyboardFocusBg: 'rgba(10, 132, 255, 0.2)', indentBorder: `solid 1px ${colors.GRAY_3}`, active: { indentBorder: `solid 1px ${colors.GRAY_3}` diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 1d1ba13e7..cc93fa503 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -773,7 +773,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection }); // delete file/folder - ipcMain.handle('renderer:delete-item', async (event, pathname, type) => { + ipcMain.handle('renderer:delete-item', async (event, pathname, type, collectionPathname) => { try { if (type === 'folder') { if (!fs.existsSync(pathname)) { @@ -781,7 +781,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection } // delete the request uid mappings - const requestFilesAtSource = await searchForRequestFiles(pathname); + const requestFilesAtSource = await searchForRequestFiles(pathname, collectionPathname); for (let requestFile of requestFilesAtSource) { deleteRequestUid(requestFile); } @@ -947,7 +947,12 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection items.forEach(async (item) => { if (['http-request', 'graphql-request', 'grpc-request'].includes(item.type)) { const content = await stringifyRequestViaWorker(item, { format }); - const filePath = path.join(currentPath, item.filename); + + // Use the correct file extension based on target format + const baseName = path.parse(item.filename).name; + const newFilename = format === 'yml' ? `${baseName}.yml` : `${baseName}.bru`; + const filePath = path.join(currentPath, newFilename); + safeWriteFileSync(filePath, content); } if (item.type === 'folder') { diff --git a/packages/bruno-filestore/src/formats/yml/common/variables.ts b/packages/bruno-filestore/src/formats/yml/common/variables.ts index e4e30ef8c..ad5856f63 100644 --- a/packages/bruno-filestore/src/formats/yml/common/variables.ts +++ b/packages/bruno-filestore/src/formats/yml/common/variables.ts @@ -9,14 +9,17 @@ export const toOpenCollectionVariables = (variables: BrunoFolderRequest['vars'] const reqVars = hasReqRes ? variables.req : variables as BrunoVariables; const resVars = hasReqRes && 'res' in variables ? variables.res : []; - const allVars = [...(reqVars || []), ...(resVars || [])]; + const reqVarsArray = Array.isArray(reqVars) ? reqVars : []; + const resVarsArray = Array.isArray(resVars) ? resVars : []; + + const allVars = [...reqVarsArray, ...resVarsArray]; if (!allVars.length) { return undefined; } const ocVariables: Variable[] = allVars.map((v: BrunoVariable, index: number): Variable => { - const isResVar = reqVars && index >= (reqVars?.length || 0); + const isResVar = index >= reqVarsArray.length; const variable: Variable = { name: v.name || '', value: v.value || '' diff --git a/tests/request/copy-request/copy-folder.spec.ts b/tests/request/copy-request/copy-folder.spec.ts new file mode 100644 index 000000000..247b038a0 --- /dev/null +++ b/tests/request/copy-request/copy-folder.spec.ts @@ -0,0 +1,92 @@ +import { test, expect } from '../../../playwright'; +import { closeAllCollections, createCollection } from '../../utils/page'; + +test.describe('Copy and Paste Folders', () => { + test.afterAll(async ({ page }) => { + await closeAllCollections(page); + }); + + test('should copy and paste a folder within the same collection', async ({ page, createTmpDir }) => { + await createCollection(page, 'test-collection', await createTmpDir('test-collection'), { openWithSandboxMode: 'safe' }); + const collection = page.locator('.collection-name').filter({ hasText: 'test-collection' }); + + // Create a new folder with a request inside + 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('folder-to-copy'); + await page.getByRole('button', { name: 'Create' }).click(); + + const folder = page.locator('.collection-item-name').filter({ hasText: 'folder-to-copy' }); + await expect(folder).toBeVisible(); + + // Add a request to the folder + await folder.hover(); + await folder.locator('.menu-icon').click(); + await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).click(); + await page.getByPlaceholder('Request Name').fill('request-in-folder'); + await page.locator('#new-request-url .CodeMirror').click(); + await page.locator('textarea').fill('https://echo.usebruno.com/test'); + await page.getByRole('button', { name: 'Create' }).click(); + + await folder.click(); + await expect(page.locator('.collection-item-name').filter({ hasText: 'request-in-folder' })).toBeVisible(); + + // Copy the folder + await folder.hover(); + await folder.locator('.menu-icon').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Copy' }).click(); + + // Paste into the collection root + await collection.hover(); + await collection.locator('.collection-actions .icon').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Paste' }).click(); + + // Verify the pasted folder appears + await expect(page.locator('.collection-item-name').filter({ hasText: 'folder-to-copy' })).toHaveCount(2); + }); + + test('should copy and paste a folder into a different collection', async ({ page, createTmpDir }) => { + // Create second collection + await createCollection(page, 'test-collection-2', await createTmpDir('test-collection-2'), { openWithSandboxMode: 'safe' }); + const collection2 = page.locator('.collection-name').filter({ hasText: 'test-collection-2' }); + + // Paste the folder from clipboard into the new collection + await collection2.hover(); + await collection2.locator('.collection-actions .icon').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Paste' }).click(); + + // Verify the pasted folder appears in the new collection + await expect(page.locator('.collection-item-name').filter({ hasText: 'folder-to-copy' })).toHaveCount(3); + }); + + test('should paste folder into another folder', async ({ page }) => { + const collection = page.locator('.collection-name').filter({ hasText: 'test-collection-2' }); + const folderToCopy = page.locator('.collection-item-name').filter({ hasText: 'folder-to-copy' }).first(); + + // Create a target folder + 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('target-folder'); + await page.getByRole('button', { name: 'Create' }).click(); + + const targetFolder = page.locator('.collection-item-name').filter({ hasText: 'target-folder' }); + await expect(targetFolder).toBeVisible(); + await targetFolder.click(); + + // Copy folder-to-copy + await folderToCopy.hover(); + await folderToCopy.locator('.menu-icon').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Copy' }).click(); + await folderToCopy.click(); + + // Paste into target folder + await targetFolder.hover(); + await targetFolder.locator('.menu-icon').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Paste' }).click(); + + // Verify folder was pasted inside target folder + await expect(page.locator('.collection-item-name').filter({ hasText: 'folder-to-copy' })).toHaveCount(4); + }); +}); diff --git a/tests/request/copy-request/copy-request.spec.ts b/tests/request/copy-request/copy-request.spec.ts index 1646a4969..bd913b4cd 100644 --- a/tests/request/copy-request/copy-request.spec.ts +++ b/tests/request/copy-request/copy-request.spec.ts @@ -50,7 +50,6 @@ test.describe('Copy and Paste Requests', () => { await folder.locator('.menu-icon').click(); 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); }); diff --git a/tests/request/copy-request/keyboard-shortcuts.spec.ts b/tests/request/copy-request/keyboard-shortcuts.spec.ts new file mode 100644 index 000000000..9a3f20ed7 --- /dev/null +++ b/tests/request/copy-request/keyboard-shortcuts.spec.ts @@ -0,0 +1,93 @@ +import { test, expect } from '../../../playwright'; +import { closeAllCollections, createCollection } from '../../utils/page'; + +test.describe('Copy and Paste with Keyboard Shortcuts', () => { + test.afterAll(async ({ page }) => { + await closeAllCollections(page); + }); + + test('should copy and paste request using keyboard shortcuts', async ({ page, createTmpDir }) => { + await createCollection(page, 'keyboard-test', await createTmpDir('keyboard-test'), { openWithSandboxMode: 'safe' }); + const collection = page.locator('.collection-name').filter({ hasText: 'keyboard-test' }); + + // Create a request + 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('test-request'); + await page.locator('#new-request-url .CodeMirror').click(); + await page.locator('textarea').fill('https://echo.usebruno.com'); + await page.getByRole('button', { name: 'Create' }).click(); + + const requestItem = page.locator('.collection-item-name').filter({ hasText: 'test-request' }); + await expect(requestItem).toBeVisible(); + + // Focus the request item + await requestItem.click(); + await requestItem.focus(); + + // Wait for keyboard focus indicator + await expect(requestItem).toHaveClass(/item-keyboard-focused/); + + // Use Cmd+C on Mac, Ctrl+C on Windows/Linux + const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'; + await page.keyboard.press(`${modifier}+KeyC`); + + // Verify copy success (toast message) + await expect(page.getByText(/copied to clipboard/i).first()).toBeVisible(); + + // Focus the collection to paste + await collection.click(); + await collection.focus(); + + // Use Cmd+V on Mac, Ctrl+V on Windows/Linux + await page.keyboard.press(`${modifier}+KeyV`); + + // Verify paste success + await expect(page.getByText(/pasted successfully/i).first()).toBeVisible(); + + // Verify the pasted request appears + await expect(page.locator('.collection-item-name').filter({ hasText: 'test-request' })).toHaveCount(2); + }); + + test('should copy and paste folder using keyboard shortcuts', async ({ page }) => { + const collection = page.locator('.collection-name').filter({ hasText: 'keyboard-test' }); + + // Create a folder + 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(); + + const folder = page.locator('.collection-item-name').filter({ hasText: 'test-folder' }); + await expect(folder).toBeVisible(); + + // Focus the folder + await folder.click(); + await folder.focus(); + + // Wait for keyboard focus indicator + await expect(folder).toHaveClass(/item-keyboard-focused/); + + // Use keyboard shortcut to copy + const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'; + await page.keyboard.press(`${modifier}+KeyC`); + + // Verify copy success + await expect(page.getByText(/copied to clipboard/i).first()).toBeVisible(); + + // Focus the collection to paste + await collection.click(); + await collection.focus(); + + // Use keyboard shortcut to paste + await page.keyboard.press(`${modifier}+KeyV`); + + // Verify paste success + await expect(page.getByText(/pasted successfully/i).first()).toBeVisible(); + + // Verify the pasted folder appears + await expect(page.locator('.collection-item-name').filter({ hasText: 'test-folder' })).toHaveCount(2); + }); +}); From 2a251b1a626d8faaa8d82c0615a12eb53d5d9a0f Mon Sep 17 00:00:00 2001 From: Pooja Date: Tue, 2 Dec 2025 15:06:39 +0530 Subject: [PATCH 03/16] added copy button to copy response (#6131) * added copy button to copy response * add: response action component * fix: lint error * add: playwright test --------- Co-authored-by: Shashank Shekhar <48152748+sha5-git@users.noreply.github.com> Co-authored-by: Sid --- .../ResponseActions/StyledWrapper.js | 29 +++++++++++ .../ResponsePane/ResponseActions/index.js | 35 +++++++++++++ .../ResponsePane/ResponseClear/index.js | 15 +++++- .../ResponseCopy/StyledWrapper.js | 2 +- .../ResponsePane/ResponseCopy/index.js | 39 +++++++++----- .../ResponsePane/ResponseSave/index.js | 18 ++++++- .../src/components/ResponsePane/index.js | 12 ++--- tests/response/response-actions.spec.ts | 51 +++++++++++++++++++ 8 files changed, 176 insertions(+), 25 deletions(-) create mode 100644 packages/bruno-app/src/components/ResponsePane/ResponseActions/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/ResponsePane/ResponseActions/index.js create mode 100644 tests/response/response-actions.spec.ts diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseActions/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/ResponseActions/StyledWrapper.js new file mode 100644 index 000000000..6ba69090e --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/ResponseActions/StyledWrapper.js @@ -0,0 +1,29 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + button { + color: var(--color-tab-inactive); + cursor: pointer; + + &:hover { + color: var(--color-tab-active); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + } + + .cursor-pointer { + display: flex; + align-items: center; + color: var(--color-tab-inactive); + + &:hover { + color: var(--color-tab-active); + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseActions/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseActions/index.js new file mode 100644 index 000000000..a430153a0 --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/ResponseActions/index.js @@ -0,0 +1,35 @@ +import React, { useRef, forwardRef } from 'react'; +import { IconDots } from '@tabler/icons'; +import Dropdown from 'components/Dropdown'; +import StyledWrapper from './StyledWrapper'; +import ResponseClear from 'src/components/ResponsePane/ResponseClear'; +import ResponseSave from 'src/components/ResponsePane/ResponseSave'; + +const ResponseActions = ({ collection, item }) => { + const menuDropdownTippyRef = useRef(); + + const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref); + + const MenuIcon = forwardRef((_props, ref) => { + return ( +
+ +
+ ); + }); + + const handleClose = () => { + menuDropdownTippyRef.current.hide(); + }; + + return ( + + } placement="bottom-end"> + + + + + ); +}; + +export default ResponseActions; diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseClear/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseClear/index.js index 747543347..b18418592 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseClear/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseClear/index.js @@ -4,10 +4,11 @@ import { useDispatch } from 'react-redux'; import StyledWrapper from './StyledWrapper'; import { responseCleared } from 'providers/ReduxStore/slices/collections/index'; -const ResponseClear = ({ collection, item }) => { +const ResponseClear = ({ collection, item, asDropdownItem, onClose }) => { const dispatch = useDispatch(); - const clearResponse = () => + const clearResponse = () => { + if (onClose) onClose(); dispatch( responseCleared({ itemUid: item.uid, @@ -15,6 +16,16 @@ const ResponseClear = ({ collection, item }) => { response: null }) ); + }; + + if (asDropdownItem) { + return ( +
+ + Clear +
+ ); + } return ( diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseCopy/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/ResponseCopy/StyledWrapper.js index b2acb52b8..8c32a8bab 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseCopy/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseCopy/StyledWrapper.js @@ -5,4 +5,4 @@ const StyledWrapper = styled.div` color: ${(props) => props.theme.requestTabPanel.responseStatus}; `; -export default StyledWrapper; \ No newline at end of file +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseCopy/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseCopy/index.js index 80763f131..7c59301ef 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseCopy/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseCopy/index.js @@ -1,33 +1,46 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import StyledWrapper from './StyledWrapper'; import toast from 'react-hot-toast'; -import { IconCopy } from '@tabler/icons'; +import { IconCopy, IconCheck } from '@tabler/icons'; const ResponseCopy = ({ item }) => { const response = item.response || {}; + const [copied, setCopied] = useState(false); - const copyResponse = () => { + useEffect(() => { + if (copied) { + const timer = setTimeout(() => { + setCopied(false); + }, 2000); + return () => clearTimeout(timer); + } + }, [copied]); + + const copyResponse = async () => { try { - const textToCopy = typeof response.data === 'string' - ? response.data - : JSON.stringify(response.data, null, 2); + const textToCopy = typeof response.data === 'string' + ? response.data + : JSON.stringify(response.data, null, 2); - navigator.clipboard.writeText(textToCopy).then(() => { + await navigator.clipboard.writeText(textToCopy); toast.success('Response copied to clipboard'); - }).catch(() => { - toast.error('Failed to copy response'); - }); + setCopied(true); } catch (error) { - toast.error('Failed to copy response'); + toast.error('Failed to copy response'); } }; return ( ); }; -export default ResponseCopy; \ No newline at end of file + +export default ResponseCopy; diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseSave/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseSave/index.js index 15eef651b..c791463cd 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseSave/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseSave/index.js @@ -4,11 +4,13 @@ import toast from 'react-hot-toast'; import get from 'lodash/get'; import { IconDownload } from '@tabler/icons'; -const ResponseSave = ({ item }) => { +const ResponseSave = ({ item, asDropdownItem, onClose }) => { const { ipcRenderer } = window; const response = item.response || {}; const saveResponseToFile = () => { + if (!response.dataBuffer) return; + if (onClose) onClose(); return new Promise((resolve, reject) => { ipcRenderer .invoke('renderer:save-response-to-file', response, item?.requestSent?.url, item.pathname) @@ -20,6 +22,20 @@ const ResponseSave = ({ item }) => { }); }; + if (asDropdownItem) { + return ( +
+ + Download +
+ ); + } + return (