From cbab5972361b85bfe402f0b05f5a6f2b3b889637 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20B=C3=BCchele?= Date: Mon, 25 Jun 2018 09:56:10 -0700 Subject: [PATCH] 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 --- .flowconfig | 1 - src/App.js | 182 +++------------ src/MenuBar.js | 27 ++- src/PluginContainer.js | 233 +++++++++++-------- src/chrome/DevicesButton.js | 345 +++++++---------------------- src/chrome/MainSidebar.js | 290 +++++++++--------------- src/chrome/SonarSidebar.js | 61 +++++ src/createTablePlugin.js | 3 +- src/device-plugins/index.js | 5 +- src/device-plugins/screen/index.js | 7 + src/devices/AndroidDevice.js | 3 +- src/devices/BaseDevice.js | 4 +- src/dispatcher/androidDevice.js | 52 ++++- src/dispatcher/iOSDevice.js | 7 +- src/index.js | 1 + src/init.js | 2 + src/plugin.js | 108 ++------- src/plugins/index.js | 8 +- src/plugins/layout/index.js | 12 +- src/plugins/network/index.js | 2 + src/reducers.js | 184 --------------- src/reducers/connections.js | 148 +++++++++++++ src/reducers/devices.js | 39 ---- src/reducers/index.js | 16 +- src/reducers/pluginStates.js | 42 ++++ src/ui/components/Button.js | 1 + src/ui/components/ErrorBoundary.js | 4 +- 27 files changed, 736 insertions(+), 1051 deletions(-) create mode 100644 src/chrome/SonarSidebar.js delete mode 100644 src/reducers.js create mode 100644 src/reducers/connections.js delete mode 100644 src/reducers/devices.js create mode 100644 src/reducers/pluginStates.js diff --git a/.flowconfig b/.flowconfig index a065c3782..f397aa460 100644 --- a/.flowconfig +++ b/.flowconfig @@ -9,7 +9,6 @@ .*/website/.* [libs] -lib flow-typed [options] diff --git a/src/App.js b/src/App.js index c0ba3f410..8e2f6dfae 100644 --- a/src/App.js +++ b/src/App.js @@ -4,11 +4,9 @@ * LICENSE file in the root directory of this source tree. * @format */ -import {ErrorBoundary, FlexColumn, FlexRow} from 'sonar'; +import {FlexColumn, FlexRow} from 'sonar'; import {connect} from 'react-redux'; import {toggleBugDialogVisible} from './reducers/application.js'; -import {setupMenu, activateMenuItems} from './MenuBar.js'; -import {devicePlugins} from './device-plugins/index.js'; import WelcomeScreen from './chrome/WelcomeScreen.js'; import SonarTitleBar from './chrome/SonarTitleBar.js'; import BaseDevice from './devices/BaseDevice.js'; @@ -16,7 +14,6 @@ import MainSidebar from './chrome/MainSidebar.js'; import {SonarBasePlugin} from './plugin.js'; import Server from './server.js'; import Client from './Client.js'; -import * as reducers from './reducers.js'; import React from 'react'; import BugReporter from './fb-stubs/BugReporter.js'; import BugReporterDialog from './chrome/BugReporterDialog.js'; @@ -47,7 +44,6 @@ export type State = { activeAppKey: ?string, plugins: StatePlugins, error: ?string, - server: Server, }; type Props = { @@ -55,6 +51,8 @@ type Props = { leftSidebarVisible: boolean, bugDialogVisible: boolean, pluginManagerVisible: boolean, + selectedDeviceIndex: number, + selectedApp: ?string, toggleBugDialogVisible: (visible?: boolean) => void, }; @@ -67,6 +65,7 @@ export class App extends React.Component { setupEnvironment(); this.logger = new Logger(); replaceGlobalConsole(this.logger); + this.server = this.initServer(); this.state = { activeAppKey: null, @@ -74,14 +73,13 @@ export class App extends React.Component { error: null, devices: {}, plugins: {}, - server: this.initServer(), }; this.bugReporter = new BugReporter(this.logger); this.commandLineArgs = yargs.parse(electron.remote.process.argv); - setupMenu(this.sendKeyboardAction); } + server: Server; bugReporter: BugReporter; logger: Logger; commandLineArgs: Object; @@ -92,40 +90,35 @@ export class App extends React.Component { // close socket before reloading window.addEventListener('beforeunload', () => { - this.state.server.close(); + this.server.close(); }); } - componentDidUpdate(prevProps: Props) { - if (prevProps.devices !== this.props.devices) { - this.ensurePluginSelected(); - } - } - toJSON() { return null; } initServer(): Server { const server = new Server(this); - server.addListener('new-client', client => { client.addListener('close', () => { - this.setState(state => - reducers.TeardownClient(this, state, {appKey: client.id}), - ); + this.setState(state => { + this.forceUpdate(); + // TODO: + //reducers.TeardownClient(this, state, {appKey: client.id}), + }); if (this.state.activeAppKey === client.id) { - setTimeout(this.ensurePluginSelected); + this.forceUpdate(); } }); client.addListener('plugins-change', () => { - this.setState({}, this.ensurePluginSelected); + this.forceUpdate(); }); }); server.addListener('clients-change', () => { - this.setState({}, this.ensurePluginSelected); + this.forceUpdate(); }); server.addListener('error', err => { @@ -178,143 +171,21 @@ export class App extends React.Component { }); }; - sendKeyboardAction = (action: string) => { - const {activeAppKey, activePluginKey} = this.state; - - if (activeAppKey != null && activePluginKey != null) { - const clientPlugins = this.state.plugins[activeAppKey]; - const pluginInfo = clientPlugins && clientPlugins[activePluginKey]; - const plugin = pluginInfo && pluginInfo.plugin; - if (plugin && typeof plugin.onKeyboardAction === 'function') { - plugin.onKeyboardAction(action); - } - } - }; - - getDevice = (id: string): ?BaseDevice => { + getDevice = (id: string): ?BaseDevice => this.props.devices.find((device: BaseDevice) => device.serial === id); - }; - - ensurePluginSelected = () => { - // check if we need to rehydrate this client as it may have been previously active - const {activeAppKey, activePluginKey, server} = this.state; - const {devices} = this.props; - - if (!this._hasActivatedPreferredPlugin) { - for (const connection of server.connections.values()) { - const {client} = connection; - const {plugins} = client; - - for (const plugin of plugins) { - if (plugin !== this.commandLineArgs.plugin) { - continue; - } - - this._hasActivatedPreferredPlugin = true; - this.onActivatePlugin(client.id, plugin); - return; - } - } - - if (devices.length > 0) { - const device = devices[0]; - for (const plugin of devicePlugins) { - if (plugin.id !== this.commandLineArgs.plugin) { - continue; - } - - this._hasActivatedPreferredPlugin = true; - this.onActivatePlugin(device.serial, plugin.id); - return; - } - } - } - - if (activeAppKey != null && activePluginKey != null) { - const client = this.getClient(activeAppKey); - if (client != null && client.plugins.includes(activePluginKey)) { - this.onActivatePlugin(client.id, activePluginKey); - return; - } - - const device: ?BaseDevice = this.getDevice(activeAppKey); - if (device != null) { - this.onActivatePlugin(device.serial, activePluginKey); - return; - } - } else { - // No plugin selected, let's select one - const deviceList = ((Object.values(devices): any): Array); - if (deviceList.length > 0) { - const device = deviceList[0]; - this.onActivatePlugin(device.serial, devicePlugins[0].id); - return; - } - - const connections = Array.from(server.connections.values()); - if (connections.length > 0) { - const client = connections[0].client; - const plugins = client.plugins; - if (plugins.length > 0) { - this.onActivatePlugin(client.id, client.plugins[0]); - return; - } - } - } - }; getClient(appKey: ?string): ?Client { if (appKey == null) { return null; } - const info = this.state.server.connections.get(appKey); + const info = this.server.connections.get(appKey); if (info != null) { return info.client; } } - onActivatePlugin = (appKey: string, pluginKey: string) => { - activateMenuItems(pluginKey); - - this.setState(state => - reducers.ActivatePlugin(this, state, { - appKey, - pluginKey, - }), - ); - }; - render() { - const {state} = this; - const hasDevices = - this.props.devices.length > 0 || state.server.connections.size > 0; - let mainView = null; - - const {activeAppKey, activePluginKey} = state; - if (activeAppKey != null && activePluginKey != null) { - const clientPlugins = state.plugins[activeAppKey]; - const pluginInfo = clientPlugins && clientPlugins[activePluginKey]; - const plugin = pluginInfo && pluginInfo.plugin; - if (plugin) { - mainView = this.props.pluginManagerVisible ? ( - - ) : ( - - - - ); - } - } - return ( @@ -324,25 +195,26 @@ export class App extends React.Component { close={() => this.props.toggleBugDialogVisible(false)} /> )} - {hasDevices ? ( + {this.props.selectedDeviceIndex > -1 ? ( {this.props.leftSidebarVisible && ( client, + )} /> )} - {mainView} + ) : this.props.pluginManagerVisible ? ( ) : ( )} - + ); } @@ -351,12 +223,14 @@ export class App extends React.Component { export default connect( ({ application: {pluginManagerVisible, bugDialogVisible, leftSidebarVisible}, - devices, + connections: {devices, selectedDeviceIndex, selectedApp}, }) => ({ pluginManagerVisible, bugDialogVisible, leftSidebarVisible, devices, + selectedDeviceIndex, + selectedApp, }), {toggleBugDialogVisible}, )(App); diff --git a/src/MenuBar.js b/src/MenuBar.js index 020e49f57..e677fe922 100644 --- a/src/MenuBar.js +++ b/src/MenuBar.js @@ -60,7 +60,16 @@ export type KeyboardActions = Array; const menuItems: Map = new Map(); -export function setupMenu(actionHandler: (action: string) => void) { +let pluginActionHandler; +function actionHandler(action: string) { + if (pluginActionHandler) { + pluginActionHandler(action); + } else { + console.warn(`Unhandled keybaord action "${action}".`); + } +} + +export function setupMenuBar() { const template = getTemplate(electron.remote.app, electron.remote.shell); // collect all keyboard actions from all plugins @@ -126,20 +135,20 @@ function appendMenuItem( } } -export function activateMenuItems(activePluginKey: ?string) { - const activePlugin: ?Class> = [ - ...devicePlugins, - ...plugins, - ].find((plugin: Class>) => plugin.id === activePluginKey); - +export function activateMenuItems(activePlugin: SonarBasePlugin<>) { // disable all keyboard actions for (const item of menuItems) { item[1].enabled = false; } + // set plugin action handler + if (activePlugin.onKeyboardAction) { + pluginActionHandler = activePlugin.onKeyboardAction; + } + // enable keyboard actions for the current plugin - if (activePlugin != null && activePlugin.keyboardActions != null) { - (activePlugin.keyboardActions || []).forEach(keyboardAction => { + if (activePlugin.constructor.keyboardActions != null) { + (activePlugin.constructor.keyboardActions || []).forEach(keyboardAction => { const action = typeof keyboardAction === 'string' ? keyboardAction diff --git a/src/PluginContainer.js b/src/PluginContainer.js index 6a0851057..d338e26db 100644 --- a/src/PluginContainer.js +++ b/src/PluginContainer.js @@ -4,125 +4,172 @@ * LICENSE file in the root directory of this source tree. * @format */ -import {Component, FlexColumn, Sidebar, colors} from 'sonar'; -import Intro from './ui/components/intro/intro.js'; -import {connect} from 'react-redux'; -import { - toggleRightSidebarAvailable, - toggleRightSidebarVisible, -} from './reducers/application.js'; -import type {SonarBasePlugin} from './plugin.js'; +import type {SonarPlugin, SonarBasePlugin} from './plugin.js'; import type LogManager from './fb-stubs/Logger'; +import type Client from './Client.js'; +import type BaseDevice from './devices/BaseDevice.js'; +import type {Props as PluginProps} from './plugin.js'; + +import {SonarDevicePlugin} from './plugin.js'; +import {ErrorBoundary, Component, FlexColumn, FlexRow, colors} from 'sonar'; +import React from 'react'; +import {connect} from 'react-redux'; +import {setPluginState} from './reducers/pluginStates.js'; +import {devicePlugins} from './device-plugins/index.js'; +import plugins from './plugins/index.js'; +import {activateMenuItems} from './MenuBar.js'; + +const Container = FlexColumn.extends({ + width: 0, + flexGrow: 1, + flexShrink: 1, + backgroundColor: colors.white, +}); + +const SidebarContainer = FlexRow.extends({ + backgroundColor: colors.light02, + height: '100%', + overflow: 'scroll', +}); type Props = { - plugin: SonarBasePlugin<>, - state?: any, logger: LogManager, - rightSidebarVisible: boolean, - rightSidebarAvailable: boolean, - toggleRightSidebarVisible: (available: ?boolean) => void, - toggleRightSidebarAvailable: (available: ?boolean) => void, + selectedDeviceIndex: number, + selectedPlugin: ?string, + pluginStates: Object, + client: ?Client, + devices: Array, + setPluginState: (payload: { + pluginKey: string, + state: Object, + }) => void, }; type State = { - showIntro: boolean, + activePlugin: ?Class>, + target: Client | BaseDevice | null, + pluginKey: string, }; -class PluginContainer extends Component { - state = { - showIntro: - typeof this.props.plugin.renderIntro === 'function' && - window.localStorage.getItem( - `${this.props.plugin.constructor.id}.introShown`, - ) !== 'true', +function withPluginLifecycleHooks( + PluginComponent: Class>, + target: Client | BaseDevice, +) { + return class extends React.Component> { + plugin: ?SonarBasePlugin<>; + + static displayName = `${PluginComponent.title}Plugin`; + + componentDidMount() { + const {plugin} = this; + if (plugin) { + activateMenuItems(plugin); + plugin._setup(target); + plugin._init(); + } + } + + componentWillUnmount() { + if (this.plugin) { + this.plugin._teardown(); + } + } + + render() { + return ( + ) => { + if (ref) { + this.plugin = ref; + } + }} + {...this.props} + /> + ); + } }; +} - _sidebar: ?React$Node; - - static Container = FlexColumn.extends({ - width: 0, - flexGrow: 1, - flexShrink: 1, - backgroundColor: colors.white, - }); - - componentWillUnmount() { - performance.mark(`init_${this.props.plugin.constructor.id}`); - } - - componentDidMount() { - this.props.logger.trackTimeSince( - `init_${this.props.plugin.constructor.id}`, +class PluginContainer extends Component { + static getDerivedStateFromProps(props: Props) { + let activePlugin = devicePlugins.find( + (p: Class>) => p.id === props.selectedPlugin, ); - } - - componentDidUpdate(prevProps: Props) { - if (prevProps.plugin !== this.props.plugin) { - this.props.logger.trackTimeSince( - `init_${this.props.plugin.constructor.id}`, - ); - } - } - - componentWillUpdate(nextProps: Props) { - if (this.props.plugin !== nextProps.plugin) { - performance.mark(`init_${nextProps.plugin.constructor.id}`); - } - let sidebarContent; - if (typeof nextProps.plugin.renderSidebar === 'function') { - sidebarContent = nextProps.plugin.renderSidebar(); - } - - if (sidebarContent == null) { - this._sidebar = null; - nextProps.toggleRightSidebarAvailable(false); + const device: BaseDevice = props.devices[props.selectedDeviceIndex]; + let target = device; + let pluginKey = 'unknown'; + if (activePlugin) { + pluginKey = `${device.serial}#${activePlugin.id}`; } else { - this._sidebar = ( - - {sidebarContent} - + activePlugin = plugins.find( + (p: Class>) => p.id === props.selectedPlugin, ); - nextProps.toggleRightSidebarAvailable(true); + if (!activePlugin || !props.client) { + return null; + } + target = props.client; + pluginKey = `${target.id}#${activePlugin.id}`; } + + return { + pluginKey, + activePlugin, + target, + }; } - onDismissIntro = () => { - const {plugin} = this.props; - window.localStorage.setItem(`${plugin.constructor.id}.introShown`, 'true'); - this.setState({ - showIntro: false, - }); + state = { + pluginKey: 'unknown', + activePlugin: null, + target: null, }; render() { - const {plugin} = this.props; + const {pluginStates, setPluginState} = this.props; + const {activePlugin, pluginKey, target} = this.state; - return [ - - {this.state.showIntro ? ( - - {typeof plugin.renderIntro === 'function' && plugin.renderIntro()} - - ) : ( - plugin.render() - )} - , - this.props.rightSidebarVisible === false ? null : this._sidebar, - ]; + if (!activePlugin || !target) { + return null; + } + + return ( + // $FlowFixMe: Flow doesn't know of React.Fragment yet + + + + {React.createElement( + withPluginLifecycleHooks(activePlugin, target), + { + key: pluginKey, + logger: this.props.logger, + persistedState: pluginStates[pluginKey], + setPersistedState: state => setPluginState({pluginKey, state}), + }, + )} + + + + + ); } } export default connect( - ({application: {rightSidebarVisible, rightSidebarAvailable}}) => ({ - rightSidebarVisible, - rightSidebarAvailable, + ({ + application: {rightSidebarVisible, rightSidebarAvailable}, + connections: {selectedPlugin, devices, selectedDeviceIndex}, + pluginStates, + }) => ({ + selectedPlugin, + devices, + selectedDeviceIndex, + pluginStates, }), { - toggleRightSidebarAvailable, - toggleRightSidebarVisible, + setPluginState, }, )(PluginContainer); diff --git a/src/chrome/DevicesButton.js b/src/chrome/DevicesButton.js index 2b5cfe2c7..2985a1cb3 100644 --- a/src/chrome/DevicesButton.js +++ b/src/chrome/DevicesButton.js @@ -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, devices: Array, -|}; - -type Emulator = {| - name: string, - os?: string, - isRunning: boolean, -|}; - -type State = { - androidEmulators: Array, - iOSSimulators: Array, - 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 { - 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> { - 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> { - 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 { - 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 { 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 ( - ); } } - -export default connect(({devices}) => ({ - devices, -}))(DevicesButton); +export default connect( + ({connections: {devices, androidEmulators, selectedDeviceIndex}}) => ({ + devices, + androidEmulators, + selectedDeviceIndex, + }), + {selectDevice}, +)(DevicesButton); diff --git a/src/chrome/MainSidebar.js b/src/chrome/MainSidebar.js index e0d05a8ce..4a7b7f97c 100644 --- a/src/chrome/MainSidebar.js +++ b/src/chrome/MainSidebar.js @@ -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>, - windowFocused: boolean, + plugin: Class>, + 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 ( - + - {Plugin.title} + {plugin.title} ); } } -function PluginSidebarList(props: {| - activePluginKey: ?string, - activeAppKey: ?string, - onActivatePlugin: (appKey: string, pluginKey: string) => void, - appKey: string, - appName?: string, - enabledPlugins: Array>>, - windowFocused: boolean, -|}) { - if (props.enabledPlugins.length === 0) { - return No available plugins for this device; - } - - return ( - - {props.enabledPlugins.map(Plugin => { - const isActive = - props.activeAppKey === props.appKey && - props.activePluginKey === Plugin.id; - return ( - - ); - })} - - ); -} - -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 [ - {`${client.query.app} (${ - client.query.os - }) - ${client.query.device}`}, - , - ]; -} - 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, - server: Server, + clients: Array, |}; -export default class MainSidebar extends Component { - static contextTypes = { - windowIsFocused: PropTypes.bool, - }; - +class MainSidebar extends Component { 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 ( - - ); - }); - - 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([ - {device.title}, - , - ]); - } + 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 ( - - {sidebarContent} + + {devicePlugins + .filter(device.supportsPlugin) + .map((plugin: Class>) => ( + + selectPlugin({ + selectedPlugin: plugin.id, + selectedApp: null, + }) + } + plugin={plugin} + /> + ))} + {clients.map((client: Client) => ( + // $FlowFixMe: Flow doesn't know of React.Fragment yet + + {client.query.app} + {plugins + .filter( + (p: Class>) => client.plugins.indexOf(p.id) > -1, + ) + .map((plugin: Class>) => ( + + selectPlugin({ + selectedPlugin: plugin.id, + selectedApp: client.id, + }) + } + plugin={plugin} + app={client.query.app} + /> + ))} + + ))} ); } } + +export default connect( + ({ + connections: {devices, selectedDeviceIndex, selectedPlugin, selectedApp}, + }) => ({ + devices, + selectedDeviceIndex, + selectedPlugin, + selectedApp, + }), + { + selectPlugin, + }, +)(MainSidebar); diff --git a/src/chrome/SonarSidebar.js b/src/chrome/SonarSidebar.js new file mode 100644 index 000000000..1875468be --- /dev/null +++ b/src/chrome/SonarSidebar.js @@ -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 { + 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( + + {this.props.children} + , + domNode, + ) + ); + } +} + +export default connect( + ({application: {rightSidebarVisible, rightSidebarAvailable}}) => ({ + rightSidebarVisible, + rightSidebarAvailable, + }), + { + toggleRightSidebarAvailable, + }, +)(SonarSidebar); diff --git a/src/createTablePlugin.js b/src/createTablePlugin.js index 95b8604b6..48493c19b 100644 --- a/src/createTablePlugin.js +++ b/src/createTablePlugin.js @@ -11,7 +11,7 @@ import type { TableColumnSizes, TableColumns, } from 'sonar'; -import {FlexColumn, Button} from 'sonar'; +import {FlexColumn, Button, SonarSidebar} from 'sonar'; import textContent from './utils/textContent.js'; import createPaste from './utils/createPaste.js'; import {SonarPlugin, SearchableTable} from 'sonar'; @@ -199,6 +199,7 @@ export function createTablePlugin(props: Props) { stickyBottom={true} actions={} /> + {this.renderSidebar()} ); } diff --git a/src/device-plugins/index.js b/src/device-plugins/index.js index 8d831f5d7..ce6b53cd4 100644 --- a/src/device-plugins/index.js +++ b/src/device-plugins/index.js @@ -4,12 +4,15 @@ * LICENSE file in the root directory of this source tree. * @format */ + +import type {SonarDevicePlugin} from '../plugin.js'; + import {GK} from 'sonar'; import logs from './logs/index.js'; import cpu from './cpu/index.js'; import screen from './screen/index.js'; -const plugins = [logs]; +const plugins: Array>> = [logs]; if (GK.get('sonar_uiperf')) { plugins.push(cpu); diff --git a/src/device-plugins/screen/index.js b/src/device-plugins/screen/index.js index 59d12e67f..c5b29456b 100644 --- a/src/device-plugins/screen/index.js +++ b/src/device-plugins/screen/index.js @@ -121,6 +121,13 @@ export default class ScreenPlugin extends SonarDevicePlugin { device: AndroidDevice; adbClient: AdbClient; + state = { + pullingData: false, + recording: false, + recordingEnabled: false, + capturingScreenshot: false, + }; + init() { this.adbClient = this.device.adb; diff --git a/src/devices/AndroidDevice.js b/src/devices/AndroidDevice.js index bb37e88f7..9e3ccbc6c 100644 --- a/src/devices/AndroidDevice.js +++ b/src/devices/AndroidDevice.js @@ -9,8 +9,6 @@ import type {DeviceType, DeviceShell, DeviceLogListener} from './BaseDevice.js'; import {Priority} from 'adbkit-logcat-fb'; import child_process from 'child_process'; - -// TODO import BaseDevice from './BaseDevice.js'; type ADBClient = any; @@ -39,6 +37,7 @@ export default class AndroidDevice extends BaseDevice { os = 'Android'; adb: ADBClient; pidAppMapping: {[key: number]: string} = {}; + logReader: any; supportedColumns(): Array { return ['date', 'pid', 'tid', 'tag', 'message', 'type', 'time']; diff --git a/src/devices/BaseDevice.js b/src/devices/BaseDevice.js index effb9d06a..90fdd7b17 100644 --- a/src/devices/BaseDevice.js +++ b/src/devices/BaseDevice.js @@ -53,9 +53,9 @@ export default class BaseDevice { // possible src of icon to display next to the device title icon: ?string; - supportsPlugin(DevicePlugin: Class>) { + supportsPlugin = (DevicePlugin: Class>): boolean => { return this.supportedPlugins.includes(DevicePlugin.id); - } + }; // ensure that we don't serialise devices toJSON() { diff --git a/src/dispatcher/androidDevice.js b/src/dispatcher/androidDevice.js index 5a9cc0eee..b11bfae01 100644 --- a/src/dispatcher/androidDevice.js +++ b/src/dispatcher/androidDevice.js @@ -6,6 +6,7 @@ */ import AndroidDevice from '../devices/AndroidDevice'; +import child_process from 'child_process'; import type {Store} from '../reducers/index.js'; import type BaseDevice from '../devices/BaseDevice'; const adb = require('adbkit-fb'); @@ -16,30 +17,61 @@ function createDecive(client, device): Promise { device.type !== 'device' || device.id.startsWith('emulator') ? 'emulator' : 'physical'; - client.getProperties(device.id).then(props => { - const androidDevice = new AndroidDevice( - device.id, - type, - props['ro.product.model'], - client, - ); + + client.getProperties(device.id).then(async props => { + let name = props['ro.product.model']; + if (type === 'emulator') { + name = (await getRunningEmulatorName(device.id)) || name; + } + const androidDevice = new AndroidDevice(device.id, type, name, client); androidDevice.reverse(); resolve(androidDevice); }); }); } +function getRunningEmulatorName(id: string): Promise { + return new Promise((resolve, reject) => { + const port = id.replace('emulator-', ''); + 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); + } + }, + ); + }); +} + export default (store: Store) => { const client = adb.createClient(); + // get emulators + child_process.exec( + '$ANDROID_HOME/tools/emulator -list-avds', + (error: ?Error, data: ?string) => { + if (error == null && data != null) { + const payload = data.split('\n').filter(Boolean); + store.dispatch({ + type: 'REGISTER_ANDROID_EMULATORS', + payload, + }); + } + }, + ); + client .trackDevices() .then(tracker => { tracker.on('error', err => { if (err.message === 'Connection closed') { // adb server has shutdown, remove all android devices - const {devices} = store.getState(); - const deviceIDsToRemove: Array = devices + const {connections} = store.getState(); + const deviceIDsToRemove: Array = connections.devices .filter((device: BaseDevice) => device instanceof AndroidDevice) .map((device: BaseDevice) => device.serial); @@ -56,8 +88,8 @@ export default (store: Store) => { }); tracker.on('add', async device => { - const androidDevice = await createDecive(client, device); if (device.type !== 'offline') { + const androidDevice = await createDecive(client, device); store.dispatch({ type: 'REGISTER_DEVICE', payload: androidDevice, diff --git a/src/dispatcher/iOSDevice.js b/src/dispatcher/iOSDevice.js index da9f9a5ad..559ca7fca 100644 --- a/src/dispatcher/iOSDevice.js +++ b/src/dispatcher/iOSDevice.js @@ -54,9 +54,8 @@ export default (store: Store) => { if (process.platform !== 'darwin') { return; } - let simulatorUpdateInterval = setInterval(() => { - const {devices} = store.getState(); - clearInterval(simulatorUpdateInterval); + const simulatorUpdateInterval = setInterval(() => { + const {connections} = store.getState(); querySimulatorDevices() .then((simulatorDevices: IOSDeviceMap) => { const simulators: Array = Object.values( @@ -65,7 +64,7 @@ export default (store: Store) => { ).reduce((acc, cv) => acc.concat(cv), []); const currentDeviceIDs: Set = new Set( - devices + connections.devices .filter(device => device instanceof IOSDevice) .map(device => device.serial), ); diff --git a/src/index.js b/src/index.js index 776db0f76..065734b56 100644 --- a/src/index.js +++ b/src/index.js @@ -12,6 +12,7 @@ export * from './utils/index.js'; export {default as GK} from './fb-stubs/GK.js'; export {SonarBasePlugin, SonarPlugin, SonarDevicePlugin} from './plugin.js'; export {createTablePlugin} from './createTablePlugin.js'; +export {default as SonarSidebar} from './chrome/SonarSidebar.js'; export * from './init.js'; export {default} from './init.js'; diff --git a/src/init.js b/src/init.js index 97f52223b..90a61d2fe 100644 --- a/src/init.js +++ b/src/init.js @@ -14,6 +14,7 @@ import App from './App.js'; import {createStore} from 'redux'; import reducers from './reducers/index.js'; import dispatcher from './dispatcher/index.js'; +import {setupMenuBar} from './MenuBar.js'; const path = require('path'); const store = createStore( @@ -24,6 +25,7 @@ const store = createStore( dispatcher(store); GK.init(); +setupMenuBar(); const AppFrame = () => ( diff --git a/src/plugin.js b/src/plugin.js index 02a687a61..4ae2693c1 100644 --- a/src/plugin.js +++ b/src/plugin.js @@ -7,8 +7,10 @@ import type {KeyboardActions} from './MenuBar.js'; import type {App} from './App.js'; +import type Logger from './fb-stubs/Logger.js'; import type Client from './Client.js'; +import React from 'react'; import BaseDevice from './devices/BaseDevice.js'; import {AndroidDevice, IOSDevice} from 'sonar'; @@ -22,37 +24,20 @@ export type PluginClient = {| type PluginTarget = BaseDevice | Client; -/** - * This is a wrapper for a plugin instance and state. We have a special toJSON method that removes the plugin - * instance and any state if it's not set to be persisted. - */ -export class PluginStateContainer { - constructor(plugin: SonarBasePlugin<>, state: Object) { - this.plugin = plugin; - this.state = state; - } - - plugin: ?SonarBasePlugin<>; - state: Object; - - toJSON() { - return { - plugin: null, - state: this.plugin != null ? this.state : null, - }; - } -} - -export class SonarBasePlugin { - constructor() { - // $FlowFixMe: this is fine - this.state = {}; - } +export type Props = { + logger: Logger, + persistedState: T, + setPersistedState: (state: $Shape) => void, +}; +export class SonarBasePlugin< + State = *, + Actions = *, + PersistedState = *, +> extends React.Component, State> { static title: string = 'Unknown'; static id: string = 'Unknown'; static icon: string = 'apps'; - static persist: boolean = true; static keyboardActions: ?KeyboardActions; static screenshot: ?string; @@ -60,19 +45,18 @@ export class SonarBasePlugin { title: empty; id: empty; persist: empty; + icon: empty; + keyboardActions: empty; + screenshot: empty; - namespaceKey: string; reducers: { [actionName: string]: (state: State, actionData: Object) => $Shape, } = {}; app: App; - state: State; - renderSidebar: ?() => ?React.Element<*>; - renderIntro: ?() => ?React.Element<*>; onKeyboardAction: ?(action: string) => void; toJSON() { - return null; + return this.constructor.title; } // methods to be overriden by plugins @@ -81,37 +65,7 @@ export class SonarBasePlugin { // methods to be overridden by subclasses _init(): void {} _teardown(): void {} - _setup(target: PluginTarget, app: App) { - this.app = app; - } - - setState( - state: $Shape | ((state: State) => $Shape), - callback?: () => void, - ) { - if (typeof state === 'function') { - state = state(this.state); - } - this.state = Object.assign({}, this.state, state); - - const pluginKey = this.constructor.id; - const namespaceKey = this.namespaceKey; - const appState = this.app.state; - - // update app state - this.app.setState( - { - plugins: { - ...appState.plugins, - [namespaceKey]: { - ...(appState.plugins[namespaceKey] || {}), - [pluginKey]: new PluginStateContainer(this, this.state), - }, - }, - }, - callback, - ); - } + _setup(target: PluginTarget) {} dispatchAction(actionData: Actions) { // $FlowFixMe @@ -128,29 +82,17 @@ export class SonarBasePlugin { throw new TypeError(`Reducer ${actionData.type} isn't a function`); } } - - render(): any { - return null; - } } -export class SonarDevicePlugin< - State: Object = any, - Actions = any, -> extends SonarBasePlugin { +export class SonarDevicePlugin extends SonarBasePlugin { device: BaseDevice; - _setup(target: PluginTarget, app: App) { + _setup(target: PluginTarget) { invariant(target instanceof BaseDevice, 'expected instanceof Client'); const device: BaseDevice = target; - this.namespaceKey = device.serial; this.device = device; - super._setup(device, app); - } - - _teardown() { - this.teardown(); + super._setup(device); } _init() { @@ -158,10 +100,7 @@ export class SonarDevicePlugin< } } -export class SonarPlugin< - State: Object = any, - Actions = any, -> extends SonarBasePlugin { +export class SonarPlugin extends SonarBasePlugin { constructor() { super(); this.subscriptions = []; @@ -197,14 +136,13 @@ export class SonarPlugin< return device; } - _setup(target: any, app: App) { + _setup(target: any) { /* We have to type the above as `any` since if we import the actual Client we have an unresolvable dependency cycle */ const realClient: Client = target; const id: string = this.constructor.id; - this.namespaceKey = realClient.id; this.realClient = realClient; this.client = { call: (method, params) => realClient.call(id, method, params), @@ -218,7 +156,7 @@ export class SonarPlugin< }, }; - super._setup(realClient, app); + super._setup(realClient); } _teardown() { diff --git a/src/plugins/index.js b/src/plugins/index.js index 1c9c3b377..0df6e1373 100644 --- a/src/plugins/index.js +++ b/src/plugins/index.js @@ -9,7 +9,7 @@ import {GK} from 'sonar'; import React from 'react'; import ReactDOM from 'react-dom'; import * as Sonar from 'sonar'; -import {SonarBasePlugin} from '../plugin.js'; +import {SonarPlugin, SonarBasePlugin} from '../plugin.js'; const plugins = new Map(); @@ -52,7 +52,9 @@ bundledPlugins })) .forEach(addIfNotAdded); -export default Array.from(plugins.values()) +const exportedPlugins: Array>> = Array.from( + plugins.values(), +) .map(plugin => { if ( (plugin.gatekeeper && !GK.get(plugin.gatekeeper)) || @@ -70,3 +72,5 @@ export default Array.from(plugins.values()) }) .filter(Boolean) .filter(plugin => plugin.prototype instanceof SonarBasePlugin); + +export default exportedPlugins; diff --git a/src/plugins/layout/index.js b/src/plugins/layout/index.js index 0b051ef92..e39d9b64e 100644 --- a/src/plugins/layout/index.js +++ b/src/plugins/layout/index.js @@ -21,6 +21,7 @@ import { SearchBox, SearchInput, SearchIcon, + SonarSidebar, } from 'sonar'; // $FlowFixMe @@ -313,7 +314,7 @@ export default class Layout extends SonarPlugin { this.dispatchAction({elements: [element], type: 'UpdateElements'}); this.dispatchAction({root: element.id, type: 'SetRoot'}); this.performInitialExpand(element).then(() => { - this.app.logger.trackTimeSince('LayoutInspectorInitialize'); + this.props.logger.trackTimeSince('LayoutInspectorInitialize'); this.setState({initialised: true}); }); }); @@ -400,7 +401,7 @@ export default class Layout extends SonarPlugin { return this.client .call('getNodes', {ids}) .then(({elements}: GetNodesResult) => { - this.app.logger.trackTimeSince('LayoutInspectorGetNodes'); + this.props.logger.trackTimeSince('LayoutInspectorGetNodes'); return Promise.resolve(elements); }); } else { @@ -425,7 +426,7 @@ export default class Layout extends SonarPlugin { performance.mark('LayoutInspectorExpandElement'); if (expand) { return this.getChildren(key).then((elements: Array) => { - this.app.logger.trackTimeSince('LayoutInspectorExpandElement'); + this.props.logger.trackTimeSince('LayoutInspectorExpandElement'); this.dispatchAction({elements, type: 'UpdateElements'}); return Promise.resolve(elements); }); @@ -468,7 +469,7 @@ export default class Layout extends SonarPlugin { } else { this.expandElement(key); } - this.app.logger.track('usage', 'layout:element-expanded', { + this.props.logger.track('usage', 'layout:element-expanded', { id: key, deep: deep, }); @@ -494,7 +495,7 @@ export default class Layout extends SonarPlugin { onDataValueChanged = (path: Array, value: any) => { this.client.send('setData', {id: this.state.selected, path, value}); - this.app.logger.track('usage', 'layout:value-changed', { + this.props.logger.track('usage', 'layout:value-changed', { id: this.state.selected, value: value, path: path, @@ -567,6 +568,7 @@ export default class Layout extends SonarPlugin { )} + {this.renderSidebar()} ); } diff --git a/src/plugins/network/index.js b/src/plugins/network/index.js index 316d41668..d34a8bcc3 100644 --- a/src/plugins/network/index.js +++ b/src/plugins/network/index.js @@ -15,6 +15,7 @@ import { Glyph, colors, PureComponent, + SonarSidebar, } from 'sonar'; import {SonarPlugin, SearchableTable} from 'sonar'; @@ -188,6 +189,7 @@ export default class extends SonarPlugin { clear={this.clearLogs} onRowHighlighted={this.onRowHighlighted} /> + {this.renderSidebar()} ); } diff --git a/src/reducers.js b/src/reducers.js deleted file mode 100644 index 808c17543..000000000 --- a/src/reducers.js +++ /dev/null @@ -1,184 +0,0 @@ -/** - * 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 type { - App, - State, - StatePluginInfo, - StatePlugins, - StateClientPlugins, -} from './App.js'; -import type {SonarBasePlugin} from 'sonar'; -import {devicePlugins} from './device-plugins/index.js'; -import {SonarPlugin, SonarDevicePlugin} from 'sonar'; -import {PluginStateContainer} from './plugin.js'; -import BaseDevice from './devices/BaseDevice.js'; -import plugins from './plugins/index.js'; -import Client from './Client.js'; - -const invariant = require('invariant'); - -type ActivatePluginAction = {| - appKey: string, - pluginKey: string, -|}; - -type TeardownClientAction = {| - appKey: string, -|}; - -export function ActivatePlugin( - app: App, - state: State, - {appKey, pluginKey}: ActivatePluginAction, -) { - const {activePluginKey, activeAppKey} = state; - - // get currently active plugin - const activeClientPlugins: ?StateClientPlugins = - activeAppKey != null ? state.plugins[activeAppKey] : null; - const activePluginInfo: ?StatePluginInfo = - activePluginKey != null && activeClientPlugins - ? activeClientPlugins[activePluginKey] - : null; - - // check if this plugin is already active - if ( - activePluginKey === pluginKey && - activeAppKey === appKey && - activePluginInfo && - activePluginInfo.plugin - ) { - // this is a noop - return state; - } - - // produce new plugins object - const newPluginsState: StatePlugins = { - ...state.plugins, - }; - - // check if the currently active plugin needs to be torn down after being deactivated - if ( - activeAppKey != null && - activePluginKey != null && - activePluginInfo && - activeClientPlugins - ) { - const activePlugin: ?SonarBasePlugin<> = activePluginInfo.plugin; - if (activePlugin && !activePlugin.constructor.persist) { - // teardown the currently active plugin - activePlugin._teardown(); - - // and remove it's plugin instance so next time it's made active it'll be reloaded - newPluginsState[activeAppKey] = { - ...activeClientPlugins, - [activePluginKey]: { - plugin: null, - state: activePluginInfo.state, - }, - }; - } - } - - // get the plugin state associated with the new client - const newClientPluginsState: StateClientPlugins = { - ...(newPluginsState[appKey] || {}), - }; - newPluginsState[appKey] = newClientPluginsState; - - // find the Plugin constructor with this key - let Plugin: Class>; - for (const FindPlugin of plugins) { - if (FindPlugin.id === pluginKey) { - Plugin = FindPlugin; - } - } - for (const FindPlugin of devicePlugins) { - if (FindPlugin.id === pluginKey) { - Plugin = FindPlugin; - } - } - invariant(Plugin, 'expected plugin'); - - // get target, this could be an app connection or a device - const clientInfo = state.server.connections.get(appKey); - let target: void | Client | BaseDevice; - if (clientInfo) { - target = clientInfo.client; - invariant( - // $FlowFixMe prototype not known - Plugin.prototype instanceof SonarPlugin, - 'expected plugin to be an app Plugin', - ); - } else { - target = app.props.devices.find( - (device: BaseDevice) => device.serial === appKey, - ); - invariant( - // $FlowFixMe prototype not known - Plugin.prototype instanceof SonarDevicePlugin, - 'expected plugin to be DevicePlugin', - ); - } - invariant(target, 'expected target'); - - // initialise the client if it hasn't alreadu been - const thisPluginState: ?StatePluginInfo = newClientPluginsState[pluginKey]; - if (!thisPluginState || !thisPluginState.plugin) { - const plugin = new Plugin(); - - // setup plugin, this is to avoid consumers having to pass args to super - plugin._setup(target, app); - - // if we already have state for this plugin then rehydrate it - if (thisPluginState && thisPluginState.state) { - plugin.state = thisPluginState.state; - } - - // init plugin - setup broadcasts, initial messages etc - plugin._init(); - - newClientPluginsState[pluginKey] = new PluginStateContainer( - plugin, - plugin.state, - ); - } - - return { - activeAppKey: appKey, - activePluginKey: pluginKey, - plugins: newPluginsState, - }; -} - -export function TeardownClient( - app: App, - state: State, - {appKey}: TeardownClientAction, -) { - const allPlugins: StatePlugins = {...state.plugins}; - - // teardown all plugins - const clientPlugins: StateClientPlugins = allPlugins[appKey]; - for (const pluginKey in clientPlugins) { - const {plugin} = clientPlugins[pluginKey]; - if (plugin) { - plugin._teardown(); - } - } - - // remove this client - delete allPlugins[appKey]; - - return { - activeAppKey: state.activeAppKey === appKey ? null : state.activeAppKey, - activePluginKey: - state.activeAppKey === appKey ? null : state.activePluginKey, - plugins: allPlugins, - }; -} diff --git a/src/reducers/connections.js b/src/reducers/connections.js new file mode 100644 index 000000000..f71aa4c40 --- /dev/null +++ b/src/reducers/connections.js @@ -0,0 +1,148 @@ +/** + * 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 type BaseDevice from '../devices/BaseDevice'; +export type State = { + devices: Array, + androidEmulators: Array, + selectedDeviceIndex: number, + selectedPlugin: ?string, + selectedApp: ?string, +}; + +export type Action = + | { + type: 'UNREGISTER_DEVICES', + payload: Set, + } + | { + type: 'REGISTER_DEVICE', + payload: BaseDevice, + } + | { + type: 'REGISTER_ANDROID_EMULATORS', + payload: Array, + } + | { + type: 'SELECT_DEVICE', + payload: number, + } + | { + type: 'SELECT_PLUGIN', + payload: { + selectedPlugin: ?string, + selectedApp: ?string, + }, + }; + +const DEFAULT_PLUGIN = 'DeviceLogs'; + +const INITAL_STATE: State = { + devices: [], + androidEmulators: [], + selectedDeviceIndex: -1, + selectedApp: null, + selectedPlugin: DEFAULT_PLUGIN, +}; + +export default function reducer( + state: State = INITAL_STATE, + action: Action, +): State { + switch (action.type) { + case 'SELECT_DEVICE': { + const {payload} = action; + return { + ...state, + selectedApp: null, + selectedPlugin: DEFAULT_PLUGIN, + selectedDeviceIndex: payload, + }; + } + case 'REGISTER_ANDROID_EMULATORS': { + const {payload} = action; + return { + ...state, + androidEmulators: payload, + }; + } + case 'REGISTER_DEVICE': { + const {payload} = action; + const devices = state.devices.concat(payload); + let {selectedDeviceIndex} = state; + let selection = {}; + if (selectedDeviceIndex === -1) { + selectedDeviceIndex = devices.length - 1; + selection = { + selectedApp: null, + selectedPlugin: DEFAULT_PLUGIN, + }; + } + return { + ...state, + devices, + // select device if none was selected before + selectedDeviceIndex, + ...selection, + }; + } + case 'UNREGISTER_DEVICES': { + const {payload} = action; + const {selectedDeviceIndex} = state; + let selectedDeviceWasRemoved = false; + const devices = state.devices.filter((device: BaseDevice, i: number) => { + if (payload.has(device.serial)) { + if (selectedDeviceIndex === i) { + // removed device is the selected + selectedDeviceWasRemoved = true; + } + return false; + } else { + return true; + } + }); + + let selection = {}; + if (selectedDeviceWasRemoved) { + selection = { + selectedDeviceIndex: devices.length - 1, + selectedApp: null, + selectedPlugin: DEFAULT_PLUGIN, + }; + } + + return { + ...state, + devices, + ...selection, + }; + } + case 'SELECT_PLUGIN': { + const {payload} = action; + + return { + ...state, + ...payload, + }; + } + default: + return state; + } +} + +export const selectDevice = (payload: number): Action => ({ + type: 'SELECT_DEVICE', + payload, +}); + +export const selectPlugin = (payload: { + selectedPlugin: ?string, + selectedApp: ?string, +}): Action => ({ + type: 'SELECT_PLUGIN', + payload, +}); diff --git a/src/reducers/devices.js b/src/reducers/devices.js deleted file mode 100644 index 763d68c13..000000000 --- a/src/reducers/devices.js +++ /dev/null @@ -1,39 +0,0 @@ -/** - * 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 type BaseDevice from '../devices/BaseDevice'; -export type State = Array; - -export type Action = - | { - type: 'UNREGISTER_DEVICES', - payload: Set, - } - | { - type: 'REGISTER_DEVICE', - payload: BaseDevice, - }; - -const INITIAL_STATE: State = []; - -export default function reducer( - state: State = INITIAL_STATE, - action: Action, -): State { - switch (action.type) { - case 'REGISTER_DEVICE': { - const {payload} = action; - return state.concat(payload); - } - case 'UNREGISTER_DEVICES': { - const {payload} = action; - return state.filter((device: BaseDevice) => !payload.has(device.serial)); - } - default: - return state; - } -} diff --git a/src/reducers/index.js b/src/reducers/index.js index de34adb89..2af1bbd96 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -7,7 +7,8 @@ import {combineReducers} from 'redux'; import application from './application.js'; -import devices from './devices.js'; +import connections from './connections.js'; +import pluginStates from './pluginStates.js'; import type { State as ApplicationState, Action as ApplicationAction, @@ -15,15 +16,20 @@ import type { import type { State as DevicesState, Action as DevicesAction, -} from './devices.js'; +} from './connections.js'; +import type { + State as PluginsState, + Action as PluginsAction, +} from './pluginStates.js'; import type {Store as ReduxStore} from 'redux'; export type Store = ReduxStore< { application: ApplicationState, - devices: DevicesState, + connections: DevicesState, + pluginStates: PluginsState, }, - ApplicationAction | DevicesAction, + ApplicationAction | DevicesAction | PluginsAction, >; -export default combineReducers({application, devices}); +export default combineReducers({application, connections, pluginStates}); diff --git a/src/reducers/pluginStates.js b/src/reducers/pluginStates.js new file mode 100644 index 000000000..fc9aeee8e --- /dev/null +++ b/src/reducers/pluginStates.js @@ -0,0 +1,42 @@ +/** + * 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 + */ + +export type State = { + [pluginKey: string]: Object, +}; + +export type Action = { + type: 'SET_PLUGIN_STATE', + payload: { + pluginKey: string, + state: Object, + }, +}; + +const INITIAL_STATE: State = {}; + +export default function reducer( + state: State = INITIAL_STATE, + action: Action, +): State { + if (action.type === 'SET_PLUGIN_STATE') { + return { + ...state, + [action.payload.pluginKey]: action.payload.state, + }; + } else { + return state; + } +} + +export const setPluginState = (payload: { + pluginKey: string, + state: Object, +}): Action => ({ + type: 'SET_PLUGIN_STATE', + payload, +}); diff --git a/src/ui/components/Button.js b/src/ui/components/Button.js index 384d1b2e8..5a0818352 100644 --- a/src/ui/components/Button.js +++ b/src/ui/components/Button.js @@ -125,6 +125,7 @@ const StyledButton = styled.view( }, { ignoreAttributes: [ + 'dropdown', 'dispatch', 'compact', 'large', diff --git a/src/ui/components/ErrorBoundary.js b/src/ui/components/ErrorBoundary.js index 683b49e4d..798e7bb4f 100644 --- a/src/ui/components/ErrorBoundary.js +++ b/src/ui/components/ErrorBoundary.js @@ -19,6 +19,7 @@ const ErrorBoundaryContainer = View.extends({ const ErrorBoundaryStack = ErrorBlock.extends({ marginBottom: 10, + whiteSpace: 'pre', }); type ErrorBoundaryProps = { @@ -43,8 +44,7 @@ export default class ErrorBoundary extends Component< } componentDidCatch(err: Error) { - this.props.logger && - console.error(err.toString(), 'ErrorBoundary'); + this.props.logger && console.error(err.toString(), 'ErrorBoundary'); this.setState({error: err}); }