show only one device in sidbar

Summary:
Refactors the plugin architecture of Sonar:
- Before plugin rendering had it's own implementation of the react lifecycle. This means the `render`-function was not called by react, but rather by the application it self. In this diff, the render method is now called from react, which enables better debugging and allows react to do optimizations.
- Business logic for querying emulators is moved away from the view components into its own dispatcher
- All plugin handling is moved from `App.js` to `PluginContainer`.
- The sidebar only shows one selected device. This allows us to add the screenshot feature as part of the Sonar main app and not a plugin.
- This also fixes the inconsistency between the devices button and the sidebar

Reviewed By: jknoxville

Differential Revision: D8186933

fbshipit-source-id: 46404443025bcf18d6eeba0679e098d5440822d5
This commit is contained in:
Daniel Büchele
2018-06-25 09:56:10 -07:00
committed by Facebook Github Bot
parent 0c2f4d7cff
commit cbab597236
27 changed files with 736 additions and 1051 deletions

View File

@@ -5,293 +5,96 @@
* @format
*/
import {Component, styled, Glyph, Button, colors} from 'sonar';
import {Component, Button} from 'sonar';
import {connect} from 'react-redux';
import BaseDevice from '../devices/BaseDevice.js';
import child_process from 'child_process';
import DevicesList from './DevicesList.js';
import {exec} from 'child_process';
import {selectDevice} from '../reducers/connections.js';
import type BaseDevice from '../devices/BaseDevice.js';
const adb = require('adbkit-fb');
const Light = styled.view(
{
width: 10,
height: 10,
borderRadius: '999em',
backgroundColor: props => (props.active ? '#70f754' : colors.light20),
border: props => `1px solid ${props.active ? '#52d936' : colors.light30}`,
},
{
ignoreAttributes: ['active'],
},
);
type Props = {|
type Props = {
selectedDeviceIndex: number,
androidEmulators: Array<string>,
devices: Array<BaseDevice>,
|};
type Emulator = {|
name: string,
os?: string,
isRunning: boolean,
|};
type State = {
androidEmulators: Array<Emulator>,
iOSSimulators: Array<Emulator>,
popoverVisible: boolean,
selectDevice: (i: number) => void,
};
type IOSSimulatorList = {
devices: {
[os: string]: Array<{
state: 'Shutdown' | 'Booted',
availability: string,
name: string,
udid: string,
os?: string,
}>,
},
};
class DevicesButton extends Component<Props, State> {
state = {
androidEmulators: [],
iOSSimulators: [],
popoverVisible: false,
};
client = adb.createClient();
_iOSSimulatorRefreshInterval: ?number;
componentDidMount() {
this.updateEmulatorState(this.openMenuWhenNoDevicesConnected);
this.fetchIOSSimulators();
this._iOSSimulatorRefreshInterval = window.setInterval(
this.fetchIOSSimulators,
5000,
);
this.client.trackDevices().then(tracker => {
tracker.on('add', () => this.updateEmulatorState());
tracker.on('remove', () => this.updateEmulatorState());
tracker.on('end', () => this.updateEmulatorState());
});
}
componentWillUnmount() {
if (this._iOSSimulatorRefreshInterval != null) {
window.clearInterval(this._iOSSimulatorRefreshInterval);
}
}
fetchIOSSimulators = () => {
child_process.exec(
'xcrun simctl list devices --json',
(err: ?Error, data: ?string) => {
if (data != null && err == null) {
const devicesList: IOSSimulatorList = JSON.parse(data);
const iOSSimulators = Object.keys(devicesList.devices)
.map(os =>
devicesList.devices[os].map(device => {
device.os = os;
return device;
}),
)
.reduce((acc, cv) => acc.concat(cv), [])
.filter(device => device.state === 'Booted')
.map(device => ({
name: device.name,
os: device.os,
isRunning: true,
}));
this.setState({iOSSimulators});
}
},
);
};
openMenuWhenNoDevicesConnected = () => {
const numberOfEmulators = this.state.androidEmulators.filter(
e => e.isRunning,
).length;
const numberOfDevices = Object.values(this.props.devices).length;
if (numberOfEmulators + numberOfDevices === 0) {
this.setState({popoverVisible: true});
}
};
updateEmulatorState = async (cb?: Function) => {
try {
const devices = await this.getEmulatorNames();
const ports = await this.getRunningEmulatorPorts();
const runningDevices = await Promise.all(
ports.map(port => this.getRunningName(port)),
);
this.setState(
{
androidEmulators: devices.map(name => ({
name,
isRunning: runningDevices.indexOf(name) > -1,
})),
},
cb,
);
} catch (e) {
console.error(e);
}
};
getEmulatorNames(): Promise<Array<string>> {
return new Promise((resolve, reject) => {
child_process.exec(
'$ANDROID_HOME/tools/emulator -list-avds',
(error: ?Error, data: ?string) => {
if (error == null && data != null) {
resolve(data.split('\n').filter(name => name !== ''));
} else {
reject(error);
}
},
);
});
}
getRunningEmulatorPorts(): Promise<Array<string>> {
const EMULATOR_PREFIX = 'emulator-';
return adb
.createClient()
.listDevices()
.then((devices: Array<{id: string}>) =>
devices
.filter(d => d.id.startsWith(EMULATOR_PREFIX))
.map(d => d.id.replace(EMULATOR_PREFIX, '')),
)
.catch((e: Error) => {
return [];
});
}
getRunningName(port: string): Promise<?string> {
return new Promise((resolve, reject) => {
child_process.exec(
`echo "avd name" | nc -w 1 localhost ${port}`,
(error: ?Error, data: ?string) => {
if (error == null && data != null) {
const match = data.trim().match(/(.*)\r\nOK$/);
resolve(match != null && match.length > 0 ? match[1] : null);
} else {
reject(error);
}
},
);
});
}
class DevicesButton extends Component<Props> {
launchEmulator = (name: string) => {
if (/^[a-zA-Z0-9-_\s]+$/.test(name)) {
child_process.exec(
`$ANDROID_HOME/tools/emulator -avd "${name}"`,
this.updateEmulatorState,
);
} else {
console.error(
`Can not launch emulator named ${name}, because it's name contains invalid characters.`,
);
}
};
createEmualtor = () => {};
onClick = () => {
this.setState({popoverVisible: !this.state.popoverVisible});
this.updateEmulatorState();
this.fetchIOSSimulators();
};
onDismissPopover = () => {
this.setState({popoverVisible: false});
exec(`$ANDROID_HOME/tools/emulator @${name}`, error => {
if (error) {
console.error(error);
}
});
};
render() {
let text = 'No devices running';
let glyph = 'minus-circle';
const {
devices,
androidEmulators,
selectedDeviceIndex,
selectDevice,
} = this.props;
let text = 'No device selected';
let icon = 'minus-circle';
const runnningEmulators = this.state.androidEmulators.filter(
emulator => emulator.isRunning,
);
const numberOfRunningDevices =
runnningEmulators.length + this.state.iOSSimulators.length;
if (numberOfRunningDevices > 0) {
text = `${numberOfRunningDevices} device${
numberOfRunningDevices > 1 ? 's' : ''
} running`;
glyph = 'mobile';
if (selectedDeviceIndex > -1) {
text = devices[selectedDeviceIndex].title;
icon = 'mobile';
}
const connectedDevices = this.props.devices;
const dropdown = [];
if (devices.length > 0) {
dropdown.push(
{
label: 'Running devices',
enabled: false,
},
...devices.map((device: BaseDevice, i: number) => ({
click: () => selectDevice(i),
checked: i === selectedDeviceIndex,
label: `${device.deviceType === 'physical' ? '📱 ' : ''}${
device.title
}`,
type: 'checkbox',
})),
);
}
if (androidEmulators.length > 0) {
const emulators = Array.from(androidEmulators)
.filter(
(name: string) =>
devices.findIndex((device: BaseDevice) => device.title === name) ===
-1,
)
.map((name: string) => ({
label: name,
click: () => this.launchEmulator(name),
}));
if (emulators.length > 0) {
dropdown.push(
{type: 'separator'},
{
label: 'Launch Android emulators',
enabled: false,
},
...emulators,
);
}
}
return (
<Button
compact={true}
onClick={this.onClick}
icon={glyph}
disabled={this.state.androidEmulators.length === 0}>
<Button compact={true} icon={icon} dropdown={dropdown} disabled={false}>
{text}
{this.state.popoverVisible && (
<DevicesList
onDismiss={this.onDismissPopover}
sections={[
{
title: 'Running',
items: [
...connectedDevices
.filter(device => device.deviceType === 'physical')
.map(device => ({
title: device.title,
subtitle: device.os,
icon: <Light active={true} />,
})),
...runnningEmulators.map(emulator => ({
title: emulator.name,
subtitle: 'Android Emulator',
icon: <Light active={true} />,
})),
...this.state.iOSSimulators.map(simulator => ({
title: simulator.name,
subtitle: `${String(simulator.os)} Simulator`,
icon: <Light active={true} />,
})),
],
},
{
title: 'Not Running',
items: [
...this.state.androidEmulators
.filter(emulator => !emulator.isRunning)
.map(emulator => ({
title: emulator.name,
subtitle: 'Android Emulator',
onClick: () => this.launchEmulator(emulator.name),
icon: <Light active={false} />,
})),
{
title: 'Connect a device',
subtitle: 'Plugins will load automatically',
icon: <Glyph name="mobile" size={12} />,
},
],
},
]}
/>
)}
</Button>
);
}
}
export default connect(({devices}) => ({
devices,
}))(DevicesButton);
export default connect(
({connections: {devices, androidEmulators, selectedDeviceIndex}}) => ({
devices,
androidEmulators,
selectedDeviceIndex,
}),
{selectDevice},
)(DevicesButton);

View File

@@ -5,24 +5,29 @@
* @format
*/
import type {SonarBasePlugin} from '../plugin.js';
import type {
SonarPlugin,
SonarDevicePlugin,
SonarBasePlugin,
} from '../plugin.js';
import type BaseDevice from '../devices/BaseDevice.js';
import type Client from '../Client.js';
import {
Component,
Sidebar,
FlexBox,
ClickableList,
ClickableListItem,
colors,
brandColors,
Text,
Glyph,
} from 'sonar';
import React from 'react';
import {devicePlugins} from '../device-plugins/index.js';
import type BaseDevice from '../devices/BaseDevice.js';
import PropTypes from 'prop-types';
import plugins from '../plugins/index.js';
import {selectPlugin} from '../reducers/connections.js';
import {connect} from 'react-redux';
const CustomClickableListItem = ClickableListItem.extends({
paddingLeft: 10,
@@ -85,29 +90,17 @@ function PluginIcon({
}
class PluginSidebarListItem extends Component<{
activePluginKey: ?string,
activeAppKey: ?string,
onActivatePlugin: (appKey: string, pluginKey: string) => void,
appKey: string,
appName?: string,
onClick: () => void,
isActive: boolean,
Plugin: Class<SonarBasePlugin<>>,
windowFocused: boolean,
plugin: Class<SonarBasePlugin<>>,
app?: ?string,
}> {
onClick = () => {
const {props} = this;
props.onActivatePlugin(props.appKey, props.Plugin.id);
};
render() {
const {isActive, Plugin, windowFocused, appKey, appName} = this.props;
const {isActive, plugin} = this.props;
const app = this.props.app || 'Facebook';
let iconColor = brandColors[app];
let iconColor;
if (appName != null) {
iconColor = brandColors[appName];
}
if (iconColor == null) {
if (!iconColor) {
const pluginColors = [
colors.seaFoam,
colors.teal,
@@ -120,186 +113,121 @@ class PluginSidebarListItem extends Component<{
colors.grape,
];
iconColor = pluginColors[parseInt(appKey, 36) % pluginColors.length];
iconColor = pluginColors[parseInt(app, 36) % pluginColors.length];
}
return (
<CustomClickableListItem
active={isActive}
onClick={this.onClick}
windowFocused={windowFocused}>
<CustomClickableListItem active={isActive} onClick={this.props.onClick}>
<PluginIcon
name={Plugin.icon}
backgroundColor={
isActive
? windowFocused
? colors.white
: colors.macOSSidebarSectionTitle
: iconColor
}
color={
isActive
? windowFocused
? colors.macOSTitleBarIconSelected
: colors.white
: colors.white
}
name={plugin.icon}
backgroundColor={iconColor}
color={colors.white}
/>
<PluginName>{Plugin.title}</PluginName>
<PluginName>{plugin.title}</PluginName>
</CustomClickableListItem>
);
}
}
function PluginSidebarList(props: {|
activePluginKey: ?string,
activeAppKey: ?string,
onActivatePlugin: (appKey: string, pluginKey: string) => void,
appKey: string,
appName?: string,
enabledPlugins: Array<Class<SonarBasePlugin<>>>,
windowFocused: boolean,
|}) {
if (props.enabledPlugins.length === 0) {
return <Text>No available plugins for this device</Text>;
}
return (
<ClickableList>
{props.enabledPlugins.map(Plugin => {
const isActive =
props.activeAppKey === props.appKey &&
props.activePluginKey === Plugin.id;
return (
<PluginSidebarListItem
key={Plugin.id}
activePluginKey={props.activePluginKey}
activeAppKey={props.activeAppKey}
onActivatePlugin={props.onActivatePlugin}
appKey={props.appKey}
appName={props.appName}
isActive={isActive}
Plugin={Plugin}
windowFocused={props.windowFocused}
/>
);
})}
</ClickableList>
);
}
function AppSidebarInfo(props: {|
client: Client,
appKey: string,
activePluginKey: ?string,
activeAppKey: ?string,
onActivatePlugin: (appKey: string, pluginKey: string) => void,
windowFocused: boolean,
|}): any {
const {appKey, client, windowFocused} = props;
let enabledPlugins = [];
for (const Plugin of plugins) {
if (client.supportsPlugin(Plugin)) {
enabledPlugins.push(Plugin);
}
}
enabledPlugins = enabledPlugins.sort((a, b) => {
return (a.title || '').localeCompare(b.title);
});
return [
<SidebarHeader key={client.query.app}>{`${client.query.app} (${
client.query.os
}) - ${client.query.device}`}</SidebarHeader>,
<PluginSidebarList
key={`list-${appKey}`}
activePluginKey={props.activePluginKey}
activeAppKey={props.activeAppKey}
onActivatePlugin={props.onActivatePlugin}
appKey={appKey}
appName={client.query.app}
enabledPlugins={enabledPlugins}
windowFocused={windowFocused}
/>,
];
}
type MainSidebarProps = {|
activePluginKey: ?string,
activeAppKey: ?string,
onActivatePlugin: (appKey: string, pluginKey: string) => void,
selectedPlugin: ?string,
selectedApp: ?string,
selectedDeviceIndex: number,
selectPlugin: (payload: {
selectedPlugin: ?string,
selectedApp: ?string,
}) => void,
devices: Array<BaseDevice>,
server: Server,
clients: Array<Client>,
|};
export default class MainSidebar extends Component<MainSidebarProps> {
static contextTypes = {
windowIsFocused: PropTypes.bool,
};
class MainSidebar extends Component<MainSidebarProps> {
render() {
const connections = Array.from(this.props.server.connections.values()).sort(
(a, b) => {
return (a.client.query.app || '').localeCompare(b.client.query.app);
},
);
const {
devices,
selectedDeviceIndex,
selectedPlugin,
selectedApp,
selectPlugin,
} = this.props;
let {clients} = this.props;
const device: BaseDevice = devices[selectedDeviceIndex];
const sidebarContent = connections.map(conn => {
const {client} = conn;
return (
<AppSidebarInfo
key={`app=${client.id}`}
client={client}
appKey={client.id}
activePluginKey={this.props.activePluginKey}
activeAppKey={this.props.activeAppKey}
onActivatePlugin={this.props.onActivatePlugin}
windowFocused={this.context.windowIsFocused}
/>
);
});
let {devices} = this.props;
devices = devices.sort((a, b) => {
let enabledPlugins = [];
for (const devicePlugin of devicePlugins) {
if (device.supportsPlugin(devicePlugin)) {
enabledPlugins.push(devicePlugin);
}
}
enabledPlugins = enabledPlugins.sort((a, b) => {
return (a.title || '').localeCompare(b.title);
});
for (const device of devices) {
let enabledPlugins = [];
for (const DevicePlugin of devicePlugins) {
if (device.supportsPlugin(DevicePlugin)) {
enabledPlugins.push(DevicePlugin);
}
}
enabledPlugins = enabledPlugins.sort((a, b) => {
return (a.title || '').localeCompare(b.title);
});
sidebarContent.unshift([
<SidebarHeader key={device.title}>{device.title}</SidebarHeader>,
<PluginSidebarList
key={`list-${device.serial}`}
activePluginKey={this.props.activePluginKey}
activeAppKey={this.props.activeAppKey}
onActivatePlugin={this.props.onActivatePlugin}
appKey={device.serial}
enabledPlugins={enabledPlugins}
windowFocused={this.context.windowIsFocused}
/>,
]);
}
clients = clients
// currently we can't filter clients for a device, because all clients
// are reporting `unknown` as their deviceID, due to a change in Android's
// API.
//.filter((client: Client) => client.getDevice() === device)
.sort((a, b) => (a.query.app || '').localeCompare(b.query.app));
return (
<Sidebar
position="left"
width={200}
backgroundColor={
this.context.windowIsFocused ? 'transparent' : '#f6f6f6'
}>
{sidebarContent}
<Sidebar position="left" width={200}>
{devicePlugins
.filter(device.supportsPlugin)
.map((plugin: Class<SonarDevicePlugin<>>) => (
<PluginSidebarListItem
key={plugin.id}
isActive={plugin.id === selectedPlugin}
onClick={() =>
selectPlugin({
selectedPlugin: plugin.id,
selectedApp: null,
})
}
plugin={plugin}
/>
))}
{clients.map((client: Client) => (
// $FlowFixMe: Flow doesn't know of React.Fragment yet
<React.Fragment key={client.id}>
<SidebarHeader>{client.query.app}</SidebarHeader>
{plugins
.filter(
(p: Class<SonarPlugin<>>) => client.plugins.indexOf(p.id) > -1,
)
.map((plugin: Class<SonarPlugin<>>) => (
<PluginSidebarListItem
key={plugin.id}
isActive={
plugin.id === selectedPlugin && selectedApp === client.id
}
onClick={() =>
selectPlugin({
selectedPlugin: plugin.id,
selectedApp: client.id,
})
}
plugin={plugin}
app={client.query.app}
/>
))}
</React.Fragment>
))}
</Sidebar>
);
}
}
export default connect(
({
connections: {devices, selectedDeviceIndex, selectedPlugin, selectedApp},
}) => ({
devices,
selectedDeviceIndex,
selectedPlugin,
selectedApp,
}),
{
selectPlugin,
},
)(MainSidebar);

View File

@@ -0,0 +1,61 @@
/**
* Copyright 2018-present Facebook.
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
* @format
*/
import React from 'react';
import ReactDOM from 'react-dom';
import {Sidebar} from 'sonar';
import {connect} from 'react-redux';
import {toggleRightSidebarAvailable} from '../reducers/application.js';
type Props = {
children: any,
rightSidebarVisible: boolean,
rightSidebarAvailable: boolean,
toggleRightSidebarAvailable: (visible: boolean) => void,
};
class SonarSidebar extends React.Component<Props> {
componentDidMount() {
this.updateSidebarAvailablility();
}
componentDidUpdate() {
this.updateSidebarAvailablility();
}
updateSidebarAvailablility() {
const available = Boolean(this.props.children);
if (available !== this.props.rightSidebarAvailable) {
this.props.toggleRightSidebarAvailable(available);
}
}
render() {
const domNode = document.getElementById('sonarSidebar');
return (
this.props.children &&
this.props.rightSidebarVisible &&
domNode &&
ReactDOM.createPortal(
<Sidebar width={300} position="right">
{this.props.children}
</Sidebar>,
domNode,
)
);
}
}
export default connect(
({application: {rightSidebarVisible, rightSidebarAvailable}}) => ({
rightSidebarVisible,
rightSidebarAvailable,
}),
{
toggleRightSidebarAvailable,
},
)(SonarSidebar);