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:
363
src/App.js
Normal file
363
src/App.js
Normal 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
361
src/MenuBar.js
Normal 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
128
src/PluginContainer.js
Normal 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);
|
||||
103
src/chrome/AutoUpdateVersion.js
Normal file
103
src/chrome/AutoUpdateVersion.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
239
src/chrome/BugReporterDialog.js
Normal file
239
src/chrome/BugReporterDialog.js
Normal 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
291
src/chrome/DevicesButton.js
Normal 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
142
src/chrome/DevicesList.js
Normal 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
28
src/chrome/ErrorBar.js
Normal 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
305
src/chrome/MainSidebar.js
Normal 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
404
src/chrome/PluginManager.js
Normal 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>
|
||||
|
||||
<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} />
|
||||
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
196
src/chrome/Popover.js
Normal 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
157
src/chrome/SonarTitleBar.js
Normal 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
92
src/chrome/Version.js
Normal 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
177
src/chrome/WelcomeScreen.js
Normal 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
206
src/createTablePlugin.js
Normal 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>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
323
src/device-plugins/cpu/index.js
Normal file
323
src/device-plugins/cpu/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
22
src/device-plugins/index.js
Normal file
22
src/device-plugins/index.js
Normal 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;
|
||||
562
src/device-plugins/logs/LogTable.js
Normal file
562
src/device-plugins/logs/LogTable.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
216
src/device-plugins/logs/LogWatcher.js
Normal file
216
src/device-plugins/logs/LogWatcher.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
8
src/device-plugins/logs/index.js
Normal file
8
src/device-plugins/logs/index.js
Normal 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;
|
||||
282
src/device-plugins/screen/index.js
Normal file
282
src/device-plugins/screen/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
95
src/devices/AndroidDevice.js
Normal file
95
src/devices/AndroidDevice.js
Normal 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
78
src/devices/BaseDevice.js
Normal 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
162
src/devices/IOSDevice.js
Normal 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
142
src/devices/OculusDevice.js
Normal 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);
|
||||
}
|
||||
}
|
||||
97
src/dispatcher/androidDevice.js
Normal file
97
src/dispatcher/androidDevice.js
Normal 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;
|
||||
}
|
||||
});
|
||||
};
|
||||
25
src/dispatcher/application.js
Normal file
25
src/dispatcher/application.js
Normal 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,
|
||||
}),
|
||||
);
|
||||
};
|
||||
99
src/dispatcher/iOSDevice.js
Normal file
99
src/dispatcher/iOSDevice.js
Normal 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
14
src/dispatcher/index.js
Normal 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));
|
||||
15
src/fb-stubs/BugReporter.js
Normal file
15
src/fb-stubs/BugReporter.js
Normal 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);
|
||||
}
|
||||
}
|
||||
21
src/fb-stubs/ErrorReporter.js
Normal file
21
src/fb-stubs/ErrorReporter.js
Normal 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
16
src/fb-stubs/GK.js
Normal 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
40
src/fb-stubs/Logger.js
Normal 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);
|
||||
}
|
||||
}
|
||||
18
src/fb-stubs/ScribeLogger.js
Normal file
18
src/fb-stubs/ScribeLogger.js
Normal 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
11
src/fb-stubs/config.js
Normal 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
19
src/fb-stubs/constants.js
Normal 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
21
src/index.js
Normal 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
48
src/init.js
Normal 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
241
src/plugin.js
Normal 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
72
src/plugins/index.js
Normal 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
576
src/plugins/layout/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
9
src/plugins/layout/package.json
Normal file
9
src/plugins/layout/package.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "sonar-plugin-layout",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash.debounce": "^4.0.8"
|
||||
}
|
||||
}
|
||||
7
src/plugins/layout/yarn.lock
Normal file
7
src/plugins/layout/yarn.lock
Normal 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"
|
||||
539
src/plugins/network/RequestDetails.js
Normal file
539
src/plugins/network/RequestDetails.js
Normal 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(),
|
||||
];
|
||||
411
src/plugins/network/index.js
Normal file
411
src/plugins/network/index.js
Normal 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;
|
||||
}
|
||||
}
|
||||
10
src/plugins/network/package.json
Normal file
10
src/plugins/network/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
11
src/plugins/network/yarn.lock
Normal file
11
src/plugins/network/yarn.lock
Normal 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
184
src/reducers.js
Normal 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,
|
||||
};
|
||||
}
|
||||
88
src/reducers/application.js
Normal file
88
src/reducers/application.js
Normal 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
39
src/reducers/devices.js
Normal 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
29
src/reducers/index.js
Normal 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
520
src/server.js
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/ui/components/Block.js
Normal file
12
src/ui/components/Block.js
Normal 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
15
src/ui/components/Box.js
Normal 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
363
src/ui/components/Button.js
Normal 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>);
|
||||
45
src/ui/components/ButtonGroup.js
Normal file
45
src/ui/components/ButtonGroup.js
Normal 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,
|
||||
};
|
||||
28
src/ui/components/ButtonNavigationGroup.js
Normal file
28
src/ui/components/ButtonNavigationGroup.js
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/ui/components/Checkbox.js
Normal file
36
src/ui/components/Checkbox.js
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
12
src/ui/components/ClickableList.js
Normal file
12
src/ui/components/ClickableList.js
Normal 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,
|
||||
});
|
||||
33
src/ui/components/ClickableListItem.js
Normal file
33
src/ui/components/ClickableListItem.js
Normal 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'],
|
||||
},
|
||||
);
|
||||
12
src/ui/components/CodeBlock.js
Normal file
12
src/ui/components/CodeBlock.js
Normal 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',
|
||||
});
|
||||
56
src/ui/components/ContextMenu.js
Normal file
56
src/ui/components/ContextMenu.js
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
50
src/ui/components/ContextMenuProvider.js
Normal file
50
src/ui/components/ContextMenuProvider.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
35
src/ui/components/Dropdown.js
Normal file
35
src/ui/components/Dropdown.js
Normal 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>;
|
||||
}
|
||||
}
|
||||
38
src/ui/components/ErrorBlock.js
Normal file
38
src/ui/components/ErrorBlock.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
80
src/ui/components/ErrorBoundary.js
Normal file
80
src/ui/components/ErrorBoundary.js
Normal 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
82
src/ui/components/File.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
173
src/ui/components/FileList.js
Normal file
173
src/ui/components/FileList.js
Normal 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
18
src/ui/components/FlexBox.js
Normal file
18
src/ui/components/FlexBox.js
Normal 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'],
|
||||
},
|
||||
);
|
||||
14
src/ui/components/FlexCenter.js
Normal file
14
src/ui/components/FlexCenter.js
Normal 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',
|
||||
});
|
||||
12
src/ui/components/FlexColumn.js
Normal file
12
src/ui/components/FlexColumn.js
Normal 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',
|
||||
});
|
||||
12
src/ui/components/FlexRow.js
Normal file
12
src/ui/components/FlexRow.js
Normal 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',
|
||||
});
|
||||
72
src/ui/components/FocusableBox.js
Normal file
72
src/ui/components/FocusableBox.js
Normal 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
107
src/ui/components/Glyph.js
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
57
src/ui/components/Heading.js
Normal file
57
src/ui/components/Heading.js
Normal 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>;
|
||||
}
|
||||
}
|
||||
14
src/ui/components/HorizontalRule.js
Normal file
14
src/ui/components/HorizontalRule.js
Normal 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',
|
||||
});
|
||||
14
src/ui/components/InlineContextMenu.js
Normal file
14
src/ui/components/InlineContextMenu.js
Normal 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>;
|
||||
}
|
||||
}
|
||||
41
src/ui/components/Input.js
Normal file
41
src/ui/components/Input.js
Normal 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;
|
||||
696
src/ui/components/Interactive.js
Normal file
696
src/ui/components/Interactive.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
13
src/ui/components/Label.js
Normal file
13
src/ui/components/Label.js
Normal 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
39
src/ui/components/Link.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
42
src/ui/components/LoadingIndicator.js
Normal file
42
src/ui/components/LoadingIndicator.js
Normal 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;
|
||||
49
src/ui/components/ModalOverlay.js
Normal file
49
src/ui/components/ModalOverlay.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
428
src/ui/components/Orderable.js
Normal file
428
src/ui/components/Orderable.js
Normal 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
179
src/ui/components/Panel.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
53
src/ui/components/PathBreadcrumbs.js
Normal file
53
src/ui/components/PathBreadcrumbs.js
Normal 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>
|
||||
);
|
||||
}
|
||||
89
src/ui/components/Popover.js
Normal file
89
src/ui/components/Popover.js
Normal 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>,
|
||||
];
|
||||
}
|
||||
}
|
||||
52
src/ui/components/ResizeSensor.js
Normal file
52
src/ui/components/ResizeSensor.js
Normal 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);
|
||||
};
|
||||
}
|
||||
29
src/ui/components/Select.js
Normal file
29
src/ui/components/Select.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
178
src/ui/components/Sidebar.js
Normal file
178
src/ui/components/Sidebar.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
15
src/ui/components/SidebarLabel.js
Normal file
15
src/ui/components/SidebarLabel.js
Normal 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
37
src/ui/components/Tab.js
Normal 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
271
src/ui/components/Tabs.js
Normal 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
159
src/ui/components/Text.js
Normal 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;
|
||||
21
src/ui/components/TextParagraph.js
Normal file
21
src/ui/components/TextParagraph.js
Normal 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;
|
||||
22
src/ui/components/Textarea.js
Normal file
22
src/ui/components/Textarea.js
Normal 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'],
|
||||
},
|
||||
);
|
||||
61
src/ui/components/ToggleSwitch.js
Normal file
61
src/ui/components/ToggleSwitch.js
Normal 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} />
|
||||
);
|
||||
}
|
||||
}
|
||||
49
src/ui/components/Toolbar.js
Normal file
49
src/ui/components/Toolbar.js
Normal 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;
|
||||
75
src/ui/components/Tooltip.js
Normal file
75
src/ui/components/Tooltip.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
92
src/ui/components/TooltipProvider.js
Normal file
92
src/ui/components/TooltipProvider.js
Normal 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
Reference in New Issue
Block a user