From f0866be3b34f25ae28826d358079aa911005092b Mon Sep 17 00:00:00 2001 From: shubh-bruno Date: Tue, 31 Mar 2026 12:39:00 +0530 Subject: [PATCH] feat: keybindings customisation (#7603) --- .../FileEditor/CodeEditor/index.js | 10 - .../src/components/CodeEditor/index.js | 26 +- .../Console/TerminalTab/SessionList.js | 4 +- .../src/components/GlobalSearchModal/index.js | 1 + .../src/components/MultiLineEditor/index.js | 26 +- .../Preferences/Keybindings/StyledWrapper.js | 343 +++- .../Preferences/Keybindings/index.js | 982 +++++++++- .../components/Preferences/StyledWrapper.js | 4 +- .../RequestPane/QueryEditor/index.js | 22 - .../src/components/RequestTabPanel/index.js | 8 +- .../RequestTabs/CollectionHeader/index.js | 4 +- .../RequestTabs/RequestTab/index.js | 71 +- .../CloneCollectionItem/index.js | 2 +- .../RenameCollectionItem/index.js | 2 +- .../Collection/CollectionItem/index.js | 64 +- .../Sidebar/Collections/Collection/index.js | 30 +- .../src/components/Sidebar/NewFolder/index.js | 1 + .../components/Sidebar/NewRequest/index.js | 2 +- .../Sections/CollectionsSection/index.js | 13 +- .../src/components/SingleLineEditor/index.js | 11 +- .../src/hooks/useKeybinding/index.js | 42 + .../App/ConfirmAppClose/SaveRequestsModal.js | 54 +- .../bruno-app/src/providers/Hotkeys/index.js | 535 ++++-- .../src/providers/Hotkeys/keyMappings.js | 209 ++- .../src/providers/ReduxStore/slices/app.js | 10 + .../src/providers/ReduxStore/slices/tabs.js | 34 +- .../bruno-electron/src/app/menu-template.js | 11 +- .../collection/moving-tabs/move-tabs.spec.ts | 6 +- .../focus-retention/environment-focus.spec.ts | 5 +- tests/shortcuts/bound-actions.spec.ts | 1624 +++++++++++++++++ tests/utils/page/actions.ts | 13 +- 31 files changed, 3719 insertions(+), 450 deletions(-) create mode 100644 packages/bruno-app/src/hooks/useKeybinding/index.js create mode 100644 tests/shortcuts/bound-actions.spec.ts diff --git a/packages/bruno-app/src/components/ApiSpecPanel/FileEditor/CodeEditor/index.js b/packages/bruno-app/src/components/ApiSpecPanel/FileEditor/CodeEditor/index.js index 7f88c99e9..e30f0aeed 100644 --- a/packages/bruno-app/src/components/ApiSpecPanel/FileEditor/CodeEditor/index.js +++ b/packages/bruno-app/src/components/ApiSpecPanel/FileEditor/CodeEditor/index.js @@ -57,16 +57,6 @@ export default class CodeEditor extends React.Component { scrollbarStyle: 'overlay', theme: this.props.theme === 'dark' ? 'monokai' : 'default', extraKeys: { - 'Cmd-S': () => { - if (this.props.onSave) { - this.props.onSave(); - } - }, - 'Ctrl-S': () => { - if (this.props.onSave) { - this.props.onSave(); - } - }, 'Cmd-F': 'findPersistent', 'Ctrl-F': 'findPersistent', 'Cmd-H': 'replace', diff --git a/packages/bruno-app/src/components/CodeEditor/index.js b/packages/bruno-app/src/components/CodeEditor/index.js index 98593967f..95eaa8894 100644 --- a/packages/bruno-app/src/components/CodeEditor/index.js +++ b/packages/bruno-app/src/components/CodeEditor/index.js @@ -74,26 +74,6 @@ export default class CodeEditor extends React.Component { scrollbarStyle: 'overlay', theme: this.props.theme === 'dark' ? 'monokai' : 'default', extraKeys: { - 'Cmd-Enter': () => { - if (this.props.onRun) { - this.props.onRun(); - } - }, - 'Ctrl-Enter': () => { - if (this.props.onRun) { - this.props.onRun(); - } - }, - 'Cmd-S': () => { - if (this.props.onSave) { - this.props.onSave(); - } - }, - 'Ctrl-S': () => { - if (this.props.onSave) { - this.props.onSave(); - } - }, 'Cmd-F': (cm) => { this.setState({ searchBarVisible: true }, () => { this.searchBarRef.current?.focus(); @@ -217,6 +197,12 @@ export default class CodeEditor extends React.Component { // Setup lint error tooltip on line number hover this.cleanupLintErrorTooltip = setupLintErrorTooltip(editor); + + // Add mousetrap class so Mousetrap captures shortcuts even when CodeMirror is focused + const cmInput = editor.getInputField(); + if (cmInput) { + cmInput.classList.add('mousetrap'); + } } } diff --git a/packages/bruno-app/src/components/Devtools/Console/TerminalTab/SessionList.js b/packages/bruno-app/src/components/Devtools/Console/TerminalTab/SessionList.js index cf8327c36..94abbc9ae 100644 --- a/packages/bruno-app/src/components/Devtools/Console/TerminalTab/SessionList.js +++ b/packages/bruno-app/src/components/Devtools/Console/TerminalTab/SessionList.js @@ -113,7 +113,7 @@ const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSessio return ( - {sessions.map((session) => { + {sessions.map((session, idx) => { const { name } = getSessionDisplayInfo(session); return (
onSelectSession(session.sessionId)} >
@@ -133,6 +134,7 @@ const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSessio
{ e.stopPropagation(); onCloseSession(session.sessionId); diff --git a/packages/bruno-app/src/components/GlobalSearchModal/index.js b/packages/bruno-app/src/components/GlobalSearchModal/index.js index d802cbcd2..647d02732 100644 --- a/packages/bruno-app/src/components/GlobalSearchModal/index.js +++ b/packages/bruno-app/src/components/GlobalSearchModal/index.js @@ -402,6 +402,7 @@ const GlobalSearchModal = ({ isOpen, onClose }) => { aria-activedescendant={results.length > 0 ? `search-result-${selectedIndex}` : undefined} role="combobox" aria-autocomplete="list" + data-testid="global-search-input" /> {query && ( + )} + + {showPencil && ( + + + + )} + + {showLock && ( + + )} +
+ )} +
+ + + ); + })} + + ))} + + + + ) : ( +
No key bindings available
+ )} ); diff --git a/packages/bruno-app/src/components/Preferences/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/StyledWrapper.js index bde16f70b..1d5925522 100644 --- a/packages/bruno-app/src/components/Preferences/StyledWrapper.js +++ b/packages/bruno-app/src/components/Preferences/StyledWrapper.js @@ -3,7 +3,7 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` div.tabs { padding: 12px; - min-width: 160px; + min-width: 180px; div.tab { display: flex; @@ -38,7 +38,7 @@ const StyledWrapper = styled.div` } section.tab-panel { - min-height: 70vh; + max-height: calc(100% - 55px); overflow-y: auto; flex-grow: 1; padding: 12px; diff --git a/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js b/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js index 76e16ddef..6eb9f6f5a 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js +++ b/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js @@ -103,16 +103,6 @@ export default class QueryEditor extends React.Component { 'Alt-Space': () => editor.showHint({ completeSingle: true, container: this._node }), 'Shift-Space': () => editor.showHint({ completeSingle: true, container: this._node }), 'Shift-Alt-Space': () => editor.showHint({ completeSingle: true, container: this._node }), - 'Cmd-Enter': () => { - if (this.props.onRun) { - this.props.onRun(); - } - }, - 'Ctrl-Enter': () => { - if (this.props.onRun) { - this.props.onRun(); - } - }, 'Shift-Ctrl-C': () => { if (this.props.onCopyQuery) { this.props.onCopyQuery(); @@ -134,18 +124,6 @@ export default class QueryEditor extends React.Component { this.props.onMergeQuery(); } }, - 'Cmd-S': () => { - if (this.props.onSave) { - this.props.onSave(); - return false; - } - }, - 'Ctrl-S': () => { - if (this.props.onSave) { - this.props.onSave(); - return false; - } - }, 'Cmd-F': 'findPersistent', 'Ctrl-F': 'findPersistent' } diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index e56585e6d..2a20fca61 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -32,6 +32,7 @@ import WsQueryUrl from 'components/RequestPane/WsQueryUrl'; import WSRequestPane from 'components/RequestPane/WSRequestPane'; import WSResponsePane from 'components/ResponsePane/WsResponsePane'; import { useTabPaneBoundaries } from 'hooks/useTabPaneBoundaries/index'; +import useKeybinding from 'hooks/useKeybinding'; import { ScopedPersistenceProvider } from 'hooks/usePersistedState/PersistedScopeProvider'; import ResponseExample from 'components/ResponseExample'; import WorkspaceOverview from 'components/WorkspaceHome/WorkspaceOverview'; @@ -59,6 +60,12 @@ const RequestTabPanel = () => { const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical'; const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen); + const isRequestTab = focusedTab && ['request', 'grpc-request', 'ws-request', 'graphql-request'].includes(focusedTab.type); + useKeybinding('sendRequest', () => { + handleRun(); + return false; + }, { enabled: !!isRequestTab, deps: [isRequestTab] }); + // Use ref to avoid stale closure in event handlers const isVerticalLayoutRef = useRef(isVerticalLayout); useEffect(() => { @@ -304,7 +311,6 @@ const RequestTabPanel = () => { })); } }; - const renderQueryUrl = () => { if (isGrpcRequest) { return ; diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js index d3139801a..7c832aeca 100644 --- a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js +++ b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js @@ -580,14 +580,14 @@ const CollectionHeader = ({ collection, isScratchCollection }) => { )} {/* Runner - always visible */} - + {/* JS Sandbox Mode - always visible */} {/* Overflow menu */} - + diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js index 6c1a3644d..dd1efc646 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js @@ -1,7 +1,8 @@ import React, { useCallback, useState, useRef, Fragment, useMemo, useEffect } from 'react'; import get from 'lodash/get'; import { makeTabPermanent } from 'providers/ReduxStore/slices/tabs'; -import { saveRequest, saveCollectionRoot, saveFolderRoot, saveEnvironment, closeTabs } from 'providers/ReduxStore/slices/collections/actions'; +import { saveRequest, saveCollectionRoot, saveFolderRoot, saveEnvironment, saveCollectionSettings, closeTabs } from 'providers/ReduxStore/slices/collections/actions'; +import useKeybinding from 'hooks/useKeybinding'; import { deleteRequestDraft, deleteCollectionDraft, deleteFolderDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections'; import { clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-environments'; import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; @@ -167,6 +168,74 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi const globalEnvironmentDraft = useSelector((state) => state.globalEnvironments.globalEnvironmentDraft); const hasGlobalEnvironmentDraft = tab.type === 'global-environment-settings' && globalEnvironmentDraft; + const activeTabUid = useSelector((state) => state.tabs.activeTabUid); + const isActive = tab.uid === activeTabUid; + + // Close tab shortcut — draft-aware, only active for the focused tab + useKeybinding('closeTab', () => { + if (tab.type === 'request' || tab.type === 'grpc-request' || tab.type === 'ws-request' || tab.type === 'graphql-request') { + if (hasChanges) { + setShowConfirmClose(true); + } else { + if (item?.type === 'ws-request') { + closeWsConnection(item.uid); + } + dispatch(closeTabs({ tabUids: [tab.uid] })); + } + } else if (tab.type === 'collection-settings') { + if (collection?.draft) { + setShowConfirmCollectionClose(true); + } else { + dispatch(closeTabs({ tabUids: [tab.uid] })); + } + } else if (tab.type === 'folder-settings') { + if (folder?.draft) { + setShowConfirmFolderClose(true); + } else { + dispatch(closeTabs({ tabUids: [tab.uid] })); + } + } else if (tab.type === 'environment-settings') { + if (collection?.environmentsDraft) { + setShowConfirmEnvironmentClose(true); + } else { + dispatch(closeTabs({ tabUids: [tab.uid] })); + } + } else if (tab.type === 'global-environment-settings') { + if (globalEnvironmentDraft) { + setShowConfirmGlobalEnvironmentClose(true); + } else { + dispatch(closeTabs({ tabUids: [tab.uid] })); + } + } else { + dispatch(closeTabs({ tabUids: [tab.uid] })); + } + return false; + }, { enabled: isActive, deps: [isActive, tab, hasChanges, item, collection, folder, globalEnvironmentDraft] }); + + // Save shortcut — tab-type-aware, only active for the focused tab + useKeybinding('save', () => { + if (tab.type === 'environment-settings') { + if (collection?.environmentsDraft) { + const { environmentUid, variables } = collection.environmentsDraft; + dispatch(saveEnvironment(variables, environmentUid, collection.uid)); + } + } else if (tab.type === 'global-environment-settings') { + if (globalEnvironmentDraft) { + const { environmentUid, variables } = globalEnvironmentDraft; + dispatch(saveGlobalEnvironment({ variables, environmentUid })); + } + } else if (tab.type === 'folder-settings') { + if (folder) { + dispatch(saveFolderRoot(collection.uid, folder.uid)); + } + } else if (tab.type === 'collection-settings') { + dispatch(saveCollectionSettings(collection.uid)); + } else if (item && item.uid) { + dispatch(saveRequest(tab.uid, tab.collectionUid)); + } + return false; + }, { enabled: isActive, deps: [isActive, tab, item, collection, folder, globalEnvironmentDraft] }); + const handleCloseEnvironmentSettings = (event) => { if (!collection?.environmentsDraft) { return handleCloseClick(event); diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index.js index 5d0a537da..eac42ef42 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index.js @@ -203,7 +203,7 @@ const CloneCollectionItem = ({ collectionUid, item, onClose }) => { - diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RenameCollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RenameCollectionItem/index.js index 73c2ddc03..e10b0fe93 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RenameCollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RenameCollectionItem/index.js @@ -221,7 +221,7 @@ const RenameCollectionItem = ({ collectionUid, item, onClose }) => { - diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js index e4160593d..ad6f4688c 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js @@ -26,7 +26,7 @@ import { handleCollectionItemDrop, sendRequest, showInFolder, pasteItem, saveReq import { toggleCollectionItem, addResponseExample } from 'providers/ReduxStore/slices/collections'; import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app'; import { uuid } from 'utils/common'; -import { copyRequest } from 'providers/ReduxStore/slices/app'; +import { copyRequest, setFocusedSidebarPath } from 'providers/ReduxStore/slices/app'; import NewRequest from 'components/Sidebar/NewRequest'; import NewFolder from 'components/Sidebar/NewFolder'; import RenameCollectionItem from './RenameCollectionItem'; @@ -39,7 +39,6 @@ import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from ' import { getDefaultRequestPaneTab } from 'utils/collections'; import toast from 'react-hot-toast'; import StyledWrapper from './StyledWrapper'; -import { getKeyBindingsForActionAllOS } from 'providers/Hotkeys/keyMappings'; import NetworkError from 'components/ResponsePane/NetworkError/index'; import CollectionItemInfo from './CollectionItemInfo/index'; import CollectionItemIcon from './CollectionItemIcon'; @@ -57,6 +56,7 @@ import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal'; import ActionIcon from 'ui/ActionIcon'; import MenuDropdown from 'ui/MenuDropdown'; import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext'; +import useKeybinding from 'hooks/useKeybinding'; const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) => { const { dropdownContainerRef } = useSidebarAccordion(); @@ -93,6 +93,27 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) // Check if request has examples (only for HTTP requests) const hasExamples = isItemARequest(item) && item.type === 'http-request' && item.examples && item.examples.length > 0; + // Sidebar shortcuts — only active when this sidebar item has keyboard focus + useKeybinding('cloneItem', () => { + setCloneItemModalOpen(true); + return false; + }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); + + useKeybinding('copyItem', () => { + handleCopyItem(); + return false; + }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); + + useKeybinding('pasteItem', () => { + handlePasteItem(); + return false; + }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); + + useKeybinding('renameItem', () => { + setRenameItemModalOpen(true); + return false; + }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); + const [dropType, setDropType] = useState(null); // 'adjacent' or 'inside' const [{ isDragging }, drag, dragPreview] = useDrag({ @@ -544,12 +565,9 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) }; const handlePasteItem = () => { - // Determine target folder: if item is a folder, paste into it; otherwise paste into parent folder - let targetFolderUid = item.uid; - if (!isFolder) { - const parentFolder = findParentItemInCollection(collection, item.uid); - targetFolderUid = parentFolder ? parentFolder.uid : null; - } + // Paste as sibling: find the parent folder so the pasted item appears next to the focused item + const parentFolder = findParentItemInCollection(collection, item.uid); + const targetFolderUid = parentFolder ? parentFolder.uid : null; dispatch(pasteItem(collectionUid, targetFolderUid)) .then(() => { @@ -560,38 +578,15 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) }); }; - // Keyboard shortcuts handler - const handleKeyDown = (e) => { - // Detect Mac by checking both metaKey and platform - const isMac = navigator.userAgent?.includes('Mac') || navigator.platform?.startsWith('Mac'); - const isModifierPressed = isMac ? e.metaKey : e.ctrlKey; - - const [macRenameKey, winRenameKey] = getKeyBindingsForActionAllOS('renameItem'); - const renameKey = isMac ? macRenameKey : winRenameKey; - - // Only trigger rename if no modifier keys are pressed (allow Cmd+Enter for run request) - const hasModifier = e.metaKey || e.ctrlKey || e.shiftKey || e.altKey; - if (e.key.toLowerCase() === renameKey && !hasModifier) { - e.preventDefault(); - e.stopPropagation(); - setRenameItemModalOpen(true); - } else if (isModifierPressed && e.key.toLowerCase() === 'c') { - e.preventDefault(); - e.stopPropagation(); - handleCopyItem(); - } else if (isModifierPressed && e.key.toLowerCase() === 'v') { - e.preventDefault(); - e.stopPropagation(); - handlePasteItem(); - } - }; - const handleFocus = () => { setIsKeyboardFocused(true); + // For folders, set the folder path; for requests, set empty string (no terminal) + dispatch(setFocusedSidebarPath(isFolder ? item.pathname : '')); }; const handleBlur = () => { setIsKeyboardFocused(false); + dispatch(setFocusedSidebarPath(null)); }; return ( @@ -634,7 +629,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) drag(drop(node)); }} tabIndex={0} - onKeyDown={handleKeyDown} onFocus={handleFocus} onBlur={handleBlur} onContextMenu={handleContextMenu} diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js index 0ba4ec39a..9a4c6344f 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js @@ -27,6 +27,7 @@ import { toggleCollection, collapseFullCollection } from 'providers/ReduxStore/s import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop, pasteItem, showInFolder, saveCollectionSecurityConfig } from 'providers/ReduxStore/slices/collections/actions'; import { useDispatch, useSelector } from 'react-redux'; import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs'; +import { setFocusedSidebarPath } from 'providers/ReduxStore/slices/app'; import toast from 'react-hot-toast'; import NewRequest from 'components/Sidebar/NewRequest'; import NewFolder from 'components/Sidebar/NewFolder'; @@ -52,6 +53,7 @@ import StatusBadge from 'ui/StatusBadge'; import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features'; import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext'; import { createEmptyStateMenuItems } from 'utils/collections/emptyStateRequest'; +import useKeybinding from 'hooks/useKeybinding'; // Delay before showing empty collection state (ms) // This prevents flicker from race condition between loading state and item batch updates @@ -202,25 +204,30 @@ const Collection = ({ collection, searchText }) => { }); }; - // Keyboard shortcuts handler for collection - const handleKeyDown = (e) => { - // Detect Mac by checking both metaKey and platform - const isMac = navigator.userAgent?.includes('Mac') || navigator.platform?.startsWith('Mac'); - const isModifierPressed = isMac ? e.metaKey : e.ctrlKey; + // Sidebar shortcuts — only active when this collection has keyboard focus + useKeybinding('cloneItem', () => { + setShowCloneCollectionModalOpen(true); + return false; + }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); - if (isModifierPressed && e.key.toLowerCase() === 'v') { - e.preventDefault(); - e.stopPropagation(); - handlePasteItem(); - } - }; + useKeybinding('renameItem', () => { + setShowRenameCollectionModal(true); + return false; + }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); + + useKeybinding('pasteItem', () => { + handlePasteItem(); + return false; + }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); const handleFocus = () => { setIsKeyboardFocused(true); + dispatch(setFocusedSidebarPath(collection.pathname)); }; const handleBlur = () => { setIsKeyboardFocused(false); + dispatch(setFocusedSidebarPath(null)); }; const isCollectionItem = (itemType) => { @@ -468,7 +475,6 @@ const Collection = ({ collection, searchText }) => { drag(drop(node)); }} tabIndex={0} - onKeyDown={handleKeyDown} onFocus={handleFocus} onBlur={handleBlur} data-testid="sidebar-collection-row" diff --git a/packages/bruno-app/src/components/Sidebar/NewFolder/index.js b/packages/bruno-app/src/components/Sidebar/NewFolder/index.js index ec6a36aa3..00c868f41 100644 --- a/packages/bruno-app/src/components/Sidebar/NewFolder/index.js +++ b/packages/bruno-app/src/components/Sidebar/NewFolder/index.js @@ -104,6 +104,7 @@ const NewFolder = ({ collectionUid, item, onClose }) => { formik.setFieldValue('folderName', e.target.value); !isEditing && formik.setFieldValue('directoryName', sanitizeName(e.target.value)); }} + data-testid="new-folder-input" value={formik.values.folderName || ''} /> {formik.touched.folderName && formik.errors.folderName ? ( diff --git a/packages/bruno-app/src/components/Sidebar/NewRequest/index.js b/packages/bruno-app/src/components/Sidebar/NewRequest/index.js index e8b2ad5bd..4023d801d 100644 --- a/packages/bruno-app/src/components/Sidebar/NewRequest/index.js +++ b/packages/bruno-app/src/components/Sidebar/NewRequest/index.js @@ -605,7 +605,7 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => { - diff --git a/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js b/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js index 8e1544dcf..30530367e 100644 --- a/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js @@ -18,7 +18,7 @@ import { import { importCollection, openCollection, importCollectionFromZip, newHttpRequest } from 'providers/ReduxStore/slices/collections/actions'; import { sortCollections } from 'providers/ReduxStore/slices/collections/index'; -import { savePreferences, setIsCreatingCollection } from 'providers/ReduxStore/slices/app'; +import { savePreferences, setIsCreatingCollection, toggleSidebarSearch } from 'providers/ReduxStore/slices/app'; import { normalizePath } from 'utils/common/path'; import { isScratchCollection, flattenItems, isItemTransientRequest } from 'utils/collections'; import { sanitizeName } from 'utils/common/regex'; @@ -36,10 +36,11 @@ import WelcomeModal from 'components/WelcomeModal'; import Collections from 'components/Sidebar/Collections'; import SidebarSection from 'components/Sidebar/SidebarSection'; import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal'; +import useKeybinding from 'hooks/useKeybinding'; const CollectionsSection = () => { - const [showSearch, setShowSearch] = useState(false); const dispatch = useDispatch(); + const showSearch = useSelector((state) => state.app.showSidebarSearch); const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces); const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid); @@ -58,6 +59,12 @@ const CollectionsSection = () => { const [showCloneGitModal, setShowCloneGitModal] = useState(false); const [gitRepositoryUrl, setGitRepositoryUrl] = useState(null); + // Import collection shortcut + useKeybinding('importCollection', () => { + setImportCollectionModalOpen(true); + return false; + }); + // Default to true (don't show modal) so that: // 1. Existing users who upgrade (no hasSeenWelcomeModal in their prefs) don't see it // 2. The modal doesn't flash before preferences are loaded from the electron process @@ -120,7 +127,7 @@ const CollectionsSection = () => { }; const handleToggleSearch = () => { - setShowSearch((prev) => !prev); + dispatch(toggleSidebarSearch()); }; const handleSortCollections = () => { diff --git a/packages/bruno-app/src/components/SingleLineEditor/index.js b/packages/bruno-app/src/components/SingleLineEditor/index.js index 8f2aae9e5..626b7058f 100644 --- a/packages/bruno-app/src/components/SingleLineEditor/index.js +++ b/packages/bruno-app/src/components/SingleLineEditor/index.js @@ -59,8 +59,6 @@ class SingleLineEditor extends Component { readOnly: this.props.readOnly, extraKeys: { 'Enter': runHandler, - 'Ctrl-Enter': runHandler, - 'Cmd-Enter': runHandler, 'Alt-Enter': () => { if (this.props.allowNewlines) { this.editor.setValue(this.editor.getValue() + '\n'); @@ -69,9 +67,6 @@ class SingleLineEditor extends Component { this.props.onRun(); } }, - 'Shift-Enter': runHandler, - 'Cmd-S': saveHandler, - 'Ctrl-S': saveHandler, 'Cmd-F': noopHandler, 'Ctrl-F': noopHandler, // Tabbing disabled to make tabindex work @@ -108,6 +103,12 @@ class SingleLineEditor extends Component { this._updateNewlineMarkers(); } setupLinkAware(this.editor); + + // Add mousetrap class so Mousetrap captures shortcuts even when CodeMirror is focused + const cmInput = this.editor.getInputField(); + if (cmInput) { + cmInput.classList.add('mousetrap'); + } } /** Enable or disable masking the rendered content of the editor */ diff --git a/packages/bruno-app/src/hooks/useKeybinding/index.js b/packages/bruno-app/src/hooks/useKeybinding/index.js new file mode 100644 index 000000000..029f24ee6 --- /dev/null +++ b/packages/bruno-app/src/hooks/useKeybinding/index.js @@ -0,0 +1,42 @@ +import { useEffect, useRef } from 'react'; +import Mousetrap from 'mousetrap'; +import { useSelector } from 'react-redux'; +import { getKeyBindingsForActionAllOS } from 'providers/Hotkeys/keyMappings'; + +/** + * Hook for binding a customizable keyboard shortcut to a handler. + * Reads merged keybindings (defaults + user overrides) and binds via Mousetrap. + * + * Use this for COMPONENT-LEVEL shortcuts (e.g. clone, rename) where the handler + * lives inside the component, not in HotkeysProvider. + * + * @param {string} action - The action ID from KEY_BINDING_SECTIONS (e.g. 'cloneItem') + * @param {Function} handler - Callback to run when the shortcut is pressed. Should return false to stop bubbling. + * @param {Object} [options] + * @param {boolean} [options.enabled=true] - Whether the binding is active. Pass false to skip binding. + * @param {Array} [options.deps=[]] - Additional dependencies that should trigger rebinding. + */ +function useKeybinding(action, handler, { enabled = true, deps = [] } = {}) { + const handlerRef = useRef(handler); + handlerRef.current = handler; + + const userKeyBindings = useSelector((state) => state.app.preferences?.keyBindings); + const keybindingsEnabled = useSelector((state) => state.app.preferences?.keybindingsEnabled !== false); + + useEffect(() => { + if (!enabled || !keybindingsEnabled) return; + + const combos = getKeyBindingsForActionAllOS(action, userKeyBindings); + if (!combos) return; + + Mousetrap.bind(combos, (e) => { + return handlerRef.current(e); + }); + + return () => { + Mousetrap.unbind(combos); + }; + }, [action, enabled, keybindingsEnabled, userKeyBindings, ...deps]); +} + +export default useKeybinding; diff --git a/packages/bruno-app/src/providers/App/ConfirmAppClose/SaveRequestsModal.js b/packages/bruno-app/src/providers/App/ConfirmAppClose/SaveRequestsModal.js index d87c9d7e4..7030c5fcf 100644 --- a/packages/bruno-app/src/providers/App/ConfirmAppClose/SaveRequestsModal.js +++ b/packages/bruno-app/src/providers/App/ConfirmAppClose/SaveRequestsModal.js @@ -7,13 +7,14 @@ import { useDispatch } from 'react-redux'; import { findCollectionByUid, flattenItems, isItemARequest, hasRequestChanges, findEnvironmentInCollection } from 'utils/collections'; import { pluralizeWord } from 'utils/common'; import { completeQuitFlow } from 'providers/ReduxStore/slices/app'; -import { saveMultipleRequests, saveMultipleCollections, saveMultipleFolders, saveEnvironment } from 'providers/ReduxStore/slices/collections/actions'; -import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; +import { saveMultipleRequests, saveMultipleCollections, saveMultipleFolders, saveEnvironment, closeTabs } from 'providers/ReduxStore/slices/collections/actions'; +import { saveGlobalEnvironment, clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-environments'; +import { deleteRequestDraft, deleteCollectionDraft, deleteFolderDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections'; import { IconAlertTriangle } from '@tabler/icons'; import Modal from 'components/Modal'; import Button from 'ui/Button'; -const SaveRequestsModal = ({ onClose }) => { +const SaveRequestsModal = ({ onClose, forceCloseTabs = false, tabUidsToClose = [] }) => { const MAX_UNSAVED_ITEMS_TO_SHOW = 5; const collections = useSelector((state) => state.collections.collections); const tabs = useSelector((state) => state.tabs.tabs); @@ -26,7 +27,8 @@ const SaveRequestsModal = ({ onClose }) => { const collectionDrafts = []; const folderDrafts = []; const environmentDrafts = []; - const tabsByCollection = groupBy(tabs, (t) => t.collectionUid); + const relevantTabs = forceCloseTabs ? tabs.filter((t) => tabUidsToClose.includes(t.uid)) : tabs; + const tabsByCollection = groupBy(relevantTabs, (t) => t.collectionUid); Object.keys(tabsByCollection).forEach((collectionUid) => { const collection = findCollectionByUid(collections, collectionUid); @@ -95,18 +97,48 @@ const SaveRequestsModal = ({ onClose }) => { } return [...collectionDrafts, ...folderDrafts, ...environmentDrafts, ...requestDrafts]; - }, [collections, tabs, globalEnvironments, globalEnvironmentDraft]); + }, [collections, tabs, globalEnvironments, globalEnvironmentDraft, forceCloseTabs, tabUidsToClose]); const totalDraftsCount = allDrafts.length; useEffect(() => { if (totalDraftsCount === 0) { - return dispatch(completeQuitFlow()); + if (forceCloseTabs) { + dispatch(closeTabs({ tabUids: tabUidsToClose })); + onClose(); + } else { + dispatch(completeQuitFlow()); + } } - }, [totalDraftsCount, dispatch]); + }, [totalDraftsCount, dispatch, forceCloseTabs, tabUidsToClose]); const closeWithoutSave = () => { - dispatch(completeQuitFlow()); + if (forceCloseTabs) { + // Discard all draft states before closing tabs + allDrafts.forEach((draft) => { + switch (draft.type) { + case 'collection': + dispatch(deleteCollectionDraft({ collectionUid: draft.collectionUid })); + break; + case 'folder': + dispatch(deleteFolderDraft({ collectionUid: draft.collectionUid, folderUid: draft.folderUid })); + break; + case 'collection-environment': + dispatch(clearEnvironmentsDraft({ collectionUid: draft.collectionUid })); + break; + case 'global-environment': + dispatch(clearGlobalEnvironmentDraft()); + break; + default: + // Request drafts + dispatch(deleteRequestDraft({ collectionUid: draft.collectionUid, itemUid: draft.uid })); + break; + } + }); + dispatch(closeTabs({ tabUids: tabUidsToClose })); + } else { + dispatch(completeQuitFlow()); + } onClose(); }; @@ -144,7 +176,11 @@ const SaveRequestsModal = ({ onClose }) => { await dispatch(saveGlobalEnvironment({ variables: draft.variables, environmentUid: draft.environmentUid })); } - dispatch(completeQuitFlow()); + if (forceCloseTabs) { + dispatch(closeTabs({ tabUids: tabUidsToClose })); + } else { + dispatch(completeQuitFlow()); + } onClose(); } catch (error) { console.error('Error saving drafts:', error); diff --git a/packages/bruno-app/src/providers/Hotkeys/index.js b/packages/bruno-app/src/providers/Hotkeys/index.js index a87b67e29..0ae807af7 100644 --- a/packages/bruno-app/src/providers/Hotkeys/index.js +++ b/packages/bruno-app/src/providers/Hotkeys/index.js @@ -1,22 +1,17 @@ import React, { useState, useEffect } from 'react'; -import toast from 'react-hot-toast'; import find from 'lodash/find'; import Mousetrap from 'mousetrap'; import { useSelector, useDispatch } from 'react-redux'; -import NetworkError from 'components/ResponsePane/NetworkError'; import NewRequest from 'components/Sidebar/NewRequest'; import GlobalSearchModal from 'components/GlobalSearchModal'; -import { - sendRequest, - saveRequest, - saveCollectionRoot, - saveFolderRoot, - saveCollectionSettings, - closeTabs -} from 'providers/ReduxStore/slices/collections/actions'; -import { findCollectionByUid, findItemInCollection } from 'utils/collections'; -import { addTab, reorderTabs, switchTab } from 'providers/ReduxStore/slices/tabs'; -import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app'; +import SaveRequestsModal from 'providers/App/ConfirmAppClose/SaveRequestsModal'; +import filter from 'lodash/filter'; +import each from 'lodash/each'; +import { findCollectionByUid, findItemInCollection, flattenItems, isItemARequest, hasRequestChanges, findEnvironmentInCollection } from 'utils/collections'; +import { addTab, focusTab, reorderTabs, reopenLastClosedTab } from 'providers/ReduxStore/slices/tabs'; +import { saveMultipleRequests, saveMultipleCollections, saveMultipleFolders, saveEnvironment } from 'providers/ReduxStore/slices/collections/actions'; +import { toggleSidebarCollapse, toggleSidebarSearch, savePreferences } from 'providers/ReduxStore/slices/app'; +import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal'; import { getKeyBindingsForActionAllOS } from './keyMappings'; export const HotkeysContext = React.createContext(); @@ -26,8 +21,13 @@ export const HotkeysProvider = (props) => { const tabs = useSelector((state) => state.tabs.tabs); const collections = useSelector((state) => state.collections.collections); const activeTabUid = useSelector((state) => state.tabs.activeTabUid); + const userKeyBindings = useSelector((state) => state.app.preferences?.keyBindings); + const keybindingsEnabled = useSelector((state) => state.app.preferences?.keybindingsEnabled !== false); const [showNewRequestModal, setShowNewRequestModal] = useState(false); const [showGlobalSearchModal, setShowGlobalSearchModal] = useState(false); + const [showSaveRequestsModal, setShowSaveRequestsModal] = useState(false); + const [tabUidsToClose, setTabUidsToClose] = useState([]); + const preferences = useSelector((state) => state.app.preferences); const getCurrentCollection = () => { const activeTab = find(tabs, (t) => t.uid === activeTabUid); @@ -38,81 +38,33 @@ export const HotkeysProvider = (props) => { } }; - // save hotkey + // Get tabs scoped to the active tab's collection + const getCollectionTabs = () => { + const activeTab = find(tabs, (t) => t.uid === activeTabUid); + if (!activeTab) return []; + return tabs.filter((t) => t.collectionUid === activeTab.collectionUid); + }; + + // Helper: get Mousetrap combos for an action, merged with user overrides + const getCombos = (action) => getKeyBindingsForActionAllOS(action, userKeyBindings); + + // Helper: bind a shortcut only if keybindings are enabled + const bindAction = (action, handler) => { + if (!keybindingsEnabled) return; + const combos = getCombos(action); + if (!combos) return; + Mousetrap.bind(combos, handler); + }; + + const unbindAction = (action) => { + const combos = getCombos(action); + if (!combos) return; + Mousetrap.unbind(combos); + }; + + // edit environments useEffect(() => { - Mousetrap.bind([...getKeyBindingsForActionAllOS('save')], (e) => { - const activeTab = find(tabs, (t) => t.uid === activeTabUid); - if (activeTab) { - if (activeTab.type === 'environment-settings' || activeTab.type === 'global-environment-settings') { - window.dispatchEvent(new CustomEvent('environment-save')); - return false; - } - - const collection = findCollectionByUid(collections, activeTab.collectionUid); - if (collection) { - const item = findItemInCollection(collection, activeTab.uid); - if (item && item.uid) { - if (activeTab.type === 'folder-settings') { - dispatch(saveFolderRoot(collection.uid, item.uid)); - } else { - dispatch(saveRequest(activeTab.uid, activeTab.collectionUid)); - } - } else if (activeTab.type === 'collection-settings') { - dispatch(saveCollectionSettings(collection.uid)); - } - } - } - - return false; // this stops the event bubbling - }); - - return () => { - Mousetrap.unbind([...getKeyBindingsForActionAllOS('save')]); - }; - }, [activeTabUid, tabs, saveRequest, collections, dispatch]); - - // send request (ctrl/cmd + enter) - useEffect(() => { - Mousetrap.bind([...getKeyBindingsForActionAllOS('sendRequest')], (e) => { - const activeTab = find(tabs, (t) => t.uid === activeTabUid); - if (activeTab) { - const collection = findCollectionByUid(collections, activeTab.collectionUid); - - if (collection) { - const item = findItemInCollection(collection, activeTab.uid); - if (item) { - if (item.type === 'grpc-request') { - const request = item.draft ? item.draft.request : item.request; - if (!request.url) { - toast.error('Please enter a valid gRPC server URL'); - return; - } - if (!request.method) { - toast.error('Please select a gRPC method'); - return; - } - } - - dispatch(sendRequest(item, collection.uid)).catch((err) => - toast.custom((t) => toast.dismiss(t.id)} />, { - duration: 5000 - }) - ); - } - } - } - - return false; // this stops the event bubbling - }); - - return () => { - Mousetrap.unbind([...getKeyBindingsForActionAllOS('sendRequest')]); - }; - }, [activeTabUid, tabs, saveRequest, collections]); - - // edit environments (ctrl/cmd + e) - useEffect(() => { - Mousetrap.bind([...getKeyBindingsForActionAllOS('editEnvironment')], (e) => { + bindAction('editEnvironment', (e) => { const activeTab = find(tabs, (t) => t.uid === activeTabUid); if (activeTab) { const collection = findCollectionByUid(collections, activeTab.collectionUid); @@ -132,13 +84,13 @@ export const HotkeysProvider = (props) => { }); return () => { - Mousetrap.unbind([...getKeyBindingsForActionAllOS('editEnvironment')]); + unbindAction('editEnvironment'); }; - }, [activeTabUid, tabs, collections, dispatch]); + }, [activeTabUid, tabs, collections, dispatch, userKeyBindings, keybindingsEnabled]); - // new request (ctrl/cmd + b) + // new request useEffect(() => { - Mousetrap.bind([...getKeyBindingsForActionAllOS('newRequest')], (e) => { + bindAction('newRequest', (e) => { const activeTab = find(tabs, (t) => t.uid === activeTabUid); if (activeTab) { const collection = findCollectionByUid(collections, activeTab.collectionUid); @@ -152,90 +104,96 @@ export const HotkeysProvider = (props) => { }); return () => { - Mousetrap.unbind([...getKeyBindingsForActionAllOS('newRequest')]); + unbindAction('newRequest'); }; - }, [activeTabUid, tabs, collections, setShowNewRequestModal]); + }, [activeTabUid, tabs, collections, setShowNewRequestModal, userKeyBindings, keybindingsEnabled]); - // global search (ctrl/cmd + k) + // global search useEffect(() => { - Mousetrap.bind([...getKeyBindingsForActionAllOS('globalSearch')], (e) => { + bindAction('globalSearch', (e) => { setShowGlobalSearchModal(true); return false; // stop bubbling }); return () => { - Mousetrap.unbind([...getKeyBindingsForActionAllOS('globalSearch')]); + unbindAction('globalSearch'); }; - }, []); + }, [userKeyBindings, keybindingsEnabled]); - // close tab hotkey + // Switch to the previous tab (active-collection-tabs-only) useEffect(() => { - Mousetrap.bind([...getKeyBindingsForActionAllOS('closeTab')], (e) => { - if (activeTabUid) { - dispatch( - closeTabs({ - tabUids: [activeTabUid] - }) - ); + bindAction('switchToPreviousTab', (e) => { + const collectionTabs = getCollectionTabs(); + if (collectionTabs.length === 0) return false; + const currentIndex = collectionTabs.findIndex((t) => t.uid === activeTabUid); + const prevIndex = (currentIndex - 1 + collectionTabs.length) % collectionTabs.length; + dispatch(focusTab({ uid: collectionTabs[prevIndex].uid })); + return false; + }); + + return () => { + unbindAction('switchToPreviousTab'); + }; + }, [activeTabUid, tabs, dispatch, userKeyBindings, keybindingsEnabled]); + + // Switch to the next tab (active-collection-tabs-only) + useEffect(() => { + bindAction('switchToNextTab', (e) => { + const collectionTabs = getCollectionTabs(); + if (collectionTabs.length === 0) return false; + const currentIndex = collectionTabs.findIndex((t) => t.uid === activeTabUid); + const nextIndex = (currentIndex + 1) % collectionTabs.length; + dispatch(focusTab({ uid: collectionTabs[nextIndex].uid })); + return false; + }); + + return () => { + unbindAction('switchToNextTab'); + }; + }, [activeTabUid, tabs, dispatch, userKeyBindings, keybindingsEnabled]); + + // Switch to tab at position (Cmd+1 through Cmd+8) and last tab (Cmd+9) — collection-scoped + useEffect(() => { + for (let i = 1; i <= 8; i++) { + bindAction(`switchToTab${i}`, (e) => { + const collectionTabs = getCollectionTabs(); + const tab = collectionTabs[i - 1]; + if (tab) { + dispatch(focusTab({ uid: tab.uid })); + } + return false; + }); + } + + bindAction('switchToLastTab', (e) => { + const collectionTabs = getCollectionTabs(); + const lastTab = collectionTabs[collectionTabs.length - 1]; + if (lastTab) { + dispatch(focusTab({ uid: lastTab.uid })); } - - return false; // this stops the event bubbling + return false; }); return () => { - Mousetrap.unbind([...getKeyBindingsForActionAllOS('closeTab')]); + for (let i = 1; i <= 8; i++) { + unbindAction(`switchToTab${i}`); + } + unbindAction('switchToLastTab'); }; - }, [activeTabUid]); - - // Switch to the previous tab - useEffect(() => { - Mousetrap.bind([...getKeyBindingsForActionAllOS('switchToPreviousTab')], (e) => { - dispatch( - switchTab({ - direction: 'pageup' - }) - ); - - return false; // this stops the event bubbling - }); - - return () => { - Mousetrap.unbind([...getKeyBindingsForActionAllOS('switchToPreviousTab')]); - }; - }, [dispatch]); - - // Switch to the next tab - useEffect(() => { - Mousetrap.bind([...getKeyBindingsForActionAllOS('switchToNextTab')], (e) => { - dispatch( - switchTab({ - direction: 'pagedown' - }) - ); - - return false; // this stops the event bubbling - }); - - return () => { - Mousetrap.unbind([...getKeyBindingsForActionAllOS('switchToNextTab')]); - }; - }, [dispatch]); + }, [activeTabUid, tabs, dispatch, userKeyBindings, keybindingsEnabled]); // Close all tabs useEffect(() => { - Mousetrap.bind([...getKeyBindingsForActionAllOS('closeAllTabs')], (e) => { + bindAction('closeAllTabs', (e) => { const activeTab = find(tabs, (t) => t.uid === activeTabUid); if (activeTab) { const collection = findCollectionByUid(collections, activeTab.collectionUid); if (collection) { const tabUids = tabs.filter((tab) => tab.collectionUid === collection.uid).map((tab) => tab.uid); - dispatch( - closeTabs({ - tabUids: tabUids - }) - ); + setTabUidsToClose(tabUids); + setShowSaveRequestsModal(true); } } @@ -243,45 +201,278 @@ export const HotkeysProvider = (props) => { }); return () => { - Mousetrap.unbind([...getKeyBindingsForActionAllOS('closeAllTabs')]); + unbindAction('closeAllTabs'); }; - }, [activeTabUid, tabs, collections, dispatch]); + }, [activeTabUid, tabs, collections, userKeyBindings, keybindingsEnabled]); - // Collapse sidebar (ctrl/cmd + \) + // Reopen last closed tab (active-collection-tabs-only) useEffect(() => { - Mousetrap.bind([...getKeyBindingsForActionAllOS('collapseSidebar')], (e) => { + bindAction('reopenLastClosedTab', (e) => { + const activeTab = find(tabs, (t) => t.uid === activeTabUid); + if (activeTab) { + dispatch(reopenLastClosedTab({ collectionUid: activeTab.collectionUid })); + } + return false; + }); + + return () => { + unbindAction('reopenLastClosedTab'); + }; + }, [activeTabUid, tabs, dispatch, userKeyBindings, keybindingsEnabled]); + + // Save all tabs (active-collection-tabs-only) + useEffect(() => { + bindAction('saveAllTabs', (e) => { + const collection = getCurrentCollection(); + if (!collection) return false; + const collectionUid = collection.uid; + + const requestDrafts = []; + const collectionDrafts = []; + const folderDrafts = []; + + // Collection settings draft + if (collection.draft) { + collectionDrafts.push({ collectionUid }); + } + + // Environment draft + if (collection.environmentsDraft) { + const { environmentUid, variables } = collection.environmentsDraft; + const environment = findEnvironmentInCollection(collection, environmentUid); + if (environment && variables) { + dispatch(saveEnvironment(variables, environmentUid, collectionUid)); + } + } + + // Request and folder drafts + const items = flattenItems(collection.items); + const requests = filter(items, (item) => isItemARequest(item) && hasRequestChanges(item)); + each(requests, (draft) => { + requestDrafts.push({ ...draft, collectionUid }); + }); + + const folders = filter(items, (item) => item.type === 'folder' && item.draft); + each(folders, (folder) => { + folderDrafts.push({ folderUid: folder.uid, collectionUid }); + }); + + if (collectionDrafts.length > 0) { + dispatch(saveMultipleCollections(collectionDrafts)); + } + if (folderDrafts.length > 0) { + dispatch(saveMultipleFolders(folderDrafts)); + } + if (requestDrafts.length > 0) { + dispatch(saveMultipleRequests(requestDrafts)); + } + + return false; + }); + + return () => { + unbindAction('saveAllTabs'); + }; + }, [activeTabUid, tabs, collections, dispatch, userKeyBindings, keybindingsEnabled]); + + // Collapse sidebar + useEffect(() => { + bindAction('collapseSidebar', (e) => { dispatch(toggleSidebarCollapse()); return false; }); return () => { - Mousetrap.unbind([...getKeyBindingsForActionAllOS('collapseSidebar')]); + unbindAction('collapseSidebar'); }; - }, [dispatch]); + }, [dispatch, userKeyBindings, keybindingsEnabled]); - // Move tab left + // Sidebar search useEffect(() => { - Mousetrap.bind([...getKeyBindingsForActionAllOS('moveTabLeft')], (e) => { - dispatch(reorderTabs({ direction: -1 })); - return false; // this stops the event bubbling + bindAction('sidebarSearch', (e) => { + dispatch(toggleSidebarSearch()); + return false; }); return () => { - Mousetrap.unbind([...getKeyBindingsForActionAllOS('moveTabLeft')]); + unbindAction('sidebarSearch'); }; - }, [dispatch]); + }, [dispatch, userKeyBindings, keybindingsEnabled]); + + // Open terminal — context-aware: + // focusedSidebarPath: null = no sidebar focus, '' = request focused (no-op), '/path' = folder/collection + const focusedSidebarPath = useSelector((state) => state.app.focusedSidebarPath); + const activeWorkspace = useSelector((state) => { + const { workspaces, activeWorkspaceUid } = state.workspaces; + return workspaces?.find((w) => w.uid === activeWorkspaceUid); + }); - // Move tab right useEffect(() => { - Mousetrap.bind([...getKeyBindingsForActionAllOS('moveTabRight')], (e) => { - dispatch(reorderTabs({ direction: 1 })); - return false; // this stops the event bubbling + bindAction('openTerminal', (e) => { + // 1. Sidebar focus takes priority + if (focusedSidebarPath) { + openDevtoolsAndSwitchToTerminal(dispatch, focusedSidebarPath); + return false; + } + if (focusedSidebarPath === '') { + // Request focused in sidebar → no-op + return false; + } + + // 2. No sidebar focus → check active tab type + const activeTab = find(tabs, (t) => t.uid === activeTabUid); + if (activeTab) { + if (activeTab.type === 'collection-settings' && activeTab.collectionUid) { + const collection = findCollectionByUid(collections, activeTab.collectionUid); + if (collection?.pathname) { + openDevtoolsAndSwitchToTerminal(dispatch, collection.pathname); + return false; + } + } else if (activeTab.type === 'folder-settings' && activeTab.collectionUid && activeTab.uid) { + const collection = findCollectionByUid(collections, activeTab.collectionUid); + if (collection) { + const item = findItemInCollection(collection, activeTab.uid); + if (item?.pathname) { + openDevtoolsAndSwitchToTerminal(dispatch, item.pathname); + return false; + } + } + } + } + + // 3. Default to workspace root + if (activeWorkspace?.pathname) { + openDevtoolsAndSwitchToTerminal(dispatch, activeWorkspace.pathname); + } + return false; }); return () => { - Mousetrap.unbind([...getKeyBindingsForActionAllOS('moveTabRight')]); + unbindAction('openTerminal'); }; - }, [dispatch]); + }, [focusedSidebarPath, activeTabUid, tabs, collections, activeWorkspace, dispatch, userKeyBindings, keybindingsEnabled]); + + // Move tab left (active-collection-tabs-only) + useEffect(() => { + bindAction('moveTabLeft', (e) => { + const collectionTabs = getCollectionTabs(); + const currentIndex = collectionTabs.findIndex((t) => t.uid === activeTabUid); + if (currentIndex <= 0) return false; // already at leftmost position in collection + dispatch(reorderTabs({ sourceUid: activeTabUid, targetUid: collectionTabs[currentIndex - 1].uid })); + return false; + }); + + return () => { + unbindAction('moveTabLeft'); + }; + }, [activeTabUid, tabs, dispatch, userKeyBindings, keybindingsEnabled]); + + // Move tab right (active-collection-tabs-only) + useEffect(() => { + bindAction('moveTabRight', (e) => { + const collectionTabs = getCollectionTabs(); + const currentIndex = collectionTabs.findIndex((t) => t.uid === activeTabUid); + if (currentIndex < 0 || currentIndex >= collectionTabs.length - 1) return false; // already at rightmost + dispatch(reorderTabs({ sourceUid: activeTabUid, targetUid: collectionTabs[currentIndex + 1].uid })); + return false; + }); + + return () => { + unbindAction('moveTabRight'); + }; + }, [activeTabUid, tabs, dispatch, userKeyBindings, keybindingsEnabled]); + + // Open preferences + useEffect(() => { + bindAction('openPreferences', (e) => { + const activeTab = find(tabs, (t) => t.uid === activeTabUid); + const collectionUid = activeTab?.collectionUid || activeWorkspace?.scratchCollectionUid; + + dispatch( + addTab({ + type: 'preferences', + uid: collectionUid ? `${collectionUid}-preferences` : 'preferences', + collectionUid + }) + ); + return false; + }); + + return () => { + unbindAction('openPreferences'); + }; + }, [activeTabUid, tabs, activeWorkspace, dispatch, userKeyBindings, keybindingsEnabled]); + + // Change layout orientation + useEffect(() => { + bindAction('changeLayout', (e) => { + const orientation = preferences?.layout?.responsePaneOrientation || 'horizontal'; + const newOrientation = orientation === 'horizontal' ? 'vertical' : 'horizontal'; + dispatch(savePreferences({ + ...preferences, + layout: { + ...preferences?.layout, + responsePaneOrientation: newOrientation + } + })); + return false; + }); + + return () => { + unbindAction('changeLayout'); + }; + }, [preferences, dispatch, userKeyBindings, keybindingsEnabled]); + + // Zoom in + useEffect(() => { + bindAction('zoomIn', () => { + const { ipcRenderer } = window; + ipcRenderer?.invoke('renderer:zoom-in'); + return false; + }); + + return () => { + unbindAction('zoomIn'); + }; + }, [userKeyBindings, keybindingsEnabled]); + + // Zoom out + useEffect(() => { + bindAction('zoomOut', () => { + const { ipcRenderer } = window; + ipcRenderer?.invoke('renderer:zoom-out'); + return false; + }); + + return () => { + unbindAction('zoomOut'); + }; + }, [userKeyBindings, keybindingsEnabled]); + + // Reset zoom + useEffect(() => { + bindAction('resetZoom', () => { + const { ipcRenderer } = window; + ipcRenderer?.invoke('renderer:reset-zoom'); + return false; + }); + + return () => { + unbindAction('resetZoom'); + }; + }, [userKeyBindings, keybindingsEnabled]); + + // Close Bruno + useEffect(() => { + bindAction('closeBruno', () => { + window.close(); + return false; + }); + + return () => { + unbindAction('closeBruno'); + }; + }, [userKeyBindings, keybindingsEnabled]); const currentCollection = getCurrentCollection(); @@ -293,6 +484,16 @@ export const HotkeysProvider = (props) => { {showGlobalSearchModal && ( setShowGlobalSearchModal(false)} /> )} + {showSaveRequestsModal && ( + { + setShowSaveRequestsModal(false); + setTabUidsToClose([]); + }} + /> + )}
{props.children}
); diff --git a/packages/bruno-app/src/providers/Hotkeys/keyMappings.js b/packages/bruno-app/src/providers/Hotkeys/keyMappings.js index 0c439baf2..998592d8e 100644 --- a/packages/bruno-app/src/providers/Hotkeys/keyMappings.js +++ b/packages/bruno-app/src/providers/Hotkeys/keyMappings.js @@ -1,76 +1,173 @@ -const KeyMapping = { - save: { mac: 'command+s', windows: 'ctrl+s', name: 'Save' }, - sendRequest: { mac: 'command+enter', windows: 'ctrl+enter', name: 'Send Request' }, - editEnvironment: { mac: 'command+e', windows: 'ctrl+e', name: 'Edit Environment' }, - newRequest: { mac: 'command+b', windows: 'ctrl+b', name: 'New Request' }, - globalSearch: { mac: 'command+k', windows: 'ctrl+k', name: 'Global Search' }, - closeTab: { mac: 'command+w', windows: 'ctrl+w', name: 'Close Tab' }, - openPreferences: { mac: 'command+,', windows: 'ctrl+,', name: 'Open Preferences' }, - closeBruno: { - mac: 'command+Q', - windows: 'ctrl+shift+q', - name: 'Close Bruno' +export const KEY_BINDING_SECTIONS = [ + { + heading: 'Tabs', + bindings: { + closeTab: { mac: 'command+bind+w', windows: 'ctrl+bind+w', name: 'Close Tab' }, // D + closeAllTabs: { mac: 'command+bind+shift+bind+w', windows: 'ctrl+bind+shift+bind+w', name: 'Close All Tabs' }, // D + save: { mac: 'command+bind+s', windows: 'ctrl+bind+s', name: 'Save' }, // D + saveAllTabs: { mac: 'command+bind+shift+bind+s', windows: 'ctrl+bind+shift+bind+s', name: 'Save All Tabs' }, // D + reopenLastClosedTab: { mac: 'command+bind+shift+bind+t', windows: 'ctrl+bind+shift+bind+t', name: 'Reopen Last Closed Tab' }, // D + switchToTabAtPosition: { mac: 'command+bind+1+bind+command+bind+8', windows: 'ctrl+bind+1+bind+ctrl+bind+8', name: 'Switch to Tab at Position', readOnly: true, displayValue: { mac: 'command+bind+1 - command+bind+8', windows: 'ctrl+bind+1 - ctrl+bind+8' } }, // D + switchToLastTab: { mac: 'command+bind+9', windows: 'ctrl+bind+9', name: 'Switch to Last Tab' }, // D + switchToPreviousTab: { mac: 'shift+bind+command+bind+[', windows: 'shift+bind+ctrl+bind+[', name: 'Switch to Previous Tab' }, // D + switchToNextTab: { mac: 'shift+bind+command+bind+]', windows: 'shift+bind+ctrl+bind+]', name: 'Switch to Next Tab' }, + moveTabLeft: { mac: 'command+bind+[', windows: 'ctrl+bind+[', name: 'Move Tab Left' }, // D + moveTabRight: { mac: 'command+bind+]', windows: 'ctrl+bind+]', name: 'Move Tab Right' }, // D + switchToTab1: { mac: 'command+bind+1', windows: 'ctrl+bind+1', name: 'Switch to Tab at Position', readOnly: true, hidden: true }, + switchToTab2: { mac: 'command+bind+2', windows: 'ctrl+bind+2', name: 'Switch to Tab at Position', readOnly: true, hidden: true }, + switchToTab3: { mac: 'command+bind+3', windows: 'ctrl+bind+3', name: 'Switch to Tab at Position', readOnly: true, hidden: true }, + switchToTab4: { mac: 'command+bind+4', windows: 'ctrl+bind+4', name: 'Switch to Tab at Position', readOnly: true, hidden: true }, + switchToTab5: { mac: 'command+bind+5', windows: 'ctrl+bind+5', name: 'Switch to Tab at Position', readOnly: true, hidden: true }, + switchToTab6: { mac: 'command+bind+6', windows: 'ctrl+bind+6', name: 'Switch to Tab at Position', readOnly: true, hidden: true }, + switchToTab7: { mac: 'command+bind+7', windows: 'ctrl+bind+7', name: 'Switch to Tab at Position', readOnly: true, hidden: true }, + switchToTab8: { mac: 'command+bind+8', windows: 'ctrl+bind+8', name: 'Switch to Tab at Position', readOnly: true, hidden: true } + } }, - switchToPreviousTab: { - mac: 'command+pageup', - windows: 'ctrl+pageup', - name: 'Switch to Previous Tab' + { + heading: 'Sidebar', + bindings: { + sidebarSearch: { mac: 'command+bind+f', windows: 'ctrl+bind+f', name: 'Search Sidebar' }, // D + copyItem: { mac: 'command+bind+c', windows: 'ctrl+bind+c', name: 'Copy Item' }, // D + pasteItem: { mac: 'command+bind+v', windows: 'ctrl+bind+v', name: 'Paste Item' }, // D + cloneItem: { mac: 'command+bind+d', windows: 'ctrl+bind+d', name: 'Clone Item' }, // D + renameItem: { mac: 'command+bind+r', windows: 'ctrl+bind+r', name: 'Rename Item' }, // D + collapseSidebar: { mac: 'command+bind+\\', windows: 'ctrl+bind+\\', name: 'Collapse Sidebar' } // D + } }, - switchToNextTab: { - mac: 'command+pagedown', - windows: 'ctrl+pagedown', - name: 'Switch to Next Tab' + { + heading: 'Requests', + bindings: { + sendRequest: { mac: 'command+bind+enter', windows: 'ctrl+bind+enter', name: 'Send Request' }, // D + changeLayout: { mac: 'command+bind+j', windows: 'ctrl+bind+j', name: 'Change Orientation' } // D + } }, - moveTabLeft: { - mac: 'command+shift+pageup', - windows: 'ctrl+shift+pageup', - name: 'Move Tab Left' + { + heading: 'Collections & Environment', + bindings: { + importCollection: { mac: 'command+bind+o', windows: 'ctrl+bind+o', name: 'Import Collection' }, // D + editEnvironment: { mac: 'command+bind+e', windows: 'ctrl+bind+e', name: 'Edit Environment' }, // D + newRequest: { mac: 'command+bind+n', windows: 'ctrl+bind+n', name: 'New Request' } // D + } }, - moveTabRight: { - mac: 'command+shift+pagedown', - windows: 'ctrl+shift+pagedown', - name: 'Move Tab Right' + { + heading: 'Search', + bindings: { + globalSearch: { mac: 'command+bind+k', windows: 'ctrl+bind+k', name: 'Global Search' } // D + } }, - closeAllTabs: { mac: 'command+shift+w', windows: 'ctrl+shift+w', name: 'Close All Tabs' }, - collapseSidebar: { mac: 'command+\\', windows: 'ctrl+\\', name: 'Collapse Sidebar' }, - zoomIn: { mac: 'command+=', windows: 'ctrl+=', name: 'Zoom In' }, - zoomOut: { mac: 'command+-', windows: 'ctrl+-', name: 'Zoom Out' }, - resetZoom: { mac: 'command+0', windows: 'ctrl+0', name: 'Reset Zoom' }, - renameItem: { mac: 'enter', windows: 'f2', name: 'Rename Collection Item' } -}; - -/** - * Retrieves the key bindings for a specific operating system. - * - * @param {string} os - The operating system (e.g., 'mac', 'windows'). - * @returns {Object} An object containing the key bindings for the specified OS. - */ -export const getKeyBindingsForOS = (os) => { - const keyBindings = {}; - for (const [action, { name, ...keys }] of Object.entries(KeyMapping)) { - if (keys[os]) { - keyBindings[action] = { - keys: keys[os], - name - }; + { + heading: 'View', + bindings: { + zoomIn: { mac: 'command+bind+=', windows: 'ctrl+bind+=', name: 'Zoom In' }, + zoomOut: { mac: 'command+bind+-', windows: 'ctrl+bind+-', name: 'Zoom Out' }, + resetZoom: { mac: 'command+bind+0', windows: 'ctrl+bind+0', name: 'Reset Zoom' } + } + }, + { + heading: 'Developer Tool', + bindings: { + openTerminal: { mac: 'command+bind+t', windows: 'ctrl+bind+t', name: 'Open in Terminal' } // D + } + }, + { + heading: 'Others', + bindings: { + openPreferences: { mac: 'command+bind+,', windows: 'ctrl+bind+,', name: 'Open Preferences' }, // D + closeBruno: { mac: 'command+bind+q', windows: 'ctrl+bind+shift+bind+q', name: 'Close Bruno' } // D } } - return keyBindings; +]; + +/** + * Converts keybindings from storage format (+bind+) to Mousetrap format (+) + * Storage format uses +bind+ as separator to avoid conflicts with the actual + key + * Mousetrap uses + as the separator + * Also converts arrow key names to Mousetrap format + * + * @param {string} keysStr - Keybinding string in storage format + * @returns {string|null} Keybinding string in Mousetrap format, or null if empty + */ +export const toMousetrapCombo = (keysStr) => { + if (!keysStr) return null; + + // Split by +bind+ separator + const parts = keysStr.split('+bind+').filter(Boolean); + + // Convert arrow key names from browser format to Mousetrap format + const converted = parts.map((part) => { + const lower = part.toLowerCase(); + if (lower === 'arrowup') return 'up'; + if (lower === 'arrowdown') return 'down'; + if (lower === 'arrowleft') return 'left'; + if (lower === 'arrowright') return 'right'; + return lower; + }); + + return converted.join('+'); }; /** - * Retrieves the key bindings for a specific action across all operating systems. + * Merges default key bindings with user preferences. + * Uses KEY_BINDING_SECTIONS as the source of truth for defaults. + * + * @param {Object} userKeyBindings - User's custom key bindings from preferences (preferences.keyBindings) + * @returns {Object} Merged key bindings object + */ +export const getMergedKeyBindings = (userKeyBindings) => { + const merged = {}; + + // Start with defaults from KEY_BINDING_SECTIONS (source of truth) + for (const section of KEY_BINDING_SECTIONS) { + for (const [action, binding] of Object.entries(section.bindings || {})) { + merged[action] = { ...binding }; + } + } + + // Override with user preferences + if (userKeyBindings && typeof userKeyBindings === 'object') { + for (const [action, binding] of Object.entries(userKeyBindings)) { + if (merged[action]) { + merged[action] = { + ...merged[action], + ...binding + }; + } + } + } + + return merged; +}; + +/** + * Retrieves the Mousetrap-compatible key combos for a specific action across all operating systems. + * Reads from merged defaults + user preferences. * * @param {string} action - The action for which to retrieve key bindings. - * @returns {Object|null} An object containing the key bindings for macOS, Windows, or null if the action is not found. + * @param {Object} [userKeyBindings] - User's custom key bindings from preferences + * @returns {string[]|null} Array of Mousetrap-compatible combo strings, or null if the action is not found. */ -export const getKeyBindingsForActionAllOS = (action) => { - const actionBindings = KeyMapping[action]; +export const getKeyBindingsForActionAllOS = (action, userKeyBindings) => { + const merged = getMergedKeyBindings(userKeyBindings); + const actionBindings = merged[action]; if (!actionBindings) { console.warn(`Action "${action}" not found in KeyMapping.`); return null; } - return [actionBindings.mac, actionBindings.windows]; + const combos = []; + + // Detect current OS and use appropriate bindings only + const isMac = navigator.platform.toLowerCase().includes('mac'); + + if (isMac && actionBindings.mac) { + const combo = toMousetrapCombo(actionBindings.mac); + if (combo) combos.push(combo); + } else if (!isMac && actionBindings.windows) { + const combo = toMousetrapCombo(actionBindings.windows); + if (combo) combos.push(combo); + } + + // console.log('[keyMappings] getKeyBindingsForActionAllOS:', action, '->', combos); + return combos.length > 0 ? combos : null; }; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js index 3d3c552f2..bb8684e1d 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/app.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/app.js @@ -8,6 +8,8 @@ const initialState = { idbConnectionReady: false, leftSidebarWidth: 250, sidebarCollapsed: false, + showSidebarSearch: false, + focusedSidebarPath: null, screenWidth: 500, showHomePage: false, showApiSpecPage: false, @@ -139,6 +141,12 @@ export const appSlice = createSlice({ toggleSidebarCollapse: (state) => { state.sidebarCollapsed = !state.sidebarCollapsed; }, + toggleSidebarSearch: (state) => { + state.showSidebarSearch = !state.showSidebarSearch; + }, + setFocusedSidebarPath: (state, action) => { + state.focusedSidebarPath = action.payload; + }, updateGitOperationProgress: (state, action) => { const { uid, data } = action.payload; if (!state.gitOperationProgress[uid]) { @@ -204,6 +212,8 @@ export const { updateSystemProxyVariables, updateGenerateCode, toggleSidebarCollapse, + toggleSidebarSearch, + setFocusedSidebarPath, updateGitOperationProgress, removeGitOperationProgress, setGitVersion, diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js index 69913cf05..6697933f9 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js @@ -5,9 +5,12 @@ import last from 'lodash/last'; // todo: errors should be tracked in each slice and displayed as toasts +const MAX_RECENTLY_CLOSED_TABS = 50; + const initialState = { tabs: [], - activeTabUid: null + activeTabUid: null, + recentlyClosedTabs: [] // LIFO stack of closed tabs, grouped by collection }; const tabTypeAlreadyExists = (tabs, collectionUid, type) => { @@ -265,6 +268,19 @@ export const tabsSlice = createSlice({ const tabUids = action.payload.tabUids || []; const nonClosableTypes = ['workspaceOverview', 'workspaceEnvironments']; + + // Push closed tabs onto the recently closed stack (LIFO) + const closedTabs = state.tabs.filter((t) => + tabUids.includes(t.uid) && !nonClosableTypes.includes(t.type) + ); + if (closedTabs.length > 0) { + state.recentlyClosedTabs.push(...closedTabs); + // Trim to max size + if (state.recentlyClosedTabs.length > MAX_RECENTLY_CLOSED_TABS) { + state.recentlyClosedTabs = state.recentlyClosedTabs.slice(-MAX_RECENTLY_CLOSED_TABS); + } + } + state.tabs = filter(state.tabs, (t) => !tabUids.includes(t.uid) || nonClosableTypes.includes(t.type) ); @@ -338,6 +354,21 @@ export const tabsSlice = createSlice({ tabs.splice(targetIdx, 0, moved); state.tabs = tabs; + }, + reopenLastClosedTab: (state, action) => { + const { collectionUid } = action.payload; + // Find the last closed tab for this collection (LIFO) + const index = state.recentlyClosedTabs.findLastIndex((t) => t.collectionUid === collectionUid); + if (index === -1) return; + + const [tab] = state.recentlyClosedTabs.splice(index, 1); + + // Don't reopen if a tab with this uid already exists + const alreadyOpen = state.tabs.some((t) => t.uid === tab.uid); + if (alreadyOpen) return; + + state.tabs.push(tab); + state.activeTabUid = tab.uid; } } }); @@ -364,6 +395,7 @@ export const { closeAllCollectionTabs, makeTabPermanent, reorderTabs, + reopenLastClosedTab, updateQueryBuilderOpen, updateQueryBuilderWidth, updateVariablesPaneOpen, diff --git a/packages/bruno-electron/src/app/menu-template.js b/packages/bruno-electron/src/app/menu-template.js index fe6cbc716..b3d9812b3 100644 --- a/packages/bruno-electron/src/app/menu-template.js +++ b/packages/bruno-electron/src/app/menu-template.js @@ -25,15 +25,13 @@ const template = [ } ] }, + { type: 'separator' }, { - label: 'Preferences', - accelerator: 'CommandOrControl+,', + label: 'Quit', click() { - ipcMain.emit('main:open-preferences'); + ipcMain.emit('main:start-quit-flow'); } }, - { type: 'separator' }, - { role: 'quit' }, { label: 'Force Quit', click() { @@ -65,6 +63,7 @@ const template = [ { label: 'Actual Size', accelerator: 'CommandOrControl+0', + registerAccelerator: false, click() { ipcMain.emit('menu:reset-zoom'); } @@ -72,6 +71,7 @@ const template = [ { label: 'Zoom In', accelerator: 'CommandOrControl+Plus', + registerAccelerator: false, click() { ipcMain.emit('menu:zoom-in'); } @@ -79,6 +79,7 @@ const template = [ { label: 'Zoom Out', accelerator: 'CommandOrControl+-', + registerAccelerator: false, click() { ipcMain.emit('menu:zoom-out'); } diff --git a/tests/collection/moving-tabs/move-tabs.spec.ts b/tests/collection/moving-tabs/move-tabs.spec.ts index 63c8f0f9f..69d2e009d 100644 --- a/tests/collection/moving-tabs/move-tabs.spec.ts +++ b/tests/collection/moving-tabs/move-tabs.spec.ts @@ -1,6 +1,8 @@ import { test, expect } from '../../../playwright'; import { closeAllCollections, createCollection } from '../../utils/page'; +const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'; + test.describe('Move tabs', () => { test.afterEach(async ({ page }) => { // cleanup: close all collections @@ -135,7 +137,7 @@ test.describe('Move tabs', () => { // 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.keyboard.press(`${modifier}+BracketLeft`); await page.waitForTimeout(500); // Verify order of tabs after move @@ -144,7 +146,7 @@ test.describe('Move tabs', () => { // Move the request tab back to its original position using keyboard shortcut await source.click(); - await page.keyboard.press('ControlOrMeta+Shift+PageDown'); + await page.keyboard.press(`${modifier}+BracketRight`); await page.waitForTimeout(500); // Verify order of tabs after move diff --git a/tests/environments/focus-retention/environment-focus.spec.ts b/tests/environments/focus-retention/environment-focus.spec.ts index fc14284c5..933f400c5 100644 --- a/tests/environments/focus-retention/environment-focus.spec.ts +++ b/tests/environments/focus-retention/environment-focus.spec.ts @@ -18,8 +18,9 @@ test.describe('Environment Variables Focus Retention', () => { await page.keyboard.type('apiKey'); await expect(nameInput).toBeFocused(); - await page.keyboard.press('Control+s'); - await expect(page.getByText('Changes saved successfully').last()).toBeVisible({ timeout: 5000 }); + const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s'; + await page.keyboard.press(saveShortcut); + await expect(page.getByText(/(^Environment changed to)/).last()).toBeVisible({ timeout: 5000 }); // intentionally wait a few seconds because the focus is lost after a while await page.waitForTimeout(1000); diff --git a/tests/shortcuts/bound-actions.spec.ts b/tests/shortcuts/bound-actions.spec.ts new file mode 100644 index 000000000..2ab28fac2 --- /dev/null +++ b/tests/shortcuts/bound-actions.spec.ts @@ -0,0 +1,1624 @@ +import { test, expect, Page } from '../../playwright'; +import { createCollection, createRequest, openRequest, closeAllCollections, createFolder, openCollection } from '../utils/page'; + +const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'; + +const openKeybindingsTab = async (page: Page) => { + await page.getByRole('button', { name: 'Open Preferences' }).click(); + await page.getByRole('tab', { name: 'Keybindings' }).click(); + await expect(page.locator('.section-header').filter({ hasText: 'Keybindings' })).toBeVisible(); +}; + +/** + * Close the Preferences tab by clicking its close button. + * Using the close button avoids depending on any keyboard shortcut that may + * have just been reconfigured. + */ +const closePreferencesTab = async (page: Page) => { + const prefTab = page.locator('.request-tab').filter({ hasText: 'Preferences' }); + await prefTab.hover(); + await prefTab.getByTestId('request-tab-close-icon').click({ force: true }); + await expect(prefTab).not.toBeVisible({ timeout: 2000 }); +}; + +const closeTabByName = async (page: any, name: string | RegExp) => { + const tab = page.locator('.request-tab').filter({ hasText: name }); + await tab.hover(); + await tab.getByTestId('request-tab-close-icon').click({ force: true }); + await expect(tab).not.toBeVisible({ timeout: 2000 }); +}; + +// ─── Tests ──── + +test.describe('Shortcut Keys - BOUND_ACTIONS', () => { + test.beforeEach(async ({ page }) => { + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 5000 }); + }); + + test.afterAll(async ({ page }) => { + await closeAllCollections(page); + }); + + test.describe('TABS', () => { + test.describe('SHORTCUT: Close Tab', () => { + test('default Cmd/Ctrl+W closes the active tab', async ({ page, createTmpDir }) => { + const path = await createTmpDir('kb-collection-path'); + await createCollection(page, 'kb-collection', path); + await createRequest(page, 'req-1', 'kb-collection'); + await openRequest(page, 'kb-collection', 'req-1', { persist: true }); + await expect(page.locator('.request-tab').filter({ hasText: 'req-1' })).toBeVisible({ timeout: 2000 }); + + await page.keyboard.press(`${modifier}+KeyW`); + await expect(page.locator('.request-tab')).toHaveCount(2, { timeout: 3000 }); + }); + + test('customized Cmd/Ctrl+Shift+X closes the active tab', async ({ page, createTmpDir }) => { + // Remap closeTab to Cmd/Ctrl+Shift+X + await openKeybindingsTab(page); + const row = page.getByTestId(`keybinding-row-closeTab`); + await row.hover(); + await page.getByTestId(`keybinding-edit-closeTab`).click(); + // Wait for input to enter recording mode + await expect(page.getByTestId(`keybinding-input-closeTab`)).toBeVisible({ timeout: 2000 }); + + // Remove the old keybindings + await page.keyboard.down('Backspace'); + + await page.keyboard.down('Shift'); + await page.keyboard.down('KeyX'); + await page.keyboard.up('KeyX'); + await page.keyboard.up('Shift'); + + await closePreferencesTab(page); + + await openRequest(page, 'kb-collection', 'req-1', { persist: true }); + await expect(page.locator('.request-tab').filter({ hasText: 'req-1' })).toBeVisible({ timeout: 2000 }); + + await page.keyboard.down('Shift'); + await page.keyboard.down('KeyX'); + await page.keyboard.up('KeyX'); + await page.keyboard.up('Shift'); + await expect(page.locator('.request-tab')).toHaveCount(2, { timeout: 3000 }); + }); + }); + + test.describe('SHORTCUT: Close All Tabs', () => { + test('default Cmd/Ctrl+Shift+W closes all tabs', async ({ page }) => { + await createRequest(page, 'req-2', 'kb-collection'); + await createRequest(page, 'req-3', 'kb-collection'); + await openRequest(page, 'kb-collection', 'req-1', { persist: true }); + await openRequest(page, 'kb-collection', 'req-2', { persist: true }); + await openRequest(page, 'kb-collection', 'req-3', { persist: true }); + await page.getByTestId('runner').click(); + await expect(page.locator('.request-tab').filter({ hasText: 'req-1' })).toBeVisible({ timeout: 2000 }); + await expect(page.locator('.request-tab').filter({ hasText: 'req-2' })).toBeVisible({ timeout: 2000 }); + await expect(page.locator('.request-tab').filter({ hasText: 'req-3' })).toBeVisible({ timeout: 2000 }); + + await page.keyboard.down(modifier); + await page.keyboard.down('Shift'); + await page.keyboard.down('KeyW'); + await page.keyboard.up('KeyW'); + await page.keyboard.up('Shift'); + await page.keyboard.up(modifier); + await expect(page.locator('.request-tab')).toHaveCount(2, { timeout: 3000 }); + }); + + test('customized Alt+Y closes all tabs', async ({ page }) => { + // Remap closeAllTabs to Alt+Y + await openKeybindingsTab(page); + const row = page.getByTestId('keybinding-row-closeAllTabs'); + await row.hover(); + await page.getByTestId('keybinding-row-closeAllTabs').click(); + await expect(page.getByTestId('keybinding-input-closeAllTabs')).toBeVisible({ timeout: 2000 }); + + await page.keyboard.down('Backspace'); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Alt'); + + await closePreferencesTab(page); + + await openRequest(page, 'kb-collection', 'req-1', { persist: true }); + await openRequest(page, 'kb-collection', 'req-2', { persist: true }); + await openRequest(page, 'kb-collection', 'req-3', { persist: true }); + await expect(page.locator('.request-tab').filter({ hasText: 'req-1' })).toBeVisible({ timeout: 2000 }); + await expect(page.locator('.request-tab').filter({ hasText: 'req-2' })).toBeVisible({ timeout: 2000 }); + await expect(page.locator('.request-tab').filter({ hasText: 'req-3' })).toBeVisible({ timeout: 2000 }); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Alt'); + await expect(page.locator('.request-tab')).toHaveCount(2, { timeout: 3000 }); + }); + }); + + test.describe('SHORTCUT: Save', () => { + test('default Cmd/Ctrl+S save tab', async ({ page, createTmpDir }) => { + await page.locator('.collection-name').filter({ hasText: 'kb-collection' }).dblclick(); + await expect(page.locator('.request-tab').filter({ hasText: 'collection' })).toBeVisible({ timeout: 2000 }); + + // Verify initially there is NO draft indicator (close icon is present) + const collectionTab = page.locator('.request-tab').filter({ has: page.locator('.tab-label', { hasText: 'Collection' }) }); + await expect(collectionTab.locator('.close-icon')).toBeVisible(); + await expect(collectionTab.locator('.has-changes-icon')).not.toBeVisible(); + + await page.locator('.tab.headers').click(); + + const headerTable = page.locator('table').first(); + const headerRow = headerTable.locator('tbody tr').first(); + + const nameEditor = headerRow.locator('.CodeMirror').first(); + await nameEditor.click(); + await page.keyboard.type('X-Custom-Header'); + + const valueEditor = headerRow.locator('.CodeMirror').nth(1); + await valueEditor.click(); + await page.keyboard.type('custom-value'); + + // Verify draft indicator appears in the tab + await expect(collectionTab.locator('.has-changes-icon')).toBeVisible(); + await expect(collectionTab.locator('.close-icon')).not.toBeVisible(); + + // Save the changes + await page.keyboard.down(modifier); + await page.keyboard.down('KeyS'); + await page.keyboard.up('KeyS'); + await page.keyboard.up(modifier); + + // Verify draft indicator is gone after saving + await expect(collectionTab.locator('.close-icon')).toBeVisible(); + await expect(collectionTab.locator('.has-changes-icon')).not.toBeVisible(); + }); + + test('customized Alt+S save tab', async ({ page, createTmpDir }) => { + // Remap save to Alt+S + await openKeybindingsTab(page); + const row = page.getByTestId('keybinding-row-save'); + await row.hover(); + await page.getByTestId('keybinding-edit-save').click(); + await expect(page.getByTestId('keybinding-input-save')).toBeVisible({ timeout: 2000 }); + + await page.keyboard.down('Backspace'); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyS'); + await page.keyboard.up('KeyS'); + await page.keyboard.up('Alt'); + + await closePreferencesTab(page); + + await page.locator('.collection-name').filter({ hasText: 'kb-collection' }).dblclick(); + await expect(page.locator('.request-tab').filter({ hasText: 'collection' })).toBeVisible({ timeout: 2000 }); + + // Verify initially there is NO draft indicator (close icon is present) + const collectionTab = page.locator('.request-tab').filter({ has: page.locator('.tab-label', { hasText: 'Collection' }) }); + await expect(collectionTab.locator('.close-icon')).toBeVisible(); + await expect(collectionTab.locator('.has-changes-icon')).not.toBeVisible(); + + await page.locator('.tab.headers').click(); + + const headerTable = page.locator('table').first(); + const headerRow = headerTable.locator('tbody tr').first(); + + const nameEditor = headerRow.locator('.CodeMirror').first(); + await nameEditor.click(); + await page.keyboard.type('X-Custom-Header'); + + const valueEditor = headerRow.locator('.CodeMirror').nth(1); + await valueEditor.click(); + await page.keyboard.type('custom-value'); + + // Verify draft indicator appears in the tab + await expect(collectionTab.locator('.has-changes-icon')).toBeVisible(); + await expect(collectionTab.locator('.close-icon')).not.toBeVisible(); + + await page.locator('body').click({ position: { x: 1, y: 1 } }); + + // Save the changes + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyS'); + await page.keyboard.up('KeyS'); + await page.keyboard.up('Alt'); + + // Verify draft indicator is gone after saving + await expect(collectionTab.locator('.close-icon')).toBeVisible(); + await expect(collectionTab.locator('.has-changes-icon')).not.toBeVisible(); + }); + }); + + test.describe('SHORTCUT: Save All Tabs', () => { + test('default Cmd/Ctrl+Shift+S save all tabs', async ({ page }) => { + await page.locator('.collection-name').filter({ hasText: 'kb-collection' }).dblclick(); + await expect(page.locator('.request-tab').filter({ hasText: 'collection' })).toBeVisible({ timeout: 2000 }); + + // Verify initially there is NO draft indicator (close icon is present) + const collectionTab = page.locator('.request-tab').filter({ has: page.locator('.tab-label', { hasText: 'Collection' }) }); + await expect(collectionTab.locator('.close-icon')).toBeVisible(); + await expect(collectionTab.locator('.has-changes-icon')).not.toBeVisible(); + + await page.locator('.tab.headers').click(); + + const headerTable = page.locator('table').first(); + const headerRow = headerTable.locator('tbody tr').first(); + + const nameEditor = headerRow.locator('.CodeMirror').first(); + await nameEditor.click(); + await page.keyboard.type('X-Custom-Header'); + + const valueEditor = headerRow.locator('.CodeMirror').nth(1); + await valueEditor.click(); + await page.keyboard.type('custom-value'); + + // Verify draft indicator appears in the tab + await expect(collectionTab.locator('.has-changes-icon')).toBeVisible(); + await expect(collectionTab.locator('.close-icon')).not.toBeVisible(); + + // Open Folder-Settings tab (create folder + double-click) + await createFolder(page, 'kb-draft-folder', 'kb-collection', true); + await page.locator('.collection-item-name').filter({ hasText: 'kb-draft-folder' }).dblclick(); + + // Verify folder settings tab is open + const folderTab = page.locator('.request-tab').filter({ has: page.locator('.tab-label', { hasText: 'kb-draft-folder' }) }); + await expect(folderTab).toBeVisible(); + + await expect(folderTab.locator('.close-icon')).toBeVisible(); + await expect(folderTab.locator('.has-changes-icon')).not.toBeVisible(); + + const folderHeaderTable = page.locator('table').first(); + const folderHeaderRow = folderHeaderTable.locator('tbody tr').first(); + + const folderNameEditor = folderHeaderRow.locator('.CodeMirror').first(); + await folderNameEditor.click(); + await page.keyboard.type('X-Folder-Header'); + + const folderValueEditor = folderHeaderRow.locator('.CodeMirror').nth(1); + await folderValueEditor.click(); + await page.keyboard.type('folder-value'); + + // Verify draft indicator appears in the folder tab + await expect(folderTab.locator('.has-changes-icon')).toBeVisible(); + await expect(folderTab.locator('.close-icon')).not.toBeVisible(); + + // Save the changes + await page.keyboard.down(modifier); + await page.keyboard.down('Shift'); + await page.keyboard.down('KeyS'); + await page.keyboard.up('KeyS'); + await page.keyboard.up('Shift'); + await page.keyboard.up(modifier); + + // Verify draft indicator is gone after saving + await expect(folderTab.locator('.close-icon')).toBeVisible(); + await expect(folderTab.locator('.has-changes-icon')).not.toBeVisible(); + + // Verify draft indicator is gone after saving + await expect(collectionTab.locator('.close-icon')).toBeVisible(); + await expect(collectionTab.locator('.has-changes-icon')).not.toBeVisible(); + }); + + test('customized Alt+Shift+S save all tabs', async ({ page }) => { + // Remap saveAllTabs to Alt+Shift+S + await openKeybindingsTab(page); + const row = page.getByTestId('keybinding-row-saveAllTabs'); + await row.hover(); + await page.getByTestId('keybinding-edit-saveAllTabs').click(); + await expect(page.getByTestId('keybinding-input-saveAllTabs')).toBeVisible({ timeout: 2000 }); + + await page.keyboard.down('Backspace'); + + await page.keyboard.down('Alt'); + await page.keyboard.down('Shift'); + await page.keyboard.down('KeyS'); + await page.keyboard.up('KeyS'); + await page.keyboard.up('Shift'); + await page.keyboard.up('Alt'); + + await closePreferencesTab(page); + + await page.locator('.collection-name').filter({ hasText: 'kb-collection' }).dblclick(); + await expect(page.locator('.request-tab').filter({ hasText: 'collection' })).toBeVisible({ timeout: 2000 }); + + // Verify initially there is NO draft indicator (close icon is present) + const collectionTab = page.locator('.request-tab').filter({ has: page.locator('.tab-label', { hasText: 'Collection' }) }); + await expect(collectionTab.locator('.close-icon')).toBeVisible(); + await expect(collectionTab.locator('.has-changes-icon')).not.toBeVisible(); + + await page.locator('.tab.headers').click(); + + const headerTable = page.locator('table').first(); + const headerRow = headerTable.locator('tbody tr').first(); + + const nameEditor = headerRow.locator('.CodeMirror').first(); + await nameEditor.click(); + await page.keyboard.type('X-Custom-Header'); + + const valueEditor = headerRow.locator('.CodeMirror').nth(1); + await valueEditor.click(); + await page.keyboard.type('custom-value'); + + // Verify draft indicator appears in the tab + await expect(collectionTab.locator('.has-changes-icon')).toBeVisible(); + await expect(collectionTab.locator('.close-icon')).not.toBeVisible(); + + // Open Folder-Settings tab (create folder + double-click) + await page.locator('.collection-item-name').filter({ hasText: 'kb-draft-folder' }).dblclick(); + + // Verify folder settings tab is open + const folderTab = page.locator('.request-tab').filter({ has: page.locator('.tab-label', { hasText: 'kb-draft-folder' }) }); + await expect(folderTab).toBeVisible(); + + await expect(folderTab.locator('.close-icon')).toBeVisible(); + await expect(folderTab.locator('.has-changes-icon')).not.toBeVisible(); + + const folderHeaderTable = page.locator('table').first(); + const folderHeaderRow = folderHeaderTable.locator('tbody tr').first(); + + const folderNameEditor = folderHeaderRow.locator('.CodeMirror').first(); + await folderNameEditor.click(); + await page.keyboard.type('X-Folder-Header'); + + const folderValueEditor = folderHeaderRow.locator('.CodeMirror').nth(1); + await folderValueEditor.click(); + await page.keyboard.type('folder-value'); + + // Verify draft indicator appears in the folder tab + await expect(folderTab.locator('.has-changes-icon')).toBeVisible(); + await expect(folderTab.locator('.close-icon')).not.toBeVisible(); + + // Save the changes + await page.keyboard.down('Alt'); + await page.keyboard.down('Shift'); + await page.keyboard.down('KeyS'); + await page.keyboard.up('KeyS'); + await page.keyboard.up('Shift'); + await page.keyboard.up('Alt'); + + // Verify draft indicator is gone after saving + await expect(folderTab.locator('.close-icon')).toBeVisible(); + await expect(folderTab.locator('.has-changes-icon')).not.toBeVisible(); + + // Verify draft indicator is gone after saving + await expect(collectionTab.locator('.close-icon')).toBeVisible(); + await expect(collectionTab.locator('.has-changes-icon')).not.toBeVisible(); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Alt'); + }); + }); + + test.describe('SHORTCUT: Switch to Previous Tab', () => { + test('default Cmd/Ctrl+Shift+[ switches to previous tab', async ({ page }) => { + await createRequest(page, 'req-4', 'kb-collection'); + await createRequest(page, 'req-5', 'kb-collection'); + await createRequest(page, 'req-6', 'kb-collection'); + await openRequest(page, 'kb-collection', 'req-4', { persist: true }); + await openRequest(page, 'kb-collection', 'req-5', { persist: true }); + await openRequest(page, 'kb-collection', 'req-6', { persist: true }); + await expect(page.locator('.request-tab').filter({ hasText: 'req-6' })).toBeVisible({ timeout: 2000 }); + + // req-6 is active (last opened) — press previous → req-5 + await page.keyboard.press(`${modifier}+Shift+BracketLeft`); + await expect(page.locator('li.request-tab.active')).toHaveText(/req-5/, { timeout: 3000 }); + + // Press again → req-4 + await page.keyboard.press(`${modifier}+Shift+BracketLeft`); + await expect(page.locator('li.request-tab.active')).toHaveText(/req-4/, { timeout: 3000 }); + }); + + test('customized Shift+P switches to previous tab', async ({ page }) => { + // Remap switchToPreviousTab to Shift+P + await openKeybindingsTab(page); + const row = page.getByTestId('keybinding-row-switchToPreviousTab'); + await row.hover(); + await page.getByTestId('keybinding-edit-switchToPreviousTab').click(); + await expect(page.getByTestId('keybinding-input-switchToPreviousTab')).toBeVisible({ timeout: 2000 }); + + await page.keyboard.down('Backspace'); + + await page.keyboard.down('Shift'); + await page.keyboard.down('KeyP'); + await page.keyboard.up('KeyP'); + await page.keyboard.up('Shift'); + + await closePreferencesTab(page); + + // Reuse the same requests opened in the default test + await openRequest(page, 'kb-collection', 'req-4', { persist: true }); + await openRequest(page, 'kb-collection', 'req-5', { persist: true }); + await openRequest(page, 'kb-collection', 'req-6', { persist: true }); + await expect(page.locator('.request-tab').filter({ hasText: 'req-6' })).toBeVisible({ timeout: 2000 }); + + // req-6 is active — press Shift+P → req-5 + await page.keyboard.down('Shift'); + await page.keyboard.down('KeyP'); + await page.keyboard.up('KeyP'); + await page.keyboard.up('Shift'); + await expect(page.locator('li.request-tab.active')).toHaveText(/req-5/, { timeout: 3000 }); + }); + }); + + test.describe('SHORTCUT: Switch to Next Tab', () => { + test('default Cmd/Ctrl+Shift+] switches to next tab', async ({ page }) => { + await openRequest(page, 'kb-collection', 'req-4', { persist: true }); + await openRequest(page, 'kb-collection', 'req-5', { persist: true }); + await openRequest(page, 'kb-collection', 'req-6', { persist: true }); + + // Go back to req-4 to start from the left + await openRequest(page, 'kb-collection', 'req-4', { persist: true }); + await expect(page.locator('li.request-tab.active')).toHaveText(/req-4/); + + // req-4 is active — press next → req-5 + await page.keyboard.press(`${modifier}+Shift+BracketRight`); + await expect(page.locator('li.request-tab.active')).toHaveText(/req-5/, { timeout: 3000 }); + + // Press again → req-6 + await page.keyboard.press(`${modifier}+Shift+BracketRight`); + await expect(page.locator('li.request-tab.active')).toHaveText(/req-6/, { timeout: 3000 }); + }); + + test('customized Shift+N switches to next tab', async ({ page }) => { + // Remap switchToNextTab to Shift+N + await openKeybindingsTab(page); + const row = page.getByTestId('keybinding-row-switchToNextTab'); + await row.hover(); + await page.getByTestId('keybinding-edit-switchToNextTab').click(); + await expect(page.getByTestId('keybinding-input-switchToNextTab')).toBeVisible({ timeout: 2000 }); + + await page.keyboard.down('Backspace'); + + await page.keyboard.down('Shift'); + await page.keyboard.down('KeyN'); + await page.keyboard.up('KeyN'); + await page.keyboard.up('Shift'); + + await closePreferencesTab(page); + + await openRequest(page, 'kb-collection', 'req-4', { persist: true }); + await openRequest(page, 'kb-collection', 'req-5', { persist: true }); + await openRequest(page, 'kb-collection', 'req-6', { persist: true }); + + // Go back to req-4 + await openRequest(page, 'kb-collection', 'req-4', { persist: true }); + await expect(page.locator('li.request-tab.active')).toHaveText(/req-4/); + + // req-4 is active — press Shift+N → req-5 + await page.keyboard.down('Shift'); + await page.keyboard.down('KeyN'); + await page.keyboard.up('KeyN'); + await page.keyboard.up('Shift'); + await expect(page.locator('li.request-tab.active')).toHaveText(/req-5/, { timeout: 3000 }); + }); + }); + + test.describe('SHORTCUT: Move Tab Left', () => { + test('default Cmd/Ctrl+[ moves active tab left', async ({ page }) => { + await createRequest(page, 'req-7', 'kb-collection'); + await createRequest(page, 'req-8', 'kb-collection'); + await createRequest(page, 'req-9', 'kb-collection'); + await openRequest(page, 'kb-collection', 'req-7', { persist: true }); + await openRequest(page, 'kb-collection', 'req-8', { persist: true }); + await openRequest(page, 'kb-collection', 'req-9', { persist: true }); + + // req-9 is active and last + const tabs = page.locator('.request-tab'); + const totalTabs = await tabs.count(); + await expect(tabs.nth(totalTabs - 1)).toHaveText(/req-9/); + + // Press Cmd/Ctrl+[ → req-9 moves left, req-8 becomes last + await page.keyboard.press(`${modifier}+BracketLeft`); + await expect(tabs.nth(totalTabs - 1)).toHaveText(/req-8/, { timeout: 3000 }); + await expect(tabs.nth(totalTabs - 2)).toHaveText(/req-9/); + + // Press again → req-9 moves one more position left + await page.keyboard.press(`${modifier}+BracketLeft`); + await expect(tabs.nth(totalTabs - 3)).toHaveText(/req-9/, { timeout: 3000 }); + }); + + test('customized Alt+L moves active tab left', async ({ page }) => { + // Remap moveTabLeft to Alt+L + await openKeybindingsTab(page); + const row = page.getByTestId('keybinding-row-moveTabLeft'); + await row.hover(); + await page.getByTestId('keybinding-edit-moveTabLeft').click(); + await expect(page.getByTestId('keybinding-input-moveTabLeft')).toBeVisible({ timeout: 2000 }); + + await page.keyboard.down('Backspace'); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyL'); + await page.keyboard.up('KeyL'); + await page.keyboard.up('Alt'); + + await closePreferencesTab(page); + + await openRequest(page, 'kb-collection', 'req-7', { persist: true }); + await openRequest(page, 'kb-collection', 'req-8', { persist: true }); + await openRequest(page, 'kb-collection', 'req-9', { persist: true }); + + // req-9 is active + const tabs = page.locator('.request-tab'); + + // Press Alt+L → req-9 moves left, req-8 becomes last + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyL'); + await page.keyboard.up('KeyL'); + await page.keyboard.up('Alt'); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyL'); + await page.keyboard.up('KeyL'); + await page.keyboard.up('Alt'); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyL'); + await page.keyboard.up('KeyL'); + await page.keyboard.up('Alt'); + await expect(tabs.nth(0)).toHaveText(/req-9/); + }); + }); + + test.describe('SHORTCUT: Move Tab Right', () => { + test('default Cmd/Ctrl+] moves active tab right', async ({ page }) => { + // req-9 is active and first + const tabs = page.locator('.request-tab'); + + await page.keyboard.press(`${modifier}+BracketRight`); + await expect(tabs.nth(1)).toHaveText(/req-9/, { timeout: 3000 }); + await page.keyboard.press(`${modifier}+BracketRight`); + await expect(tabs.nth(2)).toHaveText(/req-9/); + await page.keyboard.press(`${modifier}+BracketRight`); + await expect(tabs.nth(3)).toHaveText(/req-9/, { timeout: 3000 }); + }); + + test('customized Alt+R moves active tab right', async ({ page }) => { + // Remap moveTabRight to Alt+R + await openKeybindingsTab(page); + const row = page.getByTestId('keybinding-row-moveTabRight'); + await row.hover(); + await page.getByTestId('keybinding-edit-moveTabRight').click(); + await expect(page.getByTestId('keybinding-input-moveTabRight')).toBeVisible({ timeout: 2000 }); + + await page.keyboard.down('Backspace'); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyR'); + await page.keyboard.up('KeyR'); + await page.keyboard.up('Alt'); + + await closePreferencesTab(page); + + await openRequest(page, 'kb-collection', 'req-9', { persist: true }); + + // req-9 is active + const tabs = page.locator('.request-tab'); + await expect(tabs.nth(3)).toHaveText(/req-9/, { timeout: 3000 }); + + // Press Alt+L → req-9 moves right, req-8 becomes last + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyR'); + await page.keyboard.up('KeyR'); + await page.keyboard.up('Alt'); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyR'); + await page.keyboard.up('KeyR'); + await page.keyboard.up('Alt'); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyR'); + await page.keyboard.up('KeyR'); + await page.keyboard.up('Alt'); + await expect(tabs.nth(6)).toHaveText(/req-9/); + + // Close all tabs + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Alt'); + }); + }); + + test.describe('SHORTCUT: Switch to Tab at Position', () => { + test('default Cmd/Ctrl+1-8 open tab from 1-8', async ({ page }) => { + await openRequest(page, 'kb-collection', 'req-1', { persist: true }); + await openRequest(page, 'kb-collection', 'req-2', { persist: true }); + await openRequest(page, 'kb-collection', 'req-3', { persist: true }); + await openRequest(page, 'kb-collection', 'req-4', { persist: true }); + await openRequest(page, 'kb-collection', 'req-5', { persist: true }); + await openRequest(page, 'kb-collection', 'req-6', { persist: true }); + await openRequest(page, 'kb-collection', 'req-7', { persist: true }); + await openRequest(page, 'kb-collection', 'req-8', { persist: true }); + await openRequest(page, 'kb-collection', 'req-9', { persist: true }); + + await expect(page.locator('.request-tab')).toHaveCount(9, { timeout: 2000 }); + const tabs = page.locator('.request-tab'); + + await expect(tabs.nth(0)).toHaveText(/req-1/, { timeout: 2000 }); + await page.keyboard.press(`${modifier}+1`); + await expect(page.locator('li.request-tab.active')).toHaveText(/req-1/, { timeout: 3000 }); + await page.keyboard.press(`${modifier}+2`); + await expect(page.locator('li.request-tab.active')).toHaveText(/req-2/, { timeout: 3000 }); + await page.keyboard.press(`${modifier}+3`); + await expect(page.locator('li.request-tab.active')).toHaveText(/req-3/, { timeout: 3000 }); + await page.keyboard.press(`${modifier}+4`); + await expect(page.locator('li.request-tab.active')).toHaveText(/req-4/, { timeout: 3000 }); + await page.keyboard.press(`${modifier}+5`); + await expect(page.locator('li.request-tab.active')).toHaveText(/req-5/, { timeout: 3000 }); + await page.keyboard.press(`${modifier}+6`); + await expect(page.locator('li.request-tab.active')).toHaveText(/req-6/, { timeout: 3000 }); + await page.keyboard.press(`${modifier}+7`); + await expect(page.locator('li.request-tab.active')).toHaveText(/req-7/, { timeout: 3000 }); + await page.keyboard.press(`${modifier}+8`); + await expect(page.locator('li.request-tab.active')).toHaveText(/req-8/, { timeout: 3000 }); + }); + }); + + test.describe('SHORTCUT: Reopen Last Closed Tab', () => { + test('default Cmd/Ctrl+Shift+T reopens last closed request tab', async ({ page }) => { + await openRequest(page, 'kb-collection', 'req-1', { persist: true }); + await expect(page.locator('.request-tab').filter({ hasText: 'req-1' })).toBeVisible({ timeout: 2000 }); + + await page.keyboard.down('Shift'); + await page.keyboard.down('KeyX'); + await page.keyboard.up('KeyX'); + await page.keyboard.up('Shift'); + await expect(page.locator('.request-tab').filter({ hasText: 'req-1' })).not.toBeVisible({ timeout: 2000 }); + + await page.keyboard.press(`${modifier}+Shift+KeyT`); + await expect(page.locator('.request-tab').filter({ hasText: 'req-1' })).toBeVisible({ timeout: 3000 }); + }); + + test('default Cmd/Ctrl+Shift+T reopens multiple tab types in LIFO order', async ({ page }) => { + // Open Collection-Settings tab (double-click collection name) + await page.locator('.collection-name').filter({ hasText: 'kb-collection' }).dblclick(); + await expect(page.locator('.request-tab').filter({ hasText: 'collection' })).toBeVisible({ timeout: 2000 }); + + // Open Runner tab + await page.getByTestId('runner').click(); + await expect(page.locator('.request-tab').filter({ hasText: 'Runner' })).toBeVisible({ timeout: 2000 }); + + // Open Variables tab + await page.getByTestId('more-actions').click(); + await page.getByTestId('more-actions-variables').click(); + await expect(page.locator('.request-tab').filter({ hasText: 'Variables' })).toBeVisible({ timeout: 2000 }); + + // Open Folder-Settings tab (create folder + double-click) + await createFolder(page, 'kb-folder', 'kb-collection', true); + await page.locator('.collection-item-name').filter({ hasText: 'kb-folder' }).dblclick(); + + // Close in order: kb-folder (last closed) → kb-collection → variables → Runner + await closeTabByName(page, 'kb-folder'); + await closeTabByName(page, 'Collection'); + await closeTabByName(page, 'Variables'); + await closeTabByName(page, 'Runner'); + + // Reopen LIFO: Runner was closed last → reopens first + await page.keyboard.press(`${modifier}+Shift+KeyT`); + await expect(page.locator('.request-tab').filter({ hasText: 'Runner' })).toBeVisible({ timeout: 3000 }); + + await page.keyboard.press(`${modifier}+Shift+KeyT`); + await expect(page.locator('.request-tab').filter({ hasText: /variables/i })).toBeVisible({ timeout: 3000 }); + + await page.keyboard.press(`${modifier}+Shift+KeyT`); + await expect(page.locator('.request-tab').filter({ hasText: 'Collection' })).toBeVisible({ timeout: 3000 }); + + await page.keyboard.press(`${modifier}+Shift+KeyT`); + await expect(page.locator('.request-tab').filter({ hasText: 'kb-folder' })).toBeVisible({ timeout: 3000 }); + }); + + test('customized Alt+Z reopens last closed tab', async ({ page }) => { + // Remap reopenLastClosedTab to Alt+Z + await openKeybindingsTab(page); + const row = page.getByTestId('keybinding-row-reopenLastClosedTab'); + await row.hover(); + await page.getByTestId('keybinding-edit-reopenLastClosedTab').click(); + await expect(page.getByTestId('keybinding-input-reopenLastClosedTab')).toBeVisible({ timeout: 2000 }); + + await page.keyboard.down('Backspace'); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyZ'); + await page.keyboard.up('KeyZ'); + await page.keyboard.up('Alt'); + + await closePreferencesTab(page); + + await openRequest(page, 'kb-collection', 'req-1', { persist: true }); + await expect(page.locator('.request-tab').filter({ hasText: 'req-1' })).toBeVisible({ timeout: 2000 }); + + await page.keyboard.down('Shift'); + await page.keyboard.down('KeyX'); + await page.keyboard.up('KeyX'); + await page.keyboard.up('Shift'); + await expect(page.locator('.request-tab').filter({ hasText: 'req-1' })).not.toBeVisible({ timeout: 2000 }); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyZ'); + await page.keyboard.up('KeyZ'); + await page.keyboard.up('Alt'); + await expect(page.locator('.request-tab').filter({ hasText: 'req-1' })).toBeVisible({ timeout: 3000 }); + }); + }); + }); + + test.describe('SIDEBAR', () => { + test.describe('SHORTCUT: Sidebar search', () => { + test('default Cmd/Ctrl+F open sidebar search', async ({ page, createTmpDir }) => { + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Alt'); + + await page.keyboard.press(`${modifier}+KeyF`); + + await expect(page.getByPlaceholder('Search requests...')).toBeVisible({ timeout: 3000 }); + await page.getByTitle('Search requests').click(); + }); + + test('customized Alt+F opens sidebar search', async ({ page }) => { + // Remap sidebarSearch to Alt+F + await openKeybindingsTab(page); + const row = page.getByTestId('keybinding-row-sidebarSearch'); + await row.hover(); + await page.getByTestId('keybinding-edit-sidebarSearch').click(); + await expect(page.getByTestId('keybinding-input-sidebarSearch')).toBeVisible({ timeout: 2000 }); + + await page.keyboard.down('Backspace'); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyF'); + await page.keyboard.up('KeyF'); + await page.keyboard.up('Alt'); + + // Press Cmd/Ctrl+T to open sidebar search + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyF'); + await page.keyboard.up('KeyF'); + await page.keyboard.up('Alt'); + + await expect(page.getByPlaceholder('Search requests...')).toBeVisible({ timeout: 3000 }); + await page.getByTitle('Search requests').click(); + }); + }); + + test.describe('SHORTCUT: New request', () => { + test('default Cmd/Ctrl+N open new request modal', async ({ page, createTmpDir }) => { + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Alt'); + + await page.locator('.collection-item-name').filter({ hasText: 'kb-folder' }).click(); + + await page.keyboard.press(`${modifier}+KeyN`); + + await page.getByTestId('request-name').fill('nr-folder'); + await page.getByTestId('new-request-url').locator('.CodeMirror').click(); + await page.keyboard.type('https://echo.usebruno.com'); + await page.getByTestId('create-new-request-button').click(); + + await expect(page.locator('.request-tab').filter({ hasText: 'nr-folder' })).toBeVisible({ timeout: 2000 }); + }); + + test('customized Alt+N open new request modal', async ({ page, createTmpDir }) => { + // Remap newRequest to Alt+N + await openKeybindingsTab(page); + const row = page.getByTestId('keybinding-row-newRequest'); + await row.hover(); + await page.getByTestId('keybinding-edit-newRequest').click(); + await expect(page.getByTestId('keybinding-input-newRequest')).toBeVisible({ timeout: 2000 }); + + await page.keyboard.down('Backspace'); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyN'); + await page.keyboard.up('KeyN'); + await page.keyboard.up('Alt'); + + await page.locator('.collection-name').filter({ hasText: 'kb-collection' }).click(); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyN'); + await page.keyboard.up('KeyN'); + await page.keyboard.up('Alt'); + + await page.getByTestId('request-name').fill('nr-collection'); + await page.getByTestId('new-request-url').locator('.CodeMirror').click(); + await page.keyboard.type('https://echo.usebruno.com'); + await page.getByTestId('create-new-request-button').click(); + + await expect(page.locator('.request-tab').filter({ hasText: 'nr-collection' })).toBeVisible({ timeout: 2000 }); + }); + }); + + test.describe('SHORTCUT: Rename Item', () => { + test('default Cmd/Ctrl+R open rename item modal for request', async ({ page, createTmpDir }) => { + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Alt'); + + await page.locator('.collection-name').filter({ hasText: 'kb-collection' }).dblclick(); + await openRequest(page, 'kb-collection', 'req-1', { persist: true }); + await page.keyboard.press(`${modifier}+KeyR`); + + // Verify rename modal opens + const renameModal = page.locator('.bruno-modal-card').filter({ hasText: /rename request/i }); + await expect(renameModal).toBeVisible({ timeout: 3000 }); + + // Fill in the rename req name + const requestNameInput = page.locator('#collection-item-name'); + await requestNameInput.fill('req-1-renamed'); + + // Click the rename button + await page.getByTestId('rename-item-button').click(); + + // Verify renamed request appears in sidebar + // await expect(page.locator('.collection-item-name').filter({ hasText: 'req-1' })).toBeVisible({ timeout: 2000 }); + await expect(page.locator('.collection-item-name').filter({ hasText: 'req-1-rename' })).toBeVisible({ timeout: 2000 }); + }); + + test('default Cmd/Ctrl+R open rename item modal for folder', async ({ page, createTmpDir }) => { + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Alt'); + + await page.locator('.collection-item-name').filter({ hasText: 'kb-folder' }).dblclick(); + await page.keyboard.press(`${modifier}+KeyR`); + + // Verify rename modal opens + const renameModal = page.locator('.bruno-modal-card').filter({ hasText: /rename folder/i }); + await expect(renameModal).toBeVisible({ timeout: 3000 }); + + // Fill in the rename req name + const folderNameInput = page.locator('#collection-item-name'); + await folderNameInput.fill('kb-folder-renamed'); + + // Click the rename button + await page.getByTestId('rename-item-button').click(); + + // Verify renamed request appears in sidebar + await expect(page.locator('.collection-item-name').filter({ hasText: 'kb-folder-renamed' })).toBeVisible({ timeout: 2000 }); + }); + + test('default Cmd/Ctrl+R open rename item modal for collection', async ({ page, createTmpDir }) => { + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Alt'); + + await page.locator('.collection-name').filter({ hasText: 'kb-collection' }).click(); + await page.keyboard.press(`${modifier}+KeyR`); + + // Verify rename modal opens + const renameModal = page.locator('.bruno-modal-card').filter({ hasText: /rename collection/i }); + await expect(renameModal).toBeVisible({ timeout: 3000 }); + + // Fill in the rename req name + const collectionInput = page.locator('#collection-name'); + await collectionInput.fill('kb-collection-renamed'); + + // Click the rename button + await page.locator('.submit').click(); + + // Verify renamed request appears in sidebar + await expect(page.locator('.collection-name').filter({ hasText: 'kb-collection-renamed' })).toBeVisible({ timeout: 3000 }); + }); + + test('customized Alt+X open rename item modal for request', async ({ page, createTmpDir }) => { + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Alt'); + + // Remap renameItem to Alt+R + await openKeybindingsTab(page); + const row = page.getByTestId('keybinding-row-renameItem'); + await row.hover(); + await page.getByTestId('keybinding-edit-renameItem').click(); + await expect(page.getByTestId('keybinding-input-renameItem')).toBeVisible({ timeout: 2000 }); + + await page.keyboard.down('Backspace'); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyX'); + await page.keyboard.up('KeyX'); + await page.keyboard.up('Alt'); + + await openRequest(page, 'kb-collection', 'req-1-renamed', { persist: true }); + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyX'); + await page.keyboard.up('KeyX'); + await page.keyboard.up('Alt'); + + // Verify rename modal opens + const renameModal = page.locator('.bruno-modal-card').filter({ hasText: /rename request/i }); + await expect(renameModal).toBeVisible({ timeout: 3000 }); + + // Fill in the rename req name + const requestNameInput = page.locator('#collection-item-name'); + await requestNameInput.fill('req-1'); + + // Click the rename button + await page.getByTestId('rename-item-button').click(); + + // Verify renamed request appears in sidebar + await expect(page.locator('.collection-item-name').filter({ hasText: 'req-1' })).toBeVisible({ timeout: 2000 }); + }); + + test('customized Alt+R open rename item modal for folder', async ({ page, createTmpDir }) => { + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Alt'); + + await page.locator('.collection-item-name').filter({ hasText: 'kb-folder-renamed' }).dblclick(); + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyX'); + await page.keyboard.up('KeyX'); + await page.keyboard.up('Alt'); + + // Verify rename modal opens + const renameModal = page.locator('.bruno-modal-card').filter({ hasText: /rename folder/i }); + await expect(renameModal).toBeVisible({ timeout: 3000 }); + + // Fill in the rename req name + const folderNameInput = page.locator('#collection-item-name'); + await folderNameInput.fill('kb-folder'); + + // Click the rename button + await page.getByTestId('rename-item-button').click(); + + // Verify renamed request appears in sidebar + await expect(page.locator('.collection-item-name').filter({ hasText: 'kb-folder' })).toBeVisible({ timeout: 2000 }); + }); + + test('customized Alt+R open rename item modal for collection', async ({ page, createTmpDir }) => { + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Alt'); + + await page.locator('.collection-name').filter({ hasText: 'kb-collection-renamed' }).click(); + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyX'); + await page.keyboard.up('KeyX'); + await page.keyboard.up('Alt'); + + // Verify rename modal opens + const renameModal = page.locator('.bruno-modal-card').filter({ hasText: /rename collection/i }); + await expect(renameModal).toBeVisible({ timeout: 3000 }); + + // Fill in the rename req name + const collectionInput = page.locator('#collection-name'); + await collectionInput.fill('kb-collection'); + + // Click the rename button + await page.locator('.submit').click(); + + // Verify renamed request appears in sidebar + await expect(page.locator('.collection-name').filter({ hasText: 'kb-collection' })).toBeVisible({ timeout: 2000 }); + }); + }); + + test.describe('SHORTCUT: Clone Item', () => { + test('default Cmd/Ctrl+D open clone item modal for request', async ({ page, createTmpDir }) => { + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Alt'); + + await openRequest(page, 'kb-collection', 'req-1', { persist: true }); + await page.keyboard.press(`${modifier}+KeyD`); + + // Verify clone modal opens + const cloneModal = page.locator('.bruno-modal-card').filter({ hasText: /clone request/i }); + await expect(cloneModal).toBeVisible({ timeout: 3000 }); + + // Fill in the clone req name + const requestNameInput = page.locator('#collection-item-name'); + await requestNameInput.fill('req-1 clone 1'); + + // Click the clone button + await page.getByTestId('clone-item-button').click(); + + // Verify cloned request appears in sidebar + await expect(page.locator('.collection-item-name').filter({ hasText: 'req-1 clone 1' })).toBeVisible({ timeout: 2000 }); + }); + + test('default Cmd/Ctrl+D open clone item modal for folder', async ({ page, createTmpDir }) => { + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Alt'); + + await page.locator('.collection-item-name').filter({ hasText: 'kb-folder' }).dblclick(); + await page.keyboard.press(`${modifier}+KeyD`); + + // Verify clone modal opens + const cloneModal = page.locator('.bruno-modal-card').filter({ hasText: /clone folder/i }); + await expect(cloneModal).toBeVisible({ timeout: 3000 }); + + // Fill in the clone kb-folder name + const folderNameInput = page.locator('#collection-item-name'); + await folderNameInput.fill('kb-folder clone 1'); + + // Click the clone button + await page.getByTestId('clone-item-button').click(); + + // Verify cloned request appears in sidebar + await expect(page.locator('.collection-item-name').filter({ hasText: 'kb-folder clone 1' })).toBeVisible({ timeout: 2000 }); + }); + + test('customized Alt+D open clone item modal for request', async ({ page, createTmpDir }) => { + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Alt'); + + // Remap cloneItem to Alt+D + await openKeybindingsTab(page); + const row = page.getByTestId('keybinding-row-cloneItem'); + await row.hover(); + await page.getByTestId('keybinding-edit-cloneItem').click(); + await expect(page.getByTestId('keybinding-input-cloneItem')).toBeVisible({ timeout: 2000 }); + + await page.keyboard.down('Backspace'); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyD'); + await page.keyboard.up('KeyD'); + await page.keyboard.up('Alt'); + + await openRequest(page, 'kb-collection', 'req-2', { persist: true }); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyD'); + await page.keyboard.up('KeyD'); + await page.keyboard.up('Alt'); + + // Verify clone modal opens + const cloneModal = page.locator('.bruno-modal-card').filter({ hasText: /clone request/i }); + await expect(cloneModal).toBeVisible({ timeout: 3000 }); + + // Fill in the clone req name + const requestNameInput = page.locator('#collection-item-name'); + await requestNameInput.fill('req-2 clone 1'); + + // Click the clone button + await page.getByTestId('clone-item-button').click(); + + // Verify renamed request appears in sidebar + await expect(page.locator('.collection-item-name').filter({ hasText: 'req-2 clone 1' })).toBeVisible({ timeout: 2000 }); + }); + + test('customized Alt+D open clone item modal for folder', async ({ page, createTmpDir }) => { + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Alt'); + + await page.locator('.collection-item-name').filter({ hasText: 'kb-folder clone 1' }).dblclick(); + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyD'); + await page.keyboard.up('KeyD'); + await page.keyboard.up('Alt'); + + // Verify clone modal opens + const cloneModal = page.locator('.bruno-modal-card').filter({ hasText: /clone folder/i }); + await expect(cloneModal).toBeVisible({ timeout: 3000 }); + + // Fill in the clone req name + const folderNameInput = page.locator('#collection-item-name'); + await folderNameInput.fill('kb-folder clone 2'); + + // Click the clone button + await page.getByTestId('clone-item-button').click(); + + // Verify renamed request appears in sidebar + await expect(page.locator('.collection-item-name').filter({ hasText: 'kb-folder clone 2' })).toBeVisible({ timeout: 2000 }); + }); + }); + + test.describe('SHORTCUT: Copy Paste Item', () => { + test('default Cmd/Ctrl+C/V copy paste item for request', async ({ page, createTmpDir }) => { + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Alt'); + + await openRequest(page, 'kb-collection', 'req-3', { persist: true }); + await page.keyboard.press(`${modifier}+KeyC`); + await page.keyboard.press(`${modifier}+KeyV`); + + // Verify cloned request appears in sidebar + await expect(page.locator('.collection-item-name').filter({ hasText: 'req-3 (1)' })).toBeVisible({ timeout: 2000 }); + }); + + test('default Cmd/Ctrl+C/V copy paste item for folder', async ({ page }) => { + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Alt'); + + await openRequest(page, 'kb-collection', 'kb-folder clone 2', { persist: true }); + await page.keyboard.press(`${modifier}+KeyC`); + await page.keyboard.press(`${modifier}+KeyV`); + + // Verify cloned request appears in sidebar + await expect(page.locator('.collection-item-name').filter({ hasText: 'kb-folder clone 2 (1)' })).toBeVisible({ timeout: 2000 }); + }); + + test('customized Alt+C/V copy paste item for request', async ({ page, createTmpDir }) => { + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Alt'); + + // Remap copyItem to Alt+D + await openKeybindingsTab(page); + const row = page.getByTestId('keybinding-row-copyItem'); + await row.hover(); + await page.getByTestId('keybinding-edit-copyItem').click(); + await expect(page.getByTestId('keybinding-input-copyItem')).toBeVisible({ timeout: 2000 }); + + await page.keyboard.down('Backspace'); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyC'); + await page.keyboard.up('KeyC'); + await page.keyboard.up('Alt'); + + // Remap pasteItem to Alt+V + await openKeybindingsTab(page); + const row2 = page.getByTestId('keybinding-row-pasteItem'); + await row2.hover(); + await page.getByTestId('keybinding-edit-pasteItem').click(); + await expect(page.getByTestId('keybinding-input-pasteItem')).toBeVisible({ timeout: 2000 }); + + await page.keyboard.down('Backspace'); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyV'); + await page.keyboard.up('KeyV'); + await page.keyboard.up('Alt'); + + await openRequest(page, 'kb-collection', 'req-4', { persist: true }); + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyC'); + await page.keyboard.up('KeyC'); + await page.keyboard.up('Alt'); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyV'); + await page.keyboard.up('KeyV'); + await page.keyboard.up('Alt'); + + // Verify cloned request appears in sidebar + await expect(page.locator('.collection-item-name').filter({ hasText: 'req-4 (1)' })).toBeVisible({ timeout: 2000 }); + }); + + test('customized Alt+C/V copy paste item for folder', async ({ page, createTmpDir }) => { + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Alt'); + + await page.locator('.collection-item-name').filter({ hasText: 'kb-folder clone 2 (1)' }).dblclick(); + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyC'); + await page.keyboard.up('KeyC'); + await page.keyboard.up('Alt'); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyV'); + await page.keyboard.up('KeyV'); + await page.keyboard.up('Alt'); + + // Verify cloned request appears in sidebar + await expect(page.locator('.collection-item-name').filter({ hasText: 'kb-folder clone 2 (2)' })).toBeVisible({ timeout: 2000 }); + }); + }); + + test.describe('SHORTCUT: Collapse Sidebar', () => { + test('default collapse sidebar using default Cmd/Ctrl+\\', async ({ page, createTmpDir }) => { + await expect(page.getByTestId('collections')).toBeVisible(); + + // Press Cmd/Ctrl+\ to collapse sidebar + await page.keyboard.press(`${modifier}+Backslash`); + + await expect.poll( + () => page.locator('aside.sidebar').evaluate((el) => getComputedStyle(el).width), + { timeout: 5000 } + ).toBe('0px'); + + // Press Cmd/Ctrl+\ to collapse expanded sidebar + await page.keyboard.press(`${modifier}+Backslash`); + + await expect.poll( + () => page.locator('aside.sidebar').evaluate((el) => getComputedStyle(el).width), + { timeout: 5000 } + ).toBe('250px'); + }); + + test('should expand -> collapse -> expand the sidebar using customized Shift+G', async ({ page, createTmpDir }) => { + // Remap collapseSidebar to Shift+G + await openKeybindingsTab(page); + const row = page.getByTestId('keybinding-row-collapseSidebar'); + await row.hover(); + await page.getByTestId('keybinding-edit-collapseSidebar').click(); + await expect(page.getByTestId('keybinding-input-collapseSidebar')).toBeVisible({ timeout: 2000 }); + + await page.keyboard.down('Backspace'); + + await page.keyboard.down('Shift'); + await page.keyboard.down('KeyG'); + await page.keyboard.up('KeyG'); + await page.keyboard.up('Shift'); + + await closePreferencesTab(page); + + // Trigger the remapped shortcut to collapse sidebar + await page.keyboard.down('Shift'); + await page.keyboard.down('KeyG'); + await page.keyboard.up('KeyG'); + await page.keyboard.up('Shift'); + + // Verify sidebar collapsed to 0px + await expect.poll( + () => page.locator('aside.sidebar').evaluate((el) => getComputedStyle(el).width), + { timeout: 5000 } + ).toBe('0px'); + + // Trigger the remapped shortcut to expand sidebar + await page.keyboard.down('Shift'); + await page.keyboard.down('KeyG'); + await page.keyboard.up('KeyG'); + await page.keyboard.up('Shift'); + + await expect.poll( + () => page.locator('aside.sidebar').evaluate((el) => getComputedStyle(el).width), + { timeout: 5000 } + ).toBe('250px'); + }); + }); + }); + + test.describe('DEVELOPER TOOLS', () => { + test.describe('SHORTCUT: Open Terminal', () => { + test('default Cmd/Ctrl+T opens terminal', async ({ page, createTmpDir }) => { + // Open Collection-Settings tab (double-click collection name) + await page.locator('.collection-name').filter({ hasText: 'kb-collection' }).click(); + await expect(page.locator('.request-tab').filter({ hasText: 'collection' })).toBeVisible({ timeout: 2000 }); + + // Press Cmd/Ctrl+T to open terminal at workspace level + await page.keyboard.press(`${modifier}+KeyT`); + + // Verify terminal session is visible using data-testid + const collectionTerminalSession = page.getByTestId('session-list-0'); + await expect(collectionTerminalSession).toBeVisible({ timeout: 2000 }); + + const collectionSession = collectionTerminalSession; + await expect(collectionSession).toContainText('kb-collection'); + await page.getByTitle('Close console').click(); + + // Open Folder-Settings tab (create folder + double-click) + await createFolder(page, 'kb-terminal-folder', 'kb-collection', true); + + // Open folder settings + await page.locator('.collection-item-name').filter({ hasText: 'kb-terminal-folder' }).dblclick(); + await expect(page.locator('.request-tab').filter({ hasText: 'kb-terminal-folder' })).toBeVisible({ timeout: 2000 }); + + await page.keyboard.press(`${modifier}+KeyT`); + const folderTerminalSession = page.getByTestId('session-list-1'); + await expect(folderTerminalSession).toBeVisible({ timeout: 2000 }); + + // Verify the terminal session name is the workspace name (default_workspace) + const folderSessionName = folderTerminalSession; + await expect(folderSessionName).toContainText('kb-terminal-folder'); + + // Close all sessions with terminal tab + await page.getByTestId('session-close-1').click(); + await page.waitForTimeout(1000); + await page.getByTestId('session-close-0').click(); + await expect(page.getByTestId('session-close-0')).not.toBeVisible({ timeout: 3000 }); + await page.getByTitle('Close console').click(); + }); + + test('customized Alt+T opens terminal', async ({ page, createTmpDir }) => { + // Remap openTerminal to Alt+T + await openKeybindingsTab(page); + const row = page.getByTestId('keybinding-row-openTerminal'); + await row.hover(); + await page.getByTestId('keybinding-edit-openTerminal').click(); + await expect(page.getByTestId('keybinding-input-openTerminal')).toBeVisible({ timeout: 2000 }); + + await page.keyboard.down('Backspace'); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyT'); + await page.keyboard.up('KeyT'); + await page.keyboard.up('Alt'); + + await page.locator('.collection-name').filter({ hasText: 'kb-collection' }).click(); + await expect(page.locator('.request-tab').filter({ hasText: 'collection' })).toBeVisible({ timeout: 2000 }); + + // Press Cmd/Ctrl+T to open terminal at workspace level + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyT'); + await page.keyboard.up('KeyT'); + await page.keyboard.up('Alt'); + await page.waitForTimeout(500); + + // Verify terminal session is visible using data-testid + const collectionTerminalSession = page.getByTestId('session-list-0'); + await expect(collectionTerminalSession).toBeVisible({ timeout: 2000 }); + + const collectionSession = collectionTerminalSession; + await expect(collectionSession).toContainText('kb-collection'); + + // Open folder settings + await page.locator('.collection-item-name').filter({ hasText: 'kb-terminal-folder' }).dblclick(); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyT'); + await page.keyboard.up('KeyT'); + await page.keyboard.up('Alt'); + const folderTerminalSession = page.getByTestId('session-list-1'); + await expect(folderTerminalSession).toBeVisible({ timeout: 2000 }); + + // Verify the terminal session name is the workspace name (default_workspace) + const folderSessionName = folderTerminalSession; + await expect(folderSessionName).toContainText('kb-terminal-folder'); + + // Close all sessions with terminal tab + await page.getByTestId('session-close-1').click(); + await page.waitForTimeout(1000); + await page.getByTestId('session-close-0').click(); + await expect(page.getByTestId('session-close-0')).not.toBeVisible({ timeout: 3000 }); + await page.getByTitle('Close console').click(); + }); + }); + }); + + test.describe('LAYOUT', () => { + test.describe('SHORTCUT: Change Layout', () => { + test('default Cmd/Ctrl+J change layout orientation', async ({ page, createTmpDir }) => { + await openRequest(page, 'kb-collection', 'req-5', { persist: true }); + + // Press Cmd/Ctrl+J to change layout + await page.keyboard.press(`${modifier}+KeyJ`); + + await expect( + page.getByTestId('response-layout-toggle-btn') + ).toHaveAttribute('title', 'Switch to horizontal layout', { timeout: 2000 }); + + // Press Cmd/Ctrl+J to change layout + await page.keyboard.press(`${modifier}+KeyJ`); + + await expect( + page.getByTestId('response-layout-toggle-btn') + ).toHaveAttribute('title', 'Switch to vertical layout', { timeout: 2000 }); + + // Press Cmd/Ctrl+J to change layout + await page.keyboard.press(`${modifier}+KeyJ`); + + await expect( + page.getByTestId('response-layout-toggle-btn') + ).toHaveAttribute('title', 'Switch to horizontal layout', { timeout: 2000 }); + }); + + test('customized Alt+Shift+Y change layout orientation', async ({ page, createTmpDir }) => { + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Alt'); + + // Remap changeLayout to Alt+D + await openKeybindingsTab(page); + const row = page.getByTestId('keybinding-row-changeLayout'); + await row.hover(); + await page.getByTestId('keybinding-edit-changeLayout').click(); + await expect(page.getByTestId('keybinding-input-changeLayout')).toBeVisible({ timeout: 2000 }); + + await page.keyboard.down('Backspace'); + + await page.keyboard.down('Alt'); + await page.keyboard.down('Shift'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Shift'); + await page.keyboard.up('Alt'); + + await openRequest(page, 'kb-collection', 'req-5', { persist: true }); + + await page.keyboard.down('Alt'); + await page.keyboard.down('Shift'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Shift'); + await page.keyboard.up('Alt'); + + await expect( + page.getByTestId('response-layout-toggle-btn') + ).toHaveAttribute('title', 'Switch to vertical layout', { timeout: 2000 }); + + // Press Cmd/Ctrl+J to change layout + await page.keyboard.down('Alt'); + await page.keyboard.down('Shift'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Shift'); + await page.keyboard.up('Alt'); + + await expect( + page.getByTestId('response-layout-toggle-btn') + ).toHaveAttribute('title', 'Switch to horizontal layout', { timeout: 2000 }); + + // Press Cmd/Ctrl+J to change layout + await page.keyboard.down('Alt'); + await page.keyboard.down('Shift'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Shift'); + await page.keyboard.up('Alt'); + + await expect( + page.getByTestId('response-layout-toggle-btn') + ).toHaveAttribute('title', 'Switch to vertical layout', { timeout: 2000 }); + }); + }); + + test.describe('SHORTCUT: Open Preferences', () => { + test('default Cmd/Ctrl+, open preferences', async ({ page }) => { + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Alt'); + + // Press Cmd/Ctrl+J to change layout + await page.keyboard.down(modifier); + await page.keyboard.down('Comma'); + await page.keyboard.up('Comma'); + await page.keyboard.up(modifier); + + await expect(page.locator('.request-tab.select-none.active.last-tab').filter({ hasText: 'Preferences' })).toBeVisible({ timeout: 2000 }); + }); + + test('customized Cmd/Ctrl+P open preferences', async ({ page }) => { + // Remap openPreferences to Ctrl+P + await openKeybindingsTab(page); + const row = page.getByTestId('keybinding-row-openPreferences'); + await row.hover(); + await page.getByTestId('keybinding-edit-openPreferences').click(); + await expect(page.getByTestId('keybinding-input-openPreferences')).toBeVisible({ timeout: 2000 }); + + await page.keyboard.down('Backspace'); + + await page.keyboard.down(modifier); + await page.keyboard.down('KeyP'); + await page.keyboard.up('KeyP'); + await page.keyboard.up(modifier); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Alt'); + + // Press Cmd/Ctrl+J to change layout + await page.keyboard.down(modifier); + await page.keyboard.down('KeyP'); + await page.keyboard.up('KeyP'); + await page.keyboard.up(modifier); + + await expect(page.locator('.request-tab.select-none.active.last-tab').filter({ hasText: 'Preferences' })).toBeVisible({ timeout: 2000 }); + }); + }); + }); + + test.describe('SEARCH', () => { + test.describe('SHORTCUT: Global Search', () => { + test('default Cmd/Ctrl+K Global Search Modal', async ({ page, createTmpDir }) => { + // Press Cmd/Ctrl+K to global search modal + await page.keyboard.press(`${modifier}+KeyK`); + + await page.keyboard.down(modifier); + await page.keyboard.down('KeyK'); + await page.keyboard.up('KeyK'); + await page.keyboard.up(modifier); + + await page.getByTestId('global-search-input').click(); + await expect(page.getByTestId('global-search-input')).toBeVisible({ timeout: 2000 }); + + // await page.waitForTimeout(500); + await page.keyboard.down('Escape'); + await page.keyboard.up('Escape'); + }); + + test('customized Alt+K Global Search Modal', async ({ page, createTmpDir }) => { + // Remap globalSearch to Alt+K + await openKeybindingsTab(page); + const row = page.getByTestId('keybinding-row-globalSearch'); + await row.hover(); + await page.getByTestId('keybinding-edit-globalSearch').click(); + await expect(page.getByTestId('keybinding-input-globalSearch')).toBeVisible({ timeout: 2000 }); + + await page.keyboard.down('Backspace'); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyK'); + await page.keyboard.up('KeyK'); + await page.keyboard.up('Alt'); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyK'); + await page.keyboard.up('KeyK'); + await page.keyboard.up('Alt'); + + await page.getByTestId('global-search-input').click(); + await expect(page.getByTestId('global-search-input')).toBeVisible({ timeout: 2000 }); + + await page.keyboard.down('Escape'); + await page.keyboard.up('Escape'); + }); + }); + }); + + test.describe('SHORTCUT: Edit Environment', () => { + test('open environment tab of collection Cmd/Ctrl+E', async ({ page, createTmpDir }) => { + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Alt'); + + await openRequest(page, 'kb-collection', 'req-7', { persist: true }); + + await page.keyboard.down(modifier); + await page.keyboard.down('KeyE'); + await page.keyboard.up('KeyE'); + await page.keyboard.up(modifier); + + await expect(page.locator('.request-tab').filter({ hasText: 'Environments' })).toBeVisible({ timeout: 2000 }); + }); + + test('open environment tab of collection customized Alt+E', async ({ page, createTmpDir }) => { + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyY'); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Alt'); + + // Remap editEnvironment to Alt+E + await openKeybindingsTab(page); + const row = page.getByTestId('keybinding-row-editEnvironment'); + await row.hover(); + await page.getByTestId('keybinding-edit-editEnvironment').click(); + await expect(page.getByTestId('keybinding-input-editEnvironment')).toBeVisible({ timeout: 2000 }); + + await page.keyboard.down('Backspace'); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyE'); + await page.keyboard.up('KeyE'); + await page.keyboard.up('Alt'); + + await openRequest(page, 'kb-collection', 'req-7', { persist: true }); + + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyE'); + await page.keyboard.up('KeyE'); + await page.keyboard.up('Alt'); + + await expect(page.locator('.request-tab').filter({ hasText: 'Environments' })).toBeVisible({ timeout: 2000 }); + + // Rest Default - just in case to not fail shortcuts in other places + await openKeybindingsTab(page); + await page.getByTestId('reset-all-keybindings-btn').click({ timeout: 2000 }); + }); + }); +}); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index 980f4857f..cd212f0fc 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -1,4 +1,5 @@ import { test, expect, Page } from '../../../playwright'; +import process from 'node:process'; import { buildCommonLocators, buildScriptErrorLocators } from './locators'; type SandboxMode = 'safe' | 'developer'; @@ -153,7 +154,8 @@ const createUntitledRequest = async ( await tagInput.press('Enter'); await page.waitForTimeout(200); await expect(page.locator('.tag-item', { hasText: tag })).toBeVisible(); - await page.keyboard.press('Meta+s'); + const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s'; + await page.keyboard.press(saveShortcut); await page.waitForTimeout(200); } @@ -261,7 +263,9 @@ const createRequest = async ( await locators.actions.collectionItemActions(parentName).click(); } else { await locators.sidebar.collection(parentName).hover(); - await locators.actions.collectionActions(parentName).click(); + const collectionAction = locators.actions.collectionActions(parentName); + await expect(collectionAction).toBeVisible({ timeout: 2000 }); + await collectionAction.click(); } await locators.dropdown.item('New Request').click(); @@ -486,7 +490,7 @@ const createFolder = async ( } await locators.dropdown.item('New Folder').click(); - await page.getByPlaceholder('Folder Name').fill(folderName); + await page.getByTestId('new-folder-input').fill(folderName); await locators.modal.button('Create').click(); await expect(locators.sidebar.folder(folderName)).toBeVisible(); }); @@ -1016,7 +1020,8 @@ const deleteAssertion = async (page: Page, rowIndex: number) => { */ const saveRequest = async (page: Page) => { await test.step('Save request', async () => { - await page.keyboard.press('Meta+s'); + const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s'; + await page.keyboard.press(saveShortcut); await expect(page.getByText('Request saved successfully').last()).toBeVisible({ timeout: 3000 }); await page.waitForTimeout(200); });