Add React Native/Metro hotkeys (#822)

Summary:
This PR fixes https://github.com/facebook/flipper/issues/798 by adding customizable hotkeys to reload and/or open developer menu in React Native apps.

![Screenshot of the Preferences window with hotkeys](https://user-images.githubusercontent.com/6207220/75113976-b27c0280-5652-11ea-8d5d-020d2650425b.png)

#### TODO:

- [x] Add correct icon for removing content of the hotkey input (currently using `undo`) - cc passy 😄

## Changelog

Add customizable hotkeys to reload and/or open developer menu in React Native apps.
Pull Request resolved: https://github.com/facebook/flipper/pull/822

Test Plan:
- Run React Native on version `0.62.0-rc.2` (you can use this app: https://github.com/lucasbento/RNWithFlipper);
- Open the Preferences window (`⌘,`);
- Customise the React Native hotkeys to whatever you want;
- Test them out with Flipper's window active and inactive.

> **Note**: this has been tested only in macOS.

Reviewed By: jknoxville

Differential Revision: D20061833

Pulled By: passy

fbshipit-source-id: 601d29e07d7de2683d2c70c7c87f0d841aa3559e
This commit is contained in:
Lucas Bento
2020-03-03 09:19:05 -08:00
committed by Facebook Github Bot
parent 2d9d0314b9
commit d1fb8bed4a
6 changed files with 405 additions and 9 deletions

View File

@@ -21,11 +21,11 @@ import {Settings, DEFAULT_ANDROID_SDK_PATH} from '../reducers/settings';
import {flush} from '../utils/persistor'; import {flush} from '../utils/persistor';
import ToggledSection from './settings/ToggledSection'; import ToggledSection from './settings/ToggledSection';
import {FilePathConfigField, ConfigText} from './settings/configFields'; import {FilePathConfigField, ConfigText} from './settings/configFields';
import KeyboardShortcutInput from './settings/KeyboardShortcutInput';
import isEqual from 'lodash.isequal'; import isEqual from 'lodash.isequal';
import restartFlipper from '../utils/restartFlipper'; import restartFlipper from '../utils/restartFlipper';
import LauncherSettingsPanel from '../fb-stubs/LauncherSettingsPanel'; import LauncherSettingsPanel from '../fb-stubs/LauncherSettingsPanel';
import {reportUsage} from '../utils/metrics'; import {reportUsage} from '../utils/metrics';
import os from 'os';
const Container = styled(FlexColumn)({ const Container = styled(FlexColumn)({
padding: 20, padding: 20,
@@ -81,12 +81,20 @@ class SettingsSheet extends Component<Props, State> {
}; };
render() { render() {
const {
enableAndroid,
androidHome,
enableIOS,
enablePrefetching,
reactNative,
} = this.state.updatedSettings;
return ( return (
<Container> <Container>
<Title>Settings</Title> <Title>Settings</Title>
<ToggledSection <ToggledSection
label="Android Developer" label="Android Developer"
toggled={this.state.updatedSettings.enableAndroid} toggled={enableAndroid}
onChange={v => { onChange={v => {
this.setState({ this.setState({
updatedSettings: { updatedSettings: {
@@ -98,7 +106,7 @@ class SettingsSheet extends Component<Props, State> {
<FilePathConfigField <FilePathConfigField
label="Android SDK Location" label="Android SDK Location"
resetValue={DEFAULT_ANDROID_SDK_PATH} resetValue={DEFAULT_ANDROID_SDK_PATH}
defaultValue={this.state.updatedSettings.androidHome} defaultValue={androidHome}
onChange={v => { onChange={v => {
this.setState({ this.setState({
updatedSettings: { updatedSettings: {
@@ -111,10 +119,7 @@ class SettingsSheet extends Component<Props, State> {
</ToggledSection> </ToggledSection>
<ToggledSection <ToggledSection
label="iOS Developer" label="iOS Developer"
toggled={ toggled={enableIOS && this.props.platform === 'darwin'}
this.state.updatedSettings.enableIOS &&
this.props.platform === 'darwin'
}
frozen={this.props.platform !== 'darwin'} frozen={this.props.platform !== 'darwin'}
onChange={v => { onChange={v => {
this.setState({ this.setState({
@@ -134,7 +139,7 @@ class SettingsSheet extends Component<Props, State> {
)} )}
</ToggledSection> </ToggledSection>
<LauncherSettingsPanel <LauncherSettingsPanel
isPrefetchingEnabled={this.state.updatedSettings.enablePrefetching} isPrefetchingEnabled={enablePrefetching}
onEnablePrefetchingChange={v => { onEnablePrefetchingChange={v => {
this.setState({ this.setState({
updatedSettings: { updatedSettings: {
@@ -153,6 +158,60 @@ class SettingsSheet extends Component<Props, State> {
}); });
}} }}
/> />
<ToggledSection
label="React Native keyboard shortcuts"
toggled={reactNative.shortcuts.enabled}
onChange={enabled => {
this.setState(prevState => ({
updatedSettings: {
...prevState.updatedSettings,
reactNative: {
...prevState.updatedSettings.reactNative,
shortcuts: {
...prevState.updatedSettings.reactNative.shortcuts,
enabled,
},
},
},
}));
}}>
<KeyboardShortcutInput
label="Reload application"
value={reactNative.shortcuts.reload}
onChange={reload => {
this.setState(prevState => ({
updatedSettings: {
...prevState.updatedSettings,
reactNative: {
...prevState.updatedSettings.reactNative,
shortcuts: {
...prevState.updatedSettings.reactNative.shortcuts,
reload,
},
},
},
}));
}}
/>
<KeyboardShortcutInput
label="Open developer menu"
value={reactNative.shortcuts.openDevMenu}
onChange={openDevMenu => {
this.setState(prevState => ({
updatedSettings: {
...prevState.updatedSettings,
reactNative: {
...prevState.updatedSettings.reactNative,
shortcuts: {
...prevState.updatedSettings.reactNative.shortcuts,
openDevMenu,
},
},
},
}));
}}
/>
</ToggledSection>
<br /> <br />
<FlexRow> <FlexRow>
<Spacer /> <Spacer />

View File

@@ -0,0 +1,251 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {FlexColumn, styled, FlexRow, Text, Glyph, colors} from 'flipper';
import React, {useRef, useState, useEffect} from 'react';
type PressedKeys = {
metaKey: boolean;
altKey: boolean;
ctrlKey: boolean;
shiftKey: boolean;
character: string;
};
const KEYCODES = {
DELETE: 8,
ALT: 18,
SHIFT: 16,
CTRL: 17,
LEFT_COMMAND: 91, // Left ⌘ / Windows Key / Chromebook Search key
RIGHT_COMMAND: 93, // Right ⌘ / Windows Menu
};
const ACCELERATORS = {
COMMAND: 'Command',
ALT: 'Alt',
CONTROL: 'Control',
SHIFT: 'Shift',
};
const Container = styled(FlexRow)({
paddingTop: 5,
paddingLeft: 10,
paddingRight: 10,
width: 343,
});
const Label = styled(Text)({
alignSelf: 'center',
width: 140,
});
const ShortcutKeysContainer = styled(FlexRow)<{invalid: boolean}>(
{
backgroundColor: colors.white,
borderWidth: 1,
borderStyle: 'solid',
borderRadius: 4,
display: 'flex',
height: 28,
flexGrow: 1,
padding: 2,
},
props => ({borderColor: props.invalid ? colors.red : colors.light15}),
);
const ShortcutKeyContainer = styled.div({
border: `1px solid ${colors.light20}`,
backgroundColor: colors.light05,
padding: 3,
margin: '0 1px',
borderRadius: 3,
width: 23,
textAlign: 'center',
boxShadow: `inset 0 -1px 0 ${colors.light20}`,
});
const ShortcutKey = styled.span({
color: colors.dark70,
verticalAlign: 'middle',
});
const HiddenInput = styled.input({
opacity: 0,
width: 0,
height: 0,
position: 'absolute',
});
const CenteredGlyph = styled(Glyph)({
margin: 'auto',
marginLeft: 10,
});
const KeyboardShortcutInput = (props: {
label: string;
value: string;
onChange?: (value: string) => void;
}) => {
const getInitialStateFromProps = (): PressedKeys => ({
metaKey: Boolean(props.value && props.value.includes(ACCELERATORS.COMMAND)),
altKey: Boolean(props.value && props.value.includes(ACCELERATORS.ALT)),
ctrlKey: Boolean(props.value && props.value.includes(ACCELERATORS.CONTROL)),
shiftKey: Boolean(props.value && props.value.includes(ACCELERATORS.SHIFT)),
character:
props.value &&
props.value.replace(
new RegExp(
`${ACCELERATORS.COMMAND}|${ACCELERATORS.ALT}|Or|${ACCELERATORS.CONTROL}|${ACCELERATORS.SHIFT}|\\+`,
'g',
),
'',
),
});
const [initialPressedKeys] = useState<PressedKeys>(
getInitialStateFromProps(),
);
const [pressedKeys, setPressedKeys] = useState<PressedKeys>(
initialPressedKeys,
);
const [isShortcutValid, setIsShortcutValid] = useState<boolean | undefined>(
undefined,
);
useEffect(() => {
if (!isShortcutValid) {
return;
}
const {metaKey, altKey, ctrlKey, shiftKey, character} = pressedKeys;
const accelerator = [
metaKey && ACCELERATORS.COMMAND,
altKey && ACCELERATORS.ALT,
ctrlKey && ACCELERATORS.CONTROL,
shiftKey && ACCELERATORS.SHIFT,
character,
].filter(Boolean);
if (typeof props.onChange === 'function') {
props.onChange(accelerator.join('+'));
}
}, [isShortcutValid]);
const inputRef = useRef<HTMLInputElement>(null);
let typingTimeout: NodeJS.Timeout;
const handleFocusInput = () => {
if (inputRef.current !== null) {
inputRef.current.focus();
}
};
const isCharacterSpecial = (keycode: number) =>
Object.values(KEYCODES).includes(keycode);
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.which === 9) {
return;
}
event.preventDefault();
const {metaKey, altKey, ctrlKey, shiftKey} = event;
const character = isCharacterSpecial(event.which)
? ''
: String.fromCharCode(event.which);
setPressedKeys({
metaKey,
altKey,
ctrlKey,
shiftKey,
character,
});
setIsShortcutValid(undefined);
};
const handleKeyUp = () => {
const {metaKey, altKey, ctrlKey, shiftKey, character} = pressedKeys;
clearTimeout(typingTimeout);
typingTimeout = setTimeout(
() =>
setIsShortcutValid(
([metaKey, altKey, ctrlKey, shiftKey].includes(true) &&
character !== '') ||
[metaKey, altKey, ctrlKey, shiftKey, character].every(
value => !value,
),
),
500,
);
};
const handleUpdatePressedKeys = (keys: PressedKeys) => {
setPressedKeys(keys);
handleKeyUp();
handleFocusInput();
setIsShortcutValid(undefined);
};
const renderKeys = () => {
const keys = [
pressedKeys.metaKey && '⌘',
pressedKeys.altKey && '⌥',
pressedKeys.ctrlKey && '⌃',
pressedKeys.shiftKey && '⇧',
pressedKeys.character,
].filter(Boolean);
return keys.map((key, index) => (
<ShortcutKeyContainer key={index}>
<ShortcutKey>{key}</ShortcutKey>
</ShortcutKeyContainer>
));
};
return (
<Container>
<Label>{props.label}</Label>
<ShortcutKeysContainer
invalid={isShortcutValid === false}
onClick={handleFocusInput}>
{renderKeys()}
<HiddenInput
ref={inputRef}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
/>
</ShortcutKeysContainer>
<FlexColumn onClick={() => handleUpdatePressedKeys(initialPressedKeys)}>
<CenteredGlyph name="undo" variant="outline" />
</FlexColumn>
<FlexColumn
onClick={() =>
handleUpdatePressedKeys({
metaKey: false,
altKey: false,
ctrlKey: false,
shiftKey: false,
character: '',
})
}>
<CenteredGlyph name="cross" variant="outline" />
</FlexColumn>
</Container>
);
};
export default KeyboardShortcutInput;

View File

@@ -7,6 +7,7 @@
* @format * @format
*/ */
import {remote} from 'electron';
import androidDevice from './androidDevice'; import androidDevice from './androidDevice';
import metroDevice from './metroDevice'; import metroDevice from './metroDevice';
import iOSDevice from './iOSDevice'; import iOSDevice from './iOSDevice';
@@ -18,6 +19,7 @@ import notifications from './notifications';
import plugins from './plugins'; import plugins from './plugins';
import user from './user'; import user from './user';
import pluginManager from './pluginManager'; import pluginManager from './pluginManager';
import reactNative from './reactNative';
import {Logger} from '../fb-interfaces/Logger'; import {Logger} from '../fb-interfaces/Logger';
import {Store} from '../reducers/index'; import {Store} from '../reducers/index';
@@ -25,6 +27,12 @@ import {Dispatcher} from './types';
import {notNull} from '../utils/typeUtils'; import {notNull} from '../utils/typeUtils';
export default function(store: Store, logger: Logger): () => Promise<void> { export default function(store: Store, logger: Logger): () => Promise<void> {
// This only runs in development as when the reload
// kicks in it doesn't unregister the shortcuts
if (process.env.NODE_ENV === 'development') {
remote.globalShortcut.unregisterAll();
}
const dispatchers: Array<Dispatcher> = [ const dispatchers: Array<Dispatcher> = [
application, application,
store.getState().settingsState.enableAndroid ? androidDevice : null, store.getState().settingsState.enableAndroid ? androidDevice : null,
@@ -37,6 +45,7 @@ export default function(store: Store, logger: Logger): () => Promise<void> {
plugins, plugins,
user, user,
pluginManager, pluginManager,
reactNative,
].filter(notNull); ].filter(notNull);
const globalCleanup = dispatchers const globalCleanup = dispatchers
.map(dispatcher => dispatcher(store, logger)) .map(dispatcher => dispatcher(store, logger))

View File

@@ -0,0 +1,53 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {remote} from 'electron';
import {MetroDevice} from 'flipper';
import {Store} from 'src/reducers';
type ShortcutEventCommand =
| {
shortcut: string;
command: string;
}
| '';
export default (store: Store) => {
const settings = store.getState().settingsState.reactNative;
if (!settings.shortcuts.enabled) {
return;
}
const shortcuts: ShortcutEventCommand[] = [
settings.shortcuts.reload && {
shortcut: settings.shortcuts.reload,
command: 'reload',
},
settings.shortcuts.openDevMenu && {
shortcut: settings.shortcuts.openDevMenu,
command: 'devMenu',
},
];
shortcuts.forEach(
(shortcut: ShortcutEventCommand) =>
shortcut &&
shortcut.shortcut &&
remote.globalShortcut.register(shortcut.shortcut, () => {
const devices = store
.getState()
.connections.devices.filter(
device => device.os === 'Metro' && !device.isArchived,
) as MetroDevice[];
devices.forEach(device => device.sendCommand(shortcut.command));
}),
);
};

View File

@@ -34,6 +34,13 @@ export type Settings = {
width: number; width: number;
}; };
}; };
reactNative: {
shortcuts: {
enabled: boolean;
reload: string;
openDevMenu: string;
};
};
}; };
export type Action = export type Action =
@@ -57,6 +64,13 @@ const initialState: Settings = {
width: 800, width: 800,
}, },
}, },
reactNative: {
shortcuts: {
enabled: false,
reload: 'Alt+Shift+R',
openDevMenu: 'Alt+Shift+D',
},
},
}; };
export default function reducer( export default function reducer(

View File

@@ -10,7 +10,13 @@
const [s, ns] = process.hrtime(); const [s, ns] = process.hrtime();
let launchStartTime: number | undefined = s * 1e3 + ns / 1e6; let launchStartTime: number | undefined = s * 1e3 + ns / 1e6;
import {app, BrowserWindow, ipcMain, Notification} from 'electron'; import {
app,
BrowserWindow,
ipcMain,
Notification,
globalShortcut,
} from 'electron';
import path from 'path'; import path from 'path';
import url from 'url'; import url from 'url';
import fs from 'fs'; import fs from 'fs';
@@ -199,6 +205,10 @@ app.on('ready', () => {
}); });
}); });
app.on('will-quit', () => {
globalShortcut.unregisterAll();
});
ipcMain.on('componentDidMount', _event => { ipcMain.on('componentDidMount', _event => {
if (deeplinkURL) { if (deeplinkURL) {
win.webContents.send('flipper-protocol-handler', deeplinkURL); win.webContents.send('flipper-protocol-handler', deeplinkURL);