diff --git a/packages/bruno-app/src/components/RequestTabs/DraggableTab.js b/packages/bruno-app/src/components/RequestTabs/DraggableTab.js
new file mode 100644
index 000000000..548cbdbea
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestTabs/DraggableTab.js
@@ -0,0 +1,47 @@
+import React from 'react';
+import { useDrag, useDrop } from 'react-dnd';
+
+const DraggableTab = ({ id, onMoveTab, index, children, className, onClick }) => {
+ const ref = React.useRef(null);
+
+ const [{ handlerId, isOver }, drop] = useDrop({
+ accept: 'tab',
+ hover(item, monitor) {
+ onMoveTab(item.id, id);
+ },
+ collect: (monitor) => ({
+ handlerId: monitor.getHandlerId(),
+ isOver: monitor.isOver()
+ })
+ });
+
+ const [{ isDragging }, drag] = useDrag({
+ type: 'tab',
+ item: () => {
+ return { id, index };
+ },
+ collect: (monitor) => ({
+ isDragging: monitor.isDragging()
+ }),
+ options: {
+ dropEffect: 'move'
+ }
+ });
+
+ drag(drop(ref));
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default DraggableTab;
diff --git a/packages/bruno-app/src/components/RequestTabs/index.js b/packages/bruno-app/src/components/RequestTabs/index.js
index fcba790a6..724707b4f 100644
--- a/packages/bruno-app/src/components/RequestTabs/index.js
+++ b/packages/bruno-app/src/components/RequestTabs/index.js
@@ -4,11 +4,12 @@ import filter from 'lodash/filter';
import classnames from 'classnames';
import { IconChevronRight, IconChevronLeft } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
-import { focusTab } from 'providers/ReduxStore/slices/tabs';
+import { focusTab, reorderTabs } from 'providers/ReduxStore/slices/tabs';
import NewRequest from 'components/Sidebar/NewRequest';
import CollectionToolBar from './CollectionToolBar';
import RequestTab from './RequestTab';
import StyledWrapper from './StyledWrapper';
+import DraggableTab from './DraggableTab';
const RequestTabs = () => {
const dispatch = useDispatch();
@@ -106,10 +107,17 @@ const RequestTabs = () => {
{collectionRequestTabs && collectionRequestTabs.length
? collectionRequestTabs.map((tab, index) => {
return (
- {
+ dispatch(reorderTabs({
+ sourceUid: source,
+ targetUid: target
+ }));
+ }}
className={getTabClassname(tab, index)}
- role="tab"
onClick={() => handleClick(tab)}
>
{
collection={activeCollection}
folderUid={tab.folderUid}
/>
-
+
);
})
: null}
diff --git a/packages/bruno-app/src/providers/Hotkeys/index.js b/packages/bruno-app/src/providers/Hotkeys/index.js
index 7aef15122..0073d73bf 100644
--- a/packages/bruno-app/src/providers/Hotkeys/index.js
+++ b/packages/bruno-app/src/providers/Hotkeys/index.js
@@ -14,7 +14,7 @@ import {
saveFolderRoot
} from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid, findItemInCollection } from 'utils/collections';
-import { closeTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
+import { closeTabs, reorderTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { getKeyBindingsForActionAllOS } from './keyMappings';
@@ -252,6 +252,30 @@ export const HotkeysProvider = (props) => {
};
}, [dispatch]);
+ // Move tab left
+ useEffect(() => {
+ Mousetrap.bind([...getKeyBindingsForActionAllOS('moveTabLeft')], (e) => {
+ dispatch(reorderTabs({ direction: -1 }));
+ return false; // this stops the event bubbling
+ });
+
+ return () => {
+ Mousetrap.unbind([...getKeyBindingsForActionAllOS('moveTabLeft')]);
+ };
+ }, [dispatch]);
+
+ // Move tab right
+ useEffect(() => {
+ Mousetrap.bind([...getKeyBindingsForActionAllOS('moveTabRight')], (e) => {
+ dispatch(reorderTabs({ direction: 1 }));
+ return false; // this stops the event bubbling
+ });
+
+ return () => {
+ Mousetrap.unbind([...getKeyBindingsForActionAllOS('moveTabRight')]);
+ };
+ }, [dispatch]);
+
const currentCollection = getCurrentCollection();
return (
diff --git a/packages/bruno-app/src/providers/Hotkeys/keyMappings.js b/packages/bruno-app/src/providers/Hotkeys/keyMappings.js
index 997eb2cd0..74aa2970a 100644
--- a/packages/bruno-app/src/providers/Hotkeys/keyMappings.js
+++ b/packages/bruno-app/src/providers/Hotkeys/keyMappings.js
@@ -21,6 +21,16 @@ const KeyMapping = {
windows: 'ctrl+pagedown',
name: 'Switch to Next Tab'
},
+ moveTabLeft: {
+ mac: 'command+shift+pageup',
+ windows: 'ctrl+shift+pageup',
+ name: 'Move Tab Left'
+ },
+ moveTabRight: {
+ mac: 'command+shift+pagedown',
+ windows: 'ctrl+shift+pagedown',
+ name: 'Move Tab Right'
+ },
closeAllTabs: { mac: 'command+shift+w', windows: 'ctrl+shift+w', name: 'Close All Tabs' },
collapseSidebar: { mac: 'command+\\', windows: 'ctrl+\\', name: 'Collapse Sidebar' }
};
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js
index 694239da6..9ed845611 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js
@@ -1,5 +1,4 @@
import { createSlice } from '@reduxjs/toolkit';
-import { findIndex } from 'lodash';
import filter from 'lodash/filter';
import find from 'lodash/find';
import last from 'lodash/last';
@@ -178,6 +177,33 @@ export const tabsSlice = createSlice({
} else {
console.error('Tab not found!');
}
+ },
+ reorderTabs: (state, action) => {
+ const { direction, sourceUid, targetUid } = action.payload;
+ const tabs = state.tabs;
+
+ let sourceIdx, targetIdx;
+ if (direction) {
+ sourceIdx = tabs.findIndex((t) => t.uid === state.activeTabUid);
+ if (sourceIdx < 0) {
+ return;
+ }
+ targetIdx = sourceIdx + (direction === -1 ? -1 : 1);
+ } else {
+ sourceIdx = tabs.findIndex((t) => t.uid === sourceUid);
+ targetIdx = tabs.findIndex((t) => t.uid === targetUid);
+ }
+
+ const sourceBoundary = sourceIdx < 0;
+ const targetBoundary = targetIdx < 0 || targetIdx >= tabs.length;
+ if (sourceBoundary || sourceIdx === targetIdx || targetBoundary) {
+ return;
+ }
+
+ const [moved] = tabs.splice(sourceIdx, 1);
+ tabs.splice(targetIdx, 0, moved);
+
+ state.tabs = tabs;
}
}
});
@@ -192,7 +218,8 @@ export const {
updateResponsePaneScrollPosition,
closeTabs,
closeAllCollectionTabs,
- makeTabPermanent
+ makeTabPermanent,
+ reorderTabs
} = tabsSlice.actions;
-export default tabsSlice.reducer;
\ No newline at end of file
+export default tabsSlice.reducer;
diff --git a/tests/collection/moving-tabs/move-tabs.spec.ts b/tests/collection/moving-tabs/move-tabs.spec.ts
new file mode 100644
index 000000000..c672f5435
--- /dev/null
+++ b/tests/collection/moving-tabs/move-tabs.spec.ts
@@ -0,0 +1,168 @@
+import { test, expect } from '../../../playwright';
+
+test.describe('Move tabs', () => {
+ test('Verify tab move by drag and drop', async ({ pageWithUserData: page, createTmpDir }) => {
+ // Create a collection
+ await page.locator('.dropdown-icon').click();
+ await page.locator('.dropdown-item').filter({ hasText: 'Create Collection' }).click();
+ await page.getByLabel('Name').fill('source-collection');
+ await page.getByLabel('Location').fill(await createTmpDir('source-collection'));
+ await page.getByRole('button', { name: 'Create', exact: true }).click();
+
+ // Wait for collection to appear and click on it
+ await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'source-collection' })).toBeVisible();
+ await page.locator('#sidebar-collection-name').filter({ hasText: 'source-collection' }).click();
+ await page.getByLabel('Safe Mode').check();
+ await page.getByRole('button', { name: 'Save' }).click();
+
+ // Create a folder in the collection
+ const sourceCollection = page.locator('.collection-name').filter({ hasText: 'source-collection' });
+ await sourceCollection.locator('.collection-actions').hover();
+ await sourceCollection.locator('.collection-actions .icon').click();
+ 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 page.getByRole('button', { name: 'Create' }).click();
+
+ // Wait for the folder to be created and appear in the sidebar
+ await page.waitForTimeout(2000);
+ await expect(page.locator('.collection-item-name').filter({ hasText: 'test-folder' })).toBeVisible();
+
+ // Open the folder tab
+ await page.locator('.collection-item-name').filter({ hasText: 'test-folder' }).dblclick();
+ await page.waitForTimeout(500);
+ await expect(page.locator('.request-tab .tab-label').filter({ hasText: 'test-folder' })).toBeVisible();
+
+ // Add a request to the collection
+ await sourceCollection.locator('.collection-actions').hover();
+ await sourceCollection.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://httpbin.org/get');
+ await page.getByRole('button', { name: 'Create' }).click();
+
+ // Wait for the request to be created
+ await page.waitForTimeout(1000);
+ await expect(page.locator('.collection-item-name').filter({ hasText: 'test-request' })).toBeVisible();
+
+ // Open the request tab
+ await page.locator('.collection-item-name').filter({ hasText: 'test-request' }).dblclick();
+ await page.waitForTimeout(500);
+ await expect(page.locator('.request-tab .tab-label').filter({ hasText: 'test-request' })).toBeVisible();
+
+ // Verify order of tabs before move
+ const tabs = page.locator('.request-tab .tab-label');
+ await expect(tabs.nth(0)).toHaveText('test-folder');
+ await expect(tabs.nth(1)).toHaveText('GETtest-request');
+
+ // Drag and drop the request tab before the folder tab
+ let source = page.locator('.request-tab .tab-label').filter({ hasText: 'test-request' });
+ let target = page.locator('.request-tab .tab-label').filter({ hasText: 'test-folder' });
+ let sourceBox = await source.boundingBox();
+ let targetBox = await target.boundingBox();
+
+ if (sourceBox && targetBox) {
+ await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, { steps: 5 });
+ await page.mouse.up();
+ }
+
+ // Verify order of tabs after drag and drop
+ await expect(tabs.nth(0)).toHaveText('GETtest-request');
+ await expect(tabs.nth(1)).toHaveText('test-folder');
+
+ // Drag and drop the request tab back to its original position
+ source = page.locator('.request-tab .tab-label').filter({ hasText: 'test-request' });
+ target = page.locator('.request-tab .tab-label').filter({ hasText: 'test-folder' });
+ sourceBox = await source.boundingBox();
+ targetBox = await target.boundingBox();
+
+ if (sourceBox && targetBox) {
+ await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height + 10, { steps: 5 });
+ await page.mouse.up();
+ }
+ });
+
+ test('Verify tab move by keyboard shortcut', async ({ pageWithUserData: page, createTmpDir }) => {
+ // Create a collection
+ await page.locator('.dropdown-icon').click();
+ await page.locator('.dropdown-item').filter({ hasText: 'Create Collection' }).click();
+ await page.getByLabel('Name').fill('source-collection');
+ await page.getByLabel('Location').fill(await createTmpDir('source-collection'));
+ await page.getByRole('button', { name: 'Create', exact: true }).click();
+
+ // Wait for collection to appear and click on it
+ await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'source-collection' })).toBeVisible();
+ await page.locator('#sidebar-collection-name').filter({ hasText: 'source-collection' }).click();
+ await page.getByLabel('Safe Mode').check();
+ await page.getByRole('button', { name: 'Save' }).click();
+
+ // Create a folder in the collection
+ const sourceCollection = page.locator('.collection-name').filter({ hasText: 'source-collection' });
+ await sourceCollection.locator('.collection-actions').hover();
+ await sourceCollection.locator('.collection-actions .icon').click();
+ 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 page.getByRole('button', { name: 'Create' }).click();
+
+ // Wait for the folder to be created and appear in the sidebar
+ await page.waitForTimeout(2000);
+ await expect(page.locator('.collection-item-name').filter({ hasText: 'test-folder' })).toBeVisible();
+
+ // Open the folder tab
+ await page.locator('.collection-item-name').filter({ hasText: 'test-folder' }).dblclick();
+ await page.waitForTimeout(500);
+ await expect(page.locator('.request-tab .tab-label').filter({ hasText: 'test-folder' })).toBeVisible();
+
+ // Add a request to the collection
+ await sourceCollection.locator('.collection-actions').hover();
+ await sourceCollection.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://httpbin.org/get');
+ await page.getByRole('button', { name: 'Create' }).click();
+
+ // Wait for the request to be created
+ await page.waitForTimeout(1000);
+ await expect(page.locator('.collection-item-name').filter({ hasText: 'test-request' })).toBeVisible();
+
+ // Open the request tab
+ await page.locator('.collection-item-name').filter({ hasText: 'test-request' }).dblclick();
+ await page.waitForTimeout(500);
+ await expect(page.locator('.request-tab .tab-label').filter({ hasText: 'test-request' })).toBeVisible();
+
+ // Verify order of tabs before move
+ const tabs = page.locator('.request-tab .tab-label');
+ await expect(tabs.nth(0)).toHaveText('test-folder');
+ await expect(tabs.nth(1)).toHaveText('GETtest-request');
+
+ // Move the request tab before the folder tab using keyboard shortcut
+ const source = page.locator('.request-tab .tab-label').filter({ hasText: 'test-request' });
+ await source.click();
+ await page.keyboard.press('ControlOrMeta+Shift+PageUp');
+ await page.waitForTimeout(500);
+
+ // Verify order of tabs after move
+ await expect(tabs.nth(0)).toHaveText('GETtest-request');
+ await expect(tabs.nth(1)).toHaveText('test-folder');
+
+ // Move the request tab back to its original position using keyboard shortcut
+ await source.click();
+ await page.keyboard.press('ControlOrMeta+Shift+PageDown');
+ await page.waitForTimeout(500);
+
+ // Verify order of tabs after move
+ await expect(tabs.nth(0)).toHaveText('test-folder');
+ await expect(tabs.nth(1)).toHaveText('GETtest-request');
+ });
+});