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