fix: cross-collection drag and drop tab and format issues (#7584)

Close the open tab when a request is moved to a different collection
via drag and drop, preventing the "Request no longer exists" error.

Add format conversion when dragging requests between collections with
different formats (.bru vs .yml). A new IPC handler parses the source
file and re-serializes it in the target collection's format. Folder
cross-format moves are blocked with a toast error.

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
This commit is contained in:
Chirag Chandrashekhar
2026-03-26 20:32:29 +05:30
committed by GitHub
parent 03dcb6b7b9
commit ff975c44f2
9 changed files with 206 additions and 4 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
{
"version": "1",
"name": "bru-collection",
"type": "collection"
}

View File

@@ -0,0 +1,11 @@
meta {
name: cross-format-request
type: http
seq: 1
}
get {
url: https://echo.usebruno.com
body: none
auth: none
}

View File

@@ -0,0 +1,3 @@
opencollection: "1"
info:
name: yml-collection

View File

@@ -0,0 +1,16 @@
{
"collections": [
{
"path": "{{collectionPath}}/bru-collection",
"securityConfig": {
"jsSandboxMode": "safe"
}
},
{
"path": "{{collectionPath}}/yml-collection",
"securityConfig": {
"jsSandboxMode": "safe"
}
}
]
}

View File

@@ -0,0 +1,12 @@
{
"lastOpenedCollections": [
"{{collectionPath}}/bru-collection",
"{{collectionPath}}/yml-collection"
],
"preferences": {
"onboarding": {
"hasLaunchedBefore": true,
"hasSeenWelcomeModal": true
}
}
}