Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
61c588e
Let an empty key list in keymap.json unbind a command
Oct 8, 2026
decf3e8
Record a shortcut from a + button, adding it beside the command's keys
Oct 8, 2026
202f9bb
Merge branch 'keymap-empty-unbinds' into shortcut-remove-keys
Oct 8, 2026
bbc8476
Remove shortcuts from the Shortcuts page: a key at a time, a section,…
Oct 8, 2026
462b942
Warn when a shortcut you added also runs another command
Oct 8, 2026
a5c6d7c
Make the Shortcuts page easier to read: keycaps, + Add, a changed marker
Oct 8, 2026
ffb4951
Search the Shortcuts page by command name or key, or by pressing a key
Oct 8, 2026
1928620
Merge remote-tracking branch 'origin/master' into keymap-empty-unbinds
Oct 10, 2026
4663a6c
Merge remote-tracking branch 'origin/master' into shortcut-record-exp…
Oct 10, 2026
47531d2
Merge branch 'keymap-empty-unbinds' into shortcut-remove-keys
Oct 10, 2026
dd9cee1
Merge branch 'shortcut-record-explicitly' into shortcut-remove-keys
Oct 10, 2026
4eae2c7
Merge branch 'shortcut-remove-keys' into shortcut-conflicts
Oct 10, 2026
5a6c098
Merge branch 'shortcut-conflicts' into shortcut-visual-pass
Oct 10, 2026
e7e8775
Merge branch 'shortcut-visual-pass' into shortcut-search
Oct 10, 2026
e675fb1
Merge remote-tracking branch 'origin/master' into shortcut-record-exp…
Oct 16, 2026
6d48a47
Merge remote-tracking branch 'origin/master' into shortcut-remove-keys
Oct 16, 2026
263db5d
Merge branch 'shortcut-record-explicitly' into shortcut-remove-keys
Oct 16, 2026
8226cde
Merge branch 'shortcut-remove-keys' into shortcut-conflicts
Oct 16, 2026
aa5ca90
Merge branch 'shortcut-conflicts' into shortcut-visual-pass
Oct 16, 2026
6965501
Merge branch 'shortcut-visual-pass' into shortcut-search
Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
291 changes: 123 additions & 168 deletions app/internal_packages/preferences/lib/tabs/keymaps/command-item.tsx
Original file line number Diff line number Diff line change
@@ -1,218 +1,173 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { Flexbox } from 'mailspring-component-kit';
import { localized } from 'mailspring-exports';
import fs from 'fs';

import { keyAndModifiersForEvent } from './mousetrap-keybinding-helpers';
import {
readUserKeymap,
writeUserKeymap,
withBinding,
withoutBinding,
pressedKeys,
} from './user-keymap';
import { renderKeystrokes } from './keystrokes';
import { ShortcutRecorder } from './shortcut-recorder';

interface CommandKeybindingProps {
bindings: string[];
label: string;
command: string;
/** Whether the user keymap sets this command's keys, so it can be reset to the template's. */
customized: boolean;
/** The keys the user added to this command, beyond its default keys. */
added: string[];
/** For each of this command's keys that another command also runs on, those commands. */
conflicts: { [binding: string]: { command: string; label: string }[] };
onRemoveFrom: (command: string, binding: string) => void;
}
interface CommandKeybindingState {
editing: boolean;
editingBinding?: string;
modifiers?: string[];
keys?: string[];
recording: boolean;
}

export default class CommandKeybinding extends React.Component<
CommandKeybindingProps,
CommandKeybindingState
> {
_mounted = false;
_addButtonRef = React.createRef<HTMLButtonElement>();

constructor(props) {
super(props);

this.state = {
editing: false,
};
this.state = { recording: false };
}

componentDidMount() {
this._mounted = true;
}
_onStartRecording = () => {
this.setState({ recording: true });
};

componentDidUpdate() {
const { modifiers, keys, editing } = this.state;
if (editing) {
const finished = (modifiers.length > 0 && keys.length > 0) || keys.length >= 2;
if (finished) {
(ReactDOM.findDOMNode(this) as HTMLElement).blur();
// Focus goes back to the + button: left on the body, the next key would reach the mail list.
_stopRecording = ({ refocus }: { refocus: boolean }) => {
this.setState({ recording: false }, () => {
if (refocus) {
this._addButtonRef.current.focus();
}
}
}

componentWillUnmount() {
this._mounted = false;
}
});
};

_formatKeystrokes(original: string) {
// macOS shows menu-bar glyphs (⌘⇧D); Windows and Linux spell shortcuts out
// (Ctrl+Shift+D), and their users don't read ^ or ⌥ as modifier keys.
const isMac = process.platform === 'darwin';
const modifiers: [RegExp, string][] = isMac
? [
[/\+(?!$)/gi, ''],
[/command/gi, '⌘'],
[/meta/gi, '⌘'],
[/alt/gi, '⌥'],
[/shift/gi, '⇧'],
[/ctrl/gi, '^'],
[/mod/gi, '⌘'],
]
: [
[/alt/gi, 'Alt'],
[/shift/gi, 'Shift'],
[/ctrl/gi, 'Ctrl'],
[/mod/gi, 'Ctrl'],
];
let clean = original;
for (const [regexp, char] of modifiers) {
clean = clean.replace(regexp, char);
/** Writes this command's keys to the user keymap, or removes its entry when given null. */
_saveBindings(bindings: string[] | null) {
const keymapPath = AppEnv.keymaps.getUserKeymapPath();
const keymap = readUserKeymap(keymapPath);
if (bindings) {
keymap[this.props.command] = bindings;
} else {
delete keymap[this.props.command];
}

if (isMac) {
// ⌘⇧c => ⌘⇧C
if (clean !== original) {
clean = clean.toUpperCase();
}
// backspace => Backspace
if (original.length > 1 && clean === original) {
clean = clean[0].toUpperCase() + clean.slice(1);
}
return clean;
try {
writeUserKeymap(keymapPath, keymap);
} catch (err) {
AppEnv.showErrorDialog(
localized(`Mailspring was unable to modify your keymaps at %@.`, keymapPath) +
' ' +
err.toString()
);
}

// ctrl+shift+d => Ctrl+Shift+D, alt+backspace => Alt+Backspace
return clean
.split('+')
.map((part) => (part.length > 0 ? part[0].toUpperCase() + part.slice(1) : part))
.join('+');
}

_renderKeystrokes = (keystrokes: string, idx: number) => {
const elements = [];
const splitKeystrokes = keystrokes.split(' ');
splitKeystrokes.forEach((keystroke, kidx) => {
elements.push(<span key={kidx}>{this._formatKeystrokes(keystroke)}</span>);
if (kidx < splitKeystrokes.length - 1) {
elements.push(
<span className="then" key={`then${kidx}`}>
{` ${localized('then')} `}
</span>
);
}
});
return (
<span key={`keystrokes-${idx}`} className="shortcut-value">
{elements}
</span>
);
_onRecord = (binding: string) => {
this._saveBindings(withBinding(this.props.bindings, binding));
this._stopRecording({ refocus: true });
};

_onEdit = () => {
this.setState({ editing: true, editingBinding: null, keys: [], modifiers: [] });
AppEnv.keymaps.suspendAllKeymaps();
_onRemove = (binding: string) => {
this._saveBindings(withoutBinding(this.props.bindings, binding, process.platform));
};

_onFinishedEditing = () => {
if (this.state.editingBinding) {
const keymapPath = AppEnv.keymaps.getUserKeymapPath();
let keymaps = {};

try {
const exists = fs.existsSync(keymapPath);
if (exists) {
keymaps = JSON.parse(fs.readFileSync(keymapPath).toString());
}
} catch (err) {
console.error(err);
}

keymaps[this.props.command] = this.state.editingBinding;

try {
fs.writeFileSync(keymapPath, JSON.stringify(keymaps, null, 2));
} catch (err) {
AppEnv.showErrorDialog(
localized(`Mailspring was unable to modify your keymaps at %@.`, keymapPath) +
' ' +
err.toString()
);
}
}

AppEnv.keymaps.resumeAllKeymaps();

setTimeout(() => {
if (!this._mounted) return;
this.setState({ editing: false, editingBinding: null });
}, 100);
_onReset = () => {
this._saveBindings(null);
};

_onKey = (event: React.KeyboardEvent<HTMLElement>) => {
if (!this.state.editing) {
return;
}

event.preventDefault();
event.stopPropagation();

const [eventKey, eventMods] = keyAndModifiersForEvent(event);
if (!eventKey || ['mod', 'meta', 'command', 'ctrl', 'alt', 'shift'].includes(eventKey)) {
return;
}

let { keys, modifiers } = this.state;
keys = keys.concat([eventKey]);
modifiers = [...new Set(modifiers.concat(eventMods))];

let editingBinding = keys.join(' ');
if (modifiers.length > 0) {
editingBinding = [...modifiers, ...keys].join('+');
if (process.platform === 'darwin') {
editingBinding = editingBinding.replace(/meta/g, 'mod');
} else {
editingBinding = editingBinding.replace(/ctrl/g, 'mod');
}
}

this.setState({ keys, modifiers, editingBinding });
// Shown on the command the key was added to, which is the change the user can take back.
_renderConflict = (binding: string) => {
const others = this.props.conflicts[binding];
const names = others.map((o) => o.label).join(', ');
return (
<div key={binding} className="shortcut-conflict">
{renderKeystrokes(binding, 0)} {localized('also runs %@.', names)}{' '}
<a
className="remove-from-others"
onClick={() => others.forEach((o) => this.props.onRemoveFrom(o.command, binding))}
>
{localized('Remove from %@', names)}
</a>{' '}
<a className="remove-here" onClick={() => this._onRemove(binding)}>
{localized('Remove here')}
</a>
</div>
);
};

render() {
const { editing, editingBinding } = this.state;
const bindings = editingBinding ? [editingBinding] : this.props.bindings;
const { recording } = this.state;
const { bindings, customized, added, conflicts } = this.props;

let value: React.ReactChild | React.ReactChild[] = 'None';
let value: React.ReactChild | React.ReactChild[] = localized('None');
if (bindings.length > 0) {
// Templates may list mod+a and ctrl+a for one command; they are the same key
// on Windows and Linux, so dedupe by what the user would actually press.
const mod = process.platform === 'darwin' ? 'command' : 'ctrl';
const byKey = new Map(bindings.map((b) => [b.replace(/\bmod\b/g, mod), b]));
value = [...byKey.values()].map(this._renderKeystrokes);
const byKey = new Map(bindings.map((b) => [pressedKeys(b, process.platform), b]));
value = [...byKey.values()].map((binding, idx) => (
<span
key={binding}
className={conflicts[binding] ? 'shortcut-chip conflict' : 'shortcut-chip'}
title={
conflicts[binding]
? localized('Also runs %@', conflicts[binding].map((o) => o.label).join(', '))
: undefined
}
>
{renderKeystrokes(binding, idx)}
<button
className="remove-shortcut"
title={localized('Remove')}
onClick={() => this._onRemove(binding)}
>
×
</button>
</span>
));
}

let classnames = 'shortcut';
if (editing) {
classnames += ' editing';
}
return (
<Flexbox
className={classnames}
tabIndex={-1}
onKeyDown={this._onKey}
onKeyPress={this._onKey}
onFocus={this._onEdit}
onBlur={this._onFinishedEditing}
>
<div className="col-left shortcut-name">{this.props.label}</div>
<Flexbox className={recording ? 'shortcut recording' : 'shortcut'}>
<div className="col-left">
{customized && (
<span className="changed-dot" title={localized('Changed from the default')} />
)}
<span className="shortcut-name">{this.props.label}</span>
</div>
<div className="col-right">
<div className="values">{value}</div>
{recording ? (
<ShortcutRecorder
placeholder={localized('Press a shortcut, Esc to cancel')}
onRecord={this._onRecord}
onCancel={this._stopRecording}
/>
) : (
<button
className="btn btn-small add-shortcut"
ref={this._addButtonRef}
title={localized('Add a shortcut')}
onClick={this._onStartRecording}
>
+ {localized('Add')}
</button>
)}
{customized && !recording && (
<a className="reset-shortcut" onClick={this._onReset}>
{localized('Reset')}
</a>
)}
{added.filter((binding) => conflicts[binding]).map(this._renderConflict)}
</div>
</Flexbox>
);
Expand Down
Loading