revert: feat(phase-1): allow user to customize keybindings#7163 (#7457)

* Revert "feat(phase-1): allow user to customize keybindings (#7163)"

This reverts commit 14532b48a6.

* Revert "chore: UI Polish for Zoom and Keybindings panel (#7376)"

This reverts commit 5151d29aac.
This commit is contained in:
Sid
2026-03-12 20:48:16 +05:30
committed by GitHub
parent ddb1c69fc9
commit 670f11be37
25 changed files with 494 additions and 3912 deletions

View File

@@ -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)/'

View File

@@ -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)
}
};

View File

@@ -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);

View File

@@ -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();
}

View File

@@ -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};
}
`;

View File

@@ -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 cant 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>

View File

@@ -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;

View File

@@ -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();

View File

@@ -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>

View File

@@ -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();
}
};

View File

@@ -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(() => {

View File

@@ -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 ? (

View File

@@ -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

View File

@@ -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();

View File

@@ -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 (

View File

@@ -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`);

View File

@@ -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];
};

View File

@@ -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 };

View File

@@ -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',

View File

@@ -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();

View File

@@ -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
},

View File

@@ -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

View 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

View File

@@ -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();
});