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:
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user