From 14532b48a6c55f8020f8b06646dfefaa6559aefa Mon Sep 17 00:00:00 2001 From: shubh-bruno Date: Tue, 3 Mar 2026 18:49:18 +0530 Subject: [PATCH] feat(phase-1): allow user to customize keybindings (#7163) * feat(phase-1): allow user to customize keybindings * fix: necessary changes for customizied keybindings to work * fix: updated hotkeys provider * fix: test cases for edit keybindings in preferences * fix: removed old keyboard shortcuts test cases * fix: resolved coderabbit coments * fix: fixed move tabs test cases * feat: provided customized keybindings shorcut for codemirror instacnces * fix: handle closetabs/closeAllTabs in RequestTab/index.js for better consitency * fix: resolved comments * fix: resolved zoom issues * fix: revert codemirror instacnces * fix: handle codemirror instances shortcut in .Pass * feat: integrate shorcut keys with codemirror instacneces * fix: ui updates * fix: updated shortcuts * fix: test cases * chore: revert `alt-enter` keybind * chore: allow jest to replace esm spec in store * chore: lint whitespace fix --------- Co-authored-by: shubh-bruno --- packages/bruno-app/jest.config.js | 3 +- .../babel-with-esm-replacements.cjs | 8 + .../src/components/CodeEditor/index.js | 13 + .../src/components/MultiLineEditor/index.js | 33 +- .../Preferences/Keybindings/StyledWrapper.js | 207 +- .../Preferences/Keybindings/index.js | 614 ++++- .../RequestTabs/RequestTab/index.js | 60 + .../CloneCollectionItem/index.js | 2 +- .../Collection/CollectionItem/index.js | 93 +- .../Sidebar/Collections/Collection/index.js | 28 + .../src/components/Sidebar/NewFolder/index.js | 1 + .../Sections/CollectionsSection/index.js | 18 +- .../src/components/SingleLineEditor/index.js | 21 +- .../src/components/StatusBar/index.js | 5 +- .../bruno-app/src/providers/Hotkeys/index.js | 573 +++-- .../src/providers/Hotkeys/keyMappings.js | 133 +- .../src/utils/codemirror/shortcuts.js | 233 ++ .../bruno-electron/src/app/menu-template.js | 3 +- packages/bruno-electron/src/index.js | 34 +- .../bruno-electron/src/store/preferences.js | 46 + .../collection/moving-tabs/move-tabs.spec.ts | 6 +- .../copy-request/keyboard-shortcuts.spec.ts | 93 - .../preference-shortcuts-edit.spec.js | 2140 +++++++++++++++++ tests/utils/page/actions.ts | 2 +- 24 files changed, 3897 insertions(+), 472 deletions(-) create mode 100644 packages/bruno-app/jest/transformers/babel-with-esm-replacements.cjs create mode 100644 packages/bruno-app/src/utils/codemirror/shortcuts.js delete mode 100644 tests/request/copy-request/keyboard-shortcuts.spec.ts create mode 100644 tests/shortcuts/preference-shortcuts-edit.spec.js diff --git a/packages/bruno-app/jest.config.js b/packages/bruno-app/jest.config.js index 31cac61da..c4e9b8ac6 100644 --- a/packages/bruno-app/jest.config.js +++ b/packages/bruno-app/jest.config.js @@ -1,7 +1,8 @@ module.exports = { rootDir: '.', transform: { - '^.+\\.[jt]sx?$': 'babel-jest' + '^.+\\.[jt]sx?$': '/jest/transformers/babel-with-esm-replacements.cjs' + // '^.+\\.[jt]sx?$': [require("./jest/transformers/with-replacements.cjs"),'babel-jest'] }, transformIgnorePatterns: [ '/node_modules/(?!strip-json-comments|nanoid|xml-formatter)/' diff --git a/packages/bruno-app/jest/transformers/babel-with-esm-replacements.cjs b/packages/bruno-app/jest/transformers/babel-with-esm-replacements.cjs new file mode 100644 index 000000000..d4d8a12ff --- /dev/null +++ b/packages/bruno-app/jest/transformers/babel-with-esm-replacements.cjs @@ -0,0 +1,8 @@ +const babelJest = require('babel-jest') + +module.exports = { + process(sourceText, sourcePath, options) { + const transformer = babelJest.createTransformer(); + return transformer.process(sourceText.replace(`import.meta.env.MODE`, 'test'), sourcePath, options) + } +}; diff --git a/packages/bruno-app/src/components/CodeEditor/index.js b/packages/bruno-app/src/components/CodeEditor/index.js index 98593967f..323be9a3c 100644 --- a/packages/bruno-app/src/components/CodeEditor/index.js +++ b/packages/bruno-app/src/components/CodeEditor/index.js @@ -16,6 +16,7 @@ import stripJsonComments from 'strip-json-comments'; import { getAllVariables } from 'utils/collections'; import { setupLinkAware } from 'utils/codemirror/linkAware'; import { setupLintErrorTooltip } from 'utils/codemirror/lint-errors'; +import { setupShortcuts } from 'utils/codemirror/shortcuts'; import CodeMirrorSearch from 'components/CodeMirrorSearch/index'; const CodeMirror = require('codemirror'); @@ -46,6 +47,9 @@ export default class CodeEditor extends React.Component { this.state = { searchBarVisible: false }; + + // Shortcuts cleanup function + this._shortcutsCleanup = null; } componentDidMount() { @@ -217,6 +221,9 @@ export default class CodeEditor extends React.Component { // Setup lint error tooltip on line number hover this.cleanupLintErrorTooltip = setupLintErrorTooltip(editor); + + // Setup keyboard shortcuts + this._shortcutsCleanup = setupShortcuts(editor, this); } } @@ -288,6 +295,12 @@ export default class CodeEditor extends React.Component { } componentWillUnmount() { + // Cleanup shortcuts (keymap and store subscription) + if (this._shortcutsCleanup) { + this._shortcutsCleanup(); + this._shortcutsCleanup = null; + } + if (this.editor) { if (this.props.onScroll) { this.props.onScroll(this.editor); diff --git a/packages/bruno-app/src/components/MultiLineEditor/index.js b/packages/bruno-app/src/components/MultiLineEditor/index.js index 25f1cf52f..56e57d48c 100644 --- a/packages/bruno-app/src/components/MultiLineEditor/index.js +++ b/packages/bruno-app/src/components/MultiLineEditor/index.js @@ -6,6 +6,7 @@ import { setupAutoComplete } from 'utils/codemirror/autocomplete'; import { MaskedEditor } from 'utils/common/masked-editor'; import StyledWrapper from './StyledWrapper'; import { setupLinkAware } from 'utils/codemirror/linkAware'; +import { setupShortcuts } from 'utils/codemirror/shortcuts'; import { IconEye, IconEyeOff } from '@tabler/icons'; const CodeMirror = require('codemirror'); @@ -24,6 +25,9 @@ class MultiLineEditor extends Component { this.state = { maskInput: props.isSecret || false // Always mask the input by default (if it's a secret) }; + + // Shortcuts cleanup function + this._shortcutsCleanup = null; } componentDidMount() { @@ -45,16 +49,16 @@ class MultiLineEditor extends Component { readOnly: this.props.readOnly, tabindex: 0, extraKeys: { - 'Ctrl-Enter': () => { - if (this.props.onRun) { - this.props.onRun(); - } - }, - 'Cmd-Enter': () => { - if (this.props.onRun) { - this.props.onRun(); - } - }, + // 'Ctrl-Enter': () => { + // if (this.props.onRun) { + // this.props.onRun(); + // } + // }, + // 'Cmd-Enter': () => { + // if (this.props.onRun) { + // this.props.onRun(); + // } + // }, 'Cmd-S': () => { if (this.props.onSave) { this.props.onSave(); @@ -90,6 +94,9 @@ class MultiLineEditor extends Component { setupLinkAware(this.editor); + // Setup keyboard shortcuts + this._shortcutsCleanup = setupShortcuts(this.editor, this); + this.editor.setValue(String(this.props.value) || ''); this.editor.on('change', this._onEdit); this.addOverlay(variables); @@ -179,6 +186,12 @@ class MultiLineEditor extends Component { } componentWillUnmount() { + // Cleanup shortcuts (keymap and store subscription) + if (this._shortcutsCleanup) { + this._shortcutsCleanup(); + this._shortcutsCleanup = null; + } + if (this.brunoAutoCompleteCleanup) { this.brunoAutoCompleteCleanup(); } diff --git a/packages/bruno-app/src/components/Preferences/Keybindings/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/Keybindings/StyledWrapper.js index 01a80ba78..f89ff0a26 100644 --- a/packages/bruno-app/src/components/Preferences/Keybindings/StyledWrapper.js +++ b/packages/bruno-app/src/components/Preferences/Keybindings/StyledWrapper.js @@ -1,53 +1,198 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` + min-height: 0; + height: 100%; + display: flex; flex-direction: column; - gap: 1rem; - width: 100%; - - table { - width: 80%; - border-collapse: collapse; - thead, - td { - border: 1px solid ${(props) => props.theme.table.border}; + &::-webkit-scrollbar { + display: none; + } + scrollbar-width: none; + -ms-overflow-style: none; + + .section-header { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 14px; + } + + .reset-all-btn { + display: flex; + align-items: center; + background: transparent; + border: 1px solid ${(props) => props.theme.table.border}; + border-radius: 6px; + padding: 4px 4px; + cursor: pointer; + color: ${(props) => props.theme.text}; + font-size: 12px; + font-weight: 500; + transition: all 0.2s ease; + + &:hover { + background: ${(props) => props.theme.button.secondary.hoverBg}; + border-color: ${(props) => props.theme.button.secondary.hoverBorder}; + } + } + + .keybinding-row { + display: flex; + align-items: center; + gap: 10px; + } + + .keybinding-row:hover .edit-btn { + opacity: 0.9; + } + + .shortcut-wrap { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 260px; + flex: 1; + } + + .shortcut-input { + width: 200px; + max-width: 200px; + flex-shrink: 0; + + caret-color: ${(props) => props.theme.table.input.color}; + + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + border: none; + outline: none; + background: transparent; + + font-family: monospace; + color: ${(props) => props.theme.table.input.color}; + cursor: pointer; + + &:hover { + opacity: 0.85; } - thead { - color: ${(props) => props.theme.table.thead.color}; - font-size: ${(props) => props.theme.font.size.base}; - user-select: none; + &:focus { + opacity: 1; } - td { - padding: 6px 10px; - font-size: ${(props) => props.theme.font.size.sm}; + &::placeholder { + opacity: 0.5; } + } - thead th { - font-weight: 500; - padding: 10px; - text-align: left; - border: 1px solid ${(props) => props.theme.table.border}; + .edit-btn { + background: transparent; + border: none; + color: ${(props) => props.theme.table.thead.color}; + padding: 0; + cursor: pointer; + opacity: 0.6; + + &:hover { + opacity: 1; } } + .reset-btn { + background: transparent; + border: none; + color: ${(props) => props.theme.table.thead.color}; + border-radius: 8px; + padding: 0px; + cursor: pointer; + user-select: none; + white-space: nowrap; + } + + .shortcut-input--error { + opacity: 1; + } + + .kb-tooltip { + border-radius: 8px; + padding: 6px 8px; + font-size: 12px; + line-height: 1.2; + max-width: 320px; + white-space: normal; + } + + .kb-tooltip--error { + color: ${(props) => props.theme.colors?.text?.red || '#ef4444'}; + } + .table-container { + flex: 1 1 auto; + min-height: 0; + max-height: 650px; overflow-y: auto; + + border-radius: 8px; + border-top: 1px solid ${(props) => props.theme.table.border}; + border-bottom: 1px solid ${(props) => props.theme.table.border}; + + &::-webkit-scrollbar { + width: 0; + height: 0; + } + scrollbar-width: none; + -ms-overflow-style: none; } - .key-button { - display: inline-block; - color: ${(props) => props.theme.table.input.color}; - opacity: 0.7; - border-radius: 4px; - padding: 1px 5px; - font-family: monospace; - margin-right: 8px; - border: 1px solid #ccc; - border-bottom: 1.44px solid ${(props) => props.theme.table.input.border}; + table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + table-layout: fixed; + } + + thead th:first-child, + tbody td:first-child { + width: 35%; + } + + thead th:last-child, + tbody td:last-child { + width: 45%; + } + + thead th { + position: sticky; + top: 0; + z-index: 5; + + background: ${(props) => props.theme.background}; + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + + color: ${(props) => props.theme.table.thead.color}; + font-size: ${(props) => props.theme.font.size.base}; + user-select: none; + font-weight: 500; + padding: 10px; + text-align: left; + + border-left: 1px solid ${(props) => props.theme.table.border}; + border-right: 1px solid ${(props) => props.theme.table.border}; + border-bottom: 1px solid ${(props) => props.theme.table.border}; + box-shadow: 0 1px 0 ${(props) => props.theme.table.border}; + } + + td { + padding: 6px 10px; + font-size: ${(props) => props.theme.font.size.sm}; + border-top: 1px solid ${(props) => props.theme.table.border}; + border-left: 1px solid ${(props) => props.theme.table.border}; + border-right: 1px solid ${(props) => props.theme.table.border}; } `; diff --git a/packages/bruno-app/src/components/Preferences/Keybindings/index.js b/packages/bruno-app/src/components/Preferences/Keybindings/index.js index 599e6655c..cb61f4d89 100644 --- a/packages/bruno-app/src/components/Preferences/Keybindings/index.js +++ b/packages/bruno-app/src/components/Preferences/Keybindings/index.js @@ -1,14 +1,524 @@ +import React, { useMemo, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import toast from 'react-hot-toast'; + import StyledWrapper from './StyledWrapper'; -import React from 'react'; -import { getKeyBindingsForOS } from 'providers/Hotkeys/keyMappings'; +import { IconRefresh, IconPencil } from '@tabler/icons'; import { isMacOS } from 'utils/common/platform'; -const Keybindings = ({ close }) => { - const keyMapping = getKeyBindingsForOS(isMacOS() ? 'mac' : 'windows'); +import { savePreferences } from 'providers/ReduxStore/slices/app'; +import { DEFAULT_KEY_BINDINGS } from 'providers/Hotkeys/keyMappings.js'; +import { Tooltip } from 'react-tooltip'; + +const SEP = '+bind+'; +const getOS = () => (isMacOS() ? 'mac' : 'windows'); + +// Stored tokens must match your preferences defaults (lowercase) +const MODIFIERS = new Set(['ctrl', 'command', 'alt', 'shift']); + +const REQUIRED_MODIFIERS_BY_OS = { + mac: new Set(['command', 'alt', 'shift', 'ctrl']), + windows: new Set(['ctrl', 'alt', 'shift']) // command (Win key) should NOT count +}; + +const hasRequiredModifier = (os, arr) => arr.some((k) => REQUIRED_MODIFIERS_BY_OS[os]?.has(k)); +const isOnlyModifiers = (arr) => arr.length > 0 && arr.every((k) => MODIFIERS.has(k)); + +const sortCombo = (arr) => { + const order = ['ctrl', 'command', 'alt', 'shift']; + const modifiers = []; + const nonModifiers = []; + + // Separate modifiers from non-modifiers + arr.forEach((key) => { + if (order.includes(key)) { + modifiers.push(key); + } else { + nonModifiers.push(key); + } + }); + + // Sort modifiers by their order + modifiers.sort((a, b) => order.indexOf(a) - order.indexOf(b)); + + // Keep non-modifiers in the order they were pressed (don't sort them) + return [...modifiers, ...nonModifiers]; +}; + +const uniqSorted = (arr) => { + // Remove duplicates while preserving order + const unique = []; + const seen = new Set(); + arr.forEach((key) => { + if (!seen.has(key)) { + seen.add(key); + unique.push(key); + } + }); + return sortCombo(unique); +}; + +const fromKeysString = (keysStr) => (keysStr ? keysStr.split(SEP).filter(Boolean) : []); +const toKeysString = (keysArr) => uniqSorted(keysArr).join(SEP); + +// Signature MUST be stable: unique + sorted +const comboSignature = (arr) => toKeysString(arr); + +// OS reserved shortcuts in stored-token format +const RESERVED_BY_OS = { + mac: new Set([ + comboSignature(['command', 'q']), + comboSignature(['command', 'w']), + comboSignature(['command', 'h']), + comboSignature(['command', 'm']), + comboSignature(['command', 'tab']), + comboSignature(['command', 'space']), + comboSignature(['ctrl', 'command', 'q']), + comboSignature(['command', ',']), + comboSignature(['command', 'shift', '3']), + comboSignature(['command', 'shift', '4']), + comboSignature(['command', 'shift', '5']), + comboSignature(['command', 'alt', 'esc']) + ]), + windows: new Set([ + comboSignature(['alt', 'tab']), + comboSignature(['alt', 'f4']), + comboSignature(['ctrl', 'alt', 'delete']), + comboSignature(['command', 'l']), + comboSignature(['command', 'd']), + comboSignature(['command', 'e']), + comboSignature(['command', 'r']), + comboSignature(['command', 'tab']), + comboSignature(['ctrl', 'shift', 'esc']) + ]) +}; + +// normalize keyboard event -> stored tokens +const normalizeKey = (e) => { + const k = e.key; + + // ignore lock keys + if (k === 'CapsLock' || k === 'NumLock' || k === 'ScrollLock') return null; + + if (k === ' ') return 'space'; + if (k === 'Escape') return 'esc'; + if (k === 'Control') return 'ctrl'; + if (k === 'Alt') return 'alt'; + if (k === 'Shift') return 'shift'; + if (k === 'Enter') return 'enter'; + if (k === 'Backspace') return 'backspace'; + if (k === 'Tab') return 'tab'; + if (k === 'Delete') return 'delete'; + + // Meta -> command (matches your stored default format) + if (k === 'Meta') return 'command'; + + // single char (letters/punct) to lowercase + if (k.length === 1) return k.toLowerCase(); + + // ArrowUp -> arrowup, PageUp -> pageup, etc + return k.toLowerCase(); +}; + +const ERROR = { + EMPTY: 'EMPTY', + ONLY_MODIFIERS: 'ONLY_MODIFIERS', + MISSING_REQUIRED_MOD: 'MISSING_REQUIRED_MOD', + RESERVED: 'RESERVED', + DUPLICATE: 'DUPLICATE', + CONFLICT: 'CONFLICT' +}; + +const Keybindings = () => { + const dispatch = useDispatch(); + const preferences = useSelector((state) => state.app.preferences); + + const os = getOS(); + + // Source of truth: merge defaults with user preferences + const keyBindings = useMemo(() => { + const merged = {}; + + // Start with defaults + for (const [action, binding] of Object.entries(DEFAULT_KEY_BINDINGS)) { + merged[action] = { ...binding }; + } + + // Override with user preferences + const userBindings = preferences?.keyBindings || {}; + for (const [action, binding] of Object.entries(userBindings)) { + if (merged[action]) { + // Merge user's OS-specific overrides into defaults + merged[action] = { + ...merged[action], + ...binding + }; + } + } + + return merged; + }, [preferences?.keyBindings]); + + // Build table data (action -> { name, keys }) + const keyMapping = useMemo(() => { + const out = {}; + for (const [action, binding] of Object.entries(keyBindings)) { + if (binding?.[os]) out[action] = { name: binding.name, keys: binding[os] }; + } + return out; + }, [keyBindings, os]); + + // ✏️ which row is allowed to edit (pencil clicked) + const [editingAction, setEditingAction] = useState(null); + + // hover tracking (for showing pencil/refresh only on hover row) + const [hoveredAction, setHoveredAction] = useState(null); + + // Recording state + const [recordingAction, setRecordingAction] = useState(null); + const pressedKeysRef = useRef(new Set()); + const inputRefs = useRef({}); + const [draftByAction, setDraftByAction] = useState({}); // action -> string[] + const [errorByAction, setErrorByAction] = useState({}); // action -> { code, message } + + const getCurrentRowKeysString = (action) => keyBindings?.[action]?.[os] || ''; + const getDefaultRowKeysString = (action) => DEFAULT_KEY_BINDINGS?.[action]?.[os] || ''; + + const isRowDirty = (action) => { + const current = getCurrentRowKeysString(action); + const def = getDefaultRowKeysString(action); + if (!DEFAULT_KEY_BINDINGS) return false; + return current !== def; + }; + + // Check if any keybinding is dirty (different from default) + const hasDirtyRows = useMemo(() => { + for (const action of Object.keys(DEFAULT_KEY_BINDINGS)) { + if (isRowDirty(action)) { + return true; + } + } + return false; + }, [keyBindings, os]); + + const buildUsedSignatures = (excludeAction) => { + const used = new Set(); + for (const [action, binding] of Object.entries(keyBindings)) { + if (action === excludeAction) continue; + const keysStr = binding?.[os]; + if (!keysStr) continue; + used.add(comboSignature(fromKeysString(keysStr))); + } + return used; + }; + + const validateCombo = (action, arrRaw) => { + const arr = uniqSorted(arrRaw); + const sig = comboSignature(arr); + + if (!sig) return { code: ERROR.EMPTY, message: `Shortcut can’t be empty.` }; + if (isOnlyModifiers(arr)) + return { code: ERROR.ONLY_MODIFIERS, message: 'Add a non-modifier key (e.g. Ctrl + K).' }; + + // OS-specific must-have modifier rule + if (!hasRequiredModifier(os, arr)) { + return { + code: ERROR.MISSING_REQUIRED_MOD, + message: + os === 'mac' + ? 'macOS shortcuts must include at least one modifier (command/alt/shift/ctrl).' + : 'Windows shortcuts must include at least one modifier (ctrl/alt/shift).' + }; + } + + // OS reserved + if (RESERVED_BY_OS[os]?.has(sig)) + return { code: ERROR.RESERVED, message: 'This shortcut is reserved by the OS.' }; + + // No duplicates (across all other actions) + if (buildUsedSignatures(action).has(sig)) + return { code: ERROR.DUPLICATE, message: 'That shortcut is already in use.' }; + + // Check for subset conflicts (e.g., Cmd+A conflicts with Cmd+Z+A) + for (const [otherAction, binding] of Object.entries(keyBindings)) { + if (otherAction === action) continue; + const otherKeysStr = binding?.[os]; + if (!otherKeysStr) continue; + + const otherKeys = fromKeysString(otherKeysStr); + + // Check if current is a subset of other (current is shorter) + if (arr.length < otherKeys.length) { + const isSubset = arr.every((k) => otherKeys.includes(k)); + if (isSubset) { + return { + code: ERROR.CONFLICT, + message: `Conflicts with "${binding.name}" (${otherKeys.join(' + ')}). Remove the longer shortcut first.` + }; + } + } + + // Check if other is a subset of current (current is longer) + if (arr.length > otherKeys.length) { + const isSubset = otherKeys.every((k) => arr.includes(k)); + if (isSubset) { + return { + code: ERROR.CONFLICT, + message: `Conflicts with "${binding.name}" (${otherKeys.join(' + ')}). Remove that shortcut first.` + }; + } + } + } + + return null; + }; + + const persistToPreferences = (action, nextKeys) => { + const updatedPreferences = { + ...preferences, + keyBindings: { + ...(preferences?.keyBindings || {}), + [action]: { + ...(preferences?.keyBindings?.[action] || {}), + name: preferences?.keyBindings?.[action]?.name || action, + [os]: nextKeys + } + } + }; + + dispatch(savePreferences(updatedPreferences)); + }; + + // Commit only if valid. Returns true if commit succeeded (or no-op), false if invalid. + const commitCombo = (action) => { + const draftArr = draftByAction[action] || []; + if (!draftArr.length) return; + + const arr = uniqSorted(draftArr); + const err = validateCombo(action, arr); + + if (err) { + setErrorByAction((prev) => ({ ...prev, [action]: err })); + return false; + } + + setErrorByAction((prev) => { + const next = { ...prev }; + delete next[action]; + return next; + }); + + const nextKeys = toKeysString(arr); + const currentKeys = getCurrentRowKeysString(action); + if (nextKeys === currentKeys) return true; + + persistToPreferences(action, nextKeys); + // toast success for 2s with Command name + const commandName = keyBindings?.[action]?.name || action; + toast.success(`"${commandName}" shortcut updated`, { autoClose: 2000 }); + return true; + }; + + const resetRowToDefault = (action) => { + const def = DEFAULT_KEY_BINDINGS?.[action]?.[os]; + if (!def) return; + + setErrorByAction((prev) => { + const next = { ...prev }; + delete next[action]; + return next; + }); + + setDraftByAction((prev) => { + const next = { ...prev }; + delete next[action]; + return next; + }); + + persistToPreferences(action, def); + }; + + const resetAllKeybindings = () => { + const updatedPreferences = { + ...preferences, + keyBindings: {} + }; + dispatch(savePreferences(updatedPreferences)); + }; + + const startEditing = (action) => { + // if another row is editing, commit/stop it first + if (editingAction && editingAction !== action) { + const ok = commitCombo(editingAction); + if (ok) { + setRecordingAction(null); + setEditingAction(null); + pressedKeysRef.current = new Set(); + } else { + // keep previous row editing if invalid + return; + } + } + + setEditingAction(action); + setRecordingAction(action); + pressedKeysRef.current = new Set(); + + // seed draft with current value + setDraftByAction((prev) => ({ + ...prev, + [action]: fromKeysString(getCurrentRowKeysString(action)) + })); + + // clear error on start edit + setErrorByAction((prev) => { + const next = { ...prev }; + delete next[action]; + return next; + }); + + requestAnimationFrame(() => { + inputRefs.current[action]?.focus?.(); + inputRefs.current[action]?.setSelectionRange?.( + inputRefs.current[action].value.length, + inputRefs.current[action].value.length + ); + }); + }; + + const stopEditing = (action) => { + const ok = commitCombo(action); + if (!ok) { + // If commit failed (validation error), reset to original value + cancelEditing(action); + return; + } + + setRecordingAction(null); + setEditingAction(null); + pressedKeysRef.current = new Set(); + }; + + // Reset draft to original value and clear error (used on blur with invalid state) + const cancelEditing = (action) => { + // Clear error for this action + setErrorByAction((prev) => { + const next = { ...prev }; + delete next[action]; + return next; + }); + + // Reset draft to current saved value + setDraftByAction((prev) => { + const next = { ...prev }; + delete next[action]; + return next; + }); + + setRecordingAction(null); + setEditingAction(null); + pressedKeysRef.current = new Set(); + }; + + const handleKeyDown = (action, e) => { + if (recordingAction !== action || editingAction !== action) return; + + e.preventDefault(); + e.stopPropagation(); + + // allow user to clear and keep editing (do NOT auto-stop) + if (e.key === 'Backspace' || e.key === 'Delete') { + pressedKeysRef.current = new Set(); + setDraftByAction((prev) => ({ ...prev, [action]: [] })); + setErrorByAction((prev) => ({ + ...prev, + [action]: { code: ERROR.EMPTY, message: `Shortcut can't be empty.` } + })); + return; + } + + if (e.repeat) return; + + const keyName = normalizeKey(e); + if (!keyName) return; + + pressedKeysRef.current.add(keyName); + + const currentDraft = uniqSorted(Array.from(pressedKeysRef.current)); + + setDraftByAction((prev) => ({ + ...prev, + [action]: currentDraft + })); + + const err = validateCombo(action, currentDraft); + if (err) { + setErrorByAction((prev) => ({ ...prev, [action]: err })); + } else { + setErrorByAction((prev) => { + const next = { ...prev }; + delete next[action]; + return next; + }); + } + }; + + const handleKeyUp = (action, e) => { + if (recordingAction !== action || editingAction !== action) return; + + e.preventDefault(); + e.stopPropagation(); + + const keyName = normalizeKey(e); + if (!keyName) return; + + pressedKeysRef.current.delete(keyName); + + // commit only when released AND currently valid + if (pressedKeysRef.current.size === 0) { + const currentDraft = draftByAction[action] || []; + + // if empty -> keep editing + if (currentDraft.length === 0) return; + + // if error -> keep editing + if (errorByAction[action]?.message) return; + + stopEditing(action); + } + }; + + const renderValue = (action) => { + const arr + = recordingAction === action ? draftByAction[action] : fromKeysString(getCurrentRowKeysString(action)); + + return (arr || []).join(' + '); + }; return ( -
Keybindings
+ + +
+ Keybindings + {hasDirtyRows && ( + + )} +
+
@@ -19,18 +529,90 @@ const Keybindings = ({ close }) => { {keyMapping ? ( - Object.entries(keyMapping).map(([action, { name, keys }], index) => ( - - - setHoveredAction(action)} + onMouseLeave={() => setHoveredAction((prev) => (prev === action ? null : prev))} + > + + + - - )) + + + ); + }) ) : ( diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js index d6a4746cd..f7bfd4884 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js @@ -36,6 +36,10 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi const [showConfirmEnvironmentClose, setShowConfirmEnvironmentClose] = useState(false); const [showConfirmGlobalEnvironmentClose, setShowConfirmGlobalEnvironmentClose] = useState(false); + const tabs = useSelector((state) => state.tabs.tabs); + const activeTabUid = useSelector((state) => state.tabs.activeTabUid); + const activeTab = tabs.find((t) => t.uid === activeTabUid); + const menuDropdownRef = useRef(); const item = findItemInCollection(collection, tab.uid); @@ -86,6 +90,62 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi }; }, [item, item?.name, method, setHasOverflow]); + useEffect(() => { + const handleCloseTabFromHotkeys = () => { + if (!activeTabUid || !activeTab) return; + + // Only the active tab component should handle this + if (tab.uid !== activeTabUid) return; + + // Always compute item for the active tab + const activeItem = findItemInCollection(collection, activeTabUid); + + switch (activeTab.type) { + case 'request': + if (activeItem && hasRequestChanges(activeItem)) { + console.log('Item have changes'); + setShowConfirmClose(true); + } else { + console.log('Item dont have changes'); + dispatch(closeTabs({ tabUids: [activeTabUid] })); + } + break; + + case 'collection-settings': + if (collection?.draft) { + setShowConfirmCollectionClose(true); + } else { + dispatch(closeTabs({ tabUids: [activeTabUid] })); + } + break; + + case 'folder-settings': { + const folderItem = findItemInCollection(collection, activeTab.folderUid || tab.folderUid); + if (folderItem?.draft) { + setShowConfirmFolderClose(true); + } else { + dispatch(closeTabs({ tabUids: [activeTabUid] })); + } + break; + } + + case 'environment-settings': + if (collection?.environmentsDraft) { + setShowConfirmEnvironmentClose(true); + } else { + dispatch(closeTabs({ tabUids: [activeTabUid] })); + } + break; + + default: + break; + } + }; + + window.addEventListener('close-active-tab', handleCloseTabFromHotkeys); + return () => window.removeEventListener('close-active-tab', handleCloseTabFromHotkeys); + }, [dispatch, activeTab, activeTabUid, tab.uid, collection]); + const handleCloseClick = (event) => { event.stopPropagation(); event.preventDefault(); 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 54df38a33..31e328dd2 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 @@ -202,7 +202,7 @@ const CloneCollectionItem = ({ 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..7ddc0ec34 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 @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef, useEffect, useCallback } from 'react'; import { getEmptyImage } from 'react-dnd-html5-backend'; import range from 'lodash/range'; import filter from 'lodash/filter'; @@ -69,12 +69,21 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) const isSidebarDragging = useSelector((state) => state.app.isDragging); const collection = useSelector((state) => state.collections.collections?.find((c) => c.uid === collectionUid)); const { hasCopiedItems } = useSelector((state) => state.app.clipboard); + const preferences = useSelector((state) => state.app.preferences); + const userKeyBindings = preferences?.keyBindings || {}; + const hasCustomCopyBinding = !!userKeyBindings?.copyItem; + const hasCustomPasteBinding = !!userKeyBindings?.pasteItem; + const hasCustomRenameBinding = !!userKeyBindings?.renameItem; const dispatch = useDispatch(); // We use a single ref for drag and drop. const ref = useRef(null); const menuDropdownRef = useRef(null); + // Refs to store current handler references for event listeners (avoid stale closures) + const copyHandlerRef = useRef(null); + const pasteHandlerRef = useRef(null); + const [renameItemModalOpen, setRenameItemModalOpen] = useState(false); const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false); const [deleteItemModalOpen, setDeleteItemModalOpen] = useState(false); @@ -121,6 +130,52 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) } }, [isTabForItemActive]); + // Listen for clone-item-open event from Hotkeys provider + const isFocusedRef = useRef(isKeyboardFocused); + isFocusedRef.current = isKeyboardFocused; + + useEffect(() => { + const handleCloneItemOpen = () => { + // Only open modal if this item is keyboard focused + if (isFocusedRef.current) { + setCloneItemModalOpen(true); + } + }; + + const handleCopyItemOpen = () => { + // Copy item to clipboard if this item is keyboard focused + if (isFocusedRef.current && copyHandlerRef.current) { + copyHandlerRef.current(); + } + }; + + const handlePasteItemOpen = () => { + // Paste item from clipboard if this item is keyboard focused + if (isFocusedRef.current && pasteHandlerRef.current) { + pasteHandlerRef.current(); + } + }; + + const handleRenameItemOpen = () => { + // Rename item if this item is keyboard focused + if (isFocusedRef.current) { + setRenameItemModalOpen(true); + } + }; + + window.addEventListener('clone-item-open', handleCloneItemOpen); + window.addEventListener('copy-item-open', handleCopyItemOpen); + window.addEventListener('paste-item-open', handlePasteItemOpen); + window.addEventListener('rename-item-open', handleRenameItemOpen); + + return () => { + window.removeEventListener('clone-item-open', handleCloneItemOpen); + window.removeEventListener('copy-item-open', handleCopyItemOpen); + window.removeEventListener('paste-item-open', handlePasteItemOpen); + window.removeEventListener('rename-item-open', handleRenameItemOpen); + }; + }, []); + const determineDropType = (monitor) => { const hoverBoundingRect = ref.current?.getBoundingClientRect(); const clientOffset = monitor.getClientOffset(); @@ -537,13 +592,13 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) } }; - const handleCopyItem = () => { + const handleCopyItem = useCallback(() => { dispatch(copyRequest(item)); const itemType = isFolder ? 'Folder' : 'Request'; toast.success(`${itemType} copied`); - }; + }, [dispatch, item, isFolder]); - const handlePasteItem = () => { + const handlePasteItem = useCallback(() => { // Determine target folder: if item is a folder, paste into it; otherwise paste into parent folder let targetFolderUid = item.uid; if (!isFolder) { @@ -558,7 +613,11 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) .catch((err) => { toast.error(err ? err.message : 'An error occurred while pasting the item'); }); - }; + }, [dispatch, collection, item, isFolder, collectionUid]); + + // Update refs whenever handlers change + copyHandlerRef.current = handleCopyItem; + pasteHandlerRef.current = handlePasteItem; // Keyboard shortcuts handler const handleKeyDown = (e) => { @@ -566,23 +625,19 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) 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) { + // Only use default handler if no custom keybinding is set for copy/paste + if (!hasCustomCopyBinding && isModifierPressed && e.key.toLowerCase() === 'c') { + e.preventDefault(); + e.stopPropagation(); + if (copyHandlerRef.current) copyHandlerRef.current(); + } else if (!hasCustomPasteBinding && isModifierPressed && e.key.toLowerCase() === 'v') { + e.preventDefault(); + e.stopPropagation(); + if (pasteHandlerRef.current) pasteHandlerRef.current(); + } else if (!hasCustomRenameBinding && e.key === 'F2') { 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(); } }; 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 5aa740152..b643092e4 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js @@ -264,6 +264,34 @@ const Collection = ({ collection, searchText }) => { } }, [isCollectionFocused]); + // Listen for clone-item-open event from Hotkeys provider + const isFocusedRef = useRef(isKeyboardFocused); + isFocusedRef.current = isKeyboardFocused; + + useEffect(() => { + const handleCloneItemOpen = () => { + // Only open modal if this collection is keyboard focused + if (isFocusedRef.current) { + setShowCloneCollectionModalOpen(true); + } + }; + + const handleRenameCollectionOpen = () => { + // Only open rename collection modal if this collection is keyboard focused + if (isFocusedRef.current) { + setShowRenameCollectionModal(true); + } + }; + + window.addEventListener('clone-item-open', handleCloneItemOpen); + window.addEventListener('rename-item-open', handleRenameCollectionOpen); + + return () => { + window.removeEventListener('clone-item-open', handleCloneItemOpen); + window.removeEventListener('rename-item-open', handleRenameCollectionOpen); + }; + }, []); + // Debounce showing empty state to prevent flicker // Race condition: isLoading can become false before items batch arrives from IPC useEffect(() => { 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/Sections/CollectionsSection/index.js b/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js index 43271f249..2f623bb9b 100644 --- a/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useEffect } from 'react'; import toast from 'react-hot-toast'; import get from 'lodash/get'; import { useDispatch, useSelector } from 'react-redux'; @@ -56,6 +56,22 @@ const CollectionsSection = () => { const [showCloneGitModal, setShowCloneGitModal] = useState(false); const [gitRepositoryUrl, setGitRepositoryUrl] = useState(null); + // Listen for sidebar-search-open hotkey event + useEffect(() => { + const handleSidebarSearch = () => { + setShowSearch(true); + // Focus the search input after it's rendered + setTimeout(() => { + const searchInput = document.querySelector('.collection-search-input'); + if (searchInput) { + searchInput.focus(); + } + }, 50); + }; + + window.addEventListener('sidebar-search-open', handleSidebarSearch); + return () => window.removeEventListener('sidebar-search-open', handleSidebarSearch); + }, []); // 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 diff --git a/packages/bruno-app/src/components/SingleLineEditor/index.js b/packages/bruno-app/src/components/SingleLineEditor/index.js index ae8e89551..e08a1c073 100644 --- a/packages/bruno-app/src/components/SingleLineEditor/index.js +++ b/packages/bruno-app/src/components/SingleLineEditor/index.js @@ -7,6 +7,7 @@ import { setupAutoComplete } from 'utils/codemirror/autocomplete'; import StyledWrapper from './StyledWrapper'; import { IconEye, IconEyeOff } from '@tabler/icons'; import { setupLinkAware } from 'utils/codemirror/linkAware'; +import { setupShortcuts } from 'utils/codemirror/shortcuts'; const CodeMirror = require('codemirror'); @@ -21,8 +22,11 @@ class SingleLineEditor extends Component { this.variables = {}; this.readOnly = props.readOnly || false; + // Shortcuts cleanup function + this._shortcutsCleanup = null; + this.state = { - maskInput: props.isSecret || false // Always mask the input by default (if it's a secret) + maskInput: props.isSecret || false }; } @@ -59,8 +63,8 @@ class SingleLineEditor extends Component { readOnly: this.props.readOnly, extraKeys: { 'Enter': runHandler, - 'Ctrl-Enter': runHandler, - 'Cmd-Enter': runHandler, + // 'Ctrl-Enter': runHandler, + // 'Cmd-Enter': runHandler, 'Alt-Enter': () => { if (this.props.allowNewlines) { this.editor.setValue(this.editor.getValue() + '\n'); @@ -69,7 +73,7 @@ class SingleLineEditor extends Component { this.props.onRun(); } }, - 'Shift-Enter': runHandler, + // 'Shift-Enter': runHandler, 'Cmd-S': saveHandler, 'Ctrl-S': saveHandler, 'Cmd-F': noopHandler, @@ -108,6 +112,9 @@ class SingleLineEditor extends Component { this._updateNewlineMarkers(); } setupLinkAware(this.editor); + + // Setup keyboard shortcuts using the dedicated utility + this._shortcutsCleanup = setupShortcuts(this.editor, this); } /** Enable or disable masking the rendered content of the editor */ @@ -202,6 +209,12 @@ class SingleLineEditor extends Component { } componentWillUnmount() { + // Cleanup shortcuts (keymap and store subscription) + if (this._shortcutsCleanup) { + this._shortcutsCleanup(); + this._shortcutsCleanup = null; + } + if (this.editor) { if (this.editor?._destroyLinkAware) { this.editor._destroyLinkAware(); diff --git a/packages/bruno-app/src/components/StatusBar/index.js b/packages/bruno-app/src/components/StatusBar/index.js index 7a44ef1db..29e9ab7d3 100644 --- a/packages/bruno-app/src/components/StatusBar/index.js +++ b/packages/bruno-app/src/components/StatusBar/index.js @@ -49,10 +49,7 @@ const StatusBar = () => { }; const openGlobalSearch = () => { - const bindings = getKeyBindingsForActionAllOS('globalSearch') || []; - bindings.forEach((binding) => { - Mousetrap.trigger(binding); - }); + window.dispatchEvent(new CustomEvent('global-search-open')); }; return ( diff --git a/packages/bruno-app/src/providers/Hotkeys/index.js b/packages/bruno-app/src/providers/Hotkeys/index.js index a87b67e29..6fb0750c6 100644 --- a/packages/bruno-app/src/providers/Hotkeys/index.js +++ b/packages/bruno-app/src/providers/Hotkeys/index.js @@ -1,290 +1,366 @@ -import React, { useState, useEffect } from 'react'; -import toast from 'react-hot-toast'; +import React, { createContext, useEffect, useContext, useRef, useState } from 'react'; import find from 'lodash/find'; import Mousetrap from 'mousetrap'; -import { useSelector, useDispatch } from 'react-redux'; -import NetworkError from 'components/ResponsePane/NetworkError'; +import toast from 'react-hot-toast'; +import { useSelector } from 'react-redux'; + import NewRequest from 'components/Sidebar/NewRequest'; +import NetworkError from 'components/ResponsePane/NetworkError'; import GlobalSearchModal from 'components/GlobalSearchModal'; +import ImportCollection from 'components/Sidebar/ImportCollection'; + +import store from 'providers/ReduxStore/index'; import { sendRequest, saveRequest, saveCollectionRoot, saveFolderRoot, saveCollectionSettings, - closeTabs + closeTabs, + cloneItem, + pasteItem } 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 { savePreferences, toggleSidebarCollapse, copyRequest } from 'providers/ReduxStore/slices/app'; import { getKeyBindingsForActionAllOS } from './keyMappings'; -export const HotkeysContext = React.createContext(); +export const HotkeysContext = createContext(null); +// List of all actions that are bound in this provider +const BOUND_ACTIONS = [ + 'save', + 'sendRequest', + 'editEnvironment', + 'newRequest', + 'globalSearch', + 'closeTab', + 'switchToPreviousTab', + 'switchToNextTab', + 'closeAllTabs', + 'collapseSidebar', + 'moveTabLeft', + 'moveTabRight', + 'changeLayout', + 'closeBruno', + 'openPreferences', + 'importCollection', + 'sidebarSearch', + 'zoomIn', + 'zoomOut', + 'resetZoom', + 'cloneItem', + 'copyItem', + 'pasteItem', + 'renameItem' +]; + +/** + * Bind a single hotkey action using Mousetrap. + * Reads from merged defaults + user preferences via getKeyBindingsForActionAllOS. + */ +function bindHotkey(action, handler, userKeyBindings) { + const combos = getKeyBindingsForActionAllOS(action, userKeyBindings); + if (!combos?.length) return; + + Mousetrap.bind([...combos], (e) => { + e?.preventDefault?.(); + handler(e); + return false; + }); +} + +/** + * Unbind a single hotkey action. + */ +function unbindHotkey(action, userKeyBindings) { + const combos = getKeyBindingsForActionAllOS(action, userKeyBindings); + if (!combos?.length) return; + Mousetrap.unbind([...combos]); +} + +/** + * Unbind all known actions for the given user key bindings. + */ +function unbindAllHotkeys(userKeyBindings) { + BOUND_ACTIONS.forEach((action) => unbindHotkey(action, userKeyBindings)); +} + +/** + * Bind all hotkey actions. + */ +function bindAllHotkeys(userKeyBindings) { + const { dispatch, getState } = store; + + // SAVE + bindHotkey('save', () => { + const state = getState(); + const tabs = state.tabs.tabs; + const collections = state.collections.collections; + const activeTabUid = state.tabs.activeTabUid; + + const activeTab = find(tabs, (t) => t.uid === activeTabUid); + if (!activeTab) return; + + if (activeTab.type === 'environment-settings' || activeTab.type === 'global-environment-settings') { + window.dispatchEvent(new CustomEvent('environment-save')); + return; + } + + const collection = findCollectionByUid(collections, activeTab.collectionUid); + if (!collection) return; + + const item = findItemInCollection(collection, activeTab.uid); + + if (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)); + } + }, userKeyBindings); + + // SEND REQUEST + bindHotkey('sendRequest', () => { + const state = getState(); + const tabs = state.tabs.tabs; + const collections = state.collections.collections; + const activeTabUid = state.tabs.activeTabUid; + + const activeTab = find(tabs, (t) => t.uid === activeTabUid); + if (!activeTab) return; + + const collection = findCollectionByUid(collections, activeTab.collectionUid); + if (!collection) return; + + const item = findItemInCollection(collection, activeTab.uid); + if (!item) return; + + if (item.type === 'grpc-request') { + const request = item.draft ? item.draft.request : item.request; + if (!request.url) return toast.error('Please enter a valid gRPC server URL'); + if (!request.method) return toast.error('Please select a gRPC method'); + } + + dispatch(sendRequest(item, collection.uid)).catch(() => + toast.custom((t) => toast.dismiss(t.id)} />, { duration: 5000 }) + ); + }, userKeyBindings); + + // EDIT ENV + bindHotkey('editEnvironment', () => { + const state = getState(); + const tabs = state.tabs.tabs; + const collections = state.collections.collections; + const activeTabUid = state.tabs.activeTabUid; + + const activeTab = find(tabs, (t) => t.uid === activeTabUid); + if (!activeTab) return; + + const collection = findCollectionByUid(collections, activeTab.collectionUid); + if (!collection) return; + + dispatch( + addTab({ + uid: `${collection.uid}-environment-settings`, + collectionUid: collection.uid, + type: 'environment-settings' + }) + ); + }, userKeyBindings); + + // NEW REQUEST -> trigger via event so the provider can open the modal + bindHotkey('newRequest', () => { + window.dispatchEvent(new CustomEvent('new-request-open')); + }, userKeyBindings); + + // GLOBAL SEARCH -> trigger via event so the provider can open the modal + bindHotkey('globalSearch', () => { + window.dispatchEvent(new CustomEvent('global-search-open')); + }, userKeyBindings); + + // CLOSE TAB + bindHotkey('closeTab', () => { + window.dispatchEvent(new CustomEvent('close-active-tab')); + }, userKeyBindings); + + // SWITCH PREV TAB + bindHotkey('switchToPreviousTab', () => { + dispatch(switchTab({ direction: 'pageup' })); + }, userKeyBindings); + + // SWITCH NEXT TAB + bindHotkey('switchToNextTab', () => { + dispatch(switchTab({ direction: 'pagedown' })); + }, userKeyBindings); + + // CLOSE ALL TABS + bindHotkey('closeAllTabs', () => { + window.dispatchEvent(new CustomEvent('close-active-tab')); + }, userKeyBindings); + + // COLLAPSE SIDEBAR + bindHotkey('collapseSidebar', () => { + dispatch(toggleSidebarCollapse()); + }, userKeyBindings); + + // MOVE TAB LEFT + bindHotkey('moveTabLeft', () => { + dispatch(reorderTabs({ direction: -1 })); + }, userKeyBindings); + + // MOVE TAB RIGHT + bindHotkey('moveTabRight', () => { + dispatch(reorderTabs({ direction: 1 })); + }, userKeyBindings); + + // CHANGE LAYOUT -> toggle response pane orientation + bindHotkey('changeLayout', () => { + const state = getState(); + const preferences = state.app.preferences; + const currentOrientation = preferences?.layout?.responsePaneOrientation || 'horizontal'; + const newOrientation = currentOrientation === 'horizontal' ? 'vertical' : 'horizontal'; + const updatedPreferences = { + ...preferences, + layout: { + ...preferences.layout, + responsePaneOrientation: newOrientation + } + }; + dispatch(savePreferences(updatedPreferences)); + }, userKeyBindings); + + // CLOSE BRUNO -> send IPC to close the window + bindHotkey('closeBruno', () => { + window.ipcRenderer?.send('renderer:window-close'); + }, userKeyBindings); + + // OPEN PREFERENCES -> open preferences tab + bindHotkey('openPreferences', () => { + const state = getState(); + const tabs = state.tabs.tabs; + const activeTabUid = state.tabs.activeTabUid; + const activeTab = tabs.find((t) => t.uid === activeTabUid); + + dispatch( + addTab({ + type: 'preferences', + uid: activeTab?.collectionUid ? `${activeTab.collectionUid}-preferences` : 'preferences', + collectionUid: activeTab?.collectionUid + }) + ); + }, userKeyBindings); + + // IMPORT COLLECTION -> trigger event to open import modal + bindHotkey('importCollection', () => { + window.dispatchEvent(new CustomEvent('import-collection-open')); + }, userKeyBindings); + + // SIDEBAR SEARCH -> trigger event to focus sidebar search + bindHotkey('sidebarSearch', () => { + window.dispatchEvent(new CustomEvent('sidebar-search-open')); + }, userKeyBindings); + + // ZOOM IN + bindHotkey('zoomIn', () => { + window.ipcRenderer?.invoke('renderer:zoom-in'); + }, userKeyBindings); + + // ZOOM OUT + bindHotkey('zoomOut', () => { + window.ipcRenderer?.invoke('renderer:zoom-out'); + }, userKeyBindings); + + // RESET ZOOM + bindHotkey('resetZoom', () => { + window.ipcRenderer?.invoke('renderer:reset-zoom'); + }, userKeyBindings); + + // CLONE ITEM -> trigger event so the sidebar can handle opening the clone modal + bindHotkey('cloneItem', () => { + window.dispatchEvent(new CustomEvent('clone-item-open')); + }, userKeyBindings); + + // COPY ITEM -> copy currently selected item to clipboard + bindHotkey('copyItem', () => { + window.dispatchEvent(new CustomEvent('copy-item-open')); + }, userKeyBindings); + + // PASTE ITEM -> paste from clipboard to current location + bindHotkey('pasteItem', () => { + window.dispatchEvent(new CustomEvent('paste-item-open')); + }, userKeyBindings); + + // RENAME ITEM -> trigger event so the sidebar can handle opening the rename modal + bindHotkey('renameItem', () => { + window.dispatchEvent(new CustomEvent('rename-item-open')); + }, userKeyBindings); +} + +// ----------------------- +// Provider (manages hotkey lifecycle + modal state) +// ----------------------- export const HotkeysProvider = (props) => { - const dispatch = useDispatch(); 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 [showNewRequestModal, setShowNewRequestModal] = useState(false); const [showGlobalSearchModal, setShowGlobalSearchModal] = useState(false); + const [showImportCollectionModal, setShowImportCollectionModal] = useState(false); + + // Keep a ref to the previous userKeyBindings so we can unbind old combos + const prevKeyBindingsRef = useRef(undefined); const getCurrentCollection = () => { const activeTab = find(tabs, (t) => t.uid === activeTabUid); - if (activeTab) { - const collection = findCollectionByUid(collections, activeTab.collectionUid); - - return collection; - } + if (!activeTab) return undefined; + return findCollectionByUid(collections, activeTab.collectionUid); }; - // save hotkey + const currentCollection = getCurrentCollection(); + + // Bind/rebind hotkeys whenever user preferences change 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; - } + // Store previous bindings before updating + const prevBindings = prevKeyBindingsRef.current; - 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)); - } - } - } + // Unbind previous bindings (if any) + if (prevBindings !== undefined) { + unbindAllHotkeys(prevBindings); + } - return false; // this stops the event bubbling - }); + // Bind with current preferences + bindAllHotkeys(userKeyBindings); + prevKeyBindingsRef.current = userKeyBindings; return () => { - Mousetrap.unbind([...getKeyBindingsForActionAllOS('save')]); + // Cleanup on unmount + unbindAllHotkeys(userKeyBindings); }; - }, [activeTabUid, tabs, saveRequest, collections, dispatch]); + }, [userKeyBindings]); - // send request (ctrl/cmd + enter) + // Listen for hotkey-triggered events for modals useEffect(() => { - Mousetrap.bind([...getKeyBindingsForActionAllOS('sendRequest')], (e) => { - const activeTab = find(tabs, (t) => t.uid === activeTabUid); - if (activeTab) { - const collection = findCollectionByUid(collections, activeTab.collectionUid); + const openNewRequest = () => setShowNewRequestModal(true); + const openGlobalSearch = () => setShowGlobalSearchModal(true); + const openImportCollection = () => setShowImportCollectionModal(true); - 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 - }); + window.addEventListener('new-request-open', openNewRequest); + window.addEventListener('global-search-open', openGlobalSearch); + window.addEventListener('import-collection-open', openImportCollection); return () => { - Mousetrap.unbind([...getKeyBindingsForActionAllOS('sendRequest')]); - }; - }, [activeTabUid, tabs, saveRequest, collections]); - - // edit environments (ctrl/cmd + e) - useEffect(() => { - Mousetrap.bind([...getKeyBindingsForActionAllOS('editEnvironment')], (e) => { - const activeTab = find(tabs, (t) => t.uid === activeTabUid); - if (activeTab) { - const collection = findCollectionByUid(collections, activeTab.collectionUid); - - if (collection) { - dispatch( - addTab({ - uid: `${collection.uid}-environment-settings`, - collectionUid: collection.uid, - type: 'environment-settings' - }) - ); - } - } - - return false; // this stops the event bubbling - }); - - return () => { - Mousetrap.unbind([...getKeyBindingsForActionAllOS('editEnvironment')]); - }; - }, [activeTabUid, tabs, collections, dispatch]); - - // new request (ctrl/cmd + b) - useEffect(() => { - Mousetrap.bind([...getKeyBindingsForActionAllOS('newRequest')], (e) => { - const activeTab = find(tabs, (t) => t.uid === activeTabUid); - if (activeTab) { - const collection = findCollectionByUid(collections, activeTab.collectionUid); - - if (collection) { - setShowNewRequestModal(true); - } - } - - return false; // this stops the event bubbling - }); - - return () => { - Mousetrap.unbind([...getKeyBindingsForActionAllOS('newRequest')]); - }; - }, [activeTabUid, tabs, collections, setShowNewRequestModal]); - - // global search (ctrl/cmd + k) - useEffect(() => { - Mousetrap.bind([...getKeyBindingsForActionAllOS('globalSearch')], (e) => { - setShowGlobalSearchModal(true); - - return false; // stop bubbling - }); - - return () => { - Mousetrap.unbind([...getKeyBindingsForActionAllOS('globalSearch')]); + window.removeEventListener('new-request-open', openNewRequest); + window.removeEventListener('global-search-open', openGlobalSearch); + window.removeEventListener('import-collection-open', openImportCollection); }; }, []); - // close tab hotkey - useEffect(() => { - Mousetrap.bind([...getKeyBindingsForActionAllOS('closeTab')], (e) => { - if (activeTabUid) { - dispatch( - closeTabs({ - tabUids: [activeTabUid] - }) - ); - } - - return false; // this stops the event bubbling - }); - - return () => { - Mousetrap.unbind([...getKeyBindingsForActionAllOS('closeTab')]); - }; - }, [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]); - - // Close all tabs - useEffect(() => { - Mousetrap.bind([...getKeyBindingsForActionAllOS('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 - }) - ); - } - } - - return false; // this stops the event bubbling - }); - - return () => { - Mousetrap.unbind([...getKeyBindingsForActionAllOS('closeAllTabs')]); - }; - }, [activeTabUid, tabs, collections, dispatch]); - - // Collapse sidebar (ctrl/cmd + \) - useEffect(() => { - Mousetrap.bind([...getKeyBindingsForActionAllOS('collapseSidebar')], (e) => { - dispatch(toggleSidebarCollapse()); - return false; - }); - - return () => { - Mousetrap.unbind([...getKeyBindingsForActionAllOS('collapseSidebar')]); - }; - }, [dispatch]); - - // Move tab left - useEffect(() => { - Mousetrap.bind([...getKeyBindingsForActionAllOS('moveTabLeft')], (e) => { - dispatch(reorderTabs({ direction: -1 })); - return false; // this stops the event bubbling - }); - - return () => { - Mousetrap.unbind([...getKeyBindingsForActionAllOS('moveTabLeft')]); - }; - }, [dispatch]); - - // Move tab right - useEffect(() => { - Mousetrap.bind([...getKeyBindingsForActionAllOS('moveTabRight')], (e) => { - dispatch(reorderTabs({ direction: 1 })); - return false; // this stops the event bubbling - }); - - return () => { - Mousetrap.unbind([...getKeyBindingsForActionAllOS('moveTabRight')]); - }; - }, [dispatch]); - - const currentCollection = getCurrentCollection(); - return ( {showNewRequestModal && ( @@ -293,13 +369,16 @@ export const HotkeysProvider = (props) => { {showGlobalSearchModal && ( setShowGlobalSearchModal(false)} /> )} + {showImportCollectionModal && ( + setShowImportCollectionModal(false)} /> + )}
{props.children}
); }; export const useHotkeys = () => { - const context = React.useContext(HotkeysContext); + const context = useContext(HotkeysContext); if (!context) { throw new Error(`useHotkeys must be used within a HotkeysProvider`); diff --git a/packages/bruno-app/src/providers/Hotkeys/keyMappings.js b/packages/bruno-app/src/providers/Hotkeys/keyMappings.js index 0c439baf2..1415e1e9f 100644 --- a/packages/bruno-app/src/providers/Hotkeys/keyMappings.js +++ b/packages/bruno-app/src/providers/Hotkeys/keyMappings.js @@ -1,42 +1,76 @@ -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' }, +export const DEFAULT_KEY_BINDINGS = { + save: { mac: 'command+bind+s', windows: 'ctrl+bind+s', name: 'Save' }, + sendRequest: { mac: 'command+bind+enter', windows: 'ctrl+bind+enter', name: 'Send Request' }, + editEnvironment: { mac: 'command+bind+e', windows: 'ctrl+bind+e', name: 'Edit Environment' }, + newRequest: { mac: 'command+bind+n', windows: 'ctrl+bind+n', name: 'New Request' }, + importCollection: { mac: 'command+bind+o', windows: 'ctrl+bind+o', name: 'Import Collection' }, + globalSearch: { mac: 'command+bind+k', windows: 'ctrl+bind+k', name: 'Global Search' }, + sidebarSearch: { mac: 'command+bind+f', windows: 'ctrl+bind+f', name: 'Search Sidebar' }, + closeTab: { mac: 'command+bind+w', windows: 'ctrl+bind+w', name: 'Close Tab' }, + openPreferences: { mac: 'command+bind+,', windows: 'ctrl+bind+,', name: 'Open Preferences' }, + changeLayout: { mac: 'command+bind+j', windows: 'ctrl+bind+j', name: 'Change Orientation' }, closeBruno: { - mac: 'command+Q', - windows: 'ctrl+shift+q', + mac: 'command+bind+q', + windows: 'ctrl+bind+shift+bind+q', name: 'Close Bruno' }, switchToPreviousTab: { - mac: 'command+pageup', - windows: 'ctrl+pageup', + mac: 'command+bind+2', + windows: 'ctrl+bind+2', name: 'Switch to Previous Tab' }, switchToNextTab: { - mac: 'command+pagedown', - windows: 'ctrl+pagedown', + mac: 'command+bind+1', + windows: 'ctrl+bind+1', name: 'Switch to Next Tab' }, moveTabLeft: { - mac: 'command+shift+pageup', - windows: 'ctrl+shift+pageup', + mac: 'command+bind+[', + windows: 'ctrl+bind+[', name: 'Move Tab Left' }, moveTabRight: { - mac: 'command+shift+pagedown', - windows: 'ctrl+shift+pagedown', + mac: 'command+bind+]', + windows: 'ctrl+bind+]', name: 'Move Tab Right' }, - closeAllTabs: { mac: 'command+shift+w', windows: 'ctrl+shift+w', name: 'Close All Tabs' }, - collapseSidebar: { mac: 'command+\\', windows: 'ctrl+\\', name: 'Collapse Sidebar' }, - 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' } + closeAllTabs: { mac: 'command+bind+shift+bind+w', windows: 'ctrl+bind+shift+bind+w', name: 'Close All Tabs' }, + collapseSidebar: { mac: 'command+bind+\\', windows: 'ctrl+bind+\\', name: 'Collapse Sidebar' }, + 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' }, + cloneItem: { mac: 'command+bind+d', windows: 'ctrl+bind+d', name: 'Clone Item' }, + copyItem: { mac: 'command+bind+c', windows: 'ctrl+bind+c', name: 'Copy Item' }, + pasteItem: { mac: 'command+bind+v', windows: 'ctrl+bind+v', name: 'Paste Item' }, + renameItem: { mac: 'command+bind+r', windows: 'ctrl+bind+r', name: 'Rename Item' } +}; + +/** + * 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('+'); }; /** @@ -47,7 +81,7 @@ const KeyMapping = { */ export const getKeyBindingsForOS = (os) => { const keyBindings = {}; - for (const [action, { name, ...keys }] of Object.entries(KeyMapping)) { + for (const [action, { name, ...keys }] of Object.entries(DEFAULT_KEY_BINDINGS)) { if (keys[os]) { keyBindings[action] = { keys: keys[os], @@ -59,18 +93,57 @@ export const getKeyBindingsForOS = (os) => { }; /** - * Retrieves the key bindings for a specific action across all operating systems. + * Merges default key bindings with user preferences. + * + * @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 + for (const [action, binding] of Object.entries(DEFAULT_KEY_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 = []; + if (actionBindings.mac) { + const combo = toMousetrapCombo(actionBindings.mac); + if (combo) combos.push(combo); + } + if (actionBindings.windows) { + const combo = toMousetrapCombo(actionBindings.windows); + if (combo) combos.push(combo); + } + + return combos.length > 0 ? combos : null; }; diff --git a/packages/bruno-app/src/utils/codemirror/shortcuts.js b/packages/bruno-app/src/utils/codemirror/shortcuts.js new file mode 100644 index 000000000..2d526501d --- /dev/null +++ b/packages/bruno-app/src/utils/codemirror/shortcuts.js @@ -0,0 +1,233 @@ +import { getKeyBindingsForActionAllOS } from 'providers/Hotkeys/keyMappings'; +import store from 'providers/ReduxStore/index'; +import { reorderTabs, switchTab } from 'providers/ReduxStore/slices/tabs'; +import { savePreferences, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app'; + +const CodeMirror = require('codemirror'); + +const KEYBINDING_ACTIONS = [ + { + actionName: 'closeTab', + handler: () => { + window.dispatchEvent(new CustomEvent('close-active-tab')); + return true; + } + }, + { + actionName: 'sendRequest', + handler: (context) => { + if (context?.props?.onRun) context.props.onRun(); + return true; + } + }, + { + actionName: 'switchToPreviousTab', + handler: () => { + store.dispatch(switchTab({ direction: 'pageup' })); + return true; + } + }, + { + actionName: 'switchToNextTab', + handler: () => { + store.dispatch(switchTab({ direction: 'pagedown' })); + return true; + } + }, + { + actionName: 'moveTabLeft', + handler: () => { + store.dispatch(reorderTabs({ direction: -1 })); + return true; + } + }, + { + actionName: 'moveTabRight', + handler: () => { + store.dispatch(reorderTabs({ direction: 1 })); + return true; + } + }, + { + actionName: 'changeLayout', + handler: () => { + const state = store.getState(); + const preferences = state.app.preferences; + const currentOrientation = preferences?.layout?.responsePaneOrientation || 'horizontal'; + const newOrientation = currentOrientation === 'horizontal' ? 'vertical' : 'horizontal'; + const updatedPreferences = { + ...preferences, + layout: { + ...preferences.layout, + responsePaneOrientation: newOrientation + } + }; + store.dispatch(savePreferences(updatedPreferences)); + return true; + } + }, + { + actionName: 'collapseSidebar', + handler: () => { + store.dispatch(toggleSidebarCollapse()); + return true; + } + } +]; + +/** + * Converts user keybinding format to CodeMirror format + * e.g., "command+bind+enter" -> "Cmd-Enter" + * @param {string} combo - The keybinding combo string + * @returns {string|null} CodeMirror formatted combo or null + */ +function convertToCodeMirrorFormat(combo) { + if (!combo || typeof combo !== 'string') return null; + + const normalized = combo + .replace(/-/g, '+') + .split('+') + .map((p) => p.trim()) + .filter(Boolean) + .filter((p) => p.toLowerCase() !== 'bind') + .join('+'); + + const parts = normalized.split('+').map((p) => p.trim()).filter(Boolean); + + const out = parts.map((key) => { + const lower = key.toLowerCase(); + + if (lower === 'command' || lower === 'cmd') return 'Cmd'; + if (lower === 'control' || lower === 'ctrl') return 'Ctrl'; + if (lower === 'option' || lower === 'alt') return 'Alt'; + if (lower === 'shift') return 'Shift'; + if (lower === 'mod') return 'Mod'; + + if (lower === 'enter' || lower === 'return') return 'Enter'; + if (lower === 'esc' || lower === 'escape') return 'Esc'; + if (lower === 'space') return 'Space'; + if (lower === 'tab') return 'Tab'; + if (lower === 'backspace') return 'Backspace'; + if (lower === 'delete' || lower === 'del') return 'Delete'; + if (lower === 'up') return 'Up'; + if (lower === 'down') return 'Down'; + if (lower === 'left') return 'Left'; + if (lower === 'right') return 'Right'; + + if (key.length === 1) return key.toUpperCase(); + return key.charAt(0).toUpperCase() + key.slice(1); + }); + + return out.join('-'); +} + +/** + * Builds a consolidated CodeMirror keymap from all configured keybinding actions. + * Uses CodeMirror.Pass for non-matching keys to allow default behavior. + * @param {Object} context - Context object containing props and other editor context + * @returns {Object} CodeMirror keymap object + */ +function buildKeymap(context) { + let state; + try { + const reduxState = store.getState(); + state = reduxState; + } catch (e) { + state = { app: { preferences: {} } }; + } + + const userKeyBindings = state.app.preferences?.keyBindings || {}; + + // Create a comprehensive keymap with CodeMirror.Pass as fallthrough + // This allows non-matching keys to pass through to default CodeMirror behavior + const keyMap = { + name: 'singleLineEditor.custom', + // CodeMirror.Pass tells CodeMirror to pass this key event to the next keymap + // This is the key to making non-configured keys work normally + fallthrough: CodeMirror.Pass + }; + + // Build keymap entries for each configured action + KEYBINDING_ACTIONS.forEach(({ actionName, handler }) => { + const combos = getKeyBindingsForActionAllOS(actionName, userKeyBindings) || []; + const cmCombos = combos + .map((k) => convertToCodeMirrorFormat(k)) + .filter(Boolean); + + if (cmCombos.length > 0) { + cmCombos.forEach((cmKey) => { + // Create handler that passes context as argument + keyMap[cmKey] = () => handler(context); + }); + } + }); + + return keyMap; +} + +/** + * Sets up keyboard shortcuts for a CodeMirror editor instance. + * This enables custom keybindings with CodeMirror.Pass fallthrough support. + * @param {Object} editor - The CodeMirror editor instance + * @param {Object} context - Context object containing props and other editor context + * @returns {Object} Cleanup function to remove the keymap + */ +function setupShortcuts(editor, context = {}) { + if (!editor) { + return () => { }; + } + + let currentKeyMap = null; + let unsubscribeStore = null; + + /** + * Apply the consolidated custom keymap to the CodeMirror editor + */ + const applyKeyMap = () => { + if (!editor) return; + + // Remove existing custom keymap if any + if (currentKeyMap) { + try { + editor.removeKeyMap(currentKeyMap); + } catch (e) { + console.warn('[SingleLineEditor] Error removing keymap:', e); + } + } + + // Build and apply new consolidated keymap + currentKeyMap = buildKeymap(context); + editor.addKeyMap(currentKeyMap); + }; + + // Apply keymap on setup + applyKeyMap(); + + // Subscribe to store changes to rebuild keymap when preferences change + unsubscribeStore = store.subscribe(() => { + applyKeyMap(); + }); + + /** + * Cleanup function to remove the keymap and unsubscribe from store + */ + const cleanup = () => { + if (unsubscribeStore) { + unsubscribeStore(); + unsubscribeStore = null; + } + + if (editor && currentKeyMap) { + try { + editor.removeKeyMap(currentKeyMap); + } catch (e) { + console.warn('[SingleLineEditor] Error removing keymap on cleanup:', e); + } + currentKeyMap = null; + } + }; + + return cleanup; +} + +export { setupShortcuts, buildKeymap, convertToCodeMirrorFormat, KEYBINDING_ACTIONS }; diff --git a/packages/bruno-electron/src/app/menu-template.js b/packages/bruno-electron/src/app/menu-template.js index fe6cbc716..f04971877 100644 --- a/packages/bruno-electron/src/app/menu-template.js +++ b/packages/bruno-electron/src/app/menu-template.js @@ -27,7 +27,6 @@ const template = [ }, { label: 'Preferences', - accelerator: 'CommandOrControl+,', click() { ipcMain.emit('main:open-preferences'); } @@ -89,7 +88,7 @@ const template = [ }, { role: 'window', - submenu: [{ role: 'minimize' }, { role: 'close', accelerator: 'CommandOrControl+Shift+Q' }] + submenu: [{ role: 'minimize' }, { role: 'close' }] }, { role: 'help', diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js index 1c6495820..d207ad372 100644 --- a/packages/bruno-electron/src/index.js +++ b/packages/bruno-electron/src/index.js @@ -216,7 +216,8 @@ app.on('ready', async () => { nodeIntegration: true, contextIsolation: true, preload: path.join(__dirname, 'preload.js'), - webviewTag: true + webviewTag: true, + zoomFactor: 1.0 }, title: 'Bruno', icon: path.join(__dirname, 'about/256x256.png'), @@ -246,8 +247,29 @@ app.on('ready', async () => { } }); + // Handle zoom shortcuts + ipcMain.on('main:zoom-in', () => { + if (mainWindow && mainWindow.webContents) { + const currentZoom = mainWindow.webContents.getZoomLevel(); + mainWindow.webContents.setZoomLevel(currentZoom + 0.5); + } + }); + + ipcMain.on('main:zoom-out', () => { + if (mainWindow && mainWindow.webContents) { + const currentZoom = mainWindow.webContents.getZoomLevel(); + mainWindow.webContents.setZoomLevel(currentZoom - 0.5); + } + }); + + ipcMain.on('main:zoom-reset', () => { + if (mainWindow && mainWindow.webContents) { + mainWindow.webContents.setZoomLevel(0); + } + }); + ipcMain.on('renderer:window-close', () => { - if (!isWindows && !isLinux) return; + // if (!isWindows && !isLinux) return; mainWindow.close(); }); @@ -485,14 +507,6 @@ app.on('open-file', (event, path) => { openCollection(mainWindow, collectionWatcher, path); }); -// Register the global shortcuts -app.on('browser-window-focus', () => { - // Quick fix for Electron issue #29996: https://github.com/electron/electron/issues/29996 - globalShortcut.register('Ctrl+=', () => { - incrementZoomAndPersist(10); - }); -}); - // Disable global shortcuts when not focused app.on('browser-window-blur', () => { globalShortcut.unregisterAll(); diff --git a/packages/bruno-electron/src/store/preferences.js b/packages/bruno-electron/src/store/preferences.js index 1be589223..a37d363a7 100644 --- a/packages/bruno-electron/src/store/preferences.js +++ b/packages/bruno-electron/src/store/preferences.js @@ -58,6 +58,52 @@ const defaultPreferences = { enabled: false, interval: 1000 }, + keyBindings: { + save: { mac: 'command+bind+s', windows: 'ctrl+bind+s', name: 'Save' }, + sendRequest: { mac: 'command+bind+enter', windows: 'ctrl+bind+enter', name: 'Send Request' }, + editEnvironment: { mac: 'command+bind+e', windows: 'ctrl+bind+e', name: 'Edit Environment' }, + newRequest: { mac: 'command+bind+n', windows: 'ctrl+bind+n', name: 'New Request' }, + importCollection: { mac: 'command+bind+o', windows: 'ctrl+bind+o', name: 'Import Collection' }, + globalSearch: { mac: 'command+bind+k', windows: 'ctrl+bind+k', name: 'Global Search' }, + sidebarSearch: { mac: 'command+bind+f', windows: 'ctrl+bind+f', name: 'Search Sidebar' }, + closeTab: { mac: 'command+bind+w', windows: 'ctrl+bind+w', name: 'Close Tab' }, + openPreferences: { mac: 'command+bind+,', windows: 'ctrl+bind+,', name: 'Open Preferences' }, + changeLayout: { mac: 'command+bind+j', windows: 'ctrl+bind+j', name: 'Change Orientation' }, + closeBruno: { + mac: 'command+bind+q', + windows: 'ctrl+bind+shift+bind+q', + name: 'Close Bruno' + }, + switchToPreviousTab: { + mac: 'command+bind+2', + windows: 'ctrl+bind+2', + name: 'Switch to Previous Tab' + }, + switchToNextTab: { + mac: 'command+bind+1', + windows: 'ctrl+bind+1', + name: 'Switch to Next Tab' + }, + moveTabLeft: { + mac: 'command+bind+[', + windows: 'ctrl+bind+[', + name: 'Move Tab Left' + }, + moveTabRight: { + mac: 'command+bind+]', + windows: 'ctrl+bind+]', + name: 'Move Tab Right' + }, + closeAllTabs: { mac: 'command+bind+shift+bind+w', windows: 'ctrl+bind+shift+bind+w', name: 'Close All Tabs' }, + collapseSidebar: { mac: 'command+bind+\\', windows: 'ctrl+bind+\\', name: 'Collapse Sidebar' }, + 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' }, + cloneItem: { mac: 'command+bind+d', windows: 'ctrl+bind+d', name: 'Clone Item' }, + copyItem: { mac: 'command+bind+c', windows: 'ctrl+bind+c', name: 'Copy Item' }, + pasteItem: { mac: 'command+bind+v', windows: 'ctrl+bind+v', name: 'Paste Item' }, + renameItem: { mac: 'command+bind+r', windows: 'ctrl+bind+r', name: 'Rename Item' } + }, display: { zoomPercentage: 100 } diff --git a/tests/collection/moving-tabs/move-tabs.spec.ts b/tests/collection/moving-tabs/move-tabs.spec.ts index 63c8f0f9f..1898f8663 100644 --- a/tests/collection/moving-tabs/move-tabs.spec.ts +++ b/tests/collection/moving-tabs/move-tabs.spec.ts @@ -2,6 +2,8 @@ import { test, expect } from '../../../playwright'; import { closeAllCollections, createCollection } from '../../utils/page'; test.describe('Move tabs', () => { + const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'; + test.afterEach(async ({ page }) => { // cleanup: close all collections await closeAllCollections(page); @@ -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/request/copy-request/keyboard-shortcuts.spec.ts b/tests/request/copy-request/keyboard-shortcuts.spec.ts deleted file mode 100644 index cff366254..000000000 --- a/tests/request/copy-request/keyboard-shortcuts.spec.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { test, expect } from '../../../playwright'; -import { closeAllCollections, createCollection } from '../../utils/page'; - -test.describe('Copy and Paste with Keyboard Shortcuts', () => { - test.afterAll(async ({ page }) => { - await closeAllCollections(page); - }); - - test('should copy and paste request using keyboard shortcuts', async ({ page, createTmpDir }) => { - await createCollection(page, 'keyboard-test', await createTmpDir('keyboard-test')); - const collection = page.locator('.collection-name').filter({ hasText: 'keyboard-test' }); - - // Create a request - await collection.hover(); - await collection.locator('.collection-actions .icon').click(); - await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).click(); - await page.getByPlaceholder('Request Name').fill('test-request'); - await page.locator('#new-request-url .CodeMirror').click(); - await page.locator('textarea').fill('https://echo.usebruno.com'); - await page.getByRole('button', { name: 'Create' }).click(); - - const requestItem = page.locator('.collection-item-name').filter({ hasText: 'test-request' }); - await expect(requestItem).toBeVisible(); - - // Focus the request item - await requestItem.click(); - await requestItem.focus(); - - // Wait for keyboard focus indicator - await expect(requestItem).toHaveClass(/item-keyboard-focused/); - - // Use Cmd+C on Mac, Ctrl+C on Windows/Linux - const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'; - await page.keyboard.press(`${modifier}+KeyC`); - - // Verify copy success (toast message) - await expect(page.getByText(/Request copied/i).first()).toBeVisible(); - - // Focus the collection to paste - await collection.click(); - await collection.focus(); - - // Use Cmd+V on Mac, Ctrl+V on Windows/Linux - await page.keyboard.press(`${modifier}+KeyV`); - - // Verify paste success - await expect(page.getByText(/pasted successfully/i).first()).toBeVisible(); - - // Verify the pasted request appears - await expect(page.locator('.collection-item-name').filter({ hasText: 'test-request' })).toHaveCount(2); - }); - - test('should copy and paste folder using keyboard shortcuts', async ({ page }) => { - const collection = page.locator('.collection-name').filter({ hasText: 'keyboard-test' }); - - // Create a folder - await collection.hover(); - await collection.locator('.collection-actions .icon').click(); - await page.locator('.dropdown-item').filter({ hasText: 'New Folder' }).click(); - await page.locator('#folder-name').fill('test-folder'); - await page.getByRole('button', { name: 'Create' }).click(); - - const folder = page.locator('.collection-item-name').filter({ hasText: 'test-folder' }); - await expect(folder).toBeVisible(); - - // Focus the folder - await folder.click(); - await folder.focus(); - - // Wait for keyboard focus indicator - await expect(folder).toHaveClass(/item-keyboard-focused/); - - // Use keyboard shortcut to copy - const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'; - await page.keyboard.press(`${modifier}+KeyC`); - - // Verify copy success - await expect(page.getByText(/Folder copied/i).first()).toBeVisible(); - - // Focus the collection to paste - await collection.click(); - await collection.focus(); - - // Use keyboard shortcut to paste - await page.keyboard.press(`${modifier}+KeyV`); - - // Verify paste success - await expect(page.getByText(/pasted successfully/i).first()).toBeVisible(); - - // Verify the pasted folder appears - await expect(page.locator('.collection-item-name').filter({ hasText: 'test-folder' })).toHaveCount(2); - }); -}); diff --git a/tests/shortcuts/preference-shortcuts-edit.spec.js b/tests/shortcuts/preference-shortcuts-edit.spec.js new file mode 100644 index 000000000..574f192a0 --- /dev/null +++ b/tests/shortcuts/preference-shortcuts-edit.spec.js @@ -0,0 +1,2140 @@ +import { test, expect } from '../../playwright'; +import { createCollection, createRequest, createFolder, openRequest, closeAllCollections } from '../utils/page'; + +test.describe('Preferences - Keybindings Editor', () => { + const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'; + const modifierName = process.platform === 'darwin' ? 'command' : 'ctrl'; + + test.beforeEach(async ({ page }) => { + // Wait for app to be fully loaded + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 5000 }); + }); + + const openKeybindingsTab = async (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(); + }; + + const getInput = (page, action) => page.getByTestId(`keybinding-input-${action}`); + const getResetBtn = (page, action) => page.getByTestId(`keybinding-reset-${action}`); + const getEditBtn = (page, action) => page.getByTestId(`keybinding-edit-${action}`); + const getError = (page, action) => page.getByTestId(`keybinding-error-${action}`); + + test('should open Preferences tab', async ({ 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(); + }); + + test.describe('FUNCTIONAL: Global Search', () => { + test('should open global search using default Cmd/Ctrl+K', async ({ page, createTmpDir }) => { + // Close all collections first for clean state + await closeAllCollections(page); + + const collectionPath = await createTmpDir('global-search-default'); + await createCollection(page, 'test-collection-global-search-default', collectionPath); + + // Create a request + await createRequest(page, 'request-1', 'test-collection-global-search-default'); + + // Wait for collection to be ready + await page.waitForTimeout(500); + + // Press Cmd/Ctrl+K to open global search + await page.keyboard.press(`${modifier}+KeyK`); + await page.waitForTimeout(500); + + // Verify global search modal is visible + const searchModal = page.locator('.command-k-modal'); + await expect(searchModal).toBeVisible(); + + // Close the modal + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + + // Verify modal is closed + await expect(searchModal).not.toBeVisible(); + }); + + test('should open global search using customized-1 Cmd/Ctrl+Shift+G', async ({ page, createTmpDir }) => { + // Close all collections, tabs, and preferences first + await closeAllCollections(page); + + // Close any open preference tabs + const preferenceTabs = page.locator('.request-tab').filter({ hasText: 'Preferences' }); + const prefTabCount = await preferenceTabs.count(); + for (let i = 0; i < prefTabCount; i++) { + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(200); + } + + // Open Keybindings preferences and customize globalSearch FIRST + await openKeybindingsTab(page); + + const row = page.getByTestId('keybinding-row-globalSearch'); + await row.hover(); + + // Start recording + await getEditBtn(page, 'globalSearch').click(); + await page.waitForTimeout(300); + + // Press new combo: Cmd/Ctrl+Shift+G (G is not used in any existing keybinding) + // Use down/up to ensure proper key recording + await page.keyboard.down(modifier); + await page.keyboard.down('Shift'); + await page.keyboard.down('KeyG'); + await page.waitForTimeout(200); + await page.keyboard.up('KeyG'); + await page.keyboard.up('Shift'); + await page.keyboard.up(modifier); + await page.waitForTimeout(200); + + // Verify the keybinding was saved + const input = getInput(page, 'globalSearch'); + const newValue = await input.inputValue(); + expect(newValue).toContain('shift'); + expect(newValue).toContain('g'); + + // Close preferences + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(200); + + // Now create collection and request + const collectionPath = await createTmpDir('global-search-customized-1'); + await createCollection(page, 'test-collection-global-search-customized-1', collectionPath); + + // Create a request + await createRequest(page, 'request-1', 'test-collection-global-search-customized-1'); + + // Wait for collection to be ready + await page.waitForTimeout(500); + + // Press Cmd/Ctrl+Shift+G to open global search + await page.keyboard.press(`${modifier}+Shift+KeyG`); + await page.waitForTimeout(500); + + // Verify global search modal is visible + const searchModal = page.locator('.command-k-modal'); + await expect(searchModal).toBeVisible(); + + // Close the modal + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + + // Verify modal is closed + await expect(searchModal).not.toBeVisible(); + }); + }); + + test.describe('FUNCTIONAL: Sidebar Search', () => { + test('should open sidebar search using default Cmd/Ctrl+F', async ({ page, createTmpDir }) => { + const collectionPath = await createTmpDir('sidebar-search-default'); + await createCollection(page, 'test-collection-sidebar-search-default', collectionPath); + + // Create a request + await createRequest(page, 'request-1', 'test-collection-sidebar-search-default'); + + // Wait for collection to be ready + await page.waitForTimeout(500); + + // Press Cmd/Ctrl+F to open sidebar search + await page.keyboard.press(`${modifier}+KeyF`); + await page.waitForTimeout(500); + + // // Verify sidebar search appears (look for search input in sidebar) + // await expect(page.get('Search requests...').toBeVisible()); + await page.locator('body').click({ position: { x: 1, y: 1 } }); + + // Clear the search + await page.keyboard.press(`${modifier}+KeyF`); + await page.waitForTimeout(300); + }); + + test('should open sidebar search using customized-1 Cmd/Ctrl+Shift+H', async ({ page, createTmpDir }) => { + // Close all collections, tabs, and preferences first + await closeAllCollections(page); + + // Close any open preference tabs + const preferenceTabs = page.locator('.request-tab').filter({ hasText: 'Preferences' }); + const prefTabCount = await preferenceTabs.count(); + for (let i = 0; i < prefTabCount; i++) { + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(200); + } + + // Open Keybindings preferences and customize sidebarSearch FIRST + await openKeybindingsTab(page); + + const row = page.getByTestId('keybinding-row-sidebarSearch'); + await row.hover(); + + // Start recording + await getEditBtn(page, 'sidebarSearch').click(); + await page.waitForTimeout(300); + + // Press new combo: Cmd/Ctrl+Shift+H (H is not used in any existing keybinding) + // Use down/up to ensure proper key recording + await page.keyboard.down(modifier); + await page.keyboard.down('Shift'); + await page.keyboard.down('KeyH'); + await page.waitForTimeout(200); + await page.keyboard.up('KeyH'); + await page.keyboard.up('Shift'); + await page.keyboard.up(modifier); + await page.waitForTimeout(200); + + // Verify the keybinding was saved + const input = getInput(page, 'sidebarSearch'); + const newValue = await input.inputValue(); + expect(newValue).toContain('shift'); + expect(newValue).toContain('h'); + + // Close preferences + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(200); + + // Now create collection and request + const collectionPath = await createTmpDir('sidebar-search-customized-1'); + await createCollection(page, 'test-collection-sidebar-search-customized-1', collectionPath); + + // Create a request + await createRequest(page, 'request-1', 'test-collection-sidebar-search-customized-1'); + + // Wait for collection to be ready + await page.waitForTimeout(500); + + // Press Cmd/Ctrl+Shift+H to open sidebar search + await page.keyboard.press(`${modifier}+Shift+KeyH`); + await page.waitForTimeout(500); + + // Verify sidebar search input is visible and focused + // const searchInput = page.locator('#sidebar-search-input'); + + // Clear the search + await page.locator('body').click({ position: { x: 1, y: 1 } }); + + // Verify sidebar search appears (look for search input in sidebar) + await page.keyboard.press(`${modifier}+Shift+KeyH`); + await page.waitForTimeout(300); + }); + }); + + test.describe('FUNCTIONAL: Import Collection', () => { + test('should open import collection modal using default Cmd/Ctrl+O', async ({ page, createTmpDir }) => { + const collectionPath = await createTmpDir('import-collection-default'); + await createCollection(page, 'test-collection-import-default', collectionPath); + + // Wait for collection to be ready + await page.waitForTimeout(500); + + // Press Cmd/Ctrl+O to open import collection modal + await page.keyboard.press(`${modifier}+KeyO`); + await page.waitForTimeout(500); + + // Verify import collection modal is visible + const importModal = page.locator('.bruno-modal-card').filter({ hasText: 'Import Collection' }); + await expect(importModal).toBeVisible(); + + // Close the modal + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + + // Verify modal is closed + await expect(importModal).not.toBeVisible(); + }); + + test('should open import collection modal using customized-1 Cmd/Ctrl+Shift+I', async ({ page, createTmpDir }) => { + // Close all collections, tabs, and preferences first + await closeAllCollections(page); + + // Close any open preference tabs + const preferenceTabs = page.locator('.request-tab').filter({ hasText: 'Preferences' }); + const prefTabCount = await preferenceTabs.count(); + for (let i = 0; i < prefTabCount; i++) { + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(200); + } + + // Open Keybindings preferences and customize importCollection FIRST + await openKeybindingsTab(page); + + const row = page.getByTestId('keybinding-row-importCollection'); + await row.hover(); + + // Start recording + await getEditBtn(page, 'importCollection').click(); + await page.waitForTimeout(300); + + // Press new combo: Cmd/Ctrl+Shift+I (I is not used in any existing keybinding) + // Use down/up to ensure proper key recording + await page.keyboard.down(modifier); + await page.keyboard.down('Shift'); + await page.keyboard.down('KeyI'); + await page.waitForTimeout(200); + await page.keyboard.up('KeyI'); + await page.keyboard.up('Shift'); + await page.keyboard.up(modifier); + await page.waitForTimeout(200); + + // Verify the keybinding was saved + const input = getInput(page, 'importCollection'); + const newValue = await input.inputValue(); + expect(newValue).toContain('shift'); + expect(newValue).toContain('i'); + + // Close preferences + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(200); + + // Now create collection + const collectionPath = await createTmpDir('import-collection-customized-1'); + await createCollection(page, 'test-collection-import-customized-1', collectionPath); + + // Wait for collection to be ready + await page.waitForTimeout(500); + + // Press Cmd/Ctrl+Shift+I to open import collection modal + await page.keyboard.press(`${modifier}+Shift+KeyI`); + await page.waitForTimeout(500); + + // Verify import collection modal is visible + const importModal = page.locator('.bruno-modal-card').filter({ hasText: 'Import Collection' }); + await expect(importModal).toBeVisible(); + + // Close the modal + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + + // Verify modal is closed + await expect(importModal).not.toBeVisible(); + }); + }); + + test.describe('FUNCTIONAL: Change Layout', () => { + test('should change layout orientation using default Cmd/Ctrl+J', async ({ page, createTmpDir }) => { + const collectionPath = await createTmpDir('change-layout-default'); + await createCollection(page, 'test-collection-layout-default', collectionPath); + + // Create and open a request + await createRequest(page, 'request-1', 'test-collection-layout-default'); + await openRequest(page, 'test-collection-layout-default', 'request-1'); + + // Press Cmd/Ctrl+J to change layout + await page.keyboard.press(`${modifier}+KeyJ`); + await page.waitForTimeout(500); + + await expect( + page.getByTestId('response-layout-toggle-btn') + ).toHaveAttribute('title', 'Switch to horizontal layout'); + + // Press Cmd/Ctrl+J to change layout + await page.keyboard.press(`${modifier}+KeyJ`); + await page.waitForTimeout(500); + + await expect( + page.getByTestId('response-layout-toggle-btn') + ).toHaveAttribute('title', 'Switch to vertical layout'); + }); + + test('should change layout orientation using customized-1 Cmd/Ctrl+Shift+Y', async ({ page, createTmpDir }) => { + // Close all collections, tabs, and preferences first + await closeAllCollections(page); + + // Close any open preference tabs + const preferenceTabs = page.locator('.request-tab').filter({ hasText: 'Preferences' }); + const prefTabCount = await preferenceTabs.count(); + for (let i = 0; i < prefTabCount; i++) { + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(200); + } + + // Open Keybindings preferences and customize changeLayout FIRST + await openKeybindingsTab(page); + + const row = page.getByTestId('keybinding-row-changeLayout'); + await row.hover(); + + // Start recording + await getEditBtn(page, 'changeLayout').click(); + await page.waitForTimeout(300); + + // Press new combo: Cmd/Ctrl+Shift+Y (Y is not used in any existing keybinding) + // Use down/up to ensure proper key recording + await page.keyboard.down(modifier); + await page.keyboard.down('Shift'); + await page.keyboard.down('KeyY'); + await page.waitForTimeout(200); + await page.keyboard.up('KeyY'); + await page.keyboard.up('Shift'); + await page.keyboard.up(modifier); + await page.waitForTimeout(200); + + // Verify the keybinding was saved + const input = getInput(page, 'changeLayout'); + const newValue = await input.inputValue(); + expect(newValue).toContain('shift'); + expect(newValue).toContain('y'); + + // Close preferences + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(500); + + // Now create collection and request + const collectionPath = await createTmpDir('change-layout-customized-1'); + await createCollection(page, 'test-collection-layout-customized-1', collectionPath); + + // Create and open a request + await createRequest(page, 'request-1', 'test-collection-layout-customized-1'); + await openRequest(page, 'test-collection-layout-customized-1', 'request-1'); + + // Press Cmd/Ctrl+Shift+J to change layout + await page.keyboard.press(`${modifier}+Shift+KeyY`); + await page.waitForTimeout(500); + + await expect( + page.getByTestId('response-layout-toggle-btn') + ).toHaveAttribute('title', 'Switch to horizontal layout'); + + // Press Cmd/Ctrl+Shift+J to change layout + await page.keyboard.press(`${modifier}+Shift+KeyY`); + await page.waitForTimeout(500); + + await expect( + page.getByTestId('response-layout-toggle-btn') + ).toHaveAttribute('title', 'Switch to vertical layout'); + }); + }); + + test.describe('FUNCTIONAL: Collapse Sidebar', () => { + test('should collapse/expand sidebar using default Cmd/Ctrl+\\', async ({ page, createTmpDir }) => { + const collectionPath = await createTmpDir('collapse-sidebar-default'); + await createCollection(page, 'test-collection-sidebar-default', collectionPath); + + // Wait for collection to be ready + await page.waitForTimeout(500); + + // // Get initial sidebar state + const width = await page.locator('aside.sidebar').evaluate((el) => + getComputedStyle(el).width + ); + + expect(width).not.toBe('0px'); + + await expect(page.getByTestId('collections')).toBeVisible(); + + // Press Cmd/Ctrl+\ to collapse sidebar + await page.keyboard.press(`${modifier}+Backslash`); + await page.waitForTimeout(500); + + // await expect(page.getByTestId('collections')).not.toBeVisible(); + + const width2 = await page.locator('aside.sidebar').evaluate((el) => + getComputedStyle(el).width + ); + + expect(width2).toBe('0px'); + + // Press again to expand + await page.keyboard.press(`${modifier}+Backslash`); + await page.waitForTimeout(500); + }); + + test('should collapse/expand sidebar using customized-1 Cmd/Ctrl+Shift+B', async ({ page, createTmpDir }) => { + // Close all collections, tabs, and preferences first + await closeAllCollections(page); + + // Close any open preference tabs + const preferenceTabs = page.locator('.request-tab').filter({ hasText: 'Preferences' }); + const prefTabCount = await preferenceTabs.count(); + for (let i = 0; i < prefTabCount; i++) { + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(200); + } + + // Open Keybindings preferences and customize collapseSidebar FIRST + await openKeybindingsTab(page); + + const row = page.getByTestId('keybinding-row-collapseSidebar'); + await row.hover(); + + // Start recording + await getEditBtn(page, 'collapseSidebar').click(); + await page.waitForTimeout(300); + + // Press new combo: Cmd/Ctrl+Shift+B (B is not used in any existing keybinding) + // Use down/up to ensure proper key recording + await page.keyboard.down(modifier); + await page.keyboard.down('Shift'); + await page.keyboard.down('KeyB'); + await page.waitForTimeout(200); + await page.keyboard.up('KeyB'); + await page.keyboard.up('Shift'); + await page.keyboard.up(modifier); + await page.waitForTimeout(200); + + // Verify the keybinding was saved + const input = getInput(page, 'collapseSidebar'); + const newValue = await input.inputValue(); + expect(newValue).toContain('shift'); + expect(newValue).toContain('b'); + + // Close preferences + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(200); + + // Now create collection + const collectionPath = await createTmpDir('collapse-sidebar-customized-1'); + await createCollection(page, 'test-collection-sidebar-customized-1', collectionPath); + + // Wait for collection to be ready + await page.waitForTimeout(500); + + // // Get initial sidebar state + const width = await page.locator('aside.sidebar').evaluate((el) => + getComputedStyle(el).width + ); + + expect(width).not.toBe('0px'); + + await expect(page.getByTestId('collections')).toBeVisible(); + + // Press Cmd/Ctrl+\ to collapse sidebar + await page.keyboard.press(`${modifier}+Shift+KeyB`); + await page.waitForTimeout(500); + + // await expect(page.getByTestId('collections')).not.toBeVisible(); + + const width2 = await page.locator('aside.sidebar').evaluate((el) => + getComputedStyle(el).width + ); + + expect(width2).toBe('0px'); + + // Press again to expand + await page.keyboard.press(`${modifier}+Shift+KeyB`); + await page.waitForTimeout(500); + }); + }); + + test.describe('FUNCTIONAL: Close Tab', () => { + test('should close single tab using default Cmd/Ctrl+W', async ({ page, createTmpDir }) => { + // Close all collections first for clean state + await closeAllCollections(page); + + const collectionPath = await createTmpDir('close-tab-default'); + await createCollection(page, 'test-collection-close-tab-default', collectionPath); + + // Create multiple requests + await createRequest(page, 'request-1', 'test-collection-close-tab-default'); + await createRequest(page, 'request-2', 'test-collection-close-tab-default'); + await createRequest(page, 'request-3', 'test-collection-close-tab-default'); + + // Open and pin all requests + await openRequest(page, 'test-collection-close-tab-default', 'request-1', { persist: true }); + await openRequest(page, 'test-collection-close-tab-default', 'request-2', { persist: true }); + await openRequest(page, 'test-collection-close-tab-default', 'request-3', { persist: true }); + + // Wait for tabs to be ready + await page.waitForTimeout(500); + + // Verify all 3 tabs are open + const tabs = page.locator('.request-tab'); + await expect(tabs).toHaveCount(3); + + // Verify request-3 is active + const activeTab = page.locator('li.request-tab.active'); + await expect(activeTab).toHaveText(/request-3/); + + // Press Cmd/Ctrl+W to close active tab + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(500); + + // Verify only 2 tabs remain and request-3 is closed + await expect(tabs).toHaveCount(2); + await expect(page.locator('.request-tab').filter({ hasText: 'request-3' })).not.toBeVisible(); + + // Verify request-2 is now active + await expect(activeTab).toHaveText(/request-2/); + + // Close another tab + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(500); + + // Verify only 1 tab remains + await expect(tabs).toHaveCount(1); + await expect(page.locator('.request-tab').filter({ hasText: 'request-2' })).not.toBeVisible(); + + // Verify request-1 is now active + await expect(activeTab).toHaveText(/request-1/); + }); + + test('should close single tab using customized Alt+T+C', async ({ page, createTmpDir }) => { + // Close all collections first + await closeAllCollections(page); + + // Open Keybindings preferences and customize closeTab FIRST + await openKeybindingsTab(page); + + const row = page.getByTestId('keybinding-row-closeTab'); + await row.hover(); + + // Start recording + await getEditBtn(page, 'closeTab').click(); + await page.waitForTimeout(300); + + // Press new combo: Alt+T+C + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyT'); + await page.keyboard.down('KeyC'); + await page.waitForTimeout(200); + await page.keyboard.up('KeyC'); + await page.keyboard.up('KeyT'); + await page.keyboard.up('Alt'); + await page.waitForTimeout(1000); + + // Verify the keybinding was saved + const input = getInput(page, 'closeTab'); + const newValue = await input.inputValue(); + expect(newValue).toContain('alt'); + expect(newValue).toContain('t'); + expect(newValue).toContain('c'); + + await page.keyboard.press(`${modifier}+KeyT+KeyC`); + await page.waitForTimeout(500); + + // Now create collection and requests + const collectionPath = await createTmpDir('close-tab-customized'); + await createCollection(page, 'test-collection-close-tab-customized', collectionPath); + + // Create multiple requests + await createRequest(page, 'request-1', 'test-collection-close-tab-customized'); + await createRequest(page, 'request-2', 'test-collection-close-tab-customized'); + await createRequest(page, 'request-3', 'test-collection-close-tab-customized'); + + // Open and pin all requests + await openRequest(page, 'test-collection-close-tab-customized', 'request-1', { persist: true }); + await openRequest(page, 'test-collection-close-tab-customized', 'request-2', { persist: true }); + await openRequest(page, 'test-collection-close-tab-customized', 'request-3', { persist: true }); + + // Wait for tabs to be ready + await page.waitForTimeout(500); + + // Verify all 3 tabs are open + const tabs = page.locator('.request-tab'); + await expect(tabs).toHaveCount(3); + + // Verify request-3 is active + const activeTab = page.locator('li.request-tab.active'); + await expect(activeTab).toHaveText(/request-3/); + + // Press Alt+T+C to close active tab + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyT'); + await page.keyboard.down('KeyC'); + await page.waitForTimeout(200); + await page.keyboard.up('KeyC'); + await page.keyboard.up('KeyT'); + await page.keyboard.up('Alt'); + await page.waitForTimeout(500); + + // Verify only 2 tabs remain and request-3 is closed + await expect(tabs).toHaveCount(2); + await expect(page.locator('.request-tab').filter({ hasText: 'request-3' })).not.toBeVisible(); + + // Verify request-2 is now active + await expect(activeTab).toHaveText(/request-2/); + + // Close another tab + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyT'); + await page.keyboard.down('KeyC'); + await page.waitForTimeout(200); + await page.keyboard.up('KeyC'); + await page.keyboard.up('KeyT'); + await page.keyboard.up('Alt'); + await page.waitForTimeout(500); + + // Verify only 1 tab remains + await expect(tabs).toHaveCount(1); + await expect(page.locator('.request-tab').filter({ hasText: 'request-2' })).not.toBeVisible(); + + // Verify request-1 is now active + await expect(activeTab).toHaveText(/request-1/); + }); + }); + + test.describe('FUNCTIONAL: Close All Tabs', () => { + test('should close all tabs using default Cmd/Ctrl+Shift+W', async ({ page, createTmpDir }) => { + const collectionPath = await createTmpDir('close-all-tabs-default'); + await createCollection(page, 'test-collection-close-all-default', collectionPath); + + // Create multiple requests + await createRequest(page, 'request-1', 'test-collection-close-all-default'); + await createRequest(page, 'request-2', 'test-collection-close-all-default'); + await createRequest(page, 'request-3', 'test-collection-close-all-default'); + + // Open and pin all requests + await openRequest(page, 'test-collection-close-all-default', 'request-1', { persist: true }); + await openRequest(page, 'test-collection-close-all-default', 'request-2', { persist: true }); + await openRequest(page, 'test-collection-close-all-default', 'request-3', { persist: true }); + + // Wait for tabs to be ready + await page.waitForTimeout(500); + + // Verify all 3 tabs are open + await expect(page.locator('.request-tab')).toHaveCount(3); // 'request-1/2/3' + + // Press Cmd/Ctrl+Shift+W to close all tabs + await page.keyboard.press(`${modifier}+Shift+KeyW`); + await page.waitForTimeout(500); + + // Verify all tabs are closed + await expect(page.locator('.request-tab')).toHaveCount(2); // Overview / Global Environments + }); + + test('should close all tabs using customized-1 Alt+W+A', async ({ page, createTmpDir }) => { + // Close all collections, tabs, and preferences first + await closeAllCollections(page); + + // Close any open preference tabs + const preferenceTabs = page.locator('.request-tab').filter({ hasText: 'Preferences' }); + const prefTabCount = await preferenceTabs.count(); + for (let i = 0; i < prefTabCount; i++) { + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(200); + } + + // Open Keybindings preferences and customize closeAllTabs FIRST + await openKeybindingsTab(page); + + const row = page.getByTestId('keybinding-row-closeAllTabs'); + await row.hover(); + + // Start recording + await getEditBtn(page, 'closeAllTabs').click(); + await page.waitForTimeout(300); + + // Press new combo: Alt+W+A (multi-key sequence) + // Use down/up to ensure proper key recording + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyW'); + await page.keyboard.down('KeyA'); + await page.waitForTimeout(200); + await page.keyboard.up('KeyA'); + await page.keyboard.up('KeyW'); + await page.keyboard.up('Alt'); + await page.waitForTimeout(500); + + // Verify the keybinding was saved + const input = getInput(page, 'closeAllTabs'); + const newValue = await input.inputValue(); + expect(newValue).toContain('alt'); + expect(newValue).toContain('w'); + expect(newValue).toContain('a'); + + // Close preferences + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(500); + + // Now create collection and requests + const collectionPath = await createTmpDir('close-all-tabs-customized-1'); + await createCollection(page, 'test-collection-close-all-customized-1', collectionPath); + + // Create multiple requests + await createRequest(page, 'request-1', 'test-collection-close-all-customized-1'); + await createRequest(page, 'request-2', 'test-collection-close-all-customized-1'); + await createRequest(page, 'request-3', 'test-collection-close-all-customized-1'); + + // Open and pin all requests + await openRequest(page, 'test-collection-close-all-customized-1', 'request-1', { persist: true }); + await openRequest(page, 'test-collection-close-all-customized-1', 'request-2', { persist: true }); + await openRequest(page, 'test-collection-close-all-customized-1', 'request-3', { persist: true }); + + // Wait for tabs to be ready + await page.waitForTimeout(500); + + // Verify all tabs are closed + await expect(page.locator('.request-tab')).toHaveCount(3); // Overview / Global Environments / Preferences + }); + }); + + test.describe('FUNCTIONAL: Switch to Previous Tab', () => { + test('should switch to previous tab using default Cmd/Ctrl+2', async ({ page, createTmpDir }) => { + // Close all collections first for clean state + await closeAllCollections(page); + + const collectionPath = await createTmpDir('switch-tabs-default'); + await createCollection(page, 'test-collection-switching-default', collectionPath); + + // Create multiple requests + await createRequest(page, 'request-1', 'test-collection-switching-default'); + await createRequest(page, 'request-2', 'test-collection-switching-default'); + await createRequest(page, 'request-3', 'test-collection-switching-default'); + + // Open and pin all requests (persist: true means double-click to pin) + await openRequest(page, 'test-collection-switching-default', 'request-1', { persist: true }); + await openRequest(page, 'test-collection-switching-default', 'request-2', { persist: true }); + await openRequest(page, 'test-collection-switching-default', 'request-3', { persist: true }); + + // Wait for tabs to be ready - request-3 should be active + await page.waitForTimeout(500); + + // Click on request-1 to make it active + await page.locator('.request-tab').filter({ hasText: 'request-3' }).click(); + await page.waitForTimeout(300); + + // Press Cmd/Ctrl+1 to switch to next tab + await page.keyboard.press(`${modifier}+Digit2`); + await page.waitForTimeout(500); + + // Verify next tab is active (request-2) + const activeTab = page.locator('li.request-tab.active'); + await expect(activeTab).toHaveText(/request-2/); + + // Press again + await page.keyboard.press(`${modifier}+Digit2`); + await page.waitForTimeout(500); + + // Verify next tab is active (request-3) + await expect(activeTab).toHaveText(/request-1/); + }); + + test('should switch to previous tab using customized-1 Cmd/Ctrl+Shift+P', async ({ page, createTmpDir }) => { + // Close all collections, tabs, and preferences first + await closeAllCollections(page); + + // Close any open preference tabs + const preferenceTabs = page.locator('.request-tab').filter({ hasText: 'Preferences' }); + const prefTabCount = await preferenceTabs.count(); + for (let i = 0; i < prefTabCount; i++) { + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(200); + } + + // Open Keybindings preferences and customize switchToPreviousTab FIRST + await openKeybindingsTab(page); + + const row = page.getByTestId('keybinding-row-switchToPreviousTab'); + await row.hover(); + + // Start recording + await getEditBtn(page, 'switchToPreviousTab').click(); + await page.waitForTimeout(300); + + // Press new combo: Cmd/Ctrl+Shift+P (P is not used in any existing keybinding) + // Use down/up to ensure proper key recording + await page.keyboard.down(modifier); + await page.keyboard.down('Shift'); + await page.keyboard.down('KeyP'); + await page.waitForTimeout(200); + await page.keyboard.up('KeyP'); + await page.keyboard.up('Shift'); + await page.keyboard.up(modifier); + await page.waitForTimeout(200); + + // Verify the keybinding was saved + const input = getInput(page, 'switchToPreviousTab'); + const newValue = await input.inputValue(); + expect(newValue).toContain('shift'); + expect(newValue).toContain('p'); + + // Close preferences + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(200); + + // Now create collection and requests + const collectionPath = await createTmpDir('switch-tabs-customized-1'); + await createCollection(page, 'test-collection-switching-customized-1', collectionPath); + + // Create multiple requests + await createRequest(page, 'request-1', 'test-collection-switching-customized-1'); + await createRequest(page, 'request-2', 'test-collection-switching-customized-1'); + await createRequest(page, 'request-3', 'test-collection-switching-customized-1'); + + // Open and pin all requests (persist: true means double-click to pin) + await openRequest(page, 'test-collection-switching-customized-1', 'request-1', { persist: true }); + await openRequest(page, 'test-collection-switching-customized-1', 'request-2', { persist: true }); + await openRequest(page, 'test-collection-switching-customized-1', 'request-3', { persist: true }); + + // Wait for tabs to be ready + await page.waitForTimeout(500); + + // Press Cmd/Ctrl+2 to switch to previous tab + await page.keyboard.press(`${modifier}+Shift+KeyP`); + await page.waitForTimeout(500); + + // Verify previous tab is active (request-2) + const activeTab = page.locator('li.request-tab.active'); + await expect(activeTab).toHaveText(/request-2/); + + // Press again + await page.keyboard.press(`${modifier}+Shift+KeyP`); + await page.waitForTimeout(200); + + // Verify previous tab is active (request-1) + await expect(activeTab).toHaveText(/request-1/); + }); + }); + + test.describe('FUNCTIONAL: Switch to Next Tab', () => { + test('should switch to next tab using default Cmd/Ctrl+1', async ({ page, createTmpDir }) => { + // Close all collections first for clean state + await closeAllCollections(page); + + const collectionPath = await createTmpDir('switch-tabs-next-default'); + await createCollection(page, 'test-collection-switching-next-default', collectionPath); + + // Create multiple requests + await createRequest(page, 'request-1', 'test-collection-switching-next-default'); + await createRequest(page, 'request-2', 'test-collection-switching-next-default'); + await createRequest(page, 'request-3', 'test-collection-switching-next-default'); + + // Open and pin all requests (persist: true means double-click to pin) + await openRequest(page, 'test-collection-switching-next-default', 'request-1', { persist: true }); + await openRequest(page, 'test-collection-switching-next-default', 'request-2', { persist: true }); + await openRequest(page, 'test-collection-switching-next-default', 'request-3', { persist: true }); + + // Wait for tabs to be ready - request-3 should be active + await page.waitForTimeout(500); + + // Click on request-1 to make it active + await page.locator('.request-tab').filter({ hasText: 'request-1' }).click(); + await page.waitForTimeout(300); + + // Press Cmd/Ctrl+1 to switch to next tab + await page.keyboard.press(`${modifier}+Digit1`); + await page.waitForTimeout(500); + + // Verify next tab is active (request-2) + const activeTab = page.locator('li.request-tab.active'); + await expect(activeTab).toHaveText(/request-2/); + + // Press again + await page.keyboard.press(`${modifier}+Digit1`); + await page.waitForTimeout(500); + + // Verify next tab is active (request-3) + await expect(activeTab).toHaveText(/request-3/); + }); + + test('should switch to next tab using customized-1 Cmd/Ctrl+Shift+L', async ({ page, createTmpDir }) => { + // Close all collections, tabs, and preferences first + await closeAllCollections(page); + + // Close any open preference tabs + const preferenceTabs = page.locator('.request-tab').filter({ hasText: 'Preferences' }); + const prefTabCount = await preferenceTabs.count(); + for (let i = 0; i < prefTabCount; i++) { + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(200); + } + + // Open Keybindings preferences and customize switchToNextTab FIRST + await openKeybindingsTab(page); + + const row = page.getByTestId('keybinding-row-switchToNextTab'); + await row.hover(); + + // Start recording + await getEditBtn(page, 'switchToNextTab').click(); + await page.waitForTimeout(300); + + // Press new combo: Cmd/Ctrl+Shift+L (L is not used in any existing keybinding) + // Use down/up to ensure proper key recording + await page.keyboard.down(modifier); + await page.keyboard.down('Shift'); + await page.keyboard.down('KeyL'); + await page.waitForTimeout(200); + await page.keyboard.up('KeyL'); + await page.keyboard.up('Shift'); + await page.keyboard.up(modifier); + await page.waitForTimeout(200); + + // Verify the keybinding was saved + const input = getInput(page, 'switchToNextTab'); + const newValue = await input.inputValue(); + expect(newValue).toContain('shift'); + expect(newValue).toContain('l'); + + // Close preferences + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(200); + + // Now create collection and requests + const collectionPath = await createTmpDir('switch-tabs-next-customized-1'); + await createCollection(page, 'test-collection-switching-next-customized-1', collectionPath); + + // Create multiple requests + await createRequest(page, 'request-1', 'test-collection-switching-next-customized-1'); + await createRequest(page, 'request-2', 'test-collection-switching-next-customized-1'); + await createRequest(page, 'request-3', 'test-collection-switching-next-customized-1'); + + // Open and pin all requests (persist: true means double-click to pin) + await openRequest(page, 'test-collection-switching-next-customized-1', 'request-1', { persist: true }); + await openRequest(page, 'test-collection-switching-next-customized-1', 'request-2', { persist: true }); + await openRequest(page, 'test-collection-switching-next-customized-1', 'request-3', { persist: true }); + + // Wait for tabs to be ready - request-3 should be active + await page.waitForTimeout(500); + + // Click on request-1 to make it active + await page.locator('.request-tab').filter({ hasText: 'request-1' }).click(); + await page.waitForTimeout(300); + + // Press Cmd/Ctrl+Shift+L to switch to next tab + await page.keyboard.press(`${modifier}+Shift+KeyL`); + await page.waitForTimeout(500); + + // Verify next tab is active (request-2) + const activeTab = page.locator('li.request-tab.active'); + await expect(activeTab).toHaveText(/request-2/); + + // Press again + await page.keyboard.press(`${modifier}+Shift+KeyL`); + await page.waitForTimeout(200); + + // Verify next tab is active (request-3) + await expect(activeTab).toHaveText(/request-3/); + }); + }); + + test.describe('FUNCTIONAL: Edit Environment', () => { + test('should open environment editor using default Cmd/Ctrl+E', async ({ page, createTmpDir }) => { + const collectionPath = await createTmpDir('edit-environment-default'); + await createCollection(page, 'test-collection-environment-default', collectionPath); + + // Wait for collection to be ready + await page.waitForTimeout(500); + + // Press Cmd/Ctrl+E to open environment editor + await page.keyboard.press(`${modifier}+KeyE`); + await page.waitForTimeout(500); + + // Verify environment editor tab is visible + const envTab = page.locator('.request-tab').filter({ hasText: 'Environments' }); + await expect(envTab).toBeVisible(); + }); + + test('should open environment editor using customized-1 Alt+E+G', async ({ page, createTmpDir }) => { + // Close all collections, tabs, and preferences first + await closeAllCollections(page); + + // Close any open preference tabs + const preferenceTabs = page.locator('.request-tab').filter({ hasText: 'Preferences' }); + const prefTabCount = await preferenceTabs.count(); + for (let i = 0; i < prefTabCount; i++) { + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(200); + } + + // Open Keybindings preferences and customize editEnvironment FIRST + await openKeybindingsTab(page); + + const row = page.getByTestId('keybinding-row-editEnvironment'); + await row.hover(); + + // Start recording + await getEditBtn(page, 'editEnvironment').click(); + await page.waitForTimeout(300); + + // Press new combo: Alt+E+G (multi-key sequence) + // Use down/up to ensure proper key recording + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyE'); + await page.keyboard.down('KeyG'); + await page.waitForTimeout(200); + await page.keyboard.up('KeyG'); + await page.keyboard.up('KeyE'); + await page.keyboard.up('Alt'); + await page.waitForTimeout(500); + + // Verify the keybinding was saved + const input = getInput(page, 'editEnvironment'); + const newValue = await input.inputValue(); + expect(newValue).toContain('alt'); + expect(newValue).toContain('e'); + expect(newValue).toContain('g'); + + // Close preferences + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(500); + + // Now create collection + const collectionPath = await createTmpDir('edit-environment-customized-1'); + await createCollection(page, 'test-collection-environment-customized-1', collectionPath); + + // Wait for collection to be ready + await page.waitForTimeout(500); + + // Close the environment tab + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(300); + }); + }); + + test.describe('FUNCTIONAL: Zoom In', () => { + test('should zoom in using default Cmd/Ctrl+=', async ({ page, createTmpDir }) => { + // Close all collections first for clean state + await closeAllCollections(page); + + const collectionPath = await createTmpDir('zoom-in-default'); + await createCollection(page, 'test-collection-zoom-in-default', collectionPath); + + // Wait for collection to be ready + await page.waitForTimeout(500); + + await page.keyboard.press(`${modifier}+Shift+Equal`); + await page.waitForTimeout(500); + }); + + test('should zoom in using customized-1 Alt+Z+ArrowUp', async ({ page, createTmpDir }) => { + // Close all collections, tabs, and preferences first + await closeAllCollections(page); + + // Close any open preference tabs + const preferenceTabs = page.locator('.request-tab').filter({ hasText: 'Preferences' }); + const prefTabCount = await preferenceTabs.count(); + for (let i = 0; i < prefTabCount; i++) { + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(200); + } + + // Open Keybindings preferences and customize zoomIn FIRST + await openKeybindingsTab(page); + + const row = page.getByTestId('keybinding-row-zoomIn'); + await row.hover(); + + // Start recording + await getEditBtn(page, 'zoomIn').click(); + await page.waitForTimeout(300); + + // Press new combo: Alt+Z+ArrowUp + // Use down/up to ensure proper key recording + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyZ'); + await page.keyboard.down('ArrowUp'); + await page.waitForTimeout(200); + await page.keyboard.up('ArrowUp'); + await page.keyboard.up('KeyZ'); + await page.keyboard.up('Alt'); + await page.waitForTimeout(1000); + + // Verify the keybinding was saved + const input = getInput(page, 'zoomIn'); + const newValue = await input.inputValue(); + expect(newValue).toContain('alt'); + expect(newValue).toContain('z'); + expect(newValue).toContain('arrowup'); + + // Close preferences + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(1000); + + // Wait for collection to be ready + await page.waitForTimeout(500); + + // Press Alt+Z+ArrowUp to zoom in + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyZ'); + await page.keyboard.down('ArrowUp'); + await page.waitForTimeout(200); + await page.keyboard.up('ArrowUp'); + await page.keyboard.up('KeyZ'); + await page.keyboard.up('Alt'); + await page.waitForTimeout(500); + }); + }); + + test.describe('FUNCTIONAL: Zoom Out', () => { + test('should zoom out using default Cmd/Ctrl+-', async ({ page, createTmpDir }) => { + // Close all collections first for clean state + await closeAllCollections(page); + + const collectionPath = await createTmpDir('zoom-out-default'); + await createCollection(page, 'test-collection-zoom-out-default', collectionPath); + + // Wait for collection to be ready + await page.waitForTimeout(500); + + // Press Cmd/Ctrl+- to zoom out + await page.keyboard.press(`${modifier}+Minus`); + await page.waitForTimeout(500); + }); + + test('should zoom out using customized-1 Alt+Z+ArrowDown', async ({ page, createTmpDir }) => { + // Close all collections, tabs, and preferences first + await closeAllCollections(page); + + // Close any open preference tabs + const preferenceTabs = page.locator('.request-tab').filter({ hasText: 'Preferences' }); + const prefTabCount = await preferenceTabs.count(); + for (let i = 0; i < prefTabCount; i++) { + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(200); + } + + // Open Keybindings preferences and customize zoomOut FIRST + await openKeybindingsTab(page); + + const row = page.getByTestId('keybinding-row-zoomOut'); + await row.hover(); + + // Start recording + await getEditBtn(page, 'zoomOut').click(); + await page.waitForTimeout(300); + + // Press new combo: Alt+Z+ArrowDown + // Use down/up to ensure proper key recording + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyZ'); + await page.keyboard.down('ArrowDown'); + await page.waitForTimeout(200); + await page.keyboard.up('ArrowDown'); + await page.keyboard.up('KeyZ'); + await page.keyboard.up('Alt'); + await page.waitForTimeout(1000); + + // Verify the keybinding was saved + const input = getInput(page, 'zoomOut'); + const newValue = await input.inputValue(); + expect(newValue).toContain('alt'); + expect(newValue).toContain('z'); + expect(newValue).toContain('arrowdown'); + + // Close preferences + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(1000); + + // Now create collection + const collectionPath = await createTmpDir('zoom-out-customized-1'); + await createCollection(page, 'test-collection-zoom-out-customized-1', collectionPath); + + // Wait for collection to be ready + await page.waitForTimeout(500); + + // Press Alt+Z+ArrowDown to zoom out + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyZ'); + await page.keyboard.down('ArrowDown'); + await page.waitForTimeout(200); + await page.keyboard.up('ArrowDown'); + await page.keyboard.up('KeyZ'); + await page.keyboard.up('Alt'); + await page.waitForTimeout(500); + }); + }); + + test.describe('FUNCTIONAL: Reset Zoom', () => { + test('should reset zoom using default Cmd/Ctrl+0', async ({ page, createTmpDir }) => { + // Close all collections first for clean state + await closeAllCollections(page); + + const collectionPath = await createTmpDir('reset-zoom-default'); + await createCollection(page, 'test-collection-reset-zoom-default', collectionPath); + + // Wait for collection to be ready + await page.waitForTimeout(500); + + // Zoom in first + await page.keyboard.press(`${modifier}+Shift+Equal`); + await page.waitForTimeout(300); + + // Press Cmd/Ctrl+0 to reset zoom + await page.keyboard.press(`${modifier}+Digit0`); + await page.waitForTimeout(500); + }); + + test('should reset zoom using customized-1 Alt+Z+0', async ({ page, createTmpDir }) => { + // Close all collections, tabs, and preferences first + await closeAllCollections(page); + + // Close any open preference tabs + const preferenceTabs = page.locator('.request-tab').filter({ hasText: 'Preferences' }); + const prefTabCount = await preferenceTabs.count(); + for (let i = 0; i < prefTabCount; i++) { + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(200); + } + + // Open Keybindings preferences and customize resetZoom FIRST + await openKeybindingsTab(page); + + const row = page.getByTestId('keybinding-row-resetZoom'); + await row.hover(); + + // Start recording + await getEditBtn(page, 'resetZoom').click(); + await page.waitForTimeout(300); + + // Press new combo: Alt+Z+0 + // Use down/up to ensure proper key recording + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyZ'); + await page.keyboard.down('Digit0'); + await page.waitForTimeout(200); + await page.keyboard.up('Digit0'); + await page.keyboard.up('KeyZ'); + await page.keyboard.up('Alt'); + await page.waitForTimeout(1000); + + // Verify the keybinding was saved + const input = getInput(page, 'resetZoom'); + const newValue = await input.inputValue(); + expect(newValue).toContain('alt'); + expect(newValue).toContain('z'); + expect(newValue).toContain('0'); + + // Close preferences + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(1000); + + // Wait for collection to be ready + await page.waitForTimeout(500); + + // Zoom in first using default shortcut + await page.keyboard.press(`${modifier}+Shift+Equal`); + await page.waitForTimeout(300); + + // Press Alt+Z+0 to reset zoom + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyZ'); + await page.keyboard.down('Digit0'); + await page.waitForTimeout(200); + await page.keyboard.up('Digit0'); + await page.keyboard.up('KeyZ'); + await page.keyboard.up('Alt'); + await page.waitForTimeout(500); + }); + }); + + test.describe('FUNCTIONAL: Clone Item', () => { + test('should clone request using default Cmd/Ctrl+D', async ({ page, createTmpDir }) => { + // Close all collections first for clean state + await closeAllCollections(page); + + const collectionPath = await createTmpDir('clone-request-default'); + await createCollection(page, 'test-collection-clone-request-default', collectionPath); + + // Create a request + await createRequest(page, 'original-request', 'test-collection-clone-request-default'); + + // Open the request + await openRequest(page, 'test-collection-clone-request-default', 'original-request'); + await page.waitForTimeout(500); + + // Press Cmd/Ctrl+D to clone + 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 request name + const requestNameInput = page.locator('#collection-item-name'); + await requestNameInput.fill('cloned-request'); + + // Click the clone button + await page.getByTestId('collection-item-clone').click(); + + // Wait for clone operation to complete + await page.waitForTimeout(500); + + // Verify cloned request appears in sidebar + await expect(page.locator('.collection-item-name').filter({ hasText: 'cloned-request' })).toBeVisible(); + await expect(page.locator('.collection-item-name').filter({ hasText: 'original-request' })).toBeVisible(); + }); + + test('should clone folder using default Cmd/Ctrl+D', async ({ page, createTmpDir }) => { + // Close all collections first for clean state + await closeAllCollections(page); + + // Close any open preference tabs + const preferenceTabs = page.locator('.request-tab').filter({ hasText: 'Preferences' }); + const prefTabCount = await preferenceTabs.count(); + for (let i = 0; i < prefTabCount; i++) { + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(200); + } + + // Reset cloneItem keybinding to default Cmd/Ctrl+D first + await openKeybindingsTab(page); + + const row = page.getByTestId('keybinding-row-cloneItem'); + await row.hover(); + + // Close preferences + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(500); + + // Now create collection and folder + const collectionPath = await createTmpDir('clone-folder-default'); + await createCollection(page, 'test-collection-clone-folder-default', collectionPath); + + // Create a folder + await createFolder(page, 'original-folder', 'test-collection-clone-folder-default', true); + await page.waitForTimeout(500); + + // Open folder settings + const folder = page.locator('.collection-item-name').filter({ hasText: 'original-folder' }); + await folder.click(); + + // Verify folder settings tab is open + await expect(page.locator('.request-tab').filter({ hasText: 'original-folder' })).toBeVisible(); + await page.waitForTimeout(500); + + // Press Cmd/Ctrl+D to clone folder + 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 folder name + const folderNameInput = page.locator('#collection-item-name'); + await folderNameInput.fill('cloned-folder'); + + // Click the clone button + await page.getByTestId('collection-item-clone').click(); + + // Wait for clone operation to complete + await page.waitForTimeout(500); + + // Verify cloned folder appears in sidebar + await expect(page.locator('.collection-item-name').filter({ hasText: 'cloned-folder' })).toBeVisible(); + await expect(page.locator('.collection-item-name').filter({ hasText: 'original-folder' })).toBeVisible(); + }); + + test('should clone request using customized Cmd/Ctrl+Shift+D', async ({ page, createTmpDir }) => { + // Close all collections, tabs, and preferences first + await closeAllCollections(page); + + // Close any open preference tabs + const preferenceTabs = page.locator('.request-tab').filter({ hasText: 'Preferences' }); + const prefTabCount = await preferenceTabs.count(); + for (let i = 0; i < prefTabCount; i++) { + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(200); + } + + // Open Keybindings preferences and customize cloneItem FIRST + await openKeybindingsTab(page); + + const row = page.getByTestId('keybinding-row-cloneItem'); + await row.hover(); + + // Start recording + await getEditBtn(page, 'cloneItem').click(); + await page.waitForTimeout(300); + + // Press new combo: Cmd/Ctrl+Shift+D + await page.keyboard.down(modifier === 'Meta' ? 'Meta' : 'Control'); + await page.keyboard.down('Shift'); + await page.keyboard.down('KeyD'); + await page.waitForTimeout(200); + await page.keyboard.up('KeyD'); + await page.keyboard.up('Shift'); + await page.keyboard.up(modifier === 'Meta' ? 'Meta' : 'Control'); + await page.waitForTimeout(1000); + + // Verify the keybinding was saved + const input = getInput(page, 'cloneItem'); + const newValue = await input.inputValue(); + expect(newValue).toContain('shift'); + expect(newValue).toContain('d'); + + // Close preferences + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(1000); + + // Now create collection and request + const collectionPath = await createTmpDir('clone-request-customized'); + await createCollection(page, 'test-collection-clone-request-customized', collectionPath); + + // Create a request + await createRequest(page, 'original-request', 'test-collection-clone-request-customized'); + + // Open the request + await openRequest(page, 'test-collection-clone-request-customized', 'original-request'); + await page.waitForTimeout(500); + + // Press Cmd/Ctrl+Shift+D to clone + await page.keyboard.press(`${modifier}+Shift+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 request name + const requestNameInput = page.locator('#collection-item-name'); + await requestNameInput.fill('cloned-request'); + + // Click the clone button + await page.getByTestId('collection-item-clone').click(); + + // Wait for clone operation to complete + await page.waitForTimeout(500); + + // Verify cloned request appears in sidebar + await expect(page.locator('.collection-item-name').filter({ hasText: 'cloned-request' })).toBeVisible(); + await expect(page.locator('.collection-item-name').filter({ hasText: 'original-request' })).toBeVisible(); + }); + + test('should clone folder using customized Cmd/Ctrl+Shift+D', async ({ page, createTmpDir }) => { + // Close all collections, tabs, and preferences first + await closeAllCollections(page); + + // Close any open preference tabs + const preferenceTabs = page.locator('.request-tab').filter({ hasText: 'Preferences' }); + const prefTabCount = await preferenceTabs.count(); + for (let i = 0; i < prefTabCount; i++) { + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(200); + } + + // Open Keybindings preferences and customize cloneItem FIRST + await openKeybindingsTab(page); + + const row = page.getByTestId('keybinding-row-cloneItem'); + await row.hover(); + + // Verify the keybinding was saved + const input = getInput(page, 'cloneItem'); + const newValue = await input.inputValue(); + expect(newValue).toContain('shift'); + expect(newValue).toContain('d'); + + // Close preferences + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(1000); + + // Now create collection + const collectionPath = await createTmpDir('clone-folder-customized'); + await createCollection(page, 'test-collection-clone-folder-customized', collectionPath); + + // Create a folder + await createFolder(page, 'original-folder', 'test-collection-clone-folder-customized', true); + await page.waitForTimeout(500); + + // Open folder settings + const folder = page.locator('.collection-item-name').filter({ hasText: 'original-folder' }); + await folder.click(); + // await folder.locator('.menu-icon').click(); + // await page.locator('.dropdown-item').filter({ hasText: 'Settings' }).click(); + + // Verify folder settings tab is open + await expect(page.locator('.request-tab').filter({ hasText: 'original-folder' })).toBeVisible(); + await page.waitForTimeout(500); + + // Press Cmd/Ctrl+Shift+D to clone folder + await page.keyboard.press(`${modifier}+Shift+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 folder name + const folderNameInput = page.locator('#collection-item-name'); + await folderNameInput.fill('cloned-folder'); + + // Click the clone button + await page.getByTestId('collection-item-clone').click(); + + // Wait for clone operation to complete + await page.waitForTimeout(500); + + // Verify cloned folder appears in sidebar + await expect(page.locator('.collection-item-name').filter({ hasText: 'cloned-folder' })).toBeVisible(); + await expect(page.locator('.collection-item-name').filter({ hasText: 'original-folder' })).toBeVisible(); + }); + }); + + test.describe('FUNCTIONAL: Copy and Paste Item', () => { + test('should copy and paste request using default Cmd/Ctrl+C and Cmd/Ctrl+V', async ({ page, createTmpDir }) => { + // Close all collections first for clean state + await closeAllCollections(page); + + const collectionPath = await createTmpDir('copy-paste-request-default'); + await createCollection(page, 'test-collection-copy-paste-request-default', collectionPath); + + // Create a request + await createRequest(page, 'original-request', 'test-collection-copy-paste-request-default'); + + // Open the request + await openRequest(page, 'test-collection-copy-paste-request-default', 'original-request'); + await page.waitForTimeout(500); + + // Press Cmd/Ctrl+C to copy + await page.keyboard.press(`${modifier}+KeyC`); + await page.waitForTimeout(300); + + // Press Cmd/Ctrl+V to paste + await page.keyboard.press(`${modifier}+KeyV`); + await page.waitForTimeout(500); + + // Verify pasted request appears in sidebar (should be named "original-request (1)") + await expect(page.locator('.collection-item-name').filter({ hasText: 'original-request (1)' })).toBeVisible(); + await expect(page.locator('.collection-item-name').filter({ hasText: 'original-request' }).first()).toBeVisible(); + }); + + test('should copy and paste request using customized Alt+C+R and Alt+V+R', async ({ page, createTmpDir }) => { + // Close all collections, tabs, and preferences first + await closeAllCollections(page); + + // Close any open preference tabs + let preferenceTabs = page.locator('.request-tab').filter({ hasText: 'Preferences' }); + let prefTabCount = await preferenceTabs.count(); + for (let i = 0; i < prefTabCount; i++) { + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(200); + } + + // Open Keybindings preferences and customize copyItem FIRST + await openKeybindingsTab(page); + + let copyRow = page.getByTestId('keybinding-row-copyItem'); + await copyRow.hover(); + + // Start recording for copyItem + await getEditBtn(page, 'copyItem').click(); + await page.waitForTimeout(300); + + // Press new combo: Alt+C+R + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyC'); + await page.keyboard.down('KeyR'); + await page.waitForTimeout(300); + await page.keyboard.up('KeyR'); + await page.keyboard.up('KeyC'); + await page.keyboard.up('Alt'); + await page.waitForTimeout(500); + + // Verify the keybinding was saved + let input = getInput(page, 'copyItem'); + let newValue = await input.inputValue(); + expect(newValue).toContain('alt'); + expect(newValue).toContain('c'); + expect(newValue).toContain('r'); + + // Now customize pasteItem + const pasteRow = page.getByTestId('keybinding-row-pasteItem'); + await pasteRow.hover(); + + // Start recording for pasteItem + await getEditBtn(page, 'pasteItem').click(); + await page.waitForTimeout(300); + + // Press new combo: Alt+V+R + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyV'); + await page.keyboard.down('KeyR'); + await page.waitForTimeout(200); + await page.keyboard.up('KeyR'); + await page.keyboard.up('KeyV'); + await page.keyboard.up('Alt'); + await page.waitForTimeout(1000); + + // Verify the keybinding was saved + input = getInput(page, 'pasteItem'); + newValue = await input.inputValue(); + expect(newValue).toContain('alt'); + expect(newValue).toContain('v'); + expect(newValue).toContain('r'); + + // Close preferences + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(500); + + // Now create collection and request + const collectionPath = await createTmpDir('copy-paste-request-customized'); + await createCollection(page, 'test-collection-copy-paste-request-customized', collectionPath); + + // Create a request + await createRequest(page, 'original-request', 'test-collection-copy-paste-request-customized'); + + // Open the request + await openRequest(page, 'test-collection-copy-paste-request-customized', 'original-request'); + await page.waitForTimeout(500); + + // Press Alt+C+R to copy + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyC'); + await page.keyboard.down('KeyR'); + await page.waitForTimeout(200); + await page.keyboard.up('KeyR'); + await page.keyboard.up('KeyC'); + await page.keyboard.up('Alt'); + await page.waitForTimeout(300); + + // Press Alt+V+R to paste + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyV'); + await page.keyboard.down('KeyR'); + await page.waitForTimeout(200); + await page.keyboard.up('KeyR'); + await page.keyboard.up('KeyV'); + await page.keyboard.up('Alt'); + await page.waitForTimeout(1000); + + // Verify pasted request appears in sidebar (should be named "original-request (1)") + // Wait for the new item to appear + await page.waitForTimeout(500); + const allRequests = page.locator('.collection-item-name'); + const requestCount = await allRequests.count(); + await page.waitForTimeout(500); + expect(requestCount).toBeGreaterThanOrEqual(1); // Should have at least original and pasted + + // Verify both original and pasted exist + await expect(page.locator('.collection-item-name').filter({ hasText: 'original-request' }).first()).toBeVisible(); + }); + + test('should copy and paste folder using customized Alt+C+F and Alt+V+F', async ({ page, createTmpDir }) => { + // Close all collections, tabs, and preferences first + await closeAllCollections(page); + + // Close any open preference tabs + let preferenceTabs = page.locator('.request-tab').filter({ hasText: 'Preferences' }); + let prefTabCount = await preferenceTabs.count(); + for (let i = 0; i < prefTabCount; i++) { + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(200); + } + + // Open Keybindings preferences and customize copyItem FIRST + await openKeybindingsTab(page); + + // Reset first to remove old keybindings + await openKeybindingsTab(page); + const resetCopyRow = page.getByTestId('keybinding-reset-copyItem'); + await resetCopyRow.hover(); + await getResetBtn(page, 'copyItem').click(); + await page.waitForTimeout(500); + + // Start recording for copyItem + await getEditBtn(page, 'copyItem').click(); + await page.waitForTimeout(300); + + // Press new combo: Alt+C+F + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyC'); + await page.keyboard.down('KeyF'); + await page.waitForTimeout(200); + await page.keyboard.up('KeyF'); + await page.keyboard.up('KeyC'); + await page.keyboard.up('Alt'); + await page.waitForTimeout(1000); + + // Verify the keybinding was saved + let input = getInput(page, 'copyItem'); + let newValue = await input.inputValue(); + expect(newValue).toContain('alt'); + expect(newValue).toContain('c'); + expect(newValue).toContain('f'); + + await openKeybindingsTab(page); + const resetPasteRow = page.getByTestId('keybinding-reset-pasteItem'); + await resetPasteRow.hover(); + await getResetBtn(page, 'pasteItem').click(); + await page.waitForTimeout(500); + + // Start recording for copyItem + await getEditBtn(page, 'pasteItem').click(); + await page.waitForTimeout(300); + + // Press new combo: Alt+V+F + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyV'); + await page.keyboard.down('KeyF'); + await page.waitForTimeout(200); + await page.keyboard.up('KeyF'); + await page.keyboard.up('KeyV'); + await page.keyboard.up('Alt'); + await page.waitForTimeout(1000); + + // Verify the keybinding was saved + input = getInput(page, 'pasteItem'); + newValue = await input.inputValue(); + expect(newValue).toContain('alt'); + expect(newValue).toContain('v'); + expect(newValue).toContain('f'); + + // Close preferences + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(1000); + + // Now create collection + const collectionPath = await createTmpDir('copy-paste-folder-customized'); + await createCollection(page, 'test-collection-copy-paste-folder-customized', collectionPath); + + // Create a folder + await createFolder(page, 'original-folder', 'test-collection-copy-paste-folder-customized', true); + await page.waitForTimeout(500); + + // Open folder settings + const folder = page.locator('.collection-item-name').filter({ hasText: 'original-folder' }); + await folder.hover(); + await folder.locator('.menu-icon').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Settings' }).click(); + + // Verify folder settings tab is open + await expect(page.locator('.request-tab').filter({ hasText: 'original-folder' })).toBeVisible(); + await page.waitForTimeout(500); + + // Press Alt+C+F to copy folder + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyC'); + await page.keyboard.down('KeyF'); + await page.waitForTimeout(200); + await page.keyboard.up('KeyF'); + await page.keyboard.up('KeyC'); + await page.keyboard.up('Alt'); + await page.waitForTimeout(300); + + // Press Alt+V+F to paste folder + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyV'); + await page.keyboard.down('KeyF'); + await page.waitForTimeout(200); + await page.keyboard.up('KeyF'); + await page.keyboard.up('KeyV'); + await page.keyboard.up('Alt'); + await page.waitForTimeout(1000); + + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(1000); + + // Verify pasted folder appears in sidebar + // Wait for the new item to appear + await page.waitForTimeout(500); + const allFolders = page.locator('.collection-item-name'); + const folderCount = await allFolders.count(); + await page.waitForTimeout(500); + expect(folderCount).toBeGreaterThanOrEqual(1); // Should have at least original and pasted + + // Verify both original and pasted exist + await expect(page.locator('.collection-item-name').filter({ hasText: 'original-folder' }).first()).toBeVisible(); + }); + }); + + test.describe('FUNCTIONAL: Move Tab Left', () => { + test('should move tab left using default Cmd/Ctrl+[', async ({ page, createTmpDir }) => { + // Close all collections first for clean state + await closeAllCollections(page); + + const collectionPath = await createTmpDir('move-tab-left-default'); + await createCollection(page, 'test-collection-move-tab-left-default', collectionPath); + + // Create multiple requests + await createRequest(page, 'request-1', 'test-collection-move-tab-left-default'); + await createRequest(page, 'request-2', 'test-collection-move-tab-left-default'); + await createRequest(page, 'request-3', 'test-collection-move-tab-left-default'); + + // Open and pin all requests (persist: true means double-click to pin) + await openRequest(page, 'test-collection-move-tab-left-default', 'request-1', { persist: true }); + await openRequest(page, 'test-collection-move-tab-left-default', 'request-2', { persist: true }); + await openRequest(page, 'test-collection-move-tab-left-default', 'request-3', { persist: true }); + + // Wait for tabs to be ready - request-3 should be active and last + await page.waitForTimeout(500); + + // Get initial tab order + const tabs = page.locator('.request-tab'); + const initialCount = await tabs.count(); + expect(initialCount).toBe(3); + + // Verify request-3 is active and last + const lastTab = tabs.last(); + const activeTab = page.locator('li.request-tab.active'); + await expect(lastTab).toHaveText(/request-3/); + + // Press Cmd/Ctrl+[ to move tab left + await page.keyboard.press(`${modifier}+BracketLeft`); + await page.waitForTimeout(500); + + // Verify request-3 is still active but moved left (no longer last tab) + await expect(activeTab).toHaveText(/request-3/); + await expect(activeTab).not.toHaveClass(/last-tab/); + + // Press again to move further left + await page.keyboard.press(`${modifier}+BracketLeft`); + await page.waitForTimeout(500); + + // Verify request-3 is now first tab + const firstTab = tabs.first(); + await expect(firstTab).toHaveText(/request-3/); + await expect(activeTab).toHaveText(/request-3/); + }); + + test('should move tab left using customized Alt+M+L', async ({ page, createTmpDir }) => { + // Close all collections, tabs, and preferences first + await closeAllCollections(page); + + // Close any open preference tabs + const preferenceTabs = page.locator('.request-tab').filter({ hasText: 'Preferences' }); + const prefTabCount = await preferenceTabs.count(); + for (let i = 0; i < prefTabCount; i++) { + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(200); + } + + // Open Keybindings preferences and customize moveTabLeft FIRST + await openKeybindingsTab(page); + + const row = page.getByTestId('keybinding-row-moveTabLeft'); + await row.hover(); + + // Start recording + await getEditBtn(page, 'moveTabLeft').click(); + await page.waitForTimeout(300); + + // Press new combo: Alt+M+L + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyM'); + await page.keyboard.down('KeyL'); + await page.waitForTimeout(200); + await page.keyboard.up('KeyL'); + await page.keyboard.up('KeyM'); + await page.keyboard.up('Alt'); + await page.waitForTimeout(1000); + + // Verify the keybinding was saved + const input = getInput(page, 'moveTabLeft'); + const newValue = await input.inputValue(); + expect(newValue).toContain('alt'); + expect(newValue).toContain('m'); + expect(newValue).toContain('l'); + + // Close preferences + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(1000); + + // Now create collection and requests + const collectionPath = await createTmpDir('move-tab-left-customized'); + await createCollection(page, 'test-collection-move-tab-left-customized', collectionPath); + + // Create multiple requests + await createRequest(page, 'request-1', 'test-collection-move-tab-left-customized'); + await createRequest(page, 'request-2', 'test-collection-move-tab-left-customized'); + await createRequest(page, 'request-3', 'test-collection-move-tab-left-customized'); + + // Open and pin all requests + await openRequest(page, 'test-collection-move-tab-left-customized', 'request-1', { persist: true }); + await openRequest(page, 'test-collection-move-tab-left-customized', 'request-2', { persist: true }); + await openRequest(page, 'test-collection-move-tab-left-customized', 'request-3', { persist: true }); + + // Wait for tabs to be ready + await page.waitForTimeout(500); + + // Get initial tab order + const tabs = page.locator('.request-tab'); + const initialCount = await tabs.count(); + expect(initialCount).toBe(3); + + // Verify request-3 is active and last + const lastTab = tabs.last(); + const activeTab = page.locator('li.request-tab.active'); + await expect(lastTab).toHaveText(/request-3/); + + // Press Alt+M+L to move tab left + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyM'); + await page.keyboard.down('KeyL'); + await page.waitForTimeout(200); + await page.keyboard.up('KeyL'); + await page.keyboard.up('KeyM'); + await page.keyboard.up('Alt'); + await page.waitForTimeout(500); + + // Verify request-3 is still active but moved left + await expect(activeTab).toHaveText(/request-3/); + await expect(activeTab).not.toHaveClass(/last-tab/); + + // Press again to move further left + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyM'); + await page.keyboard.down('KeyL'); + await page.waitForTimeout(200); + await page.keyboard.up('KeyL'); + await page.keyboard.up('KeyM'); + await page.keyboard.up('Alt'); + await page.waitForTimeout(500); + + // Verify request-3 is now first tab + const firstTab = tabs.first(); + await expect(firstTab).toHaveText(/request-3/); + await expect(activeTab).toHaveText(/request-3/); + }); + }); + + test.describe('FUNCTIONAL: Move Tab Right', () => { + test('should move tab right using default Cmd/Ctrl+]', async ({ page, createTmpDir }) => { + // Close all collections first for clean state + await closeAllCollections(page); + + const collectionPath = await createTmpDir('move-tab-right-default'); + await createCollection(page, 'test-collection-move-tab-right-default', collectionPath); + + // Create multiple requests + await createRequest(page, 'request-1', 'test-collection-move-tab-right-default'); + await createRequest(page, 'request-2', 'test-collection-move-tab-right-default'); + await createRequest(page, 'request-3', 'test-collection-move-tab-right-default'); + + // Open and pin all requests (persist: true means double-click to pin) + await openRequest(page, 'test-collection-move-tab-right-default', 'request-1', { persist: true }); + await openRequest(page, 'test-collection-move-tab-right-default', 'request-2', { persist: true }); + await openRequest(page, 'test-collection-move-tab-right-default', 'request-3', { persist: true }); + await openRequest(page, 'test-collection-move-tab-right-default', 'request-1', { persist: true }); + + // Wait for tabs to be ready - request-3 should be active and last + await page.waitForTimeout(500); + + // Get initial tab order + const tabs = page.locator('.request-tab'); + const initialCount = await tabs.count(); + expect(initialCount).toBe(4); + + // Verify request-3 is active and last + const firstTab = tabs.first(); + await expect(firstTab).toHaveText(/request-1/); + const activeTab = page.locator('li.request-tab.active'); + await expect(activeTab).toHaveText(/request-1/); + + // Press Cmd/Ctrl+] to move tab right + await page.keyboard.press(`${modifier}+BracketRight`); + await page.waitForTimeout(500); + + // Verify request-1 is still active but moved right (no longer last tab) + await expect(activeTab).toHaveText(/request-1/); + + // Press again to move further right + await page.keyboard.press(`${modifier}+BracketRight`); + await page.waitForTimeout(500); + + await expect(activeTab).toHaveText(/request-1/); + + // Press again to move further right + await page.keyboard.press(`${modifier}+BracketRight`); + await page.waitForTimeout(500); + + // Verify request-3 is now first tab + const lastTab = tabs.last(); + await expect(lastTab).toHaveText(/request-1/); + await expect(activeTab).toHaveText(/request-1/); + }); + + test('should move tab right using customized Alt+M+R', async ({ page, createTmpDir }) => { + // Close all collections, tabs, and preferences first + await closeAllCollections(page); + + // Close any open preference tabs + const preferenceTabs = page.locator('.request-tab').filter({ hasText: 'Preferences' }); + const prefTabCount = await preferenceTabs.count(); + for (let i = 0; i < prefTabCount; i++) { + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(200); + } + + // Open Keybindings preferences and customize moveTabRight FIRST + await openKeybindingsTab(page); + + const row = page.getByTestId('keybinding-row-moveTabRight'); + await row.hover(); + + // Start recording + await getEditBtn(page, 'moveTabRight').click(); + await page.waitForTimeout(300); + + // Press new combo: Alt+M+R + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyM'); + await page.keyboard.down('KeyR'); + await page.waitForTimeout(200); + await page.keyboard.up('KeyR'); + await page.keyboard.up('KeyM'); + await page.keyboard.up('Alt'); + await page.waitForTimeout(1000); + + // Verify the keybinding was saved + const input = getInput(page, 'moveTabRight'); + const newValue = await input.inputValue(); + expect(newValue).toContain('alt'); + expect(newValue).toContain('m'); + expect(newValue).toContain('r'); + + // Close preferences + await page.keyboard.press(`${modifier}+KeyW`); + await page.waitForTimeout(1000); + + // Now create collection and requests + const collectionPath = await createTmpDir('move-tab-right-customized'); + await createCollection(page, 'test-collection-move-tab-right-customized', collectionPath); + + // Create multiple requests + await createRequest(page, 'request-1', 'test-collection-move-tab-right-customized'); + await createRequest(page, 'request-2', 'test-collection-move-tab-right-customized'); + await createRequest(page, 'request-3', 'test-collection-move-tab-right-customized'); + + // Open and pin all requests (persist: true means double-click to pin) + await openRequest(page, 'test-collection-move-tab-right-customized', 'request-1', { persist: true }); + await openRequest(page, 'test-collection-move-tab-right-customized', 'request-2', { persist: true }); + await openRequest(page, 'test-collection-move-tab-right-customized', 'request-3', { persist: true }); + await openRequest(page, 'test-collection-move-tab-right-customized', 'request-1', { persist: true }); + + // Wait for tabs to be ready - request-3 should be active and last + await page.waitForTimeout(500); + + // Get initial tab order + const tabs = page.locator('.request-tab'); + const initialCount = await tabs.count(); + expect(initialCount).toBe(4); + + // Verify request-3 is active and last + const firstTab = tabs.first(); + await expect(firstTab).toHaveText(/request-1/); + const activeTab = page.locator('li.request-tab.active'); + await expect(activeTab).toHaveText(/request-1/); + + // Press again to move further right + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyM'); + await page.keyboard.down('KeyR'); + await page.waitForTimeout(200); + await page.keyboard.up('KeyR'); + await page.keyboard.up('KeyM'); + await page.keyboard.up('Alt'); + await page.waitForTimeout(500); + + // Verify request-1 is still active but moved right (no longer last tab) + await expect(activeTab).toHaveText(/request-1/); + + // Press again to move further right + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyM'); + await page.keyboard.down('KeyR'); + await page.waitForTimeout(200); + await page.keyboard.up('KeyR'); + await page.keyboard.up('KeyM'); + await page.keyboard.up('Alt'); + await page.waitForTimeout(500); + + // Verify request-1 is still active but moved right (no longer last tab) + await expect(activeTab).toHaveText(/request-1/); + + // Press again to move further right + await page.keyboard.down('Alt'); + await page.keyboard.down('KeyM'); + await page.keyboard.down('KeyR'); + await page.waitForTimeout(200); + await page.keyboard.up('KeyR'); + await page.keyboard.up('KeyM'); + await page.keyboard.up('Alt'); + await page.waitForTimeout(500); + + // Verify request-3 is now first tab + const lastTab = tabs.last(); + await expect(lastTab).toHaveText(/request-1/); + await expect(activeTab).toHaveText(/request-1/); + }); + }); +}); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index a4bcc7184..e66078fdf 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -444,7 +444,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(); });
{name} - {keys.split('+').map((key, i) => ( -
- {key} + Object.entries(keyMapping).map(([action, row]) => { + const isEditing = editingAction === action; + const isHovered = hoveredAction === action; + const isDirty = isRowDirty(action); + + const showPencil = isHovered && !isEditing && !isDirty; + const showRefresh = isDirty && !isEditing; + const hasError = Boolean(errorByAction[action]?.message); + const errorMessage = errorByAction[action]?.message; + const inputId = `kb-input-${action}`; + + return ( +
{row.name} +
+
+ { + if (el) inputRefs.current[action] = el; + }} + data-testid={`keybinding-input-${action}`} + className={`shortcut-input ${hasError ? 'shortcut-input--error' : ''}`} + value={renderValue(action)} + readOnly={!isEditing} + onKeyDown={(e) => handleKeyDown(action, e)} + onKeyUp={(e) => handleKeyUp(action, e)} + onBlur={() => { + // If there's an error, reset to original value instead of keeping invalid state + if (isEditing && hasError) { + cancelEditing(action); + } else if (isEditing) { + stopEditing(action); + } + }} + spellCheck={false} + /> + {isEditing && hasError && ( + + )} +
+ + {showRefresh && ( + + )} + {showPencil && ( + + )}
- ))} -
No key bindings available