mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
feat: keybindings customisation (#7603)
This commit is contained in:
@@ -57,16 +57,6 @@ export default class CodeEditor extends React.Component {
|
||||
scrollbarStyle: 'overlay',
|
||||
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
|
||||
extraKeys: {
|
||||
'Cmd-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Ctrl-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Cmd-F': 'findPersistent',
|
||||
'Ctrl-F': 'findPersistent',
|
||||
'Cmd-H': 'replace',
|
||||
|
||||
@@ -74,26 +74,6 @@ export default class CodeEditor extends React.Component {
|
||||
scrollbarStyle: 'overlay',
|
||||
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
|
||||
extraKeys: {
|
||||
'Cmd-Enter': () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Ctrl-Enter': () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Cmd-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Ctrl-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Cmd-F': (cm) => {
|
||||
this.setState({ searchBarVisible: true }, () => {
|
||||
this.searchBarRef.current?.focus();
|
||||
@@ -217,6 +197,12 @@ export default class CodeEditor extends React.Component {
|
||||
|
||||
// Setup lint error tooltip on line number hover
|
||||
this.cleanupLintErrorTooltip = setupLintErrorTooltip(editor);
|
||||
|
||||
// Add mousetrap class so Mousetrap captures shortcuts even when CodeMirror is focused
|
||||
const cmInput = editor.getInputField();
|
||||
if (cmInput) {
|
||||
cmInput.classList.add('mousetrap');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSessio
|
||||
|
||||
return (
|
||||
<StyledSessionList>
|
||||
{sessions.map((session) => {
|
||||
{sessions.map((session, idx) => {
|
||||
const { name } = getSessionDisplayInfo(session);
|
||||
return (
|
||||
<ToolHint
|
||||
@@ -125,6 +125,7 @@ const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSessio
|
||||
>
|
||||
<div
|
||||
className={`session-list-item ${activeSessionId === session.sessionId ? 'active' : ''}`}
|
||||
data-testid={`session-list-${idx}`}
|
||||
onClick={() => onSelectSession(session.sessionId)}
|
||||
>
|
||||
<div className="session-name">
|
||||
@@ -133,6 +134,7 @@ const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSessio
|
||||
</div>
|
||||
<div
|
||||
className="session-close-btn"
|
||||
data-testid={`session-close-${idx}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCloseSession(session.sessionId);
|
||||
|
||||
@@ -402,6 +402,7 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
|
||||
aria-activedescendant={results.length > 0 ? `search-result-${selectedIndex}` : undefined}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
data-testid="global-search-input"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
|
||||
@@ -45,26 +45,6 @@ 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();
|
||||
}
|
||||
},
|
||||
'Cmd-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Ctrl-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
}
|
||||
},
|
||||
'Cmd-F': () => {},
|
||||
'Ctrl-F': () => {},
|
||||
// Tabbing disabled to make tabindex work
|
||||
@@ -90,6 +70,12 @@ class MultiLineEditor extends Component {
|
||||
|
||||
setupLinkAware(this.editor);
|
||||
|
||||
// Add mousetrap calss so Mousetrap captures shortcuts even when Codemirror is focused
|
||||
const cmInput = this.editor.getInputField();
|
||||
if (cmInput) {
|
||||
cmInput.classList.add('mousetrap');
|
||||
}
|
||||
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this.editor.on('change', this._onEdit);
|
||||
this.addOverlay(variables);
|
||||
|
||||
@@ -1,53 +1,328 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
min-height: 0;
|
||||
max-height: calc(100% - 30px);
|
||||
|
||||
max-width: 80%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
|
||||
table {
|
||||
width: 80%;
|
||||
border-collapse: collapse;
|
||||
|
||||
thead,
|
||||
td {
|
||||
border: 1px solid ${(props) => props.theme.table.border};
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 0px;
|
||||
}
|
||||
|
||||
.section-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section-actions-divider {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: ${(props) => props.theme.input.border};
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
height: 1px;
|
||||
background: ${(props) => props.theme.input.border};
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.tables-container {
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
thead {
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
&.tables-disabled {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
}
|
||||
|
||||
thead th {
|
||||
font-weight: 500;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
border: 1px solid ${(props) => props.theme.table.border};
|
||||
}
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
border: solid 1px ${(props) => props.theme.border.border0};
|
||||
}
|
||||
|
||||
.key-button {
|
||||
display: inline-block;
|
||||
color: ${(props) => props.theme.table.input.color};
|
||||
opacity: 0.7;
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
}
|
||||
|
||||
thead {
|
||||
color: ${(props) => props.theme.table.thead.color} !important;
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
user-select: none;
|
||||
|
||||
td {
|
||||
padding: 5px 10px !important;
|
||||
border: none !important;
|
||||
border-bottom: solid 1px ${(props) => props.theme.border.border0} !important;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
thead td:first-child,
|
||||
tbody td:first-child {
|
||||
width: 35%;
|
||||
}
|
||||
|
||||
thead td:last-child,
|
||||
tbody td:last-child {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
tbody {
|
||||
tr {
|
||||
transition: background 0.1s ease;
|
||||
height: 30px;
|
||||
|
||||
td {
|
||||
padding: 0 10px !important;
|
||||
border: none !important;
|
||||
vertical-align: middle;
|
||||
background: transparent;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
}
|
||||
|
||||
tr:hover:not(.row-editing) td {
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tr.row-editing td {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
tr.section-heading-row td {
|
||||
font-weight: 600;
|
||||
padding: 6px 10px !important;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
tr.section-heading-row:hover td {
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
tr.section-last-row td {
|
||||
border-bottom: solid 1px ${(props) => props.theme.border.border0} !important;
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 24px;
|
||||
min-width: 200px;
|
||||
max-width: 200px;
|
||||
flex-shrink: 0;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shortcut-input--editing {
|
||||
outline: 1px solid #E4AE49;
|
||||
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};
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 0 8px;
|
||||
caret-color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.shortcut-input--error.shortcut-input--editing {
|
||||
outline: 1px solid #CE4F3B;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.shortcut-input--readonly {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.shortcut-text {
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.table.input.color};
|
||||
}
|
||||
|
||||
.shortcut-pills {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.shortcut-separator {
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
margin: 0 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.keycap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 22px;
|
||||
padding: 2px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
background: ${(props) => props.theme.background.base};
|
||||
color: ${(props) => props.theme.table.input.color};
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
tbody tr.row-success td {
|
||||
background: #2E8A540F;
|
||||
}
|
||||
|
||||
tbody tr.row-error td {
|
||||
background: #D32F2F0F;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
color: #2E8A54;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: #CE4F3B;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-error-icon {
|
||||
color: #CE4F3B;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes blink-caret {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.editing-caret {
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
height: 12px;
|
||||
background: ${(props) => props.theme.text};
|
||||
margin-left: 1px;
|
||||
vertical-align: middle;
|
||||
animation: blink-caret 1s step-end infinite;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
border-radius: 6px;
|
||||
padding: 0px 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pencil-icon {
|
||||
color: ${(props) => props.theme.table.thead.color};
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.shortcut-input--error {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tooltip-mod.tooltip-mod--error {
|
||||
color: ${(props) => props.theme.status.danger.text} !important;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 12px 2px;
|
||||
color: ${(props) => props.theme.text};
|
||||
opacity: 0.8;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,43 +1,959 @@
|
||||
import React, { useMemo, useRef, useState, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import React from 'react';
|
||||
import { getKeyBindingsForOS } from 'providers/Hotkeys/keyMappings';
|
||||
import { IconReload, IconPencil, IconLock, IconCircleCheck, IconAlertCircle } from '@tabler/icons';
|
||||
import { isMacOS } from 'utils/common/platform';
|
||||
|
||||
const Keybindings = ({ close }) => {
|
||||
const keyMapping = getKeyBindingsForOS(isMacOS() ? 'mac' : 'windows');
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import { KEY_BINDING_SECTIONS } from 'providers/Hotkeys/keyMappings.js';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import ToggleSwitch from 'components/ToggleSwitch/index';
|
||||
|
||||
const SEP = '+bind+';
|
||||
const getOS = () => (isMacOS() ? 'mac' : 'windows');
|
||||
|
||||
// Modifier tokens used in stored preferences.
|
||||
// These are lowercase on purpose so they match persisted values.
|
||||
const MODIFIERS = new Set(['ctrl', 'command', 'alt', 'shift']);
|
||||
|
||||
const MODIFIER_SYMBOLS = {
|
||||
mac: {
|
||||
command: '⌘',
|
||||
ctrl: '⌃',
|
||||
alt: '⌥',
|
||||
shift: '⇧'
|
||||
},
|
||||
windows: {
|
||||
ctrl: 'Ctrl',
|
||||
alt: 'Alt',
|
||||
shift: 'Shift',
|
||||
command: 'Win'
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to parse displayValue string into arrays of key arrays for rendering as keycaps
|
||||
// Takes a raw string like "command+bind+1 - command+bind+8" and returns [["command", "1"], ["command", "8"]]
|
||||
// This allows rendering in the same pills style as regular keybindings
|
||||
const parseDisplayValue = (displayValue, os) => {
|
||||
if (!displayValue || typeof displayValue !== 'string') return null;
|
||||
|
||||
const symbols = MODIFIER_SYMBOLS[os] || MODIFIER_SYMBOLS.windows;
|
||||
|
||||
// Reverse mapping from symbol to key name
|
||||
const symbolToKey = {};
|
||||
Object.entries(symbols).forEach(([key, symbol]) => {
|
||||
symbolToKey[symbol.toLowerCase()] = key;
|
||||
});
|
||||
|
||||
// Split by " - " to get range parts (e.g., ["command+bind+1", "command+bind+8"])
|
||||
const rangeParts = displayValue.split(/\s*-\s*/);
|
||||
|
||||
const result = rangeParts.map((part) => {
|
||||
// Split by "+bind+" to get individual keys (consistent with storage format)
|
||||
// Filter out empty strings that may result from the split
|
||||
const keys = part.split(SEP).filter(Boolean).map((key) => {
|
||||
const lowerKey = key.toLowerCase().trim();
|
||||
// Check if it's a symbol and convert back to key name
|
||||
if (symbolToKey[lowerKey]) {
|
||||
return symbolToKey[lowerKey];
|
||||
}
|
||||
// For non-modifier keys, return as-is but lowercase
|
||||
return lowerKey;
|
||||
});
|
||||
return keys;
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Render displayValue using the same pills style as regular keybindings
|
||||
const renderDisplayValue = (displayValue, os) => {
|
||||
const parsed = parseDisplayValue(displayValue, os);
|
||||
if (!parsed || !parsed.length) return null;
|
||||
|
||||
// If there's only one shortcut, render it normally
|
||||
if (parsed.length === 1) {
|
||||
return <span className="shortcut-pills">{renderKeycaps(parsed[0], os)}</span>;
|
||||
}
|
||||
|
||||
// If there are multiple shortcuts (range), render each as a group with separator
|
||||
return (
|
||||
<span className="shortcut-pills">
|
||||
{parsed.map((keysArr, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{index > 0 && <span className="shortcut-separator"> - </span>}
|
||||
{renderKeycaps(keysArr, os)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Required modifier policy by OS.
|
||||
// On macOS, command/ctrl/alt/shift are allowed as the required modifier.
|
||||
// On Windows, command should not count as a valid modifier for app shortcuts.
|
||||
const REQUIRED_MODIFIERS_BY_OS = {
|
||||
mac: new Set(['command', 'alt', 'shift', 'ctrl']),
|
||||
windows: new Set(['ctrl', 'alt', 'shift'])
|
||||
};
|
||||
|
||||
const FUNCTION_KEY_PATTERN = /^f([1-9]|1[0-2])$/;
|
||||
const isFunctionKey = (k) => FUNCTION_KEY_PATTERN.test(k);
|
||||
const hasRequiredModifier = (os, arr) => {
|
||||
// Function keys (F1-F12) are allowed without a modifier
|
||||
if (arr.some(isFunctionKey)) return true;
|
||||
return arr.some((k) => REQUIRED_MODIFIERS_BY_OS[os]?.has(k));
|
||||
};
|
||||
const isOnlyModifiers = (arr) => arr.length > 0 && arr.every((k) => MODIFIERS.has(k));
|
||||
|
||||
// Keep a stable modifier order for display, storage, and duplicate detection.
|
||||
// Non-modifier keys keep their original order.
|
||||
const MODIFIER_ORDER = ['ctrl', 'command', 'alt', 'shift'];
|
||||
|
||||
const sortCombo = (arr) => {
|
||||
const modifiers = [];
|
||||
const nonModifiers = [];
|
||||
|
||||
arr.forEach((key) => {
|
||||
if (MODIFIERS.has(key)) {
|
||||
modifiers.push(key);
|
||||
} else {
|
||||
nonModifiers.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
modifiers.sort((a, b) => MODIFIER_ORDER.indexOf(a) - MODIFIER_ORDER.indexOf(b));
|
||||
|
||||
return [...modifiers, ...nonModifiers];
|
||||
};
|
||||
|
||||
// Remove duplicates while preserving insertion order, then apply stable sorting.
|
||||
const uniqSorted = (arr) => {
|
||||
const seen = new Set();
|
||||
const unique = [];
|
||||
|
||||
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);
|
||||
|
||||
const formatSingleKeyForDisplay = (key, os) => {
|
||||
if (MODIFIER_SYMBOLS[os]?.[key]) return MODIFIER_SYMBOLS[os][key];
|
||||
if (key.length === 1) return key.toUpperCase();
|
||||
|
||||
const SPECIAL_LABELS = {
|
||||
enter: os === 'mac' ? '↩' : 'Enter',
|
||||
backspace: os === 'mac' ? '⌫' : 'Backspace',
|
||||
tab: os === 'mac' ? '⇥' : 'Tab',
|
||||
delete: os === 'mac' ? '⌦' : 'Delete',
|
||||
esc: os === 'mac' ? '⎋' : 'Esc',
|
||||
space: os === 'mac' ? '␣' : 'Space',
|
||||
arrowup: '↑',
|
||||
arrowdown: '↓',
|
||||
arrowleft: '←',
|
||||
arrowright: '→',
|
||||
pageup: 'PageUp',
|
||||
pagedown: 'PageDown',
|
||||
home: 'Home',
|
||||
end: 'End'
|
||||
};
|
||||
|
||||
return SPECIAL_LABELS[key] || key.charAt(0).toUpperCase() + key.slice(1);
|
||||
};
|
||||
|
||||
const renderKeycaps = (keysArr, os) => {
|
||||
if (!keysArr?.length) return null;
|
||||
|
||||
return keysArr.map((key, index) => (
|
||||
<span key={`${key}-${index}`} className="keycap">
|
||||
{formatSingleKeyForDisplay(key, os)}
|
||||
</span>
|
||||
));
|
||||
};
|
||||
|
||||
// Signature is intentionally exact.
|
||||
// This means:
|
||||
// - command + f
|
||||
// - command + shift + f
|
||||
// are treated as different shortcuts and can coexist.
|
||||
// Only an exact same normalized combo is considered duplicate.
|
||||
const comboSignature = (arr) => toKeysString(arr);
|
||||
|
||||
// OS reserved shortcuts in stored-token format.
|
||||
// These are blocked because they are usually intercepted by the OS/window manager.
|
||||
// Also includes common editing shortcuts that should not be overridden.
|
||||
const RESERVED_BY_OS = {
|
||||
mac: new Set([
|
||||
comboSignature(['command', 'h']),
|
||||
comboSignature(['command', 'alt', 'h']),
|
||||
comboSignature(['ctrl', 'command', 'f']),
|
||||
comboSignature(['command', 'shift', 'q']),
|
||||
comboSignature(['command', 'alt', 'd']),
|
||||
comboSignature(['command', 'm']),
|
||||
comboSignature(['command', 'tab']),
|
||||
comboSignature(['command', 'space']),
|
||||
comboSignature(['ctrl', 'command', 'q']),
|
||||
comboSignature(['command', 'shift', '3']),
|
||||
comboSignature(['command', 'shift', '4']),
|
||||
comboSignature(['command', 'shift', '5']),
|
||||
comboSignature(['command', 'alt', 'esc']),
|
||||
// Undo/Redo - standard text editing shortcuts that browsers handle natively
|
||||
comboSignature(['command', 'z']),
|
||||
comboSignature(['command', 'shift', 'z']),
|
||||
comboSignature(['command', 'alt', 'z']),
|
||||
// Toggle Developer Tools
|
||||
comboSignature(['command', 'alt', 'i']),
|
||||
// Function keys reserved by macOS
|
||||
comboSignature(['f11']), // Show Desktop
|
||||
comboSignature(['f12']) // Dashboard (older macOS)
|
||||
]),
|
||||
windows: new Set([
|
||||
comboSignature(['alt', 'tab']),
|
||||
comboSignature(['alt', 'f4']),
|
||||
comboSignature(['f1']), // Windows Help
|
||||
comboSignature(['ctrl', 'alt', 'delete']),
|
||||
comboSignature(['command', 'l']),
|
||||
comboSignature(['command', 'd']),
|
||||
comboSignature(['command', 'e']),
|
||||
comboSignature(['command', 'r']),
|
||||
comboSignature(['command', 'i']),
|
||||
comboSignature(['command', 's']),
|
||||
comboSignature(['command', 'a']),
|
||||
comboSignature(['command', 'x']),
|
||||
comboSignature(['command', 'm']),
|
||||
comboSignature(['command', 'tab']),
|
||||
comboSignature(['ctrl', 'shift', 'esc']),
|
||||
// Undo/Redo - standard text editing shortcuts that browsers handle natively
|
||||
comboSignature(['ctrl', 'z']),
|
||||
comboSignature(['ctrl', 'shift', 'z']),
|
||||
// Toggle Developer Tools
|
||||
comboSignature(['ctrl', 'shift', 'i'])
|
||||
])
|
||||
};
|
||||
|
||||
// Normalize keyboard event to stored token format.
|
||||
// The output must stay aligned with default preference values.
|
||||
const normalizeKey = (e) => {
|
||||
const k = e.key;
|
||||
|
||||
// Handle dead keys on macOS - Option+letter produces dead key characters
|
||||
// Convert dead key back to the base character for consistent normalization
|
||||
if (k === 'Dead') {
|
||||
// Use code to determine the base key (e.g., 'KeyI' for 'i')
|
||||
const code = e.code;
|
||||
if (code) {
|
||||
const baseKey = code.replace('Key', '').toLowerCase();
|
||||
return baseKey;
|
||||
}
|
||||
return 'dead';
|
||||
}
|
||||
|
||||
// Ignore lock keys. They should not be recordable shortcuts.
|
||||
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 maps to command so storage format stays consistent across the app.
|
||||
if (k === 'Meta') return 'command';
|
||||
|
||||
// For letter and digit keys always use e.code (the physical key) instead of e.key.
|
||||
// When Option/Alt is held, e.key produces a composed character (e.g. Option+X → '≈')
|
||||
// which Mousetrap does not recognise — it expects the base key name ('x').
|
||||
// e.code is unaffected by modifier state: 'KeyX' → 'x', 'Digit1' → '1'.
|
||||
const code = e.code || '';
|
||||
if (code.startsWith('Key')) return code.slice(3).toLowerCase();
|
||||
if (code.startsWith('Digit')) return code.slice(5);
|
||||
|
||||
// Single printable chars become 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',
|
||||
MULTIPLE_NON_MODIFIERS: 'MULTIPLE_NON_MODIFIERS',
|
||||
RESERVED: 'RESERVED',
|
||||
DUPLICATE: 'DUPLICATE'
|
||||
};
|
||||
|
||||
const Keybindings = () => {
|
||||
const dispatch = useDispatch();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const { theme } = useTheme();
|
||||
|
||||
const os = getOS();
|
||||
const keybindingsEnabled = preferences?.keybindingsEnabled !== false;
|
||||
|
||||
const handleToggleKeybindings = () => {
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
keybindingsEnabled: !keybindingsEnabled
|
||||
};
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
};
|
||||
|
||||
// Flatten KEY_BINDING_SECTIONS into a single lookup map for internal logic.
|
||||
const sectionDefaults = useMemo(() => {
|
||||
const merged = {};
|
||||
|
||||
for (const section of KEY_BINDING_SECTIONS) {
|
||||
for (const [action, binding] of Object.entries(section.bindings || {})) {
|
||||
merged[action] = { ...binding };
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}, []);
|
||||
|
||||
// Source of truth:
|
||||
// Start from grouped defaults, then merge user-specific overrides on top.
|
||||
const keyBindings = useMemo(() => {
|
||||
const merged = {};
|
||||
|
||||
for (const [action, binding] of Object.entries(sectionDefaults)) {
|
||||
merged[action] = { ...binding };
|
||||
}
|
||||
|
||||
const userBindings = preferences?.keyBindings || {};
|
||||
for (const [action, binding] of Object.entries(userBindings)) {
|
||||
if (merged[action]) {
|
||||
merged[action] = {
|
||||
...merged[action],
|
||||
...binding
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}, [preferences?.keyBindings, sectionDefaults]);
|
||||
|
||||
// Build grouped rows for current OS only and skip hidden bindings.
|
||||
const groupedKeyMappings = useMemo(() => {
|
||||
return KEY_BINDING_SECTIONS.map((section) => {
|
||||
const rows = Object.entries(section.bindings || {})
|
||||
.map(([action]) => {
|
||||
const binding = keyBindings[action];
|
||||
if (!binding?.[os] || binding.hidden) return null;
|
||||
|
||||
return {
|
||||
action,
|
||||
name: binding.name,
|
||||
keys: binding[os],
|
||||
readOnly: binding.readOnly,
|
||||
displayValue: binding.displayValue
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
heading: section.heading,
|
||||
rows
|
||||
};
|
||||
}).filter((section) => section.rows.length > 0);
|
||||
}, [keyBindings, os]);
|
||||
|
||||
// editingAction:
|
||||
// The row currently in edit mode.
|
||||
const [editingAction, setEditingAction] = useState(null);
|
||||
|
||||
// hoveredAction:
|
||||
// Tracks row hover state to show pencil/reset/lock controls.
|
||||
const [hoveredAction, setHoveredAction] = useState(null);
|
||||
|
||||
// recordingAction:
|
||||
// The row actively listening for key presses.
|
||||
const [recordingAction, setRecordingAction] = useState(null);
|
||||
|
||||
// Tracks currently held keys while recording.
|
||||
// A Set allows more than 2 keys and avoids duplicates naturally.
|
||||
const pressedKeysRef = useRef(new Set());
|
||||
|
||||
// Refs for row inputs, used to focus the selected row when editing starts.
|
||||
const inputRefs = useRef({});
|
||||
|
||||
// draftByAction:
|
||||
// Temporary in-progress shortcut for a row while editing.
|
||||
const [draftByAction, setDraftByAction] = useState({});
|
||||
|
||||
// errorByAction:
|
||||
// Validation result per row while editing.
|
||||
const [errorByAction, setErrorByAction] = useState({});
|
||||
|
||||
// successAction:
|
||||
// Tracks which row just saved successfully for a 1-second flash.
|
||||
const [successAction, setSuccessAction] = useState(null);
|
||||
const successTimerRef = useRef(null);
|
||||
|
||||
const getCurrentRowKeysString = (action) => keyBindings?.[action]?.[os] || '';
|
||||
const getDefaultRowKeysString = (action) => sectionDefaults?.[action]?.[os] || '';
|
||||
|
||||
const isRowDirty = (action) => {
|
||||
const current = getCurrentRowKeysString(action);
|
||||
const def = getDefaultRowKeysString(action);
|
||||
|
||||
if (!sectionDefaults[action]) return false;
|
||||
return current !== def;
|
||||
};
|
||||
|
||||
// Whether any row differs from the default binding.
|
||||
const hasDirtyRows = useMemo(() => {
|
||||
for (const action of Object.keys(sectionDefaults)) {
|
||||
if (isRowDirty(action)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [keyBindings, os, sectionDefaults]);
|
||||
|
||||
// Build a set of exact normalized signatures for all shortcuts except the row being edited.
|
||||
// This allows:
|
||||
// - command + f
|
||||
// - command + shift + f
|
||||
// to coexist, because signatures differ.
|
||||
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;
|
||||
|
||||
const normalized = comboSignature(fromKeysString(keysStr));
|
||||
if (normalized) used.add(normalized);
|
||||
}
|
||||
|
||||
return used;
|
||||
};
|
||||
|
||||
// Validate only the exact current combo.
|
||||
// No subset/superset conflict detection is done here.
|
||||
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).'
|
||||
};
|
||||
}
|
||||
|
||||
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).'
|
||||
};
|
||||
}
|
||||
|
||||
const nonModifierCount = arr.filter((k) => !MODIFIERS.has(k)).length;
|
||||
if (nonModifierCount > 1) {
|
||||
return {
|
||||
code: ERROR.MULTIPLE_NON_MODIFIERS,
|
||||
message: 'Only one non-modifier key allowed (e.g. Cmd + Shift + K).'
|
||||
};
|
||||
}
|
||||
|
||||
if (RESERVED_BY_OS[os]?.has(sig)) {
|
||||
return {
|
||||
code: ERROR.RESERVED,
|
||||
message: 'This shortcut is reserved by the OS.'
|
||||
};
|
||||
}
|
||||
|
||||
if (buildUsedSignatures(action).has(sig)) {
|
||||
return {
|
||||
code: ERROR.DUPLICATE,
|
||||
message: 'That shortcut is already in use.'
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const persistToPreferences = (action, nextKeys) => {
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
keyBindings: {
|
||||
...(preferences?.keyBindings || {}),
|
||||
[action]: {
|
||||
...(preferences?.keyBindings?.[action] || {}),
|
||||
name: preferences?.keyBindings?.[action]?.name || sectionDefaults?.[action]?.name || action,
|
||||
[os]: nextKeys
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
};
|
||||
|
||||
// Commit the draft only if it is valid.
|
||||
// Returns true if saved or unchanged, 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);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const resetRowToDefault = (action) => {
|
||||
const def = sectionDefaults?.[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 (!keybindingsEnabled) return;
|
||||
// If another row is already editing, try to commit it first.
|
||||
// If invalid, keep the previous row active.
|
||||
if (editingAction && editingAction !== action) {
|
||||
const ok = commitCombo(editingAction);
|
||||
if (ok) {
|
||||
setRecordingAction(null);
|
||||
setEditingAction(null);
|
||||
pressedKeysRef.current = new Set();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setEditingAction(action);
|
||||
setRecordingAction(action);
|
||||
pressedKeysRef.current = new Set();
|
||||
|
||||
// Seed the draft with the current saved value so the row reflects existing state.
|
||||
setDraftByAction((prev) => ({
|
||||
...prev,
|
||||
[action]: fromKeysString(getCurrentRowKeysString(action))
|
||||
}));
|
||||
|
||||
// Clear any previous validation error for this row.
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Focus the input div after React has committed the editingAction state change.
|
||||
// Runs only when editingAction changes — no extra renders beyond what already happens.
|
||||
useEffect(() => {
|
||||
if (editingAction) {
|
||||
inputRefs.current[editingAction]?.focus?.();
|
||||
}
|
||||
}, [editingAction]);
|
||||
|
||||
const showSuccessFlash = (action) => {
|
||||
if (successTimerRef.current) clearTimeout(successTimerRef.current);
|
||||
setSuccessAction(action);
|
||||
successTimerRef.current = setTimeout(() => {
|
||||
setSuccessAction(null);
|
||||
successTimerRef.current = null;
|
||||
}, 800);
|
||||
};
|
||||
|
||||
const stopEditing = (action) => {
|
||||
const draftArr = draftByAction[action] || [];
|
||||
const currentKeys = getCurrentRowKeysString(action);
|
||||
const nextKeys = draftArr.length ? toKeysString(draftArr) : currentKeys;
|
||||
const willChange = nextKeys !== currentKeys;
|
||||
|
||||
const ok = commitCombo(action);
|
||||
|
||||
if (!ok) {
|
||||
// On invalid commit, discard the invalid draft and restore saved value.
|
||||
cancelEditing(action);
|
||||
return;
|
||||
}
|
||||
|
||||
setRecordingAction(null);
|
||||
setEditingAction(null);
|
||||
pressedKeysRef.current = new Set();
|
||||
|
||||
if (willChange) {
|
||||
showSuccessFlash(action);
|
||||
}
|
||||
};
|
||||
|
||||
// Cancel editing and restore the persisted value.
|
||||
const cancelEditing = (action) => {
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
|
||||
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 clearing current draft while staying in edit mode.
|
||||
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;
|
||||
}
|
||||
|
||||
// Ignore key repeat so holding a key does not cause noise.
|
||||
if (e.repeat) return;
|
||||
|
||||
const keyName = normalizeKey(e);
|
||||
if (!keyName) return;
|
||||
|
||||
// Starting a new combo after a failed one — clear stale draft
|
||||
if (pressedKeysRef.current.size === 0 && errorByAction[action]?.message) {
|
||||
setDraftByAction((prev) => ({ ...prev, [action]: [] }));
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[action];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// Max 3 keys allowed per keybinding
|
||||
if (pressedKeysRef.current.size >= 3 && !pressedKeysRef.current.has(keyName)) return;
|
||||
|
||||
pressedKeysRef.current.add(keyName);
|
||||
|
||||
const nextDraft = uniqSorted(Array.from(pressedKeysRef.current));
|
||||
|
||||
setDraftByAction((prev) => ({
|
||||
...prev,
|
||||
[action]: nextDraft
|
||||
}));
|
||||
|
||||
const err = validateCombo(action, nextDraft);
|
||||
setErrorByAction((prev) => {
|
||||
const next = { ...prev };
|
||||
|
||||
if (err) {
|
||||
next[action] = err;
|
||||
} else {
|
||||
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);
|
||||
|
||||
const currentDraft = draftByAction[action] || [];
|
||||
|
||||
// If empty, keep editing.
|
||||
if (currentDraft.length === 0) return;
|
||||
|
||||
// If invalid, keep the draft visible but mark for reset on next keypress.
|
||||
if (errorByAction[action]?.message) return;
|
||||
|
||||
// Commit as soon as the draft is valid, regardless of how many keys are still held.
|
||||
// On macOS, keyup events for non-Meta keys are swallowed when Cmd is held, so
|
||||
// pressedKeysRef.size may never reach 0 — committing on any keyup fixes this.
|
||||
stopEditing(action);
|
||||
};
|
||||
|
||||
const renderValue = (action) => {
|
||||
const binding = keyBindings[action];
|
||||
|
||||
if (binding?.displayValue) {
|
||||
// Use the same pills style rendering as regular keybindings
|
||||
if (typeof binding.displayValue === 'string') {
|
||||
return <span className="shortcut-text">{renderDisplayValue(binding.displayValue, os)}</span>;
|
||||
}
|
||||
|
||||
// displayValue can be an object with OS-specific values
|
||||
const rawDisplayText = binding.displayValue[os] || binding.displayValue.mac || binding.displayValue.windows;
|
||||
return <span className="shortcut-text">{renderDisplayValue(rawDisplayText, os)}</span>;
|
||||
}
|
||||
|
||||
const isRecording = recordingAction === action;
|
||||
const arr = isRecording
|
||||
? draftByAction[action]
|
||||
: fromKeysString(getCurrentRowKeysString(action));
|
||||
|
||||
if (isRecording) {
|
||||
const textParts = (arr || []).map((key) => formatSingleKeyForDisplay(key, os));
|
||||
return (
|
||||
<span className="shortcut-text">
|
||||
{textParts.join(' ')}
|
||||
<span className="editing-caret" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return renderKeycaps(arr || [], os);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<div className="section-header">Keybindings</div>
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Command</th>
|
||||
<th>Keybinding</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keyMapping ? (
|
||||
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>
|
||||
<div className="section-header">
|
||||
<span>Keybindings</span>
|
||||
|
||||
<div className="section-actions">
|
||||
<ToggleSwitch
|
||||
isOn={keybindingsEnabled}
|
||||
handleToggle={handleToggleKeybindings}
|
||||
size="2xs"
|
||||
activeColor={theme.primary.solid}
|
||||
/>
|
||||
<div className="section-actions-divider" />
|
||||
<button
|
||||
onClick={resetAllKeybindings}
|
||||
className="reset-btn"
|
||||
data-testid="reset-all-keybindings-btn"
|
||||
>
|
||||
Reset Default
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`tables-container ${!keybindingsEnabled ? 'tables-disabled' : ''}`}>
|
||||
{groupedKeyMappings.length > 0 ? (
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Command</td>
|
||||
<td>Keybinding</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan="2">No key bindings available</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupedKeyMappings.map((section, sectionIndex) => (
|
||||
<React.Fragment key={section.heading}>
|
||||
<tr className="section-heading-row">
|
||||
<td colSpan={2}>{section.heading}</td>
|
||||
</tr>
|
||||
{section.rows.map((row, rowIndex) => {
|
||||
const { action } = row;
|
||||
const isEditing = editingAction === action;
|
||||
const isHovered = hoveredAction === action;
|
||||
const isDirty = isRowDirty(action);
|
||||
const isReadOnly = row?.readOnly === true;
|
||||
|
||||
const isSuccess = successAction === action;
|
||||
const hasError = Boolean(errorByAction[action]?.message);
|
||||
const errorMessage = errorByAction[action]?.message;
|
||||
|
||||
const showPencil = isHovered && !isDirty && !isEditing && !isReadOnly && !isSuccess && !hasError;
|
||||
const showRefresh = isDirty && !isEditing && !isSuccess && !hasError;
|
||||
const showLock = isHovered && isReadOnly && !isEditing && !isSuccess;
|
||||
const inputId = `kb-input-${action}`;
|
||||
|
||||
const isLastInSection = rowIndex === section.rows.length - 1
|
||||
&& sectionIndex < groupedKeyMappings.length - 1;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={action}
|
||||
className={`${isSuccess ? 'row-success' : ''} ${isEditing ? 'row-editing' : ''} ${isLastInSection ? 'section-last-row' : ''}`}
|
||||
data-testid={`keybinding-row-${action}`}
|
||||
onMouseEnter={() => setHoveredAction(action)}
|
||||
onMouseLeave={() =>
|
||||
setHoveredAction((prev) => (prev === action ? null : prev))}
|
||||
onClick={() => !isReadOnly && !isEditing && startEditing(action)}
|
||||
>
|
||||
<td data-testid={`keybinding-name-${action}`}>{row.name}</td>
|
||||
|
||||
<td>
|
||||
<div className="keybinding-row">
|
||||
<div className="shortcut-wrap">
|
||||
<div
|
||||
id={inputId}
|
||||
ref={(el) => {
|
||||
if (el) inputRefs.current[action] = el;
|
||||
}}
|
||||
data-testid={`keybinding-input-${action}`}
|
||||
className={`shortcut-input ${hasError && errorByAction[action]?.code !== ERROR.EMPTY ? 'shortcut-input--error' : ''} ${isEditing ? 'shortcut-input--editing' : ''
|
||||
} ${isReadOnly ? 'shortcut-input--readonly' : ''}`}
|
||||
tabIndex={isReadOnly ? -1 : 0}
|
||||
role="textbox"
|
||||
aria-readonly={!isEditing || isReadOnly}
|
||||
aria-disabled={isReadOnly}
|
||||
onKeyDown={(e) => (isReadOnly ? null : handleKeyDown(action, e))}
|
||||
onKeyUp={(e) => (isReadOnly ? null : handleKeyUp(action, e))}
|
||||
onBlur={() => {
|
||||
if (isEditing && hasError) {
|
||||
cancelEditing(action);
|
||||
} else if (isEditing) {
|
||||
stopEditing(action);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{renderValue(action)}
|
||||
{hasError && errorByAction[action]?.code !== ERROR.EMPTY && (
|
||||
<span className="input-error-icon">
|
||||
<IconAlertCircle size={14} stroke={1.5} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing && hasError && errorByAction[action]?.code !== ERROR.EMPTY && (
|
||||
<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>
|
||||
|
||||
{!isEditing && (
|
||||
<div className="button-placeholder">
|
||||
{isSuccess && !hasError && (
|
||||
<span className="success-icon">
|
||||
<IconCircleCheck size={14} stroke={1.5} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{showRefresh && !hasError && (
|
||||
<button
|
||||
className="action-btn"
|
||||
data-testid={`keybinding-reset-${action}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); resetRowToDefault(action);
|
||||
}}
|
||||
title="Reset to default"
|
||||
>
|
||||
<IconReload size={14} stroke={1.5} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showPencil && (
|
||||
<span
|
||||
className="pencil-icon"
|
||||
data-testid={`keybinding-edit-${action}`}
|
||||
title="Customize keys"
|
||||
>
|
||||
<IconPencil size={14} stroke={1.5} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{showLock && (
|
||||
<button
|
||||
type="button"
|
||||
className="edit-btn"
|
||||
data-testid={`keybinding-locked-${action}`}
|
||||
title="Reserved shortcut"
|
||||
>
|
||||
<IconLock size={14} stroke={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state">No key bindings available</div>
|
||||
)}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import styled from 'styled-components';
|
||||
const StyledWrapper = styled.div`
|
||||
div.tabs {
|
||||
padding: 12px;
|
||||
min-width: 160px;
|
||||
min-width: 180px;
|
||||
|
||||
div.tab {
|
||||
display: flex;
|
||||
@@ -38,7 +38,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
section.tab-panel {
|
||||
min-height: 70vh;
|
||||
max-height: calc(100% - 55px);
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
padding: 12px;
|
||||
|
||||
@@ -103,16 +103,6 @@ export default class QueryEditor extends React.Component {
|
||||
'Alt-Space': () => editor.showHint({ completeSingle: true, container: this._node }),
|
||||
'Shift-Space': () => editor.showHint({ completeSingle: true, container: this._node }),
|
||||
'Shift-Alt-Space': () => editor.showHint({ completeSingle: true, container: this._node }),
|
||||
'Cmd-Enter': () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Ctrl-Enter': () => {
|
||||
if (this.props.onRun) {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Shift-Ctrl-C': () => {
|
||||
if (this.props.onCopyQuery) {
|
||||
this.props.onCopyQuery();
|
||||
@@ -134,18 +124,6 @@ export default class QueryEditor extends React.Component {
|
||||
this.props.onMergeQuery();
|
||||
}
|
||||
},
|
||||
'Cmd-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
return false;
|
||||
}
|
||||
},
|
||||
'Ctrl-S': () => {
|
||||
if (this.props.onSave) {
|
||||
this.props.onSave();
|
||||
return false;
|
||||
}
|
||||
},
|
||||
'Cmd-F': 'findPersistent',
|
||||
'Ctrl-F': 'findPersistent'
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import WsQueryUrl from 'components/RequestPane/WsQueryUrl';
|
||||
import WSRequestPane from 'components/RequestPane/WSRequestPane';
|
||||
import WSResponsePane from 'components/ResponsePane/WsResponsePane';
|
||||
import { useTabPaneBoundaries } from 'hooks/useTabPaneBoundaries/index';
|
||||
import useKeybinding from 'hooks/useKeybinding';
|
||||
import { ScopedPersistenceProvider } from 'hooks/usePersistedState/PersistedScopeProvider';
|
||||
import ResponseExample from 'components/ResponseExample';
|
||||
import WorkspaceOverview from 'components/WorkspaceHome/WorkspaceOverview';
|
||||
@@ -59,6 +60,12 @@ const RequestTabPanel = () => {
|
||||
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
|
||||
const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen);
|
||||
|
||||
const isRequestTab = focusedTab && ['request', 'grpc-request', 'ws-request', 'graphql-request'].includes(focusedTab.type);
|
||||
useKeybinding('sendRequest', () => {
|
||||
handleRun();
|
||||
return false;
|
||||
}, { enabled: !!isRequestTab, deps: [isRequestTab] });
|
||||
|
||||
// Use ref to avoid stale closure in event handlers
|
||||
const isVerticalLayoutRef = useRef(isVerticalLayout);
|
||||
useEffect(() => {
|
||||
@@ -304,7 +311,6 @@ const RequestTabPanel = () => {
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const renderQueryUrl = () => {
|
||||
if (isGrpcRequest) {
|
||||
return <GrpcQueryUrl item={item} collection={collection} handleRun={handleRun} />;
|
||||
|
||||
@@ -580,14 +580,14 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
)}
|
||||
{/* Runner - always visible */}
|
||||
<ToolHint text="Runner" toolhintId="RunnerToolhintId" place="bottom">
|
||||
<ActionIcon onClick={handleRun} aria-label="Runner" size="sm">
|
||||
<ActionIcon onClick={handleRun} aria-label="Runner" size="sm" data-testid="runner">
|
||||
<IconRun size={16} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
</ToolHint>
|
||||
{/* JS Sandbox Mode - always visible */}
|
||||
<JsSandboxMode collection={collection} />
|
||||
{/* Overflow menu */}
|
||||
<MenuDropdown items={overflowMenuItems} placement="bottom-end">
|
||||
<MenuDropdown items={overflowMenuItems} placement="bottom-end" data-testid="more-actions">
|
||||
<ActionIcon label="More actions" size="sm" style={{ border: `1px solid ${theme.border.border1}`, borderRadius: theme.border.radius.base, width: 24, marginRight: 4, marginLeft: 4 }}>
|
||||
<IconDots size={16} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useCallback, useState, useRef, Fragment, useMemo, useEffect } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import { saveRequest, saveCollectionRoot, saveFolderRoot, saveEnvironment, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { saveRequest, saveCollectionRoot, saveFolderRoot, saveEnvironment, saveCollectionSettings, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import useKeybinding from 'hooks/useKeybinding';
|
||||
import { deleteRequestDraft, deleteCollectionDraft, deleteFolderDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
|
||||
import { clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
@@ -167,6 +168,74 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
const globalEnvironmentDraft = useSelector((state) => state.globalEnvironments.globalEnvironmentDraft);
|
||||
const hasGlobalEnvironmentDraft = tab.type === 'global-environment-settings' && globalEnvironmentDraft;
|
||||
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const isActive = tab.uid === activeTabUid;
|
||||
|
||||
// Close tab shortcut — draft-aware, only active for the focused tab
|
||||
useKeybinding('closeTab', () => {
|
||||
if (tab.type === 'request' || tab.type === 'grpc-request' || tab.type === 'ws-request' || tab.type === 'graphql-request') {
|
||||
if (hasChanges) {
|
||||
setShowConfirmClose(true);
|
||||
} else {
|
||||
if (item?.type === 'ws-request') {
|
||||
closeWsConnection(item.uid);
|
||||
}
|
||||
dispatch(closeTabs({ tabUids: [tab.uid] }));
|
||||
}
|
||||
} else if (tab.type === 'collection-settings') {
|
||||
if (collection?.draft) {
|
||||
setShowConfirmCollectionClose(true);
|
||||
} else {
|
||||
dispatch(closeTabs({ tabUids: [tab.uid] }));
|
||||
}
|
||||
} else if (tab.type === 'folder-settings') {
|
||||
if (folder?.draft) {
|
||||
setShowConfirmFolderClose(true);
|
||||
} else {
|
||||
dispatch(closeTabs({ tabUids: [tab.uid] }));
|
||||
}
|
||||
} else if (tab.type === 'environment-settings') {
|
||||
if (collection?.environmentsDraft) {
|
||||
setShowConfirmEnvironmentClose(true);
|
||||
} else {
|
||||
dispatch(closeTabs({ tabUids: [tab.uid] }));
|
||||
}
|
||||
} else if (tab.type === 'global-environment-settings') {
|
||||
if (globalEnvironmentDraft) {
|
||||
setShowConfirmGlobalEnvironmentClose(true);
|
||||
} else {
|
||||
dispatch(closeTabs({ tabUids: [tab.uid] }));
|
||||
}
|
||||
} else {
|
||||
dispatch(closeTabs({ tabUids: [tab.uid] }));
|
||||
}
|
||||
return false;
|
||||
}, { enabled: isActive, deps: [isActive, tab, hasChanges, item, collection, folder, globalEnvironmentDraft] });
|
||||
|
||||
// Save shortcut — tab-type-aware, only active for the focused tab
|
||||
useKeybinding('save', () => {
|
||||
if (tab.type === 'environment-settings') {
|
||||
if (collection?.environmentsDraft) {
|
||||
const { environmentUid, variables } = collection.environmentsDraft;
|
||||
dispatch(saveEnvironment(variables, environmentUid, collection.uid));
|
||||
}
|
||||
} else if (tab.type === 'global-environment-settings') {
|
||||
if (globalEnvironmentDraft) {
|
||||
const { environmentUid, variables } = globalEnvironmentDraft;
|
||||
dispatch(saveGlobalEnvironment({ variables, environmentUid }));
|
||||
}
|
||||
} else if (tab.type === 'folder-settings') {
|
||||
if (folder) {
|
||||
dispatch(saveFolderRoot(collection.uid, folder.uid));
|
||||
}
|
||||
} else if (tab.type === 'collection-settings') {
|
||||
dispatch(saveCollectionSettings(collection.uid));
|
||||
} else if (item && item.uid) {
|
||||
dispatch(saveRequest(tab.uid, tab.collectionUid));
|
||||
}
|
||||
return false;
|
||||
}, { enabled: isActive, deps: [isActive, tab, item, collection, folder, globalEnvironmentDraft] });
|
||||
|
||||
const handleCloseEnvironmentSettings = (event) => {
|
||||
if (!collection?.environmentsDraft) {
|
||||
return handleCloseClick(event);
|
||||
|
||||
@@ -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">
|
||||
<Button type="submit" data-testid="clone-item-button">
|
||||
Clone
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -221,7 +221,7 @@ const RenameCollectionItem = ({ collectionUid, item, onClose }) => {
|
||||
<Button type="button" color="secondary" variant="ghost" onClick={onClose} className="mr-2">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Button type="submit" data-testid="rename-item-button">
|
||||
Rename
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@ import { handleCollectionItemDrop, sendRequest, showInFolder, pasteItem, saveReq
|
||||
import { toggleCollectionItem, addResponseExample } from 'providers/ReduxStore/slices/collections';
|
||||
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
|
||||
import { uuid } from 'utils/common';
|
||||
import { copyRequest } from 'providers/ReduxStore/slices/app';
|
||||
import { copyRequest, setFocusedSidebarPath } from 'providers/ReduxStore/slices/app';
|
||||
import NewRequest from 'components/Sidebar/NewRequest';
|
||||
import NewFolder from 'components/Sidebar/NewFolder';
|
||||
import RenameCollectionItem from './RenameCollectionItem';
|
||||
@@ -39,7 +39,6 @@ import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from '
|
||||
import { getDefaultRequestPaneTab } from 'utils/collections';
|
||||
import toast from 'react-hot-toast';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { getKeyBindingsForActionAllOS } from 'providers/Hotkeys/keyMappings';
|
||||
import NetworkError from 'components/ResponsePane/NetworkError/index';
|
||||
import CollectionItemInfo from './CollectionItemInfo/index';
|
||||
import CollectionItemIcon from './CollectionItemIcon';
|
||||
@@ -57,6 +56,7 @@ import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal';
|
||||
import ActionIcon from 'ui/ActionIcon';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext';
|
||||
import useKeybinding from 'hooks/useKeybinding';
|
||||
|
||||
const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) => {
|
||||
const { dropdownContainerRef } = useSidebarAccordion();
|
||||
@@ -93,6 +93,27 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
// Check if request has examples (only for HTTP requests)
|
||||
const hasExamples = isItemARequest(item) && item.type === 'http-request' && item.examples && item.examples.length > 0;
|
||||
|
||||
// Sidebar shortcuts — only active when this sidebar item has keyboard focus
|
||||
useKeybinding('cloneItem', () => {
|
||||
setCloneItemModalOpen(true);
|
||||
return false;
|
||||
}, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] });
|
||||
|
||||
useKeybinding('copyItem', () => {
|
||||
handleCopyItem();
|
||||
return false;
|
||||
}, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] });
|
||||
|
||||
useKeybinding('pasteItem', () => {
|
||||
handlePasteItem();
|
||||
return false;
|
||||
}, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] });
|
||||
|
||||
useKeybinding('renameItem', () => {
|
||||
setRenameItemModalOpen(true);
|
||||
return false;
|
||||
}, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] });
|
||||
|
||||
const [dropType, setDropType] = useState(null); // 'adjacent' or 'inside'
|
||||
|
||||
const [{ isDragging }, drag, dragPreview] = useDrag({
|
||||
@@ -544,12 +565,9 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
};
|
||||
|
||||
const handlePasteItem = () => {
|
||||
// Determine target folder: if item is a folder, paste into it; otherwise paste into parent folder
|
||||
let targetFolderUid = item.uid;
|
||||
if (!isFolder) {
|
||||
const parentFolder = findParentItemInCollection(collection, item.uid);
|
||||
targetFolderUid = parentFolder ? parentFolder.uid : null;
|
||||
}
|
||||
// Paste as sibling: find the parent folder so the pasted item appears next to the focused item
|
||||
const parentFolder = findParentItemInCollection(collection, item.uid);
|
||||
const targetFolderUid = parentFolder ? parentFolder.uid : null;
|
||||
|
||||
dispatch(pasteItem(collectionUid, targetFolderUid))
|
||||
.then(() => {
|
||||
@@ -560,38 +578,15 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
});
|
||||
};
|
||||
|
||||
// Keyboard shortcuts handler
|
||||
const handleKeyDown = (e) => {
|
||||
// Detect Mac by checking both metaKey and platform
|
||||
const isMac = navigator.userAgent?.includes('Mac') || navigator.platform?.startsWith('Mac');
|
||||
const isModifierPressed = isMac ? e.metaKey : e.ctrlKey;
|
||||
|
||||
const [macRenameKey, winRenameKey] = getKeyBindingsForActionAllOS('renameItem');
|
||||
const renameKey = isMac ? macRenameKey : winRenameKey;
|
||||
|
||||
// Only trigger rename if no modifier keys are pressed (allow Cmd+Enter for run request)
|
||||
const hasModifier = e.metaKey || e.ctrlKey || e.shiftKey || e.altKey;
|
||||
if (e.key.toLowerCase() === renameKey && !hasModifier) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setRenameItemModalOpen(true);
|
||||
} else if (isModifierPressed && e.key.toLowerCase() === 'c') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCopyItem();
|
||||
} else if (isModifierPressed && e.key.toLowerCase() === 'v') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handlePasteItem();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsKeyboardFocused(true);
|
||||
// For folders, set the folder path; for requests, set empty string (no terminal)
|
||||
dispatch(setFocusedSidebarPath(isFolder ? item.pathname : ''));
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsKeyboardFocused(false);
|
||||
dispatch(setFocusedSidebarPath(null));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -634,7 +629,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
drag(drop(node));
|
||||
}}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onContextMenu={handleContextMenu}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { toggleCollection, collapseFullCollection } from 'providers/ReduxStore/s
|
||||
import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop, pasteItem, showInFolder, saveCollectionSecurityConfig } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import { setFocusedSidebarPath } from 'providers/ReduxStore/slices/app';
|
||||
import toast from 'react-hot-toast';
|
||||
import NewRequest from 'components/Sidebar/NewRequest';
|
||||
import NewFolder from 'components/Sidebar/NewFolder';
|
||||
@@ -52,6 +53,7 @@ import StatusBadge from 'ui/StatusBadge';
|
||||
import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features';
|
||||
import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext';
|
||||
import { createEmptyStateMenuItems } from 'utils/collections/emptyStateRequest';
|
||||
import useKeybinding from 'hooks/useKeybinding';
|
||||
|
||||
// Delay before showing empty collection state (ms)
|
||||
// This prevents flicker from race condition between loading state and item batch updates
|
||||
@@ -202,25 +204,30 @@ const Collection = ({ collection, searchText }) => {
|
||||
});
|
||||
};
|
||||
|
||||
// Keyboard shortcuts handler for collection
|
||||
const handleKeyDown = (e) => {
|
||||
// Detect Mac by checking both metaKey and platform
|
||||
const isMac = navigator.userAgent?.includes('Mac') || navigator.platform?.startsWith('Mac');
|
||||
const isModifierPressed = isMac ? e.metaKey : e.ctrlKey;
|
||||
// Sidebar shortcuts — only active when this collection has keyboard focus
|
||||
useKeybinding('cloneItem', () => {
|
||||
setShowCloneCollectionModalOpen(true);
|
||||
return false;
|
||||
}, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] });
|
||||
|
||||
if (isModifierPressed && e.key.toLowerCase() === 'v') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handlePasteItem();
|
||||
}
|
||||
};
|
||||
useKeybinding('renameItem', () => {
|
||||
setShowRenameCollectionModal(true);
|
||||
return false;
|
||||
}, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] });
|
||||
|
||||
useKeybinding('pasteItem', () => {
|
||||
handlePasteItem();
|
||||
return false;
|
||||
}, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] });
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsKeyboardFocused(true);
|
||||
dispatch(setFocusedSidebarPath(collection.pathname));
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsKeyboardFocused(false);
|
||||
dispatch(setFocusedSidebarPath(null));
|
||||
};
|
||||
|
||||
const isCollectionItem = (itemType) => {
|
||||
@@ -468,7 +475,6 @@ const Collection = ({ collection, searchText }) => {
|
||||
drag(drop(node));
|
||||
}}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
data-testid="sidebar-collection-row"
|
||||
|
||||
@@ -104,6 +104,7 @@ const NewFolder = ({ collectionUid, item, onClose }) => {
|
||||
formik.setFieldValue('folderName', e.target.value);
|
||||
!isEditing && formik.setFieldValue('directoryName', sanitizeName(e.target.value));
|
||||
}}
|
||||
data-testid="new-folder-input"
|
||||
value={formik.values.folderName || ''}
|
||||
/>
|
||||
{formik.touched.folderName && formik.errors.folderName ? (
|
||||
|
||||
@@ -605,7 +605,7 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
|
||||
<Button type="button" color="secondary" variant="ghost" onClick={onClose} className="mr-2">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Button type="submit" data-testid="create-new-request-button">
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
|
||||
import { importCollection, openCollection, importCollectionFromZip, newHttpRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { sortCollections } from 'providers/ReduxStore/slices/collections/index';
|
||||
import { savePreferences, setIsCreatingCollection } from 'providers/ReduxStore/slices/app';
|
||||
import { savePreferences, setIsCreatingCollection, toggleSidebarSearch } from 'providers/ReduxStore/slices/app';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
import { isScratchCollection, flattenItems, isItemTransientRequest } from 'utils/collections';
|
||||
import { sanitizeName } from 'utils/common/regex';
|
||||
@@ -36,10 +36,11 @@ import WelcomeModal from 'components/WelcomeModal';
|
||||
import Collections from 'components/Sidebar/Collections';
|
||||
import SidebarSection from 'components/Sidebar/SidebarSection';
|
||||
import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal';
|
||||
import useKeybinding from 'hooks/useKeybinding';
|
||||
|
||||
const CollectionsSection = () => {
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
const showSearch = useSelector((state) => state.app.showSidebarSearch);
|
||||
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
@@ -58,6 +59,12 @@ const CollectionsSection = () => {
|
||||
const [showCloneGitModal, setShowCloneGitModal] = useState(false);
|
||||
const [gitRepositoryUrl, setGitRepositoryUrl] = useState(null);
|
||||
|
||||
// Import collection shortcut
|
||||
useKeybinding('importCollection', () => {
|
||||
setImportCollectionModalOpen(true);
|
||||
return false;
|
||||
});
|
||||
|
||||
// Default to true (don't show modal) so that:
|
||||
// 1. Existing users who upgrade (no hasSeenWelcomeModal in their prefs) don't see it
|
||||
// 2. The modal doesn't flash before preferences are loaded from the electron process
|
||||
@@ -120,7 +127,7 @@ const CollectionsSection = () => {
|
||||
};
|
||||
|
||||
const handleToggleSearch = () => {
|
||||
setShowSearch((prev) => !prev);
|
||||
dispatch(toggleSidebarSearch());
|
||||
};
|
||||
|
||||
const handleSortCollections = () => {
|
||||
|
||||
@@ -59,8 +59,6 @@ class SingleLineEditor extends Component {
|
||||
readOnly: this.props.readOnly,
|
||||
extraKeys: {
|
||||
'Enter': runHandler,
|
||||
'Ctrl-Enter': runHandler,
|
||||
'Cmd-Enter': runHandler,
|
||||
'Alt-Enter': () => {
|
||||
if (this.props.allowNewlines) {
|
||||
this.editor.setValue(this.editor.getValue() + '\n');
|
||||
@@ -69,9 +67,6 @@ class SingleLineEditor extends Component {
|
||||
this.props.onRun();
|
||||
}
|
||||
},
|
||||
'Shift-Enter': runHandler,
|
||||
'Cmd-S': saveHandler,
|
||||
'Ctrl-S': saveHandler,
|
||||
'Cmd-F': noopHandler,
|
||||
'Ctrl-F': noopHandler,
|
||||
// Tabbing disabled to make tabindex work
|
||||
@@ -108,6 +103,12 @@ class SingleLineEditor extends Component {
|
||||
this._updateNewlineMarkers();
|
||||
}
|
||||
setupLinkAware(this.editor);
|
||||
|
||||
// Add mousetrap class so Mousetrap captures shortcuts even when CodeMirror is focused
|
||||
const cmInput = this.editor.getInputField();
|
||||
if (cmInput) {
|
||||
cmInput.classList.add('mousetrap');
|
||||
}
|
||||
}
|
||||
|
||||
/** Enable or disable masking the rendered content of the editor */
|
||||
|
||||
42
packages/bruno-app/src/hooks/useKeybinding/index.js
Normal file
42
packages/bruno-app/src/hooks/useKeybinding/index.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import Mousetrap from 'mousetrap';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { getKeyBindingsForActionAllOS } from 'providers/Hotkeys/keyMappings';
|
||||
|
||||
/**
|
||||
* Hook for binding a customizable keyboard shortcut to a handler.
|
||||
* Reads merged keybindings (defaults + user overrides) and binds via Mousetrap.
|
||||
*
|
||||
* Use this for COMPONENT-LEVEL shortcuts (e.g. clone, rename) where the handler
|
||||
* lives inside the component, not in HotkeysProvider.
|
||||
*
|
||||
* @param {string} action - The action ID from KEY_BINDING_SECTIONS (e.g. 'cloneItem')
|
||||
* @param {Function} handler - Callback to run when the shortcut is pressed. Should return false to stop bubbling.
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.enabled=true] - Whether the binding is active. Pass false to skip binding.
|
||||
* @param {Array} [options.deps=[]] - Additional dependencies that should trigger rebinding.
|
||||
*/
|
||||
function useKeybinding(action, handler, { enabled = true, deps = [] } = {}) {
|
||||
const handlerRef = useRef(handler);
|
||||
handlerRef.current = handler;
|
||||
|
||||
const userKeyBindings = useSelector((state) => state.app.preferences?.keyBindings);
|
||||
const keybindingsEnabled = useSelector((state) => state.app.preferences?.keybindingsEnabled !== false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !keybindingsEnabled) return;
|
||||
|
||||
const combos = getKeyBindingsForActionAllOS(action, userKeyBindings);
|
||||
if (!combos) return;
|
||||
|
||||
Mousetrap.bind(combos, (e) => {
|
||||
return handlerRef.current(e);
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind(combos);
|
||||
};
|
||||
}, [action, enabled, keybindingsEnabled, userKeyBindings, ...deps]);
|
||||
}
|
||||
|
||||
export default useKeybinding;
|
||||
@@ -7,13 +7,14 @@ import { useDispatch } from 'react-redux';
|
||||
import { findCollectionByUid, flattenItems, isItemARequest, hasRequestChanges, findEnvironmentInCollection } from 'utils/collections';
|
||||
import { pluralizeWord } from 'utils/common';
|
||||
import { completeQuitFlow } from 'providers/ReduxStore/slices/app';
|
||||
import { saveMultipleRequests, saveMultipleCollections, saveMultipleFolders, saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { saveMultipleRequests, saveMultipleCollections, saveMultipleFolders, saveEnvironment, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { saveGlobalEnvironment, clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { deleteRequestDraft, deleteCollectionDraft, deleteFolderDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
|
||||
import { IconAlertTriangle } from '@tabler/icons';
|
||||
import Modal from 'components/Modal';
|
||||
import Button from 'ui/Button';
|
||||
|
||||
const SaveRequestsModal = ({ onClose }) => {
|
||||
const SaveRequestsModal = ({ onClose, forceCloseTabs = false, tabUidsToClose = [] }) => {
|
||||
const MAX_UNSAVED_ITEMS_TO_SHOW = 5;
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
@@ -26,7 +27,8 @@ const SaveRequestsModal = ({ onClose }) => {
|
||||
const collectionDrafts = [];
|
||||
const folderDrafts = [];
|
||||
const environmentDrafts = [];
|
||||
const tabsByCollection = groupBy(tabs, (t) => t.collectionUid);
|
||||
const relevantTabs = forceCloseTabs ? tabs.filter((t) => tabUidsToClose.includes(t.uid)) : tabs;
|
||||
const tabsByCollection = groupBy(relevantTabs, (t) => t.collectionUid);
|
||||
|
||||
Object.keys(tabsByCollection).forEach((collectionUid) => {
|
||||
const collection = findCollectionByUid(collections, collectionUid);
|
||||
@@ -95,18 +97,48 @@ const SaveRequestsModal = ({ onClose }) => {
|
||||
}
|
||||
|
||||
return [...collectionDrafts, ...folderDrafts, ...environmentDrafts, ...requestDrafts];
|
||||
}, [collections, tabs, globalEnvironments, globalEnvironmentDraft]);
|
||||
}, [collections, tabs, globalEnvironments, globalEnvironmentDraft, forceCloseTabs, tabUidsToClose]);
|
||||
|
||||
const totalDraftsCount = allDrafts.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (totalDraftsCount === 0) {
|
||||
return dispatch(completeQuitFlow());
|
||||
if (forceCloseTabs) {
|
||||
dispatch(closeTabs({ tabUids: tabUidsToClose }));
|
||||
onClose();
|
||||
} else {
|
||||
dispatch(completeQuitFlow());
|
||||
}
|
||||
}
|
||||
}, [totalDraftsCount, dispatch]);
|
||||
}, [totalDraftsCount, dispatch, forceCloseTabs, tabUidsToClose]);
|
||||
|
||||
const closeWithoutSave = () => {
|
||||
dispatch(completeQuitFlow());
|
||||
if (forceCloseTabs) {
|
||||
// Discard all draft states before closing tabs
|
||||
allDrafts.forEach((draft) => {
|
||||
switch (draft.type) {
|
||||
case 'collection':
|
||||
dispatch(deleteCollectionDraft({ collectionUid: draft.collectionUid }));
|
||||
break;
|
||||
case 'folder':
|
||||
dispatch(deleteFolderDraft({ collectionUid: draft.collectionUid, folderUid: draft.folderUid }));
|
||||
break;
|
||||
case 'collection-environment':
|
||||
dispatch(clearEnvironmentsDraft({ collectionUid: draft.collectionUid }));
|
||||
break;
|
||||
case 'global-environment':
|
||||
dispatch(clearGlobalEnvironmentDraft());
|
||||
break;
|
||||
default:
|
||||
// Request drafts
|
||||
dispatch(deleteRequestDraft({ collectionUid: draft.collectionUid, itemUid: draft.uid }));
|
||||
break;
|
||||
}
|
||||
});
|
||||
dispatch(closeTabs({ tabUids: tabUidsToClose }));
|
||||
} else {
|
||||
dispatch(completeQuitFlow());
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -144,7 +176,11 @@ const SaveRequestsModal = ({ onClose }) => {
|
||||
await dispatch(saveGlobalEnvironment({ variables: draft.variables, environmentUid: draft.environmentUid }));
|
||||
}
|
||||
|
||||
dispatch(completeQuitFlow());
|
||||
if (forceCloseTabs) {
|
||||
dispatch(closeTabs({ tabUids: tabUidsToClose }));
|
||||
} else {
|
||||
dispatch(completeQuitFlow());
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error saving drafts:', error);
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import find from 'lodash/find';
|
||||
import Mousetrap from 'mousetrap';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import NetworkError from 'components/ResponsePane/NetworkError';
|
||||
import NewRequest from 'components/Sidebar/NewRequest';
|
||||
import GlobalSearchModal from 'components/GlobalSearchModal';
|
||||
import {
|
||||
sendRequest,
|
||||
saveRequest,
|
||||
saveCollectionRoot,
|
||||
saveFolderRoot,
|
||||
saveCollectionSettings,
|
||||
closeTabs
|
||||
} from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { findCollectionByUid, findItemInCollection } from 'utils/collections';
|
||||
import { addTab, reorderTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import SaveRequestsModal from 'providers/App/ConfirmAppClose/SaveRequestsModal';
|
||||
import filter from 'lodash/filter';
|
||||
import each from 'lodash/each';
|
||||
import { findCollectionByUid, findItemInCollection, flattenItems, isItemARequest, hasRequestChanges, findEnvironmentInCollection } from 'utils/collections';
|
||||
import { addTab, focusTab, reorderTabs, reopenLastClosedTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { saveMultipleRequests, saveMultipleCollections, saveMultipleFolders, saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { toggleSidebarCollapse, toggleSidebarSearch, savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal';
|
||||
import { getKeyBindingsForActionAllOS } from './keyMappings';
|
||||
|
||||
export const HotkeysContext = React.createContext();
|
||||
@@ -26,8 +21,13 @@ export const HotkeysProvider = (props) => {
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const userKeyBindings = useSelector((state) => state.app.preferences?.keyBindings);
|
||||
const keybindingsEnabled = useSelector((state) => state.app.preferences?.keybindingsEnabled !== false);
|
||||
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
|
||||
const [showGlobalSearchModal, setShowGlobalSearchModal] = useState(false);
|
||||
const [showSaveRequestsModal, setShowSaveRequestsModal] = useState(false);
|
||||
const [tabUidsToClose, setTabUidsToClose] = useState([]);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const getCurrentCollection = () => {
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
@@ -38,81 +38,33 @@ export const HotkeysProvider = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
// save hotkey
|
||||
// Get tabs scoped to the active tab's collection
|
||||
const getCollectionTabs = () => {
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (!activeTab) return [];
|
||||
return tabs.filter((t) => t.collectionUid === activeTab.collectionUid);
|
||||
};
|
||||
|
||||
// Helper: get Mousetrap combos for an action, merged with user overrides
|
||||
const getCombos = (action) => getKeyBindingsForActionAllOS(action, userKeyBindings);
|
||||
|
||||
// Helper: bind a shortcut only if keybindings are enabled
|
||||
const bindAction = (action, handler) => {
|
||||
if (!keybindingsEnabled) return;
|
||||
const combos = getCombos(action);
|
||||
if (!combos) return;
|
||||
Mousetrap.bind(combos, handler);
|
||||
};
|
||||
|
||||
const unbindAction = (action) => {
|
||||
const combos = getCombos(action);
|
||||
if (!combos) return;
|
||||
Mousetrap.unbind(combos);
|
||||
};
|
||||
|
||||
// edit environments
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('save')], (e) => {
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (activeTab) {
|
||||
if (activeTab.type === 'environment-settings' || activeTab.type === 'global-environment-settings') {
|
||||
window.dispatchEvent(new CustomEvent('environment-save'));
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = findCollectionByUid(collections, activeTab.collectionUid);
|
||||
if (collection) {
|
||||
const item = findItemInCollection(collection, activeTab.uid);
|
||||
if (item && item.uid) {
|
||||
if (activeTab.type === 'folder-settings') {
|
||||
dispatch(saveFolderRoot(collection.uid, item.uid));
|
||||
} else {
|
||||
dispatch(saveRequest(activeTab.uid, activeTab.collectionUid));
|
||||
}
|
||||
} else if (activeTab.type === 'collection-settings') {
|
||||
dispatch(saveCollectionSettings(collection.uid));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false; // this stops the event bubbling
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('save')]);
|
||||
};
|
||||
}, [activeTabUid, tabs, saveRequest, collections, dispatch]);
|
||||
|
||||
// send request (ctrl/cmd + enter)
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('sendRequest')], (e) => {
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (activeTab) {
|
||||
const collection = findCollectionByUid(collections, activeTab.collectionUid);
|
||||
|
||||
if (collection) {
|
||||
const item = findItemInCollection(collection, activeTab.uid);
|
||||
if (item) {
|
||||
if (item.type === 'grpc-request') {
|
||||
const request = item.draft ? item.draft.request : item.request;
|
||||
if (!request.url) {
|
||||
toast.error('Please enter a valid gRPC server URL');
|
||||
return;
|
||||
}
|
||||
if (!request.method) {
|
||||
toast.error('Please select a gRPC method');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(sendRequest(item, collection.uid)).catch((err) =>
|
||||
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
|
||||
duration: 5000
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false; // this stops the event bubbling
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('sendRequest')]);
|
||||
};
|
||||
}, [activeTabUid, tabs, saveRequest, collections]);
|
||||
|
||||
// edit environments (ctrl/cmd + e)
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('editEnvironment')], (e) => {
|
||||
bindAction('editEnvironment', (e) => {
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (activeTab) {
|
||||
const collection = findCollectionByUid(collections, activeTab.collectionUid);
|
||||
@@ -132,13 +84,13 @@ export const HotkeysProvider = (props) => {
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('editEnvironment')]);
|
||||
unbindAction('editEnvironment');
|
||||
};
|
||||
}, [activeTabUid, tabs, collections, dispatch]);
|
||||
}, [activeTabUid, tabs, collections, dispatch, userKeyBindings, keybindingsEnabled]);
|
||||
|
||||
// new request (ctrl/cmd + b)
|
||||
// new request
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('newRequest')], (e) => {
|
||||
bindAction('newRequest', (e) => {
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (activeTab) {
|
||||
const collection = findCollectionByUid(collections, activeTab.collectionUid);
|
||||
@@ -152,90 +104,96 @@ export const HotkeysProvider = (props) => {
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('newRequest')]);
|
||||
unbindAction('newRequest');
|
||||
};
|
||||
}, [activeTabUid, tabs, collections, setShowNewRequestModal]);
|
||||
}, [activeTabUid, tabs, collections, setShowNewRequestModal, userKeyBindings, keybindingsEnabled]);
|
||||
|
||||
// global search (ctrl/cmd + k)
|
||||
// global search
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('globalSearch')], (e) => {
|
||||
bindAction('globalSearch', (e) => {
|
||||
setShowGlobalSearchModal(true);
|
||||
|
||||
return false; // stop bubbling
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('globalSearch')]);
|
||||
unbindAction('globalSearch');
|
||||
};
|
||||
}, []);
|
||||
}, [userKeyBindings, keybindingsEnabled]);
|
||||
|
||||
// close tab hotkey
|
||||
// Switch to the previous tab (active-collection-tabs-only)
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('closeTab')], (e) => {
|
||||
if (activeTabUid) {
|
||||
dispatch(
|
||||
closeTabs({
|
||||
tabUids: [activeTabUid]
|
||||
})
|
||||
);
|
||||
bindAction('switchToPreviousTab', (e) => {
|
||||
const collectionTabs = getCollectionTabs();
|
||||
if (collectionTabs.length === 0) return false;
|
||||
const currentIndex = collectionTabs.findIndex((t) => t.uid === activeTabUid);
|
||||
const prevIndex = (currentIndex - 1 + collectionTabs.length) % collectionTabs.length;
|
||||
dispatch(focusTab({ uid: collectionTabs[prevIndex].uid }));
|
||||
return false;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unbindAction('switchToPreviousTab');
|
||||
};
|
||||
}, [activeTabUid, tabs, dispatch, userKeyBindings, keybindingsEnabled]);
|
||||
|
||||
// Switch to the next tab (active-collection-tabs-only)
|
||||
useEffect(() => {
|
||||
bindAction('switchToNextTab', (e) => {
|
||||
const collectionTabs = getCollectionTabs();
|
||||
if (collectionTabs.length === 0) return false;
|
||||
const currentIndex = collectionTabs.findIndex((t) => t.uid === activeTabUid);
|
||||
const nextIndex = (currentIndex + 1) % collectionTabs.length;
|
||||
dispatch(focusTab({ uid: collectionTabs[nextIndex].uid }));
|
||||
return false;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unbindAction('switchToNextTab');
|
||||
};
|
||||
}, [activeTabUid, tabs, dispatch, userKeyBindings, keybindingsEnabled]);
|
||||
|
||||
// Switch to tab at position (Cmd+1 through Cmd+8) and last tab (Cmd+9) — collection-scoped
|
||||
useEffect(() => {
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
bindAction(`switchToTab${i}`, (e) => {
|
||||
const collectionTabs = getCollectionTabs();
|
||||
const tab = collectionTabs[i - 1];
|
||||
if (tab) {
|
||||
dispatch(focusTab({ uid: tab.uid }));
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
bindAction('switchToLastTab', (e) => {
|
||||
const collectionTabs = getCollectionTabs();
|
||||
const lastTab = collectionTabs[collectionTabs.length - 1];
|
||||
if (lastTab) {
|
||||
dispatch(focusTab({ uid: lastTab.uid }));
|
||||
}
|
||||
|
||||
return false; // this stops the event bubbling
|
||||
return false;
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('closeTab')]);
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
unbindAction(`switchToTab${i}`);
|
||||
}
|
||||
unbindAction('switchToLastTab');
|
||||
};
|
||||
}, [activeTabUid]);
|
||||
|
||||
// Switch to the previous tab
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('switchToPreviousTab')], (e) => {
|
||||
dispatch(
|
||||
switchTab({
|
||||
direction: 'pageup'
|
||||
})
|
||||
);
|
||||
|
||||
return false; // this stops the event bubbling
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('switchToPreviousTab')]);
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
// Switch to the next tab
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('switchToNextTab')], (e) => {
|
||||
dispatch(
|
||||
switchTab({
|
||||
direction: 'pagedown'
|
||||
})
|
||||
);
|
||||
|
||||
return false; // this stops the event bubbling
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('switchToNextTab')]);
|
||||
};
|
||||
}, [dispatch]);
|
||||
}, [activeTabUid, tabs, dispatch, userKeyBindings, keybindingsEnabled]);
|
||||
|
||||
// Close all tabs
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('closeAllTabs')], (e) => {
|
||||
bindAction('closeAllTabs', (e) => {
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (activeTab) {
|
||||
const collection = findCollectionByUid(collections, activeTab.collectionUid);
|
||||
|
||||
if (collection) {
|
||||
const tabUids = tabs.filter((tab) => tab.collectionUid === collection.uid).map((tab) => tab.uid);
|
||||
dispatch(
|
||||
closeTabs({
|
||||
tabUids: tabUids
|
||||
})
|
||||
);
|
||||
setTabUidsToClose(tabUids);
|
||||
setShowSaveRequestsModal(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,45 +201,278 @@ export const HotkeysProvider = (props) => {
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('closeAllTabs')]);
|
||||
unbindAction('closeAllTabs');
|
||||
};
|
||||
}, [activeTabUid, tabs, collections, dispatch]);
|
||||
}, [activeTabUid, tabs, collections, userKeyBindings, keybindingsEnabled]);
|
||||
|
||||
// Collapse sidebar (ctrl/cmd + \)
|
||||
// Reopen last closed tab (active-collection-tabs-only)
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('collapseSidebar')], (e) => {
|
||||
bindAction('reopenLastClosedTab', (e) => {
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (activeTab) {
|
||||
dispatch(reopenLastClosedTab({ collectionUid: activeTab.collectionUid }));
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unbindAction('reopenLastClosedTab');
|
||||
};
|
||||
}, [activeTabUid, tabs, dispatch, userKeyBindings, keybindingsEnabled]);
|
||||
|
||||
// Save all tabs (active-collection-tabs-only)
|
||||
useEffect(() => {
|
||||
bindAction('saveAllTabs', (e) => {
|
||||
const collection = getCurrentCollection();
|
||||
if (!collection) return false;
|
||||
const collectionUid = collection.uid;
|
||||
|
||||
const requestDrafts = [];
|
||||
const collectionDrafts = [];
|
||||
const folderDrafts = [];
|
||||
|
||||
// Collection settings draft
|
||||
if (collection.draft) {
|
||||
collectionDrafts.push({ collectionUid });
|
||||
}
|
||||
|
||||
// Environment draft
|
||||
if (collection.environmentsDraft) {
|
||||
const { environmentUid, variables } = collection.environmentsDraft;
|
||||
const environment = findEnvironmentInCollection(collection, environmentUid);
|
||||
if (environment && variables) {
|
||||
dispatch(saveEnvironment(variables, environmentUid, collectionUid));
|
||||
}
|
||||
}
|
||||
|
||||
// Request and folder drafts
|
||||
const items = flattenItems(collection.items);
|
||||
const requests = filter(items, (item) => isItemARequest(item) && hasRequestChanges(item));
|
||||
each(requests, (draft) => {
|
||||
requestDrafts.push({ ...draft, collectionUid });
|
||||
});
|
||||
|
||||
const folders = filter(items, (item) => item.type === 'folder' && item.draft);
|
||||
each(folders, (folder) => {
|
||||
folderDrafts.push({ folderUid: folder.uid, collectionUid });
|
||||
});
|
||||
|
||||
if (collectionDrafts.length > 0) {
|
||||
dispatch(saveMultipleCollections(collectionDrafts));
|
||||
}
|
||||
if (folderDrafts.length > 0) {
|
||||
dispatch(saveMultipleFolders(folderDrafts));
|
||||
}
|
||||
if (requestDrafts.length > 0) {
|
||||
dispatch(saveMultipleRequests(requestDrafts));
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unbindAction('saveAllTabs');
|
||||
};
|
||||
}, [activeTabUid, tabs, collections, dispatch, userKeyBindings, keybindingsEnabled]);
|
||||
|
||||
// Collapse sidebar
|
||||
useEffect(() => {
|
||||
bindAction('collapseSidebar', (e) => {
|
||||
dispatch(toggleSidebarCollapse());
|
||||
return false;
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('collapseSidebar')]);
|
||||
unbindAction('collapseSidebar');
|
||||
};
|
||||
}, [dispatch]);
|
||||
}, [dispatch, userKeyBindings, keybindingsEnabled]);
|
||||
|
||||
// Move tab left
|
||||
// Sidebar search
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('moveTabLeft')], (e) => {
|
||||
dispatch(reorderTabs({ direction: -1 }));
|
||||
return false; // this stops the event bubbling
|
||||
bindAction('sidebarSearch', (e) => {
|
||||
dispatch(toggleSidebarSearch());
|
||||
return false;
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('moveTabLeft')]);
|
||||
unbindAction('sidebarSearch');
|
||||
};
|
||||
}, [dispatch]);
|
||||
}, [dispatch, userKeyBindings, keybindingsEnabled]);
|
||||
|
||||
// Open terminal — context-aware:
|
||||
// focusedSidebarPath: null = no sidebar focus, '' = request focused (no-op), '/path' = folder/collection
|
||||
const focusedSidebarPath = useSelector((state) => state.app.focusedSidebarPath);
|
||||
const activeWorkspace = useSelector((state) => {
|
||||
const { workspaces, activeWorkspaceUid } = state.workspaces;
|
||||
return workspaces?.find((w) => w.uid === activeWorkspaceUid);
|
||||
});
|
||||
|
||||
// Move tab right
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('moveTabRight')], (e) => {
|
||||
dispatch(reorderTabs({ direction: 1 }));
|
||||
return false; // this stops the event bubbling
|
||||
bindAction('openTerminal', (e) => {
|
||||
// 1. Sidebar focus takes priority
|
||||
if (focusedSidebarPath) {
|
||||
openDevtoolsAndSwitchToTerminal(dispatch, focusedSidebarPath);
|
||||
return false;
|
||||
}
|
||||
if (focusedSidebarPath === '') {
|
||||
// Request focused in sidebar → no-op
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. No sidebar focus → check active tab type
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (activeTab) {
|
||||
if (activeTab.type === 'collection-settings' && activeTab.collectionUid) {
|
||||
const collection = findCollectionByUid(collections, activeTab.collectionUid);
|
||||
if (collection?.pathname) {
|
||||
openDevtoolsAndSwitchToTerminal(dispatch, collection.pathname);
|
||||
return false;
|
||||
}
|
||||
} else if (activeTab.type === 'folder-settings' && activeTab.collectionUid && activeTab.uid) {
|
||||
const collection = findCollectionByUid(collections, activeTab.collectionUid);
|
||||
if (collection) {
|
||||
const item = findItemInCollection(collection, activeTab.uid);
|
||||
if (item?.pathname) {
|
||||
openDevtoolsAndSwitchToTerminal(dispatch, item.pathname);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Default to workspace root
|
||||
if (activeWorkspace?.pathname) {
|
||||
openDevtoolsAndSwitchToTerminal(dispatch, activeWorkspace.pathname);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('moveTabRight')]);
|
||||
unbindAction('openTerminal');
|
||||
};
|
||||
}, [dispatch]);
|
||||
}, [focusedSidebarPath, activeTabUid, tabs, collections, activeWorkspace, dispatch, userKeyBindings, keybindingsEnabled]);
|
||||
|
||||
// Move tab left (active-collection-tabs-only)
|
||||
useEffect(() => {
|
||||
bindAction('moveTabLeft', (e) => {
|
||||
const collectionTabs = getCollectionTabs();
|
||||
const currentIndex = collectionTabs.findIndex((t) => t.uid === activeTabUid);
|
||||
if (currentIndex <= 0) return false; // already at leftmost position in collection
|
||||
dispatch(reorderTabs({ sourceUid: activeTabUid, targetUid: collectionTabs[currentIndex - 1].uid }));
|
||||
return false;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unbindAction('moveTabLeft');
|
||||
};
|
||||
}, [activeTabUid, tabs, dispatch, userKeyBindings, keybindingsEnabled]);
|
||||
|
||||
// Move tab right (active-collection-tabs-only)
|
||||
useEffect(() => {
|
||||
bindAction('moveTabRight', (e) => {
|
||||
const collectionTabs = getCollectionTabs();
|
||||
const currentIndex = collectionTabs.findIndex((t) => t.uid === activeTabUid);
|
||||
if (currentIndex < 0 || currentIndex >= collectionTabs.length - 1) return false; // already at rightmost
|
||||
dispatch(reorderTabs({ sourceUid: activeTabUid, targetUid: collectionTabs[currentIndex + 1].uid }));
|
||||
return false;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unbindAction('moveTabRight');
|
||||
};
|
||||
}, [activeTabUid, tabs, dispatch, userKeyBindings, keybindingsEnabled]);
|
||||
|
||||
// Open preferences
|
||||
useEffect(() => {
|
||||
bindAction('openPreferences', (e) => {
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const collectionUid = activeTab?.collectionUid || activeWorkspace?.scratchCollectionUid;
|
||||
|
||||
dispatch(
|
||||
addTab({
|
||||
type: 'preferences',
|
||||
uid: collectionUid ? `${collectionUid}-preferences` : 'preferences',
|
||||
collectionUid
|
||||
})
|
||||
);
|
||||
return false;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unbindAction('openPreferences');
|
||||
};
|
||||
}, [activeTabUid, tabs, activeWorkspace, dispatch, userKeyBindings, keybindingsEnabled]);
|
||||
|
||||
// Change layout orientation
|
||||
useEffect(() => {
|
||||
bindAction('changeLayout', (e) => {
|
||||
const orientation = preferences?.layout?.responsePaneOrientation || 'horizontal';
|
||||
const newOrientation = orientation === 'horizontal' ? 'vertical' : 'horizontal';
|
||||
dispatch(savePreferences({
|
||||
...preferences,
|
||||
layout: {
|
||||
...preferences?.layout,
|
||||
responsePaneOrientation: newOrientation
|
||||
}
|
||||
}));
|
||||
return false;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unbindAction('changeLayout');
|
||||
};
|
||||
}, [preferences, dispatch, userKeyBindings, keybindingsEnabled]);
|
||||
|
||||
// Zoom in
|
||||
useEffect(() => {
|
||||
bindAction('zoomIn', () => {
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer?.invoke('renderer:zoom-in');
|
||||
return false;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unbindAction('zoomIn');
|
||||
};
|
||||
}, [userKeyBindings, keybindingsEnabled]);
|
||||
|
||||
// Zoom out
|
||||
useEffect(() => {
|
||||
bindAction('zoomOut', () => {
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer?.invoke('renderer:zoom-out');
|
||||
return false;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unbindAction('zoomOut');
|
||||
};
|
||||
}, [userKeyBindings, keybindingsEnabled]);
|
||||
|
||||
// Reset zoom
|
||||
useEffect(() => {
|
||||
bindAction('resetZoom', () => {
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer?.invoke('renderer:reset-zoom');
|
||||
return false;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unbindAction('resetZoom');
|
||||
};
|
||||
}, [userKeyBindings, keybindingsEnabled]);
|
||||
|
||||
// Close Bruno
|
||||
useEffect(() => {
|
||||
bindAction('closeBruno', () => {
|
||||
window.close();
|
||||
return false;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unbindAction('closeBruno');
|
||||
};
|
||||
}, [userKeyBindings, keybindingsEnabled]);
|
||||
|
||||
const currentCollection = getCurrentCollection();
|
||||
|
||||
@@ -293,6 +484,16 @@ export const HotkeysProvider = (props) => {
|
||||
{showGlobalSearchModal && (
|
||||
<GlobalSearchModal isOpen={showGlobalSearchModal} onClose={() => setShowGlobalSearchModal(false)} />
|
||||
)}
|
||||
{showSaveRequestsModal && (
|
||||
<SaveRequestsModal
|
||||
forceCloseTabs={true}
|
||||
tabUidsToClose={tabUidsToClose}
|
||||
onClose={() => {
|
||||
setShowSaveRequestsModal(false);
|
||||
setTabUidsToClose([]);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div>{props.children}</div>
|
||||
</HotkeysContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,76 +1,173 @@
|
||||
const KeyMapping = {
|
||||
save: { mac: 'command+s', windows: 'ctrl+s', name: 'Save' },
|
||||
sendRequest: { mac: 'command+enter', windows: 'ctrl+enter', name: 'Send Request' },
|
||||
editEnvironment: { mac: 'command+e', windows: 'ctrl+e', name: 'Edit Environment' },
|
||||
newRequest: { mac: 'command+b', windows: 'ctrl+b', name: 'New Request' },
|
||||
globalSearch: { mac: 'command+k', windows: 'ctrl+k', name: 'Global Search' },
|
||||
closeTab: { mac: 'command+w', windows: 'ctrl+w', name: 'Close Tab' },
|
||||
openPreferences: { mac: 'command+,', windows: 'ctrl+,', name: 'Open Preferences' },
|
||||
closeBruno: {
|
||||
mac: 'command+Q',
|
||||
windows: 'ctrl+shift+q',
|
||||
name: 'Close Bruno'
|
||||
export const KEY_BINDING_SECTIONS = [
|
||||
{
|
||||
heading: 'Tabs',
|
||||
bindings: {
|
||||
closeTab: { mac: 'command+bind+w', windows: 'ctrl+bind+w', name: 'Close Tab' }, // D
|
||||
closeAllTabs: { mac: 'command+bind+shift+bind+w', windows: 'ctrl+bind+shift+bind+w', name: 'Close All Tabs' }, // D
|
||||
save: { mac: 'command+bind+s', windows: 'ctrl+bind+s', name: 'Save' }, // D
|
||||
saveAllTabs: { mac: 'command+bind+shift+bind+s', windows: 'ctrl+bind+shift+bind+s', name: 'Save All Tabs' }, // D
|
||||
reopenLastClosedTab: { mac: 'command+bind+shift+bind+t', windows: 'ctrl+bind+shift+bind+t', name: 'Reopen Last Closed Tab' }, // D
|
||||
switchToTabAtPosition: { mac: 'command+bind+1+bind+command+bind+8', windows: 'ctrl+bind+1+bind+ctrl+bind+8', name: 'Switch to Tab at Position', readOnly: true, displayValue: { mac: 'command+bind+1 - command+bind+8', windows: 'ctrl+bind+1 - ctrl+bind+8' } }, // D
|
||||
switchToLastTab: { mac: 'command+bind+9', windows: 'ctrl+bind+9', name: 'Switch to Last Tab' }, // D
|
||||
switchToPreviousTab: { mac: 'shift+bind+command+bind+[', windows: 'shift+bind+ctrl+bind+[', name: 'Switch to Previous Tab' }, // D
|
||||
switchToNextTab: { mac: 'shift+bind+command+bind+]', windows: 'shift+bind+ctrl+bind+]', name: 'Switch to Next Tab' },
|
||||
moveTabLeft: { mac: 'command+bind+[', windows: 'ctrl+bind+[', name: 'Move Tab Left' }, // D
|
||||
moveTabRight: { mac: 'command+bind+]', windows: 'ctrl+bind+]', name: 'Move Tab Right' }, // D
|
||||
switchToTab1: { mac: 'command+bind+1', windows: 'ctrl+bind+1', name: 'Switch to Tab at Position', readOnly: true, hidden: true },
|
||||
switchToTab2: { mac: 'command+bind+2', windows: 'ctrl+bind+2', name: 'Switch to Tab at Position', readOnly: true, hidden: true },
|
||||
switchToTab3: { mac: 'command+bind+3', windows: 'ctrl+bind+3', name: 'Switch to Tab at Position', readOnly: true, hidden: true },
|
||||
switchToTab4: { mac: 'command+bind+4', windows: 'ctrl+bind+4', name: 'Switch to Tab at Position', readOnly: true, hidden: true },
|
||||
switchToTab5: { mac: 'command+bind+5', windows: 'ctrl+bind+5', name: 'Switch to Tab at Position', readOnly: true, hidden: true },
|
||||
switchToTab6: { mac: 'command+bind+6', windows: 'ctrl+bind+6', name: 'Switch to Tab at Position', readOnly: true, hidden: true },
|
||||
switchToTab7: { mac: 'command+bind+7', windows: 'ctrl+bind+7', name: 'Switch to Tab at Position', readOnly: true, hidden: true },
|
||||
switchToTab8: { mac: 'command+bind+8', windows: 'ctrl+bind+8', name: 'Switch to Tab at Position', readOnly: true, hidden: true }
|
||||
}
|
||||
},
|
||||
switchToPreviousTab: {
|
||||
mac: 'command+pageup',
|
||||
windows: 'ctrl+pageup',
|
||||
name: 'Switch to Previous Tab'
|
||||
{
|
||||
heading: 'Sidebar',
|
||||
bindings: {
|
||||
sidebarSearch: { mac: 'command+bind+f', windows: 'ctrl+bind+f', name: 'Search Sidebar' }, // D
|
||||
copyItem: { mac: 'command+bind+c', windows: 'ctrl+bind+c', name: 'Copy Item' }, // D
|
||||
pasteItem: { mac: 'command+bind+v', windows: 'ctrl+bind+v', name: 'Paste Item' }, // D
|
||||
cloneItem: { mac: 'command+bind+d', windows: 'ctrl+bind+d', name: 'Clone Item' }, // D
|
||||
renameItem: { mac: 'command+bind+r', windows: 'ctrl+bind+r', name: 'Rename Item' }, // D
|
||||
collapseSidebar: { mac: 'command+bind+\\', windows: 'ctrl+bind+\\', name: 'Collapse Sidebar' } // D
|
||||
}
|
||||
},
|
||||
switchToNextTab: {
|
||||
mac: 'command+pagedown',
|
||||
windows: 'ctrl+pagedown',
|
||||
name: 'Switch to Next Tab'
|
||||
{
|
||||
heading: 'Requests',
|
||||
bindings: {
|
||||
sendRequest: { mac: 'command+bind+enter', windows: 'ctrl+bind+enter', name: 'Send Request' }, // D
|
||||
changeLayout: { mac: 'command+bind+j', windows: 'ctrl+bind+j', name: 'Change Orientation' } // D
|
||||
}
|
||||
},
|
||||
moveTabLeft: {
|
||||
mac: 'command+shift+pageup',
|
||||
windows: 'ctrl+shift+pageup',
|
||||
name: 'Move Tab Left'
|
||||
{
|
||||
heading: 'Collections & Environment',
|
||||
bindings: {
|
||||
importCollection: { mac: 'command+bind+o', windows: 'ctrl+bind+o', name: 'Import Collection' }, // D
|
||||
editEnvironment: { mac: 'command+bind+e', windows: 'ctrl+bind+e', name: 'Edit Environment' }, // D
|
||||
newRequest: { mac: 'command+bind+n', windows: 'ctrl+bind+n', name: 'New Request' } // D
|
||||
}
|
||||
},
|
||||
moveTabRight: {
|
||||
mac: 'command+shift+pagedown',
|
||||
windows: 'ctrl+shift+pagedown',
|
||||
name: 'Move Tab Right'
|
||||
{
|
||||
heading: 'Search',
|
||||
bindings: {
|
||||
globalSearch: { mac: 'command+bind+k', windows: 'ctrl+bind+k', name: 'Global Search' } // D
|
||||
}
|
||||
},
|
||||
closeAllTabs: { mac: 'command+shift+w', windows: 'ctrl+shift+w', name: 'Close All Tabs' },
|
||||
collapseSidebar: { mac: 'command+\\', windows: 'ctrl+\\', name: 'Collapse Sidebar' },
|
||||
zoomIn: { mac: 'command+=', windows: 'ctrl+=', name: 'Zoom In' },
|
||||
zoomOut: { mac: 'command+-', windows: 'ctrl+-', name: 'Zoom Out' },
|
||||
resetZoom: { mac: 'command+0', windows: 'ctrl+0', name: 'Reset Zoom' },
|
||||
renameItem: { mac: 'enter', windows: 'f2', name: 'Rename Collection Item' }
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the key bindings for a specific operating system.
|
||||
*
|
||||
* @param {string} os - The operating system (e.g., 'mac', 'windows').
|
||||
* @returns {Object} An object containing the key bindings for the specified OS.
|
||||
*/
|
||||
export const getKeyBindingsForOS = (os) => {
|
||||
const keyBindings = {};
|
||||
for (const [action, { name, ...keys }] of Object.entries(KeyMapping)) {
|
||||
if (keys[os]) {
|
||||
keyBindings[action] = {
|
||||
keys: keys[os],
|
||||
name
|
||||
};
|
||||
{
|
||||
heading: 'View',
|
||||
bindings: {
|
||||
zoomIn: { mac: 'command+bind+=', windows: 'ctrl+bind+=', name: 'Zoom In' },
|
||||
zoomOut: { mac: 'command+bind+-', windows: 'ctrl+bind+-', name: 'Zoom Out' },
|
||||
resetZoom: { mac: 'command+bind+0', windows: 'ctrl+bind+0', name: 'Reset Zoom' }
|
||||
}
|
||||
},
|
||||
{
|
||||
heading: 'Developer Tool',
|
||||
bindings: {
|
||||
openTerminal: { mac: 'command+bind+t', windows: 'ctrl+bind+t', name: 'Open in Terminal' } // D
|
||||
}
|
||||
},
|
||||
{
|
||||
heading: 'Others',
|
||||
bindings: {
|
||||
openPreferences: { mac: 'command+bind+,', windows: 'ctrl+bind+,', name: 'Open Preferences' }, // D
|
||||
closeBruno: { mac: 'command+bind+q', windows: 'ctrl+bind+shift+bind+q', name: 'Close Bruno' } // D
|
||||
}
|
||||
}
|
||||
return keyBindings;
|
||||
];
|
||||
|
||||
/**
|
||||
* Converts keybindings from storage format (+bind+) to Mousetrap format (+)
|
||||
* Storage format uses +bind+ as separator to avoid conflicts with the actual + key
|
||||
* Mousetrap uses + as the separator
|
||||
* Also converts arrow key names to Mousetrap format
|
||||
*
|
||||
* @param {string} keysStr - Keybinding string in storage format
|
||||
* @returns {string|null} Keybinding string in Mousetrap format, or null if empty
|
||||
*/
|
||||
export const toMousetrapCombo = (keysStr) => {
|
||||
if (!keysStr) return null;
|
||||
|
||||
// Split by +bind+ separator
|
||||
const parts = keysStr.split('+bind+').filter(Boolean);
|
||||
|
||||
// Convert arrow key names from browser format to Mousetrap format
|
||||
const converted = parts.map((part) => {
|
||||
const lower = part.toLowerCase();
|
||||
if (lower === 'arrowup') return 'up';
|
||||
if (lower === 'arrowdown') return 'down';
|
||||
if (lower === 'arrowleft') return 'left';
|
||||
if (lower === 'arrowright') return 'right';
|
||||
return lower;
|
||||
});
|
||||
|
||||
return converted.join('+');
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the key bindings for a specific action across all operating systems.
|
||||
* Merges default key bindings with user preferences.
|
||||
* Uses KEY_BINDING_SECTIONS as the source of truth for defaults.
|
||||
*
|
||||
* @param {Object} userKeyBindings - User's custom key bindings from preferences (preferences.keyBindings)
|
||||
* @returns {Object} Merged key bindings object
|
||||
*/
|
||||
export const getMergedKeyBindings = (userKeyBindings) => {
|
||||
const merged = {};
|
||||
|
||||
// Start with defaults from KEY_BINDING_SECTIONS (source of truth)
|
||||
for (const section of KEY_BINDING_SECTIONS) {
|
||||
for (const [action, binding] of Object.entries(section.bindings || {})) {
|
||||
merged[action] = { ...binding };
|
||||
}
|
||||
}
|
||||
|
||||
// Override with user preferences
|
||||
if (userKeyBindings && typeof userKeyBindings === 'object') {
|
||||
for (const [action, binding] of Object.entries(userKeyBindings)) {
|
||||
if (merged[action]) {
|
||||
merged[action] = {
|
||||
...merged[action],
|
||||
...binding
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the Mousetrap-compatible key combos for a specific action across all operating systems.
|
||||
* Reads from merged defaults + user preferences.
|
||||
*
|
||||
* @param {string} action - The action for which to retrieve key bindings.
|
||||
* @returns {Object|null} An object containing the key bindings for macOS, Windows, or null if the action is not found.
|
||||
* @param {Object} [userKeyBindings] - User's custom key bindings from preferences
|
||||
* @returns {string[]|null} Array of Mousetrap-compatible combo strings, or null if the action is not found.
|
||||
*/
|
||||
export const getKeyBindingsForActionAllOS = (action) => {
|
||||
const actionBindings = KeyMapping[action];
|
||||
export const getKeyBindingsForActionAllOS = (action, userKeyBindings) => {
|
||||
const merged = getMergedKeyBindings(userKeyBindings);
|
||||
const actionBindings = merged[action];
|
||||
|
||||
if (!actionBindings) {
|
||||
console.warn(`Action "${action}" not found in KeyMapping.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return [actionBindings.mac, actionBindings.windows];
|
||||
const combos = [];
|
||||
|
||||
// Detect current OS and use appropriate bindings only
|
||||
const isMac = navigator.platform.toLowerCase().includes('mac');
|
||||
|
||||
if (isMac && actionBindings.mac) {
|
||||
const combo = toMousetrapCombo(actionBindings.mac);
|
||||
if (combo) combos.push(combo);
|
||||
} else if (!isMac && actionBindings.windows) {
|
||||
const combo = toMousetrapCombo(actionBindings.windows);
|
||||
if (combo) combos.push(combo);
|
||||
}
|
||||
|
||||
// console.log('[keyMappings] getKeyBindingsForActionAllOS:', action, '->', combos);
|
||||
return combos.length > 0 ? combos : null;
|
||||
};
|
||||
|
||||
@@ -8,6 +8,8 @@ const initialState = {
|
||||
idbConnectionReady: false,
|
||||
leftSidebarWidth: 250,
|
||||
sidebarCollapsed: false,
|
||||
showSidebarSearch: false,
|
||||
focusedSidebarPath: null,
|
||||
screenWidth: 500,
|
||||
showHomePage: false,
|
||||
showApiSpecPage: false,
|
||||
@@ -139,6 +141,12 @@ export const appSlice = createSlice({
|
||||
toggleSidebarCollapse: (state) => {
|
||||
state.sidebarCollapsed = !state.sidebarCollapsed;
|
||||
},
|
||||
toggleSidebarSearch: (state) => {
|
||||
state.showSidebarSearch = !state.showSidebarSearch;
|
||||
},
|
||||
setFocusedSidebarPath: (state, action) => {
|
||||
state.focusedSidebarPath = action.payload;
|
||||
},
|
||||
updateGitOperationProgress: (state, action) => {
|
||||
const { uid, data } = action.payload;
|
||||
if (!state.gitOperationProgress[uid]) {
|
||||
@@ -204,6 +212,8 @@ export const {
|
||||
updateSystemProxyVariables,
|
||||
updateGenerateCode,
|
||||
toggleSidebarCollapse,
|
||||
toggleSidebarSearch,
|
||||
setFocusedSidebarPath,
|
||||
updateGitOperationProgress,
|
||||
removeGitOperationProgress,
|
||||
setGitVersion,
|
||||
|
||||
@@ -5,9 +5,12 @@ import last from 'lodash/last';
|
||||
|
||||
// todo: errors should be tracked in each slice and displayed as toasts
|
||||
|
||||
const MAX_RECENTLY_CLOSED_TABS = 50;
|
||||
|
||||
const initialState = {
|
||||
tabs: [],
|
||||
activeTabUid: null
|
||||
activeTabUid: null,
|
||||
recentlyClosedTabs: [] // LIFO stack of closed tabs, grouped by collection
|
||||
};
|
||||
|
||||
const tabTypeAlreadyExists = (tabs, collectionUid, type) => {
|
||||
@@ -265,6 +268,19 @@ export const tabsSlice = createSlice({
|
||||
const tabUids = action.payload.tabUids || [];
|
||||
|
||||
const nonClosableTypes = ['workspaceOverview', 'workspaceEnvironments'];
|
||||
|
||||
// Push closed tabs onto the recently closed stack (LIFO)
|
||||
const closedTabs = state.tabs.filter((t) =>
|
||||
tabUids.includes(t.uid) && !nonClosableTypes.includes(t.type)
|
||||
);
|
||||
if (closedTabs.length > 0) {
|
||||
state.recentlyClosedTabs.push(...closedTabs);
|
||||
// Trim to max size
|
||||
if (state.recentlyClosedTabs.length > MAX_RECENTLY_CLOSED_TABS) {
|
||||
state.recentlyClosedTabs = state.recentlyClosedTabs.slice(-MAX_RECENTLY_CLOSED_TABS);
|
||||
}
|
||||
}
|
||||
|
||||
state.tabs = filter(state.tabs, (t) =>
|
||||
!tabUids.includes(t.uid) || nonClosableTypes.includes(t.type)
|
||||
);
|
||||
@@ -338,6 +354,21 @@ export const tabsSlice = createSlice({
|
||||
tabs.splice(targetIdx, 0, moved);
|
||||
|
||||
state.tabs = tabs;
|
||||
},
|
||||
reopenLastClosedTab: (state, action) => {
|
||||
const { collectionUid } = action.payload;
|
||||
// Find the last closed tab for this collection (LIFO)
|
||||
const index = state.recentlyClosedTabs.findLastIndex((t) => t.collectionUid === collectionUid);
|
||||
if (index === -1) return;
|
||||
|
||||
const [tab] = state.recentlyClosedTabs.splice(index, 1);
|
||||
|
||||
// Don't reopen if a tab with this uid already exists
|
||||
const alreadyOpen = state.tabs.some((t) => t.uid === tab.uid);
|
||||
if (alreadyOpen) return;
|
||||
|
||||
state.tabs.push(tab);
|
||||
state.activeTabUid = tab.uid;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -364,6 +395,7 @@ export const {
|
||||
closeAllCollectionTabs,
|
||||
makeTabPermanent,
|
||||
reorderTabs,
|
||||
reopenLastClosedTab,
|
||||
updateQueryBuilderOpen,
|
||||
updateQueryBuilderWidth,
|
||||
updateVariablesPaneOpen,
|
||||
|
||||
@@ -25,15 +25,13 @@ const template = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Preferences',
|
||||
accelerator: 'CommandOrControl+,',
|
||||
label: 'Quit',
|
||||
click() {
|
||||
ipcMain.emit('main:open-preferences');
|
||||
ipcMain.emit('main:start-quit-flow');
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ role: 'quit' },
|
||||
{
|
||||
label: 'Force Quit',
|
||||
click() {
|
||||
@@ -65,6 +63,7 @@ const template = [
|
||||
{
|
||||
label: 'Actual Size',
|
||||
accelerator: 'CommandOrControl+0',
|
||||
registerAccelerator: false,
|
||||
click() {
|
||||
ipcMain.emit('menu:reset-zoom');
|
||||
}
|
||||
@@ -72,6 +71,7 @@ const template = [
|
||||
{
|
||||
label: 'Zoom In',
|
||||
accelerator: 'CommandOrControl+Plus',
|
||||
registerAccelerator: false,
|
||||
click() {
|
||||
ipcMain.emit('menu:zoom-in');
|
||||
}
|
||||
@@ -79,6 +79,7 @@ const template = [
|
||||
{
|
||||
label: 'Zoom Out',
|
||||
accelerator: 'CommandOrControl+-',
|
||||
registerAccelerator: false,
|
||||
click() {
|
||||
ipcMain.emit('menu:zoom-out');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import { closeAllCollections, createCollection } from '../../utils/page';
|
||||
|
||||
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control';
|
||||
|
||||
test.describe('Move tabs', () => {
|
||||
test.afterEach(async ({ page }) => {
|
||||
// cleanup: close all collections
|
||||
@@ -135,7 +137,7 @@ test.describe('Move tabs', () => {
|
||||
// Move the request tab before the folder tab using keyboard shortcut
|
||||
const source = page.locator('.request-tab .tab-label').filter({ hasText: 'test-request' });
|
||||
await source.click();
|
||||
await page.keyboard.press('ControlOrMeta+Shift+PageUp');
|
||||
await page.keyboard.press(`${modifier}+BracketLeft`);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify order of tabs after move
|
||||
@@ -144,7 +146,7 @@ test.describe('Move tabs', () => {
|
||||
|
||||
// Move the request tab back to its original position using keyboard shortcut
|
||||
await source.click();
|
||||
await page.keyboard.press('ControlOrMeta+Shift+PageDown');
|
||||
await page.keyboard.press(`${modifier}+BracketRight`);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify order of tabs after move
|
||||
|
||||
@@ -18,8 +18,9 @@ test.describe('Environment Variables Focus Retention', () => {
|
||||
await page.keyboard.type('apiKey');
|
||||
await expect(nameInput).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Control+s');
|
||||
await expect(page.getByText('Changes saved successfully').last()).toBeVisible({ timeout: 5000 });
|
||||
const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s';
|
||||
await page.keyboard.press(saveShortcut);
|
||||
await expect(page.getByText(/(^Environment changed to)/).last()).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// intentionally wait a few seconds because the focus is lost after a while
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
1624
tests/shortcuts/bound-actions.spec.ts
Normal file
1624
tests/shortcuts/bound-actions.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import { test, expect, Page } from '../../../playwright';
|
||||
import process from 'node:process';
|
||||
import { buildCommonLocators, buildScriptErrorLocators } from './locators';
|
||||
|
||||
type SandboxMode = 'safe' | 'developer';
|
||||
@@ -153,7 +154,8 @@ const createUntitledRequest = async (
|
||||
await tagInput.press('Enter');
|
||||
await page.waitForTimeout(200);
|
||||
await expect(page.locator('.tag-item', { hasText: tag })).toBeVisible();
|
||||
await page.keyboard.press('Meta+s');
|
||||
const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s';
|
||||
await page.keyboard.press(saveShortcut);
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
@@ -261,7 +263,9 @@ const createRequest = async (
|
||||
await locators.actions.collectionItemActions(parentName).click();
|
||||
} else {
|
||||
await locators.sidebar.collection(parentName).hover();
|
||||
await locators.actions.collectionActions(parentName).click();
|
||||
const collectionAction = locators.actions.collectionActions(parentName);
|
||||
await expect(collectionAction).toBeVisible({ timeout: 2000 });
|
||||
await collectionAction.click();
|
||||
}
|
||||
|
||||
await locators.dropdown.item('New Request').click();
|
||||
@@ -486,7 +490,7 @@ const createFolder = async (
|
||||
}
|
||||
|
||||
await locators.dropdown.item('New Folder').click();
|
||||
await page.getByPlaceholder('Folder Name').fill(folderName);
|
||||
await page.getByTestId('new-folder-input').fill(folderName);
|
||||
await locators.modal.button('Create').click();
|
||||
await expect(locators.sidebar.folder(folderName)).toBeVisible();
|
||||
});
|
||||
@@ -1016,7 +1020,8 @@ const deleteAssertion = async (page: Page, rowIndex: number) => {
|
||||
*/
|
||||
const saveRequest = async (page: Page) => {
|
||||
await test.step('Save request', async () => {
|
||||
await page.keyboard.press('Meta+s');
|
||||
const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s';
|
||||
await page.keyboard.press(saveShortcut);
|
||||
await expect(page.getByText('Request saved successfully').last()).toBeVisible({ timeout: 3000 });
|
||||
await page.waitForTimeout(200);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user