feat: keybindings customisation (#7603)

This commit is contained in:
shubh-bruno
2026-03-31 12:39:00 +05:30
committed by GitHub
parent 882b11ca3d
commit f0866be3b3
31 changed files with 3719 additions and 450 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -203,7 +203,7 @@ const CloneCollectionItem = ({ collectionUid, item, onClose }) => {
<Button type="button" color="secondary" variant="ghost" onClick={onClose} className="mr-2">
Cancel
</Button>
<Button type="submit">
<Button type="submit" data-testid="clone-item-button">
Clone
</Button>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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