Initial commit 🎉

fbshipit-source-id: b6fc29740c6875d2e78953b8a7123890a67930f2
Co-authored-by: Sebastian McKenzie <sebmck@fb.com>
Co-authored-by: John Knox <jknox@fb.com>
Co-authored-by: Emil Sjölander <emilsj@fb.com>
Co-authored-by: Pritesh Nandgaonkar <prit91@fb.com>
This commit is contained in:
Daniel Büchele
2018-04-13 08:38:06 -07:00
committed by Daniel Buchele
commit fbbf8cf16b
659 changed files with 87130 additions and 0 deletions

363
src/App.js Normal file
View File

@@ -0,0 +1,363 @@
/**
* 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 {ErrorBoundary, 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';
import MainSidebar from './chrome/MainSidebar.js';
import {SonarBasePlugin} from './plugin.js';
import {Server, Client} from './server.js';
import * as reducers from './reducers.js';
import React from 'react';
import BugReporter from './fb-stubs/BugReporter.js';
import ErrorReporter from './fb-stubs/ErrorReporter.js';
import BugReporterDialog from './chrome/BugReporterDialog.js';
import ErrorBar from './chrome/ErrorBar.js';
import Logger from './fb-stubs/Logger.js';
import PluginContainer from './PluginContainer.js';
import PluginManager from './chrome/PluginManager.js';
const electron = require('electron');
const yargs = require('yargs');
export type {Client};
export type StatePluginInfo = {
plugin: ?SonarBasePlugin<>,
state: Object,
};
export type StateClientPlugins = {
[pluginKey: string]: StatePluginInfo,
};
export type StatePlugins = {
[appKey: string]: StateClientPlugins,
};
export type State = {
activePluginKey: ?string,
activeAppKey: ?string,
plugins: StatePlugins,
error: ?string,
server: Server,
};
type Props = {
devices: Array<BaseDevice>,
leftSidebarVisible: boolean,
bugDialogVisible: boolean,
pluginManagerVisible: boolean,
toggleBugDialogVisible: (visible?: boolean) => void,
};
export class App extends React.Component<Props, State> {
constructor() {
performance.mark('init');
super();
this.initTracking();
this.logger = new Logger();
this.state = {
activeAppKey: null,
activePluginKey: null,
error: null,
devices: {},
plugins: {},
server: this.initServer(),
};
this.errorReporter = new ErrorReporter(this.logger.scribeLogger);
this.bugReporter = new BugReporter(this.logger);
this.commandLineArgs = yargs.parse(electron.remote.process.argv);
setupMenu(this.sendKeyboardAction);
}
errorReporter: ErrorReporter;
bugReporter: BugReporter;
logger: Logger;
commandLineArgs: Object;
_hasActivatedPreferredPlugin: boolean = false;
componentDidMount() {
this.logger.trackTimeSince('init');
// close socket before reloading
window.addEventListener('beforeunload', () => {
this.state.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}),
);
if (this.state.activeAppKey === client.id) {
setTimeout(this.ensurePluginSelected);
}
});
client.addListener('plugins-change', () => {
this.setState({}, this.ensurePluginSelected);
});
});
server.addListener('clients-change', () => {
this.setState({}, this.ensurePluginSelected);
});
server.addListener('error', err => {
if (err.code === 'EADDRINUSE') {
this.setState({
error:
"Couldn't start websocket server. " +
'Looks like you have multiple copies of Sonar running.',
});
} else {
// unknown error
this.setState({
error: err.message,
});
}
});
return server;
}
initTracking = () => {
electron.ipcRenderer.on('trackUsage', () => {
// check if there's a plugin currently active
const {activeAppKey, activePluginKey} = this.state;
if (activeAppKey == null || activePluginKey == null) {
return;
}
// app plugins
const client = this.getClient(activeAppKey);
if (client) {
this.logger.track('usage', 'ping', {
app: client.query.app,
device: client.query.device,
os: client.query.os,
plugin: activePluginKey,
});
return;
}
// device plugins
const device: ?BaseDevice = this.getDevice(activeAppKey);
if (device) {
this.logger.track('usage', 'ping', {
os: device.os,
plugin: activePluginKey,
device: device.title,
});
}
});
};
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 => {
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);
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 />
{this.props.bugDialogVisible && (
<BugReporterDialog
bugReporter={this.bugReporter}
close={() => this.props.toggleBugDialogVisible(false)}
/>
)}
{hasDevices ? (
<FlexRow fill={true}>
{this.props.leftSidebarVisible && (
<MainSidebar
activePluginKey={state.activePluginKey}
activeAppKey={state.activeAppKey}
devices={this.props.devices}
server={state.server}
onActivatePlugin={this.onActivatePlugin}
/>
)}
{mainView}
</FlexRow>
) : this.props.pluginManagerVisible ? (
<PluginManager />
) : (
<WelcomeScreen />
)}
<ErrorBar text={state.error} />
</FlexColumn>
);
}
}
export default connect(
({
application: {pluginManagerVisible, bugDialogVisible, leftSidebarVisible},
devices,
}) => ({
pluginManagerVisible,
bugDialogVisible,
leftSidebarVisible,
devices,
}),
{toggleBugDialogVisible},
)(App);

361
src/MenuBar.js Normal file
View File

@@ -0,0 +1,361 @@
/**
* 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 {SonarBasePlugin} from './plugin.js';
import {devicePlugins} from './device-plugins/index.js';
import {
isProduction,
loadsDynamicPlugins,
toggleDynamicPluginLoading,
} from './utils/dynamicPluginLoading.js';
import plugins from './plugins/index.js';
import electron from 'electron';
export type DefaultKeyboardAction = 'clear' | 'goToBottom' | 'createPaste';
export type TopLevelMenu = 'Edit' | 'View' | 'Window' | 'Help';
type MenuItem = {|
label?: string,
accelerator?: string,
role?: string,
click?: Function,
submenu?: Array<MenuItem>,
type?: string,
enabled?: boolean,
|};
export type KeyboardAction = {|
action: string,
label: string,
accelerator?: string,
topLevelMenu: TopLevelMenu,
|};
const defaultKeyboardActions: Array<KeyboardAction> = [
{
label: 'Clear',
accelerator: 'CmdOrCtrl+K',
topLevelMenu: 'View',
action: 'clear',
},
{
label: 'Go To Bottom',
accelerator: 'CmdOrCtrl+B',
topLevelMenu: 'View',
action: 'goToBottom',
},
{
label: 'Create Paste',
topLevelMenu: 'Edit',
action: 'createPaste',
},
];
export type KeyboardActions = Array<DefaultKeyboardAction | KeyboardAction>;
const menuItems: Map<string, Object> = new Map();
export function setupMenu(actionHandler: (action: string) => void) {
const template = getTemplate(electron.remote.app, electron.remote.shell);
// collect all keyboard actions from all plugins
const registeredActions: Set<?KeyboardAction> = new Set(
[...devicePlugins, ...plugins]
.map((plugin: Class<SonarBasePlugin<>>) => plugin.keyboardActions || [])
.reduce((acc: KeyboardActions, cv) => acc.concat(cv), [])
.map(
(action: DefaultKeyboardAction | KeyboardAction) =>
typeof action === 'string'
? defaultKeyboardActions.find(a => a.action === action)
: action,
),
);
// add keyboard actions to
registeredActions.forEach(keyboardAction => {
if (keyboardAction != null) {
appendMenuItem(template, actionHandler, keyboardAction);
}
});
// create actual menu instance
const applicationMenu = electron.remote.Menu.buildFromTemplate(template);
// add menu items to map, so we can modify them easily later
registeredActions.forEach(keyboardAction => {
if (keyboardAction != null) {
const {topLevelMenu, label, action} = keyboardAction;
const menu = applicationMenu.items.find(
menuItem => menuItem.label === topLevelMenu,
);
const menuItem = menu.submenu.items.find(
menuItem => menuItem.label === label,
);
menuItems.set(action, menuItem);
}
});
// update menubar
electron.remote.Menu.setApplicationMenu(applicationMenu);
}
function appendMenuItem(
template: Array<MenuItem>,
actionHandler: (action: string) => void,
item: ?KeyboardAction,
) {
const keyboardAction = item;
if (keyboardAction == null) {
return;
}
const itemIndex = template.findIndex(
menu => menu.label === keyboardAction.topLevelMenu,
);
if (itemIndex > -1 && template[itemIndex].submenu != null) {
template[itemIndex].submenu.push({
click: () => actionHandler(keyboardAction.action),
label: keyboardAction.label,
accelerator: keyboardAction.accelerator,
enabled: false,
});
}
}
export function activateMenuItems(activePluginKey: ?string) {
const activePlugin: ?Class<SonarBasePlugin<>> = [
...devicePlugins,
...plugins,
].find((plugin: Class<SonarBasePlugin<>>) => plugin.id === activePluginKey);
// disable all keyboard actions
for (const item of menuItems) {
item[1].enabled = false;
}
// enable keyboard actions for the current plugin
if (activePlugin != null && activePlugin.keyboardActions != null) {
(activePlugin.keyboardActions || []).forEach(keyboardAction => {
const action =
typeof keyboardAction === 'string'
? keyboardAction
: keyboardAction.action;
const item = menuItems.get(action);
if (item != null) {
item.enabled = true;
}
});
}
// set the application menu again to make sure it updates
electron.remote.Menu.setApplicationMenu(
electron.remote.Menu.getApplicationMenu(),
);
}
function getTemplate(app: Object, shell: Object): Array<MenuItem> {
const template = [
{
label: 'Edit',
submenu: [
{
label: 'Undo',
accelerator: 'CmdOrCtrl+Z',
role: 'undo',
},
{
label: 'Redo',
accelerator: 'Shift+CmdOrCtrl+Z',
role: 'redo',
},
{
type: 'separator',
},
{
label: 'Cut',
accelerator: 'CmdOrCtrl+X',
role: 'cut',
},
{
label: 'Copy',
accelerator: 'CmdOrCtrl+C',
role: 'copy',
},
{
label: 'Paste',
accelerator: 'CmdOrCtrl+V',
role: 'paste',
},
{
label: 'Select All',
accelerator: 'CmdOrCtrl+A',
role: 'selectall',
},
],
},
{
label: 'View',
submenu: [
{
label: 'Reload',
accelerator: 'CmdOrCtrl+R',
click: function(item: Object, focusedWindow: Object) {
if (focusedWindow) {
focusedWindow.reload();
}
},
},
{
label: 'Toggle Full Screen',
accelerator: (function() {
if (process.platform === 'darwin') {
return 'Ctrl+Command+F';
} else {
return 'F11';
}
})(),
click: function(item: Object, focusedWindow: Object) {
if (focusedWindow) {
focusedWindow.setFullScreen(!focusedWindow.isFullScreen());
}
},
},
{
label: 'Toggle Developer Tools',
accelerator: (function() {
if (process.platform === 'darwin') {
return 'Alt+Command+I';
} else {
return 'Ctrl+Shift+I';
}
})(),
click: function(item: Object, focusedWindow: Object) {
if (focusedWindow) {
focusedWindow.toggleDevTools();
}
},
},
{
type: 'separator',
},
],
},
{
label: 'Window',
role: 'window',
submenu: [
{
label: 'Minimize',
accelerator: 'CmdOrCtrl+M',
role: 'minimize',
},
{
label: 'Close',
accelerator: 'CmdOrCtrl+W',
role: 'close',
},
],
},
{
label: 'Help',
role: 'help',
submenu: [
{
label: 'Getting started',
click: function() {
shell.openExternal('https://fbsonar.com/docs/getting-started.html');
},
},
{
label: 'Create plugins',
click: function() {
shell.openExternal('https://fbsonar.com/docs/create-plugin.html');
},
},
{
label: 'Report problems',
click: function() {
shell.openExternal('https://github.com/facebook/Sonar/issues');
},
},
],
},
];
if (process.platform === 'darwin') {
const name = app.getName();
template.unshift({
label: name,
submenu: [
{
label: 'About ' + name,
role: 'about',
},
{
type: 'separator',
},
{
label: 'Services',
role: 'services',
submenu: [],
},
{
type: 'separator',
},
{
label: 'Hide ' + name,
accelerator: 'Command+H',
role: 'hide',
},
{
label: 'Hide Others',
accelerator: 'Command+Shift+H',
role: 'hideothers',
},
{
label: 'Show All',
role: 'unhide',
},
{
type: 'separator',
},
{
label: `Restart in ${
loadsDynamicPlugins() ? 'Production' : 'Development'
} Mode`,
enabled: isProduction(),
click: function() {
toggleDynamicPluginLoading();
},
},
{
label: 'Quit',
accelerator: 'Command+Q',
click: function() {
app.quit();
},
},
],
});
const windowMenu = template.find(function(m: Object) {
return m.role === 'window';
});
if (windowMenu) {
windowMenu.submenu.push(
{
type: 'separator',
},
{
label: 'Bring All to Front',
role: 'front',
},
);
}
}
return template;
}

128
src/PluginContainer.js Normal file
View File

@@ -0,0 +1,128 @@
/**
* 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 {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 LogManager from './fb-stubs/Logger';
type Props = {
plugin: SonarBasePlugin<>,
state?: any,
logger: LogManager,
rightSidebarVisible: boolean,
rightSidebarAvailable: boolean,
toggleRightSidebarVisible: (available: ?boolean) => void,
toggleRightSidebarAvailable: (available: ?boolean) => void,
};
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({
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}`,
);
}
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);
} else {
this._sidebar = (
<Sidebar position="right" width={400} key="sidebar">
{sidebarContent}
</Sidebar>
);
nextProps.toggleRightSidebarAvailable(true);
}
}
onDismissIntro = () => {
const {plugin} = this.props;
window.localStorage.setItem(`${plugin.constructor.id}.introShown`, 'true');
this.setState({
showIntro: false,
});
};
render() {
const {plugin} = this.props;
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()
)}
</PluginContainer.Container>,
this.props.rightSidebarVisible === false ? null : this._sidebar,
];
}
}
export default connect(
({application: {rightSidebarVisible, rightSidebarAvailable}}) => ({
rightSidebarVisible,
rightSidebarAvailable,
}),
{
toggleRightSidebarAvailable,
toggleRightSidebarVisible,
},
)(PluginContainer);

View File

@@ -0,0 +1,103 @@
/**
* 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 {FlexRow, Text, colors, LoadingIndicator, Glyph, Component} from 'sonar';
import {remote} from 'electron';
import {isProduction} from '../utils/dynamicPluginLoading';
import config from '../fb-stubs/config.js';
const version = remote.app.getVersion();
const VersionText = Text.extends({
color: colors.light50,
marginLeft: 4,
marginTop: 2,
});
const Container = FlexRow.extends({
alignItems: 'center',
});
type State = {
updater:
| 'error'
| 'checking-for-update'
| 'update-available'
| 'update-not-available'
| 'update-downloaded',
error?: string,
};
export default class AutoUpdateVersion extends Component<{}, State> {
state = {
updater: 'update-not-available',
};
componentDidMount() {
if (isProduction()) {
remote.autoUpdater.setFeedURL(
`${config.updateServer}?version=${version}`,
);
remote.autoUpdater.on('update-downloaded', () => {
this.setState({updater: 'update-downloaded'});
remote.dialog.showMessageBox(
{
title: 'Update available',
message: 'A new version of Sonar is available!',
detail: `You have Sonar ${version} which is outdated. Update to the latest version now.`,
buttons: ['Install and Restart'],
},
() => {
remote.autoUpdater.quitAndInstall();
},
);
});
remote.autoUpdater.on('error', error => {
this.setState({updater: 'error', error: error.toString()});
});
remote.autoUpdater.on('checking-for-update', () => {
this.setState({updater: 'checking-for-update'});
});
remote.autoUpdater.on('update-available', error => {
this.setState({updater: 'update-available'});
});
remote.autoUpdater.on('update-not-available', error => {
this.setState({updater: 'update-not-available'});
});
remote.autoUpdater.checkForUpdates();
}
}
render() {
return (
<Container>
{this.state.updater === 'update-available' && (
<span title="Downloading new version">
<LoadingIndicator size={16} />
</span>
)}
{this.state.updater === 'error' && (
<span title={`Error fetching update: ${this.state.error || ''}`}>
<Glyph color={colors.light30} name="caution-triangle" />
</span>
)}
{this.state.updater === 'update-downloaded' && (
<span title="Update available. Restart Sonar.">
<Glyph color={colors.light30} name="breaking-news" />
</span>
)}
{isProduction() && <VersionText>{version}</VersionText>}
</Container>
);
}
}

View File

@@ -0,0 +1,239 @@
/**
* 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 BugReporter from '../fb-stubs/BugReporter.js';
import {Component} from 'react';
import {
Button,
colors,
Link,
Input,
FlexColumn,
FlexRow,
Textarea,
Text,
FlexCenter,
styled,
} from 'sonar';
const Container = FlexColumn.extends({
padding: 10,
});
const textareaStyle = {
margin: 0,
marginBottom: 10,
};
const DialogContainer = styled.view({
width: 400,
height: 300,
position: 'absolute',
left: '50%',
marginLeft: -200,
top: 40,
zIndex: 999999,
backgroundColor: '#fff',
border: '1px solid #ddd',
borderTop: 'none',
borderBottomLeftRadius: 5,
borderBottomRightRadius: 5,
boxShadow: '0 1px 10px rgba(0, 0, 0, 0.1)',
});
const TitleInput = Input.extends({
...textareaStyle,
height: 30,
});
const DescriptionTextarea = Textarea.extends({
...textareaStyle,
flexGrow: 1,
});
const SubmitButtonContainer = styled.view({
marginLeft: 'auto',
});
const Footer = FlexRow.extends({
lineHeight: '24px',
});
const CloseDoneButton = Button.extends({
width: 50,
margin: '10px auto',
});
type State = {
description: string,
title: string,
submitting: boolean,
success: false | number, // false if not created, id of bug if it's been created
error: ?string,
};
type Props = {
bugReporter: BugReporter,
close: () => void,
};
const DEFAULT_DESCRIPTION = `Thanks for taking the time to provide feedback!
Please fill out the following information to make addressing your issue easier.
What device platform are you using? ios/android
What sort of device are you using? emulator/physical
What app are you trying to use? wilde, fb4a, lite etc
Describe your problem in as much detail as possible: `;
export default class BugReporterDialog extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
description: DEFAULT_DESCRIPTION,
title: '',
submitting: false,
success: false,
error: null,
};
}
titleRef: HTMLElement;
descriptionRef: HTMLElement;
onDescriptionChange = (e: SyntheticInputEvent<HTMLInputElement>) => {
this.setState({description: e.target.value});
};
onTitleChange = (e: SyntheticInputEvent<HTMLInputElement>) => {
this.setState({title: e.target.value});
};
onSubmit = () => {
// validate fields
const {title, description} = this.state;
if (!title) {
this.setState({
error: 'Title required.',
});
this.titleRef.focus();
return;
}
if (!description) {
this.setState({
error: 'Description required.',
});
this.descriptionRef.focus();
return;
}
this.setState(
{
error: null,
submitting: true,
},
() => {
// this will be called before the next repaint
requestAnimationFrame(() => {
// we have to call this again to ensure a repaint has actually happened
// as requestAnimationFrame is called BEFORE a repaint, not after which
// means we have to queue up twice to actually ensure a repaint has
// happened
requestAnimationFrame(() => {
this.props.bugReporter
.report(title, description)
.then((id: number) => {
this.setState({
submitting: false,
success: id,
});
})
.catch(err => {
this.setState({
error: err.message,
submitting: false,
});
});
});
});
},
);
};
setTitleRef = (ref: HTMLElement) => {
this.titleRef = ref;
};
setDescriptionRef = (ref: HTMLElement) => {
this.descriptionRef = ref;
};
onCancel = () => {
this.props.close();
};
render() {
let content;
const {title, success, error, description} = this.state;
if (success) {
content = (
<FlexCenter fill={true}>
<FlexColumn>
<Text>
<Text>Bug </Text>
<Text bold={true}>
<Link
href={`https://our.intern.facebook.com/intern/bug/${success}`}>
{success}
</Link>
</Text>
<Text> created. Thank you for the report!</Text>
</Text>
<CloseDoneButton onClick={this.onCancel}>Close</CloseDoneButton>
</FlexColumn>
</FlexCenter>
);
} else {
content = (
<Container fill={true}>
<TitleInput
placeholder="Title..."
value={title}
innerRef={this.setTitleRef}
onChange={this.onTitleChange}
/>
<DescriptionTextarea
placeholder="Description..."
value={description}
innerRef={this.setDescriptionRef}
onChange={this.onDescriptionChange}
/>
<Footer>
{error != null && <Text color={colors.red}>{error}</Text>}
<SubmitButtonContainer>
<Button type="primary" onClick={this.onSubmit}>
Submit report
</Button>
<Button type="danger" onClick={this.onCancel}>
Cancel
</Button>
</SubmitButtonContainer>
</Footer>
</Container>
);
}
return <DialogContainer>{content}</DialogContainer>;
}
}

291
src/chrome/DevicesButton.js Normal file
View File

@@ -0,0 +1,291 @@
/**
* 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 {Component, styled, Glyph, Button, colors} from 'sonar';
import {connect} from 'react-redux';
import BaseDevice from '../devices/BaseDevice.js';
import child_process from 'child_process';
import DevicesList from './DevicesList.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 = {|
devices: Array<BaseDevice>,
|};
type Emulator = {|
name: string,
os?: string,
isRunning: boolean,
|};
type State = {
androidEmulators: Array<Emulator>,
iOSSimulators: Array<Emulator>,
popoverVisible: boolean,
};
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(
'/opt/android_sdk/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);
}
},
);
});
}
launchEmulator = (name: string) => {
child_process.exec(
`/opt/android_sdk/tools/emulator @${name}`,
this.updateEmulatorState,
);
};
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 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';
}
const connectedDevices = this.props.devices;
return (
<Button
compact={true}
onClick={this.onClick}
icon={glyph}
disabled={this.state.androidEmulators.length === 0}>
{text}
{this.state.popoverVisible && (
<DevicesList
onDismiss={this.onDismissPopover}
sections={[
{
title: 'Running',
items: [
...connectedDevices
.filter(device => device.deviceType === 'physical')
.map(device => ({
title: device.title,
subtitle: device.os,
icon: <Light active={true} />,
})),
...runnningEmulators.map(emulator => ({
title: emulator.name,
subtitle: 'Android Emulator',
icon: <Light active={true} />,
})),
...this.state.iOSSimulators.map(simulator => ({
title: simulator.name,
subtitle: `${String(simulator.os)} Simulator`,
icon: <Light active={true} />,
})),
],
},
{
title: 'Not Running',
items: [
...this.state.androidEmulators
.filter(emulator => !emulator.isRunning)
.map(emulator => ({
title: emulator.name,
subtitle: 'Android Emulator',
onClick: () => this.launchEmulator(emulator.name),
icon: <Light active={false} />,
})),
{
title: 'Connect a device',
subtitle: 'Plugins will load automatically',
icon: <Glyph name="mobile" size={12} />,
},
],
},
]}
/>
)}
</Button>
);
}
}
export default connect(({devices}) => ({
devices,
}))(DevicesButton);

142
src/chrome/DevicesList.js Normal file
View File

@@ -0,0 +1,142 @@
/**
* 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 {
PureComponent,
FlexRow,
FlexBox,
Text,
Button,
Popover,
styled,
colors,
} from 'sonar';
const Heading = Text.extends({
display: 'block',
backgroundColor: colors.white,
color: colors.light30,
fontSize: 11,
fontWeight: 600,
lineHeight: '21px',
padding: '4px 8px 0',
});
const PopoverItem = FlexRow.extends({
alignItems: 'center',
borderBottom: `1px solid ${colors.light05}`,
height: 50,
'&:last-child': {
borderBottom: 'none',
},
});
const ItemTitle = Text.extends({
display: 'block',
fontSize: 14,
fontWeight: 400,
lineHeight: '120%',
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
marginBottom: 1,
});
const ItemSubtitle = Text.extends({
display: 'block',
fontWeight: 400,
fontSize: 11,
color: colors.light30,
lineHeight: '14px',
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
});
const ItemImage = FlexBox.extends({
alignItems: 'center',
justifyContent: 'center',
width: 40,
flexShrink: 0,
});
const ItemContent = styled.view({
minWidth: 0,
paddingRight: 5,
flexGrow: 1,
});
const Section = styled.view({
maxWidth: 260,
borderBottom: `1px solid ${colors.light05}`,
'&:last-child': {
borderBottom: 'none',
},
});
const Action = Button.extends({
border: `1px solid ${colors.macOSTitleBarButtonBorder}`,
background: 'transparent',
color: colors.macOSTitleBarIconSelected,
marginRight: 8,
marginLeft: 4,
lineHeight: '22px',
'&:hover': {
background: 'transparent',
},
'&:active': {
background: 'transparent',
border: `1px solid ${colors.macOSTitleBarButtonBorder}`,
},
});
type Props = {|
sections: Array<{
title: string,
items: Array<{
title: string,
subtitle: string,
onClick?: Function,
icon?: React.Element<*>,
}>,
}>,
onDismiss: Function,
|};
export default class DevicesList extends PureComponent<Props> {
render() {
return (
<Popover onDismiss={this.props.onDismiss}>
{this.props.sections.map(section => {
if (section.items.length > 0) {
return (
<Section key={section.title}>
<Heading>{section.title}</Heading>
{section.items.map(item => (
<PopoverItem key={item.title}>
<ItemImage>{item.icon}</ItemImage>
<ItemContent>
<ItemTitle>{item.title}</ItemTitle>
<ItemSubtitle>{item.subtitle}</ItemSubtitle>
</ItemContent>
{item.onClick && (
<Action onClick={item.onClick} compact={true}>
Run
</Action>
)}
</PopoverItem>
))}
</Section>
);
} else {
return null;
}
})}
</Popover>
);
}
}

28
src/chrome/ErrorBar.js Normal file
View File

@@ -0,0 +1,28 @@
/**
* 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 {styled, colors} from 'sonar';
const ErrorBarContainer = styled.view({
backgroundColor: colors.cherry,
bottom: 0,
color: '#fff',
left: 0,
lineHeight: '26px',
position: 'absolute',
right: 0,
textAlign: 'center',
zIndex: 2,
});
export default function ErrorBar(props: {|text: ?string|}) {
if (props.text == null) {
return null;
} else {
return <ErrorBarContainer>{props.text}</ErrorBarContainer>;
}
}

305
src/chrome/MainSidebar.js Normal file
View File

@@ -0,0 +1,305 @@
/**
* 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 {SonarBasePlugin} from '../plugin.js';
import type {Client} from '../server.js';
import {
Component,
Sidebar,
FlexBox,
ClickableList,
ClickableListItem,
colors,
brandColors,
Text,
Glyph,
} from 'sonar';
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';
const CustomClickableListItem = ClickableListItem.extends({
paddingLeft: 10,
display: 'flex',
alignItems: 'center',
marginBottom: 2,
});
const SidebarHeader = FlexBox.extends({
display: 'block',
alignItems: 'center',
padding: 3,
color: colors.macOSSidebarSectionTitle,
fontSize: 11,
fontWeight: 500,
marginLeft: 7,
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
flexShrink: 0,
});
const PluginShape = FlexBox.extends(
{
marginRight: 5,
backgroundColor: props => props.backgroundColor,
borderRadius: 3,
flexShrink: 0,
width: 18,
height: 18,
justifyContent: 'center',
alignItems: 'center',
},
{
ignoreAttributes: ['backgroundColor'],
},
);
const PluginName = Text.extends({
minWidth: 0,
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
});
function PluginIcon({
backgroundColor,
name,
color,
}: {
backgroundColor: string,
name: string,
color: string,
}) {
return (
<PluginShape backgroundColor={backgroundColor}>
<Glyph size={12} name={name} color={color} />
</PluginShape>
);
}
class PluginSidebarListItem extends Component<{
activePluginKey: ?string,
activeAppKey: ?string,
onActivatePlugin: (appKey: string, pluginKey: string) => void,
appKey: string,
appName?: string,
isActive: boolean,
Plugin: Class<SonarBasePlugin<>>,
windowFocused: boolean,
}> {
onClick = () => {
const {props} = this;
props.onActivatePlugin(props.appKey, props.Plugin.id);
};
render() {
const {isActive, Plugin, windowFocused, appKey, appName} = this.props;
let iconColor;
if (appName != null) {
iconColor = brandColors[appName];
}
if (iconColor == null) {
const pluginColors = [
colors.seaFoam,
colors.teal,
colors.lime,
colors.lemon,
colors.orange,
colors.tomato,
colors.cherry,
colors.pink,
colors.grape,
];
iconColor = pluginColors[parseInt(appKey, 36) % pluginColors.length];
}
return (
<CustomClickableListItem
active={isActive}
onClick={this.onClick}
windowFocused={windowFocused}>
<PluginIcon
name={Plugin.icon}
backgroundColor={
isActive
? windowFocused
? colors.white
: colors.macOSSidebarSectionTitle
: iconColor
}
color={
isActive
? windowFocused
? colors.macOSTitleBarIconSelected
: colors.white
: colors.white
}
/>
<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,
devices: Array<BaseDevice>,
server: Server,
|};
export default class MainSidebar extends Component<MainSidebarProps> {
static contextTypes = {
windowIsFocused: PropTypes.bool,
};
render() {
const connections = Array.from(this.props.server.connections.values()).sort(
(a, b) => {
return (a.client.query.app || '').localeCompare(b.client.query.app);
},
);
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);
}
}
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}
/>,
]);
}
return (
<Sidebar
position="left"
width={200}
backgroundColor={
this.context.windowIsFocused ? 'transparent' : '#f6f6f6'
}>
{sidebarContent}
</Sidebar>
);
}
}

404
src/chrome/PluginManager.js Normal file
View File

@@ -0,0 +1,404 @@
/**
* 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 {
PureComponent,
Button,
FlexColumn,
FlexBox,
Text,
LoadingIndicator,
ButtonGroup,
colors,
Glyph,
FlexRow,
styled,
Searchable,
} from 'sonar';
const {spawn} = require('child_process');
const path = require('path');
const {app, shell} = require('electron').remote;
const SONAR_PLUGIN_PATH = path.join(app.getPath('home'), '.sonar');
const DYNAMIC_PLUGINS = JSON.parse(window.process.env.PLUGINS || '[]');
type NPMModule = {
name: string,
version: string,
description?: string,
error?: Object,
};
type Status =
| 'installed'
| 'outdated'
| 'install'
| 'remove'
| 'update'
| 'uninstalled'
| 'uptodate';
type PluginT = {
name: string,
version?: string,
description?: string,
status: Status,
managed?: boolean,
entry?: string,
rootDir?: string,
};
type Props = {
searchTerm: string,
};
type State = {
plugins: {
[name: string]: PluginT,
},
restartRequired: boolean,
searchCompleted: boolean,
};
const Container = FlexBox.extends({
width: '100%',
flexGrow: 1,
background: colors.light02,
overflowY: 'scroll',
});
const Title = Text.extends({
fontWeight: 500,
});
const Plugin = FlexColumn.extends({
backgroundColor: colors.white,
borderRadius: 4,
padding: 15,
margin: '0 15px 25px',
boxShadow: '0 1px 2px rgba(0,0,0,0.05)',
});
const SectionTitle = styled.text({
fontWeight: 'bold',
fontSize: 24,
margin: 15,
marginLeft: 20,
});
const Loading = FlexBox.extends({
padding: 50,
alignItems: 'center',
justifyContent: 'center',
});
const RestartRequired = FlexBox.extends({
textAlign: 'center',
justifyContent: 'center',
fontWeight: 500,
color: colors.white,
padding: 12,
backgroundColor: colors.green,
cursor: 'pointer',
});
const TitleRow = FlexRow.extends({
alignItems: 'center',
marginBottom: 10,
fontSize: '1.1em',
});
const Description = FlexRow.extends({
marginBottom: 15,
lineHeight: '130%',
});
const PluginGlyph = Glyph.extends({
marginRight: 5,
});
const PluginLoading = LoadingIndicator.extends({
marginLeft: 5,
marginTop: 5,
});
const getLatestVersion = (name: string): Promise<NPMModule> => {
return fetch(`http://registry.npmjs.org/${name}/latest`).then(res =>
res.json(),
);
};
const getPluginList = (): Promise<Array<NPMModule>> => {
return fetch(
'http://registry.npmjs.org/-/v1/search?text=keywords:sonar&size=250',
)
.then(res => res.json())
.then(res => res.objects.map(o => o.package));
};
const sortByName = (a: PluginT, b: PluginT): 1 | -1 =>
a.name > b.name ? 1 : -1;
const INSTALLED = ['installed', 'outdated', 'uptodate'];
class PluginItem extends PureComponent<
{
plugin: PluginT,
onChangeState: (action: Status) => void,
},
{
working: boolean,
},
> {
state = {
working: false,
};
npmAction = (action: Status) => {
const {name, status: initialStatus} = this.props.plugin;
this.setState({working: true});
const npm = spawn('npm', [action, name], {
cwd: SONAR_PLUGIN_PATH,
});
npm.stderr.on('data', e => {
console.error(e.toString());
});
npm.on('close', code => {
this.setState({working: false});
const newStatus = action === 'remove' ? 'uninstalled' : 'uptodate';
this.props.onChangeState(code !== 0 ? initialStatus : newStatus);
});
};
render() {
const {
entry,
status,
version,
description,
managed,
name,
rootDir,
} = this.props.plugin;
return (
<Plugin>
<TitleRow>
<PluginGlyph
name="apps"
size={24}
variant="outline"
color={colors.light30}
/>
<Title>{name}</Title>
&nbsp;
<Text code={true}>{version}</Text>
</TitleRow>
{description && <Description>{description}</Description>}
<FlexRow>
{managed ? (
<Text size="0.9em" color={colors.light30}>
This plugin is not managed by Sonar, but loaded from{' '}
<Text size="1em" code={true}>
{rootDir}
</Text>
</Text>
) : (
<ButtonGroup>
{status === 'outdated' && (
<Button
disabled={this.state.working}
onClick={() => this.npmAction('update')}>
Update
</Button>
)}
{INSTALLED.includes(status) ? (
<Button
disabled={this.state.working}
title={
managed === true && entry != null
? `This plugin is dynamically loaded from ${entry}`
: undefined
}
onClick={() => this.npmAction('remove')}>
Remove
</Button>
) : (
<Button
disabled={this.state.working}
onClick={() => this.npmAction('install')}>
Install
</Button>
)}
<Button
onClick={() =>
shell.openExternal(`https://www.npmjs.com/package/${name}`)
}>
Info
</Button>
</ButtonGroup>
)}
{this.state.working && <PluginLoading size={18} />}
</FlexRow>
</Plugin>
);
}
}
class PluginManager extends PureComponent<Props, State> {
state = {
plugins: DYNAMIC_PLUGINS.reduce((acc, plugin) => {
acc[plugin.name] = {
...plugin,
managed: !(plugin.entry, '').startsWith(SONAR_PLUGIN_PATH),
status: 'installed',
};
return acc;
}, {}),
restartRequired: false,
searchCompleted: false,
};
componentDidMount() {
Promise.all(
Object.keys(this.state.plugins)
.filter(name => this.state.plugins[name].managed)
.map(getLatestVersion),
).then((res: Array<NPMModule>) => {
const updates = {};
res.forEach(plugin => {
if (
plugin.error == null &&
this.state.plugins[plugin.name].version !== plugin.version
) {
updates[plugin.name] = {
...plugin,
...this.state.plugins[plugin.name],
status: 'outdated',
};
}
});
this.setState({
plugins: {
...this.state.plugins,
...updates,
},
});
});
getPluginList().then(pluginList => {
const plugins = {...this.state.plugins};
pluginList.forEach(plugin => {
if (plugins[plugin.name] != null) {
plugins[plugin.name] = {
...plugin,
...plugins[plugin.name],
status:
plugin.version === plugins[plugin.name].version
? 'uptodate'
: 'outdated',
};
} else {
plugins[plugin.name] = {
...plugin,
status: 'uninstalled',
};
}
});
this.setState({
plugins,
searchCompleted: true,
});
});
}
onChangePluginState = (name: string, status: Status) => {
this.setState({
plugins: {
...this.state.plugins,
[name]: {
...this.state.plugins[name],
status,
},
},
restartRequired: true,
});
};
relaunch() {
app.relaunch();
app.exit(0);
}
render() {
// $FlowFixMe
const plugins: Array<PluginT> = Object.values(this.state.plugins);
const availablePlugins = plugins.filter(
({status}) => !INSTALLED.includes(status),
);
return (
<Container>
<FlexColumn fill={true}>
{this.state.restartRequired && (
<RestartRequired onClick={this.relaunch}>
<Glyph name="arrows-circle" size={12} color={colors.white} />
&nbsp; Restart Required: Click to Restart
</RestartRequired>
)}
<SectionTitle>Installed Plugins</SectionTitle>
{plugins
.filter(
({status, name}) =>
INSTALLED.includes(status) &&
name.indexOf(this.props.searchTerm) > -1,
)
.sort(sortByName)
.map((plugin: PluginT) => (
<PluginItem
plugin={plugin}
key={plugin.name}
onChangeState={action =>
this.onChangePluginState(plugin.name, action)
}
/>
))}
<SectionTitle>Available Plugins</SectionTitle>
{availablePlugins
.filter(({name}) => name.indexOf(this.props.searchTerm) > -1)
.sort(sortByName)
.map((plugin: PluginT) => (
<PluginItem
plugin={plugin}
key={plugin.name}
onChangeState={action =>
this.onChangePluginState(plugin.name, action)
}
/>
))}
{!this.state.searchCompleted && (
<Loading>
<LoadingIndicator size={32} />
</Loading>
)}
</FlexColumn>
</Container>
);
}
}
const SearchablePluginManager = Searchable(PluginManager);
export default class extends PureComponent<{}> {
render() {
return (
<FlexColumn fill={true}>
<SearchablePluginManager />
</FlexColumn>
);
}
}

196
src/chrome/Popover.js Normal file
View File

@@ -0,0 +1,196 @@
/**
* 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 {
PureComponent,
FlexColumn,
FlexRow,
FlexBox,
Text,
Button,
styled,
colors,
} from 'sonar';
const Anchor = styled.image({
zIndex: 6,
position: 'absolute',
bottom: 0,
left: '50%',
transform: 'translate(-50%, calc(100% + 2px))',
});
const PopoverContainer = FlexColumn.extends({
backgroundColor: colors.white,
borderRadius: 7,
border: '1px solid rgba(0,0,0,0.3)',
boxShadow: '0 2px 10px 0 rgba(0,0,0,0.3)',
position: 'absolute',
zIndex: 5,
minWidth: 240,
bottom: 0,
marginTop: 15,
left: '50%',
transform: 'translate(-50%, calc(100% + 15px))',
overflow: 'hidden',
'&::before': {
content: '""',
display: 'block',
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
height: 13,
top: -13,
width: 26,
backgroundColor: colors.white,
},
});
const Heading = Text.extends({
display: 'block',
backgroundColor: colors.white,
color: colors.light30,
fontSize: 11,
fontWeight: 600,
lineHeight: '21px',
padding: '4px 8px 0',
});
const PopoverItem = FlexRow.extends({
alignItems: 'center',
borderBottom: `1px solid ${colors.light05}`,
height: 50,
'&:last-child': {
borderBottom: 'none',
},
});
const ItemTitle = Text.extends({
display: 'block',
fontSize: 14,
fontWeight: 400,
lineHeight: '120%',
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
marginBottom: 1,
});
const ItemSubtitle = Text.extends({
display: 'block',
fontWeight: 400,
fontSize: 11,
color: colors.light30,
lineHeight: '14px',
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
});
const ItemImage = FlexBox.extends({
alignItems: 'center',
justifyContent: 'center',
width: 40,
flexShrink: 0,
});
const ItemContent = styled.view({
minWidth: 0,
paddingRight: 5,
flexGrow: 1,
});
const Section = styled.view({
borderBottom: `1px solid ${colors.light05}`,
'&:last-child': {
borderBottom: 'none',
},
});
const Action = Button.extends({
border: `1px solid ${colors.macOSTitleBarButtonBorder}`,
background: 'transparent',
color: colors.macOSTitleBarIconSelected,
marginRight: 8,
lineHeight: '22px',
'&:hover': {
background: 'transparent',
},
'&:active': {
background: 'transparent',
border: `1px solid ${colors.macOSTitleBarButtonBorder}`,
},
});
type Props = {|
sections: Array<{
title: string,
items: Array<{
title: string,
subtitle: string,
onClick?: Function,
icon?: React.Element<*>,
}>,
}>,
onDismiss: Function,
|};
export default class Popover extends PureComponent<Props> {
_ref: ?Element;
componentDidMount() {
window.document.addEventListener('click', this.handleClick);
}
componentWillUnmount() {
window.document.addEventListener('click', this.handleClick);
}
handleClick = (e: SyntheticMouseEvent<>) => {
// $FlowFixMe
if (this._ref && !this._ref.contains(e.target)) {
this.props.onDismiss();
}
};
_setRef = (ref: ?Element) => {
this._ref = ref;
};
render() {
return [
<Anchor src="./anchor.svg" key="anchor" />,
<PopoverContainer innerRef={this._setRef} key="popup">
{this.props.sections.map(section => {
if (section.items.length > 0) {
return (
<Section key={section.title}>
<Heading>{section.title}</Heading>
{section.items.map(item => (
<PopoverItem key={item.title}>
<ItemImage>{item.icon}</ItemImage>
<ItemContent>
<ItemTitle>{item.title}</ItemTitle>
<ItemSubtitle>{item.subtitle}</ItemSubtitle>
</ItemContent>
{item.onClick && (
<Action onClick={item.onClick} compact={true}>
Run
</Action>
)}
</PopoverItem>
))}
</Section>
);
} else {
return null;
}
})}
</PopoverContainer>,
];
}
}

157
src/chrome/SonarTitleBar.js Normal file
View File

@@ -0,0 +1,157 @@
/**
* 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 {
colors,
Button,
ButtonGroup,
FlexRow,
FlexBox,
Component,
Spacer,
Glyph,
GK,
} from 'sonar';
import {
loadsDynamicPlugins,
dynamicPluginPath,
} from '../utils/dynamicPluginLoading.js';
import {connect} from 'react-redux';
import {
toggleBugDialogVisible,
toggleLeftSidebarVisible,
toggleRightSidebarVisible,
togglePluginManagerVisible,
} from '../reducers/application.js';
import DevicesButton from './DevicesButton.js';
import Version from './Version.js';
import AutoUpdateVersion from './AutoUpdateVersion.js';
import config from '../fb-stubs/config.js';
const TitleBar = FlexRow.extends(
{
background: props =>
props.focused
? `linear-gradient(to bottom, ${
colors.macOSTitleBarBackgroundTop
} 0%, ${colors.macOSTitleBarBackgroundBottom} 100%)`
: colors.macOSTitleBarBackgroundBlur,
borderBottom: props =>
`1px solid ${
props.focused
? colors.macOSTitleBarBorder
: colors.macOSTitleBarBorderBlur
}`,
height: 38,
flexShrink: 0,
width: '100%',
alignItems: 'center',
paddingLeft: 80,
paddingRight: 10,
justifyContent: 'space-between',
// $FlowFixMe
WebkitAppRegion: 'drag',
},
{
ignoreAttributes: ['focused'],
},
);
const Icon = FlexBox.extends({
marginRight: 3,
});
type Props = {|
windowIsFocused: boolean,
leftSidebarVisible: boolean,
rightSidebarVisible: boolean,
rightSidebarAvailable: boolean,
pluginManagerVisible: boolean,
toggleBugDialogVisible: (visible?: boolean) => void,
toggleLeftSidebarVisible: (visible?: boolean) => void,
toggleRightSidebarVisible: (visible?: boolean) => void,
togglePluginManagerVisible: (visible?: boolean) => void,
|};
class SonarTitleBar extends Component<Props> {
render() {
return (
<TitleBar focused={this.props.windowIsFocused} className="toolbar">
<DevicesButton />
<Spacer />
{loadsDynamicPlugins() && (
<Icon
title={`Plugins are loaded dynamically from ${dynamicPluginPath() ||
''}`}>
<Glyph color={colors.light30} name="flash-default" size={16} />
</Icon>
)}
{process.platform === 'darwin' ? <AutoUpdateVersion /> : <Version />}
{config.bugReportButtonVisible && (
<Button
compact={true}
onClick={() => this.props.toggleBugDialogVisible()}
title="Report Bug"
icon="bug"
/>
)}
{GK.get('sonar_dynamic_plugins') && (
<Button
compact={true}
onClick={() => this.props.toggleBugDialogVisible()}
selected={this.props.pluginManagerVisible}
title="Plugin Manager"
icon="apps"
/>
)}
<ButtonGroup>
<Button
compact={true}
selected={this.props.leftSidebarVisible}
onClick={() => this.props.toggleLeftSidebarVisible()}
icon="icons/sidebar_left.svg"
iconSize={20}
title="Toggle Plugins"
/>
<Button
compact={true}
selected={this.props.rightSidebarVisible}
onClick={() => this.props.toggleRightSidebarVisible()}
icon="icons/sidebar_right.svg"
iconSize={20}
title="Toggle Details"
disabled={!this.props.rightSidebarAvailable}
/>
</ButtonGroup>
</TitleBar>
);
}
}
export default connect(
({
application: {
windowIsFocused,
leftSidebarVisible,
rightSidebarVisible,
rightSidebarAvailable,
pluginManagerVisible,
},
}) => ({
windowIsFocused,
leftSidebarVisible,
rightSidebarVisible,
rightSidebarAvailable,
pluginManagerVisible,
}),
{
toggleBugDialogVisible,
toggleLeftSidebarVisible,
toggleRightSidebarVisible,
togglePluginManagerVisible,
},
)(SonarTitleBar);

92
src/chrome/Version.js Normal file
View File

@@ -0,0 +1,92 @@
/**
* 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 {Component, FlexRow, colors, LoadingIndicator} from 'sonar';
import {version} from '../../package.json';
import {remote} from 'electron';
import * as path from 'path';
import {userInfo} from 'os';
import * as fs from 'fs';
const VERSION_URL =
'https://interngraph.intern.facebook.com/sonar/version?app=543626909362475&token=AeNRaexWgPooanyxG0';
type VersionState = {
status: 'unknown' | 'outdated' | 'latest' | 'updated' | 'errored',
};
export default class Version extends Component<{}, VersionState> {
state = {
status: 'unknown',
};
static Container = FlexRow.extends({
alignItems: 'center',
marginRight: 7,
marginLeft: 7,
marginTop: -1,
color: colors.light50,
});
static UpdatedContainer = FlexRow.extends({
backgroundColor: colors.blackAlpha10,
borderRadius: '999em',
padding: '2px 6px',
marginLeft: 7,
color: colors.light80,
'&:hover': {
backgroundColor: colors.blackAlpha15,
},
});
componentDidMount() {
this.watchUpdates();
this.checkVersion().catch(() => {
this.setState({status: 'errored'});
});
}
async watchUpdates() {
fs.watch(path.join(userInfo().homedir, '.sonar-desktop'), () =>
this.setState({status: 'updated'}),
);
}
async checkVersion() {
const req = await fetch(VERSION_URL);
const json = await req.json();
this.setState({status: json.version === version ? 'latest' : 'outdated'});
}
onClick = () => {
// mark the app to relaunch once killed
remote.app.relaunch();
// close the current window
remote.getCurrentWindow().destroy();
};
render() {
const {status} = this.state;
return (
<Version.Container>
{version}
{status === 'outdated' && [
<Version.Container key="loading">
<LoadingIndicator size={16} />
</Version.Container>,
'Updating...',
]}
{status === 'updated' && (
<Version.UpdatedContainer onClick={this.onClick}>
Restart Sonar
</Version.UpdatedContainer>
)}
</Version.Container>
);
}
}

177
src/chrome/WelcomeScreen.js Normal file
View File

@@ -0,0 +1,177 @@
/**
* 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 {
styled,
PureComponent,
FlexColumn,
FlexRow,
Text,
Glyph,
colors,
brandColors,
} from 'sonar';
import {isProduction} from '../utils/dynamicPluginLoading';
import {shell, remote} from 'electron';
const Container = FlexColumn.extends({
height: '100%',
width: '100%',
justifyContent: 'center',
alignItems: 'center',
backgroundImage: 'url(./pattern.gif)',
});
const Welcome = FlexColumn.extends(
{
width: 460,
background: colors.white,
borderRadius: 10,
boxShadow: '0 1px 3px rgba(0,0,0,0.25)',
overflow: 'hidden',
opacity: props => (props.isMounted ? 1 : 0),
transform: props => `translateY(${props.isMounted ? 0 : 20}px)`,
transition: '0.6s all ease-out',
},
{
ignoreAttributes: ['isMounted'],
},
);
const Title = Text.extends({
fontSize: 24,
fontWeight: 300,
textAlign: 'center',
color: colors.light50,
marginBottom: 16,
});
const Version = Text.extends({
textAlign: 'center',
fontSize: 11,
fontWeight: 300,
color: colors.light30,
marginBottom: 60,
});
const Item = FlexRow.extends({
padding: 10,
cursor: 'pointer',
alignItems: 'center',
borderTop: `1px solid ${colors.light10}`,
'&:hover, &:focus, &:active': {
backgroundColor: colors.light02,
textDecoration: 'none',
},
});
const ItemTitle = Text.extends({
color: colors.light50,
fontSize: 15,
});
const ItemSubTitle = Text.extends({
color: colors.light30,
fontSize: 11,
marginTop: 2,
});
const Icon = Glyph.extends({
marginRight: 11,
marginLeft: 6,
});
const Logo = styled.image({
width: 128,
height: 128,
alignSelf: 'center',
marginTop: 50,
marginBottom: 20,
});
type Props = {};
type State = {
isMounted: boolean,
};
export default class WelcomeScreen extends PureComponent<Props, State> {
state = {
isMounted: false,
};
componentDidMount() {
setTimeout(
() =>
this.setState({
isMounted: true,
}),
100,
);
}
render() {
return (
<Container>
<Welcome isMounted={this.state.isMounted}>
<Logo src="./icon.png" />
<Title>Welcome to Sonar</Title>
<Version>
{isProduction()
? `Version ${remote.app.getVersion()}`
: 'Development Mode'}
</Version>
<Item
onClick={() =>
shell.openExternal('https://fbsonar.com/docs/understand.html')
}>
<Icon size={20} name="rocket" color={brandColors.Sonar} />
<FlexColumn>
<ItemTitle>Using Sonar</ItemTitle>
<ItemSubTitle>
Learn how Sonar can help you debugging your App
</ItemSubTitle>
</FlexColumn>
</Item>
<Item
onClick={() =>
shell.openExternal('https://fbsonar.com/docs/create-plugin.html')
}>
<Icon size={20} name="magic-wand" color={brandColors.Sonar} />
<FlexColumn>
<ItemTitle>Create your own plugin</ItemTitle>
<ItemSubTitle>Get started with these pointers</ItemSubTitle>
</FlexColumn>
</Item>
<Item
onClick={() =>
shell.openExternal(
'https://fbsonar.com/docs/getting-started.html',
)
}>
<Icon size={20} name="tools" color={brandColors.Sonar} />
<FlexColumn>
<ItemTitle>Add Sonar support to your app</ItemTitle>
<ItemSubTitle>Get started with these pointers</ItemSubTitle>
</FlexColumn>
</Item>
<Item
onClick={() =>
shell.openExternal('https://github.com/facebook/Sonar/issues')
}>
<Icon size={20} name="posts" color={brandColors.Sonar} />
<FlexColumn>
<ItemTitle>Contributing and Feedback</ItemTitle>
<ItemSubTitle>
Report issues and help us improving Sonar
</ItemSubTitle>
</FlexColumn>
</Item>
</Welcome>
</Container>
);
}
}

206
src/createTablePlugin.js Normal file
View File

@@ -0,0 +1,206 @@
/**
* 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 {
TableHighlightedRows,
TableRows,
TableColumnSizes,
TableColumns,
} from 'sonar';
import {FlexColumn, Button} from 'sonar';
import textContent from './utils/textContent.js';
import createPaste from './utils/createPaste.js';
import {SonarPlugin, SearchableTable} from 'sonar';
type ID = string;
type RowData = {
id: ID,
};
type Props<T> = {|
title: string,
id: string,
icon: string,
method: string,
resetMethod?: string,
columns: TableColumns,
columnSizes: TableColumnSizes,
renderSidebar: (row: T) => any,
buildRow: (row: T) => any,
|};
type State<T> = {|
rows: TableRows,
datas: {[key: ID]: T},
selectedIds: Array<ID>,
|};
type AppendAndUpdateAction<T> = {|type: 'AppendAndUpdate', datas: Array<T>|};
type ResetAndUpdateAction<T> = {|type: 'ResetAndUpdate', datas: Array<T>|};
type Actions<T> = AppendAndUpdateAction<T> | ResetAndUpdateAction<T>;
/**
* createTablePlugin creates a Plugin class which handles fetching data from the client and
* displaying in in a table. The table handles selection of items and rendering a sidebar where
* more detailed information can be presented about the selected row.
*
* The plugin expects the be able to subscribe to the `method` argument and recieve either an array
* of data objects or a single data object. Each data object represents a row in the table which is
* build by calling the `buildRow` function argument.
*
* An optional resetMethod argument can be provided which will replace the current rows with the
* data provided. This is useful when connecting to sonar for this first time, or reconnecting to
* the client in an unknown state.
*/
export function createTablePlugin<T: RowData>(props: Props<T>) {
return class extends SonarPlugin<State<T>, Actions<T>> {
static title = props.title;
static id = props.id;
static icon = props.icon;
static keyboardActions = ['clear', 'createPaste'];
state = {
rows: [],
datas: {},
selectedIds: [],
};
onKeyboardAction = (action: string) => {
if (action === 'clear') {
this.clear();
} else if (action === 'createPaste') {
this.createPaste();
}
};
reducers = {
AppendAndUpdate(state: State<T>, action: AppendAndUpdateAction<T>) {
const newRows = [];
const newData = {};
for (const data of action.datas.reverse()) {
if (data.id == null) {
console.warn('The data sent did not contain an ID.');
}
if (this.state.datas[data.id] == null) {
newData[data.id] = data;
newRows.push(props.buildRow(data));
}
}
return {
datas: {...state.datas, ...newData},
rows: [...state.rows, ...newRows],
};
},
ResetAndUpdate(state: State<T>, action: ResetAndUpdateAction<T>) {
const newRows = [];
const newData = {};
for (const data of action.datas.reverse()) {
if (data.id == null) {
console.warn('The data sent did not contain an ID.');
}
if (this.state.datas[data.id] == null) {
newData[data.id] = data;
newRows.push(props.buildRow(data));
}
}
return {
datas: newData,
rows: newRows,
};
},
};
init() {
this.client.subscribe(props.method, (data: T | Array<T>) => {
this.dispatchAction({
type: 'AppendAndUpdate',
datas: data instanceof Array ? data : [data],
});
});
if (props.resetMethod) {
this.client.subscribe(props.resetMethod, (data: Array<T>) => {
this.dispatchAction({
type: 'ResetAndUpdate',
datas: data instanceof Array ? data : [],
});
});
}
}
clear = () => {
this.setState({
datas: {},
rows: [],
selectedIds: [],
});
};
createPaste = () => {
let paste = '';
const mapFn = row =>
Object.keys(props.columns)
.map(key => textContent(row.columns[key].value))
.join('\t');
if (this.state.selectedIds.length > 0) {
// create paste from selection
paste = this.state.rows
.filter(row => this.state.selectedIds.indexOf(row.key) > -1)
.map(mapFn)
.join('\n');
} else {
// create paste with all rows
paste = this.state.rows.map(mapFn).join('\n');
}
createPaste(paste);
};
onRowHighlighted = (keys: TableHighlightedRows) => {
this.setState({
selectedIds: keys,
});
};
renderSidebar = () => {
const {renderSidebar} = props;
const {datas, selectedIds} = this.state;
const selectedId = selectedIds.length !== 1 ? null : selectedIds[0];
if (selectedId != null) {
return renderSidebar(datas[selectedId]);
} else {
return null;
}
};
render() {
const {columns, columnSizes} = props;
const {rows} = this.state;
return (
<FlexColumn fill={true}>
<SearchableTable
key={props.id}
rowLineHeight={28}
floating={false}
multiline={true}
columnSizes={columnSizes}
columns={columns}
onRowHighlighted={this.onRowHighlighted}
multiHighlight={true}
rows={rows}
stickyBottom={true}
actions={<Button onClick={this.clear}>Clear Table</Button>}
/>
</FlexColumn>
);
}
};
}

View File

@@ -0,0 +1,323 @@
/**
* 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 {SonarDevicePlugin} from 'sonar';
var adb = require('adbkit-fb');
import {
FlexColumn,
FlexRow,
Button,
Toolbar,
Text,
ManagedTable,
colors,
} from 'sonar';
type ADBClient = any;
type AndroidDevice = any;
type TableRows = any;
// we keep vairable name with underline for to physical path mappings on device
type CPUFrequency = {|
cpu_id: number,
scaling_cur_freq: number,
scaling_min_freq: number,
scaling_max_freq: number,
cpuinfo_max_freq: number,
cpuinfo_min_freq: number,
|};
type CPUState = {|
cpuFreq: Array<CPUFrequency>,
cpuCount: number,
monitoring: boolean,
|};
type ShellCallBack = (output: string) => void;
const ColumnSizes = {
cpu_id: '10%',
scaling_cur_freq: 'flex',
scaling_min_freq: 'flex',
scaling_max_freq: 'flex',
cpuinfo_min_freq: 'flex',
cpuinfo_max_freq: 'flex',
};
const Columns = {
cpu_id: {
value: 'CPU ID',
resizable: true,
},
scaling_cur_freq: {
value: 'Scaling Current',
resizable: true,
},
scaling_min_freq: {
value: 'Scaling MIN',
resizable: true,
},
scaling_max_freq: {
value: 'Scaling MAX',
resizable: true,
},
cpuinfo_min_freq: {
value: 'MIN Frequency',
resizable: true,
},
cpuinfo_max_freq: {
value: 'MAX Frequency',
resizable: true,
},
};
// check if str is a number
function isNormalInteger(str) {
let n = Math.floor(Number(str));
return String(n) === str && n >= 0;
}
// format frequency to MHz, GHz
function formatFrequency(freq) {
if (freq == -1) {
return 'N/A';
} else if (freq == -2) {
return 'off';
} else if (freq > 1000 * 1000) {
return (freq / 1000 / 1000).toFixed(2) + ' GHz';
} else {
return freq / 1000 + ' MHz';
}
}
export default class CPUFrequencyTable extends SonarDevicePlugin<CPUState> {
static id = 'DeviceCPU';
static title = 'CPU';
static icon = 'underline';
adbClient: ADBClient;
intervalID: ?IntervalID;
device: AndroidDevice;
init() {
this.setState({
cpuFreq: [],
cpuCount: 0,
monitoring: false,
});
this.adbClient = this.device.adb;
// check how many cores we have on this device
this.executeShell((output: string) => {
let idx = output.indexOf('-');
let cpuFreq = [];
let count = parseInt(output.substring(idx + 1), 10) + 1;
for (let i = 0; i < count; ++i) {
cpuFreq[i] = {
cpu_id: i,
scaling_cur_freq: -1,
scaling_min_freq: -1,
scaling_max_freq: -1,
cpuinfo_min_freq: -1,
cpuinfo_max_freq: -1,
};
}
this.setState({
cpuCount: count,
cpuFreq: cpuFreq,
});
}, 'cat /sys/devices/system/cpu/possible');
}
executeShell = (callback: ShellCallBack, command: string) => {
this.adbClient
.shell(this.device.serial, command)
.then(adb.util.readAll)
.then(function(output) {
return callback(output.toString().trim());
});
};
updateCoreFrequency = (core: number, type: string) => {
this.executeShell((output: string) => {
let cpuFreq = this.state.cpuFreq;
let newFreq = isNormalInteger(output) ? parseInt(output, 10) : -1;
// update table only if frequency changed
if (cpuFreq[core][type] != newFreq) {
cpuFreq[core][type] = newFreq;
if (type == 'scaling_cur_freq' && cpuFreq[core][type] < 0) {
// cannot find current freq means offline
cpuFreq[core][type] = -2;
}
this.setState({
cpuFreq: cpuFreq,
});
}
}, 'cat /sys/devices/system/cpu/cpu' + core + '/cpufreq/' + type);
};
readCoreFrequency = (core: number) => {
let freq = this.state.cpuFreq[core];
if (freq.cpuinfo_max_freq < 0) {
this.updateCoreFrequency(core, 'cpuinfo_max_freq');
}
if (freq.cpuinfo_min_freq < 0) {
this.updateCoreFrequency(core, 'cpuinfo_min_freq');
}
this.updateCoreFrequency(core, 'scaling_cur_freq');
this.updateCoreFrequency(core, 'scaling_min_freq');
this.updateCoreFrequency(core, 'scaling_max_freq');
};
onStartMonitor = () => {
if (this.intervalID) {
return;
}
this.intervalID = setInterval(() => {
for (let i = 0; i < this.state.cpuCount; ++i) {
this.readCoreFrequency(i);
}
}, 500);
this.setState({
monitoring: true,
});
};
onStopMonitor = () => {
if (!this.intervalID) {
return;
} else {
clearInterval(this.intervalID);
this.intervalID = null;
this.setState({
monitoring: false,
});
this.cleanup();
}
};
cleanup = () => {
let cpuFreq = this.state.cpuFreq;
for (let i = 0; i < this.state.cpuCount; ++i) {
cpuFreq[i].scaling_cur_freq = -1;
cpuFreq[i].scaling_min_freq = -1;
cpuFreq[i].scaling_max_freq = -1;
// we don't cleanup cpuinfo_min_freq, cpuinfo_max_freq
// because usually they are fixed (hardware)
}
this.setState({
cpuFreq: cpuFreq,
});
};
teardown = () => {
this.cleanup();
};
buildRow = (freq: CPUFrequency) => {
let style = {};
if (freq.scaling_cur_freq == -2) {
style = {
style: {
backgroundColor: colors.blueTint30,
color: colors.white,
fontWeight: 700,
},
};
} else if (
freq.scaling_min_freq != freq.cpuinfo_min_freq &&
freq.scaling_min_freq > 0 &&
freq.cpuinfo_min_freq > 0
) {
style = {
style: {
backgroundColor: colors.redTint,
color: colors.red,
fontWeight: 700,
},
};
} else if (
freq.scaling_max_freq != freq.cpuinfo_max_freq &&
freq.scaling_max_freq > 0 &&
freq.cpuinfo_max_freq > 0
) {
style = {
style: {
backgroundColor: colors.yellowTint,
color: colors.yellow,
fontWeight: 700,
},
};
}
return {
columns: {
cpu_id: {value: <Text>CPU_{freq.cpu_id}</Text>},
scaling_cur_freq: {
value: <Text>{formatFrequency(freq.scaling_cur_freq)}</Text>,
},
scaling_min_freq: {
value: <Text>{formatFrequency(freq.scaling_min_freq)}</Text>,
},
scaling_max_freq: {
value: <Text>{formatFrequency(freq.scaling_max_freq)}</Text>,
},
cpuinfo_min_freq: {
value: <Text>{formatFrequency(freq.cpuinfo_min_freq)}</Text>,
},
cpuinfo_max_freq: {
value: <Text>{formatFrequency(freq.cpuinfo_max_freq)}</Text>,
},
},
key: freq.cpu_id,
style,
};
};
frequencyRows = (cpuFreqs: Array<CPUFrequency>): TableRows => {
let rows = [];
for (const cpuFreq of cpuFreqs) {
rows.push(this.buildRow(cpuFreq));
}
return rows;
};
render() {
return (
<FlexRow>
<FlexColumn fill={true}>
<Toolbar position="top">
{this.state.monitoring ? (
<Button onClick={this.onStopMonitor} icon="pause">
Pause
</Button>
) : (
<Button onClick={this.onStartMonitor} icon="play">
Start
</Button>
)}
</Toolbar>
<ManagedTable
multiline={true}
columnSizes={ColumnSizes}
columns={Columns}
autoHeight={true}
floating={false}
zebra={true}
rows={this.frequencyRows(this.state.cpuFreq)}
/>
</FlexColumn>
</FlexRow>
);
}
}

View File

@@ -0,0 +1,22 @@
/**
* 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 {GK} from 'sonar';
import logs from './logs/index.js';
import cpu from './cpu/index.js';
import screen from './screen/index.js';
const plugins = [logs];
if (GK.get('sonar_uiperf')) {
plugins.push(cpu);
}
if (GK.get('sonar_screen_plugin')) {
plugins.push(screen);
}
export const devicePlugins = plugins;

View File

@@ -0,0 +1,562 @@
/**
* 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 {
TableBodyRow,
TableColumnOrder,
TableColumnSizes,
TableColumns,
} from 'sonar';
import type {Counter} from './LogWatcher.js';
import type {DeviceLogEntry} from '../../devices/BaseDevice.js';
import {
Text,
ManagedTable,
Button,
colors,
FlexCenter,
LoadingIndicator,
ContextMenu,
FlexColumn,
Glyph,
} from 'sonar';
import {SonarDevicePlugin, SearchableTable} from 'sonar';
import textContent from '../../utils/textContent.js';
import createPaste from '../../utils/createPaste.js';
import LogWatcher from './LogWatcher';
const LOG_WATCHER_LOCAL_STORAGE_KEY = 'LOG_WATCHER_LOCAL_STORAGE_KEY';
type Entries = Array<{
row: TableBodyRow,
entry: DeviceLogEntry,
}>;
type LogsState = {|
initialising: boolean,
rows: Array<TableBodyRow>,
entries: Entries,
key2entry: {[key: string]: DeviceLogEntry},
highlightedRows: Array<string>,
counters: Array<Counter>,
|};
const Icon = Glyph.extends({
marginTop: 5,
});
function getLineCount(str: string): number {
let count = 1;
for (let i = 0; i < str.length; i++) {
if (str[i] === '\n') {
count++;
}
}
return count;
}
function keepKeys(obj, keys) {
const result = {};
for (const key in obj) {
if (keys.includes(key)) {
result[key] = obj[key];
}
}
return result;
}
const COLUMN_SIZE = {
type: 32,
time: 120,
pid: 60,
tid: 60,
tag: 120,
app: 200,
message: 'flex',
};
const COLUMNS = {
type: {
value: '',
},
time: {
value: 'Time',
},
pid: {
value: 'PID',
},
tid: {
value: 'TID',
},
tag: {
value: 'Tag',
},
app: {
value: 'App',
},
message: {
value: 'Message',
},
};
const INITIAL_COLUMN_ORDER = [
{
key: 'type',
visible: true,
},
{
key: 'time',
visible: false,
},
{
key: 'pid',
visible: false,
},
{
key: 'tid',
visible: false,
},
{
key: 'tag',
visible: true,
},
{
key: 'app',
visible: true,
},
{
key: 'message',
visible: true,
},
];
const LOG_TYPES: {
[level: string]: {
label: string,
color: string,
icon?: React.Node,
style?: Object,
},
} = {
verbose: {
label: 'Verbose',
color: colors.purple,
},
debug: {
label: 'Debug',
color: colors.grey,
},
info: {
label: 'Info',
icon: <Icon name="info-circle" color={colors.cyan} />,
color: colors.cyan,
},
warn: {
label: 'Warn',
style: {
backgroundColor: colors.yellowTint,
color: colors.yellow,
fontWeight: 500,
},
icon: <Icon name="caution-triangle" color={colors.yellow} />,
color: colors.yellow,
},
error: {
label: 'Error',
style: {
backgroundColor: colors.redTint,
color: colors.red,
fontWeight: 500,
},
icon: <Icon name="caution-octagon" color={colors.red} />,
color: colors.red,
},
fatal: {
label: 'Fatal',
style: {
backgroundColor: colors.redTint,
color: colors.red,
fontWeight: 700,
},
icon: <Icon name="stop" color={colors.red} />,
color: colors.red,
},
};
const DEFAULT_FILTERS = [
{
type: 'enum',
enum: Object.keys(LOG_TYPES).map(value => ({
label: LOG_TYPES[value].label,
value,
})),
key: 'type',
value: [],
persistent: true,
},
];
const NonSelectableText = Text.extends({
alignSelf: 'baseline',
userSelect: 'none',
lineHeight: '130%',
marginTop: 6,
});
const LogCount = NonSelectableText.extends(
{
backgroundColor: props => props.color,
borderRadius: '999em',
fontSize: 11,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
marginTop: 4,
width: 16,
height: 16,
color: colors.white,
},
{
ignoreAttributes: ['color'],
},
);
const HiddenScrollText = NonSelectableText.extends({
'&::-webkit-scrollbar': {
display: 'none',
},
});
function pad(chunk: mixed, len: number): string {
let str = String(chunk);
while (str.length < len) {
str = `0${str}`;
}
return str;
}
export default class LogTable extends SonarDevicePlugin<LogsState> {
static id = 'DeviceLogs';
static title = 'Logs';
static icon = 'arrow-right';
static keyboardActions = ['clear', 'goToBottom', 'createPaste'];
onKeyboardAction = (action: string) => {
if (action === 'clear') {
this.clearLogs();
} else if (action === 'goToBottom') {
this.goToBottom();
} else if (action === 'createPaste') {
this.createPaste();
}
};
restoreSavedCounters = (): Array<Counter> => {
const savedCounters =
window.localStorage.getItem(LOG_WATCHER_LOCAL_STORAGE_KEY) || '[]';
return JSON.parse(savedCounters).map((counter: Counter) => ({
...counter,
expression: new RegExp(counter.label, 'gi'),
count: 0,
}));
};
state = {
rows: [],
entries: [],
key2entry: {},
initialising: true,
highlightedRows: [],
counters: this.restoreSavedCounters(),
};
tableRef: ?ManagedTable;
columns: TableColumns;
columnSizes: TableColumnSizes;
columnOrder: TableColumnOrder;
init() {
let batch: Entries = [];
let queued = false;
let counter = 0;
const supportedColumns = this.device.supportedColumns();
this.columns = keepKeys(COLUMNS, supportedColumns);
this.columnSizes = keepKeys(COLUMN_SIZE, supportedColumns);
this.columnOrder = INITIAL_COLUMN_ORDER.filter(obj =>
supportedColumns.includes(obj.key),
);
this.device.addLogListener((entry: DeviceLogEntry) => {
const {icon, style} =
LOG_TYPES[(entry.type: string)] || LOG_TYPES.verbose;
// clean message
const message = entry.message.trim();
entry.type === 'error';
let counterUpdated = false;
const counters = this.state.counters.map(counter => {
if (message.match(counter.expression)) {
counterUpdated = true;
if (counter.notify) {
new window.Notification(`${counter.label}`, {
body: 'The watched log message appeared',
});
}
return {
...counter,
count: counter.count + 1,
};
} else {
return counter;
}
});
if (counterUpdated) {
this.setState({counters});
}
// build the item, it will either be batched or added straight away
const item = {
entry,
row: {
columns: {
type: {
value: icon,
},
time: {
value: (
<HiddenScrollText code={true}>
{entry.date.toTimeString().split(' ')[0] +
'.' +
pad(entry.date.getMilliseconds(), 3)}
</HiddenScrollText>
),
},
message: {
value: <HiddenScrollText code={true}>{message}</HiddenScrollText>,
},
tag: {
value: (
<NonSelectableText code={true}>{entry.tag}</NonSelectableText>
),
isFilterable: true,
},
pid: {
value: (
<NonSelectableText code={true}>
{String(entry.pid)}
</NonSelectableText>
),
isFilterable: true,
},
tid: {
value: (
<NonSelectableText code={true}>
{String(entry.tid)}
</NonSelectableText>
),
isFilterable: true,
},
app: {
value: (
<NonSelectableText code={true}>{entry.app}</NonSelectableText>
),
isFilterable: true,
},
},
height: getLineCount(message) * 15 + 10, // 15px per line height + 8px padding
style,
type: entry.type,
filterValue: entry.message,
key: String(counter++),
},
};
// batch up logs to be processed every 250ms, if we have lots of log
// messages coming in, then calling an setState 200+ times is actually
// pretty expensive
batch.push(item);
if (!queued) {
queued = true;
setTimeout(() => {
const thisBatch = batch;
batch = [];
queued = false;
// update rows/entries
this.setState(state => {
const rows = [...state.rows];
const entries = [...state.entries];
const key2entry = {...state.key2entry};
for (let i = 0; i < thisBatch.length; i++) {
const {entry, row} = thisBatch[i];
entries.push({row, entry});
key2entry[row.key] = entry;
let previousEntry: ?DeviceLogEntry = null;
if (i > 0) {
previousEntry = thisBatch[i - 1].entry;
} else if (state.rows.length > 0 && state.entries.length > 0) {
previousEntry = state.entries[state.entries.length - 1].entry;
}
this.addRowIfNeeded(rows, row, entry, previousEntry);
}
return {
entries,
rows,
key2entry,
};
});
}, 100);
}
});
setTimeout(() => {
this.setState({
initialising: false,
});
}, 2000);
}
addRowIfNeeded(
rows: Array<TableBodyRow>,
row: TableBodyRow,
entry: DeviceLogEntry,
previousEntry: ?DeviceLogEntry,
) {
const previousRow = rows.length > 0 ? rows[rows.length - 1] : null;
if (
previousRow &&
previousEntry &&
entry.message === previousEntry.message &&
entry.tag === previousEntry.tag &&
previousRow.type != null
) {
const count = (previousRow.columns.time.value.props.count || 1) + 1;
previousRow.columns.type.value = (
<LogCount color={LOG_TYPES[previousRow.type].color}>{count}</LogCount>
);
} else {
rows.push(row);
}
}
clearLogs = () => {
this.setState({
entries: [],
rows: [],
highlightedRows: [],
key2entry: {},
counters: this.state.counters.map(counter => ({
...counter,
count: 0,
})),
});
};
createPaste = () => {
let paste = '';
const mapFn = row =>
Object.keys(COLUMNS)
.map(key => textContent(row.columns[key].value))
.join('\t');
if (this.state.highlightedRows.length > 0) {
// create paste from selection
paste = this.state.rows
.filter(row => this.state.highlightedRows.indexOf(row.key) > -1)
.map(mapFn)
.join('\n');
} else {
// create paste with all rows
paste = this.state.rows.map(mapFn).join('\n');
}
createPaste(paste);
};
setTableRef = (ref: React.ElementRef<*>) => {
this.tableRef = ref;
};
goToBottom = () => {
if (this.tableRef != null) {
this.tableRef.scrollToBottom();
}
};
onRowHighlighted = (highlightedRows: Array<string>) => {
this.setState({
...this.state,
highlightedRows,
});
};
renderSidebar = () => {
return (
<LogWatcher
counters={this.state.counters}
onChange={counters =>
this.setState({counters}, () =>
window.localStorage.setItem(
LOG_WATCHER_LOCAL_STORAGE_KEY,
JSON.stringify(this.state.counters),
),
)
}
/>
);
};
static ContextMenu = ContextMenu.extends({
flex: 1,
});
render() {
const {initialising, highlightedRows, rows} = this.state;
const contextMenuItems = [
{
type: 'separator',
},
{
label: 'Clear all',
click: this.clearLogs,
},
];
return initialising ? (
<FlexCenter fill={true}>
<LoadingIndicator />
</FlexCenter>
) : (
<LogTable.ContextMenu items={contextMenuItems} component={FlexColumn}>
<SearchableTable
innerRef={this.setTableRef}
floating={false}
multiline={true}
columnSizes={this.columnSizes}
columnOrder={this.columnOrder}
columns={this.columns}
stickyBottom={highlightedRows.length === 0}
rows={rows}
onRowHighlighted={this.onRowHighlighted}
multiHighlight={true}
defaultFilters={DEFAULT_FILTERS}
zebra={false}
actions={<Button onClick={this.clearLogs}>Clear Logs</Button>}
/>
</LogTable.ContextMenu>
);
}
}

View File

@@ -0,0 +1,216 @@
/**
* 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 {
PureComponent,
FlexColumn,
Panel,
Input,
Toolbar,
Text,
ManagedTable,
Button,
colors,
} from 'sonar';
export type Counter = {
expression: RegExp,
count: number,
notify: boolean,
label: string,
};
type Props = {|
onChange: (counters: Array<Counter>) => void,
counters: Array<Counter>,
|};
type State = {
input: string,
highlightedRow: ?string,
};
const ColumnSizes = {
expression: '70%',
count: '15%',
notify: 'flex',
};
const Columns = {
expression: {
value: 'Expression',
resizable: false,
},
count: {
value: 'Count',
resizable: false,
},
notify: {
value: 'Notify',
resizable: false,
},
};
const Count = Text.extends({
alignSelf: 'center',
background: colors.macOSHighlightActive,
color: colors.white,
fontSize: 12,
fontWeight: 500,
textAlign: 'center',
borderRadius: '999em',
padding: '4px 9px 3px',
lineHeight: '100%',
marginLeft: 'auto',
});
const Checkbox = Input.extends({
lineHeight: '100%',
padding: 0,
margin: 0,
height: 'auto',
alignSelf: 'center',
});
const ExpressionInput = Input.extends({
flexGrow: 1,
});
const WatcherPanel = Panel.extends({
minHeight: 200,
});
export default class LogWatcher extends PureComponent<Props, State> {
state = {
input: '',
highlightedRow: null,
};
_inputRef: ?HTMLInputElement;
onAdd = () => {
if (
this.props.counters.findIndex(({label}) => label === this.state.input) >
-1
) {
// prevent duplicates
return;
}
this.props.onChange([
...this.props.counters,
{
label: this.state.input,
expression: new RegExp(this.state.input, 'gi'),
notify: false,
count: 0,
},
]);
this.setState({input: ''});
};
onChange = (e: SyntheticInputEvent<HTMLInputElement>) => {
this.setState({
input: e.target.value,
});
};
resetCount = (index: number) => {
const newCounters = [...this.props.counters];
newCounters[index] = {
...newCounters[index],
count: 0,
};
this.props.onChange(newCounters);
};
buildRows = () => {
return this.props.counters.map(({label, count, notify}, i) => ({
columns: {
expression: {
value: <Text code={true}>{label}</Text>,
},
count: {
value: <Count onClick={() => this.resetCount(i)}>{count}</Count>,
},
notify: {
value: (
<Checkbox
type="checkbox"
checked={notify}
onChange={() => this.setNotification(i, !notify)}
/>
),
},
},
key: label,
}));
};
setNotification = (index: number, notify: boolean) => {
const newCounters: Array<Counter> = [...this.props.counters];
newCounters[index] = {
...newCounters[index],
notify,
};
this.props.onChange(newCounters);
};
onRowHighlighted = (rows: Array<string>) => {
this.setState({
highlightedRow: rows.length === 1 ? rows[0] : null,
});
};
onKeyDown = (e: SyntheticKeyboardEvent<>) => {
if (
(e.key === 'Delete' || e.key === 'Backspace') &&
this.state.highlightedRow != null
) {
this.props.onChange(
this.props.counters.filter(
({label}) => label !== this.state.highlightedRow,
),
);
}
};
onSubmit = (e: SyntheticKeyboardEvent<>) => {
if (e.key === 'Enter') {
this.onAdd();
}
};
render() {
return (
<FlexColumn fill={true} tabIndex={-1} onKeyDown={this.onKeyDown}>
<WatcherPanel
heading="Expression Watcher"
floating={false}
padded={false}>
<Toolbar>
<ExpressionInput
value={this.state.input}
placeholder="Expression..."
onChange={this.onChange}
onKeyDown={this.onSubmit}
/>
<Button onClick={this.onAdd}>Add counter</Button>
</Toolbar>
<ManagedTable
onRowHighlighted={this.onRowHighlighted}
columnSizes={ColumnSizes}
columns={Columns}
rows={this.buildRows()}
autoHeight={true}
floating={false}
zebra={false}
/>
</WatcherPanel>
</FlexColumn>
);
}
}

View File

@@ -0,0 +1,8 @@
/**
* 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 LogTable from './LogTable.js';
export default LogTable;

View File

@@ -0,0 +1,282 @@
/**
* 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 {SonarDevicePlugin} from 'sonar';
import {
Button,
FlexColumn,
FlexRow,
LoadingIndicator,
styled,
colors,
Component,
} from 'sonar';
const os = require('os');
const fs = require('fs');
const adb = require('adbkit-fb');
const path = require('path');
const exec = require('child_process').exec;
const SCREENSHOT_FILE_NAME = 'screen.png';
const VIDEO_FILE_NAME = 'video.mp4';
const SCREENSHOT_PATH = path.join(
os.homedir(),
'/.sonar/',
SCREENSHOT_FILE_NAME,
);
const VIDEO_PATH = path.join(os.homedir(), '.sonar', VIDEO_FILE_NAME);
type AndroidDevice = any;
type AdbClient = any;
type PullTransfer = any;
type State = {|
pullingData: boolean,
recording: boolean,
recordingEnabled: boolean,
capturingScreenshot: boolean,
|};
const BigButton = Button.extends({
height: 200,
width: 200,
flexGrow: 1,
fontSize: 24,
});
const ButtonContainer = FlexRow.extends({
alignItems: 'center',
justifyContent: 'space-around',
padding: 20,
});
const LoadingSpinnerContainer = FlexRow.extends({
flexGrow: 1,
padding: 24,
justifyContent: 'center',
alignItems: 'center',
});
const LoadingSpinnerText = styled.text({
fontSize: 24,
marginLeft: 12,
color: colors.grey,
});
class LoadingSpinner extends Component<{}, {}> {
render() {
return (
<LoadingSpinnerContainer>
<LoadingIndicator />
<LoadingSpinnerText>Pulling files from device...</LoadingSpinnerText>
</LoadingSpinnerContainer>
);
}
}
function openFile(path: string): Promise<*> {
return new Promise((resolve, reject) => {
exec(`${getOpenCommand()} ${path}`, (error, stdout, stderr) => {
if (error) {
reject(error);
} else {
resolve(path);
}
});
});
}
function getOpenCommand(): string {
//TODO: TESTED ONLY ON MAC!
switch (os.platform()) {
case 'win32':
return 'start';
case 'linux':
return 'xdg-open';
default:
return 'open';
}
}
function writePngStreamToFile(stream: PullTransfer): Promise<string> {
return new Promise((resolve, reject) => {
stream.on('end', () => {
resolve(SCREENSHOT_PATH);
});
stream.on('error', reject);
stream.pipe(fs.createWriteStream(SCREENSHOT_PATH));
});
}
export default class ScreenPlugin extends SonarDevicePlugin<State> {
static id = 'DeviceScreen';
static title = 'Screen';
static icon = 'mobile';
device: AndroidDevice;
adbClient: AdbClient;
init() {
this.adbClient = this.device.adb;
this.executeShell(
`[ ! -f /system/bin/screenrecord ] && echo "File does not exist"`,
).then(output => {
if (output) {
console.error(
'screenrecord util does not exist. Most likely it is an emulator which does not support screen recording via adb',
);
this.setState({
recordingEnabled: false,
});
} else {
this.setState({
recordingEnabled: true,
});
}
});
}
captureScreenshot = () => {
return this.adbClient
.screencap(this.device.serial)
.then(writePngStreamToFile)
.then(openFile)
.catch(error => {
//TODO: proper logging?
console.error(error);
});
};
pullFromDevice = (src: string, dst: string): Promise<string> => {
return new Promise((resolve, reject) => {
return this.adbClient.pull(this.device.serial, src).then(stream => {
stream.on('end', () => {
resolve(dst);
});
stream.on('error', reject);
stream.pipe(fs.createWriteStream(dst));
});
});
};
onRecordingClicked = () => {
if (this.state.recording) {
this.stopRecording();
} else {
this.startRecording();
}
};
onScreenshotClicked = () => {
var self = this;
this.setState({
capturingScreenshot: true,
});
this.captureScreenshot().then(() => {
self.setState({
capturingScreenshot: false,
});
});
};
startRecording = () => {
const self = this;
this.setState({
recording: true,
});
this.executeShell(`screenrecord --bugreport /sdcard/${VIDEO_FILE_NAME}`)
.then(output => {
if (output) {
throw output;
}
})
.then(() => {
self.setState({
recording: false,
pullingData: true,
});
})
.then((): Promise<string> => {
return self.pullFromDevice(`/sdcard/${VIDEO_FILE_NAME}`, VIDEO_PATH);
})
.then(openFile)
.then(() => {
self.executeShell(`rm /sdcard/${VIDEO_FILE_NAME}`);
})
.then(() => {
self.setState({
pullingData: false,
});
})
.catch(error => {
console.error(`unable to capture video: ${error}`);
self.setState({
recording: false,
pullingData: false,
});
});
};
stopRecording = () => {
this.executeShell(`pgrep 'screenrecord' -L 2`);
};
executeShell = (command: string): Promise<string> => {
return this.adbClient
.shell(this.device.serial, command)
.then(adb.util.readAll)
.then(output => {
return new Promise((resolve, reject) => {
resolve(output.toString().trim());
});
});
};
getLoadingSpinner = () => {
return this.state.pullingData ? <LoadingSpinner /> : null;
};
render() {
const recordingEnabled =
this.state.recordingEnabled &&
!this.state.capturingScreenshot &&
!this.state.pullingData;
const screenshotEnabled =
!this.state.recording &&
!this.state.capturingScreenshot &&
!this.state.pullingData;
return (
<FlexColumn>
<ButtonContainer>
<BigButton
key="video_btn"
onClick={!recordingEnabled ? null : this.onRecordingClicked}
icon={this.state.recording ? 'stop' : 'camcorder'}
disabled={!recordingEnabled}
selected={true}
pulse={this.state.recording}
iconSize={24}>
{!this.state.recording ? 'Record screen' : 'Stop recording'}
</BigButton>
<BigButton
key="screenshot_btn"
icon="camera"
selected={true}
onClick={!screenshotEnabled ? null : this.onScreenshotClicked}
iconSize={24}
pulse={this.state.capturingScreenshot}
disabled={!screenshotEnabled}>
Take screenshot
</BigButton>
</ButtonContainer>
{this.getLoadingSpinner()}
</FlexColumn>
);
}
}

View File

@@ -0,0 +1,95 @@
/**
* 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 {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;
export default class AndroidDevice extends BaseDevice {
constructor(
serial: string,
deviceType: DeviceType,
title: string,
adb: ADBClient,
) {
super(serial, deviceType, title);
this.adb = adb;
if (deviceType == 'physical') {
this.supportedPlugins.push('DeviceCPU');
}
}
supportedPlugins = [
'DeviceLogs',
'DeviceShell',
'DeviceFiles',
'DeviceScreen',
];
icon = 'icons/android.svg';
os = 'Android';
adb: ADBClient;
pidAppMapping: {[key: number]: string} = {};
supportedColumns(): Array<string> {
return ['date', 'pid', 'tid', 'tag', 'message', 'type', 'time'];
}
addLogListener(callback: DeviceLogListener) {
this.adb.openLogcat(this.serial).then(reader => {
reader.on('entry', async entry => {
let type = 'unknown';
if (entry.priority === Priority.VERBOSE) {
type = 'verbose';
}
if (entry.priority === Priority.DEBUG) {
type = 'debug';
}
if (entry.priority === Priority.INFO) {
type = 'info';
}
if (entry.priority === Priority.WARN) {
type = 'warn';
}
if (entry.priority === Priority.ERROR) {
type = 'error';
}
if (entry.priority === Priority.FATAL) {
type = 'fatal';
}
callback({
tag: entry.tag,
pid: entry.pid,
tid: entry.tid,
message: entry.message,
date: entry.date,
type,
});
});
});
}
reverse(): Promise<void> {
if (this.deviceType === 'physical') {
return this.adb
.reverse(this.serial, 'tcp:8088', 'tcp:8088')
.then(_ => this.adb.reverse(this.serial, 'tcp:8089', 'tcp:8089'));
} else {
return Promise.resolve();
}
}
spawnShell(): DeviceShell {
return child_process.spawn('adb', ['-s', this.serial, 'shell', '-t', '-t']);
}
}

78
src/devices/BaseDevice.js Normal file
View File

@@ -0,0 +1,78 @@
/**
* 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 stream from 'stream';
import {SonarDevicePlugin} from 'sonar';
export type DeviceLogEntry = {
date: Date,
pid: number,
tid: number,
app?: string,
type: 'unknown' | 'verbose' | 'debug' | 'info' | 'warn' | 'error' | 'fatal',
tag: string,
message: string,
};
export type DeviceShell = {
stdout: stream.Readable,
stderr: stream.Readable,
stdin: stream.Writable,
};
export type DeviceLogListener = (entry: DeviceLogEntry) => void;
export type DeviceType = 'emulator' | 'physical';
export default class BaseDevice {
constructor(serial: string, deviceType: DeviceType, title: string) {
this.serial = serial;
this.title = title;
this.deviceType = deviceType;
}
// operating system of this device
os: string;
// human readable name for this device
title: string;
// type of this device
deviceType: DeviceType;
// serial number for this device
serial: string;
// supported device plugins for this platform
supportedPlugins: Array<string> = [];
// possible src of icon to display next to the device title
icon: ?string;
supportsPlugin(DevicePlugin: Class<SonarDevicePlugin<>>) {
return this.supportedPlugins.includes(DevicePlugin.id);
}
// ensure that we don't serialise devices
toJSON() {
return null;
}
teardown() {}
supportedColumns(): Array<string> {
throw new Error('unimplemented');
}
addLogListener(listener: DeviceLogListener) {
throw new Error('unimplemented');
}
spawnShell(): DeviceShell {
throw new Error('unimplemented');
}
}

162
src/devices/IOSDevice.js Normal file
View File

@@ -0,0 +1,162 @@
/**
* 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 {
DeviceType,
DeviceLogEntry,
DeviceLogListener,
} from './BaseDevice.js';
import child_process from 'child_process';
import BaseDevice from './BaseDevice.js';
import JSONStream from 'JSONStream';
import {Transform} from 'stream';
type RawLogEntry = {
activityID: string, // Number in string format
eventMessage: string,
eventType: string,
machTimestamp: number,
processID: number,
processImagePath: string,
processImageUUID: string,
processUniqueID: number,
senderImagePath: string,
senderImageUUID: string,
senderProgramCounter: number,
threadID: number,
timestamp: string, // "2017-09-27 16:21:15.771213-0400"
timezoneName: string,
traceID: string,
};
export default class IOSDevice extends BaseDevice {
supportedPlugins = ['DeviceLogs'];
icon = 'icons/ios.svg';
os = 'iOS';
log: any;
buffer: string;
constructor(serial: string, deviceType: DeviceType, title: string) {
super(serial, deviceType, title);
this.buffer = '';
this.log = null;
}
teardown() {
if (this.log) {
this.log.kill();
}
}
supportedColumns(): Array<string> {
return ['date', 'pid', 'tid', 'tag', 'message', 'type', 'time'];
}
addLogListener(callback: DeviceLogListener) {
if (!this.log) {
this.log = child_process.spawn(
'xcrun',
[
'simctl',
'spawn',
'booted',
'log',
'stream',
'--style',
'json',
'--predicate',
'senderImagePath contains "Containers"',
'--info',
'--debug',
],
{},
);
this.log.on('error', err => {
console.error(err);
});
this.log.stderr.on('data', data => {
console.error(data.toString());
});
this.log.on('exit', () => {
this.log = null;
});
}
this.log.stdout
.pipe(new StripLogPrefix())
.pipe(JSONStream.parse('*'))
.on('data', (data: RawLogEntry) => {
callback(IOSDevice.parseLogEntry(data));
});
}
static parseLogEntry(entry: RawLogEntry): DeviceLogEntry {
let type = 'unknown';
if (entry.eventMessage.indexOf('[debug]') !== -1) {
type = 'debug';
} else if (entry.eventMessage.indexOf('[info]') !== -1) {
type = 'info';
} else if (entry.eventMessage.indexOf('[warn]') !== -1) {
type = 'warn';
} else if (entry.eventMessage.indexOf('[error]') !== -1) {
type = 'error';
}
// remove timestamp in front of message
entry.eventMessage = entry.eventMessage.replace(
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} /,
'',
);
// remove type from mesage
entry.eventMessage = entry.eventMessage.replace(
/^\[(debug|info|warn|error)\]/,
'',
);
const tags = entry.processImagePath.split('/');
const tag = tags[tags.length - 1];
return {
date: new Date(entry.timestamp),
pid: entry.processID,
tid: entry.threadID,
tag,
message: entry.eventMessage,
type,
};
}
}
// Used to strip the initial output of the logging utility where it prints out settings.
// We know the log stream is json so it starts with an open brace.
class StripLogPrefix extends Transform {
passedPrefix = false;
_transform(
data: any,
encoding: string,
callback: (err?: Error, data?: any) => void,
) {
if (this.passedPrefix) {
this.push(data);
} else {
const dataString = data.toString();
const index = dataString.indexOf('[');
if (index >= 0) {
this.push(dataString.substring(index));
this.passedPrefix = true;
}
}
callback();
}
}

142
src/devices/OculusDevice.js Normal file
View File

@@ -0,0 +1,142 @@
/**
* 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 {
DeviceType,
DeviceLogEntry,
DeviceLogListener,
} from './BaseDevice.js';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import BaseDevice from './BaseDevice.js';
function getLogsPath(fileName: ?string): string {
const dir = '/AppData/Local/Oculus/';
if (fileName) {
return path.join(os.homedir(), dir, fileName);
}
return path.join(os.homedir(), dir);
}
export default class OculusDevice extends BaseDevice {
supportedPlugins = ['DeviceLogs'];
icon = 'icons/oculus.png';
os = 'Oculus';
watcher: any;
processedFileMap: {};
watchedFile: ?string;
timer: TimeoutID;
constructor(serial: string, deviceType: DeviceType, title: string) {
super(serial, deviceType, title);
this.watcher = null;
this.processedFileMap = {};
}
teardown() {
clearTimeout(this.timer);
const file = this.watchedFile;
if (file) {
fs.unwatchFile(path.join(getLogsPath(), file));
}
}
supportedColumns(): Array<string> {
return ['date', 'tag', 'message', 'type', 'time'];
}
mapLogLevel(type: string): $PropertyType<DeviceLogEntry, 'type'> {
switch (type) {
case 'WARNING':
return 'warn';
case '!ERROR!':
return 'error';
case 'DEBUG':
return 'debug';
case 'INFO':
return 'info';
default:
return 'verbose';
}
}
processText(text: Buffer, callback: DeviceLogListener) {
text
.toString()
.split('\r\n')
.forEach(line => {
const regex = /(.*){(\S+)}\s*\[([\w :.\\]+)\](.*)/;
const match = regex.exec(line);
if (match && match.length === 5) {
callback({
tid: 0,
pid: 0,
date: new Date(Date.parse(match[1])),
type: this.mapLogLevel(match[2]),
tag: match[3],
message: match[4],
});
} else if (line.trim() === '') {
// skip
} else {
callback({
tid: 0,
pid: 0,
date: new Date(),
type: 'verbose',
tag: 'failed-parse',
message: line,
});
}
});
}
addLogListener = (callback: DeviceLogListener) => {
this.setupListener(callback);
};
async setupListener(callback: DeviceLogListener) {
const files = await fs.readdir(getLogsPath());
this.watchedFile = files
.filter(file => file.startsWith('Service_'))
.sort()
.pop();
this.watch(callback);
this.timer = setTimeout(() => this.checkForNewLog(callback), 5000);
}
watch(callback: DeviceLogListener) {
const filePath = getLogsPath(this.watchedFile);
fs.watchFile(filePath, async (current, previous) => {
const readLen = current.size - previous.size;
const buffer = new Buffer(readLen);
const fd = await fs.open(filePath, 'r');
await fs.read(fd, buffer, 0, readLen, previous.size);
this.processText(buffer, callback);
});
}
async checkForNewLog(callback: DeviceLogListener) {
const files = await fs.readdir(getLogsPath());
const latestLog = files
.filter(file => file.startsWith('Service_'))
.sort()
.pop();
if (this.watchedFile !== latestLog) {
const oldFilePath = getLogsPath(this.watchedFile);
fs.unwatchFile(oldFilePath);
this.watchedFile = latestLog;
this.watch(callback);
}
this.timer = setTimeout(() => this.checkForNewLog(callback), 5000);
}
}

View File

@@ -0,0 +1,97 @@
/**
* 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 AndroidDevice from '../devices/AndroidDevice';
import type {Store} from '../reducers/index.js';
import type BaseDevice from '../devices/BaseDevice';
const adb = require('adbkit-fb');
function createDecive(client, device): Promise<AndroidDevice> {
return new Promise((resolve, reject) => {
const type =
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,
);
androidDevice.reverse();
resolve(androidDevice);
});
});
}
export default (store: Store) => {
const client = adb.createClient();
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
.filter((device: BaseDevice) => device instanceof AndroidDevice)
.map((device: BaseDevice) => device.serial);
store.dispatch({
type: 'UNREGISTER_DEVICES',
payload: new Set(deviceIDsToRemove),
});
console.error(
'adb server shutdown. Run `adb start-server` and restart Sonar.',
);
} else {
throw err;
}
});
tracker.on('add', async device => {
const androidDevice = await createDecive(client, device);
if (device.type !== 'offline') {
store.dispatch({
type: 'REGISTER_DEVICE',
payload: androidDevice,
});
}
});
tracker.on('change', async device => {
if (device.type === 'offline') {
store.dispatch({
type: 'UNREGISTER_DEVICES',
payload: new Set([device.id]),
});
} else {
const androidDevice = await createDecive(client, device);
store.dispatch({
type: 'REGISTER_DEVICE',
payload: androidDevice,
});
}
});
tracker.on('remove', device => {
store.dispatch({
type: 'UNREGISTER_DEVICES',
payload: new Set([device.id]),
});
});
})
.catch(err => {
if (err.code === 'ECONNREFUSED') {
// adb server isn't running
} else {
throw err;
}
});
};

View File

@@ -0,0 +1,25 @@
/**
* 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 {remote} from 'electron';
import type {Store} from '../reducers/index.js';
export default (store: Store) => {
const currentWindow = remote.getCurrentWindow();
currentWindow.on('focus', () =>
store.dispatch({
type: 'windowIsFocused',
payload: true,
}),
);
currentWindow.on('blur', () =>
store.dispatch({
type: 'windowIsFocused',
payload: false,
}),
);
};

View File

@@ -0,0 +1,99 @@
/**
* 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 {ChildProcess} from 'child_process';
import type {Store} from '../reducers/index.js';
import child_process from 'child_process';
import IOSDevice from '../devices/IOSDevice';
type iOSSimulatorDevice = {|
state: 'Booted' | 'Shutdown' | 'Shutting Down',
availability: string,
name: string,
udid: string,
|};
type IOSDeviceMap = {[id: string]: Array<iOSSimulatorDevice>};
// start port forwarding server for real device connections
const portForwarder: ChildProcess = child_process.exec(
'PortForwardingMacApp.app/Contents/MacOS/PortForwardingMacApp -portForward=8088 -multiplexChannelPort=8078',
);
window.addEventListener('beforeunload', () => {
portForwarder.kill();
});
function querySimulatorDevices(): Promise<IOSDeviceMap> {
return new Promise((resolve, reject) => {
child_process.execFile(
'xcrun',
['simctl', 'list', 'devices', '--json'],
{encoding: 'utf8'},
(err, stdout) => {
if (err) {
console.error('Failed to load iOS devices', err);
return resolve({});
}
try {
const {devices} = JSON.parse(stdout.toString());
resolve(devices);
} catch (err) {
console.error('Failed to parse iOS device list', err);
resolve({});
}
},
);
});
}
export default (store: Store) => {
// monitoring iOS devices only available on MacOS.
if (process.platform !== 'darwin') {
return;
}
setInterval(() => {
const {devices} = store.getState();
querySimulatorDevices().then((simulatorDevices: IOSDeviceMap) => {
const simulators: Array<iOSSimulatorDevice> = Object.values(
simulatorDevices,
// $FlowFixMe
).reduce((acc, cv) => acc.concat(cv), []);
const currentDeviceIDs: Set<string> = new Set(
devices
.filter(device => device instanceof IOSDevice)
.map(device => device.serial),
);
const deviceIDsToRemove = new Set();
simulators.forEach((simulator: iOSSimulatorDevice) => {
const isRunning =
simulator.state === 'Booted' &&
simulator.availability === '(available)';
if (isRunning && !currentDeviceIDs.has(simulator.udid)) {
// create device
store.dispatch({
type: 'REGISTER_DEVICE',
payload: new IOSDevice(simulator.udid, 'emulator', simulator.name),
});
} else if (!isRunning && currentDeviceIDs.has(simulator.udid)) {
deviceIDsToRemove.add(simulator.udid);
// delete device
}
});
if (deviceIDsToRemove.size > 0) {
store.dispatch({
type: 'UNREGISTER_DEVICES',
payload: deviceIDsToRemove,
});
}
});
}, 3000);
};

14
src/dispatcher/index.js Normal file
View File

@@ -0,0 +1,14 @@
/**
* 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 androidDevice from './androidDevice';
import iOSDevice from './iOSDevice';
import application from './application';
import type {Store} from '../reducers/index.js';
export default (store: Store) =>
[application, androidDevice, iOSDevice].forEach(fn => fn(store));

View File

@@ -0,0 +1,15 @@
/**
* 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 LogManager from './Logger';
export default class BugReporter {
constructor(logManager: LogManager) {}
async report(title: string, body: string): Promise<number> {
return Promise.resolve(-1);
}
}

View File

@@ -0,0 +1,21 @@
/**
* 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 function cleanStack(stack: string, loc: ?string) {}
import type ScribeLogger from './ScribeLogger';
export type ObjectError =
| Error
| {
message: string,
stack?: string,
};
export default class ErrorReporter {
constructor(scribeLogger: ScribeLogger) {}
report(err: ObjectError) {}
}

16
src/fb-stubs/GK.js Normal file
View File

@@ -0,0 +1,16 @@
/**
* 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 GKID = string;
export default class GK {
static init() {}
static get(id: GKID): boolean {
return false;
}
}

40
src/fb-stubs/Logger.js Normal file
View File

@@ -0,0 +1,40 @@
/**
* 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 LogTypes = 'error' | 'warn' | 'info' | 'debug';
export type TrackType = 'duration' | 'usage' | 'performance';
import ScribeLogger from './ScribeLogger';
export default class LogManager {
constructor() {
this.scribeLogger = new ScribeLogger(this);
}
scribeLogger: ScribeLogger;
track(type: TrackType, event: string, data: ?any) {}
trackTimeSince(mark: string, eventName: ?string) {}
info(data: any, category: string) {
// eslint-disable-next-line
console.info(data, category);
}
warn(data: any, category: string) {
console.warn(data, category);
}
error(data: any, category: string) {
console.error(data, category);
}
debug(data: any, category: string) {
// eslint-disable-next-line
console.debug(data, category);
}
}

View File

@@ -0,0 +1,18 @@
/**
* 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 ScribeMessage = {|
category: string,
message: string,
|};
import type Logger from './Logger.js';
export default class ScribeLogger {
constructor(logger: Logger) {}
send(message: ScribeMessage) {}
}

11
src/fb-stubs/config.js Normal file
View File

@@ -0,0 +1,11 @@
/**
* 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 default {
updateServer: 'https://www.facebook.com/sonar/public/latest.json',
bugReportButtonVisible: false,
};

19
src/fb-stubs/constants.js Normal file
View File

@@ -0,0 +1,19 @@
/**
* 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 const GRAPH_APP_ID = '';
export const GRAPH_CLIENT_TOKEN = '';
export const GRAPH_ACCESS_TOKEN = '';
// this provides elevated access to scribe. we really shouldn't be exposing this.
// need to investigate how to abstract the scribe logging so it's safe.
export const GRAPH_SECRET = '';
export const GRAPH_SECRET_ACCESS_TOKEN = '';
// Provides access to Insights Validation ednpoint on interngraph
export const INSIGHT_INTERN_APP_ID = '';
export const INSIGHT_INTERN_APP_TOKEN = '';

21
src/index.js Normal file
View File

@@ -0,0 +1,21 @@
/**
* 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 {default as styled} from './ui/styled/index.js';
export * from './ui/index.js';
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 * from './init.js';
export {default} from './init.js';
export {default as AndroidDevice} from './devices/AndroidDevice.js';
export {default as Device} from './devices/BaseDevice.js';
export {default as IOSDevice} from './devices/IOSDevice.js';

48
src/init.js Normal file
View File

@@ -0,0 +1,48 @@
/**
* 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 {Provider} from 'react-redux';
import ReactDOM from 'react-dom';
import {ContextMenuProvider} from 'sonar';
import {precachedIcons} from './utils/icons.js';
import GK from './fb-stubs/GK.js';
import App from './App.js';
import {createStore} from 'redux';
import reducers from './reducers/index.js';
import dispatcher from './dispatcher/index.js';
const path = require('path');
const store = createStore(
reducers,
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__(),
);
dispatcher(store);
GK.init();
const AppFrame = () => (
<ContextMenuProvider>
<Provider store={store}>
<App />
</Provider>
</ContextMenuProvider>
);
// $FlowFixMe: this element exists!
ReactDOM.render(<AppFrame />, document.getElementById('root'));
// $FlowFixMe: service workers exist!
navigator.serviceWorker
.register(
process.env.NODE_ENV === 'production'
? path.join(__dirname, 'serviceWorker.js')
: './serviceWorker.js',
)
.then(r => {
(r.installing || r.active).postMessage({precachedIcons});
})
.catch(console.error);

241
src/plugin.js Normal file
View File

@@ -0,0 +1,241 @@
/**
* 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 {KeyboardActions} from './MenuBar.js';
import type {App} from './App.js';
import type {Client} from './server.js';
import BaseDevice from './devices/BaseDevice.js';
import {AndroidDevice, IOSDevice} from 'sonar';
const invariant = require('invariant');
export type PluginClient = {|
send: (method: string, params?: Object) => void,
call: (method: string, params?: Object) => Promise<any>,
subscribe: (method: string, callback: (params: any) => void) => void,
|};
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<State: Object = any, Actions = any> {
constructor() {
// $FlowFixMe: this is fine
this.state = {};
}
static title: string = 'Unknown';
static id: string = 'Unknown';
static icon: string = 'apps';
static persist: boolean = true;
static keyboardActions: ?KeyboardActions;
static screenshot: ?string;
// forbid instance properties that should be static
title: empty;
id: empty;
persist: 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;
}
// methods to be overriden by plugins
init(): void {}
teardown(): void {}
// 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,
);
}
dispatchAction(actionData: Actions) {
// $FlowFixMe
const action = this.reducers[actionData.type];
if (!action) {
// $FlowFixMe
throw new ReferenceError(`Unknown action ${actionData.type}`);
}
if (typeof action === 'function') {
this.setState(action.call(this, this.state, actionData));
} else {
// $FlowFixMe
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> {
device: BaseDevice;
_setup(target: PluginTarget, app: App) {
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();
}
_init() {
this.init();
}
}
export class SonarPlugin<
State: Object = any,
Actions = any,
> extends SonarBasePlugin<State, Actions> {
constructor() {
super();
this.subscriptions = [];
}
subscriptions: Array<{
method: string,
callback: Function,
}>;
client: PluginClient;
realClient: Client;
getDevice(): ?BaseDevice {
return this.realClient.getDevice();
}
getAndroidDevice(): AndroidDevice {
const device = this.getDevice();
invariant(
device != null && device instanceof AndroidDevice,
'expected android device',
);
return device;
}
getIOSDevice() {
const device = this.getDevice();
invariant(
device != null && device instanceof IOSDevice,
'expected ios device',
);
return device;
}
_setup(target: any, app: App) {
/* 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),
send: (method, params) => realClient.send(id, method, params),
subscribe: (method, callback) => {
this.subscriptions.push({
method,
callback,
});
realClient.subscribe(id, method, callback);
},
};
super._setup(realClient, app);
}
_teardown() {
// automatically unsubscribe subscriptions
for (const {method, callback} of this.subscriptions) {
this.realClient.unsubscribe(this.constructor.id, method, callback);
}
// run plugin teardown
this.teardown();
if (this.realClient.connected) {
this.realClient.rawSend('deinit', {plugin: this.constructor.id});
}
}
_init() {
this.realClient.rawSend('init', {plugin: this.constructor.id});
this.init();
}
}

72
src/plugins/index.js Normal file
View File

@@ -0,0 +1,72 @@
/**
* 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 {GK} from 'sonar';
import React from 'react';
import ReactDOM from 'react-dom';
import * as Sonar from 'sonar';
import {SonarBasePlugin} from '../plugin.js';
const plugins = new Map();
// expose Sonar and exact globally for dynamically loaded plugins
window.React = React;
window.ReactDOM = ReactDOM;
window.Sonar = Sonar;
const addIfNotAdded = plugin => {
if (!plugins.has(plugin.name)) {
plugins.set(plugin.name, plugin);
}
};
let disabledPlugins = [];
try {
disabledPlugins =
JSON.parse(window.process.env.CONFIG || '{}').disabledPlugins || [];
} catch (e) {
console.error(e);
}
// Load dynamic plugins
try {
JSON.parse(window.process.env.PLUGINS || '[]').forEach(addIfNotAdded);
} catch (e) {
console.error(e);
}
// DefaultPlugins that are included in the bundle.
// List of defaultPlugins is written at build time
let bundledPlugins = [];
try {
bundledPlugins = window.electronRequire('./defaultPlugins/index.json');
} catch (e) {}
bundledPlugins
.map(plugin => ({
...plugin,
out: './' + plugin.out,
}))
.forEach(addIfNotAdded);
export default Array.from(plugins.values())
.map(plugin => {
if (
(plugin.gatekeeper && !GK.get(plugin.gatekeeper)) ||
disabledPlugins.indexOf(plugin.name) > -1
) {
return null;
} else {
try {
return window.electronRequire(plugin.out);
} catch (e) {
console.error(plugin, e);
return null;
}
}
})
.filter(Boolean)
.filter(plugin => plugin.prototype instanceof SonarBasePlugin);

576
src/plugins/layout/index.js Normal file
View File

@@ -0,0 +1,576 @@
/**
* 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 {ElementID, Element, ElementSearchResultSet} from 'sonar';
import {
colors,
Glyph,
GK,
FlexRow,
FlexColumn,
Toolbar,
SonarPlugin,
ElementsInspector,
InspectorSidebar,
LoadingIndicator,
styled,
Component,
SearchBox,
SearchInput,
SearchIcon,
} from 'sonar';
// $FlowFixMe
import debounce from 'lodash.debounce';
export type InspectorState = {|
initialised: boolean,
selected: ?ElementID,
root: ?ElementID,
elements: {[key: ElementID]: Element},
isSearchActive: boolean,
searchResults: ?ElementSearchResultSet,
outstandingSearchQuery: ?string,
|};
type SelectElementArgs = {|
key: ElementID,
|};
type ExpandElementArgs = {|
key: ElementID,
expand: boolean,
|};
type ExpandElementsArgs = {|
elements: Array<ElementID>,
|};
type UpdateElementsArgs = {|
elements: Array<$Shape<Element>>,
|};
type SetRootArgs = {|
root: ElementID,
|};
type GetNodesResult = {|
elements: Array<Element>,
|};
type SearchResultTree = {|
id: string,
isMatch: Boolean,
children: ?Array<SearchResultTree>,
element: Element,
|};
const LoadingSpinner = LoadingIndicator.extends({
marginRight: 4,
marginLeft: 3,
marginTop: -1,
});
const Center = FlexRow.extends({
alignItems: 'center',
justifyContent: 'center',
});
const SearchIconContainer = styled.view({
marginRight: 9,
marginTop: -3,
marginLeft: 4,
});
class LayoutSearchInput extends Component<
{
onSubmit: string => void,
},
{
value: string,
},
> {
static TextInput = styled.textInput({
width: '100%',
marginLeft: 6,
});
state = {
value: '',
};
timer: TimeoutID;
onChange = (e: SyntheticInputEvent<>) => {
clearTimeout(this.timer);
this.setState({
value: e.target.value,
});
this.timer = setTimeout(() => this.props.onSubmit(this.state.value), 200);
};
onKeyDown = (e: SyntheticKeyboardEvent<>) => {
if (e.key === 'Enter') {
this.props.onSubmit(this.state.value);
}
};
render() {
return (
<SearchInput
placeholder={'Search'}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
value={this.state.value}
/>
);
}
}
export default class Layout extends SonarPlugin<InspectorState> {
static title = 'Layout';
static id = 'Inspector';
static icon = 'target';
state = {
elements: {},
initialised: false,
isSearchActive: false,
root: null,
selected: null,
searchResults: null,
outstandingSearchQuery: null,
};
reducers = {
SelectElement(state: InspectorState, {key}: SelectElementArgs) {
return {
selected: key,
};
},
ExpandElement(state: InspectorState, {expand, key}: ExpandElementArgs) {
return {
elements: {
...state.elements,
[key]: {
...state.elements[key],
expanded: expand,
},
},
};
},
ExpandElements(state: InspectorState, {elements}: ExpandElementsArgs) {
const expandedSet = new Set(elements);
const newState = {
elements: {
...state.elements,
},
};
for (const key of Object.keys(state.elements)) {
newState.elements[key] = {
...newState.elements[key],
expanded: expandedSet.has(key),
};
}
return newState;
},
UpdateElements(state: InspectorState, {elements}: UpdateElementsArgs) {
const updatedElements = state.elements;
for (const element of elements) {
const current = updatedElements[element.id] || {};
// $FlowFixMe
updatedElements[element.id] = {
...current,
...element,
};
}
return {elements: updatedElements};
},
SetRoot(state: InspectorState, {root}: SetRootArgs) {
return {root};
},
SetSearchActive(
state: InspectorState,
{isSearchActive}: {isSearchActive: boolean},
) {
return {isSearchActive};
},
};
search(query: string) {
if (!query) {
return;
}
this.setState({
outstandingSearchQuery: query,
});
this.client
.call('getSearchResults', {query: query})
.then(response => this.displaySearchResults(response));
}
executeCommand(command: string) {
return this.client.call('executeCommand', {
command: command,
context: this.state.selected,
});
}
/**
* When opening the inspector for the first time, expand all elements that contain only 1 child
* recursively.
*/
async performInitialExpand(element: Element): Promise<void> {
if (!element.children.length) {
// element has no children so we're as deep as we can be
return;
}
this.dispatchAction({expand: true, key: element.id, type: 'ExpandElement'});
return this.getChildren(element.id).then((elements: Array<Element>) => {
this.dispatchAction({elements, type: 'UpdateElements'});
if (element.children.length >= 2) {
// element has two or more children so we can stop expanding
return;
}
return this.performInitialExpand(
this.state.elements[element.children[0]],
);
});
}
displaySearchResults({
results,
query,
}: {
results: SearchResultTree,
query: string,
}) {
const elements = this.getElementsFromSearchResultTree(results);
const idsToExpand = elements
.filter(x => x.hasChildren)
.map(x => x.element.id);
const finishedSearching = query === this.state.outstandingSearchQuery;
this.dispatchAction({
elements: elements.map(x => x.element),
type: 'UpdateElements',
});
this.dispatchAction({
elements: idsToExpand,
type: 'ExpandElements',
});
this.setState({
searchResults: {
matches: new Set(
elements.filter(x => x.isMatch).map(x => x.element.id),
),
query: query,
},
outstandingSearchQuery: finishedSearching
? null
: this.state.outstandingSearchQuery,
});
}
getElementsFromSearchResultTree(tree: SearchResultTree) {
if (!tree) {
return [];
}
var elements = [
{
id: tree.id,
isMatch: tree.isMatch,
hasChildren: Boolean(tree.children),
element: tree.element,
},
];
if (tree.children) {
for (const child of tree.children) {
elements = elements.concat(this.getElementsFromSearchResultTree(child));
}
}
return elements;
}
init() {
performance.mark('LayoutInspectorInitialize');
this.client.call('getRoot').then((element: Element) => {
this.dispatchAction({elements: [element], type: 'UpdateElements'});
this.dispatchAction({root: element.id, type: 'SetRoot'});
this.performInitialExpand(element).then(() => {
this.app.logger.trackTimeSince('LayoutInspectorInitialize');
this.setState({initialised: true});
});
});
this.client.subscribe(
'invalidate',
({nodes}: {nodes: Array<{id: ElementID}>}) => {
this.invalidate(nodes.map(node => node.id)).then(
(elements: Array<Element>) => {
this.dispatchAction({elements, type: 'UpdateElements'});
},
);
},
);
this.client.subscribe('select', ({path}: {path: Array<ElementID>}) => {
this.getNodesAndDirectChildren(path).then((elements: Array<Element>) => {
const selected = path[path.length - 1];
this.dispatchAction({elements, type: 'UpdateElements'});
this.dispatchAction({key: selected, type: 'SelectElement'});
this.dispatchAction({isSearchActive: false, type: 'SetSearchActive'});
for (const key of path) {
this.dispatchAction({expand: true, key, type: 'ExpandElement'});
}
this.client.send('setHighlighted', {id: selected});
this.client.send('setSearchActive', {active: false});
});
});
}
invalidate(ids: Array<ElementID>): Promise<Array<Element>> {
if (ids.length === 0) {
return Promise.resolve([]);
}
return this.getNodes(ids, true).then((elements: Array<Element>) => {
const children = elements
.filter(element => {
const prev = this.state.elements[element.id];
return prev && prev.expanded;
})
.map(element => element.children)
.reduce((acc, val) => acc.concat(val), []);
return Promise.all([elements, this.invalidate(children)]).then(arr => {
return arr.reduce((acc, val) => acc.concat(val), []);
});
});
}
getNodesAndDirectChildren(ids: Array<ElementID>): Promise<Array<Element>> {
return this.getNodes(ids, false).then((elements: Array<Element>) => {
const children = elements
.map(element => element.children)
.reduce((acc, val) => acc.concat(val), []);
return Promise.all([elements, this.getNodes(children, false)]).then(
arr => {
return arr.reduce((acc, val) => acc.concat(val), []);
},
);
});
}
getChildren(key: ElementID): Promise<Array<Element>> {
return this.getNodes(this.state.elements[key].children, false);
}
getNodes(
ids: Array<ElementID> = [],
force: boolean,
): Promise<Array<Element>> {
if (!force) {
ids = ids.filter(id => {
return this.state.elements[id] === undefined;
});
}
if (ids.length > 0) {
performance.mark('LayoutInspectorGetNodes');
return this.client
.call('getNodes', {ids})
.then(({elements}: GetNodesResult) => {
this.app.logger.trackTimeSince('LayoutInspectorGetNodes');
return Promise.resolve(elements);
});
} else {
return Promise.resolve([]);
}
}
isExpanded(key: ElementID): boolean {
return this.state.elements[key].expanded;
}
expandElement = (key: ElementID): Promise<Array<Element>> => {
const expand = !this.isExpanded(key);
return this.setElementExpanded(key, expand);
};
setElementExpanded = (
key: ElementID,
expand: boolean,
): Promise<Array<Element>> => {
this.dispatchAction({expand, key, type: 'ExpandElement'});
performance.mark('LayoutInspectorExpandElement');
if (expand) {
return this.getChildren(key).then((elements: Array<Element>) => {
this.app.logger.trackTimeSince('LayoutInspectorExpandElement');
this.dispatchAction({elements, type: 'UpdateElements'});
return Promise.resolve(elements);
});
} else {
return Promise.resolve([]);
}
};
deepExpandElement = async (key: ElementID) => {
const expand = !this.isExpanded(key);
if (!expand) {
// we never deep unexpand
return this.setElementExpanded(key, false);
}
// queue of keys to open
const keys = [key];
// amount of elements we've expanded, we stop at 100 just to be safe
let count = 0;
while (keys.length && count < 100) {
const key = keys.shift();
// expand current element
const children = await this.setElementExpanded(key, true);
// and add it's children to the queue
for (const child of children) {
keys.push(child.id);
}
count++;
}
};
onElementExpanded = (key: ElementID, deep: boolean) => {
if (deep) {
this.deepExpandElement(key);
} else {
this.expandElement(key);
}
this.app.logger.track('usage', 'layout:element-expanded', {
id: key,
deep: deep,
});
};
onFindClick = () => {
const isSearchActive = !this.state.isSearchActive;
this.dispatchAction({isSearchActive, type: 'SetSearchActive'});
this.client.send('setSearchActive', {active: isSearchActive});
};
onElementSelected = debounce((key: ElementID) => {
this.dispatchAction({key, type: 'SelectElement'});
this.client.send('setHighlighted', {id: key});
this.getNodes([key], true).then((elements: Array<Element>) => {
this.dispatchAction({elements, type: 'UpdateElements'});
});
});
onElementHovered = debounce((key: ?ElementID) => {
this.client.send('setHighlighted', {id: key});
});
onDataValueChanged = (path: Array<string>, value: any) => {
this.client.send('setData', {id: this.state.selected, path, value});
this.app.logger.track('usage', 'layout:value-changed', {
id: this.state.selected,
value: value,
path: path,
});
};
renderSidebar = () => {
return this.state.selected != null ? (
<InspectorSidebar
element={this.state.elements[this.state.selected]}
onValueChanged={this.onDataValueChanged}
client={this.client}
/>
) : null;
};
render() {
const {
initialised,
selected,
root,
elements,
isSearchActive,
outstandingSearchQuery,
} = this.state;
return (
<FlexColumn fill={true}>
<Toolbar>
<SearchIconContainer
onClick={this.onFindClick}
role="button"
tabIndex={-1}
title="Select an element on the device to inspect it">
<Glyph
name="target"
size={16}
color={
isSearchActive
? colors.macOSTitleBarIconSelected
: colors.macOSTitleBarIconActive
}
/>
</SearchIconContainer>
{GK.get('sonar_layout_search') && (
<SearchBox tabIndex={-1}>
<SearchIcon
name="magnifying-glass"
color={colors.macOSTitleBarIcon}
size={16}
/>
<LayoutSearchInput onSubmit={this.search.bind(this)} />
{outstandingSearchQuery && <LoadingSpinner size={16} />}
</SearchBox>
)}
</Toolbar>
<FlexRow fill={true}>
{initialised ? (
<ElementsInspector
onElementSelected={this.onElementSelected}
onElementHovered={this.onElementHovered}
onElementExpanded={this.onElementExpanded}
onValueChanged={this.onDataValueChanged}
selected={selected}
searchResults={this.state.searchResults}
root={root}
elements={elements}
/>
) : (
<Center fill={true}>
<LoadingIndicator />
</Center>
)}
</FlexRow>
</FlexColumn>
);
}
}

View File

@@ -0,0 +1,9 @@
{
"name": "sonar-plugin-layout",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"dependencies": {
"lodash.debounce": "^4.0.8"
}
}

View File

@@ -0,0 +1,7 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
lodash.debounce@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"

View File

@@ -0,0 +1,539 @@
/**
* 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
*/
// $FlowFixMe
import pako from 'pako';
import type {Request, Response, Header} from './index.js';
import {
Component,
FlexColumn,
ManagedTable,
ManagedDataInspector,
Text,
Panel,
styled,
colors,
} from 'sonar';
import {getHeaderValue} from './index.js';
import querystring from 'querystring';
const WrappingText = Text.extends({
wordWrap: 'break-word',
width: '100%',
lineHeight: '125%',
padding: '3px 0',
});
const KeyValueColumnSizes = {
key: '30%',
value: 'flex',
};
const KeyValueColumns = {
key: {
value: 'Key',
resizable: false,
},
value: {
value: 'Value',
resizable: false,
},
};
type RequestDetailsProps = {
request: Request,
response: ?Response,
};
function decodeBody(container: Request | Response): string {
if (!container.data) {
return '';
}
const b64Decoded = atob(container.data);
const encodingHeader = container.headers.find(
header => header.key === 'Content-Encoding',
);
return encodingHeader && encodingHeader.value === 'gzip'
? decompress(b64Decoded)
: b64Decoded;
}
function decompress(body: string): string {
const charArray = body.split('').map(x => x.charCodeAt(0));
const byteArray = new Uint8Array(charArray);
let data;
try {
if (body) {
data = pako.inflate(byteArray);
} else {
return body;
}
} catch (e) {
// Sometimes Content-Encoding is 'gzip' but the body is already decompressed.
// Assume this is the case when decompression fails.
return body;
}
return String.fromCharCode.apply(null, new Uint8Array(data));
}
export default class RequestDetails extends Component<RequestDetailsProps> {
static Container = FlexColumn.extends({
height: '100%',
overflow: 'auto',
});
urlColumns = (url: URL) => {
return [
{
columns: {
key: {value: <WrappingText>Full URL</WrappingText>},
value: {
value: <WrappingText>{url.href}</WrappingText>,
},
},
copyText: url.href,
key: 'url',
},
{
columns: {
key: {value: <WrappingText>Host</WrappingText>},
value: {
value: <WrappingText>{url.host}</WrappingText>,
},
},
copyText: url.host,
key: 'host',
},
{
columns: {
key: {value: <WrappingText>Path</WrappingText>},
value: {
value: <WrappingText>{url.pathname}</WrappingText>,
},
},
copyText: url.pathname,
key: 'path',
},
{
columns: {
key: {value: <WrappingText>Query String</WrappingText>},
value: {
value: <WrappingText>{url.search}</WrappingText>,
},
},
copyText: url.search,
key: 'query',
},
];
};
render() {
const {request, response} = this.props;
const url = new URL(request.url);
return (
<RequestDetails.Container>
<Panel heading={'Request'} floating={false} padded={false}>
<ManagedTable
multiline={true}
columnSizes={KeyValueColumnSizes}
columns={KeyValueColumns}
rows={this.urlColumns(url)}
autoHeight={true}
floating={false}
zebra={false}
/>
</Panel>
{url.search ? (
<Panel
heading={'Request Query Parameters'}
floating={false}
padded={false}>
<QueryInspector queryParams={url.searchParams} />
</Panel>
) : null}
{request.headers.length > 0 ? (
<Panel heading={'Request Headers'} floating={false} padded={false}>
<HeaderInspector headers={request.headers} />
</Panel>
) : null}
{request.data != null ? (
<Panel heading={'Request Body'} floating={false}>
<RequestBodyInspector request={request} />
</Panel>
) : null}
{response
? [
response.headers.length > 0 ? (
<Panel
heading={'Response Headers'}
floating={false}
padded={false}>
<HeaderInspector headers={response.headers} />
</Panel>
) : null,
<Panel heading={'Response Body'} floating={false}>
<ResponseBodyInspector request={request} response={response} />
</Panel>,
]
: null}
</RequestDetails.Container>
);
}
}
class QueryInspector extends Component<{queryParams: URLSearchParams}> {
render() {
const {queryParams} = this.props;
const rows = [];
for (const kv of queryParams.entries()) {
rows.push({
columns: {
key: {
value: <WrappingText>{kv[0]}</WrappingText>,
},
value: {
value: <WrappingText>{kv[1]}</WrappingText>,
},
},
copyText: kv[1],
key: kv[0],
});
}
return rows.length > 0 ? (
<ManagedTable
multiline={true}
columnSizes={KeyValueColumnSizes}
columns={KeyValueColumns}
rows={rows}
autoHeight={true}
floating={false}
zebra={false}
/>
) : null;
}
}
type HeaderInspectorProps = {
headers: Array<Header>,
};
type HeaderInspectorState = {
computedHeaders: Object,
};
class HeaderInspector extends Component<
HeaderInspectorProps,
HeaderInspectorState,
> {
render() {
const computedHeaders = this.props.headers.reduce((sum, header) => {
return {...sum, [header.key]: header.value};
}, {});
const rows = [];
for (const key in computedHeaders) {
rows.push({
columns: {
key: {
value: <WrappingText>{key}</WrappingText>,
},
value: {
value: <WrappingText>{computedHeaders[key]}</WrappingText>,
},
},
copyText: computedHeaders[key],
key,
});
}
return rows.length > 0 ? (
<ManagedTable
multiline={true}
columnSizes={KeyValueColumnSizes}
columns={KeyValueColumns}
rows={rows}
autoHeight={true}
floating={false}
zebra={false}
/>
) : null;
}
}
const BodyContainer = styled.view({
paddingTop: 10,
paddingBottom: 20,
});
type BodyFormatter = {
formatRequest?: (request: Request) => any,
formatResponse?: (request: Request, response: Response) => any,
};
class RequestBodyInspector extends Component<{
request: Request,
}> {
render() {
const {request} = this.props;
let component;
try {
for (const formatter of BodyFormatters) {
if (formatter.formatRequest) {
component = formatter.formatRequest(request);
if (component) {
break;
}
}
}
} catch (e) {}
if (component == null && request.data != null) {
component = <Text>{decodeBody(request)}</Text>;
}
if (component == null) {
return null;
}
return <BodyContainer>{component}</BodyContainer>;
}
}
class ResponseBodyInspector extends Component<{
response: Response,
request: Request,
}> {
render() {
const {request, response} = this.props;
let component;
try {
for (const formatter of BodyFormatters) {
if (formatter.formatResponse) {
component = formatter.formatResponse(request, response);
if (component) {
break;
}
}
}
} catch (e) {}
component = component || <Text>{decodeBody(response)}</Text>;
return <BodyContainer>{component}</BodyContainer>;
}
}
const MediaContainer = FlexColumn.extends({
alignItems: 'center',
justifyContent: 'center',
width: '100%',
});
type ImageWithSizeProps = {
src: string,
};
type ImageWithSizeState = {
width: number,
height: number,
};
class ImageWithSize extends Component<ImageWithSizeProps, ImageWithSizeState> {
static Image = styled.image({
objectFit: 'scale-down',
maxWidth: 500,
maxHeight: 500,
marginBottom: 10,
});
static Text = Text.extends({
color: colors.dark70,
fontSize: 14,
});
constructor(props, context) {
super(props, context);
this.state = {
width: 0,
height: 0,
};
}
componentDidMount() {
const image = new Image();
image.src = this.props.src;
image.onload = () => {
image.width;
image.height;
this.setState({
width: image.width,
height: image.height,
});
};
}
render() {
return (
<MediaContainer>
<ImageWithSize.Image src={this.props.src} />
<ImageWithSize.Text>
{this.state.width} x {this.state.height}
</ImageWithSize.Text>
</MediaContainer>
);
}
}
class ImageFormatter {
formatResponse = (request: Request, response: Response) => {
if (getHeaderValue(response.headers, 'content-type').startsWith('image')) {
return <ImageWithSize src={request.url} />;
}
};
}
class VideoFormatter {
static Video = styled.customHTMLTag('video', {
maxWidth: 500,
maxHeight: 500,
});
formatResponse = (request: Request, response: Response) => {
const contentType = getHeaderValue(response.headers, 'content-type');
if (contentType.startsWith('video')) {
return (
<MediaContainer>
<VideoFormatter.Video controls={true}>
<source src={request.url} type={contentType} />
</VideoFormatter.Video>
</MediaContainer>
);
}
};
}
class JSONFormatter {
formatRequest = (request: Request) => {
return this.format(
decodeBody(request),
getHeaderValue(request.headers, 'content-type'),
);
};
formatResponse = (request: Request, response: Response) => {
return this.format(
decodeBody(response),
getHeaderValue(response.headers, 'content-type'),
);
};
format = (body: string, contentType: string) => {
if (
contentType.startsWith('application/json') ||
contentType.startsWith('text/javascript') ||
contentType.startsWith('application/x-fb-flatbuffer')
) {
try {
const data = JSON.parse(body);
return (
<ManagedDataInspector
collapsed={true}
expandRoot={true}
data={data}
/>
);
} catch (SyntaxError) {
// Multiple top level JSON roots, map them one by one
const roots = body.split('\n');
return (
<ManagedDataInspector
collapsed={true}
expandRoot={true}
data={roots.map(json => JSON.parse(json))}
/>
);
}
}
};
}
class LogEventFormatter {
formatRequest = (request: Request) => {
if (request.url.indexOf('logging_client_event') > 0) {
const data = querystring.parse(decodeBody(request));
if (data.message) {
data.message = JSON.parse(data.message);
}
return <ManagedDataInspector expandRoot={true} data={data} />;
}
};
}
class GraphQLBatchFormatter {
formatRequest = (request: Request) => {
if (request.url.indexOf('graphqlbatch') > 0) {
const data = querystring.parse(decodeBody(request));
if (data.queries) {
data.queries = JSON.parse(data.queries);
}
return <ManagedDataInspector expandRoot={true} data={data} />;
}
};
}
class GraphQLFormatter {
formatRequest = (request: Request) => {
if (request.url.indexOf('graphql') > 0) {
const data = querystring.parse(decodeBody(request));
if (data.variables) {
data.variables = JSON.parse(data.variables);
}
if (data.query_params) {
data.query_params = JSON.parse(data.query_params);
}
return <ManagedDataInspector expandRoot={true} data={data} />;
}
};
}
class FormUrlencodedFormatter {
formatRequest = (request: Request) => {
const contentType = getHeaderValue(request.headers, 'content-type');
if (contentType.startsWith('application/x-www-form-urlencoded')) {
return (
<ManagedDataInspector
expandRoot={true}
data={querystring.parse(decodeBody(request))}
/>
);
}
};
}
const BodyFormatters: Array<BodyFormatter> = [
new ImageFormatter(),
new VideoFormatter(),
new LogEventFormatter(),
new GraphQLBatchFormatter(),
new GraphQLFormatter(),
new JSONFormatter(),
new FormUrlencodedFormatter(),
];

View File

@@ -0,0 +1,411 @@
/**
* 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 {TableHighlightedRows, TableRows} from 'sonar';
import {
ContextMenu,
FlexColumn,
Button,
Text,
Glyph,
colors,
PureComponent,
} from 'sonar';
import {SonarPlugin, SearchableTable} from 'sonar';
import RequestDetails from './RequestDetails.js';
import {URL} from 'url';
// $FlowFixMe
import sortBy from 'lodash.sortby';
type RequestId = string;
type State = {|
requests: {[id: RequestId]: Request},
responses: {[id: RequestId]: Response},
selectedIds: Array<RequestId>,
|};
export type Request = {|
id: RequestId,
timestamp: number,
method: string,
url: string,
headers: Array<Header>,
data: ?string,
|};
export type Response = {|
id: RequestId,
timestamp: number,
status: number,
reason: string,
headers: Array<Header>,
data: ?string,
|};
export type Header = {|
key: string,
value: string,
|};
const COLUMN_SIZE = {
domain: 'flex',
method: 100,
status: 70,
size: 100,
duration: 100,
};
const COLUMNS = {
domain: {
value: 'Domain',
},
method: {
value: 'Method',
},
status: {
value: 'Status',
},
size: {
value: 'Size',
},
duration: {
value: 'Duration',
},
};
export function getHeaderValue(headers: Array<Header>, key: string) {
for (const header of headers) {
if (header.key.toLowerCase() === key.toLowerCase()) {
return header.value;
}
}
return '';
}
export function formatBytes(count: number): string {
if (count > 1024 * 1024) {
return (count / (1024.0 * 1024)).toFixed(1) + ' MB';
}
if (count > 1024) {
return (count / 1024.0).toFixed(1) + ' kB';
}
return count + ' B';
}
const TextEllipsis = Text.extends({
overflowX: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '100%',
lineHeight: '18px',
paddingTop: 4,
});
export default class extends SonarPlugin<State> {
static title = 'Network';
static id = 'Network';
static icon = 'internet';
static keyboardActions = ['clear'];
onKeyboardAction = (action: string) => {
if (action === 'clear') {
this.clearLogs();
}
};
state = {
requests: {},
responses: {},
selectedIds: [],
};
init() {
this.client.subscribe('newRequest', (request: Request) => {
this.dispatchAction({request, type: 'NewRequest'});
});
this.client.subscribe('newResponse', (response: Response) => {
this.dispatchAction({response, type: 'NewResponse'});
});
}
reducers = {
NewRequest(state: State, {request}: {request: Request}) {
return {
requests: {...state.requests, [request.id]: request},
responses: state.responses,
};
},
NewResponse(state: State, {response}: {response: Response}) {
return {
requests: state.requests,
responses: {...state.responses, [response.id]: response},
};
},
Clear(state: State) {
return {
requests: {},
responses: {},
};
},
};
onRowHighlighted = (selectedIds: Array<RequestId>) =>
this.setState({selectedIds});
clearLogs = () => {
this.setState({selectedIds: []});
this.dispatchAction({type: 'Clear'});
};
renderSidebar = () => {
const {selectedIds, requests, responses} = this.state;
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null;
return selectedId != null ? (
<RequestDetails
key={selectedId}
request={requests[selectedId]}
response={responses[selectedId]}
/>
) : null;
};
render() {
return (
<FlexColumn fill={true}>
<NetworkTable
requests={this.state.requests}
responses={this.state.responses}
clear={this.clearLogs}
onRowHighlighted={this.onRowHighlighted}
/>
</FlexColumn>
);
}
}
type NetworkTableProps = {|
requests: {[id: RequestId]: Request},
responses: {[id: RequestId]: Response},
clear: () => void,
onRowHighlighted: (keys: TableHighlightedRows) => void,
|};
type NetworkTableState = {|
sortedRows: TableRows,
|};
class NetworkTable extends PureComponent<NetworkTableProps, NetworkTableState> {
static ContextMenu = ContextMenu.extends({
flex: 1,
});
state = {
sortedRows: [],
};
componentWillReceiveProps(nextProps: NetworkTableProps) {
if (Object.keys(nextProps.requests).length === 0) {
// cleared
this.setState({sortedRows: []});
} else if (this.props.requests !== nextProps.requests) {
// new request
for (const requestId in nextProps.requests) {
if (this.props.requests[requestId] == null) {
this.buildRow(nextProps.requests[requestId], null);
break;
}
}
} else if (this.props.responses !== nextProps.responses) {
// new response
for (const responseId in nextProps.responses) {
if (this.props.responses[responseId] == null) {
this.buildRow(
nextProps.requests[responseId],
nextProps.responses[responseId],
);
break;
}
}
}
}
buildRow(request: Request, response: ?Response) {
if (request == null) {
return;
}
const url = new URL(request.url);
const domain = url.host + url.pathname;
const friendlyName = getHeaderValue(request.headers, 'X-FB-Friendly-Name');
const newRow = {
columns: {
domain: {
value: (
<TextEllipsis>{friendlyName ? friendlyName : domain}</TextEllipsis>
),
isFilterable: true,
},
method: {
value: <TextEllipsis>{request.method}</TextEllipsis>,
isFilterable: true,
},
status: {
value: (
<StatusColumn>
{response ? response.status : undefined}
</StatusColumn>
),
isFilterable: true,
},
size: {
value: <SizeColumn response={response ? response : undefined} />,
},
duration: {
value: <DurationColumn request={request} response={response} />,
},
},
key: request.id,
filterValue: `${request.method} ${request.url}`,
sortKey: request.timestamp,
copyText: request.url,
highlightOnHover: true,
};
let rows;
if (response == null) {
rows = [...this.state.sortedRows, newRow];
} else {
const index = this.state.sortedRows.findIndex(r => r.key === request.id);
if (index > -1) {
rows = [...this.state.sortedRows];
rows[index] = newRow;
}
}
this.setState({
sortedRows: sortBy(rows, x => x.sortKey),
});
}
contextMenuItems = [
{
type: 'separator',
},
{
label: 'Clear all',
click: this.props.clear,
},
];
render() {
return (
<NetworkTable.ContextMenu items={this.contextMenuItems}>
<SearchableTable
virtual={true}
multiline={false}
multiHighlight={true}
stickyBottom={true}
floating={false}
columnSizes={COLUMN_SIZE}
columns={COLUMNS}
rows={this.state.sortedRows}
onRowHighlighted={this.props.onRowHighlighted}
rowLineHeight={26}
zebra={false}
actions={<Button onClick={this.props.clear}>Clear Table</Button>}
/>
</NetworkTable.ContextMenu>
);
}
}
const Icon = Glyph.extends({
marginTop: -3,
marginRight: 3,
});
class StatusColumn extends PureComponent<{
children?: number,
}> {
render() {
const {children} = this.props;
let glyph;
if (children != null && children >= 400 && children < 600) {
glyph = <Icon name="stop-solid" color={colors.red} />;
}
return (
<TextEllipsis>
{glyph}
{children}
</TextEllipsis>
);
}
}
class DurationColumn extends PureComponent<{
request: Request,
response: ?Response,
}> {
static Text = Text.extends({
flex: 1,
textAlign: 'right',
paddingRight: 10,
});
render() {
const {request, response} = this.props;
const duration = response
? response.timestamp - request.timestamp
: undefined;
return (
<DurationColumn.Text selectable={false}>
{duration != null ? duration.toLocaleString() + 'ms' : ''}
</DurationColumn.Text>
);
}
}
class SizeColumn extends PureComponent<{
response: ?Response,
}> {
static Text = Text.extends({
flex: 1,
textAlign: 'right',
paddingRight: 10,
});
render() {
const {response} = this.props;
if (response) {
const text = formatBytes(this.getResponseLength(response));
return <SizeColumn.Text>{text}</SizeColumn.Text>;
} else {
return null;
}
}
getResponseLength(response) {
let length = 0;
const lengthString = response.headers
? getHeaderValue(response.headers, 'content-length')
: undefined;
if (lengthString != null && lengthString != '') {
length = parseInt(lengthString, 10);
} else if (response.data) {
length = atob(response.data).length;
}
return length;
}
}

View File

@@ -0,0 +1,10 @@
{
"name": "sonar-plugin-network",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"dependencies": {
"lodash.sortby": "^4.7.0",
"pako": "^1.0.6"
}
}

View File

@@ -0,0 +1,11 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
lodash.sortby@^4.7.0:
version "4.7.0"
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
pako@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.6.tgz#0101211baa70c4bca4a0f63f2206e97b7dfaf258"

184
src/reducers.js Normal file
View File

@@ -0,0 +1,184 @@
/**
* 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 './server.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,
};
}

View File

@@ -0,0 +1,88 @@
/**
* 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 {remote} from 'electron';
export type State = {
leftSidebarVisible: boolean,
rightSidebarVisible: boolean,
rightSidebarAvailable: boolean,
bugDialogVisible: boolean,
windowIsFocused: boolean,
pluginManagerVisible: boolean,
};
type ActionType =
| 'leftSidebarVisible'
| 'rightSidebarVisible'
| 'rightSidebarAvailable'
| 'bugDialogVisible'
| 'windowIsFocused'
| 'pluginManagerVisible';
export type Action = {
type: ActionType,
payload?: boolean,
};
const INITAL_STATE: State = {
leftSidebarVisible: true,
rightSidebarVisible: true,
rightSidebarAvailable: false,
bugDialogVisible: false,
windowIsFocused: remote.getCurrentWindow().isFocused(),
pluginManagerVisible: false,
};
export default function reducer(
state: State = INITAL_STATE,
action: Action,
): State {
const newValue =
typeof action.payload === 'undefined'
? !state[action.type]
: action.payload;
if (state[action.type] === newValue) {
// value hasn't changed, do nothing
return state;
} else {
return {
...state,
[action.type]: newValue,
};
}
}
export const toggleAction = (type: ActionType, payload?: boolean): Action => ({
type,
payload,
});
export const toggleBugDialogVisible = (payload?: boolean): Action => ({
type: 'bugDialogVisible',
payload,
});
export const toggleLeftSidebarVisible = (payload?: boolean): Action => ({
type: 'leftSidebarVisible',
payload,
});
export const toggleRightSidebarVisible = (payload?: boolean): Action => ({
type: 'rightSidebarVisible',
payload,
});
export const toggleRightSidebarAvailable = (payload?: boolean): Action => ({
type: 'rightSidebarAvailable',
payload,
});
export const togglePluginManagerVisible = (payload?: boolean): Action => ({
type: 'pluginManagerVisible',
payload,
});

39
src/reducers/devices.js Normal file
View File

@@ -0,0 +1,39 @@
/**
* 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 INITAL_STATE: State = [];
export default function reducer(
state: State = INITAL_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;
}
}

29
src/reducers/index.js Normal file
View File

@@ -0,0 +1,29 @@
/**
* 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 {combineReducers} from 'redux';
import application from './application.js';
import devices from './devices.js';
import type {
State as ApplicationState,
Action as ApplicationAction,
} from './application.js';
import type {
State as DevicesState,
Action as DevicesAction,
} from './devices.js';
import type {Store as ReduxStore} from 'redux';
export type Store = ReduxStore<
{
application: ApplicationState,
devices: DevicesState,
},
ApplicationAction | DevicesAction,
>;
export default combineReducers({application, devices});

520
src/server.js Normal file
View File

@@ -0,0 +1,520 @@
/**
* 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.js';
import type {App} from './App.js';
import type {SonarPlugin} from './plugin.js';
import plugins from './plugins/index.js';
import CertificateProvider from './utils/CertificateProvider';
import type {SecureServerConfig} from './utils/CertificateProvider';
import {RSocketServer, ReactiveSocket, PartialResponder} from 'rsocket-core';
import RSocketTCPServer from 'rsocket-tcp-server';
const tls = require('tls');
const net = require('net');
const EventEmitter = (require('events'): any);
const invariant = require('invariant');
const SECURE_PORT = 8088;
const INSECURE_PORT = 8089;
type RSocket = {|
fireAndForget(payload: {data: string}): void,
connectionStatus(): any,
close(): void,
|};
type ClientInfo = {|
connection: ?ReactiveSocket,
client: Client,
|};
type Plugins = Array<string>;
type ClientQuery = {|
app: string,
os: string,
device: string,
device_id: ?string,
|};
type RequestMetadata = {method: string, id: number, params: ?Object};
export class Client extends EventEmitter {
constructor(app: App, id: string, query: ClientQuery, conn: ReactiveSocket) {
super();
this.connected = true;
this.plugins = [];
this.connection = conn;
this.id = id;
this.query = query;
this.messageIdCounter = 0;
this.app = app;
this.broadcastCallbacks = new Map();
this.requestCallbacks = new Map();
const client = this;
this.responder = {
fireAndForget: (payload: {data: string}) => {
client.onMessage(payload.data);
},
};
conn.connectionStatus().subscribe({
onNext(payload) {
if (payload.kind == 'ERROR' || payload.kind == 'CLOSED') {
client.connected = false;
}
},
onSubscribe(subscription) {
subscription.request(Number.MAX_SAFE_INTEGER);
},
});
}
on: ((event: 'plugins-change', callback: () => void) => void) &
((event: 'close', callback: () => void) => void);
app: App;
connected: boolean;
id: string;
query: ClientQuery;
messageIdCounter: number;
plugins: Plugins;
connection: ReactiveSocket;
responder: PartialResponder;
broadcastCallbacks: Map<?string, Map<string, Set<Function>>>;
requestCallbacks: Map<
number,
{|
resolve: (data: any) => void,
reject: (err: Error) => void,
metadata: RequestMetadata,
|},
>;
getDevice(): ?BaseDevice {
const {device_id} = this.query;
if (device_id == null) {
return null;
} else {
return this.app.getDevice(device_id);
}
}
supportsPlugin(Plugin: Class<SonarPlugin<>>): boolean {
return this.plugins.includes(Plugin.id);
}
getFirstSupportedPlugin(): ?string {
for (const Plugin of plugins) {
if (this.supportsPlugin(Plugin)) {
return Plugin.id;
}
}
}
async init() {
await this.getPlugins();
}
// get the supported plugins
async getPlugins(): Promise<Plugins> {
const plugins = await this.rawCall('getPlugins').then(data => data.plugins);
this.plugins = plugins;
return plugins;
}
// get the plugins, and update the UI
async refreshPlugins() {
await this.getPlugins();
this.emit('plugins-change');
}
onMessage(msg: string) {
if (typeof msg !== 'string') {
return;
}
let rawData;
try {
rawData = JSON.parse(msg);
} catch (err) {
this.app.logger.error(`Invalid JSON: ${msg}`, 'clientMessage');
return;
}
const data: {|
id?: number,
method?: string,
params?: Object,
success?: Object,
error?: Object,
|} = rawData;
this.app.logger.info(data, 'message:receive');
const {id, method} = data;
if (id == null) {
const {error} = data;
if (error != null) {
this.app.logger.error(error.stacktrace || error.message, 'deviceError');
this.app.errorReporter.report({
message: error.message,
stack: error.stacktrace,
});
} else if (method === 'refreshPlugins') {
this.refreshPlugins();
} else if (method === 'execute') {
const params = data.params;
invariant(params, 'expected params');
const apiCallbacks = this.broadcastCallbacks.get(params.api);
if (!apiCallbacks) {
return;
}
const methodCallbacks: ?Set<Function> = apiCallbacks.get(params.method);
if (methodCallbacks) {
for (const callback of methodCallbacks) {
callback(params.params);
}
}
}
return;
}
const callbacks = this.requestCallbacks.get(id);
if (!callbacks) {
return;
}
this.requestCallbacks.delete(id);
this.finishTimingRequestResponse(callbacks.metadata);
if (data.success) {
callbacks.resolve(data.success);
} else if (data.error) {
callbacks.reject(data.error);
} else {
// ???
}
}
toJSON() {
return null;
}
subscribe(
api: ?string = null,
method: string,
callback: (params: Object) => void,
) {
let apiCallbacks = this.broadcastCallbacks.get(api);
if (!apiCallbacks) {
apiCallbacks = new Map();
this.broadcastCallbacks.set(api, apiCallbacks);
}
let methodCallbacks = apiCallbacks.get(method);
if (!methodCallbacks) {
methodCallbacks = new Set();
apiCallbacks.set(method, methodCallbacks);
}
methodCallbacks.add(callback);
}
unsubscribe(api: ?string = null, method: string, callback: Function) {
const apiCallbacks = this.broadcastCallbacks.get(api);
if (!apiCallbacks) {
return;
}
const methodCallbacks = apiCallbacks.get(method);
if (!methodCallbacks) {
return;
}
methodCallbacks.delete(callback);
}
rawCall(method: string, params?: Object): Promise<Object> {
return new Promise((resolve, reject) => {
const id = this.messageIdCounter++;
const metadata: RequestMetadata = {
method,
id,
params,
};
this.requestCallbacks.set(id, {reject, resolve, metadata});
const data = {
id,
method,
params,
};
this.app.logger.info(data, 'message:call');
this.startTimingRequestResponse({method, id, params});
this.connection.fireAndForget({data: JSON.stringify(data)});
});
}
startTimingRequestResponse(data: RequestMetadata) {
performance.mark(this.getPerformanceMark(data));
}
finishTimingRequestResponse(data: RequestMetadata) {
const mark = this.getPerformanceMark(data);
const logEventName = this.getLogEventName(data);
this.app.logger.trackTimeSince(mark, logEventName);
}
getPerformanceMark(data: RequestMetadata): string {
const {method, id} = data;
return `request_response_${method}_${id}`;
}
getLogEventName(data: RequestMetadata): string {
const {method, params} = data;
return params && params.api && params.method
? `request_response_${method}_${params.api}_${params.method}`
: `request_response_${method}`;
}
rawSend(method: string, params?: Object): void {
const data = {
method,
params,
};
this.app.logger.info(data, 'message:send');
this.connection.fireAndForget({data: JSON.stringify(data)});
}
call(api: string, method: string, params?: Object): Promise<Object> {
return this.rawCall('execute', {api, method, params});
}
send(api: string, method: string, params?: Object): void {
return this.rawSend('execute', {api, method, params});
}
}
export class Server extends EventEmitter {
connections: Map<string, ClientInfo>;
secureServer: RSocketServer;
insecureServer: RSocketServer;
certificateProvider: CertificateProvider;
app: App;
constructor(app: App) {
super();
this.app = app;
this.connections = new Map();
this.certificateProvider = new CertificateProvider(this, app.logger);
this.init();
}
on: ((event: 'new-client', callback: (client: Client) => void) => void) &
((event: 'error', callback: (err: Error) => void) => void) &
((event: 'clients-change', callback: () => void) => void);
init() {
if (process.env.NODE_ENV === 'test') {
this.app.logger.warn(
"rsocket server has not been started as we're in test mode",
'server',
);
return;
}
this.certificateProvider
.loadSecureServerConfig()
.then(
options => (this.secureServer = this.startServer(SECURE_PORT, options)),
);
this.insecureServer = this.startServer(INSECURE_PORT);
}
startServer(port: number, sslConfig?: SecureServerConfig) {
const server = this;
const serverFactory = onConnect => {
const transportServer = sslConfig
? tls.createServer(sslConfig, socket => {
onConnect(socket);
})
: net.createServer(onConnect);
transportServer
.on('error', err => {
server.emit('error', err);
server.app.logger.error(
`Error opening server on port ${port}`,
'server',
);
})
.on('listening', () => {
server.app.logger.warn(
`${
sslConfig ? 'Secure' : 'Certificate'
} server started on port ${port}`,
'server',
);
});
return transportServer;
};
const rsServer = new RSocketServer({
getRequestHandler: sslConfig
? this._trustedRequestHandler
: this._untrustedRequestHandler,
transport: new RSocketTCPServer({
port: port,
serverFactory: serverFactory,
}),
});
rsServer.start();
return rsServer;
}
_trustedRequestHandler = (conn: RSocket, connectRequest: {data: string}) => {
const server = this;
const client = this.addConnection(conn, connectRequest.data);
conn.connectionStatus().subscribe({
onNext(payload) {
if (payload.kind == 'ERROR' || payload.kind == 'CLOSED') {
server.app.logger.warn(
`Device disconnected ${client.id}`,
'connection',
);
server.removeConnection(client.id);
}
},
onSubscribe(subscription) {
subscription.request(Number.MAX_SAFE_INTEGER);
},
});
return client.responder;
};
_untrustedRequestHandler = (
conn: RSocket,
connectRequest: {data: string},
) => {
const connectionParameters = JSON.parse(connectRequest.data);
return {
fireAndForget: (payload: {data: string}) => {
if (typeof payload.data !== 'string') {
return;
}
let rawData;
try {
rawData = JSON.parse(payload.data);
} catch (err) {
this.app.logger.error(
`Invalid JSON: ${payload.data}`,
'clientMessage',
);
return;
}
const json: {|
method: 'signCertificate',
csr: string,
destination: string,
|} = rawData;
if (json.method === 'signCertificate') {
this.app.logger.warn('CSR received from device', 'server');
const {csr, destination} = json;
this.certificateProvider.processCertificateSigningRequest(
csr,
connectionParameters.os,
destination
);
}
},
};
};
close() {
this.secureServer.stop();
this.insecureServer.stop();
}
toJSON() {
return null;
}
addConnection(conn: ReactiveSocket, queryString: string): Client {
const query = JSON.parse(queryString);
invariant(query, 'expected query');
this.app.logger.warn(`Device connected: ${queryString}`, 'connection');
const id = `${query.app}-${query.os}-${query.device}`;
const client = new Client(this.app, id, query, conn);
const info = {
client,
connection: conn,
};
client.init().then(() => {
this.app.logger.info(
`Device client initialised: ${id}. Supported plugins: ${client.plugins.join(
', ',
)}`,
'connection',
);
/* If a device gets disconnected without being cleaned up properly,
* sonar won't be aware until it attempts to reconnect.
* When it does we need to terminate the zombie connection.
*/
if (this.connections.has(id)) {
const connectionInfo = this.connections.get(id);
connectionInfo &&
connectionInfo.connection &&
connectionInfo.connection.close();
this.removeConnection(id);
}
this.connections.set(id, info);
this.emit('new-client', client);
this.emit('clients-change');
client.emit('plugins-change');
});
return client;
}
attachFakeClient(client: Client) {
this.connections.set(client.id, {
client,
connection: null,
});
}
removeConnection(id: string) {
const info = this.connections.get(id);
if (info) {
info.client.emit('close');
this.connections.delete(id);
this.emit('clients-change');
}
}
}

View File

@@ -0,0 +1,12 @@
/**
* 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 styled from '../styled/index.js';
export default styled.view({
display: 'block',
});

15
src/ui/components/Box.js Normal file
View File

@@ -0,0 +1,15 @@
/**
* 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 FlexBox from './FlexBox.js';
export default FlexBox.extends({
height: '100%',
overflow: 'auto',
position: 'relative',
width: '100%',
});

363
src/ui/components/Button.js Normal file
View File

@@ -0,0 +1,363 @@
/**
* 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 Glyph from './Glyph.js';
import styled from '../styled/index.js';
import type {StyledComponent} from '../styled/index.js';
import {findDOMNode} from 'react-dom';
import PropTypes from 'prop-types';
import {colors} from './colors.js';
import {connect} from 'react-redux';
import electron from 'electron';
const borderColor = props => {
if (!props.windowIsFocused) {
return colors.macOSTitleBarButtonBorderBlur;
} else if (props.type === 'danger') {
return colors.red;
} else {
return colors.macOSTitleBarButtonBorder;
}
};
const borderBottomColor = props => {
if (!props.windowIsFocused) {
return colors.macOSTitleBarButtonBorderBlur;
} else if (props.type === 'danger') {
return colors.red;
} else {
return colors.macOSTitleBarButtonBorderBottom;
}
};
const StyledButton = styled.view(
{
backgroundColor: props => {
if (!props.windowIsFocused) {
return colors.macOSTitleBarButtonBackgroundBlur;
} else {
return colors.white;
}
},
backgroundImage: props =>
props.windowIsFocused
? `linear-gradient(to bottom, transparent 0%,${
colors.macOSTitleBarButtonBackground
} 100%)`
: 'none',
borderStyle: 'solid',
borderWidth: 1,
borderColor,
borderBottomColor,
fontSize: props => (props.compact === true ? 11 : '1em'),
color: props => {
if (props.type === 'danger' && props.windowIsFocused) {
return colors.red;
} else if (props.disabled) {
return colors.macOSTitleBarIconBlur;
} else {
return colors.light50;
}
},
borderRadius: 4,
position: 'relative',
padding: '0 6px',
height: props => (props.compact === true ? 24 : 28),
margin: 0,
marginLeft: props => (props.inButtonGroup === true ? 0 : 10),
minWidth: 34,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
boxShadow: props =>
props.pulse && props.windowIsFocused
? `0 0 0 ${colors.macOSTitleBarIconSelected}`
: '',
animation: props =>
props.pulse && props.windowIsFocused ? 'pulse 1s infinite' : '',
'&:not(:first-child)': {
borderTopLeftRadius: props => (props.inButtonGroup === true ? 0 : 4),
borderBottomLeftRadius: props => (props.inButtonGroup === true ? 0 : 4),
},
'&:not(:last-child)': {
borderTopRightRadius: props => (props.inButtonGroup === true ? 0 : 4),
borderBottomRightRadius: props => (props.inButtonGroup === true ? 0 : 4),
borderRight: props => (props.inButtonGroup === true ? 0 : ''),
},
'&:first-of-type': {
marginLeft: 0,
},
'&:active': {
borderColor: colors.macOSTitleBarButtonBorder,
borderBottomColor: colors.macOSTitleBarButtonBorderBottom,
background: `linear-gradient(to bottom, ${
colors.macOSTitleBarButtonBackgroundActiveHighlight
} 1px, ${colors.macOSTitleBarButtonBackgroundActive} 0%, ${
colors.macOSTitleBarButtonBorderBlur
} 100%)`,
},
'&:disabled': {
borderColor,
borderBottomColor,
pointerEvents: 'none',
},
'&:hover::before': {
content: props => (props.dropdown ? "''" : ''),
position: 'absolute',
bottom: 1,
right: 2,
borderStyle: 'solid',
borderWidth: '4px 3px 0 3px',
borderColor: props =>
`${colors.macOSTitleBarIcon} transparent transparent transparent`,
},
},
{
ignoreAttributes: [
'dispatch',
'compact',
'large',
'windowIsFocused',
'inButtonGroup',
'danger',
'pulse',
],
},
);
const Icon = Glyph.extends(
{
marginRight: props => (props.hasText ? 3 : 0),
},
{
ignoreAttributes: ['hasText', 'type'],
},
);
type Props = {
/**
* onClick handler.
*/
onClick?: (event: SyntheticMouseEvent<>) => void,
/**
* Whether this button is disabled.
*/
disabled?: boolean,
/**
* Whether this button is large. Increases padding and line-height.
*/
large?: boolean,
/**
* Whether this button is compact. Decreases padding and line-height.
*/
compact?: boolean,
/**
* Type of button.
*/
type?: 'primary' | 'success' | 'warning' | 'danger',
/**
* Children.
*/
children?: React$Node,
/**
* Dropdown menu template shown on click.
*/
dropdown?: Array<Electron$MenuItemOptions>,
/**
* Name of the icon dispalyed next to the text
*/
icon?: string,
iconSize?: number,
/**
* For toggle buttons, if the button is selected
*/
selected?: boolean,
/**
* Button is pulsing
*/
pulse?: boolean,
/**
* URL to open in the browser on click
*/
href?: string,
};
type State = {
active: boolean,
};
/**
* Simple button.
*
* **Usage**
*
* ```jsx
* import {Button} from 'sonar';
* <Button onClick={handler}>Click me</Button>
* ```
*
* @example Default button
* <Button>Click me</Button>
* @example Primary button
* <Button type="primary">Click me</Button>
* @example Success button
* <Button type="success">Click me</Button>
* @example Warning button
* <Button type="warning">Click me</Button>
* @example Danger button
* <Button type="danger">Click me</Button>
* @example Default solid button
* <Button solid={true}>Click me</Button>
* @example Primary solid button
* <Button type="primary" solid={true}>Click me</Button>
* @example Success solid button
* <Button type="success" solid={true}>Click me</Button>
* @example Warning solid button
* <Button type="warning" solid={true}>Click me</Button>
* @example Danger solid button
* <Button type="danger" solid={true}>Click me</Button>
* @example Compact button
* <Button compact={true}>Click me</Button>
* @example Large button
* <Button large={true}>Click me</Button>
* @example Disabled button
* <Button disabled={true}>Click me</Button>
*/
class Button extends styled.StylableComponent<
Props & {windowIsFocused: boolean},
State,
> {
static contextTypes = {
inButtonGroup: PropTypes.bool,
};
state = {
active: false,
};
_ref: ?Element | ?Text;
onMouseDown = () => this.setState({active: true});
onMouseUp = () => this.setState({active: false});
onClick = (e: SyntheticMouseEvent<>) => {
if (this.props.disabled === true) {
return;
}
if (this.props.dropdown) {
const menu = electron.remote.Menu.buildFromTemplate(this.props.dropdown);
const position = {};
if (this._ref != null && this._ref instanceof Element) {
const {left, bottom} = this._ref.getBoundingClientRect();
position.x = parseInt(left, 10);
position.y = parseInt(bottom + 6, 10);
}
menu.popup(electron.remote.getCurrentWindow(), {
async: true,
...position,
});
}
if (this.props.onClick) {
this.props.onClick(e);
}
if (this.props.href != null) {
electron.shell.openExternal(this.props.href);
}
};
setRef = (ref: ?React.ElementRef<any>) => {
this._ref = findDOMNode(ref);
};
render() {
const {
icon,
children,
selected,
iconSize,
windowIsFocused,
...props
} = this.props;
const {active} = this.state;
let color = colors.macOSTitleBarIcon;
if (props.disabled === true) {
color = colors.macOSTitleBarIconBlur;
} else if (windowIsFocused && selected === true) {
color = colors.macOSTitleBarIconSelected;
} else if (!windowIsFocused && (selected == null || selected === false)) {
color = colors.macOSTitleBarIconBlur;
} else if (!windowIsFocused && selected === true) {
color = colors.macOSTitleBarIconSelectedBlur;
} else if (selected == null && active) {
color = colors.macOSTitleBarIconActive;
} else if (props.type === 'danger') {
color = colors.red;
}
let iconComponent;
if (icon != null) {
iconComponent = (
<Icon
name={icon}
size={
iconSize != null ? iconSize : this.props.compact === true ? 12 : 16
}
color={color}
hasText={Boolean(children)}
/>
);
}
return (
<StyledButton
{...props}
ref={this.setRef}
windowIsFocused={windowIsFocused}
onClick={this.onClick}
onMouseDown={this.onMouseDown}
onMouseUp={this.onMouseUp}
inButtonGroup={this.context.inButtonGroup}>
{iconComponent}
{children}
{this.props.pulse === true && (
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes pulse {
0% {
box-shadow: 0 0 4px 0 ${colors.macOSTitleBarIconSelected};
}
70% {
box-shadow: 0 0 4px 6px transparent;
}
100% {
box-shadow: 0 0 4px 0 transparent;
}
}
`,
}}
/>
)}
</StyledButton>
);
}
}
const ConnectedButton = connect(({application: {windowIsFocused}}) => ({
windowIsFocused,
}))(Button);
// $FlowFixMe
export default (ConnectedButton: StyledComponent<Props>);

View File

@@ -0,0 +1,45 @@
/**
* 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 styled from '../styled/index.js';
import {Component} from 'react';
const PropTypes = require('prop-types');
const ButtonGroupContainer = styled.view({
display: 'inline-flex',
marginLeft: 10,
'&:first-child': {
marginLeft: 0,
},
});
/**
* Group a series of buttons together.
*
* @example List of buttons
* <ButtonGroup>
* <Button>One</Button>
* <Button>Two</Button>
* <Button>Three</Button>
* </ButtonGroup>
*/
export default class ButtonGroup extends Component<{
children: React$Node,
}> {
getChildContext() {
return {inButtonGroup: true};
}
render() {
return <ButtonGroupContainer>{this.props.children}</ButtonGroupContainer>;
}
}
ButtonGroup.childContextTypes = {
inButtonGroup: PropTypes.bool,
};

View File

@@ -0,0 +1,28 @@
/**
* 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 ButtonGroup from './ButtonGroup.js';
import Button from './Button.js';
export default function ButtonNavigationGroup(props: {|
canGoBack: boolean,
canGoForward: boolean,
onBack: () => void,
onForward: () => void,
|}) {
return (
<ButtonGroup>
<Button disabled={!props.canGoBack} onClick={props.onBack}>
{'<'}
</Button>
<Button disabled={!props.canGoForward} onClick={props.onForward}>
{'<'}
</Button>
</ButtonGroup>
);
}

View File

@@ -0,0 +1,36 @@
/**
* 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 {PureComponent} from 'react';
import styled from '../styled/index.js';
type CheckboxProps = {
checked: boolean,
onChange: (checked: boolean) => void,
};
const CheckboxContainer = styled.textInput({
display: 'inline-block',
marginRight: 5,
verticalAlign: 'middle',
});
export default class Checkbox extends PureComponent<CheckboxProps> {
onChange = (e: SyntheticInputEvent<HTMLInputElement>) => {
this.props.onChange(e.target.checked);
};
render() {
return (
<CheckboxContainer
type="checkbox"
checked={this.props.checked}
onChange={this.onChange}
/>
);
}
}

View File

@@ -0,0 +1,12 @@
/**
* 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 styled from '../styled/index.js';
export default styled.view({
marginBottom: 10,
});

View File

@@ -0,0 +1,33 @@
/**
* 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 styled from '../styled/index.js';
import {colors} from './colors.js';
export default styled.view(
{
backgroundColor: ({active, windowFocused}) => {
if (active && windowFocused) {
return colors.macOSTitleBarIconSelected;
} else if (active && !windowFocused) {
return colors.macOSTitleBarBorderBlur;
} else {
return 'none';
}
},
color: ({active, windowFocused}) =>
active && windowFocused ? colors.white : colors.macOSSidebarSectionItem,
lineHeight: '25px',
padding: '0 10px',
'&[disabled]': {
color: 'rgba(0, 0, 0, 0.5)',
},
},
{
ignoreAttributes: ['active', 'windowFocused'],
},
);

View File

@@ -0,0 +1,12 @@
/**
* 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 styled from '../styled/index.js';
export default styled.view({
fontFamily: 'monospace',
});

View File

@@ -0,0 +1,56 @@
/**
* 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 FlexColumn from './FlexColumn.js';
import styled from '../styled/index.js';
import PropTypes from 'prop-types';
type MenuTemplate = Array<Electron$MenuItemOptions>;
type Props = {
items?: MenuTemplate,
buildItems?: () => MenuTemplate,
children: React$Node,
component: React.ComponentType<any> | string,
};
export default class ContextMenu extends styled.StylablePureComponent<Props> {
static defaultProps = {
component: FlexColumn,
};
static contextTypes = {
appendToContextMenu: PropTypes.func,
};
onContextMenu = (e: SyntheticMouseEvent<>) => {
if (typeof this.context.appendToContextMenu === 'function') {
if (this.props.items != null) {
this.context.appendToContextMenu(this.props.items);
} else if (this.props.buildItems != null) {
this.context.appendToContextMenu(this.props.buildItems());
}
}
};
render() {
const {
items: _itesm,
buildItems: _buildItems,
component,
...props
} = this.props;
return React.createElement(
component,
{
onContextMenu: this.onContextMenu,
...props,
},
this.props.children,
);
}
}

View File

@@ -0,0 +1,50 @@
/**
* 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 {Component} from 'react';
import styled from '../styled/index.js';
import electron from 'electron';
const PropTypes = require('prop-types');
type MenuTemplate = Array<Electron$MenuItemOptions>;
const Container = styled.view({
display: 'contents',
});
export default class ContextMenuProvider extends Component<{|
children: React$Node,
|}> {
static childContextTypes = {
appendToContextMenu: PropTypes.func,
};
getChildContext() {
return {appendToContextMenu: this.appendToContextMenu};
}
_menuTemplate: MenuTemplate = [];
appendToContextMenu = (items: MenuTemplate) => {
this._menuTemplate = this._menuTemplate.concat(items);
};
onContextMenu = () => {
const menu = electron.remote.Menu.buildFromTemplate(this._menuTemplate);
this._menuTemplate = [];
menu.popup(electron.remote.getCurrentWindow(), {async: true});
};
render() {
return (
<Container onContextMenu={this.onContextMenu}>
{this.props.children}
</Container>
);
}
}

View File

@@ -0,0 +1,35 @@
/**
* 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 ContextMenu from './ContextMenu.js';
const invariant = require('invariant');
export default class Dropdown extends ContextMenu {
trigger: string = 'onClick';
ref: ?HTMLElement;
getCoordinates(): {top: number, left: number} {
const {ref} = this;
invariant(ref, 'expected ref');
const rect = ref.getBoundingClientRect();
return {
left: rect.left,
top: rect.top,
};
}
setRef = (ref: ?HTMLElement) => {
this.ref = ref;
};
render() {
return <span ref={this.setRef}>{this.props.children}</span>;
}
}

View File

@@ -0,0 +1,38 @@
/**
* 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 styled from '../styled/index.js';
import CodeBlock from './CodeBlock.js';
const ErrorBlockContainer = CodeBlock.extends({
backgroundColor: '#f2dede',
border: '1px solid #ebccd1',
borderRadius: 4,
color: '#a94442',
overflow: 'auto',
padding: 10,
});
export default class ErrorBlock extends styled.StylableComponent<{
error: Error | string | void,
className?: string,
}> {
render() {
const {className, error} = this.props;
let stack = 'Unknown Error';
if (typeof error === 'string') {
stack = error;
} else if (error && typeof error === 'object') {
stack = error.stack || error.message || stack;
}
return (
<ErrorBlockContainer className={className}>{stack}</ErrorBlockContainer>
);
}
}

View File

@@ -0,0 +1,80 @@
/**
* 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 ErrorBlock from './ErrorBlock.js';
import {Component} from 'react';
import Heading from './Heading.js';
import Button from './Button.js';
import View from './View.js';
import LogManager from '../../fb-stubs/Logger.js';
const ErrorBoundaryContainer = View.extends({
overflow: 'auto',
padding: 10,
});
const ErrorBoundaryStack = ErrorBlock.extends({
marginBottom: 10,
});
type ErrorBoundaryProps = {
buildHeading?: (err: Error) => string,
heading?: string,
logger?: LogManager,
showStack?: boolean,
children?: React$Node,
};
type ErrorBoundaryState = {|
error: ?Error,
|};
export default class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState,
> {
constructor(props: ErrorBoundaryProps, context: Object) {
super(props, context);
this.state = {error: null};
}
componentDidCatch(err: Error) {
this.props.logger &&
this.props.logger.error(err.toString(), 'ErrorBoundary');
this.setState({error: err});
}
clearError = () => {
this.setState({error: null});
};
render() {
const {error} = this.state;
if (error) {
const {buildHeading} = this.props;
let {heading} = this.props;
if (buildHeading) {
heading = buildHeading(error);
}
if (heading == null) {
heading = 'An error has occured';
}
return (
<ErrorBoundaryContainer fill={true}>
<Heading>{heading}</Heading>
{this.props.showStack !== false && (
<ErrorBoundaryStack error={error} />
)}
<Button onClick={this.clearError}>Clear error and try again</Button>
</ErrorBoundaryContainer>
);
} else {
return this.props.children;
}
}
}

82
src/ui/components/File.js Normal file
View File

@@ -0,0 +1,82 @@
/**
* 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 {Component} from 'react';
const React = require('react');
const fs = require('fs');
type FileProps = {|
src: string,
buffer?: ?string,
encoding: string,
onError?: (err: Error) => React.Element<*>,
onLoading?: () => React.Element<*>,
onData?: (content: string) => void,
onLoad: (content: string) => React.Element<*>,
|};
type FileState = {|
error: ?Error,
loaded: boolean,
content: string,
|};
export default class File extends Component<FileProps, FileState> {
constructor(props: FileProps, context: Object) {
super(props, context);
this.state = {
content: props.buffer || '',
error: null,
loaded: props.buffer != null,
};
}
static defaultProps = {
encoding: 'utf8',
};
componentWillReceiveProps(nextProps: FileProps) {
if (nextProps.buffer != null) {
this.setState({content: nextProps.buffer, loaded: true});
}
}
componentDidMount() {
if (this.state.loaded) {
return;
}
fs.readFile(this.props.src, this.props.encoding, (err, content) => {
if (err) {
this.setState({error: err});
return;
}
this.setState({content, loaded: true});
if (this.props.onData) {
this.props.onData(content);
}
});
}
render() {
const {onError, onLoad, onLoading} = this.props;
const {content, error, loaded} = this.state;
if (error && onError) {
return onError(error);
} else if (loaded) {
return onLoad(content);
} else if (onLoading) {
return onLoading();
} else {
return null;
}
}
}

View File

@@ -0,0 +1,173 @@
/**
* 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 {Component} from 'react';
const path = require('path');
const fs = require('fs');
const EMPTY_MAP = new Map();
const EMPTY_FILE_LIST_STATE = {error: null, files: EMPTY_MAP};
export type FileListFileType = 'file' | 'folder';
export type FileListFile = {|
name: string,
src: string,
type: FileListFileType,
size: number,
mtime: number,
atime: number,
ctime: number,
birthtime: number,
|};
export type FileListFiles = Array<FileListFile>;
type FileListProps = {
src: string,
onError?: ?(err: Error) => React$Node,
onLoad?: () => void,
onFiles: (files: FileListFiles) => React$Node,
};
type FileListState = {|
files: Map<string, FileListFile>,
error: ?Error,
|};
export default class FileList extends Component<FileListProps, FileListState> {
constructor(props: FileListProps, context: Object) {
super(props, context);
this.state = EMPTY_FILE_LIST_STATE;
}
watcher: ?fs.FSWatcher;
fetchFile(name: string): Promise<FileListFile> {
return new Promise((resolve, reject) => {
const loc = path.join(this.props.src, name);
fs.lstat(loc, (err, stat) => {
if (err) {
reject(err);
} else {
const details: FileListFile = {
atime: Number(stat.atime),
birthtime:
typeof stat.birthtime === 'object' ? Number(stat.birthtime) : 0,
ctime: Number(stat.ctime),
mtime: Number(stat.mtime),
name,
size: stat.size,
src: loc,
type: stat.isDirectory() ? 'folder' : 'file',
};
resolve(details);
}
});
});
}
fetchFiles(callback?: Function) {
const {src} = this.props;
const setState = data => {
if (!hasChangedDir()) {
this.setState(data);
}
};
const hasChangedDir = () => this.props.src !== src;
fs.readdir(src, (err, files) => {
if (err) {
setState({error: err, files: EMPTY_MAP});
return;
}
const filesSet: Map<string, FileListFile> = new Map();
const next = () => {
if (hasChangedDir()) {
return;
}
if (!files.length) {
setState({error: null, files: filesSet});
if (callback) {
callback();
}
return;
}
const name = files.shift();
this.fetchFile(name)
.then(data => {
filesSet.set(name, data);
next();
})
.catch(err => {
setState({error: err, files: EMPTY_MAP});
});
};
next();
});
}
componentWillReceiveProps(nextProps: FileListProps) {
if (nextProps.src !== this.props.src) {
this.initialFetch(nextProps);
}
}
componentDidMount() {
this.initialFetch(this.props);
}
componentWillUnmount() {
this.removeWatcher();
}
initialFetch(props: FileListProps) {
this.removeWatcher();
fs.access(props.src, fs.constants.R_OK, err => {
if (err) {
this.setState({error: err, files: EMPTY_MAP});
return;
}
this.fetchFiles(props.onLoad);
this.watcher = fs.watch(props.src, () => {
this.fetchFiles();
});
this.watcher.on('error', err => {
this.setState({error: err, files: EMPTY_MAP});
this.removeWatcher();
});
});
}
removeWatcher() {
if (this.watcher) {
this.watcher.close();
}
}
render() {
const {error, files} = this.state;
const {onError, onFiles} = this.props;
if (error && onError) {
return onError(error);
} else {
return onFiles(Array.from(files.values()));
}
}
}

View File

@@ -0,0 +1,18 @@
/**
* 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 View from './View.js';
export default View.extends(
{
display: 'flex',
flexShrink: props => (props.shrink == null || props.shrink ? 1 : 0),
},
{
ignoreAttributes: ['shrink'],
},
);

View File

@@ -0,0 +1,14 @@
/**
* 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 View from './View.js';
export default View.extends({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});

View File

@@ -0,0 +1,12 @@
/**
* 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 FlexBox from './FlexBox.js';
export default FlexBox.extends({
flexDirection: 'column',
});

View File

@@ -0,0 +1,12 @@
/**
* 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 FlexBox from './FlexBox.js';
export default FlexBox.extends({
flexDirection: 'row',
});

View File

@@ -0,0 +1,72 @@
/**
* 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 {Component} from 'react';
import Box from './Box.js';
import {colors} from './colors';
const FocusableBoxBorder = Box.extends(
{
border: `1px solid ${colors.highlight}`,
bottom: '0',
left: '0',
pointerEvents: 'none',
position: 'absolute',
right: '0',
top: '0',
},
{
ignoreAttributes: [],
},
);
export default class FocusableBox extends Component<
Object,
{|
focused: boolean,
|},
> {
constructor(props: Object, context: Object) {
super(props, context);
this.state = {focused: false};
}
static defaultProps = {
focusable: true,
};
onBlur = (e: SyntheticFocusEvent<>) => {
const {onBlur} = this.props;
if (onBlur) {
onBlur(e);
}
if (this.state.focused) {
this.setState({focused: false});
}
};
onFocus = (e: SyntheticFocusEvent<>) => {
const {onFocus} = this.props;
if (onFocus) {
onFocus(e);
}
if (this.props.focusable) {
this.setState({focused: true});
}
};
render() {
const {props} = this;
return (
<Box {...props} onFocus={this.onFocus} onBlur={this.onBlur} tabIndex="0">
{props.children}
{this.state.focused && <FocusableBoxBorder />}
</Box>
);
}
}

107
src/ui/components/Glyph.js Normal file
View File

@@ -0,0 +1,107 @@
/**
* 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 styled from '../styled/index.js';
const PropTypes = require('prop-types');
import {getIconUrl} from '../../utils/icons.js';
const ColoredIconBlack = styled.image(
{
height: props => props.size,
verticalAlign: 'middle',
width: props => props.size,
},
{
ignoreAttributes: ['size'],
},
);
const ColoredIconCustom = styled.view(
{
height: props => props.size,
verticalAlign: 'middle',
width: props => props.size,
backgroundColor: props => props.color,
display: 'inline-block',
maskImage: props => `url('${props.src}')`,
maskSize: '100% 100%',
// $FlowFixMe: prefixed property
WebkitMaskImage: props => `url('${props.src}')`,
// $FlowFixMe: prefixed property
WebkitMaskSize: '100% 100%',
},
{
ignoreAttributes: ['color', 'size', 'src'],
},
);
export function ColoredIcon(
props: {|
name: string,
src: string,
size?: number,
className?: string,
color?: string,
|},
context: {|
glyphColor?: string,
|},
) {
const {color = context.glyphColor, name, size = 16, src} = props;
const isBlack =
color == null ||
color === '#000' ||
color === 'black' ||
color === '#000000';
if (isBlack) {
return (
<ColoredIconBlack
alt={name}
src={src}
size={size}
className={props.className}
/>
);
} else {
return (
<ColoredIconCustom
color={color}
size={size}
src={src}
className={props.className}
/>
);
}
}
ColoredIcon.contextTypes = {
glyphColor: PropTypes.string,
};
export default class Glyph extends styled.StylablePureComponent<{
name: string,
size?: 8 | 10 | 12 | 16 | 18 | 20 | 24 | 32,
variant?: 'filled' | 'outline',
className?: string,
color?: string,
}> {
render() {
const {name, size, variant, color, className} = this.props;
return (
<ColoredIcon
name={name}
className={className}
color={color}
size={size}
src={getIconUrl(name, size, variant)}
/>
);
}
}

View File

@@ -0,0 +1,57 @@
/**
* 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 styled from '../styled/index.js';
const LargeHeading = styled.view({
fontSize: 18,
fontWeight: 'bold',
lineHeight: '20px',
borderBottom: '1px solid #ddd',
marginBottom: 10,
});
const SmallHeading = styled.view({
fontSize: 12,
color: '#90949c',
fontWeight: 'bold',
marginBottom: 10,
textTransform: 'uppercase',
});
/**
* A heading component.
*
* @example Heading 1
* <Heading level={1}>I'm a heading</Heading>
* @example Heading 2
* <Heading level={2}>I'm a heading</Heading>
* @example Heading 3
* <Heading level={3}>I'm a heading</Heading>
* @example Heading 4
* <Heading level={4}>I'm a heading</Heading>
* @example Heading 5
* <Heading level={5}>I'm a heading</Heading>
* @example Heading 6
* <Heading level={6}>I'm a heading</Heading>
*/
export default function Heading(props: {
/**
* Level of the heading. A number from 1-6. Where 1 is the largest heading.
*/
level?: number,
/**
* Children.
*/
children?: React$Node,
}) {
if (props.level === 1) {
return <LargeHeading>{props.children}</LargeHeading>;
} else {
return <SmallHeading>{props.children}</SmallHeading>;
}
}

View File

@@ -0,0 +1,14 @@
/**
* 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 styled from '../styled/index.js';
export default styled.view({
backgroundColor: '#c9ced4',
height: 1,
margin: '5px 0',
});

View File

@@ -0,0 +1,14 @@
/**
* 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 ContextMenu from './ContextMenu.js';
export default class InlineContextMenu extends ContextMenu {
render() {
return <span>{this.props.children}</span>;
}
}

View File

@@ -0,0 +1,41 @@
/**
* 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 styled from '../styled/index.js';
import {colors} from './colors.js';
export const inputStyle = {
border: `1px solid ${colors.light15}`,
borderRadius: 4,
font: 'inherit',
fontSize: '1em',
height: (props: Object) => (props.compact ? '17px' : '28px'),
lineHeight: (props: Object) => (props.compact ? '17px' : '28px'),
marginRight: 5,
'&:disabled': {
backgroundColor: '#ddd',
borderColor: '#ccc',
cursor: 'not-allowed',
},
};
const Input = styled.textInput(
{
...inputStyle,
padding: props => (props.compact ? '0 5px' : '0 10px'),
},
{
ignoreAttributes: ['compact'],
},
);
Input.defaultProps = {
type: 'text',
};
export default Input;

View File

@@ -0,0 +1,696 @@
/**
* 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 {Rect} from '../../utils/geometry.js';
import LowPassFilter from '../../utils/LowPassFilter.js';
import {
getDistanceTo,
maybeSnapLeft,
maybeSnapTop,
SNAP_SIZE,
} from '../../utils/snap.js';
import {styled} from 'sonar';
const invariant = require('invariant');
const React = require('react');
const WINDOW_CURSOR_BOUNDARY = 5;
type CursorState = {|
top: number,
left: number,
|};
type ResizingSides = ?{|
left?: boolean,
top?: boolean,
bottom?: boolean,
right?: boolean,
|};
const ALL_RESIZABLE: ResizingSides = {
bottom: true,
left: true,
right: true,
top: true,
};
type InteractiveProps = {|
isMovableAnchor?: (event: SyntheticMouseEvent<>) => boolean,
onMoveStart?: () => void,
onMoveEnd?: () => void,
onMove?: (top: number, left: number, event: SyntheticMouseEvent<>) => void,
id?: string,
movable?: boolean,
hidden?: boolean,
moving?: boolean,
fill?: boolean,
siblings?: $Shape<{[key: string]: $Shape<Rect>}>,
updateCursor?: (cursor: ?string) => void,
zIndex?: number,
top?: number,
left?: number,
minTop: number,
minLeft: number,
width?: number | string,
height?: number | string,
minWidth: number,
minHeight: number,
maxWidth?: number,
maxHeight?: number,
onCanResize?: (sides: ResizingSides) => void,
onResizeStart?: () => void,
onResizeEnd?: () => void,
onResize?: (width: number, height: number) => void,
resizing?: boolean,
resizable?: boolean | ResizingSides,
innerRef?: (elem: HTMLElement) => void,
style?: Object,
className?: string,
children?: React.Element<*>,
|};
type InteractiveState = {|
moving: boolean,
movingInitialProps: ?InteractiveProps,
movingInitialCursor: ?CursorState,
cursor: ?string,
resizingSides: ResizingSides,
couldResize: boolean,
resizing: boolean,
resizingInitialRect: ?Rect,
resizingInitialCursor: ?CursorState,
|};
const InteractiveContainer = styled.view({
willChange: 'transform, height, width, z-index',
});
export default class Interactive extends styled.StylableComponent<
InteractiveProps,
InteractiveState,
> {
constructor(props: InteractiveProps, context: Object) {
super(props, context);
this.state = {
couldResize: false,
cursor: null,
moving: false,
movingInitialCursor: null,
movingInitialProps: null,
resizing: false,
resizingInitialCursor: null,
resizingInitialRect: null,
resizingSides: null,
};
this.globalMouse = false;
}
globalMouse: boolean;
ref: HTMLElement;
nextTop: ?number;
nextLeft: ?number;
nextEvent: ?SyntheticMouseEvent<>;
static defaultProps = {
minHeight: 0,
minLeft: 0,
minTop: 0,
minWidth: 0,
};
onMouseMove = (event: SyntheticMouseEvent<>) => {
if (this.state.moving) {
this.calculateMove(event);
} else if (this.state.resizing) {
this.calculateResize(event);
} else {
this.calculateResizable(event);
}
};
startAction = (event: SyntheticMouseEvent<>) => {
this.globalMouse = true;
window.addEventListener('pointerup', this.endAction, {passive: true});
window.addEventListener('pointermove', this.onMouseMove, {passive: true});
const {isMovableAnchor} = this.props;
if (isMovableAnchor && isMovableAnchor(event)) {
this.startTitleAction(event);
} else {
this.startWindowAction(event);
}
};
startTitleAction(event: SyntheticMouseEvent<>) {
if (this.state.couldResize) {
this.startResizeAction(event);
} else if (this.props.movable === true) {
this.startMoving(event);
}
}
startMoving(event: SyntheticMouseEvent<>) {
const {onMoveStart} = this.props;
if (onMoveStart) {
onMoveStart();
}
if (this.context.os) {
// pause OS timers to avoid lag when dragging
this.context.os.timers.pause();
}
const topLpf = new LowPassFilter();
const leftLpf = new LowPassFilter();
this.nextTop = null;
this.nextLeft = null;
this.nextEvent = null;
const onFrame = () => {
if (!this.state.moving) {
return;
}
const {nextEvent, nextTop, nextLeft} = this;
if (nextEvent && nextTop != null && nextLeft != null) {
if (topLpf.hasFullBuffer()) {
const newTop = topLpf.next(nextTop);
const newLeft = leftLpf.next(nextLeft);
this.move(newTop, newLeft, nextEvent);
} else {
this.move(nextTop, nextLeft, nextEvent);
}
}
requestAnimationFrame(onFrame);
};
this.setState(
{
cursor: 'move',
moving: true,
movingInitialCursor: {
left: event.clientX,
top: event.clientY,
},
movingInitialProps: this.props,
},
onFrame,
);
}
getPossibleTargetWindows(rect: Rect) {
const closeWindows = [];
const {siblings} = this.props;
if (siblings) {
for (const key in siblings) {
if (key === this.props.id) {
// don't target ourselves
continue;
}
const win = siblings[key];
const distance = getDistanceTo(rect, win);
if (distance <= SNAP_SIZE) {
closeWindows.push(win);
}
}
}
return closeWindows;
}
startWindowAction(event: SyntheticMouseEvent<>) {
if (this.state.couldResize) {
this.startResizeAction(event);
}
}
startResizeAction(event: SyntheticMouseEvent<>) {
event.stopPropagation();
event.preventDefault();
const {onResizeStart} = this.props;
if (onResizeStart) {
onResizeStart();
}
this.setState({
resizing: true,
resizingInitialCursor: {
left: event.clientX,
top: event.clientY,
},
resizingInitialRect: this.getRect(),
});
}
componentDidUpdate(prevProps: InteractiveProps, prevState: InteractiveState) {
if (prevState.cursor !== this.state.cursor) {
const {updateCursor} = this.props;
if (updateCursor) {
updateCursor(this.state.cursor);
}
}
}
resetMoving() {
const {onMoveEnd} = this.props;
if (onMoveEnd) {
onMoveEnd();
}
if (this.context.os) {
// resume os timers
this.context.os.timers.resume();
}
this.setState({
cursor: undefined,
moving: false,
movingInitialProps: undefined,
resizingInitialCursor: undefined,
});
}
resetResizing() {
const {onResizeEnd} = this.props;
if (onResizeEnd) {
onResizeEnd();
}
this.setState({
resizing: false,
resizingInitialCursor: undefined,
resizingInitialRect: undefined,
resizingSides: undefined,
});
}
componentWillUnmount() {
this.endAction();
}
endAction = () => {
this.globalMouse = false;
window.removeEventListener('pointermove', this.onMouseMove);
window.removeEventListener('pointerup', this.endAction);
if (this.state.moving) {
this.resetMoving();
}
if (this.state.resizing) {
this.resetResizing();
}
};
onMouseLeave = () => {
if (!this.state.resizing && !this.state.moving) {
this.setState({
cursor: undefined,
});
}
};
calculateMove(event: SyntheticMouseEvent<>) {
const {movingInitialCursor, movingInitialProps} = this.state;
invariant(movingInitialProps, 'TODO');
invariant(movingInitialCursor, 'TODO');
const {clientX: cursorLeft, clientY: cursorTop} = event;
const movedLeft = movingInitialCursor.left - cursorLeft;
const movedTop = movingInitialCursor.top - cursorTop;
let newLeft = (movingInitialProps.left || 0) - movedLeft;
let newTop = (movingInitialProps.top || 0) - movedTop;
if (event.altKey) {
const snapProps = this.getRect();
const windows = this.getPossibleTargetWindows(snapProps);
newLeft = maybeSnapLeft(snapProps, windows, newLeft);
newTop = maybeSnapTop(snapProps, windows, newTop);
}
this.nextTop = newTop;
this.nextLeft = newLeft;
this.nextEvent = event;
}
resize(width: number, height: number) {
if (width === this.props.width && height === this.props.height) {
// noop
return;
}
const {onResize} = this.props;
if (!onResize) {
return;
}
width = Math.max(this.props.minWidth, width);
height = Math.max(this.props.minHeight, height);
const {maxHeight, maxWidth} = this.props;
if (maxWidth != null) {
width = Math.min(maxWidth, width);
}
if (maxHeight != null) {
height = Math.min(maxHeight, height);
}
onResize(width, height);
}
move(top: number, left: number, event: SyntheticMouseEvent<>) {
top = Math.max(this.props.minTop, top);
left = Math.max(this.props.minLeft, left);
if (top === this.props.top && left === this.props.left) {
// noop
return;
}
const {onMove} = this.props;
if (onMove) {
onMove(top, left, event);
}
}
calculateResize(event: SyntheticMouseEvent<>) {
const {
resizingInitialCursor,
resizingInitialRect,
resizingSides,
} = this.state;
invariant(resizingInitialRect, 'TODO');
invariant(resizingInitialCursor, 'TODO');
invariant(resizingSides, 'TODO');
const deltaLeft = resizingInitialCursor.left - event.clientX;
const deltaTop = resizingInitialCursor.top - event.clientY;
let newLeft = resizingInitialRect.left;
let newTop = resizingInitialRect.top;
let newWidth = resizingInitialRect.width;
let newHeight = resizingInitialRect.height;
// right
if (resizingSides.right === true) {
newWidth -= deltaLeft;
}
// bottom
if (resizingSides.bottom === true) {
newHeight -= deltaTop;
}
const rect = this.getRect();
// left
if (resizingSides.left === true) {
newLeft -= deltaLeft;
newWidth += deltaLeft;
if (this.props.movable === true) {
// prevent from being shrunk past the minimum width
const right = rect.left + rect.width;
const maxLeft = right - this.props.minWidth;
let cleanLeft = Math.max(0, newLeft);
cleanLeft = Math.min(cleanLeft, maxLeft);
newWidth -= Math.abs(newLeft - cleanLeft);
newLeft = cleanLeft;
}
}
// top
if (resizingSides.top === true) {
newTop -= deltaTop;
newHeight += deltaTop;
if (this.props.movable === true) {
// prevent from being shrunk past the minimum height
const bottom = rect.top + rect.height;
const maxTop = bottom - this.props.minHeight;
let cleanTop = Math.max(0, newTop);
cleanTop = Math.min(cleanTop, maxTop);
newHeight += newTop - cleanTop;
newTop = cleanTop;
}
}
if (event.altKey) {
const windows = this.getPossibleTargetWindows(rect);
if (resizingSides.left === true) {
const newLeft2 = maybeSnapLeft(rect, windows, newLeft);
newWidth += newLeft - newLeft2;
newLeft = newLeft2;
}
if (resizingSides.top === true) {
const newTop2 = maybeSnapTop(rect, windows, newTop);
newHeight += newTop - newTop2;
newTop = newTop2;
}
if (resizingSides.bottom === true) {
newHeight = maybeSnapTop(rect, windows, newTop + newHeight) - newTop;
}
if (resizingSides.right === true) {
newWidth = maybeSnapLeft(rect, windows, newLeft + newWidth) - newLeft;
}
}
this.move(newTop, newLeft, event);
this.resize(newWidth, newHeight);
}
getRect(): Rect {
const {props, ref} = this;
invariant(ref, 'expected ref');
return {
height: ref.offsetHeight || 0,
left: props.left || 0,
top: props.top || 0,
width: ref.offsetWidth || 0,
};
}
getResizable(): ResizingSides {
const {resizable} = this.props;
if (resizable === true) {
return ALL_RESIZABLE;
} else if (resizable == null || resizable === false) {
return;
} else {
return resizable;
}
}
checkIfResizable(
event: SyntheticMouseEvent<>,
): ?{|
left: boolean,
right: boolean,
top: boolean,
bottom: boolean,
|} {
const canResize = this.getResizable();
if (!canResize) {
return;
}
const {left: offsetLeft, top: offsetTop} = this.ref.getBoundingClientRect();
const {height, width} = this.getRect();
const x = event.clientX - offsetLeft;
const y = event.clientY - offsetTop;
const atTop: boolean = y <= WINDOW_CURSOR_BOUNDARY;
const atBottom: boolean = y >= height - WINDOW_CURSOR_BOUNDARY;
const atLeft: boolean = x <= WINDOW_CURSOR_BOUNDARY;
const atRight: boolean = x >= width - WINDOW_CURSOR_BOUNDARY;
return {
bottom: canResize.bottom === true && atBottom,
left: canResize.left === true && atLeft,
right: canResize.right === true && atRight,
top: canResize.top === true && atTop,
};
}
calculateResizable(event: SyntheticMouseEvent<>) {
const resizing = this.checkIfResizable(event);
if (!resizing) {
return;
}
const canResize = this.getResizable();
if (!canResize) {
return;
}
const {bottom, left, right, top} = resizing;
let newCursor;
const movingHorizontal = left || right;
const movingVertical = top || left;
// left
if (left) {
newCursor = 'ew-resize';
}
// right
if (right) {
newCursor = 'ew-resize';
}
// if resizing vertically and one side can't be resized then use different cursor
if (
movingHorizontal &&
(canResize.left !== true || canResize.right !== true)
) {
newCursor = 'col-resize';
}
// top
if (top) {
newCursor = 'ns-resize';
// top left
if (left) {
newCursor = 'nwse-resize';
}
// top right
if (right) {
newCursor = 'nesw-resize';
}
}
// bottom
if (bottom) {
newCursor = 'ns-resize';
// bottom left
if (left) {
newCursor = 'nesw-resize';
}
// bottom right
if (right) {
newCursor = 'nwse-resize';
}
}
// if resizing horziontally and one side can't be resized then use different cursor
if (
movingVertical &&
!movingHorizontal &&
(canResize.top !== true || canResize.bottom !== true)
) {
newCursor = 'row-resize';
}
const resizingSides = {
bottom,
left,
right,
top,
};
const {onCanResize} = this.props;
if (onCanResize) {
onCanResize();
}
this.setState({
couldResize: Boolean(newCursor),
cursor: newCursor,
resizingSides,
});
}
setRef = (ref: HTMLElement) => {
this.ref = ref;
const {innerRef} = this.props;
if (innerRef) {
innerRef(ref);
}
};
onLocalMouseMove = (event: SyntheticMouseEvent<>) => {
if (!this.globalMouse) {
this.onMouseMove(event);
}
};
render() {
const {fill, height, left, movable, top, width, zIndex} = this.props;
const style: Object = {
cursor: this.state.cursor,
zIndex: zIndex == null ? 'auto' : zIndex,
};
if (movable === true || top != null || left != null) {
if (fill === true) {
style.left = left || 0;
style.top = top || 0;
} else {
style.transform = `translate3d(${left || 0}px, ${top || 0}px, 0)`;
}
}
if (fill === true) {
style.right = 0;
style.bottom = 0;
style.width = '100%';
style.height = '100%';
} else {
style.width = width == null ? 'auto' : width;
style.height = height == null ? 'auto' : height;
}
if (this.props.style) {
Object.assign(style, this.props.style);
}
return (
<InteractiveContainer
className={this.props.className}
hidden={this.props.hidden}
innerRef={this.setRef}
onMouseDown={this.startAction}
onMouseMove={this.onLocalMouseMove}
onMouseLeave={this.onMouseLeave} // eslint-disable-next-line
style={style}>
{this.props.children}
</InteractiveContainer>
);
}
}

View File

@@ -0,0 +1,13 @@
/**
* 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 styled from '../styled/index.js';
export default styled.view({
fontSize: 12,
fontWeight: 'bold',
});

39
src/ui/components/Link.js Normal file
View File

@@ -0,0 +1,39 @@
/**
* 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 styled from '../styled/index.js';
import {colors} from './colors.js';
import {Component} from 'react';
import {shell} from 'electron';
const StyledLink = styled.text(
{
color: colors.highlight,
'&:hover': {
cursor: 'pointer',
textDecoration: 'underline',
},
},
{
ignoreAttributes: [],
},
);
export default class Link extends Component<{
href: string,
children?: React$Node,
}> {
onClick = () => {
shell.openExternal(this.props.href);
};
render() {
return (
<StyledLink onClick={this.onClick}>{this.props.children}</StyledLink>
);
}
}

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
*/
import type {StyledComponent} from '../styled/index.js';
import styled from '../styled/index.js';
const animation = styled.keyframes({
'0%': {
transform: 'rotate(0deg)',
},
'100%': {
transform: 'rotate(360deg)',
},
});
const LoadingIndicator: StyledComponent<{
size?: number,
}> = styled.view(
{
animation: `${animation} 1s infinite linear`,
width: props => props.size,
height: props => props.size,
minWidth: props => props.size,
minHeight: props => props.size,
borderRadius: '50%',
border: props => `${props.size / 6}px solid rgba(0, 0, 0, 0.2)`,
borderLeftColor: 'rgba(0, 0, 0, 0.4)',
},
{
ignoreAttributes: ['size'],
},
);
LoadingIndicator.defaultProps = {
size: 50,
};
export default LoadingIndicator;

View File

@@ -0,0 +1,49 @@
/**
* 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 styled from '../styled/index.js';
import {Component} from 'react';
const Overlay = styled.view({
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.6)',
bottom: 0,
display: 'flex',
justifyContent: 'center',
left: 0,
position: 'absolute',
right: 0,
top: 0,
zIndex: 99999,
});
export default class ModalOverlay extends Component<{
onClose: () => void,
children?: React$Node,
}> {
ref: HTMLElement;
setRef = (ref: HTMLElement) => {
this.ref = ref;
};
onClick = (e: SyntheticMouseEvent<>) => {
if (e.target === this.ref) {
this.props.onClose();
}
};
render() {
const {props} = this;
return (
<Overlay innerRef={this.setRef} onClick={this.onClick}>
{props.children}
</Overlay>
);
}
}

View File

@@ -0,0 +1,428 @@
/**
* 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 {Rect} from '../../utils/geometry.js';
import styled from '../styled/index.js';
import {Component} from 'react';
const React = require('react');
export type OrderableOrder = Array<string>;
type OrderableOrientation = 'horizontal' | 'vertical';
type OrderableProps = {
items: {[key: string]: React.Element<*>},
orientation: OrderableOrientation,
onChange?: (order: OrderableOrder, key: string) => void,
order?: ?OrderableOrder,
className?: string,
reverse?: boolean,
altKey?: boolean,
moveDelay?: number,
dragOpacity?: number,
ignoreChildEvents?: boolean,
};
type OrderableState = {|
order?: ?OrderableOrder,
movingOrder?: ?OrderableOrder,
|};
type TabSizes = {
[key: string]: Rect,
};
const OrderableContainer = styled.view({
position: 'relative',
});
const OrderableItemContainer = styled.view(
{
display: props =>
props.orientation === 'vertical' ? 'block' : 'inline-block',
},
{
ignoreAttributes: ['orientation'],
},
);
class OrderableItem extends Component<{
orientation: OrderableOrientation,
id: string,
children?: React$Node,
addRef: (key: string, ref: HTMLElement) => void,
startMove: (KEY: string, event: SyntheticMouseEvent<>) => void,
}> {
addRef = (ref: HTMLElement) => {
this.props.addRef(this.props.id, ref);
};
startMove = (event: SyntheticMouseEvent<>) => {
this.props.startMove(this.props.id, event);
};
render() {
return (
<OrderableItemContainer
orientation={this.props.orientation}
key={this.props.id}
innerRef={this.addRef}
onMouseDown={this.startMove}>
{this.props.children}
</OrderableItemContainer>
);
}
}
export default class Orderable extends styled.StylableComponent<
OrderableProps,
OrderableState,
> {
constructor(props: OrderableProps, context: Object) {
super(props, context);
this.tabRefs = {};
this.state = {order: props.order};
this.setProps(props);
}
_mousemove: ?Function;
_mouseup: ?Function;
timer: any;
sizeKey: 'width' | 'height';
offsetKey: 'left' | 'top';
mouseKey: 'offsetX' | 'offsetY';
screenKey: 'screenX' | 'screenY';
containerRef: ?HTMLElement;
tabRefs: {
[key: string]: ?HTMLElement,
};
static defaultProps = {
dragOpacity: 1,
moveDelay: 50,
};
setProps(props: OrderableProps) {
const {orientation} = props;
this.sizeKey = orientation === 'horizontal' ? 'width' : 'height';
this.offsetKey = orientation === 'horizontal' ? 'left' : 'top';
this.mouseKey = orientation === 'horizontal' ? 'offsetX' : 'offsetY';
this.screenKey = orientation === 'horizontal' ? 'screenX' : 'screenY';
}
shouldComponentUpdate() {
return !this.state.movingOrder;
}
componentWillReceiveProps(nextProps: OrderableProps) {
this.setState({
order: nextProps.order,
});
this.setProps(nextProps);
}
startMove = (key: string, event: SyntheticMouseEvent<*>) => {
if (this.props.altKey === true && event.altKey === false) {
return;
}
if (this.props.ignoreChildEvents === true) {
const tabRef = this.tabRefs[key];
// $FlowFixMe parentNode not implemented
if (event.target !== tabRef && event.target.parentNode !== tabRef) {
return;
}
}
this.reset();
event.persist();
const {moveDelay} = this.props;
if (moveDelay == null) {
this._startMove(key, event);
} else {
const cancel = () => {
clearTimeout(this.timer);
document.removeEventListener('mouseup', cancel);
};
document.addEventListener('mouseup', cancel);
this.timer = setTimeout(() => {
cancel();
this._startMove(key, event);
}, moveDelay);
}
};
_startMove(activeKey: string, event: SyntheticMouseEvent<>) {
// $FlowFixMe
const clickOffset = event.nativeEvent[this.mouseKey];
// calculate offsets before we start moving element
const sizes: TabSizes = {};
for (const key in this.tabRefs) {
const elem = this.tabRefs[key];
if (elem) {
const rect: Rect = elem.getBoundingClientRect();
sizes[key] = {
height: rect.height,
left: elem.offsetLeft,
top: elem.offsetTop,
width: rect.width,
};
}
}
const {containerRef} = this;
if (containerRef) {
containerRef.style.height = `${containerRef.offsetHeight}px`;
containerRef.style.width = `${containerRef.offsetWidth}px`;
}
for (const key in this.tabRefs) {
const elem = this.tabRefs[key];
if (elem) {
const size = sizes[key];
elem.style.position = 'absolute';
elem.style.top = `${size.top}px`;
elem.style.left = `${size.left}px`;
elem.style.height = `${size.height}px`;
elem.style.width = `${size.width}px`;
}
}
document.addEventListener(
'mouseup',
(this._mouseup = () => {
this.stopMove(activeKey, sizes);
}),
{passive: true},
);
// $FlowFixMe
const screenClickPos = event.nativeEvent[this.screenKey];
document.addEventListener(
'mousemove',
(this._mousemove = (event: MouseEvent) => {
// $FlowFixMe
const goingOpposite = event[this.screenKey] < screenClickPos;
this.possibleMove(activeKey, goingOpposite, event, clickOffset, sizes);
}),
{passive: true},
);
}
possibleMove(
activeKey: string,
goingOpposite: boolean,
event: MouseEvent,
cursorOffset: number,
sizes: TabSizes,
) {
// update moving tab position
const {containerRef} = this;
const movingSize = sizes[activeKey];
const activeTab = this.tabRefs[activeKey];
if (containerRef) {
const containerRect: Rect = containerRef.getBoundingClientRect();
let newActivePos = // $FlowFixMe
event[this.screenKey] - containerRect[this.offsetKey] - cursorOffset;
newActivePos = Math.max(-1, newActivePos);
newActivePos = Math.min(
newActivePos,
containerRect[this.sizeKey] - movingSize[this.sizeKey],
);
movingSize[this.offsetKey] = newActivePos;
if (activeTab) {
activeTab.style.setProperty(this.offsetKey, `${newActivePos}px`);
const {dragOpacity} = this.props;
if (dragOpacity != null && dragOpacity !== 1) {
activeTab.style.opacity = `${dragOpacity}`;
}
}
}
// figure out new order
const zipped: Array<[string, number]> = [];
for (const key in sizes) {
const rect = sizes[key];
let offset = rect[this.offsetKey];
let size = rect[this.sizeKey];
if (goingOpposite) {
// when dragging opposite add the size to the offset
if (key === activeKey) {
// calculate the active tab to be a quarter of the actual size so when dragging in the opposite
// direction, you need to cover 75% of the previous tab to trigger a movement
size *= 0.25;
}
offset += size;
} else if (key === activeKey) {
// if not dragging in the opposite direction and we're the active tab, require covering 25% of the
// next tab in roder to trigger a movement
offset += size * 0.75;
}
zipped.push([key, offset]);
}
// calculate ordering
const order = zipped
.sort(([, a], [, b]) => {
return Number(a > b);
})
.map(([key]) => key);
this.moveTabs(order, activeKey, sizes);
this.setState({movingOrder: order});
}
moveTabs(order: OrderableOrder, activeKey: ?string, sizes: TabSizes) {
let offset = 0;
for (const key of order) {
const size = sizes[key];
const tab = this.tabRefs[key];
if (tab) {
let newZIndex = key === activeKey ? 2 : 1;
const prevZIndex = tab.style.zIndex;
if (prevZIndex) {
newZIndex += Number(prevZIndex);
}
tab.style.zIndex = String(newZIndex);
if (key === activeKey) {
tab.style.transition = 'opacity 100ms ease-in-out';
} else {
tab.style.transition = `${this.offsetKey} 300ms ease-in-out`;
tab.style.setProperty(this.offsetKey, `${offset}px`);
}
offset += size[this.sizeKey];
}
}
}
getMidpoint(rect: Rect) {
return rect[this.offsetKey] + rect[this.sizeKey] / 2;
}
stopMove(activeKey: string, sizes: TabSizes) {
const {movingOrder} = this.state;
const {onChange} = this.props;
if (onChange && movingOrder) {
const activeTab = this.tabRefs[activeKey];
if (activeTab) {
activeTab.style.opacity = '';
const transitionend = () => {
activeTab.removeEventListener('transitionend', transitionend);
this.reset();
};
activeTab.addEventListener('transitionend', transitionend);
}
this.resetListeners();
this.moveTabs(movingOrder, null, sizes);
onChange(movingOrder, activeKey);
} else {
this.reset();
}
this.setState({movingOrder: null});
}
resetListeners() {
clearTimeout(this.timer);
const {_mousemove, _mouseup} = this;
if (_mouseup) {
document.removeEventListener('mouseup', _mouseup);
}
if (_mousemove) {
document.removeEventListener('mousemove', _mousemove);
}
}
reset() {
this.resetListeners();
const {containerRef} = this;
if (containerRef) {
containerRef.removeAttribute('style');
}
for (const key in this.tabRefs) {
const elem = this.tabRefs[key];
if (elem) {
elem.removeAttribute('style');
}
}
}
componentWillUnmount() {
this.reset();
}
addRef = (key: string, elem: ?HTMLElement) => {
this.tabRefs[key] = elem;
};
setContainerRef = (ref: HTMLElement) => {
this.containerRef = ref;
};
render() {
const {items} = this.props;
// calculate order of elements
let {order} = this.state;
if (!order) {
order = Object.keys(items);
}
for (const key in items) {
if (order.indexOf(key) < 0) {
if (this.props.reverse === true) {
order.unshift(key);
} else {
order.push(key);
}
}
}
return (
<OrderableContainer
className={this.props.className}
innerRef={this.setContainerRef}>
{order.map(key => {
const item = items[key];
if (item) {
return (
<OrderableItem
orientation={this.props.orientation}
key={key}
id={key}
addRef={this.addRef}
startMove={this.startMove}>
{item}
</OrderableItem>
);
} else {
return null;
}
})}
</OrderableContainer>
);
}
}

179
src/ui/components/Panel.js Normal file
View File

@@ -0,0 +1,179 @@
/**
* 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 FlexColumn from './FlexColumn.js';
import styled from '../styled/index.js';
import FlexBox from './FlexBox.js';
import {colors} from './colors.js';
import Glyph from './Glyph.js';
const BORDER = '1px solid #dddfe2';
const ignoreAttributes = ['floating', 'padded'];
const Chevron = Glyph.extends({
marginRight: 4,
marginLeft: -2,
marginBottom: 1,
});
/**
* A Panel component.
*/
export default class Panel extends styled.StylableComponent<
{|
/**
* Class name to customise styling.
*/
className?: string,
/**
* Whether this panel is floating from the rest of the UI. ie. if it has
* margin and a border.
*/
floating?: boolean,
/**
* Whether the panel takes up all the space it can. Equivalent to the following CSS:
*
* height: 100%;
* width: 100%;
*/
fill?: boolean,
/**
* Heading for this panel. If this is anything other than a string then no
* padding is applied to the heading.
*/
heading: React$Node,
/**
* Contents of the panel.
*/
children?: React$Node,
/**
* Whether the panel header and body have padding.
*/
padded?: boolean,
/**
* Whether the panel can be collapsed. Defaults to true
*/
collapsable: boolean,
/**
* Initial state for panel if it is collapsable
*/
collapsed?: boolean,
/**
* Heading for this panel. If this is anything other than a string then no
* padding is applied to the heading.
*/
accessory?: React$Node,
|},
{
collapsed: boolean,
},
> {
static defaultProps: {|
floating: boolean,
fill: boolean,
collapsable: boolean,
|} = {
fill: false,
floating: true,
collapsable: true,
};
static PanelContainer = FlexColumn.extends(
{
flexShrink: 0,
padding: props => (props.floating ? 10 : 0),
borderBottom: props => (props.collapsed ? 'none' : BORDER),
},
{ignoreAttributes: ['collapsed', ...ignoreAttributes]},
);
static PanelHeader = FlexBox.extends(
{
backgroundColor: '#f6f7f9',
border: props => (props.floating ? BORDER : 'none'),
borderBottom: BORDER,
borderTopLeftRadius: 2,
borderTopRightRadius: 2,
justifyContent: 'space-between',
lineHeight: '27px',
fontWeight: 500,
flexShrink: 0,
padding: props => (props.padded ? '0 10px' : 0),
'&:not(:first-child)': {
borderTop: BORDER,
},
},
{ignoreAttributes},
);
static PanelBody = FlexColumn.extends(
{
backgroundColor: '#fff',
border: props => (props.floating ? BORDER : 'none'),
borderBottomLeftRadius: 2,
borderBottomRightRadius: 2,
borderTop: 'none',
flexGrow: 1,
padding: props => (props.padded ? 10 : 0),
},
{ignoreAttributes},
);
state = {
collapsed: this.props.collapsed == null ? false : this.props.collapsed,
};
onClick = () => this.setState({collapsed: !this.state.collapsed});
render() {
const {
padded,
children,
className,
fill,
floating,
heading,
collapsable,
accessory,
} = this.props;
const {collapsed} = this.state;
return (
<Panel.PanelContainer
className={className}
floating={floating}
fill={fill}
collapsed={collapsed}>
<Panel.PanelHeader
floating={floating}
padded={typeof heading === 'string'}
onClick={this.onClick}>
<span>
{collapsable && (
<Chevron
color={colors.macOSTitleBarIcon}
name={collapsed ? 'triangle-right' : 'triangle-down'}
size={12}
/>
)}
{heading}
</span>
{accessory}
</Panel.PanelHeader>
{children == null || (collapsable && collapsed) ? null : (
<Panel.PanelBody
fill={fill}
padded={padded == null ? true : padded}
floating={floating}>
{children}
</Panel.PanelBody>
)}
</Panel.PanelContainer>
);
}
}

View File

@@ -0,0 +1,53 @@
/**
* 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 ButtonGroup from './ButtonGroup.js';
import {Component} from 'react';
import Button from './Button.js';
const path = require('path');
class PathBreadcrumbsItem extends Component<{
name: string,
path: string,
isFolder: boolean,
onClick: (path: string) => void,
}> {
onClick = () => {
this.props.onClick(this.props.path);
};
render() {
return <Button onClick={this.onClick}>{this.props.name}</Button>;
}
}
export default function PathBreadcrumbs(props: {|
path: string,
isFile?: boolean,
onClick: (path: string) => void,
|}) {
const parts = props.path === path.sep ? [''] : props.path.split(path.sep);
const {onClick} = props;
return (
<ButtonGroup>
{parts.map((part, i) => {
const fullPath = parts.slice(0, i + 1).join(path.sep) || path.sep;
return (
<PathBreadcrumbsItem
key={`${i}:${part}`}
name={part || fullPath}
path={fullPath}
isFolder={!(props.isFile === true && i === parts.length - 1)}
onClick={onClick}
/>
);
})}
</ButtonGroup>
);
}

View File

@@ -0,0 +1,89 @@
/**
* 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 {PureComponent} from 'react';
import FlexColumn from './FlexColumn.js';
import styled from '../styled/index.js';
import {colors} from './colors.js';
const Anchor = styled.image({
zIndex: 6,
position: 'absolute',
bottom: 0,
left: '50%',
transform: 'translate(-50%, calc(100% + 2px))',
});
const PopoverContainer = FlexColumn.extends({
backgroundColor: colors.white,
borderRadius: 7,
border: '1px solid rgba(0,0,0,0.3)',
boxShadow: '0 2px 10px 0 rgba(0,0,0,0.3)',
position: 'absolute',
zIndex: 5,
bottom: 0,
marginTop: 15,
left: '50%',
transform: 'translate(-50%, calc(100% + 15px))',
overflow: 'hidden',
'&::before': {
content: '""',
display: 'block',
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
height: 13,
top: -13,
width: 26,
backgroundColor: colors.white,
},
});
type Props = {|
children: React.Node,
onDismiss: Function,
|};
export default class Popover extends PureComponent<Props> {
_ref: ?Element;
componentDidMount() {
window.document.addEventListener('click', this.handleClick);
window.document.addEventListener('keydown', this.handleKeydown);
}
componentWillUnmount() {
window.document.addEventListener('click', this.handleClick);
window.document.addEventListener('keydown', this.handleKeydown);
}
handleClick = (e: SyntheticMouseEvent<>) => {
// $FlowFixMe
if (this._ref && !this._ref.contains(e.target)) {
this.props.onDismiss();
}
};
handleKeydown = (e: SyntheticKeyboardEvent<>) => {
if (e.key === 'Escape') {
this.props.onDismiss();
}
};
_setRef = (ref: ?Element) => {
this._ref = ref;
};
render() {
return [
<Anchor src="./anchor.svg" key="anchor" />,
<PopoverContainer innerRef={this._setRef} key="popup">
{this.props.children}
</PopoverContainer>,
];
}
}

View File

@@ -0,0 +1,52 @@
/**
* 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 styled from '../styled/index.js';
import {Component} from 'react';
const IFrame = styled.customHTMLTag('iframe', {
height: '100%',
width: '100%',
border: 'none',
background: 'transparent',
position: 'absolute',
zIndex: -1,
top: 0,
left: 0,
});
export default class ResizeSensor extends Component<{
onResize: (e: UIEvent) => void,
}> {
iframe: ?HTMLIFrameElement;
setRef = (ref: ?HTMLIFrameElement) => {
this.iframe = ref;
};
render() {
return <IFrame innerRef={this.setRef} />;
}
componentDidMount() {
const {iframe} = this;
if (iframe != null) {
iframe.contentWindow.addEventListener('resize', this.handleResize);
}
}
componentWillUnmount() {
const {iframe} = this;
if (iframe != null) {
iframe.contentWindow.removeEventListener('resize', this.handleResize);
}
}
handleResize = () => {
window.requestAnimationFrame(this.props.onResize);
};
}

View File

@@ -0,0 +1,29 @@
/**
* 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 {Component} from 'react';
export default class Select extends Component<{
className?: string,
options: {
[key: string]: string,
},
onChange: (key: string) => void,
selected?: ?string,
}> {
render() {
const {className, options, selected} = this.props;
return (
<select onChange={this.props.onChange} className={className}>
{Object.keys(options).map(key => (
<option selected={key === selected}>{options[key]}</option>
))}
</select>
);
}
}

View File

@@ -0,0 +1,178 @@
/**
* 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 {StyledComponent} from '../styled/index.js';
import Interactive from './Interactive.js';
import FlexColumn from './FlexColumn.js';
import {Component} from 'react';
const SidebarInteractiveContainer = Interactive.extends({
flex: 'none',
});
type SidebarPosition = 'left' | 'top' | 'right' | 'bottom';
const SidebarContainer: StyledComponent<{
position: SidebarPosition,
overflow?: boolean,
}> = FlexColumn.extends(
{
backgroundColor: props => props.backgroundColor || '#f7f7f7',
borderLeft: props =>
props.position === 'right' ? '1px solid #b3b3b3' : 'none',
borderTop: props =>
props.position === 'bottom' ? '1px solid #b3b3b3' : 'none',
borderRight: props =>
props.position === 'left' ? '1px solid #b3b3b3' : 'none',
borderBottom: props =>
props.position === 'top' ? '1px solid #b3b3b3' : 'none',
height: '100%',
overflowX: 'hidden',
overflowY: 'auto',
textOverflow: props => (props.overflow ? 'ellipsis' : 'auto'),
whiteSpace: props => (props.overflow ? 'nowrap' : 'normal'),
},
{
ignoreAttributes: ['backgroundColor', 'position'],
},
);
type SidebarProps = {
/**
* Position of the sidebar.
*/
position: SidebarPosition,
/**
* Default width of the sidebar. Only used for left/right sidebars.
*/
width?: number,
/**
* Minimum sidebar width. Only used for left/right sidebars.
*/
minWidth?: number,
/**
* Maximum sidebar width. Only used for left/right sidebars.
*/
maxWidth?: number,
/**
* Default height of the sidebar.
*/
height?: number,
/**
* Minimum sidebar height. Only used for top/bottom sidebars.
*/
minHeight?: number,
/**
* Maximum sidebar height. Only used for top/bottom sidebars.
*/
maxHeight?: number,
/**
* Background color.
*/
backgroundColor?: string,
/**
* Callback when the sidebar size ahs changed.
*/
onResize?: (width: number, height: number) => void,
/**
* Contents of the sidebar.
*/
children?: React$Node,
/**
* Class name to customise styling.
*/
className?: string,
};
type SidebarState = {|
width?: number,
height?: number,
userChange: boolean,
|};
/**
* A resizable sidebar.
*/
export default class Sidebar extends Component<SidebarProps, SidebarState> {
constructor(props: SidebarProps, context: Object) {
super(props, context);
this.state = {userChange: false, width: props.width, height: props.height};
}
static defaultProps = {
position: 'left',
};
componentWillReceiveProps(nextProps: SidebarProps) {
if (!this.state.userChange) {
this.setState({width: nextProps.width, height: nextProps.height});
}
}
onResize = (width: number, height: number) => {
const {onResize} = this.props;
if (onResize) {
onResize(width, height);
} else {
this.setState({userChange: true, width, height});
}
};
render() {
const {backgroundColor, onResize, position, children} = this.props;
let height, minHeight, maxHeight, width, minWidth, maxWidth;
const resizable: {[key: string]: boolean} = {};
if (position === 'left') {
resizable.right = true;
({width, minWidth, maxWidth} = this.props);
} else if (position === 'top') {
resizable.bottom = true;
({height, minHeight, maxHeight} = this.props);
} else if (position === 'right') {
resizable.left = true;
({width, minWidth, maxWidth} = this.props);
} else if (position === 'bottom') {
resizable.top = true;
({height, minHeight, maxHeight} = this.props);
}
const horizontal = position === 'left' || position === 'right';
if (horizontal) {
width = width == null ? 200 : width;
minWidth = minWidth == null ? 100 : minWidth;
maxWidth = maxWidth == null ? 600 : maxWidth;
} else {
height = height == null ? 200 : height;
minHeight = minHeight == null ? 100 : minHeight;
maxHeight = maxHeight == null ? 600 : maxHeight;
}
return (
<SidebarInteractiveContainer
className={this.props.className}
minWidth={minWidth}
maxWidth={maxWidth}
width={horizontal ? (onResize ? width : this.state.width) : undefined}
minHeight={minHeight}
maxHeight={maxHeight}
height={
!horizontal ? (onResize ? height : this.state.height) : undefined
}
resizable={resizable}
onResize={this.onResize}>
<SidebarContainer position={position} backgroundColor={backgroundColor}>
{children}
</SidebarContainer>
</SidebarInteractiveContainer>
);
}
}

View File

@@ -0,0 +1,15 @@
/**
* 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 {colors} from './colors.js';
import Label from './Label.js';
export default Label.extends({
color: colors.blackAlpha30,
fontSize: 12,
padding: 10,
});

37
src/ui/components/Tab.js Normal file
View File

@@ -0,0 +1,37 @@
/**
* 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 default function Tab(props: {|
/**
* Label of this tab to show in the tab list.
*/
label: React$Node,
/**
* Whether this tab is closable.
*/
closable?: boolean,
/**
* Whether this tab is hidden. Useful for when you want a tab to be
* inaccessible via the user but you want to manually set the `active` props
* yourself.
*/
hidden?: boolean,
/**
* Whether this tab should always be included in the DOM and have it's
* visibility toggled.
*/
persist?: boolean,
/**
* Callback for when tab is closed.
*/
onClose?: () => void,
/**
* Contents of this tab.
*/
children?: React$Node,
|}) {
throw new Error("don't render me");
}

271
src/ui/components/Tabs.js Normal file
View File

@@ -0,0 +1,271 @@
/**
* 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 FlexColumn from './FlexColumn.js';
import styled from '../styled/index.js';
import Orderable from './Orderable.js';
import FlexRow from './FlexRow.js';
import {colors} from './colors.js';
import Tab from './Tab.js';
const TabList = FlexRow.extends({
alignItems: 'stretch',
});
const TabListItem = styled.view(
{
backgroundColor: props => (props.active ? colors.light15 : colors.light02),
borderBottom: '1px solid #dddfe2',
boxShadow: props =>
props.active ? 'inset 0px 0px 3px rgba(0,0,0,0.25)' : 'none',
color: colors.dark80,
flex: 1,
fontSize: 13,
lineHeight: '28px',
overflow: 'hidden',
padding: '0 10px',
position: 'relative',
textAlign: 'center',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
'&:hover': {
backgroundColor: props =>
props.active ? colors.light15 : colors.light05,
},
},
{
ignoreAttributes: ['active'],
},
);
const TabListAddItem = TabListItem.extends({
borderRight: 'none',
flex: 0,
flexGrow: 0,
fontWeight: 'bold',
});
const CloseButton = styled.view({
color: '#000',
float: 'right',
fontSize: 10,
fontWeight: 'bold',
textAlign: 'center',
marginLeft: 6,
marginTop: 6,
width: 16,
height: 16,
lineHeight: '16px',
borderRadius: '50%',
'&:hover': {
backgroundColor: colors.cherry,
color: '#fff',
},
});
const OrderableContainer = styled.view({
display: 'inline-block',
});
const TabContent = styled.view({
height: '100%',
overflow: 'auto',
width: '100%',
});
/**
* A Tabs component.
*/
export default function Tabs(props: {|
/**
* Callback for when the active tab has changed.
*/
onActive?: (key: ?string) => void,
/**
* The key of the default active tab.
*/
defaultActive?: string,
/**
* The key of the currently active tab.
*/
active?: ?string,
/**
* Tab elements.
*/
children?: Array<React$Element<any>>,
/**
* Whether the tabs can be reordered by the user.
*/
orderable?: boolean,
/**
* Callback when the tab order changes.
*/
onOrder?: (order: Array<string>) => void,
/**
* Order of tabs.
*/
order?: Array<string>,
/**
* Whether to include the contents of every tab in the DOM and just toggle
* it's visibility.
*/
persist?: boolean,
/**
* Whether to include a button to create additional items.
*/
newable?: boolean,
/**
* Callback for when the new button is clicked.
*/
onNew?: () => void,
/**
* Elements to insert before all tabs in the tab list.
*/
before?: Array<React$Node>,
/**
* Elements to insert after all tabs in the tab list.
*/
after?: Array<React$Node>,
|}) {
const {onActive} = props;
const active: ?string =
props.active == null ? props.defaultActive : props.active;
// array of other components that aren't tabs
const before = props.before || [];
const after = props.after || [];
//
const tabs = {};
// a list of keys
const keys = props.order ? props.order.slice() : [];
const tabContents = [];
const tabSiblings = [];
function add(comps) {
for (const comp of [].concat(comps || [])) {
if (Array.isArray(comp)) {
add(comp);
continue;
}
if (!comp) {
continue;
}
if (comp.type !== Tab) {
// if element isn't a tab then just push it into the tab list
tabSiblings.push(comp);
continue;
}
const {children, closable, label, onClose, width} = comp.props;
const key = comp.key == null ? label : comp.key;
if (typeof key !== 'string') {
throw new Error('tab needs a string key or a label');
}
if (!keys.includes(key)) {
keys.push(key);
}
const isActive: boolean = active === key;
if (isActive || props.persist === true || comp.props.persist === true) {
tabContents.push(
<TabContent key={key} hidden={!isActive}>
{children}
</TabContent>,
);
}
// this tab has been hidden from the tab bar but can still be selected if it's key is active
if (comp.props.hidden) {
continue;
}
let closeButton;
tabs[key] = (
<TabListItem
key={key}
width={width}
active={isActive}
onMouseDown={
!isActive &&
onActive &&
((event: SyntheticMouseEvent<>) => {
if (event.target !== closeButton) {
onActive(key);
}
})
}>
{comp.props.label}
{closable && (
<CloseButton // eslint-disable-next-line react/jsx-no-bind
innerRef={ref => (closeButton = ref)} // eslint-disable-next-line react/jsx-no-bind
onMouseDown={() => {
if (isActive && onActive) {
const index = keys.indexOf(key);
const newActive = keys[index + 1] || keys[index - 1] || null;
onActive(newActive);
}
onClose();
}}>
X
</CloseButton>
)}
</TabListItem>
);
}
}
add(props.children);
let tabList;
if (props.orderable === true) {
tabList = (
<OrderableContainer key="orderable-list">
<Orderable
orientation="horizontal"
items={tabs}
onChange={props.onOrder}
order={keys}
/>
</OrderableContainer>
);
} else {
tabList = [];
for (const key in tabs) {
tabList.push(tabs[key]);
}
}
if (props.newable === true) {
after.push(
<TabListAddItem key={keys.length} onMouseDown={props.onNew}>
+
</TabListAddItem>,
);
}
return (
<FlexColumn fill={true}>
<TabList>
{before}
{tabList}
{after}
</TabList>
{tabContents}
{tabSiblings}
</FlexColumn>
);
}

159
src/ui/components/Text.js Normal file
View File

@@ -0,0 +1,159 @@
/**
* 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 {StyledComponent} from '../styled/index.js';
import styled from '../styled/index.js';
/**
* A Text component.
*/
const Text: StyledComponent<{
/**
* Color of text.
*/
color?: string,
/**
* Whether this text is bold. Equivalent to the following CSS:
*
* font-weight: bold;
*/
bold?: boolean,
/**
* Whether this text is italic. Equivalent to the following CSS:
*
* font-style: italic;
*/
italic?: boolean,
/**
* Whether to format the text as code. Equivalent to the following CSS:
*
* font-size: Andale Mono, monospace;
* overflow: auto;
* user-select: text;
* white-space: pre-wrap;
* word-wrap: break-word;
*/
code?: boolean,
/**
* Whether this text is underlined. Equivalent to the following CSS:
*
* text-decoration: underline;
*/
underline?: boolean,
/**
* Whether this text is striked. Equivalent to the following CSS:
*
* text-decoration: line-through;
*/
strike?: boolean,
/**
* Whether this text is selectable by the cursor. Equivalent to the following CSS:
*
* user-select: text;
*/
selectable?: boolean,
/**
* Alignment of the text. Equivalent to the `text-align` CSS rule.
*/
align?: 'left' | 'center' | 'right',
/**
* Font size to use. Equivalent to the `font-size` CSS rule.
*/
size?: string | number,
/**
* Font family to use. Equivalent to the `font-family` CSS rule.
*/
family?: string,
/**
* Word wrap to use. Equivalent to the `word-wrap` CSS rule.
*/
wordWrap?: string,
/**
* White space to use. Equivalent to the `white-space` CSS rule.
*/
whiteSpace?: string,
}> = styled.text(
{
color: props => (props.color ? props.color : 'inherit'),
display: 'inline',
fontWeight: props => (props.bold ? 'bold' : 'inherit'),
fontStyle: props => (props.italic ? 'italic' : 'normal'),
textAlign: props => props.align || 'left',
fontSize: props => {
if (props.size == null && props.code) {
return 12;
} else {
return props.size;
}
},
fontFamily: props => {
if (props.code) {
return 'SF Mono, Monaco, Andale Mono, monospace';
} else {
return props.family;
}
},
overflow: props => {
if (props.code) {
return 'auto';
} else {
return 'visible';
}
},
textDecoration: props => {
if (props.underline) {
return 'underline';
} else if (props.strike) {
return 'line-through';
} else {
return 'none';
}
},
userSelect: props => {
if (
props.selectable ||
(props.code && typeof props.selectable === 'undefined')
) {
return 'text';
} else {
return 'none';
}
},
wordWrap: props => {
if (props.code) {
return 'break-word';
} else {
return props.wordWrap;
}
},
whiteSpace: props => {
if (props.code && typeof props.whiteSpace === 'undefined') {
return 'pre';
} else {
return props.whiteSpace;
}
},
},
{
ignoreAttributes: [
'selectable',
'whiteSpace',
'wordWrap',
'align',
'code',
'family',
'size',
'bold',
'italic',
'strike',
'underline',
'color',
],
},
);
export default Text;

View File

@@ -0,0 +1,21 @@
/**
* 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 styled from '../styled/index.js';
/**
* A TextParagraph component.
*/
const TextParagraph = styled.view({
marginBottom: 10,
'&:last-child': {
marginBottom: 0,
},
});
export default TextParagraph;

View File

@@ -0,0 +1,22 @@
/**
* 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 styled from '../styled/index.js';
import {inputStyle} from './Input.js';
export default styled.customHTMLTag(
'textarea',
{
...inputStyle,
lineHeight: 'normal',
padding: props => (props.compact ? '5px' : '8px'),
resize: 'none',
},
{
ignoreAttributes: ['compact'],
},
);

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 styled from '../styled/index.js';
import {colors} from './colors.js';
export const StyledButton = styled.view({
cursor: 'pointer',
width: '30px',
height: '16px',
background: props => (props.toggled ? colors.green : colors.grey),
display: 'block',
borderRadius: '100px',
position: 'relative',
marginLeft: '15px',
'&::after': {
content: `''`,
position: 'absolute',
top: '3px',
left: props => (props.toggled ? '18px' : '3px'),
width: '10px',
height: '10px',
background: 'white',
borderRadius: '100px',
transition: 'all cubic-bezier(0.3, 1.5, 0.7, 1) 0.3s',
},
});
type Props = {
/**
* onClick handler.
*/
onClick?: (event: SyntheticMouseEvent<>) => void,
/**
* whether the button is toggled
*/
toggled?: boolean,
};
/**
* Toggle Button.
*
* **Usage**
*
* ```jsx
* import {ToggleButton} from 'sonar';
* <ToggleButton onClick={handler} toggled={boolean}/>
* ```
*/
export default class ToggleButton extends styled.StylableComponent<Props> {
render() {
return (
<StyledButton toggled={this.props.toggled} onClick={this.props.onClick} />
);
}
}

View File

@@ -0,0 +1,49 @@
/**
* 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 {StyledComponent} from '../styled/index.js';
import {colors} from './colors.js';
import FlexRow from './FlexRow.js';
import FlexBox from './FlexBox.js';
/**
* A toolbar.
*/
const Toolbar: StyledComponent<{
/**
* Position of the toolbar. Dictates the location of the border.
*/
position?: 'top' | 'bottom',
compact?: boolean,
}> = FlexRow.extends(
{
backgroundColor: colors.light02,
borderBottom: props =>
props.position === 'bottom'
? 'none'
: `1px solid ${colors.sectionHeaderBorder}`,
borderTop: props =>
props.position === 'bottom'
? `1px solid ${colors.sectionHeaderBorder}`
: 'none',
flexShrink: 0,
height: props => (props.compact ? 28 : 42),
lineHeight: '32px',
alignItems: 'center',
padding: 6,
width: '100%',
},
{
ignoreAttributes: ['position'],
},
);
export const Spacer = FlexBox.extends({
flexGrow: 1,
});
export default Toolbar;

View File

@@ -0,0 +1,75 @@
/**
* 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 TooltipProvider from './TooltipProvider.js';
import styled from '../styled/index.js';
import {Component} from 'react';
const PropTypes = require('prop-types');
const TooltipContainer = styled.view({
display: 'contents',
});
type TooltipProps = {
title: React$Node,
children: React$Node,
};
type TooltipState = {
open: boolean,
};
export default class Tooltip extends Component<TooltipProps, TooltipState> {
static contextTypes = {
TOOLTIP_PROVIDER: PropTypes.object,
};
context: {
TOOLTIP_PROVIDER: TooltipProvider,
};
ref: ?HTMLDivElement;
state = {
open: false,
};
componentWillUnmount() {
if (this.state.open === true) {
this.context.TOOLTIP_PROVIDER.close();
}
}
onMouseEnter = () => {
if (this.ref != null) {
this.context.TOOLTIP_PROVIDER.open(this.ref, this.props.title);
this.setState({open: true});
}
};
onMouseLeave = () => {
this.context.TOOLTIP_PROVIDER.close();
this.setState({open: false});
};
setRef = (ref: ?HTMLDivElement) => {
this.ref = ref;
};
render() {
return (
<TooltipContainer
innerRef={this.setRef}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}>
{this.props.children}
</TooltipContainer>
);
}
}

View File

@@ -0,0 +1,92 @@
/**
* 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 styled from '../styled/index.js';
import {Component} from 'react';
const PropTypes = require('prop-types');
const TooltipBubble = styled.view(
{
backgroundColor: '#000',
lineHeight: '25px',
padding: '0 6px',
borderRadius: 4,
position: 'absolute',
width: 'auto',
top: props => props.top,
left: props => props.left,
zIndex: 99999999999,
pointerEvents: 'none',
color: '#fff',
marginTop: '-30px',
},
{
ignoreAttributes: ['top', 'left'],
},
);
type TooltipProps = {
children: React$Node,
};
type TooltipState = {
tooltip: ?{
rect: ClientRect,
title: React$Node,
},
};
export default class TooltipProvider extends Component<
TooltipProps,
TooltipState,
> {
static childContextTypes = {
TOOLTIP_PROVIDER: PropTypes.object,
};
state = {
tooltip: null,
};
getChildContext() {
return {TOOLTIP_PROVIDER: this};
}
open(container: HTMLDivElement, title: React$Node) {
const node = container.childNodes[0];
if (node == null || !(node instanceof HTMLElement)) {
return;
}
this.setState({
tooltip: {
rect: node.getBoundingClientRect(),
title,
},
});
}
close() {
this.setState({tooltip: null});
}
render() {
const {tooltip} = this.state;
let tooltipElem = null;
if (tooltip != null) {
tooltipElem = (
<TooltipBubble top={tooltip.rect.top} left={tooltip.rect.left}>
{tooltip.title}
</TooltipBubble>
);
}
return [tooltipElem, this.props.children];
}
}

Some files were not shown because too many files have changed in this diff Show More