From b1840d189d4c416d79a720726a50b506aa039be2 Mon Sep 17 00:00:00 2001 From: Roland Schaer Date: Tue, 30 Sep 2025 05:29:25 +0200 Subject: [PATCH] feat: make tabs reorderable (#5413) --- .../src/components/RequestTabs/index.js | 43 ++++- .../bruno-app/src/providers/Hotkeys/index.js | 26 ++- .../src/providers/Hotkeys/keyMappings.js | 10 ++ .../src/providers/ReduxStore/slices/tabs.js | 33 +++- .../collection/moving-tabs/move-tabs.spec.ts | 168 ++++++++++++++++++ 5 files changed, 274 insertions(+), 6 deletions(-) create mode 100644 tests/collection/moving-tabs/move-tabs.spec.ts diff --git a/packages/bruno-app/src/components/RequestTabs/index.js b/packages/bruno-app/src/components/RequestTabs/index.js index fcba790a6..296c17d6f 100644 --- a/packages/bruno-app/src/components/RequestTabs/index.js +++ b/packages/bruno-app/src/components/RequestTabs/index.js @@ -4,7 +4,7 @@ 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'; @@ -14,6 +14,8 @@ const RequestTabs = () => { const dispatch = useDispatch(); const tabsRef = useRef(); const [newRequestModalOpen, setNewRequestModalOpen] = useState(false); + const [draggedTabUid, setDraggedTabUid] = useState(null); + const [dragOverTabUid, setDragOverTabUid] = useState(null); const tabs = useSelector((state) => state.tabs.tabs); const activeTabUid = useSelector((state) => state.tabs.activeTabUid); const collections = useSelector((state) => state.collections.collections); @@ -24,10 +26,42 @@ const RequestTabs = () => { const getTabClassname = (tab, index) => { return classnames('request-tab select-none', { active: tab.uid === activeTabUid, - 'last-tab': tabs && tabs.length && index === tabs.length - 1 + 'last-tab': tabs && tabs.length && index === tabs.length - 1, + 'dragged': tab.uid === draggedTabUid, + 'drag-over': tab.uid === dragOverTabUid && draggedTabUid !== null }); }; + const handleDragStart = (e, tab) => { + setDraggedTabUid(tab.uid); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', tab.uid); + }; + + const handleDragOver = (e, tab) => { + e.preventDefault(); + setDragOverTabUid(tab.uid); + }; + + const handleDrop = (e, targetTab) => { + e.preventDefault(); + setDragOverTabUid(null); + const sourceUid = draggedTabUid; + setDraggedTabUid(null); + if (!sourceUid || sourceUid === targetTab.uid) { + return; + } + dispatch(reorderTabs({ + sourceUid, + targetUid: targetTab.uid + })); + }; + + const handleDragEnd = () => { + setDraggedTabUid(null); + setDragOverTabUid(null); + }; + const handleClick = (tab) => { dispatch( focusTab({ @@ -111,6 +145,11 @@ const RequestTabs = () => { className={getTabClassname(tab, index)} role="tab" onClick={() => handleClick(tab)} + draggable + onDragStart={(e) => handleDragStart(e, tab)} + onDragOver={(e) => handleDragOver(e, tab)} + onDrop={(e) => handleDrop(e, tab)} + onDragEnd={() => handleDragEnd} > { }; }, [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'); + }); +});