mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
revert: feat(phase-1): allow user to customize keybindings#7163 (#7457)
* Revert "feat(phase-1): allow user to customize keybindings (#7163)" This reverts commit14532b48a6. * Revert "chore: UI Polish for Zoom and Keybindings panel (#7376)" This reverts commit5151d29aac.
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
module.exports = {
|
||||
rootDir: '.',
|
||||
transform: {
|
||||
'^.+\\.[jt]sx?$': '<rootDir>/jest/transformers/babel-with-esm-replacements.cjs'
|
||||
// '^.+\\.[jt]sx?$': [require("./jest/transformers/with-replacements.cjs"),'babel-jest']
|
||||
'^.+\\.[jt]sx?$': 'babel-jest'
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'/node_modules/(?!strip-json-comments|nanoid|xml-formatter)/'
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
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)
|
||||
}
|
||||
};
|
||||
@@ -16,7 +16,6 @@ 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');
|
||||
@@ -47,9 +46,6 @@ export default class CodeEditor extends React.Component {
|
||||
this.state = {
|
||||
searchBarVisible: false
|
||||
};
|
||||
|
||||
// Shortcuts cleanup function
|
||||
this._shortcutsCleanup = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -221,9 +217,6 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,12 +288,6 @@ 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);
|
||||
|
||||
@@ -6,7 +6,6 @@ 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');
|
||||
@@ -25,9 +24,6 @@ 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() {
|
||||
@@ -49,16 +45,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();
|
||||
@@ -94,9 +90,6 @@ 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);
|
||||
@@ -186,12 +179,6 @@ class MultiLineEditor extends Component {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// Cleanup shortcuts (keymap and store subscription)
|
||||
if (this._shortcutsCleanup) {
|
||||
this._shortcutsCleanup();
|
||||
this._shortcutsCleanup = null;
|
||||
}
|
||||
|
||||
if (this.brunoAutoCompleteCleanup) {
|
||||
this.brunoAutoCompleteCleanup();
|
||||
}
|
||||
|
||||
@@ -1,199 +1,53 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
min-height: 0;
|
||||
max-height: calc(100% - 30px);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
|
||||
table {
|
||||
width: 80%;
|
||||
border-collapse: collapse;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.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 .edit-btn,
|
||||
.keybinding-row .reset-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.button-placeholder {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.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,
|
||||
td {
|
||||
border: 1px solid ${(props) => props.theme.table.border};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
opacity: 1;
|
||||
thead {
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
opacity: 0.5;
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
}
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
thead th {
|
||||
font-weight: 500;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
border: 1px solid ${(props) => props.theme.table.border};
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.tooltip-mod.tooltip-mod--error{
|
||||
color: ${(props) => props.theme.status.danger.text} !important;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
margin-bottom: 24px;
|
||||
min-height: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
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.base};
|
||||
|
||||
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};
|
||||
.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};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,516 +1,14 @@
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconReload, IconPencil } from '@tabler/icons';
|
||||
import React from 'react';
|
||||
import { getKeyBindingsForOS } from 'providers/Hotkeys/keyMappings';
|
||||
import { isMacOS } from 'utils/common/platform';
|
||||
|
||||
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/reset 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 || DEFAULT_KEY_BINDINGS?.[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(' + ');
|
||||
};
|
||||
const Keybindings = ({ close }) => {
|
||||
const keyMapping = getKeyBindingsForOS(isMacOS() ? 'mac' : 'windows');
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<div className="section-header">
|
||||
<span>Keybindings</span>
|
||||
{hasDirtyRows && (
|
||||
<button
|
||||
type="button"
|
||||
className="reset-all-btn"
|
||||
onClick={resetAllKeybindings}
|
||||
title="Reset all keybindings to default"
|
||||
>
|
||||
<IconReload size={14} stroke={1} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="section-header">Keybindings</div>
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
@@ -521,89 +19,18 @@ const Keybindings = () => {
|
||||
</thead>
|
||||
<tbody>
|
||||
{keyMapping ? (
|
||||
Object.entries(keyMapping).map(([action, row]) => {
|
||||
const isEditing = editingAction === action;
|
||||
const isHovered = hoveredAction === action;
|
||||
const isDirty = isRowDirty(action);
|
||||
|
||||
const showPencil = isHovered && !isEditing && !isDirty;
|
||||
const showReset = isDirty && !isEditing;
|
||||
const hasError = Boolean(errorByAction[action]?.message);
|
||||
const errorMessage = errorByAction[action]?.message;
|
||||
const inputId = `kb-input-${action}`;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={action}
|
||||
data-testid={`keybinding-row-${action}`}
|
||||
onMouseEnter={() => setHoveredAction(action)}
|
||||
onMouseLeave={() => setHoveredAction((prev) => (prev === action ? null : prev))}
|
||||
>
|
||||
<td data-testid={`keybinding-name-${action}`}>{row.name}</td>
|
||||
<td>
|
||||
<div className="keybinding-row">
|
||||
<div className="shortcut-wrap">
|
||||
<input
|
||||
id={inputId}
|
||||
ref={(el) => {
|
||||
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 && (
|
||||
<Tooltip
|
||||
id={`kb-editing-error-tooltip-${action}`}
|
||||
anchorSelect={`#${inputId}`}
|
||||
place="bottom-start"
|
||||
opacity={1}
|
||||
isOpen={true}
|
||||
content={errorMessage}
|
||||
className="tooltip-mod tooltip-mod--error"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showReset ? (
|
||||
<button
|
||||
type="button"
|
||||
className="reset-btn"
|
||||
data-testid={`keybinding-reset-${action}`}
|
||||
onClick={() => resetRowToDefault(action)}
|
||||
title="Reset to default"
|
||||
>
|
||||
<IconReload size={14} stroke={1} />
|
||||
</button>
|
||||
) : null}
|
||||
{showPencil ? (
|
||||
<button
|
||||
type="button"
|
||||
className="edit-btn"
|
||||
data-testid={`keybinding-edit-${action}`}
|
||||
onClick={() => startEditing(action)}
|
||||
title="Edit shortcut"
|
||||
>
|
||||
<IconPencil size={14} stroke={1.5} />
|
||||
</button>
|
||||
) : null}
|
||||
Object.entries(keyMapping).map(([action, { name, keys }], index) => (
|
||||
<tr key={index}>
|
||||
<td>{name}</td>
|
||||
<td>
|
||||
{keys.split('+').map((key, i) => (
|
||||
<div className="key-button" key={i}>
|
||||
{key}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan="2">No key bindings available</td>
|
||||
|
||||
@@ -3,7 +3,7 @@ import styled from 'styled-components';
|
||||
const StyledWrapper = styled.div`
|
||||
div.tabs {
|
||||
padding: 12px;
|
||||
min-width: 180px;
|
||||
min-width: 160px;
|
||||
|
||||
div.tab {
|
||||
display: flex;
|
||||
@@ -38,7 +38,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
section.tab-panel {
|
||||
max-height: calc(100% - 24px);
|
||||
min-height: 70vh;
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
padding: 12px;
|
||||
|
||||
@@ -36,10 +36,6 @@ 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);
|
||||
@@ -90,62 +86,6 @@ 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();
|
||||
|
||||
@@ -203,7 +203,7 @@ const CloneCollectionItem = ({ collectionUid, item, onClose }) => {
|
||||
<Button type="button" color="secondary" variant="ghost" onClick={onClose} className="mr-2">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" data-testid="collection-item-clone">
|
||||
<Button type="submit">
|
||||
Clone
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { getEmptyImage } from 'react-dnd-html5-backend';
|
||||
import range from 'lodash/range';
|
||||
import filter from 'lodash/filter';
|
||||
@@ -69,21 +69,12 @@ 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);
|
||||
@@ -130,52 +121,6 @@ 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();
|
||||
@@ -483,33 +428,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
return items;
|
||||
};
|
||||
|
||||
const handleCopyItem = useCallback(() => {
|
||||
dispatch(copyRequest(item));
|
||||
const itemType = isFolder ? 'Folder' : 'Request';
|
||||
toast.success(`${itemType} copied`);
|
||||
}, [dispatch, item, isFolder]);
|
||||
|
||||
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) {
|
||||
const parentFolder = findParentItemInCollection(collection, item.uid);
|
||||
targetFolderUid = parentFolder ? parentFolder.uid : null;
|
||||
}
|
||||
|
||||
dispatch(pasteItem(collectionUid, targetFolderUid))
|
||||
.then(() => {
|
||||
toast.success('Item pasted successfully');
|
||||
})
|
||||
.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;
|
||||
|
||||
const className = classnames('flex flex-col w-full', {
|
||||
'is-sidebar-dragging': isSidebarDragging
|
||||
});
|
||||
@@ -619,25 +537,52 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyItem = () => {
|
||||
dispatch(copyRequest(item));
|
||||
const itemType = isFolder ? 'Folder' : 'Request';
|
||||
toast.success(`${itemType} copied`);
|
||||
};
|
||||
|
||||
const handlePasteItem = () => {
|
||||
// Determine target folder: if item is a folder, paste into it; otherwise paste into parent folder
|
||||
let targetFolderUid = item.uid;
|
||||
if (!isFolder) {
|
||||
const parentFolder = findParentItemInCollection(collection, item.uid);
|
||||
targetFolderUid = parentFolder ? parentFolder.uid : null;
|
||||
}
|
||||
|
||||
dispatch(pasteItem(collectionUid, targetFolderUid))
|
||||
.then(() => {
|
||||
toast.success('Item pasted successfully');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err ? err.message : 'An error occurred while pasting the item');
|
||||
});
|
||||
};
|
||||
|
||||
// Keyboard shortcuts handler
|
||||
const handleKeyDown = (e) => {
|
||||
// Detect Mac by checking both metaKey and platform
|
||||
const isMac = navigator.userAgent?.includes('Mac') || navigator.platform?.startsWith('Mac');
|
||||
const isModifierPressed = isMac ? e.metaKey : e.ctrlKey;
|
||||
|
||||
// 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') {
|
||||
const [macRenameKey, winRenameKey] = getKeyBindingsForActionAllOS('renameItem');
|
||||
const renameKey = isMac ? macRenameKey : winRenameKey;
|
||||
|
||||
// Only trigger rename if no modifier keys are pressed (allow Cmd+Enter for run request)
|
||||
const hasModifier = e.metaKey || e.ctrlKey || e.shiftKey || e.altKey;
|
||||
if (e.key.toLowerCase() === renameKey && !hasModifier) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setRenameItemModalOpen(true);
|
||||
} else if (isModifierPressed && e.key.toLowerCase() === 'c') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCopyItem();
|
||||
} else if (isModifierPressed && e.key.toLowerCase() === 'v') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handlePasteItem();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -278,34 +278,6 @@ 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(() => {
|
||||
|
||||
@@ -104,7 +104,6 @@ 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 ? (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { setIsCreatingCollection } from 'providers/ReduxStore/slices/app';
|
||||
import { useState, useMemo } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -19,7 +18,7 @@ import {
|
||||
|
||||
import { importCollection, openCollection, importCollectionFromZip, newHttpRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { sortCollections } from 'providers/ReduxStore/slices/collections/index';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import { savePreferences, setIsCreatingCollection } from 'providers/ReduxStore/slices/app';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
import { isScratchCollection, flattenItems, isItemTransientRequest } from 'utils/collections';
|
||||
import { sanitizeName } from 'utils/common/regex';
|
||||
@@ -59,22 +58,6 @@ 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
|
||||
|
||||
@@ -7,7 +7,6 @@ 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');
|
||||
|
||||
@@ -22,11 +21,8 @@ class SingleLineEditor extends Component {
|
||||
this.variables = {};
|
||||
this.readOnly = props.readOnly || false;
|
||||
|
||||
// Shortcuts cleanup function
|
||||
this._shortcutsCleanup = null;
|
||||
|
||||
this.state = {
|
||||
maskInput: props.isSecret || false
|
||||
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,8 +59,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');
|
||||
@@ -73,7 +69,7 @@ class SingleLineEditor extends Component {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
// 'Shift-Enter': runHandler,
|
||||
'Shift-Enter': runHandler,
|
||||
'Cmd-S': saveHandler,
|
||||
'Ctrl-S': saveHandler,
|
||||
'Cmd-F': noopHandler,
|
||||
@@ -112,9 +108,6 @@ 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 */
|
||||
@@ -209,12 +202,6 @@ 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();
|
||||
|
||||
@@ -49,7 +49,10 @@ const StatusBar = () => {
|
||||
};
|
||||
|
||||
const openGlobalSearch = () => {
|
||||
window.dispatchEvent(new CustomEvent('global-search-open'));
|
||||
const bindings = getKeyBindingsForActionAllOS('globalSearch') || [];
|
||||
bindings.forEach((binding) => {
|
||||
Mousetrap.trigger(binding);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,366 +1,290 @@
|
||||
import React, { createContext, useEffect, useContext, useRef, useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import find from 'lodash/find';
|
||||
import Mousetrap from 'mousetrap';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import NewRequest from 'components/Sidebar/NewRequest';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import NetworkError from 'components/ResponsePane/NetworkError';
|
||||
import NewRequest from 'components/Sidebar/NewRequest';
|
||||
import GlobalSearchModal from 'components/GlobalSearchModal';
|
||||
import ImportCollection from 'components/Sidebar/ImportCollection';
|
||||
|
||||
import store from 'providers/ReduxStore/index';
|
||||
import {
|
||||
sendRequest,
|
||||
saveRequest,
|
||||
saveCollectionRoot,
|
||||
saveFolderRoot,
|
||||
saveCollectionSettings,
|
||||
closeTabs,
|
||||
cloneItem,
|
||||
pasteItem
|
||||
closeTabs
|
||||
} from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { findCollectionByUid, findItemInCollection } from 'utils/collections';
|
||||
import { addTab, reorderTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { savePreferences, toggleSidebarCollapse, copyRequest } from 'providers/ReduxStore/slices/app';
|
||||
import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import { getKeyBindingsForActionAllOS } from './keyMappings';
|
||||
|
||||
export const HotkeysContext = createContext(null);
|
||||
export const HotkeysContext = React.createContext();
|
||||
|
||||
// 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) => <NetworkError onClose={() => 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) return undefined;
|
||||
return findCollectionByUid(collections, activeTab.collectionUid);
|
||||
if (activeTab) {
|
||||
const collection = findCollectionByUid(collections, activeTab.collectionUid);
|
||||
|
||||
return collection;
|
||||
}
|
||||
};
|
||||
|
||||
const currentCollection = getCurrentCollection();
|
||||
|
||||
// Bind/rebind hotkeys whenever user preferences change
|
||||
// save hotkey
|
||||
useEffect(() => {
|
||||
// Store previous bindings before updating
|
||||
const prevBindings = prevKeyBindingsRef.current;
|
||||
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;
|
||||
}
|
||||
|
||||
// Unbind previous bindings (if any)
|
||||
if (prevBindings !== undefined) {
|
||||
unbindAllHotkeys(prevBindings);
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bind with current preferences
|
||||
bindAllHotkeys(userKeyBindings);
|
||||
prevKeyBindingsRef.current = userKeyBindings;
|
||||
return false; // this stops the event bubbling
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Cleanup on unmount
|
||||
unbindAllHotkeys(userKeyBindings);
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('save')]);
|
||||
};
|
||||
}, [userKeyBindings]);
|
||||
}, [activeTabUid, tabs, saveRequest, collections, dispatch]);
|
||||
|
||||
// Listen for hotkey-triggered events for modals
|
||||
// send request (ctrl/cmd + enter)
|
||||
useEffect(() => {
|
||||
const openNewRequest = () => setShowNewRequestModal(true);
|
||||
const openGlobalSearch = () => setShowGlobalSearchModal(true);
|
||||
const openImportCollection = () => setShowImportCollectionModal(true);
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('sendRequest')], (e) => {
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (activeTab) {
|
||||
const collection = findCollectionByUid(collections, activeTab.collectionUid);
|
||||
|
||||
window.addEventListener('new-request-open', openNewRequest);
|
||||
window.addEventListener('global-search-open', openGlobalSearch);
|
||||
window.addEventListener('import-collection-open', openImportCollection);
|
||||
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) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
|
||||
duration: 5000
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false; // this stops the event bubbling
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('new-request-open', openNewRequest);
|
||||
window.removeEventListener('global-search-open', openGlobalSearch);
|
||||
window.removeEventListener('import-collection-open', openImportCollection);
|
||||
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')]);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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 (
|
||||
<HotkeysContext.Provider {...props} value="hotkey">
|
||||
{showNewRequestModal && (
|
||||
@@ -369,16 +293,13 @@ export const HotkeysProvider = (props) => {
|
||||
{showGlobalSearchModal && (
|
||||
<GlobalSearchModal isOpen={showGlobalSearchModal} onClose={() => setShowGlobalSearchModal(false)} />
|
||||
)}
|
||||
{showImportCollectionModal && (
|
||||
<ImportCollection onClose={() => setShowImportCollectionModal(false)} />
|
||||
)}
|
||||
<div>{props.children}</div>
|
||||
</HotkeysContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useHotkeys = () => {
|
||||
const context = useContext(HotkeysContext);
|
||||
const context = React.useContext(HotkeysContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(`useHotkeys must be used within a HotkeysProvider`);
|
||||
|
||||
@@ -1,76 +1,42 @@
|
||||
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' },
|
||||
const KeyMapping = {
|
||||
save: { mac: 'command+s', windows: 'ctrl+s', name: 'Save' },
|
||||
sendRequest: { mac: 'command+enter', windows: 'ctrl+enter', name: 'Send Request' },
|
||||
editEnvironment: { mac: 'command+e', windows: 'ctrl+e', name: 'Edit Environment' },
|
||||
newRequest: { mac: 'command+b', windows: 'ctrl+b', name: 'New Request' },
|
||||
globalSearch: { mac: 'command+k', windows: 'ctrl+k', name: 'Global Search' },
|
||||
closeTab: { mac: 'command+w', windows: 'ctrl+w', name: 'Close Tab' },
|
||||
openPreferences: { mac: 'command+,', windows: 'ctrl+,', name: 'Open Preferences' },
|
||||
closeBruno: {
|
||||
mac: 'command+bind+q',
|
||||
windows: 'ctrl+bind+shift+bind+q',
|
||||
mac: 'command+Q',
|
||||
windows: 'ctrl+shift+q',
|
||||
name: 'Close Bruno'
|
||||
},
|
||||
switchToPreviousTab: {
|
||||
mac: 'command+bind+2',
|
||||
windows: 'ctrl+bind+2',
|
||||
mac: 'command+pageup',
|
||||
windows: 'ctrl+pageup',
|
||||
name: 'Switch to Previous Tab'
|
||||
},
|
||||
switchToNextTab: {
|
||||
mac: 'command+bind+1',
|
||||
windows: 'ctrl+bind+1',
|
||||
mac: 'command+pagedown',
|
||||
windows: 'ctrl+pagedown',
|
||||
name: 'Switch to Next Tab'
|
||||
},
|
||||
moveTabLeft: {
|
||||
mac: 'command+bind+[',
|
||||
windows: 'ctrl+bind+[',
|
||||
mac: 'command+shift+pageup',
|
||||
windows: 'ctrl+shift+pageup',
|
||||
name: 'Move Tab Left'
|
||||
},
|
||||
moveTabRight: {
|
||||
mac: 'command+bind+]',
|
||||
windows: 'ctrl+bind+]',
|
||||
mac: 'command+shift+pagedown',
|
||||
windows: 'ctrl+shift+pagedown',
|
||||
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' }
|
||||
};
|
||||
|
||||
/**
|
||||
* 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('+');
|
||||
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' }
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -81,7 +47,7 @@ export const toMousetrapCombo = (keysStr) => {
|
||||
*/
|
||||
export const getKeyBindingsForOS = (os) => {
|
||||
const keyBindings = {};
|
||||
for (const [action, { name, ...keys }] of Object.entries(DEFAULT_KEY_BINDINGS)) {
|
||||
for (const [action, { name, ...keys }] of Object.entries(KeyMapping)) {
|
||||
if (keys[os]) {
|
||||
keyBindings[action] = {
|
||||
keys: keys[os],
|
||||
@@ -93,57 +59,18 @@ export const getKeyBindingsForOS = (os) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Retrieves the key bindings for a specific action across all operating systems.
|
||||
*
|
||||
* @param {string} action - The action for which to retrieve key bindings.
|
||||
* @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.
|
||||
* @returns {Object|null} An object containing the key bindings for macOS, Windows, or null if the action is not found.
|
||||
*/
|
||||
export const getKeyBindingsForActionAllOS = (action, userKeyBindings) => {
|
||||
const merged = getMergedKeyBindings(userKeyBindings);
|
||||
const actionBindings = merged[action];
|
||||
export const getKeyBindingsForActionAllOS = (action) => {
|
||||
const actionBindings = KeyMapping[action];
|
||||
|
||||
if (!actionBindings) {
|
||||
console.warn(`Action "${action}" not found in KeyMapping.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
return [actionBindings.mac, actionBindings.windows];
|
||||
};
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
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 };
|
||||
@@ -27,6 +27,7 @@ const template = [
|
||||
},
|
||||
{
|
||||
label: 'Preferences',
|
||||
accelerator: 'CommandOrControl+,',
|
||||
click() {
|
||||
ipcMain.emit('main:open-preferences');
|
||||
}
|
||||
@@ -88,7 +89,7 @@ const template = [
|
||||
},
|
||||
{
|
||||
role: 'window',
|
||||
submenu: [{ role: 'minimize' }, { role: 'close' }]
|
||||
submenu: [{ role: 'minimize' }, { role: 'close', accelerator: 'CommandOrControl+Shift+Q' }]
|
||||
},
|
||||
{
|
||||
role: 'help',
|
||||
|
||||
@@ -216,8 +216,7 @@ app.on('ready', async () => {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: true,
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
webviewTag: true,
|
||||
zoomFactor: 1.0
|
||||
webviewTag: true
|
||||
},
|
||||
title: 'Bruno',
|
||||
icon: path.join(__dirname, 'about/256x256.png'),
|
||||
@@ -247,29 +246,8 @@ 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();
|
||||
});
|
||||
|
||||
@@ -505,6 +483,14 @@ 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();
|
||||
|
||||
@@ -58,52 +58,6 @@ 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
|
||||
},
|
||||
|
||||
@@ -2,8 +2,6 @@ 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);
|
||||
@@ -137,7 +135,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(`${modifier}+BracketLeft`);
|
||||
await page.keyboard.press('ControlOrMeta+Shift+PageUp');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify order of tabs after move
|
||||
@@ -146,7 +144,7 @@ test.describe('Move tabs', () => {
|
||||
|
||||
// Move the request tab back to its original position using keyboard shortcut
|
||||
await source.click();
|
||||
await page.keyboard.press(`${modifier}+BracketRight`);
|
||||
await page.keyboard.press('ControlOrMeta+Shift+PageDown');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify order of tabs after move
|
||||
|
||||
93
tests/request/copy-request/keyboard-shortcuts.spec.ts
Normal file
93
tests/request/copy-request/keyboard-shortcuts.spec.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -450,7 +450,7 @@ const createFolder = async (
|
||||
}
|
||||
|
||||
await locators.dropdown.item('New Folder').click();
|
||||
await page.getByTestId('new-folder-input').fill(folderName);
|
||||
await page.getByPlaceholder('Folder Name').fill(folderName);
|
||||
await locators.modal.button('Create').click();
|
||||
await expect(locators.sidebar.folder(folderName)).toBeVisible();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user