diff --git a/packages/bruno-app/jest.config.js b/packages/bruno-app/jest.config.js new file mode 100644 index 000000000..18896316c --- /dev/null +++ b/packages/bruno-app/jest.config.js @@ -0,0 +1,15 @@ +/** @type {import('jest').Config} */ +const config = { + moduleNameMapper: { + '^assets/(.*)$': '/src/assets/$1', + '^components/(.*)$': '/src/components/$1', + '^hooks/(.*)$': '/src/hooks/$1', + '^themes/(.*)$': '/src/themes/$1', + '^api/(.*)$': '/src/api/$1', + '^pageComponents/(.*)$': '/src/pageComponents/$1', + '^providers/(.*)$': '/src/providers/$1', + '^utils/(.*)$': '/src/utils/$1' + } +}; + +module.exports = config; diff --git a/packages/bruno-app/src/components/CtrlTabPopup/StyledWrapper.js b/packages/bruno-app/src/components/CtrlTabPopup/StyledWrapper.js new file mode 100644 index 000000000..d4132b97e --- /dev/null +++ b/packages/bruno-app/src/components/CtrlTabPopup/StyledWrapper.js @@ -0,0 +1,12 @@ +import styled from 'styled-components'; + +const Wrapper = styled.dialog` + background-color: ${(props) => props.theme.ctrlTabPopup.bg}; + color: ${(props) => props.theme.ctrlTabPopup.text}; + button:focus { + outline: none; + background-color: ${(props) => props.theme.ctrlTabPopup.highlightBg}; + } +`; + +export default Wrapper; diff --git a/packages/bruno-app/src/components/CtrlTabPopup/index.js b/packages/bruno-app/src/components/CtrlTabPopup/index.js new file mode 100644 index 000000000..c6db54f67 --- /dev/null +++ b/packages/bruno-app/src/components/CtrlTabPopup/index.js @@ -0,0 +1,81 @@ +import React from 'react'; +import { findItemInCollection, findCollectionByUid } from 'utils/collections'; +import { useDispatch, useSelector } from 'react-redux'; +import classnames from 'classnames'; +import StyledWrapper from './StyledWrapper'; +import reverse from 'lodash/reverse'; +import { getSpecialTabName, isSpecialTab, isItemAFolder } from 'utils/tabs'; +import { selectCtrlTabAction } from 'providers/ReduxStore/slices/tabs'; + +export const tabStackToPopupTabs = (collections, ctrlTabStack) => { + if (!collections) { + return []; + } + + return ctrlTabStack + .map((tab) => { + const collection = findCollectionByUid(collections, tab.collectionUid); + return { collection, tab: findItemInCollection(collection, tab.uid) ?? tab }; + }) + .map(({ collection, tab }) => ({ + tabName: isSpecialTab(tab) ? getSpecialTabName(tab.type) : tab.name, + path: getItemPath(collection, tab), + uid: tab.uid + })); +}; + +const getItemPath = (collection, item) => { + if (isSpecialTab(item)) { + return collection.name; + } + if (collection.items.find((i) => i.uid === item.uid)) { + return collection.name; + } + + return ( + collection.name + '/' + collection.items.map((i) => (isItemAFolder(i) ? getItemPath(i, item) : null)).find(Boolean) + ); +}; + +// required in cases where we remove a tab from the stack but the user is still holding ctrl +const tabStackToUniqueId = (ctrlTabStack) => ctrlTabStack.map((tab) => tab.uid).join('-'); + +export default function CtrlTabPopup() { + const ctrlTabIndex = useSelector((state) => state.tabs.ctrlTabIndex); + const ctrlTabStack = useSelector((state) => state.tabs.ctrlTabStack); + const collections = useSelector((state) => state.collections.collections); + const dispatch = useDispatch(); + + if (ctrlTabIndex === null) { + return null; + } + + const popupTabs = tabStackToPopupTabs(collections, ctrlTabStack); + + const currentTabbedTab = popupTabs.at(ctrlTabIndex); + + return ( +
+ + {reverse(popupTabs).map((popupTab) => ( + + ))} + +
+ ); +} diff --git a/packages/bruno-app/src/components/CtrlTabPopup/index.spec.js b/packages/bruno-app/src/components/CtrlTabPopup/index.spec.js new file mode 100644 index 000000000..0530caa8b --- /dev/null +++ b/packages/bruno-app/src/components/CtrlTabPopup/index.spec.js @@ -0,0 +1,59 @@ +import { tabStackToPopupTabs } from './index'; + +describe('CtrlTabPopup', () => { + describe('tabStackToPopupTabs', () => { + it('should return an empty array if collections is falsy', () => { + const collections = null; + const ctrlTabStack = []; + + const result = tabStackToPopupTabs(collections, ctrlTabStack); + + expect(result).toEqual([]); + }); + + it('should return an array of popup tabs', () => { + const collections = [ + { + name: 'Collection 1', + uid: 'collection1', + items: [{ name: 'Request 1', uid: 'aaa', type: 'http-request', collectionUid: 'collection1' }] + }, + { + name: 'Collection 2', + uid: 'collection2', + items: [ + { name: 'Request 2', uid: 'ccc', type: 'http-request', collectionUid: 'collection2' }, + { + name: 'Folder 1', + type: 'folder', + items: [ + { name: 'Request 3', uid: 'ddd', type: 'http-request', collectionUid: 'collection2' }, + + { + name: 'Folder 2', + type: 'folder', + items: [{ name: 'Request 4', uid: 'eee', type: 'http-request', collectionUid: 'collection2' }] + } + ] + } + ] + } + ]; + const ctrlTabStack = [ + { collectionUid: 'collection1', uid: 'aaa', type: 'http-request' }, + { collectionUid: 'collection2', uid: 'ddd', type: 'http-request' }, + { collectionUid: 'collection2', uid: 'eee', type: 'http-request' }, + { collectionUid: 'collection2', uid: 'yyy', type: 'collection-settings' } + ]; + + const result = tabStackToPopupTabs(collections, ctrlTabStack); + + expect(result).toEqual([ + { tabName: 'Request 1', path: 'Collection 1', uid: 'aaa' }, + { tabName: 'Request 3', path: 'Collection 2/Folder 1', uid: 'ddd' }, + { tabName: 'Request 4', path: 'Collection 2/Folder 1/Folder 2', uid: 'eee' }, + { tabName: 'Settings', path: 'Collection 2', uid: 'yyy' } + ]); + }); + }); +}); diff --git a/packages/bruno-app/src/pages/Bruno/index.js b/packages/bruno-app/src/pages/Bruno/index.js index 71e24dcfa..00c553c98 100644 --- a/packages/bruno-app/src/pages/Bruno/index.js +++ b/packages/bruno-app/src/pages/Bruno/index.js @@ -9,6 +9,7 @@ import StyledWrapper from './StyledWrapper'; import 'codemirror/theme/material.css'; import 'codemirror/theme/monokai.css'; import 'codemirror/addon/scroll/simplescrollbars.css'; +import CtrlTabPopup from 'components/CtrlTabPopup'; const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true; if (!SERVER_RENDERED) { @@ -60,6 +61,7 @@ export default function Main() { return (
+
{showHomePage ? ( diff --git a/packages/bruno-app/src/providers/Hotkeys/index.js b/packages/bruno-app/src/providers/Hotkeys/index.js index 1b28b891b..cbc3867aa 100644 --- a/packages/bruno-app/src/providers/Hotkeys/index.js +++ b/packages/bruno-app/src/providers/Hotkeys/index.js @@ -9,7 +9,7 @@ import NetworkError from 'components/ResponsePane/NetworkError'; import NewRequest from 'components/Sidebar/NewRequest'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import { findCollectionByUid, findItemInCollection } from 'utils/collections'; -import { closeTabs, switchTab } from 'providers/ReduxStore/slices/tabs'; +import { CTRL_TAB_ACTIONS, closeTabs, switchTab } from 'providers/ReduxStore/slices/tabs'; export const HotkeysContext = React.createContext(); @@ -154,6 +154,48 @@ export const HotkeysProvider = (props) => { }; }, [activeTabUid]); + useEffect(() => { + const shortcuts = ['mod+tab']; + const shiftedShortcuts = ['shift+mod+tab']; + + const bindCtrlTabShortcut = () => { + Mousetrap.bind(shortcuts, () => { + dispatch(ctrlTab(CTRL_TAB_ACTIONS.ENTER)); + Mousetrap.unbind(shortcuts); + + Mousetrap.bind(shortcuts, () => { + dispatch(ctrlTab(CTRL_TAB_ACTIONS.PLUS)); + return false; // this stops the event bubbling + }); + Mousetrap.bind(shiftedShortcuts, () => { + dispatch(ctrlTab(CTRL_TAB_ACTIONS.MINUS)); + return false; // this stops the event bubbling + }); + + Mousetrap.bind( + ['mod'], + () => { + dispatch(ctrlTab(CTRL_TAB_ACTIONS.SWITCH)); + Mousetrap.unbind(['mod'], 'keyup'); + Mousetrap.unbind(shortcuts); + Mousetrap.unbind(shiftedShortcuts); + + bindCtrlTabShortcut(); + return false; // this stops the event bubbling + }, + 'keyup' + ); + + return false; // this stops the event bubbling + }); + }; + bindCtrlTabShortcut(); + + return () => { + Mousetrap.unbind(shortcuts); + }; + }, []); + // Switch to the previous tab useEffect(() => { Mousetrap.bind(['command+pageup', 'ctrl+pageup'], (e) => { diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js index 935be6075..006b1ca7c 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js @@ -7,13 +7,48 @@ import last from 'lodash/last'; const initialState = { tabs: [], - activeTabUid: null + activeTabUid: null, + ctrlTabStack: [], + ctrlTabIndex: null }; const tabTypeAlreadyExists = (tabs, collectionUid, type) => { return find(tabs, (tab) => tab.collectionUid === collectionUid && tab.type === type); }; +const uidToTab = (state, uid) => find(state.tabs, (tab) => tab.uid === uid); + +const focusTabWithStack = (state, uid) => { + if (state.activeTabUid === uid) { + return; + } + if (state.activeTabUid) { + const previousTab = uidToTab(state, state.activeTabUid); + const currentTab = uidToTab(state, uid); + state.ctrlTabStack = [ + ...filter(state.ctrlTabStack, (tab) => tab.uid !== state.activeTabUid && tab.uid !== uid), + ...(previousTab ? [previousTab] : []), // if previousTab is undefined, it means the tab was closed while focused + currentTab + ]; + } + state.activeTabUid = uid; +}; + +const removeClosedTabs = (state, filterFunction) => { + state.tabs = filter(state.tabs, filterFunction); + state.ctrlTabStack = filter(state.ctrlTabStack, filterFunction); + if (state.ctrlTabStack.length < 2) { + state.ctrlTabIndex = null; + } +}; + +export const CTRL_TAB_ACTIONS = Object.freeze({ + ENTER: 'enter', + PLUS: 'plus', + MINUS: 'minus', + SWITCH: 'switch' +}); + export const tabsSlice = createSlice({ name: 'tabs', initialState, @@ -29,7 +64,7 @@ export const tabsSlice = createSlice({ ) { const tab = tabTypeAlreadyExists(state.tabs, action.payload.collectionUid, action.payload.type); if (tab) { - state.activeTabUid = tab.uid; + focusTabWithStack(state, tab.uid); return; } } @@ -43,10 +78,38 @@ export const tabsSlice = createSlice({ type: action.payload.type || 'request', ...(action.payload.uid ? { folderUid: action.payload.uid } : {}) }); - state.activeTabUid = action.payload.uid; + focusTabWithStack(state, action.payload.uid); }, focusTab: (state, action) => { - state.activeTabUid = action.payload.uid; + focusTabWithStack(state, action.payload.uid); + }, + focusCtrlTab: (state, action) => { + focusTabWithStack(state, action.payload.uid); + state.ctrlTabIndex = null; + }, + ctrlTab: (state, action) => { + if (state.ctrlTabStack.length < 2) { + return; + } + switch (action.payload) { + case CTRL_TAB_ACTIONS.ENTER: + state.ctrlTabIndex = -2; + return; + case CTRL_TAB_ACTIONS.PLUS: + state.ctrlTabIndex = (state.ctrlTabIndex - 1) % state.ctrlTabStack.length; + return; + case CTRL_TAB_ACTIONS.MINUS: + state.ctrlTabIndex = (state.ctrlTabIndex + 1) % state.ctrlTabStack.length; + return; + case CTRL_TAB_ACTIONS.SWITCH: + if (state.ctrlTabIndex === null) { + // if already switched (eg, from click), do nothing + return; + } + focusTabWithStack(state, state.ctrlTabStack.at(state.ctrlTabIndex).uid); + state.ctrlTabIndex = null; + return; + } }, switchTab: (state, action) => { if (!state.tabs || !state.tabs.length) { @@ -94,7 +157,7 @@ export const tabsSlice = createSlice({ const tabUids = action.payload.tabUids || []; // remove the tabs from the state - state.tabs = filter(state.tabs, (t) => !tabUids.includes(t.uid)); + removeClosedTabs(state, (t) => !tabUids.includes(t.uid)); if (activeTab && state.tabs.length) { const { collectionUid } = activeTab; @@ -109,9 +172,9 @@ export const tabsSlice = createSlice({ // if there are sibling tabs, set the active tab to the last sibling tab // otherwise, set the active tab to the last tab in the list if (siblingTabs && siblingTabs.length) { - state.activeTabUid = last(siblingTabs).uid; + focusTabWithStack(state, last(siblingTabs).uid); } else { - state.activeTabUid = last(state.tabs).uid; + focusTabWithStack(state, last(state.tabs).uid); } } } @@ -122,15 +185,24 @@ export const tabsSlice = createSlice({ }, closeAllCollectionTabs: (state, action) => { const collectionUid = action.payload.collectionUid; - state.tabs = filter(state.tabs, (t) => t.collectionUid !== collectionUid); + removeClosedTabs(state, (t) => t.collectionUid !== collectionUid); state.activeTabUid = null; } } }); +export const selectCtrlTabAction = (uid) => (dispatch) => { + dispatch( + tabsSlice.actions.focusCtrlTab({ + uid + }) + ); +}; + export const { addTab, focusTab, + ctrlTab, switchTab, updateRequestPaneTabWidth, updateRequestPaneTab, diff --git a/packages/bruno-app/src/themes/dark.js b/packages/bruno-app/src/themes/dark.js index 9e8e923aa..7a44bb967 100644 --- a/packages/bruno-app/src/themes/dark.js +++ b/packages/bruno-app/src/themes/dark.js @@ -174,7 +174,11 @@ const darkTheme = { opacity: 0.2 } }, - + ctrlTabPopup: { + bg: '#1f2937', + text: '#ccc', + highlightBg: '#374151' + }, button: { secondary: { color: 'rgb(204, 204, 204)', diff --git a/packages/bruno-app/src/themes/light.js b/packages/bruno-app/src/themes/light.js index a25583136..7c13fd1af 100644 --- a/packages/bruno-app/src/themes/light.js +++ b/packages/bruno-app/src/themes/light.js @@ -178,7 +178,11 @@ const lightTheme = { opacity: 0.4 } }, - + ctrlTabPopup: { + bg: '#fff8f7', + text: 'rgb(52, 52, 52)', + highlightBg: '#ffb63154' + }, button: { secondary: { color: '#212529', diff --git a/packages/bruno-app/src/utils/tabs/index.js b/packages/bruno-app/src/utils/tabs/index.js index a6fa29dd7..f509a906b 100644 --- a/packages/bruno-app/src/utils/tabs/index.js +++ b/packages/bruno-app/src/utils/tabs/index.js @@ -11,3 +11,24 @@ export const isItemAFolder = (item) => { export const itemIsOpenedInTabs = (item, tabs) => { return find(tabs, (t) => t.uid === item.uid); }; + +export const isSpecialTab = ({ type }) => { + if (!type) { + return false; + } + return ['variables', 'collection-settings', 'collection-runner'].includes(type); +}; + +export const getSpecialTabName = (type) => { + switch (type) { + case 'variables': + return 'Variables'; + case 'collection-settings': + return 'Settings'; + case 'collection-runner': + return 'Runner'; + default: + console.error('Unknown special tab type', type); + return type; + } +};