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 90e600e6e..918a9fa5f 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -1112,6 +1112,10 @@ export const handleCollectionItemDrop const draggedItemDirectory = findParentItemInCollection(sourceCollection, draggedItemUid) || sourceCollection; const draggedItemDirectoryItems = cloneDeep(draggedItemDirectory.items); + const sourceFormat = sourceCollection?.format || 'bru'; + const targetFormat = collection?.format || 'bru'; + const isCrossFormatMove = isCrossCollectionMove && sourceFormat !== targetFormat; + const handleMoveToNewLocation = async ({ draggedItem, draggedItemDirectoryItems, @@ -1124,10 +1128,22 @@ export const handleCollectionItemDrop const { pathname: draggedItemPathname, uid: draggedItemUid } = draggedItem; const newDirname = path.dirname(newPathname); - await dispatch(moveItem({ - targetDirname: newDirname, - sourcePathname: draggedItemPathname - })); + + if (isCrossFormatMove && isItemARequest(draggedItem)) { + const { ipcRenderer } = window; + const result = await ipcRenderer.invoke('renderer:move-item-cross-format', { + targetDirname: newDirname, + sourcePathname: draggedItemPathname, + sourceFormat, + targetFormat + }); + newPathname = result.newPathname; + } else { + await dispatch(moveItem({ + targetDirname: newDirname, + sourcePathname: draggedItemPathname + })); + } // Update sequences in the source directory if (draggedItemDirectoryItems?.length) { @@ -1191,6 +1207,11 @@ export const handleCollectionItemDrop if (!newPathname) return; if (targetItemPathname?.startsWith(draggedItemPathname)) return; + if (isCrossFormatMove && isItemAFolder(draggedItem)) { + toast.error('Moving folders between collections with different formats is not supported'); + return; + } + // Discard operation if dragging a root item to the collection name (same location) const isTargetTheCollection = targetItemPathname === collection.pathname; const isDraggedItemAtRoot = draggedItemDirectory === sourceCollection; @@ -1210,6 +1231,11 @@ export const handleCollectionItemDrop } else { await handleReorderInSameLocation({ draggedItem, targetItemDirectoryItems, targetItem }); } + + if (isCrossCollectionMove) { + dispatch(closeTabs({ tabUids: [draggedItemUid] })); + } + resolve(); } catch (error) { console.error(error); diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 323541dcc..5e08a5e6b 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -1448,6 +1448,40 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { } }); + ipcMain.handle('renderer:move-item-cross-format', async (event, { targetDirname, sourcePathname, sourceFormat, targetFormat }) => { + try { + if (!fs.existsSync(sourcePathname)) { + throw new Error(`Source path: ${sourcePathname} does not exist`); + } + if (!fs.existsSync(targetDirname)) { + throw new Error(`Target directory: ${targetDirname} does not exist`); + } + + const sourceBasename = path.basename(sourcePathname); + const filenameWithoutExt = sourceBasename.replace(/\.(bru|yml|yaml)$/, ''); + const targetExt = targetFormat === 'yml' ? 'yml' : 'bru'; + const targetFilename = `${filenameWithoutExt}.${targetExt}`; + const targetPathname = path.join(targetDirname, targetFilename); + + if (fs.existsSync(targetPathname)) { + throw new Error(`A file with the name "${targetFilename}" already exists in the target location`); + } + + const sourceContent = await fs.promises.readFile(sourcePathname, 'utf8'); + const parsedRequest = parseRequest(sourceContent, { format: sourceFormat }); + const finalContent = stringifyRequest(parsedRequest, { format: targetFormat }); + + await writeFile(targetPathname, finalContent); + await removePath(sourcePathname); + + moveRequestUid(sourcePathname, targetPathname); + + return { newPathname: targetPathname }; + } catch (error) { + return Promise.reject(error); + } + }); + ipcMain.handle('renderer:move-folder-item', async (event, folderPath, destinationPath) => { try { const folderName = path.basename(folderPath); diff --git a/tests/collection/moving-requests/cross-collection-cross-format-drag-drop.spec.ts b/tests/collection/moving-requests/cross-collection-cross-format-drag-drop.spec.ts new file mode 100644 index 000000000..dcb2d7f7d --- /dev/null +++ b/tests/collection/moving-requests/cross-collection-cross-format-drag-drop.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from '../../../playwright'; +import { closeAllCollections } from '../../utils/page'; +import * as path from 'path'; +import * as fs from 'fs'; + +test.describe('Cross-Format Collection Drag and Drop', () => { + test.afterEach(async ({ pageWithUserData: page }) => { + await closeAllCollections(page); + }); + + test('Cross-format drag and drop should convert request between bru and yml', async ({ + pageWithUserData: page, + collectionFixturePath + }) => { + const requestName = 'cross-format-request'; + + // Both collections should already be loaded via init-user-data + await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'bru-collection' })).toBeVisible(); + await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'yml-collection' })).toBeVisible(); + + // Expand the bru collection and locate the request + await page.locator('#sidebar-collection-name').filter({ hasText: 'bru-collection' }).click(); + const bruCollectionContainer = page + .locator('.collection-name') + .filter({ hasText: 'bru-collection' }) + .locator('..'); + const bruRequest = bruCollectionContainer.locator('.collection-item-name').filter({ hasText: requestName }).first(); + await expect(bruRequest).toBeVisible(); + + // Drag the .bru request into the yml collection + const ymlCollection = page.locator('.collection-name').filter({ hasText: 'yml-collection' }); + await bruRequest.dragTo(ymlCollection); + + // Verify the request appears in the yml collection (increase timeout for file watcher processing) + const ymlCollectionContainer = page + .locator('.collection-name') + .filter({ hasText: 'yml-collection' }) + .locator('..'); + // The yml collection may need to be expanded after the drop + const ymlCollectionItems = ymlCollectionContainer.locator('.collection-item-name').filter({ hasText: requestName }); + // Wait for file watcher to process the new file, then expand collection if needed + await expect(async () => { + if (await ymlCollectionItems.count() === 0) { + await page.locator('#sidebar-collection-name').filter({ hasText: 'yml-collection' }).click(); + } + await expect(ymlCollectionItems).toBeVisible(); + }).toPass({ timeout: 15000 }); + + // Verify the request is no longer in the bru collection + await page.locator('#sidebar-collection-name').filter({ hasText: 'bru-collection' }).click(); + await expect(bruCollectionContainer.locator('.collection-item-name').filter({ hasText: requestName })).toHaveCount(0); + + // Verify the file was converted to .yml format on disk + const ymlFile = path.join(collectionFixturePath!, 'yml-collection', `${requestName}.yml`); + const bruFile = path.join(collectionFixturePath!, 'yml-collection', `${requestName}.bru`); + expect(fs.existsSync(ymlFile)).toBe(true); + expect(fs.existsSync(bruFile)).toBe(false); + }); +}); diff --git a/tests/collection/moving-requests/cross-collection-drag-drop-request.spec.ts b/tests/collection/moving-requests/cross-collection-drag-drop-request.spec.ts index d2f46f59f..a3f8b4ecd 100644 --- a/tests/collection/moving-requests/cross-collection-drag-drop-request.spec.ts +++ b/tests/collection/moving-requests/cross-collection-drag-drop-request.spec.ts @@ -98,4 +98,40 @@ test.describe('Cross-Collection Drag and Drop', () => { await page.locator('#sidebar-collection-name').filter({ hasText: 'target-collection' }).click(); await expect(targetCollectionContainer.locator('.collection-item-name').filter({ hasText: requestName }).first()).toBeVisible(); }); + + test('Tab should be closed after cross-collection drag and drop', async ({ page, createTmpDir }) => { + const requestName = 'tab-close-request'; + + // Create source and target collections + await createCollection(page, 'source-collection', await createTmpDir('source-collection')); + await createRequest(page, requestName, 'source-collection', { url: 'https://echo.usebruno.com' }); + + await createCollection(page, 'target-collection', await createTmpDir('target-collection')); + + // Open the request to create a tab + const sourceCollectionContainer = page + .locator('.collection-name') + .filter({ hasText: 'source-collection' }) + .locator('..'); + const sourceRequest = sourceCollectionContainer.locator('.collection-item-name').filter({ hasText: requestName }).first(); + await sourceRequest.click(); + + // Verify the tab is open + const requestTab = page.locator('.request-tab .tab-label').filter({ hasText: requestName }); + await expect(requestTab).toBeVisible(); + + // Drag the request to target collection + const targetCollection = page.locator('.collection-name').filter({ hasText: 'target-collection' }); + await sourceRequest.dragTo(targetCollection); + + // Verify the tab is closed after cross-collection move + await expect(requestTab).not.toBeVisible(); + + // Verify the request appears in the target collection + const targetCollectionContainer = page + .locator('.collection-name') + .filter({ hasText: 'target-collection' }) + .locator('..'); + await expect(targetCollectionContainer.locator('.collection-item-name').filter({ hasText: requestName })).toBeVisible(); + }); }); diff --git a/tests/collection/moving-requests/fixtures/collections/bru-collection/bruno.json b/tests/collection/moving-requests/fixtures/collections/bru-collection/bruno.json new file mode 100644 index 000000000..d4d9fb4bc --- /dev/null +++ b/tests/collection/moving-requests/fixtures/collections/bru-collection/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "bru-collection", + "type": "collection" +} diff --git a/tests/collection/moving-requests/fixtures/collections/bru-collection/cross-format-request.bru b/tests/collection/moving-requests/fixtures/collections/bru-collection/cross-format-request.bru new file mode 100644 index 000000000..6c6989a1e --- /dev/null +++ b/tests/collection/moving-requests/fixtures/collections/bru-collection/cross-format-request.bru @@ -0,0 +1,11 @@ +meta { + name: cross-format-request + type: http + seq: 1 +} + +get { + url: https://echo.usebruno.com + body: none + auth: none +} diff --git a/tests/collection/moving-requests/fixtures/collections/yml-collection/opencollection.yml b/tests/collection/moving-requests/fixtures/collections/yml-collection/opencollection.yml new file mode 100644 index 000000000..c978cb7aa --- /dev/null +++ b/tests/collection/moving-requests/fixtures/collections/yml-collection/opencollection.yml @@ -0,0 +1,3 @@ +opencollection: "1" +info: + name: yml-collection diff --git a/tests/collection/moving-requests/init-user-data/collection-security.json b/tests/collection/moving-requests/init-user-data/collection-security.json new file mode 100644 index 000000000..e015276c4 --- /dev/null +++ b/tests/collection/moving-requests/init-user-data/collection-security.json @@ -0,0 +1,16 @@ +{ + "collections": [ + { + "path": "{{collectionPath}}/bru-collection", + "securityConfig": { + "jsSandboxMode": "safe" + } + }, + { + "path": "{{collectionPath}}/yml-collection", + "securityConfig": { + "jsSandboxMode": "safe" + } + } + ] +} diff --git a/tests/collection/moving-requests/init-user-data/preferences.json b/tests/collection/moving-requests/init-user-data/preferences.json new file mode 100644 index 000000000..cdc98904c --- /dev/null +++ b/tests/collection/moving-requests/init-user-data/preferences.json @@ -0,0 +1,12 @@ +{ + "lastOpenedCollections": [ + "{{collectionPath}}/bru-collection", + "{{collectionPath}}/yml-collection" + ], + "preferences": { + "onboarding": { + "hasLaunchedBefore": true, + "hasSeenWelcomeModal": true + } + } +}