show only one device in sidbar

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

Reviewed By: jknoxville

Differential Revision: D8186933

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

View File

@@ -9,7 +9,6 @@
.*/website/.*
[libs]
lib
flow-typed
[options]

View File

@@ -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<Props, State> {
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<Props, State> {
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<Props, State> {
// 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<Props, State> {
});
};
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<BaseDevice>);
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 ? (
<PluginManager />
) : (
<ErrorBoundary
heading={`Plugin "${
plugin.constructor.title
}" encountered an error during render`}
logger={this.logger}>
<PluginContainer
logger={this.logger}
plugin={plugin}
state={plugin.state}
/>
</ErrorBoundary>
);
}
}
return (
<FlexColumn fill={true}>
<SonarTitleBar />
@@ -324,25 +195,26 @@ export class App extends React.Component<Props, State> {
close={() => this.props.toggleBugDialogVisible(false)}
/>
)}
{hasDevices ? (
{this.props.selectedDeviceIndex > -1 ? (
<FlexRow fill={true}>
{this.props.leftSidebarVisible && (
<MainSidebar
activePluginKey={state.activePluginKey}
activeAppKey={state.activeAppKey}
devices={this.props.devices}
server={state.server}
onActivatePlugin={this.onActivatePlugin}
clients={Array.from(this.server.connections.values()).map(
({client}) => client,
)}
/>
)}
{mainView}
<PluginContainer
logger={this.logger}
client={this.getClient(this.props.selectedApp)}
/>
</FlexRow>
) : this.props.pluginManagerVisible ? (
<PluginManager />
) : (
<WelcomeScreen />
)}
<ErrorBar text={state.error} />
<ErrorBar text={this.state.error} />
</FlexColumn>
);
}
@@ -351,12 +223,14 @@ export class App extends React.Component<Props, State> {
export default connect(
({
application: {pluginManagerVisible, bugDialogVisible, leftSidebarVisible},
devices,
connections: {devices, selectedDeviceIndex, selectedApp},
}) => ({
pluginManagerVisible,
bugDialogVisible,
leftSidebarVisible,
devices,
selectedDeviceIndex,
selectedApp,
}),
{toggleBugDialogVisible},
)(App);

View File

@@ -60,7 +60,16 @@ export type KeyboardActions = Array<DefaultKeyboardAction | KeyboardAction>;
const menuItems: Map<string, Object> = 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<SonarBasePlugin<>> = [
...devicePlugins,
...plugins,
].find((plugin: Class<SonarBasePlugin<>>) => 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

View File

@@ -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';
type Props = {
plugin: SonarBasePlugin<>,
state?: any,
logger: LogManager,
rightSidebarVisible: boolean,
rightSidebarAvailable: boolean,
toggleRightSidebarVisible: (available: ?boolean) => void,
toggleRightSidebarAvailable: (available: ?boolean) => void,
};
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';
type State = {
showIntro: boolean,
};
class PluginContainer extends Component<Props, State> {
state = {
showIntro:
typeof this.props.plugin.renderIntro === 'function' &&
window.localStorage.getItem(
`${this.props.plugin.constructor.id}.introShown`,
) !== 'true',
};
_sidebar: ?React$Node;
static Container = FlexColumn.extends({
const Container = FlexColumn.extends({
width: 0,
flexGrow: 1,
flexShrink: 1,
backgroundColor: colors.white,
});
componentWillUnmount() {
performance.mark(`init_${this.props.plugin.constructor.id}`);
}
const SidebarContainer = FlexRow.extends({
backgroundColor: colors.light02,
height: '100%',
overflow: 'scroll',
});
type Props = {
logger: LogManager,
selectedDeviceIndex: number,
selectedPlugin: ?string,
pluginStates: Object,
client: ?Client,
devices: Array<BaseDevice>,
setPluginState: (payload: {
pluginKey: string,
state: Object,
}) => void,
};
type State = {
activePlugin: ?Class<SonarBasePlugin<>>,
target: Client | BaseDevice | null,
pluginKey: string,
};
function withPluginLifecycleHooks(
PluginComponent: Class<SonarBasePlugin<>>,
target: Client | BaseDevice,
) {
return class extends React.Component<PluginProps<any>> {
plugin: ?SonarBasePlugin<>;
static displayName = `${PluginComponent.title}Plugin`;
componentDidMount() {
this.props.logger.trackTimeSince(
`init_${this.props.plugin.constructor.id}`,
const {plugin} = this;
if (plugin) {
activateMenuItems(plugin);
plugin._setup(target);
plugin._init();
}
}
componentWillUnmount() {
if (this.plugin) {
this.plugin._teardown();
}
}
render() {
return (
<PluginComponent
ref={(ref: ?SonarBasePlugin<>) => {
if (ref) {
this.plugin = ref;
}
}}
{...this.props}
/>
);
}
};
}
componentDidUpdate(prevProps: Props) {
if (prevProps.plugin !== this.props.plugin) {
this.props.logger.trackTimeSince(
`init_${this.props.plugin.constructor.id}`,
class PluginContainer extends Component<Props, State> {
static getDerivedStateFromProps(props: Props) {
let activePlugin = devicePlugins.find(
(p: Class<SonarDevicePlugin<>>) => p.id === props.selectedPlugin,
);
}
}
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 = (
<Sidebar position="right" width={400} key="sidebar">
{sidebarContent}
</Sidebar>
activePlugin = plugins.find(
(p: Class<SonarPlugin<>>) => p.id === props.selectedPlugin,
);
nextProps.toggleRightSidebarAvailable(true);
if (!activePlugin || !props.client) {
return null;
}
target = props.client;
pluginKey = `${target.id}#${activePlugin.id}`;
}
onDismissIntro = () => {
const {plugin} = this.props;
window.localStorage.setItem(`${plugin.constructor.id}.introShown`, 'true');
this.setState({
showIntro: false,
});
return {
pluginKey,
activePlugin,
target,
};
}
state = {
pluginKey: 'unknown',
activePlugin: null,
target: null,
};
render() {
const {plugin} = this.props;
const {pluginStates, setPluginState} = this.props;
const {activePlugin, pluginKey, target} = this.state;
return [
<PluginContainer.Container key="plugin">
{this.state.showIntro ? (
<Intro
title={plugin.constructor.title}
icon={plugin.constructor.icon}
screenshot={plugin.constructor.screenshot}
onDismiss={this.onDismissIntro}>
{typeof plugin.renderIntro === 'function' && plugin.renderIntro()}
</Intro>
) : (
plugin.render()
if (!activePlugin || !target) {
return null;
}
return (
// $FlowFixMe: Flow doesn't know of React.Fragment yet
<React.Fragment>
<Container key="plugin">
<ErrorBoundary
heading={`Plugin "${
activePlugin.title
}" encountered an error during render`}
logger={this.props.logger}>
{React.createElement(
withPluginLifecycleHooks(activePlugin, target),
{
key: pluginKey,
logger: this.props.logger,
persistedState: pluginStates[pluginKey],
setPersistedState: state => setPluginState({pluginKey, state}),
},
)}
</PluginContainer.Container>,
this.props.rightSidebarVisible === false ? null : this._sidebar,
];
</ErrorBoundary>
</Container>
<SidebarContainer id="sonarSidebar" />
</React.Fragment>
);
}
}
export default connect(
({application: {rightSidebarVisible, rightSidebarAvailable}}) => ({
rightSidebarVisible,
rightSidebarAvailable,
({
application: {rightSidebarVisible, rightSidebarAvailable},
connections: {selectedPlugin, devices, selectedDeviceIndex},
pluginStates,
}) => ({
selectedPlugin,
devices,
selectedDeviceIndex,
pluginStates,
}),
{
toggleRightSidebarAvailable,
toggleRightSidebarVisible,
setPluginState,
},
)(PluginContainer);

View File

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

View File

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

View File

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

View File

@@ -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<T: RowData>(props: Props<T>) {
stickyBottom={true}
actions={<Button onClick={this.clear}>Clear Table</Button>}
/>
<SonarSidebar>{this.renderSidebar()}</SonarSidebar>
</FlexColumn>
);
}

View File

@@ -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<Class<SonarDevicePlugin<any>>> = [logs];
if (GK.get('sonar_uiperf')) {
plugins.push(cpu);

View File

@@ -121,6 +121,13 @@ export default class ScreenPlugin extends SonarDevicePlugin<State> {
device: AndroidDevice;
adbClient: AdbClient;
state = {
pullingData: false,
recording: false,
recordingEnabled: false,
capturingScreenshot: false,
};
init() {
this.adbClient = this.device.adb;

View File

@@ -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<string> {
return ['date', 'pid', 'tid', 'tag', 'message', 'type', 'time'];

View File

@@ -53,9 +53,9 @@ export default class BaseDevice {
// possible src of icon to display next to the device title
icon: ?string;
supportsPlugin(DevicePlugin: Class<SonarDevicePlugin<>>) {
supportsPlugin = (DevicePlugin: Class<SonarDevicePlugin<>>): boolean => {
return this.supportedPlugins.includes(DevicePlugin.id);
}
};
// ensure that we don't serialise devices
toJSON() {

View File

@@ -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<AndroidDevice> {
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<?string> {
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<string> = devices
const {connections} = store.getState();
const deviceIDsToRemove: Array<string> = 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,

View File

@@ -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<iOSSimulatorDevice> = Object.values(
@@ -65,7 +64,7 @@ export default (store: Store) => {
).reduce((acc, cv) => acc.concat(cv), []);
const currentDeviceIDs: Set<string> = new Set(
devices
connections.devices
.filter(device => device instanceof IOSDevice)
.map(device => device.serial),
);

View File

@@ -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';

View File

@@ -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 = () => (
<ContextMenuProvider>

View File

@@ -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 type Props<T> = {
logger: Logger,
persistedState: T,
setPersistedState: (state: $Shape<T>) => void,
};
}
}
export class SonarBasePlugin<State: Object = any, Actions = any> {
constructor() {
// $FlowFixMe: this is fine
this.state = {};
}
export class SonarBasePlugin<
State = *,
Actions = *,
PersistedState = *,
> extends React.Component<Props<PersistedState>, 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<State: Object = any, Actions = any> {
title: empty;
id: empty;
persist: empty;
icon: empty;
keyboardActions: empty;
screenshot: empty;
namespaceKey: string;
reducers: {
[actionName: string]: (state: State, actionData: Object) => $Shape<State>,
} = {};
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<State: Object = any, Actions = any> {
// methods to be overridden by subclasses
_init(): void {}
_teardown(): void {}
_setup(target: PluginTarget, app: App) {
this.app = app;
}
setState(
state: $Shape<State> | ((state: State) => $Shape<State>),
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<State: Object = any, Actions = any> {
throw new TypeError(`Reducer ${actionData.type} isn't a function`);
}
}
render(): any {
return null;
}
}
export class SonarDevicePlugin<
State: Object = any,
Actions = any,
> extends SonarBasePlugin<State, Actions> {
export class SonarDevicePlugin<S = *, A = *> extends SonarBasePlugin<S, A> {
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<State, Actions> {
export class SonarPlugin<S = *, A = *> extends SonarBasePlugin<S, A> {
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() {

View File

@@ -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<Class<SonarPlugin<>>> = 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;

View File

@@ -21,6 +21,7 @@ import {
SearchBox,
SearchInput,
SearchIcon,
SonarSidebar,
} from 'sonar';
// $FlowFixMe
@@ -313,7 +314,7 @@ export default class Layout extends SonarPlugin<InspectorState> {
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<InspectorState> {
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<InspectorState> {
performance.mark('LayoutInspectorExpandElement');
if (expand) {
return this.getChildren(key).then((elements: Array<Element>) => {
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<InspectorState> {
} 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<InspectorState> {
onDataValueChanged = (path: Array<string>, 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<InspectorState> {
</Center>
)}
</FlexRow>
<SonarSidebar>{this.renderSidebar()}</SonarSidebar>
</FlexColumn>
);
}

View File

@@ -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<State> {
clear={this.clearLogs}
onRowHighlighted={this.onRowHighlighted}
/>
<SonarSidebar>{this.renderSidebar()}</SonarSidebar>
</FlexColumn>
);
}

View File

@@ -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<SonarBasePlugin<>>;
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,
};
}

148
src/reducers/connections.js Normal file
View File

@@ -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<BaseDevice>,
androidEmulators: Array<string>,
selectedDeviceIndex: number,
selectedPlugin: ?string,
selectedApp: ?string,
};
export type Action =
| {
type: 'UNREGISTER_DEVICES',
payload: Set<string>,
}
| {
type: 'REGISTER_DEVICE',
payload: BaseDevice,
}
| {
type: 'REGISTER_ANDROID_EMULATORS',
payload: Array<string>,
}
| {
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,
});

View File

@@ -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<BaseDevice>;
export type Action =
| {
type: 'UNREGISTER_DEVICES',
payload: Set<string>,
}
| {
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;
}
}

View File

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

View File

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

View File

@@ -125,6 +125,7 @@ const StyledButton = styled.view(
},
{
ignoreAttributes: [
'dropdown',
'dispatch',
'compact',
'large',

View File

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